@sabaiway/agent-workflow-kit 14.36.0 → 14.37.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,32 @@ 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
+ ## 14.37.0 — the first project rules theme, project layout, ships with the writer of a record of what each effective rules file was built from and a read-only check; nothing is laid into a project yet (AD-189)
8
+
9
+ **The kit now carries default project rules as thematic prose files, starting with project layout, and a command
10
+ line that checks the file agents read still stands for its sources.** This release ships the files' shape, the theme
11
+ registry, the record's writer and the check; the upgrade op that lays the theme into your project comes in a later release,
12
+ so this one writes no rules file into your project.
13
+
14
+ - **Four files per theme.** `docs/ai/rules/<theme>.md` is the kit's rules, `docs/ai/rules/local/<theme>.md` is
15
+ yours and wins, `docs/ai/rules/effective/<theme>.md` is what agents read, and
16
+ `docs/ai/rules/effective/sources.json` records, per theme, the sha256 of the kit, local (or `absent`) and effective
17
+ files. Rules are plain prose with no keys; with no local file the effective file equals the kit file, with one an
18
+ agent merges the two by meaning once. No code merges two texts.
19
+ - **The project-layout template** (`references/rules/project-layout.md`): a module is a directory holding its code,
20
+ tests, mocks, fixtures and command-line entry; its index exports only what is used outside; no README repeats its
21
+ spec; modules nest by meaning, never by technical layer; a spec's path mirrors its module's; old code keeps its
22
+ place as a recorded exception.
23
+ - **`node tools/project-rules/rules-cli.mjs --check [--cwd <project>]`** prints one line per theme — `absent`,
24
+ `current`, `drift` naming every moved file, or `record` with its state — and writes nothing. It exits 0 when every
25
+ theme is `current` or `absent`, 1 otherwise, every stop line saying `run /agent-workflow-kit upgrade`; 2 is usage.
26
+ It judges only against the record, never against the bundled template.
27
+ - **`--record --theme <name> --kit <sha256> --local <sha256|absent> --effective <sha256>`** is the merging agent's
28
+ call: it writes the theme's entry only when the files still hash to the passed digests and the effective file fits
29
+ the merge, and otherwise exits 1 naming the failed condition, writing nothing.
30
+
31
+ **Upgrade note.** No step: the module ships, and no rules file reaches a project until the later upgrade op.
32
+
7
33
  ## 14.36.0 — the review lens runs on opus/high by default and its model is a setting; the task tier offer proposes the executor, not codex (AD-188)
8
34
 
9
35
  **The bundled review lens now takes its model and effort from `docs/ai/vehicles.json`, opus and high unless you set
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". New to the epic, story and task tier? Run `/agent-workflow-kit tier`. A typed decision with a confidence from Jev? Run `/agent-workflow-kit jev`.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.36.0'
6
+ version: '14.37.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": "14.36.0",
6
+ "version": "14.37.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": "14.36.0",
3
+ "version": "14.37.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",
@@ -0,0 +1,41 @@
1
+ ---
2
+ type: rules
3
+ lastUpdated: 2026-10-18
4
+ scope: permanent
5
+ staleAfter: never
6
+ owner: kit
7
+ maxLines: 80
8
+ ---
9
+
10
+ # Project layout
11
+
12
+ Where a file goes is decided by what it means, never by where there is room.
13
+
14
+ ## A module is a directory
15
+
16
+ A module is a directory holding everything that belongs to it: its code, its tests, its mocks, its
17
+ fixtures and its command-line entry. Tests live inside the module, next to the code they check, never
18
+ in a separate tree.
19
+
20
+ ## The index is the one door
21
+
22
+ The module's own index file, the one its language uses for that purpose, exports only what is used
23
+ outside the module. Nothing outside reaches past the index into a file of the module.
24
+
25
+ ## The spec is the description
26
+
27
+ No README repeats the module's spec. The spec describes the module; a README that restates it drifts
28
+ from it.
29
+
30
+ ## Modules nest by meaning
31
+
32
+ Modules nest by meaning: a slice holds its modules, and a shared layer holds what several slices use.
33
+ Modules never nest by technical layer, so there is no directory of all controllers or all helpers.
34
+
35
+ ## The spec mirrors the module
36
+
37
+ A spec's path mirrors its module's path, so the name of one gives the place of the other.
38
+
39
+ ## Old code
40
+
41
+ Old code keeps its place as a recorded exception. New and touched code follows these rules.
@@ -0,0 +1,3 @@
1
+ // The one door of the project-rules module. Contract: docs/ai/specs/kit/project-rules/index.md.
2
+ export { THEMES } from './themes.mjs';
3
+ export { checkRules } from './rules-check.mjs';
@@ -0,0 +1,115 @@
1
+ // The read-only check: one verdict per registry theme, judged only against the record. It imports no
2
+ // writer and reads every file through readUnder, so no symlink inside the root is followed. Contract:
3
+ // docs/ai/specs/kit/project-rules/rules-check.md.
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { lstatSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { readFileBytesNoFollow } from '../fs-read-nofollow.mjs';
9
+ import { RECORD_PATH, THEMES, themePaths } from './themes.mjs';
10
+
11
+ const UPGRADE = 'run /agent-workflow-kit upgrade';
12
+ const EDIT_NOTE = 'move your edit to local/, a rebuild drops it';
13
+ const DIGEST = /^[0-9a-f]{64}$/;
14
+ const ROLES = ['kit', 'local', 'effective'];
15
+
16
+ export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
17
+
18
+ const isDigest = (value) => typeof value === 'string' && DIGEST.test(value);
19
+
20
+ // readUnder(root, rel, deps?) → a readFileBytesNoFollow outcome for root/rel, or foreign or error
21
+ // without a read when a directory on rel's path inside root is a symlink, not a directory, or cannot be
22
+ // inspected; a missing directory ends the walk and the leaf then reads absent.
23
+ export const readUnder = (root, rel, deps = {}) => {
24
+ const lstat = deps.lstat ?? lstatSync;
25
+ const parts = rel.split('/');
26
+ for (let i = 1; i < parts.length; i += 1) {
27
+ const prefix = parts.slice(0, i).join('/');
28
+ let st;
29
+ try {
30
+ st = lstat(join(root, prefix));
31
+ } catch (err) {
32
+ if (err && err.code === 'ENOENT') break;
33
+ return { outcome: 'error', code: `${prefix} cannot be inspected (${err?.code ?? err})` };
34
+ }
35
+ if (st.isSymbolicLink() || !st.isDirectory()) return { outcome: 'foreign', code: `${prefix} is a symlink or not a directory` };
36
+ }
37
+ return (deps.read ?? readFileBytesNoFollow)(join(root, rel));
38
+ };
39
+
40
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
41
+ const sameKeys = (value, keys) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
42
+
43
+ // parseRecord(text, themes?) → { ok: true, record } | { ok: false, reason }: the record's one schema reader.
44
+ export const parseRecord = (text, themes = THEMES) => {
45
+ const bad = (reason) => ({ ok: false, reason });
46
+ let record;
47
+ try {
48
+ record = JSON.parse(text);
49
+ } catch {
50
+ return bad('not JSON');
51
+ }
52
+ if (!isObject(record) || !sameKeys(record, ['schema', 'themes'])) return bad('the keys are not exactly schema and themes');
53
+ if (record.schema !== 1) return bad(`schema ${JSON.stringify(record.schema)} is not 1`);
54
+ if (!isObject(record.themes)) return bad('themes is not an object');
55
+ const names = new Set(themes.map((theme) => theme.name));
56
+ for (const [name, entry] of Object.entries(record.themes)) {
57
+ if (!names.has(name)) return bad(`theme ${name} is not in the registry`);
58
+ if (!isObject(entry) || !sameKeys(entry, ROLES)) return bad(`the entry of ${name} does not hold exactly kit, local and effective`);
59
+ if (!isDigest(entry.kit) || !isDigest(entry.effective)) return bad(`the entry of ${name} holds a value that is not a sha256`);
60
+ if (entry.local !== 'absent' && !isDigest(entry.local)) return bad(`the local of ${name} is neither a sha256 nor absent`);
61
+ }
62
+ return { ok: true, record };
63
+ };
64
+
65
+ // readRecord(outcome, themes) → { ok: true, themes } | { ok: false, reason }: an absent record holds no entry.
66
+ export const readRecord = (outcome, themes = THEMES) => {
67
+ if (outcome.outcome === 'absent') return { ok: true, themes: {} };
68
+ if (outcome.outcome !== 'ok') return { ok: false, reason: 'it is not a regular file this check can read' };
69
+ const parsed = parseRecord(outcome.bytes.toString('utf8'), themes);
70
+ return parsed.ok ? { ok: true, themes: parsed.record.themes } : parsed;
71
+ };
72
+
73
+ const recordVerdict = (theme, state, detail) =>
74
+ ({ theme, verdict: 'record', state, line: `${theme}: record (${state}) — ${detail} — ${UPGRADE}` });
75
+
76
+ const judgeTheme = (theme, paths, files, record) => {
77
+ if (!record.ok) return recordVerdict(theme, 'unreadable-record', `${paths.record} is unreadable: ${record.reason}`);
78
+ const unreadable = ROLES.find((role) => !['ok', 'absent'].includes(files[role].outcome));
79
+ if (unreadable) return recordVerdict(theme, 'unreadable-file', `${paths[unreadable]} is not a regular file this check can read`);
80
+ const present = (role) => files[role].outcome === 'ok';
81
+ const entry = record.themes[theme];
82
+ if (entry === undefined) {
83
+ const existing = ROLES.find(present);
84
+ if (existing === undefined) return { theme, verdict: 'absent', line: `${theme}: absent` };
85
+ return recordVerdict(theme, 'no-entry', `no record entry while ${paths[existing]} exists`);
86
+ }
87
+ const missing = ['kit', 'effective'].find((role) => !present(role));
88
+ if (missing) return recordVerdict(theme, 'missing-file', `${paths[missing]} is missing under its record entry`);
89
+ const differs = {
90
+ kit: digestOf(files.kit.bytes) !== entry.kit,
91
+ local: present('local') ? digestOf(files.local.bytes) !== entry.local : entry.local !== 'absent',
92
+ effective: digestOf(files.effective.bytes) !== entry.effective,
93
+ };
94
+ const moved = ROLES.filter((role) => differs[role])
95
+ .map((role) => (role === 'effective' ? { role, file: paths[role], note: EDIT_NOTE } : { role, file: paths[role] }));
96
+ if (moved.length > 0) {
97
+ const named = moved.map(({ file, note }) => (note ? `${file} (${note})` : file)).join('; ');
98
+ return { theme, verdict: 'drift', moved, line: `${theme}: drift — moved ${named} — ${UPGRADE}` };
99
+ }
100
+ if (!present('local') && !files.kit.bytes.equals(files.effective.bytes)) {
101
+ return recordVerdict(theme, 'kit-effective-differ', `no local file, yet ${paths.effective} differs from ${paths.kit}`);
102
+ }
103
+ return { theme, verdict: 'current', line: `${theme}: current` };
104
+ };
105
+
106
+ // checkRules(root, deps?) → [{ theme, verdict, line, moved?, state? }] in registry order; writes nothing.
107
+ export const checkRules = (root, deps = {}) => {
108
+ const themes = deps.themes ?? THEMES;
109
+ const record = readRecord(readUnder(root, RECORD_PATH, deps), themes);
110
+ return themes.map(({ name }) => {
111
+ const paths = themePaths(name);
112
+ const files = Object.fromEntries(ROLES.map((role) => [role, readUnder(root, paths[role], deps)]));
113
+ return judgeTheme(name, paths, files, record);
114
+ });
115
+ };
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // The command line over the read-only check and the record writer. Contract:
3
+ // docs/ai/specs/kit/project-rules/rules-cli.md. Exit codes: 0 accept; 1 a stop or a refused record; 2 usage.
4
+
5
+ import { resolve } from 'node:path';
6
+ import { isDirectRun } from '../direct-run.mjs';
7
+ import { checkRules } from './rules-check.mjs';
8
+ import { writeRecord } from './rules-record.mjs';
9
+
10
+ const USAGE = `project-rules — the effective rule files still stand for their sources.
11
+
12
+ Usage:
13
+ node rules-cli.mjs --check [--cwd <project>]
14
+ node rules-cli.mjs --record --theme <name> --kit <sha256> --local <sha256|absent> --effective <sha256> [--cwd <project>]
15
+
16
+ --check one line per theme: current, absent, drift or record; writes nothing.
17
+ --record the merging agent's call: records the digests of the inputs it read and of the file it wrote.
18
+
19
+ Exit codes: 0 accept; 1 a stop or a refused record; 2 usage.`;
20
+
21
+ const MODES = { '--check': [], '--record': ['--theme', '--kit', '--local', '--effective'] };
22
+ const VALUED = new Set(['--cwd', '--theme', '--kit', '--local', '--effective']);
23
+
24
+ // parseArgs(argv) → { mode, options } | { help: true } | { error }; reads nothing.
25
+ const parseArgs = (argv) => {
26
+ if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { help: true };
27
+ let mode = null;
28
+ const options = {};
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const arg = argv[i];
31
+ if (Object.hasOwn(MODES, arg)) {
32
+ if (mode !== null) return { error: `${mode} and ${arg} are two modes; one run takes exactly one` };
33
+ mode = arg;
34
+ } else if (VALUED.has(arg)) {
35
+ const value = argv[i + 1];
36
+ if (value === undefined || value.startsWith('--')) return { error: `${arg} needs a value` };
37
+ if (arg in options) return { error: `${arg} is given twice` };
38
+ options[arg] = value;
39
+ i += 1;
40
+ } else {
41
+ return { error: `unknown argument: ${arg}` };
42
+ }
43
+ }
44
+ if (mode === null) return { error: 'no mode: give --check or --record' };
45
+ const allowed = new Set(['--cwd', ...MODES[mode]]);
46
+ const extra = Object.keys(options).find((name) => !allowed.has(name));
47
+ if (extra) return { error: `${mode} does not take ${extra}` };
48
+ const absent = MODES[mode].find((name) => !(name in options));
49
+ if (absent) return { error: `${mode} needs ${absent}` };
50
+ return { mode, options };
51
+ };
52
+
53
+ const lines = (list) => (list.length === 0 ? '' : `${list.join('\n')}\n`);
54
+
55
+ // main(argv, ctx?) → { code, stdout, stderr }; never exits the process.
56
+ export const main = (argv, ctx = {}) => {
57
+ const parsed = parseArgs(argv);
58
+ if (parsed.help) return { code: 0, stdout: `${USAGE}\n`, stderr: '' };
59
+ if (parsed.error) return { code: 2, stdout: '', stderr: `project-rules: ${parsed.error}\n\n${USAGE}\n` };
60
+ const root = resolve(ctx.cwd ?? process.cwd(), parsed.options['--cwd'] ?? '.');
61
+ const deps = ctx.deps ?? {};
62
+ if (parsed.mode === '--check') {
63
+ const verdicts = checkRules(root, deps);
64
+ const stops = verdicts.filter(({ verdict }) => verdict !== 'current' && verdict !== 'absent');
65
+ return { code: stops.length === 0 ? 0 : 1, stdout: lines(verdicts.map(({ line }) => line)), stderr: '' };
66
+ }
67
+ const { '--theme': theme, '--kit': kit, '--local': local, '--effective': effective } = parsed.options;
68
+ const result = writeRecord(root, theme, { kit, local, effective }, deps);
69
+ if (!result.ok) return { code: 1, stdout: '', stderr: `project-rules: --record refused (${result.condition}) — ${result.message}; nothing written\n` };
70
+ return { code: 0, stdout: `project-rules: recorded ${theme} in ${result.path}\n`, stderr: '' };
71
+ };
72
+
73
+ if (isDirectRun(import.meta.url)) {
74
+ const { code, stdout, stderr } = main(process.argv.slice(2));
75
+ process.stdout.write(stdout);
76
+ process.stderr.write(stderr);
77
+ process.exitCode = code;
78
+ }
@@ -0,0 +1,78 @@
1
+ // The record's one writer: docs/ai/rules/effective/sources.json states what every effective file was
2
+ // built from, and a merge it cannot vouch for is refused. Contract:
3
+ // docs/ai/specs/kit/project-rules/rules-record.md.
4
+
5
+ import { lstatSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { writeDocsAiFileAtomic } from '../atomic-write.mjs';
8
+ import { parseFrontmatter } from '../../references/scripts/check-docs-size.mjs';
9
+ import { digestOf, readRecord, readUnder } from './rules-check.mjs';
10
+ import { RECORD_PATH, THEMES, themePaths } from './themes.mjs';
11
+
12
+ const ROLES = ['kit', 'local', 'effective'];
13
+
14
+ // Why the first node on the record's path that lstat cannot inspect, is a symlink or is of the wrong
15
+ // kind fails, or null when the path holds or ends in a missing node.
16
+ const wrongNode = (root, lstat) => {
17
+ const parts = RECORD_PATH.split('/');
18
+ for (let i = 1; i <= parts.length; i += 1) {
19
+ const rel = parts.slice(0, i).join('/');
20
+ let st;
21
+ try {
22
+ st = lstat(join(root, rel));
23
+ } catch (err) {
24
+ if (err && err.code === 'ENOENT') return null;
25
+ return `${rel} cannot be inspected (${err?.code ?? err})`;
26
+ }
27
+ const isLeaf = i === parts.length;
28
+ if (st.isSymbolicLink() || (isLeaf ? !st.isFile() : !st.isDirectory())) return `${rel} is a symlink or not of its kind`;
29
+ }
30
+ return null;
31
+ };
32
+
33
+ // The conditions in the order they are judged; each returns null when it holds, else the refusal.
34
+ const judge = (root, theme, passed, deps) => {
35
+ const themes = deps.themes ?? THEMES;
36
+ const fail = (condition, message) => ({ ok: false, condition, message });
37
+ if (!themes.some(({ name }) => name === theme)) return fail('theme', `${theme} is not a registry theme`);
38
+ const node = wrongNode(root, deps.lstat ?? lstatSync);
39
+ if (node !== null) return fail('record-path', node);
40
+ const recordRead = readUnder(root, RECORD_PATH, deps);
41
+ if (!['ok', 'absent'].includes(recordRead.outcome)) return fail('record-path', `${RECORD_PATH} is not a regular file`);
42
+ const record = readRecord(recordRead, themes);
43
+ if (!record.ok) return fail('unreadable-record', `${RECORD_PATH} is unreadable: ${record.reason}`);
44
+ const paths = themePaths(theme);
45
+ const files = Object.fromEntries(ROLES.map((role) => [role, readUnder(root, paths[role], deps)]));
46
+ const unreadable = ROLES.find((role) => !['ok', 'absent'].includes(files[role].outcome));
47
+ if (unreadable) return fail('unreadable-file', `${paths[unreadable]} is not a regular file this writer can read`);
48
+ const missing = ['kit', 'effective'].find((role) => files[role].outcome !== 'ok');
49
+ if (missing) return fail('missing-file', `${paths[missing]} is missing`);
50
+ const { kit, local, effective } = { kit: files.kit.bytes, local: files.local.bytes, effective: files.effective.bytes };
51
+ if (parseFrontmatter(effective.toString('utf8')) === null) return fail('frontmatter', `${paths.effective} carries no frontmatter`);
52
+ if (digestOf(kit) !== passed.kit) return fail('kit-moved', `${paths.kit} no longer hashes to the passed kit digest`);
53
+ if (digestOf(effective) !== passed.effective) return fail('effective-moved', `${paths.effective} no longer hashes to the passed effective digest`);
54
+ if ((local !== undefined) !== (passed.local !== 'absent')) {
55
+ return fail('local-presence', `${paths.local} ${local === undefined ? 'is absent' : 'exists'} while the passed local is ${passed.local}`);
56
+ }
57
+ if (local !== undefined) {
58
+ if (digestOf(local) !== passed.local) return fail('local-moved', `${paths.local} no longer hashes to the passed local digest`);
59
+ if (effective.equals(kit) || effective.equals(local)) return fail('effective-equals-input', `${paths.effective} equals one of its inputs, so no merge was written`);
60
+ } else if (!effective.equals(kit)) {
61
+ return fail('effective-differs-from-kit', `with no local file ${paths.effective} must equal ${paths.kit}`);
62
+ }
63
+ return { ok: true, themes: record.themes };
64
+ };
65
+
66
+ // writeRecord(root, theme, passed, deps?) → { ok: true, path, entry } | { ok: false, condition, message }.
67
+ export const writeRecord = (root, theme, passed, deps = {}) => {
68
+ const judged = judge(root, theme, passed, deps);
69
+ if (!judged.ok) return judged;
70
+ const entry = { kit: passed.kit, local: passed.local, effective: passed.effective };
71
+ const body = `${JSON.stringify({ schema: 1, themes: { ...judged.themes, [theme]: entry } }, null, 2)}\n`;
72
+ try {
73
+ (deps.write ?? writeDocsAiFileAtomic)(root, RECORD_PATH, body);
74
+ } catch (err) {
75
+ return { ok: false, condition: 'write', message: String(err?.message ?? err) };
76
+ }
77
+ return { ok: true, path: RECORD_PATH, entry };
78
+ };
@@ -0,0 +1,19 @@
1
+ // The theme registry: pure data and pure derivation. Every surface that names a theme or its files
2
+ // reads this list, never a second copy. Contract: docs/ai/specs/kit/project-rules/themes.md.
3
+
4
+ export const THEMES = Object.freeze([
5
+ Object.freeze({
6
+ name: 'project-layout',
7
+ template: 'references/rules/project-layout.md',
8
+ readWhen: 'Creating, moving or naming a file, a module or a spec',
9
+ }),
10
+ ]);
11
+
12
+ export const RECORD_PATH = 'docs/ai/rules/effective/sources.json';
13
+
14
+ export const themePaths = (name) => Object.freeze({
15
+ kit: `docs/ai/rules/${name}.md`,
16
+ local: `docs/ai/rules/local/${name}.md`,
17
+ effective: `docs/ai/rules/effective/${name}.md`,
18
+ record: RECORD_PATH,
19
+ });
@@ -1,5 +1,5 @@
1
1
  // spec-adoption.mjs — which of the four adoption states the feature-spec store is in, and whether the layer
2
- // was declined. Contract: docs/ai/specs/kit/spec-adoption.md. The census and the per-document read are
2
+ // was declined. Contract: docs/ai/specs/kit/spec-layer/spec-adoption.md. The census and the per-document read are
3
3
  // spec-check's own exported seam; this module walks nothing of its own. Read-only, Node >= 22.
4
4
 
5
5
  import { join } from 'node:path';
@@ -7,7 +7,7 @@
7
7
  // contract a REQUIREMENT with a ratchet — a shipped tool no contract governs is a REFUSAL, and the
8
8
  // debt of today's uncovered tools may only shrink.
9
9
  //
10
- // The contract this module is built to is `docs/ai/specs/kit/spec-coverage.md`, and it was written
10
+ // The contract this module is built to is `docs/ai/specs/kit/spec-layer/spec-coverage.md`, and it was written
11
11
  // BEFORE this file. That order is the point: a contract amended after the code describes whatever
12
12
  // the last review happened to find, which makes review an open question with no bounded answer.
13
13
  //