remove Python stopgap; rewire to pql 1.0
test / unit + widget + golden + a11y (push) Failing after 38s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped

pql 1.0 ships full feature parity for decisions and tickets.
Sunset condition from D-040 met; deletion per R-011.

Removed: tools/scripts/plan entrypoint, tools/scripts/planning/
Python modules, tools/scripts/README.md, .gitignore pycache
entries.

Rewired: make decisions-validate now calls pql decisions validate.
CLAUDE.md decision discipline guardrail points at pql. Extension
stub comments updated. decisions/README.md examples use pql.
D-040 amended as superseded.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 13:39:28 +02:00
co-authored by Claude
parent 7164ec11d6
commit e43438d369
19 changed files with 39 additions and 1210 deletions
+1 -7
View File
@@ -52,15 +52,9 @@ coverage.*
# app/test/goldens/goldens/{ci,linux}/).
app/test/goldens/failures/
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
# here when running queries locally). `pql.db` is also where the
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
/.pql/
# -- Python stopgap tooling under tools/scripts/plan ------------------
tools/scripts/__pycache__/
tools/scripts/planning/__pycache__/
# -- SQLite index files (defensive; should never land at repo root) ----
*.sqlite
*.sqlite-journal
+22
View File
@@ -18,6 +18,13 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
### Added
- pql skill installed via `pql init --with-skill=yes`
(`.claude/skills/pql/SKILL.md`). Covers vault queries and the
planning surface (decisions + tickets).
- `Bash(pql)` and `Bash(pql *)` permissions in
`.claude/settings.json`.
- Git subsystem in the daemon (`lib/src/git/`). Status parser
(`git status --porcelain`), unified-diff parser, and operations
(stage, unstage, stage-hunk, discard, commit, stash, log, pull,
@@ -270,6 +277,21 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
updated to reference `decisions/architecture.md` instead of the
deleted `docs/ADRs/`.
- `make decisions-validate` rewired from `tools/scripts/plan` to
`pql decisions validate`.
- Decision discipline guardrail in CLAUDE.md now points at
`pql decisions claim` instead of the Python stopgap.
### Removed
- `tools/scripts/plan` — Python stopgap planning scripts, superseded
by `pql` 1.0 native `decisions` and `ticket` subcommands. Sunset
condition from
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
met; deletion per
[`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
### Removed
- `docs/ADRs/` directory — content lifted into `decisions/` as D/R
+1 -1
View File
@@ -28,7 +28,7 @@ These are load-bearing. Violating any means the design is wrong, not the rule.
- **pql: wrap, don't duplicate, and treat it as a clide subsystem when present.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-003`](decisions/architecture.md#d-003-pql-as-supporter-tool-clide-wraps-never-duplicates).
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept. Clide dogfoods against its own repo.
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. Default `[.gitignore]`; clide writes `[.gitignore, .clideignore]` when `.clideignore` exists. See [`D-004`](decisions/architecture.md#d-004-ignore-file-strategy).
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions live in `decisions/questions-<domain>.md` as `Q-NNN`. Rejected alternatives live in `decisions/rejected.md` as `R-NNN`. Before an architectural change, read the relevant domain file; before disagreeing with a guardrail, propose an amendment to the underlying `D-NNN` rather than a one-off. Claim new IDs via `tools/scripts/plan decisions claim D <domain> "title"` (until pql ships planning subcommands — see [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)). See [`decisions/README.md`](decisions/README.md).
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions live in `decisions/questions-<domain>.md` as `Q-NNN`. Rejected alternatives live in `decisions/rejected.md` as `R-NNN`. Before an architectural change, read the relevant domain file; before disagreeing with a guardrail, propose an amendment to the underlying `D-NNN` rather than a one-off. Claim new IDs via `pql decisions claim D <domain> "title"`. See [`decisions/README.md`](decisions/README.md).
## Tier ordering (don't skip ahead)
+1 -1
View File
@@ -174,7 +174,7 @@ security: ## Dart advisory review + ptyc source review (manual — no floating d
.PHONY: decisions-validate
decisions-validate: ## Parser dry-run over decisions/*.md (cheap pre-push gate).
tools/scripts/plan decisions validate
pql decisions validate
.PHONY: push-check
push-check: decisions-validate test-core test test-a11y ## Pre-push gate: decisions + core + fast unit + widget + golden + a11y (<90s).
+1 -3
View File
@@ -2,9 +2,7 @@ import 'package:clide_app/extension/extension.dart';
/// Tier-reserved stub. Will surface a sidebar tab (filter by domain /
/// status, backlinks from current file) + commands (`decisions.open`,
/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions
/// …` once `Q-021` resolves; `tools/scripts/plan decisions …` until
/// then.
/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions …`.
class DecisionsExtension extends ClideExtension {
@override
String get id => 'builtin.decisions';
+2 -3
View File
@@ -2,9 +2,8 @@ import 'package:clide_app/extension/extension.dart';
/// Tier-reserved stub. Will surface a sidebar tab (filtered list) + a
/// workspace tab (kanban board) + commands (`tickets.open`,
/// `tickets.new`, `tickets.move`, `tickets.block`). Data source: `pql
/// ticket …` once `Q-021` resolves; `tools/scripts/plan ticket …` until
/// then. Ticket persistence strategy open at `Q-022`.
/// `tickets.new`, `tickets.move`, `tickets.block`). Data source:
/// `pql ticket …`. Ticket persistence strategy open at `Q-022`.
class TicketsExtension extends ClideExtension {
@override
String get id => 'builtin.tickets';
+9 -12
View File
@@ -82,26 +82,23 @@ same semantics, no race on concurrent sessions.
## Querying
Stopgap today: `tools/scripts/plan decisions …` reads and writes
`.pql/pql.db` (gitignored; markdown is source of truth). Replaced
by `pql decisions …` when pql ships parity — see
[D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan) and
[R-011](rejected.md#r-011-permanent-stopgap).
`pql decisions …` reads `decisions/*.md` and writes `.pql/pql.db`
(gitignored; markdown is the source of truth).
Common queries:
```bash
tools/scripts/plan decisions list --type confirmed --domain architecture
tools/scripts/plan decisions show D-005 --with-refs
tools/scripts/plan decisions coverage # D-records without tickets
tools/scripts/plan decisions validate # pre-push parser gate
pql decisions list --type confirmed --domain architecture
pql decisions show D-005 --with-refs
pql decisions coverage # D-records without tickets
pql decisions validate # pre-push parser gate
pql ticket board # kanban view of tickets
```
## Adding a decision
1. Edit the appropriate domain file.
2. Follow the record shape above.
3. Run `tools/scripts/plan decisions validate` (also runs in
`make push-check`).
3. Run `pql decisions validate` (also runs in `make push-check`).
4. Commit. The SQLite index rebuilds from markdown on any
`tools/scripts/plan decisions sync`.
`pql decisions sync`.
+2 -1
View File
@@ -46,12 +46,13 @@ Q&D record system itself, kanban, commit conventions, changelog.
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-040](#d-040-python-stopgap-under-toolsscriptsplan). Gated by [Q-021](questions-process.md#q-021-pql-absorbs-planning-vs-keeps-separate).
- **Raised by:** 2026-04-21 planning.
### D-040: Python stopgap under `tools/scripts/plan`
### D-040: [SUPERSEDED] Python stopgap under `tools/scripts/plan`
- **Date:** 2026-04-21
- **Decision:** A time-limited Python port of settled-reach's `decisions_sync.py` + `ticket` + `decision` scripts lives at `tools/scripts/plan` with support modules under `tools/scripts/planning/`. Writes to `.pql/pql.db` (gitignored). Ticket IDs are `T-NNN` (TEXT PK, reshape from settled-reach's integers). Same schema, same markdown, same verb shape as the eventual `pql` subcommands.
- **Sunset:** Delete the stopgap when pql ships `pql decisions sync | validate | list | show | claim | coverage` + `pql ticket new | list | show | status | assign | block | board` with feature parity, and reads the same `.pql/pql.db` file the stopgap wrote. Removal commit shape: [R-011](rejected.md#r-011-permanent-stopgap).
- **Rationale:** Planning tooling must work day one. Pql's Go implementation won't land for at least a cycle or two. Without a stopgap, the convention lives on paper; with one, tickets + decisions are queryable from today. Same schema means migration is call-site find-replace (`tools/scripts/plan ``pql `), no data migration.
- **Cost:** Python dep on contributors' machines (already present on most Linux dists). One time-limited tool to maintain. See [R-010](rejected.md#r-010-python-script-stopgap-at-toolingdb) for why `tools/scripts/plan` and not `tooling/db/`.
- **Raised by:** 2026-04-21 planning.
- **Amendment (2026-04-22):** Sunset condition met. pql 1.0.0 ships full feature parity. Stopgap deleted per [R-011](rejected.md#r-011-permanent-stopgap).
---
-49
View File
@@ -1,49 +0,0 @@
# STOPGAP — Planning tooling
This directory is a **time-limited stopgap**. The Python scripts here
port settled-reach's `decisions_sync.py` + `ticket` + `decision`
scripts, Scrum-stripped, and write to `.pql/pql.db` (gitignored).
## Sunset clause
Per [`D-040`](../../decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
and [`R-011`](../../decisions/rejected.md#r-011-permanent-stopgap),
this stopgap deletes when pql ships:
- `pql decisions sync | validate | list | show | claim | coverage`
- `pql ticket new | list | show | status | assign | block | board`
with feature parity on the same `.pql/pql.db` file this stopgap wrote.
Migration is a call-site find-replace:
```
tools/scripts/plan decisions sync → pql decisions sync
tools/scripts/plan ticket new task "…" → pql ticket new task "…"
tools/scripts/plan ticket board → pql ticket board
```
See [`Q-021`](../../decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)
for the open gate on whether pql absorbs planning long-term.
## Entrypoint
`plan` is a Python executable. Support modules live under `planning/`.
```
plan decisions sync | validate | claim D|Q|R <domain> [title] | list | show <id> | coverage
plan ticket new <type> "title" | list | show <id> | status <id> <new> | assign <id> <agent>
plan ticket block <id> --by <other> | unblock <id> --from <other>
plan ticket team <id> <team> | label <id> add|rm <label> | board | search "query"
plan sqlite-query "SELECT …"
```
Needs Python 3.9+ (f-strings + `pathlib`). No third-party deps.
## Where things live
- DB: `.pql/pql.db` (gitignored, per-dev)
- Records (source of truth): `decisions/*.md`
- Tickets: today SQLite-only; see
[`Q-022`](../../decisions/questions-architecture.md#q-022-ticket-persistence-strategy)
for the markdown-mirror question.
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""plan — clide planning stopgap.
Writes to .pql/pql.db. Replaced by `pql` subcommands when pql ships
feature parity. See tools/scripts/README.md for the sunset clause.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
# Make "from planning import ..." work when invoked as a script.
HERE = Path(__file__).resolve().parent
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
from planning import cmd_decisions, cmd_ticket, db # noqa: E402
def _build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
prog="plan",
description="clide planning stopgap — decisions + tickets",
)
sub = ap.add_subparsers(dest="cmd", required=True)
cmd_decisions.add_subparsers(sub)
cmd_ticket.add_subparsers(sub)
sq = sub.add_parser("sqlite-query", help="run a raw SQL query")
sq.add_argument("sql")
return ap
def main(argv: list[str] | None = None) -> int:
ap = _build_parser()
args = ap.parse_args(argv)
if args.cmd == "decisions":
return cmd_decisions.dispatch(args)
if args.cmd == "ticket":
return cmd_ticket.dispatch(args)
if args.cmd == "sqlite-query":
conn = db.connect()
try:
cur = conn.execute(args.sql)
if cur.description:
cols = [d[0] for d in cur.description]
print("\t".join(cols))
for row in cur.fetchall():
print("\t".join(str(v) for v in row))
conn.commit()
finally:
conn.close()
return 0
return 1
if __name__ == "__main__":
sys.exit(main())
-5
View File
@@ -1,5 +0,0 @@
"""Planning stopgap — decisions + tickets written to .pql/pql.db.
Time-limited per D-040; replaced by pql subcommands when feature parity
lands. See ../README.md for the sunset clause.
"""
-104
View File
@@ -1,104 +0,0 @@
"""`plan decisions …` subcommands."""
from __future__ import annotations
import argparse
import sys
from . import db, parser, repo_decisions
from .format import emit, rows_to_dicts
def add_subparsers(parent: argparse._SubParsersAction) -> None:
p = parent.add_parser("decisions", help="decisions subcommands")
sub = p.add_subparsers(dest="subcmd", required=True)
sub.add_parser("sync", help="parse decisions/*.md → upsert into sqlite")
sub.add_parser("validate", help="dry-run parser; exit non-zero on errors")
claim = sub.add_parser("claim", help="print next available D/Q/R id")
claim.add_argument("prefix", choices=["D", "Q", "R"])
claim.add_argument("domain", nargs="?")
claim.add_argument("title", nargs="*")
ls = sub.add_parser("list", help="list decisions")
ls.add_argument("--type", choices=["confirmed", "question", "rejected"])
ls.add_argument("--domain")
ls.add_argument("--status")
ls.add_argument("--output", choices=["table", "json"])
show = sub.add_parser("show", help="show a decision")
show.add_argument("id")
show.add_argument("--with-refs", action="store_true")
show.add_argument("--with-tickets", action="store_true")
show.add_argument("--output", choices=["table", "json"])
cov = sub.add_parser("coverage", help="D-records without implementing tickets")
cov.add_argument("--output", choices=["table", "json"])
def dispatch(args: argparse.Namespace) -> int:
root = db.repo_root()
if args.subcmd == "sync":
conn = db.connect(root)
try:
result = repo_decisions.sync(conn, root)
finally:
conn.close()
emit(result, mode="json" if not sys.stdout.isatty() else "table")
return 0
if args.subcmd == "validate":
ok, errors = parser.validate(root)
if ok:
print("decisions validate: ok")
return 0
for err in errors:
print(f"error: {err}", file=sys.stderr)
return 1
if args.subcmd == "claim":
new_id = parser.next_id(root, args.prefix)
if args.domain is None:
print(new_id)
return 0
title = " ".join(args.title) if args.title else "(unclaimed)"
print(f"{new_id} {args.domain} {title}")
print(
"(claim is advisory in the stopgap — write the record to "
f"decisions/{args.domain}.md manually, then `plan decisions sync`)"
)
return 0
conn = db.connect(root)
try:
if args.subcmd == "list":
rows = repo_decisions.list_decisions(
conn, type_=args.type, domain=args.domain, status=args.status
)
emit(rows_to_dicts(rows), mode=args.output)
return 0
if args.subcmd == "show":
row = repo_decisions.get(conn, args.id)
if row is None:
print(f"error: {args.id} not found", file=sys.stderr)
return 3
data: dict = dict(row)
if args.with_refs:
data["refs"] = rows_to_dicts(repo_decisions.refs_of(conn, args.id))
if args.with_tickets:
data["tickets"] = rows_to_dicts(
repo_decisions.tickets_for(conn, args.id)
)
emit(data, mode=args.output)
return 0
if args.subcmd == "coverage":
rows = repo_decisions.coverage(conn)
emit(rows_to_dicts(rows), mode=args.output)
return 0
finally:
conn.close()
return 1
-178
View File
@@ -1,178 +0,0 @@
"""`plan ticket …` subcommands."""
from __future__ import annotations
import argparse
import sys
from . import db, repo_tickets
from .format import emit, emit_board, rows_to_dicts
def add_subparsers(parent: argparse._SubParsersAction) -> None:
p = parent.add_parser("ticket", help="ticket subcommands")
sub = p.add_subparsers(dest="subcmd", required=True)
new = sub.add_parser("new", help="create a ticket")
new.add_argument("type", choices=["initiative", "epic", "story", "task", "bug"])
new.add_argument("title")
new.add_argument("--parent")
new.add_argument("--priority", choices=["critical", "high", "medium", "low"], default="medium")
new.add_argument("--decision", dest="decision_ref")
new.add_argument("--team")
new.add_argument("--description")
ls = sub.add_parser("list", help="list tickets")
ls.add_argument("--status")
ls.add_argument("--team")
ls.add_argument("--assigned")
ls.add_argument("--decision")
ls.add_argument("--label")
ls.add_argument("--output", choices=["table", "json"])
show = sub.add_parser("show", help="show a ticket")
show.add_argument("id")
show.add_argument("--with-decision", action="store_true")
show.add_argument("--with-blockers", action="store_true")
show.add_argument("--with-children", action="store_true")
show.add_argument("--output", choices=["table", "json"])
st = sub.add_parser("status", help="change status")
st.add_argument("id")
st.add_argument("new_status", choices=[
"backlog", "ready", "in_progress", "review", "done", "cancelled",
])
asg = sub.add_parser("assign", help="assign to agent")
asg.add_argument("id")
asg.add_argument("agent")
tm = sub.add_parser("team", help="set team")
tm.add_argument("id")
tm.add_argument("team")
blk = sub.add_parser("block", help="mark <id> as blocked by <other>")
blk.add_argument("id")
blk.add_argument("--by", required=True, dest="by")
ublk = sub.add_parser("unblock", help="remove a blocker")
ublk.add_argument("id")
ublk.add_argument("--from", required=True, dest="from_")
lbl = sub.add_parser("label", help="add/rm labels")
lbl.add_argument("id")
lbl.add_argument("op", choices=["add", "rm"])
lbl.add_argument("label")
srch = sub.add_parser("search", help="search title + description")
srch.add_argument("query")
srch.add_argument("--output", choices=["table", "json"])
brd = sub.add_parser("board", help="kanban columns")
brd.add_argument("--team")
brd.add_argument("--output", choices=["table", "json"])
def dispatch(args: argparse.Namespace) -> int:
conn = db.connect()
try:
if args.subcmd == "new":
tid = repo_tickets.create(
conn,
type_=args.type,
title=args.title,
description=args.description,
parent_id=args.parent,
priority=args.priority,
decision_ref=args.decision_ref,
team=args.team,
)
print(tid)
return 0
if args.subcmd == "list":
rows = repo_tickets.list_(
conn,
status=args.status,
team=args.team,
assigned=args.assigned,
decision=args.decision,
label=args.label,
)
emit(rows_to_dicts(rows), mode=args.output)
return 0
if args.subcmd == "show":
row = repo_tickets.get(conn, args.id)
if row is None:
print(f"error: {args.id} not found", file=sys.stderr)
return 3
data: dict = dict(row)
if args.with_decision and row["decision_ref"]:
dec = conn.execute(
"SELECT * FROM decisions WHERE id = ?", (row["decision_ref"],)
).fetchone()
data["decision"] = dict(dec) if dec else None
if args.with_blockers:
data["blocked_by"] = rows_to_dicts(repo_tickets.blockers(conn, args.id))
if args.with_children:
data["children"] = rows_to_dicts(repo_tickets.children(conn, args.id))
emit(data, mode=args.output)
return 0
if args.subcmd == "status":
n = repo_tickets.set_status(conn, args.id, args.new_status)
if n == 0:
print(f"error: {args.id} not found", file=sys.stderr)
return 3
print(f"{args.id}{args.new_status}")
return 0
if args.subcmd == "assign":
n = repo_tickets.set_field(conn, args.id, "assigned_to", args.agent)
if n == 0:
print(f"error: {args.id} not found", file=sys.stderr)
return 3
print(f"{args.id} assigned to {args.agent}")
return 0
if args.subcmd == "team":
n = repo_tickets.set_field(conn, args.id, "team", args.team)
if n == 0:
print(f"error: {args.id} not found", file=sys.stderr)
return 3
print(f"{args.id} team = {args.team}")
return 0
if args.subcmd == "block":
repo_tickets.block(conn, args.id, args.by)
print(f"{args.id} blocked by {args.by}")
return 0
if args.subcmd == "unblock":
n = repo_tickets.unblock(conn, args.id, args.from_)
if n == 0:
print(f"(no such blocker)")
else:
print(f"{args.id} unblocked from {args.from_}")
return 0
if args.subcmd == "label":
if args.op == "add":
repo_tickets.label_add(conn, args.id, args.label)
else:
repo_tickets.label_rm(conn, args.id, args.label)
print(f"{args.id} labels {args.op} {args.label}")
return 0
if args.subcmd == "search":
rows = repo_tickets.search(conn, args.query)
emit(rows_to_dicts(rows), mode=args.output)
return 0
if args.subcmd == "board":
columns = repo_tickets.board(conn, team=args.team)
emit_board(columns, mode=args.output)
return 0
finally:
conn.close()
return 1
-34
View File
@@ -1,34 +0,0 @@
"""SQLite wrapper — opens `.pql/pql.db` at the repo root, applies schema.
Discovery: walk up from CWD looking for a .git/ sibling; the repo root
hosts .pql/pql.db. If .pql/ doesn't exist, it's created on first open.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from .schema import SCHEMA_SQL
def repo_root(start: Path | None = None) -> Path:
path = (start or Path.cwd()).resolve()
for parent in (path, *path.parents):
if (parent / ".git").exists():
return parent
raise RuntimeError(f"not inside a git repository (starting from {path})")
def db_path(root: Path | None = None) -> Path:
root = root or repo_root()
return root / ".pql" / "pql.db"
def connect(root: Path | None = None) -> sqlite3.Connection:
path = db_path(root)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(SCHEMA_SQL)
return conn
-77
View File
@@ -1,77 +0,0 @@
"""Output formatting — table (default for TTY), json (for scripting)."""
from __future__ import annotations
import json
import sqlite3
import sys
from typing import Iterable, Mapping, Sequence
def _is_tty() -> bool:
return sys.stdout.isatty()
def rows_to_dicts(rows: Iterable[sqlite3.Row]) -> list[dict]:
return [dict(r) for r in rows]
def emit(data, *, mode: str | None = None) -> None:
"""Emit `data` as either JSON or a human table.
`mode` is "json" | "table" | None (auto: table on TTY, json otherwise).
"""
if mode is None:
mode = "table" if _is_tty() else "json"
if mode == "json":
print(json.dumps(data, indent=2, default=str))
return
# table
if isinstance(data, dict) and "rows" in data:
_print_table(data["rows"])
return
if isinstance(data, list):
_print_table(data)
return
if isinstance(data, dict):
for k, v in data.items():
print(f"{k}: {v}")
return
print(data)
def _print_table(rows: Sequence[Mapping]) -> None:
if not rows:
print("(no rows)")
return
cols = list(rows[0].keys())
widths = {c: len(c) for c in cols}
for r in rows:
for c in cols:
widths[c] = max(widths[c], len(str(r.get(c, ""))))
header = " ".join(c.ljust(widths[c]) for c in cols)
print(header)
print(" ".join("-" * widths[c] for c in cols))
for r in rows:
print(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in cols))
def emit_board(columns: dict[str, list[sqlite3.Row]], *, mode: str | None = None) -> None:
if mode is None:
mode = "table" if _is_tty() else "json"
if mode == "json":
print(json.dumps(
{status: rows_to_dicts(rows) for status, rows in columns.items()},
indent=2,
default=str,
))
return
for status, rows in columns.items():
print(f"== {status.upper()} ({len(rows)}) ==")
if not rows:
print(" (empty)")
continue
for row in rows:
prio = row["priority"] or "medium"
who = row["assigned_to"] or "-"
print(f" {row['id']} [{prio:8}] @{who:10} {row['title']}")
print()
-202
View File
@@ -1,202 +0,0 @@
"""Markdown parser for decisions/*.md.
Yields `Record` dicts with id, type, domain, title, status, date,
file_path, and a list of (target_id, ref_type, note) cross-refs.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
HEADING_RE = re.compile(r"^###\s+((?:D|Q|R)-\d+):\s+(.+)$")
DATE_RE = re.compile(r"^\s*-\s+\*\*(?:Date|Rejected):\*\*\s+(\d{4}-\d{2}-\d{2})")
STATUS_RE = re.compile(r"^\s*-\s+\*\*Status:\*\*\s+(.+)")
SUPERSEDES_RE = re.compile(r"^\s*-\s+\*\*Supersedes:\*\*", re.IGNORECASE)
SUPERSEDED_BY_RE = re.compile(r"^\s*-\s+\*\*Superseded\s+by:\*\*", re.IGNORECASE)
RESOLVES_RE = re.compile(r"^\s*-\s+\*\*Resolves:\*\*", re.IGNORECASE)
DEPENDS_RE = re.compile(r"^\s*-\s+\*\*Depends\s+on:\*\*", re.IGNORECASE)
AMENDS_RE = re.compile(r"^\s*\*\*Amendment\s*\(", re.IGNORECASE)
CROSS_REF_RE = re.compile(r"^\s*-\s+\*\*Cross-reference:\*\*", re.IGNORECASE)
REF_ID_RE = re.compile(r"(?:D|Q|R|T)-\d+")
TYPE_FROM_PREFIX = {"D": "confirmed", "Q": "question", "R": "rejected"}
@dataclass
class Record:
id: str
type: str
domain: str
title: str
status: str
date: str | None
file_path: str
refs: list[tuple[str, str, str]] = field(default_factory=list)
def _infer_status(rec_type: str, title: str, body: list[str]) -> str:
if rec_type == "rejected":
return "active"
for line in body:
if SUPERSEDED_BY_RE.match(line):
return "superseded"
if rec_type == "question":
for line in body:
m = STATUS_RE.match(line)
if not m:
continue
s = m.group(1).strip().lower()
if "partial" in s or "remaining" in s:
return "open"
if s.startswith("resolved"):
return "resolved"
return "open"
return "open"
return "active"
def _extract_date(body: list[str]) -> str | None:
for line in body:
m = DATE_RE.match(line)
if m:
return m.group(1)
return None
def _extract_refs(rec_id: str, body: list[str]) -> list[tuple[str, str, str]]:
refs: list[tuple[str, str, str]] = []
seen: set[tuple[str, str]] = set()
for line in body:
if SUPERSEDES_RE.match(line):
ref_type = "supersedes"
elif SUPERSEDED_BY_RE.match(line):
ref_type = "references"
elif RESOLVES_RE.match(line):
ref_type = "resolves"
elif DEPENDS_RE.match(line):
ref_type = "depends_on"
elif AMENDS_RE.match(line):
ref_type = "amends"
else:
ref_type = "references"
for target in REF_ID_RE.findall(line):
if target == rec_id or target.startswith("T-"):
# T-NNN refs are ticket→decision, not decision↔decision
continue
key = (target, ref_type)
if key in seen:
continue
seen.add(key)
note = line.strip().lstrip("- ").rstrip()
if len(note) > 200:
note = note[:197] + "..."
refs.append((target, ref_type, note))
return refs
def parse_file(path: Path, repo_root: Path) -> list[Record]:
domain = path.stem
if domain.startswith("questions-"):
domain = domain[len("questions-"):]
rel_path = str(path.relative_to(repo_root))
text = path.read_text(encoding="utf-8")
records: list[Record] = []
cur_id: str | None = None
cur_title: str | None = None
cur_body: list[str] = []
def flush() -> None:
nonlocal cur_id, cur_title, cur_body
if cur_id is None:
return
rec_type = TYPE_FROM_PREFIX[cur_id[0]]
records.append(
Record(
id=cur_id,
type=rec_type,
domain=domain,
title=(cur_title or "").strip(),
status=_infer_status(rec_type, cur_title or "", cur_body),
date=_extract_date(cur_body),
file_path=rel_path,
refs=_extract_refs(cur_id, cur_body),
)
)
cur_id = None
cur_title = None
cur_body = []
for line in text.split("\n"):
m = HEADING_RE.match(line)
if m:
flush()
cur_id = m.group(1)
cur_title = m.group(2)
cur_body = []
elif cur_id is not None:
if line.strip() == "---":
flush()
else:
cur_body.append(line)
flush()
return records
def parse_all(repo_root: Path) -> tuple[list[Record], list[str]]:
decisions_dir = repo_root / "decisions"
if not decisions_dir.is_dir():
return [], [f"decisions/ not found at {decisions_dir}"]
records: list[Record] = []
warnings: list[str] = []
for path in sorted(decisions_dir.glob("*.md")):
if path.name.lower() == "readme.md":
continue
try:
records.extend(parse_file(path, repo_root))
except Exception as exc: # noqa: BLE001 - we want a useful warning
warnings.append(f"error parsing {path.name}: {exc}")
return records, warnings
def validate(repo_root: Path) -> tuple[bool, list[str]]:
"""Return (ok, errors). Non-zero errors fail push-check."""
records, warnings = parse_all(repo_root)
errors: list[str] = list(warnings)
ids_seen: dict[str, str] = {}
for rec in records:
if rec.id in ids_seen and ids_seen[rec.id] != rec.file_path:
errors.append(
f"duplicate id {rec.id}: {ids_seen[rec.id]} and {rec.file_path}"
)
ids_seen[rec.id] = rec.file_path
if not rec.title:
errors.append(f"{rec.id}: empty title")
known = set(ids_seen)
for rec in records:
for target, ref_type, _ in rec.refs:
if target not in known:
errors.append(
f"{rec.id}{target} ({ref_type}): target not found"
)
return (len(errors) == 0), errors
def next_id(repo_root: Path, prefix: str) -> str:
prefix = prefix.upper()
if prefix not in TYPE_FROM_PREFIX:
raise ValueError(f"invalid prefix {prefix!r} — must be D, Q, or R")
records, _ = parse_all(repo_root)
highest = 0
for rec in records:
if rec.id.startswith(f"{prefix}-"):
try:
highest = max(highest, int(rec.id.split("-", 1)[1]))
except ValueError:
continue
return f"{prefix}-{highest + 1:03d}"
-129
View File
@@ -1,129 +0,0 @@
"""Decisions repository — upsert records, query joined views."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from .parser import Record, parse_all
def sync(conn: sqlite3.Connection, repo_root: Path) -> dict:
records, warnings = parse_all(repo_root)
known_ids = {r.id for r in records}
conn.execute("DELETE FROM decision_refs")
upserted = 0
for r in records:
conn.execute(
"""
INSERT INTO decisions (id, type, domain, title, status, date, file_path, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
domain = excluded.domain,
title = excluded.title,
status = excluded.status,
date = excluded.date,
file_path = excluded.file_path,
synced_at = datetime('now')
""",
(r.id, r.type, r.domain, r.title, r.status, r.date, r.file_path),
)
upserted += 1
refs_created = 0
broken = 0
for r in records:
for target, ref_type, note in r.refs:
if target not in known_ids:
broken += 1
warnings.append(f"broken ref: {r.id}{target} ({ref_type})")
continue
try:
conn.execute(
"""INSERT OR IGNORE INTO decision_refs
(source_id, target_id, ref_type, note)
VALUES (?, ?, ?, ?)""",
(r.id, target, ref_type, note),
)
refs_created += 1
except sqlite3.IntegrityError:
pass
conn.commit()
return {
"synced": upserted,
"refs": refs_created,
"broken": broken,
"warnings": warnings,
}
def list_decisions(
conn: sqlite3.Connection,
type_: str | None = None,
domain: str | None = None,
status: str | None = None,
) -> list[sqlite3.Row]:
conditions: list[str] = []
params: list[str] = []
if type_:
conditions.append("type = ?")
params.append(type_)
if domain:
conditions.append("domain = ?")
params.append(domain)
if status:
conditions.append("status = ?")
params.append(status)
where = " AND ".join(conditions) if conditions else "1=1"
return list(
conn.execute(
f"SELECT id, type, domain, title, status, date, file_path "
f"FROM decisions WHERE {where} ORDER BY id",
params,
)
)
def get(conn: sqlite3.Connection, rid: str) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM decisions WHERE id = ?", (rid,)
).fetchone()
def refs_of(conn: sqlite3.Connection, rid: str) -> list[sqlite3.Row]:
return list(
conn.execute(
"""
SELECT source_id, target_id, ref_type, note
FROM decision_refs
WHERE source_id = ? OR target_id = ?
ORDER BY ref_type, source_id, target_id
""",
(rid, rid),
)
)
def tickets_for(conn: sqlite3.Connection, rid: str) -> list[sqlite3.Row]:
return list(
conn.execute(
"SELECT id, type, title, status, priority FROM tickets "
"WHERE decision_ref = ? ORDER BY id",
(rid,),
)
)
def coverage(conn: sqlite3.Connection) -> list[sqlite3.Row]:
"""D-records without an implementing ticket."""
return list(
conn.execute(
"""
SELECT d.id, d.domain, d.title
FROM decisions d
LEFT JOIN tickets t ON t.decision_ref = d.id
WHERE d.type = 'confirmed' AND t.id IS NULL
ORDER BY d.id
"""
)
)
-249
View File
@@ -1,249 +0,0 @@
"""Tickets repository — CRUD with T-NNN TEXT PKs and history tracking."""
from __future__ import annotations
import sqlite3
from .schema import PRIORITIES, TICKET_STATUSES, TICKET_TYPES
def next_ticket_id(conn: sqlite3.Connection) -> str:
row = conn.execute(
"SELECT id FROM tickets WHERE id LIKE 'T-%' ORDER BY "
"CAST(SUBSTR(id, 3) AS INTEGER) DESC LIMIT 1"
).fetchone()
highest = 0
if row is not None:
try:
highest = int(row["id"].split("-", 1)[1])
except ValueError:
highest = 0
return f"T-{highest + 1:03d}"
def create(
conn: sqlite3.Connection,
*,
type_: str,
title: str,
description: str | None = None,
parent_id: str | None = None,
priority: str = "medium",
decision_ref: str | None = None,
team: str | None = None,
) -> str:
if type_ not in TICKET_TYPES:
raise ValueError(f"invalid type {type_!r}; must be one of {TICKET_TYPES}")
if priority not in PRIORITIES:
raise ValueError(f"invalid priority {priority!r}; must be one of {PRIORITIES}")
tid = next_ticket_id(conn)
conn.execute(
"""
INSERT INTO tickets
(id, type, parent_id, title, description, priority, decision_ref, team)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(tid, type_, parent_id, title, description, priority, decision_ref, team),
)
conn.commit()
return tid
def list_(
conn: sqlite3.Connection,
*,
status: str | None = None,
team: str | None = None,
assigned: str | None = None,
decision: str | None = None,
label: str | None = None,
) -> list[sqlite3.Row]:
conds: list[str] = []
params: list[str] = []
if status:
conds.append("t.status = ?")
params.append(status)
if team:
conds.append("t.team = ?")
params.append(team)
if assigned:
conds.append("t.assigned_to = ?")
params.append(assigned)
if decision:
conds.append("t.decision_ref = ?")
params.append(decision)
join = ""
if label:
join = " JOIN ticket_labels l ON l.ticket_id = t.id "
conds.append("l.label = ?")
params.append(label)
where = " AND ".join(conds) if conds else "1=1"
return list(
conn.execute(
f"""
SELECT t.id, t.type, t.title, t.status, t.priority,
t.assigned_to, t.team, t.decision_ref
FROM tickets t{join}
WHERE {where}
ORDER BY
CASE t.priority
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
ELSE 3
END,
t.id
""",
params,
)
)
def get(conn: sqlite3.Connection, tid: str) -> sqlite3.Row | None:
return conn.execute("SELECT * FROM tickets WHERE id = ?", (tid,)).fetchone()
def children(conn: sqlite3.Connection, tid: str) -> list[sqlite3.Row]:
return list(
conn.execute(
"SELECT id, type, title, status, priority FROM tickets "
"WHERE parent_id = ? ORDER BY id",
(tid,),
)
)
def blockers(conn: sqlite3.Connection, tid: str) -> list[sqlite3.Row]:
return list(
conn.execute(
"""
SELECT t.id, t.title, t.status
FROM ticket_deps d JOIN tickets t ON d.blocker_id = t.id
WHERE d.blocked_id = ?
ORDER BY t.id
""",
(tid,),
)
)
def set_status(
conn: sqlite3.Connection, tid: str, new_status: str, *, changed_by: str | None = None
) -> int:
if new_status not in TICKET_STATUSES:
raise ValueError(
f"invalid status {new_status!r}; must be one of {TICKET_STATUSES}"
)
row = get(conn, tid)
if row is None:
return 0
old_status = row["status"]
n = conn.execute(
"UPDATE tickets SET status = ?, updated_at = datetime('now') WHERE id = ?",
(new_status, tid),
).rowcount
if n:
conn.execute(
"""
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by)
VALUES (?, 'status', ?, ?, ?)
""",
(tid, old_status, new_status, changed_by),
)
conn.commit()
return n
def set_field(
conn: sqlite3.Connection,
tid: str,
field: str,
value: str | None,
*,
changed_by: str | None = None,
) -> int:
if field not in {"assigned_to", "team", "priority", "decision_ref"}:
raise ValueError(f"not a settable field: {field!r}")
row = get(conn, tid)
if row is None:
return 0
old = row[field]
n = conn.execute(
f"UPDATE tickets SET {field} = ?, updated_at = datetime('now') WHERE id = ?",
(value, tid),
).rowcount
if n:
conn.execute(
"""
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by)
VALUES (?, ?, ?, ?, ?)
""",
(tid, field, old, value, changed_by),
)
conn.commit()
return n
def block(conn: sqlite3.Connection, blocked: str, blocker: str) -> None:
conn.execute(
"INSERT OR IGNORE INTO ticket_deps (blocker_id, blocked_id) VALUES (?, ?)",
(blocker, blocked),
)
conn.commit()
def unblock(conn: sqlite3.Connection, blocked: str, blocker: str) -> int:
n = conn.execute(
"DELETE FROM ticket_deps WHERE blocked_id = ? AND blocker_id = ?",
(blocked, blocker),
).rowcount
conn.commit()
return n
def label_add(conn: sqlite3.Connection, tid: str, label: str) -> None:
conn.execute(
"INSERT OR IGNORE INTO ticket_labels (ticket_id, label) VALUES (?, ?)",
(tid, label),
)
conn.commit()
def label_rm(conn: sqlite3.Connection, tid: str, label: str) -> int:
n = conn.execute(
"DELETE FROM ticket_labels WHERE ticket_id = ? AND label = ?",
(tid, label),
).rowcount
conn.commit()
return n
def search(conn: sqlite3.Connection, query: str) -> list[sqlite3.Row]:
like = f"%{query}%"
return list(
conn.execute(
"""
SELECT id, type, title, status, priority, assigned_to, team
FROM tickets
WHERE title LIKE ? OR COALESCE(description, '') LIKE ?
ORDER BY id
""",
(like, like),
)
)
def board(conn: sqlite3.Connection, team: str | None = None) -> dict[str, list[sqlite3.Row]]:
columns: dict[str, list[sqlite3.Row]] = {s: [] for s in TICKET_STATUSES}
conds = ["1=1"]
params: list[str] = []
if team:
conds.append("team = ?")
params.append(team)
where = " AND ".join(conds)
for row in conn.execute(
f"SELECT id, title, status, priority, assigned_to, team FROM tickets "
f"WHERE {where} ORDER BY id",
params,
):
columns[row["status"]].append(row)
return columns
-93
View File
@@ -1,93 +0,0 @@
"""SQL schema for .pql/pql.db — adapted from settled-reach, Scrum stripped.
Differences from settled-reach:
- Tickets use TEXT PK (`T-NNN`), not INTEGER AUTOINCREMENT.
- No `sprints` table; no `sprint_id` column.
- `ticket_deps.{blocker,blocked}_id` are TEXT (referencing `tickets.id`).
"""
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
PRIMARY KEY (source_id, target_id, ref_type)
);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_id TEXT REFERENCES tickets(id),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'backlog'
CHECK(status IN ('backlog','ready','in_progress','review','done','cancelled')),
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_id TEXT NOT NULL REFERENCES tickets(id),
blocked_id TEXT NOT NULL REFERENCES tickets(id),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_id TEXT NOT NULL REFERENCES tickets(id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_id TEXT NOT NULL REFERENCES tickets(id),
label TEXT NOT NULL,
PRIMARY KEY (ticket_id, label)
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
"""
TICKET_STATUSES = (
"backlog",
"ready",
"in_progress",
"review",
"done",
"cancelled",
)
TICKET_TYPES = ("initiative", "epic", "story", "task", "bug")
PRIORITIES = ("critical", "high", "medium", "low")