@sabaiway/agent-workflow-kit 7.5.0 → 8.0.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,78 @@ 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
+ ## 8.0.0 — the bundled reader gets the scenario floor, and the refresh lane carries it out (AD-117)
8
+
9
+ Memory **5.0.0** gives the spec reader one new refusal — a `## Scenarios` section carrying no
10
+ scenario line — and this kit ships those bytes. Nothing in the kit's own logic changed shape; what
11
+ changed is what the deployed reader says about a document, and therefore what a project's spec gate
12
+ says about its store.
13
+
14
+ > ### ⚠ BREAKING — inherited from memory 5.0.0
15
+ >
16
+ > `spec-check` (both lanes) and the `spec-check` gate row relay the reader's verdict, so a spec that
17
+ > pins NOTHING now turns a deployment red with no edit of its own. The remedy is one line per
18
+ > scenario — `- S<N> <name> :: unbound` while no test pins it. The refusal is the migration signal:
19
+ > a contract that pins nothing is the one shape the layer exists to refuse.
20
+
21
+ - **`references/scripts/spec-schema{,.test}.mjs`** — refreshed to the 5.0.0 bodies; the four mirrored
22
+ copies stay byte-identical, `sync-mirrors --check` green.
23
+ - **`tools/script-priors.mjs`** — the catalog gains the two OUTGOING `4.7.0..4.7.0` reader rows
24
+ (`spec-schema.mjs`, `spec-schema.test.mjs`), read from the released bytes, so an unmodified 4.7.0
25
+ deployment is recognised as unmodified and converges on the next kit touch instead of stranding as
26
+ `custom`. The independent `FROZEN_PRIORS` pin in the test moves with it — 8 catalog rows, none
27
+ equal to the bundled body.
28
+ - **Obligation discharged: KIT-SUBTREE-CHANGED-WITHOUT-A-BUMP.** Slice 4 wrote 19 spec-binding
29
+ markers into this package's subtree and shipped no bump. This release carries them.
30
+
31
+
32
+ ## 7.6.0 — `spec-check`: the spec store judged against what the session SAYS it changed (AD-115)
33
+
34
+ The feature-spec layer gets its structural checker. The change source is EXPLICIT and never git — a
35
+ session states what it did, and the checker decides whether the store on disk says the same thing.
36
+ "Well-formed" keeps its ONE definition: every per-document verdict is RELAYED from the shipped
37
+ reader's 7.5.0 `structure` extraction, and this release adds no second parser.
38
+
39
+ - **`tools/spec-check-ops.mjs` — one accepted spelling per document.** Four verbs
40
+ (`add` | `modify` | `remove` | `rename=<old>:<new>`) over POSIX repo-relative `.md` paths inside
41
+ `docs/ai/specs/` whose every segment is D-schema shaped. Nothing is normalized away: `./x.md`,
42
+ `a//b.md`, `a/../b.md`, a backslash, a drive letter, an absolute path and a trailing-slash
43
+ directory all REFUSE rather than resolving to some other path — so a dedup can be an equality, and
44
+ because no accepted target can carry a `:` the rename separator is unambiguous. A path holds at
45
+ most ONE role (self-rename, fan-out, fan-in, chain and any two-role clash refuse); the store root
46
+ is never a target; any error empties the whole op list.
47
+ - **`tools/spec-check.mjs` — the judge, with no filesystem of its own.** `{read, probe, realpath,
48
+ list}` are injected and an uninjected one throws. The SESSION lane judges the closure of the
49
+ declared ops (targets plus each one's listing parent) on the post-state, the reader verdict, the
50
+ per-kind line cap, the scenario bindings (the marker must occur EXACTLY once in the file the
51
+ scenario names) and the containment of every path each document names. A `## Module` path must
52
+ also EXIST with the kind it declares — a `dir/` root resolving to a directory, a file-list entry
53
+ to a regular file — so a contract can no longer name code that is not there. `--all` judges the
54
+ whole store instead and adds the four questions no document can answer about itself: an unlisted
55
+ child (DISTINCT from an orphan), acyclicity, store-wide slug uniqueness and module overlap.
56
+ - **Fail-closed means PROVEN contained.** A path whose realpath does not resolve is never read.
57
+ Containment asks the platform's own path model, because a textual prefix test reads
58
+ `/repo\outside` as a child of `/repo` on POSIX and mis-reads a filesystem root in both directions.
59
+ Every listed edge is resolved, contained and probed before it may enter the reachability graph — a
60
+ phantom target would otherwise be marked reached and launder an orphan into a reached document.
61
+ The census admits a CLOSED set — plain directories and regular `.md` files — and states everything
62
+ else: an unlistable directory, one that resolves outside the root, any non-regular entry, and a
63
+ regular file that is not a spec document. A census that observed nothing keeps its own refusal
64
+ instead of collapsing into a usage error. The leaf read is descriptor-bound and no-follow.
65
+ - **`tools/spec-check-cli.mjs` — the IO shell.** `--op` (repeatable) unions `--ops-file`, `--all` is
66
+ exclusive of both, `--root` defaults to the cwd. The register is never defaulted: guessing which
67
+ file states the change set would attest a post-state nobody declared. Blank and `#` lines parse
68
+ away, CRLF reads like LF, and every other line is passed on UNTRIMMED so both op sources accept
69
+ exactly the same strings. Exit 0 clean, 1 findings, 2 usage.
70
+ - **`tools/procedures.mjs` — the advisor names it.** A `plan-execution` render now carries the
71
+ populated `spec-check` commands for both lanes and the session register they read, plus the
72
+ additive `specCheck` `--json` key. Advisory, like the finding-scope block beside it: nothing
73
+ records that it ran.
74
+ - **Proof.** Every suite authored RED first and observed failing before its module existed; the
75
+ D-scale gate measures the checker's OWN median (573 ms) beside the unchanged hook median against
76
+ the 1500 ms budget, so a slower checker can never pass by sitting inside the hook's headroom.
77
+ Tarball count 247 -> 250.
78
+
7
79
  ## 7.5.0 — the reader pair joins the known-prior refresh lane; the mirrored reader gains the structure verdict (AD-114)
8
80
 
9
81
  Slice 2b's `spec-check` will need a structural read of a spec document, and the reader is the ONE
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.5.0'
6
+ version: '8.0.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.5.0",
6
+ "version": "8.0.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.5.0",
3
+ "version": "8.0.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.
@@ -38,7 +38,7 @@ export const SPEC_SCHEMA = Object.freeze({
38
38
  'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
39
39
  'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
40
40
  'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
- 'scenario-number', 'scenario-marker', 'scenario-path', 'out-of-scope', 'module-line', 'module-empty',
41
+ 'scenario-number', 'scenario-marker', 'scenario-path', 'scenarios-empty', 'out-of-scope', 'module-line', 'module-empty',
42
42
  'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
43
43
  ]),
44
44
  });
@@ -238,6 +238,10 @@ const checkScenarios = (parsed, slug, status, errors, warnings) => {
238
238
  }
239
239
  scenarios.push(scenario);
240
240
  }
241
+ if (scenarios.length === 0) {
242
+ errors.push({ rule: 'scenarios-empty', message: `at least one scenario line (\`${SPEC_SCHEMA.unboundMarker}\` while no test pins it)` });
243
+ return;
244
+ }
241
245
  const gap = scenarios.findIndex((scenario, i) => scenario.n !== i + 1);
242
246
  if (gap !== -1) errors.push({ rule: 'scenario-number', message: `scenario ${gap + 1} is numbered S${scenarios[gap].n} — N runs contiguously from 1` });
243
247
  for (const scenario of scenarios) {
@@ -131,6 +131,14 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
131
131
  refuses(specDoc({ scenarios: ['- S1 a :: /abs/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
132
132
  });
133
133
 
134
+ it('scenarios-empty / no empty-marker escape', () => {
135
+ refuses(specDoc({ scenarios: [] }), 'login.md', 'scenarios-empty');
136
+ refuses(specDoc({ scenarios: ['*(empty)*'] }), 'login.md', 'scenario-line');
137
+ for (const status of SPEC_SCHEMA.statuses) {
138
+ expect(readSpecDocument(specDoc({ scenarios: ['- S1 x :: unbound'], fields: { status } }), 'login.md').errors).toEqual([]);
139
+ }
140
+ });
141
+
134
142
  it('out-of-scope / module-empty / module-* path refusals / module-mix / parts', () => {
135
143
  refuses(specDoc({ outOfScope: '' }), 'login.md', 'out-of-scope');
136
144
  refuses(specDoc({ outOfScope: 'nothing excluded' }), 'login.md', 'out-of-scope');
@@ -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
  }
@@ -31,6 +31,8 @@ export const SCRIPT_PRIORS = Object.freeze([
31
31
  prior('check-docs-size.test.mjs', '4.0.0', '4.5.4', '88fbb3d7f097d74771b7c5d9ad99fcd58b274ae33f391e1ff01f4b138b9236cd'),
32
32
  prior('spec-schema.mjs', '4.6.0', '4.6.1', 'f8ee23d81e90fd4225ca4ece288cba41982c4430290bc6d033f5ca18d2d283f4'),
33
33
  prior('spec-schema.test.mjs', '4.6.0', '4.6.1', 'a12d6d3f5d32c6dabdee7e15af7d2ab15a0ced37515d1844fe0951f60cddbc99'),
34
+ prior('spec-schema.mjs', '4.7.0', '4.7.0', '40b5b038d5ec5ed53c327c6d269d22fe5fa2bed99ae711fbf84306ad047be452'),
35
+ prior('spec-schema.test.mjs', '4.7.0', '4.7.0', 'fde896419924223e54cfcabfdb1ef5807df5386fac700ed7c1463b6e7f81501b'),
34
36
  ]);
35
37
 
36
38
  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
+ };