@sabaiway/agent-workflow-kit 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/SKILL.md +13 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +19 -13
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
  10. package/bridges/codex-cli-bridge/SKILL.md +17 -7
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
  12. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
  15. package/bridges/codex-cli-bridge/capability.json +10 -7
  16. package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
  17. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
  18. package/capability.json +1 -1
  19. package/package.json +1 -1
  20. package/references/modes/flow-writer.md +37 -0
  21. package/references/modes/gates.md +4 -4
  22. package/references/modes/procedures.md +4 -2
  23. package/references/modes/receipt-deadline.md +16 -0
  24. package/references/modes/review-state.md +1 -1
  25. package/references/modes/set-flow.md +22 -0
  26. package/references/scripts/archive-decisions.mjs +340 -15
  27. package/references/scripts/archive-decisions.test.mjs +522 -2
  28. package/tools/cheap-agents.mjs +8 -2
  29. package/tools/commands.mjs +24 -2
  30. package/tools/commit-guard.mjs +44 -9
  31. package/tools/core-evidence.mjs +25 -22
  32. package/tools/detect-backends.mjs +33 -11
  33. package/tools/dispatch-record.mjs +926 -0
  34. package/tools/doc-parity.mjs +21 -6
  35. package/tools/flow-check.mjs +842 -0
  36. package/tools/flow-record.mjs +795 -0
  37. package/tools/flow-store-read.mjs +114 -0
  38. package/tools/flow-store.mjs +1178 -0
  39. package/tools/flow-writer.mjs +1265 -0
  40. package/tools/fs-read-nofollow.mjs +128 -0
  41. package/tools/gates-declaration.mjs +184 -0
  42. package/tools/gates-init.mjs +59 -17
  43. package/tools/orchestration-config.mjs +87 -10
  44. package/tools/orchestration-write.mjs +3 -3
  45. package/tools/plan-files.mjs +35 -0
  46. package/tools/procedures.mjs +75 -11
  47. package/tools/receipt-deadline.mjs +242 -0
  48. package/tools/recipes.mjs +21 -0
  49. package/tools/repo-lex.mjs +22 -0
  50. package/tools/review-state.mjs +240 -80
  51. package/tools/run-gates.mjs +361 -139
  52. package/tools/set-flow.mjs +465 -0
  53. package/tools/velocity-profile.mjs +8 -2
@@ -0,0 +1,16 @@
1
+ ### Mode: receipt-deadline
2
+
3
+ <!-- opt-in-capability: none — a read-only per-dispatch waiter; it guards no repeatable surface (the review obligations gate is review-state, already covered) -->
4
+
5
+ The **per-dispatch receipt-ARRIVAL deadline runner** (flow-orchestration #41/#50): it waits for ONE dispatched review to **answer**, never for the review obligations to be satisfied — satisfaction is receipt ARRIVAL past the watermark — a strictly-newer parseable receipt line from the dispatched backend (or its nonce-matched finding manifest, preferred when present) — never obligation satisfaction. For "block until `--check` would PASS" use `review-state --await` instead; this runner answers the narrower per-dispatch question "did THIS dispatch come back at all?", which is what the round dispatch ledger's deadline discipline needs.
6
+
7
+ Run **`node ${CLAUDE_SKILL_DIR}/tools/receipt-deadline.mjs --backend <name> --watermark <bytes> [--nonce <nonce>] [--timeout <s>]`**:
8
+
9
+ 1. **`--watermark`** is the receipts-file **byte length minted BEFORE the dispatch** (the round dispatch-ledger `receiptWatermark`). The runner additionally binds the file **prefix below that offset IN-PROCESS at start**: a shrunken file or a rewritten prefix refuses **loudly for the lifetime of the run** — a truncate-and-rewrite can never masquerade as arrival — and the watermark must sit **on a line boundary** (a positive offset whose preceding byte is not a newline refuses loudly at start: the pre-dispatch tail was unterminated, so an appended receipt would physically continue that malformed line). Honest limit: the prefix binding is a **runtime guard, never a persisted proof** (the persisted ledger watermark stays the plain integer).
10
+ 2. **Arrival** = a newline-terminated, parseable receipt line **from that backend** starting at/after the watermark offset. A malformed line never satisfies (and never masks a later valid one); a foreign backend's line never satisfies; a partial (unterminated) append is not a receipt yet.
11
+ 3. **`--nonce`** (the dispatch nonce under the safe grammar `[A-Za-z0-9._-]{1,64}`): when the `{backend, nonce}`-named finding manifest exists beside the receipts file, the runner **prefers that correlation** — the manifest is minted atomically BEFORE the receipt append, and it carries the dispatch identity, so it can never be another dispatch's receipt. A malformed or foreign-identity manifest refuses loudly.
12
+ 4. **Timeout** (default 900s) fires ONLY when no receipt landed, and its wording **names the watermark**. An authoritative NEGATIVE verdict is not this tool's business — arrival is arrival, whatever the verdict says; the obligations verdict lives in `review-state`.
13
+
14
+ **Exit codes:** `0` arrived; `1` timeout or a loud refusal (shrunken/rewritten store, malformed manifest, no git tree); `2` usage (including an unsafe nonce).
15
+
16
+ **Invariants:** read-only · never writes, never commits, never runs a subscription CLI · the clock is injectable for tests · the receipts path is `<git dir>/agent-workflow-review-receipts.jsonl` (`AW_REVIEW_RECEIPTS` overrides).
@@ -9,7 +9,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
9
9
  1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
10
10
  2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a CONFIGURED solo recipe (a computed readiness-degrade NEVER silently becomes solo — it needs the explicit degrade record below), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend SATISFIED for the current tree. **A clean-tree PASS under a NON-SOLO review obligation is never silent about a latent arm:** when a `reviewed` or `council` recipe is configured, it NAMES every plan in flight and states that this gate arms as soon as the tree is dirty — the condition is discoverable BEFORE it blocks, instead of surfacing at the worst moment (a pending commit, or the landing of a feature worktree when main first turns dirty). Nothing is announced under a configured `solo` recipe or a non-git cwd, where the gate can never arm. **Satisfaction is ship-class-only on the LATEST NORMAL receipt (D3(b)):** per backend, the latest probe-free current-fingerprint receipt is selected FIRST and THEN verdict-checked — only the recognized ship-class vocabulary (`ship` / `ship with nits`) satisfies; a recognized NEGATIVE (`revise` / `rethink` / `rework`) is an authoritative VETO (an earlier ship never survives a later revise); an UNRECOGNIZED verdict (e.g. `unknown` from a dead run) fails CLOSED unconditionally — a later `unknown` never lets an earlier SHIP stand, and a fresh normal re-run supersedes it. **The ONLY escape is an explicit degrade RECORD** (`core-evidence.mjs degrade --backend <name> --reason "…"`, fingerprint-bound to the current tree) — and never all backends: ≥1 non-degraded ship-class receipt is required whenever ≥1 backend is configured; a malformed evidence store denies the escape fail-closed but never fails an independently-satisfied tree. Exit 1 otherwise — missing, **stale** (ANY edit after a review moves the fingerprint), ungrounded, vetoed, or unrecognized. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green. **Probe receipts never attest either:** a `CODEX_PROBE=1` / `AGY_PROBE=1` review runs with the frontier-model/max-effort guard OFF, so the wrapper stamps `probe:true` and this checker excludes it — per receipt, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a malformed *or* absent probe marker is rejected fail-closed and stated in the check line. Honest bound: receipts are **not authenticated** (a forger could write `probe:false` as easily as any other field) — like the rest of the receipt this is a self-discipline mechanism, not a security boundary.
11
11
  3. **Wire it as a gate by hand OR via the explicit-consent init preview — never without consent (AD-021/D9).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated gates-init preview (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`, consent-fill section) offers exactly this entry — path resolved and quoted — ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces); it writes nothing without your explicit yes. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate — and `run-gates --final` requires this check among its canonical core gates.
12
- 4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It inherits the `--check` decision whole — a backend with a current-tree degrade RECORD stops being waited on (the shared decideCheck), so you never hand-`--await` around a known degrade. Still read-only (it re-reads the receipts + the evidence store); solo / no-plan / clean-tree resolve instantly.
12
+ 4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It inherits the `--check` decision whole — a backend with a current-tree degrade RECORD stops being waited on (the shared decideCheck), so you never hand-`--await` around a known degrade. **An AUTHORITATIVE veto terminates the wait loudly BEFORE the deadline** (flow-orchestration Decision 4/#50): a landed recognized-negative verdict for the current tree is the dispatched review's *answer*, so `--await` exits 1 with `VETO — …` immediately instead of misclassifying it as a timeout — only a fresh review can move a landed negative, never waiting. Still read-only (it re-reads the receipts + the evidence store); solo / no-plan / clean-tree resolve instantly. For waiting on **one dispatch's arrival** (never obligation satisfaction) use the receipt-arrival deadline runner, `${CLAUDE_SKILL_DIR}/references/modes/receipt-deadline.md`.
13
13
 
14
14
  **Human residual (stated, accepted):** `git commit --no-verify` and receipt-file deletion/forgery remain possible — this is a self-discipline mechanism against silent process drift, not a security boundary.
15
15
 
@@ -0,0 +1,22 @@
1
+ ### Mode: set-flow
2
+
3
+ <!-- opt-in-capability: none — the flow arms only by explicit maintainer action at a plan boundary (#52); the advisor-offer decision rides the Plan-4 dogfood/release wave -->
4
+
5
+ The **arming writer** for the `flow` block of `docs/ai/orchestration.json` — the answer to *"turn the review-flow machinery on for this project."* **Division of labor (AD-025 discipline):** YOU turn the user's plain language into explicit `--preset` / `--set <key>=<value>` / `--unset <key>` ops; the KIT does the deterministic parse → merge → floor-check → preview → write. It **previews by default** (writes nothing); `--write` applies. It **never runs a backend and never commits**. Hand-editing the config stays fully supported.
6
+
7
+ Run **`node ${CLAUDE_SKILL_DIR}/tools/set-flow.mjs [--preset <council|reviewed|internal-only>] [--set <key>=<value>]… [--unset <key>]… [--write] [--json]`**:
8
+
9
+ 1. **Merge (#30):** the preset is a **seed** — its values come verbatim from the kit's schema-1 canon (the same literal fixture the config validator pins); explicit `--set` keys win over the seed, the seed wins over the existing block, and `schema` is pinned by the kit (never an op). `candidates` are never seeded — they name the project's REAL backends (`--set candidates=codex:review,agy:review`). The **merged flow block previews** before any write.
10
+ 2. **Arming floors (#31 — the config validator stays shape-only; every DEEP floor lives on this path):**
11
+ - **`kitMinVersion` (Decision 6, #54):** the null-guarded semver comparison — an unparseable version on either side **never** passes (the bare `>= 0` shape fails open on null and is banned).
12
+ - **`debtQueue` / `convergenceSummary` (#37/#69):** each declared path must be a **single regular TRACKED file** or carry its explicit `…Excluded: true` declaration (**loud**); never a symlink or directory; never under `docs/ai/`; never a literal substring of any declared gate `cmd` (`docs/ai/gates.json`).
13
+ - Floors hold on the preview **and** the write lane — exit `1`, nothing written, until every floor passes.
14
+ 3. **The disclosed residual (printed on every floor evaluation):** the bookkeeping floors decide only what is decidable at arming time: a gate command reading the declared path INDIRECTLY (through its own script), content-level abuse inside the file, and a path re-pointed after arming stay undecided — bookkeeping WRITES are bound by digest and custody proof at the checker instead (#37/#69); this line is the honest boundary, not a pretended rule
15
+ 4. **After a successful `--write`:** only the **config half** is armed — the **chain half** arms at plan adoption (`flow-writer adoption <plan-file>`), and `gates-init` offers the full checker TRIO (review-state + coverage-check + flow-check) for a flow-carrying config.
16
+ 5. **Exit codes:** `0` success/preview; `2` usage (bad key/value/flag, a bare `--write`); `1` floor refusal, config error (the file is left untouched), or a write STOP (no deployment / symlinked config).
17
+
18
+ **Lagging-kit honesty (verbatim contract):** a kit predating the `"flow"` key that reads a config carrying one fails this config load loudly (exit `1`, reddening its full gate matrix); the `set-flow` arming path now enforces the declared `kitMinVersion` floor with a null-guarded comparison (an unparseable version never passes), while tolerate-first ordering remains the only protection for readers older than the `"flow"` key itself — no in-config floor can reach a kit that dies on the unknown key
19
+
20
+ Output is **English/structured** — **localize it to the user's conversational language** when you narrate.
21
+
22
+ **Invariants:** writer (writes only `docs/ai/orchestration.json`) · never commits · never runs a subscription CLI · previews by default · every deep floor on this path only · hand-edit stays first-class.
@@ -17,25 +17,45 @@
17
17
  // plateaus at O(governing), never O(cumulative). Not a ledger.
18
18
  //
19
19
  // Modes:
20
- // (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records, then
21
- // regenerate the navigator + docs/ai/index.md (item (h)). Monoliths present → a
22
- // LOUD legacy-guard refusal ("run --migrate first"); it never half-explodes.
23
- // --check verify HOT cap + adr/ store integrity + the legacy guard + navigator freshness;
24
- // exit 1 on any breach. A STATED skip (exit 0) only when NO ADR substrate exists
25
- // (neither decisions.md NOR docs/ai/adr/).
20
+ // (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records,
21
+ // REWRITE the inbound `decisions.md#ad-NNN…` links across docs/ai/** to the
22
+ // record files (fragment preserved), then regenerate the navigator +
23
+ // docs/ai/index.md (item (h)). Monoliths present a LOUD legacy-guard refusal
24
+ // ("run --migrate first"); it never half-explodes.
25
+ // --check verify HOT cap + adr/ store integrity + the legacy guard + navigator freshness
26
+ // + reference integrity (every inbound `decisions.md#ad-NNN…` anchor must
27
+ // resolve to the CURRENT HOT window — an archived id is stale, not resolving —
28
+ // and every `adr/AD-NNN-slug.md` link must name an existing record FILE); exit 1
29
+ // listing every breach with file:line. A STATED skip (exit 0) only when NO ADR
30
+ // substrate exists (neither decisions.md NOR docs/ai/adr/) AND the docs tree
31
+ // carries no matching ADR reference.
26
32
  // --migrate one-time retirement of the 3-tier monoliths → per-file adr/ records. Dry-run by
27
- // default (prints the file set + id diff + conservation proof, writes nothing).
28
- // --migrate --apply writes a durable pre-delete snapshot, writes the records, rewrites the retained
29
- // HOT preamble, and only THEN removes the monoliths gated on conservation AND
30
- // the snapshot. Re-run skips byte-identical records (crash-resumable).
33
+ // default (prints the file set + id diff + conservation proof + the planned
34
+ // inbound-rewrite set, writes nothing).
35
+ // --migrate --apply writes a durable pre-delete snapshot, writes the records, rewrites the inbound
36
+ // links (monolith-form anchors too, targets computed relative to the linking
37
+ // file) and the retained HOT preamble, and only THEN removes the monoliths —
38
+ // gated on conservation AND the snapshot. Re-run skips byte-identical records
39
+ // (crash-resumable). Combining with --dry-run is a loud pre-spend refusal.
31
40
  // --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
32
41
  // supersession write-side; the --write-index analog). With --dry-run it runs
33
42
  // EXACTLY the same validation (parse, half-migrated guard, store integrity) and
34
43
  // stops before every write — the read-only preflight a guarded caller needs to
35
44
  // earn a go-ahead without risking a partial write.
36
- // --dry-run print the planned rotation move-set, change nothing.
45
+ // --dry-run print the planned rotation move-set + inbound-rewrite set (file:line, old
46
+ // target → new target), change nothing.
37
47
  // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
38
48
  //
49
+ // Reference-scan boundary + stated limitations: the inbound-link scan covers docs/ai/** ONLY (links
50
+ // in README / agent entry points are out of scope); the ADR corpus surfaces themselves are NEVER
51
+ // rewritten — a rewrite-form link to a moved id inside decisions.md, an adr/ record or a monolith
52
+ // tier is a loud pre-write refusal (convert it, e.g. to the [[AD-NNN]] form, then re-run). Fenced
53
+ // regions never count; inline code is NOT tracked (a backtick-wrapped link is treated as live);
54
+ // matching is line-scoped — a link hand-wrapped across a line break is not matched (the same
55
+ // accepted residual as the hand-wrapped preamble continuation line below); and frontmatter is
56
+ // opaque metadata — an ADR link inside YAML frontmatter is neither rewritten nor checked (it is
57
+ // preserved byte-exactly on every write).
58
+ //
39
59
  // FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
40
60
  // • every `## ` heading MUST parse canonically as `## AD-NNN — <title>` (AD-\d{3,}) — a malformed
41
61
  // heading is exit 1 naming file:line, never a silent merge;
@@ -43,6 +63,10 @@
43
63
  // • migration is CONSERVATION-checked before any destructive write: the full multiset
44
64
  // {id → sha256(verbatim block)} across the OLD monoliths equals {retained-HOT ∪ written records};
45
65
  // a drop / renumber / edited-block / stray adr record fails exit 1 before any remove or overwrite;
66
+ // • inbound-link rewrites are CONSERVATION-checked before the run's first write: every moved-id
67
+ // link rewritten, every other byte of every scanned file identical — any mismatch is exit 1 with
68
+ // nothing written; the cross-file write order is pinned (records → inbound rewrites → HOT rewrite
69
+ // / monolith removal) so every interrupted state re-runs to completion;
46
70
  // • a legacy monolith still on disk fails LOUD on default/--check (it is a half-migrated tree).
47
71
  //
48
72
  // docs/ai here is git-ignored, so the monoliths were NEVER committed (no VCS recovery) — every
@@ -51,8 +75,8 @@
51
75
  //
52
76
  // Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
53
77
 
54
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
55
- import { dirname, resolve, join } from 'node:path';
78
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
79
+ import { dirname, resolve, join, posix } from 'node:path';
56
80
  import { fileURLToPath, pathToFileURL } from 'node:url';
57
81
  import { spawnSync } from 'node:child_process';
58
82
  import { createHash } from 'node:crypto';
@@ -275,6 +299,265 @@ export const verifyConservation = (oldItems, newItems) => {
275
299
  }
276
300
  };
277
301
 
302
+ // ── inbound reference integrity (the anchor-orphan fix) ─────────────────────────────────
303
+ //
304
+ // Match contract (line-scoped; fenced lines never count): a textual occurrence of the
305
+ // source-file-plus-fragment form. The lookbehind rejects a longer filename
306
+ // (`other-decisions.md#…` is a different file, never a match); a bare `#ad-NNN` has no file part.
307
+ const DOCS_AI_REL = 'docs/ai';
308
+ const HOT_LINK_RE = /(?<![\w.-])decisions\.md#(ad-(\d{3,})[\w-]*)/g;
309
+ const MONOLITH_LINK_RE = /(?<![\w.-])(?:\.\.?\/)*(?:history\/)?decisions-archive(?:-early)?\.md#(ad-(\d{3,})[\w-]*)/g;
310
+ // The right boundary mirrors the lookbehind: `…md.bak` / `…md/child` are DIFFERENT targets, never
311
+ // a match for the base record (the exact-filename contract cuts both ways).
312
+ const RECORD_LINK_RE = /(?<![\w.-])adr\/(AD-(\d{3,})-[A-Za-z0-9-]+\.md)(?![\w./-])/g;
313
+
314
+ const walkDocsMarkdown = (root) => {
315
+ const base = resolve(root, DOCS_AI_REL);
316
+ if (!existsSync(base)) return [];
317
+ const out = [];
318
+ const walk = (dir, relDir) => {
319
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
320
+ const rel = `${relDir}/${entry.name}`;
321
+ if (entry.isDirectory()) {
322
+ walk(join(dir, entry.name), rel);
323
+ continue;
324
+ }
325
+ // read/write both FOLLOW a symlink (out of the tree), and silently skipping one hides a
326
+ // scannable doc or a whole subtree from the reference scan — refuse loudly either way; only
327
+ // a symlink resolving to a plain non-markdown file stays an ignored stray.
328
+ if (entry.isSymbolicLink() && !entry.name.endsWith('.md')) {
329
+ let targetIsDirectory = false;
330
+ try {
331
+ targetIsDirectory = statSync(join(dir, entry.name)).isDirectory();
332
+ } catch {
333
+ throw fail(1, `${rel}: a dangling symlink in the scan tree — the reference scan cannot classify it; remove or materialize it, then re-run`);
334
+ }
335
+ if (targetIsDirectory) {
336
+ throw fail(1, `${rel}: a symlinked directory in the scan tree would hide its subtree from the reference scan — materialize or remove it, then re-run`);
337
+ }
338
+ continue;
339
+ }
340
+ if (entry.name.endsWith('.md')) {
341
+ if (!entry.isFile()) {
342
+ throw fail(1, `${rel}: a markdown name in the scan tree is not a regular file (a symlink or special file) — the reference scan never reads or writes THROUGH it; materialize or remove it, then re-run`);
343
+ }
344
+ out.push(rel);
345
+ }
346
+ }
347
+ };
348
+ walk(base, DOCS_AI_REL);
349
+ return out;
350
+ };
351
+
352
+ // REWRITE scope: docs/ai/** minus the ADR corpus surfaces (HOT, adr/ records + navigator) and the
353
+ // retired monoliths — those are parsed, conservation-hashed and deleted in the same run; a same-run
354
+ // disk rewrite would poison the crash-resume corpus-union guard and write into removed files.
355
+ const isRewriteScope = (rel) =>
356
+ rel !== HOT_REL && rel !== WARM_REL && rel !== COLD_REL && rel !== NAV_REL && !rel.startsWith(`${ADR_DIR_REL}/`);
357
+
358
+ // The corpus surfaces are never rewritten: a rewrite-form link to a MOVED id anywhere in them is a
359
+ // loud pre-write refusal — the operator converts the link and re-runs; verbatim blocks stay absolute.
360
+ const assertAdrCorpusFreeOfMovedLinks = (root, movedById, corpusRels, linkRes) => {
361
+ const violations = [];
362
+ for (const rel of corpusRels) {
363
+ const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
364
+ for (let index = 0; index < lines.length; index += 1) {
365
+ if (fencedLines.has(index)) continue;
366
+ for (const re of linkRes) {
367
+ for (const m of lines[index].matchAll(re)) {
368
+ if (movedById.has(m[2])) violations.push(`${rel}:${frontLines + index + 1}: "${m[0]}"`);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ if (violations.length > 0) {
374
+ throw fail(1, `refusing pre-write: the ADR corpus itself carries rewrite-form links to a moved id — a corpus surface is never rewritten; convert each link (e.g. to the [[AD-NNN]] form), then re-run:\n ${violations.join('\n ')}`);
375
+ }
376
+ };
377
+
378
+ // A block leaving decisions.md cannot keep a RELATIVE ADR link meaningful: the block moves
379
+ // verbatim (the conservation invariant forbids editing it) while its base directory changes —
380
+ // refuse pre-write when a TO-EXPLODE block links a RETAINED id (the moved-id complement is
381
+ // refused corpus-wide by assertAdrCorpusFreeOfMovedLinks) or carries ANY record-form link
382
+ // (valid from decisions.md, broken from inside adr/).
383
+ const assertMovingBlocksFreeOfRelativeAdrLinks = (root, movedIds, retainedIds) => {
384
+ const { frontLines, lines, fencedLines, headings } = tokenizeMarkdown(readFileSync(resolve(root, HOT_REL), 'utf8'), HOT_REL);
385
+ const violations = [];
386
+ const h2 = headings.filter((h) => h.level === 2 && HEADING_RE.test(h.text));
387
+ for (let i = 0; i < h2.length; i += 1) {
388
+ if (!movedIds.has(HEADING_RE.exec(h2[i].text)[1])) continue;
389
+ const end = i + 1 < h2.length ? h2[i + 1].index : lines.length;
390
+ for (let index = h2[i].index; index < end; index += 1) {
391
+ if (fencedLines.has(index)) continue;
392
+ for (const m of lines[index].matchAll(HOT_LINK_RE)) {
393
+ if (retainedIds.has(m[2])) violations.push(`${HOT_REL}:${frontLines + index + 1}: "${m[0]}"`);
394
+ }
395
+ for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
396
+ violations.push(`${HOT_REL}:${frontLines + index + 1}: "${m[0]}"`);
397
+ }
398
+ }
399
+ }
400
+ if (violations.length > 0) {
401
+ throw fail(1, `refusing pre-write: a block leaving ${HOT_REL} carries a relative ADR link (a RETAINED decisions.md#… anchor or an adr/… record link) — the block moves verbatim, so the link would change meaning inside the record; convert each link (e.g. to the [[AD-NNN]] form), then re-run:\n ${violations.join('\n ')}`);
402
+ }
403
+ };
404
+
405
+ // Migrate deletes the monolith files: a monolith-form link whose id is NOT in the moved set could
406
+ // never point at a record after --apply (D5's "all point at records" outcome) — refuse pre-write
407
+ // wherever it sits (rewrite scope, HOT, records, the monolith blocks about to become records).
408
+ const assertNoOrphanedMonolithLinks = (root, movedById, rels) => {
409
+ const violations = [];
410
+ for (const rel of rels) {
411
+ const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
412
+ for (let index = 0; index < lines.length; index += 1) {
413
+ if (fencedLines.has(index)) continue;
414
+ for (const m of lines[index].matchAll(MONOLITH_LINK_RE)) {
415
+ if (!movedById.has(m[2])) violations.push(`${rel}:${frontLines + index + 1}: "${m[0]}"`);
416
+ }
417
+ }
418
+ }
419
+ if (violations.length > 0) {
420
+ throw fail(1, `refusing pre-write: monolith-form links target an id OUTSIDE the moved set — after --apply removes the monoliths these links could never resolve to a record; fix each link, then re-run:\n ${violations.join('\n ')}`);
421
+ }
422
+ };
423
+
424
+ // The inbound-rewrite plan across the REWRITE scope. HOT-form links keep their leading relative
425
+ // prefix (adr/ is a SIBLING of decisions.md, so the same prefix reaches the record and the fragment
426
+ // resolves verbatim there); monolith-form targets are computed RELATIVE TO THE LINKING FILE with
427
+ // URL-style forward-slash separators on every platform.
428
+ const planInboundRewrites = (root, movedById, withMonolithForms) => {
429
+ const plans = [];
430
+ for (const rel of walkDocsMarkdown(root).filter(isRewriteScope)) {
431
+ const { frontmatter, frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
432
+ const rewrites = [];
433
+ const afterLines = lines.map((line, index) => {
434
+ if (fencedLines.has(index)) return line;
435
+ // Every match of every form is collected POSITIONALLY on the ORIGINAL line (the forms are
436
+ // textually disjoint), then the line is rebuilt by range-splicing — a negative sharing
437
+ // bytes with a valid link elsewhere on the line can never contaminate the rewrite or its
438
+ // re-derivation.
439
+ const matches = [];
440
+ for (const m of line.matchAll(HOT_LINK_RE)) {
441
+ const moved = movedById.get(m[2]);
442
+ if (moved) matches.push({ start: m.index, old: m[0], new: `adr/${moved.fileName}#${m[1]}` });
443
+ }
444
+ if (withMonolithForms) {
445
+ for (const m of line.matchAll(MONOLITH_LINK_RE)) {
446
+ const moved = movedById.get(m[2]);
447
+ if (moved) matches.push({ start: m.index, old: m[0], new: `${posix.relative(posix.dirname(rel), `${ADR_DIR_REL}/${moved.fileName}`)}#${m[1]}` });
448
+ }
449
+ }
450
+ if (matches.length === 0) return line;
451
+ matches.sort((a, b) => a.start - b.start);
452
+ let cursor = 0;
453
+ let next = '';
454
+ for (const match of matches) {
455
+ if (match.start < cursor) throw fail(1, `${rel}:${frontLines + index + 1}: overlapping link matches — refusing to plan a rewrite`);
456
+ next += line.slice(cursor, match.start) + match.new;
457
+ cursor = match.start + match.old.length;
458
+ rewrites.push({ index, line: frontLines + index + 1, start: match.start, old: match.old, new: match.new });
459
+ }
460
+ next += line.slice(cursor);
461
+ return next;
462
+ });
463
+ if (rewrites.length > 0) plans.push({ rel, frontmatter, frontLines, beforeLines: lines, afterLines, rewrites });
464
+ }
465
+ return plans;
466
+ };
467
+
468
+ // Conservation (fail-loud): re-derive the rewritten file from { before + planned rewrites } and
469
+ // require byte-equality — a dropped/altered link or any changed byte outside the plan is exit 1
470
+ // with nothing written. Exported so its failure paths are unit-testable directly.
471
+ export const verifyRewriteConservation = (plan) => {
472
+ const { rel, frontLines, beforeLines, afterLines, rewrites } = plan;
473
+ const refuse = (line, detail) => fail(1, `${rel}:${line}: inbound-rewrite conservation violation — ${detail}; refusing with nothing written`);
474
+ if (beforeLines.length !== afterLines.length) throw refuse(frontLines + 1, `the rewrite changed the line count (${beforeLines.length} → ${afterLines.length})`);
475
+ const byIndex = new Map();
476
+ for (const rw of rewrites) {
477
+ const list = byIndex.get(rw.index);
478
+ if (list) list.push(rw);
479
+ else byIndex.set(rw.index, [rw]);
480
+ }
481
+ for (let index = 0; index < beforeLines.length; index += 1) {
482
+ const fileLine = frontLines + index + 1;
483
+ const planned = byIndex.get(index);
484
+ if (!planned) {
485
+ if (beforeLines[index] !== afterLines[index]) throw refuse(fileLine, 'a line outside the planned rewrite set changed');
486
+ continue;
487
+ }
488
+ let expected;
489
+ if (planned.every((rw) => typeof rw.start === 'number')) {
490
+ // Range-splicing re-derivation (offsets recorded on the ORIGINAL line) — a same-bytes
491
+ // negative elsewhere on the line can never contaminate it.
492
+ const ordered = [...planned].sort((a, b) => a.start - b.start);
493
+ let cursor = 0;
494
+ expected = '';
495
+ for (const rw of ordered) {
496
+ if (rw.start < cursor) throw refuse(fileLine, 'overlapping planned rewrites');
497
+ if (beforeLines[index].slice(rw.start, rw.start + rw.old.length) !== rw.old) throw refuse(fileLine, `the planned link "${rw.old}" is not at its recorded offset in the source line`);
498
+ expected += beforeLines[index].slice(cursor, rw.start) + rw.new;
499
+ cursor = rw.start + rw.old.length;
500
+ }
501
+ expected += beforeLines[index].slice(cursor);
502
+ } else {
503
+ // Occurrence-based fallback for plans without offsets (the exported contract's original
504
+ // shape); longest-first so a link that is a textual prefix of another cannot corrupt it.
505
+ const unique = [...new Map(planned.map((rw) => [`${rw.old} ${rw.new}`, rw])).values()].sort((a, b) => b.old.length - a.old.length);
506
+ expected = beforeLines[index];
507
+ for (const rw of unique) {
508
+ if (!expected.includes(rw.old)) throw refuse(fileLine, `the planned link "${rw.old}" is absent from the source line`);
509
+ expected = expected.split(rw.old).join(rw.new);
510
+ }
511
+ }
512
+ if (afterLines[index] !== expected) throw refuse(fileLine, 'the rewritten line diverges from the planned substitution (a link would be dropped or altered)');
513
+ for (const rw of planned) {
514
+ if (!afterLines[index].includes(rw.new)) throw refuse(fileLine, `the rewritten link "${rw.new}" is missing from the output line (a link would be dropped)`);
515
+ }
516
+ }
517
+ };
518
+
519
+ const summarizeRewrites = (plans) =>
520
+ plans.flatMap((plan) => plan.rewrites.map((rw) => `${plan.rel}:${rw.line} ${rw.old} → ${rw.new}`));
521
+
522
+ export const writeInboundRewrites = (root, plans) => {
523
+ // Two passes (the plan is computed pre-write, D4): verify the LIVE bytes of EVERY planned file
524
+ // first, then write — a drift anywhere refuses with NOTHING written in the rewrite phase.
525
+ for (const plan of plans) {
526
+ if (readFileSync(resolve(root, plan.rel), 'utf8') !== `${plan.frontmatter}${plan.beforeLines.join('\n')}`) {
527
+ throw fail(1, `${plan.rel}: changed between the rewrite plan and the write — refusing to overwrite from a stale snapshot; re-run`);
528
+ }
529
+ }
530
+ for (const plan of plans) {
531
+ writeFileSync(resolve(root, plan.rel), `${plan.frontmatter}${plan.afterLines.join('\n')}`, 'utf8');
532
+ }
533
+ };
534
+
535
+ // CHECK scope (read-only, wider): docs/ai/** including decisions.md (whole file) and the adr/
536
+ // records, excluding only the generated navigator. (a) a `decisions.md#ad-NNN…` id outside the
537
+ // CURRENT HOT window is dead (an archived id is stale, not resolving); (b) an `adr/AD-NNN-slug.md`
538
+ // link must name an existing record FILE (exact filename, not id-presence).
539
+ const collectReferenceViolations = (root, hotIds, archivedIds) => {
540
+ const violations = [];
541
+ for (const rel of walkDocsMarkdown(root).filter((r) => r !== NAV_REL)) {
542
+ const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
543
+ for (let index = 0; index < lines.length; index += 1) {
544
+ if (fencedLines.has(index)) continue;
545
+ const fileLine = frontLines + index + 1;
546
+ for (const m of lines[index].matchAll(HOT_LINK_RE)) {
547
+ if (hotIds.has(m[2])) continue;
548
+ const why = archivedIds.has(m[2]) ? `AD-${m[2]} is archived — repoint the link at ${ADR_DIR_REL}/` : `AD-${m[2]} is not in the current HOT window`;
549
+ violations.push(`${rel}:${fileLine}: dead ADR anchor "${m[0]}" — ${why}`);
550
+ }
551
+ for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
552
+ if (!existsSync(resolve(root, ADR_DIR_REL, m[1]))) {
553
+ violations.push(`${rel}:${fileLine}: dead ADR record link "${m[0]}" — no record file ${ADR_DIR_REL}/${m[1]}`);
554
+ }
555
+ }
556
+ }
557
+ }
558
+ return violations;
559
+ };
560
+
278
561
  // ── tier / store IO ─────────────────────────────────────────────────────────────────────
279
562
 
280
563
  export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
@@ -581,6 +864,10 @@ const parseArgs = (argv) => {
581
864
  else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
582
865
  else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
583
866
  }
867
+ // Pre-fix, --apply silently won over --dry-run and wrote — a fail-closed contract cannot keep that.
868
+ if (flags.migrate && flags.apply && flags.dryRun) {
869
+ throw fail(2, `--migrate --apply --dry-run is contradictory — plain --migrate IS the dry run; drop --apply to preview or --dry-run to apply\n${USAGE}`);
870
+ }
584
871
  return { flags, today };
585
872
  };
586
873
 
@@ -672,10 +959,23 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
672
959
  for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
673
960
  assertStoreIntegrity(retained, [...finalStoreById.values()]);
674
961
 
962
+ const movedById = new Map(records.map((r) => [r.id, r]));
963
+ assertAdrCorpusFreeOfMovedLinks(root, movedById, [HOT_REL, ...existingStore.map((e) => e.rel), ...present], [HOT_LINK_RE, MONOLITH_LINK_RE]);
964
+ assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
965
+ assertNoOrphanedMonolithLinks(root, movedById, [
966
+ ...walkDocsMarkdown(root).filter(isRewriteScope),
967
+ HOT_REL,
968
+ ...existingStore.map((e) => e.rel),
969
+ ...present,
970
+ ]);
971
+ const rewritePlans = planInboundRewrites(root, movedById, true);
972
+ for (const plan of rewritePlans) verifyRewriteConservation(plan);
973
+
675
974
  const summary = {
676
975
  records: records.map((r) => r.fileName),
677
976
  retainedHot: retained.map((e) => `AD-${e.id}`),
678
977
  monolithsRetired: present,
978
+ inboundRewrites: summarizeRewrites(rewritePlans),
679
979
  conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
680
980
  };
681
981
 
@@ -692,7 +992,9 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
692
992
  ];
693
993
  const snapshot = writeSnapshot(root, snapshotFiles, deps);
694
994
 
995
+ // Pinned write order: records → inbound rewrites → HOT rewrite / monolith removal.
695
996
  writeRecords(root, records);
997
+ writeInboundRewrites(root, rewritePlans);
696
998
  const corpus = [...retained, ...loadAdrStore(root)];
697
999
  writeNavigatorFile(root, corpus, today);
698
1000
  writeHot(root, hot, retained, today);
@@ -703,6 +1005,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
703
1005
  log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
704
1006
  log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
705
1007
  log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
1008
+ log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
706
1009
  log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
707
1010
  log(` retired monoliths: ${present.join(', ')}`);
708
1011
  log(` navigator: ${NAV_REL}`);
@@ -756,6 +1059,13 @@ const runCheck = (root, today, log, logError) => {
756
1059
  return 1;
757
1060
  }
758
1061
  if (!hasHot && !hasStore) {
1062
+ // The reference scan still runs: a matching reference over NO substrate is a dead link, never a
1063
+ // clean skip — the SKIP remains only for a tree with zero matches.
1064
+ const orphaned = collectReferenceViolations(root, new Set(), new Set());
1065
+ if (orphaned.length > 0) {
1066
+ for (const v of orphaned) logError(`[archive-decisions] FAIL: ${v}.`);
1067
+ return 1;
1068
+ }
759
1069
  log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to check.`);
760
1070
  return 0;
761
1071
  }
@@ -783,11 +1093,13 @@ const runCheck = (root, today, log, logError) => {
783
1093
  problems.push(`${NAV_REL} is stale (out of sync with the ADR corpus) — run \`node scripts/archive-decisions.mjs --write-navigator\` and commit it`);
784
1094
  }
785
1095
 
1096
+ problems.push(...collectReferenceViolations(root, new Set(hotEntries.map((e) => e.id)), new Set(adrEntries.map((e) => e.id))));
1097
+
786
1098
  if (problems.length > 0) {
787
1099
  for (const p of problems) logError(`[archive-decisions] FAIL: ${p}.`);
788
1100
  return 1;
789
1101
  }
790
- log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh.');
1102
+ log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh, inbound ADR references resolve.');
791
1103
  return 0;
792
1104
  };
793
1105
 
@@ -825,20 +1137,33 @@ const runRotate = (root, flags, today, deps, log, logError) => {
825
1137
  for (const rec of records) finalStoreById.set(rec.id, { id: rec.id, idNum: rec.idNum, fileName: rec.fileName });
826
1138
  assertStoreIntegrity(retained, [...finalStoreById.values()]);
827
1139
 
828
- const summary = { explode: records.map((r) => r.fileName), retainedHot: retained.map((e) => `AD-${e.id}`) };
1140
+ const movedById = new Map(records.map((r) => [r.id, r]));
1141
+ assertAdrCorpusFreeOfMovedLinks(root, movedById, [HOT_REL, ...existingStore.map((e) => e.rel)], [HOT_LINK_RE]);
1142
+ assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
1143
+ const rewritePlans = planInboundRewrites(root, movedById, false);
1144
+ for (const plan of rewritePlans) verifyRewriteConservation(plan);
1145
+
1146
+ const summary = {
1147
+ explode: records.map((r) => r.fileName),
1148
+ retainedHot: retained.map((e) => `AD-${e.id}`),
1149
+ inboundRewrites: summarizeRewrites(rewritePlans),
1150
+ };
829
1151
  if (flags.dryRun) {
830
1152
  log('[archive-decisions] DRY-RUN — no files will be changed.');
831
1153
  log(JSON.stringify(summary, null, 2));
832
1154
  return 0;
833
1155
  }
834
1156
 
1157
+ // Pinned write order: records → inbound rewrites → HOT rewrite (crash-resume idempotency).
835
1158
  writeRecords(root, records);
1159
+ writeInboundRewrites(root, rewritePlans);
836
1160
  const corpus = [...retained, ...loadAdrStore(root)];
837
1161
  writeNavigatorFile(root, corpus, today);
838
1162
  writeHot(root, hot, retained, today);
839
1163
  const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
840
1164
  log('[archive-decisions] rotated:');
841
1165
  log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
1166
+ log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
842
1167
  log(` retained HOT: ${summary.retainedHot.join(', ')}`);
843
1168
  if (regen.ok) log(' regenerated docs/ai/index.md');
844
1169
  else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);