docs(prd): backlog sharpened for cold sessions — execution footers, splits, D9 parked
- STATUS resume protocol: milestones ordered by due date, skip human-gated tops, named entry points (erp#38 / #51 / write-skill quartet); every issue now carries an Execution footer. - Backlog map: +erp#59 (T14 split from #48), +erp#60 (T11 loop split from #54), retitles, post-replay markers. - D9 meeting capture parked (nice-to-have; calls are iPhone-first). - prd_check.py preserved from the session scratchpad into scripts/ (the closure protocol references the pattern — now it's runnable). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mechanical link/anchor + convention checks for the ai-back-office PRD tree."""
|
||||
import re
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/Users/gabrielradureau/Work/Arcodange/factory/.claude/worktrees/client-dossier-synergy/vibe/PRD/ai-back-office")
|
||||
FILES = ["README.md", "task-inventory.md", "agent-architecture.md", "model-fleet.md", "compliance.md", "roadmap.md", "agent-catalog.md",
|
||||
"challenges.md", "poc-plan.md", "qa-strategy.md", "STATUS.md"]
|
||||
|
||||
LINK_RE = re.compile(r'\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)')
|
||||
HEADING_RE = re.compile(r'^(#{1,6})\s+(.+?)\s*$')
|
||||
|
||||
|
||||
def strip_md(text: str) -> str:
|
||||
text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text) # links -> text
|
||||
text = text.replace('`', '')
|
||||
return text
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""GitHub-style slug per the given spec: lowercase; keep alnum/space/hyphen; space->hyphen."""
|
||||
t = strip_md(text).strip().lower()
|
||||
kept = ''.join(ch for ch in t if ch.isalnum() or ch in ' -')
|
||||
return kept.replace(' ', '-')
|
||||
|
||||
|
||||
def load(path: Path):
|
||||
return path.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
|
||||
def headings_and_slugs(path: Path):
|
||||
"""Return ordered list of (level, text) headings outside code fences, plus slug set with GitHub dup handling."""
|
||||
slugs = {}
|
||||
heads = []
|
||||
in_fence = False
|
||||
for line in load(path):
|
||||
if line.strip().startswith('```'):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
text = m.group(2)
|
||||
heads.append((len(m.group(1)), text))
|
||||
base = slugify(text)
|
||||
if base in slugs:
|
||||
slugs[base] += 1
|
||||
slugs[f"{base}-{slugs[base]}"] = 0
|
||||
else:
|
||||
slugs[base] = 0
|
||||
return heads, set(slugs.keys())
|
||||
|
||||
|
||||
def extract_links(path: Path):
|
||||
"""(lineno, text, target) for every markdown link."""
|
||||
out = []
|
||||
for i, line in enumerate(load(path), 1):
|
||||
for m in LINK_RE.finditer(line):
|
||||
out.append((i, m.group(1), m.group(2)))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
slug_cache = {}
|
||||
|
||||
def slugs_for(p: Path):
|
||||
rp = p.resolve()
|
||||
if rp not in slug_cache:
|
||||
slug_cache[rp] = headings_and_slugs(rp)[1] if rp.exists() else set()
|
||||
return slug_cache[rp]
|
||||
|
||||
broken = []
|
||||
total_links = 0
|
||||
for fname in FILES:
|
||||
fpath = BASE / fname
|
||||
for lineno, text, target in extract_links(fpath):
|
||||
total_links += 1
|
||||
t = target.strip('<>')
|
||||
if t.startswith(('http://', 'https://', 'mailto:')):
|
||||
continue
|
||||
if '#' in t:
|
||||
pathpart, anchor = t.split('#', 1)
|
||||
else:
|
||||
pathpart, anchor = t, None
|
||||
if pathpart:
|
||||
resolved = (fpath.parent / pathpart).resolve()
|
||||
if not resolved.exists():
|
||||
broken.append(f"{fname}:{lineno} -> {target} [MISSING FILE {resolved}]")
|
||||
continue
|
||||
else:
|
||||
resolved = fpath.resolve()
|
||||
if anchor is not None:
|
||||
if resolved.suffix != '.md':
|
||||
broken.append(f"{fname}:{lineno} -> {target} [ANCHOR ON NON-MD]")
|
||||
continue
|
||||
sl = slugs_for(resolved)
|
||||
if anchor not in sl:
|
||||
близ = difflib.get_close_matches(anchor, sl, n=2)
|
||||
broken.append(f"{fname}:{lineno} -> {target} [UNRESOLVED ANCHOR; close: {близ}]")
|
||||
|
||||
print("=== JOB 1: LINK & ANCHOR CHECK ===")
|
||||
print(f"total links scanned: {total_links}")
|
||||
if broken:
|
||||
for b in broken:
|
||||
print("BROKEN:", b)
|
||||
else:
|
||||
print("all relative file links + anchors resolve: NONE BROKEN")
|
||||
|
||||
# ---------------- Job 2 mechanical parts ----------------
|
||||
print("\n=== JOB 2a: BREADCRUMBS ===")
|
||||
crumb_re = re.compile(r'^(\[[^\]]+\]\([^)]+\) > )+\*\*[^*]+\*\*$')
|
||||
for fname in FILES:
|
||||
first = load(BASE / fname)[0]
|
||||
ok = bool(crumb_re.match(first))
|
||||
print(f"{fname}:1 breadcrumb {'OK' if ok else 'VIOLATION: ' + first!r}")
|
||||
|
||||
print("\n=== JOB 2b: HEADER BLOCKQUOTE (Status + Last Updated) ===")
|
||||
for fname in FILES:
|
||||
lines = load(BASE / fname)
|
||||
bq = [l for l in lines[:12] if l.startswith('>')]
|
||||
joined = '\n'.join(bq)
|
||||
has_status = '**Status:**' in joined
|
||||
mdate = re.search(r'\*\*Last Updated:\*\*\s*(\S+)', joined)
|
||||
date = mdate.group(1) if mdate else None
|
||||
ok = has_status and date == '2026-07-11'
|
||||
print(f"{fname}: Status={'Y' if has_status else 'N'} LastUpdated={date} -> {'OK' if ok else 'VIOLATION'}")
|
||||
|
||||
print("\n=== JOB 2c: TOMBSTONE SCAN (eyeball hits) ===")
|
||||
tomb = re.compile(r'(?i)previously|formerly|renamed from|was renamed|no longer|used to be|correction \(|changelog|superseded|deprecated|instead of the old|updated? on \d{4}')
|
||||
hits = 0
|
||||
for fname in FILES:
|
||||
for i, line in enumerate(load(BASE / fname), 1):
|
||||
if tomb.search(line):
|
||||
hits += 1
|
||||
print(f"{fname}:{i}: {line.strip()[:140]}")
|
||||
if not hits:
|
||||
print("no tombstone-pattern hits")
|
||||
|
||||
print("\n=== JOB 2d: BIDIRECTIONAL LINKS ===")
|
||||
hub_text = (BASE / "README.md").read_text()
|
||||
leaves = [f for f in FILES if f != "README.md"]
|
||||
for leaf in leaves:
|
||||
in_hub = f']({leaf})' in hub_text or f']({leaf}#' in hub_text
|
||||
leaf_text = (BASE / leaf).read_text()
|
||||
back = '](README.md)' in leaf_text or '](README.md#' in leaf_text
|
||||
print(f"{leaf}: hub->leaf {'OK' if in_hub else 'MISSING'} | leaf->hub {'OK' if back else 'MISSING'}")
|
||||
|
||||
print("\n=== JOB 2e: MERMAID CONVENTION ===")
|
||||
for fname in FILES:
|
||||
lines = load(BASE / fname)
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i].strip().startswith('```mermaid'):
|
||||
start = i
|
||||
first_inner = lines[i + 1].strip() if i + 1 < len(lines) else ''
|
||||
has_init = first_inner.startswith('%%{init')
|
||||
j = i + 1
|
||||
while j < len(lines) and not lines[j].strip().startswith('```'):
|
||||
j += 1
|
||||
k = j + 1
|
||||
while k < len(lines) and lines[k].strip() == '':
|
||||
k += 1
|
||||
followed = k < len(lines) and re.match(r'^1[.)]\s', lines[k].strip())
|
||||
print(f"{fname}:{start+1} mermaid: init={'OK' if has_init else 'MISSING'} numbered-list-after={'OK' if followed else 'MISSING'}")
|
||||
i = j
|
||||
i += 1
|
||||
|
||||
print("\n=== JOB 2f: ID CONSISTENCY SCAN ===")
|
||||
# definitions from headings
|
||||
def defined_ids(fname, pat):
|
||||
heads, _ = headings_and_slugs(BASE / fname)
|
||||
out = set()
|
||||
for _, h in heads:
|
||||
m = re.match(pat, h)
|
||||
if m:
|
||||
out.add(m.group(1))
|
||||
return out
|
||||
|
||||
tasks_def = defined_ids("task-inventory.md", r'^(T\d{2})\b')
|
||||
chal_def = defined_ids("challenges.md", r'^(C\d{1,2})\b')
|
||||
poc_def = defined_ids("poc-plan.md", r'^(POC-\d)\b')
|
||||
|
||||
refs = {'T': {}, 'C': {}, 'POC': {}, 'D': {}, 'A': {}, 'phase': {}}
|
||||
for fname in FILES:
|
||||
text = (BASE / fname).read_text()
|
||||
for m in re.finditer(r'\bT\d{2}\b', text):
|
||||
refs['T'].setdefault(m.group(0), set()).add(fname)
|
||||
for m in re.finditer(r'\bC\d{1,2}\b', text):
|
||||
refs['C'].setdefault(m.group(0), set()).add(fname)
|
||||
for m in re.finditer(r'\bPOC-\d\b', text):
|
||||
refs['POC'].setdefault(m.group(0), set()).add(fname)
|
||||
for m in re.finditer(r'\bD\d\b', text):
|
||||
refs['D'].setdefault(m.group(0), set()).add(fname)
|
||||
for m in re.finditer(r'\bA\d\b', text):
|
||||
refs['A'].setdefault(m.group(0), set()).add(fname)
|
||||
for m in re.finditer(r'(?i)\bphase\s+(\d)\b', text):
|
||||
refs['phase'].setdefault(m.group(1), set()).add(fname)
|
||||
|
||||
print(f"tasks defined: {sorted(tasks_def)}")
|
||||
print(f"task refs outside defined set: {sorted(set(refs['T']) - tasks_def)}")
|
||||
print(f"challenges defined: {sorted(chal_def, key=lambda x: int(x[1:]))}")
|
||||
print(f"challenge refs outside defined set: {sorted(set(refs['C']) - chal_def)}")
|
||||
print(f"POCs defined: {sorted(poc_def)}")
|
||||
print(f"POC refs outside defined set: {sorted(set(refs['POC']) - poc_def)}")
|
||||
print(f"D refs: {sorted(refs['D'])} (defined D1-D6 in agent-architecture open-decisions table)")
|
||||
print(f"A-token refs: {sorted(refs['A'])} <- note A4 is a CA3 box, not an autonomy level")
|
||||
print(f"phase numbers referenced: {sorted(refs['phase'])}")
|
||||
|
||||
print("\n=== JOB 2g: DATE SCAN (regulatory) ===")
|
||||
date_pat = re.compile(r'20\d{2}-\d{2}(?:-\d{2})?')
|
||||
for fname in FILES:
|
||||
for i, line in enumerate(load(BASE / fname), 1):
|
||||
for m in date_pat.finditer(line):
|
||||
d = m.group(0)
|
||||
if d.startswith(('2026-09', '2027-09', '2027-01', '2026-12', '2027-05')):
|
||||
print(f"{fname}:{i}: {d} | {line.strip()[:100]}")
|
||||
|
||||
print("\n=== BONUS: parent PRD hub backlink ===")
|
||||
parent = BASE.parent / "README.md"
|
||||
if parent.exists():
|
||||
ptext = parent.read_text()
|
||||
print(f"vibe/PRD/README.md links ai-back-office: {'YES' if 'ai-back-office' in ptext else 'NO — tree not registered in parent hub'}")
|
||||
else:
|
||||
print("parent PRD hub missing")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user