@sabaiway/agent-workflow-kit 7.3.0 → 7.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,79 @@ 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
+ ## 7.5.0 — the reader pair joins the known-prior refresh lane; the mirrored reader gains the structure verdict (AD-114)
8
+
9
+ Slice 2b's `spec-check` will need a structural read of a spec document, and the reader is the ONE
10
+ definition of well-formed — so the memory canon's reader (mirrored here byte-identically) now
11
+ returns an additive `structure` field. Because the bundled reader bytes move, the reader pair joins
12
+ the AD-113 known-prior refresh lane in the SAME release: without catalog rows for the outgoing
13
+ bodies, every 4.6.x deployment would classify `custom` against the new bundle and be stranded out of
14
+ the store seed forever.
15
+
16
+ - **`tools/script-priors.mjs` — the catalog spans both pairs.** `PRIOR_FILES` is now the FULL
17
+ refreshable catalog (checker pair + reader pair); `SCRIPT_PRIORS` appends the outgoing
18
+ 4.6.0..4.6.1 reader-pair bodies (rows 4 -> 6, fixtures under `4.6.0/`, the literal frozen prefix
19
+ extended with its first four rows byte-identical). The maintenance rule generalizes with it: a
20
+ release that changes ANY catalog member appends the outgoing body's row.
21
+ - **`tools/ensure-specs.mjs` — the pairs decoupled, the reader refreshes.** `CHECKER_PAIR` was an
22
+ alias of `PRIOR_FILES`, so the widened catalog would have double-surveyed the readers: the op now
23
+ owns exported `READER_PAIR`/`CHECKER_PAIR` (the suite pins pairs-union === catalog domain), and
24
+ `decideWrites` admits a reader REFRESH — reader seeds stay unconditional create-only (a custom
25
+ neighbour never blocks them); a refresh runs only inside a pair whose every file is
26
+ current | prior | absent; one custom file withholds its pair's REFRESHES and every downstream
27
+ write, DIRECTIONALLY (a prior reader still refreshes beside a custom checker, which holds back
28
+ only its own pair and the store). Order reader -> checker -> store unchanged; lines and tokens
29
+ unchanged (`refreshed` now covers either pair).
30
+ - **Prose caught up at every live surface** — `references/modes/upgrade.md` (the configs summary
31
+ and the spec-layer paragraph), the `ensure-configs` header + help, the vocabulary comment, the
32
+ package-content ledger, and the memory twin's refresh-lane sentence all name BOTH pairs.
33
+ - **Proof.** The state table gains the reader-prior rows, the directional-withhold pin and dry-run
34
+ rows; the E2E upgrades a real 4.6.x deployment (reader on the 4.6.0 bodies, checker current)
35
+ whole under the real installed pre-commit hook, and the whole-chain prior+prior cell stays
36
+ table-covered. Red-first throughout — three red-proofs minted on frozen test bytes against the
37
+ pre-fix modules. Tarball count unchanged at 247.
38
+
39
+ ## 7.4.0 — the spec layer reaches existing deployments: known-prior refresh, seed-behind-checker, the `specs` ensure (AD-113)
40
+
41
+ 7.3.0 made a FRESH bootstrap deliver the feature-spec layer whole; this release gives every EXISTING
42
+ deployment the same layer on an equal-head upgrade, with no lineage bump. The shaping fact: the kit's
43
+ `index` ensure drives the BUNDLED navigator generator, which collapses `docs/ai/specs/` into one
44
+ counted row, while the project's pre-commit hook runs ITS deployed `check-docs-size.mjs` — a store
45
+ root seeded behind an older checker renders row by row and reds `--check-index` on the next commit.
46
+
47
+ - **`tools/script-priors.mjs` — a deployed script is refreshed only when its bytes are a body a
48
+ release shipped.** An append-only catalog of sha256 digests of every `check-docs-size.mjs` body
49
+ since memory 4.0.0 (three checker bodies + the one test body); `classifyDeployedScript` answers
50
+ `current` · `prior` · `custom`. The fixtures are the shipped bytes verbatim
51
+ (`test/fixtures/script-priors/<firstShipped>/<file>.txt`, outside the test glob, the source-size
52
+ scope and `files[]`); the test holds catalog and fixtures equal BOTH ways and pins the rows as a
53
+ literal immutable prefix — a count alone would let a row and its fixture be replaced together,
54
+ turning a shipped body into `custom` on every deployment that carries it. No other deployed script
55
+ is refreshed; a custom body is preserved verbatim and said so.
56
+ - **`tools/ensure-specs.mjs` — the sixth ensure op, and the store root seeds ONLY behind a checker
57
+ pair proven current after the run.** `specs` sits after `scripts` and before `index`. It surveys
58
+ the four script files and the store root into current | prior | custom | absent | wrong-kind
59
+ BEFORE writing and admits each write through ONE conjunction: an absent reader file seeds
60
+ create-only (nothing depends on it); the checker pair refreshes or seeds only when BOTH reader
61
+ files are current after the seed and NEITHER checker file is custom; the store root only when the
62
+ checker pair is then current. A custom file preserves itself AND withholds every dependent write,
63
+ and the line names the remedy (copy the pair from `references/scripts/` by hand, re-run). A seed
64
+ that loses the create-only race is re-PROVEN — the appeared file must classify `current`, else
65
+ `race-unresolved` and nothing further; every line composes from the FATE of its write (written ·
66
+ stood · would · withheld · stopped); the run token follows one precedence — `seeded` >
67
+ `refreshed` > `customized-preserved` > `already-present`. The no-Node skip stays; there is NO
68
+ legacy-ADR early return (the store has no ADR-layout dependency).
69
+ - **Vocabulary + wiring.** The closed vocabulary gains the relayed pair `refreshed` /
70
+ `would-refresh` (doc-parity binds the `upgrade.md` step-3 line, the run-list registry mirrors the
71
+ op, the composed-lines guard witnesses the tokens); `ENSURE_IMPLEMENTATIONS` moved from
72
+ `ensure-ops.mjs` to `ensure-configs.mjs`, its only consumer, keeping the tools graph acyclic;
73
+ `--only specs` works and the CLI counts 6 slots.
74
+ - **Proof.** The state table runs as a table-driven test over every cell with injected fs
75
+ (`--dry-run` emits only `would-*`); the integration E2E upgrades a 4.5.4-byte deployment with
76
+ `--reconcile` and the real installed hook — seeded/refreshed on the plain and legacy-ADR layouts,
77
+ and the custom-checker variant keeps the checker verbatim, seeds NO store root and prints
78
+ `customized-preserved`. Package-content pins 245 -> 247.
79
+
7
80
  ## 7.3.0 — the spec layer's delivery half: hidden from day one, mirrored, bootstrapped (AD-112)
8
81
 
9
82
  The memory substrate ships the feature-spec layer in 4.6.0 and the engine its canon in 3.3.0; this
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: '7.3.0'
6
+ version: '7.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": "7.3.0",
6
+ "version": "7.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": "7.3.0",
3
+ "version": "7.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",
@@ -10,7 +10,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
10
10
 
11
11
  1. `pointers` — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md` → per pointer: added · already present · skipped (reported) · a hard STOP.
12
12
  2. `footprint` — `node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile --dry-run` → visibility: visible · ambiguous · hidden — consent (conditional): ambiguous → ask which it is BEFORE anything; hidden → the conditional re-run without `--dry-run` (its surfaced paths ask per bootstrap step 9).
13
- 3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
13
+ 3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
14
14
  4. `gates-migration` — `node ${CLAUDE_SKILL_DIR}/references/scripts/migrate-gates.mjs --kit-tools ${CLAUDE_SKILL_DIR}/tools --cwd <project>` → the preview plan · INERT checker · CUSTOMIZED entries, each named — consent: apply only on an explicit yes, re-run with `--apply`.
15
15
  5. `bridges` — `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` → per-bridge lines: refreshed · already current · skipped — with its stated reason (not placed / newer than the bundle / unsupported host) · `skipped-readonly` · could not refresh.
16
16
  6. `lens` — `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md` → per section: refreshed · already current · custom edit preserved · file absent / engine too old — skipped · over the line cap — refused · section absent — noted · a fully absent/invalid engine → hard STOP.
@@ -30,7 +30,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
30
30
 
31
31
  **`footprint` — hidden-mode footprint reconcile (D9 / AD-014).** A deployment does not record whether it chose `hidden`, so first **infer visibility** — the dry-run writes **zero bytes** and reports one of — **visible** (the entry point is tracked) → nothing to do; **ambiguous** (untracked but not ignored — could be a fresh uncommitted repo, or a hide that broke) → **ASK** the user which it is, never guess; **hidden** → re-run without `--dry-run` to migrate any older **machine-global** hide to the **project-local** `.git/info/exclude` (one managed block; folds in the legacy `.claude/skills/` line), idempotently (a clean re-run is zero-diff). Handle its surfaced paths exactly as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) (already-committed → show `git rm --cached`, ask before `--include`; generic-name present file → ask; **leftover machine-wide ignore block → ASK before `--remove-global`**, default keep + report). No Node on the agent host / Windows → as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`). This runs on **every** hidden upgrade, like the methodology slot — no lineage-head bump, no migration file.
32
32
 
33
- **`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all five** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the two refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all five reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact, inherently outside the docs cap-validator).
33
+ **`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all six** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · spec layer · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the three refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped), the spec-layer reader and checker pairs (below) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all six reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact / a seeded store root, inherently outside the docs cap-validator).
34
34
 
35
35
  **What the orchestration-config ensure does.** `docs/ai/orchestration.json` must exist **and its onboarding note must be current**: created from the canonical seed if missing; if it already exists, **every activity/slot the user set is preserved** and ONLY the `_README` note is refreshed, and only when the existing one still matches a known prior canonical — the tested `refreshIfCanonical` / `refreshReadme` in `tools/orchestration-config.mjs` is the source of truth for that decision (it normalizes CRLF/whitespace before comparing; a *customized* `_README` is preserved verbatim → `customized-preserved`; a *malformed* existing config is **preserved untouched + LOUD** → `malformed-preserved`, never clobbered and never silently skipped). The current note points at `/agent-workflow-kit set-recipe`. **Kit-owned:** in the **delegated** path memory only seeds/preserves the file (memory upgrade step 2) and this ensure applies the `_README` refresh; in the **fallback** path it does both. (Memory stays standalone.)
36
36
 
@@ -42,6 +42,8 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
42
42
 
43
43
  **What the navigator ensure does.** `docs/ai/index.md` is the always-loaded navigator the entry point declares, and it is GENERATED — no template ships it, so a deployment that never ran the generator boots from a broken entry point (and, on a Node project, carries a pre-commit hook that fails its own index check). The ensure runs the bundled generator's finalizer and reports `regenerated` (it was missing or stale — it was written) or `already-current` (nothing written). It never skips a No-Node project: the generator runs from `${CLAUDE_SKILL_DIR}/references/scripts/` on the agent host, not from the project's `scripts/`. **Its position in the run-list is EARLY and therefore NOT authoritative** — `lens` (and, on the migrated path, steps 6–7) still change `docs/ai` afterwards — so the authoritative run is the LATE `--only index` rung documented at both exits; the early one is idempotent and costs at most an `already-current` line.
44
44
 
45
+ **What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node` without a `package.json`; every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it.
46
+
45
47
  **`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. This is the ONLY gates.json writer at upgrade (the consented FILL preview runs at init).
46
48
 
47
49
  **`bridges` — placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
@@ -86,7 +88,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
86
88
  flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
87
89
  **The LATE navigator finalizer — the AUTHORITATIVE run, after the last `docs/ai` mutation.** `lens` above rewrites `docs/ai/agent_rules.md`, so the step-3 index ensure is already behind by the time the run-list ends. Re-run that ONE op here, before the step-4 report: `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>`. Relay **this** line in the report (it supersedes the early one; an untouched tree reports `already-current`, a failure STOPs the upgrade like any other ensure). On the migrated path the same rung runs again at the END of step 7 — after the migrations, before the step-8 re-stamp.
88
90
  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:
89
- - **Report step 3's outcome in plain language** — for **each** `pointers` slot (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); the **five project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts, navigator index — their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`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** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`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`).
91
+ - **Report step 3's outcome in plain language** — for **each** `pointers` slot (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); the **six project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts, spec layer, navigator index — their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`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** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`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`).
90
92
  - **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*.
91
93
  - **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).
92
94
  - **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`.
@@ -95,4 +97,4 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
95
97
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
96
98
  6. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0 deployment) — ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks (its own "Ask the user" step is the standalone fallback); a single missing block keeps its single ask (step 7). Then apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
97
99
  7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.) **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have added or changed `docs/ai` files, so run the one op again — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>` — and relay THAT line in the step-8 report (idempotent: an untouched tree reports `already-current`; a failure STOPs this upgrade like any other ensure).
98
- 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **five project-configuration ensure** (`configs`) lines — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** outcomes (same outcome sets as step 4), and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: 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, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent-gated applies (per picked item: posture note → explicit confirm → run EXACTLY the rendered one-liner; a HAND-APPLY item is never run by you — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
100
+ 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **six project-configuration ensure** (`configs`) lines — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** outcomes (same outcome sets as step 4), and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: 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, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent-gated applies (per picked item: posture note → explicit confirm → run EXACTLY the rendered one-liner; a HAND-APPLY item is never run by you — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
@@ -310,13 +310,45 @@ const checkParts = (parsed, at, errors) => {
310
310
  if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
311
311
  };
312
312
 
313
- // The verdict: { kind, status, revision, errors: [{ rule, message }], warnings: [{ rule, message }] }.
314
- // Errors are collected past the first defect wherever later checks stay meaningful; a missing or
315
- // defective frontmatter and an unknown kind each end the read, because no shape can be judged without them.
313
+ // The structure verdict (additive, slice 2a): the DETERMINISTIC extraction of what parsed, per
314
+ // section, targets VERBATIM as written (`./x.md` and `./x/index.md` stay distinct strings). The
315
+ // grammar is per line for scenarios/children/parts a malformed line is simply absent while the
316
+ // module is a conjunction (ONE `dir/` root or an all-file list): prose, a refused path, a mix or
317
+ // `*(empty)*` extracts null. `## Links` is free prose and is never extracted.
318
+ const scenarioEntry = (line) => {
319
+ const scenario = parseScenario(line);
320
+ if (scenario === null) return null;
321
+ return { ordinal: scenario.n, binding: scenario.bound ? { file: scenario.path, marker: scenario.marker } : null };
322
+ };
323
+ const linkEntry = (re) => (line) => {
324
+ const m = line.match(re);
325
+ return m && SLUG_RE.test(m[2]) ? { name: m[1], target: `./${m[2]}${m[3] ?? ''}.md` } : null;
326
+ };
327
+ const entriesOf = (parsed, heading, entryOf) =>
328
+ contentOf(sectionLines(parsed, heading) ?? []).map(entryOf).filter((entry) => entry !== null);
329
+ const extractModule = (parsed) => {
330
+ const content = contentOf(sectionLines(parsed, '## Module') ?? []);
331
+ const paths = bulletsOf(content);
332
+ if (paths.length === 0 || content.some((line) => !isBullet(line))) return null;
333
+ const kinds = paths.map(classifyPath);
334
+ if (paths.length === 1 && kinds[0] === 'dir') return { form: 'root', paths };
335
+ return kinds.every((kind) => kind === 'file') ? { form: 'fileSet', paths } : null;
336
+ };
337
+ const extractStructure = (parsed) => ({
338
+ scenarios: entriesOf(parsed, '## Scenarios', scenarioEntry),
339
+ children: entriesOf(parsed, '## Children', linkEntry(CHILD_LINK_RE)),
340
+ parts: entriesOf(parsed, '## Parts', linkEntry(PART_LINK_RE)),
341
+ module: extractModule(parsed),
342
+ });
343
+
344
+ // The verdict: { kind, status, revision, structure, errors: [{ rule, message }], warnings: [{ rule,
345
+ // message }] }. Errors are collected past the first defect wherever later checks stay meaningful; a
346
+ // missing or defective frontmatter and an unknown kind each end the read (structure stays null),
347
+ // because no shape can be judged without them.
316
348
  export const readSpecDocument = (text, rel) => {
317
349
  const errors = [];
318
350
  const warnings = [];
319
- const verdict = (kind, status, revision) => ({ kind, status, revision, errors, warnings });
351
+ const verdict = (kind, status, revision, structure = null) => ({ kind, status, revision, structure, errors, warnings });
320
352
  const front = parseFrontmatter(text.replace(/\r\n/g, '\n'));
321
353
  if (front === null) {
322
354
  errors.push({ rule: 'frontmatter', message: 'missing YAML frontmatter' });
@@ -351,5 +383,5 @@ export const readSpecDocument = (text, rel) => {
351
383
  }
352
384
  const status = kind === 'spec' ? fields.status ?? null : null;
353
385
  const revision = kind === 'spec' && REVISION_RE.test(fields.revision ?? '') ? Number(fields.revision) : null;
354
- return verdict(kind, status, revision);
386
+ return verdict(kind, status, revision, extractStructure(parsed));
355
387
  };
@@ -194,6 +194,73 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
194
194
  });
195
195
  });
196
196
 
197
+ describe('readSpecDocument — the structure verdict (additive, slice 2a)', () => {
198
+ const structureOf = (text, rel) => readSpecDocument(text, rel).structure;
199
+ const BOTH_SCENARIOS = [
200
+ { ordinal: 1, binding: { file: 'test/login.test.mjs', marker: 'spec:login/S1' } },
201
+ { ordinal: 2, binding: null },
202
+ ];
203
+
204
+ it('a flat spec extracts scenarios (bound + unbound) and its one dir/ module root', () => {
205
+ expect(structureOf(specDoc(), 'login.md')).toEqual({
206
+ scenarios: BOTH_SCENARIOS,
207
+ children: [],
208
+ parts: [],
209
+ module: { form: 'root', paths: ['src/login/'] },
210
+ });
211
+ });
212
+
213
+ it('a promoted root extracts parts and a fileSet module; ## Links stays free prose, never extracted', () => {
214
+ const text = specDoc({ module: '- src/a.mjs\n- src/b.mjs', extra: '\n## Parts\n\n- [sessions](./sessions.md)\n\n## Links\n\n- [[AD-112]]\n' });
215
+ expect(structureOf(text, 'auth/login/index.md')).toEqual({
216
+ scenarios: BOTH_SCENARIOS,
217
+ children: [],
218
+ parts: [{ name: 'sessions', target: './sessions.md' }],
219
+ module: { form: 'fileSet', paths: ['src/a.mjs', 'src/b.mjs'] },
220
+ });
221
+ });
222
+
223
+ it('an index extracts children with VERBATIM targets — ./x.md and ./x/index.md stay distinct strings', () => {
224
+ expect(structureOf(indexDoc(), 'auth/index.md')).toEqual({
225
+ scenarios: [],
226
+ children: [{ name: 'login', target: './login.md' }, { name: 'billing', target: './billing/index.md' }],
227
+ parts: [],
228
+ module: null,
229
+ });
230
+ });
231
+
232
+ it('a part extracts the empty structure; a retired *(empty)* module extracts null on a CLEAN document', () => {
233
+ expect(structureOf(partDoc(), 'auth/login/sessions.md')).toEqual({ scenarios: [], children: [], parts: [], module: null });
234
+ const retired = specDoc({ fields: { status: 'retired' }, module: '*(empty)*', scenarios: ['- S1 gone :: unbound'] });
235
+ expect(structureOf(retired, 'login.md').module).toBeNull();
236
+ });
237
+
238
+ it('EVERY early refusal reads structure null: missing frontmatter, a frontmatter defect, an unknown or absent kind', () => {
239
+ expect(structureOf('# Spec: Login\n', 'login.md')).toBeNull();
240
+ expect(structureOf(specDoc({ fields: { priority: 'high' } }), 'login.md')).toBeNull();
241
+ expect(structureOf(specDoc({ fields: { kind: 'feature' } }), 'login.md')).toBeNull();
242
+ expect(structureOf(specDoc({ drop: ['kind'] }), 'login.md')).toBeNull();
243
+ });
244
+
245
+ it('a grammar-malformed scenario/child/part line is simply ABSENT — valid lines before and after it extract', () => {
246
+ const s = structureOf(specDoc({ scenarios: ['- S1 a :: unbound', '- S2 broken', '- S3 c :: unbound'] }), 'login.md');
247
+ expect(s.scenarios).toEqual([{ ordinal: 1, binding: null }, { ordinal: 3, binding: null }]);
248
+ const c = structureOf(indexDoc({ children: ['- [a](./a.md)', '- broken', '- [b](./b/index.md)'] }), 'auth/index.md');
249
+ expect(c.children).toEqual([{ name: 'a', target: './a.md' }, { name: 'b', target: './b/index.md' }]);
250
+ const p = structureOf(specDoc({ extra: '\n## Parts\n\n- [a](./a.md)\n- broken\n- [b](./b.md)\n' }), 'auth/login/index.md');
251
+ expect(p.parts).toEqual([{ name: 'a', target: './a.md' }, { name: 'b', target: './b.md' }]);
252
+ });
253
+
254
+ it('the module is a CONJUNCTION — prose, a refused path, a dir/file mix each extract null; a rule-refused scenario line still extracts verbatim', () => {
255
+ for (const module of ['the root is\n- src/login/', '- ../src/', '- src/login/\n- src/login/a.mjs']) {
256
+ expect(structureOf(specDoc({ module }), 'login.md').module).toBeNull();
257
+ }
258
+ const v = readSpecDocument(specDoc({ scenarios: ['- S1 a :: test/a.mjs :: spec:login/S2'] }), 'login.md');
259
+ expect(v.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
260
+ expect(v.structure.scenarios).toEqual([{ ordinal: 1, binding: { file: 'test/a.mjs', marker: 'spec:login/S2' } }]);
261
+ });
262
+ });
263
+
197
264
  describe('classifyPath + the frozen constants', () => {
198
265
  it('classifies the lexical path forms', () => {
199
266
  expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // ensure-configs.mjs — ONE runnable command for the five stamp-independent upgrade ensures:
2
+ // ensure-configs.mjs — ONE runnable command for the six stamp-independent upgrade ensures:
3
3
  //
4
4
  // orchestration docs/ai/orchestration.json seed, or refresh a still-canonical onboarding note
5
5
  // gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
6
6
  // autonomy docs/ai/autonomy.json seed-if-missing (same)
7
7
  // scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
8
+ // specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
9
+ // + docs/ai/specs/index.md a shipped prior body, store root seeded behind a current checker
8
10
  // index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
9
11
  //
10
12
  // Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
11
- // of five is deliberate: five independent runs would be five chances to skip one, and the mode doc now
12
- // has a single invocation point whose five outcome lines it relays.
13
+ // of six is deliberate: six independent runs would be six chances to skip one, and the mode doc now
14
+ // has a single invocation point whose six outcome lines it relays.
13
15
  //
14
16
  // The contract (pinned by this module's tests):
15
17
  // • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
@@ -17,7 +19,7 @@
17
19
  // • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
18
20
  // token, and the exit is non-zero when any of them failed.
19
21
  // • The deployment gate runs ONCE, before any op: an absent/symlinked docs/ai stops the whole run
20
- // with the gate's own message rather than five copies of it.
22
+ // with the gate's own message rather than six copies of it.
21
23
  //
22
24
  // Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
23
25
  // Exit codes: 0 every op fine · 1 an op failed, or the deployment gate stopped the run · 2 usage.
@@ -29,10 +31,15 @@ import { dirname, resolve } from 'node:path';
29
31
  import { fileURLToPath } from 'node:url';
30
32
  import { assertDocsAiDeployment } from './atomic-write.mjs';
31
33
  import { isDirectRun } from './direct-run.mjs';
32
- import { ENSURE_IMPLEMENTATIONS, ENSURE_OPS, failedOutcome } from './ensure-ops.mjs';
34
+ import { ENSURE_OPS, OWN_IMPLEMENTATIONS, failedOutcome } from './ensure-ops.mjs';
35
+ import { ensureSpecs } from './ensure-specs.mjs';
33
36
 
34
37
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
35
38
 
39
+ // The op table this CLI walks — name → implementation, in ENSURE_OPS order. Composed HERE (the one
40
+ // consumer) because the spec-layer ensure imports ensure-ops.mjs for its outcome door and probes.
41
+ export const ENSURE_IMPLEMENTATIONS = Object.freeze({ ...OWN_IMPLEMENTATIONS, specs: ensureSpecs });
42
+
36
43
  const EXIT_OK = 0;
37
44
  const EXIT_FAILED = 1;
38
45
  const EXIT_USAGE = 2;
@@ -44,22 +51,25 @@ const EMPTY_ONLY = `--only needs one operation name (${ENSURE_OPS.join(' | ')})
44
51
  const REPEATED_ONLY = '--only was passed more than once — this selector names exactly ONE operation';
45
52
  const unknownOp = (value) => `--only ${value}: no such operation (${ENSURE_OPS.join(' | ')}) — nothing was run`;
46
53
 
47
- const HELP = `ensure-configs — the five stamp-independent upgrade ensures, as ONE command.
54
+ const HELP = `ensure-configs — the six stamp-independent upgrade ensures, as ONE command.
48
55
 
49
56
  Usage:
50
57
  node ensure-configs.mjs --reconcile [--dry-run] [--only <op>] [--cwd <project>]
51
58
 
52
- --reconcile required — run the five ensures (${ENSURE_OPS.join(', ')})
59
+ --reconcile required — run the six ensures (${ENSURE_OPS.join(', ')})
53
60
  --dry-run report what each ensure WOULD do; write nothing
54
61
  --only <op> run EXACTLY ONE of them (an unknown, missing or repeated value is a usage error)
55
62
  --cwd <dir> the target project (default: the current directory)
56
63
  --help, -h this help
57
64
 
58
65
  Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
59
- refreshed in place. Two ops refresh instead: the orchestration onboarding note, only while it still
60
- matches a canonical the kit shipped (your own wording is preserved verbatim), and the navigator
61
- index a GENERATED artifact, regenerated whenever it is missing or stale. The enforcement-script
62
- ensure detects an older ADR-store layout FIRST and instructs the opt-in migration instead of seeding.
66
+ refreshed in place. Three ops refresh instead: the orchestration onboarding note, only while it still
67
+ matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer reader
68
+ and checker pairs, only while a file's bytes are a body a release shipped (an edited file is
69
+ preserved, and the writes that depend on it the spec store root included — are withheld behind
70
+ it); and the navigator index — a GENERATED artifact, regenerated
71
+ whenever it is missing or stale. The enforcement-script ensure detects an older ADR-store layout
72
+ FIRST and instructs the opt-in migration instead of seeding.
63
73
 
64
74
  Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
65
75
 
@@ -138,7 +148,7 @@ export const main = (argv = [], ctx = {}) => {
138
148
  const cwd = resolve(args.cwd ?? ctx.cwd ?? process.cwd());
139
149
  const deps = ctx.deps ?? {};
140
150
  // ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
141
- // reconcile, and five identical STOPs would read as five separate problems.
151
+ // reconcile, and six identical STOPs would read as six separate problems.
142
152
  assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
143
153
  const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only });
144
154
  return {
@@ -1,6 +1,8 @@
1
- // ensure-ops.mjs — the FIVE upgrade ensure operations, one function each, behind one shared outcome
2
- // shape. The CLI that orders and runs them is ensure-configs.mjs; this module owns what each ensure
3
- // DOES and, more importantly, what it is allowed to CLAIM.
1
+ // ensure-ops.mjs — FIVE of the upgrade ensure operations, one function each, behind one shared
2
+ // outcome shape; the sixth (the spec-layer ensure, ensure-specs.mjs) composes its outcomes through
3
+ // the same door and probes exported below. The CLI that orders and runs them is ensure-configs.mjs —
4
+ // it owns the op table, so the import graph stays acyclic; this module owns what each ensure DOES
5
+ // and, more importantly, what it is allowed to CLAIM.
4
6
  //
5
7
  // Why they became code at all: `references/modes/upgrade.md` prescribed each of them as prose an agent
6
8
  // was expected to carry out by hand ("create it from the template if missing", "copy the pair from
@@ -86,14 +88,14 @@ export const failedOutcome = (op, err) => loud(op, 'unexpected-error', `${op}: $
86
88
  // directory or a symlink named gates.json report a green ensure while the declaration the project
87
89
  // needs does not exist — an exit 0 proving nothing (both review backends found this).
88
90
  const NODE_KIND = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : 'not a regular file');
89
- const probeSeedTarget = (abs, lstat) => {
91
+ export const probeSeedTarget = (abs, lstat) => {
90
92
  const st = lstatNoFollow(abs, lstat);
91
93
  if (st === null) return { present: false };
92
94
  return st.isFile() ? { present: true } : { present: true, wrongKind: NODE_KIND(st) };
93
95
  };
94
96
 
95
97
  // A leftover temp file never fails a completed write, and is never silent either.
96
- const tmpNote = (rel, tmpLeftBehind) =>
98
+ export const tmpNote = (rel, tmpLeftBehind) =>
97
99
  (tmpLeftBehind ? [`${rel}: the write stands, but its temp file could not be removed — delete it by hand: ${tmpLeftBehind}`] : []);
98
100
 
99
101
  // ── 1. orchestration.json — seed, or refresh ONLY a still-canonical onboarding note ────────────────
@@ -204,7 +206,7 @@ export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
204
206
 
205
207
  // A project with no package.json at its root is not where Node enforcement scripts belong. Stated,
206
208
  // never silent: the token names the evidence, and the three config ensures still run.
207
- const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
209
+ export const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
208
210
 
209
211
  const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
210
212
 
@@ -352,8 +354,9 @@ export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
352
354
  return loud('index', 'index-probe-failed', `${INDEX_REL}: the generator reported a regeneration, but the verifying probe answered neither fresh nor stale — ${MAY_HAVE_WRITTEN}. ${verify.error ? causeOf(verify.error) : verdict}`);
353
355
  };
354
356
 
355
- // The op table the CLI walks name implementation, in ENSURE_OPS order.
356
- export const ENSURE_IMPLEMENTATIONS = Object.freeze({
357
+ // The five ops this module owns, by name. The CLI composes the full ENSURE_OPS table from these plus
358
+ // the spec-layer ensure (ensure-specs.mjs imports THIS module, so the table cannot live here).
359
+ export const OWN_IMPLEMENTATIONS = Object.freeze({
357
360
  orchestration: ensureOrchestration,
358
361
  gates: ensureGates,
359
362
  autonomy: ensureAutonomy,
@@ -0,0 +1,223 @@
1
+ // ensure-specs.mjs — the SIXTH upgrade ensure: the spec layer (memory 4.6.0, AD-112) delivered to an
2
+ // EXISTING deployment on an equal-head upgrade. Four deployed scripts and one store root, written in
3
+ // a FIXED order the measured coupling dictates:
4
+ // 1. the reader pair scripts/spec-schema.mjs + .test.mjs — created when absent; since 2a
5
+ // also REFRESHED when the deployed bytes are a body a release shipped (script-priors.mjs), a
6
+ // custom body preserved verbatim (brief D1 shipped it create-only; the 4.6.x bodies joining the
7
+ // catalog is what keeps those deployments out of the stranded-custom cell);
8
+ // 2. the checker pair scripts/check-docs-size.mjs + .test.mjs — the same prior-refresh lane, and
9
+ // only behind a reader pair this run leaves byte-current;
10
+ // 3. the store root docs/ai/specs/index.md — seeded (placeholders rendered)
11
+ // only once both pairs are current.
12
+ // Why the order: the kit's bundled navigator generator collapses `specs/` into one row, while the
13
+ // project's pre-commit hook runs ITS deployed checker — an older one renders the store row by row and
14
+ // reds `--check-index`. A store root seeded behind a stale checker breaks the hook, so the store is
15
+ // the LAST write and admits only behind a checker this run has proven current.
16
+ //
17
+ // The state table (enumerate by PROOF, never by exclusion): every file is classified into exactly one
18
+ // of current | prior | custom | absent | wrong-kind before anything is written, each write admits
19
+ // through ONE conjunction of those facts (decideWrites), and every other cell is a stated refusal —
20
+ // a custom file of either pair preserves itself AND withholds every write that depends on it. Writes
21
+ // run in order and each is idempotent, so a run that stopped partway converges on the next run.
22
+ //
23
+ // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
24
+
25
+ import { readFileSync, lstatSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
28
+ import { classifyDeployedScript } from './script-priors.mjs';
29
+ import { composeFailure, composeOutcome, isNodeProject, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
30
+
31
+ const OP = 'specs';
32
+ const SCRIPTS_DIR = 'scripts';
33
+ const BUNDLED_SCRIPTS = ['references', 'scripts'];
34
+ const STORE_ROOT_REL = 'docs/ai/specs/index.md';
35
+ const STORE_ROOT_TEMPLATE = ['references', 'templates', 'specs', 'index.md'];
36
+ const DATE_PLACEHOLDER = '{{DATE}}';
37
+ // Explicit pairs, deliberately NOT aliased to the catalog's PRIOR_FILES (which spans both): each
38
+ // file is surveyed exactly once. Exported so the suite pins pairs-union === catalog domain.
39
+ export const READER_PAIR = Object.freeze(['spec-schema.mjs', 'spec-schema.test.mjs']);
40
+ export const CHECKER_PAIR = Object.freeze(['check-docs-size.mjs', 'check-docs-size.test.mjs']);
41
+
42
+ const ok = (token, lines) => composeOutcome(OP, token, lines, false);
43
+ const loud = (cause, ...lines) => composeFailure(OP, cause, ...lines);
44
+ const causeOf = (err) => String((err && err.message) || err);
45
+ const todayIso = () => new Date().toISOString().slice(0, 10);
46
+
47
+ // ── the survey: five paths classified, nothing written ────────────────────────────────────────────
48
+
49
+ // One deployed script → { rel, name, state, bundle }; `wrongKind` carries the node kind for the
50
+ // refusal, `prior`/`custom` come from the catalog classifier against the bundled body.
51
+ const surveyScript = ({ cwd, kitRoot, name, read, lstat }) => {
52
+ const rel = `${SCRIPTS_DIR}/${name}`;
53
+ let bundle;
54
+ try {
55
+ bundle = read(join(kitRoot, ...BUNDLED_SCRIPTS, name));
56
+ } catch (err) {
57
+ return { rel, name, state: 'bundle-unreadable', error: causeOf(err) };
58
+ }
59
+ const probe = probeSeedTarget(join(cwd, SCRIPTS_DIR, name), lstat);
60
+ if (probe.wrongKind) return { rel, name, state: 'wrong-kind', wrongKind: probe.wrongKind, bundle };
61
+ if (!probe.present) return { rel, name, state: 'absent', bundle };
62
+ return { rel, name, state: classifyDeployedScript(read(join(cwd, SCRIPTS_DIR, name)), name, bundle), bundle };
63
+ };
64
+
65
+ const surveyStoreRoot = (cwd, lstat) => {
66
+ const probe = probeSeedTarget(join(cwd, STORE_ROOT_REL), lstat);
67
+ if (probe.wrongKind) return { rel: STORE_ROOT_REL, state: 'wrong-kind', wrongKind: probe.wrongKind };
68
+ return { rel: STORE_ROOT_REL, state: probe.present ? 'present' : 'absent' };
69
+ };
70
+
71
+ // ── the decision: which writes the survey admits (pure over the survey) ───────────────────────────
72
+
73
+ // Reader seeds are create-only and always admitted. A REFRESH overwrites bytes, so it runs only
74
+ // inside a pair whose every file is proven refreshable (current | prior | absent) — one custom file
75
+ // withholds its whole pair; the checker lane (its seeds included) and the store additionally wait
76
+ // for a reader pair this run leaves byte-current (seeded + refreshed = current).
77
+ const PAIR_ELIGIBLE = new Set(['current', 'prior', 'absent']);
78
+
79
+ export const decideWrites = ({ reader, checker, store }) => {
80
+ const readerEligible = reader.every((f) => PAIR_ELIGIBLE.has(f.state));
81
+ const checkerEligible = readerEligible && checker.every((f) => PAIR_ELIGIBLE.has(f.state));
82
+ const writes = [];
83
+ for (const f of reader) {
84
+ if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
85
+ if (readerEligible && f.state === 'prior') writes.push({ kind: 'refresh', file: f });
86
+ }
87
+ if (checkerEligible) {
88
+ for (const f of checker) {
89
+ if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
90
+ if (f.state === 'prior') writes.push({ kind: 'refresh', file: f });
91
+ }
92
+ }
93
+ if (checkerEligible && store.state === 'absent') writes.push({ kind: 'store' });
94
+ return { writes, withheld: !checkerEligible };
95
+ };
96
+
97
+ // ── the lines (composed from what HAPPENED, never from what was planned) ──────────────────────────
98
+
99
+ // Every file's line is a function of its surveyed state and the FATE of its write this run:
100
+ // `written` · `stood` (lost the create-only race to the bundled body) · `would` (dry run) ·
101
+ // `withheld` (a custom file in the pair) · `stopped` (the run failed before reaching it) · `none`
102
+ // (no write was admitted or needed). A line never claims a write that did not happen.
103
+ const LEFT_FOR = { withheld: 'it waits on the pair named above', stopped: 'the run stopped before it' };
104
+ const SCRIPT_LINES = {
105
+ current: () => 'already the bundled body — nothing written',
106
+ custom: () => "carries a body this kit did not ship as current — preserved verbatim; the writes that depend on it wait until the pair matches the bundled scripts (copy them by hand from the kit's references/scripts/ when convenient, then re-run the upgrade)",
107
+ absent: (fate) => ({
108
+ written: 'copied from the bundled scripts',
109
+ stood: 'appeared while this run was seeding it — the bundled body stands',
110
+ would: 'absent — would be copied from the bundled scripts',
111
+ })[fate] ?? `absent — not copied${fate === 'withheld' ? ' this run' : ''}; ${LEFT_FOR[fate]}`,
112
+ prior: (fate) => `matches a body an earlier release shipped — ${({
113
+ written: 'refreshed to the bundled one',
114
+ would: 'would be refreshed to the bundled one',
115
+ })[fate] ?? `left as is; ${LEFT_FOR[fate]}`}`,
116
+ };
117
+ const scriptLine = (f, fate) => `${f.rel}: ${SCRIPT_LINES[f.state](fate)}`;
118
+ const STORE_LINES = {
119
+ present: 'already present — preserved byte-for-byte, nothing written',
120
+ written: "created from the bundled template (today's date rendered)",
121
+ stood: 'appeared while this run was seeding it — the existing file stands',
122
+ would: 'absent — would be created from the bundled template',
123
+ withheld: "not seeded — an older or edited checker renders the spec store row by row and reds the hook's index check, so the store root waits for a current checker pair",
124
+ stopped: 'absent — not created; the run stopped before it',
125
+ };
126
+ const storeLine = (store, fate) => `${STORE_ROOT_REL}: ${STORE_LINES[store.state === 'present' ? 'present' : fate]}`;
127
+ const relOf = (write) => (write.kind === 'store' ? STORE_ROOT_REL : write.file.rel);
128
+
129
+ // The fate of every path for the lines: a write that ran carries its result, an admitted write the
130
+ // run never reached is `stopped`, and a path with no admitted write is `withheld` behind a custom
131
+ // file or `none` when nothing was needed.
132
+ const fateOf = (rel, { writes, fates, withheld, stopped }) => {
133
+ if (fates.has(rel)) return fates.get(rel);
134
+ if (writes.some((w) => relOf(w) === rel)) return stopped ? 'stopped' : 'would';
135
+ return withheld ? 'withheld' : 'none';
136
+ };
137
+ const composeLines = ({ reader, checker, store, writes, fates, withheld, stopped }) => {
138
+ const fate = (rel) => fateOf(rel, { writes, fates, withheld, stopped });
139
+ const lines = [...reader, ...checker].map((f) => scriptLine(f, fate(f.rel)));
140
+ const storeFate = fate(STORE_ROOT_REL);
141
+ if (store.state === 'present' || storeFate !== 'none') lines.push(storeLine(store, storeFate));
142
+ return lines;
143
+ };
144
+
145
+ // A per-file loop can stop partway; the run says which files already landed — never "nothing happened".
146
+ const partialNote = (count) =>
147
+ (count > 0 ? [`the writes stopped PARTWAY — the ${count} file(s) named above were already written and are NOT rolled back`] : []);
148
+
149
+ // ── the ensure ────────────────────────────────────────────────────────────────────────────────────
150
+
151
+ // The store body is read and rendered BEFORE the first write, so a missing or unrenderable template
152
+ // refuses with nothing written.
153
+ const renderStoreRoot = (kitRoot, read, today) => {
154
+ const body = String(read(join(kitRoot, ...STORE_ROOT_TEMPLATE), 'utf8')).replaceAll(DATE_PLACEHOLDER, today);
155
+ if (body.includes('{{')) throw new Error('the bundled template carries a placeholder this kit cannot render — reinstall the kit');
156
+ return body;
157
+ };
158
+
159
+ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
160
+ const lstat = deps.lstat ?? lstatSync;
161
+ const read = deps.readFile ?? readFileSync;
162
+ if (!isNodeProject(cwd, lstat)) {
163
+ return ok('skipped-no-node', [`${SCRIPTS_DIR}/: no package.json at the project root — the spec reader and checker are Node scripts; nothing written`]);
164
+ }
165
+ const survey = (name) => surveyScript({ cwd, kitRoot, name, read, lstat });
166
+ const reader = READER_PAIR.map(survey);
167
+ const checker = CHECKER_PAIR.map(survey);
168
+ const store = surveyStoreRoot(cwd, lstat);
169
+ for (const f of [...reader, ...checker]) {
170
+ if (f.state === 'bundle-unreadable') return loud('bundle-unreadable', `${f.rel}: the bundled script could not be read, so nothing was written — reinstall the kit. ${f.error}`);
171
+ }
172
+ for (const f of [...reader, ...checker, store]) {
173
+ if (f.state === 'wrong-kind') return loud('wrong-node-kind', `${f.rel}: exists but is ${f.wrongKind} — nothing was read or written; resolve it by hand, then re-run`);
174
+ }
175
+
176
+ const { writes, withheld } = decideWrites({ reader, checker, store });
177
+ const kinds = new Set(writes.map((w) => w.kind));
178
+ // ONE token by precedence — a write that happened (or, dry, would happen) always outranks the
179
+ // preserved-custom report, which names a run that wrote nothing behind an edited pair.
180
+ const tokenFor = (seeded, refreshed) => {
181
+ if (seeded) return dryRun ? 'would-seed' : 'seeded';
182
+ if (refreshed) return dryRun ? 'would-refresh' : 'refreshed';
183
+ return withheld ? 'customized-preserved' : 'already-present';
184
+ };
185
+ const fates = new Map();
186
+ const linesNow = (stopped) => composeLines({ reader, checker, store, writes, fates, withheld, stopped });
187
+ const writtenCount = () => [...fates.values()].filter((fate) => fate === 'written').length;
188
+ if (dryRun) return ok(tokenFor(kinds.has('seed') || kinds.has('store'), kinds.has('refresh')), linesNow(false));
189
+
190
+ let storeBody = null;
191
+ if (kinds.has('store')) {
192
+ try {
193
+ storeBody = renderStoreRoot(kitRoot, read, deps.today ?? todayIso());
194
+ } catch (err) {
195
+ return loud('template-unreadable', `${STORE_ROOT_REL}: ${causeOf(err)}`, ...linesNow(true));
196
+ }
197
+ }
198
+ const writeOne = ({ kind, file }) => {
199
+ if (kind === 'store') return writeProjectFileCreateOnly(cwd, STORE_ROOT_REL, storeBody, deps, { noun: 'the spec store root' });
200
+ if (kind === 'seed') return writeProjectFileCreateOnly(cwd, file.rel, String(file.bundle), deps, { noun: 'a seeded spec-layer script' });
201
+ return writeContainedFileAtomic(cwd, join(cwd, file.rel), String(file.bundle), deps, { label: file.rel });
202
+ };
203
+ // A seed that lost the create-only race is re-PROVEN before anything that depends on it runs: the
204
+ // file that appeared must carry the bundled body, or the tree is changing underneath this run.
205
+ const stoodCurrent = ({ kind, file }) => kind === 'store' || classifyDeployedScript(read(join(cwd, file.rel)), file.name, file.bundle) === 'current';
206
+ const notes = [];
207
+ for (const write of writes) {
208
+ const rel = relOf(write);
209
+ let result;
210
+ try {
211
+ result = writeOne(write);
212
+ } catch (err) {
213
+ return loud('write-refused', `${rel}: ${causeOf(err)}`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
214
+ }
215
+ if (write.kind !== 'refresh' && !result.created && !stoodCurrent(write)) {
216
+ return loud('race-unresolved', `${rel}: appeared while this run was seeding it and does not carry the bundled body — something is writing there underneath this run; nothing further written, re-run when the tree is settled`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
217
+ }
218
+ fates.set(rel, write.kind !== 'refresh' && !result.created ? 'stood' : 'written');
219
+ notes.push(...tmpNote(rel, result.tmpLeftBehind));
220
+ }
221
+ const landed = (kind) => writes.some((w) => w.kind === kind && fates.get(relOf(w)) === 'written');
222
+ return ok(tokenFor(landed('seed') || landed('store'), landed('refresh')), [...linesNow(false), ...notes]);
223
+ };
@@ -8,12 +8,16 @@
8
8
  // read-only tool's import graph. Vocabulary here, behaviour in ensure-ops.mjs.
9
9
 
10
10
  // The FIXED order the CLI runs them in — the order references/modes/upgrade.md already prescribed.
11
- export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts', 'index']);
11
+ // `specs` sits after `scripts` (its reader pair is a scripts/ seed too) and BEFORE `index`: the store
12
+ // root it seeds is a docs/ai file the navigator must count.
13
+ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts', 'specs', 'index']);
12
14
 
13
15
  // Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
14
- // walks this set), and each has exactly one `would-` counterpart below.
15
- export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'regenerated']);
16
- export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-regenerate']);
16
+ // walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
17
+ // ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
18
+ // rewritten to the bundled one (a custom body is never refreshed).
19
+ export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
20
+ export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
17
21
 
18
22
  // The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
19
23
  // token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
@@ -65,6 +69,7 @@ export const RELAYED_FAILURE_CAUSES = FAILURE_CAUSES;
65
69
  export const RELAYED_ENSURE_TOKENS = Object.freeze([
66
70
  'seeded',
67
71
  'note-refreshed',
72
+ 'refreshed',
68
73
  'regenerated',
69
74
  'already-current',
70
75
  'customized-preserved',
@@ -0,0 +1,46 @@
1
+ // script-priors.mjs — the APPEND-ONLY catalog of every enforcement-script body this family has shipped
2
+ // into a project's `scripts/`, as sha256 digests, and the ONE classifier the spec-layer ensure asks
3
+ // before it refreshes a deployed script: `current` (byte-equal to the bundled copy) · `prior` (a body
4
+ // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, never
5
+ // overwritten). The lens-region idiom (AD-041) over whole script bodies.
6
+ //
7
+ // Append discipline: when a release changes one of PRIOR_FILES, append the OUTGOING body's row here
8
+ // in the same release and copy its bytes to test/fixtures/script-priors/<firstShipped>/<file>.txt —
9
+ // script-priors.test.mjs holds the two sides equal both ways and pins the row count. Rows are never
10
+ // edited or removed: a deployment on any shipped body must keep classifying `prior`.
11
+ //
12
+ // Pure leaf: node:crypto only. No side effects on import.
13
+
14
+ import { createHash } from 'node:crypto';
15
+
16
+ // The deployed scripts the spec-layer ensure may REFRESH — the FULL refreshable catalog: the checker
17
+ // pair that imports the reader, and (since 2a) the reader pair itself. Every other deployed script is
18
+ // outside the refresh lane by design.
19
+ export const PRIOR_FILES = Object.freeze([
20
+ 'check-docs-size.mjs', 'check-docs-size.test.mjs', 'spec-schema.mjs', 'spec-schema.test.mjs',
21
+ ]);
22
+
23
+ const prior = (file, firstShipped, lastShipped, digest) => Object.freeze({ file, firstShipped, lastShipped, digest });
24
+
25
+ // One row per DISTINCT shipped body (memory package versions; the body is the memory canon the kit
26
+ // mirrors). `firstShipped`..`lastShipped` is the inclusive release range that carried the body.
27
+ export const SCRIPT_PRIORS = Object.freeze([
28
+ prior('check-docs-size.mjs', '4.0.0', '4.3.0', '84fb3673b034d4b2ba5bedf4a3e47899f98da3971c17902d1f2a548d07dc53bf'),
29
+ prior('check-docs-size.mjs', '4.4.0', '4.5.0', '7a5cd7f98571c3248d0378623172e9c60073b8d8761bce7a95c263f99bfb3a42'),
30
+ prior('check-docs-size.mjs', '4.5.1', '4.5.4', 'fef3555b14a5ade46071bac18bd6dfc87daec39dd63ce1f7965864c3e51558d9'),
31
+ prior('check-docs-size.test.mjs', '4.0.0', '4.5.4', '88fbb3d7f097d74771b7c5d9ad99fcd58b274ae33f391e1ff01f4b138b9236cd'),
32
+ prior('spec-schema.mjs', '4.6.0', '4.6.1', 'f8ee23d81e90fd4225ca4ece288cba41982c4430290bc6d033f5ca18d2d283f4'),
33
+ prior('spec-schema.test.mjs', '4.6.0', '4.6.1', 'a12d6d3f5d32c6dabdee7e15af7d2ab15a0ced37515d1844fe0951f60cddbc99'),
34
+ ]);
35
+
36
+ export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
37
+
38
+ // classifyDeployedScript(deployedBytes, file, bundleBytes, priors?) → 'current' | 'prior' | 'custom'.
39
+ // The bundled body is compared FIRST, so a bundle that happens to equal a catalogued body still
40
+ // reads as current; the catalog is consulted by file name, so a body shipped under another name is
41
+ // never a prior for this one.
42
+ export const classifyDeployedScript = (deployedBytes, file, bundleBytes, priors = SCRIPT_PRIORS) => {
43
+ const digest = digestOf(deployedBytes);
44
+ if (digest === digestOf(bundleBytes)) return 'current';
45
+ return priors.some((row) => row.file === file && row.digest === digest) ? 'prior' : 'custom';
46
+ };
@@ -38,6 +38,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
38
38
  [
39
39
  'seeded',
40
40
  'note-refreshed',
41
+ 'refreshed',
41
42
  'regenerated',
42
43
  'already-current',
43
44
  'customized-preserved',