@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
@@ -0,0 +1,108 @@
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.OUTCOMES_FILE = void 0;
37
+ exports.appendOutcomes = appendOutcomes;
38
+ exports.readOutcomes = readOutcomes;
39
+ exports.buildOutcomeRecords = buildOutcomeRecords;
40
+ exports.groupOutcomesByAspec = groupOutcomesByAspec;
41
+ // @implements A-SPEC-534.3
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ exports.OUTCOMES_FILE = path.join('.ax', 'ledger', 'test-outcomes.jsonl');
45
+ /** Append outcome records as JSONL lines. Fail-open (recording must never break a run). */
46
+ function appendOutcomes(root, records) {
47
+ try {
48
+ const file = path.join(root, exports.OUTCOMES_FILE);
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ fs.appendFileSync(file, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers). */
58
+ function readOutcomes(root) {
59
+ let text;
60
+ try {
61
+ text = fs.readFileSync(path.join(root, exports.OUTCOMES_FILE), 'utf8');
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ const out = [];
67
+ for (const line of text.split('\n')) {
68
+ const s = line.trim();
69
+ if (!s)
70
+ continue;
71
+ try {
72
+ const r = JSON.parse(s);
73
+ if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
74
+ && typeof r.ts === 'string' && typeof r.head === 'string') {
75
+ out.push(r);
76
+ }
77
+ }
78
+ catch { /* skip a corrupt line rather than fail the whole read */ }
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * @implements A-SPEC-534.5
84
+ * Expand per-file outcomes into per-A-SPEC records via the anchor map, stamping each with the run's
85
+ * head and ts. A file with no anchor (or an empty anchor list) contributes nothing — an outcome that
86
+ * cannot be attributed to an A-SPEC is not evidence about one. Pure.
87
+ */
88
+ function buildOutcomeRecords(outcomeByFile, anchors, head, ts) {
89
+ const out = [];
90
+ for (const [file, outcome] of Object.entries(outcomeByFile)) {
91
+ for (const aspec of anchors[file] ?? [])
92
+ out.push({ aspec, outcome, ts, head });
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * Group outcomes by A-SPEC, keeping only records stamped with the given baseline `head` — a stale
98
+ * record from another commit cannot vouch for the current work (the isFresh discipline). Pure.
99
+ */
100
+ function groupOutcomesByAspec(records, head) {
101
+ const out = {};
102
+ for (const r of records) {
103
+ if (r.head !== head)
104
+ continue;
105
+ (out[r.aspec] ??= []).push({ outcome: r.outcome, ts: r.ts });
106
+ }
107
+ return out;
108
+ }
@@ -32,6 +32,12 @@ export interface TestRunResult {
32
32
  unsupported?: string[];
33
33
  /** Which adapters actually ran, for evidence provenance. */
34
34
  ranWith?: string[];
35
+ /**
36
+ * @implements A-SPEC-534.5
37
+ * Per-file RED-first outcome (jest only). red-assertion | red-error | green, for the outcome ledger
38
+ * ART-8 reads. Other ecosystems do not classify outcomes yet, so they contribute nothing here.
39
+ */
40
+ outcomeByFile?: Record<string, TestOutcome>;
35
41
  }
36
42
  export type Ecosystem = 'jest' | 'pytest' | 'go' | 'rust' | 'java' | 'dotnet';
37
43
  /** Ecosystem of a test file, by extension/convention. `null` when no runner adapter exists for it. */
@@ -51,6 +57,24 @@ export declare function parseGoTestJson(out: string): Record<string, number>;
51
57
  * fabrications cannot produce evidence. Returns {} for unparseable output (caller falls back).
52
58
  */
53
59
  export declare function parseExecutedCounts(stdout: string, cwd: string): Record<string, number>;
60
+ /**
61
+ * Per-file test outcome for RED-first evidence (REQ-534). `red-assertion` is a REAL red — a case ran
62
+ * and its assertion failed; `red-error` is a file that could not run its cases (load/collection/import
63
+ * error), which ART-8 (534.2) must NOT accept as a valid RED; `green` ran and all assertions passed.
64
+ */
65
+ export type TestOutcome = 'red-assertion' | 'red-error' | 'green';
66
+ /**
67
+ * @implements A-SPEC-534.1
68
+ * Classify each jest-executed file into red-assertion | red-error | green from `jest --json` stdout.
69
+ * Pure; unparseable/empty input → {} (same fail-soft convention as parseExecutedCounts).
70
+ *
71
+ * A failed assertion anywhere in a file wins (`red-assertion`) — that is a real red. A file that ran
72
+ * NO assertions but errored (testExecError, or status 'failed' with an empty assertion list — a
73
+ * load/collection/import failure) is `red-error`, which ART-8 must not accept as a valid RED. A file
74
+ * that executed ≥1 assertion with none failing is `green`. A file that only pended/skipped carries no
75
+ * evidence and is omitted entirely.
76
+ */
77
+ export declare function classifyJestOutcomes(stdout: string, cwd: string): Record<string, TestOutcome>;
54
78
  /**
55
79
  * Parse pytest's built-in JUnit XML into per-file EXECUTED case counts — the pytest analogue of
56
80
  * parseExecutedCounts. `--junit-xml` ships with pytest core, so this needs no plugin, and it is the
@@ -75,6 +99,12 @@ export declare function parseJUnitXmlCounts(xml: string, ext?: string): Record<s
75
99
  * there), and a repo with no local jest could never yield meaningful suite evidence anyway.
76
100
  */
77
101
  export declare function jestEntry(cwd: string): string | null;
102
+ /**
103
+ * @implements A-SPEC-534.8
104
+ * Run specific jest files and return only their RED-first outcomes — the runner `test_run --mutate`
105
+ * injects into runKillsOnFile. Scoped, jest only (the classifier is jest-only), fail-soft to {}.
106
+ */
107
+ export declare function runJestOutcomes(files: string[], cwd: string): Record<string, TestOutcome>;
78
108
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
79
109
  export declare function runPytest(files: string[], mode: TestRunPlan['mode'], cwd: string): {
80
110
  passed: boolean;
@@ -37,8 +37,10 @@ exports.planTestRun = planTestRun;
37
37
  exports.ecosystemOf = ecosystemOf;
38
38
  exports.parseGoTestJson = parseGoTestJson;
39
39
  exports.parseExecutedCounts = parseExecutedCounts;
40
+ exports.classifyJestOutcomes = classifyJestOutcomes;
40
41
  exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
41
42
  exports.jestEntry = jestEntry;
43
+ exports.runJestOutcomes = runJestOutcomes;
42
44
  exports.runPytest = runPytest;
43
45
  exports.parseCargoTest = parseCargoTest;
44
46
  exports.runCargo = runCargo;
@@ -153,9 +155,57 @@ function parseExecutedCounts(stdout, cwd) {
153
155
  const abs = tr.name ?? tr.testFilePath ?? '';
154
156
  if (!abs)
155
157
  continue;
156
- const rel = abs.startsWith(cwd) ? abs.slice(cwd.length).replace(/^[/\\]/, '') : abs;
157
158
  const ran = (tr.assertionResults ?? []).filter((a) => a.status === 'passed' || a.status === 'failed').length;
158
- out[rel.split('\\').join('/')] = ran;
159
+ out[relTestPath(abs, cwd)] = ran;
160
+ }
161
+ return out;
162
+ }
163
+ /** Repo-relative, forward-slashed key for a jest test-file path — shared by the parsers below. */
164
+ function relTestPath(abs, cwd) {
165
+ const rel = abs.startsWith(cwd) ? abs.slice(cwd.length).replace(/^[/\\]/, '') : abs;
166
+ return rel.split('\\').join('/');
167
+ }
168
+ /**
169
+ * @implements A-SPEC-534.1
170
+ * Classify each jest-executed file into red-assertion | red-error | green from `jest --json` stdout.
171
+ * Pure; unparseable/empty input → {} (same fail-soft convention as parseExecutedCounts).
172
+ *
173
+ * A failed assertion anywhere in a file wins (`red-assertion`) — that is a real red. A file that ran
174
+ * NO assertions but errored (testExecError, or status 'failed' with an empty assertion list — a
175
+ * load/collection/import failure) is `red-error`, which ART-8 must not accept as a valid RED. A file
176
+ * that executed ≥1 assertion with none failing is `green`. A file that only pended/skipped carries no
177
+ * evidence and is omitted entirely.
178
+ */
179
+ function classifyJestOutcomes(stdout, cwd) {
180
+ const start = stdout.indexOf('{');
181
+ if (start < 0)
182
+ return {};
183
+ let data;
184
+ try {
185
+ data = JSON.parse(stdout.slice(start));
186
+ }
187
+ catch {
188
+ return {};
189
+ }
190
+ const out = {};
191
+ for (const tr of data?.testResults ?? []) {
192
+ const abs = tr.name ?? tr.testFilePath ?? '';
193
+ if (!abs)
194
+ continue;
195
+ const rel = relTestPath(abs, cwd);
196
+ const assertions = tr.assertionResults ?? [];
197
+ if (assertions.some((a) => a.status === 'failed')) {
198
+ out[rel] = 'red-assertion';
199
+ continue;
200
+ }
201
+ const executed = assertions.filter((a) => a.status === 'passed' || a.status === 'failed').length;
202
+ const errored = !!tr.testExecError || tr.status === 'failed';
203
+ if (executed === 0) {
204
+ if (errored)
205
+ out[rel] = 'red-error'; // ran nothing AND failed → could not execute its cases
206
+ continue; // otherwise all pending/skipped → no evidence, omit
207
+ }
208
+ out[rel] = errored ? 'red-error' : 'green';
159
209
  }
160
210
  return out;
161
211
  }
@@ -248,14 +298,25 @@ function runJest(files, mode, cwd) {
248
298
  // @implements A-SPEC-515.1 — the green path says what happened instead of handing back the
249
299
  // whole `--json` document. `tailOf` wraps the result too, so an unexpectedly long summary is
250
300
  // still bounded: a cap that any one path can escape is not a cap.
251
- return { passed: true, tail: tailOf(summarizeJestJson(out) ?? out), executed: parseExecutedCounts(out, cwd) };
301
+ return { passed: true, tail: tailOf(summarizeJestJson(out) ?? out), executed: parseExecutedCounts(out, cwd), outcomes: classifyJestOutcomes(out, cwd) };
252
302
  }
253
303
  catch (e) {
254
304
  const err = e;
255
- // A failing suite still emits --json on stdout, so execution evidence survives a red run.
256
- return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: parseExecutedCounts(err.stdout ?? '', cwd) };
305
+ // A failing suite still emits --json on stdout, so execution evidence and the red/green outcome
306
+ // classification (A-SPEC-534.1) survives a red run.
307
+ return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: parseExecutedCounts(err.stdout ?? '', cwd), outcomes: classifyJestOutcomes(err.stdout ?? '', cwd) };
257
308
  }
258
309
  }
310
+ /**
311
+ * @implements A-SPEC-534.8
312
+ * Run specific jest files and return only their RED-first outcomes — the runner `test_run --mutate`
313
+ * injects into runKillsOnFile. Scoped, jest only (the classifier is jest-only), fail-soft to {}.
314
+ */
315
+ function runJestOutcomes(files, cwd) {
316
+ if (files.length === 0)
317
+ return {};
318
+ return runJest(files, 'scoped', cwd).outcomes;
319
+ }
259
320
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
260
321
  // @implements A-SPEC-502.1 — exported for the wiring test (a fake venv python capturing argv).
261
322
  function runPytest(files, mode, cwd) {
@@ -592,12 +653,16 @@ function runTestScope(scope, cwd) {
592
653
  let passed = true;
593
654
  const tails = [];
594
655
  const executedByFile = {};
656
+ const outcomeByFile = {}; // @implements A-SPEC-534.5 — jest only
595
657
  for (const eco of [...groups.keys()].sort()) {
596
658
  const r = runners[eco](groups.get(eco), plan.mode, cwd);
597
659
  ran.push(eco);
598
660
  passed = passed && r.passed;
599
661
  tails.push(`[${eco}] ${r.tail}`);
600
662
  Object.assign(executedByFile, r.executed);
663
+ const adapterOutcomes = r.outcomes;
664
+ if (adapterOutcomes)
665
+ Object.assign(outcomeByFile, adapterOutcomes);
601
666
  // @implements A-SPEC-137.1 — an adapter whose toolchain is absent reports its files as
602
667
  // unsupported; merge them so a scope that could not run them never reads as fully covered.
603
668
  const adapterUnsupported = r.unsupported;
@@ -613,6 +678,7 @@ function runTestScope(scope, cwd) {
613
678
  return {
614
679
  tier: scope.tier, mode: plan.mode, ranFiles: plan.testFiles, passed, skipped: false,
615
680
  tail: tails.join('\n'), executedByFile, ranWith: ran,
681
+ ...(Object.keys(outcomeByFile).length ? { outcomeByFile } : {}),
616
682
  ...(unsupported.length ? { unsupported } : {}),
617
683
  };
618
684
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A discriminating-power mutation (REQ-534 C-layer): a named change to production source that a
3
+ * covering test SHOULD turn red. Minimal DSL — a literal `where` replaced by `mutate`. `test_run
4
+ * --mutate` applies each and confirms the covering cases go red-assertion; a mutation that kills
5
+ * nothing is a coverage gap. Deliberately small; widen the DSL only when a real case needs it.
6
+ */
7
+ export interface Mutation {
8
+ where: string;
9
+ mutate: string;
10
+ }
11
+ /** Extract well-formed {where,mutate} entries from `kills`. Lenient: anything else yields []. Pure. */
12
+ export declare function parseKills(frontmatter: Record<string, unknown>): Mutation[];
13
+ /** Replace the FIRST occurrence of `where` with `mutate`. null when `where` is empty or absent. Pure. */
14
+ export declare function applyMutation(source: string, m: Mutation): string | null;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseKills = parseKills;
4
+ exports.applyMutation = applyMutation;
5
+ /** Extract well-formed {where,mutate} entries from `kills`. Lenient: anything else yields []. Pure. */
6
+ function parseKills(frontmatter) {
7
+ const raw = frontmatter.kills;
8
+ if (!Array.isArray(raw))
9
+ return [];
10
+ const out = [];
11
+ for (const item of raw) {
12
+ if (item && typeof item === 'object'
13
+ && typeof item.where === 'string'
14
+ && typeof item.mutate === 'string') {
15
+ out.push({ where: item.where, mutate: item.mutate });
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+ /** Replace the FIRST occurrence of `where` with `mutate`. null when `where` is empty or absent. Pure. */
21
+ function applyMutation(source, m) {
22
+ if (!m.where)
23
+ return null;
24
+ const i = source.indexOf(m.where);
25
+ if (i < 0)
26
+ return null;
27
+ return source.slice(0, i) + m.mutate + source.slice(i + m.where.length);
28
+ }
@@ -59,6 +59,15 @@ export interface SpecStore {
59
59
  * exist" while looking straight at the file. The verdict stays; the silence does not.
60
60
  */
61
61
  export declare function unreadableSpecFiles(specsDir: string): string[];
62
+ /**
63
+ * @implements A-SPEC-536.1
64
+ * The "not found" reason, enriched when the store is silently dropping unparseable files. BUG-1's
65
+ * silent-loss bit at the tool surface: a spec whose YAML frontmatter is broken is dropped by list()/
66
+ * read() and every handler answered a bare "spec <id> not found", hiding that the file EXISTS but
67
+ * cannot be parsed. When `unreadable` is empty the string is byte-identical to the legacy message
68
+ * (no regression); otherwise it names the skipped files and the usual cause.
69
+ */
70
+ export declare function notFoundReason(id: string, unreadable: readonly string[]): string;
62
71
  export declare class LocalMarkdownRepository implements SpecStore {
63
72
  private readonly root;
64
73
  constructor(root: string);
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MemorySpecStore = exports.LocalMarkdownRepository = exports.TargetPathOccupiedError = exports.SpecVersionConflictError = void 0;
37
37
  exports.unreadableSpecFiles = unreadableSpecFiles;
38
+ exports.notFoundReason = notFoundReason;
38
39
  const node_crypto_1 = require("node:crypto");
39
40
  const fs = __importStar(require("node:fs"));
40
41
  const path = __importStar(require("node:path"));
@@ -118,6 +119,22 @@ function unreadableSpecFiles(specsDir) {
118
119
  walk(specsDir);
119
120
  return out.sort();
120
121
  }
122
+ /**
123
+ * @implements A-SPEC-536.1
124
+ * The "not found" reason, enriched when the store is silently dropping unparseable files. BUG-1's
125
+ * silent-loss bit at the tool surface: a spec whose YAML frontmatter is broken is dropped by list()/
126
+ * read() and every handler answered a bare "spec <id> not found", hiding that the file EXISTS but
127
+ * cannot be parsed. When `unreadable` is empty the string is byte-identical to the legacy message
128
+ * (no regression); otherwise it names the skipped files and the usual cause.
129
+ */
130
+ function notFoundReason(id, unreadable) {
131
+ const base = `spec ${id} not found`;
132
+ if (unreadable.length === 0)
133
+ return base;
134
+ return `${base} — but ${unreadable.length} spec file(s) failed to parse and were skipped `
135
+ + `(a malformed one may be this spec): ${unreadable.join(', ')}. `
136
+ + `Check the YAML frontmatter (an unquoted ':' in a value is the usual cause).`;
137
+ }
121
138
  class LocalMarkdownRepository {
122
139
  root;
123
140
  constructor(root) {
@@ -257,6 +257,24 @@ function validateSpec(spec, resolve) {
257
257
  if (!def.parents.includes(parent.type))
258
258
  err('wrong-parent-type', `parent ${pid} is ${parent.type}, expected ${def.parents.join('|')}`);
259
259
  }
260
+ // @implements A-SPEC-534.7 — optional `kills` (discriminating-power mutations). Absent → no check
261
+ // (every existing T-SPEC is unaffected); present → it must be an array of {string where, string
262
+ // mutate}. A malformed shape is always an error, independent of status.
263
+ if (spec.type === 'T-SPEC' && spec.frontmatter.kills != null) {
264
+ const raw = spec.frontmatter.kills;
265
+ if (!Array.isArray(raw)) {
266
+ err('kills-shape', 'kills must be an array of {where, mutate}');
267
+ }
268
+ else {
269
+ raw.forEach((item, i) => {
270
+ const ok = !!item && typeof item === 'object'
271
+ && typeof item.where === 'string'
272
+ && typeof item.mutate === 'string';
273
+ if (!ok)
274
+ err('kills-shape', `kills[${i}] must be {where: string, mutate: string}`);
275
+ });
276
+ }
277
+ }
260
278
  // gate: T-SPEC 4-quadrant coverage on approve attempt
261
279
  if (spec.type === 'T-SPEC' && spec.status === 'approved') {
262
280
  const cov = (spec.frontmatter.coverage ?? {});
@@ -0,0 +1,28 @@
1
+ export declare const NPM_URL = "https://www.npmjs.com/package/@holmes-lab/holmes-kit";
2
+ /** How this install was wired — decides how (or whether) to phrase the update command. */
3
+ export type InstallMode = 'global-npx' | 'local-dep' | 'source';
4
+ export interface UpdateCache {
5
+ latest: string;
6
+ checkedAt: number;
7
+ }
8
+ export interface BannerInput {
9
+ current: string;
10
+ cached: UpdateCache | null;
11
+ mode: InstallMode;
12
+ npmUrl: string;
13
+ }
14
+ /** -1 if a<b, 0 if equal, 1 if a>b — NUMERIC per field, so 0.9.0 < 0.10.0 (a string compare fails). */
15
+ export declare function compareSemver(a: string, b: string): -1 | 0 | 1;
16
+ /** The cache file, read only — no network, no write. Anything unreadable/malformed/mis-shaped → null. */
17
+ export declare function readCache(home: string, readFile: (p: string) => string): UpdateCache | null;
18
+ /** The one-line update guidance, branched by install mode. `source` returns null (git updates it). */
19
+ export declare function installModeGuide(mode: InstallMode, latest: string, current: string): string | null;
20
+ /**
21
+ * The banner: always an English intro line (version + governance rule + npm page). If the cache
22
+ * knows a newer version AND the install mode has an update path, a second guidance line follows.
23
+ */
24
+ export declare function composeBanner(input: BannerInput): string;
25
+ /** Whether a network refresh is allowed at all. Opt-out via HOLMES_NO_UPDATE_CHECK or CI. */
26
+ export declare function shouldQuery(env: NodeJS.ProcessEnv): boolean;
27
+ /** Whether the cache is old enough to refresh. A missing cache is stale. */
28
+ export declare function cacheIsStale(cached: UpdateCache | null, now: number, ttlMs?: number): boolean;
@@ -0,0 +1,131 @@
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.NPM_URL = void 0;
37
+ exports.compareSemver = compareSemver;
38
+ exports.readCache = readCache;
39
+ exports.installModeGuide = installModeGuide;
40
+ exports.composeBanner = composeBanner;
41
+ exports.shouldQuery = shouldQuery;
42
+ exports.cacheIsStale = cacheIsStale;
43
+ // @implements A-SPEC-531.1
44
+ // The session banner and update notice, as a PURE core. Every side channel — the filesystem, the
45
+ // clock, the environment — is a parameter, so the whole module verifies offline. The delivery
46
+ // points (SessionStart hook, MCP instructions, init wiring) consume this in A-SPEC-531.2.
47
+ //
48
+ // No new dependency: semver comparison is a three-integer compare (our versions are plain
49
+ // major.minor.patch — a full semver library would carry prerelease/build code this never runs), and
50
+ // the registry query (A-SPEC-531.2) uses Node's built-in https.
51
+ const path = __importStar(require("node:path"));
52
+ exports.NPM_URL = 'https://www.npmjs.com/package/@holmes-lab/holmes-kit';
53
+ const DEFAULT_TTL_MS = 24 * 3600_000;
54
+ /** Parse `major.minor.patch` to a 3-tuple; a non-numeric field becomes 0 (never throws). */
55
+ function triple(v) {
56
+ const parts = String(v).split('.');
57
+ const n = (i) => { const x = Number.parseInt(parts[i] ?? '', 10); return Number.isFinite(x) ? x : 0; };
58
+ return [n(0), n(1), n(2)];
59
+ }
60
+ /** -1 if a<b, 0 if equal, 1 if a>b — NUMERIC per field, so 0.9.0 < 0.10.0 (a string compare fails). */
61
+ function compareSemver(a, b) {
62
+ const x = triple(a);
63
+ const y = triple(b);
64
+ for (let i = 0; i < 3; i++) {
65
+ if (x[i] < y[i])
66
+ return -1;
67
+ if (x[i] > y[i])
68
+ return 1;
69
+ }
70
+ return 0;
71
+ }
72
+ /** The cache file, read only — no network, no write. Anything unreadable/malformed/mis-shaped → null. */
73
+ function readCache(home, readFile) {
74
+ let raw;
75
+ try {
76
+ raw = readFile(path.join(home, '.holmes', 'update-check.json'));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!raw)
82
+ return null;
83
+ try {
84
+ const v = JSON.parse(raw);
85
+ if (v && typeof v === 'object'
86
+ && typeof v.latest === 'string'
87
+ && typeof v.checkedAt === 'number') {
88
+ return { latest: v.latest, checkedAt: v.checkedAt };
89
+ }
90
+ return null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /** The one-line update guidance, branched by install mode. `source` returns null (git updates it). */
97
+ function installModeGuide(mode, latest, current) {
98
+ const head = `[Holmes-Kit] Update available: ${latest} (current ${current}).`;
99
+ switch (mode) {
100
+ case 'global-npx':
101
+ return `${head} Run: npm i -g @holmes-lab/holmes-kit@latest, then holmes-kit init --force (re-pins wiring; requires HOLMES_APPROVAL).`;
102
+ case 'local-dep':
103
+ return `${head} Run: npm i -D @holmes-lab/holmes-kit@latest`;
104
+ case 'source':
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * The banner: always an English intro line (version + governance rule + npm page). If the cache
110
+ * knows a newer version AND the install mode has an update path, a second guidance line follows.
111
+ */
112
+ function composeBanner(input) {
113
+ const intro = `[Holmes-Kit] This session is governed by Holmes-Kit v${input.current} (No Spec, No Code) — ${input.npmUrl}`;
114
+ const latest = input.cached?.latest;
115
+ if (latest && compareSemver(latest, input.current) > 0) {
116
+ const guide = installModeGuide(input.mode, latest, input.current);
117
+ if (guide)
118
+ return `${intro}\n${guide}`;
119
+ }
120
+ return intro;
121
+ }
122
+ /** Whether a network refresh is allowed at all. Opt-out via HOLMES_NO_UPDATE_CHECK or CI. */
123
+ function shouldQuery(env) {
124
+ return !env.HOLMES_NO_UPDATE_CHECK && !env.CI;
125
+ }
126
+ /** Whether the cache is old enough to refresh. A missing cache is stale. */
127
+ function cacheIsStale(cached, now, ttlMs = DEFAULT_TTL_MS) {
128
+ if (!cached)
129
+ return true;
130
+ return now - cached.checkedAt > ttlMs;
131
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.7.1",
4
+ "version": "0.9.0",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: holmes-tdd-slice
3
+ description: >-
4
+ Use when writing or changing code under a Holmes-Kit gate — the built-in TDD discipline. Reach for
5
+ it when the gate says "Approved specification required before code modification", or when the Stop
6
+ hook prints an "ART-8 RED-first" observation. Restates the Iron Law and Red-Green-Refactor in holmes
7
+ terms and tags each rule with the article that enforces it (ART-1 no-spec-no-code, ART-4 coverage
8
+ honesty, ART-8 RED-first evidence), so you know which rules the gate checks and which are
9
+ honor-system. Key trap: a RED must be a red-assertion, not a red-error.
10
+ ---
11
+
12
+ # tdd-slice
13
+
14
+ 테스트를 먼저 쓰고, **실패를 눈으로 본 뒤**, 통과시키는 최소 코드를 쓴다. 이것은 권고가 아니다 —
15
+ holmes-kit은 이 규율의 상당 부분을 **헌법 조항으로 집행**한다. 그래서 이 스킬의 각 규칙 옆에는
16
+ 그것을 강제하는 조항이 붙어 있다: `[ART-N]`은 게이트가 막는 것, `[명예제]`는 네 판단에 맡기는 것.
17
+ 합리화로 빠져나갈 수 있는 규칙과 없는 규칙을 구별하라 — 없는 것은 정말 없다.
18
+
19
+ ## Iron Law
20
+
21
+ **승인된 T-SPEC이 코드보다 먼저 있어야 하고, 그 커버 테스트가 코드 전에 RED로 관측돼야 한다.**
22
+
23
+ - `[ART-1]` No Spec, No Code — 소스는 승인된 A-SPEC(+그것을 depends_on 하는 승인된 T-SPEC) 아래서만
24
+ 바뀐다. 게이트가 코드 쓰기를 막는다. `promote-slice`가 그 승격을 다룬다.
25
+ - `[ART-8]` 테스트-우선은 가정이 아니라 **관측**된다 — 소스가 바뀐 A-SPEC은 원장에
26
+ `red-assertion`(코드 전) → `green`(코드 후) 시퀀스를 남겨야 한다. 코드 뒤에 써서 즉시 통과한
27
+ 테스트는 실패를 관측한 적이 없으니 아무것도 증명하지 못한다. 기본은 `track`(관측), 오너가
28
+ 실측 후 `strict`(차단)로 올린다(config `redFirstEvidence`).
29
+
30
+ ## Red-Green-Refactor (holmes 판)
31
+
32
+ 1. **RED** — 커버 케이스를 `test_run`으로 돌려 **실패를 본다**. 이 실패는 `red-assertion`(케이스가
33
+ 실행되고 어서션이 실패)이어야 한다.
34
+ 2. **GREEN** — 통과시키는 **최소** 코드를 쓴다. `test_run`이 `green`을 기록한다.
35
+ 3. **REFACTOR** — green을 유지한 채 정리한다. 원장은 그대로다.
36
+
37
+ `test_run`이 매 실행마다 커버 파일별 outcome을 분류(`red-assertion`/`red-error`/`green`)해 원장에
38
+ 남긴다 — 이것이 ART-8이 읽는 증거다.
39
+
40
+ ## RED은 red-assertion 이어야 한다 (가장 흔한 함정)
41
+
42
+ `[ART-8]` **`red-error`는 유효 RED가 아니다.** 심볼 부재로 인한 컴파일 실패, 임포트 오류,
43
+ 수집(collection) 실패는 케이스가 **실행조차 못 된** 상태다 — 그것은 "실패를 올바른 이유로
44
+ 관측했다"가 아니다. 함수가 아직 없어 컴파일이 깨지면, **틀린 값을 반환하는 스텁**을 먼저 넣어
45
+ 어서션이 붉어지게(=`red-assertion`) 만든 뒤 구현하라. superpowers가 "fails *correctly*"라 부른 것을
46
+ holmes는 기계적으로 판별한다: `red-error`로는 red→green 시퀀스가 성립하지 않는다.
47
+
48
+ ## 좋은 테스트의 규칙
49
+
50
+ - `[ART-4]` 선언한 커버리지는 **실제 앵커된 테스트 케이스**로 뒷받침돼야 한다 — 산문이 아니다.
51
+ T-SPEC이 커버를 선언하면 그 A-SPEC을 `@implements`로 앵커한 테스트 파일에 실제 케이스가 있고
52
+ 실행돼야 한다. 없으면 게이트가 막는다.
53
+ - `[ART-4]` 앵커는 **파일당 첫 줄**에 둔다 — 스캐너가 파일당 첫 `@implements`만 잡는다. 기존
54
+ 파일의 비-첫줄에 새 A-SPEC 앵커를 넣으면 ART-4에 안 보인다. 새 테스트는 새 파일로.
55
+ - `[명예제]` mock이 아니라 **실제 동작**을 단언하라. mock의 호출 여부를 재는 테스트는 제품이 아니라
56
+ 네 mock을 시험한다. holmes의 T-SPEC 4분면(normal/negative/corner/boundary)은 관측 가능한 동작을
57
+ 요구한다.
58
+ - `[명예제]` 테스트를 쓰기 전에 **그 테스트를 실패시킬 프로덕션 변경을 한 문장으로 말하라**. 말할
59
+ 수 없다면 그 테스트는 무엇도 지키지 못한다. (C층 `kills:`가 이를 스펙 필드로 승격한다.)
60
+
61
+ ## 집행 요약
62
+
63
+ | 규칙 | 집행 |
64
+ |---|---|
65
+ | 스펙 없이 코드 없음 | `[ART-1]` PreToolUse 게이트 |
66
+ | 커버 테스트가 실재·실행 | `[ART-4]` Stop 헌법 |
67
+ | 코드 전 red-assertion→green | `[ART-8]` Stop 헌법(track/strict) |
68
+ | mock 아닌 실동작·판별력 | `[명예제]` (C층 `kills:`로 선택 검증) |
69
+
70
+ ## 절차
71
+
72
+ 1. `promote-slice`로 대상 A-SPEC과 그 T-SPEC을 승인한다(`[ART-1]` 게이트를 연다).
73
+ 2. 커버 테스트를 **먼저** 쓴다. 심볼이 없어 컴파일이 깨지면 틀린-값 스텁을 넣는다.
74
+ 3. `test_run` — **red-assertion**을 본다. `red-error`면 그건 아직 RED가 아니다; 스텁으로 고쳐라.
75
+ 4. 통과시키는 최소 코드를 쓴다.
76
+ 5. `test_run` — `green`. 원장에 red→green이 남는다(`[ART-8]` 증거).
77
+ 6. green 유지하며 refactor.
78
+
79
+ ## 검증
80
+
81
+ `src/holmes/playbooks/tdd-slice.test.ts` — 이 스킬이 집행 태그(ART-1/4/8)와 red-assertion/red-error
82
+ 구별을 담고, 설치기가 이를 발견함을 고정한다. 상시 스위트 포함.