Files
settled-reach/tooling/db/decisions_sync.py
T
jpmschweitzerandClaude Opus 4.6 873746ff8b fix(ci): address PR #139 review — 5 issues + source list sync
1. decisions_sync.py: fix refs_created inflation (check rowcount),
   remove dead IntegrityError except block
2. Extract SCHEMA_VERSION to shared tooling/schema_version.py —
   both generators import from single source of truth
3. generate_atlas.py: narrow bare except to OperationalError +
   "duplicate column" check
4. check-systems-db-stamp: add cross-generator schema_version
   agreement assertion (defense-in-depth)
5. decision wrapper: add show + orphan-tickets to usage text
6. Add schema_version.py to all three source watch lists
   (GENERATOR_SOURCES, IMPORT_ECONOMICS_SOURCES, generate_atlas
   _write_stamp) — prevents silent staleness on version bump

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 10:29:24 +02:00

596 lines
20 KiB
Python

#!/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()