Phase 1 of the pql migration. Moves the flat decisions/*.md layout into
governance/{decisions,questions,rejected}/<domain>.md — the tree pql's
`decisions sync` parses natively (record type from subdir, domain from
filename stem). Proven against pql 1.6.2: sync reports 357 records
(237 D / 108 Q / 12 R), 1057 refs, broken: 0; validate ok.
- 6 D-domain files -> governance/decisions/ (git renames)
- 5 questions-<domain>.md -> governance/questions/<domain>.md (prefix dropped)
- rejected.md split by domain -> governance/rejected/{architecture(R-001..010),
economics(R-011),perception(R-012)}.md
- decisions/README.md + questions.md index folded into governance/README.md;
pql's `decisions sync` now auto-maintains the record index appended below
the hand-written domain guidance (no more manual ID-list table upkeep).
- .pql/config.yaml: canonical vault config (tracked, not ignored).
Link rewrites are token-preserving: only the relative `foo.md` path portion
changes (e.g. `rejected.md#r-011` -> `../rejected/economics.md#r-011`); every
`[D-NNN]` bracket text and `#anchor` stays byte-identical, so pql's reference
extraction is unaffected. The one-shot transform is committed at
tooling/pql-migrate/restructure_decisions.py for provenance.
Codebase path references to decisions/ (CLAUDE.md, rules, skills, docs) are
updated in a follow-up commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
5.8 KiB
Python
155 lines
5.8 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
|
|
|
|
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()
|