@holmes-lab/holmes-kit 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +10 -6
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.js +21 -11
  5. package/dist/holmes/cli/doctor.js +25 -2
  6. package/dist/holmes/cli/init.js +3 -0
  7. package/dist/holmes/cli/mcp-schema-cost.d.ts +18 -0
  8. package/dist/holmes/cli/mcp-schema-cost.js +28 -0
  9. package/dist/holmes/cli/settings-merge.d.ts +1 -0
  10. package/dist/holmes/cli/settings-merge.js +6 -1
  11. package/dist/holmes/config/config.d.ts +8 -0
  12. package/dist/holmes/config/config.js +1 -1
  13. package/dist/holmes/governance/autonomy.d.ts +14 -0
  14. package/dist/holmes/governance/autonomy.js +75 -0
  15. package/dist/holmes/governance/constitution.d.ts +26 -0
  16. package/dist/holmes/governance/constitution.js +33 -0
  17. package/dist/holmes/guardrail/write-target.d.ts +25 -0
  18. package/dist/holmes/guardrail/write-target.js +143 -0
  19. package/dist/holmes/hooks/pre-tool-use.js +131 -48
  20. package/dist/holmes/hooks/session-start.d.ts +23 -0
  21. package/dist/holmes/hooks/session-start.js +111 -0
  22. package/dist/holmes/hooks/stop.d.ts +24 -0
  23. package/dist/holmes/hooks/stop.js +83 -3
  24. package/dist/holmes/mcp/handlers.d.ts +19 -0
  25. package/dist/holmes/mcp/handlers.js +126 -21
  26. package/dist/holmes/mcp/server-instructions.d.ts +8 -0
  27. package/dist/holmes/mcp/server-instructions.js +13 -0
  28. package/dist/holmes/mcp/server.js +21 -1
  29. package/dist/holmes/mcp/tool-schemas.js +1 -0
  30. package/dist/holmes/review/mutate.d.ts +17 -0
  31. package/dist/holmes/review/mutate.js +66 -0
  32. package/dist/holmes/review/test-outcomes.d.ts +35 -0
  33. package/dist/holmes/review/test-outcomes.js +108 -0
  34. package/dist/holmes/review/test-runner.d.ts +30 -0
  35. package/dist/holmes/review/test-runner.js +71 -5
  36. package/dist/holmes/spec/kills.d.ts +14 -0
  37. package/dist/holmes/spec/kills.js +28 -0
  38. package/dist/holmes/spec/spec-store.d.ts +9 -0
  39. package/dist/holmes/spec/spec-store.js +17 -0
  40. package/dist/holmes/spec/validator.js +18 -0
  41. package/dist/holmes/update/update-notice.d.ts +28 -0
  42. package/dist/holmes/update/update-notice.js +131 -0
  43. package/package.json +1 -1
  44. package/playbooks/tdd-slice/PLAYBOOK.md +82 -0
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
+ exports.changedAnchoredAspecs = changedAnchoredAspecs;
37
38
  exports.unanchoredChangedSources = unanchoredChangedSources;
38
39
  exports.unrecordedApprovals = unrecordedApprovals;
39
40
  exports.rolledBackLedgers = rolledBackLedgers;
@@ -56,10 +57,50 @@ const test_scope_1 = require("../rtm/test-scope");
56
57
  const constitution_1 = require("../governance/constitution");
57
58
  const provenance_chain_1 = require("../governance/provenance-chain");
58
59
  const test_evidence_1 = require("../review/test-evidence");
60
+ const test_outcomes_1 = require("../review/test-outcomes");
61
+ const config_1 = require("../config/config");
59
62
  const pre_tool_use_1 = require("./pre-tool-use");
60
63
  const governance_history_1 = require("../guardrail/governance-history");
61
64
  const constitution_debt_1 = require("../governance/constitution-debt");
62
65
  const root_1 = require("../project/root");
66
+ /**
67
+ * @implements A-SPEC-534.4
68
+ * ART-8 evidence (I/O half): the A-SPECs whose DIRTY source files carry an @implements anchor. git is
69
+ * a refinement — no repository means `undefined` (no signal), never a false clean.
70
+ */
71
+ function changedAnchoredAspecs(root) {
72
+ const SOURCE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift)$/;
73
+ const VENDORED = /^(?:reference|node_modules|dist|build|vendor|third_party)\//;
74
+ let raw;
75
+ try {
76
+ raw = (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-uall'], {
77
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
78
+ });
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ const ids = new Set();
84
+ for (const line of raw.split('\n')) {
85
+ if (line.trim() === '')
86
+ continue;
87
+ let rel = line.slice(3).trim().replace(/^"|"$/g, '');
88
+ if (rel.includes(' -> '))
89
+ rel = rel.split(' -> ')[1]; // renames name the destination
90
+ if (!SOURCE.test(rel) || VENDORED.test(rel))
91
+ continue;
92
+ let text;
93
+ try {
94
+ text = fs.readFileSync(path.join(root, rel), 'utf8');
95
+ }
96
+ catch {
97
+ continue;
98
+ } // deleted/unreadable
99
+ for (const m of text.matchAll(/@implements\s+(A-SPEC-\d+(?:\.\d+)?)/g))
100
+ ids.add(m[1]);
101
+ }
102
+ return [...ids].sort();
103
+ }
63
104
  /**
64
105
  * @implements A-SPEC-452
65
106
  * ART-1 evidence: which changed source files claim nothing.
@@ -248,7 +289,21 @@ function evaluateStop(specs, evidence) {
248
289
  // L1: the Stop gate IS the constitution's re-verification point — every turn boundary re-runs the
249
290
  // inviolable articles (ART-2 RTM, ART-3 validity incl. 4-quadrant GWT, ART-4 coverage evidence).
250
291
  // The articles live in ONE place (governance/constitution.ts); this gate merely executes them.
251
- const violations = (0, constitution_1.verifyConstitution)({ specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings });
292
+ // @implements A-SPEC-534.4 ART-8 evidence rides through to the constitution, which emits a
293
+ // BLOCKING ART-8 violation only in `strict` mode. `track`/`off` produce none here.
294
+ const violations = (0, constitution_1.verifyConstitution)({
295
+ specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings,
296
+ redFirstMode: evidence?.redFirstMode, changedAspecs: evidence?.changedAspecs, outcomesByAspec: evidence?.outcomesByAspec,
297
+ });
298
+ // @implements A-SPEC-534.4 — `track` records ART-8 findings without blocking the turn. Computed
299
+ // separately (the constitution stays silent on ART-8 outside strict) and returned in `tracked` for
300
+ // the CLI to record; it never enters `problems`/the block decision.
301
+ let tracked;
302
+ if (evidence?.redFirstMode === 'track' && evidence.changedAspecs && evidence.outcomesByAspec) {
303
+ const t = (0, constitution_1.redFirstViolations)(evidence.changedAspecs, evidence.outcomesByAspec);
304
+ if (t.length)
305
+ tracked = t;
306
+ }
252
307
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
253
308
  // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
254
309
  // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
@@ -287,7 +342,7 @@ function evaluateStop(specs, evidence) {
287
342
  structured.push({ article: 'ART-2', detail });
288
343
  }
289
344
  if (problems.length === 0)
290
- return { block: false };
345
+ return { block: false, ...(tracked ? { tracked } : {}) };
291
346
  const shown = problems.slice(0, 20);
292
347
  const more = problems.length > shown.length ? `\n…and ${problems.length - shown.length} more` : '';
293
348
  // @implements A-SPEC-134 — the distinct articles feed the constitution-debt state on a cap-yield.
@@ -301,6 +356,7 @@ function evaluateStop(specs, evidence) {
301
356
  block: true,
302
357
  articles,
303
358
  violations: structured,
359
+ ...(tracked ? { tracked } : {}),
304
360
  reason: `[Holmes-Kit] constitution gate: ${problems.length} article violation(s) must be ` +
305
361
  `fixed before finishing:\n${shown.join('\n')}${more}`,
306
362
  };
@@ -656,6 +712,25 @@ if (require.main === module) {
656
712
  catch {
657
713
  executedByAspec = undefined;
658
714
  }
715
+ // @implements A-SPEC-534.4 — ART-8 RED-first evidence (I/O half). Outcomes recorded at the
716
+ // current baseline HEAD (where the dirty work's red+green ran); changed anchored A-SPECs from
717
+ // the working tree; the posture from config. Fail-open: any error leaves ART-8 inert this turn.
718
+ let redFirstMode;
719
+ let changedAspecs;
720
+ let outcomesByAspec;
721
+ try {
722
+ redFirstMode = (0, config_1.loadConfig)(stopProjectRoot()).guardrail.redFirstEvidence;
723
+ if (redFirstMode !== 'off') {
724
+ const head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: stopProjectRoot(), stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).toString().trim();
725
+ changedAspecs = changedAnchoredAspecs(stopProjectRoot());
726
+ outcomesByAspec = (0, test_outcomes_1.groupOutcomesByAspec)((0, test_outcomes_1.readOutcomes)(stopProjectRoot()), head);
727
+ }
728
+ }
729
+ catch {
730
+ redFirstMode = undefined;
731
+ changedAspecs = undefined;
732
+ outcomesByAspec = undefined;
733
+ }
659
734
  // Provenance-chain verification (fail-open: a verify error skips the check, never crashes).
660
735
  let provenance;
661
736
  // @implements A-SPEC-148
@@ -690,7 +765,12 @@ if (require.main === module) {
690
765
  const unrecorded = unrecordedApprovals(stopProjectRoot());
691
766
  // @implements A-SPEC-455
692
767
  const rolledBack = rolledBackLedgers(stopProjectRoot());
693
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack });
768
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec });
769
+ // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
770
+ // the operator observes RED-first gaps before an owner promotes the posture to strict.
771
+ if (out.tracked && out.tracked.length > 0) {
772
+ process.stderr.write(`[Holmes-Kit] ART-8 RED-first (track): ${out.tracked.map((t) => t.detail).join(' | ')}\n`);
773
+ }
694
774
  // @implements A-SPEC-247 — before deciding to re-block, ask whether every unresolved debt is
695
775
  // already queued for the owner. If so, tell the user ONCE and let the turn finish; a single
696
776
  // non-waiting violation and we block exactly as before.
@@ -347,7 +347,25 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
347
347
  head?: string;
348
348
  since?: string;
349
349
  mark?: string;
350
+ mutate?: string;
350
351
  }): Promise<{
352
+ mutate: {
353
+ tspec: string;
354
+ aspec: string | undefined;
355
+ coveringFiles: string[];
356
+ results: ({
357
+ mutation: import("../spec/kills").Mutation;
358
+ applied: boolean;
359
+ reason: string;
360
+ } | {
361
+ applied: boolean;
362
+ verdict?: "killed" | "survived";
363
+ mutation: import("../spec/kills").Mutation;
364
+ reason?: undefined;
365
+ })[];
366
+ survivors: import("../spec/kills").Mutation[];
367
+ };
368
+ } | {
351
369
  baselineRecorded?: string | undefined;
352
370
  scopeFallback?: "full" | undefined;
353
371
  tier: import("../rtm/test-scope").RegressionTier;
@@ -359,6 +377,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
359
377
  tail: string;
360
378
  unresolvedFiles: string[];
361
379
  changeSource: ChangeSourceInfo;
380
+ mutate?: undefined;
362
381
  }>;
363
382
  issue_localize(a: {
364
383
  root: string;
@@ -68,7 +68,10 @@ const rtm_check_1 = require("../rtm/rtm-check");
68
68
  const test_scope_1 = require("../rtm/test-scope");
69
69
  const gap_analyzer_1 = require("../rtm/gap-analyzer");
70
70
  const test_runner_1 = require("../review/test-runner");
71
+ const kills_1 = require("../spec/kills");
72
+ const mutate_1 = require("../review/mutate");
71
73
  const test_evidence_1 = require("../review/test-evidence");
74
+ const test_outcomes_1 = require("../review/test-outcomes");
72
75
  const localize_1 = require("../rtm/localize");
73
76
  const maintenance_analyze_1 = require("./maintenance-analyze");
74
77
  const maintenance_evidence_1 = require("./maintenance-evidence");
@@ -90,6 +93,53 @@ const root_1 = require("../project/root");
90
93
  // under that tree stopped at the minted marker. This is the harm §8 closed for `review_record`,
91
94
  // left open on the scan path. When the store is bound, the cache is the bound project's; otherwise
92
95
  // only an anchored answer may be written to, and an unanchored one falls back to a temp cache.
96
+ // @implements A-SPEC-534.8
97
+ // The governed source file a `kills` mutation targets: one that @implements the A-SPEC AND contains
98
+ // the `where` literal. Bounded walk, skips vendored/test/hidden dirs. null when none qualifies.
99
+ function sourceFileWithMutation(root, aspecId, where) {
100
+ if (!where)
101
+ return null;
102
+ const SOURCE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift)$/;
103
+ let found = null;
104
+ const walk = (d) => {
105
+ if (found)
106
+ return;
107
+ let entries;
108
+ try {
109
+ entries = fs.readdirSync(d, { withFileTypes: true });
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ for (const e of entries) {
115
+ if (found)
116
+ return;
117
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'reference'
118
+ || e.name === 'vendor' || e.name === 'third_party' || e.name.startsWith('.'))
119
+ continue;
120
+ const p = path.join(d, e.name);
121
+ if (e.isDirectory()) {
122
+ walk(p);
123
+ continue;
124
+ }
125
+ if (!SOURCE.test(e.name) || /\.test\./.test(e.name))
126
+ continue;
127
+ let text;
128
+ try {
129
+ text = fs.readFileSync(p, 'utf8');
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (text.includes(`@implements ${aspecId}`) && text.includes(where)) {
135
+ found = p;
136
+ return;
137
+ }
138
+ }
139
+ };
140
+ walk(root);
141
+ return found;
142
+ }
93
143
  const cacheDirFor = (root) => {
94
144
  const r = (0, root_2.resolveProjectRoot)(root);
95
145
  if (r.marker !== 'given')
@@ -130,6 +180,7 @@ const package_1 = require("../review/package");
130
180
  const risk_classifier_1 = require("../guardrail/risk-classifier");
131
181
  const risk_gate_1 = require("../guardrail/risk-gate");
132
182
  const elicit_approval_1 = require("./elicit-approval");
183
+ const autonomy_1 = require("../governance/autonomy");
133
184
  const anchor_comment_1 = require("../rtm/anchor-comment");
134
185
  const consistency_lints_1 = require("../cpg/consistency-lints");
135
186
  const approval_queue_1 = require("../governance/approval-queue");
@@ -669,6 +720,15 @@ function makeRawHandlers(store, opts) {
669
720
  token: crypto.randomUUID(),
670
721
  rationale: reason ?? 'elicitation grant',
671
722
  });
723
+ // @implements A-SPEC-532.2 — the autonomous channel: the SAME synthesized-Approval shape that
724
+ // rides the existing seal path, but its actor names `autonomous:<client>` so an audit can tell a
725
+ // self-approved seal from a human-approved (elicitation) or operator (env/grant) one. Single-use
726
+ // by construction — it exists only inside this call, persisted nowhere.
727
+ const autonomousApproval = () => ({
728
+ actor: `autonomous:${opts?.clientName?.() ?? 'unknown'}`,
729
+ token: crypto.randomUUID(),
730
+ rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto)`,
731
+ });
672
732
  /**
673
733
  * Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
674
734
  *
@@ -1134,26 +1194,38 @@ function makeRawHandlers(store, opts) {
1134
1194
  if (approveResolved === undefined) {
1135
1195
  const target = await store.read(a.id).catch(() => null);
1136
1196
  if (target) {
1137
- const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
1138
- // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
1139
- // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
1140
- // more consequential act) as a first approval. The cap cuts the title, never the marker.
1141
- const out = await tryElicit('spec-approve', a.id, `${a.id} ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1142
- if (out.kind === 'answered' && out.decision.granted) {
1143
- approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
1197
+ // @implements A-SPEC-532.2 the autonomous gate sits BEFORE the human ask: when the
1198
+ // out-of-band autonomy switch is on AND the spec is low/mid-risk (never gate-behavior, an
1199
+ // architecture/taint file, or an upstream REQ/H/C those stay human), the agent seals it
1200
+ // itself. The switch is env-only and an agent cannot set it (pre-tool-use blocks that,
1201
+ // A-SPEC-532.2). Off, or a hitl-classed spec, falls straight through to the elicitor
1202
+ // unchanged the autonomous-OFF path is byte-identical to before.
1203
+ if ((0, autonomy_1.autonomousApprovalEnabled)(process.env)
1204
+ && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec])) === 'auto') {
1205
+ approveResolved = { approval: autonomousApproval(), source: 'autonomous' };
1144
1206
  }
1145
- else if (out.kind === 'answered') {
1146
- // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
1147
- // entry is filed a decided request is not a pending one (REQ-246 visibility).
1148
- return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 다시 시도하십시오.` };
1149
- }
1150
- else if (out.kind === 'expired') {
1151
- // @implements A-SPEC-497.1 only the expiry earns a name: the notice LEADS the same
1152
- // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1153
- // message learned to say what happened.
1154
- elicitExpiredMs = out.waitedMs;
1207
+ else {
1208
+ const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
1209
+ // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
1210
+ // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
1211
+ // more consequential act) as a first approval. The cap cuts the title, never the marker.
1212
+ const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1213
+ if (out.kind === 'answered' && out.decision.granted) {
1214
+ approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
1215
+ }
1216
+ else if (out.kind === 'answered') {
1217
+ // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
1218
+ // entry is filed — a decided request is not a pending one (REQ-246 visibility).
1219
+ return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1220
+ }
1221
+ else if (out.kind === 'expired') {
1222
+ // @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
1223
+ // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1224
+ // message learned to say what happened.
1225
+ elicitExpiredMs = out.waitedMs;
1226
+ }
1227
+ // silent: the channel gave no answer — fall through to the byte-identical refusal.
1155
1228
  }
1156
- // silent: the channel gave no answer — fall through to the byte-identical refusal.
1157
1229
  }
1158
1230
  }
1159
1231
  if (approveResolved === undefined) {
@@ -1237,8 +1309,15 @@ function makeRawHandlers(store, opts) {
1237
1309
  // element instead leaves a window in which an external edit is silently destroyed and the
1238
1310
  // STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
1239
1311
  const cur = await store.read(a.id);
1240
- if (!cur)
1241
- return { ok: false, reason: `spec ${a.id} not found` };
1312
+ // @implements A-SPEC-536.1 — BUG-1: a spec whose YAML is broken is dropped by read()/list(),
1313
+ // so a bare "not found" hid that the file EXISTS but cannot be parsed. Surface the skipped
1314
+ // files when there are any; byte-identical to the legacy message when there are none. The
1315
+ // store's specsRoot is read through the same cast the reachability checks use (A-SPEC-169).
1316
+ if (!cur) {
1317
+ const dir = store.specsRoot;
1318
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1319
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1320
+ }
1242
1321
  const spec = cur.spec;
1243
1322
  // @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
1244
1323
  // PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
@@ -1807,6 +1886,26 @@ function makeRawHandlers(store, opts) {
1807
1886
  // Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
1808
1887
  // (what the constitution's ART-4 prefers over the syntactic count).
1809
1888
  const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
1889
+ // @implements A-SPEC-534.8 — `mutate`: run a T-SPEC's declared `kills` mutations against the
1890
+ // A-SPEC's source and report which SURVIVED (a discriminating-power gap). Selective, opt-in via
1891
+ // the argument; absent → the ordinary run below is untouched.
1892
+ if (a.mutate) {
1893
+ const tspec = specs.find((s) => s.id === a.mutate && s.type === 'T-SPEC');
1894
+ const kills = tspec ? (0, kills_1.parseKills)(tspec.frontmatter) : [];
1895
+ const aspecId = tspec?.dependsOn[0];
1896
+ const anchors = (0, test_scope_1.scanTestAnchors)(root);
1897
+ const coveringFiles = aspecId
1898
+ ? Object.entries(anchors).filter(([, ids]) => ids.includes(aspecId)).map(([f]) => f) : [];
1899
+ const results = kills.map((m) => {
1900
+ const src = aspecId ? sourceFileWithMutation(root, aspecId, m.where) : null;
1901
+ if (!src)
1902
+ return { mutation: m, applied: false, reason: 'no governed source anchors this A-SPEC and contains `where`' };
1903
+ const r = (0, mutate_1.runKillsOnFile)(src, m, coveringFiles, (files) => (0, test_runner_1.runJestOutcomes)(files, root));
1904
+ return { mutation: m, ...r };
1905
+ });
1906
+ const survivors = results.filter((r) => r.verdict === 'survived').map((r) => r.mutation);
1907
+ return { mutate: { tspec: a.mutate, aspec: aspecId, coveringFiles, results, survivors } };
1908
+ }
1810
1909
  const g = new rtm_graph_1.RtmGraph();
1811
1910
  let testScope;
1812
1911
  try {
@@ -1847,6 +1946,12 @@ function makeRawHandlers(store, opts) {
1847
1946
  if (verified) {
1848
1947
  (0, test_evidence_1.writeTestEvidence)(root, { ts: new Date().toISOString(), head, tier: testScope.tier, passed: true, executedByAspec });
1849
1948
  }
1949
+ // @implements A-SPEC-534.5 — RED-first outcome evidence for ART-8. Recorded UNCONDITIONALLY,
1950
+ // unlike the green-only baseline above: a red-assertion recorded before the code is exactly what
1951
+ // the red→green sequence needs. Append-only, one record per (A-SPEC, outcome) at this HEAD.
1952
+ if (result.outcomeByFile) {
1953
+ (0, test_outcomes_1.appendOutcomes)(root, (0, test_outcomes_1.buildOutcomeRecords)(result.outcomeByFile, anchors, head, new Date().toISOString()));
1954
+ }
1850
1955
  // @implements A-SPEC-128
1851
1956
  // The baseline is written under EXACTLY the condition that already gates evidence: a run that
1852
1957
  // actually executed and passed. A red or skipped run must never become the reference point for
@@ -3038,7 +3143,7 @@ id: ${aspecId}
3038
3143
  type: A-SPEC
3039
3144
  title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
3040
3145
  status: draft
3041
- slice: ${a.sliceName}
3146
+ slice: ${(0, yaml_scalar_1.yamlScalar)(a.sliceName)}
3042
3147
  priority: P1
3043
3148
  independent_test: true
3044
3149
  depends_on:
@@ -0,0 +1,8 @@
1
+ import { InstallMode } from '../update/update-notice';
2
+ export interface ServerInstructionsInput {
3
+ version: string;
4
+ home: string;
5
+ mode: InstallMode;
6
+ readFile: (p: string) => string;
7
+ }
8
+ export declare function buildServerInstructions(input: ServerInstructionsInput): string;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildServerInstructions = buildServerInstructions;
4
+ // @implements A-SPEC-531.2
5
+ // The MCP server's `instructions` banner. A harness with no SessionStart hook (codex, antigravity)
6
+ // still gets the intro (+ optional update notice) delivered to the agent through the server's
7
+ // initialize response. PURE and fail-soft — a cache read failure yields the intro only, and never
8
+ // throws, so server construction is never blocked by the banner.
9
+ const update_notice_1 = require("../update/update-notice");
10
+ function buildServerInstructions(input) {
11
+ const cached = (0, update_notice_1.readCache)(input.home, input.readFile); // readCache already swallows a throw → null
12
+ return (0, update_notice_1.composeBanner)({ current: input.version, cached, mode: input.mode, npmUrl: update_notice_1.NPM_URL });
13
+ }
@@ -54,7 +54,27 @@ const PKG_VERSION = (() => {
54
54
  return '0.0.0';
55
55
  }
56
56
  })();
57
- const server = new index_js_1.Server({ name: 'holmes-kit', version: PKG_VERSION }, { capabilities: { tools: {} } });
57
+ // @implements A-SPEC-531.2 the banner rides `instructions` so a harness without a SessionStart
58
+ // hook still delivers the intro (+ update notice) to the agent. Fail-soft: any failure omits it and
59
+ // the server starts normally.
60
+ const SERVER_INSTRUCTIONS = (() => {
61
+ try {
62
+ const { buildServerInstructions } = require('./server-instructions');
63
+ const { detectInstallMode } = require('../hooks/session-start');
64
+ const os = require('node:os');
65
+ const fs = require('node:fs');
66
+ return buildServerInstructions({
67
+ version: PKG_VERSION,
68
+ home: os.homedir(),
69
+ mode: detectInstallMode(require('node:path').resolve(__dirname, '..', '..', '..')),
70
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
71
+ });
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ })();
77
+ const server = new index_js_1.Server({ name: 'holmes-kit', version: PKG_VERSION }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
58
78
  const fullProfile = process.env.HOLMES_MCP_PROFILE === 'full';
59
79
  // Register each handler as a tool with its real typed inputSchema so MCP
60
80
  // clients can marshal complex (array/object) arguments; fall back to a
@@ -203,6 +203,7 @@ exports.TOOL_SCHEMAS = {
203
203
  head: str('Head rev (e.g. HEAD). Optional — omit to compare against a baseline.'),
204
204
  since: str('Baseline label to compare against when no git range is given (default `last-green`, recorded by a passing test_run). Works with or without version control.'),
205
205
  mark: str('Baseline label to record when the run passes (default `last-green`). Nothing is recorded for a red or skipped run.'),
206
+ mutate: str('T-SPEC id (e.g. T-SPEC-129.1). When set, runs that spec’s declared `kills` mutations against the A-SPEC’s source and reports which SURVIVED — a discriminating-power gap. Selective, opt-in; the ordinary run is skipped.'),
206
207
  },
207
208
  required: ['root'],
208
209
  },
@@ -0,0 +1,17 @@
1
+ import { Mutation } from '../spec/kills';
2
+ import { TestOutcome } from './test-runner';
3
+ /**
4
+ * Did the mutation KILL the covering tests? A kill is a `red-assertion` in at least one covering
5
+ * file — the mutation broke behaviour and a test caught it. A green (or absent, or red-error) result
6
+ * is `survived`: the tests did not catch the change, which is a discriminating-power gap. Pure.
7
+ */
8
+ export declare function mutationVerdict(outcomeByFile: Record<string, TestOutcome>, coveringFiles: string[]): 'killed' | 'survived';
9
+ /**
10
+ * Apply one mutation to a source file, run the covering tests through the injected runner, judge the
11
+ * verdict, and ALWAYS restore the original source. `applied:false` when `where` is absent (the file
12
+ * is never touched).
13
+ */
14
+ export declare function runKillsOnFile(sourceFile: string, mutation: Mutation, coveringFiles: string[], run: (files: string[]) => Record<string, TestOutcome>): {
15
+ applied: boolean;
16
+ verdict?: 'killed' | 'survived';
17
+ };
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.mutationVerdict = mutationVerdict;
37
+ exports.runKillsOnFile = runKillsOnFile;
38
+ // @implements A-SPEC-534.8
39
+ const fs = __importStar(require("node:fs"));
40
+ const kills_1 = require("../spec/kills");
41
+ /**
42
+ * Did the mutation KILL the covering tests? A kill is a `red-assertion` in at least one covering
43
+ * file — the mutation broke behaviour and a test caught it. A green (or absent, or red-error) result
44
+ * is `survived`: the tests did not catch the change, which is a discriminating-power gap. Pure.
45
+ */
46
+ function mutationVerdict(outcomeByFile, coveringFiles) {
47
+ return coveringFiles.some((f) => outcomeByFile[f] === 'red-assertion') ? 'killed' : 'survived';
48
+ }
49
+ /**
50
+ * Apply one mutation to a source file, run the covering tests through the injected runner, judge the
51
+ * verdict, and ALWAYS restore the original source. `applied:false` when `where` is absent (the file
52
+ * is never touched).
53
+ */
54
+ function runKillsOnFile(sourceFile, mutation, coveringFiles, run) {
55
+ const original = fs.readFileSync(sourceFile, 'utf8');
56
+ const mutated = (0, kills_1.applyMutation)(original, mutation);
57
+ if (mutated === null)
58
+ return { applied: false }; // where absent: never touch the file
59
+ fs.writeFileSync(sourceFile, mutated);
60
+ try {
61
+ return { applied: true, verdict: mutationVerdict(run(coveringFiles), coveringFiles) };
62
+ }
63
+ finally {
64
+ fs.writeFileSync(sourceFile, original); // ALWAYS restore, even if run() threw
65
+ }
66
+ }
@@ -0,0 +1,35 @@
1
+ import { TestOutcome } from './test-runner';
2
+ export declare const OUTCOMES_FILE: string;
3
+ /**
4
+ * A durable, append-only record of a per-A-SPEC test outcome (REQ-534 RED-first evidence). Unlike
5
+ * `test-evidence.json` (overwritten each run), the SEQUENCE matters here — a red-assertion followed
6
+ * by a green is what ART-8 reads — so outcomes accumulate. `head` stamps the baseline commit the
7
+ * run sat on; the red (before code) and green (after code) of one slice share it while the tree is
8
+ * still dirty, which is exactly when the Stop gate reads them.
9
+ */
10
+ export interface OutcomeRecord {
11
+ aspec: string;
12
+ outcome: TestOutcome;
13
+ ts: string;
14
+ head: string;
15
+ testFileDigest?: string;
16
+ }
17
+ /** Append outcome records as JSONL lines. Fail-open (recording must never break a run). */
18
+ export declare function appendOutcomes(root: string, records: OutcomeRecord[]): boolean;
19
+ /** Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers). */
20
+ export declare function readOutcomes(root: string): OutcomeRecord[];
21
+ /**
22
+ * @implements A-SPEC-534.5
23
+ * Expand per-file outcomes into per-A-SPEC records via the anchor map, stamping each with the run's
24
+ * head and ts. A file with no anchor (or an empty anchor list) contributes nothing — an outcome that
25
+ * cannot be attributed to an A-SPEC is not evidence about one. Pure.
26
+ */
27
+ export declare function buildOutcomeRecords(outcomeByFile: Record<string, TestOutcome>, anchors: Record<string, string[]>, head: string, ts: string): OutcomeRecord[];
28
+ /**
29
+ * Group outcomes by A-SPEC, keeping only records stamped with the given baseline `head` — a stale
30
+ * record from another commit cannot vouch for the current work (the isFresh discipline). Pure.
31
+ */
32
+ export declare function groupOutcomesByAspec(records: OutcomeRecord[], head: string): Record<string, Array<{
33
+ outcome: TestOutcome;
34
+ ts: string;
35
+ }>>;