docs(decisions): restructure into pql governance DQR tree
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>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user