@sabaiway/agent-workflow-kit 7.4.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,38 @@ 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
+
7
39
  ## 7.4.0 — the spec layer reaches existing deployments: known-prior refresh, seed-behind-checker, the `specs` ensure (AD-113)
8
40
 
9
41
  7.3.0 made a FRESH bootstrap deliver the feature-spec layer whole; this release gives every EXISTING
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.4.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.4.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.4.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",
@@ -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 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 checker pair (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).
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,7 +42,7 @@ 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 is seeded (create-only); the checker pair is REFRESHED only while its 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 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.
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
46
 
47
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).
48
48
 
@@ -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([
@@ -5,7 +5,7 @@
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 pair seed-if-missing, checker pair refreshed only on
8
+ // specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
9
9
  // + docs/ai/specs/index.md a shipped prior body, store root seeded behind a current checker
10
10
  // index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
11
11
  //
@@ -64,9 +64,10 @@ Usage:
64
64
 
65
65
  Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
66
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 checker
68
- pair, only while its bytes are a body a release shipped (an edited checker is preserved, and the spec
69
- store root is then NOT seeded behind it); and the navigator indexa GENERATED artifact, regenerated
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
70
71
  whenever it is missing or stale. The enforcement-script ensure detects an older ADR-store layout
71
72
  FIRST and instructs the opt-in migration instead of seeding.
72
73
 
@@ -1,11 +1,12 @@
1
1
  // ensure-specs.mjs — the SIXTH upgrade ensure: the spec layer (memory 4.6.0, AD-112) delivered to an
2
2
  // EXISTING deployment on an equal-head upgrade. Four deployed scripts and one store root, written in
3
3
  // a FIXED order the measured coupling dictates:
4
- // 1. the reader pair scripts/spec-schema.mjs + .test.mjs — CREATE-ONLY (brief D1: a new
5
- // deployed script ships create-only before any refresh of a file that imports it);
6
- // 2. the checker pair scripts/check-docs-size.mjs + .test.mjs — REFRESHED only when the deployed
7
- // bytes are a body a release shipped (script-priors.mjs), created when absent, a custom body
8
- // preserved verbatim and only behind a reader pair that is byte-current;
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;
9
10
  // 3. the store root docs/ai/specs/index.md — seeded (placeholders rendered)
10
11
  // only once both pairs are current.
11
12
  // Why the order: the kit's bundled navigator generator collapses `specs/` into one row, while the
@@ -24,7 +25,7 @@
24
25
  import { readFileSync, lstatSync } from 'node:fs';
25
26
  import { join } from 'node:path';
26
27
  import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
27
- import { PRIOR_FILES, classifyDeployedScript } from './script-priors.mjs';
28
+ import { classifyDeployedScript } from './script-priors.mjs';
28
29
  import { composeFailure, composeOutcome, isNodeProject, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
29
30
 
30
31
  const OP = 'specs';
@@ -33,8 +34,10 @@ const BUNDLED_SCRIPTS = ['references', 'scripts'];
33
34
  const STORE_ROOT_REL = 'docs/ai/specs/index.md';
34
35
  const STORE_ROOT_TEMPLATE = ['references', 'templates', 'specs', 'index.md'];
35
36
  const DATE_PLACEHOLDER = '{{DATE}}';
36
- const READER_PAIR = Object.freeze(['spec-schema.mjs', 'spec-schema.test.mjs']);
37
- const CHECKER_PAIR = PRIOR_FILES;
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']);
38
41
 
39
42
  const ok = (token, lines) => composeOutcome(OP, token, lines, false);
40
43
  const loud = (cause, ...lines) => composeFailure(OP, cause, ...lines);
@@ -67,14 +70,20 @@ const surveyStoreRoot = (cwd, lstat) => {
67
70
 
68
71
  // ── the decision: which writes the survey admits (pure over the survey) ───────────────────────────
69
72
 
70
- const CURRENT_AFTER_SEED = new Set(['current', 'absent']);
71
- const CHECKER_ELIGIBLE = new Set(['current', 'prior', 'absent']);
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']);
72
78
 
73
79
  export const decideWrites = ({ reader, checker, store }) => {
74
- const readerCurrentAfter = reader.every((f) => CURRENT_AFTER_SEED.has(f.state));
75
- const checkerEligible = readerCurrentAfter && checker.every((f) => CHECKER_ELIGIBLE.has(f.state));
80
+ const readerEligible = reader.every((f) => PAIR_ELIGIBLE.has(f.state));
81
+ const checkerEligible = readerEligible && checker.every((f) => PAIR_ELIGIBLE.has(f.state));
76
82
  const writes = [];
77
- for (const f of reader) if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
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
+ }
78
87
  if (checkerEligible) {
79
88
  for (const f of checker) {
80
89
  if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
@@ -14,8 +14,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
14
14
 
15
15
  // Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
16
16
  // walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
17
- // ensure's checker-pair refresh — a deployed script on a body a release shipped, rewritten to the
18
- // bundled one (a custom body is never refreshed).
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
19
  export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
20
20
  export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
21
21
 
@@ -13,9 +13,12 @@
13
13
 
14
14
  import { createHash } from 'node:crypto';
15
15
 
16
- // The deployed scripts the spec-layer ensure may REFRESH (the checker pair that imports the reader).
17
- // Every other deployed script is outside the refresh lane by design.
18
- export const PRIOR_FILES = Object.freeze(['check-docs-size.mjs', 'check-docs-size.test.mjs']);
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
+ ]);
19
22
 
20
23
  const prior = (file, firstShipped, lastShipped, digest) => Object.freeze({ file, firstShipped, lastShipped, digest });
21
24
 
@@ -26,6 +29,8 @@ export const SCRIPT_PRIORS = Object.freeze([
26
29
  prior('check-docs-size.mjs', '4.4.0', '4.5.0', '7a5cd7f98571c3248d0378623172e9c60073b8d8761bce7a95c263f99bfb3a42'),
27
30
  prior('check-docs-size.mjs', '4.5.1', '4.5.4', 'fef3555b14a5ade46071bac18bd6dfc87daec39dd63ce1f7965864c3e51558d9'),
28
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'),
29
34
  ]);
30
35
 
31
36
  export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');