@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- 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
|
+
}
|