diff --git a/.config/hooks/pre-commit b/.config/hooks/pre-commit index bb5bc7e6c..5a3334de7 100755 --- a/.config/hooks/pre-commit +++ b/.config/hooks/pre-commit @@ -21,6 +21,7 @@ run_check() { # --- Checks --- run_check "tooling/check-fact-ids" "fact_id validation" +run_check "tooling/check-decision-ids" "decision ID duplication" if [ "$ERRORS" -gt 0 ]; then echo "" diff --git a/CLAUDE.md b/CLAUDE.md index ae65dd053..780a2c88d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,12 +56,14 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha | Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill | | SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — | | SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — | +| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — | | Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill | | Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill | ### File conventions - Decisions: domain files in `decisions/` (see `decisions/README.md` for index) - Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected) +- **Claim IDs before writing:** `db/connectors/decision claim D "title"` — prevents ID collisions across worktrees - Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified. - Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete - Briefings: one per agent, updated after decision-producing rounds diff --git a/db/connectors/decision b/db/connectors/decision new file mode 100755 index 000000000..82492f616 --- /dev/null +++ b/db/connectors/decision @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Decision ID management — claim, query, and validate decision IDs. +# Usage: +# decision next [D|Q|R] Show next available ID +# decision claim [title] Claim next ID (reserves in DB) +# decision check-dupes Check for duplicate IDs in markdown +# decision sync Sync markdown -> DB +exec python3 "$(dirname "$0")/decisions_sync.py" "$@" diff --git a/db/connectors/decisions_sync.py b/db/connectors/decisions_sync.py index d32df5cc7..d03d304b4 100644 --- a/db/connectors/decisions_sync.py +++ b/db/connectors/decisions_sync.py @@ -366,22 +366,133 @@ def sync(cfg): 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 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 = """\ -Commonwealth Decisions Sync +Commonwealth Decisions Sync & ID Management Usage: - decisions_sync.py sync Parse decisions/*.md and upsert into SQLite - decisions_sync.py --help Show this help message + decisions_sync.py sync Parse decisions/*.md and upsert into SQLite + decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one) + decisions_sync.py claim [title] Claim next ID and insert placeholder + decisions_sync.py check-dupes Check for duplicate IDs across markdown files + decisions_sync.py --help Show this help message -Parses all markdown files in decisions/ (excluding README.md), extracts -decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions -and decision_refs tables. - -Idempotent: safe to run repeatedly. References are rebuilt on every sync. +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} @@ -404,6 +515,18 @@ def main(): if cmd == "sync": result = sync(cfg) + elif cmd == "next": + result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None) + elif cmd == "claim": + if len(sys.argv) < 4: + result = {"ok": False, "error": "Usage: claim [title]"} + else: + prefix = sys.argv[2].upper() + domain = sys.argv[3] + title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)" + result = claim_id(cfg, prefix, domain, title) + elif cmd == "check-dupes": + result = check_dupes(cfg) else: result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."} diff --git a/tooling/check-decision-ids b/tooling/check-decision-ids new file mode 100755 index 000000000..14c59c2f3 --- /dev/null +++ b/tooling/check-decision-ids @@ -0,0 +1,28 @@ +#!/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/db/connectors/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