Files
settled-reach/tooling/pql-migrate/restructure_decisions.py
T
jpmschweitzerandClaude Opus 4.8 f8d9b777b0 fix(config): address PR #154 review — gitattributes glob + script roots
Tyre (architecture review):
- .gitattributes: `.pql/changelog/*.sql` matched nothing (files are one level
  deeper at .pql/changelog/<table>/<YYYY-MM>.sql), so the union-merge driver never
  applied — `git check-attr merge` returned `unspecified`. Fixed to
  `.pql/changelog/**/*.sql`; now resolves to `merge: union` for monthly + schema
  files. Restores the changelog's conflict-free merge guarantee.
- Migration scripts: the re-runnable ones (seed_tickets.py, add_workshop_provenance.py)
  now derive the repo root from `git rev-parse --show-toplevel` instead of a hardcoded
  /main path, so re-running from a worktree/clone targets the right checkout. The three
  one-shot transforms (restructure_decisions, repath_references, retag_ticket_refs)
  get a comment noting they're already-applied and unsafe to re-run (git mv on moved
  sources) — keeping the path honest rather than implying re-runnability.

Hoshe approved (all QA checks passed). ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:05:31 +02:00

158 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Restructure decisions/ into the pql governance/ DQR tree (pql migration, Phase 1).
Moves the flat decisions/*.md layout into governance/{decisions,questions,rejected}/<domain>.md,
which is what `pql decisions sync` parses (type from subdir, domain from filename stem).
Link rewrites are TOKEN-PRESERVING: only the relative `foo.md` path portion of a
markdown link changes; the `[D-NNN]` bracket text and `#d-nnn-...` anchor stay
byte-identical, so pql's reference extraction is unaffected regardless of whether it
reads bracket text, anchor, or path.
Default is a dry run (prints every planned move + link change). Pass --apply to execute.
Idempotent-ish: re-running after apply is a no-op for moves (sources already gone).
"""
import re
import subprocess
import sys
from pathlib import Path
# One-shot transform, already applied (decisions/ -> governance/). Re-running is
# unsupported: the git mv calls would fail on already-moved sources. The path below
# is the canonical main checkout where it was run; kept committed for provenance.
ROOT = Path("/var/mnt/data/projects/settled-reach/main")
APPLY = "--apply" in sys.argv
DSTEMS = {"architecture", "content", "economics", "perception", "process", "scope"}
QSTEMS = ["architecture", "content", "perception", "process", "scope"]
# R-record -> domain (R-001..R-010 platform/engine = architecture; R-011 economics; R-012 perception)
def rdomain(n: int) -> str:
if n == 11:
return "economics"
if n == 12:
return "perception"
return "architecture"
REJECTED_DOMAINS = sorted({rdomain(n) for n in range(1, 13)}) # architecture, economics, perception
LINK = re.compile(r"\]\((?P<path>[A-Za-z0-9_-]+\.md)(?P<frag>#[^)]*)?\)")
def new_target_path(path: str, frag: str, src_subdir: str):
"""Return the rewritten relative path for a link target, or None to leave unchanged."""
stem = path[:-3]
if path == "rejected.md":
m = re.match(r"#r-0*(\d+)", frag or "")
rd = rdomain(int(m.group(1))) if m else "architecture"
ttype, tdom = "rejected", rd
elif path.startswith("questions-"):
ttype, tdom = "questions", stem[len("questions-"):]
elif path in ("questions.md", "README.md"):
ttype, tdom = "index", None
elif stem in DSTEMS:
ttype, tdom = "decisions", stem
else:
return None # unknown stem; leave as-is
if ttype == "index":
return "README.md" if src_subdir == "root" else "../README.md"
if ttype == src_subdir:
# same subdir: bare filename
return f"{tdom}.md"
# cross-subdir
prefix = "" if src_subdir == "root" else "../"
return f"{prefix}{ttype}/{tdom}.md"
def rewrite_links(text: str, src_subdir: str):
changes = []
def repl(mo):
path, frag = mo.group("path"), mo.group("frag") or ""
np = new_target_path(path, frag, src_subdir)
if np is None or np == path:
return mo.group(0)
changes.append((f"{path}{frag}", f"{np}{frag}"))
return f"]({np}{frag})"
return LINK.sub(repl, text), changes
def git(*args):
subprocess.run(["git", "-C", str(ROOT), *args], check=True)
def log_changes(rel, changes):
if changes:
print(f" {rel}: {len(changes)} link(s) rewritten")
for old, new in changes:
print(f" {old} -> {new}")
def move_and_rewrite(src_rel, dst_rel, src_subdir):
src, dst = ROOT / src_rel, ROOT / dst_rel
text = src.read_text(encoding="utf-8")
new_text, changes = rewrite_links(text, src_subdir)
print(f"MOVE {src_rel} -> {dst_rel}")
log_changes(dst_rel, changes)
if APPLY:
dst.parent.mkdir(parents=True, exist_ok=True)
git("mv", src_rel, dst_rel)
dst.write_text(new_text, encoding="utf-8")
def split_rejected():
src_rel = "decisions/rejected.md"
text = (ROOT / src_rel).read_text(encoding="utf-8")
# split into records on '### R-' headings
parts = re.split(r"(?m)^(?=### R-\d)", text)
head = parts[0] # title + intro before first record
records = parts[1:]
buckets = {d: [] for d in REJECTED_DOMAINS}
for rec in records:
m = re.match(r"### R-0*(\d+)", rec)
n = int(m.group(1))
# strip a trailing '---' / footer that may cling to the last record
body = re.split(r"(?m)^---\s*$", rec)[0].rstrip() + "\n"
buckets[rdomain(n)].append(body)
print(f"SPLIT {src_rel} -> {len(REJECTED_DOMAINS)} domain files")
for dom, recs in buckets.items():
dst_rel = f"governance/rejected/{dom}.md"
title = f"# Rejected Alternatives — {dom.capitalize()}\n\nRejected proposals in the **{dom}** domain, rationale preserved for the audit trail.\n\n"
content = title + "\n".join(recs).rstrip() + "\n"
content, changes = rewrite_links(content, "rejected")
print(f" -> {dst_rel}: {len(recs)} record(s)")
log_changes(dst_rel, changes)
if APPLY:
(ROOT / dst_rel).parent.mkdir(parents=True, exist_ok=True)
(ROOT / dst_rel).write_text(content, encoding="utf-8")
if APPLY:
git("rm", src_rel)
def main():
print(f"=== restructure_decisions.py ({'APPLY' if APPLY else 'DRY-RUN'}) ===\n")
# 1. D-domain files -> governance/decisions/
for s in sorted(DSTEMS):
move_and_rewrite(f"decisions/{s}.md", f"governance/decisions/{s}.md", "decisions")
print()
# 2. questions-<domain>.md -> governance/questions/<domain>.md
for q in QSTEMS:
move_and_rewrite(f"decisions/questions-{q}.md", f"governance/questions/{q}.md", "questions")
print()
# 3. rejected.md -> governance/rejected/<domain>.md (split by domain)
split_rejected()
print()
# 4. index files (README.md, questions.md) are folded into governance/README.md by hand;
# remove the originals here.
for idx in ("decisions/README.md", "decisions/questions.md"):
print(f"REMOVE {idx} (folded into governance/README.md)")
if APPLY:
git("rm", idx)
print("\nDone." + ("" if APPLY else " (dry run — pass --apply to execute)"))
if __name__ == "__main__":
main()