feat(db): decision ID claim system — prevent cross-worktree collisions
- `db/connectors/decision next [D|Q|R]` — show next available ID - `db/connectors/decision claim <prefix> <domain> [title]` — reserve ID in DB - `db/connectors/decision check-dupes` — detect duplicate IDs in markdown - `tooling/check-decision-ids` — pre-commit hook for dupe detection - D-035 added as known exception (139 files, too embedded to renumber) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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 <domain> "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
|
||||
|
||||
Executable
+8
@@ -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 <D|Q|R> <domain> [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" "$@"
|
||||
@@ -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 <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 --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 <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)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
|
||||
Executable
+28
@@ -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
|
||||
Reference in New Issue
Block a user