@sabaiway/agent-workflow-memory 4.5.4 → 4.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,50 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
6
6
 
7
+ ## 4.6.0 — the feature-spec layer: a store under `docs/ai/specs/`, one text-only reader, a counted navigator row (AD-112)
8
+
9
+ A deployed project gains a middle altitude of durable knowledge between the whole-project
10
+ `technical_specification.md` and the ephemeral plan: one contract per feature — what it accepts and
11
+ returns, which scenarios pin it, and, the part the family actually suffers from, what is OUT of its
12
+ scope. This is an adapted layer in the substrate's own vocabulary, not a methodology import: the
13
+ existing plan-approval checkpoint confirms a contract, the existing `maxLines` caps bound every file,
14
+ the existing `[[AD-NNN]]` convention links the why, and hidden mode covers the store with no new
15
+ machinery (`/docs/ai/` already subsumes it).
16
+
17
+ - **`references/scripts/spec-schema.mjs`** (+ its deploy-payload test) — the ONE reader that DEFINES
18
+ a well-formed spec. Pure text in (`readSpecDocument(text, rel)`), a verdict out; it imports nothing
19
+ and opens no file, so it seeds layout-free into any deployment. It carries the frozen schema
20
+ (`SPEC_SCHEMA`: kinds `index|spec|part`, statuses `draft|live|retired`, fan-out 30, promotion at
21
+ `maxLines: 150`, the kebab slug, the scenario-binding grammar, the module-root grammar) and names
22
+ exactly ONE rule id per defect — 33 rules, each pinned by a repo-only fixture. It models no markdown
23
+ code: a fence line refuses, a spec carries no code sample.
24
+ - **`references/scripts/check-docs-size.mjs`** — the ADR-only collapse became a GROUPS loop the spec
25
+ store joins: every file the reader accepts folds into ONE `specs/` navigator row with live counts
26
+ (specs / parts / indexes), so adding or removing a valid spec changes the generated index and
27
+ `--check-index` sees it; a file the reader refuses keeps its own visible row and its schema
28
+ refusal surfaces as `spec <rule>:` WARNINGS — advisory (the structural checker is a later slice);
29
+ the substrate's own frontmatter and `maxLines` errors stay blocking, as for every `docs/ai` file.
30
+ The 591-line ratchet held by trimming prose, not by raising a record.
31
+ - **`references/templates/specs/index.md`** — the seed store root (`kind: index`, the exact up-link
32
+ line to `technical_specification.md`, an empty `## Children`); deployed by the bootstrap like the
33
+ `adr/` seed. **`references/templates/SPEC_TEMPLATE.md`** — the `kind: spec` authoring reference
34
+ with one bound and one unbound scenario; skill-home only, excluded by NAME like `adr-record.md` —
35
+ in the SKILL prose AND in the bootstrap E2E copy loop, which also runs the real installed
36
+ pre-commit hook over a seeded spec and asserts the one counted row.
37
+ - **`references/templates/agent_rules.md`** §1.2/§1.3 — name the governing spec(s) before a
38
+ feature (zero, one or many; page-only coverage governs as an adoption shim), the revision lands
39
+ with the code; the `### 2.6` lens region carries the engine's new `Spec-first` bullet.
40
+ **`references/templates/AGENTS.md`** — the `technical_specification.md` Memory-Map row now points
41
+ at the contracts under `docs/ai/specs/` (96 lines; the cap is 100).
42
+ - **Scale is a release gate.** 1000 valid specs in a 30-per-folder tree: both hook runs sum to a
43
+ median of 843 ms against a 1500 ms budget (pre-slice 0.75 s for 1161 docs); over budget blocks the
44
+ release.
45
+
46
+ **Reaches a FRESH bootstrap only.** An existing deployment keeps its old checker (which never imports
47
+ the reader) and has no `docs/ai/specs/` to collapse; delivery to existing deployments — the `specs`
48
+ ensure op, the layout-free reader seed, the prior-matching checker refresh, an upgrade E2E — is slice
49
+ 1b of the same series.
50
+
7
51
  ## 4.5.4 — the deployed `agent_rules.md` carries the state-table clause (AD-111)
8
52
 
9
53
  Template-only follow-up to engine 3.2.0: the rendered lens block in
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '4.5.4'
6
+ version: '4.6.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -102,11 +102,13 @@ bootstrapping over a live system, but the user makes the final call.
102
102
  (`ln -s AGENTS.md CLAUDE.md`). **Leave ALL THREE pointer slots (`workflow:methodology` +
103
103
  `workflow:orchestration` + `workflow:autonomy`) exactly as shipped — empty.** Filling them is the
104
104
  composition root's job.
105
- 6. **Deploy `docs/ai/`.** Create the files + `pages/` + the seed `adr/` store from
106
- `${CLAUDE_SKILL_DIR}/references/templates/` (every non-`AGENTS.md`, non-`adr-record.md` template —
107
- the latter is a skill-home authoring reference, never deployed). This deploys the HOT ADR window
108
- `docs/ai/decisions.md` **and** the seed navigator `docs/ai/adr/log.md` (its `adr/` subdir copies
109
- in wholesale), plus the three seeded, **user-editable** strict-JSON configs:
105
+ 6. **Deploy `docs/ai/`.** Create the files + `pages/` + the seed `adr/` store + the seed `specs/`
106
+ store from `${CLAUDE_SKILL_DIR}/references/templates/` (every template except `AGENTS.md`,
107
+ `adr-record.md` and `SPEC_TEMPLATE.md` — the latter two are skill-home authoring references, never
108
+ deployed). This deploys the HOT ADR window `docs/ai/decisions.md` **and** the seed navigator
109
+ `docs/ai/adr/log.md` (its `adr/` subdir copies in wholesale), the spec store root
110
+ `docs/ai/specs/index.md` (its `specs/` subdir copies in wholesale; feature contracts are authored
111
+ under it from `SPEC_TEMPLATE.md`), plus the three seeded, **user-editable** strict-JSON configs:
110
112
  `docs/ai/orchestration.json` — the per-project recipe defaults the composition root's `procedures`
111
113
  advisor reads — `docs/ai/gates.json` — the project's gate declaration, an empty list to fill with
112
114
  its own verification commands, consumed by the composition root's gate runner — and
@@ -333,9 +335,12 @@ The three setup choices each have a full contract in
333
335
  - [`references/templates/`](references/templates/) — stack-agnostic `AGENTS.md` (with the three empty
334
336
  pointer slots — methodology + orchestration + autonomy), `agent_rules.md`, the seeded user-editable
335
337
  `orchestration.json` + `autonomy.json` configs, the `adr-record.md` ADR authoring reference + the
336
- seed `adr/log.md` navigator, and all `docs/ai/` files to deploy.
338
+ seed `adr/log.md` navigator, the `SPEC_TEMPLATE.md` spec authoring reference + the seed
339
+ `specs/index.md` store root, and all `docs/ai/` files to deploy.
337
340
  - [`references/scripts/`](references/scripts/) — the Node enforcement scripts (caps + staleness +
338
- index-freshness gate, one-file-per-ADR archive, hook installer) and their unit tests.
341
+ index-freshness gate with the counted `specs/` collapse, the import-free spec reader
342
+ `spec-schema.mjs` that defines a well-formed spec, one-file-per-ADR archive, hook installer) and
343
+ their unit tests.
339
344
  - [`scripts/stamp-takeover.mjs`](scripts/stamp-takeover.mjs) — the upgrade-time lineage state
340
345
  machine (`LINEAGE_HEAD`, atomic stamp writes) + tests.
341
346
  - [`migrations/`](migrations/) — per-version upgrade steps, incl. `legacy-stamp-takeover.md`;
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "4.5.4",
6
+ "version": "4.6.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "4.5.4",
3
+ "version": "4.6.0",
4
4
  "description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -29,6 +29,7 @@ import { existsSync, lstatSync, realpathSync } from 'node:fs';
29
29
  import { dirname, resolve, relative, join, basename, sep } from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { randomBytes } from 'node:crypto';
32
+ import { readSpecDocument, SPECS_COLLAPSE } from './spec-schema.mjs';
32
33
 
33
34
  const __filename = fileURLToPath(import.meta.url);
34
35
  const __dirname = dirname(__filename);
@@ -36,10 +37,8 @@ const ROOT = resolve(__dirname, '..');
36
37
  const DOCS_DIR = resolve(ROOT, 'docs/ai');
37
38
  const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
38
39
 
39
- // Root-parameterized (BUGFREE-3 / AD-049, item (h)): the module ROOT constants are the CLI DEFAULT
40
- // (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
41
- // override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
42
- // an arbitrary root's index without ever touching the real repo tree.
40
+ // The module ROOT constants are the CLI DEFAULT (this deployment's own root); `--root=<dir>` and the
41
+ // exported `regenerateIndex(root, today)` target an arbitrary root (the ADR-rotation hook, hermetic tests).
43
42
  const pathsFor = (root) => {
44
43
  const base = resolve(root);
45
44
  return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
@@ -58,12 +57,9 @@ const ENSURE_INDEX_PREFIX = 'ensure-index:';
58
57
  const DEFAULT_PROJECT_NAME = 'this project';
59
58
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
60
59
 
61
- // `strict` is the finalizer's lens on the SAME walk: for a report, an unreadable subtree is fairly
62
- // skipped, but a run that WRITES the navigator may not silently treat "could not read" as "nothing
63
- // there" it would publish an index missing whatever it could not see and call that success. Only
64
- // a genuine ENOENT stays an absence; every other fs error propagates.
65
- // Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
66
- // not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
60
+ // `strict` is the finalizer's lens on the SAME walk: a run that WRITES the navigator may not treat
61
+ // "could not read" as "nothing there". Only a genuine ENOENT is an absence a code-LESS throw (an
62
+ // injected reader, a wrapped client) propagates too: "unknown" must never read as "empty".
67
63
  const rethrowUnlessAbsent = (err, strict) => {
68
64
  if (strict && err?.code !== 'ENOENT') throw err;
69
65
  };
@@ -204,24 +200,25 @@ export const computeToday = (todayStr) =>
204
200
  ? new Date(`${todayStr}T00:00:00Z`)
205
201
  : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
206
202
 
203
+ // A file under the spec store is ALSO read through the shared reader (already-read text, no second
204
+ // read): its verdict rides the row as `spec` and decides collapse eligibility; a refusal is ADVISORY
205
+ // here — surfaced as warnings, never a cap error — the structural checker owns the refusal.
206
+ const readSpecVerdict = (rel, text) =>
207
+ rel.startsWith(SPECS_COLLAPSE.prefix) ? readSpecDocument(text, rel.slice(SPECS_COLLAPSE.prefix.length)) : null;
208
+
207
209
  export const inspectFile = async (filePath, today, root = ROOT) => {
208
210
  const text = await readFile(filePath, 'utf8');
209
211
  const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
210
212
  const fm = parseFrontmatter(text);
211
213
  const rel = relative(root, filePath);
214
+ const spec = readSpecVerdict(rel, text);
212
215
 
213
216
  if (!fm) {
214
- return {
215
- path: rel,
216
- lineCount,
217
- frontmatter: null,
218
- errors: [`missing YAML frontmatter`],
219
- warnings: [],
220
- };
217
+ return { path: rel, lineCount, frontmatter: null, spec, errors: [`missing YAML frontmatter`], warnings: [] };
221
218
  }
222
219
 
223
220
  const errors = [];
224
- const warnings = [];
221
+ const warnings = (spec === null ? [] : [...spec.errors, ...spec.warnings]).map((finding) => `spec ${finding.rule}: ${finding.message}`);
225
222
 
226
223
  const maxLines = fm.maxLines ? Number(fm.maxLines) : null;
227
224
  if (maxLines === null || Number.isNaN(maxLines)) {
@@ -241,7 +238,7 @@ export const inspectFile = async (filePath, today, root = ROOT) => {
241
238
  }
242
239
  }
243
240
 
244
- return { path: rel, lineCount, frontmatter: fm, errors, warnings };
241
+ return { path: rel, lineCount, frontmatter: fm, spec, errors, warnings };
245
242
  };
246
243
 
247
244
  const formatRow = (row) => {
@@ -304,19 +301,16 @@ const formatIndexRow = (row) => {
304
301
  return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
305
302
  };
306
303
 
307
- // The one-file-per-ADR store (docs/ai/adr/) grows O(n) forever, so its rows would blow the index's
308
- // own 80-line cap. It COLLAPSES to a single aggregate row (link the navigator adr/log.md, record
309
- // count + numeric id range) — while walkMarkdownFiles still finds + cap-checks every individual body
310
- // (a body over its own cap still fails in the main flow; only the index RENDERING is collapsed).
304
+ // Two stores grow O(n) forever and would blow the index's own 80-line cap: the one-file-per-ADR store
305
+ // (docs/ai/adr/) and the spec store (docs/ai/specs/). Each is a GROUP that COLLAPSES to a single
306
+ // counted row — while walkMarkdownFiles still finds + cap-checks every individual body (only the
307
+ // index RENDERING is collapsed). Membership is by PROOF: a genuine ADR record / the adr navigator,
308
+ // or a spec-store file the shared reader accepts. Anything else under either dir (a stray README.md,
309
+ // a malformed spec) renders as its OWN visible row — never silently hidden by the collapse.
311
310
  const ADR_DIR_PREFIX = 'docs/ai/adr/';
312
311
  const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
313
312
  const ADR_NAV_PATH = 'docs/ai/adr/log.md';
314
313
 
315
- // Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
316
- // adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
317
- // by the collapse (it also fails archive-decisions' own store-integrity check).
318
- const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
319
-
320
314
  const formatAdrCollapseRow = (adrRows) => {
321
315
  const recs = adrRows
322
316
  .map((r) => {
@@ -329,23 +323,37 @@ const formatAdrCollapseRow = (adrRows) => {
329
323
  return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
330
324
  };
331
325
 
332
- // Pure index renderer given inspected rows + the date to stamp in the header,
333
- // returns the exact bytes `docs/ai/index.md` should contain. Shared by
334
- // `--write-index` (writes it) and `--check-index` (diffs against on-disk).
326
+ // The live counts make the row change when a spec is added or removed, so --check-index sees it.
327
+ const formatSpecsCollapseRow = (specRows) => {
328
+ const count = (kind) => specRows.filter((r) => r.spec.kind === kind).length;
329
+ const link = `[\`${SPECS_COLLAPSE.label}\`](./${SPECS_COLLAPSE.navPath.replace(/^docs\/ai\//, '')})`;
330
+ return `| ${link} | ${SPECS_COLLAPSE.type} | ${count('spec')} specs | ${count('part')} parts · ${count('index')} indexes | — |`;
331
+ };
332
+
333
+ const COLLAPSE_GROUPS = [
334
+ { sortPath: ADR_DIR_PREFIX, isMember: (r) => r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
335
+ { sortPath: SPECS_COLLAPSE.prefix, isMember: (r) => Boolean(r.spec) && r.spec.errors.length === 0, format: formatSpecsCollapseRow },
336
+ ];
337
+
338
+ // Pure index renderer — inspected rows + the header date → the exact bytes `docs/ai/index.md` should
339
+ // contain. Shared by `--write-index` (writes it) and `--check-index` (diffs against on-disk).
335
340
  export const buildIndex = (rows, todayStr, meta = {}) => {
336
341
  const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
337
342
  const onDemandLinks = meta.onDemandLinks ?? [];
338
343
  const hierarchicalLinks = meta.hierarchicalLinks ?? [];
339
344
  const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
340
345
  const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
341
- const nonAdr = [];
342
- const adrRows = [];
346
+ const singles = [];
347
+ const grouped = COLLAPSE_GROUPS.map(() => []);
343
348
  for (const r of rows) {
344
349
  if (r.path === 'docs/ai/index.md') continue;
345
- (isCollapsibleAdr(r.path) ? adrRows : nonAdr).push(r);
350
+ const group = COLLAPSE_GROUPS.findIndex((g) => g.isMember(r));
351
+ (group === -1 ? singles : grouped[group]).push(r);
346
352
  }
347
- const tableEntries = nonAdr.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
348
- if (adrRows.length > 0) tableEntries.push({ sortPath: ADR_DIR_PREFIX, md: formatAdrCollapseRow(adrRows) });
353
+ const tableEntries = singles.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
354
+ COLLAPSE_GROUPS.forEach((g, i) => {
355
+ if (grouped[i].length > 0) tableEntries.push({ sortPath: g.sortPath, md: g.format(grouped[i]) });
356
+ });
349
357
  tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
350
358
  const tableRows = tableEntries.map((e) => e.md).join('\n');
351
359
  const onDemandSection =
@@ -359,11 +367,8 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
359
367
  return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
360
368
  };
361
369
 
362
- // Decides whether an on-disk index is in sync with the source frontmatter.
363
- // The index is regenerated in memory using the on-disk index's OWN `lastUpdated`
364
- // for the header, so a mere day-rollover (no content change) is NOT flagged —
365
- // only genuine drift in the file table (added/removed files, changed
366
- // type/cap/lastUpdated/staleAfter, or a changed line count) makes it stale.
370
+ // Freshness regenerates the index with the on-disk header's OWN `lastUpdated`, so a mere day-rollover
371
+ // is NOT flagged only genuine drift in the file table makes it stale.
367
372
  export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
368
373
  if (onDiskText === null || onDiskText === undefined || onDiskText === '') {
369
374
  return { fresh: false, expected: buildIndex(rows, 'unknown', meta) };
@@ -374,13 +379,11 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
374
379
  return { fresh: expected === onDiskText, expected };
375
380
  };
376
381
 
377
- // The navigator is a GENERATED artifact, so its write must land on the deployment's own file and
378
- // nowhere else: every component of <root>/docs/ai/index.md is lstat'ed no-follow (a symlinked root,
379
- // `docs`, `docs/ai` or leaf REFUSES publishing through one would clobber whatever it points at),
380
- // the body goes out through a unique exclusive-create temp renamed into place with the chain
381
- // re-checked immediately before the rename, and the temp never survives a failure. The kit runs the
382
- // same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
383
- // semantics are REIMPLEMENTED here rather than imported.
382
+ // The navigator write lands on the deployment's own file and nowhere else: every component of
383
+ // <root>/docs/ai/index.md is lstat'ed no-follow (a symlink anywhere REFUSES — publishing through one
384
+ // would clobber its target), the body goes out through an exclusive-create temp renamed into place
385
+ // with the chain re-checked before the rename, and the temp never survives a failure. The kit's
386
+ // atomic-write.mjs runs the same discipline; this script ships dependency-free, so it is REIMPLEMENTED.
384
387
  export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
385
388
  const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
386
389
 
@@ -444,11 +447,8 @@ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PA
444
447
  }
445
448
  };
446
449
 
447
- // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
448
- // the SAME walk → inspect → discoverMeta → writeIndex pipeline as `--write-index`, against `root`
449
- // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
450
- // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
451
- // index path + row count. No second index implementation exists.
450
+ // regenerateIndex(root, todayStr) — the ONE reused generator: the SAME walk inspect → discoverMeta →
451
+ // writeIndex pipeline as `--write-index`, against `root`. `todayStr` is 'YYYY-MM-DD' (null → today).
452
452
  export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
453
453
  const paths = pathsFor(root);
454
454
  const today = computeToday(todayStr);
@@ -460,12 +460,10 @@ export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
460
460
  return { indexPath: paths.indexPath, files: rows.length };
461
461
  };
462
462
 
463
- // The finalizer promises its caller EXACTLY ONE outcome line, so every step it owns the walk, the
464
- // metadata discovery, the freshness read and the write runs inside one classified error path: an
465
- // unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard runs BEFORE the
466
- // freshness read for the same reason `already-present` needs a kind probe: a symlinked navigator
467
- // whose target happens to hold current bytes would otherwise report `already-current` over a file
468
- // this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
463
+ // The finalizer promises EXACTLY ONE outcome line, so every step it owns runs inside one classified
464
+ // error path: an unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard
465
+ // runs BEFORE the freshness read: a symlinked navigator whose target holds current bytes would
466
+ // otherwise report `already-current` over a file this mode refuses to write through.
469
467
  const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
470
468
  const lstat = deps.lstat ?? lstatSync;
471
469
  const read = deps.readFile ?? readFile;
@@ -571,9 +569,8 @@ export const runCli = async (argv, deps = {}) => {
571
569
  return result(errorCount > 0 && !flags.report ? 1 : 0);
572
570
  };
573
571
 
574
- // Run main() only when executed directly, never on import. Compare by REAL path: an entry point
575
- // reached through a symlink resolves to its target, so a raw string compare reads the two as
576
- // different and the CLI never runs. realpathSync collapses the link so both sides match.
572
+ // Run main() only when executed directly, never on import compared by REAL path, so an entry point
573
+ // reached through a symlink still runs.
577
574
  const isDirectRun = (() => {
578
575
  const invoked = process.argv[1];
579
576
  if (!invoked) return false;
@@ -178,11 +178,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
178
178
  });
179
179
 
180
180
  it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
181
- const rows = [
182
- makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
183
- makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
184
- makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
185
- ];
181
+ const rows = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } })];
186
182
  const out = buildIndex(rows, '2026-07-09');
187
183
  expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
188
184
  const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
@@ -190,11 +186,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
190
186
  });
191
187
 
192
188
  it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
193
- const rows = [
194
- makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
195
- makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
196
- makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
197
- ];
189
+ const rows = [makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
198
190
  const out = buildIndex(rows, '2026-07-09');
199
191
  expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
200
192
  });
@@ -219,13 +211,70 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
219
211
  });
220
212
  });
221
213
 
214
+ // The spec store (docs/ai/specs/**) joins the collapse as a second GROUP: every file whose reader
215
+ // verdict is clean folds into ONE counted `specs/` row; a file the reader refuses keeps its own row.
216
+ const SPEC_FRONT = (kind, maxLines, extra = '') =>
217
+ `---\ntype: spec\nlastUpdated: 2026-08-23\nscope: permanent\nstaleAfter: 90d\nowner: none\nmaxLines: ${maxLines}\nkind: ${kind}\n${extra}---\n`;
218
+ const specBody = (slug) =>
219
+ `\n# Spec: ${slug}\n\n## Contract\n\nc\n\n## Scenarios\n\n- S1 a :: test/${slug}.test.mjs :: spec:${slug}/S1\n\n## Out of scope\n\n- b\n\n## Module\n\n- src/${slug}/\n`;
220
+ const specRow = (path, kind, errors = []) => makeRow(path, { frontmatter: { type: 'spec', maxLines: '150' }, spec: { kind, status: 'draft', revision: 1, errors, warnings: [] } });
221
+ const SPECS_ROW_RE = /\| \[`specs\/`\]\(\.\/specs\/index\.md\) \| spec \| (\d+) specs \| (\d+) parts · (\d+) indexes \| — \|/;
222
+
223
+ describe('buildIndex — docs/ai/specs/ store collapse (spec layer 1a)', () => {
224
+ it('1000 valid specs + their indexes render ONE counted specs/ row; the index stays <= 80 lines', () => {
225
+ const specs = Array.from({ length: 1000 }, (_, i) => specRow(`docs/ai/specs/d${Math.floor(i / 30)}/s${i}.md`, 'spec'));
226
+ const indexes = Array.from({ length: 34 }, (_, i) => specRow(`docs/ai/specs/d${i}/index.md`, 'index'));
227
+ const rows = [makeRow('docs/ai/handover.md'), specRow('docs/ai/specs/index.md', 'index'), ...indexes, ...specs];
228
+ const out = buildIndex(rows, '2026-08-23');
229
+ const lines = out.split('\n');
230
+ expect(lines.filter((l) => l.includes('](./specs/index.md)')).length).toBe(1);
231
+ expect(out).not.toMatch(/s999\.md/);
232
+ expect(lines.length <= 80).toBe(true);
233
+ expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['1000', '0', '35']);
234
+ });
235
+
236
+ it('the counted row carries live counts per kind; a malformed file under specs/ keeps its OWN visible row beside it', () => {
237
+ const rows = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a/index.md', 'spec'), specRow('docs/ai/specs/a/p.md', 'part'), specRow('docs/ai/specs/b.md', 'spec'), specRow('docs/ai/specs/broken.md', null, [{ rule: 'kind', message: 'x' }])];
238
+ const out = buildIndex(rows, '2026-08-23');
239
+ expect(out).toMatch(/specs\/broken\.md/);
240
+ expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['2', '1', '1']);
241
+ });
242
+
243
+ it('adding or removing one valid spec drifts the counted row -> the on-disk index is stale', () => {
244
+ const base = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a.md', 'spec')];
245
+ const onDisk = buildIndex(base, '2026-08-23');
246
+ expect(checkIndexFreshness([...base, specRow('docs/ai/specs/b.md', 'spec')], onDisk).fresh).toBe(false);
247
+ expect(checkIndexFreshness(base.slice(0, 1), onDisk).fresh).toBe(false);
248
+ expect(checkIndexFreshness(base, onDisk).fresh).toBe(true);
249
+ });
250
+
251
+ it('inspectFile reads a file under docs/ai/specs/ through the reader: clean -> spec verdict, malformed -> advisory warnings only', async () => {
252
+ const root = await mkdtemp(join(tmpdir(), 'specs-inspect-'));
253
+ try {
254
+ await mkdir(join(root, 'docs', 'ai', 'specs'), { recursive: true });
255
+ const clean = join(root, 'docs', 'ai', 'specs', 'login.md');
256
+ await writeFile(clean, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
257
+ const ok = await inspectFile(clean, computeToday('2026-08-23'), root);
258
+ expect(ok.errors).toEqual([]);
259
+ expect(ok.spec.kind).toBe('spec');
260
+ expect(ok.spec.errors).toEqual([]);
261
+ const malformed = join(root, 'docs', 'ai', 'specs', 'broken.md');
262
+ await writeFile(malformed, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
263
+ const bad = await inspectFile(malformed, computeToday('2026-08-23'), root);
264
+ expect(bad.errors).toEqual([]);
265
+ expect(bad.spec.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
266
+ expect(bad.warnings.some((w) => /spec scenario-marker/.test(w))).toBe(true);
267
+ await writeFile(join(root, 'docs', 'ai', 'handover.md'), `${SPEC_FRONT('spec', 150)}\n# outside the store\n`);
268
+ expect((await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-08-23'), root)).spec).toBe(null);
269
+ } finally {
270
+ await rm(root, { recursive: true, force: true });
271
+ }
272
+ });
273
+ });
274
+
222
275
  describe('buildIndex', () => {
223
276
  it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
224
- const rows = [
225
- makeRow('docs/ai/index.md'),
226
- makeRow('docs/ai/b.md'),
227
- makeRow('docs/ai/a.md'),
228
- ];
277
+ const rows = [makeRow('docs/ai/index.md'), makeRow('docs/ai/b.md'), makeRow('docs/ai/a.md')];
229
278
  const out = buildIndex(rows, '2026-05-29');
230
279
  expect(out).toBe(buildIndex(rows, '2026-05-29')); // deterministic
231
280
  expect(out).not.toMatch(/\[`index\.md`\]/); // index.md row excluded
@@ -328,4 +377,19 @@ describe('root parameterization (item (h))', () => {
328
377
  expect(stale.status).toBe(1);
329
378
  expect(stale.stderr).toMatch(/stale/);
330
379
  });
380
+
381
+ it('the hook path over a real specs/ store: ONE counted row in the written index, a new spec makes --check-index exit 1', async () => {
382
+ const store = join(root, 'docs', 'ai', 'specs');
383
+ await mkdir(store, { recursive: true });
384
+ await writeFile(join(store, 'index.md'), `${SPEC_FRONT('index', 80)}\n# Specs\n\n> Up: [technical_specification.md](../technical_specification.md)\n\n## Children\n\n- [login](./login.md)\n`);
385
+ await writeFile(join(store, 'login.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
386
+ expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
387
+ const index = await readFile(join(root, 'docs', 'ai', 'index.md'), 'utf8');
388
+ expect(index.match(SPECS_ROW_RE).slice(1)).toEqual(['1', '0', '1']);
389
+ expect(index).not.toMatch(/specs\/login\.md/);
390
+ expect(runCli(['--check-index', `--root=${root}`]).status).toBe(0);
391
+ await writeFile(join(store, 'signup.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('signup')}`);
392
+ expect(runCli(['--check-index', `--root=${root}`]).status).toBe(1);
393
+ expect(runCli([`--root=${root}`]).status).toBe(0);
394
+ });
331
395
  });
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ // spec-schema.mjs — the ONE reader that DEFINES a well-formed spec document under docs/ai/specs/.
3
+ //
4
+ // Pure text in, verdict out: readSpecDocument(text, rel) never touches the filesystem and imports
5
+ // nothing, so it seeds layout-free into any deployment. The navigator's collapse (check-docs-size.mjs)
6
+ // and the future spec-check read through THIS module — "malformed" has one definition, never two.
7
+ // `rel` is the path INSIDE docs/ai/specs/ (the store root navigator is `index.md`).
8
+ //
9
+ // SPEC_SCHEMA carries the frozen values; the engine canon (references/specs.md) is pinned against it.
10
+ // A refusal names exactly one rule id per defect, so a fixture corpus can be read rule by rule.
11
+
12
+ export const SPEC_SCHEMA = Object.freeze({
13
+ storePrefix: 'docs/ai/specs/',
14
+ navigatorFile: 'index.md',
15
+ upLink: 'technical_specification.md',
16
+ upLinkLine: '> Up: [technical_specification.md](../technical_specification.md)',
17
+ type: 'spec',
18
+ substrateKeys: Object.freeze(['type', 'lastUpdated', 'scope', 'staleAfter', 'owner', 'maxLines']),
19
+ kinds: Object.freeze(['index', 'spec', 'part']),
20
+ statuses: Object.freeze(['draft', 'live', 'retired']),
21
+ transitions: Object.freeze([Object.freeze(['draft', 'live']), Object.freeze(['live', 'retired'])]),
22
+ maxLines: Object.freeze({ index: 80, spec: 150, part: 150 }),
23
+ fanOutMax: 30,
24
+ slugPattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
25
+ emptyMarker: '*(empty)*',
26
+ unboundMarker: 'unbound',
27
+ titlePrefix: Object.freeze({ index: '# ', spec: '# Spec: ', part: '# Part: ' }),
28
+ requiredSections: Object.freeze({
29
+ index: Object.freeze(['## Children']),
30
+ spec: Object.freeze(['## Contract', '## Scenarios', '## Out of scope', '## Module']),
31
+ part: Object.freeze([]),
32
+ }),
33
+ optionalSections: Object.freeze({ spec: Object.freeze(['## Parts', '## Links']) }),
34
+ rootOwnedKeys: Object.freeze(['status', 'revision']),
35
+ rootOwnedSections: Object.freeze(['## Scenarios', '## Out of scope', '## Module', '## Parts']),
36
+ scenarioGrammar: '- S<N> <name> :: <repo-relative test path> :: spec:<slug>/S<N> | - S<N> <name> :: unbound',
37
+ rules: Object.freeze([
38
+ 'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
39
+ 'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
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',
42
+ 'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
43
+ ]),
44
+ });
45
+
46
+ // The descriptor check-docs-size.mjs joins to its ADR group: rows under `prefix` whose reader verdict
47
+ // is clean collapse into ONE navigator row linking `navPath`; a row with reader errors stays visible.
48
+ export const SPECS_COLLAPSE = Object.freeze({
49
+ prefix: SPEC_SCHEMA.storePrefix,
50
+ navPath: `${SPEC_SCHEMA.storePrefix}${SPEC_SCHEMA.navigatorFile}`,
51
+ label: 'specs/',
52
+ type: SPEC_SCHEMA.type,
53
+ });
54
+
55
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
56
+ const FIELD_RE = /^([a-zA-Z][a-zA-Z0-9_]*):\s*(.*)$/;
57
+ const SLUG_RE = new RegExp(SPEC_SCHEMA.slugPattern);
58
+ const REVISION_RE = /^[1-9][0-9]*$/;
59
+ const CHILD_LINK_RE = /^- \[([^\]]+)\]\(\.\/([^/)]+)(\/index)?\.md\)$/;
60
+ const PART_LINK_RE = /^- \[([^\]]+)\]\(\.\/([^/)]+)\.md\)$/;
61
+ const SCENARIO_HEAD_RE = /^S([0-9]+) (.+)$/;
62
+ const GLOB_RE = /[*?[\]{}]/;
63
+ const WINDOWS_DRIVE_RE = /^[A-Za-z]:/;
64
+ const SEPARATOR = ' :: ';
65
+ const MD_SUFFIX = '.md';
66
+
67
+ const FORBIDDEN_SECTIONS = Object.freeze({
68
+ index: SPEC_SCHEMA.rootOwnedSections,
69
+ spec: Object.freeze(['## Children']),
70
+ part: Object.freeze([...SPEC_SCHEMA.rootOwnedSections, '## Children']),
71
+ });
72
+
73
+ const KNOWN_KEYS = Object.freeze([...SPEC_SCHEMA.substrateKeys, 'kind', ...SPEC_SCHEMA.rootOwnedKeys]);
74
+
75
+ // The frontmatter is a closed key set: an unknown key, a repeated key or a line that is not
76
+ // `key: value` is a defect (`frontmatter-key`), never silently dropped or last-one-wins.
77
+ const parseFrontmatter = (text) => {
78
+ const match = text.match(FRONTMATTER_RE);
79
+ if (!match) return null;
80
+ const fields = {};
81
+ const defects = [];
82
+ for (const line of match[1].split('\n')) {
83
+ const m = line.match(FIELD_RE);
84
+ if (!m) defects.push(`"${line}" is not \`key: value\``);
85
+ else if (!KNOWN_KEYS.includes(m[1])) defects.push(`unknown key ${m[1]}`);
86
+ else if (m[1] in fields) defects.push(`duplicate key ${m[1]}`);
87
+ else fields[m[1]] = m[2].trim();
88
+ }
89
+ return { fields, defects, body: text.slice(match[0].length) };
90
+ };
91
+
92
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
93
+
94
+ // The body as { title, sections: [{ heading, lines }], preamble, fenced } — the title is the FIRST
95
+ // `# ` line, every `## ` line opens a section, the lines before the first section are the preamble.
96
+ // The reader parses NO markdown code: a fence line is recorded (the `fence` refusal) rather than
97
+ // modelled, so a spec carries no code sample and no line is ever ambiguous between code and structure.
98
+ const parseBody = (body) => {
99
+ const sections = [];
100
+ const preamble = [];
101
+ const fenced = [];
102
+ const state = { title: null, current: null, sectionBeforeTitle: false };
103
+ for (const raw of body.split('\n')) {
104
+ const line = raw.replace(/\s+$/, '');
105
+ if (FENCE_RE.test(line)) fenced.push(line);
106
+ if (line.startsWith('## ')) {
107
+ if (state.title === null) state.sectionBeforeTitle = true;
108
+ state.current = { heading: line, lines: [] };
109
+ sections.push(state.current);
110
+ } else if (state.title === null && line.startsWith('# ')) {
111
+ state.title = line;
112
+ } else {
113
+ (state.current ? state.current.lines : preamble).push(line);
114
+ }
115
+ }
116
+ return { title: state.title, sections, preamble, fenced, sectionBeforeTitle: state.sectionBeforeTitle };
117
+ };
118
+
119
+ // Lexical classification of a repo-relative path field — the same vocabulary for module roots and
120
+ // scenario bindings. Realpath/symlink containment needs the filesystem and is the checker's duty.
121
+ export const classifyPath = (path) => {
122
+ if (path.trim() === '') return 'empty';
123
+ if (path.includes('\\')) return 'backslash';
124
+ if (path.startsWith('/') || WINDOWS_DRIVE_RE.test(path)) return 'absolute';
125
+ if (path.split('/').includes('..')) return 'traversal';
126
+ if (GLOB_RE.test(path)) return 'glob';
127
+ return path.endsWith('/') ? 'dir' : 'file';
128
+ };
129
+
130
+ const MODULE_PATH_RULES = Object.freeze({
131
+ empty: 'module-empty',
132
+ backslash: 'module-backslash',
133
+ absolute: 'module-absolute',
134
+ traversal: 'module-traversal',
135
+ glob: 'module-glob',
136
+ });
137
+
138
+ // A bare `-` is a bullet with a blank payload (trailing whitespace is stripped before parsing).
139
+ const isBullet = (line) => line === '-' || line.startsWith('- ');
140
+ const bulletsOf = (lines) => lines.filter(isBullet).map((line) => line.slice(2));
141
+ const contentOf = (lines) => lines.filter((line) => line.trim() !== '');
142
+
143
+ // The slug a document owns: the file stem for a flat file, the folder name for an index.md.
144
+ const describeRel = (rel) => {
145
+ const segments = rel.split('/');
146
+ const file = segments[segments.length - 1];
147
+ const dirs = segments.slice(0, -1);
148
+ const stem = file.endsWith(MD_SUFFIX) ? file.slice(0, -MD_SUFFIX.length) : file;
149
+ const isIndexFile = file === SPEC_SCHEMA.navigatorFile;
150
+ const slug = isIndexFile ? dirs[dirs.length - 1] ?? null : stem;
151
+ const slugSegments = [...dirs, ...(isIndexFile ? [] : [stem])];
152
+ return { dirs, stem, isIndexFile, slug, slugSegments, isStoreRoot: rel === SPEC_SCHEMA.navigatorFile };
153
+ };
154
+
155
+ const checkFrontmatter = (fields, kind, errors) => {
156
+ const missing = SPEC_SCHEMA.substrateKeys.filter((key) => !(key in fields));
157
+ if (missing.length > 0) errors.push({ rule: 'substrate-key', message: `frontmatter is missing ${missing.join(', ')}` });
158
+ if ('type' in fields && fields.type !== SPEC_SCHEMA.type) errors.push({ rule: 'type', message: `type must be ${SPEC_SCHEMA.type}` });
159
+ if ('maxLines' in fields && fields.maxLines !== String(SPEC_SCHEMA.maxLines[kind])) {
160
+ errors.push({ rule: 'maxlines', message: `a ${kind} carries maxLines: ${SPEC_SCHEMA.maxLines[kind]}` });
161
+ }
162
+ if (kind === 'spec') {
163
+ if (!SPEC_SCHEMA.statuses.includes(fields.status)) errors.push({ rule: 'status', message: `status must be one of ${SPEC_SCHEMA.statuses.join('|')}` });
164
+ if (!REVISION_RE.test(fields.revision ?? '')) errors.push({ rule: 'revision', message: 'revision must be an integer >= 1' });
165
+ } else {
166
+ const carried = SPEC_SCHEMA.rootOwnedKeys.filter((key) => key in fields);
167
+ if (carried.length > 0) errors.push({ rule: 'root-owns', message: `a ${kind} never carries ${carried.join(', ')} — the contract root owns them` });
168
+ }
169
+ };
170
+
171
+ const checkPath = (rel, kind, errors) => {
172
+ const at = describeRel(rel);
173
+ const badSegment = at.slugSegments.find((segment) => !SLUG_RE.test(segment));
174
+ if (badSegment !== undefined) errors.push({ rule: 'slug', message: `"${badSegment}" is not a slug (${SPEC_SCHEMA.slugPattern})` });
175
+ if (kind === 'index' && !at.isIndexFile) errors.push({ rule: 'kind-path', message: 'a kind: index document is an index.md' });
176
+ if (kind === 'part' && (at.isIndexFile || at.dirs.length === 0)) errors.push({ rule: 'kind-path', message: 'a kind: part document is a <name>.md beside a promoted root, never an index.md or a store-root file' });
177
+ if (kind === 'spec' && at.isIndexFile && at.slug === null) errors.push({ rule: 'kind-path', message: 'the store root is the navigator, never a contract root' });
178
+ return at;
179
+ };
180
+
181
+ const checkSections = (parsed, kind, errors) => {
182
+ const headings = parsed.sections.map((section) => section.heading);
183
+ const prefix = SPEC_SCHEMA.titlePrefix[kind];
184
+ if (parsed.title === null || parsed.sectionBeforeTitle || !parsed.title.startsWith(prefix) || parsed.title.slice(prefix.length).trim() === '') {
185
+ errors.push({ rule: 'title', message: `the FIRST heading is \`${prefix}<title>\`, before every section` });
186
+ }
187
+ const required = SPEC_SCHEMA.requiredSections[kind];
188
+ const missing = required.filter((heading) => !headings.includes(heading));
189
+ if (missing.length > 0) errors.push({ rule: 'section-missing', message: `missing ${missing.join(', ')}` });
190
+ const ordered = [...required, ...(SPEC_SCHEMA.optionalSections[kind] ?? [])];
191
+ const positions = ordered.filter((heading) => headings.includes(heading)).map((heading) => headings.indexOf(heading));
192
+ if (missing.length === 0 && positions.some((position, i) => i > 0 && position < positions[i - 1])) {
193
+ errors.push({ rule: 'section-order', message: `sections run ${ordered.join(', ')}` });
194
+ }
195
+ const forbidden = FORBIDDEN_SECTIONS[kind].filter((heading) => headings.includes(heading));
196
+ if (forbidden.length > 0) errors.push({ rule: 'section-forbidden', message: `a ${kind} never carries ${forbidden.join(', ')}` });
197
+ };
198
+
199
+ const sectionLines = (parsed, heading) => parsed.sections.find((section) => section.heading === heading)?.lines ?? null;
200
+
201
+ const checkChildren = (parsed, errors) => {
202
+ const lines = sectionLines(parsed, '## Children');
203
+ if (lines === null) return;
204
+ const targets = [];
205
+ for (const line of contentOf(lines)) {
206
+ const m = line.match(CHILD_LINK_RE);
207
+ if (!m || !SLUG_RE.test(m[2])) {
208
+ errors.push({ rule: 'children-link', message: `"${line}" is not \`- [name](./<child>.md)\` or \`- [name](./<child>/index.md)\`` });
209
+ return;
210
+ }
211
+ targets.push(m[2]);
212
+ }
213
+ const duplicate = targets.find((target, i) => targets.indexOf(target) !== i);
214
+ if (duplicate !== undefined) errors.push({ rule: 'children-duplicate', message: `child "${duplicate}" is listed twice` });
215
+ if (targets.length > SPEC_SCHEMA.fanOutMax) errors.push({ rule: 'fan-out', message: `${targets.length} children > ${SPEC_SCHEMA.fanOutMax} — subdivide along slice boundaries` });
216
+ };
217
+
218
+ const parseScenario = (line) => {
219
+ if (!line.startsWith('- ')) return null;
220
+ const fields = line.slice(2).split(SEPARATOR);
221
+ const head = fields[0].match(SCENARIO_HEAD_RE);
222
+ if (!head) return null;
223
+ const base = { n: Number(head[1]), name: head[2] };
224
+ if (fields.length === 2 && fields[1] === SPEC_SCHEMA.unboundMarker) return { ...base, bound: false };
225
+ if (fields.length === 3) return { ...base, bound: true, path: fields[1], marker: fields[2] };
226
+ return null;
227
+ };
228
+
229
+ const checkScenarios = (parsed, slug, status, errors, warnings) => {
230
+ const lines = sectionLines(parsed, '## Scenarios');
231
+ if (lines === null) return;
232
+ const scenarios = [];
233
+ for (const line of contentOf(lines)) {
234
+ const scenario = parseScenario(line);
235
+ if (scenario === null) {
236
+ errors.push({ rule: 'scenario-line', message: `"${line}" does not match \`${SPEC_SCHEMA.scenarioGrammar}\`` });
237
+ return;
238
+ }
239
+ scenarios.push(scenario);
240
+ }
241
+ const gap = scenarios.findIndex((scenario, i) => scenario.n !== i + 1);
242
+ if (gap !== -1) errors.push({ rule: 'scenario-number', message: `scenario ${gap + 1} is numbered S${scenarios[gap].n} — N runs contiguously from 1` });
243
+ for (const scenario of scenarios) {
244
+ if (!scenario.bound) {
245
+ if (status === 'live') warnings.push({ rule: 'unbound', message: `S${scenario.n} is unbound on a live spec` });
246
+ continue;
247
+ }
248
+ const expected = `spec:${slug}/S${scenario.n}`;
249
+ if (scenario.marker !== expected) errors.push({ rule: 'scenario-marker', message: `S${scenario.n} marker "${scenario.marker}" must be "${expected}"` });
250
+ if (classifyPath(scenario.path) !== 'file') errors.push({ rule: 'scenario-path', message: `S${scenario.n} test path "${scenario.path}" is not a repo-relative file` });
251
+ }
252
+ };
253
+
254
+ const checkOutOfScope = (parsed, errors) => {
255
+ const lines = sectionLines(parsed, '## Out of scope');
256
+ if (lines === null) return;
257
+ const content = contentOf(lines);
258
+ const exclusions = bulletsOf(content).filter((text) => text.trim() !== '');
259
+ if (exclusions.length === 0 && !(content.length === 1 && content[0] === SPEC_SCHEMA.emptyMarker)) {
260
+ errors.push({ rule: 'out-of-scope', message: `at least one non-blank \`- \` bullet, or exactly \`${SPEC_SCHEMA.emptyMarker}\`` });
261
+ }
262
+ };
263
+
264
+ const checkModule = (parsed, status, errors) => {
265
+ const lines = sectionLines(parsed, '## Module');
266
+ if (lines === null) return;
267
+ const content = contentOf(lines);
268
+ const isEmptyMarker = content.length === 1 && content[0] === SPEC_SCHEMA.emptyMarker;
269
+ const prose = isEmptyMarker ? undefined : content.find((line) => !isBullet(line));
270
+ if (prose !== undefined) {
271
+ errors.push({ rule: 'module-line', message: `"${prose}" — every ## Module line is a \`- <path>\` bullet` });
272
+ return;
273
+ }
274
+ const paths = bulletsOf(content);
275
+ if (paths.length === 0) {
276
+ if (!(status === 'retired' && isEmptyMarker)) {
277
+ errors.push({ rule: 'module-empty', message: `a module root is required (\`${SPEC_SCHEMA.emptyMarker}\` only on a retired spec)` });
278
+ }
279
+ return;
280
+ }
281
+ const kinds = paths.map(classifyPath);
282
+ const offending = kinds.map((kind, i) => (kind in MODULE_PATH_RULES ? { rule: MODULE_PATH_RULES[kind], path: paths[i] } : null)).filter(Boolean);
283
+ if (offending.length > 0) {
284
+ for (const { rule, path } of offending) errors.push({ rule, message: `module path "${path}" refused` });
285
+ return;
286
+ }
287
+ const dirs = kinds.filter((kind) => kind === 'dir').length;
288
+ if (!(dirs === 1 && paths.length === 1) && dirs !== 0) {
289
+ errors.push({ rule: 'module-mix', message: 'the module is ONE `dir/` root OR a literal file list' });
290
+ }
291
+ };
292
+
293
+ const checkParts = (parsed, at, errors) => {
294
+ const lines = sectionLines(parsed, '## Parts');
295
+ if (lines === null) return;
296
+ if (!at.isIndexFile) {
297
+ errors.push({ rule: 'parts', message: 'only a promoted root (<slug>/index.md) carries ## Parts' });
298
+ return;
299
+ }
300
+ const names = [];
301
+ for (const line of contentOf(lines)) {
302
+ const m = line.match(PART_LINK_RE);
303
+ if (!m || !SLUG_RE.test(m[2])) {
304
+ errors.push({ rule: 'parts', message: `"${line}" is not \`- [name](./<part>.md)\`` });
305
+ return;
306
+ }
307
+ names.push(m[2]);
308
+ }
309
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i);
310
+ if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
311
+ };
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.
316
+ export const readSpecDocument = (text, rel) => {
317
+ const errors = [];
318
+ const warnings = [];
319
+ const verdict = (kind, status, revision) => ({ kind, status, revision, errors, warnings });
320
+ const front = parseFrontmatter(text.replace(/\r\n/g, '\n'));
321
+ if (front === null) {
322
+ errors.push({ rule: 'frontmatter', message: 'missing YAML frontmatter' });
323
+ return verdict(null, null, null);
324
+ }
325
+ const { fields, defects, body } = front;
326
+ if (defects.length > 0) {
327
+ errors.push({ rule: 'frontmatter-key', message: defects.join('; ') });
328
+ return verdict(null, null, null);
329
+ }
330
+ const kind = fields.kind;
331
+ if (!SPEC_SCHEMA.kinds.includes(kind)) {
332
+ errors.push({ rule: 'kind', message: `kind must be one of ${SPEC_SCHEMA.kinds.join('|')}` });
333
+ return verdict(null, null, null);
334
+ }
335
+ checkFrontmatter(fields, kind, errors);
336
+ const at = checkPath(rel, kind, errors);
337
+ const parsed = parseBody(body);
338
+ if (parsed.fenced.length > 0) errors.push({ rule: 'fence', message: `"${parsed.fenced[0]}" — a spec document carries no code fence` });
339
+ checkSections(parsed, kind, errors);
340
+ if (kind === 'index') {
341
+ checkChildren(parsed, errors);
342
+ if (at.isStoreRoot && !parsed.preamble.includes(SPEC_SCHEMA.upLinkLine)) {
343
+ errors.push({ rule: 'root-uplink', message: `the store root carries the line \`${SPEC_SCHEMA.upLinkLine}\` before its first section` });
344
+ }
345
+ }
346
+ if (kind === 'spec') {
347
+ checkScenarios(parsed, at.slug, fields.status, errors, warnings);
348
+ checkOutOfScope(parsed, errors);
349
+ checkModule(parsed, fields.status, errors);
350
+ checkParts(parsed, at, errors);
351
+ }
352
+ const status = kind === 'spec' ? fields.status ?? null : null;
353
+ const revision = kind === 'spec' && REVISION_RE.test(fields.revision ?? '') ? Number(fields.revision) : null;
354
+ return verdict(kind, status, revision);
355
+ };
@@ -0,0 +1,212 @@
1
+ import { describe, it } from 'node:test';
2
+ import { expect } from './_expect-shim.mjs';
3
+
4
+ // Dynamic import: the suite LOADS without the module (red-proof observes it failing pre-fix).
5
+ const reader = await import('./spec-schema.mjs').catch(() => ({}));
6
+ const { readSpecDocument, classifyPath, SPEC_SCHEMA, SPECS_COLLAPSE } = reader;
7
+
8
+ // Inline fixtures ONLY — this suite runs inside a deployed project's scripts/ where no corpus exists.
9
+ // The repo-only corpus (engine test/fixtures/specs) is the durable record; this is the unit pin.
10
+
11
+ const frontmatter = (fields) =>
12
+ `---\n${Object.entries(fields).map(([key, value]) => `${key}: ${value}`).join('\n')}\n---\n`;
13
+
14
+ const SUBSTRATE = { type: 'spec', lastUpdated: '2026-08-23', scope: 'permanent', staleAfter: '90d', owner: 'none' };
15
+ const UPLINK_LINE = '> Up: [technical_specification.md](../technical_specification.md)';
16
+
17
+ const specDoc = ({ fields = {}, drop = [], title = '# Spec: Login', scenarios, outOfScope = '- Password reset', module = '- src/login/', extra = '' } = {}) => {
18
+ const all = { ...SUBSTRATE, maxLines: '150', kind: 'spec', status: 'draft', revision: '1', ...fields };
19
+ for (const key of drop) delete all[key];
20
+ const lines = scenarios ?? ['- S1 happy path :: test/login.test.mjs :: spec:login/S1', '- S2 lockout :: unbound'];
21
+ return `${frontmatter(all)}\n${title}\n\n## Contract\n\nAccepts a credential pair.\n\n## Scenarios\n\n${lines.join('\n')}\n\n## Out of scope\n\n${outOfScope}\n\n## Module\n\n${module}\n${extra}`;
22
+ };
23
+
24
+ const indexDoc = ({ fields = {}, children = ['- [login](./login.md)', '- [billing](./billing/index.md)'], preamble = '', title = '# Auth' } = {}) =>
25
+ `${frontmatter({ ...SUBSTRATE, maxLines: '80', kind: 'index', ...fields })}\n${title}\n${preamble}\n## Children\n\n${children.join('\n')}\n`;
26
+
27
+ const partDoc = ({ fields = {}, title = '# Part: Sessions', extra = '' } = {}) =>
28
+ `${frontmatter({ ...SUBSTRATE, maxLines: '150', kind: 'part', ...fields })}\n${title}\n\nSession details.\n${extra}`;
29
+
30
+ const rulesOf = (verdict) => verdict.errors.map((e) => e.rule);
31
+ const refuses = (text, rel, rule) => {
32
+ const verdict = readSpecDocument(text, rel);
33
+ expect(rulesOf(verdict)).toEqual([rule]);
34
+ };
35
+
36
+ describe('readSpecDocument — accept', () => {
37
+ it('a flat draft spec reads clean with kind/status/revision', () => {
38
+ const verdict = readSpecDocument(specDoc(), 'login.md');
39
+ expect(verdict.errors).toEqual([]);
40
+ expect(verdict.warnings).toEqual([]);
41
+ expect({ kind: verdict.kind, status: verdict.status, revision: verdict.revision }).toEqual({ kind: 'spec', status: 'draft', revision: 1 });
42
+ });
43
+
44
+ it('a promoted root under <slug>/index.md with ## Parts and ## Links reads clean', () => {
45
+ const text = specDoc({ extra: '\n## Parts\n\n- [sessions](./sessions.md)\n\n## Links\n\n- [[AD-112]]\n' });
46
+ expect(readSpecDocument(text, 'auth/login/index.md').errors).toEqual([]);
47
+ });
48
+
49
+ it('a domain index, the store root (with the up-link) and a part read clean', () => {
50
+ expect(readSpecDocument(indexDoc(), 'auth/index.md').errors).toEqual([]);
51
+ const root = indexDoc({ preamble: `\n${UPLINK_LINE}\n`, children: [] });
52
+ const rootVerdict = readSpecDocument(root, 'index.md');
53
+ expect(rootVerdict.errors).toEqual([]);
54
+ expect(rootVerdict.kind).toBe('index');
55
+ expect(readSpecDocument(partDoc(), 'auth/login/sessions.md').errors).toEqual([]);
56
+ });
57
+
58
+ it('a retired spec may carry *(empty)* as its module; a live spec with an unbound scenario WARNS, never refuses', () => {
59
+ const retired = specDoc({ fields: { status: 'retired' }, module: '*(empty)*', scenarios: ['- S1 gone :: unbound'] });
60
+ expect(readSpecDocument(retired, 'login.md').errors).toEqual([]);
61
+ const live = readSpecDocument(specDoc({ fields: { status: 'live', revision: '3' } }), 'login.md');
62
+ expect(live.errors).toEqual([]);
63
+ expect(live.warnings.map((w) => w.rule)).toEqual(['unbound']);
64
+ });
65
+
66
+ it('CRLF line endings read identically', () => {
67
+ expect(readSpecDocument(specDoc().replace(/\n/g, '\r\n'), 'login.md').errors).toEqual([]);
68
+ });
69
+
70
+ it('*(empty)* is a legal Out of scope; a file-list module is legal', () => {
71
+ const text = specDoc({ outOfScope: '*(empty)*', module: '- src/a.mjs\n- src/b.mjs' });
72
+ expect(readSpecDocument(text, 'login.md').errors).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
77
+ it('frontmatter / substrate-key / type / kind / maxlines', () => {
78
+ refuses('# Spec: Login\n', 'login.md', 'frontmatter');
79
+ refuses(specDoc({ drop: ['owner'] }), 'login.md', 'substrate-key');
80
+ refuses(specDoc({ fields: { type: 'reference' } }), 'login.md', 'type');
81
+ refuses(specDoc({ fields: { kind: 'feature' } }), 'login.md', 'kind');
82
+ refuses(specDoc({ drop: ['kind'] }), 'login.md', 'kind');
83
+ refuses(specDoc({ fields: { maxLines: '400' } }), 'login.md', 'maxlines');
84
+ });
85
+
86
+ it('status / revision / root-owns', () => {
87
+ refuses(specDoc({ fields: { status: 'approved' } }), 'login.md', 'status');
88
+ refuses(specDoc({ drop: ['status'] }), 'login.md', 'status');
89
+ refuses(specDoc({ fields: { revision: '0' } }), 'login.md', 'revision');
90
+ refuses(specDoc({ fields: { revision: '1.5' } }), 'login.md', 'revision');
91
+ refuses(indexDoc({ fields: { status: 'draft' } }), 'auth/index.md', 'root-owns');
92
+ refuses(partDoc({ fields: { revision: '2' } }), 'auth/login/sessions.md', 'root-owns');
93
+ });
94
+
95
+ it('slug / kind-path / root-uplink', () => {
96
+ refuses(specDoc({ scenarios: ['- S1 x :: unbound'] }), 'Login_Page.md', 'slug');
97
+ refuses(indexDoc(), 'auth/Auth Stuff/index.md', 'slug');
98
+ refuses(indexDoc(), 'auth/overview.md', 'kind-path');
99
+ refuses(partDoc(), 'auth/login/index.md', 'kind-path');
100
+ refuses(specDoc({ scenarios: ['- S1 x :: unbound'] }), 'index.md', 'kind-path');
101
+ refuses(indexDoc({ children: [] }), 'index.md', 'root-uplink');
102
+ });
103
+
104
+ it('title / section-missing / section-order / section-forbidden', () => {
105
+ refuses(specDoc({ title: '# Login' }), 'login.md', 'title');
106
+ refuses(specDoc({ title: '# Spec: ' }), 'login.md', 'title');
107
+ refuses(partDoc({ title: '# Sessions' }), 'auth/login/sessions.md', 'title');
108
+ refuses(specDoc().replace('## Out of scope', '## Out-of-scope'), 'login.md', 'section-missing');
109
+ refuses(specDoc().replace('## Contract\n\nAccepts a credential pair.\n\n', '').concat('\n## Contract\n\nlate\n'), 'login.md', 'section-order');
110
+ refuses(indexDoc().concat('\n## Module\n\n- src/\n'), 'auth/index.md', 'section-forbidden');
111
+ refuses(partDoc({ extra: '\n## Scenarios\n\n- S1 x :: unbound\n' }), 'auth/login/sessions.md', 'section-forbidden');
112
+ });
113
+
114
+ it('children-link / children-duplicate / fan-out', () => {
115
+ refuses(indexDoc({ children: ['- login'] }), 'auth/index.md', 'children-link');
116
+ refuses(indexDoc({ children: ['- [login](../login.md)'] }), 'auth/index.md', 'children-link');
117
+ refuses(indexDoc({ children: ['- [login](./login.md)', '- [login again](./login.md)'] }), 'auth/index.md', 'children-duplicate');
118
+ const many = Array.from({ length: SPEC_SCHEMA.fanOutMax + 1 }, (_, i) => `- [c${i}](./c${i}.md)`);
119
+ refuses(indexDoc({ children: many }), 'auth/index.md', 'fan-out');
120
+ expect(readSpecDocument(indexDoc({ children: many.slice(0, SPEC_SCHEMA.fanOutMax) }), 'auth/index.md').errors).toEqual([]);
121
+ });
122
+
123
+ it('scenario-line / scenario-number / scenario-marker / scenario-path', () => {
124
+ refuses(specDoc({ scenarios: ['- S1 no binding'] }), 'login.md', 'scenario-line');
125
+ refuses(specDoc({ scenarios: ['S1 happy :: unbound'] }), 'login.md', 'scenario-line');
126
+ refuses(specDoc({ scenarios: ['- S1 a :: unbound', '- S3 b :: unbound'] }), 'login.md', 'scenario-number');
127
+ refuses(specDoc({ scenarios: ['- S2 a :: unbound'] }), 'login.md', 'scenario-number');
128
+ refuses(specDoc({ scenarios: ['- S1 a :: test/login.test.mjs :: spec:login/S2'] }), 'login.md', 'scenario-marker');
129
+ refuses(specDoc({ scenarios: ['- S1 a :: test/login.test.mjs :: spec:signup/S1'] }), 'login.md', 'scenario-marker');
130
+ refuses(specDoc({ scenarios: ['- S1 a :: ../test/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
131
+ refuses(specDoc({ scenarios: ['- S1 a :: /abs/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
132
+ });
133
+
134
+ it('out-of-scope / module-empty / module-* path refusals / module-mix / parts', () => {
135
+ refuses(specDoc({ outOfScope: '' }), 'login.md', 'out-of-scope');
136
+ refuses(specDoc({ outOfScope: 'nothing excluded' }), 'login.md', 'out-of-scope');
137
+ refuses(specDoc({ outOfScope: '- ' }), 'login.md', 'out-of-scope');
138
+ refuses(specDoc({ module: '' }), 'login.md', 'module-empty');
139
+ refuses(specDoc({ module: '*(empty)*' }), 'login.md', 'module-empty');
140
+ refuses(specDoc({ module: '- ../src/' }), 'login.md', 'module-traversal');
141
+ refuses(specDoc({ module: '- /src/login/' }), 'login.md', 'module-absolute');
142
+ refuses(specDoc({ module: '- C:/src/login/' }), 'login.md', 'module-absolute');
143
+ refuses(specDoc({ module: '- src\\login\\' }), 'login.md', 'module-backslash');
144
+ refuses(specDoc({ module: '- src/**/*.mjs' }), 'login.md', 'module-glob');
145
+ refuses(specDoc({ module: '- src/login/\n- src/login/a.mjs' }), 'login.md', 'module-mix');
146
+ refuses(specDoc({ module: '- src/login/\n- src/signup/' }), 'login.md', 'module-mix');
147
+ refuses(specDoc({ extra: '\n## Parts\n\n- [sessions](./sessions.md)\n' }), 'login.md', 'parts');
148
+ refuses(specDoc({ extra: '\n## Parts\n\n- sessions\n' }), 'auth/login/index.md', 'parts');
149
+ refuses(specDoc({ extra: '\n## Parts\n\n- [a](./a.md)\n- [b](./a.md)\n' }), 'auth/login/index.md', 'parts');
150
+ });
151
+
152
+ it('frontmatter-key: an unknown key, a duplicate key and a malformed line each refuse; an absent type is substrate-key ALONE', () => {
153
+ refuses(specDoc({ fields: { priority: 'high' } }), 'login.md', 'frontmatter-key');
154
+ refuses(specDoc().replace('owner: none\n', 'owner: none\nowner: none\n'), 'login.md', 'frontmatter-key');
155
+ refuses(specDoc().replace('owner: none\n', 'owner: none\n- a list item\n'), 'login.md', 'frontmatter-key');
156
+ refuses(specDoc({ drop: ['type'] }), 'login.md', 'substrate-key');
157
+ });
158
+
159
+ it('a part at the store root (no folder) is kind-path; a blank binding path is scenario-path; a blank module bullet is module-empty', () => {
160
+ refuses(partDoc(), 'orphan.md', 'kind-path');
161
+ refuses(specDoc({ scenarios: ['- S1 a :: :: spec:login/S1'] }), 'login.md', 'scenario-path');
162
+ refuses(specDoc({ module: '- ' }), 'login.md', 'module-empty');
163
+ });
164
+
165
+ it('module-line: prose beside a module bullet refuses; root-uplink needs the exact markdown link, not a mention', () => {
166
+ refuses(specDoc({ module: 'the root is\n- src/login/' }), 'login.md', 'module-line');
167
+ refuses(indexDoc({ preamble: '\nSee technical_specification.md for the top spec.\n', children: [] }), 'index.md', 'root-uplink');
168
+ expect(readSpecDocument(indexDoc({ preamble: `\n${UPLINK_LINE}\n`, children: [] }), 'index.md').errors).toEqual([]);
169
+ });
170
+
171
+ it('the reader parses no markdown code: a fence line refuses `fence`; the up-link is a whole exact line, so an inline span or a trailing note is root-uplink', () => {
172
+ refuses(indexDoc({ preamble: `\n${UPLINK_LINE}\n\n\`\`\`\n${UPLINK_LINE}\n\`\`\`\n`, children: [] }), 'index.md', 'fence');
173
+ refuses(partDoc({ extra: '\n~~~js\nconst sample = 1;\n~~~\n' }), 'auth/login/sessions.md', 'fence');
174
+ refuses(indexDoc({ preamble: `\nWrite \`\`${UPLINK_LINE}\`\` here.\n`, children: [] }), 'index.md', 'root-uplink');
175
+ refuses(indexDoc({ preamble: `\n${UPLINK_LINE} — the top spec\n`, children: [] }), 'index.md', 'root-uplink');
176
+ });
177
+
178
+ it('a frontmatter defect ENDS the read with frontmatter-key alone (a malformed kind line is not a missing kind)', () => {
179
+ refuses(specDoc().replace('kind: spec\n', 'kind spec\n'), 'login.md', 'frontmatter-key');
180
+ refuses(specDoc({ fields: { priority: 'high', kind: 'feature' } }), 'login.md', 'frontmatter-key');
181
+ });
182
+
183
+ it('the title precedes every section; prose-only Module is module-line; a blank Module on a retired spec is module-empty', () => {
184
+ refuses(specDoc().replace('# Spec: Login\n', '## Surprise\n\nx\n\n# Spec: Login\n'), 'login.md', 'title');
185
+ refuses(specDoc({ module: 'the root is src' }), 'login.md', 'module-line');
186
+ refuses(specDoc({ fields: { status: 'retired' }, module: '' }), 'login.md', 'module-empty');
187
+ });
188
+
189
+ it('every rule id a refusal names is declared in SPEC_SCHEMA.rules', () => {
190
+ for (const rule of ['frontmatter', 'frontmatter-key', 'fence', 'kind', 'module-mix', 'module-line', 'scenario-path', 'parts', 'root-uplink']) {
191
+ expect(SPEC_SCHEMA.rules.includes(rule)).toBe(true);
192
+ }
193
+ expect(new Set(SPEC_SCHEMA.rules).size).toBe(SPEC_SCHEMA.rules.length);
194
+ });
195
+ });
196
+
197
+ describe('classifyPath + the frozen constants', () => {
198
+ it('classifies the lexical path forms', () => {
199
+ expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([
200
+ 'dir', 'file', 'traversal', 'absolute', 'absolute', 'backslash', 'glob',
201
+ ]);
202
+ });
203
+
204
+ it('SPEC_SCHEMA carries the frozen numbers and SPECS_COLLAPSE joins the store root', () => {
205
+ expect(SPEC_SCHEMA.fanOutMax).toBe(30);
206
+ expect(SPEC_SCHEMA.maxLines).toEqual({ index: 80, spec: 150, part: 150 });
207
+ expect(SPEC_SCHEMA.statuses).toEqual(['draft', 'live', 'retired']);
208
+ expect(SPEC_SCHEMA.kinds).toEqual(['index', 'spec', 'part']);
209
+ expect(Object.isFrozen(SPEC_SCHEMA)).toBe(true);
210
+ expect(SPECS_COLLAPSE).toEqual({ prefix: 'docs/ai/specs/', navPath: 'docs/ai/specs/index.md', label: 'specs/', type: 'spec' });
211
+ });
212
+ });
@@ -37,7 +37,7 @@ All project knowledge lives in `docs/ai/`. Layered, lazy-loaded context:
37
37
  | [`docs/ai/handover.md`](./docs/ai/handover.md) | **Start of every session** | End of session if context changed |
38
38
  | [`docs/ai/active_plan.md`](./docs/ai/active_plan.md) | Picking next task | Completing a task |
39
39
  | [`docs/ai/current_state.md`](./docs/ai/current_state.md) | Need system overview | After feature completion |
40
- | [`docs/ai/technical_specification.md`](./docs/ai/technical_specification.md) | App overview & data models | Data-model changes |
40
+ | [`docs/ai/technical_specification.md`](./docs/ai/technical_specification.md) | App overview & data models; feature contracts live under `docs/ai/specs/` | Data-model or contract changes |
41
41
  | [`docs/ai/pages/index.md`](./docs/ai/pages/index.md) | Understanding a page | Page behaviour changes |
42
42
  | [`docs/ai/architecture.md`](./docs/ai/architecture.md) | Understanding structure | Architecture changes |
43
43
  | [`docs/ai/known_issues.md`](./docs/ai/known_issues.md) | Debugging | New issue discovered |
@@ -72,7 +72,7 @@ Start-of-session, during-work, and task-completion procedures live in [`docs/ai/
72
72
  | Interactive elements semantic (button/link, not div+onClick) | Linter / a11y |
73
73
  | No business logic in components → hooks/services | Architecture review |
74
74
  | No changes without tests (TDD) | Required |
75
- | Check page docs before changes; update them after | Process |
75
+ | Check the governing spec(s) + page docs before changes; update them after | Process |
76
76
  | Ask user before committing | Process |
77
77
  | Every page has an HTML-validity / a11y E2E test | Required |
78
78
  | **No silent failures** — structured logging on every rejected action | Required |
@@ -0,0 +1,45 @@
1
+ ---
2
+ type: spec
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 150
8
+ kind: spec
9
+ status: draft
10
+ revision: 1
11
+ ---
12
+
13
+ # Spec: example-feature
14
+
15
+ > Authoring reference for a `kind: spec` contract root — copy it to `docs/ai/specs/<slug>.md`
16
+ > (the slug mirrors the feature-slice or module name, `^[a-z0-9]+(-[a-z0-9]+)*$`) and replace every
17
+ > section. NOT deployed on bootstrap — it stays in the skill home like `adr-record.md`. The full
18
+ > schema (kinds, statuses, transitions, promotion, precedence) is the engine canon `references/specs.md`;
19
+ > the deployed reader `scripts/spec-schema.mjs` is what decides well-formed.
20
+
21
+ ## Contract
22
+
23
+ What the feature accepts and returns, stated as invariants a test can pin. A `draft` spec is authored
24
+ WITH the plan that lands the feature and exists at plan review; approval of that plan confirms the
25
+ contract (no separate stop). It becomes `live` on the plan's landing row; `revision` increments by one
26
+ per live contract change; `retired` on the removal row — never backwards.
27
+
28
+ ## Scenarios
29
+
30
+ - S1 accepts a well-formed request :: test/example-feature.test.mjs :: spec:example-feature/S1
31
+ - S2 refuses a malformed request with a typed error :: unbound
32
+
33
+ ## Out of scope
34
+
35
+ - The neighbouring feature this one is often confused with (name it — exclusions are the core value)
36
+ - Retry, caching and observability concerns owned by their own slices
37
+
38
+ ## Module
39
+
40
+ - src/example-feature/
41
+
42
+ ## Links
43
+
44
+ - [[AD-NNN]] — the decision that shaped this contract
45
+ - `pages/<page>.md` — the page spec this feature's view layer is described in (a subordinate view)
@@ -23,12 +23,12 @@ Read in order, then confirm before starting:
23
23
  4. Confirm with the user: *"I'm taking task X. Confirm?"*
24
24
 
25
25
  ### 1.2. During Work
26
- **Before any feature:** read the relevant page spec (`docs/ai/pages/<page>.md`). If behaviour changes, update the spec FIRST so docs and code never diverge.
26
+ **Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (zero is legal during adoption; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
27
27
 
28
28
  **For every code change:**
29
29
  1. Grep for similar implementations — reuse existing patterns.
30
30
  2. Check the design-system layer for an existing component; if missing, add it there FIRST, then use it.
31
- 3. Verify changes align with `docs/ai/pages/<page>.md`; for a new page, create a full spec.
31
+ 3. Verify changes align with the governing spec(s) and `docs/ai/pages/<page>.md`; a new feature gets a `draft` spec (the skill's `SPEC_TEMPLATE.md`), a new page a full page spec.
32
32
  4. Follow §2 (Self-Review): functional style, named exports, full variable names, no magic literals.
33
33
  5. Write/update tests FIRST (TDD): unit for pure functions, E2E for user flows.
34
34
  6. Run quality checks: lint, type-check, tests.
@@ -38,7 +38,7 @@ Read in order, then confirm before starting:
38
38
 
39
39
  Before claiming "done":
40
40
  1. Run all quality gates (lint + type-check + tests) — all green.
41
- 2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation). Only bump "Last Updated" when content actually changed.
41
+ 2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation), the governing spec (its revision lands with the code: `draft` -> `live` on the landing row). Only bump "Last Updated" when content actually changed.
42
42
  3. Run the docs cap-validator + index-freshness gate (pre-commit also enforces). On failure: trim the offending file, or run the changelog rotation if the offender is `changelog.md`.
43
43
  4. If the work executed a plan file — complete it per the project's **planning skill / methodology** (the `workflow:methodology` slot in `AGENTS.md` points to it; see §5). Without completing that step the plan is not done. (The methodology, including any mandatory final cleanup, is defined there — not in this substrate.)
44
44
  5. **Ask before committing** (§4): report lint / type-check / test counts + docs status, then wait for explicit approval. DO NOT auto-commit.
@@ -80,6 +80,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
80
80
  - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
81
81
  - **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
82
82
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
83
+ - **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
83
84
  - **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
84
85
  - **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
85
86
  - **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
@@ -0,0 +1,21 @@
1
+ ---
2
+ type: spec
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 80
8
+ kind: index
9
+ ---
10
+
11
+ # Specs
12
+
13
+ > Up: [technical_specification.md](../technical_specification.md)
14
+
15
+ The top spec is the system root; this store holds the per-feature contracts under it. Every folder's
16
+ `index.md` lists ONLY its immediate children (at most 30); a spec over 150 lines is promoted to
17
+ `<slug>/index.md` + parts. Author a new spec from the skill's `SPEC_TEMPLATE.md`; the canon is the
18
+ engine's `references/specs.md`.
19
+
20
+ ## Children
21
+