diff --git a/.claude/rules/asset-pipeline.md b/.claude/rules/asset-pipeline.md index d5ff93659..50ad10ab5 100644 --- a/.claude/rules/asset-pipeline.md +++ b/.claude/rules/asset-pipeline.md @@ -160,7 +160,7 @@ the stamp picks it up automatically. **NOT legitimate and forbidden:** -- Running `tooling/db/sqlite-exec` (or any raw SQL) against `systems.db` by +- Running any raw SQL (e.g. the `sqlite3` CLI) against `systems.db` by hand. Any changes you make are silently reverted by the next `regen-db` run — your edits die, not the pipeline's. - One-off patch scripts that open `systems.db` and modify rows. diff --git a/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index 8110b7f4f..da983cfcb 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -26,11 +26,11 @@ docs/ db/ schema.sql # Database schema tooling/ - db/ # Connector scripts for SQLite and audio + db/ # Asset/connector scripts (audio, image, trellis, wiki) config.json # Endpoint configuration - ticket # Ticket + milestone CLI - sqlite_connector.py # SQLite mini MCP + common.py # Shared venv/config helpers audio_connector.py # Stable Audio Open connector + pql-migrate/ # One-shot scripts from the pql migration (provenance) .claude/ agents/ # Agent personality files skills/ # Skill definitions diff --git a/.claude/rules/ticket-cli.md b/.claude/rules/ticket-cli.md index f0d2072a8..cc70a20f4 100644 --- a/.claude/rules/ticket-cli.md +++ b/.claude/rules/ticket-cli.md @@ -102,5 +102,6 @@ pql ticket show T-745 --tree # the whole cascade - **Don't hand-edit `.pql/pql.db`** — it's rebuildable. Mutations go through `pql ticket …`; the pre-commit hook exports + stages `.pql/changelog/` automatically. - **Verify after create** — `pql ticket show ` to confirm the title is clean. -- The legacy `tooling/db/*` CLI and `SR_DB_PATH` remain only as a migration rollback path - and are removed once the cutover is stable. +- The legacy `tooling/db/{ticket,decision,sqlite-*}` CLI, `SR_DB_PATH`, and the committed + `settledreach.db` backup were removed in Phase 6 of the migration. The live + `settledreach.db` file (gitignored, repo-parent) is left on disk as a cold rollback only. diff --git a/.claude/settings.json b/.claude/settings.json index 992684786..5f05f3f1c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,8 +1,7 @@ { "autoMemoryDirectory": "/home/jeroenschweitzer/Projects/settled-reach/.memory", "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - "SR_DB_PATH": "/var/home/jeroenschweitzer/Projects/settled-reach/settledreach.db" + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }, "teammateMode": "auto", "permissions": { @@ -27,13 +26,6 @@ "Bash(git ls-tree *)", "Bash(git rev-parse --show-toplevel)", - "Bash(tooling/db/ticket *)", - "Bash(tooling/db/sqlite-query *)", - "Bash(tooling/db/sqlite-exec *)", - "Bash(tooling/db/sqlite-init)", - "Bash(tooling/db/decisions-sync)", - "Bash(tooling/db/decision *)", - "Bash(tooling/db/audio-generate *)", "Bash(tooling/db/audio-health)", "Bash(tooling/db/audio-post *)", diff --git a/Makefile b/Makefile index 3beff9186..d92f00f2a 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \ decisions-sync decisions-active decisions-validate \ - db-backup db-install validate-content check-fact-ids setup-hooks install-hooks \ + validate-content check-fact-ids setup-hooks install-hooks \ audit deny atlas-verify economy-db regen-db check-systems-db \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ pre-pr-server pre-pr-client pre-pr-content \ @@ -42,9 +42,6 @@ help: @echo " make check-protocol Verify server/client protocol versions match" @echo " make clean Remove build artifacts and caches" @echo "" - @echo " make db-backup Backup shared database to git (main only)" - @echo " make db-install Restore shared database from backup" - @echo "" @echo " make decisions-sync Sync governance/*.md decision records into pql.db" @echo " make decisions-active List active confirmed decisions (pql)" @echo " make decisions-validate Validate decision records — malformed-record gate (pql)" @@ -329,12 +326,6 @@ ci-client: lint-client build-client test-client # --- Database --- -db-backup: - @tooling/db-backup - -db-install: - @tooling/db-install - economy-db: ## Import economics data (commodities, chains, gate links) into systems.db @echo " Generating minor brands (D-189 #829)..." @tooling/generate-brands diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 4cd94d3c2..a2566cd5f 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -317,28 +317,26 @@ Key components: - **CauseChain** (production ECS component) — Tracks causal attribution for testable observation sequences (D-030). - **Deterministic replay** — Server simulation is deterministic given the same seed + input sequence. Replay logs enable regression testing (#201, critical). -## SQLite Access +## Planning store (pql) -**Never use the `sqlite3` CLI** — it crashes in Claude Code (std::bad_alloc). - -Use wrapper scripts: -```bash -tooling/db/sqlite-query "SELECT * FROM tickets WHERE status='open'" -tooling/db/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1" -``` - -## Decisions System - -Decisions are split into domain files under `decisions/` (see `decisions/README.md` for the full index). A SQLite index table syncs metadata for cross-referencing and querying. +Tickets and decisions live in **pql**, not in the old SQLite wrapper scripts. Decisions +are markdown-sourced under `governance/{decisions,questions,rejected}/`; tickets live in +`.pql/pql.db`, rebuilt from the git-tracked `.pql/changelog/`. Never use the `sqlite3` +CLI — it crashes in Claude Code (std::bad_alloc). ```bash -make decisions-sync # Parse decisions/*.md into SQLite -make decisions-coverage # Decision-to-ticket coverage by domain -make decisions-active # List all active confirmed decisions -make decisions-orphan # Decisions without implementing tickets +pql ticket list --status in_progress # query tickets (T-NNN ids) +pql ticket status T-440 done # mutate — flushed to the changelog +pql decisions list --type confirmed # query decisions +pql decisions show D-010 --with-tickets +make decisions-sync # parse governance/*.md into pql.db +make decisions-validate # malformed-record gate ``` -The sync runs automatically as part of `make setup` and via pre-commit hook. Markdown files are the source of truth; the DB is a derived index. +Markdown records + the changelog are the source of truth; `pql.db` is a rebuildable +index. The pre-commit hook runs `pql decisions validate` and exports ticket mutations to +the changelog; post-merge/checkout/rewrite hooks replay it. Full reference: +`.claude/rules/ticket-cli.md`. ## Commit Conventions diff --git a/docs/backups/settledreach.db.backup b/docs/backups/settledreach.db.backup deleted file mode 100644 index 1c1ca4beb..000000000 Binary files a/docs/backups/settledreach.db.backup and /dev/null differ diff --git a/tooling/check-decision-ids b/tooling/check-decision-ids deleted file mode 100755 index 298d011a8..000000000 --- a/tooling/check-decision-ids +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Pre-commit check: detect duplicate decision IDs across decisions/*.md files. -# Fails if the same D-NNN, Q-NNN, or R-NNN appears in more than one file. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(dirname "$SCRIPT_DIR")" - -# Use the decisions_sync.py check-dupes command -RESULT=$(python3 "$REPO_ROOT/tooling/db/decisions_sync.py" check-dupes 2>&1) - -# Parse the JSON result -DUPES=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('duplicates',0))" 2>/dev/null || echo "0") -TOTAL=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('total_ids',0))" 2>/dev/null || echo "0") - -if [ "$DUPES" -gt 0 ]; then - echo "check-decision-ids: FAILED — $DUPES duplicate ID(s) found" - echo "$RESULT" | python3 -c " -import sys, json -d = json.load(sys.stdin) -for detail in d.get('details', []): - print(f' {detail}') -" 2>/dev/null - exit 1 -fi - -echo "check-decision-ids: OK — $TOTAL unique IDs, no duplicates" -exit 0 diff --git a/tooling/db-backup b/tooling/db-backup deleted file mode 100755 index 71dd20213..000000000 --- a/tooling/db-backup +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Copy the shared database into git-tracked docs/backups/ for disaster recovery. -# Only runs on the main branch — called via `make db-backup`. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKTREE_ROOT="$(dirname "$SCRIPT_DIR")" -# Database path: SR_DB_PATH env var (absolute), or fallback to parent directory heuristic. -DB_PATH="${SR_DB_PATH:-$(dirname "$WORKTREE_ROOT")/settledreach.db}" -BACKUP_PATH="$WORKTREE_ROOT/docs/backups/settledreach.db.backup" - -branch="$(git -C "$WORKTREE_ROOT" branch --show-current)" -if [ "$branch" != "main" ]; then - echo "error: db-backup only runs on the main branch (currently on '$branch')" >&2 - exit 1 -fi - -if [ ! -f "$DB_PATH" ]; then - echo "error: shared database not found at $DB_PATH" >&2 - exit 1 -fi - -cp "$DB_PATH" "$BACKUP_PATH" -echo "ok: backed up $(du -h "$BACKUP_PATH" | cut -f1) to docs/backups/settledreach.db.backup" diff --git a/tooling/db-install b/tooling/db-install deleted file mode 100755 index ec90d8b80..000000000 --- a/tooling/db-install +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Restore the shared database from the git-tracked backup. -# Use after cloning or when the shared database is missing. -# Called via `make db-install`. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKTREE_ROOT="$(dirname "$SCRIPT_DIR")" -DB_PATH="$(dirname "$WORKTREE_ROOT")/settledreach.db" -BACKUP_PATH="$WORKTREE_ROOT/docs/backups/settledreach.db.backup" - -if [ -f "$DB_PATH" ]; then - echo "error: shared database already exists at $DB_PATH" >&2 - echo " delete it first if you want to restore from backup" >&2 - exit 1 -fi - -if [ ! -f "$BACKUP_PATH" ]; then - echo "error: backup not found at $BACKUP_PATH" >&2 - echo " run 'make db-backup' on the main branch first" >&2 - exit 1 -fi - -cp "$BACKUP_PATH" "$DB_PATH" -echo "ok: restored $(du -h "$DB_PATH" | cut -f1) to $DB_PATH" diff --git a/tooling/db/decision b/tooling/db/decision deleted file mode 100755 index 4c5260b14..000000000 --- a/tooling/db/decision +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# Decision ID management — claim, query, and validate decision IDs. -# Usage: -# decision sync Sync decisions/*.md into SQLite -# decision show Show a decision with linked tickets + refs -# decision next [D|Q|R] Show next available ID -# decision claim [title] Claim next ID (reserves in DB) -# decision check-dupes Check for duplicate IDs in markdown -# decision orphan-tickets List tickets with invalid/missing decision_ref -exec python3 "$(dirname "$0")/decisions_sync.py" "$@" diff --git a/tooling/db/decisions-sync b/tooling/db/decisions-sync deleted file mode 100755 index 85035d224..000000000 --- a/tooling/db/decisions-sync +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Sync decisions/*.md domain files into the SQLite database. Whitelistable command. -# Usage: decisions-sync -exec python3 "$(dirname "$0")/decisions_sync.py" sync "$@" diff --git a/tooling/db/decisions_sync.py b/tooling/db/decisions_sync.py deleted file mode 100644 index c85d823a9..000000000 --- a/tooling/db/decisions_sync.py +++ /dev/null @@ -1,595 +0,0 @@ -#!/usr/bin/env python3 -""" -Settled Reach Decisions Sync — parse decisions/*.md domain files into SQLite. - -Reads all markdown files from the decisions/ directory, parses decision blocks -(D-NNN, Q-NNN, R-NNN), extracts metadata, and upserts into the decisions and -decision_refs tables. - -Usage: - python3 decisions_sync.py sync Parse and upsert all decisions - python3 decisions_sync.py --help Show this help message -""" - -import json -import re -import sqlite3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import CONFIG_PATH, WORKTREE_ROOT, get_connection, load_config # noqa: E402 - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - -SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" -DECISIONS_DIR = WORKTREE_ROOT / "decisions" - - -# --------------------------------------------------------------------------- -# Parsing -# --------------------------------------------------------------------------- - -# Matches headings like: ### D-008: Action pillar design principles -HEADING_RE = re.compile(r"^###\s+((?:D|Q|R)-\d{3}):\s+(.+)$") - -# Matches metadata lines like: - **Date:** 2026-02-08 -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+(.+)") -ROUND_RE = re.compile(r"Round\s+(\d+)", re.IGNORECASE) - -# Cross-reference patterns in body text -REF_RE = re.compile(r"(?:D|Q|R)-\d{3}") - -# Contextual reference patterns (on specific metadata lines) -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) -CROSS_REF_RE = re.compile(r"^\s*-\s+\*\*Cross-reference:\*\*", re.IGNORECASE) -DEPENDS_RE = re.compile(r"^\s*-\s+\*\*Depends\s+on:\*\*", re.IGNORECASE) - -# Title may include [SUPERSEDED] suffix -SUPERSEDED_TITLE_RE = re.compile(r"\s*\[SUPERSEDED\]\s*$", re.IGNORECASE) - - -def classify_id(decision_id): - """Return the type string for a decision ID prefix.""" - prefix = decision_id[0] - return {"D": "confirmed", "Q": "question", "R": "rejected"}[prefix] - - -def infer_status(decision_id, title, body_lines): - """Infer the status of a decision from its content.""" - id_type = classify_id(decision_id) - - # Rejected alternatives are always 'rejected' (maps to our status concept) - if id_type == "rejected": - return "active" - - # Check for [SUPERSEDED] in title - if SUPERSEDED_TITLE_RE.search(title): - return "superseded" - - # Check body for "Superseded by:" line - for line in body_lines: - if SUPERSEDED_BY_RE.match(line): - return "superseded" - - # Questions: check if resolved - if id_type == "question": - for line in body_lines: - m = STATUS_RE.match(line) - if m: - status_text = m.group(1).strip() - lower = status_text.lower() - # "Partially resolved/scoped" or qualified "X resolved...Remaining" = still open - if "partial" in lower or "remaining" in lower: - return "open" - # Clean "Resolved ->" pattern = fully resolved - if lower.startswith("resolved"): - return "resolved" - # Everything else (not yet discussed, etc.) = open - return "open" - return "open" - - return "active" - - -def extract_round(body_lines): - """Try to find a Round number from the decision body.""" - for line in body_lines: - m = ROUND_RE.search(line) - if m: - return int(m.group(1)) - return None - - -def extract_date(body_lines): - """Extract date from metadata lines.""" - for line in body_lines: - m = DATE_RE.match(line) - if m: - return m.group(1) - return None - - -def extract_refs(decision_id, body_lines): - """ - Extract typed references from the body of a decision block. - - Returns a list of (target_id, ref_type, note) tuples. - """ - refs = [] - seen = set() - - for line in body_lines: - # Determine the ref_type based on the line context - if SUPERSEDES_RE.match(line): - ref_type = "supersedes" - elif SUPERSEDED_BY_RE.match(line): - # The *other* decision supersedes *this* one. - # We record it as the other decision superseding us, - # but from our perspective we store it as a reference. - # The canonical direction: source supersedes target. - # Here source=other, target=us. We'll record source=us, - # target=other with ref_type='references' (since we're - # the superseded party; the superseder's block carries - # the 'supersedes' ref). - ref_type = "references" - elif RESOLVES_RE.match(line): - ref_type = "resolves" - elif DEPENDS_RE.match(line): - ref_type = "depends_on" - elif CROSS_REF_RE.match(line): - ref_type = "references" - else: - ref_type = "references" - - # Find all decision IDs on this line - for target in REF_RE.findall(line): - if target == decision_id: - continue # skip self-references - key = (target, ref_type) - if key not in seen: - seen.add(key) - note = line.strip().lstrip("- ").rstrip() - # Truncate note to something reasonable - if len(note) > 200: - note = note[:197] + "..." - refs.append((target, ref_type, note)) - - return refs - - -def parse_file(filepath): - """ - Parse a single decisions/*.md file into a list of decision dicts. - - Each dict has: id, type, domain, title, status, round, date, file_path, - and a refs list of (target_id, ref_type, note). - """ - domain = filepath.stem # e.g. "architecture" from "architecture.md" - rel_path = str(filepath.relative_to(WORKTREE_ROOT)) - text = filepath.read_text(encoding="utf-8") - lines = text.split("\n") - - decisions = [] - current_id = None - current_title = None - current_body = [] - - def flush(): - if current_id is None: - return - clean_title = SUPERSEDED_TITLE_RE.sub("", current_title).strip() - decisions.append({ - "id": current_id, - "type": classify_id(current_id), - "domain": domain, - "title": clean_title, - "status": infer_status(current_id, current_title, current_body), - "round": extract_round(current_body), - "date": extract_date(current_body), - "file_path": rel_path, - "refs": extract_refs(current_id, current_body), - }) - - for line in lines: - m = HEADING_RE.match(line) - if m: - flush() - current_id = m.group(1) - current_title = m.group(2) - current_body = [] - elif current_id is not None: - # Stop collecting body at the next --- separator or new ### heading - if line.strip() == "---": - flush() - current_id = None - current_title = None - current_body = [] - else: - current_body.append(line) - - # Flush final block (file may not end with ---) - flush() - - return decisions - - -def parse_all(): - """Parse all decisions/*.md files. Returns (decisions_list, warnings).""" - if not DECISIONS_DIR.is_dir(): - return [], [f"Decisions directory not found: {DECISIONS_DIR}"] - - all_decisions = [] - warnings = [] - - md_files = sorted(DECISIONS_DIR.glob("*.md")) - # Skip README.md - md_files = [f for f in md_files if f.name.lower() != "readme.md"] - - if not md_files: - warnings.append(f"No .md files found in {DECISIONS_DIR}") - return all_decisions, warnings - - for filepath in md_files: - try: - decisions = parse_file(filepath) - all_decisions.extend(decisions) - except Exception as exc: - warnings.append(f"Error parsing {filepath.name}: {exc}") - - return all_decisions, warnings - - -# --------------------------------------------------------------------------- -# Database sync -# --------------------------------------------------------------------------- - - -def sync(cfg): - """Parse all decision files and upsert into the database.""" - decisions, warnings = parse_all() - - if not decisions and warnings: - return { - "ok": False, - "error": "No decisions parsed", - "warnings": warnings, - } - - # Collect all known IDs for reference validation - known_ids = {d["id"] for d in decisions} - - conn = get_connection(cfg) - try: - # Ensure tables exist (idempotent) - schema_sql = SCHEMA_PATH.read_text() - conn.executescript(schema_sql) - - upserted = 0 - refs_created = 0 - broken_refs = [] - - # Clear existing refs (we rebuild every sync) - conn.execute("DELETE FROM decision_refs") - - # Pass 1: Upsert all decisions (so foreign keys resolve in pass 2) - for d in decisions: - conn.execute( - """INSERT INTO decisions (id, type, domain, title, status, round, 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, - round = excluded.round, - date = excluded.date, - file_path = excluded.file_path, - synced_at = datetime('now')""", - (d["id"], d["type"], d["domain"], d["title"], - d["status"], d["round"], d["date"], d["file_path"]), - ) - upserted += 1 - - # Pass 2: Insert all references (all targets now exist) - for d in decisions: - for target_id, ref_type, note in d["refs"]: - if target_id not in known_ids: - broken_refs.append( - f"{d['id']} -> {target_id} ({ref_type}): target not found" - ) - warnings.append( - f"Broken reference: {d['id']} -> {target_id} " - f"({ref_type}) in {d['file_path']}" - ) - continue - - cur = conn.execute( - """INSERT OR IGNORE INTO decision_refs - (source_id, target_id, ref_type, note) - VALUES (?, ?, ?, ?)""", - (d["id"], target_id, ref_type, note), - ) - if cur.rowcount > 0: - refs_created += 1 - - conn.commit() - - return { - "ok": True, - "decisions_synced": upserted, - "refs_created": refs_created, - "broken_refs": len(broken_refs), - "warnings": warnings, - "summary": ( - f"Synced {upserted} decisions, " - f"{refs_created} refs created, " - f"{len(broken_refs)} broken refs, " - f"{len(warnings)} warnings" - ), - } - - except sqlite3.Error as exc: - conn.rollback() - return {"ok": False, "error": str(exc), "warnings": warnings} - finally: - conn.close() - - -# --------------------------------------------------------------------------- -# ID claiming — database is authority for ID allocation -# --------------------------------------------------------------------------- - - -def next_id(cfg, prefix=None): - """Return the next available ID for a given prefix (D, Q, R) or all.""" - conn = get_connection(cfg) - try: - result = {} - prefixes = [prefix.upper()] if prefix else ["D", "Q", "R"] - for p in prefixes: - # Check both DB and markdown files for the highest ID - row = conn.execute( - "SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as max_num " - "FROM decisions WHERE id LIKE ?", - (f"{p}-%",), - ).fetchone() - db_max = row["max_num"] if row and row["max_num"] else 0 - - # Also scan markdown files in case they're ahead of the DB - md_max = 0 - for filepath in sorted(DECISIONS_DIR.glob("*.md")): - if filepath.name.lower() == "readme.md": - continue - text = filepath.read_text(encoding="utf-8") - for m in re.finditer(rf"^###\s+{p}-(\d{{3}}):", text, re.MULTILINE): - num = int(m.group(1)) - if num > md_max: - md_max = num - - highest = max(db_max, md_max) - next_num = highest + 1 - result[p] = f"{p}-{next_num:03d}" - - return {"ok": True, **result} - finally: - conn.close() - - -def claim_id(cfg, prefix, domain, title): - """Claim the next available ID and insert a placeholder into the DB.""" - if prefix not in ("D", "Q", "R"): - return {"ok": False, "error": f"Invalid prefix: {prefix}. Must be D, Q, or R."} - - type_map = {"D": "confirmed", "Q": "question", "R": "rejected"} - status_map = {"D": "active", "Q": "open", "R": "active"} - - nxt = next_id(cfg, prefix) - if not nxt.get("ok"): - return nxt - - new_id = nxt[prefix] - conn = get_connection(cfg) - try: - conn.execute( - """INSERT INTO decisions (id, type, domain, title, status, file_path, synced_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""", - (new_id, type_map[prefix], domain, title, status_map[prefix], - f"decisions/{domain}.md"), - ) - conn.commit() - return {"ok": True, "id": new_id, "domain": domain, "title": title} - except sqlite3.IntegrityError as exc: - conn.rollback() - return {"ok": False, "error": f"ID conflict: {exc}"} - finally: - conn.close() - - -def show_decision(cfg, decision_id): - """Show a single decision with full details including linked tickets and cross-refs.""" - conn = get_connection(cfg) - try: - row = conn.execute( - "SELECT * FROM decisions WHERE id = ?", - (decision_id,), - ).fetchone() - - if not row: - return {"ok": False, "error": f"Decision not found: {decision_id}"} - - decision = dict(row) - - # Implementing tickets: tickets where decision_ref = this ID - ticket_rows = conn.execute( - "SELECT id, title, status, type FROM tickets" - " WHERE decision_ref = ? ORDER BY id", - (decision_id,), - ).fetchall() - decision["implementing_tickets"] = [dict(t) for t in ticket_rows] - - # Cross-refs outbound: references from this decision to others - refs_out = conn.execute( - "SELECT target_id, ref_type, note FROM decision_refs" - " WHERE source_id = ? ORDER BY target_id", - (decision_id,), - ).fetchall() - decision["refs_out"] = [dict(r) for r in refs_out] - - # Cross-refs inbound: other decisions referencing this one - refs_in = conn.execute( - "SELECT source_id, ref_type, note FROM decision_refs" - " WHERE target_id = ? ORDER BY source_id", - (decision_id,), - ).fetchall() - decision["refs_in"] = [dict(r) for r in refs_in] - - return {"ok": True, "decision": decision} - finally: - conn.close() - - -def orphan_tickets(cfg): - """List tickets whose decision_ref is set but does not match any decision in the DB.""" - conn = get_connection(cfg) - try: - rows = conn.execute( - """SELECT t.id, t.title, t.decision_ref, t.status, t.team - FROM tickets t - WHERE t.decision_ref IS NOT NULL - AND t.decision_ref != '' - AND t.decision_ref NOT IN (SELECT id FROM decisions) - ORDER BY t.decision_ref, t.id""", - ).fetchall() - - orphans = [dict(r) for r in rows] - - return { - "ok": True, - "count": len(orphans), - "orphans": orphans, - "summary": ( - f"{len(orphans)} orphan ticket(s) found" - if orphans - else "No orphan tickets — all decision_ref values are valid" - ), - } - finally: - conn.close() - - -def check_dupes(cfg): - """Check for duplicate decision IDs across all markdown files.""" - # Pre-existing collisions too deeply embedded to renumber (139+ references). - # New collisions are prevented by the claim workflow. - KNOWN_EXCEPTIONS = {"D-035"} - - id_locations = {} # id -> [(file, line_number)] - warnings = [] - - for filepath in sorted(DECISIONS_DIR.glob("*.md")): - if filepath.name.lower() == "readme.md": - continue - text = filepath.read_text(encoding="utf-8") - for i, line in enumerate(text.split("\n"), 1): - m = HEADING_RE.match(line) - if m: - did = m.group(1) - if did not in id_locations: - id_locations[did] = [] - id_locations[did].append((filepath.name, i)) - - dupes = {did: locs for did, locs in id_locations.items() - if len(locs) > 1 and did not in KNOWN_EXCEPTIONS} - - if dupes: - for did, locs in sorted(dupes.items()): - loc_str = ", ".join(f"{f}:{ln}" for f, ln in locs) - warnings.append(f"DUPLICATE {did}: {loc_str}") - - return { - "ok": len(dupes) == 0, - "total_ids": len(id_locations), - "duplicates": len(dupes), - "known_exceptions": list(KNOWN_EXCEPTIONS), - "details": warnings, - } - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -HELP_TEXT = """\ -Settled Reach Decisions Sync & ID Management - -Usage: - decisions_sync.py sync Parse decisions/*.md and upsert into SQLite - decisions_sync.py show Show a decision with linked tickets + refs - decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one) - decisions_sync.py claim [title] Claim next ID and insert placeholder - decisions_sync.py check-dupes Check for duplicate IDs across markdown files - decisions_sync.py orphan-tickets List tickets with invalid/missing decision_ref - decisions_sync.py --help Show this help message - -ID claiming workflow: - 1. Agent calls 'claim D architecture "Per-game save dirs"' - 2. Gets back D-085 (or whatever is next) - 3. Agent writes D-085 in the appropriate domain file - 4. Pre-commit hook runs check-dupes to catch collisions - -Config: {config} -Schema: {schema} -Source: {decisions} -""".format(config=CONFIG_PATH, schema=SCHEMA_PATH, decisions=DECISIONS_DIR) - - -def main(): - if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"): - print(HELP_TEXT) - sys.exit(0) - - cmd = sys.argv[1] - - try: - cfg = load_config() - except (FileNotFoundError, json.JSONDecodeError) as exc: - print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2)) - sys.exit(1) - - if cmd == "sync": - result = sync(cfg) - elif cmd == "show": - if len(sys.argv) < 3: - result = {"ok": False, "error": "Usage: show e.g. show D-159"} - else: - result = show_decision(cfg, sys.argv[2]) - elif cmd == "next": - result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None) - elif cmd == "claim": - if len(sys.argv) < 4: - result = {"ok": False, "error": "Usage: claim [title]"} - else: - prefix = sys.argv[2].upper() - domain = sys.argv[3] - title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)" - result = claim_id(cfg, prefix, domain, title) - elif cmd == "check-dupes": - result = check_dupes(cfg) - elif cmd == "orphan-tickets": - result = orphan_tickets(cfg) - else: - result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."} - - print(json.dumps(result, indent=2)) - sys.exit(0 if result.get("ok") else 1) - - -if __name__ == "__main__": - main() diff --git a/tooling/db/sqlite-exec b/tooling/db/sqlite-exec deleted file mode 100755 index 15c687b13..000000000 --- a/tooling/db/sqlite-exec +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Run an INSERT/UPDATE/DELETE on the ticketing database. Whitelistable command. -# Usage: sqlite-exec "UPDATE tickets SET status='done' WHERE id=1" -if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then - echo "Usage: sqlite-exec " >&2 - echo "" >&2 - echo "Run an INSERT, UPDATE, or DELETE on the ticketing database." >&2 - echo "Output is JSON: {\"ok\": true, \"affected_rows\": N}" >&2 - echo "" >&2 - echo "Examples:" >&2 - echo " sqlite-exec \"UPDATE tickets SET status='done' WHERE id=42\"" >&2 - echo " sqlite-exec \"UPDATE tickets SET assigned_to='stig' WHERE id=844\"" >&2 - exit 0 -fi -exec python3 "$(dirname "$0")/sqlite_connector.py" execute "$@" diff --git a/tooling/db/sqlite-init b/tooling/db/sqlite-init deleted file mode 100755 index 8ef705cb9..000000000 --- a/tooling/db/sqlite-init +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Initialise the ticketing database from schema.sql. Whitelistable command. -# Usage: sqlite-init -exec python3 "$(dirname "$0")/sqlite_connector.py" init "$@" diff --git a/tooling/db/sqlite-query b/tooling/db/sqlite-query deleted file mode 100755 index 71293c37b..000000000 --- a/tooling/db/sqlite-query +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Run a SELECT query on the ticketing database. Whitelistable command. -# Usage: sqlite-query "SELECT * FROM tickets WHERE status='in_progress'" -if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then - echo "Usage: sqlite-query " >&2 - echo "" >&2 - echo "Run a SELECT query on the ticketing database." >&2 - echo "Output is JSON: {\"ok\": true, \"count\": N, \"rows\": [...]}" >&2 - echo "" >&2 - echo "Examples:" >&2 - echo " sqlite-query \"SELECT id, title, status FROM tickets WHERE sprint_id=36\"" >&2 - echo " sqlite-query \"SELECT * FROM tickets WHERE assigned_to='stig'\"" >&2 - exit 0 -fi -exec python3 "$(dirname "$0")/sqlite_connector.py" query "$@" diff --git a/tooling/db/sqlite-seed b/tooling/db/sqlite-seed deleted file mode 100755 index ccb0cb502..000000000 --- a/tooling/db/sqlite-seed +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Seed the ticketing database with initiatives from decisions/ domain files. Whitelistable command. -# Usage: sqlite-seed -exec python3 "$(dirname "$0")/sqlite_connector.py" seed-decisions "$@" diff --git a/tooling/db/sqlite_connector.py b/tooling/db/sqlite_connector.py deleted file mode 100755 index c41be4dab..000000000 --- a/tooling/db/sqlite_connector.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -""" -Settled Reach SQLite Connector — mini MCP for ticket management. - -Usage: - python3 sqlite_connector.py init - python3 sqlite_connector.py query "SELECT * FROM tickets" - python3 sqlite_connector.py execute "UPDATE tickets SET status='done' WHERE id=1" - python3 sqlite_connector.py seed-decisions - python3 sqlite_connector.py --help -""" - -import json -import sqlite3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import WORKTREE_ROOT, get_connection, load_config # noqa: E402 - -SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - -def cmd_init(cfg): - """Initialise the database from schema.sql.""" - if not SCHEMA_PATH.exists(): - return {"ok": False, "error": f"Schema file not found: {SCHEMA_PATH}"} - - schema_sql = SCHEMA_PATH.read_text() - conn = get_connection(cfg) - try: - conn.executescript(schema_sql) - conn.commit() - return {"ok": True, "message": f"Database initialised at {cfg['sqlite_db_resolved']}"} - except sqlite3.Error as exc: - return {"ok": False, "error": str(exc)} - finally: - conn.close() - - -def cmd_query(cfg, sql): - """Run a SELECT query and return results as a JSON array of objects.""" - conn = get_connection(cfg) - try: - cursor = conn.execute(sql) - columns = [desc[0] for desc in cursor.description] if cursor.description else [] - rows = [dict(zip(columns, row)) for row in cursor.fetchall()] - return {"ok": True, "count": len(rows), "rows": rows} - except sqlite3.Error as exc: - return {"ok": False, "error": str(exc)} - finally: - conn.close() - - -def cmd_execute(cfg, sql): - """Run an INSERT/UPDATE/DELETE and return affected row count.""" - conn = get_connection(cfg) - try: - cursor = conn.execute(sql) - conn.commit() - return { - "ok": True, - "affected_rows": cursor.rowcount, - "last_id": cursor.lastrowid, - } - except sqlite3.Error as exc: - return {"ok": False, "error": str(exc)} - finally: - conn.close() - - -def cmd_seed_decisions(cfg): - """Seed the database with initiatives derived from decisions and open questions.""" - decisions = [ - ("initiative", "Custom game, not a mod", "backlog", "medium", "D-001"), - ("initiative", "Settled Reach as first campaign", "backlog", "medium", "D-003"), - ("initiative", "Single character first-person story generator", "backlog", "medium", "D-005"), - ("initiative", "Prototype scenario — Institute/Armstrong City/Guardians", "backlog", "medium", "D-006"), - ("initiative", "Five pillars of game design", "backlog", "medium", "D-007"), - ("initiative", "Action pillar design principles", "backlog", "medium", "D-008"), - ("initiative", "Multiplayer — design for it, build single-player first", "backlog", "medium", "D-009"), - ("initiative", "Multiplayer-ready architectural baseline", "backlog", "medium", "D-010"), - ("initiative", "Fog of perception non-negotiable", "backlog", "medium", "D-011"), - ("initiative", "Chunk-based map architecture", "backlog", "medium", "D-012"), - ("initiative", "Diegetic insert/POI navigation", "backlog", "medium", "D-013"), - ("initiative", "v0.1 map specification", "backlog", "medium", "D-014"), - ("initiative", "Camera locked to character", "backlog", "medium", "D-015"), - ("initiative", "Internal monologue system", "backlog", "medium", "D-016"), - ("initiative", "Perception modes as character build", "backlog", "medium", "D-017"), - ("initiative", "Three-range sound model", "backlog", "medium", "D-018"), - ("initiative", "Top-down with 3D cutscenes", "backlog", "medium", "D-019"), - ] - - questions = [ - ("story", "Game engine selection", "ready", "critical", "Q-001"), - ("story", "v0.1 prototype scope", "backlog", "medium", "Q-002"), - ("story", "Art direction", "backlog", "medium", "Q-003"), - ("story", "One campaign or separate eras", "backlog", "medium", "Q-004"), - ("story", "Prototype scale", "backlog", "medium", "Q-005"), - ("story", "Target platforms", "backlog", "medium", "Q-007"), - ("story", "Licensing/distribution", "backlog", "medium", "Q-008"), - ("story", "Time system", "backlog", "medium", "Q-009"), - ("story", "Storyteller AI design", "backlog", "medium", "Q-010"), - ("story", "Character selection roster", "backlog", "medium", "Q-011"), - ] - - conn = get_connection(cfg) - inserted = 0 - skipped = 0 - try: - for ticket_type, title, status, priority, decision_ref in decisions + questions: - # Check if a ticket with this decision_ref already exists - existing = conn.execute( - "SELECT id FROM tickets WHERE decision_ref = ?", (decision_ref,) - ).fetchone() - if existing: - skipped += 1 - continue - conn.execute( - "INSERT INTO tickets (type, title, status, priority, decision_ref) " - "VALUES (?, ?, ?, ?, ?)", - (ticket_type, title, status, priority, decision_ref), - ) - inserted += 1 - conn.commit() - return { - "ok": True, - "inserted": inserted, - "skipped": skipped, - "message": f"Seeded {inserted} tickets ({skipped} already existed)", - } - except sqlite3.Error as exc: - conn.rollback() - return {"ok": False, "error": str(exc)} - finally: - conn.close() - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -HELP_TEXT = """\ -Settled Reach SQLite Connector - -Usage: - sqlite_connector.py init Create/update database from schema.sql - sqlite_connector.py query "" Run a SELECT and return JSON rows - sqlite_connector.py execute "" Run INSERT/UPDATE/DELETE, return affected rows - sqlite_connector.py seed-decisions Seed initiatives from decisions D-001..D-019 and Q-001..Q-011 - sqlite_connector.py --help Show this help message - -All output is JSON on stdout. Errors also use JSON with {{"ok": false, "error": "..."}}. - -Schema: {schema} -""".format(schema=SCHEMA_PATH) - - -def main(): - if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"): - print(HELP_TEXT) - sys.exit(0) - - cmd = sys.argv[1] - - try: - cfg = load_config() - except (FileNotFoundError, json.JSONDecodeError) as exc: - print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2)) - sys.exit(1) - - if cmd == "init": - result = cmd_init(cfg) - elif cmd == "query": - if len(sys.argv) < 3: - result = {"ok": False, "error": "query requires a SQL string argument"} - else: - result = cmd_query(cfg, sys.argv[2]) - elif cmd == "execute": - if len(sys.argv) < 3: - result = {"ok": False, "error": "execute requires a SQL string argument"} - else: - result = cmd_execute(cfg, sys.argv[2]) - elif cmd == "seed-decisions": - result = cmd_seed_decisions(cfg) - else: - result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."} - - print(json.dumps(result, indent=2)) - sys.exit(0 if result.get("ok") else 1) - - -if __name__ == "__main__": - main() diff --git a/tooling/db/ticket b/tooling/db/ticket deleted file mode 100755 index c136bb700..000000000 --- a/tooling/db/ticket +++ /dev/null @@ -1,568 +0,0 @@ -#!/usr/bin/env python3 -""" -Ticket CLI — ergonomic interface to the project ticketing database. - -Usage: - ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T] - ticket show - ticket done [ ...] - ticket status - ticket assign - ticket unassign - ticket team - ticket deps - ticket dep add - ticket dep rm - ticket search - ticket create [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT] - ticket epics [--status S] - ticket children <id> - ticket count [--status S] - ticket wip - ticket milestone list [--status S] - ticket milestone create <name> [--description TEXT] [--phase N] - ticket milestone link <ticket_id> <milestone_id> - ticket milestone unlink <ticket_id> <milestone_id> - ticket milestone complete <milestone_id> - ticket milestone show <milestone_id> - ticket milestone dep <blocker_id> <blocked_id> - -All output is JSON on stdout. -""" - -import json -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from common import get_connection, load_config # noqa: E402 - -WIP_LIMIT = 5 - - -def query(conn, sql, params=()): - cursor = conn.execute(sql, params) - columns = [desc[0] for desc in cursor.description] if cursor.description else [] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - - -def execute(conn, sql, params=()): - cursor = conn.execute(sql, params) - conn.commit() - return cursor.rowcount - - -def out(data): - print(json.dumps(data, indent=2)) - - -def parse_flags(args, known_flags): - """Parse --flag value pairs from args, return (flags_dict, positional_args).""" - flags = {} - positional = [] - i = 0 - while i < len(args): - if args[i].startswith("--") and args[i][2:] in known_flags: - key = args[i][2:] - if i + 1 < len(args): - flags[key] = args[i + 1] - i += 2 - else: - positional.append(args[i]) - i += 1 - else: - positional.append(args[i]) - i += 1 - return flags, positional - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - -def cmd_list(conn, args): - flags, _ = parse_flags(args, ["status", "priority", "epic", "milestone", "assigned", "team"]) - conditions = [] - params = [] - join = "" - if "status" in flags: - conditions.append("t.status = ?") - params.append(flags["status"]) - if "priority" in flags: - conditions.append("t.priority = ?") - params.append(flags["priority"]) - if "epic" in flags: - conditions.append("t.parent_id = ?") - params.append(int(flags["epic"])) - if "milestone" in flags: - join = "JOIN ticket_milestones tm ON tm.ticket_id = t.id" - conditions.append("tm.milestone_id = ?") - params.append(int(flags["milestone"])) - if "assigned" in flags: - conditions.append("t.assigned_to = ?") - params.append(flags["assigned"]) - if "team" in flags: - conditions.append("(',' || t.team || ',' LIKE '%,' || ? || ',%')") - params.append(flags["team"]) - where = " AND ".join(conditions) if conditions else "1=1" - sql = f"""SELECT DISTINCT t.id, t.type, t.title, t.status, t.priority, t.assigned_to, - t.team, t.parent_id - 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""" - rows = query(conn, sql, tuple(params)) - out({"ok": True, "count": len(rows), "rows": rows}) - - -def cmd_show(conn, ids, brief=False): - tickets = [] - for ticket_id in ids: - rows = query(conn, """SELECT t.*, p.title as parent_title - FROM tickets t LEFT JOIN tickets p ON t.parent_id = p.id - WHERE t.id = ?""", (ticket_id,)) - if not rows: - tickets.append({"id": ticket_id, "error": f"Ticket #{ticket_id} not found"}) - continue - ticket = rows[0] - children = query(conn, "SELECT id, title, status, priority FROM tickets WHERE parent_id = ? ORDER BY id", (ticket_id,)) - blockers = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d - JOIN tickets t ON d.blocker_id = t.id - WHERE d.blocked_id = ?""", (ticket_id,)) - blocks = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d - JOIN tickets t ON d.blocked_id = t.id - WHERE d.blocker_id = ?""", (ticket_id,)) - milestones = query(conn, """SELECT m.id, m.name, m.status FROM ticket_milestones tm - JOIN milestones m ON tm.milestone_id = m.id - WHERE tm.ticket_id = ?""", (ticket_id,)) - ticket["children"] = children - ticket["blocked_by"] = blockers - ticket["blocks"] = blocks - ticket["milestones"] = milestones - tickets.append(ticket) - if brief: - _print_brief(tickets) - elif len(tickets) == 1: - out({"ok": True, "ticket": tickets[0]}) - else: - out({"ok": True, "count": len(tickets), "tickets": tickets}) - - -def _print_brief(tickets): - for i, t in enumerate(tickets): - if "error" in t: - print(f"#{t['id']}: NOT FOUND") - continue - print(f"#{t['id']}: {t['title']}") - parts = [f"{t['type']}", f"P:{t['priority']}", f"S:{t['status']}"] - if t.get("assigned_to"): - parts.append(f"@{t['assigned_to']}") - if t.get("team"): - parts.append(f"Team:{t['team']}") - if t.get("parent_id"): - parts.append(f"Epic:#{t['parent_id']} ({t.get('parent_title', '?')})") - if t.get("milestones"): - ms = ", ".join(f"M{m['id']}" for m in t["milestones"]) - parts.append(f"Milestones:{ms}") - if t.get("decision_ref"): - parts.append(f"Ref:{t['decision_ref']}") - print(f" {' | '.join(parts)}") - desc = t.get("description") or "" - if desc: - if len(desc) > 200: - desc = desc[:197] + "..." - print(f" {desc}") - if t.get("blocked_by"): - blockers = ", ".join(f"#{b['id']} ({b['status']})" for b in t["blocked_by"]) - print(f" Blocked by: {blockers}") - if t.get("blocks"): - blocks = ", ".join(f"#{b['id']}" for b in t["blocks"]) - print(f" Blocks: {blocks}") - if i < len(tickets) - 1: - print() - - -def cmd_done(conn, ids): - updated = 0 - for tid in ids: - updated += execute(conn, "UPDATE tickets SET status='done', updated_at=datetime('now') WHERE id=?", (int(tid),)) - out({"ok": True, "updated": updated, "ids": [int(i) for i in ids]}) - - -def cmd_status(conn, ticket_id, new_status): - valid = ('backlog', 'ready', 'in_progress', 'review', 'done', 'cancelled') - if new_status not in valid: - out({"ok": False, "error": f"Invalid status '{new_status}'. Valid: {', '.join(valid)}"}) - return - if new_status == 'in_progress': - wip = query(conn, "SELECT COUNT(*) as count FROM tickets WHERE status = 'in_progress'") - count = wip[0]["count"] - if count >= WIP_LIMIT: - print(f"WARNING: WIP limit ({WIP_LIMIT}) reached — {count} tickets already in_progress", file=sys.stderr) - updated = execute(conn, "UPDATE tickets SET status=?, updated_at=datetime('now') WHERE id=?", (new_status, int(ticket_id))) - out({"ok": True, "updated": updated, "id": int(ticket_id), "status": new_status}) - - -def cmd_assign(conn, ticket_id, agent): - updated = execute(conn, "UPDATE tickets SET assigned_to=?, updated_at=datetime('now') WHERE id=?", (agent, int(ticket_id))) - out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": agent}) - - -def cmd_unassign(conn, ticket_id): - updated = execute(conn, "UPDATE tickets SET assigned_to=NULL, updated_at=datetime('now') WHERE id=?", (int(ticket_id),)) - out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": None}) - - -def cmd_deps(conn, ticket_id): - blockers = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d - JOIN tickets t ON d.blocker_id = t.id - WHERE d.blocked_id = ? ORDER BY t.id""", (int(ticket_id),)) - blocks = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d - JOIN tickets t ON d.blocked_id = t.id - WHERE d.blocker_id = ? ORDER BY t.id""", (int(ticket_id),)) - out({"ok": True, "id": int(ticket_id), "blocked_by": blockers, "blocks": blocks}) - - -def cmd_dep(conn, args): - if len(args) < 3: - out({"ok": False, "error": "Usage: ticket dep add|rm <blocker_id> <blocked_id>"}) - return - action, blocker_id, blocked_id = args[0], int(args[1]), int(args[2]) - if action == "add": - try: - execute(conn, "INSERT INTO ticket_deps (blocker_id, blocked_id) VALUES (?, ?)", (blocker_id, blocked_id)) - out({"ok": True, "action": "added", "blocker_id": blocker_id, "blocked_id": blocked_id}) - except Exception as e: - out({"ok": False, "error": str(e)}) - elif action == "rm": - updated = execute(conn, "DELETE FROM ticket_deps WHERE blocker_id = ? AND blocked_id = ?", (blocker_id, blocked_id)) - out({"ok": True, "action": "removed", "updated": updated, "blocker_id": blocker_id, "blocked_id": blocked_id}) - else: - out({"ok": False, "error": f"Unknown dep action: {action}. Use 'add' or 'rm'."}) - - -def cmd_search(conn, keyword): - rows = query(conn, """SELECT id, type, title, status, priority, assigned_to, team - FROM tickets WHERE title LIKE ? OR description LIKE ? - ORDER BY id""", (f"%{keyword}%", f"%{keyword}%")) - out({"ok": True, "count": len(rows), "rows": rows}) - - -def cmd_create(conn, args): - flags, positional = parse_flags(args, ["parent", "priority", "decision", "team", "description"]) - if len(positional) < 2: - out({"ok": False, "error": "Usage: ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]"}) - return - ticket_type = positional[0] - title = " ".join(positional[1:]) - parent_id = int(flags["parent"]) if "parent" in flags else None - priority = flags.get("priority", "medium") - decision_ref = flags.get("decision") - team = flags.get("team") - description = flags.get("description") - conn.execute( - "INSERT INTO tickets (type, title, description, parent_id, priority, decision_ref, team) VALUES (?, ?, ?, ?, ?, ?, ?)", - (ticket_type, title, description, parent_id, priority, decision_ref, team)) - conn.commit() - last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"] - out({"ok": True, "id": last_id, "title": title}) - - -def cmd_epics(conn, args): - flags, _ = parse_flags(args, ["status"]) - conditions = ["t.type = 'epic'"] - params = [] - if "status" in flags: - conditions.append("t.status = ?") - params.append(flags["status"]) - where = " AND ".join(conditions) - rows = query(conn, f"""SELECT t.id, t.title, t.status, t.priority, t.assigned_to, t.team, - COUNT(c.id) as child_count, - SUM(CASE WHEN c.status='done' THEN 1 ELSE 0 END) as done_count - FROM tickets t LEFT JOIN tickets c ON c.parent_id = t.id - WHERE {where} GROUP BY t.id - ORDER BY CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 - WHEN 'medium' THEN 2 ELSE 3 END, t.id""", tuple(params)) - out({"ok": True, "count": len(rows), "rows": rows}) - - -def cmd_children(conn, ticket_id): - rows = query(conn, """SELECT id, type, title, status, priority, assigned_to, team - FROM tickets WHERE parent_id = ? ORDER BY id""", (int(ticket_id),)) - out({"ok": True, "count": len(rows), "parent_id": int(ticket_id), "rows": rows}) - - -def cmd_team(conn, ticket_id, teams): - updated = execute(conn, "UPDATE tickets SET team=?, updated_at=datetime('now') WHERE id=?", (teams, int(ticket_id))) - out({"ok": True, "updated": updated, "id": int(ticket_id), "team": teams}) - - -def cmd_count(conn, args): - flags, _ = parse_flags(args, ["status"]) - if "status" in flags: - rows = query(conn, "SELECT COUNT(*) as count FROM tickets WHERE status = ?", (flags["status"],)) - else: - rows = query(conn, "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY count DESC") - out({"ok": True, "rows": rows}) - - -def cmd_wip(conn): - rows = query(conn, """SELECT id, title, assigned_to, team FROM tickets - WHERE status = 'in_progress' ORDER BY id""") - count = len(rows) - out({"ok": True, "in_progress": count, "limit": WIP_LIMIT, - "over_limit": count > WIP_LIMIT, "tickets": rows}) - - -# --------------------------------------------------------------------------- -# Milestone commands -# --------------------------------------------------------------------------- - -def cmd_milestone(conn, args): - if not args: - out({"ok": False, "error": "Usage: ticket milestone list|create|link|unlink|complete|show|dep ..."}) - return - - sub = args[0] - rest = args[1:] - - if sub == "list": - cmd_milestone_list(conn, rest) - elif sub == "create": - cmd_milestone_create(conn, rest) - elif sub == "link": - cmd_milestone_link(conn, rest) - elif sub == "unlink": - cmd_milestone_unlink(conn, rest) - elif sub == "complete": - cmd_milestone_complete(conn, rest) - elif sub == "show": - cmd_milestone_show(conn, rest) - elif sub == "dep": - cmd_milestone_dep(conn, rest) - else: - out({"ok": False, "error": f"Unknown milestone subcommand: {sub}"}) - - -def cmd_milestone_list(conn, args): - flags, _ = parse_flags(args, ["status"]) - conditions = [] - params = [] - if "status" in flags: - conditions.append("m.status = ?") - params.append(flags["status"]) - where = " AND ".join(conditions) if conditions else "1=1" - rows = query(conn, f"""SELECT m.*, - COUNT(DISTINCT tm.ticket_id) as ticket_count, - SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done_count - FROM milestones m - LEFT JOIN ticket_milestones tm ON tm.milestone_id = m.id - LEFT JOIN tickets t ON t.id = tm.ticket_id - WHERE {where} - GROUP BY m.id ORDER BY m.id""", tuple(params)) - out({"ok": True, "count": len(rows), "milestones": rows}) - - -def cmd_milestone_create(conn, args): - flags, positional = parse_flags(args, ["description", "phase"]) - if not positional: - out({"ok": False, "error": "Usage: ticket milestone create <name> [--description TEXT] [--phase N]"}) - return - name = " ".join(positional) - description = flags.get("description") - phase = int(flags["phase"]) if "phase" in flags else None - conn.execute( - "INSERT INTO milestones (name, description, cascade_phase) VALUES (?, ?, ?)", - (name, description, phase)) - conn.commit() - last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"] - out({"ok": True, "id": last_id, "name": name}) - - -def cmd_milestone_link(conn, args): - if len(args) < 2: - out({"ok": False, "error": "Usage: ticket milestone link <ticket_id> <milestone_id>"}) - return - ticket_id, milestone_id = int(args[0]), int(args[1]) - try: - execute(conn, "INSERT INTO ticket_milestones (ticket_id, milestone_id) VALUES (?, ?)", (ticket_id, milestone_id)) - out({"ok": True, "action": "linked", "ticket_id": ticket_id, "milestone_id": milestone_id}) - except Exception as e: - out({"ok": False, "error": str(e)}) - - -def cmd_milestone_unlink(conn, args): - if len(args) < 2: - out({"ok": False, "error": "Usage: ticket milestone unlink <ticket_id> <milestone_id>"}) - return - ticket_id, milestone_id = int(args[0]), int(args[1]) - updated = execute(conn, "DELETE FROM ticket_milestones WHERE ticket_id = ? AND milestone_id = ?", (ticket_id, milestone_id)) - out({"ok": True, "action": "unlinked", "updated": updated, "ticket_id": ticket_id, "milestone_id": milestone_id}) - - -def cmd_milestone_complete(conn, args): - if not args: - out({"ok": False, "error": "Usage: ticket milestone complete <milestone_id>"}) - return - milestone_id = int(args[0]) - updated = execute(conn, "UPDATE milestones SET status='completed', completed_at=datetime('now') WHERE id=?", (milestone_id,)) - out({"ok": True, "updated": updated, "id": milestone_id, "status": "completed"}) - - -def cmd_milestone_show(conn, args): - if not args: - out({"ok": False, "error": "Usage: ticket milestone show <milestone_id>"}) - return - milestone_id = int(args[0]) - ms = query(conn, "SELECT * FROM milestones WHERE id = ?", (milestone_id,)) - if not ms: - out({"ok": False, "error": f"Milestone #{milestone_id} not found"}) - return - milestone = ms[0] - tickets = query(conn, """SELECT t.id, t.type, t.title, t.status, t.priority, t.assigned_to - FROM ticket_milestones tm JOIN tickets t ON t.id = tm.ticket_id - WHERE tm.milestone_id = ? - ORDER BY CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 - WHEN 'medium' THEN 2 ELSE 3 END, t.id""", (milestone_id,)) - blockers = query(conn, """SELECT m.id, m.name, m.status FROM milestone_deps d - JOIN milestones m ON d.blocker_id = m.id - WHERE d.blocked_id = ?""", (milestone_id,)) - blocks = query(conn, """SELECT m.id, m.name, m.status FROM milestone_deps d - JOIN milestones m ON d.blocked_id = m.id - WHERE d.blocker_id = ?""", (milestone_id,)) - milestone["tickets"] = tickets - milestone["blocked_by"] = blockers - milestone["blocks"] = blocks - out({"ok": True, "milestone": milestone}) - - -def cmd_milestone_dep(conn, args): - if len(args) < 2: - out({"ok": False, "error": "Usage: ticket milestone dep <blocker_id> <blocked_id>"}) - return - blocker_id, blocked_id = int(args[0]), int(args[1]) - try: - execute(conn, "INSERT INTO milestone_deps (blocker_id, blocked_id) VALUES (?, ?)", (blocker_id, blocked_id)) - out({"ok": True, "action": "added", "blocker_id": blocker_id, "blocked_id": blocked_id}) - except Exception as e: - out({"ok": False, "error": str(e)}) - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -HELP = """ticket — project ticket CLI - -Usage: - ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T] - ticket show [--brief] <id> [<id>...] Full ticket detail (--brief for summary) - ticket done <id> [<id> ...] Mark tickets as done - ticket status <id> <new_status> Change ticket status - ticket assign <id> <agent> Assign ticket to agent - ticket unassign <id> Remove assignment - ticket team <id> <teams> Set team(s) (comma-separated) - ticket deps <id> Show ticket dependencies - ticket dep add <blocker> <blocked> Add ticket dependency - ticket dep rm <blocker> <blocked> Remove ticket dependency - ticket search <keyword> Search tickets by title/description - ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT] - ticket epics [--status S] List epics with child counts - ticket children <id> List children of a ticket - ticket count [--status S] Count tickets by status - ticket wip Show in-progress count vs WIP limit - ticket milestone list [--status S] List milestones - ticket milestone create <name> [--description TEXT] [--phase N] Create milestone - ticket milestone link <ticket> <milestone> Link ticket to milestone - ticket milestone unlink <ticket> <milestone> Unlink ticket from milestone - ticket milestone complete <milestone> Mark milestone completed - ticket milestone show <milestone> Show milestone with tickets - ticket milestone dep <blocker> <blocked> Add milestone dependency""" - - -def main(): - if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"): - print(HELP) - sys.exit(0) - - try: - cfg = load_config() - except (FileNotFoundError, json.JSONDecodeError) as exc: - out({"ok": False, "error": f"Config error: {exc}"}) - sys.exit(1) - - conn = get_connection(cfg) - cmd = sys.argv[1] - args = sys.argv[2:] - - try: - if cmd == "list": - cmd_list(conn, args) - elif cmd == "show": - brief = "--brief" in args - id_args = [a for a in args if a != "--brief"] - if not id_args: - out({"ok": False, "error": "Usage: ticket show [--brief] <id> [<id> ...]"}) - else: - cmd_show(conn, [int(a) for a in id_args], brief=brief) - elif cmd == "done": - if not args: - out({"ok": False, "error": "Usage: ticket done <id> [<id> ...]"}) - else: - cmd_done(conn, args) - elif cmd == "status": - if len(args) < 2: - out({"ok": False, "error": "Usage: ticket status <id> <new_status>"}) - else: - cmd_status(conn, args[0], args[1]) - elif cmd == "assign": - if len(args) < 2: - out({"ok": False, "error": "Usage: ticket assign <id> <agent>"}) - else: - cmd_assign(conn, args[0], args[1]) - elif cmd == "unassign": - if not args: - out({"ok": False, "error": "Usage: ticket unassign <id>"}) - else: - cmd_unassign(conn, args[0]) - elif cmd == "team": - if len(args) < 2: - out({"ok": False, "error": "Usage: ticket team <id> <teams>"}) - else: - cmd_team(conn, args[0], args[1]) - elif cmd == "deps": - if not args: - out({"ok": False, "error": "Usage: ticket deps <id>"}) - else: - cmd_deps(conn, args[0]) - elif cmd == "dep": - cmd_dep(conn, args) - elif cmd == "search": - if not args: - out({"ok": False, "error": "Usage: ticket search <keyword>"}) - else: - cmd_search(conn, " ".join(args)) - elif cmd == "create": - cmd_create(conn, args) - elif cmd == "epics": - cmd_epics(conn, args) - elif cmd == "children": - if not args: - out({"ok": False, "error": "Usage: ticket children <id>"}) - else: - cmd_children(conn, args[0]) - elif cmd == "count": - cmd_count(conn, args) - elif cmd == "wip": - cmd_wip(conn) - elif cmd == "milestone": - cmd_milestone(conn, args) - else: - out({"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}) - finally: - conn.close() - - -if __name__ == "__main__": - main()