@sabaiway/agent-workflow-kit 7.2.0 → 7.3.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,26 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 7.3.0 — the spec layer's delivery half: hidden from day one, mirrored, bootstrapped (AD-112)
8
+
9
+ The memory substrate ships the feature-spec layer in 4.6.0 and the engine its canon in 3.3.0; this
10
+ release is what makes a FRESH kit bootstrap deliver it whole and hidden.
11
+
12
+ - **Hidden mode, no new machinery.** `KIT_OWN_PATHS` gains `/scripts/spec-schema.mjs` and
13
+ `/scripts/spec-schema.test.mjs` (sentinel 28 -> 30) — the reader the navigator checker now imports
14
+ and its deployed test; `/docs/ai/` already subsumes the store. The integration suite proves it on a
15
+ bootstrapped layout with real `git check-ignore` on both files and on `docs/ai/specs/x.md`: nothing
16
+ of the layer is visible to git.
17
+ - **Mirrors.** `references/scripts/` carries the reader pair and the GROUPS-collapse checker
18
+ byte-identical to the memory canon; `references/templates/` carries `specs/index.md` and
19
+ `SPEC_TEMPLATE.md` (the mirror manifest names both; template-parity reverse-pins them). The kit's
20
+ own fallback bootstrap excludes `SPEC_TEMPLATE.md` by NAME in its prose AND its E2E copy loop, and
21
+ the finalized navigator carries ONE counted `specs/` row.
22
+ - **Templates.** `agent_rules.md` §1.2/§1.3 reworded (governing specs, adoption shim, the revision
23
+ lands with the code) and its lens region re-rendered with the engine's `Spec-first` bullet;
24
+ `AGENTS.md` routes `technical_specification.md` readers to the contracts under `docs/ai/specs/`.
25
+ - Package-content pins: 241 -> 245 (the reader pair + the two templates), each by NAME.
26
+
7
27
  ## 7.2.0 — `.mcp.json` joins the hidden-mode footprint, and a worktree never owns it (AD-111)
8
28
 
9
29
  7.0.0 taught the kit to register its MCP server. It did not teach hidden mode about the file that
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.2.0'
6
+ version: '7.3.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.2.0",
6
+ "version": "7.3.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.2.0",
3
+ "version": "7.3.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",
@@ -21,7 +21,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
21
21
  3. **Choose conversational language — answered in the step-2 batch.** Which language should the agent *talk to them* in — questions, explanations, summaries, status updates? Offer the language they're already writing in as the default. Carry the answer into the `{{COMM_LANGUAGE}}` slot of the *Communication language* block when `AGENTS.md` is created (step 5). See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract). This sets the **dialogue** language only — never the files.
22
22
  4. **Choose agent attribution — answered in the step-2 batch.** May the agent attribute work to itself / to AI — `Co-Authored-By` trailers, "Generated with …" footers, "AI"/agent/model mentions in code, comments, commit messages, PR titles/bodies, or docs? **Default to `off`** (no agent/AI mention anywhere) unless they opt in — people are routinely surprised to find an AI listed as a repo contributor. Carry the answer into the `{{AGENT_ATTRIBUTION}}` slot of the *Attribution* block when `AGENTS.md` is created (step 5). **If `off` and the project uses Claude Code**, also set `"includeCoAuthoredBy": false` in the project's `.claude/settings.json` (create it if absent) — the trailer is added by the harness, so a doc directive alone won't stop it. See [Attribution contract](${CLAUDE_SKILL_DIR}/references/contracts.md#attribution-contract).
23
23
  5. **Entry-point doc.** If `AGENTS.md` / `CLAUDE.md` already exist (step-1 recon), do **not** overwrite — show the user and ask whether to merge or replace. Otherwise create `AGENTS.md` (the cross-agent standard — Codex / Cursor / Devin Desktop / Copilot read it natively) from `${CLAUDE_SKILL_DIR}/references/templates/AGENTS.md`, and symlink `CLAUDE.md -> AGENTS.md` (`ln -s AGENTS.md CLAUDE.md`) for Claude Code — single source, no duplication. For nested context, add a subdir `AGENTS.md` (+ a `CLAUDE.md` symlink beside it for Claude Code).
24
- 6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys each non-`AGENTS.md`, non-`adr-record.md` template `adr-record.md` is a skill-home ADR authoring reference, never deployed). This deploys the HOT ADR window **`docs/ai/decisions.md`** and the seed navigator **`docs/ai/adr/log.md`** (its `adr/` subdir copies in wholesale), the `.md` docs, **and** the three seeded, user-editable strict-JSON configs: **`docs/ai/orchestration.json`** (the per-project recipe defaults the `procedures` advisor reads), **`docs/ai/gates.json`** (the project's gate declaration — seeded as the no-consent EMPTY list; FILLING it is the consented `gates-init` preview, `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), and **`docs/ai/autonomy.json`** (the per-project autonomy policy — seeded SPARSE with the onboarding note only, so it is exactly the computed defaults until the user declares levels via `/agent-workflow-kit set-autonomy` or by hand). Keep each `.md` file's frontmatter (`type / lastUpdated / scope / staleAfter / owner / maxLines`); the `.json` seeds carry no frontmatter (the docs cap-validator globs `*.md` only, so they are inherently skipped).
24
+ 6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store + the seed `specs/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys every template except `AGENTS.md`, `adr-record.md` and `SPEC_TEMPLATE.md` the latter two are skill-home authoring references, never deployed). This deploys the HOT ADR window **`docs/ai/decisions.md`** and the seed navigator **`docs/ai/adr/log.md`** (its `adr/` subdir copies in wholesale), the spec store root **`docs/ai/specs/index.md`** (its `specs/` subdir copies in wholesale), the `.md` docs, **and** the three seeded, user-editable strict-JSON configs: **`docs/ai/orchestration.json`** (the per-project recipe defaults the `procedures` advisor reads), **`docs/ai/gates.json`** (the project's gate declaration — seeded as the no-consent EMPTY list; FILLING it is the consented `gates-init` preview, `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), and **`docs/ai/autonomy.json`** (the per-project autonomy policy — seeded SPARSE with the onboarding note only, so it is exactly the computed defaults until the user declares levels via `/agent-workflow-kit set-autonomy` or by hand). Keep each `.md` file's frontmatter (`type / lastUpdated / scope / staleAfter / owner / maxLines`); the `.json` seeds carry no frontmatter (the docs cap-validator globs `*.md` only, so they are inherently skipped).
25
25
  7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai` step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact with no template, so run `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<project>` and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a `write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it before reporting the deploy, since the navigator the entry point declares does not exist yet). The generator runs from **this skill's own directory**, so a project with **no Node runtime** is covered too: the agent host runs it, and step 8's copy is not a precondition.
26
26
  8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs` (+ `*.test.mjs`) into the project's `scripts/`. They self-configure (project name from `package.json`, hierarchical/on-demand sections auto-discovered). **If the project has no Node runtime** (step-1 recon), skip this step and the hook in step 9 — follow the cap/archive/index policy manually, or port the scripts to the project's language.
27
27
  9. **Wire / hide** per visibility (see [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). Install the pre-commit hook (Node projects): `node scripts/install-git-hooks.mjs`. If the installer reports a pre-existing non-marker hook, stop and ask the user to merge it manually rather than overwriting. Once the gate declaration is final-run-capable, the installer's optional consented `--commit-guard <kit commit-guard.mjs path>` arm binds commits to the green `run-gates --final` receipt (the Recommendations advisor surfaces the exact one-liner).
@@ -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.
@@ -36,7 +36,7 @@ Read in order, then confirm before starting:
36
36
  ### 1.3. Task Completion
37
37
  Before claiming "done":
38
38
  1. Run all quality gates (lint + type-check + tests) — all green.
39
- 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.
39
+ 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.
40
40
  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`.
41
41
  4. If the work executed a plan file — run that plan's final **Phase: Cleanup** (see the planning skill / §5). Without it the plan is not done.
42
42
  5. **Ask before committing** (§4): report lint / type-check / test counts + docs status, then wait for explicit approval. DO NOT auto-commit.
@@ -78,6 +78,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
78
78
  - **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.
79
79
  - **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.
80
80
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
81
+ - **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.
81
82
  - **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.
82
83
  - **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.
83
84
  - **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
+
@@ -69,6 +69,8 @@ export const KIT_OWN_PATHS = [
69
69
  '/scripts/migrate-gates-branches.test.mjs',
70
70
  '/scripts/migrate-gates.mjs',
71
71
  '/scripts/migrate-gates.test.mjs',
72
+ '/scripts/spec-schema.mjs',
73
+ '/scripts/spec-schema.test.mjs',
72
74
  '/docs/plans/',
73
75
  '/.claude/settings.local.json',
74
76
  '/.claude/settings.json',