@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.
- package/CHANGELOG.md +32 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.js +5 -1
- package/dist/holmes/cli/approve-context.d.ts +2 -0
- package/dist/holmes/cli/approve-context.js +180 -0
- package/dist/holmes/cli/approve-ref.d.ts +27 -0
- package/dist/holmes/cli/approve-ref.js +40 -0
- package/dist/holmes/cli/approve-watch.d.ts +29 -0
- package/dist/holmes/cli/approve-watch.js +94 -0
- package/dist/holmes/cli/approve.d.ts +50 -13
- package/dist/holmes/cli/approve.js +354 -38
- package/dist/holmes/cli/codex-toml.d.ts +26 -0
- package/dist/holmes/cli/codex-toml.js +282 -0
- package/dist/holmes/cli/doctor.js +206 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
- package/dist/holmes/cli/gitignore-merge.js +17 -1
- package/dist/holmes/cli/index.d.ts +23 -0
- package/dist/holmes/cli/index.js +490 -21
- package/dist/holmes/cli/init.js +92 -0
- package/dist/holmes/cli/interactive-prompt.js +4 -4
- package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
- package/dist/holmes/cli/screen-safe.d.ts +94 -0
- package/dist/holmes/cli/screen-safe.js +760 -0
- package/dist/holmes/governance/approval-queue.js +56 -4
- package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
- package/dist/holmes/governance/ledger-rechain.js +95 -0
- package/dist/holmes/governance/provenance-chain.d.ts +33 -6
- package/dist/holmes/governance/provenance-chain.js +91 -16
- package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
- package/dist/holmes/governance/provenance-ledger.js +10 -0
- package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
- package/dist/holmes/guardrail/risk-gate.js +10 -0
- package/dist/holmes/guardrail/write-target.js +7 -0
- package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
- package/dist/holmes/mcp/elicit-approval.js +79 -0
- package/dist/holmes/mcp/handlers.d.ts +7 -2
- package/dist/holmes/mcp/handlers.js +190 -24
- package/dist/holmes/mcp/server.js +26 -1
- package/dist/holmes/spec/id-collision.d.ts +39 -0
- package/dist/holmes/spec/id-collision.js +86 -0
- package/dist/holmes/spec/spec-store.js +9 -1
- package/package.json +1 -1
package/dist/holmes/cli/init.js
CHANGED
|
@@ -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 = [
|
|
55
|
+
async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWired = [...agents_1.AGENTS]) {
|
|
56
56
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
57
|
-
return [
|
|
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 : [
|
|
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/
|
|
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/
|
|
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;
|