@holmes-lab/holmes-kit 0.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,331 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MemorySpecStore = exports.LocalMarkdownRepository = exports.TargetPathOccupiedError = exports.SpecVersionConflictError = void 0;
37
+ exports.unreadableSpecFiles = unreadableSpecFiles;
38
+ const node_crypto_1 = require("node:crypto");
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const spec_parser_1 = require("./spec-parser");
42
+ const spec_types_1 = require("./spec-types");
43
+ // @implements A-SPEC-100.1
44
+ /**
45
+ * Thrown when `expectedVersion` no longer matches what the store holds.
46
+ *
47
+ * @implements A-SPEC-151
48
+ * Optimistic concurrency, which REQ-018's deprecation note named as the surviving fragment of that
49
+ * requirement: "스펙 수준 낙관적 동시성은 ports SpecStore expectedVersion(ADR-001)으로 생존".
50
+ */
51
+ class SpecVersionConflictError extends Error {
52
+ }
53
+ exports.SpecVersionConflictError = SpecVersionConflictError;
54
+ /**
55
+ * @implements A-SPEC-188
56
+ * A write would land on a path occupied by a file that is NOT this spec's own current file —
57
+ * typically an unparseable human note or broken document that list()/read() cannot see. Thrown
58
+ * before any mutation; the occupant is a human's to look at, never the store's to replace.
59
+ */
60
+ class TargetPathOccupiedError extends Error {
61
+ occupiedPath;
62
+ constructor(message, occupiedPath) {
63
+ super(message);
64
+ this.occupiedPath = occupiedPath;
65
+ }
66
+ }
67
+ exports.TargetPathOccupiedError = TargetPathOccupiedError;
68
+ // Rejects values that are empty or would escape a single path segment (contains a path
69
+ // separator or resolves to something other than itself via path.basename, which also
70
+ // catches '..' and '.'). Throws rather than silently coercing so callers (e.g. spec_create)
71
+ // surface a clean error instead of writing outside the store root.
72
+ function assertSafePathSegment(value, label) {
73
+ if (!value || path.basename(value) !== value) {
74
+ throw new Error(`Invalid ${label}: "${value}" is not a safe path segment`);
75
+ }
76
+ }
77
+ /**
78
+ * @implements A-SPEC-176
79
+ * Markdown files under the spec tree that the parser cannot turn into a spec.
80
+ *
81
+ * `list()` drops these via `if (spec.id)`, which is the right call for a gate — an unparseable file
82
+ * cannot be treated as approved. But dropping them silently means a user reads "that spec does not
83
+ * exist" while looking straight at the file. The verdict stays; the silence does not.
84
+ */
85
+ function unreadableSpecFiles(specsDir) {
86
+ const out = [];
87
+ const walk = (d) => {
88
+ let entries;
89
+ try {
90
+ entries = fs.readdirSync(d, { withFileTypes: true });
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ for (const e of entries) {
96
+ const p = path.join(d, e.name);
97
+ if (e.isDirectory()) {
98
+ walk(p);
99
+ continue;
100
+ }
101
+ if (!e.name.endsWith('.md'))
102
+ continue;
103
+ let ok = false;
104
+ try {
105
+ ok = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8')).id !== '';
106
+ }
107
+ catch {
108
+ ok = false;
109
+ }
110
+ if (!ok)
111
+ out.push(path.relative(specsDir, p).split(path.sep).join('/'));
112
+ }
113
+ };
114
+ walk(specsDir);
115
+ return out.sort();
116
+ }
117
+ class LocalMarkdownRepository {
118
+ root;
119
+ constructor(root) {
120
+ this.root = root;
121
+ }
122
+ /**
123
+ * @implements A-SPEC-169
124
+ * Where this store writes. Exposed so a handler can tell that the `root` it was given names a
125
+ * DIFFERENT project — measured 2026-08-08, a call with a temp root wrote into this repository
126
+ * instead, because the store is bound at start-up and the argument governed only the ledger.
127
+ */
128
+ get specsRoot() { return this.root; }
129
+ dirFor(spec) {
130
+ const base = path.join(this.root, spec_types_1.SPEC_TYPES[spec.type].folder);
131
+ if (spec.type !== 'H-SPEC')
132
+ return base;
133
+ // @implements A-SPEC-174
134
+ // A placeholder is not a classification. `req_type` is required, so `spec_create` stubs it, and
135
+ // using that stub as a path segment put a literal `02_h-spec/TODO/` directory in the user's
136
+ // repository (measured 2026-08-12). Unclassified H-SPECs sit directly under the type folder — a
137
+ // layout the repository already contains — and the relocation on a later `req_type` edit is the
138
+ // same one that already handles a change of classification.
139
+ const raw = spec.frontmatter.req_type;
140
+ if (raw == null || String(raw) === spec_types_1.FIELD_PLACEHOLDER)
141
+ return base;
142
+ const reqType = String(raw);
143
+ assertSafePathSegment(reqType, 'req_type');
144
+ return path.join(base, reqType);
145
+ }
146
+ /** Version of the STORED representation — the serialized document, so a status flip changes it. */
147
+ versionOf(text) {
148
+ return `sha256:${(0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex')}`;
149
+ }
150
+ /**
151
+ * @implements A-SPEC-188
152
+ * The exact path a write of this spec would land on — PUBLIC so create-only callers can refuse
153
+ * when something already sits there. `list()` cannot answer that question: it drops files it
154
+ * cannot parse, and an unparseable document at the target path is precisely the one a create
155
+ * must not destroy (probed: a prose file at 01_req/REQ-777.md was silently overwritten by a
156
+ * guard that consulted list() alone — and the overwrite also emptied unreadableSpecFiles, so
157
+ * the doctor warning that would have surfaced the loss died with the document).
158
+ */
159
+ targetPathFor(spec) {
160
+ assertSafePathSegment(spec.id, 'spec id');
161
+ return path.join(this.dirFor(spec), `${spec.id}.md`);
162
+ }
163
+ /**
164
+ * @implements A-SPEC-188
165
+ * Writes are SERIALIZED per store instance. The expectedVersion check and the file write are
166
+ * separated by an `await this.list()` (the orphan sweep), so two concurrent write() calls could
167
+ * both pass the check before either wrote — a check-then-act hole round-3 review demonstrated.
168
+ * The server is single-process; a promise chain closes the in-process window. Cross-process
169
+ * concurrency remains the caller's problem, as it was.
170
+ */
171
+ writeChain = Promise.resolve();
172
+ async write(spec, opts) {
173
+ const run = () => this.writeSerialized(spec, opts);
174
+ const p = this.writeChain.then(run, run);
175
+ this.writeChain = p.catch(() => undefined);
176
+ return p;
177
+ }
178
+ async writeSerialized(spec, opts) {
179
+ const newPath = this.targetPathFor(spec);
180
+ // @implements A-SPEC-151
181
+ // Checked BEFORE any filesystem change. A refusal that half-wrote would be the worst outcome:
182
+ // the caller is told it failed while the store has already moved.
183
+ if (opts?.expectedVersion !== undefined) {
184
+ const current = await this.read(spec.id);
185
+ if (current === null || current.version !== opts.expectedVersion) {
186
+ throw new SpecVersionConflictError(`spec ${spec.id} changed since it was read (expected ${opts.expectedVersion}, found ${current?.version ?? 'nothing'})`);
187
+ }
188
+ }
189
+ // @implements A-SPEC-188
190
+ // A write that RELOCATES (legacy filename → canonical on upgrade, base folder → req_type
191
+ // subfolder on approve after classification) must not land on a file that is not this spec's
192
+ // own. list()/read() skip unparseable files, so an occupied destination is invisible to every
193
+ // version check — round-3 review probed spec_upgrade and spec_approve silently replacing a
194
+ // human note at the destination, with the doctor's unreadable-file warning dying alongside it.
195
+ // Same-path overwrite (currentPath === newPath) is the normal update and stays allowed.
196
+ const currentPath = await this.findFilePath(spec.id);
197
+ if (currentPath !== newPath && fs.existsSync(newPath)) {
198
+ throw new TargetPathOccupiedError(`spec ${spec.id}: destination ${newPath} is occupied by a file the store cannot read as this spec — a human must move or fix it first`, newPath);
199
+ }
200
+ // Remove orphaned files with same id from different paths (e.g., after req_type change)
201
+ const allSpecs = await this.list();
202
+ for (const existing of allSpecs) {
203
+ if (existing.id === spec.id) {
204
+ // Find the actual file path by walking the tree
205
+ const existingPath = await this.findFilePath(existing.id);
206
+ if (existingPath && existingPath !== newPath) {
207
+ fs.unlinkSync(existingPath);
208
+ }
209
+ }
210
+ }
211
+ const dir = this.dirFor(spec);
212
+ fs.mkdirSync(dir, { recursive: true });
213
+ const text = (0, spec_parser_1.serializeSpec)(spec);
214
+ fs.writeFileSync(newPath, text);
215
+ return { version: this.versionOf(text) };
216
+ }
217
+ async findFilePath(id) {
218
+ let result = null;
219
+ const walk = (d) => {
220
+ if (!fs.existsSync(d))
221
+ return;
222
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
223
+ const p = path.join(d, e.name);
224
+ if (e.isDirectory()) {
225
+ walk(p);
226
+ }
227
+ else if (e.name.endsWith('.md')) {
228
+ try {
229
+ const spec = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8'));
230
+ if (spec.id === id) {
231
+ result = p;
232
+ }
233
+ }
234
+ catch {
235
+ // Skip unparseable files
236
+ }
237
+ }
238
+ }
239
+ };
240
+ walk(this.root);
241
+ return result;
242
+ }
243
+ async list() {
244
+ const out = [];
245
+ const walk = (d) => {
246
+ if (!fs.existsSync(d))
247
+ return;
248
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
249
+ const p = path.join(d, e.name);
250
+ if (e.isDirectory())
251
+ walk(p);
252
+ else if (e.name.endsWith('.md')) {
253
+ try {
254
+ const spec = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8'));
255
+ // Only include valid specs with non-empty id
256
+ if (spec.id) {
257
+ out.push(spec);
258
+ }
259
+ }
260
+ catch {
261
+ // Skip files that cannot be parsed (e.g., legacy/malformed markdown)
262
+ }
263
+ }
264
+ }
265
+ };
266
+ walk(this.root);
267
+ return out;
268
+ }
269
+ async read(id) {
270
+ const file = await this.findFilePath(id);
271
+ if (file === null)
272
+ return null;
273
+ let text;
274
+ try {
275
+ text = fs.readFileSync(file, 'utf8');
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ let spec;
281
+ try {
282
+ spec = (0, spec_parser_1.parseSpec)(text);
283
+ }
284
+ catch {
285
+ return null;
286
+ } // unparseable is "not readable", as list() treats it
287
+ return { spec, version: this.versionOf(text) };
288
+ }
289
+ }
290
+ exports.LocalMarkdownRepository = LocalMarkdownRepository;
291
+ /**
292
+ * The second adapter — the instrument that makes the contract testable.
293
+ *
294
+ * @implements A-SPEC-151
295
+ * Uses NO filesystem, deliberately: an in-memory store backed by a temp directory would be the file
296
+ * adapter in disguise and would prove nothing about coupling. The version is derived the same way in
297
+ * spirit — from the stored representation — but the representation here is an object, so it is
298
+ * hashed from a canonical serialization of the whole spec INCLUDING status, which is the property
299
+ * the conformance suite pins.
300
+ */
301
+ class MemorySpecStore {
302
+ items = new Map();
303
+ versionOf(spec) {
304
+ const canonical = JSON.stringify({
305
+ id: spec.id, type: spec.type, title: spec.title, status: spec.status,
306
+ dependsOn: [...spec.dependsOn].sort(),
307
+ sections: Object.keys(spec.sections).sort().map((k) => [k, spec.sections[k]]),
308
+ frontmatter: Object.keys(spec.frontmatter).sort().map((k) => [k, spec.frontmatter[k]]),
309
+ });
310
+ return `sha256:${(0, node_crypto_1.createHash)('sha256').update(canonical, 'utf8').digest('hex')}`;
311
+ }
312
+ async read(id) {
313
+ const spec = this.items.get(id);
314
+ return spec ? { spec, version: this.versionOf(spec) } : null;
315
+ }
316
+ async write(spec, opts) {
317
+ if (opts?.expectedVersion !== undefined) {
318
+ const current = this.items.get(spec.id);
319
+ const version = current ? this.versionOf(current) : null;
320
+ if (version !== opts.expectedVersion) {
321
+ throw new SpecVersionConflictError(`spec ${spec.id} changed since it was read (expected ${opts.expectedVersion}, found ${version ?? 'nothing'})`);
322
+ }
323
+ }
324
+ this.items.set(spec.id, spec);
325
+ return { version: this.versionOf(spec) };
326
+ }
327
+ async list() {
328
+ return [...this.items.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
329
+ }
330
+ }
331
+ exports.MemorySpecStore = MemorySpecStore;
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FIELD_PLACEHOLDER = exports.SPEC_TYPES = exports.CITATION_PLACEHOLDER = exports.MUTABLE_CITATION_KINDS = exports.CITATION_KINDS = exports.SPEC_ORDER = exports.SPEC_STATUSES = void 0;
4
+ exports.specTypeDef = specTypeDef;
5
+ exports.isGoverned = isGoverned;
6
+ exports.filterGoverned = filterGoverned;
7
+ exports.specTypeOfId = specTypeOfId;
8
+ exports.parentRuleText = parentRuleText;
9
+ // @implements A-SPEC-192
10
+ // The RUNTIME list is the single truth; the type derives from it (the same rule A-SPEC-189 set for
11
+ // phase ACTIONS). Review round 1 measured THREE hand-copies of this union — handlers' legacy set,
12
+ // its test's CANON, and the type itself — so adding a status compiled clean while spec_list kept
13
+ // flagging it legacy.
14
+ exports.SPEC_STATUSES = ['draft', 'review', 'approved', 'outdated'];
15
+ exports.SPEC_ORDER = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC'];
16
+ /**
17
+ * Where a requirement came from.
18
+ *
19
+ * A REQ is the one artefact whose content cannot be recovered from the codebase — intent lives in
20
+ * people, tickets, incidents and decisions. That makes its provenance the weakest link in the whole
21
+ * chain: everything downstream is traceable to the REQ, and the REQ was traceable to a sentence of
22
+ * free text. Structuring it is what lets a future RequirementSourceAdapter / KnowledgeBaseAdapter
23
+ * (ports-and-adapters-design §2.1) fill this field mechanically instead of a human paraphrasing a
24
+ * wiki page, and what lets a changed upstream source mark its derived specs outdated.
25
+ *
26
+ * DELIBERATELY SHAPE-ONLY. The deterministic layer can check that a citation exists, names a kind it
27
+ * understands, points somewhere, and pins either a revision or a read date. It cannot check that the
28
+ * cited document says what the REQ claims — that stays a human review judgement, exactly as with the
29
+ * prose sections a reverse-engineered spec leaves as TODO.
30
+ */
31
+ exports.CITATION_KINDS = [
32
+ 'user-request', // stated directly by the requester
33
+ 'conversation', // a decision reached in discussion
34
+ 'plan', // a planning document
35
+ 'adr', // an architecture decision record
36
+ 'architecture', // a design/architecture document
37
+ 'analysis', // competitive/market/technical analysis
38
+ 'issue', // tracker item (JIRA, GitHub, internal)
39
+ 'wiki', // knowledge base page, runbook
40
+ 'incident', // production incident or postmortem
41
+ 'code-review', // a review comment or finding
42
+ 'regulation', // a compliance obligation (HIPAA/SOC2/GDPR/…)
43
+ 'other',
44
+ ];
45
+ /**
46
+ * Kinds whose content can change after it was cited, and which therefore REQUIRE `rev`.
47
+ *
48
+ * The split is mutable vs immutable, not "has a version number". A wiki page, a ticket, an ADR or a
49
+ * plan file all keep changing under you, so a citation without a version pin cannot detect drift and
50
+ * the citation stops meaning anything. A conversation, a direct request, or an incident is fixed the
51
+ * moment it happened — `ref` plus `retrieved` already pins it completely, and demanding a `rev` there
52
+ * would only push an author to invent one, which is the fabrication this whole schema exists to stop.
53
+ */
54
+ exports.MUTABLE_CITATION_KINDS = [
55
+ 'plan', 'adr', 'architecture', 'analysis', 'issue', 'wiki', 'code-review', 'regulation', 'other',
56
+ ];
57
+ /** Placeholder a freshly created spec carries until its author supplies a real citation. */
58
+ exports.CITATION_PLACEHOLDER = 'TODO';
59
+ exports.SPEC_TYPES = {
60
+ 'REQ': {
61
+ type: 'REQ', idRegex: /^REQ-\d{3,}$/, example: 'REQ-001', folder: '01_req', parents: [],
62
+ requiredFields: ['source', 'created'],
63
+ requiredSections: ['Problem / Need', 'Desired Outcome', 'Constraints', 'Success Criteria', 'Out of Scope'],
64
+ },
65
+ 'H-SPEC': {
66
+ type: 'H-SPEC', idRegex: /^H-SPEC-\d{3,}$/, example: 'H-SPEC-001', folder: '02_h-spec', parents: ['REQ'],
67
+ requiredFields: ['req_type', 'owner'],
68
+ requiredSections: ['Intent', 'Scope (In / Out)', 'Design Overview', 'Interfaces / Contracts', 'Acceptance Criteria', 'Non-Functional', 'Assumptions', 'Open Questions'],
69
+ },
70
+ 'A-SPEC': {
71
+ type: 'A-SPEC', idRegex: /^A-SPEC-\d{3,}(\.\d+)?$/, example: 'A-SPEC-001', folder: '03_a-spec', parents: ['H-SPEC'],
72
+ requiredFields: ['slice', 'priority', 'independent_test'],
73
+ // @implements A-SPEC-146
74
+ // Scaffolded by `spec_create`, NOT required by `validateSpec`. Measured: putting
75
+ // `breaking_change` in `requiredFields` produced 38 ART-3 violations and a Stop hook that
76
+ // blocked every turn, because all 38 governed A-SPECs are already approved. The duty belongs to
77
+ // the ACT of approval (see spec/breaking-change.ts); this line exists so the field is visible
78
+ // where someone looks up "what fields does an A-SPEC have", instead of hiding in a check.
79
+ stubOnlyFields: ['breaking_change'],
80
+ requiredSections: ['Objective', 'Inputs / Outputs', 'Behavior', 'Test Points', 'Files to Touch', 'Done When'],
81
+ },
82
+ 'C-SPEC': {
83
+ type: 'C-SPEC', idRegex: /^C-SPEC-\d{3,}$/, example: 'C-SPEC-001', folder: '04_cpg', parents: ['A-SPEC'],
84
+ requiredFields: [],
85
+ requiredSections: ['Allowed Dependencies', 'Forbidden Edges', 'Affected Symbols', 'Layer Rules'],
86
+ },
87
+ 'T-SPEC': {
88
+ // @implements A-SPEC-186
89
+ // Mirrors A-SPEC's shape so a T-SPEC can carry its parent's number VERBATIM — `A-SPEC-100.1`
90
+ // is tested by `T-SPEC-100.1`, and a reader needs no arithmetic. The old habit appended a
91
+ // sequence digit (`A-SPEC-129` → `T-SPEC-1291`), which measured 0 uses across 76 A-SPECs — not
92
+ // one of them has a second T-SPEC — while producing a number that reads as a quantity and
93
+ // cannot be split back into parent and sequence.
94
+ //
95
+ // This WIDENS the accepted set; the 76 existing ids all still pass. Narrowing would have made
96
+ // every one of them a violation at once, which is the accident A-SPEC-146 recorded.
97
+ // The mirror is a CONVENTION, not a rule the validator enforces: `T-SPEC-1291` under
98
+ // `A-SPEC-129` stays valid, because renaming sealed documents is not on the table.
99
+ type: 'T-SPEC', idRegex: /^T-SPEC-\d{3,}(\.\d+)?$/, example: 'T-SPEC-001', folder: '05_t-spec', parents: ['A-SPEC'],
100
+ requiredFields: ['coverage'],
101
+ requiredSections: ['Normal Cases', 'Corner Cases', 'Negative Cases', 'Boundary Cases'],
102
+ },
103
+ };
104
+ // @implements A-SPEC-100.1
105
+ /**
106
+ * The GOVERNED set is defined by the modern spec format: a spec is governed
107
+ * iff it carries a `type` field naming a known SpecType. This deliberately
108
+ * excludes (a) pre-`type` legacy specs retained only as archived history, and
109
+ * (b) the `06_job/JOB-*` runtime ledger, which is not a governed spec type.
110
+ * Validation and RTM integrity checks operate on the governed set — validating
111
+ * archived legacy or runtime-ledger entries is a category error (audit C1).
112
+ * Generic over `{ type?: SpecType }` to stay free of an import cycle with the
113
+ * parser (which imports SpecType from here).
114
+ */
115
+ /**
116
+ * Own-property lookup into SPEC_TYPES. A bare `SPEC_TYPES[type]` inherits
117
+ * Object.prototype, so a spec with `type: constructor` / `__proto__` / etc.
118
+ * (raw unvalidated YAML frontmatter) would resolve to a prototype member and
119
+ * masquerade as a known type — then crash downstream on `def.idRegex`. This
120
+ * guard returns undefined for any non-own key.
121
+ */
122
+ function specTypeDef(type) {
123
+ if (type == null)
124
+ return undefined;
125
+ return Object.prototype.hasOwnProperty.call(exports.SPEC_TYPES, type)
126
+ ? exports.SPEC_TYPES[type]
127
+ : undefined;
128
+ }
129
+ function isGoverned(spec) {
130
+ return specTypeDef(spec.type) !== undefined;
131
+ }
132
+ function filterGoverned(specs) {
133
+ return specs.filter(isGoverned);
134
+ }
135
+ /**
136
+ * @implements A-SPEC-174
137
+ * The value `spec_create` writes into a required field it cannot answer.
138
+ *
139
+ * Named because two places must agree on it: the handler that writes it, and the store that must NOT
140
+ * treat it as a directory name. Measured 2026-08-12 — a stubbed `req_type` produced a literal
141
+ * `.ax/specs/02_h-spec/TODO/` in the user's repository.
142
+ */
143
+ exports.FIELD_PLACEHOLDER = 'TODO';
144
+ /**
145
+ * @implements A-SPEC-174
146
+ * Which spec type an id belongs to, or null if no type claims it.
147
+ *
148
+ * Judged by the same `idRegex` the approval-time validator uses, not by a prefix comparison. A second
149
+ * statement of the rule drifts, and the failure it produces is either "created but never approvable"
150
+ * (which is the defect this closes) or the worse inverse — refused at creation, accepted at approval.
151
+ *
152
+ * This is what lets a parent be checked WITHOUT resolving it: a parent that does not exist yet is a
153
+ * legitimate state an author reaches by creating the child first, while a parent of the wrong type is
154
+ * wrong forever.
155
+ */
156
+ function specTypeOfId(id) {
157
+ for (const def of Object.values(exports.SPEC_TYPES))
158
+ if (def.idRegex.test(id))
159
+ return def.type;
160
+ return null;
161
+ }
162
+ /**
163
+ * @implements A-SPEC-174
164
+ * The parent-kind rule as prose, GENERATED from the relation itself.
165
+ *
166
+ * Written by hand this drifted the moment a clause was edited, and prose assertions could not tell
167
+ * the difference — a test looking for "H-SPEC ... REQ" still matched a sentence that had lost the
168
+ * H-SPEC rule entirely. Generating it removes the second copy instead of testing around it.
169
+ */
170
+ function parentRuleText() {
171
+ return exports.SPEC_ORDER
172
+ .map((t) => {
173
+ const p = exports.SPEC_TYPES[t].parents;
174
+ return p.length === 0 ? `${t} has no parent` : `${t} depends on ${p.join(' or ')}`;
175
+ })
176
+ .join('; ');
177
+ }