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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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" "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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()
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user