Files
settled-reach/tooling/db/decisions_sync.py
T
jpmschweitzerandClaude Opus 4.6 dd75203d11 refactor(process): replace persistent team worktrees with ephemeral sprint branches
Persistent worktrees (server, client, copy, audio, visual, ci, planning,
maintenance) caused agents crossing boundaries, stuck agents leaving
uncommitted work, and index.lock collisions. Replaced with ephemeral
sprint branches (sprint-{N}/{team}) and worktrees created on demand.

Changes:
- New start-sprint script replaces start-session (dynamic tabs per active team)
- Sprint teardown integrated into sprint-start skill (A1c step)
- SR_DB_PATH env var for database access from any directory
- CLAUDE.md team boundaries rewritten (scope-based, not directory-based)
- Agent Rule 0 updated to team scope dirs instead of worktree isolation
- PR review uses git show instead of cross-directory reads
- Briefing template updated for sprint-{N}/{team} branch naming
- Deleted worktree-update skill (obsolete)
- Removed WORKTREE_TEAM env var and cross-directory Read permissions
- All 8 persistent worktrees removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:33:02 +02:00

539 lines
18 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
# ---------------------------------------------------------------------------
# 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 <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
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 <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."}
print(json.dumps(result, indent=2))
sys.exit(0 if result.get("ok") else 1)
if __name__ == "__main__":
main()