#!/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 # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" DECISIONS_DIR = WORKTREE_ROOT / "decisions" # Database path: SR_DB_PATH env var (absolute), or fallback to parent directory heuristic. DB_PATH = Path(os.environ["SR_DB_PATH"]).resolve() if os.environ.get("SR_DB_PATH") else (WORKTREE_ROOT / ".." / "settledreach.db").resolve() # --------------------------------------------------------------------------- # Config / DB (same pattern as sqlite_connector.py) # --------------------------------------------------------------------------- def load_config(): """Load config.json and resolve the SQLite database path.""" with open(CONFIG_PATH, "r") as f: cfg = json.load(f) cfg["sqlite_db_resolved"] = str(DB_PATH) return cfg def get_connection(cfg): """Return an sqlite3 connection with WAL mode and foreign keys enabled.""" conn = sqlite3.connect(cfg["sqlite_db_resolved"]) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA foreign_keys=ON;") conn.row_factory = sqlite3.Row return conn # --------------------------------------------------------------------------- # 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 try: conn.execute( """INSERT OR IGNORE INTO decision_refs (source_id, target_id, ref_type, note) VALUES (?, ?, ?, ?)""", (d["id"], target_id, ref_type, note), ) refs_created += 1 except sqlite3.IntegrityError: pass # duplicate ref, skip 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 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 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 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 == "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."} print(json.dumps(result, indent=2)) sys.exit(0 if result.get("ok") else 1) if __name__ == "__main__": main()