@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.
- package/CHANGELOG.md +112 -0
- package/README.md +8 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +38 -0
- package/dist/holmes/cli/agents.js +106 -4
- package/dist/holmes/cli/doctor.d.ts +1 -0
- package/dist/holmes/cli/doctor.js +42 -0
- package/dist/holmes/cli/index.js +1 -0
- package/dist/holmes/cli/init.js +18 -0
- package/dist/holmes/cpg/language-parser-walk.js +1 -0
- package/dist/holmes/hooks/stop.d.ts +7 -0
- package/dist/holmes/hooks/stop.js +54 -1
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +29 -1
- package/dist/holmes/mcp/handlers/operator-inspection.js +68 -2
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +5 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +59 -1
- package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
- package/dist/holmes/mcp/handlers/test-execution.js +6 -2
- package/dist/holmes/mcp/handlers.d.ts +33 -1
- package/dist/holmes/mcp/handlers.js +1 -0
- package/dist/holmes/mcp/maintenance-analyze.js +1 -0
- package/dist/holmes/mcp/supervisor.d.ts +35 -0
- package/dist/holmes/mcp/supervisor.js +105 -2
- package/dist/holmes/mcp/tool-schemas.js +1 -0
- package/dist/holmes/project/ci-runs.d.ts +46 -0
- package/dist/holmes/project/ci-runs.js +137 -0
- package/dist/holmes/project/install-scripts-policy.js +1 -0
- package/dist/holmes/review/evaluation-metrics.js +1 -0
- package/dist/holmes/review/kills-check.d.ts +40 -0
- package/dist/holmes/review/kills-check.js +147 -0
- package/dist/holmes/review/manual-baseline.js +1 -0
- package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
- package/dist/holmes/rtm/advisory-outcomes.js +314 -0
- package/dist/holmes/rtm/declared-scope.d.ts +28 -0
- package/dist/holmes/rtm/declared-scope.js +60 -0
- package/dist/holmes/rtm/rtm-graph.js +1 -0
- package/dist/holmes/rtm/taint-benchmark.js +1 -0
- package/dist/holmes/server/dashboard-launcher.d.ts +7 -0
- package/dist/holmes/server/dashboard-launcher.js +3 -0
- package/dist/holmes/server/dashboard.js +14 -1
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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:
|
|
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;
|