@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
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isBrokenPipe = void 0;
36
37
  exports.packageRoot = packageRoot;
37
38
  exports.main = main;
39
+ exports.installPipeGuard = installPipeGuard;
38
40
  // @implements A-SPEC-100.2
39
41
  // @implements A-SPEC-213
40
42
  // @implements A-SPEC-215
@@ -74,7 +76,8 @@ const KNOWN_FLAGS = {
74
76
  skills: ['help', 'target'],
75
77
  ci: ['help', 'target', 'specs-dir', 'json'],
76
78
  serve: ['help', 'target', 'port'],
77
- approve: ['help', 'target', 'list', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale'],
79
+ approve: ['help', 'target', 'list', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale', 'watch', 'poll-ms'],
80
+ ledger: ['help', 'target', 'ref', 'dry-run'],
78
81
  };
79
82
  // @implements A-SPEC-171 — subcommands that render their OWN usage on `--help`. A-SPEC-171 governs
80
83
  // "help before any side effect"; a command whose usage lives past this handler (approve, whose
@@ -84,9 +87,11 @@ const KNOWN_FLAGS = {
84
87
  const SELF_HELP_COMMANDS = new Set(['approve']);
85
88
  const APPROVE_USAGE = `holmes-kit approve — 승인 대기 요청의 결재 (HITL)
86
89
 
87
- 대화형(TTY): 항목마다 [a]승인 [d]거부 [q]질문 [s]건너뛰기
90
+ 대화형(TTY): 항목마다 [a]승인 [e]사유·기간 지정 승인 [v]상세 [d]거부 [q]질문 [s]건너뛰기 [A]남은 전체 승인
91
+ --watch 상주 모드 — 요청이 도착하는 즉시 결정, 큐가 비면 대기 (Ctrl-C 로 종료, --poll-ms <밀리초>)
88
92
  --list 대기 목록만 출력
89
- --grant <id> 해당 요청의 좁은 그랜트 발급 (--ttl <분>, --rationale <문구>)
93
+ --grant <참조> 해당 요청의 좁은 그랜트 발급 (--ttl <분>, --rationale <문구>)
94
+ 참조 = 목록 번호(1,2,…) | id | id 접두 — 긴 id를 옮겨 적을 필요가 없습니다
90
95
  --deny <id> --reason <문구> 거부 — 사유가 에이전트의 다음 거부 문면에 실립니다
91
96
  --ask <id> --question <문구> 질문 남기고 보류 — 에이전트가 사용자에게 전달합니다
92
97
  --target <dir> 프로젝트 루트 (기본: 현재 디렉터리)
@@ -99,7 +104,7 @@ class UnknownFlagError extends Error {
99
104
  * what you'd do" spelling performed a REAL write (round-3 HIGH; same class inverted --remove and
100
105
  * --force). A boolean flag must never consume a token.
101
106
  */
102
- const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list']);
107
+ const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'watch']);
103
108
  function parseFlags(argv, known) {
104
109
  const flags = {};
105
110
  const positionals = [];
@@ -180,12 +185,19 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
180
185
  --remove remove holmes-kit wiring (holmes-owned entries only)
181
186
  --force re-wire an already-wired target (requires HOLMES_APPROVAL)
182
187
 
188
+ holmes-kit ledger rechain re-chain forked ledger files against the committed mainline
189
+ --target <dir> project to repair (default: cwd)
190
+ --ref <git-ref> mainline ref (default: HEAD; walks first-parent to the first
191
+ verifying committed version)
192
+ --dry-run report what would be re-chained, write nothing
193
+
183
194
  holmes-kit approve review pending approval requests (HITL)
184
- (no flags, TTY) interactive: [a]승인 [d]거부 [q]질문 [s]건너뛰기 per request
195
+ (no flags, TTY) interactive: [a]승인 [e]지정승인 [v]상세 [d]거부 [q]질문 [s]건너뛰기 [A]전체승인
185
196
  --list print the pending queue and exit
186
- --grant <id> [--ttl <min>] [--rationale <text>] mint the narrow grant for one request
187
- --deny <id> --reason <text> record a denial the agent will see
188
- --ask <id> --question <text> hold with a question the agent relays
197
+ --grant <ref> [--ttl <min>] [--rationale <text>] mint the narrow grant for one request
198
+ --deny <ref> --reason <text> record a denial the agent will see
199
+ --ask <ref> --question <text> hold with a question the agent relays
200
+ <ref> list index (1,2,…), full id, or a unique id prefix
189
201
  --target <dir> project root (default: cwd)
190
202
 
191
203
  holmes-kit doctor [flags] diagnose the install
@@ -201,6 +213,7 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
201
213
  holmes-kit --version | --help
202
214
  `;
203
215
  async function main(argv) {
216
+ installPipeGuard(); // @implements A-SPEC-262.1 — cover the shipped bin, not just direct entry (round-11)
204
217
  const cmd = argv[0];
205
218
  // @implements A-SPEC-190 (round 7) — `holmes-kit constructor` resolved through the prototype
206
219
  // chain to a truthy non-array and crashed with an unhandled TypeError instead of saying
@@ -233,7 +246,7 @@ async function main(argv) {
233
246
  }
234
247
  // Positionals these commands do not take are refused, not dropped: `skills refresh <dir>`
235
248
  // silently wrote into CWD while the named target stayed stale (round-3 HIGH).
236
- const expected = cmd === 'skills' ? ['refresh'] : [];
249
+ const expected = cmd === 'skills' ? ['refresh'] : cmd === 'ledger' ? ['rechain'] : [];
237
250
  const unexpected = positionals.filter((p) => !expected.includes(p));
238
251
  if (unexpected.length > 0) {
239
252
  const hint = fs.existsSync(unexpected[0]) ? ` — did you mean --target ${unexpected[0]}?` : '';
@@ -354,13 +367,358 @@ async function main(argv) {
354
367
  }
355
368
  return res.ok ? 0 : 1;
356
369
  }
370
+ // @implements A-SPEC-256.1 — re-chain forked ledgers against an EXPLICIT mainline (a named git
371
+ // ref, default HEAD). Greedy mainline selection is the measured trap this command refuses to be.
372
+ if (cmd === 'ledger') {
373
+ if (positionals[0] !== 'rechain') {
374
+ process.stderr.write(`unknown ledger subcommand\n\n${USAGE}`);
375
+ return 2;
376
+ }
377
+ const { rechainLedger, acceptsAsMainline } = require('../governance/ledger-rechain');
378
+ const { verifyChain, ProvenanceChain, hasKeyedLookingEvents, keylessSelfConsistent, keyedSelfConsistent, parseLedgerLines, chainNext } = require('../governance/provenance-chain');
379
+ const { withLedgerLock, LedgerLockError } = require('../governance/ledger-lock');
380
+ const { isLedgerFilename } = require('../governance/provenance-ledger');
381
+ const { assertSafeRev } = require('../rtm/git-changes');
382
+ const { cleanSubprocessEnv } = require('../project/root');
383
+ const { execFileSync } = require('node:child_process');
384
+ const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
385
+ // A valueless `--ref` (parsed as boolean true) is already refused by the generic value-flag
386
+ // guard earlier in main() with '--ref requires a value' (round-8: the branch here was dead).
387
+ const ref = typeof flags.ref === 'string' ? flags.ref : 'HEAD';
388
+ const dry = flags['dry-run'] === true;
389
+ const key = process.env.HOLMES_LEDGER_KEY || undefined;
390
+ // Every git subprocess here runs with the scrubbed env (round-1 adversarial finding): this
391
+ // block reads HOLMES_LEDGER_KEY, and a git that inherits it hands the live signing key to
392
+ // hooks/credential helpers — the exact channel cleanSubprocessEnv exists to close.
393
+ const gitEnv = cleanSubprocessEnv();
394
+ // Name a wrong --target as a wrong PATH, not as 'not a git repo' (round-12: the git error was
395
+ // misdiagnosed) — the same class doctor's --target guard fixes.
396
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
397
+ process.stderr.write(`--target ${root} 는 존재하는 디렉터리가 아닙니다.\n`);
398
+ return 2;
399
+ }
400
+ try {
401
+ execFileSync('git', ['-C', root, 'rev-parse', '--git-dir'], { stdio: 'pipe', env: gitEnv });
402
+ }
403
+ catch {
404
+ process.stderr.write('git 저장소가 아닙니다 — 정본(ref) 없이는 재연쇄하지 않습니다: 재연쇄는 전량 재서명이며, 무엇이 정본인지는 커밋된 이력만이 말할 수 있습니다.\n');
405
+ return 1;
406
+ }
407
+ // The ref is validated BEFORE the walk (round-2 finding: a typo'd --ref silently degraded the
408
+ // explicit repair request into a "no change" success). Same guard every user-supplied-rev git
409
+ // call site uses.
410
+ try {
411
+ assertSafeRev(ref);
412
+ execFileSync('git', ['-C', root, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { stdio: 'pipe', env: gitEnv });
413
+ }
414
+ catch {
415
+ process.stderr.write(`--ref ${ref} 를 커밋으로 해석할 수 없습니다 — 정본을 지정하지 못한 채로는 재연쇄하지 않습니다.\n`);
416
+ return 1;
417
+ }
418
+ const ledgerDir = path.join(root, '.ax', 'ledger');
419
+ const files = fs.existsSync(ledgerDir)
420
+ // The CANONICAL live-ledger set is defined ONCE, in provenance-ledger.ts (round-16): the legacy
421
+ // `provenance.jsonl` or a `provenance.<id>.jsonl` replica with no dots in <id>. An inline copy
422
+ // drifted in round-15 (a loose `.*` swept in backups like `provenance.bak.jsonl`); the store's
423
+ // own matcher makes that drift impossible.
424
+ ? fs.readdirSync(ledgerDir).filter(isLedgerFilename)
425
+ : [];
426
+ if (files.length === 0) {
427
+ process.stdout.write('원장 파일이 없습니다 — 재연쇄할 것이 없습니다.\n');
428
+ return 0;
429
+ }
430
+ // STRICT parse (round-9): a torn TAIL is tolerated, but mid-file corruption or a non-object line
431
+ // throws — the repair tool must not drop a line and then 'fix' the seq gap, making the loss
432
+ // permanent and invisible to verify().
433
+ const parse = (raw, file) => parseLedgerLines(raw, file, { throwOnCorruption: true });
434
+ // The first-parent commit list is the SAME for every file (one ref), so resolve it ONCE outside
435
+ // the loop (round-9: it was re-run per file).
436
+ const WALK_LIMIT = 64;
437
+ let walkCommits = [];
438
+ try {
439
+ walkCommits = execFileSync('git', ['-C', root, 'rev-list', '--first-parent', '-n', String(WALK_LIMIT), ref], { encoding: 'utf8', env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] })
440
+ .split('\n').map((l) => l.trim()).filter(Boolean);
441
+ }
442
+ catch (e) {
443
+ // A rev-list failure (the ref was already rev-parse-verified) is a real git error, not an
444
+ // empty history — surface it rather than degrading every file to a false 'no mainline'
445
+ // (round-12).
446
+ process.stderr.write(`git rev-list 실패 — 이력을 열거할 수 없습니다: ${e.message.split('\n')[0]}\n`);
447
+ return 1;
448
+ }
449
+ // Resolve the SYMBOLIC ref once (round-13): 'HEAD' is ephemeral, so the audit event records the
450
+ // branch name too, making the trail self-describing later. Falls back to the raw ref.
451
+ let refLabel = ref;
452
+ try {
453
+ refLabel = execFileSync('git', ['-C', root, 'rev-parse', '--abbrev-ref', ref], { encoding: 'utf8', env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] }).trim() || ref;
454
+ }
455
+ catch { /* keep raw ref */ }
456
+ const planRechain = (mainline, input, mainlineAt) => {
457
+ const r = rechainLedger(mainline, input, key);
458
+ if ('refused' in r)
459
+ return { refused: r.refused };
460
+ if (r.alreadyVerified)
461
+ return { audited: r.events, orphanCount: 0, alreadyVerified: true, mainlineLen: mainline.length, mainlineAt };
462
+ const audited = [...r.events];
463
+ audited.push(chainNext(audited, {
464
+ ts: new Date().toISOString(), actor: 'holmes-kit', kind: 'ledger-rechained',
465
+ summary: `rechained against ${refLabel}@${mainlineAt.slice(0, 8)} — 본선 ${mainline.length} + 고아 ${r.orphanCount}`,
466
+ inputs: [refLabel, mainlineAt, String(r.orphanCount)], rationale: 'ledger rechain repair', authorization: '',
467
+ }, key));
468
+ // Verify the AUDITED chain (round-8: never claim "검증 통과" without verifying; round-16: verify
469
+ // the chain we will actually write, WITH its keyed audit tail, not the pre-audit prefix).
470
+ const v = verifyChain(audited, key);
471
+ if (!v.ok)
472
+ return { refused: `재연쇄 산출이 검증되지 않습니다(brokenAt=${v.brokenAt ?? '?'}: ${v.detail ?? ''}) — 정본(--ref)이나 키 설정을 확인하십시오.` };
473
+ return { audited, orphanCount: r.orphanCount, alreadyVerified: false, mainlineLen: mainline.length, mainlineAt };
474
+ };
475
+ // "Is this committed version VALIDLY KEYED under the current key?" — the load-bearing distinction
476
+ // of the two-phase walk: a keyed mainline is terminal, and a keyed ancestor is the only rollback
477
+ // evidence (round-18). Named once so the `key !== undefined` narrowing is not copy-pasted.
478
+ const isValidlyKeyed = (c) => c.some((e) => key !== undefined && keyedSelfConsistent(e, key));
479
+ let failed = 0;
480
+ for (const name of files) {
481
+ const abs = path.join(ledgerDir, name);
482
+ const rel = path.relative(root, abs).split(path.sep).join('/');
483
+ let working;
484
+ try {
485
+ working = parse(fs.readFileSync(abs, 'utf8'), name);
486
+ }
487
+ catch (e) {
488
+ process.stdout.write(`✗ ${name}: ${e.message} — 손상 줄을 조사·복구한 뒤 다시 실행하십시오.\n`);
489
+ failed++;
490
+ continue;
491
+ }
492
+ // Refuse to re-sign what this entry point cannot verify (a keyless run rewriting an intact
493
+ // keyed ledger as keyless is the exact attack signature the HMAC detector exists for,
494
+ // manufactured by the repair tool).
495
+ if (!key && hasKeyedLookingEvents(working)) {
496
+ process.stdout.write(`✗ ${name}: 자기일관 아닌 줄이 있습니다 — keyed(HMAC) 이거나 content-tampered 이며 키 없이는 구별할 수 없습니다. keyed 라면 HOLMES_LEDGER_KEY 를 설정하고, 변조라면 조사하십시오. rechain 은 포크만 복구하며 이 줄을 재서명하지 않습니다.\n`);
497
+ failed++;
498
+ continue;
499
+ }
500
+ // Mainline = the FIRST VERIFYING committed version, walking first-parent from the ref. A
501
+ // union-merge commit's own HEAD carries the seam, so "the ref, verbatim" strands the operator
502
+ // right after the scenario this command exists for. The walk selects the last healthy
503
+ // COMMITTED state; every line outside it is preserved as an orphan.
504
+ let mainline = null;
505
+ let mainlineAt = '';
506
+ let presentAnywhere = false;
507
+ let presentButCorrupt = false; // a committed version existed but strict-parse rejected it
508
+ let keyedBlocked = false; // a keyed-looking committed version the current mode can't verify
509
+ let sawKeyedCommitted = false; // any committed version in the walk carried keyed-looking events
510
+ for (const at of walkCommits) {
511
+ let cand;
512
+ // `:./` pins the pathspec to -C's cwd: a bare `<ref>:<rel>` is repo-TOPLEVEL-relative, so a
513
+ // governed subdirectory of a monorepo would adopt the PARENT repo's same-named ledger. A
514
+ // show failure means "absent at THIS commit" and the walk CONTINUES; stderr is piped. A
515
+ // committed candidate that is CORRUPT (strict-parse throws) is skipped like an absent one.
516
+ let raw;
517
+ try {
518
+ raw = execFileSync('git', ['-C', root, 'show', `${at}:./${rel}`], { encoding: 'utf8', maxBuffer: 1 << 28, env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] });
519
+ }
520
+ catch {
521
+ continue;
522
+ } // absent at THIS commit — walk continues
523
+ presentAnywhere = true;
524
+ // Present but CORRUPT is distinct from absent (round-14): remember it so the final message
525
+ // names corruption, not 'absent from history'.
526
+ try {
527
+ cand = parse(raw, `${at}:${rel}`);
528
+ }
529
+ catch {
530
+ presentButCorrupt = true;
531
+ continue;
532
+ }
533
+ // Two DISTINCT phases (round-18): MAINLINE SELECTION while `mainline === null`, then, only
534
+ // for a keyless mainline chosen under a set key, a ROLLBACK SCAN of older commits. Keeping
535
+ // them separate stops an older corrupt ancestor — reachable only because the scan walks past
536
+ // the chosen mainline — from hijacking selection-phase logic (keyedBlocked) or being counted
537
+ // as a keyed ancestor.
538
+ if (mainline === null) {
539
+ // Stop only at an UNACCOUNTABLE candidate — one carrying a line this mode can explain
540
+ // neither as keyless-self-consistent NOR (key present) as validly keyed under the current
541
+ // key. That is wrong-key / tamper (round-12), and for a keyless run any keyed-looking line
542
+ // (round-5 V2). A union-merge SEAM of a keyed ledger is NOT unaccountable: its lines ARE
543
+ // keyed-self-consistent (signed correctly, only linkage broken), so the walk continues past
544
+ // it to the healthy keyed ancestor and rechain repairs the seam (round-14 regression).
545
+ const unaccountable = cand.some((e) => !keylessSelfConsistent(e) && !(key !== undefined && keyedSelfConsistent(e, key)));
546
+ if (unaccountable) {
547
+ keyedBlocked = true;
548
+ break;
549
+ }
550
+ // An empty committed ledger is trivially "valid" but adopting it re-chains the whole working
551
+ // file from genesis, discarding its relation to real history — walk past it (round-14).
552
+ if (cand.length === 0)
553
+ continue;
554
+ if (!acceptsAsMainline(cand, key))
555
+ continue;
556
+ mainline = cand;
557
+ mainlineAt = at;
558
+ // A KEYED mainline (or a keyless run) is terminal — the walk goes newest→oldest, so nothing
559
+ // older can override the newest verifying state. Only a KEYLESS mainline under a SET key
560
+ // enters the rollback scan: KEY ADOPTION IS MONOTONIC (round-17), so it must confirm no
561
+ // OLDER committed version was validly keyed under this key (which would make this keyless
562
+ // mainline a rollback). Legit first-adoption has no such ancestor and the mainline stands.
563
+ if (!key || isValidlyKeyed(cand))
564
+ break;
565
+ }
566
+ else {
567
+ // ROLLBACK SCAN: only a version VALIDLY KEYED UNDER THE CURRENT KEY is rollback evidence
568
+ // (round-18). A merely keyed-LOOKING older commit — content-tampered, garbage-hash, or
569
+ // signed under a foreign key — is NOT: counting it would misdiagnose corruption as a
570
+ // key-rollback and block a healthy first-key adoption over an unrelated older corrupt commit.
571
+ if (isValidlyKeyed(cand)) {
572
+ sawKeyedCommitted = true;
573
+ break;
574
+ }
575
+ }
576
+ }
577
+ // A keyless mainline was selected under a set key atop a genuinely-keyed ancestor: a keyless-
578
+ // rollback of a formerly-keyed ledger (round-17). Refuse — do not launder. (Legit first-adoption
579
+ // never reaches here: sawKeyedCommitted stays false. A keyed union-merge seam repair selects a
580
+ // KEYED mainline, so it never enters the rollback scan.) Note: a full first-parent history
581
+ // rewrite that erases every keyed commit within WALK_LIMIT (rebase/squash) defeats this
582
+ // git-anchored check — the fundamental limitation named in provenance-chain.ts and spec §22.
583
+ if (mainline !== null && key && sawKeyedCommitted) {
584
+ process.stdout.write(`✗ ${name}: 커밋 이력이 keyed(HMAC)였는데 현재 본선 후보가 keyless입니다 — 키 채택은 되돌릴 수 없습니다(keyless-rollback). 이 재기록은 keyed 이력을 폐기하고 keyless 내용을 키로 재서명해 위조를 세탁합니다. verify 가 이미 이를 변조로 표시합니다. 건강한 keyed 정본을 --ref 로 지정하거나 변조를 조사하십시오.\n`);
585
+ failed++;
586
+ continue;
587
+ }
588
+ if (keyedBlocked) {
589
+ process.stdout.write(key
590
+ ? `✗ ${name}: 커밋 이력의 본선이 현재 HOLMES_LEDGER_KEY 로 검증되지 않습니다 — 키가 틀렸거나 이력이 변조됐습니다. 지나쳐 재연쇄하면 keyed 이력이 틀린 키로 재서명됩니다. 올바른 키를 확인하십시오.\n`
591
+ : `✗ ${name}: 커밋 이력의 본선에 자기일관 아닌 줄이 있습니다 — keyed(HMAC) 이거나 content-tampered 입니다. keyed 라면 HOLMES_LEDGER_KEY 를 설정하고, 변조라면 조사하십시오. 지나쳐 재연쇄하지 않습니다.\n`);
592
+ failed++;
593
+ continue;
594
+ }
595
+ if (mainline === null) {
596
+ // With no verifying committed mainline nothing can arbitrate. A verifying working file
597
+ // needs no arbitration; a broken one is refused, not guessed at.
598
+ if (verifyChain(working, key).ok) {
599
+ process.stdout.write(`· ${name}: 커밋 이력에 검증본 없음, 작업본 검증됨 — 무변경\n`);
600
+ continue;
601
+ }
602
+ process.stdout.write(presentButCorrupt
603
+ ? `✗ ${name}: 커밋 이력의 원장본이 손상되어 있고 작업본도 검증 실패 — 손상 커밋본을 복구하거나 건강한 정본을 --ref 로 지정하십시오\n`
604
+ : presentAnywhere
605
+ ? `✗ ${name}: first-parent ${WALK_LIMIT}커밋 안에 검증되는 커밋본이 없고 작업본도 검증 실패 — 정본을 --ref 로 지정하십시오\n`
606
+ : `✗ ${name}: ${ref} 이력에 없고 작업본도 검증 실패 — 정본을 --ref 로 지정하십시오\n`);
607
+ failed++;
608
+ continue;
609
+ }
610
+ // Read-rechain-write runs under the ledger lock: an unlocked wholesale rewrite would erase any
611
+ // append landing between the snapshot and the write. The working file is RE-READ inside the
612
+ // hold; the git walk stayed outside (it never touches the file).
613
+ const fixedMainline = mainline;
614
+ // --dry-run is READ-ONLY (round-10): it takes NO write lock (the lock's stale-break recorder
615
+ // would append a lock-broken event, and lock contention would fail the CI gate on drift it
616
+ // never checked) and opens nothing for write — it judges the snapshot `working` and returns an
617
+ // exit code only. The write path (lock, audit event, atomic rewrite) is for the real run only.
618
+ if (dry) {
619
+ const rd = planRechain(fixedMainline, working, mainlineAt);
620
+ if ('refused' in rd) {
621
+ process.stdout.write(`✗ ${name}: ${rd.refused}\n`);
622
+ failed++;
623
+ continue;
624
+ }
625
+ if (rd.alreadyVerified) {
626
+ process.stdout.write(`· ${name}: 이미 검증됨 — 무변경\n`);
627
+ continue;
628
+ }
629
+ process.stdout.write(`○ ${name}: 재연쇄 대상 — 고아 ${rd.orphanCount}건 (dry-run, 미기록)\n`);
630
+ failed++;
631
+ continue; // drift detected → CI gate fails
632
+ }
633
+ let verdict;
634
+ try {
635
+ verdict = withLedgerLock(abs, () => {
636
+ // Corruption appearing between the outer read and the lock throws here; catch it as a
637
+ // per-file ✗ (round-14: an uncaught strict-parse throw crashed the whole multi-file run).
638
+ let fresh;
639
+ try {
640
+ fresh = parse(fs.readFileSync(abs, 'utf8'), name);
641
+ } // read inside the lock (round-8)
642
+ catch (e) {
643
+ return { line: `✗ ${name}: ${e.message} — 손상 줄을 조사·복구한 뒤 다시 실행하십시오.`, fail: true };
644
+ }
645
+ // Re-checked INSIDE the hold (TOCTOU): a non-self-consistent line appearing between the
646
+ // snapshot and the lock is either keyed or content-tampered — honest either way (round-8).
647
+ if (!key && hasKeyedLookingEvents(fresh)) {
648
+ return { line: `✗ ${name}: 락 획득 사이에 자기일관 아닌 줄이 추가되었습니다 — keyed(HOLMES_LEDGER_KEY 설정) 이거나 변조입니다. 재서명하지 않습니다.`, fail: true };
649
+ }
650
+ // ONE judgment (round-16): rechain, chain the KEYED audit event onto the tail (the ledger's
651
+ // own doctrine — the audit trail must not silently change; the `ledger-rechained` event
652
+ // attributes the regeneration to this tool, the mainline ref/sha, and the orphan count, is
653
+ // keyed iff a key is present, and is idempotent), then verify the WHOLE audited chain. The
654
+ // SAME function the dry-run advisory used, so the two can never disagree.
655
+ const plan = planRechain(fixedMainline, fresh, mainlineAt);
656
+ if ('refused' in plan)
657
+ return { line: `✗ ${name}: ${plan.refused}`, fail: true };
658
+ if (plan.alreadyVerified)
659
+ return { line: `· ${name}: 이미 검증됨 — 무변경` };
660
+ const audited = plan.audited;
661
+ // ATOMIC rewrite: a crash mid-write must leave the old file, never a verifying prefix that
662
+ // the rerun's no-change guard silently accepts.
663
+ const serialized = audited.map((e) => JSON.stringify(e)).join('\n') + '\n';
664
+ const tmp = abs + '.rechain-tmp';
665
+ fs.writeFileSync(tmp, serialized);
666
+ fs.renameSync(tmp, abs);
667
+ // Confirm the ON-DISK result matches what we verified (round-15): the success line must
668
+ // reflect the file, not just the in-memory array — a torn/short write is reported, not
669
+ // silently blessed as '검증 통과'.
670
+ if (fs.readFileSync(abs, 'utf8') !== serialized) {
671
+ return { line: `✗ ${name}: 재기록된 파일이 검증한 내용과 다릅니다(디스크 이상?) — 다시 실행하십시오.`, fail: true };
672
+ }
673
+ return { line: `✓ ${name}: 재연쇄 완료 — 본선 ${fixedMainline.length}(${mainlineAt.slice(0, 8)}) + 고아 ${plan.orphanCount}, 감사 기록 남김, 체인 검증 통과` };
674
+ }, { onStaleBreak: new ProvenanceChain(abs).staleBreakRecorder() });
675
+ }
676
+ catch (e) {
677
+ // Lock contention is a per-file refusal, not a crash.
678
+ if (e instanceof LedgerLockError) {
679
+ process.stdout.write(`✗ ${name}: 원장 락을 얻지 못했습니다(다른 기록자 사용 중) — 잠시 후 다시 실행하십시오.\n`);
680
+ failed++;
681
+ continue;
682
+ }
683
+ // Any OTHER fault — a raw I/O error on the atomic write (ENOSPC/EACCES/EIO), etc. — is a
684
+ // per-file ✗, NOT an abort of the whole run: one replica's disk failure must not abandon its
685
+ // siblings, consistent with every other per-file fault (corruption, lock, refusal). The
686
+ // message is printed (never swallowed) so the failure stays visible and the exit is nonzero.
687
+ // The atomic tmp is left only on a mid-write crash and is ignored by isLedgerFilename; still,
688
+ // best-effort remove it so a retry starts clean.
689
+ try {
690
+ fs.rmSync(abs + '.rechain-tmp', { force: true });
691
+ }
692
+ catch { /* best-effort */ }
693
+ process.stdout.write(`✗ ${name}: 재기록 중 오류 — ${e.message.split('\n')[0]}. 이 파일은 건너뜁니다(다른 파일은 계속). 원인을 조사한 뒤 다시 실행하십시오.\n`);
694
+ failed++;
695
+ continue;
696
+ }
697
+ process.stdout.write(verdict.line + '\n');
698
+ if (verdict.fail)
699
+ failed++;
700
+ }
701
+ return failed === 0 ? 0 : 1;
702
+ }
357
703
  // @implements A-SPEC-215
358
704
  if (cmd === 'approve') {
359
705
  // @implements A-SPEC-246 — the surface where a human decides. Interactive on a TTY with no
360
706
  // decision flags; every interactive decision has a flag equivalent so a CI or remote operator
361
707
  // is never locked out.
362
- const { grantRequest, denyRequest, holdRequest, renderPending, renderNonTtyHint, runInteractive } = require('./approve');
708
+ const { grantRequest, denyRequest, holdRequest, renderPending, renderNonTtyHint, runInteractive, decisionSubject, decisionDetail } = require('./approve');
363
709
  const { readQueue } = require('../governance/approval-queue');
710
+ // @implements A-SPEC-262.1 — the flag path speaks the SAME grammar as the screen: an index, an
711
+ // exact id, or a unique prefix. A remote operator decides here, so an ambiguous reference must
712
+ // refuse by name here too — never resolve to "probably this one".
713
+ const { resolveRequestRef } = require('./approve-ref');
714
+ // The SUBJECT travels with the id: every decision line names what it acted on, so an index that
715
+ // resolved against a queue that shifted since `--list` is visible on the screen (round-1).
716
+ // The detail row travels with it: round-5 measured the one-line form at 81 columns, so the flag
717
+ // path prints the same two rows the screen does — subject read, id acted on.
718
+ const resolveRef = (ref) => {
719
+ const r = resolveRequestRef(readQueue(root).pending, ref);
720
+ return r.ok ? { id: r.entry.id, subject: (prefix) => decisionSubject(r.entry, prefix), detail: (tail) => decisionDetail(r.entry, tail) } : { refusal: r.reason };
721
+ };
364
722
  const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
365
723
  const actor = (() => { try {
366
724
  return os.userInfo().username || 'operator';
@@ -378,8 +736,13 @@ async function main(argv) {
378
736
  opts.ttlMinutes = Number(flags.ttl);
379
737
  if (typeof flags.rationale === 'string')
380
738
  opts.rationale = flags.rationale;
381
- const r = grantRequest(root, flags.grant, opts);
382
- process.stdout.write(r.ok ? `✓ 승인 — ${r.expires} 까지 유효\n` : `✗ ${r.reason}\n`);
739
+ const ref = resolveRef(flags.grant);
740
+ if ('refusal' in ref) {
741
+ process.stdout.write(`\u2717 ${ref.refusal}\n`);
742
+ return 1;
743
+ }
744
+ const r = grantRequest(root, ref.id, opts);
745
+ process.stdout.write(r.ok ? `✓ 승인 — ${ref.subject('✓ 승인 — ')}\n${ref.detail(`${r.expires} 까지 유효`)}\n` : `✗ ${r.reason}\n`);
383
746
  return r.ok ? 0 : 1;
384
747
  }
385
748
  if (typeof flags.deny === 'string') {
@@ -388,8 +751,13 @@ async function main(argv) {
388
751
  process.stderr.write('✗ --deny 는 --reason 이 필요합니다 — 사유 없는 거부는 에이전트가 볼 수 없습니다\n');
389
752
  return 1;
390
753
  }
391
- const r = denyRequest(root, flags.deny, reason, actor);
392
- process.stdout.write(r.ok ? '✓ 거부 기록됨\n' : `✗ ${r.reason}\n`);
754
+ const ref = resolveRef(flags.deny);
755
+ if ('refusal' in ref) {
756
+ process.stdout.write(`\u2717 ${ref.refusal}\n`);
757
+ return 1;
758
+ }
759
+ const r = denyRequest(root, ref.id, reason, actor);
760
+ process.stdout.write(r.ok ? `✓ 거부 — ${ref.subject('✓ 거부 — ')}\n${ref.detail('거부가 기록됐습니다')}\n` : `✗ ${r.reason}\n`);
393
761
  return r.ok ? 0 : 1;
394
762
  }
395
763
  if (typeof flags.ask === 'string') {
@@ -398,14 +766,58 @@ async function main(argv) {
398
766
  process.stderr.write('✗ --ask 는 --question 이 필요합니다\n');
399
767
  return 1;
400
768
  }
401
- const r = holdRequest(root, flags.ask, question, actor);
402
- process.stdout.write(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다\n' : `✗ ${r.reason}\n`);
769
+ const ref = resolveRef(flags.ask);
770
+ if ('refusal' in ref) {
771
+ process.stdout.write(`\u2717 ${ref.refusal}\n`);
772
+ return 1;
773
+ }
774
+ const r = holdRequest(root, ref.id, question, actor);
775
+ process.stdout.write(r.ok ? `✓ 보류 — ${ref.subject('✓ 보류 — ')}\n${ref.detail('질문은 다음 거부 문면에')}\n` : `✗ ${r.reason}\n`);
403
776
  return r.ok ? 0 : 1;
404
777
  }
405
778
  if (flags.list) {
406
779
  process.stdout.write(renderPending(readQueue(root)) + '\n');
407
780
  return 0;
408
781
  }
782
+ // @implements A-SPEC-262.2 — the RESIDENT surface. Validate --poll-ms first (so a bad value is
783
+ // named even on a non-TTY), then refuse a non-TTY (a resident interactive loop needs a terminal),
784
+ // then run the loop. SIGINT and readline close both abort it — the operator leaving, either way,
785
+ // ends the watch at once and exit 0; pending items stay in the queue.
786
+ if (flags.watch === true) {
787
+ let pollMs;
788
+ if (typeof flags['poll-ms'] === 'string') {
789
+ pollMs = Number(flags['poll-ms']);
790
+ if (!Number.isInteger(pollMs) || pollMs <= 0) {
791
+ process.stderr.write(`✗ --poll-ms 는 양의 정수(밀리초)여야 합니다 — 받은 값: ${flags['poll-ms']}\n`);
792
+ return 1;
793
+ }
794
+ }
795
+ if (!process.stdin.isTTY) {
796
+ process.stderr.write('✗ 대화형 상주 모드(--watch)는 TTY 가 필요합니다 — 스크립트에서는 --list 를 쓰십시오\n');
797
+ return 1;
798
+ }
799
+ const { runWatch } = require('./approve-watch');
800
+ const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
801
+ const ac = new AbortController();
802
+ rl.once('close', () => ac.abort());
803
+ const onSig = () => ac.abort();
804
+ process.once('SIGINT', onSig);
805
+ try {
806
+ await runWatch(root, {
807
+ print: (t) => process.stdout.write(t + '\n'),
808
+ ask: (q) => rl.question(q, { signal: ac.signal }),
809
+ }, actor, { signal: ac.signal, ...(pollMs !== undefined ? { pollMs } : {}) });
810
+ }
811
+ catch (e) {
812
+ if (e?.name !== 'AbortError')
813
+ throw e;
814
+ }
815
+ finally {
816
+ process.removeListener('SIGINT', onSig);
817
+ rl.close();
818
+ }
819
+ return 0;
820
+ }
409
821
  // @implements A-SPEC-260 — the implicit non-TTY fallback tells the operator the next command,
410
822
  // with the real pending id filled in; the explicit --list above stays script-clean.
411
823
  if (!process.stdin.isTTY) {
@@ -414,11 +826,32 @@ async function main(argv) {
414
826
  process.stdout.write(renderPending(state) + (hint ? '\n\n' + hint : '') + '\n');
415
827
  return 0;
416
828
  }
829
+ // @implements A-SPEC-262.1
830
+ // AWAITED, not fire-and-forget. Measured 2026-08-25 against the shipped bin with a real pty: the
831
+ // prompt printed and the session ended with zero grants, because `void runInteractive(...)`
832
+ // returned 0 immediately and bin/holmes-kit.js turns that into `process.exit(0)` — killing the
833
+ // loop before the first keypress. The decision surface REQ-246 shipped had never run for a real
834
+ // operator; every test called `runInteractive` directly and so never crossed this seam.
417
835
  const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
418
- void runInteractive(root, {
419
- print: (t) => process.stdout.write(t + '\n'),
420
- ask: (q) => rl.question(q),
421
- }, actor).finally(() => rl.close());
836
+ // EOF is the operator LEAVING (Ctrl-D, a closed pipe). Node leaves a pending `question`
837
+ // unsettled when the interface closes, so without this the awaited loop would hang the command
838
+ // forever measured 2026-08-25, a pty probe hung for five minutes. The abort ends the session
839
+ // the way the operator asked: quietly, exit 0, nothing decided that was not decided.
840
+ const ac = new AbortController();
841
+ rl.once('close', () => ac.abort());
842
+ try {
843
+ await runInteractive(root, {
844
+ print: (t) => process.stdout.write(t + '\n'),
845
+ ask: (q) => rl.question(q, { signal: ac.signal }),
846
+ }, actor);
847
+ }
848
+ catch (e) {
849
+ if (e?.name !== 'AbortError')
850
+ throw e;
851
+ }
852
+ finally {
853
+ rl.close();
854
+ }
422
855
  return 0;
423
856
  }
424
857
  if (cmd === 'serve') {
@@ -500,7 +933,9 @@ async function main(argv) {
500
933
  isAdditive = true;
501
934
  }
502
935
  else if (process.stdin.isTTY && process.stdout.isTTY && flags['dry-run'] !== true && flags.remove !== true) {
503
- agents = await promptAgentSelection(AGENTS, ['claude', 'antigravity']);
936
+ // Default to all harnesses checked — matches the menu's "Select All (Recommended)" and
937
+ // spares users a toggle per harness. Deselecting is one keystroke; a missed wiring is not.
938
+ agents = await promptAgentSelection(AGENTS, [...AGENTS]);
504
939
  isAdditive = true;
505
940
  }
506
941
  else {
@@ -548,6 +983,40 @@ async function main(argv) {
548
983
  : `unknown command: ${cmd}\n\n${USAGE}`);
549
984
  return cmd.startsWith('-') ? 2 : 1;
550
985
  }
986
+ /**
987
+ * @implements A-SPEC-262.1
988
+ * Is this the normal end of a pipeline rather than a fault? Round-10: `approve --list | head` printed
989
+ * a Node stack dump and exited 1 — on a command whose own help says it is for scripts, where a
990
+ * consumer that stops reading is how pipelines end. Exported so the decision is testable without
991
+ * spawning a process and racing a pipe.
992
+ */
993
+ const isBrokenPipe = (err) => err?.code === 'EPIPE';
994
+ exports.isBrokenPipe = isBrokenPipe;
995
+ let pipeGuardInstalled = false;
996
+ /**
997
+ * @implements A-SPEC-262.1
998
+ * A broken stdout pipe is the normal end of `| head`, not a fault worth a thousand bytes of Node
999
+ * internals on the operator's screen; exit quietly with the conventional status.
1000
+ *
1001
+ * Round-10 installed this ONLY inside `if (require.main === module)`. Round-11 measured that the
1002
+ * shipped `holmes-kit` never runs that block — `bin/holmes-kit.js` loads this file as a MODULE and
1003
+ * calls `main()`, so `require.main` is the bin, not this module. The guard was therefore DEAD on the
1004
+ * shipped binary: an interactive or `--watch` surface piped to a reader that closes (`| head`)
1005
+ * crashed with an unhandled `'error'` EPIPE stack and exit 1 (measured byte-identical for one-shot
1006
+ * and watch — a shared adapter property, not a watch defect). Installing from `main()` — the one
1007
+ * path every shipped invocation takes — makes the round-10 fix actually run. Idempotent: the module
1008
+ * flag keeps repeated `main()` calls (the test suite) from stacking listeners.
1009
+ */
1010
+ function installPipeGuard() {
1011
+ if (pipeGuardInstalled)
1012
+ return;
1013
+ pipeGuardInstalled = true;
1014
+ process.stdout.on('error', (err) => {
1015
+ if ((0, exports.isBrokenPipe)(err))
1016
+ process.exit(0);
1017
+ throw err;
1018
+ });
1019
+ }
551
1020
  if (require.main === module) {
552
1021
  main(process.argv.slice(2))
553
1022
  .then((code) => process.exit(code))