@sabaiway/agent-workflow-kit 4.4.0 → 4.5.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 4.5.0 — the ADR-store migration finds the projects that could never hear about it (AD-083)
8
+
9
+ **If your project still keeps its decisions in one big archive file, this release is the first thing
10
+ that will actually tell you so.** A whole class of projects was invisible to the old check.
11
+
12
+ **1. The old-layout check stopped guessing from filenames.** It used to say «old layout» only when a
13
+ retired `docs/ai/history/decisions-archive*.md` file was on disk. A project that never grew one — its
14
+ decisions file simply never got big enough to roll over — was reported as having nothing to do, and
15
+ running the migration greeted it with *«a fresh new-scheme tree»*. Its own pre-commit check agreed:
16
+ «OK — every tier is within its cap», forever, describing a retired layout as healthy. The check now
17
+ looks at the rotation script your project actually deploys: the current one knows about the
18
+ `docs/ai/adr/` store, the older one does not. That is a fact about your tree, not a guess — and it
19
+ deliberately stays quiet for two groups it would otherwise nag wrongly: projects whose current script
20
+ already reds their own commit with an actionable fix, and projects that run no Node at all.
21
+
22
+ **2. `migrate-adr-store` now finishes the job on those projects, and can always be re-run.** On a
23
+ project with no archive file to explode it snapshots, refreshes the enforcement scripts and seeds the
24
+ store (`docs/ai/adr/` + the navigator + a regenerated `docs/ai/index.md`). Interrupt it anywhere and
25
+ run it again: a store folder alone is no longer mistaken for «done» (the navigator has to exist and
26
+ your own check has to pass), the script refresh is re-planned in full each run, and a failed index
27
+ regeneration now fails the run loudly instead of reporting success. The preview earns its go-ahead —
28
+ it runs exactly the checks the real run does and writes nothing.
29
+
30
+ **3. `upgrade` stops saying «flow optimal» to a project on the old layout.** The recommendations
31
+ section now carries the migration as an item. It is HAND-APPLY on purpose: the one-liner it hands you
32
+ is the **preview**, and the real migration is a separate step you run only after seeing the plan and
33
+ agreeing to it — this crossing overwrites and deletes files your project already has, which is why it
34
+ never rides the ordinary one-click apply. Honest limit, stated plainly:
35
+ this reaches you through `status` and `upgrade`, so it mechanizes a door you already walk through — it
36
+ is not a new one for someone who never runs either.
37
+
7
38
  ## 4.4.0 — the guard stops charging you for answers it already has (AD-082)
8
39
 
9
40
  **If the agent keeps asking you to approve its own `2>/dev/null`, this release is for you.** Two
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '4.4.0'
6
+ version: '4.5.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "4.4.0",
6
+ "version": "4.5.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -1,10 +1,12 @@
1
1
  ### Mode: migrate-adr-store
2
2
 
3
- <!-- opt-in-capability: none — the old-layout condition is already reported by status and upgrade; the advisor does not duplicate a report the same run prints -->
3
+ <!-- opt-in-capability: adr-store-migration -->
4
4
 
5
5
  The **guarded ADR-store migration** — a one-time, opt-in move of an existing project's `docs/ai` from the retired 3-tier ADR cascade (HOT `decisions.md` → the WARM/COLD `decisions-archive*.md` monoliths) to the durable **one-file-per-ADR store** (HOT `decisions.md` + `docs/ai/adr/AD-NNN-slug.md` records + the `docs/ai/adr/log.md` navigator). **In-agent, opt-in**, and reached ONLY here: a normal `upgrade` never installs the new-scheme rotator into an un-migrated project — the new rotator arrives ONLY through this mode, which migrates in the same step (AD-051). Run **`--dry-run` first, always**, show the user the plan in plain language, get explicit consent, then re-run with `--apply`. It **never commits**.
6
6
 
7
- When to run it: `status` (or `upgrade`) reports an *old ADR layout* (a `decisions-archive*.md` monolith is still on disk). A project already on the one-file-per-ADR store — or a fresh deployment seeded with it — needs nothing here (the mode is a stated no-op).
7
+ When to run it: `status` (or `upgrade`) reports an *old ADR layout*. That covers **two** shapes, and the remedy is the same for both — (1) a `decisions-archive*.md` monolith is still on disk, and (2) **no monolith was ever produced**: the project's deployed `scripts/archive-decisions.mjs` simply predates the store, so its own gate reports the retired three-tier world as healthy and would never mention the store. The second shape is detected by that deployed script's own provenance — never by "has `decisions.md`, lacks `adr/`", which would falsely accuse a project whose NEW rotator already reds its gate with an actionable fix, and every project that runs no Node. A project already on the one-file-per-ADR store with a fresh navigator — or a fresh deployment seeded with it — needs nothing here (the mode is a stated no-op).
8
+
9
+ On the no-monolith shape `--apply` does the same work minus the explosion: snapshot → refresh the deployed enforcement scripts → **seed** the store (`docs/ai/adr/` + the navigator + a regenerated `docs/ai/index.md`) → verify. It is **re-runnable to completion from any interruption**: a store directory alone never counts as finished (the navigator must exist and the project's own `--check` must pass), the whole script refresh is re-planned on every run with the rotation script written last, and a failed index regeneration fails the run **closed** rather than reporting success.
8
10
 
9
11
  Run `node ${CLAUDE_SKILL_DIR}/tools/migrate-adr-store.mjs [--dry-run | --apply] [--cwd <project>]`:
10
12
 
@@ -27,6 +27,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
27
27
  - `read-lane` — enabling the opt-in read-only compound lane auto-approves *compounds* (and singles) of the seeded read-only core that carry ZERO shell metaprogramming: an UNATTENDED trust extension, bounded by the audited read-only core (never a command outside it; prompt-bypass only, never a sandbox bypass) and applied regardless of which of those core commands you seeded as individual settings rules. It is a PROJECT-PERSISTENT declaration in `docs/ai/lanes.json` — every future session, subagents' Bash too where the host fires hooks on subagent Bash, and (committed) every checkout. The apply depends on state: when the lane is OFF, it is the `gate-hook --read-lane` preview (whose own currency check refuses a stale hook — a pre-1.48 hook never reads `lanes.json`); when the placed hook is STALE (an enabled lane over an old hook) or MISSING, the item instead surfaces a **delete-to-reseed** / re-place recovery (a destructive `rm` + `--apply`, an attention item — never the safe preview). Risk profile: a bounded read-only trust-posture extension — no write/exec exposure beyond the audited core.
28
28
  - `worktrees-dir` — on a settings-native host that honors the key, the HAND-APPLY line widens the OS-sandbox WRITE surface to the whole worktrees parent dir: every sibling path under it (other repositories included) becomes agent-writable, and the widening persists for every later session. A harness-managed host may ignore that project setting; grant the narrow parent through host/session controls or use the provision terminal fallback instead. When that scope is wider than you want, narrow it FIRST: create a dedicated dir yourself (outside the agent's write surface), point `docs/ai/worktrees.json` `parentDir` at it, then re-run recommendations — the item re-renders with the narrowed dir. The kit never writes sandbox filesystem allowances itself; the line is always yours to paste. **Convergence** is two-path: a declared `sandbox.filesystem.allowWrite` entry covering the probed dir (either settings scope — `~` and `~/…` resolve against home, and coverage is path-segment-aware, so a grant on a sibling or on a child never counts), or — for a host that ignores that key — the neutral dir-bound acknowledgement recorded by this item's consent-gated apply one-liner (`ack-write --lane worktrees-dir` — a dry-run preview that prints the exact `--apply`; recorded as `worktreesDirAck` in the family-owned `docs/ai/acks.json`, never a security key), while the grant advice itself rides the `recipe:` line as the labeled FIRST step; against a trusted host NO the apply stays the HAND-APPLY grant advice and no ack is offered. Neither is proof of write CAPABILITY: the provision preflight's real create+delete probe stays the runtime truth, and the fingerprint is bound to the **resolved probe dir**, so the item re-fires only when that resolved dir changes (two absent `parentDir` values sharing an existing ancestor resolve to the same dir and keep the same ack). Risk profile: a real write-surface widening where honored — scope it deliberately.
29
29
 
30
+ - `adr-store-migration` — other items write project files too; what is unique here is that the crossing **overwrites and deletes files the project already has**: it replaces the deployed enforcement scripts in `scripts/` (the directional subset — only basenames the project already has; a locally-edited copy is snapshotted first, never silently clobbered) and, where a retired archive file exists, DELETES it once conservation has been proven. That is why it is **HAND-APPLY** and why the command shown in the apply slot is a **`--dry-run`** — it writes nothing and prints the whole plan. `--apply` is a SEPARATE step, run only after that plan has been shown and **fresh consent** obtained for it; the consent flow executes only the apply slot, so an item that needs consent AFTER its preview cannot use that lane at all. Every write is idempotent and the run is re-runnable to completion after any interruption, so a re-run repairs rather than double-applies. It never commits. Risk profile: overwrite + delete of existing project files, gated on a preview you have actually read.
31
+
30
32
  **Sandbox lanes (what to DO with the `sandbox-lane` recipe, per host class):**
31
33
 
32
34
  - **Settings-native sandbox** (the harness reads the `.claude/settings.json` sandbox keys): nothing beyond the `--bridge-tier` wiring — the tier's `excludedCommands` already routes the wrappers OUTSIDE the sandbox, so they never consult `allowedDomains`/`allowWrite`. Record the ack (the ack-write one-liner) once the tier is confirmed.
@@ -11,7 +11,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/family-registry.mjs --json [--dir <project>]
11
11
  1. **Versions — a status-only render from `installed[]` + `deploymentHead`** (this is **NOT** the shared notes-based version block — see the separation note below): the **`docs/ai` structure version** (named as such, never "lineage head"), then each member by its `display` showing its `version` (or, when there is no version, the plain phrase for its `state`, mapped above), plus the two-axes disambiguation. **Freshness comes from `installed[].refresh`, not from `notes`:** for each member whose **`refresh.behind`** is `true`, show a **localized "needs refresh"** label and the **verbatim `refresh.recommend`** command **exactly once** (the command/package name stays source-language; **do not also paste the English `notes` caveats** — `refresh.recommend` is the single source of the recovery step, so the command is never duplicated on this surface). A member whose **`refresh.freshness`** is **`unknown`** is surfaced too — a localized *"couldn't be checked"* label; it is **never counted as current and never as behind** (its `notes` caveat carries the detail on the notes-based surfaces; here the label is enough). Lead with a one-line **headline count** derived from `installed[].state` + `refresh.behind` + `refresh.freshness` (e.g. *"5 members installed · 1 needs a refresh · 1 couldn't be checked"* — omit a zero count).
12
12
 
13
13
  > **Status reads `refresh`; the shared version block + the bootstrap/upgrade footers stay `notes`-based (unchanged this release).** `${CLAUDE_SKILL_DIR}/references/modes/status.md` has its OWN status-only render (above), keyed on `installed[].refresh.behind` / `refresh.recommend`. The shared **version block** (under *The version block + welcome mat* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and the bootstrap (step 11) + every upgrade (steps 4 / 8) report footer still consume `installed[].notes` verbatim — that wiring is deliberately **untouched** here (their migration onto `refresh` is deferred). Do not rewrite those footers onto `refresh`.
14
- 2. **Deployment (`--dir`)** (from `project`): whether `docs/ai/` is deployed + the deploy stamps by `display`; and **visibility** — render `project.visibility.state` in **user-safe words only**: *visible (tracked)* / *hidden (git-ignored, local-only)* / *unclear (uncommitted or partially set up)* — **never** the words "hidden fence" or any marker term. A `visibility.error` → surface it plainly. When `project.adrLayout` is **`old`**, add a plain-language note that the project still uses an **older ADR layout** and should run the opt-in **`/agent-workflow-kit migrate-adr-store`** to move to the one-file-per-ADR store (preview first; it never commits); `migrated` / `none` need no note.
14
+ 2. **Deployment (`--dir`)** (from `project`): whether `docs/ai/` is deployed + the deploy stamps by `display`; and **visibility** — render `project.visibility.state` in **user-safe words only**: *visible (tracked)* / *hidden (git-ignored, local-only)* / *unclear (uncommitted or partially set up)* — **never** the words "hidden fence" or any marker term. A `visibility.error` → surface it plainly. When `project.adrLayout` is **`old`** or **`old-unrotated`**, add a plain-language note that the project still uses an **older ADR layout** and should run the opt-in **`/agent-workflow-kit migrate-adr-store`** to move to the one-file-per-ADR store (preview first; it never commits) — the note is the SAME for both, they differ only in how the older layout was detected (`old` = a retired archive file is still on disk; `old-unrotated` = the project's deployed rotation script predates the store); `migrated` / `none` need no note.
15
15
  3. **Settings (`--dir`, one line each)** (from `project.settings`):
16
16
  - **recipes** — the effective recipe per slot (detail → `/agent-workflow-kit procedures` / `recipes`); a `recipes.detectError` → say the backends couldn't be checked, so recipes floored at solo.
17
17
  - **attribution** — `includeCoAuthoredBy` effective; call out a **local override** only when `local` is non-null **and** differs from `project` (a `null` `local` means the key is absent there, so the project value stands — that is not an override).
@@ -28,7 +28,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
28
28
 
29
29
  **Autonomy-declaration ensure (seed-if-missing) — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Ensure `docs/ai/autonomy.json` exists: **create it from `${CLAUDE_SKILL_DIR}/references/templates/autonomy.json` if missing** (the kit's OWN template twin, mirrored from memory — so a stale memory never silently loses the seed); **an existing file is preserved byte-for-byte** (a declared policy is authored content — never clobbered, never refreshed in place). The seed is SPARSE (the onboarding note only) and **defaults-equivalent** — deploying it never changes behavior (the computed defaults stay the policy until the user declares levels with `/agent-workflow-kit set-autonomy` or by hand). Report it in the step 4 / step 8 success report (*seeded* / *already present, preserved*). Like the other config ensures, no lineage-head bump or migration file (a `.json`, outside the docs cap-validator).
30
30
 
31
- **Enforcement-script ensure (seed-if-missing) — stamp-independent, same gate, BEFORE the equal-head short-circuit.** A deployment older than the ADR-cascade feature has no `scripts/archive-decisions.mjs`, and an equal-head exit would otherwise never deliver it. Ensure the pair exists in the project's `scripts/`: **copy `archive-decisions.mjs` + `archive-decisions.test.mjs` from `${CLAUDE_SKILL_DIR}/references/scripts/` if missing** (the kit's own fallback copies — byte-identical to the memory canon by the mirror guard); **an existing file is preserved, never overwritten** (drift repair belongs to a lineage migration, not this ensure). **OLD ADR-store layout — DETECT FIRST, never auto-migrate (AD-051, Decision 13):** if the project still carries a `docs/ai/history/decisions-archive*.md` monolith (the `adrLayout: "old"` signal `status` surfaces), it is on the RETIRED 3-tier cascade — **do NOT seed or refresh the now one-file-per-ADR rotator here** (the new `archive-decisions.mjs` beside un-migrated monoliths would red their ADR gate). Instead surface a LOUD instruct: run the opt-in **`/agent-workflow-kit migrate-adr-store`** (it snapshots, refreshes the scripts, and migrates in one consent-gated step; previews first, never commits). The seed-if-missing above applies ONLY to a clean layout (no monolith). The deployed pre-commit hook gains the `archive-decisions.mjs --check` line only when the hook itself is next refreshed (re-run `node scripts/install-git-hooks.mjs` after the ensure and it will refuse a non-marker hook as always); an OLD hook without the line stays consistent-safe — the decisions gate is simply not enforced yet, never a broken hook. Skip this ensure on a No-Node project (the scripts are Node enforcement). Report it in the step 4 / step 8 success report (*added* / *already present* / *old ADR layout — migration instructed*).
31
+ **Enforcement-script ensure (seed-if-missing) — stamp-independent, same gate, BEFORE the equal-head short-circuit.** A deployment older than the ADR-cascade feature has no `scripts/archive-decisions.mjs`, and an equal-head exit would otherwise never deliver it. Ensure the pair exists in the project's `scripts/`: **copy `archive-decisions.mjs` + `archive-decisions.test.mjs` from `${CLAUDE_SKILL_DIR}/references/scripts/` if missing** (the kit's own fallback copies — byte-identical to the memory canon by the mirror guard); **an existing file is preserved, never overwritten** (drift repair belongs to a lineage migration, not this ensure). **OLD ADR-store layout — DETECT FIRST, never auto-migrate (AD-051, Decision 13):** if `status` surfaces `adrLayout: "old"` (a `docs/ai/history/decisions-archive*.md` monolith) or `"old-unrotated"` (no monolith, but the deployed rotator predates the store), it is on the RETIRED 3-tier cascade — **do NOT seed or refresh the now one-file-per-ADR rotator here** (the new `archive-decisions.mjs` beside un-migrated monoliths would red their ADR gate). Instead surface a LOUD instruct: run the opt-in **`/agent-workflow-kit migrate-adr-store`** (consent-gated; previews first, never commits). The seed-if-missing above applies ONLY to a clean layout (neither signal). The deployed pre-commit hook gains the `archive-decisions.mjs --check` line only when the hook itself is next refreshed (re-run `node scripts/install-git-hooks.mjs` after the ensure and it will refuse a non-marker hook as always); an OLD hook without the line stays consistent-safe — the decisions gate is simply not enforced yet, never a broken hook. Skip this ensure on a No-Node project (the scripts are Node enforcement). Report it in the step 4 / step 8 success report (*added* / *already present* / *old ADR layout — migration instructed*).
32
32
 
33
33
  **Placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
34
34
  `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` and **paste its per-bridge
@@ -63,7 +63,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
63
63
  **NEVER writes** it (the file lives outside every kit tree — D2), so an unknown/retired key is
64
64
  flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
65
65
  4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
66
- - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the `docs/ai/autonomy.json` declaration was *seeded* (the sparse defaults-equivalent note) or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/`, found it *already present*, or found an *old ADR layout — migration instructed*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused); the **bridge-settings reconcile** outcome (paste the tool's line verbatim); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
66
+ - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the `docs/ai/autonomy.json` declaration was *seeded* (the sparse defaults-equivalent note) or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/`, found it *already present*, or found an *old ADR layout — migration instructed* (either shape); the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused); the **bridge-settings reconcile** outcome (paste the tool's line verbatim); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
67
67
  - **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
68
68
  - **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent-gated applies: the user picks items in plain language; surface each picked item's posture note, get the explicit confirm, then run EXACTLY the rendered one-liners (a HAND-APPLY item is never run by you) — the full lane in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
69
69
  - **Live host/session facts are tool-composed only.** Any claim this report makes about the current host or session state — prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts — must trace to **live tool output** from **this session** (the lines you just composed, or a probe you ran this run); a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection. Full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`.
@@ -29,7 +29,10 @@
29
29
  // HOT preamble, and only THEN removes the monoliths — gated on conservation AND
30
30
  // the snapshot. Re-run skips byte-identical records (crash-resumable).
31
31
  // --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
32
- // supersession write-side; the --write-index analog).
32
+ // supersession write-side; the --write-index analog). With --dry-run it runs
33
+ // EXACTLY the same validation (parse, half-migrated guard, store integrity) and
34
+ // stops before every write — the read-only preflight a guarded caller needs to
35
+ // earn a go-ahead without risking a partial write.
33
36
  // --dry-run print the planned rotation move-set, change nothing.
34
37
  // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
35
38
  //
@@ -693,7 +696,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
693
696
  return 0;
694
697
  };
695
698
 
696
- const runWriteNavigator = (root, today, deps, log, logError) => {
699
+ const runWriteNavigator = (root, flags, today, deps, log, logError) => {
697
700
  if (!existsSync(resolve(root, HOT_REL)) && !existsSync(resolve(root, ADR_DIR_REL))) {
698
701
  log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to write.`);
699
702
  return 0;
@@ -704,6 +707,14 @@ const runWriteNavigator = (root, today, deps, log, logError) => {
704
707
  const adrEntries = loadAdrStore(root);
705
708
  assertStoreIntegrity(hotEntries, adrEntries); // never emit a duplicate-row / corrupt navigator
706
709
  const corpus = [...hotEntries, ...adrEntries];
710
+ // --dry-run runs EXACTLY the validation above and stops before every write: the parse, the
711
+ // half-migrated guard and the store-integrity check are the same code the write path uses, so a
712
+ // caller (the guarded ADR-store crossing) can earn a go-ahead without a partial write. A separate
713
+ // re-implementation of these checks would be an approximation that can disagree with the writer.
714
+ if (flags.dryRun) {
715
+ log(`[archive-decisions] --write-navigator DRY-RUN — no files will be changed; ${corpus.length} ADR(s) validated.`);
716
+ return 0;
717
+ }
707
718
  writeNavigatorFile(root, corpus, today);
708
719
  const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
709
720
  log(`[archive-decisions] wrote ${NAV_REL} (${corpus.length} ADRs in the corpus).`);
@@ -828,7 +839,7 @@ export const runCli = (argv, deps = {}) => {
828
839
  const today = todayOpt ?? new Date().toISOString().slice(0, 10);
829
840
 
830
841
  if (flags.migrate) return runMigrate(root, flags, today, deps, log, logError);
831
- if (flags.writeNavigator) return runWriteNavigator(root, today, deps, log, logError);
842
+ if (flags.writeNavigator) return runWriteNavigator(root, flags, today, deps, log, logError);
832
843
  if (flags.check) return runCheck(root, today, log, logError);
833
844
 
834
845
  if (!existsSync(resolve(root, HOT_REL))) {
@@ -643,6 +643,33 @@ describe('1.5 navigator — governing heads (computed), superseded drop out but
643
643
  assert.doesNotMatch(gov, /\| AD-002 \|/, 'the Proposed ADR is NOT a governing head (accepted & not-superseded only)');
644
644
  });
645
645
 
646
+ // The read-only preflight a guarded caller needs: the SAME parse / half-migrated guard / store
647
+ // integrity the writer runs, stopping before every write. Without it a caller can only learn that
648
+ // seeding is safe by seeding.
649
+ it('--write-navigator --dry-run validates and writes NOTHING', () => {
650
+ const root = makeRoot();
651
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
652
+ const navBefore = readFileSync(join(root, NAV_REL), 'utf8');
653
+ rmSync(join(root, NAV_REL));
654
+ const r = run(['--write-navigator', '--dry-run', '--today=2026-07-09'], root);
655
+ assert.equal(r.code, 0);
656
+ assert.match(r.out.join('\n'), /DRY-RUN/, 'the run states it changed nothing');
657
+ assert.equal(existsSync(join(root, NAV_REL)), false, 'the navigator was NOT written by the dry-run');
658
+ // and the real run still produces exactly what the dry-run validated
659
+ assert.equal(run(['--write-navigator', '--today=2026-07-09'], root).code, 0);
660
+ assert.equal(readFileSync(join(root, NAV_REL), 'utf8'), navBefore);
661
+ });
662
+
663
+ it('--write-navigator --dry-run REFUSES a corrupt store instead of green-lighting it', () => {
664
+ const root = makeRoot();
665
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
666
+ // a record whose id also lives in the HOT window — the store would hold the ADR twice
667
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-005-dup.md'), '---\n---\n\n## AD-005 — Dup\n\nBody.\n');
668
+ const r = run(['--write-navigator', '--dry-run', '--today=2026-07-09'], root);
669
+ assert.notEqual(r.code, 0, 'a corrupt store fails the preflight');
670
+ assert.doesNotMatch(r.out.join('\n'), /DRY-RUN/, 'no go-ahead is printed for a tree that cannot converge');
671
+ });
672
+
646
673
  it('authoring a new HOT ADR then --write-navigator keeps --check green; a stale nav with NO write → exit 1, then --write-navigator fixes it', () => {
647
674
  const root = makeRoot();
648
675
  seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
@@ -342,23 +342,89 @@ const hasHiddenFence = (projectDir, deps = {}) => {
342
342
  // The retired 3-tier ADR monoliths (AD-051): their presence is the old-layout signal a consumer must
343
343
  // migrate away from via the opt-in `migrate-adr-store` mode. Stable relative paths (a status probe
344
344
  // never imports the rotator).
345
- const DECISIONS_MONOLITHS = ['docs/ai/history/decisions-archive.md', 'docs/ai/history/decisions-archive-early.md'];
346
- const ADR_STORE_DIR = 'docs/ai/adr';
345
+ // The ADR-layout probe's paths. LOCAL literals, not an import: this module backs the read-only
346
+ // `status` view and importing the rotator would drag child_process + crypto into a status read. A
347
+ // drift guard in family-registry.test.mjs pins each literal to the rotator's exported constant —
348
+ // the same baked-frozen-copy shape the gate hook uses for its velocity constants.
349
+ export const ADR_LAYOUT_PATHS = Object.freeze({
350
+ hot: 'docs/ai/decisions.md',
351
+ monoliths: Object.freeze(['docs/ai/history/decisions-archive.md', 'docs/ai/history/decisions-archive-early.md']),
352
+ store: 'docs/ai/adr',
353
+ // The DEPLOYED rotator is the discriminator: the new-scheme file names the store path, the
354
+ // pre-migration one does not. Never "has decisions.md, lacks adr/" — that shape false-positives a
355
+ // tree whose NEW rotator already reds its own gate, and every No-Node project.
356
+ rotator: 'scripts/archive-decisions.mjs',
357
+ storeMarker: 'docs/ai/adr',
358
+ });
359
+ const ENOENT = 'ENOENT';
347
360
 
348
361
  // The ADR-store layout axis: 'old' (a retired decisions-archive monolith is still on disk — needs
349
362
  // the opt-in migration), 'migrated' (the one-file-per-ADR adr/ store is in place), or 'none' (no ADR
350
363
  // substrate at all). Keys on the monolith presence, NOT on the stamp/head (Decision 6/13).
351
- const surveyAdrLayout = (dir, exists) => {
352
- const safe = (rel) => {
364
+ // The STRICT core. Absence is ENOENT and nothing else: existsSync collapses EVERY failure to false
365
+ // (EACCES included), so a strict policy layered on it would be vacuous in production and observable
366
+ // only through injected deps — and ENOTDIR means a file where a directory belongs, i.e. corruption,
367
+ // not "not there". Everything else propagates, so the advisor can degrade to a STATED SKIP instead
368
+ // of printing "flow optimal" over a layout it could not read.
369
+ export const PATH_DIR = 'dir';
370
+ export const PATH_FILE = 'file';
371
+ export const PATH_OTHER = 'other';
372
+ const probeStat = (path) => {
373
+ const st = statSync(path);
374
+ return st.isDirectory() ? PATH_DIR : st.isFile() ? PATH_FILE : PATH_OTHER;
375
+ };
376
+
377
+ export const surveyAdrLayoutStrict = (dir, deps = {}) => {
378
+ const stat = deps.statPath ?? probeStat;
379
+ const read = deps.readFile ?? readFileSync;
380
+ const absent = (err) => err && err.code === ENOENT;
381
+ // The TYPE is part of the question: a regular file named `docs/ai/adr` is not a store, and a
382
+ // directory named `archive-decisions.mjs` is not a rotator. Answering `migrated` off a name alone
383
+ // is the same fail-open this survey exists to avoid.
384
+ const typeAt = (rel) => {
353
385
  try {
354
- return exists(join(dir, rel));
355
- } catch {
356
- return false;
386
+ return stat(join(dir, rel));
387
+ } catch (err) {
388
+ if (absent(err)) return null;
389
+ throw err;
357
390
  }
358
391
  };
359
- if (DECISIONS_MONOLITHS.some(safe)) return 'old';
360
- if (safe(ADR_STORE_DIR)) return 'migrated';
361
- return 'none';
392
+ const isA = (rel, kind) => typeAt(rel) === kind;
393
+
394
+ if (ADR_LAYOUT_PATHS.monoliths.some((rel) => isA(rel, PATH_FILE))) return 'old';
395
+
396
+ // Rotator provenance is resolved BEFORE any verdict, because it also disambiguates a tree that
397
+ // HAS a store: an old-scheme rotator beside a store is not finished — `upgrade` preserves that
398
+ // script and it will write a monolith again the next time it rotates.
399
+ const rotatorScheme = (() => {
400
+ if (!isA(ADR_LAYOUT_PATHS.rotator, PATH_FILE)) return null;
401
+ try {
402
+ return String(read(join(dir, ADR_LAYOUT_PATHS.rotator), 'utf8')).includes(ADR_LAYOUT_PATHS.storeMarker) ? 'new' : 'old';
403
+ } catch (err) {
404
+ if (absent(err)) return null;
405
+ throw err;
406
+ }
407
+ })();
408
+
409
+ if (isA(ADR_LAYOUT_PATHS.store, PATH_DIR)) return rotatorScheme === 'old' ? 'old-unrotated' : 'migrated';
410
+ // No substrate at all: the crossing cannot seed a store with nothing to put in it, so the
411
+ // detector must not ask for one (this is also what the rotator's own no-substrate skip does).
412
+ if (!isA(ADR_LAYOUT_PATHS.hot, PATH_FILE)) return 'none';
413
+ // No deployed rotator: no evidence of scheme, and nothing actionable — upgrade's seed-if-missing
414
+ // owns that path, and on a No-Node project a Node tool would be a permanent unactionable nag.
415
+ // A NEW-scheme rotator with no store is cohort A: its own --check already reds on every commit
416
+ // naming --write-navigator, so it is never nagged twice.
417
+ return rotatorScheme === 'old' ? 'old-unrotated' : 'none';
418
+ };
419
+
420
+ // The LENIENT wrapper — the read-only status view must never crash, so any failure reads `none`.
421
+ // One implementation, two stated policies; the advisor uses the strict core deliberately.
422
+ const surveyAdrLayout = (dir, deps) => {
423
+ try {
424
+ return surveyAdrLayoutStrict(dir, deps);
425
+ } catch {
426
+ return 'none';
427
+ }
362
428
  };
363
429
 
364
430
  // surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
@@ -378,7 +444,7 @@ export const surveyProject = (projectDir, deps = {}) => {
378
444
  }
379
445
  })();
380
446
  const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
381
- return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, exists), hiddenFence: hasHiddenFence(dir, deps), stamps };
447
+ return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
382
448
  };
383
449
 
384
450
  // ── report ───────────────────────────────────────────────────────────────────────
@@ -615,7 +681,7 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
615
681
  dir: project.dir,
616
682
  deployed: project.deployed,
617
683
  docsAi: project.docsAiPresent,
618
- adrLayout: project.adrLayout, // 'old' | 'migrated' | 'none' — a user-safe token, never a raw path
684
+ adrLayout: project.adrLayout, // 'old' | 'old-unrotated' | 'migrated' | 'none' — a user-safe token, never a raw path
619
685
  // member + display + version only — never the internal stamp FILENAME (s.file).
620
686
  deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
621
687
  };
@@ -7,8 +7,13 @@
7
7
  // which migrates in the same step (AD-051, Decision 13).
8
8
  //
9
9
  // What it does (in order, on --apply):
10
- // 1. GATE — docs/ai must be deployed; the OLD layout must be present (a decisions-archive monolith
11
- // on disk). No monolith a stated no-op (already migrated, or a fresh new-scheme tree).
10
+ // 1. GATE — docs/ai must be deployed, and the tree must be on the OLD layout. That is TWO shapes:
11
+ // a decisions-archive monolith on disk, OR no monolith at all but a deployed
12
+ // scripts/archive-decisions.mjs that predates the store (the project simply never
13
+ // rotated). The no-monolith shape runs the crossing WITHOUT an explosion — snapshot,
14
+ // script refresh, then SEED the store — and is re-runnable to completion from any crash
15
+ // point, because a store directory alone never counts as finished. A tree with no ADR
16
+ // substrate at all, or one already finalised, is a stated no-op.
12
17
  // 2. SNAPSHOT — write a durable pre-migration snapshot (decisions.md + both monoliths + the
13
18
  // pre-refresh consumer scripts/ copies) to the project's git dir (uncommittable), with a
14
19
  // stated out-of-tree fallback off git; fail LOUD if neither base is writable (Decision 5).
@@ -38,8 +43,11 @@ import {
38
43
  WARM_REL,
39
44
  COLD_REL,
40
45
  ADR_DIR_REL,
46
+ NAV_REL,
47
+ defaultRegenerateIndex,
41
48
  runCli as runArchiveDecisions,
42
49
  } from '../references/scripts/archive-decisions.mjs';
50
+ import { surveyAdrLayoutStrict, ADR_LAYOUT_PATHS } from './family-registry.mjs';
43
51
 
44
52
  const HERE = dirname(fileURLToPath(import.meta.url));
45
53
  const KIT_ROOT = resolve(HERE, '..');
@@ -183,16 +191,181 @@ export const writeSnapshot = (cwd, refresh, stamp, deps = {}) => {
183
191
  };
184
192
 
185
193
  // Overwrite each refresh target with the kit canon, atomically, preserving the canon's exec bit.
194
+ //
195
+ // ORDER MATTERS: the rotation script is what the layout discriminator reads, so it is written LAST.
196
+ // A crash partway through a refresh that had already flipped it would otherwise leave a tree that
197
+ // LOOKS refreshed while other scripts are still the old copies — and a resume, keying on that same
198
+ // script, would skip them forever. Written last, an interrupted refresh always re-plans in full.
199
+ const DISCRIMINATOR_SCRIPT = ADR_LAYOUT_PATHS.rotator.split('/').pop();
200
+ const refreshOrder = (refresh) => [
201
+ ...refresh.filter((r) => r.name !== DISCRIMINATOR_SCRIPT),
202
+ ...refresh.filter((r) => r.name === DISCRIMINATOR_SCRIPT),
203
+ ];
204
+
186
205
  const applyScriptRefresh = (cwd, refresh, deps = {}) => {
187
206
  const read = deps.read ?? readFileSync;
188
207
  const chmod = deps.chmod ?? chmodSync;
189
208
  const stat = deps.stat ?? statSync;
190
- for (const { canon, dst, name } of refresh) {
209
+ for (const { canon, dst, name } of refreshOrder(refresh)) {
191
210
  writeContainedFileAtomic(cwd, dst, read(canon, 'utf8'), deps, { stop, label: `${CONSUMER_SCRIPTS_REL}/${name}` });
192
211
  chmod(dst, stat(canon).mode & 0o777); // the exec bit is the git-tracked axis the mirror guard pins
193
212
  }
194
213
  };
195
214
 
215
+ // ── the no-monolith crossing ─────────────────────────────────────────────────────
216
+ //
217
+ // A consumer on the RETIRED scheme that never rotated far enough to produce a monolith used to read
218
+ // "a fresh new-scheme tree" here and be sent away. The discriminator is the deployed rotation
219
+ // script's own provenance (family-registry.mjs), never "has decisions.md, lacks adr/".
220
+ //
221
+ // Re-entry is decided by what is FINISHED, never by one existence bit: the store directory existing
222
+ // does not prove the navigator was written or the index regenerated, so a crash there must not turn
223
+ // the next --apply into a no-op. Every write below is individually idempotent, which is why this
224
+ // needs no resume ledger.
225
+
226
+ // The crossing is COMPLETE when the navigator exists, the tree's own gate passes, AND the index the
227
+ // crossing regenerates is fresh. The index is part of the criterion because it is a real output of
228
+ // the crossing that `--check` never looks at: a crash (or a failed regeneration) between the
229
+ // navigator write and the index left a tree that reported "already migrated" on the retry and never
230
+ // repaired the index. An unreachable index generator is NOT treated as fresh — the crossing re-runs
231
+ // and fails loudly again, which is the honest outcome for a broken generator.
232
+ const INDEX_GENERATOR = join(KIT_SCRIPTS, 'check-docs-size.mjs');
233
+ const isIndexFresh = (cwd, deps = {}) => {
234
+ const spawn = deps.spawnSync ?? spawnSync;
235
+ const r = spawn(process.execPath, [INDEX_GENERATOR, '--check-index', `--root=${cwd}`], { encoding: 'utf8' });
236
+ return !r.error && r.status === 0;
237
+ };
238
+
239
+ // The layout verdict leads, and it is the SAME verdict the status line and the advisor read: an
240
+ // old-scheme rotator beside a finished store still answers `old-unrotated`, so treating that tree as
241
+ // done would leave the signal permanently lit with nothing able to clear it.
242
+ const isFinalised = (cwd, runMigrate, deps = {}) =>
243
+ surveyAdrLayoutStrict(cwd, deps) === 'migrated' &&
244
+ substratePresent(join(cwd, NAV_REL), deps) &&
245
+ runMigrate(['--check'], { root: cwd, log: () => {}, logError: () => {} }) === EXIT_OK &&
246
+ isIndexFresh(cwd, deps);
247
+
248
+ // `existsSync` answers false for EVERY failure, EACCES included — so asking it whether the substrate
249
+ // is there would turn an UNREADABLE tree into a confident "nothing to migrate", exit 0. Absence is
250
+ // ENOENT and nothing else; anything else is surfaced, never swallowed. Same policy the layout survey
251
+ // already enforces, now applied where the tool acts on it.
252
+ const substratePresent = (path, deps = {}) => {
253
+ const stat = deps.statSync ?? statSync;
254
+ try {
255
+ stat(path);
256
+ return true;
257
+ } catch (err) {
258
+ if (err && err.code === 'ENOENT') return false;
259
+ throw stop(`cannot read ${path} (${err && err.message}) — refusing to report on a tree it could not inspect`);
260
+ }
261
+ };
262
+
263
+ const crossWithoutMonolith = (cwd, args, stamp, { log, error, runMigrate, deps }) => {
264
+ const hasStore = substratePresent(join(cwd, ADR_DIR_REL), deps);
265
+ const hasHot = substratePresent(join(cwd, HOT_REL), deps);
266
+
267
+ if (!hasHot && !hasStore) {
268
+ // NOT "a fresh new-scheme tree" — this tree may well be old-scheme; it simply has no ADR
269
+ // substrate for the crossing to carry across, which is exactly the rotator's own skip.
270
+ log(`[migrate-adr-store] nothing to migrate — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}/) and no legacy monolith.`);
271
+ return EXIT_OK;
272
+ }
273
+ if (hasStore && isFinalised(cwd, runMigrate, deps)) {
274
+ log('[migrate-adr-store] already migrated — the one-file-per-ADR store is in place and its gate is green; nothing to do.');
275
+ return EXIT_OK;
276
+ }
277
+ if (!hasStore && surveyAdrLayoutStrict(cwd, deps) === 'none' && !substratePresent(join(cwd, ADR_LAYOUT_PATHS.rotator), deps)) {
278
+ // Nothing to refresh (the refresh is directional — it never ADDS a script) and nothing to
279
+ // maintain a store we might seed. The normal upgrade owns seeding the pair.
280
+ log('[migrate-adr-store] nothing to migrate — no deployed rotation script; run the normal upgrade first (it seeds the ADR enforcement pair).');
281
+ return EXIT_OK;
282
+ }
283
+
284
+ const refresh = planScriptRefresh(cwd, deps);
285
+ const drifted = refresh.filter((r) => r.differs);
286
+ // The read-only preflight: the SAME parse / half-migrated guard / store-integrity check the seed
287
+ // itself runs, stopping before every write. Without it a dry-run could green-light an apply that
288
+ // writes the store and only then discovers it cannot converge.
289
+ const preflight = (logError) => runMigrate(['--write-navigator', '--dry-run'], { root: cwd, log: () => {}, logError });
290
+
291
+ if (!args.apply) {
292
+ const preview = resolveSnapshotDir(cwd, stamp, deps);
293
+ // Three attempts to SUMMARISE why this tree needs the crossing produced three wrong sentences —
294
+ // each true of the common case and false of a state this arm deliberately supports. So the
295
+ // summary is gone: the preview states the two facts it actually knows, one per line, and the
296
+ // reader draws the conclusion. Nothing here can drift out of step with the tree, because nothing
297
+ // here is an inference. (Which scripts are stale is already reported by the refresh line below —
298
+ // never re-asserted here.)
299
+ const layout = surveyAdrLayoutStrict(cwd, deps);
300
+ const rotatorFact = layout === 'old-unrotated'
301
+ ? `predates the one-file-per-ADR store`
302
+ : substratePresent(join(cwd, ADR_LAYOUT_PATHS.rotator), deps)
303
+ ? `already names the store`
304
+ : `not deployed`; // and NOT "nothing to refresh" — a sibling script may still need one
305
+ log('[migrate-adr-store] --dry-run — no files will be changed. Planned crossing (no legacy monolith to retire):');
306
+ log(` deployed ${ADR_LAYOUT_PATHS.rotator}: ${rotatorFact}`);
307
+ log(` ${ADR_DIR_REL}/: ${hasStore ? 'present, but the crossing has not been completed' : 'absent'}`);
308
+ log(` snapshot → ${preview.dir ? `${preview.dir} (${preview.viaGitDir ? 'git dir' : 'out-of-tree fallback'})` : 'NONE — no out-of-tree location; run inside a git repo (apply would refuse otherwise)'}`);
309
+ log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
310
+ log(` then seed the store: create ${ADR_DIR_REL}/, write ${NAV_REL} and regenerate docs/ai/index.md`);
311
+ const code = preflight((m) => error(` ${m}`));
312
+ if (code !== EXIT_OK) {
313
+ throw stop(`the tree cannot be seeded as it stands (exit ${code}) — NOT safe to --apply; fix the reported problem, then re-run.`);
314
+ }
315
+ if (preview.dir === null) {
316
+ throw stop('no out-of-tree snapshot location — --apply would refuse; run inside a git repo (or point the fallback outside the project), then re-run.');
317
+ }
318
+ log(' index regeneration is verified at --apply time (a dry-run cannot observe it without writing).');
319
+ log('Run `/agent-workflow-kit migrate-adr-store` again with --apply to perform it (it never commits).');
320
+ return EXIT_OK;
321
+ }
322
+
323
+ const pre = preflight(error);
324
+ if (pre !== EXIT_OK) {
325
+ throw stop(`the tree cannot be seeded as it stands (preflight exit ${pre}) — refusing to touch the tree; fix the reported problem, then re-run.`);
326
+ }
327
+
328
+ const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
329
+ // The FULL refresh is re-planned and re-applied on every entry, so an interrupted one always
330
+ // completes; the discriminator script is written last (see refreshOrder).
331
+ applyScriptRefresh(cwd, refresh, deps);
332
+
333
+ // Capture the index-regeneration verdict instead of matching log prose: the rotator logs a failed
334
+ // regeneration and still returns 0, so "the gates are green" would not mean the index is fresh.
335
+ const regen = { ok: true, detail: '' };
336
+ const seed = runMigrate(['--write-navigator'], {
337
+ root: cwd,
338
+ log,
339
+ logError: error,
340
+ regenerateIndex: (root, today, d) => {
341
+ const r = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today, d);
342
+ regen.ok = r.ok;
343
+ regen.detail = r.detail;
344
+ return r;
345
+ },
346
+ });
347
+ if (seed !== EXIT_OK) {
348
+ throw stop(`seeding the ADR store failed (exit ${seed}) — the pre-crossing snapshot is at ${snapshot.dir}; resolve the reported problem and re-run (the crossing is idempotent).`);
349
+ }
350
+ if (!regen.ok) {
351
+ throw stop(`the ADR store was seeded but docs/ai/index.md was NOT regenerated (${regen.detail}) — the pre-crossing snapshot is at ${snapshot.dir}; fix the index generator and re-run (the crossing is idempotent).`);
352
+ }
353
+ const verify = runMigrate(['--check'], { root: cwd, log: () => {}, logError: error });
354
+ if (verify !== EXIT_OK) {
355
+ throw stop(`the ADR store was seeded but its own gate does not pass (exit ${verify}) — the pre-crossing snapshot is at ${snapshot.dir}; resolve the reported problem and re-run (the crossing is idempotent).`);
356
+ }
357
+
358
+ // States what this run DID, never what the tree was before it: the same arm completes an
359
+ // interrupted crossing whose scripts were already current, which no "old-scheme" claim covers.
360
+ log('[migrate-adr-store] crossing complete — the one-file-per-ADR store is in place (no legacy monolith was present):');
361
+ log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'}, ${snapshot.fileCount} file(s))`);
362
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version`);
363
+ log(` seeded ${ADR_DIR_REL}/ with ${NAV_REL} and regenerated docs/ai/index.md`);
364
+ log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
365
+ log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
366
+ return EXIT_OK;
367
+ };
368
+
196
369
  export const main = (argv = process.argv.slice(2), deps = {}) => {
197
370
  const log = deps.log ?? console.log;
198
371
  const error = deps.error ?? console.error;
@@ -209,11 +382,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
209
382
 
210
383
  const monoliths = monolithsPresent(cwd);
211
384
  if (monoliths.length === 0) {
212
- const migrated = existsSync(join(cwd, ADR_DIR_REL));
213
- log(migrated
214
- ? '[migrate-adr-store] already migrated — the one-file-per-ADR store is in place (no legacy monolith); nothing to do.'
215
- : '[migrate-adr-store] nothing to migrate — no legacy decisions-archive monolith found (a fresh new-scheme tree).');
216
- return EXIT_OK;
385
+ return crossWithoutMonolith(cwd, args, stamp, { log, error, runMigrate, deps });
217
386
  }
218
387
 
219
388
  const refresh = planScriptRefresh(cwd, deps);
@@ -51,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
51
51
  import { deriveDoctorPlan } from './autonomy-doctor.mjs';
52
52
  import { detectBackends, findOnPath } from './detect-backends.mjs';
53
53
  import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
54
- import { surveyFamily, surveyGateHook } from './family-registry.mjs';
54
+ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
55
55
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
56
  import { shellQuoteArg } from './review-state.mjs';
57
57
  import { isFinalCapableDeclaration } from './run-gates.mjs';
@@ -108,6 +108,7 @@ export const SEVERITIES = Object.freeze({
108
108
  'state-block': SEVERITY_OPTIONAL,
109
109
  agents: SEVERITY_OPTIONAL,
110
110
  'family-freshness': SEVERITY_ATTENTION,
111
+ 'adr-store-migration': SEVERITY_ATTENTION,
111
112
  'sandbox-masks': SEVERITY_OPTIONAL,
112
113
  'sandbox-lane': SEVERITY_OPTIONAL,
113
114
  'worktrees-dir': SEVERITY_OPTIONAL,
@@ -165,6 +166,7 @@ export const WHATS = Object.freeze({
165
166
  'state-block': 'nothing checks the closing state block — a turn that ends on «nothing needed from you», or on a promise it never started, passes unseen',
166
167
  agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
167
168
  'family-freshness': '{parts}',
169
+ 'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
168
170
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
169
171
  'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
170
172
  'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
@@ -218,6 +220,7 @@ export const BENEFITS = Object.freeze({
218
220
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
219
221
  agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
220
222
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
223
+ 'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
221
224
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
222
225
  'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
223
226
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
@@ -254,6 +257,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
254
257
  { id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
255
258
  { id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
256
259
  { id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
260
+ { id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
257
261
  { id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
258
262
  // The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
259
263
  // which is why the review-recipe benefit is worded for either slot rather than for review alone.
@@ -788,7 +792,7 @@ const readReadLaneToggle = (root, deps) => {
788
792
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
789
793
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
790
794
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
791
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir']);
795
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration']);
792
796
 
793
797
  const probeSandboxLane = ({ root, deps, add, skip }) => {
794
798
  try {
@@ -965,6 +969,40 @@ const probeWorktreesDir = ({ root, deps, add, skip }) => {
965
969
  }
966
970
  };
967
971
 
972
+ // The ADR-store crossing. Until now this mode declared it had NO advisor capability, on the argument
973
+ // that status and upgrade already report the old layout — but they only reported the MONOLITH shape,
974
+ // so a project whose deployed rotator merely predates the store was told nothing by anything.
975
+ //
976
+ // Honest scope: the advisor is the deterministic section every `upgrade` run ends with, so this is
977
+ // NOT a new door for someone who never runs status or upgrade — it MECHANIZES the upgrade door.
978
+ //
979
+ // It reads the STRICT layout survey deliberately: the lenient one turns every fs failure into
980
+ // "no ADR layout here", which would print «flow optimal» over a layout the probe could not read. A
981
+ // failure must become a STATED SKIP, never an absence.
982
+ // Each shape states a fact about THIS tree that holds whether or not a store directory exists —
983
+ // `old-unrotated` also covers a tree whose store is already there but whose rotation script is not,
984
+ // and saying "the store is not in place" there would be false.
985
+ const ADR_LAYOUT_SHAPES = Object.freeze({
986
+ old: 'a legacy archive file is still on disk and must be exploded into the per-file store',
987
+ 'old-unrotated': 'the deployed rotation script predates the store and keeps writing the retired layout',
988
+ });
989
+ export const probeAdrStore = ({ root, deps, add, skip }) => {
990
+ try {
991
+ const shape = ADR_LAYOUT_SHAPES[surveyAdrLayoutStrict(root, deps)];
992
+ if (!shape) return; // migrated, or no ADR substrate at all — nothing to offer
993
+ // HAND-APPLY, not the standard lane: the consent flow executes the apply slot against the
994
+ // confirmation given BEFORE the preview, and this crossing requires informed consent AFTER its
995
+ // dry-run. A runnable one-liner here would auto-run a tree-mutating migration on stale consent.
996
+ add(
997
+ 'adr-store-migration',
998
+ fillTemplate(WHATS['adr-store-migration'], { shape }),
999
+ `HAND-APPLY: node ${q(toolPath('migrate-adr-store.mjs'))} --dry-run --cwd ${q(root)} — then re-run with --apply ONLY after showing the plan and getting fresh consent`,
1000
+ );
1001
+ } catch (err) {
1002
+ skip('adr-store-migration', err);
1003
+ }
1004
+ };
1005
+
968
1006
  // ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
969
1007
  const PROBES = Object.freeze([
970
1008
  probeVelocityItems,
@@ -977,6 +1015,7 @@ const PROBES = Object.freeze([
977
1015
  probeStateBlockHook,
978
1016
  probeCheapAgents,
979
1017
  probeFamilyFreshness,
1018
+ probeAdrStore,
980
1019
  probeMasksItem,
981
1020
  probeSandboxLane,
982
1021
  probeWorktreesDir,
@@ -15,6 +15,10 @@ const READINESS_COL = 14;
15
15
  const STAMP_COL = 26;
16
16
  const SETTINGS_COL = 14;
17
17
 
18
+ // The ADR-layout tokens that carry an action for the user. Kept as a list, not a chain of equality
19
+ // checks, so a future token joins the render by joining this line.
20
+ const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
21
+
18
22
  const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
19
23
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
20
24
  export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
@@ -86,8 +90,10 @@ const renderProject = (vm, { color }) => {
86
90
  }
87
91
  for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
88
92
  lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
89
- // Only the actionable 'old' layout renders a line — a migrated/none store needs no note (AD-051).
90
- if (p.adrLayout === 'old') {
93
+ // Only an ACTIONABLE layout renders a line — a migrated/none store needs no note (AD-051). Both
94
+ // actionable tokens render the SAME line: 'old' (a monolith on disk) and 'old-unrotated' (an
95
+ // old-scheme rotator that never rotated) differ only in the discriminator, never in the remedy.
96
+ if (ACTIONABLE_ADR_LAYOUTS.includes(p.adrLayout)) {
91
97
  lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
92
98
  }
93
99
  if (p.visibility) {