@holmes-lab/holmes-kit 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.23.1] - 2026-09-18
9
+
10
+ A release that could not be published, and the documentation that had fallen three versions behind.
11
+
12
+ ### Fixed
13
+ - **The release gate knows the ledgers 0.22.0 began tracking** (A-SPEC-668). `v0.23.0`'s publish was
14
+ refused on a dirty tree: 0.22.0 taught `.gitignore` to track `advisory-outcomes` and `ci-runs`, and
15
+ `verify-release.js`'s append-only exclusion list never heard about it. The first is appended by every
16
+ seal and every `approval_status`; the second by the launchd CI runner every fifteen minutes — between
17
+ them this repository could no longer publish at an arbitrary moment. Both are excused now, and a pin
18
+ asserts that every TRACKED ledger is excused, naming any that is not so the next person knows which
19
+ file to edit. The reverse slack is reported rather than asserted away (an excused-but-untracked ledger
20
+ costs nothing, because a gitignored file never shows as dirty).
21
+
22
+ ### Docs
23
+ - **The README lists 0.21.0 through 0.23.0.** The feature list stopped at 0.20.0 while three releases
24
+ shipped. The publish playbook's docs-currency gate forbids exactly that — "a stale doc is a false
25
+ claim" — and it is a written step with no automation, so it was skipped twice. Automating it is the
26
+ next candidate.
27
+
28
+ ## [0.23.0] - 2026-09-18
29
+
30
+ Three things this release stops doing quietly: deleting the neighbours in a config file it shares,
31
+ going deaf when its own child process dies, and reporting a coverage shortfall it cannot explain.
32
+
33
+ ### Fixed
34
+ - **A re-wire no longer deletes the other tools' entries** (A-SPEC-667). `init --agent antigravity`
35
+ and `init --agent codex` replaced `.agents/mcp_config.json`, `.agents/hooks.json` and
36
+ `marketplace.json` whole. Measured with a real `--dry-run` before the change: a neighbour MCP
37
+ server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's
38
+ `disabled` flag all survived a re-wire at a rate of zero. They now survive; only the holmes-kit
39
+ entry is refreshed, and `init` names what it kept. A `disabled` flag an operator set is preserved
40
+ rather than silently switched back on, and `init` says the gate will not run while it stands. An
41
+ existing file that is not readable JSON is refused with a reason instead of being replaced. The
42
+ Claude wiring already merged (A-SPEC-179); this gives the other two harnesses the same discipline.
43
+ - **The MCP supervisor notices a child that died** (A-SPEC-666). With no exit handler it kept writing
44
+ to a dead pipe, its in-flight count never returned to zero, and the swap that would have replaced
45
+ the child therefore never fired — the server was permanently deaf. Reproduced against the real
46
+ supervisor, twice in one session and once on demand. It now answers every outstanding request with
47
+ a JSON-RPC error before anything else (a client must not wait for ever), resets, respawns and
48
+ replays the opening exchange. A child that dies having never answered spends a restart budget, so a
49
+ broken build stops quickly while a single crash under load restarts freely; an intentional swap or
50
+ `stop()` is not counted as an accident. Affects supervised runs only (`HOLMES_MCP_AUTORELOAD`).
51
+
52
+ ### Added
53
+ - **The census says what an unlinked spec declared, not just where it is anchored** (A-SPEC-665).
54
+ `rtm_dashboard` and `/api/rtm` now classify every unlinked approved A-SPEC on a second axis —
55
+ `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` —
56
+ beside the existing `unlinkedByReason`, and report how many specs declare a path no anchor
57
+ mechanism can read. Measured here: of 52 unlinked specs, zero declare a file the scanner parses, so
58
+ the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state what was
59
+ declared; whether that is a defect remains `traceGaps`'s answer.
60
+
61
+ ### Measured
62
+ - macOS: 528 suites / 6,607 passed / 11 skipped, every suite green; each slice recorded red-assertion → green with an official `test_run` at both ends.
63
+ - Linux (OrbStack Ubuntu 24.04 arm64, Node 22): judged automatically on every commit by the launchd
64
+ runner added in 0.22.0. Residual reds are load-dependent suites that pass when run alone —
65
+ `dashboard.test` was verified at 120 s alone on the VM against 370 s under load.
66
+ - Windows: not re-run; no Windows-specific change landed.
67
+
8
68
  ## [0.22.0] - 2026-09-18
9
69
 
10
70
  The advisories learn what happened next. Until now this repository issued findings and recorded that
package/README.md CHANGED
@@ -16,6 +16,14 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 🧩 **Your config files survive a re-wire** *(new in 0.23.0)*: `init --agent antigravity` and `init --agent codex` used to replace `.agents/mcp_config.json`, `.agents/hooks.json` and `marketplace.json` **whole**. Measured with a real `--dry-run` before the fix: a neighbour MCP server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a re-wire at a rate of **zero**. They now survive — only the holmes-kit entry is refreshed, and `init` names what it kept. A `disabled` you set stays set (and `init` says the gate will not run while it stands, rather than switching it back on silently); an existing file that is not readable JSON is refused with a reason instead of being replaced. The Claude wiring already merged; the other two harnesses now have the same discipline.
20
+ - 🫀 **The MCP supervisor notices a child that died** *(new in 0.23.0)*: under `HOLMES_MCP_AUTORELOAD` the supervisor had no exit handler, so a crashed child left it writing to a dead pipe — its in-flight count never returned to zero, the swap that would have replaced the child never fired, and the server went **permanently deaf** (observed twice in one session, then reproduced on demand against the real class). It now answers every outstanding request with a JSON-RPC error **first** (a client must never wait for ever), then resets, respawns and replays the opening exchange. A child that dies having never answered spends a restart budget, so a broken build stops quickly while one crash under load restarts freely; an intentional swap or shutdown is not counted as an accident.
21
+ - 🧮 **Coverage you can explain** *(new in 0.23.0)*: the RTM census already said *where* an unlinked spec is anchored; it now also says *what it declared* — `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` — plus how many specs declare a path no anchor mechanism can read. On this repository, of 52 unlinked approved specs **zero** declare a file the scanner parses, so the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer.
22
+ - 🤖 **A CI matrix that judges every commit, and never reads silence as green** *(new in 0.22.0)*: a maintainer-side runner takes one commit onto a Linux VM (clone → install → build → full suite) and appends exactly one row to a `ci-runs` ledger — for every outcome, including the ones where the run could not judge (`clone-failed`, `install-failed`, `vm-unreachable`). A launchd agent triggers it per commit. The Stop hook reports the matrix's last word and `doctor` gains a `ci matrix` check; a missing row reads as **"not run"**, never as a pass. Workspaces that never adopted the matrix hear nothing about it.
23
+ - 🔁 **Advisories learn what happened next** *(new in 0.22.0)*: every finding — impact, anchor density, Files-to-Touch fulfilment, trace gap, `kills`-unapplicable — now carries a deterministic id. Sealing records it as `issued`; the next `approval_status` re-runs the **same** functions and records `resolved` when the cause is gone or `persisted` when it is not, at most one row per finding per day. An author who judges a finding unhelpful passes `dismiss: [id]`; an unknown id comes back in `dismissUnknown` rather than inventing a row. `rtm_dashboard`'s census gains the per-kind tallies. This is the numerator every "promote to a hard gate once we know the false-positive rate" sentence was missing.
24
+ - 🧪 **`kills` that cannot apply say so** *(new in 0.22.0)*: `test_run --mutate` reports mutations that never applied as `unapplied`, separately from `survivors`. Measured here, all 22 `kills` entries in this repository wrote `where` as a file path and `mutate` as prose, so the literal-replacement engine applied **none** of them while the response still read `survivors: []` — the shape of a clean run. Sealing a T-SPEC now reports entries whose `where` is absent from the A-SPEC's anchored source, and the authoring playbook shows the grammar.
25
+ - 🔎 **The RTM stops claiming coverage it cannot see** *(new in 0.21.0)*: `rtm_dashboard` and `/api/rtm` report `codeLinkedPct`, `unlinkedCount` and `unlinkedByReason` beside the old `coveragePct`, which read 100 while 11.4% of approved specs carried no `implements` edge. The scanner now also reads every anchor the injector can write (`.sh`/`.yml`/`.toml` anchors were planted and never indexed). Sealing gained a **Files-to-Touch fulfilment advisory** (declared paths that do not exist, as `missing`/`moved`/`alternatives`, ledgered, never blocking) and `rtm_impact` gained **trace gaps** — approved specs that declare a changed production file yet anchor only tests, with `impact_gate_check` answering `trace-gap` instead of silently widening.
26
+ - 🩹 **`@known-defect(reason, expires=YYYY-MM-DD)` and constitution article ART-9** *(new in 0.21.0)*: a test that pins a known defect as its expected value carries a machine-readable marker. Unexpired markers are listed on the Stop hook's `tracked` channel as debt; the gate blocks only when a marker has **expired** or cannot be read (`no-reason`, `no-expires`, `bad-date`). String literals are stripped first, the expiry day itself counts as expired (UTC), and a walk that cannot start is no signal rather than a clean bill. A bypass is sometimes the right call — the marker is there so the next person can see it.
19
27
  - 🧑‍🤝‍🧑 **Concurrent Multi-Agent Workspace** *(new in 0.20.0)*: several agents, machines and clones work on one spec store and converge through Git. A Git-shareable **workspace identity** with per-checkout replica registration (`workspace_identity`) stamps every governed write with run provenance; specs become **UUID-keyed entities** (`entity_store`) whose display ids can be **renumbered without losing identity, anchors or approval closure** (`entity_renumber`); independent work from a common ancestor is **integrated** with stable identity, per-side conflict evidence, automatic renumbering of colliding new ids and explicit derived-state discharge (`entity_integrate`); single-use approvals are spent on the writer's own ledger chain so offline clones merge cleanly, and a nonce spent twice is a structured **double-spend** that freezes every authority-spending act until `ledger_reconcile`. Evidence (RED→GREEN outcomes, timelines) follows the entity across renames. Reproduced end to end in a project outside this repository on macOS and Linux.
20
28
  - 🗂️ **Approval decisions you can actually see** *(new in 0.20.0)*: `holmes-kit approve --status` (and the `--watch` header) shows the workspace, the delegation posture, and per request the risk grade, who asked (run · replica · workspace), the subject digest and exactly what a grant would open — plus recent decisions with their outcome (live until / consumed / expired / revoked / denied). A grant is now **bound to the workspace it was minted in and to the content the human read**: a copied grant is refused as `foreign-workspace`, a seal of changed content as `stale-subject`, and `approve --revoke` withdraws a live grant. Grants are git-ignored by `init`.
21
29
  - 🔁 **Import cycles are governed, at the three moments that can each do something** *(new in 0.19.0)*: the spec graph has been required to be acyclic since early on; the code graph now is too. **Guidance** reaches the agent before it designs (the authoring playbooks say "share types through a third module", pinned by test). **A design-time advisory** — `approval_status`'s `graphPreview` gains `cycles` — names the cycles your declared files are *already* in, with each edge classified as `type-erasable` (TypeScript deletes it, so it is not a runtime cycle), `lazy-require` (a workaround someone already paid for) or `eager-value`; the note says in words that this is **not** a prediction, because at approval time the code that would create a cycle does not exist yet. **A ratchet** in the Stop hook's constitution catches new cycles after the code is written — shipped in `track` (records, never blocks), and the escape is a **named exception**, never a threshold, so a project carrying legacy cycles can still adopt the harness. This repository went from three cycles to zero, and the two lazy `require()` workarounds they forced are gone.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 71586207-mu68mv0a
1
+ d2304488-mu6dv817
@@ -98,6 +98,44 @@ export declare function mergeAgentsMd(existing: string | null, generated: string
98
98
  * 계산과 쓰기를 나누는 이유는 dry-run 이 실제 실행과 같은 집합을 예고해야 하기 때문이다
99
99
  * (A-SPEC-190 §9). 같은 함수가 두 경로에 답하면 둘이 어긋날 수 없다.
100
100
  */
101
+ /**
102
+ * @implements A-SPEC-667
103
+ * Merging a wiring file the harness shares with other tools.
104
+ *
105
+ * Measured 2026-09-18 with a real `--dry-run`: a neighbour MCP server, a neighbour hook namespace, a
106
+ * neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a
107
+ * re-wire at a rate of ZERO — the agy and codex writers replaced each file whole. The Claude path
108
+ * learned this in A-SPEC-179 ("merge, not replace"; `--force` means refresh the wiring, not turn off
109
+ * what I switched on). These functions give the other two harnesses the same discipline.
110
+ *
111
+ * Pure text in, pure text out, so `init` can merge during its COMPUTE phase and `--dry-run` predicts
112
+ * the bytes the write produces (the A-SPEC-190 §9 rule).
113
+ */
114
+ export interface WiringMerge {
115
+ content: string;
116
+ /** Names of things that were kept because they are not ours — reported to the person verbatim. */
117
+ preserved: string[];
118
+ /** Sentences about the merged state a person needs (e.g. the wiring is present but disabled). */
119
+ notes: string[];
120
+ /** Set when the existing file could not be read; `content` is then the ORIGINAL, and init skips it. */
121
+ refused?: string;
122
+ }
123
+ /**
124
+ * `.agents/mcp_config.json`. Neighbour servers survive whole; inside the holmes-kit entry our
125
+ * command, args and `HOLMES_SPECS` win while every other key a person put there — `disabled` above
126
+ * all — is kept. Turning someone's switch back on without a word is the surprise this prevents.
127
+ */
128
+ export declare function mergeMcpConfigJson(before: string | null, ours: string): WiringMerge;
129
+ /** `.agents/hooks.json`. Top-level keys are tool namespaces; only ours is replaced. */
130
+ export declare function mergeHooksJson(before: string | null, ours: string): WiringMerge;
131
+ /**
132
+ * `marketplace.json`. Only the holmes-kit plugin entry is ours; the marketplace's identity and every
133
+ * other plugin belong to whoever created the file. Renaming someone's marketplace to ours would be
134
+ * the same silent theft as deleting their plugins.
135
+ */
136
+ export declare function mergeMarketplaceJson(before: string | null, ours: string): WiringMerge;
137
+ /** The merged set, by basename. `null` means "no merger" — the caller writes as it always did. */
138
+ export declare function mergeWiringFile(basename: string, before: string | null, ours: string): WiringMerge | null;
101
139
  export declare function agentFiles(agent: Agent, opts: AgentWiringOptions): AgentFile[];
102
140
  export interface AgentLink {
103
141
  path: string;
@@ -38,6 +38,10 @@ exports.antigravityHookCommand = antigravityHookCommand;
38
38
  exports.shortPathIfSpaced = shortPathIfSpaced;
39
39
  exports.antigravityHookWarnings = antigravityHookWarnings;
40
40
  exports.mergeAgentsMd = mergeAgentsMd;
41
+ exports.mergeMcpConfigJson = mergeMcpConfigJson;
42
+ exports.mergeHooksJson = mergeHooksJson;
43
+ exports.mergeMarketplaceJson = mergeMarketplaceJson;
44
+ exports.mergeWiringFile = mergeWiringFile;
41
45
  exports.agentFiles = agentFiles;
42
46
  exports.agentLinks = agentLinks;
43
47
  // @implements A-SPEC-202, A-SPEC-250, A-SPEC-341
@@ -297,12 +301,109 @@ function closeRun(runs, run) {
297
301
  runs.push(run);
298
302
  return [];
299
303
  }
304
+ const HOLMES = 'holmes-kit';
305
+ /** Parse, or explain why not. An unreadable file is never replaced: silently deleting it is the defect. */
306
+ function parseOr(before, ours, what) {
307
+ if (before === null)
308
+ return { content: ours, preserved: [], notes: [] };
309
+ let prev;
310
+ try {
311
+ prev = JSON.parse(before);
312
+ }
313
+ catch {
314
+ return { content: before, preserved: [], notes: [], refused: `${what} is not readable JSON — left untouched; fix or remove it, then re-run init` };
315
+ }
316
+ if (prev === null || typeof prev !== 'object' || Array.isArray(prev)) {
317
+ return { content: before, preserved: [], notes: [], refused: `${what} is not a JSON object — left untouched; fix or remove it, then re-run init` };
318
+ }
319
+ return { prev: prev };
320
+ }
321
+ const isMerge = (x) => 'content' in x;
322
+ const asObject = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {});
300
323
  /**
301
- * 하네스에 써야 파일들. **쓰지는 않는다** 무엇을 쓸지만 말한다.
302
- *
303
- * 계산과 쓰기를 나누는 이유는 dry-run 실제 실행과 같은 집합을 예고해야 하기 때문이다
304
- * (A-SPEC-190 §9). 같은 함수가 두 경로에 답하면 둘이 어긋날 수 없다.
324
+ * `.agents/mcp_config.json`. Neighbour servers survive whole; inside the holmes-kit entry our
325
+ * command, args and `HOLMES_SPECS` win while every other key a person put there — `disabled` above
326
+ * all is kept. Turning someone's switch back on without a word is the surprise this prevents.
327
+ */
328
+ function mergeMcpConfigJson(before, ours) {
329
+ const parsed = parseOr(before, ours, 'mcp_config.json');
330
+ if (isMerge(parsed))
331
+ return parsed;
332
+ const oursObj = asObject(JSON.parse(ours));
333
+ const oursEntry = asObject(asObject(oursObj.mcpServers)[HOLMES]);
334
+ const prevServers = asObject(parsed.prev.mcpServers);
335
+ const prevEntry = asObject(prevServers[HOLMES]);
336
+ const keptKeys = Object.keys(prevEntry).filter((k) => k !== 'command' && k !== 'args' && k !== 'env');
337
+ const merged = {
338
+ ...prevEntry,
339
+ command: oursEntry.command,
340
+ args: oursEntry.args,
341
+ env: { ...asObject(prevEntry.env), ...asObject(oursEntry.env) },
342
+ };
343
+ const out = { ...parsed.prev, mcpServers: { ...prevServers, [HOLMES]: merged } };
344
+ const notes = [];
345
+ if (prevEntry.disabled === true || prevEntry.disabled === 'true') {
346
+ notes.push(`mcp_config.json: holmes-kit is wired but DISABLED in this config — the gate will not run until it is enabled.`);
347
+ }
348
+ return {
349
+ content: `${JSON.stringify(out, null, 2)}\n`,
350
+ preserved: [...Object.keys(prevServers).filter((k) => k !== HOLMES), ...keptKeys],
351
+ notes,
352
+ };
353
+ }
354
+ /** `.agents/hooks.json`. Top-level keys are tool namespaces; only ours is replaced. */
355
+ function mergeHooksJson(before, ours) {
356
+ const parsed = parseOr(before, ours, 'hooks.json');
357
+ if (isMerge(parsed))
358
+ return parsed;
359
+ const oursObj = asObject(JSON.parse(ours));
360
+ const out = { ...parsed.prev, [HOLMES]: oursObj[HOLMES] };
361
+ return {
362
+ content: `${JSON.stringify(out, null, 2)}\n`,
363
+ preserved: Object.keys(parsed.prev).filter((k) => k !== HOLMES),
364
+ notes: [],
365
+ };
366
+ }
367
+ /**
368
+ * `marketplace.json`. Only the holmes-kit plugin entry is ours; the marketplace's identity and every
369
+ * other plugin belong to whoever created the file. Renaming someone's marketplace to ours would be
370
+ * the same silent theft as deleting their plugins.
305
371
  */
372
+ function mergeMarketplaceJson(before, ours) {
373
+ const parsed = parseOr(before, ours, 'marketplace.json');
374
+ if (isMerge(parsed))
375
+ return parsed;
376
+ const oursObj = asObject(JSON.parse(ours));
377
+ const oursPlugin = (Array.isArray(oursObj.plugins) ? oursObj.plugins : []).find((p) => asObject(p).name === HOLMES);
378
+ const prevPlugins = Array.isArray(parsed.prev.plugins) ? parsed.prev.plugins : [];
379
+ const hadOurs = prevPlugins.some((p) => asObject(p).name === HOLMES);
380
+ const plugins = hadOurs
381
+ ? prevPlugins.map((p) => (asObject(p).name === HOLMES ? oursPlugin : p))
382
+ : [...prevPlugins, oursPlugin];
383
+ const keptName = typeof parsed.prev.name === 'string' && parsed.prev.name !== '' ? parsed.prev.name : undefined;
384
+ const out = {
385
+ ...parsed.prev,
386
+ name: keptName ?? oursObj.name,
387
+ interface: parsed.prev.interface ?? oursObj.interface,
388
+ plugins,
389
+ };
390
+ const neighbours = prevPlugins.map((p) => String(asObject(p).name ?? '')).filter((n) => n !== '' && n !== HOLMES);
391
+ return {
392
+ content: `${JSON.stringify(out, null, 2)}\n`,
393
+ preserved: [...neighbours, ...(keptName !== undefined && keptName !== oursObj.name ? [keptName] : [])],
394
+ notes: [],
395
+ };
396
+ }
397
+ /** The merged set, by basename. `null` means "no merger" — the caller writes as it always did. */
398
+ function mergeWiringFile(basename, before, ours) {
399
+ if (basename === 'mcp_config.json')
400
+ return mergeMcpConfigJson(before, ours);
401
+ if (basename === 'hooks.json')
402
+ return mergeHooksJson(before, ours);
403
+ if (basename === 'marketplace.json')
404
+ return mergeMarketplaceJson(before, ours);
405
+ return null;
406
+ }
306
407
  function agentFiles(agent, opts) {
307
408
  const { target, packageRoot, specsDir } = opts;
308
409
  switch (agent) {
@@ -471,6 +471,23 @@ function runInit(opts) {
471
471
  }
472
472
  continue;
473
473
  }
474
+ // @implements A-SPEC-667 — these files are shared with other tools. Replacing one whole
475
+ // deletes a neighbour's MCP server, hooks or plugins without a word (measured 2026-09-18:
476
+ // every third-party entry survived at a rate of zero). Merging happens HERE, in the compute
477
+ // phase, so `--dry-run` predicts the bytes the write produces (A-SPEC-190 §9).
478
+ const wiring = (0, agents_1.mergeWiringFile)(path.basename(f.path), before, f.content);
479
+ if (wiring) {
480
+ if (wiring.refused) {
481
+ messages.push(`${f.path}: ${wiring.refused}`);
482
+ continue;
483
+ }
484
+ changes.push({ path: f.path, before, after: wiring.content });
485
+ if (wiring.preserved.length > 0)
486
+ messages.push(`${f.path}: kept ${wiring.preserved.length} ${wiring.preserved.length === 1 ? 'entry that is' : 'entries that are'} not holmes-kit's — ${wiring.preserved.join(', ')}`);
487
+ for (const n of wiring.notes)
488
+ messages.push(n);
489
+ continue;
490
+ }
474
491
  changes.push({ path: f.path, before, after: f.content });
475
492
  }
476
493
  // @implements A-SPEC-586 — an antigravity hook whose path still carries a space is a hook the
@@ -182,6 +182,8 @@ export declare function createOperatorInspectionHandlers(context: OperatorInspec
182
182
  unmapped: number;
183
183
  nonSpec: number;
184
184
  };
185
+ declaredScopeByScope: Record<import("../../rtm/declared-scope").DeclaredScope, number>;
186
+ unreachableDeclaringCount: number;
185
187
  reqCount: number;
186
188
  pipelineCount: number;
187
189
  coveredCount: number;
@@ -839,6 +839,8 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
839
839
  unmapped: number;
840
840
  nonSpec: number;
841
841
  };
842
+ declaredScopeByScope: Record<import("../rtm/declared-scope").DeclaredScope, number>;
843
+ unreachableDeclaringCount: number;
842
844
  reqCount: number;
843
845
  pipelineCount: number;
844
846
  coveredCount: number;
@@ -1,5 +1,12 @@
1
1
  /** Opt-in switch. Absent means the entry point behaves exactly as it did before this existed. */
2
2
  export declare const AUTORELOAD_ENV = "HOLMES_MCP_AUTORELOAD";
3
+ /**
4
+ * @implements A-SPEC-666 — how many times a child may die WITHOUT ever answering before the
5
+ * supervisor stops replacing it. A server that worked and then died once has not spent this budget;
6
+ * only a child that produced nothing in its whole life counts, so "a broken build that dies on every
7
+ * spawn" stops quickly while "one OOM under load" restarts freely.
8
+ */
9
+ export declare const MAX_CONSECUTIVE_SILENT_DEATHS = 5;
3
10
  export declare function autoreloadEnabled(env: NodeJS.ProcessEnv): boolean;
4
11
  /**
5
12
  * Whether the child may be replaced right now.
@@ -63,10 +70,38 @@ export declare class Supervisor {
63
70
  * supervisor makes, that the connection does not drop.
64
71
  */
65
72
  private handshake;
73
+ /**
74
+ * @implements A-SPEC-666 — WHICH requests are unanswered, not merely how many. `inflight` counts
75
+ * for the swap decision and its counter is contracted to count without identifying (A-SPEC-516.1);
76
+ * answering a death needs identity, so it is tracked separately rather than by widening that one.
77
+ */
78
+ private pending;
79
+ /** True while an exit we caused (a swap, a stop) is expected — not every death is an accident. */
80
+ private expectedExit;
81
+ /** Consecutive children that died having never answered anything. Reset by any answer. */
82
+ private silentDeaths;
83
+ private answeredSinceSpawn;
84
+ /** True once the limit is reached: the supervisor stops replacing and says so. */
85
+ private givenUp;
66
86
  constructor(repoRoot: string, onSwap?: ((from: string, to: string, replayed: number) => void) | undefined);
67
87
  start(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): void;
68
88
  private forward;
69
89
  private swap;
70
90
  private spawnChild;
91
+ /**
92
+ * @implements A-SPEC-666
93
+ * Drop the ids of every COMPLETE response line from `pending`; return the unfinished tail so a
94
+ * reply split across chunks is not lost. A line with neither `result` nor `error` is a
95
+ * notification, not an answer, and retires nothing.
96
+ */
97
+ private retireAnsweredIds;
98
+ /**
99
+ * @implements A-SPEC-666
100
+ * The child is gone. Measured 2026-09-18: without this, `this.child` kept pointing at the corpse,
101
+ * every later request was written to a dead pipe, `inflight` never returned to zero and
102
+ * `shouldSwap`'s `inflight === 0` therefore never fired — the server was deaf for the rest of the
103
+ * session, twice in one day. Order matters: free the client first, then decide about a replacement.
104
+ */
105
+ private onChildGone;
71
106
  stop(): void;
72
107
  }
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.Supervisor = exports.AUTORELOAD_ENV = void 0;
36
+ exports.Supervisor = exports.MAX_CONSECUTIVE_SILENT_DEATHS = exports.AUTORELOAD_ENV = void 0;
37
37
  exports.autoreloadEnabled = autoreloadEnabled;
38
38
  exports.shouldSwap = shouldSwap;
39
39
  exports.createResponseCounter = createResponseCounter;
@@ -43,6 +43,13 @@ const path = __importStar(require("node:path"));
43
43
  const basis_1 = require("./basis");
44
44
  /** Opt-in switch. Absent means the entry point behaves exactly as it did before this existed. */
45
45
  exports.AUTORELOAD_ENV = 'HOLMES_MCP_AUTORELOAD';
46
+ /**
47
+ * @implements A-SPEC-666 — how many times a child may die WITHOUT ever answering before the
48
+ * supervisor stops replacing it. A server that worked and then died once has not spent this budget;
49
+ * only a child that produced nothing in its whole life counts, so "a broken build that dies on every
50
+ * spawn" stops quickly while "one OOM under load" restarts freely.
51
+ */
52
+ exports.MAX_CONSECUTIVE_SILENT_DEATHS = 5;
46
53
  function autoreloadEnabled(env) {
47
54
  const v = env[exports.AUTORELOAD_ENV];
48
55
  return typeof v === 'string' && v.trim() !== '';
@@ -144,6 +151,19 @@ class Supervisor {
144
151
  * supervisor makes, that the connection does not drop.
145
152
  */
146
153
  handshake = [];
154
+ /**
155
+ * @implements A-SPEC-666 — WHICH requests are unanswered, not merely how many. `inflight` counts
156
+ * for the swap decision and its counter is contracted to count without identifying (A-SPEC-516.1);
157
+ * answering a death needs identity, so it is tracked separately rather than by widening that one.
158
+ */
159
+ pending = new Set();
160
+ /** True while an exit we caused (a swap, a stop) is expected — not every death is an accident. */
161
+ expectedExit = false;
162
+ /** Consecutive children that died having never answered anything. Reset by any answer. */
163
+ silentDeaths = 0;
164
+ answeredSinceSpawn = false;
165
+ /** True once the limit is reached: the supervisor stops replacing and says so. */
166
+ givenUp = false;
147
167
  constructor(repoRoot, onSwap) {
148
168
  this.repoRoot = repoRoot;
149
169
  this.onSwap = onSwap;
@@ -165,12 +185,14 @@ class Supervisor {
165
185
  forward(line, stdout) {
166
186
  const trimmed = line.trim();
167
187
  let isRequest = false;
188
+ let requestId;
168
189
  if (trimmed.startsWith('{')) {
169
190
  try {
170
191
  const msg = JSON.parse(trimmed);
171
192
  if (msg.method === 'initialize' || msg.method === 'notifications/initialized')
172
193
  this.handshake.push(line);
173
194
  isRequest = msg.id !== undefined;
195
+ requestId = msg.id;
174
196
  }
175
197
  catch { /* unparseable input is relayed untouched; the child owns protocol errors */ }
176
198
  }
@@ -181,12 +203,16 @@ class Supervisor {
181
203
  const disk = (0, basis_1.loadedBuildId)(this.repoRoot);
182
204
  if (shouldSwap(this.loaded, disk, this.inflight))
183
205
  this.swap(disk, stdout);
184
- if (isRequest)
206
+ if (isRequest) {
185
207
  this.inflight++;
208
+ if (requestId !== undefined)
209
+ this.pending.add(requestId); // @implements A-SPEC-666
210
+ }
186
211
  this.child?.stdin.write(line);
187
212
  }
188
213
  swap(to, stdout) {
189
214
  const from = this.loaded;
215
+ this.expectedExit = true; // @implements A-SPEC-666 — we are replacing it on purpose
190
216
  this.child?.kill();
191
217
  this.loaded = to;
192
218
  this.spawnChild(stdout);
@@ -217,6 +243,10 @@ class Supervisor {
217
243
  // @implements A-SPEC-516.1 — one counter per child: a half-read line from the process being
218
244
  // replaced must not be finished by its successor's first chunk.
219
245
  const counter = createResponseCounter();
246
+ // @implements A-SPEC-666 — a fresh child has answered nothing yet; the silent-death budget is
247
+ // spent only by children that stay silent for their whole life.
248
+ this.answeredSinceSpawn = false;
249
+ let idCarry = '';
220
250
  child.stdout.on('data', (d) => {
221
251
  const text = d.toString();
222
252
  // Relay FIRST. Counting is an observation and must never delay or alter the bytes.
@@ -226,9 +256,82 @@ class Supervisor {
226
256
  // count would let a swap happen mid-request, which is the mis-delivered answer this whole
227
257
  // mechanism defers swaps to avoid.
228
258
  this.inflight = Math.max(0, this.inflight - counter.push(text));
259
+ // @implements A-SPEC-666 — and separately, WHICH ids were answered, so a death can reply to
260
+ // the rest. Its own line assembly: the counter above is contracted to count, not to identify.
261
+ idCarry = this.retireAnsweredIds(idCarry + text);
229
262
  });
263
+ child.on('exit', () => this.onChildGone(stdout));
264
+ child.on('error', () => this.onChildGone(stdout));
265
+ }
266
+ /**
267
+ * @implements A-SPEC-666
268
+ * Drop the ids of every COMPLETE response line from `pending`; return the unfinished tail so a
269
+ * reply split across chunks is not lost. A line with neither `result` nor `error` is a
270
+ * notification, not an answer, and retires nothing.
271
+ */
272
+ retireAnsweredIds(buffered) {
273
+ const parts = buffered.split('\n');
274
+ const tail = parts.pop() ?? '';
275
+ for (const line of parts) {
276
+ const trimmed = line.trim();
277
+ if (!trimmed.startsWith('{'))
278
+ continue;
279
+ try {
280
+ const msg = JSON.parse(trimmed);
281
+ if (msg.id === undefined)
282
+ continue;
283
+ if (msg.result === undefined && msg.error === undefined)
284
+ continue;
285
+ if (this.pending.delete(msg.id))
286
+ this.answeredSinceSpawn = true;
287
+ else
288
+ this.answeredSinceSpawn = true; // an answer we did not track is still an answer
289
+ }
290
+ catch { /* a partial or malformed line retires nothing */ }
291
+ }
292
+ return tail;
293
+ }
294
+ /**
295
+ * @implements A-SPEC-666
296
+ * The child is gone. Measured 2026-09-18: without this, `this.child` kept pointing at the corpse,
297
+ * every later request was written to a dead pipe, `inflight` never returned to zero and
298
+ * `shouldSwap`'s `inflight === 0` therefore never fired — the server was deaf for the rest of the
299
+ * session, twice in one day. Order matters: free the client first, then decide about a replacement.
300
+ */
301
+ onChildGone(stdout) {
302
+ if (this.expectedExit) {
303
+ this.expectedExit = false;
304
+ return;
305
+ } // a swap or a stop, not an accident
306
+ if (this.givenUp)
307
+ return;
308
+ // 1. Answer what the dead child never will. A request without a reply is a client waiting for ever.
309
+ for (const id of this.pending) {
310
+ stdout.write(`${JSON.stringify({
311
+ jsonrpc: '2.0', id,
312
+ error: { code: -32603, message: 'holmes-kit MCP child process exited before answering this request' },
313
+ })}\n`);
314
+ }
315
+ const orphaned = this.pending.size;
316
+ this.pending.clear();
317
+ this.inflight = 0;
318
+ // 2. A child that produced nothing in its whole life spends the budget; one that worked does not.
319
+ this.silentDeaths = this.answeredSinceSpawn ? 0 : this.silentDeaths + 1;
320
+ if (this.silentDeaths >= exports.MAX_CONSECUTIVE_SILENT_DEATHS) {
321
+ this.givenUp = true;
322
+ this.child = undefined;
323
+ process.stderr.write(`[holmes-kit] MCP child died ${this.silentDeaths} times without answering — stopped restarting it. Check the build (\`npm run build\`) and restart the server.\n`);
324
+ return;
325
+ }
326
+ // 3. Replace it and re-send the opening exchange, exactly as a swap does.
327
+ process.stderr.write(`[holmes-kit] MCP child exited unexpectedly; restarting (${orphaned} unanswered request(s) failed).\n`);
328
+ this.spawnChild(stdout);
329
+ for (const line of this.handshake)
330
+ this.child?.stdin.write(line);
230
331
  }
231
332
  stop() {
333
+ this.expectedExit = true; // @implements A-SPEC-666 — a shutdown is not an accident
334
+ this.givenUp = true; // ... and nothing may respawn after it
232
335
  this.child?.kill();
233
336
  }
234
337
  }
@@ -0,0 +1,28 @@
1
+ export type DeclaredScope = 'scanned-source' | 'file-anchor-target' | 'test-target' | 'unreachable-target' | 'no-declaration';
2
+ export interface ScopePredicates {
3
+ isScanned(rel: string): boolean;
4
+ isFileAnchorable(rel: string): boolean;
5
+ isTest(rel: string): boolean;
6
+ }
7
+ export declare const DECLARED_SCOPES: readonly DeclaredScope[];
8
+ export declare function emptyByScope(): Record<DeclaredScope, number>;
9
+ /**
10
+ * One label per spec, by the STRONGEST reachability its declaration offers: a scanned source beats a
11
+ * file-anchor target beats a test file beats nothing any mechanism reads. `unreachable` is filled
12
+ * whatever the label is, because a single label hides the `docs/x.md` sitting beside a test file —
13
+ * measured here, 21 of the 52 unlinked specs declare at least one such path.
14
+ * The Files-to-Touch parser is REQ-656's, so path extraction has exactly one definition.
15
+ */
16
+ export declare function declaredScopeOf(fttText: string, p: ScopePredicates): {
17
+ scope: DeclaredScope;
18
+ unreachable: string[];
19
+ };
20
+ /** Every entry labelled exactly once, so the aggregate sums to the population it was given. */
21
+ export declare function declaredScopeCensus(entries: ReadonlyArray<{
22
+ id: string;
23
+ ftt: string;
24
+ }>, p: ScopePredicates): {
25
+ byId: Record<string, DeclaredScope>;
26
+ byScope: Record<DeclaredScope, number>;
27
+ unreachableDeclaring: number;
28
+ };
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DECLARED_SCOPES = void 0;
4
+ exports.emptyByScope = emptyByScope;
5
+ exports.declaredScopeOf = declaredScopeOf;
6
+ exports.declaredScopeCensus = declaredScopeCensus;
7
+ // @implements A-SPEC-665
8
+ /**
9
+ * The DECLARATION axis of an unlinked approved A-SPEC.
10
+ *
11
+ * REQ-655's census answers "where is this spec anchored" (`UnlinkedReason`). It cannot answer the
12
+ * question a reader actually has when `codeLinkedPct` reads 91.9: is there production code for this
13
+ * spec to be linked to at all? Measured 2026-09-18, after REQ-664's S3 moved 21 anchors: of the 52
14
+ * unlinked approved specs, ZERO declare a file the scanner parses. They are unlinked because they
15
+ * have no such file, not because an anchor is missing.
16
+ *
17
+ * The two axes sit side by side and never share a name. This one names what a spec DECLARED — a
18
+ * fact — and says nothing about whether that is acceptable; `traceGaps` remains the reader that
19
+ * calls one of these situations a gap.
20
+ *
21
+ * Pure: every reachability question is injected, so the classification follows the mechanisms'
22
+ * own definitions rather than a second copy of them.
23
+ */
24
+ const ftt_fulfilment_1 = require("./ftt-fulfilment");
25
+ exports.DECLARED_SCOPES = ['scanned-source', 'file-anchor-target', 'test-target', 'unreachable-target', 'no-declaration'];
26
+ function emptyByScope() {
27
+ return { 'scanned-source': 0, 'file-anchor-target': 0, 'test-target': 0, 'unreachable-target': 0, 'no-declaration': 0 };
28
+ }
29
+ /**
30
+ * One label per spec, by the STRONGEST reachability its declaration offers: a scanned source beats a
31
+ * file-anchor target beats a test file beats nothing any mechanism reads. `unreachable` is filled
32
+ * whatever the label is, because a single label hides the `docs/x.md` sitting beside a test file —
33
+ * measured here, 21 of the 52 unlinked specs declare at least one such path.
34
+ * The Files-to-Touch parser is REQ-656's, so path extraction has exactly one definition.
35
+ */
36
+ function declaredScopeOf(fttText, p) {
37
+ const items = [...new Set((0, ftt_fulfilment_1.fttItems)(String(fttText ?? '')).map((i) => i.path))];
38
+ if (items.length === 0)
39
+ return { scope: 'no-declaration', unreachable: [] };
40
+ const unreachable = items.filter((rel) => !p.isScanned(rel) && !p.isFileAnchorable(rel) && !p.isTest(rel)).sort();
41
+ const scope = items.some((rel) => p.isScanned(rel)) ? 'scanned-source'
42
+ : items.some((rel) => p.isFileAnchorable(rel)) ? 'file-anchor-target'
43
+ : items.some((rel) => p.isTest(rel)) ? 'test-target'
44
+ : 'unreachable-target';
45
+ return { scope, unreachable };
46
+ }
47
+ /** Every entry labelled exactly once, so the aggregate sums to the population it was given. */
48
+ function declaredScopeCensus(entries, p) {
49
+ const byId = {};
50
+ const byScope = emptyByScope();
51
+ let unreachableDeclaring = 0;
52
+ for (const e of entries) {
53
+ const r = declaredScopeOf(e.ftt, p);
54
+ byId[e.id] = r.scope;
55
+ byScope[r.scope] += 1;
56
+ if (r.unreachable.length > 0)
57
+ unreachableDeclaring += 1;
58
+ }
59
+ return { byId, byScope, unreachableDeclaring };
60
+ }
@@ -1,4 +1,5 @@
1
1
  import { UnlinkedReason } from '../rtm/link-census';
2
+ import { DeclaredScope } from '../rtm/declared-scope';
2
3
  /** Honesty summary of what a launched dashboard shows — derived from the endpoints it serves. */
3
4
  export interface DashboardCensus {
4
5
  reqCount: number;
@@ -26,6 +27,12 @@ export interface DashboardCensusExtended extends DashboardCensus {
26
27
  unmapped: number;
27
28
  nonSpec: number;
28
29
  };
30
+ /**
31
+ * @implements A-SPEC-665 — the declaration axis beside the anchor axis: what the unlinked specs
32
+ * DECLARED, next to where they are anchored. A payload without it folds to zeros like the rest.
33
+ */
34
+ declaredScopeByScope: Record<DeclaredScope, number>;
35
+ unreachableDeclaringCount: number;
29
36
  }
30
37
  /**
31
38
  * Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
@@ -7,6 +7,7 @@ exports._resetLauncher = _resetLauncher;
7
7
  exports._stopAll = _stopAll;
8
8
  // @implements A-SPEC-545.3
9
9
  const link_census_1 = require("../rtm/link-census");
10
+ const declared_scope_1 = require("../rtm/declared-scope");
10
11
  /**
11
12
  * @implements A-SPEC-545.3
12
13
  * Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
@@ -45,6 +46,8 @@ function dashboardCensusExtended(rtm, heatmap) {
45
46
  unlinkedCount: Array.isArray(lc?.unlinked) ? lc.unlinked.length : 0,
46
47
  unlinkedByReason: { ...(0, link_census_1.emptyByReason)(), ...(lc?.byReason ?? {}) },
47
48
  excluded: { total: retired + unmapped + nonSpec, retired, unmapped, nonSpec },
49
+ declaredScopeByScope: { ...(0, declared_scope_1.emptyByScope)(), ...(rtm?.declaredScope?.byScope ?? {}) },
50
+ unreachableDeclaringCount: typeof rtm?.declaredScope?.unreachableDeclaring === 'number' ? rtm.declaredScope.unreachableDeclaring : 0,
48
51
  };
49
52
  }
50
53
  const live = new Map();
@@ -58,6 +58,8 @@ const cfg_view_1 = require("./cfg-view");
58
58
  const link_census_1 = require("../rtm/link-census");
59
59
  const test_scope_1 = require("../rtm/test-scope");
60
60
  const file_anchors_1 = require("../rtm/file-anchors");
61
+ const declared_scope_1 = require("../rtm/declared-scope");
62
+ const test_files_1 = require("../cpg/test-files");
61
63
  /**
62
64
  * Start a lightweight standalone Node.js HTTP server for interactive dashboard & RTM visualization.
63
65
  *
@@ -130,11 +132,20 @@ async function startDashboardServer(options) {
130
132
  }
131
133
  const census = (0, link_census_1.linkCensus)({ approvedIds: approvedAspecs.map((s) => s.id), implemented: implementedSpecIds, testAnchors, fileAnchors, weakMentions });
132
134
  const reasonOf = new Map(census.unlinked.map((u) => [u.id, u.reason]));
135
+ // @implements A-SPEC-665 — the second axis. The anchor axis above says WHERE a spec is
136
+ // anchored; this one says what it DECLARED, which is the only way to tell "an anchor is
137
+ // missing" from "there is no production file to anchor". Predicates come from the mechanisms
138
+ // themselves (the scan's own paths, the file-anchor predicate, the test predicate) so the two
139
+ // readers cannot drift apart.
140
+ const scannedPaths = new Set(files.map((f) => f.sourcePath));
141
+ const fttOf = new Map(specs.map((s) => [s.id, String(s.sections?.['Files to Touch'] ?? '')]));
142
+ const declaredScope = (0, declared_scope_1.declaredScopeCensus)(census.unlinked.map((u) => ({ id: u.id, ftt: fttOf.get(u.id) ?? '' })), { isScanned: (rel) => scannedPaths.has(rel), isFileAnchorable: (rel) => (0, file_anchors_1.isFileAnchorPath)(rel), isTest: (rel) => (0, test_files_1.isTestFile)(rel) });
133
143
  const enrichedSpecs = specs.map((s) => {
134
144
  const isA = s.id.startsWith('A-SPEC');
135
145
  const covered = isA ? implementedSpecIds.has(s.id) : s.status === 'approved';
136
146
  const reason = reasonOf.get(s.id);
137
- return { ...s, covered, legacyStatus: !isCanonicalStatus(s.status), ...(reason ? { unlinkedReason: reason } : {}) };
147
+ const scope = declaredScope.byId[s.id];
148
+ return { ...s, covered, legacyStatus: !isCanonicalStatus(s.status), ...(reason ? { unlinkedReason: reason } : {}), ...(scope ? { declaredScope: scope } : {}) };
138
149
  });
139
150
  // Two axes, never one number. Approval (a spec was signed off) and implementation (code is
140
151
  // anchored to it) answer different questions, and a spec whose status is outside the
@@ -194,6 +205,8 @@ async function startDashboardServer(options) {
194
205
  approval,
195
206
  implementation,
196
207
  linkCensus: census,
208
+ // @implements A-SPEC-665
209
+ declaredScope: { byScope: declaredScope.byScope, unreachableDeclaring: declaredScope.unreachableDeclaring },
197
210
  unmapped,
198
211
  nonSpec,
199
212
  retired,
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.22.0",
4
+ "version": "0.23.1",
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",