@holmes-lab/holmes-kit 0.21.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +8 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +38 -0
  5. package/dist/holmes/cli/agents.js +106 -4
  6. package/dist/holmes/cli/doctor.d.ts +1 -0
  7. package/dist/holmes/cli/doctor.js +42 -0
  8. package/dist/holmes/cli/index.js +1 -0
  9. package/dist/holmes/cli/init.js +18 -0
  10. package/dist/holmes/cpg/language-parser-walk.js +1 -0
  11. package/dist/holmes/hooks/stop.d.ts +7 -0
  12. package/dist/holmes/hooks/stop.js +54 -1
  13. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +29 -1
  14. package/dist/holmes/mcp/handlers/operator-inspection.js +68 -2
  15. package/dist/holmes/mcp/handlers/spec-approval.d.ts +5 -0
  16. package/dist/holmes/mcp/handlers/spec-approval.js +59 -1
  17. package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
  18. package/dist/holmes/mcp/handlers/test-execution.js +6 -2
  19. package/dist/holmes/mcp/handlers.d.ts +33 -1
  20. package/dist/holmes/mcp/handlers.js +1 -0
  21. package/dist/holmes/mcp/maintenance-analyze.js +1 -0
  22. package/dist/holmes/mcp/supervisor.d.ts +35 -0
  23. package/dist/holmes/mcp/supervisor.js +105 -2
  24. package/dist/holmes/mcp/tool-schemas.js +1 -0
  25. package/dist/holmes/project/ci-runs.d.ts +46 -0
  26. package/dist/holmes/project/ci-runs.js +137 -0
  27. package/dist/holmes/project/install-scripts-policy.js +1 -0
  28. package/dist/holmes/review/evaluation-metrics.js +1 -0
  29. package/dist/holmes/review/kills-check.d.ts +40 -0
  30. package/dist/holmes/review/kills-check.js +147 -0
  31. package/dist/holmes/review/manual-baseline.js +1 -0
  32. package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
  33. package/dist/holmes/rtm/advisory-outcomes.js +314 -0
  34. package/dist/holmes/rtm/declared-scope.d.ts +28 -0
  35. package/dist/holmes/rtm/declared-scope.js +60 -0
  36. package/dist/holmes/rtm/rtm-graph.js +1 -0
  37. package/dist/holmes/rtm/taint-benchmark.js +1 -0
  38. package/dist/holmes/server/dashboard-launcher.d.ts +7 -0
  39. package/dist/holmes/server/dashboard-launcher.js +3 -0
  40. package/dist/holmes/server/dashboard.js +14 -1
  41. package/package.json +1 -1
  42. package/playbooks/author-slice/PLAYBOOK.md +19 -0
@@ -210,8 +210,60 @@ function createOperatorInspectionHandlers(context) {
210
210
  catch {
211
211
  fttFulfilment = undefined;
212
212
  }
213
+ // @implements A-SPEC-662 — the same kills-applicability finding sealing will report, delivered
214
+ // before sealing and without any write. Same pure function as the seal.
215
+ let killsAdvisory;
216
+ try {
217
+ if (cur.spec.type === 'T-SPEC' && a.root) {
218
+ const { parseKills } = require('../../spec/kills');
219
+ const check = require('../../review/kills-check');
220
+ const kills = parseKills(cur.spec.frontmatter);
221
+ const aspecId = cur.spec.dependsOn[0];
222
+ if (kills.length > 0 && aspecId) {
223
+ const found = check.killsAdvisory(cur.spec.id, aspecId, kills, check.anchoredSourceTexts(a.root, aspecId));
224
+ if (found)
225
+ killsAdvisory = found;
226
+ }
227
+ }
228
+ }
229
+ catch {
230
+ killsAdvisory = undefined;
231
+ }
232
+ // @implements A-SPEC-663 — the same findings, re-judged: a finding the seal issued that this read
233
+ // no longer produces is `resolved`; one still produced is `persisted` (once per day). The query
234
+ // writes only reaction rows, never issues a finding the seal did not — the observation
235
+ // denominator (REQ-572) stays the seal's.
236
+ let advisoryIds;
237
+ let advisoryHistory;
238
+ try {
239
+ if (a.root) {
240
+ const ao = require('../../rtm/advisory-outcomes');
241
+ let traceGap = null;
242
+ if (cur.spec.type === 'A-SPEC' && cur.spec.status === 'approved' && context.cachedScan) {
243
+ try {
244
+ traceGap = ao.traceGapFor(a.id, cur.spec, context.cachedScan(a.root, a.root), a.root);
245
+ }
246
+ catch {
247
+ traceGap = null;
248
+ }
249
+ }
250
+ const impactAdvisory = graphPreview?.impact ?? null;
251
+ const anchorDensity = graphPreview?.density ?? null;
252
+ const { keys, ids } = ao.currentAdvisoryKeys(a.id, { impactAdvisory, anchorDensity, fttFulfilment, killsAdvisory, traceGap });
253
+ const r = ao.recordAdvisoryOutcomes(a.root, a.id, keys, (s) => s.aspec === a.id, ao.gitHeadOf(a.root), [], new Date().toISOString(), { issue: false });
254
+ if (keys.length > 0)
255
+ advisoryIds = ids;
256
+ if (r.history.length > 0)
257
+ advisoryHistory = r.history;
258
+ }
259
+ }
260
+ catch {
261
+ advisoryIds = undefined;
262
+ advisoryHistory = undefined;
263
+ }
213
264
  return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, context.resolver(all)), ...(graphPreview ? { graphPreview } : {}),
214
- ...(fttFulfilment ? { fttFulfilment } : {}) };
265
+ ...(fttFulfilment ? { fttFulfilment } : {}), ...(killsAdvisory ? { killsAdvisory } : {}),
266
+ ...(advisoryIds ? { advisoryIds } : {}), ...(advisoryHistory ? { advisoryHistory } : {}) };
215
267
  },
216
268
  /**
217
269
  * @implements A-SPEC-538.3
@@ -296,7 +348,21 @@ function createOperatorInspectionHandlers(context) {
296
348
  const launch = await ensureDashboard(dest.root, a.port, (opts) => startDashboardServer(opts));
297
349
  const rtm = await context.fetchJson(`${launch.url}/api/rtm`);
298
350
  const heatmap = await context.fetchJson(`${launch.url}/api/rtm/heatmap`);
299
- return { ok: true, url: launch.url, running: launch.running, census: dashboardCensusExtended(rtm, heatmap) };
351
+ // @implements A-SPEC-663 the whole-store trace-gap judgement and the reaction census: how many
352
+ // findings of each kind were issued, and how many stand resolved / persisted / dismissed now.
353
+ let advisoryOutcomes;
354
+ try {
355
+ const ao = require('../../rtm/advisory-outcomes');
356
+ if (context.cachedScan) {
357
+ const keys = ao.traceGapAdvisories(await context.listSpecs(), context.cachedScan(dest.root, dest.root), dest.root);
358
+ ao.recordAdvisoryOutcomes(dest.root, '*', keys, (s) => s.kind === 'trace-gap', ao.gitHeadOf(dest.root));
359
+ }
360
+ advisoryOutcomes = ao.advisoryCensus(ao.readAdvisoryOutcomes(dest.root));
361
+ }
362
+ catch {
363
+ advisoryOutcomes = undefined;
364
+ }
365
+ return { ok: true, url: launch.url, running: launch.running, census: { ...dashboardCensusExtended(rtm, heatmap), ...(advisoryOutcomes ? { advisoryOutcomes } : {}) } };
300
366
  }
301
367
  catch (err) {
302
368
  return { ok: false, reason: `대시보드 기동 실패: ${err?.message ?? String(err)}` };
@@ -41,6 +41,7 @@ export declare function createSpecApprovalHandlers(context: SpecApprovalContext)
41
41
  spec_approve(a: {
42
42
  root?: string;
43
43
  id: string;
44
+ dismiss?: string[];
44
45
  }): Promise<{
45
46
  ok: false;
46
47
  reason: string;
@@ -57,6 +58,10 @@ export declare function createSpecApprovalHandlers(context: SpecApprovalContext)
57
58
  reason: string;
58
59
  conflict: import("../../spec/version-conflict").ConflictDetail;
59
60
  } | {
61
+ dismissUnknown?: string[] | undefined;
62
+ advisoryHistory?: import("../../rtm/advisory-outcomes").AdvisoryState[] | undefined;
63
+ advisoryIds?: Partial<Record<string, string>> | undefined;
64
+ killsAdvisory?: import("../../review/kills-check").KillsAdvisory | undefined;
60
65
  fttFulfilment?: import("../../rtm/ftt-fulfilment").FttFulfilment | undefined;
61
66
  impactAdvisoryUnavailable?: "empty" | "unreadable" | undefined;
62
67
  impactGraph?: {
@@ -442,9 +442,67 @@ function createSpecApprovalHandlers(context) {
442
442
  catch {
443
443
  fttFulfilment = undefined;
444
444
  }
445
+ // @implements A-SPEC-662 — the `kills` whose `where` will never apply, reported when the T-SPEC
446
+ // seals (measured 2026-09-17: 22/22 entries were paths and prose; nothing said so). Advisory:
447
+ // the seal is done, the field appears only when something is wrong, a failed walk drops it.
448
+ // No ledger here — the reaction ledger for every advisory kind is REQ-663's.
449
+ let killsAdvisory;
450
+ try {
451
+ if (spec.type === 'T-SPEC' && a.root) {
452
+ const { parseKills } = require('../../spec/kills');
453
+ const check = require('../../review/kills-check');
454
+ const kills = parseKills(candidate.frontmatter);
455
+ const aspecId = candidate.dependsOn[0];
456
+ if (kills.length > 0 && aspecId) {
457
+ const found = check.killsAdvisory(a.id, aspecId, kills, check.anchoredSourceTexts(a.root, aspecId));
458
+ if (found)
459
+ killsAdvisory = found;
460
+ }
461
+ }
462
+ }
463
+ catch {
464
+ killsAdvisory = undefined;
465
+ }
466
+ // @implements A-SPEC-663 — the REACTION ledger. Every finding this seal just reported gets a
467
+ // deterministic id; the ledger learns `issued` now and, from the next approval_status on, whether
468
+ // it was resolved, persisted or dismissed. Measured 2026-09-18: 153 issue rows across two
469
+ // advisory ledgers and no row anywhere saying what happened next — the numerator every "promote
470
+ // once the false-positive rate is known" sentence lacked. Advisory: failures drop the fields.
471
+ let advisoryIds;
472
+ let advisoryHistory;
473
+ let dismissUnknown;
474
+ try {
475
+ if (a.root) {
476
+ const ao = require('../../rtm/advisory-outcomes');
477
+ let traceGap = null;
478
+ if (spec.type === 'A-SPEC') {
479
+ try {
480
+ traceGap = ao.traceGapFor(a.id, candidate, cachedScan(a.root, a.root), a.root);
481
+ }
482
+ catch {
483
+ traceGap = null;
484
+ }
485
+ }
486
+ const { keys, ids } = ao.currentAdvisoryKeys(a.id, { impactAdvisory, anchorDensity, fttFulfilment, killsAdvisory, traceGap });
487
+ const head = ao.gitHeadOf(a.root);
488
+ const r = ao.recordAdvisoryOutcomes(a.root, a.id, keys, (s) => s.aspec === a.id, head, a.dismiss ?? []);
489
+ if (keys.length > 0)
490
+ advisoryIds = ids;
491
+ if (r.history.length > 0)
492
+ advisoryHistory = r.history;
493
+ if (a.dismiss && r.dismissUnknown.length > 0)
494
+ dismissUnknown = r.dismissUnknown;
495
+ }
496
+ }
497
+ catch {
498
+ advisoryIds = undefined;
499
+ advisoryHistory = undefined;
500
+ dismissUnknown = a.dismiss;
501
+ }
445
502
  return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}),
446
503
  ...(impactGraph ? { impactGraph } : {}), ...(impactAdvisoryUnavailable ? { impactAdvisoryUnavailable } : {}),
447
- ...(fttFulfilment ? { fttFulfilment } : {}) };
504
+ ...(fttFulfilment ? { fttFulfilment } : {}), ...(killsAdvisory ? { killsAdvisory } : {}),
505
+ ...(advisoryIds ? { advisoryIds } : {}), ...(advisoryHistory ? { advisoryHistory } : {}), ...(dismissUnknown ? { dismissUnknown } : {}) };
448
506
  },
449
507
  /**
450
508
  * Pin a REQ's citations: compute the content digest of every cited source that resolves inside
@@ -44,6 +44,10 @@ export declare function createTestExecutionHandlers(context: TestExecutionContex
44
44
  reason?: undefined;
45
45
  })[];
46
46
  survivors: import("../../spec/kills").Mutation[];
47
+ unapplied: {
48
+ where: string;
49
+ reason: string;
50
+ }[];
47
51
  };
48
52
  } | {
49
53
  calibrationClosed?: string[] | undefined;
@@ -49,6 +49,7 @@ const scope_1 = require("../../review/scope");
49
49
  const test_runner_1 = require("../../review/test-runner");
50
50
  const kills_1 = require("../../spec/kills");
51
51
  const mutate_1 = require("../../review/mutate");
52
+ const kills_check_1 = require("../../review/kills-check");
52
53
  const test_evidence_1 = require("../../review/test-evidence");
53
54
  const test_outcomes_1 = require("../../review/test-outcomes");
54
55
  const maintenance_evidence_1 = require("../maintenance-evidence");
@@ -122,8 +123,11 @@ function createTestExecutionHandlers(context) {
122
123
  const r = (0, mutate_1.runKillsOnFile)(src, m, coveringFiles, (files) => (0, test_runner_1.runJestOutcomes)(files, root));
123
124
  return { mutation: m, ...r };
124
125
  });
125
- const survivors = results.filter((r) => r.verdict === 'survived').map((r) => r.mutation);
126
- return { mutate: { tspec: a.mutate, aspec: aspecId, coveringFiles, results, survivors } };
126
+ // @implements A-SPEC-662 a mutation that never applied is UNAPPLIED, not a survivor: measured
127
+ // 2026-09-17, 22/22 entries here failed to apply and the response read `survivors: []` — the
128
+ // shape of a clean run. The pair (unapplied, survivors) now says "unverified" when it is.
129
+ const { survivors, unapplied } = (0, kills_check_1.partitionMutateResults)(results);
130
+ return { mutate: { tspec: a.mutate, aspec: aspecId, coveringFiles, results, survivors, unapplied } };
127
131
  }
128
132
  const g = new rtm_graph_1.RtmGraph();
129
133
  let testScope;
@@ -771,6 +771,9 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
771
771
  ok: boolean;
772
772
  reason: string;
773
773
  } | {
774
+ advisoryHistory?: import("../rtm/advisory-outcomes").AdvisoryState[] | undefined;
775
+ advisoryIds?: Partial<Record<string, string>> | undefined;
776
+ killsAdvisory?: import("../review/kills-check").KillsAdvisory | undefined;
774
777
  fttFulfilment?: import("../rtm/ftt-fulfilment").FttFulfilment | undefined;
775
778
  graphPreview?: {
776
779
  impact?: import("../rtm/impact-advisory").ImpactAdvisory;
@@ -824,12 +827,33 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
824
827
  ok: boolean;
825
828
  url: string;
826
829
  running: boolean;
827
- census: import("../server/dashboard-launcher").DashboardCensusExtended;
830
+ census: {
831
+ advisoryOutcomes?: import("../rtm/advisory-outcomes").AdvisoryCensus | undefined;
832
+ codeLinkedPct: number;
833
+ codeLinkedCount: number;
834
+ unlinkedCount: number;
835
+ unlinkedByReason: Record<import("../rtm/link-census").UnlinkedReason, number>;
836
+ excluded: {
837
+ total: number;
838
+ retired: number;
839
+ unmapped: number;
840
+ nonSpec: number;
841
+ };
842
+ declaredScopeByScope: Record<import("../rtm/declared-scope").DeclaredScope, number>;
843
+ unreachableDeclaringCount: number;
844
+ reqCount: number;
845
+ pipelineCount: number;
846
+ coveredCount: number;
847
+ coveragePct: number;
848
+ retiredCount: number;
849
+ findingsScanned: boolean;
850
+ };
828
851
  reason?: undefined;
829
852
  }>;
830
853
  spec_approve: (a: {
831
854
  root?: string;
832
855
  id: string;
856
+ dismiss?: string[];
833
857
  }) => Promise<{
834
858
  ok: false;
835
859
  reason: string;
@@ -846,6 +870,10 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
846
870
  reason: string;
847
871
  conflict: import("../spec/version-conflict").ConflictDetail;
848
872
  } | {
873
+ dismissUnknown?: string[] | undefined;
874
+ advisoryHistory?: import("../rtm/advisory-outcomes").AdvisoryState[] | undefined;
875
+ advisoryIds?: Partial<Record<string, string>> | undefined;
876
+ killsAdvisory?: import("../review/kills-check").KillsAdvisory | undefined;
849
877
  fttFulfilment?: import("../rtm/ftt-fulfilment").FttFulfilment | undefined;
850
878
  impactAdvisoryUnavailable?: "empty" | "unreadable" | undefined;
851
879
  impactGraph?: {
@@ -1028,6 +1056,10 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
1028
1056
  reason?: undefined;
1029
1057
  })[];
1030
1058
  survivors: import("../spec/kills").Mutation[];
1059
+ unapplied: {
1060
+ where: string;
1061
+ reason: string;
1062
+ }[];
1031
1063
  };
1032
1064
  } | {
1033
1065
  calibrationClosed?: string[] | undefined;
@@ -859,6 +859,7 @@ function makeRawHandlers(store, opts) {
859
859
  resolver,
860
860
  resolveLedgerRoot,
861
861
  fetchJson,
862
+ cachedScan, // @implements A-SPEC-663
862
863
  resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
863
864
  refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
864
865
  });
@@ -4,6 +4,7 @@ exports.ANALYZE_TEXT_CAP = exports.ANALYZE_LIST_CAP = exports.PRIMARY_TIER_WIDTH
4
4
  exports.unquoteGitPath = unquoteGitPath;
5
5
  exports.analyzeMaintenance = analyzeMaintenance;
6
6
  exports.boundAnalysis = boundAnalysis;
7
+ // @implements A-SPEC-310
7
8
  // @implements A-SPEC-295
8
9
  // @implements A-SPEC-294
9
10
  // @implements A-SPEC-293
@@ -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
  }
@@ -204,6 +204,7 @@ exports.TOOL_SCHEMAS = {
204
204
  properties: {
205
205
  root: str('Optional when the server is bound to a file store — the ledger location is derived from the store itself; if supplied it must resolve to the SAME project, otherwise the approval is refused before anything is written.'),
206
206
  id: str('Id of the spec to approve and seal.'),
207
+ dismiss: { type: 'array', items: { type: 'string' }, description: 'A-SPEC-663 — advisory ids (from approval_status.advisoryIds) the author judged not useful; recorded as `dismissed` in the reaction ledger, never blocking. Unknown ids are echoed back in `dismissUnknown`.' },
207
208
  },
208
209
  required: ['id'],
209
210
  },
@@ -0,0 +1,46 @@
1
+ export type CiStatus = 'green' | 'red' | 'clone-failed' | 'checkout-failed' | 'install-failed' | 'build-failed' | 'jest-crashed' | 'vm-unreachable';
2
+ export interface CiRun {
3
+ rev: string;
4
+ os: string;
5
+ arch?: string;
6
+ machine?: string;
7
+ node?: string;
8
+ status: CiStatus | string;
9
+ suites: number;
10
+ tests: number;
11
+ failed: string[];
12
+ durationMs: number;
13
+ at: string;
14
+ note?: string;
15
+ }
16
+ export interface CiVerdict {
17
+ os: string;
18
+ state: 'green' | 'red' | 'failed' | 'not-run';
19
+ rev?: string;
20
+ at?: string;
21
+ behind?: number;
22
+ failed: string[];
23
+ status?: string;
24
+ }
25
+ /**
26
+ * @implements A-SPEC-664 — the ADOPTION predicate: does this workspace use the CI matrix at all?
27
+ *
28
+ * Existence, not contents. A `ci-runs.<host>.jsonl` that exists but is empty means a run was
29
+ * attempted here, so silence about a commit is a signal worth saying out loud. No file at all means
30
+ * nobody ever ran the matrix in this workspace — and the runner script is not in the shipped package
31
+ * (`files` carries bin/, dist/, grammars/, playbooks/, scripts/install.ps1, docs/, CHANGELOG), so
32
+ * advising every consumer to run it would be advising a path they do not have. Measured 2026-09-18
33
+ * on a consumer-shaped tree: the Stop hook printed that advice every turn.
34
+ */
35
+ export declare function hasCiLedger(root: string): boolean;
36
+ export declare function readCiRuns(root: string): CiRun[];
37
+ /** The most recent run for an OS, by `at`. */
38
+ export declare function latestCiRun(runs: CiRun[], os?: string): CiRun | undefined;
39
+ /**
40
+ * What the matrix says about `head`. `behind(rev)` is the injected git distance from that rev to
41
+ * head (commits), or undefined when git cannot answer. No run → `not-run`; an infrastructure
42
+ * status (clone/install/build failed, VM unreachable) → `failed`, which is not red and not green.
43
+ */
44
+ export declare function ciVerdict(runs: CiRun[], os: string, head: string | undefined, behind: (rev: string) => number | undefined): CiVerdict;
45
+ /** One line for the Stop hook's tracked channel or doctor. Never says green without a green row. */
46
+ export declare function ciStatusLine(v: CiVerdict): string;