@holmes-lab/holmes-kit 0.1.17 → 0.2.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 (38) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/approve-context.d.ts +2 -0
  4. package/dist/holmes/cli/approve-context.js +180 -0
  5. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  6. package/dist/holmes/cli/approve-ref.js +40 -0
  7. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  8. package/dist/holmes/cli/approve-watch.js +94 -0
  9. package/dist/holmes/cli/approve.d.ts +61 -13
  10. package/dist/holmes/cli/approve.js +374 -36
  11. package/dist/holmes/cli/doctor.js +262 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  13. package/dist/holmes/cli/gitignore-merge.js +17 -1
  14. package/dist/holmes/cli/index.d.ts +23 -0
  15. package/dist/holmes/cli/index.js +496 -21
  16. package/dist/holmes/cli/init.js +14 -0
  17. package/dist/holmes/cli/mcp-launcher.js +6 -1
  18. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  19. package/dist/holmes/cli/screen-safe.js +760 -0
  20. package/dist/holmes/governance/approval-queue.js +56 -4
  21. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  22. package/dist/holmes/governance/ledger-rechain.js +95 -0
  23. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  24. package/dist/holmes/governance/provenance-chain.js +91 -16
  25. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  26. package/dist/holmes/governance/provenance-ledger.js +10 -0
  27. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  28. package/dist/holmes/guardrail/risk-gate.js +10 -0
  29. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  30. package/dist/holmes/mcp/elicit-approval.js +79 -0
  31. package/dist/holmes/mcp/handlers.d.ts +7 -2
  32. package/dist/holmes/mcp/handlers.js +190 -24
  33. package/dist/holmes/mcp/server.js +39 -2
  34. package/dist/holmes/server/dashboard.js +2 -2
  35. package/dist/holmes/spec/id-collision.d.ts +39 -0
  36. package/dist/holmes/spec/id-collision.js +86 -0
  37. package/dist/holmes/spec/spec-store.js +9 -1
  38. package/package.json +1 -1
@@ -407,6 +407,164 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
407
407
  : `상위가 approved가 아닌 승인 스펙 ${orphaned.length}건: ${orphaned.map((s) => s.id).slice(0, 5).join(', ')}`);
408
408
  }
409
409
  catch { /* an unreadable corpus is doctor's other checks' business, not this one's */ }
410
+ // @implements A-SPEC-254.1
411
+ // Distributed id preemption (the 2026-08-23 REQ-220 incident): two offline workspaces issue the
412
+ // same number, silent at create and at push. Judgment lives in the ONE pure detector; identity is
413
+ // the CANONICAL spec digest (specDigest: CRLF-folded, SEAL_FIELDS excluded — round-1: raw bytes
414
+ // turned a CRLF checkout difference into a fake collision, and trusting the self-declared seal
415
+ // hid a real one). The existing `duplicate spec ids` check above answers a different question
416
+ // (id count) and stays untouched. OWN try/catch (round-1): sharing the block above meant an
417
+ // unrelated upstream throw silently erased these checks — a failure here must surface as WARN,
418
+ // never as a vanished line.
419
+ try {
420
+ const { detectIdCollisions } = require('../spec/id-collision');
421
+ const { parseSpec } = require('../spec/spec-parser');
422
+ const crypto = require('node:crypto');
423
+ const specsRoot = path.join(target, '.ax', 'specs');
424
+ const entries = [];
425
+ let skipped = 0; // would-be specs present but not judged — a PASS must name its reduced scope (round-1)
426
+ // The collision identity is the WHOLE BODY, canonicalized — not a composite of parser splits
427
+ // (round-3): the round-2 specDigest+preamble composite inherited every split blind spot one at
428
+ // a time (preamble missing → round-2; fence-gap text in NEITHER half → round-3), and hashing
429
+ // the preamble raw made whitespace-only byte differences collide with a false forgery
430
+ // accusation on top. One key over everything after the frontmatter ends the class: CRLF folded
431
+ // (mirrors parseSpec), per-line trailing whitespace and blank lines dropped (formatting is not
432
+ // substance for an advisory WARN), plus the substantive frontmatter (type/title/dependsOn) via
433
+ // JSON array serialization — no field aliasing. The SEAL digest is untouched, as before.
434
+ const collisionKey = (folded, spec) => {
435
+ const fence = /^---\n[\s\S]*?\n---\n?/.exec(folded);
436
+ const body = (fence ? folded.slice(fence[0].length) : folded)
437
+ .split('\n').map((l) => l.trimEnd()).filter((l) => l !== '').join('\n');
438
+ return 'sha256:' + crypto.createHash('sha256')
439
+ .update(JSON.stringify([spec.type, spec.title, [...spec.dependsOn].sort(), body])).digest('hex');
440
+ };
441
+ // Symlinked directories are FOLLOWED (round-2): `Dirent.isDirectory()` is false for a dir
442
+ // symlink, so specs behind one silently left the judgment with skipped=0 — an unqualified PASS
443
+ // over an unexamined store. The realpath visited-set breaks symlink cycles. An UNREADABLE or
444
+ // un-realpathable directory counts into `skipped` (round-3) — a subtree the walk could not
445
+ // enter is an unexamined region, and a PASS must never silently span it.
446
+ const seen = new Set();
447
+ const collect = (d) => {
448
+ let real;
449
+ // "Nothing there" is not "something escaped judgment" (round-4, extended round-7): a
450
+ // NONEXISTENT (ENOENT) or path-broken (ENOTDIR — .ax created as a stray FILE;
451
+ // ENAMETOOLONG) root/dir holds nothing that could have been judged, so counting it stamped
452
+ // a phantom '판정 밖 1건' on pre-init targets, indistinguishable from a genuinely
453
+ // permission-locked store. Real refusals (EACCES) and cycles (ELOOP — an entry exists, it
454
+ // just cannot be entered) still count as unexamined regions.
455
+ try {
456
+ real = fs.realpathSync(d);
457
+ }
458
+ catch (err) {
459
+ const code = err.code;
460
+ if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ENAMETOOLONG')
461
+ skipped++;
462
+ return;
463
+ }
464
+ if (seen.has(real))
465
+ return;
466
+ seen.add(real);
467
+ let dirents;
468
+ // The FOURTH errno site of the absence class (round-8): a regular FILE at the specs path
469
+ // (realpath succeeds, readdir throws ENOTDIR) holds nothing judgeable — same doctrine as a
470
+ // TOCTOU-deleted dir (ENOENT, round-5). EACCES stays counted (an enterable-refused dir is a
471
+ // real unexamined region — the round-3 chmod pin rides this catch).
472
+ try {
473
+ dirents = fs.readdirSync(d, { withFileTypes: true });
474
+ }
475
+ catch (err) {
476
+ const code = err.code;
477
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
478
+ skipped++;
479
+ return;
480
+ }
481
+ for (const e of dirents) {
482
+ const p = path.join(d, e.name);
483
+ let isDir = e.isDirectory();
484
+ let isFile = e.isFile();
485
+ if (!isDir && !isFile && e.isSymbolicLink()) {
486
+ // The round-4 errno rule applies HERE too (round-5): a target that DOES NOT RESOLVE —
487
+ // ENOENT/ELOOP, and equally ENOTDIR/ENAMETOOLONG (round-6: "any other errno means the
488
+ // target exists" was false for a link traversing a regular file) — is absence, so only
489
+ // a spec-LOOKING name counts (round-3: a dangling assets.png fabricates a phantom scope
490
+ // reduction). A genuinely REFUSED target (EACCES …) exists but cannot even be typed: an
491
+ // unexamined region regardless of its name, counted unconditionally.
492
+ try {
493
+ const st = fs.statSync(p);
494
+ isDir = st.isDirectory();
495
+ isFile = st.isFile();
496
+ }
497
+ catch (err) {
498
+ const code = err.code;
499
+ const unresolved = code === 'ENOENT' || code === 'ELOOP' || code === 'ENOTDIR' || code === 'ENAMETOOLONG';
500
+ if (unresolved) {
501
+ if (e.name.endsWith('.md'))
502
+ skipped++;
503
+ }
504
+ else
505
+ skipped++;
506
+ continue;
507
+ }
508
+ }
509
+ if (isDir) {
510
+ collect(p);
511
+ continue;
512
+ }
513
+ if (!e.name.endsWith('.md'))
514
+ continue;
515
+ // TYPE before READ (round-6): readFileSync's open(2) blocks FOREVER on a FIFO with no
516
+ // writer — no throw, no survival catch, doctor simply never completes. A non-regular
517
+ // file named *.md cannot be judged; it is counted, never opened.
518
+ if (!isFile) {
519
+ skipped++;
520
+ continue;
521
+ }
522
+ try {
523
+ // Strip a UTF-8 BOM before parsing (round-1): a BOM broke the frontmatter match, so a
524
+ // BOM'd colliding replica silently vanished from the judgment instead of participating.
525
+ const raw = fs.readFileSync(p, 'utf8').replace(/^/, '');
526
+ const folded = raw.replace(/\r\n/g, '\n');
527
+ const spec = parseSpec(folded);
528
+ if (!spec.id) {
529
+ skipped++;
530
+ continue;
531
+ } // unreadable-as-spec, still counted (round-1)
532
+ entries.push({
533
+ file: path.relative(specsRoot, p).split(path.sep).join('/'),
534
+ id: spec.id,
535
+ approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
536
+ contentDigest: collisionKey(folded, spec),
537
+ });
538
+ }
539
+ catch {
540
+ skipped++;
541
+ }
542
+ }
543
+ };
544
+ collect(specsRoot);
545
+ // No cross-reference to `spec readability` (round-3): that walker does not follow dir
546
+ // symlinks, so the pointer was false for symlink-reached files — doctor contradicting itself.
547
+ const scopeNote = skipped > 0 ? ` (판정 밖 ${skipped}건)` : '';
548
+ const found = detectIdCollisions(entries);
549
+ const collisions = found.filter((i) => i.kind === 'id-collision');
550
+ const families = found.filter((i) => i.kind === 'family-coexistence');
551
+ add('spec id 선점 충돌', collisions.length === 0 ? 'PASS' : 'WARN', collisions.length === 0
552
+ ? `같은 id를 서로 다른 스펙이 쓰는 충돌이 없습니다${scopeNote}`
553
+ : collisions.map((c) => `${c.id}: ${c.files.join(' ↔ ')} (${c.detail})`).join('; ') + scopeNote, collisions.length === 0 ? undefined
554
+ : '같은 번호를 두 스펙이 선점했습니다 — 한쪽을 리넘버하고(REQ-255 전까지는 수동) 재봉인하세요.');
555
+ add('spec id 가족 공존', families.length === 0 ? 'PASS' : 'WARN', families.length === 0
556
+ ? `bare id와 dot-suffix 가족의 공존이 없습니다${scopeNote}`
557
+ : families.map((f) => `${f.id}: ${f.files.join(', ')}`).join('; ') + scopeNote, families.length === 0 ? undefined
558
+ : 'bare 스펙과 점 하위 가족이 공존합니다 — 두 워크스페이스가 같은 번호를 다르게 전개한 신호이니 한쪽 전개로 통일하세요.');
559
+ }
560
+ catch (e) {
561
+ // Never a silent vanish: the corpus most likely to hold a collision (a messy half-merged
562
+ // store) is exactly where an upstream throw would otherwise suppress the report. String(e),
563
+ // not e.message — a thrown non-Error must not crash the survival branch itself (round-2).
564
+ const msg = String(e instanceof Error ? e.message : e).split('\n')[0];
565
+ add('spec id 선점 충돌', 'WARN', `검사를 실행하지 못했습니다: ${msg}`);
566
+ add('spec id 가족 공존', 'WARN', `검사를 실행하지 못했습니다: ${msg}`);
567
+ }
410
568
  }
411
569
  // @implements A-SPEC-176
412
570
  // `list()` drops any file it cannot parse, which is correct for a gate but leaves the author
@@ -487,6 +645,38 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
487
645
  }
488
646
  }
489
647
  catch { /* .mcp.json 부재/파싱 실패 → 이 점검 없이 진행 */ }
648
+ // @implements A-SPEC-259 — the wiring is judged by SPAWNING it. String comparison is exactly how
649
+ // the dead `npx -y <pkg>@<ver> holmes-mcp` form (npx resolves the default bin and hands
650
+ // `holmes-mcp` to it as an argument) passed the launcher test, this doctor's own handshake check
651
+ // (which starts the server directly, not through the wiring), and the release tarball probe —
652
+ // measured 2026-08-23. A node wiring is judged on path existence (a source checkout must not pay
653
+ // a spawn), an absent .mcp.json is skipped by name, and the spawn is BOUNDED: it succeeds or
654
+ // fails inside its deadline, never hangs (REQ-258's lesson, applied from this check onward).
655
+ {
656
+ const mcpPath = path.join(target, '.mcp.json');
657
+ if (!fs.existsSync(mcpPath)) {
658
+ add('mcp wiring spawn', 'PASS', 'skipped — no .mcp.json in the target: an absent wiring is not a dead one');
659
+ }
660
+ else {
661
+ try {
662
+ const server = JSON.parse(fs.readFileSync(mcpPath, 'utf8'))
663
+ .mcpServers?.[init_1.SERVER_NAME];
664
+ if (!server || typeof server.command !== 'string' || !Array.isArray(server.args)) {
665
+ add('mcp wiring spawn', 'WARN', `.mcp.json carries no runnable ${init_1.SERVER_NAME} entry`, 'Run `holmes-kit init` in the target to write the wiring.');
666
+ }
667
+ else if (server.command === 'node') {
668
+ const bin = server.args[0] ?? '';
669
+ add('mcp wiring spawn', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `node wiring resolves: ${bin}` : `node wiring points at a missing file: ${bin}`, fs.existsSync(bin) ? undefined : 'Run `holmes-kit init` in the target to rewire the absolute path.');
670
+ }
671
+ else {
672
+ checks.push(await wiringSpawnCheck(server.command, server.args));
673
+ }
674
+ }
675
+ catch {
676
+ add('mcp wiring spawn', 'WARN', 'could not read .mcp.json', 'Run `holmes-kit init` in the target to rewrite the wiring.');
677
+ }
678
+ }
679
+ }
490
680
  // @implements A-SPEC-193 — 배선된 하네스마다 그 하네스의 배선을 검사한다. 배선되지 않은
491
681
  // 하네스는 진단하지 않는다(없는 것을 결함이라 부르면 doctor 가 소음이 된다).
492
682
  const agyHooks = path.join(target, '.agents', 'hooks.json');
@@ -522,6 +712,30 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
522
712
  const agySkills = path.join(target, '.agents', 'skills');
523
713
  add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills} 가 스킬을 가리킵니다` : `${agySkills} 가 없습니다 — 이 하네스는 스킬을 보지 못합니다`, fs.existsSync(agySkills) ? undefined : `${path.join('..', '.claude', 'skills')} 로 링크하거나 복사하십시오.`);
524
714
  }
715
+ // @implements A-SPEC-264 — 세 하네스 중 codex 만 배선 검사가 없었다(REQ-264 실측). 배선된
716
+ // 하네스마다 그 하네스의 배선을 검사한다는 A-SPEC-193 의 원칙 그대로: 파일이 없으면
717
+ // 진단하지 않고, 있으면 이 설치본으로 해석되는지를 판정한다.
718
+ const cdxMcp = path.join(target, '.codex', 'mcp_config.json');
719
+ if (fs.existsSync(cdxMcp)) {
720
+ try {
721
+ const doc = JSON.parse(fs.readFileSync(cdxMcp, 'utf8'));
722
+ const entry = doc.mcpServers?.[init_1.SERVER_NAME];
723
+ if (!entry || typeof entry.command !== 'string' || !Array.isArray(entry.args)) {
724
+ add('codex wiring', 'WARN', `${cdxMcp} 에 실행 가능한 ${init_1.SERVER_NAME} 항목이 없습니다 — 반쯤 된 배선입니다`, 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
725
+ }
726
+ else if (entry.command === 'node') {
727
+ const bin = entry.args[0] ?? '';
728
+ add('codex wiring', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `MCP 배선이 이 설치본으로 해석됩니다: ${bin}` : `MCP 배선이 없는 파일을 가리킵니다: ${bin}`, fs.existsSync(bin) ? undefined : 'holmes-kit init --target <dir> --agent codex --force 로 절대 경로를 갱신하십시오.');
729
+ }
730
+ else {
731
+ const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
732
+ add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `npx 핀 ${pin} 으로 해석됩니다` : `배선에서 실행 버전을 읽을 수 없습니다: ${entry.command} ${entry.args.join(' ')}`, pin !== null ? undefined : 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
733
+ }
734
+ }
735
+ catch {
736
+ add('codex wiring', 'FAIL', `${cdxMcp} 를 읽을 수 없습니다(JSON 아님)`, '파일을 고치거나 지우고 다시 배선하십시오.');
737
+ }
738
+ }
525
739
  try {
526
740
  const sp = wiredSettingsPath(target);
527
741
  const st = JSON.parse(fs.readFileSync(sp, 'utf8'));
@@ -616,6 +830,54 @@ function cleanupOnSignal(dir) {
616
830
  * and closes stdin makes the server exit before answering (a false FAIL this check produced on its
617
831
  * very first run against a healthy server).
618
832
  */
833
+ // @implements A-SPEC-259
834
+ // Spawn the TARGET's wiring verbatim and require initialize inside the deadline. Unlike
835
+ // `mcpHandshakeCheck` below (which proves this INSTALL can serve), this proves the WIRING the
836
+ // harness will actually run reaches a server — the gap the dead npx form lived in.
837
+ function wiringSpawnCheck(command, args, timeoutMs = 30000) {
838
+ const quoted = `${command} ${args.join(' ')}`;
839
+ return new Promise((resolve) => {
840
+ let done = false;
841
+ let out = '';
842
+ let child;
843
+ const finish = (level, detail, fix) => {
844
+ if (done)
845
+ return;
846
+ done = true;
847
+ clearTimeout(timer);
848
+ try {
849
+ child.kill();
850
+ }
851
+ catch { /* already gone */ }
852
+ resolve({ name: 'mcp wiring spawn', level, detail, fix });
853
+ };
854
+ const timer = setTimeout(() => finish('FAIL', `wiring did not answer initialize within ${timeoutMs / 1000}s: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring, then re-run doctor.'), timeoutMs);
855
+ try {
856
+ child = (0, node_child_process_1.spawn)(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
857
+ }
858
+ catch (e) {
859
+ finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, 'Re-run `holmes-kit init` in the target to rewrite the wiring.');
860
+ return;
861
+ }
862
+ child.on('error', (e) => finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, 'Re-run `holmes-kit init` in the target to rewrite the wiring.'));
863
+ child.on('exit', (code) => finish('FAIL', `wiring exited (code ${code}) before answering initialize: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring.'));
864
+ child.stdin?.on('error', () => { });
865
+ child.stdout?.on('data', (d) => {
866
+ out += String(d);
867
+ for (const line of out.split('\n')) {
868
+ try {
869
+ const m = JSON.parse(line);
870
+ if (m?.result?.serverInfo) {
871
+ finish('PASS', `wiring reaches a live server (v${m.result.serverInfo.version ?? '?'}): \`${quoted}\``);
872
+ return;
873
+ }
874
+ }
875
+ catch { /* partial line */ }
876
+ }
877
+ });
878
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'holmes-doctor', version: '1' } } }) + '\n');
879
+ });
880
+ }
619
881
  function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
620
882
  return new Promise((resolve) => {
621
883
  const mcpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-mcp-'));
@@ -7,6 +7,10 @@ export declare const BEGIN = "# >>> holmes-kit >>>";
7
7
  export declare const END = "# <<< holmes-kit <<<";
8
8
  /** Derived/runtime paths holmes-kit creates in a target project. */
9
9
  export declare const IGNORE_LINES: string[];
10
+ export declare const ATTRIBUTE_LINES: string[];
11
+ /** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
12
+ * and user-content preservation are inherited rather than re-implemented. */
13
+ export declare function mergeGitattributes(existing: string): string;
10
14
  /**
11
15
  * Add (or refresh) the holmes-kit block. Idempotent: a second call reproduces the same text.
12
16
  * Preserves everything outside the block, and preserves whether the file ended with a newline.
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  // @implements A-SPEC-100.2
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.IGNORE_LINES = exports.END = exports.BEGIN = void 0;
4
+ exports.ATTRIBUTE_LINES = exports.IGNORE_LINES = exports.END = exports.BEGIN = void 0;
5
+ exports.mergeGitattributes = mergeGitattributes;
5
6
  exports.mergeGitignore = mergeGitignore;
6
7
  exports.removeGitignoreBlock = removeGitignoreBlock;
7
8
  exports.hasGitignoreBlock = hasGitignoreBlock;
@@ -42,6 +43,21 @@ exports.IGNORE_LINES = [
42
43
  '.agents/hooks.json',
43
44
  '.agents/mcp_config.json',
44
45
  ];
46
+ // @implements A-SPEC-256.1
47
+ // Merge attributes for the append-only files: `union` keeps BOTH sides' lines on merge — for these
48
+ // files "both" is always the right answer (the 2026-08-23 merge resolved every such conflict by
49
+ // hand to exactly this). The resulting chain seams are `ledger rechain`'s job, not the merge's.
50
+ exports.ATTRIBUTE_LINES = [
51
+ '# @implements A-SPEC-256.1 — append-only 파일은 병합에서 양쪽 줄을 모두 보존한다(union).',
52
+ '# 남는 체인 이음새는 `holmes-kit ledger rechain`의 일이다.',
53
+ '.ax/ledger/*.jsonl merge=union',
54
+ '.ax/approvals/queue.jsonl merge=union',
55
+ ];
56
+ /** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
57
+ * and user-content preservation are inherited rather than re-implemented. */
58
+ function mergeGitattributes(existing) {
59
+ return mergeGitignore(existing, exports.ATTRIBUTE_LINES);
60
+ }
45
61
  function findBlock(text) {
46
62
  const lines = text.split('\n');
47
63
  const start = lines.findIndex((l) => l.trim() === exports.BEGIN);
@@ -12,3 +12,26 @@
12
12
  */
13
13
  export declare function packageRoot(): string;
14
14
  export declare function main(argv: string[]): Promise<number>;
15
+ /**
16
+ * @implements A-SPEC-262.1
17
+ * Is this the normal end of a pipeline rather than a fault? Round-10: `approve --list | head` printed
18
+ * a Node stack dump and exited 1 — on a command whose own help says it is for scripts, where a
19
+ * consumer that stops reading is how pipelines end. Exported so the decision is testable without
20
+ * spawning a process and racing a pipe.
21
+ */
22
+ export declare const isBrokenPipe: (err: NodeJS.ErrnoException | undefined) => boolean;
23
+ /**
24
+ * @implements A-SPEC-262.1
25
+ * A broken stdout pipe is the normal end of `| head`, not a fault worth a thousand bytes of Node
26
+ * internals on the operator's screen; exit quietly with the conventional status.
27
+ *
28
+ * Round-10 installed this ONLY inside `if (require.main === module)`. Round-11 measured that the
29
+ * shipped `holmes-kit` never runs that block — `bin/holmes-kit.js` loads this file as a MODULE and
30
+ * calls `main()`, so `require.main` is the bin, not this module. The guard was therefore DEAD on the
31
+ * shipped binary: an interactive or `--watch` surface piped to a reader that closes (`| head`)
32
+ * crashed with an unhandled `'error'` EPIPE stack and exit 1 (measured byte-identical for one-shot
33
+ * and watch — a shared adapter property, not a watch defect). Installing from `main()` — the one
34
+ * path every shipped invocation takes — makes the round-10 fix actually run. Idempotent: the module
35
+ * flag keeps repeated `main()` calls (the test suite) from stacking listeners.
36
+ */
37
+ export declare function installPipeGuard(): void;