diff options
| -rwxr-xr-x | .ai/scripts/spec-sort | 715 | ||||
| -rw-r--r-- | .ai/scripts/tests/spec-sort.bats | 453 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/spec-sort | 715 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/spec-sort.bats | 453 | ||||
| -rw-r--r-- | todo.org | 4 |
5 files changed, 2338 insertions, 2 deletions
diff --git a/.ai/scripts/spec-sort b/.ai/scripts/spec-sort new file mode 100755 index 0000000..ebfef82 --- /dev/null +++ b/.ai/scripts/spec-sort @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""spec-sort — one-time docs-pile retrofit for the docs-lifecycle convention. + +Classifies every docs/**/*.org outside docs/specs/ by one predicate: a doc +carrying BOTH a "Decisions" heading AND an "Implementation phases" heading is +a spec candidate; everything else is a note. For each candidate it shows an +evidence panel (Status field, decision/finding cookies, the linking todo.org +task, recent dated history, cheap existence checks on phase-named artifacts) +and proposes a lifecycle keyword the evidence supports — conservative +non-terminal (DRAFT) when inconclusive. The helper proposes; a human confirms +every move. + +Dry-run report is the default. --apply executes under the fail-safe contract: + + - Clean-worktree preflight: refuses on a dirty git tree (exit 2) unless + --allow-dirty, which prints exactly what recovery loses. + - Every candidate must be addressed with --confirm REL=KEYWORD or + --skip REL; terminal keywords (IMPLEMENTED SUPERSEDED CANCELLED) also + need --reason REL=TEXT, recorded in the status-history line. + - The full move + relink plan is computed and validated first (every + destination free, every link resolvable), written to a plan file, and + only then executed from that recorded plan. + - Bare-path mentions of a moving doc inside the rewritten roots are + reported, never rewritten; they block --apply until --acknowledge-bare + explicitly waives them. + - Mid-apply failure stops the run, names what was and wasn't applied, and + prints the git-restore recovery recipe (plus deletion of newly created + destination copies, which git restore can't remove). + - After a successful apply, a residue scan across the rewritten roots must + find no link still resolving to an old path, or spec-sort exits non-zero + naming the residue. + +Per move: rename to carry the -spec.org suffix, prepend the status heading +(:ID: UUID + dated history line), rewrite the keyword header to the +two-sequence form, mirror the keyword into the Metadata Status field, and +recompute every affected file: link (inbound links to the moved doc AND the +moved doc's own outbound relative links). Rewritten roots: todo.org, +.ai/notes.org, docs/**, .ai/project-workflows/, .ai/project-scripts/. +Reported-never-rewritten: .ai/sessions/ (frozen history) and synced template +paths (.ai/workflows/, .ai/scripts/, .ai/protocols.org — the report names +the canonical claude-templates file instead). + +Finally stamps :LAST_SPEC_SORT: YYYY-MM-DD in .ai/notes.org's +* Workflow State section (created idempotently), which permanently clears +the startup nudge. A run with zero candidates still stamps. + +Exit codes: 0 done (or clean report), 1 blocked (confirm gate, validation, +bare mentions, residue, mid-apply failure), 2 usage / preflight refusal. + +Test hook: SPEC_SORT_INJECT_FAIL_AFTER=N aborts the apply after N write +operations, exercising the recovery path in the bats suite. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime + +LIFECYCLE = ("DRAFT", "READY", "DOING", "IMPLEMENTED", "SUPERSEDED", "CANCELLED") +TERMINAL = {"IMPLEMENTED", "SUPERSEDED", "CANCELLED"} +TODO_HEADER = [ + "#+TODO: TODO | DONE", + "#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED", +] + +# Project-owned surfaces whose file: links get rewritten. +REWRITE_ROOTS = ("todo.org", ".ai/notes.org", "docs", ".ai/project-workflows", ".ai/project-scripts") +# Frozen or synced surfaces: occurrences are reported, never rewritten. +REPORT_ROOTS = (".ai/sessions", ".ai/workflows", ".ai/scripts", ".ai/protocols.org") +# Synced template paths map to their canonical rulesets file for the report. +SYNCED_PREFIX = (".ai/workflows", ".ai/scripts", ".ai/protocols.org") + +LINK_RE = re.compile(r"\[\[file:([^\]\[]+)\](?:\[([^\]\[]*)\])?\]") +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +COOKIE_RE = re.compile(r"\[\d+/\d+\]") +DATED_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b") + + +def read_text(path): + try: + with open(path, encoding="utf-8") as f: + return f.read() + except (UnicodeDecodeError, OSError): + return None + + +def heading_text(line): + """Heading text with the org keyword and priority cookie stripped.""" + m = HEADING_RE.match(line) + if not m: + return None + text = re.sub(r"^[A-Z]+\s+", "", m.group(2)) + text = re.sub(r"^\[#[A-Z]\]\s+", "", text) + return text.strip() + + +def has_spine(content): + """The classification predicate: Decisions AND Implementation phases.""" + dec = imp = False + for line in content.splitlines(): + t = heading_text(line) + if t is None: + continue + tl = t.lower() + if tl.startswith("decisions"): + dec = True + elif tl.startswith("implementation phases"): + imp = True + return dec and imp + + +def walk_files(root, rel_base): + """Yield project-relative paths of files under rel_base (file or dir).""" + abs_base = os.path.join(root, rel_base) + if os.path.isfile(abs_base): + yield rel_base + return + for dirpath, dirs, files in os.walk(abs_base): + dirs.sort() + for name in sorted(files): + yield os.path.relpath(os.path.join(dirpath, name), root) + + +def classify(root): + """Split docs/**/*.org outside docs/specs/ into candidates / anomalies / notes.""" + candidates, anomalies, notes = [], [], [] + docs = os.path.join(root, "docs") + if not os.path.isdir(docs): + return candidates, anomalies, notes + for rel in walk_files(root, "docs"): + if not rel.endswith(".org"): + continue + parts = rel.split(os.sep) + if len(parts) > 1 and parts[1] == "specs": + continue + content = read_text(os.path.join(root, rel)) + if content is None: + continue + if has_spine(content): + candidates.append(rel) + elif os.path.basename(rel).endswith("-spec.org"): + anomalies.append(rel) + else: + notes.append(rel) + return candidates, anomalies, notes + + +def dest_for(rel): + base = os.path.basename(rel) + if not base.endswith("-spec.org"): + base = base[: -len(".org")] + "-spec.org" + return os.path.join("docs", "specs", base) + + +# ---- Evidence panel --------------------------------------------------- + + +def todo_task_for(root, rel): + """Heading of the first todo.org task whose subtree mentions the doc.""" + content = read_text(os.path.join(root, "todo.org")) + if content is None: + return None + lines = content.splitlines() + basename = os.path.basename(rel) + for i, line in enumerate(lines): + if basename in line or rel in line: + for j in range(i, -1, -1): + if HEADING_RE.match(lines[j]): + return lines[j].lstrip("* ").strip() + return None + return None + + +def gather_evidence(root, rel, content): + ev = {} + m = re.search(r"^\|\s*Status\s*\|\s*([^|]*)\|", content, re.MULTILINE | re.IGNORECASE) + ev["status"] = m.group(1).strip() if m else None + + cookies = [] + for line in content.splitlines(): + t = heading_text(line) + if t and COOKIE_RE.search(t) and ( + t.lower().startswith("decisions") or t.lower().startswith("review findings") + ): + cookies.append(t) + ev["cookies"] = cookies + + ev["todo"] = todo_task_for(root, rel) + kw = None + if ev["todo"]: + m = re.match(r"([A-Z]+)\s", ev["todo"]) + kw = m.group(1) if m else None + ev["todo_keyword"] = kw + + dated = [ln.strip() for ln in content.splitlines() if DATED_RE.search(ln)] + ev["history"] = dated[-1][:100] if dated else None + + # Cheap artifact check: =path= tokens inside the Implementation phases section. + artifacts, exists = [], 0 + section = re.split(r"^\*+\s+.*implementation phases.*$", content, maxsplit=1, flags=re.MULTILINE | re.IGNORECASE) + if len(section) > 1: + for tok in re.findall(r"=([^=\s]+)=", section[1]): + if "/" in tok: + artifacts.append(tok) + if os.path.exists(os.path.join(root, tok)): + exists += 1 + ev["artifacts"] = (exists, artifacts) + return ev + + +def propose_keyword(ev): + s = (ev["status"] or "").lower() + words = set(re.findall(r"[a-z]+", s)) + if words & {"implemented", "shipped", "complete", "completed", "done"}: + return "IMPLEMENTED" + if words & {"superseded"}: + return "SUPERSEDED" + if words & {"cancelled", "canceled", "dead", "abandoned"}: + return "CANCELLED" + if words & {"doing", "implementing"} or "in progress" in s or "in-progress" in s: + return "DOING" + if ev["todo_keyword"] == "DOING": + return "DOING" + if words & {"ready", "approved", "accepted"}: + return "READY" + return "DRAFT" # conservative non-terminal default + + +# ---- Link scanning ---------------------------------------------------- + + +def rewrite_files(root): + """Project-relative *.org files under the rewritten roots.""" + seen = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + if rel.endswith(".org") and rel not in seen: + seen.append(rel) + return seen + + +def resolve_target(root, linker_rel, raw_target, moved): + """Resolve a file: link target to a project-relative path (org semantics + first — relative to the linking file's directory — then project-root + anchoring as a fallback for root-anchored links).""" + if raw_target.startswith(("/", "~", "http:", "https:")): + return None + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker_rel), raw_target)) + if rel_a in moved or os.path.exists(os.path.join(root, rel_a)): + return rel_a + rel_b = os.path.normpath(raw_target) + if rel_b in moved or os.path.exists(os.path.join(root, rel_b)): + return rel_b + return rel_a + + +def plan_link_edits(root, moved): + """Compute every link rewrite: inbound links to moved docs and moved + docs' own outbound relative links. Returns ({linker_rel: [(old, new)]}, + [ambiguity descriptions]) — a link whose file-relative and root-anchored + readings are both live and disagree about a moving doc blocks validation + rather than being rewritten against a guess.""" + edits = {} + ambiguous = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + linker_post = moved.get(linker, linker) + for m in LINK_RE.finditer(content): + raw = m.group(1) + desc = m.group(2) + target_path, sep, anchor = raw.partition("::") + target = resolve_target(root, linker, target_path, moved) + if target is None: + continue + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker), target_path)) + rel_b = os.path.normpath(target_path) + if rel_a != rel_b: + live_a = rel_a in moved or os.path.exists(os.path.join(root, rel_a)) + live_b = rel_b in moved or os.path.exists(os.path.join(root, rel_b)) + if live_a and live_b and (rel_a in moved or rel_b in moved): + ambiguous.append( + "%s: [[file:%s]] reads as %s (file-relative) or %s (root-anchored) " + "and a moving doc is involved — resolve the link by hand" % (linker, raw, rel_a, rel_b)) + continue + if target not in moved and linker not in moved: + continue + if target not in moved and not os.path.exists(os.path.join(root, target)): + continue # already broken before this run; not ours to guess + target_post = moved.get(target, target) + new_path = os.path.relpath(target_post, os.path.dirname(linker_post) or ".") + new_raw = new_path + (sep + anchor if sep else "") + if new_raw == raw: + continue + new_link = "[[file:%s]%s]" % (new_raw, "[%s]" % desc if desc is not None else "") + if m.group(0) != new_link: + edits.setdefault(linker, []).append((m.group(0), new_link)) + return edits, ambiguous + + +def scan_bare_mentions(root, moved): + """Bare-path mentions of moving docs in the rewritten roots — text + occurrences outside any [[...]] link. Reported, never rewritten.""" + found = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for i, line in enumerate(content.splitlines(), 1): + stripped = re.sub(r"\[\[[^\]]*\](?:\[[^\]]*\])?\]", "", line) + for src in moved: + if src in stripped: + found.append((rel, i, src)) + return found + + +def scan_report_only(root, moved): + """Occurrences of moving docs in frozen/synced surfaces.""" + reports = [] + for base in REPORT_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for src in moved: + if src in content: + if rel.startswith(SYNCED_PREFIX): + note = ("synced template, not rewritten — a local edit is reverted by the " + "next sync; edit the canonical claude-templates/%s instead" % rel) + else: + note = "frozen history; not rewritten" + reports.append((rel, src, note)) + return reports + + +# ---- Content transforms ----------------------------------------------- + + +def transform_spec(content, keyword, reason, title, doc_id, link_edits): + """Apply the retrofit rewrite to a moving spec's content: two-sequence + keyword header, prepended status heading, Status-field mirror, and the + doc's own link edits.""" + for old, new in link_edits: + content = content.replace(old, new) + lines = content.splitlines() + + todo_idx = None + kept = [] + for line in lines: + if line.startswith("#+TODO:"): + if todo_idx is None: + todo_idx = len(kept) + continue + kept.append(line) + lines = kept + if todo_idx is None: + todo_idx = 0 + while todo_idx < len(lines) and lines[todo_idx].startswith("#+"): + todo_idx += 1 + lines[todo_idx:todo_idx] = TODO_HEADER + + head_end = 0 + while head_end < len(lines) and (lines[head_end].startswith("#+") or not lines[head_end].strip()): + head_end += 1 + ts = datetime.now().astimezone().strftime("%Y-%m-%d %a @ %H:%M:%S %z") + provenance = "reason: %s" % reason if reason else "evidence-based, human-confirmed" + block = [ + "* %s %s" % (keyword, title), + ":PROPERTIES:", + ":ID: %s" % doc_id, + ":END:", + "- %s — retrofitted by spec-sort; status set to %s (%s)" % (ts, keyword, provenance), + "", + ] + lines[head_end:head_end] = block + + out = [] + mirrored = False + for line in lines: + m = re.match(r"^(\|\s*Status\s*\|)([^|]*)(\|.*)$", line, re.IGNORECASE) + if m and not mirrored: + value = " %s" % keyword.lower() + width = len(m.group(2)) + line = m.group(1) + (value.ljust(width) if len(value) <= width else value + " ") + m.group(3) + mirrored = True + out.append(line) + return "\n".join(out) + "\n" + + +def title_for(content, rel): + m = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE) + if m: + return m.group(1).strip() + base = os.path.basename(rel)[: -len(".org")] + return base[: -len("-spec")] if base.endswith("-spec") else base + + +# ---- Marker ------------------------------------------------------------ + + +def stamp_marker(root, date): + path = os.path.join(root, ".ai", "notes.org") + os.makedirs(os.path.dirname(path), exist_ok=True) + content = read_text(path) or "" + line = ":LAST_SPEC_SORT: %s" % date + if ":LAST_SPEC_SORT:" in content: + content = re.sub(r":LAST_SPEC_SORT:.*", line, content, count=1) + elif re.search(r"^\* Workflow State\s*$", content, re.MULTILINE): + content = re.sub(r"(^\* Workflow State\s*$)", r"\1\n" + line, content, count=1, flags=re.MULTILINE) + else: + if content and not content.endswith("\n"): + content += "\n" + content += "\n* Workflow State\n\n%s\n" % line + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +# ---- Apply ------------------------------------------------------------- + + +class ApplyFailure(Exception): + """Mid-apply failure: args are (applied_labels, remaining_ops, cause).""" + + +def apply_plan(root, plan, fail_after): + """Execute the recorded plan. Returns the applied-op labels; raises + ApplyFailure mid-way on a write error or when the test hook fires.""" + ops = [] + for mv in plan["moves"]: + ops.append(("move", mv)) + for linker, edits in plan["link_edits"].items(): + if linker in {mv["src"] for mv in plan["moves"]}: + continue # a moving doc's own edits ride along in its transform + ops.append(("relink", (linker, edits))) + + applied = [] + specs_dir = os.path.join(root, "docs", "specs") + if plan["moves"] and not os.path.isdir(specs_dir): + os.makedirs(specs_dir) + plan["created_dirs"].append(os.path.join("docs", "specs")) + + for n, (kind, payload) in enumerate(ops, 1): + if fail_after and n > fail_after: + raise ApplyFailure(applied, ops[n - 1:], "injected test failure") + try: + if kind == "move": + mv = payload + content = read_text(os.path.join(root, mv["src"])) + new = transform_spec(content, mv["keyword"], mv["reason"], mv["title"], mv["id"], + plan["link_edits"].get(mv["src"], [])) + with open(os.path.join(root, mv["dest"]), "w", encoding="utf-8") as f: + f.write(new) + os.remove(os.path.join(root, mv["src"])) + applied.append("move %s -> %s" % (mv["src"], mv["dest"])) + else: + linker, edits = payload + path = os.path.join(root, linker) + content = read_text(path) + for old, new in edits: + content = content.replace(old, new) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + applied.append("relink %s (%d link%s)" % (linker, len(edits), "s" if len(edits) != 1 else "")) + except OSError as exc: + raise ApplyFailure(applied, ops[n - 1:], str(exc)) + return applied + + +def residue_check(root, plan): + """Post-apply: no link in the rewritten roots may still resolve to an + old path; bare mentions beyond the acknowledged set fail too.""" + moved = {mv["src"]: mv["dest"] for mv in plan["moves"]} + residue = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + for m in LINK_RE.finditer(content): + target_path = m.group(1).partition("::")[0] + target = resolve_target(root, linker, target_path, {}) + if target in moved: + residue.append("%s: link still resolves to %s" % (linker, target)) + # Acknowledged mentions were recorded pre-apply; a mention inside a moved + # doc now lives at the doc's destination, so map the file side through the + # moves before comparing. + acknowledged = {(moved.get(f, f), src) for f, _ln, src in plan["bare"]} + for f, ln, src in scan_bare_mentions(root, moved): + if (f, src) not in acknowledged: + residue.append("%s:%d: bare mention of %s" % (f, ln, src)) + return residue + + +def print_recovery(plan, applied, not_applied): + print("FAILURE — the apply did not complete.") + print(" applied:") + for a in applied or ["(nothing)"]: + print(" %s" % a) + print(" not applied:") + for kind, payload in not_applied: + if kind == "move": + print(" move %s -> %s" % (payload["src"], payload["dest"])) + else: + print(" relink %s" % payload[0]) + print("RECOVERY — restore the pre-run state (safe: preflight required a clean tree):") + touched = [mv["src"] for mv in plan["moves"]] + [l for l in plan["link_edits"] if l not in {mv["src"] for mv in plan["moves"]}] + print(" git restore -- %s" % " ".join(touched)) + created = [mv["dest"] for mv in plan["moves"]] + print(" rm -f -- %s # git restore can't remove the created copies" % " ".join(created)) + for d in plan.get("created_dirs", []): + print(" rmdir --ignore-fail-on-non-empty -- %s" % d) + + +# ---- Main --------------------------------------------------------------- + + +def parse_kv(pairs, label): + out = {} + for item in pairs or []: + if "=" not in item: + sys.exit("spec-sort: %s expects REL=VALUE, got %r" % (label, item)) + k, v = item.split("=", 1) + out[os.path.normpath(k)] = v + return out + + +def main(): + ap = argparse.ArgumentParser(prog="spec-sort", add_help=True) + ap.add_argument("--project-root", default=".") + ap.add_argument("--apply", action="store_true") + ap.add_argument("--allow-dirty", action="store_true") + ap.add_argument("--acknowledge-bare", action="store_true") + ap.add_argument("--confirm", action="append", metavar="REL=KEYWORD") + ap.add_argument("--reason", action="append", metavar="REL=TEXT") + ap.add_argument("--skip", action="append", metavar="REL") + ap.add_argument("--plan-file") + args = ap.parse_args() + + root = os.path.abspath(args.project_root) + confirms = parse_kv(args.confirm, "--confirm") + reasons = parse_kv(args.reason, "--reason") + skips = {os.path.normpath(s) for s in (args.skip or [])} + + candidates, anomalies, notes = classify(root) + if not candidates and not anomalies and not notes and not os.path.isdir(os.path.join(root, "docs")): + return 0 # no docs pile at all — silent no-op + + for named in list(confirms) + list(skips) + list(reasons): + if named not in candidates: + print("spec-sort: %s is not a spec candidate" % named) + return 1 + for rel, kw in confirms.items(): + if kw not in LIFECYCLE: + print("spec-sort: %r is not a lifecycle keyword (%s)" % (kw, " ".join(LIFECYCLE))) + return 1 + + # ---- Build the plan (shared by report and apply) ---- + moves = [] + for rel in candidates: + if rel in skips: + continue + if args.apply and rel not in confirms: + continue # gate failure reported below + content = read_text(os.path.join(root, rel)) + moves.append({ + "src": rel, + "dest": dest_for(rel), + "keyword": confirms.get(rel, None), + "reason": reasons.get(rel), + "title": title_for(content, rel), + "id": str(uuid.uuid4()), + }) + moved_map = {mv["src"]: mv["dest"] for mv in moves} + link_edits, ambiguous = plan_link_edits(root, moved_map) + bare = scan_bare_mentions(root, moved_map) + reports = scan_report_only(root, moved_map) + + # ---- Report ---- + for rel in candidates: + content = read_text(os.path.join(root, rel)) + ev = gather_evidence(root, rel, content) + proposed = propose_keyword(ev) + print("CANDIDATE %s -> %s" % (rel, dest_for(rel))) + suffix = " (terminal — requires --reason to apply)" if proposed in TERMINAL else "" + print(" proposed keyword: %s%s" % (proposed, suffix)) + print(" evidence:") + print(" status field: %s" % (ev["status"] or "(none)")) + print(" cookies: %s" % ("; ".join(ev["cookies"]) or "(none)")) + print(" todo.org: %s" % (ev["todo"] or "(no linking task)")) + print(" history: %s" % (ev["history"] or "(none)")) + n_exist, artifacts = ev["artifacts"] + if artifacts: + print(" artifacts: %d/%d named paths exist (%s)" % (n_exist, len(artifacts), ", ".join(artifacts))) + else: + print(" artifacts: (none named)") + for rel in anomalies: + print("ANOMALY %s: named -spec.org but lacks the spec spine (Decisions + Implementation phases); surfaced, not moved" % rel) + for rel in notes: + print("NOTE %s" % rel) + for linker, edits in sorted(link_edits.items()): + for old, new in edits: + print("RELINK %s: %s -> %s" % (linker, old, new)) + for a in ambiguous: + print("AMBIGUOUS %s" % a) + for f, ln, src in bare: + print("BARE-PATH %s:%d: %s (reported for manual handling, never rewritten)" % (f, ln, src)) + for rel, src, note in reports: + print("REPORT %s: reference to %s (%s)" % (rel, src, note)) + + if not args.apply: + if candidates or anomalies or notes: + print("DRY RUN — no changes written. Pass --apply with per-candidate --confirm/--skip to execute.") + return 0 + + # ---- Apply: preflight ---- + try: + porcelain = subprocess.run( + ["git", "status", "--porcelain"], cwd=root, + capture_output=True, text=True, check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + print("spec-sort: --apply needs a git worktree (recovery depends on git restore)") + return 2 + if porcelain.strip(): + dirty = [ln[3:] for ln in porcelain.splitlines()] + if not args.allow_dirty: + print("spec-sort: refusing --apply on a dirty worktree (%d path%s). Commit or stash first, or pass --allow-dirty." + % (len(dirty), "s" if len(dirty) != 1 else "")) + return 2 + print("WARNING --allow-dirty: recovery via git restore would also revert your pre-existing uncommitted changes:") + for p in dirty: + print(" %s" % p) + + # ---- Apply: confirm gate ---- + unaddressed = [rel for rel in candidates if rel not in confirms and rel not in skips] + if unaddressed: + print("spec-sort: unconfirmed candidate(s) — pass --confirm REL=KEYWORD or --skip REL for each:") + for rel in unaddressed: + print(" %s" % rel) + return 1 + for mv in moves: + if mv["keyword"] in TERMINAL and not mv["reason"]: + print("spec-sort: %s -> %s is a terminal state and requires an explicit --reason %s=TEXT" + % (mv["src"], mv["keyword"], mv["src"])) + return 1 + + # ---- Apply: validation ---- + problems = [] + dests = {} + for mv in moves: + if os.path.exists(os.path.join(root, mv["dest"])): + problems.append("%s: destination exists (%s)" % (mv["src"], mv["dest"])) + if mv["dest"] in dests: + problems.append("%s and %s: destination exists twice (%s)" % (mv["src"], dests[mv["dest"]], mv["dest"])) + dests[mv["dest"]] = mv["src"] + for a in ambiguous: + problems.append("ambiguous link: %s" % a) + if bare and not args.acknowledge_bare: + problems.append("bare-path mention(s) listed above need manual handling — re-run with --acknowledge-bare to proceed without rewriting them") + if problems: + print("spec-sort: validation blocked — nothing written:") + for p in problems: + print(" %s" % p) + return 1 + + # ---- Apply: record the plan, then execute from it ---- + today = datetime.now().astimezone().strftime("%Y-%m-%d") + plan = { + "root": root, "date": today, "moves": moves, + "link_edits": link_edits, "bare": bare, + "reports": [list(r) for r in reports], "created_dirs": [], + } + plan_path = args.plan_file or os.path.join( + tempfile.gettempdir(), "spec-sort-plan-%s.json" % os.path.basename(root)) + with open(plan_path, "w", encoding="utf-8") as f: + json.dump(plan, f, indent=2) + print("plan written: %s" % plan_path) + + fail_after = int(os.environ.get("SPEC_SORT_INJECT_FAIL_AFTER", "0") or 0) + try: + applied = apply_plan(root, plan, fail_after) + except ApplyFailure as exc: + print("write failed: %s" % exc.args[2]) + print_recovery(plan, exc.args[0], exc.args[1]) + return 1 + + residue = residue_check(root, plan) + if residue: + print("spec-sort: residue after apply — old paths still referenced:") + for r in residue: + print(" %s" % r) + print_recovery(plan, applied, []) + return 1 + + stamp_marker(root, today) + for a in applied: + print("applied: %s" % a) + print("spec-sort: done — %d spec(s) sorted, :LAST_SPEC_SORT: %s stamped" % (len(moves), today)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.ai/scripts/tests/spec-sort.bats b/.ai/scripts/tests/spec-sort.bats new file mode 100644 index 0000000..583e458 --- /dev/null +++ b/.ai/scripts/tests/spec-sort.bats @@ -0,0 +1,453 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile +# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside +# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an +# Implementation phases heading), show an evidence panel, and on --apply +# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the +# status heading (:ID:, dated history line), rewrite the keyword header to +# the two-sequence form, relink file: links across the rewritten roots, +# stamp :LAST_SPEC_SORT: in .ai/notes.org. +# +# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org, +# "The retrofit"): +# - dry-run report is the default; --apply writes +# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty +# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1 +# otherwise); terminal keywords need --reason REL=TEXT +# - plan validated before the first write; destination collisions block +# - bare-path mentions in rewritten roots block --apply until +# --acknowledge-bare waives them (reported, never rewritten) +# - mid-apply failure names applied/not-applied + git restore recovery +# - idempotent: a sorted project yields no candidates, no changes +# +# Strategy: each test builds a throwaway git project fixture and runs the +# real script against it. Mid-apply failure is forced via the test-only +# SPEC_SORT_INJECT_FAIL_AFTER env hook. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort" + +setup() { + TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)" + PROJ="$TEST_DIR/proj" + mkdir -p "$PROJ" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Standard fixture: one spec candidate, one note, a stray root spec with a +# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org, +# a sibling note, a session archive (report-only surface), and .ai/notes.org +# with a Workflow State section. +make_project() { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + mkdir -p docs/design .ai/sessions + + cat > docs/design/widget.org <<'EOF' +#+TITLE: Widget Feature +#+DATE: 2026-05-01 +#+TODO: DRAFT REVIEW | SHIPPED + +* Metadata +| Status | draft | +| Owner | Craig | + +* Summary +The widget feature. See [[file:scratch-note.org][the note]]. + +* Decisions [1/2] +** DONE Pick the widget shape +** TODO Pick the color + +* Implementation phases +** Phase 1 — build =src/widget.py= +EOF + + cat > docs/design/scratch-note.org <<'EOF' +#+TITLE: Scratch Note + +* Metadata +| Status | n/a | + +* Thoughts +See [[file:widget.org][the widget spec]]. +EOF + + cat > docs/rooty-spec.org <<'EOF' +#+TITLE: Rooty + +* Decisions +** DONE Only decision + +* Implementation phases +** Phase 1 — nothing +EOF + + cat > docs/lonely-spec.org <<'EOF' +#+TITLE: Lonely +Just prose, no spine. +EOF + + cat > todo.org <<'EOF' +* Open Work +** DOING [#B] Widget feature +Spec: [[file:docs/design/widget.org][widget spec]]. +Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]]. +EOF + + cat > .ai/notes.org <<'EOF' +* Active Reminders + +* Workflow State +:LAST_AUDIT: 2026-06-28 +EOF + + cat > .ai/sessions/2026-06-01-old.org <<'EOF' +Old log: [[file:../../docs/design/widget.org][widget]] +EOF + + git add -A + git commit -qm init +} + +# Confirm flags that satisfy the gate for the standard fixture's candidates. +CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT) + +# ---- Classification (dry-run) ---------------------------------------- + +@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]] +} + +@test "spec-sort: a Metadata table alone does not qualify — note stays a note" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]] + [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]] +} + +@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]] + [[ "$output" != *"rooty-spec-spec.org"* ]] +} + +@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]] + [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]] +} + +@test "spec-sort: docs/specs/ contents are excluded from classification" { + make_project + mkdir -p docs/specs + cp docs/design/widget.org docs/specs/sorted-spec.org + git add -A && git commit -qm more + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]] +} + +@test "spec-sort: no docs/ directory is a silent no-op" { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + echo x > README.md + git add -A && git commit -qm init + run "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- Evidence panel --------------------------------------------------- + +@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"status field: draft"* ]] + [[ "$output" == *"Decisions [1/2]"* ]] + [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]] +} + +@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + # status field says draft, but the linking todo.org task is DOING — the + # panel proposes the state the strongest evidence supports + [[ "$output" == *"proposed keyword: DOING"* ]] +} + +@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" { + make_project + sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org + git add -A && git commit -qm status + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]] +} + +# ---- Confirm gate ----------------------------------------------------- + +@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT + [ "$status" -eq 1 ] + [[ "$output" == *"unconfirmed"* ]] + [[ "$output" == *"docs/rooty-spec.org"* ]] + [ -f docs/design/widget.org ] # nothing moved +} + +@test "spec-sort --apply: a terminal keyword without --reason refuses" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org + [ "$status" -eq 1 ] + [[ "$output" == *"--reason"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \ + --reason "docs/design/widget.org=shipped in v2, confirmed against src" \ + --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org + grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" { + make_project + run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + [ -f docs/design/widget.org ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Preflight -------------------------------------------------------- + +@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" { + make_project + echo "drift" >> todo.org + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 2 ] + [[ "$output" == *"dirty"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" { + make_project + echo "drift" >> todo.org + git add todo.org && git commit -qm drift # keep the link intact; dirty a different file + echo "scratch" > untracked-note.txt + echo "local edit" >> .ai/notes.org + run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [[ "$output" == *"pre-existing"* ]] + [[ "$output" == *".ai/notes.org"* ]] + [ -f docs/specs/widget-spec.org ] +} + +# ---- Move + rename + rewrite ------------------------------------------ + +@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f docs/specs/widget-spec.org ] + [ ! -f docs/design/widget.org ] + grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org + grep -q ':ID:' docs/specs/widget-spec.org + grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: keyword header rewritten to the two-sequence form" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org + grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org + ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org + grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org +} + +# ---- Relink ----------------------------------------------------------- + +@test "spec-sort --apply: rewrites the todo.org link, preserving the description" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org + ! grep -q 'docs/design/widget.org' todo.org +} + +@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org +} + +@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org +} + +@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org +} + +@test "spec-sort: session archives are reported, never rewritten" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]] + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org +} + +@test "spec-sort: a synced template path report names the canonical rulesets file" { + make_project + mkdir -p .ai/workflows + echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org + git add -A && git commit -qm wf + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]] + [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]] +} + +# ---- Bare-path mentions ----------------------------------------------- + +@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" { + make_project + echo "raw mention: docs/design/widget.org needs review" >> todo.org + git add -A && git commit -qm bare + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + [ -f docs/design/widget.org ] # nothing moved + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten +} + +@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" { + make_project + echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org + git add -A && git commit -qm selfmention + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Plan validation --------------------------------------------------- + +@test "spec-sort --apply: a destination collision blocks validation, nothing moved" { + make_project + mkdir -p docs/specs + echo "occupied" > docs/specs/widget-spec.org + git add -A && git commit -qm occupy + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"destination exists"* ]] + [ -f docs/design/widget.org ] + [ "$(cat docs/specs/widget-spec.org)" = "occupied" ] +} + +@test "spec-sort --apply: writes the plan file before executing" { + make_project + run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f "$TEST_DIR/plan.json" ] + grep -q 'widget-spec.org' "$TEST_DIR/plan.json" +} + +# ---- Mid-apply failure recovery ---------------------------------------- + +@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" { + make_project + run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"RECOVERY"* ]] + [[ "$output" == *"git restore"* ]] + [[ "$output" == *"applied"* ]] + [[ "$output" == *"not applied"* ]] + ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply +} + +# ---- Idempotence + marker ---------------------------------------------- + +@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org + # lands inside the Workflow State section, alongside the existing marker + awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org +} + +@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" { + make_project + printf '* Active Reminders\n' > .ai/notes.org + git add -A && git commit -qm notes + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^\* Workflow State' .ai/notes.org + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org +} + +@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" { + make_project + rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org + git add -A && git commit -qm notes-only + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +@test "spec-sort: a second run after a successful apply finds nothing to do" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + git add -A && git commit -qm sorted + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE"* ]] + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + run git status --porcelain + # only the re-stamped marker (same date) may differ — tree stays clean + [ -z "$(git status --porcelain -- docs todo.org)" ] +} diff --git a/claude-templates/.ai/scripts/spec-sort b/claude-templates/.ai/scripts/spec-sort new file mode 100755 index 0000000..ebfef82 --- /dev/null +++ b/claude-templates/.ai/scripts/spec-sort @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""spec-sort — one-time docs-pile retrofit for the docs-lifecycle convention. + +Classifies every docs/**/*.org outside docs/specs/ by one predicate: a doc +carrying BOTH a "Decisions" heading AND an "Implementation phases" heading is +a spec candidate; everything else is a note. For each candidate it shows an +evidence panel (Status field, decision/finding cookies, the linking todo.org +task, recent dated history, cheap existence checks on phase-named artifacts) +and proposes a lifecycle keyword the evidence supports — conservative +non-terminal (DRAFT) when inconclusive. The helper proposes; a human confirms +every move. + +Dry-run report is the default. --apply executes under the fail-safe contract: + + - Clean-worktree preflight: refuses on a dirty git tree (exit 2) unless + --allow-dirty, which prints exactly what recovery loses. + - Every candidate must be addressed with --confirm REL=KEYWORD or + --skip REL; terminal keywords (IMPLEMENTED SUPERSEDED CANCELLED) also + need --reason REL=TEXT, recorded in the status-history line. + - The full move + relink plan is computed and validated first (every + destination free, every link resolvable), written to a plan file, and + only then executed from that recorded plan. + - Bare-path mentions of a moving doc inside the rewritten roots are + reported, never rewritten; they block --apply until --acknowledge-bare + explicitly waives them. + - Mid-apply failure stops the run, names what was and wasn't applied, and + prints the git-restore recovery recipe (plus deletion of newly created + destination copies, which git restore can't remove). + - After a successful apply, a residue scan across the rewritten roots must + find no link still resolving to an old path, or spec-sort exits non-zero + naming the residue. + +Per move: rename to carry the -spec.org suffix, prepend the status heading +(:ID: UUID + dated history line), rewrite the keyword header to the +two-sequence form, mirror the keyword into the Metadata Status field, and +recompute every affected file: link (inbound links to the moved doc AND the +moved doc's own outbound relative links). Rewritten roots: todo.org, +.ai/notes.org, docs/**, .ai/project-workflows/, .ai/project-scripts/. +Reported-never-rewritten: .ai/sessions/ (frozen history) and synced template +paths (.ai/workflows/, .ai/scripts/, .ai/protocols.org — the report names +the canonical claude-templates file instead). + +Finally stamps :LAST_SPEC_SORT: YYYY-MM-DD in .ai/notes.org's +* Workflow State section (created idempotently), which permanently clears +the startup nudge. A run with zero candidates still stamps. + +Exit codes: 0 done (or clean report), 1 blocked (confirm gate, validation, +bare mentions, residue, mid-apply failure), 2 usage / preflight refusal. + +Test hook: SPEC_SORT_INJECT_FAIL_AFTER=N aborts the apply after N write +operations, exercising the recovery path in the bats suite. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime + +LIFECYCLE = ("DRAFT", "READY", "DOING", "IMPLEMENTED", "SUPERSEDED", "CANCELLED") +TERMINAL = {"IMPLEMENTED", "SUPERSEDED", "CANCELLED"} +TODO_HEADER = [ + "#+TODO: TODO | DONE", + "#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED", +] + +# Project-owned surfaces whose file: links get rewritten. +REWRITE_ROOTS = ("todo.org", ".ai/notes.org", "docs", ".ai/project-workflows", ".ai/project-scripts") +# Frozen or synced surfaces: occurrences are reported, never rewritten. +REPORT_ROOTS = (".ai/sessions", ".ai/workflows", ".ai/scripts", ".ai/protocols.org") +# Synced template paths map to their canonical rulesets file for the report. +SYNCED_PREFIX = (".ai/workflows", ".ai/scripts", ".ai/protocols.org") + +LINK_RE = re.compile(r"\[\[file:([^\]\[]+)\](?:\[([^\]\[]*)\])?\]") +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +COOKIE_RE = re.compile(r"\[\d+/\d+\]") +DATED_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b") + + +def read_text(path): + try: + with open(path, encoding="utf-8") as f: + return f.read() + except (UnicodeDecodeError, OSError): + return None + + +def heading_text(line): + """Heading text with the org keyword and priority cookie stripped.""" + m = HEADING_RE.match(line) + if not m: + return None + text = re.sub(r"^[A-Z]+\s+", "", m.group(2)) + text = re.sub(r"^\[#[A-Z]\]\s+", "", text) + return text.strip() + + +def has_spine(content): + """The classification predicate: Decisions AND Implementation phases.""" + dec = imp = False + for line in content.splitlines(): + t = heading_text(line) + if t is None: + continue + tl = t.lower() + if tl.startswith("decisions"): + dec = True + elif tl.startswith("implementation phases"): + imp = True + return dec and imp + + +def walk_files(root, rel_base): + """Yield project-relative paths of files under rel_base (file or dir).""" + abs_base = os.path.join(root, rel_base) + if os.path.isfile(abs_base): + yield rel_base + return + for dirpath, dirs, files in os.walk(abs_base): + dirs.sort() + for name in sorted(files): + yield os.path.relpath(os.path.join(dirpath, name), root) + + +def classify(root): + """Split docs/**/*.org outside docs/specs/ into candidates / anomalies / notes.""" + candidates, anomalies, notes = [], [], [] + docs = os.path.join(root, "docs") + if not os.path.isdir(docs): + return candidates, anomalies, notes + for rel in walk_files(root, "docs"): + if not rel.endswith(".org"): + continue + parts = rel.split(os.sep) + if len(parts) > 1 and parts[1] == "specs": + continue + content = read_text(os.path.join(root, rel)) + if content is None: + continue + if has_spine(content): + candidates.append(rel) + elif os.path.basename(rel).endswith("-spec.org"): + anomalies.append(rel) + else: + notes.append(rel) + return candidates, anomalies, notes + + +def dest_for(rel): + base = os.path.basename(rel) + if not base.endswith("-spec.org"): + base = base[: -len(".org")] + "-spec.org" + return os.path.join("docs", "specs", base) + + +# ---- Evidence panel --------------------------------------------------- + + +def todo_task_for(root, rel): + """Heading of the first todo.org task whose subtree mentions the doc.""" + content = read_text(os.path.join(root, "todo.org")) + if content is None: + return None + lines = content.splitlines() + basename = os.path.basename(rel) + for i, line in enumerate(lines): + if basename in line or rel in line: + for j in range(i, -1, -1): + if HEADING_RE.match(lines[j]): + return lines[j].lstrip("* ").strip() + return None + return None + + +def gather_evidence(root, rel, content): + ev = {} + m = re.search(r"^\|\s*Status\s*\|\s*([^|]*)\|", content, re.MULTILINE | re.IGNORECASE) + ev["status"] = m.group(1).strip() if m else None + + cookies = [] + for line in content.splitlines(): + t = heading_text(line) + if t and COOKIE_RE.search(t) and ( + t.lower().startswith("decisions") or t.lower().startswith("review findings") + ): + cookies.append(t) + ev["cookies"] = cookies + + ev["todo"] = todo_task_for(root, rel) + kw = None + if ev["todo"]: + m = re.match(r"([A-Z]+)\s", ev["todo"]) + kw = m.group(1) if m else None + ev["todo_keyword"] = kw + + dated = [ln.strip() for ln in content.splitlines() if DATED_RE.search(ln)] + ev["history"] = dated[-1][:100] if dated else None + + # Cheap artifact check: =path= tokens inside the Implementation phases section. + artifacts, exists = [], 0 + section = re.split(r"^\*+\s+.*implementation phases.*$", content, maxsplit=1, flags=re.MULTILINE | re.IGNORECASE) + if len(section) > 1: + for tok in re.findall(r"=([^=\s]+)=", section[1]): + if "/" in tok: + artifacts.append(tok) + if os.path.exists(os.path.join(root, tok)): + exists += 1 + ev["artifacts"] = (exists, artifacts) + return ev + + +def propose_keyword(ev): + s = (ev["status"] or "").lower() + words = set(re.findall(r"[a-z]+", s)) + if words & {"implemented", "shipped", "complete", "completed", "done"}: + return "IMPLEMENTED" + if words & {"superseded"}: + return "SUPERSEDED" + if words & {"cancelled", "canceled", "dead", "abandoned"}: + return "CANCELLED" + if words & {"doing", "implementing"} or "in progress" in s or "in-progress" in s: + return "DOING" + if ev["todo_keyword"] == "DOING": + return "DOING" + if words & {"ready", "approved", "accepted"}: + return "READY" + return "DRAFT" # conservative non-terminal default + + +# ---- Link scanning ---------------------------------------------------- + + +def rewrite_files(root): + """Project-relative *.org files under the rewritten roots.""" + seen = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + if rel.endswith(".org") and rel not in seen: + seen.append(rel) + return seen + + +def resolve_target(root, linker_rel, raw_target, moved): + """Resolve a file: link target to a project-relative path (org semantics + first — relative to the linking file's directory — then project-root + anchoring as a fallback for root-anchored links).""" + if raw_target.startswith(("/", "~", "http:", "https:")): + return None + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker_rel), raw_target)) + if rel_a in moved or os.path.exists(os.path.join(root, rel_a)): + return rel_a + rel_b = os.path.normpath(raw_target) + if rel_b in moved or os.path.exists(os.path.join(root, rel_b)): + return rel_b + return rel_a + + +def plan_link_edits(root, moved): + """Compute every link rewrite: inbound links to moved docs and moved + docs' own outbound relative links. Returns ({linker_rel: [(old, new)]}, + [ambiguity descriptions]) — a link whose file-relative and root-anchored + readings are both live and disagree about a moving doc blocks validation + rather than being rewritten against a guess.""" + edits = {} + ambiguous = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + linker_post = moved.get(linker, linker) + for m in LINK_RE.finditer(content): + raw = m.group(1) + desc = m.group(2) + target_path, sep, anchor = raw.partition("::") + target = resolve_target(root, linker, target_path, moved) + if target is None: + continue + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker), target_path)) + rel_b = os.path.normpath(target_path) + if rel_a != rel_b: + live_a = rel_a in moved or os.path.exists(os.path.join(root, rel_a)) + live_b = rel_b in moved or os.path.exists(os.path.join(root, rel_b)) + if live_a and live_b and (rel_a in moved or rel_b in moved): + ambiguous.append( + "%s: [[file:%s]] reads as %s (file-relative) or %s (root-anchored) " + "and a moving doc is involved — resolve the link by hand" % (linker, raw, rel_a, rel_b)) + continue + if target not in moved and linker not in moved: + continue + if target not in moved and not os.path.exists(os.path.join(root, target)): + continue # already broken before this run; not ours to guess + target_post = moved.get(target, target) + new_path = os.path.relpath(target_post, os.path.dirname(linker_post) or ".") + new_raw = new_path + (sep + anchor if sep else "") + if new_raw == raw: + continue + new_link = "[[file:%s]%s]" % (new_raw, "[%s]" % desc if desc is not None else "") + if m.group(0) != new_link: + edits.setdefault(linker, []).append((m.group(0), new_link)) + return edits, ambiguous + + +def scan_bare_mentions(root, moved): + """Bare-path mentions of moving docs in the rewritten roots — text + occurrences outside any [[...]] link. Reported, never rewritten.""" + found = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for i, line in enumerate(content.splitlines(), 1): + stripped = re.sub(r"\[\[[^\]]*\](?:\[[^\]]*\])?\]", "", line) + for src in moved: + if src in stripped: + found.append((rel, i, src)) + return found + + +def scan_report_only(root, moved): + """Occurrences of moving docs in frozen/synced surfaces.""" + reports = [] + for base in REPORT_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for src in moved: + if src in content: + if rel.startswith(SYNCED_PREFIX): + note = ("synced template, not rewritten — a local edit is reverted by the " + "next sync; edit the canonical claude-templates/%s instead" % rel) + else: + note = "frozen history; not rewritten" + reports.append((rel, src, note)) + return reports + + +# ---- Content transforms ----------------------------------------------- + + +def transform_spec(content, keyword, reason, title, doc_id, link_edits): + """Apply the retrofit rewrite to a moving spec's content: two-sequence + keyword header, prepended status heading, Status-field mirror, and the + doc's own link edits.""" + for old, new in link_edits: + content = content.replace(old, new) + lines = content.splitlines() + + todo_idx = None + kept = [] + for line in lines: + if line.startswith("#+TODO:"): + if todo_idx is None: + todo_idx = len(kept) + continue + kept.append(line) + lines = kept + if todo_idx is None: + todo_idx = 0 + while todo_idx < len(lines) and lines[todo_idx].startswith("#+"): + todo_idx += 1 + lines[todo_idx:todo_idx] = TODO_HEADER + + head_end = 0 + while head_end < len(lines) and (lines[head_end].startswith("#+") or not lines[head_end].strip()): + head_end += 1 + ts = datetime.now().astimezone().strftime("%Y-%m-%d %a @ %H:%M:%S %z") + provenance = "reason: %s" % reason if reason else "evidence-based, human-confirmed" + block = [ + "* %s %s" % (keyword, title), + ":PROPERTIES:", + ":ID: %s" % doc_id, + ":END:", + "- %s — retrofitted by spec-sort; status set to %s (%s)" % (ts, keyword, provenance), + "", + ] + lines[head_end:head_end] = block + + out = [] + mirrored = False + for line in lines: + m = re.match(r"^(\|\s*Status\s*\|)([^|]*)(\|.*)$", line, re.IGNORECASE) + if m and not mirrored: + value = " %s" % keyword.lower() + width = len(m.group(2)) + line = m.group(1) + (value.ljust(width) if len(value) <= width else value + " ") + m.group(3) + mirrored = True + out.append(line) + return "\n".join(out) + "\n" + + +def title_for(content, rel): + m = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE) + if m: + return m.group(1).strip() + base = os.path.basename(rel)[: -len(".org")] + return base[: -len("-spec")] if base.endswith("-spec") else base + + +# ---- Marker ------------------------------------------------------------ + + +def stamp_marker(root, date): + path = os.path.join(root, ".ai", "notes.org") + os.makedirs(os.path.dirname(path), exist_ok=True) + content = read_text(path) or "" + line = ":LAST_SPEC_SORT: %s" % date + if ":LAST_SPEC_SORT:" in content: + content = re.sub(r":LAST_SPEC_SORT:.*", line, content, count=1) + elif re.search(r"^\* Workflow State\s*$", content, re.MULTILINE): + content = re.sub(r"(^\* Workflow State\s*$)", r"\1\n" + line, content, count=1, flags=re.MULTILINE) + else: + if content and not content.endswith("\n"): + content += "\n" + content += "\n* Workflow State\n\n%s\n" % line + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +# ---- Apply ------------------------------------------------------------- + + +class ApplyFailure(Exception): + """Mid-apply failure: args are (applied_labels, remaining_ops, cause).""" + + +def apply_plan(root, plan, fail_after): + """Execute the recorded plan. Returns the applied-op labels; raises + ApplyFailure mid-way on a write error or when the test hook fires.""" + ops = [] + for mv in plan["moves"]: + ops.append(("move", mv)) + for linker, edits in plan["link_edits"].items(): + if linker in {mv["src"] for mv in plan["moves"]}: + continue # a moving doc's own edits ride along in its transform + ops.append(("relink", (linker, edits))) + + applied = [] + specs_dir = os.path.join(root, "docs", "specs") + if plan["moves"] and not os.path.isdir(specs_dir): + os.makedirs(specs_dir) + plan["created_dirs"].append(os.path.join("docs", "specs")) + + for n, (kind, payload) in enumerate(ops, 1): + if fail_after and n > fail_after: + raise ApplyFailure(applied, ops[n - 1:], "injected test failure") + try: + if kind == "move": + mv = payload + content = read_text(os.path.join(root, mv["src"])) + new = transform_spec(content, mv["keyword"], mv["reason"], mv["title"], mv["id"], + plan["link_edits"].get(mv["src"], [])) + with open(os.path.join(root, mv["dest"]), "w", encoding="utf-8") as f: + f.write(new) + os.remove(os.path.join(root, mv["src"])) + applied.append("move %s -> %s" % (mv["src"], mv["dest"])) + else: + linker, edits = payload + path = os.path.join(root, linker) + content = read_text(path) + for old, new in edits: + content = content.replace(old, new) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + applied.append("relink %s (%d link%s)" % (linker, len(edits), "s" if len(edits) != 1 else "")) + except OSError as exc: + raise ApplyFailure(applied, ops[n - 1:], str(exc)) + return applied + + +def residue_check(root, plan): + """Post-apply: no link in the rewritten roots may still resolve to an + old path; bare mentions beyond the acknowledged set fail too.""" + moved = {mv["src"]: mv["dest"] for mv in plan["moves"]} + residue = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + for m in LINK_RE.finditer(content): + target_path = m.group(1).partition("::")[0] + target = resolve_target(root, linker, target_path, {}) + if target in moved: + residue.append("%s: link still resolves to %s" % (linker, target)) + # Acknowledged mentions were recorded pre-apply; a mention inside a moved + # doc now lives at the doc's destination, so map the file side through the + # moves before comparing. + acknowledged = {(moved.get(f, f), src) for f, _ln, src in plan["bare"]} + for f, ln, src in scan_bare_mentions(root, moved): + if (f, src) not in acknowledged: + residue.append("%s:%d: bare mention of %s" % (f, ln, src)) + return residue + + +def print_recovery(plan, applied, not_applied): + print("FAILURE — the apply did not complete.") + print(" applied:") + for a in applied or ["(nothing)"]: + print(" %s" % a) + print(" not applied:") + for kind, payload in not_applied: + if kind == "move": + print(" move %s -> %s" % (payload["src"], payload["dest"])) + else: + print(" relink %s" % payload[0]) + print("RECOVERY — restore the pre-run state (safe: preflight required a clean tree):") + touched = [mv["src"] for mv in plan["moves"]] + [l for l in plan["link_edits"] if l not in {mv["src"] for mv in plan["moves"]}] + print(" git restore -- %s" % " ".join(touched)) + created = [mv["dest"] for mv in plan["moves"]] + print(" rm -f -- %s # git restore can't remove the created copies" % " ".join(created)) + for d in plan.get("created_dirs", []): + print(" rmdir --ignore-fail-on-non-empty -- %s" % d) + + +# ---- Main --------------------------------------------------------------- + + +def parse_kv(pairs, label): + out = {} + for item in pairs or []: + if "=" not in item: + sys.exit("spec-sort: %s expects REL=VALUE, got %r" % (label, item)) + k, v = item.split("=", 1) + out[os.path.normpath(k)] = v + return out + + +def main(): + ap = argparse.ArgumentParser(prog="spec-sort", add_help=True) + ap.add_argument("--project-root", default=".") + ap.add_argument("--apply", action="store_true") + ap.add_argument("--allow-dirty", action="store_true") + ap.add_argument("--acknowledge-bare", action="store_true") + ap.add_argument("--confirm", action="append", metavar="REL=KEYWORD") + ap.add_argument("--reason", action="append", metavar="REL=TEXT") + ap.add_argument("--skip", action="append", metavar="REL") + ap.add_argument("--plan-file") + args = ap.parse_args() + + root = os.path.abspath(args.project_root) + confirms = parse_kv(args.confirm, "--confirm") + reasons = parse_kv(args.reason, "--reason") + skips = {os.path.normpath(s) for s in (args.skip or [])} + + candidates, anomalies, notes = classify(root) + if not candidates and not anomalies and not notes and not os.path.isdir(os.path.join(root, "docs")): + return 0 # no docs pile at all — silent no-op + + for named in list(confirms) + list(skips) + list(reasons): + if named not in candidates: + print("spec-sort: %s is not a spec candidate" % named) + return 1 + for rel, kw in confirms.items(): + if kw not in LIFECYCLE: + print("spec-sort: %r is not a lifecycle keyword (%s)" % (kw, " ".join(LIFECYCLE))) + return 1 + + # ---- Build the plan (shared by report and apply) ---- + moves = [] + for rel in candidates: + if rel in skips: + continue + if args.apply and rel not in confirms: + continue # gate failure reported below + content = read_text(os.path.join(root, rel)) + moves.append({ + "src": rel, + "dest": dest_for(rel), + "keyword": confirms.get(rel, None), + "reason": reasons.get(rel), + "title": title_for(content, rel), + "id": str(uuid.uuid4()), + }) + moved_map = {mv["src"]: mv["dest"] for mv in moves} + link_edits, ambiguous = plan_link_edits(root, moved_map) + bare = scan_bare_mentions(root, moved_map) + reports = scan_report_only(root, moved_map) + + # ---- Report ---- + for rel in candidates: + content = read_text(os.path.join(root, rel)) + ev = gather_evidence(root, rel, content) + proposed = propose_keyword(ev) + print("CANDIDATE %s -> %s" % (rel, dest_for(rel))) + suffix = " (terminal — requires --reason to apply)" if proposed in TERMINAL else "" + print(" proposed keyword: %s%s" % (proposed, suffix)) + print(" evidence:") + print(" status field: %s" % (ev["status"] or "(none)")) + print(" cookies: %s" % ("; ".join(ev["cookies"]) or "(none)")) + print(" todo.org: %s" % (ev["todo"] or "(no linking task)")) + print(" history: %s" % (ev["history"] or "(none)")) + n_exist, artifacts = ev["artifacts"] + if artifacts: + print(" artifacts: %d/%d named paths exist (%s)" % (n_exist, len(artifacts), ", ".join(artifacts))) + else: + print(" artifacts: (none named)") + for rel in anomalies: + print("ANOMALY %s: named -spec.org but lacks the spec spine (Decisions + Implementation phases); surfaced, not moved" % rel) + for rel in notes: + print("NOTE %s" % rel) + for linker, edits in sorted(link_edits.items()): + for old, new in edits: + print("RELINK %s: %s -> %s" % (linker, old, new)) + for a in ambiguous: + print("AMBIGUOUS %s" % a) + for f, ln, src in bare: + print("BARE-PATH %s:%d: %s (reported for manual handling, never rewritten)" % (f, ln, src)) + for rel, src, note in reports: + print("REPORT %s: reference to %s (%s)" % (rel, src, note)) + + if not args.apply: + if candidates or anomalies or notes: + print("DRY RUN — no changes written. Pass --apply with per-candidate --confirm/--skip to execute.") + return 0 + + # ---- Apply: preflight ---- + try: + porcelain = subprocess.run( + ["git", "status", "--porcelain"], cwd=root, + capture_output=True, text=True, check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + print("spec-sort: --apply needs a git worktree (recovery depends on git restore)") + return 2 + if porcelain.strip(): + dirty = [ln[3:] for ln in porcelain.splitlines()] + if not args.allow_dirty: + print("spec-sort: refusing --apply on a dirty worktree (%d path%s). Commit or stash first, or pass --allow-dirty." + % (len(dirty), "s" if len(dirty) != 1 else "")) + return 2 + print("WARNING --allow-dirty: recovery via git restore would also revert your pre-existing uncommitted changes:") + for p in dirty: + print(" %s" % p) + + # ---- Apply: confirm gate ---- + unaddressed = [rel for rel in candidates if rel not in confirms and rel not in skips] + if unaddressed: + print("spec-sort: unconfirmed candidate(s) — pass --confirm REL=KEYWORD or --skip REL for each:") + for rel in unaddressed: + print(" %s" % rel) + return 1 + for mv in moves: + if mv["keyword"] in TERMINAL and not mv["reason"]: + print("spec-sort: %s -> %s is a terminal state and requires an explicit --reason %s=TEXT" + % (mv["src"], mv["keyword"], mv["src"])) + return 1 + + # ---- Apply: validation ---- + problems = [] + dests = {} + for mv in moves: + if os.path.exists(os.path.join(root, mv["dest"])): + problems.append("%s: destination exists (%s)" % (mv["src"], mv["dest"])) + if mv["dest"] in dests: + problems.append("%s and %s: destination exists twice (%s)" % (mv["src"], dests[mv["dest"]], mv["dest"])) + dests[mv["dest"]] = mv["src"] + for a in ambiguous: + problems.append("ambiguous link: %s" % a) + if bare and not args.acknowledge_bare: + problems.append("bare-path mention(s) listed above need manual handling — re-run with --acknowledge-bare to proceed without rewriting them") + if problems: + print("spec-sort: validation blocked — nothing written:") + for p in problems: + print(" %s" % p) + return 1 + + # ---- Apply: record the plan, then execute from it ---- + today = datetime.now().astimezone().strftime("%Y-%m-%d") + plan = { + "root": root, "date": today, "moves": moves, + "link_edits": link_edits, "bare": bare, + "reports": [list(r) for r in reports], "created_dirs": [], + } + plan_path = args.plan_file or os.path.join( + tempfile.gettempdir(), "spec-sort-plan-%s.json" % os.path.basename(root)) + with open(plan_path, "w", encoding="utf-8") as f: + json.dump(plan, f, indent=2) + print("plan written: %s" % plan_path) + + fail_after = int(os.environ.get("SPEC_SORT_INJECT_FAIL_AFTER", "0") or 0) + try: + applied = apply_plan(root, plan, fail_after) + except ApplyFailure as exc: + print("write failed: %s" % exc.args[2]) + print_recovery(plan, exc.args[0], exc.args[1]) + return 1 + + residue = residue_check(root, plan) + if residue: + print("spec-sort: residue after apply — old paths still referenced:") + for r in residue: + print(" %s" % r) + print_recovery(plan, applied, []) + return 1 + + stamp_marker(root, today) + for a in applied: + print("applied: %s" % a) + print("spec-sort: done — %d spec(s) sorted, :LAST_SPEC_SORT: %s stamped" % (len(moves), today)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/tests/spec-sort.bats b/claude-templates/.ai/scripts/tests/spec-sort.bats new file mode 100644 index 0000000..583e458 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/spec-sort.bats @@ -0,0 +1,453 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile +# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside +# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an +# Implementation phases heading), show an evidence panel, and on --apply +# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the +# status heading (:ID:, dated history line), rewrite the keyword header to +# the two-sequence form, relink file: links across the rewritten roots, +# stamp :LAST_SPEC_SORT: in .ai/notes.org. +# +# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org, +# "The retrofit"): +# - dry-run report is the default; --apply writes +# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty +# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1 +# otherwise); terminal keywords need --reason REL=TEXT +# - plan validated before the first write; destination collisions block +# - bare-path mentions in rewritten roots block --apply until +# --acknowledge-bare waives them (reported, never rewritten) +# - mid-apply failure names applied/not-applied + git restore recovery +# - idempotent: a sorted project yields no candidates, no changes +# +# Strategy: each test builds a throwaway git project fixture and runs the +# real script against it. Mid-apply failure is forced via the test-only +# SPEC_SORT_INJECT_FAIL_AFTER env hook. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort" + +setup() { + TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)" + PROJ="$TEST_DIR/proj" + mkdir -p "$PROJ" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Standard fixture: one spec candidate, one note, a stray root spec with a +# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org, +# a sibling note, a session archive (report-only surface), and .ai/notes.org +# with a Workflow State section. +make_project() { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + mkdir -p docs/design .ai/sessions + + cat > docs/design/widget.org <<'EOF' +#+TITLE: Widget Feature +#+DATE: 2026-05-01 +#+TODO: DRAFT REVIEW | SHIPPED + +* Metadata +| Status | draft | +| Owner | Craig | + +* Summary +The widget feature. See [[file:scratch-note.org][the note]]. + +* Decisions [1/2] +** DONE Pick the widget shape +** TODO Pick the color + +* Implementation phases +** Phase 1 — build =src/widget.py= +EOF + + cat > docs/design/scratch-note.org <<'EOF' +#+TITLE: Scratch Note + +* Metadata +| Status | n/a | + +* Thoughts +See [[file:widget.org][the widget spec]]. +EOF + + cat > docs/rooty-spec.org <<'EOF' +#+TITLE: Rooty + +* Decisions +** DONE Only decision + +* Implementation phases +** Phase 1 — nothing +EOF + + cat > docs/lonely-spec.org <<'EOF' +#+TITLE: Lonely +Just prose, no spine. +EOF + + cat > todo.org <<'EOF' +* Open Work +** DOING [#B] Widget feature +Spec: [[file:docs/design/widget.org][widget spec]]. +Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]]. +EOF + + cat > .ai/notes.org <<'EOF' +* Active Reminders + +* Workflow State +:LAST_AUDIT: 2026-06-28 +EOF + + cat > .ai/sessions/2026-06-01-old.org <<'EOF' +Old log: [[file:../../docs/design/widget.org][widget]] +EOF + + git add -A + git commit -qm init +} + +# Confirm flags that satisfy the gate for the standard fixture's candidates. +CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT) + +# ---- Classification (dry-run) ---------------------------------------- + +@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]] +} + +@test "spec-sort: a Metadata table alone does not qualify — note stays a note" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]] + [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]] +} + +@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]] + [[ "$output" != *"rooty-spec-spec.org"* ]] +} + +@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]] + [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]] +} + +@test "spec-sort: docs/specs/ contents are excluded from classification" { + make_project + mkdir -p docs/specs + cp docs/design/widget.org docs/specs/sorted-spec.org + git add -A && git commit -qm more + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]] +} + +@test "spec-sort: no docs/ directory is a silent no-op" { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + echo x > README.md + git add -A && git commit -qm init + run "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- Evidence panel --------------------------------------------------- + +@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"status field: draft"* ]] + [[ "$output" == *"Decisions [1/2]"* ]] + [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]] +} + +@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + # status field says draft, but the linking todo.org task is DOING — the + # panel proposes the state the strongest evidence supports + [[ "$output" == *"proposed keyword: DOING"* ]] +} + +@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" { + make_project + sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org + git add -A && git commit -qm status + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]] +} + +# ---- Confirm gate ----------------------------------------------------- + +@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT + [ "$status" -eq 1 ] + [[ "$output" == *"unconfirmed"* ]] + [[ "$output" == *"docs/rooty-spec.org"* ]] + [ -f docs/design/widget.org ] # nothing moved +} + +@test "spec-sort --apply: a terminal keyword without --reason refuses" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org + [ "$status" -eq 1 ] + [[ "$output" == *"--reason"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \ + --reason "docs/design/widget.org=shipped in v2, confirmed against src" \ + --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org + grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" { + make_project + run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + [ -f docs/design/widget.org ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Preflight -------------------------------------------------------- + +@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" { + make_project + echo "drift" >> todo.org + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 2 ] + [[ "$output" == *"dirty"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" { + make_project + echo "drift" >> todo.org + git add todo.org && git commit -qm drift # keep the link intact; dirty a different file + echo "scratch" > untracked-note.txt + echo "local edit" >> .ai/notes.org + run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [[ "$output" == *"pre-existing"* ]] + [[ "$output" == *".ai/notes.org"* ]] + [ -f docs/specs/widget-spec.org ] +} + +# ---- Move + rename + rewrite ------------------------------------------ + +@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f docs/specs/widget-spec.org ] + [ ! -f docs/design/widget.org ] + grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org + grep -q ':ID:' docs/specs/widget-spec.org + grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: keyword header rewritten to the two-sequence form" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org + grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org + ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org + grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org +} + +# ---- Relink ----------------------------------------------------------- + +@test "spec-sort --apply: rewrites the todo.org link, preserving the description" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org + ! grep -q 'docs/design/widget.org' todo.org +} + +@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org +} + +@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org +} + +@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org +} + +@test "spec-sort: session archives are reported, never rewritten" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]] + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org +} + +@test "spec-sort: a synced template path report names the canonical rulesets file" { + make_project + mkdir -p .ai/workflows + echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org + git add -A && git commit -qm wf + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]] + [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]] +} + +# ---- Bare-path mentions ----------------------------------------------- + +@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" { + make_project + echo "raw mention: docs/design/widget.org needs review" >> todo.org + git add -A && git commit -qm bare + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + [ -f docs/design/widget.org ] # nothing moved + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten +} + +@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" { + make_project + echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org + git add -A && git commit -qm selfmention + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Plan validation --------------------------------------------------- + +@test "spec-sort --apply: a destination collision blocks validation, nothing moved" { + make_project + mkdir -p docs/specs + echo "occupied" > docs/specs/widget-spec.org + git add -A && git commit -qm occupy + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"destination exists"* ]] + [ -f docs/design/widget.org ] + [ "$(cat docs/specs/widget-spec.org)" = "occupied" ] +} + +@test "spec-sort --apply: writes the plan file before executing" { + make_project + run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f "$TEST_DIR/plan.json" ] + grep -q 'widget-spec.org' "$TEST_DIR/plan.json" +} + +# ---- Mid-apply failure recovery ---------------------------------------- + +@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" { + make_project + run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"RECOVERY"* ]] + [[ "$output" == *"git restore"* ]] + [[ "$output" == *"applied"* ]] + [[ "$output" == *"not applied"* ]] + ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply +} + +# ---- Idempotence + marker ---------------------------------------------- + +@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org + # lands inside the Workflow State section, alongside the existing marker + awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org +} + +@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" { + make_project + printf '* Active Reminders\n' > .ai/notes.org + git add -A && git commit -qm notes + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^\* Workflow State' .ai/notes.org + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org +} + +@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" { + make_project + rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org + git add -A && git commit -qm notes-only + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +@test "spec-sort: a second run after a successful apply finds nothing to do" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + git add -A && git commit -qm sorted + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE"* ]] + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + run git status --porcelain + # only the re-stamped marker (same date) may differ — tree stays clean + [ -z "$(git status --porcelain -- docs todo.org)" ] +} @@ -405,8 +405,8 @@ spec-response Phase 6 run: this parent now carries the =:SPEC_ID:= binding (the *** 2026-07-01 Wed @ 23:39:10 -0400 Phase 1 landed — docs-lifecycle rule + four spec-workflow updates claude-rules/docs-lifecycle.md written and linked machine-wide (make install). Canonical-side updates: spec-create Phase 5 + template (docs/specs/ location, two-sequence keyword header, DRAFT status heading with :ID:, transition mechanics), spec-review (location expectation with the legacy compatibility rule keyed on :LAST_SPEC_SORT:, plus the DRAFT→READY flip — and the demote-back-to-DRAFT path a failed re-review takes), spec-response Phase 6 (owns READY→DOING, stamps :SPEC_ID: on the build parent, always emits the flip-to-IMPLEMENTED task), task-audit Phase B (the :SPEC_ID: reconcile query, checking the parent's keyword rather than counting children). Mirror synced; make test green end to end. -*** TODO Phase 2 — spec-sort helper + bats suite :solo: -Build claude-templates/.ai/scripts/spec-sort per the spec's retrofit contract: classify (Decisions AND Implementation phases), evidence-based confirm gate, plan + validate before first write, move + rename to -spec.org + status heading + :ID:, relink file: links per the rewritten-roots/report-only contract, clean-worktree preflight (--allow-dirty override), named recovery on mid-apply failure (incl. deleting created untracked copies), residue-grep exit gate, :LAST_SPEC_SORT: stamp. Bats in claude-templates/.ai/scripts/tests/ covering classification, confirm gate, plan validation, move+rename, relink, preflight, forced-failure recovery output, idempotence, marker stamp. Verify: make test green, sync-check clean. +*** 2026-07-01 Wed @ 23:57:44 -0400 Phase 2 landed — spec-sort helper + 30-test bats suite +Built claude-templates/.ai/scripts/spec-sort (Python, TDD — the 30-test bats suite written red-first in claude-templates/.ai/scripts/tests/spec-sort.bats) covering the full retrofit contract: spine classification with the -spec.org-name-without-spine anomaly case, evidence panel (Status field, cookies, linking todo.org task, dated history, artifact existence) with conservative non-terminal proposals, per-candidate --confirm/--skip gate with --reason required on terminal keywords, clean-worktree preflight (--allow-dirty prints what recovery loses), validate-then-write from a recorded plan file, relink across the rewritten roots (inbound AND the moved doc's own outbound relative links) with report-only for sessions + synced templates (naming the canonical claude-templates file), bare-path mentions blocking until --acknowledge-bare, named recovery on injected mid-apply failure, post-apply residue gate, idempotent :LAST_SPEC_SORT: stamp. Real-data dry run against rulesets' pile matched predictions: 5 candidates, 4 anomalies, 30 notes, 0 bare, 10 report-only (incl. the startup.org synced-template case Codex flagged). make test green; sync-check clean. *** TODO Phase 3 — rulesets pilot (interactive) Run spec-sort against rulesets' own docs/ with Craig confirming each candidate (~5 spec candidates among 41 design files + 2 stray root specs). Fix what the pilot surfaces. Verify: acceptance criteria 1-6 and 10 — status board matches reality, every remaining docs/design file is a note, re-homed specs carry -spec.org, links resolve, residue grep zero, no id: links in rewritten output. |
