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>
158 lines
6.0 KiB
Python
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()
|