@holmes-lab/holmes-kit 0.26.1 → 0.27.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 +65 -0
- package/README.md +9 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/attest.d.ts +15 -0
- package/dist/holmes/cli/attest.js +168 -0
- package/dist/holmes/cli/index.d.ts +1 -0
- package/dist/holmes/cli/index.js +20 -1
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +16 -2
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +238 -66
- package/dist/holmes/mcp/handlers.d.ts +5 -0
- package/dist/holmes/mcp/handlers.js +2 -0
- package/dist/holmes/mcp/tool-schemas.js +13 -0
- package/dist/holmes/rtm/traced-files.d.ts +83 -0
- package/dist/holmes/rtm/traced-files.js +207 -0
- package/dist/holmes/spec/entity-store.js +28 -6
- package/dist/holmes/spec/id-collision.js +4 -1
- package/dist/holmes/spec/remote-spec-refs.d.ts +9 -0
- package/dist/holmes/spec/remote-spec-refs.js +88 -0
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
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.UNTRACED_LINE_CAP = exports.ATTEST_SCHEMA = exports.ATTEST_END = exports.ATTEST_BEGIN = void 0;
|
|
37
|
+
exports.normalizeExcludes = normalizeExcludes;
|
|
38
|
+
exports.flooredPct = flooredPct;
|
|
39
|
+
exports.tracedFiles = tracedFiles;
|
|
40
|
+
exports.attestProvenance = attestProvenance;
|
|
41
|
+
exports.renderAttestText = renderAttestText;
|
|
42
|
+
exports.renderAttestJson = renderAttestJson;
|
|
43
|
+
exports.renderAttestSvg = renderAttestSvg;
|
|
44
|
+
exports.renderAttestMarker = renderAttestMarker;
|
|
45
|
+
exports.replaceMarkerRegion = replaceMarkerRegion;
|
|
46
|
+
// @implements A-SPEC-702
|
|
47
|
+
/**
|
|
48
|
+
* How many of a repository's source files trace to an APPROVED spec — said as a number anyone can
|
|
49
|
+
* reproduce, with the commit and spec-store state it was measured at.
|
|
50
|
+
*
|
|
51
|
+
* This exists INSTEAD of a "generated under holmes-kit" header in every file (REQ-702). A header is
|
|
52
|
+
* a claim: it can be pasted, and it keeps saying "governed" after an ungoverned edit. Line 1 already
|
|
53
|
+
* carries the `implements` anchor, which a gate verifies. What was missing is a repository-level
|
|
54
|
+
* statement, and the spec-side half of it (`linkCensus`) lived only inside the dashboard.
|
|
55
|
+
*
|
|
56
|
+
* NO NEW JUDGEMENT. The denominator is whatever the scanner returned, the numerator is anchors ∩
|
|
57
|
+
* approved ids. Everything here is pure — no clock, no filesystem, no git — so the same input is the
|
|
58
|
+
* same bytes and a committed badge does not churn. The CLI that feeds it is `cli/attest.ts`.
|
|
59
|
+
*/
|
|
60
|
+
const crypto = __importStar(require("node:crypto"));
|
|
61
|
+
exports.ATTEST_BEGIN = '<!-- holmes-kit:attest:begin -->';
|
|
62
|
+
exports.ATTEST_END = '<!-- holmes-kit:attest:end -->';
|
|
63
|
+
exports.ATTEST_SCHEMA = 'holmes-attest/1';
|
|
64
|
+
/** Above this many untraced files the text names none and points at `--json`. */
|
|
65
|
+
exports.UNTRACED_LINE_CAP = 10;
|
|
66
|
+
const posix = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
67
|
+
/**
|
|
68
|
+
* Backslashes folded, one trailing slash. A prefix that means "everything" is DROPPED: excluding the
|
|
69
|
+
* whole tree would read as n/a at best and 100% at worst (the same rule A-SPEC-694 pinned for
|
|
70
|
+
* `cycleIgnore`). Sorted and de-duplicated so the provenance does not depend on how it was typed.
|
|
71
|
+
*/
|
|
72
|
+
function normalizeExcludes(raw) {
|
|
73
|
+
const out = new Set();
|
|
74
|
+
for (const r of raw ?? []) {
|
|
75
|
+
const p = posix(String(r).trim()).replace(/^\/+/, '').replace(/\/+$/, '');
|
|
76
|
+
if (p === '' || p === '.')
|
|
77
|
+
continue;
|
|
78
|
+
out.add(p + '/');
|
|
79
|
+
}
|
|
80
|
+
return [...out].sort();
|
|
81
|
+
}
|
|
82
|
+
/** Floored to one decimal: 99.96 must not read as 100 — 100 means every file. */
|
|
83
|
+
function flooredPct(num, den) {
|
|
84
|
+
if (den <= 0)
|
|
85
|
+
return null;
|
|
86
|
+
return Math.floor((num / den) * 1000) / 10;
|
|
87
|
+
}
|
|
88
|
+
function tracedFiles(input) {
|
|
89
|
+
const approved = new Set(input.approvedIds);
|
|
90
|
+
const excludes = normalizeExcludes(input.exclude);
|
|
91
|
+
let excluded = 0, traced = 0, total = 0;
|
|
92
|
+
const untraced = [];
|
|
93
|
+
for (const f of input.files) {
|
|
94
|
+
const p = posix(f.path);
|
|
95
|
+
// Boundary match: every prefix ends in '/', so `reference/` cannot pardon `reference-impl/`.
|
|
96
|
+
if (excludes.some((e) => p.startsWith(e))) {
|
|
97
|
+
excluded++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
total++;
|
|
101
|
+
if (f.anchors.some((id) => approved.has(id)))
|
|
102
|
+
traced++;
|
|
103
|
+
else
|
|
104
|
+
untraced.push(p);
|
|
105
|
+
}
|
|
106
|
+
untraced.sort();
|
|
107
|
+
const reason = total === 0 ? 'no source files' : approved.size === 0 ? 'no approved specs' : undefined;
|
|
108
|
+
return {
|
|
109
|
+
total, traced, excluded, untraced,
|
|
110
|
+
measurable: reason === undefined,
|
|
111
|
+
...(reason !== undefined ? { reason } : {}),
|
|
112
|
+
tracedPct: reason === undefined ? flooredPct(traced, total) : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Where and when the number is true. `+dirty` because a commit alone would call an unreproducible
|
|
117
|
+
* number reproducible. The spec digest covers (id, seal) of every approved spec — that set is what
|
|
118
|
+
* decides the numerator. No clock anywhere.
|
|
119
|
+
*/
|
|
120
|
+
function attestProvenance(input) {
|
|
121
|
+
const lines = input.approved.map((a) => `${a.id}\t${a.digest}`).sort();
|
|
122
|
+
const specs = crypto.createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 12);
|
|
123
|
+
const commit = (input.commit ?? '').trim();
|
|
124
|
+
const at = commit === '' ? 'unversioned' : commit.slice(0, 12) + (input.dirty ? '+dirty' : '');
|
|
125
|
+
return { at, specs, exclude: normalizeExcludes(input.exclude) };
|
|
126
|
+
}
|
|
127
|
+
const pctText = (pct) => (pct === null ? 'n/a' : `${pct}%`);
|
|
128
|
+
const ratio = (num, den, sep) => `${num}${sep}${den}`;
|
|
129
|
+
function renderAttestText(r) {
|
|
130
|
+
const f = r.files;
|
|
131
|
+
const filesLine = f.measurable
|
|
132
|
+
? `${ratio(f.traced, f.total, ' / ')} (${pctText(f.tracedPct)})`
|
|
133
|
+
: `n/a — ${f.reason}`;
|
|
134
|
+
const specPct = flooredPct(r.specs.linked, r.specs.total);
|
|
135
|
+
const specsLine = specPct === null ? 'n/a — no approved A-SPECs' : `${ratio(r.specs.linked, r.specs.total, ' / ')} (${pctText(specPct)})`;
|
|
136
|
+
const lines = [
|
|
137
|
+
`traced files ${filesLine} — source files anchored to an APPROVED spec`,
|
|
138
|
+
`linked specs ${specsLine} — approved A-SPECs anchored from code`,
|
|
139
|
+
];
|
|
140
|
+
// Few enough to act on → name them; a wall of paths helps nobody, so beyond that point to --json.
|
|
141
|
+
if (f.measurable && f.untraced.length > 0) {
|
|
142
|
+
lines.push(f.untraced.length <= exports.UNTRACED_LINE_CAP
|
|
143
|
+
? `untraced ${f.untraced.join(', ')}`
|
|
144
|
+
: `untraced ${f.untraced.length} file(s) — list them with --json`);
|
|
145
|
+
}
|
|
146
|
+
if (r.provenance.exclude.length > 0)
|
|
147
|
+
lines.push(`excluded ${f.excluded} file(s) under: ${r.provenance.exclude.join(', ')}`);
|
|
148
|
+
lines.push(`at ${r.provenance.at} · specs sha256:${r.provenance.specs}`);
|
|
149
|
+
return lines.join('\n') + '\n';
|
|
150
|
+
}
|
|
151
|
+
function renderAttestJson(r) {
|
|
152
|
+
return JSON.stringify({ schema: exports.ATTEST_SCHEMA, files: r.files, specs: r.specs, provenance: r.provenance }, null, 2) + '\n';
|
|
153
|
+
}
|
|
154
|
+
const xml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
155
|
+
/** Self-contained: no href, font, script or fetch. Only the ratio, the percentage and `at` go in. */
|
|
156
|
+
function renderAttestSvg(r) {
|
|
157
|
+
const f = r.files;
|
|
158
|
+
const value = f.measurable ? `${ratio(f.traced, f.total, '/')} · ${pctText(f.tracedPct)}` : 'n/a';
|
|
159
|
+
const label = 'traced files';
|
|
160
|
+
const lw = 86, vw = Math.max(64, 14 + value.length * 7), w = lw + vw;
|
|
161
|
+
const colour = !f.measurable ? '#9f9f9f' : (f.tracedPct ?? 0) >= 90 ? '#2e7d32' : (f.tracedPct ?? 0) >= 60 ? '#b08900' : '#b3541e';
|
|
162
|
+
return [
|
|
163
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="34" role="img" aria-label="${xml(label + ': ' + value)}">`,
|
|
164
|
+
`<title>${xml(label + ': ' + value + ' @ ' + r.provenance.at)}</title>`,
|
|
165
|
+
`<rect width="${lw}" height="20" fill="#444"/>`,
|
|
166
|
+
`<rect x="${lw}" width="${vw}" height="20" fill="${colour}"/>`,
|
|
167
|
+
`<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">`,
|
|
168
|
+
`<text x="${lw / 2}" y="14" text-anchor="middle">${xml(label)}</text>`,
|
|
169
|
+
`<text x="${lw + vw / 2}" y="14" text-anchor="middle">${xml(value)}</text>`,
|
|
170
|
+
`</g>`,
|
|
171
|
+
`<text x="0" y="31" fill="#666" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="9">${xml('at ' + r.provenance.at)}</text>`,
|
|
172
|
+
`</svg>`,
|
|
173
|
+
].join('') + '\n';
|
|
174
|
+
}
|
|
175
|
+
/** What goes between the markers. Carries the excludes, so the README says how to reproduce it. */
|
|
176
|
+
function renderAttestMarker(r) {
|
|
177
|
+
const f = r.files;
|
|
178
|
+
const files = f.measurable ? `**${ratio(f.traced, f.total, '/')}** source files (${pctText(f.tracedPct)}) trace to an approved spec` : `traced files: n/a — ${f.reason}`;
|
|
179
|
+
const specPct = flooredPct(r.specs.linked, r.specs.total);
|
|
180
|
+
const specs = specPct === null ? '' : ` · **${ratio(r.specs.linked, r.specs.total, '/')}** approved A-SPECs (${pctText(specPct)}) are anchored from code`;
|
|
181
|
+
const excl = r.provenance.exclude.length > 0 ? ` --exclude ${r.provenance.exclude.join(',')}` : '';
|
|
182
|
+
return [
|
|
183
|
+
``,
|
|
184
|
+
``,
|
|
185
|
+
`${files}${specs}.`,
|
|
186
|
+
`Measured at \`${r.provenance.at}\` (specs \`sha256:${r.provenance.specs}\`) — reproduce with \`holmes-kit attest${excl}\`.`,
|
|
187
|
+
].join('\n');
|
|
188
|
+
}
|
|
189
|
+
const count = (hay, needle) => hay.split(needle).length - 1;
|
|
190
|
+
/**
|
|
191
|
+
* Replace what lies between the two markers — and ONLY when each appears exactly once, begin first.
|
|
192
|
+
* No marker is `no-marker`: the person never asked, so nothing is written. Anything else malformed is
|
|
193
|
+
* `unbalanced`: a lone begin marker followed by "replace to the end" is this feature's worst failure.
|
|
194
|
+
* The README's own line ending is kept, so a CRLF file does not become one whole-file diff.
|
|
195
|
+
*/
|
|
196
|
+
function replaceMarkerRegion(readme, inner) {
|
|
197
|
+
const begins = count(readme, exports.ATTEST_BEGIN), ends = count(readme, exports.ATTEST_END);
|
|
198
|
+
if (begins === 0 && ends === 0)
|
|
199
|
+
return { ok: false, reason: 'no-marker' };
|
|
200
|
+
const b = readme.indexOf(exports.ATTEST_BEGIN), e = readme.indexOf(exports.ATTEST_END);
|
|
201
|
+
if (begins !== 1 || ends !== 1 || e < b)
|
|
202
|
+
return { ok: false, reason: 'unbalanced' };
|
|
203
|
+
const eol = readme.includes('\r\n') ? '\r\n' : '\n';
|
|
204
|
+
const body = inner.replace(/\r\n/g, '\n').split('\n').join(eol);
|
|
205
|
+
const text = readme.slice(0, b + exports.ATTEST_BEGIN.length) + eol + body + eol + readme.slice(e);
|
|
206
|
+
return { ok: true, text, changed: text !== readme };
|
|
207
|
+
}
|
|
@@ -148,10 +148,8 @@ function validUuid(v) { return typeof v === 'string' && UUID.test(v); }
|
|
|
148
148
|
function locator(v) {
|
|
149
149
|
return typeof v === 'string' && v.length > 0 && !/[\\:\x00]/.test(v) && v.split('/').every(p => p.length > 0 && p !== '.' && p !== '..');
|
|
150
150
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!bytes)
|
|
154
|
-
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
151
|
+
/** @implements A-SPEC-701 — the parse, shared, so a caller that already HOLDS the bytes never reads the file again. */
|
|
152
|
+
function parseRecord(bytes) {
|
|
155
153
|
try {
|
|
156
154
|
return JSON.parse(bytes.toString('utf8'));
|
|
157
155
|
}
|
|
@@ -159,6 +157,12 @@ function recordJson(file) {
|
|
|
159
157
|
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Entity state contains malformed JSON.');
|
|
160
158
|
}
|
|
161
159
|
}
|
|
160
|
+
function recordJson(file) {
|
|
161
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(file);
|
|
162
|
+
if (!bytes)
|
|
163
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
164
|
+
return parseRecord(bytes);
|
|
165
|
+
}
|
|
162
166
|
function validateEntityAdoptionPlan(value) {
|
|
163
167
|
const p = object(value, ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'entries']);
|
|
164
168
|
if (p.schema !== 'holmes-entity-adoption/1' || !validUuid(p.operationId) || !validUuid(p.workspaceId) || !validUuid(p.storeId) || !locator(p.storeLocator) || !Array.isArray(p.entries))
|
|
@@ -247,9 +251,27 @@ function assertNoPending(bound) {
|
|
|
247
251
|
const op = (0, entity_transaction_1.entityDirectory)(dir, name);
|
|
248
252
|
// @implements A-SPEC-634 — a pending directory without a journal is an operation that died
|
|
249
253
|
// between taking its locks and publishing; name it, do not fail on the missing record.
|
|
250
|
-
|
|
254
|
+
//
|
|
255
|
+
// @implements A-SPEC-701 — the journal is read ONCE. It used to be read twice (once to see it
|
|
256
|
+
// exists, once to parse it) while `retireEntityOperation` moves the whole operation directory
|
|
257
|
+
// with a single rename — so an operation that FINISHED while this lock-free scan was looking
|
|
258
|
+
// was reported either as "interrupted before its journal existed" (retired before the first
|
|
259
|
+
// read) or as "An entity record disappeared." (retired between the two). Measured 2026-09-20 on
|
|
260
|
+
// the Linux CI: one of four racing adopters got the second, a code outside the three that race
|
|
261
|
+
// calls legal. Both refusals were safe and both were false.
|
|
262
|
+
//
|
|
263
|
+
// What separates the two causes of "no journal" is whether the DIRECTORY remains. Retirement
|
|
264
|
+
// moves it whole, so there is no ordinary path that leaves the directory and takes the journal:
|
|
265
|
+
// directory present + no journal is A-SPEC-634's dead operation and is still refused by name;
|
|
266
|
+
// directory gone is an operation that is simply no longer pending. The real write re-runs this
|
|
267
|
+
// scan under the lock, so skipping a finished operation here cannot admit a conflicting one.
|
|
268
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(op, 'journal.json'));
|
|
269
|
+
if (!bytes) {
|
|
270
|
+
if (!(0, entity_transaction_1.entityStat)(op))
|
|
271
|
+
continue;
|
|
251
272
|
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Operation ' + name + ' was interrupted before its journal existed; inspect entity_store recovery-plan and recover it.');
|
|
252
|
-
|
|
273
|
+
}
|
|
274
|
+
const raw = parseRecord(bytes);
|
|
253
275
|
if (raw?.schema === 'holmes-entity-integration-journal/1') {
|
|
254
276
|
const journal = object(raw, ['schema', 'plan', 'storeOwner', 'sourceOwner']);
|
|
255
277
|
const plan = journal.plan;
|
|
@@ -230,5 +230,8 @@ function reconcileAdvice(plan) {
|
|
|
230
230
|
];
|
|
231
231
|
if (parts.length === 0)
|
|
232
232
|
return '';
|
|
233
|
-
|
|
233
|
+
// @implements A-SPEC-700.2 — name the tool that runs the whole plan under one approval. APPENDED:
|
|
234
|
+
// the per-move sentence stays, because `spec_renumber` is still how one family is moved by hand.
|
|
235
|
+
return parts.join('; ') + (plan.moves.length > 0
|
|
236
|
+
? '; then re-seal with spec_approve, and merge (spec_reconcile plan → apply runs every move under one approval)' : '');
|
|
234
237
|
}
|
|
@@ -14,3 +14,12 @@ export interface RemoteAddedSpecs {
|
|
|
14
14
|
/** Runs one git command and returns its stdout as a Buffer. Injectable so a failure can be staged. */
|
|
15
15
|
export type GitRunner = (root: string, args: string[], input?: string) => Buffer;
|
|
16
16
|
export declare function collectRemoteAddedSpecs(root: string, git?: GitRunner): RemoteAddedSpecs;
|
|
17
|
+
/**
|
|
18
|
+
* The local half of the comparison, spelled the way the remote half is: store-relative posix path,
|
|
19
|
+
* BOM stripped, CRLF folded, `collisionKeyOf` as the identity. Regular files only — a FIFO named
|
|
20
|
+
* `*.md` blocks `open(2)` forever (doctor round-6), so the type is asked before the read.
|
|
21
|
+
*/
|
|
22
|
+
export declare function collectLocalSpecEntries(specsRoot: string): {
|
|
23
|
+
entries: IdCollisionEntry[];
|
|
24
|
+
skipped: number;
|
|
25
|
+
};
|
|
@@ -1,6 +1,40 @@
|
|
|
1
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
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.collectRemoteAddedSpecs = collectRemoteAddedSpecs;
|
|
37
|
+
exports.collectLocalSpecEntries = collectLocalSpecEntries;
|
|
4
38
|
// @implements A-SPEC-254.2
|
|
5
39
|
/**
|
|
6
40
|
* The merge-time half of REQ-254: which spec documents did each remote-tracking ref ADD since this
|
|
@@ -24,6 +58,8 @@ exports.collectRemoteAddedSpecs = collectRemoteAddedSpecs;
|
|
|
24
58
|
* READ-ONLY. The only git subcommands are for-each-ref, merge-base, diff, ls-tree and cat-file.
|
|
25
59
|
*/
|
|
26
60
|
const node_child_process_1 = require("node:child_process");
|
|
61
|
+
const fs = __importStar(require("node:fs"));
|
|
62
|
+
const path = __importStar(require("node:path"));
|
|
27
63
|
const spec_parser_1 = require("./spec-parser");
|
|
28
64
|
const id_collision_1 = require("./id-collision");
|
|
29
65
|
const root_1 = require("../project/root");
|
|
@@ -146,3 +182,55 @@ function collectRemoteAddedSpecs(root, git = defaultGit) {
|
|
|
146
182
|
}
|
|
147
183
|
return result;
|
|
148
184
|
}
|
|
185
|
+
// @implements A-SPEC-700.2
|
|
186
|
+
/**
|
|
187
|
+
* The local half of the comparison, spelled the way the remote half is: store-relative posix path,
|
|
188
|
+
* BOM stripped, CRLF folded, `collisionKeyOf` as the identity. Regular files only — a FIFO named
|
|
189
|
+
* `*.md` blocks `open(2)` forever (doctor round-6), so the type is asked before the read.
|
|
190
|
+
*/
|
|
191
|
+
function collectLocalSpecEntries(specsRoot) {
|
|
192
|
+
const entries = [];
|
|
193
|
+
let skipped = 0;
|
|
194
|
+
const walk = (dir) => {
|
|
195
|
+
let listed;
|
|
196
|
+
try {
|
|
197
|
+
listed = fs.readdirSync(dir, { withFileTypes: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
skipped++;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
for (const e of listed.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
204
|
+
const p = path.join(dir, e.name);
|
|
205
|
+
if (e.isDirectory()) {
|
|
206
|
+
walk(p);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!e.name.endsWith('.md'))
|
|
210
|
+
continue;
|
|
211
|
+
if (!e.isFile()) {
|
|
212
|
+
skipped++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const folded = fs.readFileSync(p, 'utf8').replace(/^/, '').replace(/\r\n/g, '\n');
|
|
217
|
+
const spec = (0, spec_parser_1.parseSpec)(folded);
|
|
218
|
+
if (!spec.id) {
|
|
219
|
+
skipped++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
entries.push({
|
|
223
|
+
file: path.relative(specsRoot, p).split(path.sep).join('/'),
|
|
224
|
+
id: spec.id,
|
|
225
|
+
approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
|
|
226
|
+
contentDigest: (0, id_collision_1.collisionKeyOf)(folded, spec),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
skipped++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
walk(specsRoot);
|
|
235
|
+
return { entries, skipped };
|
|
236
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@implements A-SPEC-209",
|
|
3
3
|
"name": "@holmes-lab/holmes-kit",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.27.0",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|