@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.
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
+ `![traced files](.ax/badges/traced.svg)`,
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
- function recordJson(file) {
152
- const bytes = (0, entity_transaction_1.readEntityBytes)(file);
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
- if (!(0, entity_transaction_1.readEntityBytes)(path.join(op, 'journal.json')))
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
- const raw = recordJson(path.join(op, 'journal.json'));
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
- return parts.join('; ') + (plan.moves.length > 0 ? '; then re-seal with spec_approve, and merge' : '');
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.26.1",
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",