chore(meta): retire legacy SQLite ticket/decision tooling (pql migration phase 6)

The pql cutover is stable, so remove the superseded SQLite planning tooling. Surgical
— only the ticket/decision/raw-SQL scripts (all settledreach.db-bound and replaced by
pql) are deleted; the asset/audio/wiki connectors and shared common.py stay.

Removed:
- tooling/db/{ticket,decision,decisions-sync,decisions_sync.py,sqlite-query,sqlite-exec,
  sqlite-init,sqlite-seed,sqlite_connector.py}
- tooling/{db-backup,db-install} + docs/backups/settledreach.db.backup (the binary-DB
  backup ritual; tickets now live in the git-tracked .pql/changelog/)
- tooling/check-decision-ids (dead stub, superseded by `pql decisions validate`)
- Makefile db-backup/db-install targets; SR_DB_PATH + tooling/db/{ticket,sqlite-*,
  decision*} entries from .claude/settings.json (audio entries kept)

Updated docs to pql: DEVOPS.md (SQLite Access + Decisions System → pql), project
structure, ticket-cli closing note, asset-pipeline raw-SQL warning.

Kept (verified still imported by the asset connectors via common.ensure_venv): common.py,
config.json, audio/image/trellis/wiki connectors. The live settledreach.db file
(gitignored, repo-parent) is left on disk as a cold rollback only.

ruff clean; pql decisions validate ok (357 decisions / 1013 tickets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 22:13:29 +02:00
co-authored by Claude Opus 4.8
parent 53064b051e
commit 5a399c7924
19 changed files with 24 additions and 1533 deletions
-28
View File
@@ -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
-24
View File
@@ -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"
-25
View File
@@ -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"
-10
View File
@@ -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 <D-NNN> Show a decision with linked tickets + refs
# decision next [D|Q|R] Show next available ID
# decision claim <D|Q|R> <domain> [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" "$@"
-4
View File
@@ -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 "$@"
-595
View File
@@ -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 <D-NNN> 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 <D|Q|R> <domain> [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 <decision_id> 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 <D|Q|R> <domain> [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()
-15
View File
@@ -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 <SQL>" >&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 "$@"
-4
View File
@@ -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 "$@"
-15
View File
@@ -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 <SQL>" >&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 "$@"
-4
View File
@@ -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 "$@"
-199
View File
@@ -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 "<SQL>" Run a SELECT and return JSON rows
sqlite_connector.py execute "<SQL>" 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()
-568
View File
@@ -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 <id>
ticket done <id> [<id> ...]
ticket status <id> <new_status>
ticket assign <id> <agent>
ticket unassign <id>
ticket team <id> <teams>
ticket deps <id>
ticket dep add <blocker_id> <blocked_id>
ticket dep rm <blocker_id> <blocked_id>
ticket search <keyword>
ticket create <type> <title> [--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()