@holmes-lab/holmes-kit 0.1.18 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +5 -1
  4. package/dist/holmes/cli/approve-context.d.ts +2 -0
  5. package/dist/holmes/cli/approve-context.js +180 -0
  6. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  7. package/dist/holmes/cli/approve-ref.js +40 -0
  8. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  9. package/dist/holmes/cli/approve-watch.js +94 -0
  10. package/dist/holmes/cli/approve.d.ts +50 -13
  11. package/dist/holmes/cli/approve.js +354 -38
  12. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  13. package/dist/holmes/cli/codex-toml.js +282 -0
  14. package/dist/holmes/cli/doctor.js +206 -0
  15. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  16. package/dist/holmes/cli/gitignore-merge.js +17 -1
  17. package/dist/holmes/cli/index.d.ts +23 -0
  18. package/dist/holmes/cli/index.js +490 -21
  19. package/dist/holmes/cli/init.js +92 -0
  20. package/dist/holmes/cli/interactive-prompt.js +4 -4
  21. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  22. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  23. package/dist/holmes/cli/screen-safe.js +760 -0
  24. package/dist/holmes/governance/approval-queue.js +56 -4
  25. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  26. package/dist/holmes/governance/ledger-rechain.js +95 -0
  27. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  28. package/dist/holmes/governance/provenance-chain.js +91 -16
  29. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  30. package/dist/holmes/governance/provenance-ledger.js +10 -0
  31. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  32. package/dist/holmes/guardrail/risk-gate.js +10 -0
  33. package/dist/holmes/guardrail/write-target.js +7 -0
  34. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  35. package/dist/holmes/mcp/elicit-approval.js +79 -0
  36. package/dist/holmes/mcp/handlers.d.ts +7 -2
  37. package/dist/holmes/mcp/handlers.js +190 -24
  38. package/dist/holmes/mcp/server.js +26 -1
  39. package/dist/holmes/spec/id-collision.d.ts +39 -0
  40. package/dist/holmes/spec/id-collision.js +86 -0
  41. package/dist/holmes/spec/spec-store.js +9 -1
  42. package/package.json +1 -1
@@ -46,6 +46,7 @@ const playbook_skills_1 = require("./playbook-skills");
46
46
  const agents_1 = require("./agents");
47
47
  const roles_readme_1 = require("./roles-readme");
48
48
  const mcp_launcher_1 = require("./mcp-launcher");
49
+ const codex_toml_1 = require("./codex-toml");
49
50
  const pre_tool_use_1 = require("../hooks/pre-tool-use");
50
51
  const governed_precondition_1 = require("./governed-precondition");
51
52
  const risk_gate_1 = require("../guardrail/risk-gate");
@@ -269,6 +270,13 @@ function runInit(opts) {
269
270
  if ((0, gitignore_merge_1.hasGitignoreBlock)(gitignoreBefore)) {
270
271
  changes.push({ path: gitignorePath, before: gitignoreBefore, after: (0, gitignore_merge_1.removeGitignoreBlock)(gitignoreBefore) });
271
272
  }
273
+ // @implements A-SPEC-256.1 — remove is install's mirror: the union-attributes block this init
274
+ // wrote leaves with it (round-1 adversarial finding: it stayed behind forever).
275
+ const rmAttrsPath = path.join(opts.target, '.gitattributes');
276
+ const rmAttrsBefore = fs.existsSync(rmAttrsPath) ? fs.readFileSync(rmAttrsPath, 'utf8') : '';
277
+ if ((0, gitignore_merge_1.hasGitignoreBlock)(rmAttrsBefore)) {
278
+ changes.push({ path: rmAttrsPath, before: rmAttrsBefore, after: (0, gitignore_merge_1.removeGitignoreBlock)(rmAttrsBefore) });
279
+ }
272
280
  // round-7: the past tense fired under --dry-run too, so 'Removed' announced a removal that
273
281
  // had not happened — the one spelling whose entire purpose is to write nothing.
274
282
  messages.push(opts.dryRun
@@ -319,6 +327,13 @@ function runInit(opts) {
319
327
  const gitignoreAfter = (0, gitignore_merge_1.mergeGitignore)(gitignoreBefore);
320
328
  if (gitignoreAfter !== gitignoreBefore)
321
329
  changes.push({ path: gitignorePath, before: gitignoreBefore || null, after: gitignoreAfter });
330
+ // @implements A-SPEC-256.1 — the append-only files merge with union in every governed project,
331
+ // not only this repository: a colleague's merge losing ledger lines is the disease.
332
+ const gitattributesPath = path.join(opts.target, '.gitattributes');
333
+ const gitattributesBefore = fs.existsSync(gitattributesPath) ? fs.readFileSync(gitattributesPath, 'utf8') : '';
334
+ const gitattributesAfter = (0, gitignore_merge_1.mergeGitattributes)(gitattributesBefore);
335
+ if (gitattributesAfter !== gitattributesBefore)
336
+ changes.push({ path: gitattributesPath, before: gitattributesBefore || null, after: gitattributesAfter });
322
337
  messages.push(`mode=${opts.mode} matcher=${matcher} specs=${opts.specsDir}`);
323
338
  if (opts.mode === 'guardrail') {
324
339
  messages.push('Guardrail mode: nothing is blocked on a spec-less project; only genuinely dangerous shell commands are gated.');
@@ -329,6 +344,71 @@ function runInit(opts) {
329
344
  }
330
345
  messages.push('Restart Claude Code — hooks and MCP servers are read at session start.');
331
346
  }
347
+ // @implements A-SPEC-266 — Codex reads MCP servers from `.codex/config.toml` (TOML
348
+ // `[mcp_servers.*]`), NOT the `.codex/mcp_config.json` (JSON) this project used to write and Codex
349
+ // never loaded. That file also holds the user's other Codex settings, so — like `.mcp.json` — we
350
+ // MERGE only our table. The obsolete JSON is migrated away so no dead wiring is left behind.
351
+ let codexJsonToMigrate = null; // deleted only after the write loop confirms config.toml
352
+ {
353
+ const codexDir = path.join(opts.target, '.codex');
354
+ const codexTomlPath = path.join(codexDir, 'config.toml');
355
+ const codexJsonPath = path.join(codexDir, 'mcp_config.json');
356
+ // A `.codex/config.toml` we cannot read (a directory, a permission wall, a dangling symlink) is
357
+ // NOT touched — reading it eagerly with readFileSync threw and crashed the whole init. Mirror the
358
+ // `.mcp.json` "refusing to touch it" discipline: skip codex wiring and say so, migrate nothing.
359
+ let codexTomlBefore = null;
360
+ let codexUnreadable = false;
361
+ if (fs.existsSync(codexTomlPath)) {
362
+ try {
363
+ codexTomlBefore = fs.readFileSync(codexTomlPath, 'utf8');
364
+ }
365
+ catch {
366
+ codexUnreadable = true;
367
+ }
368
+ }
369
+ const wantsCodex = (opts.agents ?? []).includes('codex') && !codexUnreadable;
370
+ if (codexUnreadable)
371
+ messages.push(`Refusing to touch ${codexTomlPath} — it is not a readable file. Fix or remove it, then re-run.`);
372
+ // @implements A-SPEC-266 (round-2 F3) — the obsolete JSON is deleted AFTER the config.toml write is
373
+ // confirmed, never before: an eager delete followed by a failed toml write left the user with NO
374
+ // codex wiring at all (and a message claiming nothing was lost). Here we only PLAN the migration;
375
+ // the deletion runs post-write-loop (dry-run just previews it in `removals`).
376
+ const migrateAwayCodexJson = () => {
377
+ if (!fs.existsSync(codexJsonPath))
378
+ return;
379
+ if (opts.dryRun) {
380
+ removals.push(codexJsonPath);
381
+ return;
382
+ }
383
+ codexJsonToMigrate = codexJsonPath;
384
+ };
385
+ if (opts.remove) {
386
+ if (codexTomlBefore !== null) {
387
+ const after = (0, codex_toml_1.removeCodexToml)(codexTomlBefore);
388
+ if (after.trim() === '' && after !== codexTomlBefore) {
389
+ // Our table was the only content — delete the file rather than leave it empty.
390
+ if (opts.dryRun)
391
+ removals.push(codexTomlPath);
392
+ else if (fs.existsSync(codexTomlPath)) {
393
+ fs.rmSync(codexTomlPath, { force: true });
394
+ messages.push(`Removed ${codexTomlPath} (holmes-kit MCP table was its only content).`);
395
+ }
396
+ }
397
+ else if (after !== codexTomlBefore) {
398
+ changes.push({ path: codexTomlPath, before: codexTomlBefore, after });
399
+ }
400
+ }
401
+ migrateAwayCodexJson();
402
+ }
403
+ else if (wantsCodex && opts.mcp) {
404
+ const mcpBin = path.join(opts.packageRoot, 'bin', 'holmes-mcp.js');
405
+ const entry = (0, mcp_launcher_1.mcpEntryForInstall)({ packageRoot: opts.packageRoot, mcpBinPath: mcpBin, flag: opts.mcpLauncher });
406
+ const after = (0, codex_toml_1.mergeCodexToml)(codexTomlBefore, (0, codex_toml_1.codexMcpBlock)(entry, opts.specsDir));
407
+ if (after !== codexTomlBefore)
408
+ changes.push({ path: codexTomlPath, before: codexTomlBefore, after });
409
+ migrateAwayCodexJson();
410
+ }
411
+ }
332
412
  if (opts.dryRun) {
333
413
  // @implements A-SPEC-190 (round 8) — dry-run must plan the SAME set the real run touches, and
334
414
  // must not print a deletion under the verb 'would write'. Round-7 planned installs only from
@@ -408,6 +488,18 @@ function runInit(opts) {
408
488
  ] };
409
489
  }
410
490
  }
491
+ // @implements A-SPEC-266 (round-2 F3) — the config.toml write above is confirmed now, so it is
492
+ // safe to remove the obsolete JSON. Doing it earlier risked deleting codex's only wiring and then
493
+ // failing the toml write.
494
+ if (codexJsonToMigrate !== null && fs.existsSync(codexJsonToMigrate)) {
495
+ try {
496
+ fs.rmSync(codexJsonToMigrate, { force: true });
497
+ messages.push(`Removed obsolete ${codexJsonToMigrate} (Codex never read it — MCP wiring now lives in config.toml).`);
498
+ }
499
+ catch (e) {
500
+ messages.push(`Could not remove obsolete ${codexJsonToMigrate}: ${e instanceof Error ? e.message : String(e)}`);
501
+ }
502
+ }
411
503
  if (!opts.remove && opts.mode === 'governed') {
412
504
  for (const d of SPEC_SUBDIRS)
413
505
  fs.mkdirSync(path.join(opts.target, opts.specsDir, d), { recursive: true });
@@ -52,12 +52,12 @@ function parseAgentList(input) {
52
52
  /**
53
53
  * Renders an interactive TTY checkbox selection menu using standard readline & ANSI codes.
54
54
  */
55
- async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWired = ['claude']) {
55
+ async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWired = [...agents_1.AGENTS]) {
56
56
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
57
- return ['claude', 'antigravity']; // Fallback for non-TTY
57
+ return [...availableAgents]; // Fallback for non-TTY — default to all harnesses
58
58
  }
59
59
  return new Promise((resolve) => {
60
- const selected = new Set(currentWired.length ? currentWired : ['claude', 'antigravity']);
60
+ const selected = new Set(currentWired.length ? currentWired : [...availableAgents]);
61
61
  let cursor = 0;
62
62
  const items = [...availableAgents, 'all'];
63
63
  const rl = readline.createInterface({
@@ -91,7 +91,7 @@ async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWi
91
91
  }
92
92
  else if (item === 'codex') {
93
93
  isChecked = selected.has('codex');
94
- label = '💻 Codex CLI (.codex/mcp_config.json)';
94
+ label = '💻 Codex CLI (.codex/config.toml)';
95
95
  }
96
96
  const box = isChecked ? '[X]' : '[ ]';
97
97
  const line = `${prefix}${box} ${label}\n`;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * MCP 서버 배선의 launch 방식을 한 곳에서 계산한다.
3
3
  *
4
- * 왜 한 곳인가: `.mcp.json`(Claude)·`.agents/mcp_config.json`(antigravity)·`.codex/mcp_config.json`
5
- * 세 배선이 같은 서버를 띄운다. 셋이 각자 command/args 를 지으면 하나가 npx 로 옮겨갈 때 나머지가
4
+ * 왜 한 곳인가: `.mcp.json`(Claude)·`.agents/mcp_config.json`(antigravity)·`.codex/config.toml`
5
+ * (codex, TOML `[mcp_servers.holmes-kit]` — A-SPEC-266) 세 배선이 같은 서버를 띄운다. 셋이 각자 command/args 를 지으면 하나가 npx 로 옮겨갈 때 나머지가
6
6
  * 절대경로에 남아 어긋난다 — 그 드리프트가 REQ-251 자체의 출발점이었다.
7
7
  *
8
8
  * 왜 두 모드인가: 최종 사용자는 npm 설치본에서 init 하므로 `npx ...@<정확한버전> holmes-mcp` 가
@@ -0,0 +1,94 @@
1
+ /**
2
+ * One terminal row's worth of SUBJECT, with the template's own cells already subtracted: an 80
3
+ * column row, minus the widest template prefix on this screen (`\u2713 \uc2b9\uc778 \u2014 `, nine columns)
4
+ * and the kind cell (16) and its space, leaves 52. Every line the decision surface prints is bounded by it, so a queue of any size stays a
5
+ * screen the operator can read top to bottom — the property the batch confirmation depends on
6
+ * (round-5: the field was bounded and the LINE was not, so a bounded field still wrapped).
7
+ */
8
+ /** The terminal this screen promises to fit. The floor no terminal goes below. */
9
+ export declare const ROW_MAX = 80;
10
+ export declare const ROW_COLS = 50;
11
+ /**
12
+ * The `kind` cell. Round-7: at 16 the truncation NOTICE (`… (200자 잘림)`, fifteen columns) consumed
13
+ * the entire budget, so a non-ASCII kind rendered ZERO columns of the operator's text — the category
14
+ * of authority being granted, unreadable, in the list AND the item header AND the confirmation AND
15
+ * every decision echo. That is the round-5 lesson ("a bound met by showing nothing is the
16
+ * concealment this module exists to prevent") reached through a budget nobody re-checked. Twenty
17
+ * columns plus the short notice form below leaves real text on the line.
18
+ */
19
+ export declare const KIND_COLS = 20;
20
+ /**
21
+ * Did rendering this value into `max` columns hide any of it?
22
+ *
23
+ * Round-8: this used to be a REGEX OVER THE RENDERED TEXT, and the data can end with any string it
24
+ * likes — a target ending `… (5자 잘림)` was reported as clipped when nothing had been clipped, so
25
+ * `[A]` refused to batch it and told the operator, falsely, that it did not fit on a row. An agent
26
+ * could disable batch approval for a whole session by choosing its own suffix. The renderer knows
27
+ * the answer; nobody should have to parse it back out of the output.
28
+ */
29
+ export declare function isClipped(value: string, max?: number): boolean;
30
+ /**
31
+ * How many terminal COLUMNS a string occupies. Round-5 measured why a code-point budget is not a
32
+ * screen budget: a 200-code-point line of markers rendered 302 columns — four wrapped rows at 80 —
33
+ * because `⟪`, `⟫` and every Hangul character in `자 제거` are East Asian WIDE (two columns each).
34
+ * The budget exists to bound SCREEN SPACE, so it must be counted in the unit the screen uses.
35
+ */
36
+ export declare function displayWidth(s: string): number;
37
+ /**
38
+ * One line, no control bytes, bounded in terminal COLUMNS — for a field the template puts on a line
39
+ * of its own. `max` is a column budget, not a character count (round-5): the caller is buying screen
40
+ * space, and on this screen a character can cost one column or two.
41
+ *
42
+ * The budget is spent on the OPERATOR'S text: the source is clipped first so escape spam cannot
43
+ * evict the real command (round-3), and the sanitised result is clipped again so markers cannot
44
+ * inflate the output (round-4). The truncation notice is appended AFTER sanitising, so no sequence
45
+ * sitting at the clip boundary can swallow it (round-4).
46
+ */
47
+ export declare function flattenField(s: string, max?: number): string;
48
+ /**
49
+ * @implements A-SPEC-262.1 §13
50
+ * The form agent-controlled text takes on a DECISION ROW: only characters whose width is fixed by
51
+ * the standard, everything else as an ASCII escape.
52
+ *
53
+ * Eight adversarial rounds found the same shape of defect eight times — a character whose width the
54
+ * code guessed (Ambiguous, combining, zero-width), or whose cursor effect it did not model (TAB,
55
+ * ESC), or whose position let the data choose a line boundary (alignment, folding). None of them can
56
+ * occur here, because none of those characters survive to the row: a control byte is not stripped
57
+ * and counted, it is SHOWN as `\u001b`. Nothing is removed, so nothing is hidden — which also
58
+ * retires the removal marker, its forgery guard and its size accounting from this path entirely.
59
+ *
60
+ * What passes through: printable ASCII (one column each) and East_Asian_Width W or F (two columns
61
+ * each, fixed by the standard). Korean and CJK commands therefore stay readable on the screen the
62
+ * operator decides from — the requirement that made this a hybrid rather than a full escaping.
63
+ * A backslash is doubled first, so data can never spell an escape the renderer did not write.
64
+ */
65
+ export declare function rowLiteral(s: string): string;
66
+ /**
67
+ * @implements A-SPEC-262.1 §13
68
+ * A decision row's field: the row literal, cut to `max` columns, with what it could not show stated.
69
+ */
70
+ export declare function rowField(value: string, max?: number): string;
71
+ export declare function wrapColumns(s: string, cols?: number, indent?: string): string;
72
+ export declare function stripControl(s: string): string;
73
+ /**
74
+ * Clip on a CODE POINT boundary. Round-1: slicing UTF-16 units cut a surrogate pair in half and the
75
+ * last visible character of a clipped command rendered as a replacement glyph — on a screen whose
76
+ * whole job is showing the subject verbatim.
77
+ */
78
+ export declare function clipCodePoints(s: string, max: number): string;
79
+ /**
80
+ * Quote a value that is about to appear inside a COPY-PASTEABLE command line.
81
+ *
82
+ * Round-2: the non-TTY hint (A-SPEC-260) fills the real queue id into three `holmes-kit approve …`
83
+ * lines whose whole purpose is being pasted into a shell — and the id is agent-controlled. Flattening
84
+ * stops it forging lines, but a flattened `… | sh` still PIPES when pasted. POSIX single quotes make
85
+ * any byte inert; a literal quote is closed, escaped and reopened.
86
+ */
87
+ export declare function shellQuote(s: string): string;
88
+ /**
89
+ * A reference for a pasteable command line: left ALONE when it is already inert (the shape a real
90
+ * queue id has), quoted only when it is not. Round-2 wanted every pasted byte harmless; quoting
91
+ * unconditionally would also have rewritten the A-SPEC-260 hint that ships today, so the blast
92
+ * radius is kept to the case that needs it — a normal session's screen is byte-for-byte unchanged.
93
+ */
94
+ export declare function safeRef(s: string): string;