@sabaiway/agent-workflow-kit 7.4.0 → 7.6.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,85 @@ 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.6.0 — `spec-check`: the spec store judged against what the session SAYS it changed (AD-115)
8
+
9
+ The feature-spec layer gets its structural checker. The change source is EXPLICIT and never git — a
10
+ session states what it did, and the checker decides whether the store on disk says the same thing.
11
+ "Well-formed" keeps its ONE definition: every per-document verdict is RELAYED from the shipped
12
+ reader's 7.5.0 `structure` extraction, and this release adds no second parser.
13
+
14
+ - **`tools/spec-check-ops.mjs` — one accepted spelling per document.** Four verbs
15
+ (`add` | `modify` | `remove` | `rename=<old>:<new>`) over POSIX repo-relative `.md` paths inside
16
+ `docs/ai/specs/` whose every segment is D-schema shaped. Nothing is normalized away: `./x.md`,
17
+ `a//b.md`, `a/../b.md`, a backslash, a drive letter, an absolute path and a trailing-slash
18
+ directory all REFUSE rather than resolving to some other path — so a dedup can be an equality, and
19
+ because no accepted target can carry a `:` the rename separator is unambiguous. A path holds at
20
+ most ONE role (self-rename, fan-out, fan-in, chain and any two-role clash refuse); the store root
21
+ is never a target; any error empties the whole op list.
22
+ - **`tools/spec-check.mjs` — the judge, with no filesystem of its own.** `{read, probe, realpath,
23
+ list}` are injected and an uninjected one throws. The SESSION lane judges the closure of the
24
+ declared ops (targets plus each one's listing parent) on the post-state, the reader verdict, the
25
+ per-kind line cap, the scenario bindings (the marker must occur EXACTLY once in the file the
26
+ scenario names) and the containment of every path each document names. A `## Module` path must
27
+ also EXIST with the kind it declares — a `dir/` root resolving to a directory, a file-list entry
28
+ to a regular file — so a contract can no longer name code that is not there. `--all` judges the
29
+ whole store instead and adds the four questions no document can answer about itself: an unlisted
30
+ child (DISTINCT from an orphan), acyclicity, store-wide slug uniqueness and module overlap.
31
+ - **Fail-closed means PROVEN contained.** A path whose realpath does not resolve is never read.
32
+ Containment asks the platform's own path model, because a textual prefix test reads
33
+ `/repo\outside` as a child of `/repo` on POSIX and mis-reads a filesystem root in both directions.
34
+ Every listed edge is resolved, contained and probed before it may enter the reachability graph — a
35
+ phantom target would otherwise be marked reached and launder an orphan into a reached document.
36
+ The census admits a CLOSED set — plain directories and regular `.md` files — and states everything
37
+ else: an unlistable directory, one that resolves outside the root, any non-regular entry, and a
38
+ regular file that is not a spec document. A census that observed nothing keeps its own refusal
39
+ instead of collapsing into a usage error. The leaf read is descriptor-bound and no-follow.
40
+ - **`tools/spec-check-cli.mjs` — the IO shell.** `--op` (repeatable) unions `--ops-file`, `--all` is
41
+ exclusive of both, `--root` defaults to the cwd. The register is never defaulted: guessing which
42
+ file states the change set would attest a post-state nobody declared. Blank and `#` lines parse
43
+ away, CRLF reads like LF, and every other line is passed on UNTRIMMED so both op sources accept
44
+ exactly the same strings. Exit 0 clean, 1 findings, 2 usage.
45
+ - **`tools/procedures.mjs` — the advisor names it.** A `plan-execution` render now carries the
46
+ populated `spec-check` commands for both lanes and the session register they read, plus the
47
+ additive `specCheck` `--json` key. Advisory, like the finding-scope block beside it: nothing
48
+ records that it ran.
49
+ - **Proof.** Every suite authored RED first and observed failing before its module existed; the
50
+ D-scale gate measures the checker's OWN median (573 ms) beside the unchanged hook median against
51
+ the 1500 ms budget, so a slower checker can never pass by sitting inside the hook's headroom.
52
+ Tarball count 247 -> 250.
53
+
54
+ ## 7.5.0 — the reader pair joins the known-prior refresh lane; the mirrored reader gains the structure verdict (AD-114)
55
+
56
+ Slice 2b's `spec-check` will need a structural read of a spec document, and the reader is the ONE
57
+ definition of well-formed — so the memory canon's reader (mirrored here byte-identically) now
58
+ returns an additive `structure` field. Because the bundled reader bytes move, the reader pair joins
59
+ the AD-113 known-prior refresh lane in the SAME release: without catalog rows for the outgoing
60
+ bodies, every 4.6.x deployment would classify `custom` against the new bundle and be stranded out of
61
+ the store seed forever.
62
+
63
+ - **`tools/script-priors.mjs` — the catalog spans both pairs.** `PRIOR_FILES` is now the FULL
64
+ refreshable catalog (checker pair + reader pair); `SCRIPT_PRIORS` appends the outgoing
65
+ 4.6.0..4.6.1 reader-pair bodies (rows 4 -> 6, fixtures under `4.6.0/`, the literal frozen prefix
66
+ extended with its first four rows byte-identical). The maintenance rule generalizes with it: a
67
+ release that changes ANY catalog member appends the outgoing body's row.
68
+ - **`tools/ensure-specs.mjs` — the pairs decoupled, the reader refreshes.** `CHECKER_PAIR` was an
69
+ alias of `PRIOR_FILES`, so the widened catalog would have double-surveyed the readers: the op now
70
+ owns exported `READER_PAIR`/`CHECKER_PAIR` (the suite pins pairs-union === catalog domain), and
71
+ `decideWrites` admits a reader REFRESH — reader seeds stay unconditional create-only (a custom
72
+ neighbour never blocks them); a refresh runs only inside a pair whose every file is
73
+ current | prior | absent; one custom file withholds its pair's REFRESHES and every downstream
74
+ write, DIRECTIONALLY (a prior reader still refreshes beside a custom checker, which holds back
75
+ only its own pair and the store). Order reader -> checker -> store unchanged; lines and tokens
76
+ unchanged (`refreshed` now covers either pair).
77
+ - **Prose caught up at every live surface** — `references/modes/upgrade.md` (the configs summary
78
+ and the spec-layer paragraph), the `ensure-configs` header + help, the vocabulary comment, the
79
+ package-content ledger, and the memory twin's refresh-lane sentence all name BOTH pairs.
80
+ - **Proof.** The state table gains the reader-prior rows, the directional-withhold pin and dry-run
81
+ rows; the E2E upgrades a real 4.6.x deployment (reader on the 4.6.0 bodies, checker current)
82
+ whole under the real installed pre-commit hook, and the whole-chain prior+prior cell stays
83
+ table-covered. Red-first throughout — three red-proofs minted on frozen test bytes against the
84
+ pre-fix modules. Tarball count unchanged at 247.
85
+
7
86
  ## 7.4.0 — the spec layer reaches existing deployments: known-prior refresh, seed-behind-checker, the `specs` ensure (AD-113)
8
87
 
9
88
  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.6.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.6.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.6.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",
@@ -19,6 +19,8 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slo
19
19
 
20
20
  **Declared source-size practice block (upfront knowledge, D-17 U1).** When the project declares a source-size practice in `docs/ai/source-size.json`, the advisor also renders `Declared source-size practice (docs/ai/source-size.json) — known BEFORE the code is written:` — the caps, what is recorded and the canonical WHY sentence (the plan-time layout itself is the planning canon's Module ledger). `--json` carries the SAME lines under **`declaredPractice`** (an empty array when nothing is declared), so the human and structured renders never diverge. The four config states each read as themselves: **ABSENT** renders nothing at all (a project declaring no practice is handed no invented limits), **AUTHORED** and **INCOMPLETE** are pre-mint (the second names the half-written machine key), **MINTED** carries the recorded counts. A declaration that cannot be read (malformed, unknown-keyed, a dangling symlink) renders ONE loud `UNREADABLE` line and the advisor **still exits 0** — the practice's own checker owns the exit code for its config (exit 2 there) and its declared gate reds the gate matrix. Read through the practice's pure read core, so this advisor never reaches its writer half.
21
21
 
22
+ **Spec-store block (the feature-spec layer, `plan-execution` only).** After a `plan-execution` render the advisor also prints `Spec store (the feature-spec layer) — …`: the two POPULATED `spec-check` commands and the session register they read. **Session lane** — `--ops-file docs/plans/spec-ops.list` (or repeated `--op <add|modify|remove>=<path>` / `rename=<old>:<new>`) judges the closure of the declared change: the targets plus each one's listing parent, on their post-state, their reader verdict, their per-kind line cap, their scenario bindings and the containment of every path they name. **Store lane** — `--all` judges the whole store instead and adds the four cross-document invariants (an unlisted child, DISTINCT from an orphan · acyclicity · store-wide slug uniqueness · module overlap); an absent store root refuses the run rather than reporting an empty clean store. The two lanes are exclusive. **Register lifecycle:** `docs/plans/spec-ops.list` is *session scratch* — the session writes it, the plan's Phase Cleanup deletes it — and it is **never defaulted** by the tool, because an unnamed register would attest a post-state nobody declared (the `fold-scope --queue` precedent). `--json` carries the same lines under **`specCheck`** (an empty array outside `plan-execution`). Advisory like the finding-scope block: nothing records that it ran. Exit `0` clean · `1` findings · `2` usage.
23
+
22
24
  **Cap-soft-skip degradation (the feature's only AUTO route).** The activity procedures are auto-discoverable only through the one-line **`workflow:methodology`** pointer (this kit + the engine carry `disable-model-invocation:true`, so NL like "write a plan" does **not** auto-load this skill). On a deployment whose methodology pointer was cap-soft-skipped — or whose pre-existing customized pointer lacks the procedures clause — the procedures are still reachable by **explicitly** invoking `/agent-workflow-kit procedures`; surface that plainly rather than treating it as a gap.
23
25
 
24
26
  **Invariants:** read-only · never writes · never commits · never runs a subscription CLI · the deterministic resolution is the kit's, the recipe execution is the orchestrator's.
@@ -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
 
@@ -319,6 +319,22 @@ const foldScopeAdvice = (activity, config, plans) => {
319
319
  ];
320
320
  };
321
321
 
322
+ // The spec-store block (the feature-spec layer) — plan-execution ONLY and unconditional, like the
323
+ // finding-scope block above: a change to docs/ai/specs/ is judged whoever reviews it. It carries
324
+ // only what no canon can: the POPULATED checker commands, and the session register they read. The
325
+ // register is NEVER defaulted by the tool — naming it here is the whole point of the block.
326
+ export const SPEC_CHECK_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'spec-check-cli.mjs');
327
+ const SPEC_OPS_REGISTER = `${PLANS_REL}/spec-ops.list`;
328
+ const specCheckAdvice = (activity) => {
329
+ if (activity !== 'plan-execution') return [];
330
+ return [
331
+ 'Spec store (the feature-spec layer) — state what this session changed, then let the checker judge the store against it:',
332
+ ` • node ${shellQuoteArg(SPEC_CHECK_TOOL)} --ops-file ${shellQuoteArg(SPEC_OPS_REGISTER)} (or --op '<add|modify|remove>=docs/ai/specs/<slug>.md', repeatable; rename=<old>:<new>)`,
333
+ ` • node ${shellQuoteArg(SPEC_CHECK_TOOL)} --all — the whole store instead: unlisted child vs orphan, acyclicity, store-wide slug uniqueness, module overlap.`,
334
+ ` • ${SPEC_OPS_REGISTER} is SESSION SCRATCH: this session writes it, the plan's Cleanup deletes it. It is never defaulted — an unnamed register would attest a post-state nobody declared. Advisory: nothing records that it ran.`,
335
+ ];
336
+ };
337
+
322
338
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
323
339
  // at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
324
340
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -466,7 +482,7 @@ const contractLines = ({ cmd, contract, settings }) => {
466
482
  return lines;
467
483
  };
468
484
 
469
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => {
485
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }) => {
470
486
  const lines = [
471
487
  section,
472
488
  '',
@@ -486,6 +502,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
486
502
  const advice = reviewLoopAdvice(slots, activity);
487
503
  if (advice.length) lines.push('', ...advice);
488
504
  if (foldScope.length) lines.push('', ...foldScope);
505
+ if (specCheck.length) lines.push('', ...specCheck);
489
506
  lines.push('', ...costLanesAdvice());
490
507
  if (declaredPractice.length) lines.push('', ...declaredPractice);
491
508
  if (warnings.length) {
@@ -495,7 +512,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
495
512
  return lines.join('\n');
496
513
  };
497
514
 
498
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => ({
515
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }) => ({
499
516
  activity,
500
517
  section,
501
518
  slots: Object.fromEntries(
@@ -510,6 +527,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
510
527
  costLanes: costLanesAdvice(),
511
528
  // ADDITIVE (the fold channel): the finding-scope block, structured (empty outside plan-execution).
512
529
  foldScope,
530
+ // ADDITIVE (spec layer 2b): the spec-store block, structured (empty outside plan-execution).
531
+ specCheck,
513
532
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
514
533
  autonomy: autonomyAdvice(activity, autonomy),
515
534
  // ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
@@ -599,9 +618,10 @@ export const main = (argv, ctx = {}) => {
599
618
  const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
600
619
  const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
601
620
  const foldScope = foldScopeAdvice(activity, config, plans);
621
+ const specCheck = specCheckAdvice(activity);
602
622
  const stdout = json
603
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }), null, 2)
604
- : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope });
623
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck }), null, 2)
624
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope, specCheck });
605
625
  if (autonomy?.error) {
606
626
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
607
627
  }
@@ -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');
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // The CLI half of the structural checker: argv and fs, no rule (the rules are spec-check.mjs and
3
+ // spec-check-ops.mjs). The change source is EXPLICIT and never git — a session states what it did.
4
+ //
5
+ // node spec-check-cli.mjs --op modify=docs/ai/specs/login.md [--op ...] [--root <dir>]
6
+ // node spec-check-cli.mjs --ops-file docs/plans/spec-ops.list
7
+ // node spec-check-cli.mjs --all
8
+ //
9
+ // `--op` and `--ops-file` UNION and dedup by identity; `--all` is exclusive of both. The register
10
+ // `--ops-file` names is session scratch, and it is never defaulted: guessing which file states the
11
+ // change set would attest a post-state nobody declared (the fold-scope --queue precedent). A named
12
+ // register that does not exist is usage, not an empty change set.
13
+ //
14
+ // The leaf read is the family's descriptor-bound no-follow door (fs-read-nofollow.mjs) — a pathname
15
+ // swapped after the probe cannot change the bytes judged. Read-only, records nothing: ADVISORY, so
16
+ // a skipped or late call is indistinguishable from one that ran.
17
+ //
18
+ // Exit codes: 0 ACCEPT; 1 one or more findings; 2 usage — a missing/unknown flag, a bad op, an
19
+ // unreadable register, a --root that is not a directory, or an --all run with no store root.
20
+ // Dependency-free, Node >= 22.
21
+
22
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
23
+ import { resolve } from 'node:path';
24
+ import { isDirectRun } from './direct-run.mjs';
25
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
26
+ import { SPEC_OPS_GRAMMAR, parseSpecOps } from './spec-check-ops.mjs';
27
+ import { checkSpecs } from './spec-check.mjs';
28
+
29
+ const { verbs, separator, storePrefix } = SPEC_OPS_GRAMMAR;
30
+
31
+ const HELP = `spec-check — judge the feature-spec store against what this session says it changed.
32
+
33
+ Usage:
34
+ node spec-check-cli.mjs [--root <dir>] --op <op> [--op <op> ...]
35
+ node spec-check-cli.mjs [--root <dir>] --ops-file <file>
36
+ node spec-check-cli.mjs [--root <dir>] --all
37
+
38
+ An op is one of ${verbs.slice(0, -1).map((v) => `${v}=<path>`).join(' | ')} | rename=<old>${separator}<new>, and every
39
+ path is a POSIX repo-relative .md document inside ${storePrefix} whose segments are kebab slugs.
40
+ Nothing is normalized away and the store root is never an op target, so one document has exactly
41
+ one accepted spelling.
42
+
43
+ --op repeatable; unions --ops-file and dedups by identity.
44
+ --ops-file one op per line; blank lines and # comments parse away. Never defaulted — name the
45
+ register this session wrote (e.g. docs/plans/spec-ops.list).
46
+ --all judge the WHOLE store instead: reachability (unlisted child vs orphan), acyclicity,
47
+ store-wide slug uniqueness and module overlap. Exclusive of both op sources.
48
+ --root the repo root the paths are relative to (default: the process cwd).
49
+
50
+ The check is advisory — nothing records that it ran, so a skipped or late call is indistinguishable
51
+ from one made before the edit.
52
+
53
+ Exit codes: 0 ACCEPT; 1 findings; 2 usage (bad flag or op, unreadable register, bad --root).`;
54
+
55
+ const FLAGS = ['op', 'ops-file', 'all', 'root'];
56
+ const REPEATABLE = ['op'];
57
+ const BOOLEAN = ['all'];
58
+
59
+ const parseArgs = (argv) => {
60
+ const opts = { op: [] };
61
+ for (let i = 0; i < argv.length; i += 1) {
62
+ const arg = argv[i];
63
+ const eq = arg.indexOf('=');
64
+ const name = arg.startsWith('--') ? arg.slice(2, eq === -1 ? undefined : eq) : null;
65
+ if (!name || !FLAGS.includes(name)) throw new Error(`unexpected argument "${arg}" (flags: ${FLAGS.map((f) => `--${f}`).join(', ')})`);
66
+ if (BOOLEAN.includes(name)) {
67
+ if (eq !== -1) throw new Error(`--${name} takes no value`);
68
+ opts[name] = true;
69
+ continue;
70
+ }
71
+ let value = eq === -1 ? argv[i + 1] : arg.slice(eq + 1);
72
+ if (eq === -1) i += 1;
73
+ if (value === undefined || (eq === -1 && value.startsWith('--'))) throw new Error(`--${name} requires a value`);
74
+ if (REPEATABLE.includes(name)) opts[name].push(value);
75
+ else opts[name] = value;
76
+ }
77
+ return opts;
78
+ };
79
+
80
+ // The probe: a fail-closed lstat CLASSIFICATION, never a boolean. A symlink is its own state (it is
81
+ // never followed to decide what sits at a path), and anything that cannot be stat-ed at all is
82
+ // "unreadable" rather than "absent" — the two lead to different verdicts and must not collapse.
83
+ export const probe = (path) => {
84
+ try {
85
+ const st = lstatSync(path);
86
+ return st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'unreadable';
87
+ } catch (err) {
88
+ return err && err.code === 'ENOENT' ? 'absent' : 'unreadable';
89
+ }
90
+ };
91
+
92
+ export const realpath = (path) => {
93
+ try {
94
+ return realpathSync(path);
95
+ } catch {
96
+ return null;
97
+ }
98
+ };
99
+
100
+ export const list = (path) => {
101
+ try {
102
+ return readdirSync(path);
103
+ } catch {
104
+ return null;
105
+ }
106
+ };
107
+
108
+ // A LINE ENDING is not content: the split takes CRLF as well as LF, so a register saved on Windows
109
+ // reads like one saved anywhere else. Blank and `#` lines parse away; every OTHER line is handed on
110
+ // UNTRIMMED, because trimming here would let the file lane accept a spelling the --op lane refuses —
111
+ // exactly the alias the frozen grammar denies. The ops parser refuses that whitespace out loud.
112
+ const opsFileLines = (text) => text.split(/\r?\n/).filter((line) => line.trim() !== '' && !line.trim().startsWith('#'));
113
+
114
+ // main(argv, deps) -> { code, stdout, stderr }. Never calls process.exit itself (the direct-run
115
+ // guard does), and never reads anything the caller did not point it at.
116
+ export const main = (argv, deps = {}) => {
117
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
118
+ const read = deps.readFileSync ?? readFileSync;
119
+ const cwd = deps.cwd ?? (() => process.cwd());
120
+ const usage = (message) => ({ code: 2, stdout: '', stderr: `spec-check: ${message}` });
121
+ let opts;
122
+ try {
123
+ opts = parseArgs(argv);
124
+ } catch (err) {
125
+ return usage(err.message);
126
+ }
127
+ if (opts.all && (opts.op.length > 0 || opts['ops-file'])) {
128
+ return usage('--all judges the whole store and is exclusive of --op and --ops-file — run one lane or the other');
129
+ }
130
+ const root = resolve(cwd(), opts.root ?? '.');
131
+ if (probe(root) !== 'dir') return usage(`--root "${opts.root ?? cwd()}" is not a directory`);
132
+ let specs = [...opts.op];
133
+ if (opts['ops-file']) {
134
+ const register = resolve(cwd(), opts['ops-file']);
135
+ try {
136
+ specs = [...specs, ...opsFileLines(read(register, 'utf8'))];
137
+ } catch (err) {
138
+ return usage(`--ops-file "${opts['ops-file']}" is unreadable — ${(err && err.message) || err}`);
139
+ }
140
+ }
141
+ let ops = [];
142
+ if (!opts.all) {
143
+ const parsed = parseSpecOps(specs);
144
+ if (parsed.errors.length > 0) {
145
+ return usage(parsed.errors.map((e) => `${e.code} — ${e.message}`).join('\nspec-check: '));
146
+ }
147
+ ops = parsed.ops;
148
+ }
149
+ const decided = checkSpecs({ root, ops, all: Boolean(opts.all) }, { read: readRegularFileNoFollow, probe, realpath, list });
150
+ return { code: decided.exit, stdout: decided.lines.join('\n'), stderr: '' };
151
+ };
152
+
153
+ if (isDirectRun(import.meta.url)) {
154
+ const result = main(process.argv.slice(2));
155
+ if (result.stdout) console.log(result.stdout);
156
+ if (result.stderr) console.error(result.stderr);
157
+ process.exit(result.code);
158
+ }
@@ -0,0 +1,181 @@
1
+ // spec-check-ops.mjs — the frozen CHANGE-OP grammar of the structural checker (spec layer 2b).
2
+ //
3
+ // The change source is EXPLICIT and never git: a session states what it did to the spec store, and
4
+ // this module decides whether that statement is even sayable. Four verbs, one separator, one
5
+ // spelling per path:
6
+ //
7
+ // add=<p> | modify=<p> | remove=<p> | rename=<old>:<new>
8
+ //
9
+ // A target is a POSIX repo-relative `.md` path inside docs/ai/specs/ whose every segment is
10
+ // D-schema-shaped (kebab dirs; a `<slug>.md` or `index.md` leaf). Because no accepted target can
11
+ // carry a `:`, the rename separator is unambiguous — which is why the grammar can stay one line.
12
+ //
13
+ // NOTHING is normalized away: `./x.md`, `a//b.md` and `a/../b.md` REFUSE rather than resolving to
14
+ // some other path, so one document has exactly ONE accepted spelling and a dedup can be an equality.
15
+ // The store root is never an op target — it is the navigator, not a contract.
16
+ //
17
+ // The D-schema values come from the ONE reader (references/scripts/spec-schema.mjs): no second
18
+ // source of the prefix, the navigator filename or the slug pattern. Pure strings in, ops out — the
19
+ // filesystem belongs to spec-check.mjs. Dependency-free, Node >= 22.
20
+
21
+ import { SPEC_SCHEMA } from '../references/scripts/spec-schema.mjs';
22
+
23
+ const STORE = SPEC_SCHEMA.storePrefix;
24
+ const NAV = SPEC_SCHEMA.navigatorFile;
25
+ const SUFFIX = '.md';
26
+ const SEPARATOR = ':';
27
+
28
+ export const SPEC_OPS_GRAMMAR = Object.freeze({
29
+ verbs: Object.freeze(['add', 'modify', 'remove', 'rename']),
30
+ separator: SEPARATOR,
31
+ storePrefix: STORE,
32
+ navigator: NAV,
33
+ suffix: SUFFIX,
34
+ slugPattern: SPEC_SCHEMA.slugPattern,
35
+ storeRoot: `${STORE}${NAV}`,
36
+ roles: Object.freeze(['add', 'modify', 'remove', 'rename-from', 'rename-to', 'listing-parent']),
37
+ });
38
+
39
+ const SLUG_RE = new RegExp(SPEC_SCHEMA.slugPattern);
40
+ const DRIVE_RE = /^[A-Za-z]:/;
41
+ const VERBS = SPEC_OPS_GRAMMAR.verbs;
42
+
43
+ // The ONE target judgement, ordered from the most literal defect to the most structural. The store
44
+ // PREFIX is asked before the dot segments on purpose: `./docs/ai/specs/x.md` is not a store path
45
+ // that needs normalizing, it is a path outside the store, and saying so is the honest refusal.
46
+ const targetDefect = (target) => {
47
+ if (target.includes('\\')) return { code: 'op-target', reason: 'a backslash is not a path separator here' };
48
+ if (DRIVE_RE.test(target)) return { code: 'op-target', reason: 'a drive letter is not a repo-relative path' };
49
+ if (target.startsWith('/')) return { code: 'op-target', reason: 'an absolute path is not repo-relative' };
50
+ if (target.includes('//')) return { code: 'op-target', reason: 'a doubled slash is not a path segment' };
51
+ if (target.endsWith('/')) return { code: 'op-target', reason: 'a directory is never an op target — name the document' };
52
+ if (!target.startsWith(STORE)) return { code: 'op-target', reason: `the target is outside the store ${STORE}` };
53
+ if (target.split('/').some((segment) => segment === '.' || segment === '..')) {
54
+ return { code: 'op-target', reason: 'a dot segment is never resolved away — write the path as it is' };
55
+ }
56
+ if (!target.endsWith(SUFFIX)) return { code: 'op-target', reason: `a spec document is a ${SUFFIX} file` };
57
+ if (target === SPEC_OPS_GRAMMAR.storeRoot) {
58
+ return { code: 'op-root', reason: 'the store root is the navigator, never an op target' };
59
+ }
60
+ const segments = target.slice(STORE.length).split('/');
61
+ const leaf = segments[segments.length - 1];
62
+ const badDir = segments.slice(0, -1).find((segment) => !SLUG_RE.test(segment));
63
+ if (badDir !== undefined) return { code: 'op-segment', reason: `"${badDir}" is not a slug (${SPEC_SCHEMA.slugPattern})` };
64
+ const stem = leaf.slice(0, -SUFFIX.length);
65
+ if (leaf !== NAV && !SLUG_RE.test(stem)) return { code: 'op-segment', reason: `"${stem}" is not a slug (${SPEC_SCHEMA.slugPattern})` };
66
+ return null;
67
+ };
68
+
69
+ // The slug a document owns — the folder name for an index.md, the file stem otherwise. The same
70
+ // rule the reader applies to `rel`, kept here because the checker asks it of WHOLE PATHS.
71
+ export const slugOf = (path) => {
72
+ const segments = path.slice(STORE.length).split('/');
73
+ const leaf = segments[segments.length - 1];
74
+ return leaf === NAV ? segments[segments.length - 2] ?? null : leaf.slice(0, -SUFFIX.length);
75
+ };
76
+
77
+ // The document that LISTS this one: `<dir>/index.md` for a leaf, ONE level up for an index.md
78
+ // (a promoted root is listed by its parent, never by itself). The store root is listed by nothing.
79
+ export const listingParentOf = (path) => {
80
+ const segments = path.slice(STORE.length).split('/');
81
+ const dirs = segments.slice(0, -1);
82
+ if (segments[segments.length - 1] === NAV) {
83
+ return dirs.length === 0 ? null : `${STORE}${[...dirs.slice(0, -1), NAV].join('/')}`;
84
+ }
85
+ return `${STORE}${[...dirs, NAV].join('/')}`;
86
+ };
87
+
88
+ // The shape a path plays. A path in TWO roles is refused rather than reconciled: "added and then
89
+ // removed" states two different post-states for one probe, and guessing which one the session meant
90
+ // is exactly the unresolved reference this module exists to refuse.
91
+ const SHAPES = Object.freeze({ 'rename-from,rename-from': 'fan-out', 'rename-to,rename-to': 'fan-in', 'rename-from,rename-to': 'chain' });
92
+ const roleConflict = (roles) => SHAPES[[...roles].sort().join(',')] ?? 'two roles';
93
+
94
+ // parseSpecOps(specs) -> { ops, errors }. Every op is judged, so one call names EVERY defect; any
95
+ // error at all empties `ops` — a partially-understood change set would attest a post-state nobody
96
+ // declared.
97
+ export const parseSpecOps = (specs) => {
98
+ const errors = [];
99
+ const ops = [];
100
+ const seen = new Set();
101
+ const roles = new Map();
102
+ const claim = (path, role, spec) => {
103
+ const held = roles.get(path);
104
+ if (held === undefined) roles.set(path, { role, spec });
105
+ else errors.push({ code: 'op-role', message: `"${spec}" and "${held.spec}" put ${path} in two roles (${roleConflict([held.role, role])})` });
106
+ };
107
+ for (const raw of specs ?? []) {
108
+ const spec = String(raw);
109
+ // Surrounding whitespace is REFUSED rather than trimmed: trimming would give one document a
110
+ // second accepted spelling, and the whole grammar rests on there being exactly one.
111
+ if (spec !== spec.trim()) {
112
+ errors.push({ code: 'op-grammar', message: `"${spec}" — leading or trailing whitespace is never trimmed away; write the op without it` });
113
+ continue;
114
+ }
115
+ const eq = spec.indexOf('=');
116
+ const verb = eq === -1 ? spec : spec.slice(0, eq);
117
+ const payload = eq === -1 ? '' : spec.slice(eq + 1);
118
+ if (!VERBS.includes(verb)) {
119
+ errors.push({ code: 'op-grammar', message: `"${spec}" — an op is verb=<target>, the verb one of ${VERBS.join('|')}` });
120
+ continue;
121
+ }
122
+ if (payload === '') {
123
+ errors.push({ code: 'op-grammar', message: `"${spec}" — an op is verb=<target>, and the target is never empty` });
124
+ continue;
125
+ }
126
+ if (seen.has(spec)) continue;
127
+ seen.add(spec);
128
+ const sides = verb === 'rename' ? payload.split(SEPARATOR) : [payload];
129
+ if (verb === 'rename' && sides.length !== 2) {
130
+ errors.push({ code: 'op-grammar', message: `"${spec}" — rename takes <old>${SEPARATOR}<new>: exactly one separator "${SEPARATOR}"` });
131
+ continue;
132
+ }
133
+ const defect = sides.map(targetDefect).find(Boolean);
134
+ if (defect) {
135
+ errors.push({ code: defect.code, message: `"${spec}" — ${defect.reason}` });
136
+ continue;
137
+ }
138
+ if (verb === 'rename' && sides[0] === sides[1]) {
139
+ errors.push({ code: 'op-role', message: `"${spec}" — a rename to itself (self) declares no change` });
140
+ continue;
141
+ }
142
+ if (verb === 'rename') {
143
+ claim(sides[0], 'rename-from', spec);
144
+ claim(sides[1], 'rename-to', spec);
145
+ ops.push({ verb, from: sides[0], to: sides[1] });
146
+ } else {
147
+ claim(payload, verb, spec);
148
+ ops.push({ verb, target: payload });
149
+ }
150
+ }
151
+ if (errors.length === 0 && ops.length === 0) {
152
+ errors.push({ code: 'op-empty', message: 'no op to judge — name at least one --op or --ops-file entry, or run --all' });
153
+ }
154
+ return { ops: errors.length > 0 ? [] : ops, errors };
155
+ };
156
+
157
+ // buildClosure(ops) -> [{ path, roles }] sorted: every op path PLUS the document that lists it.
158
+ // A listing parent that is itself an op target keeps its op role; two ops under one parent name it
159
+ // once; ops under different parents name both.
160
+ export const buildClosure = (ops) => {
161
+ const entries = new Map();
162
+ const put = (path, role) => entries.set(path, [...new Set([...(entries.get(path) ?? []), role])]);
163
+ const targets = [];
164
+ for (const op of ops) {
165
+ if (op.verb === 'rename') {
166
+ put(op.from, 'rename-from');
167
+ put(op.to, 'rename-to');
168
+ targets.push(op.from, op.to);
169
+ } else {
170
+ put(op.target, op.verb);
171
+ targets.push(op.target);
172
+ }
173
+ }
174
+ for (const target of targets) {
175
+ const parent = listingParentOf(target);
176
+ if (parent !== null && !entries.has(parent)) put(parent, 'listing-parent');
177
+ }
178
+ return [...entries]
179
+ .map(([path, roles]) => ({ path, roles }))
180
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
181
+ };
@@ -0,0 +1,381 @@
1
+ // spec-check.mjs — the structural checker of the feature-spec layer (spec layer 2b), ADVISORY.
2
+ //
3
+ // It answers ONE question per document: given what the session says it changed, does the store on
4
+ // disk say the same thing? The change source is EXPLICIT (parsed ops, never git), and "well-formed"
5
+ // has no second definition here — every per-document verdict is RELAYED from the ONE reader
6
+ // (references/scripts/spec-schema.mjs) and its 2a `structure` extraction. This module adds only what
7
+ // text alone cannot decide: does the post-state on disk match the declared op, is the document
8
+ // listed by its parent, does its scenario marker really occur once in the file it names, and — under
9
+ // --all — the five invariants that span documents.
10
+ //
11
+ // Two lanes:
12
+ // session the closure of the declared ops (targets + their listing parents), judged per document.
13
+ // --all every document under the store, plus: unlisted child (DISTINCT from orphan),
14
+ // acyclicity, store-wide slug uniqueness, module overlap. An ABSENT store root refuses.
15
+ //
16
+ // FAIL-CLOSED before every read. Containment is judged lexically AND by realpath over each
17
+ // path-bearing field, and the parents of a leaf are judged BEFORE the leaf is opened. The LEAF read
18
+ // itself is descriptor-bound and no-follow (the CLI half passes fs-read-nofollow.mjs in), so a
19
+ // pathname swapped after the probe cannot change the bytes judged. A mid-run PARENT swap is a
20
+ // STATED non-goal: this is an advisory checker, not a custody mechanism.
21
+ //
22
+ // No IO of its own: { read, probe, realpath, list } are injected. Dependency-free, Node >= 22.
23
+
24
+ import { dirname as pathDirname, isAbsolute, relative, sep } from 'node:path';
25
+ import { SPEC_SCHEMA, readSpecDocument, classifyPath } from '../references/scripts/spec-schema.mjs';
26
+ import { SPEC_OPS_GRAMMAR, buildClosure, listingParentOf, slugOf } from './spec-check-ops.mjs';
27
+
28
+ const STORE = SPEC_OPS_GRAMMAR.storePrefix;
29
+ const NAV = SPEC_OPS_GRAMMAR.navigator;
30
+ const STORE_ROOT = SPEC_OPS_GRAMMAR.storeRoot;
31
+ const STORE_DIR = STORE.slice(0, -1);
32
+
33
+ // The post-state each role declares. A listing parent declares NONE — it was not changed, it is read
34
+ // because something else was. `present` DOES declare one: the census already OBSERVED that document
35
+ // as a regular file, so a state that has changed underneath since is a fact to state, never a
36
+ // document to drop quietly from a store the run would otherwise call clean.
37
+ const DECLARED_STATE = Object.freeze({ add: 'file', modify: 'file', 'rename-to': 'file', remove: 'absent', 'rename-from': 'absent', present: 'file' });
38
+ const missing = () => {
39
+ throw new Error('spec-check: an IO dependency was not injected — this module owns no filesystem of its own');
40
+ };
41
+
42
+ const dirOf = (rel) => rel.slice(0, rel.lastIndexOf('/'));
43
+ const leafOf = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
44
+ const bare = (rel) => (rel.endsWith('/') ? rel.slice(0, -1) : rel);
45
+ const lineCount = (text) => text.replace(/\n$/, '').split('\n').length;
46
+ const occurrences = (text, needle) => text.split(needle).length - 1;
47
+ // Containment is a question about path COMPONENTS, and only the platform's own path model answers
48
+ // it. A textual prefix test reads "/repo\outside" as a child of "/repo" on a POSIX host — where the
49
+ // backslash is an ordinary filename character — and it mis-reads a filesystem root ("/" or "C:\")
50
+ // in both directions. `..` is compared as a whole SEGMENT, so a child named "..keep" stays inside.
51
+ const contained = (rootReal, real) => {
52
+ const rel = relative(rootReal, real);
53
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
54
+ };
55
+
56
+ // The link string a parent must carry for this child — verbatim, so `./x.md` and `./x/index.md`
57
+ // stay the distinct targets the 2a extraction froze them as.
58
+ const expectedLink = (rel, parent) => `./${rel.slice(parent.length - NAV.length)}`;
59
+
60
+ const verdictOf = (findings, documents, lane) => {
61
+ if (findings.length === 0) {
62
+ return { verdict: 'ACCEPT', exit: 0, findings, documents, lines: [`spec-check: ACCEPT — ${documents} document(s) clean (${lane})`] };
63
+ }
64
+ return {
65
+ verdict: 'REFUSE',
66
+ exit: 1,
67
+ findings,
68
+ documents,
69
+ lines: [
70
+ `spec-check: REFUSE — ${findings.length} finding(s) over ${documents} document(s) (${lane})`,
71
+ ...findings.map((f) => ` ${f.path}: ${f.rule} — ${f.message}`),
72
+ ],
73
+ };
74
+ };
75
+ const refusal = (message) => ({ verdict: 'REFUSE', exit: 2, findings: [], documents: 0, lines: [`spec-check: REFUSE — ${message}`] });
76
+
77
+ // Every document of the closure, read ONCE: probe, then (only for a regular file) the descriptor-
78
+ // bound read and the reader verdict. Containment of the containing directory is decided BEFORE the
79
+ // read, so a directory that resolves outside the root is never opened through.
80
+ const readClosure = (closure, ctx) => {
81
+ const { io, at, rootReal, add } = ctx;
82
+ const docs = new Map();
83
+ for (const { path, roles } of closure) {
84
+ if (classifyPath(path) !== 'file' || !path.startsWith(STORE)) {
85
+ add('contained', path, 'the path is not a repo-relative file inside the store');
86
+ continue;
87
+ }
88
+ const dirReal = io.realpath(at(dirOf(path)));
89
+ const dirContained = dirReal !== null && contained(rootReal, dirReal);
90
+ if (dirReal !== null && !dirContained) {
91
+ add('contained', path, `its directory ${dirOf(path)} resolves outside the root (${dirReal})`);
92
+ continue;
93
+ }
94
+ const state = io.probe(at(path));
95
+ const doc = { path, roles, state, verdict: null, lines: 0, ground: [], edges: [] };
96
+ docs.set(path, doc);
97
+ const declared = roles.map((role) => DECLARED_STATE[role]).find(Boolean);
98
+ if (declared && state !== declared) {
99
+ const source = roles.includes('present')
100
+ ? 'the census observed this document as a regular file'
101
+ : `the op declares this document ${declared === 'absent' ? 'gone' : 'present as a regular file'}`;
102
+ add('post-state', path, `${source}, the store now says "${state}"`);
103
+ }
104
+ if (state !== 'file') continue;
105
+ // Fail-closed is PROVEN contained, not "not proven to escape": a directory whose realpath does
106
+ // not resolve was never observed, so the leaf inside it is never opened.
107
+ if (!dirContained) {
108
+ add('contained', path, `its directory ${dirOf(path)} does not resolve, so the read is refused (fail closed)`);
109
+ continue;
110
+ }
111
+ const read = io.read(at(path));
112
+ if (read.outcome !== 'ok') {
113
+ add('unreadable', path, `the descriptor-bound read says ${read.outcome}${read.className ? ` (${read.className})` : ''}${read.code ? ` (${read.code})` : ''} — the probe is not what is judged`);
114
+ continue;
115
+ }
116
+ doc.lines = lineCount(read.content);
117
+ doc.verdict = readSpecDocument(read.content, path.slice(STORE.length));
118
+ }
119
+ return docs;
120
+ };
121
+
122
+ const linkTargetsOf = (doc) => [...(doc?.verdict?.structure?.children ?? []), ...(doc?.verdict?.structure?.parts ?? [])].map((link) => link.target);
123
+
124
+ // Every LISTED edge is a claim about another document, and an unchecked claim is what lets a broken
125
+ // or escaping target ride into the reachability graph as if it were reached. Each edge is resolved,
126
+ // contained (lexically AND by realpath) and PROBED; only an observed regular file becomes an edge.
127
+ const judgeEdges = (doc, ctx) => {
128
+ const { io, at, rootReal, add } = ctx;
129
+ for (const target of linkTargetsOf(doc)) {
130
+ const child = `${dirOf(doc.path)}/${target.slice(2)}`;
131
+ if (classifyPath(child) !== 'file' || !child.startsWith(STORE)) {
132
+ add('link', doc.path, `it lists ${target}, which is not a document inside the store`);
133
+ continue;
134
+ }
135
+ const real = io.realpath(at(child));
136
+ if (real === null || !contained(rootReal, real)) {
137
+ add('link', doc.path, `it lists ${target}, which ${real === null ? 'does not resolve' : `resolves outside the root (${real})`}`);
138
+ continue;
139
+ }
140
+ const state = io.probe(at(child));
141
+ if (state !== 'file') {
142
+ add('link', doc.path, `it lists ${target}, which the store reports as "${state}" — a listed child is a regular file`);
143
+ continue;
144
+ }
145
+ doc.edges.push(child);
146
+ }
147
+ };
148
+
149
+ // The per-document judgement, identical in both lanes: the relayed reader rules, the kind's own
150
+ // line cap, the listed edges, the D4 scenario bindings and the containment of every path it names.
151
+ const judgeDocument = (doc, ctx) => {
152
+ const { io, at, rootReal, add } = ctx;
153
+ const { path, verdict } = doc;
154
+ if (verdict === null) return;
155
+ for (const error of verdict.errors) add('reader', path, `${error.rule}: ${error.message}`, { readerRule: error.rule });
156
+ const cap = SPEC_SCHEMA.maxLines[verdict.kind];
157
+ if (cap !== undefined && doc.lines > cap) {
158
+ add('threshold', path, `${doc.lines} lines over the ${verdict.kind} cap of ${cap} — promote it to <slug>/index.md + parts`);
159
+ }
160
+ const structure = verdict.structure;
161
+ if (structure === null) return;
162
+ // A module names the code the contract governs. Ground that is absent, unreadable or of the OTHER
163
+ // kind is a claim about code that is not there — and only a PROVEN canonical path is kept, so the
164
+ // store-wide overlap comparison never mixes observed ground with a lexical guess.
165
+ for (const claimed of structure.module?.paths ?? []) {
166
+ const wantDir = claimed.endsWith('/');
167
+ const target = bare(claimed);
168
+ const real = io.realpath(at(target));
169
+ if (real === null) {
170
+ add('module', path, `the module path ${claimed} names ground that is not there`);
171
+ continue;
172
+ }
173
+ if (!contained(rootReal, real)) {
174
+ add('contained', path, `the module path ${claimed} resolves outside the root (${real})`);
175
+ continue;
176
+ }
177
+ const state = io.probe(at(target));
178
+ if (state !== (wantDir ? 'dir' : 'file')) {
179
+ add('module', path, `the module path ${claimed} is "${state}", not the ${wantDir ? 'directory' : 'file'} it declares`);
180
+ continue;
181
+ }
182
+ doc.ground.push(real);
183
+ }
184
+ judgeEdges(doc, ctx);
185
+ for (const scenario of structure.scenarios) {
186
+ if (scenario.binding === null) continue;
187
+ const { file, marker } = scenario.binding;
188
+ if (classifyPath(file) !== 'file') {
189
+ add('binding', path, `S${scenario.ordinal} names "${file}", which is not a repo-relative file`);
190
+ continue;
191
+ }
192
+ const real = io.realpath(at(file));
193
+ if (real === null) {
194
+ add('binding', path, `S${scenario.ordinal} binds ${file}, which does not resolve — an unobserved path is never opened`);
195
+ continue;
196
+ }
197
+ if (!contained(rootReal, real)) {
198
+ add('contained', path, `the S${scenario.ordinal} binding ${file} resolves outside the root (${real})`);
199
+ continue;
200
+ }
201
+ const state = io.probe(at(file));
202
+ if (state !== 'file') {
203
+ add('binding', path, `S${scenario.ordinal} binds ${file}, which the store reports as "${state}"`);
204
+ continue;
205
+ }
206
+ const read = io.read(at(file));
207
+ if (read.outcome !== 'ok') {
208
+ add('binding', path, `S${scenario.ordinal} binds ${file}, which cannot be read (${read.outcome})`);
209
+ continue;
210
+ }
211
+ const found = occurrences(read.content, marker);
212
+ if (found !== 1) add('binding', path, `the marker ${marker} occurs ${found} time(s) in ${file} — exactly once binds a scenario`);
213
+ }
214
+ };
215
+
216
+ // The listing judgement — a SESSION-lane rule: the document the ops touched must be listed by its
217
+ // parent exactly once after an add/modify/rename-to, and not at all after a remove/rename-from.
218
+ // Under --all the reachability invariants below own this ground instead, so it never doubles up.
219
+ const judgeListing = (doc, docs, add) => {
220
+ const declared = doc.roles.map((role) => DECLARED_STATE[role]).find(Boolean);
221
+ if (!declared) return;
222
+ const parent = listingParentOf(doc.path);
223
+ if (parent === null) return;
224
+ const holder = docs.get(parent);
225
+ const link = expectedLink(doc.path, parent);
226
+ if (!holder || holder.state !== 'file' || holder.verdict === null) {
227
+ add('listed', doc.path, `its listing parent ${parent} cannot be read, so nothing states whether ${link} is listed`);
228
+ return;
229
+ }
230
+ const found = linkTargetsOf(holder).filter((target) => target === link).length;
231
+ if (declared === 'file' && found !== 1) add('listed', doc.path, `${parent} lists ${link} ${found} time(s) — a present document is listed exactly once`);
232
+ if (declared === 'absent' && found !== 0) add('listed', doc.path, `${parent} still lists ${link} — a removed document is listed by nobody`);
233
+ };
234
+
235
+ // Every `.md` document under the store, found by LISTING rather than by trusting any index. The walk
236
+ // is what makes "unlisted" and "orphan" observable at all — so a branch it could not observe is a
237
+ // FINDING, never an empty directory quietly walked past: an incomplete census that reported a clean
238
+ // store would be the one answer this lane must never give. A directory is contained BEFORE it is
239
+ // listed, and a non-regular `.md` sitting in the store is stated rather than skipped.
240
+ const walkStore = (ctx) => {
241
+ const { io, at, rootReal, add } = ctx;
242
+ const found = [];
243
+ const stack = [STORE_DIR];
244
+ while (stack.length > 0) {
245
+ const dir = stack.pop();
246
+ const dirReal = io.realpath(at(dir));
247
+ if (dirReal === null || !contained(rootReal, dirReal)) {
248
+ add('census', dir, `the store directory ${dirReal === null ? 'does not resolve' : `resolves outside the root (${dirReal})`}, so what it holds was never observed`);
249
+ continue;
250
+ }
251
+ const names = io.list(at(dir));
252
+ if (names === null) {
253
+ add('census', dir, 'the store directory cannot be listed, so this branch of the census is unobserved');
254
+ continue;
255
+ }
256
+ for (const name of names) {
257
+ const rel = `${dir}/${name}`;
258
+ const state = io.probe(at(rel));
259
+ if (state === 'dir') stack.push(rel);
260
+ else if (state === 'file' && name.endsWith(SPEC_OPS_GRAMMAR.suffix)) found.push(rel);
261
+ // Every OTHER entry is stated, `.md` or not. A symlinked DIRECTORY is the hole a `.md`-only
262
+ // census leaves: an edge can be resolved THROUGH it while the documents behind it were never
263
+ // observed, read or judged — and --all would then accept a store it never fully saw.
264
+ else add('census', rel, `a ${state} sits inside the store — it holds regular ${SPEC_OPS_GRAMMAR.suffix} documents and plain directories only`);
265
+ }
266
+ }
267
+ return found.sort();
268
+ };
269
+
270
+ // Reachability from the store root over the extracted child/part links. Identity is the realpath of
271
+ // the containing DIRECTORY plus the leaf name — that is what makes a symlinked folder pointing at an
272
+ // ancestor a cycle rather than an infinite walk, and it is judged on the trail, not on the visited
273
+ // set, so a diamond (two indexes listing one document) is not mistaken for a loop.
274
+ const reachStore = (docs, ctx) => {
275
+ const { io, at } = ctx;
276
+ const reached = new Set();
277
+ const cycles = [];
278
+ const identity = (rel) => `${io.realpath(at(dirOf(rel))) ?? dirOf(rel)}/${leafOf(rel)}`;
279
+ const visit = (rel, trail) => {
280
+ const id = identity(rel);
281
+ if (trail.has(id)) {
282
+ cycles.push(rel);
283
+ return;
284
+ }
285
+ if (reached.has(rel)) return;
286
+ reached.add(rel);
287
+ const next = new Set([...trail, id]);
288
+ // PROVEN edges only (judgeEdges): a phantom target would launder an orphan into a reached
289
+ // document and hide exactly what this lane exists to find.
290
+ for (const child of docs.get(rel)?.edges ?? []) visit(child, next);
291
+ };
292
+ visit(STORE_ROOT, new Set());
293
+ return { reached, cycles };
294
+ };
295
+
296
+ // The four cross-document invariants (the fifth — an absent store root — refuses the run before any
297
+ // of them can be asked). Each is a question no single document can answer about itself.
298
+ const judgeStore = (docs, ctx) => {
299
+ const { add } = ctx;
300
+ const { reached, cycles } = reachStore(docs, ctx);
301
+ for (const rel of cycles) add('acyclic', rel, 'the child graph reaches this document from inside itself — a cycle, not a tree');
302
+ for (const [rel, doc] of docs) {
303
+ if (reached.has(rel) || doc.state !== 'file') continue;
304
+ const parent = listingParentOf(rel);
305
+ if (parent !== null && reached.has(parent)) {
306
+ add('unlisted-child', rel, `${parent} is reached from the store root but does not list ${expectedLink(rel, parent)}`);
307
+ } else {
308
+ add('orphan', rel, 'no index reaches this document — it is not an unlisted child, it is outside the tree entirely');
309
+ }
310
+ }
311
+ const bySlug = new Map();
312
+ for (const [rel, doc] of docs) {
313
+ const slug = doc.state === 'file' ? slugOf(rel) : null;
314
+ if (slug === null || rel === STORE_ROOT) continue;
315
+ bySlug.set(slug, [...(bySlug.get(slug) ?? []), rel]);
316
+ }
317
+ for (const [slug, paths] of bySlug) {
318
+ if (paths.length > 1) for (const rel of paths) add('slug-unique', rel, `the slug "${slug}" is claimed by ${paths.length} documents (${paths.join(', ')}) — a slug is store-wide`);
319
+ }
320
+ // PROVEN canonical ground only (judgeDocument) — a path that never resolved is not compared as if
321
+ // it had. Nesting is asked with the platform's own path model — `dirname` walked upward, never a
322
+ // hand-rolled separator rule — and it is asked ONCE PER PATH rather than once per pair: the store
323
+ // this lane exists for holds a thousand specs, and a pairwise sweep would spend the whole D-scale
324
+ // budget re-deciding the same question a million times.
325
+ const owners = new Map();
326
+ for (const doc of docs.values()) {
327
+ for (const claimed of doc.ground) owners.set(claimed, new Set([...(owners.get(claimed) ?? []), doc.path]));
328
+ }
329
+ // The index accumulates unique conflicting PAIRS, which is the granularity a pairwise sweep
330
+ // reported: one finding per document per pair. Counting per ground path instead would repeat a
331
+ // pair once per descendant it shares, and a three-level chain would fuse unrelated pairs into one
332
+ // lumped message.
333
+ const pairs = new Map();
334
+ // The key is JSON, not a joined string: the census lists what is ON DISK, so a document path can
335
+ // carry any byte a filesystem allows — a space included — long before the reader refuses its slug.
336
+ // Any separator those paths might themselves contain makes the key non-injective, and the pair
337
+ // that collides would be dropped in silence.
338
+ const note = (one, other, claimed) => {
339
+ if (one === other) return;
340
+ const [a, b] = [one, other].sort();
341
+ const key = JSON.stringify([a, b]);
342
+ if (!pairs.has(key)) pairs.set(key, { a, b, claimed });
343
+ };
344
+ for (const [claimed, held] of owners) {
345
+ const owned = [...held];
346
+ for (const [i, one] of owned.entries()) for (const other of owned.slice(i + 1)) note(one, other, claimed);
347
+ for (let up = pathDirname(claimed), below = claimed; up !== below; below = up, up = pathDirname(up)) {
348
+ for (const above of owners.get(up) ?? []) for (const one of owned) note(one, above, claimed);
349
+ }
350
+ }
351
+ for (const { a, b, claimed } of pairs.values()) {
352
+ add('overlap', a, `its module ground overlaps ${claimed}, also claimed by ${b} — two contracts, one piece of code`);
353
+ add('overlap', b, `its module ground overlaps ${claimed}, also claimed by ${a} — two contracts, one piece of code`);
354
+ }
355
+ };
356
+
357
+ // checkSpecs({ root, ops, all }, deps) -> { verdict, exit, findings, documents, lines }.
358
+ // Exit 0 ACCEPT · 1 one or more findings · 2 a refusal about the RUN itself (an unresolvable root,
359
+ // an absent store root under --all) — the CLI prints the lines and returns the code.
360
+ export const checkSpecs = ({ root, ops = [], all = false } = {}, deps = {}) => {
361
+ const io = { read: missing, probe: missing, realpath: missing, list: missing, ...deps };
362
+ const at = (rel) => (rel === '' ? root : `${root}/${rel}`);
363
+ const rootReal = io.realpath(root);
364
+ if (rootReal === null) return refusal(`--root "${root}" does not resolve to a directory — nothing can be judged against it`);
365
+ const findings = [];
366
+ const add = (rule, path, message, extra = {}) => findings.push({ rule, path, message, ...extra });
367
+ const ctx = { io, at, rootReal, add };
368
+ if (all && io.probe(at(STORE_ROOT)) !== 'file') {
369
+ return refusal(`the store root ${STORE_ROOT} is not a regular file — an --all run over no store would report an empty clean store, so it refuses instead`);
370
+ }
371
+ const closure = all ? walkStore(ctx).map((path) => ({ path, roles: ['present'] })) : buildClosure(ops);
372
+ // A census that observed nothing still established one fact: its own refusal. Collapsing that into
373
+ // the empty-closure usage error would throw away the only thing the run learned, so the usage
374
+ // refusal is the SESSION lane's alone.
375
+ if (closure.length === 0 && findings.length === 0) return refusal('no document to judge — the op closure is empty');
376
+ const docs = readClosure(closure, ctx);
377
+ for (const doc of docs.values()) judgeDocument(doc, ctx);
378
+ if (all) judgeStore(docs, ctx);
379
+ else for (const doc of docs.values()) judgeListing(doc, docs, add);
380
+ return verdictOf(findings, closure.length, all ? 'the whole store' : `${ops.length} op(s)`);
381
+ };