@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/index.js
CHANGED
|
@@ -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
|
|
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]건너뛰기
|
|
195
|
+
(no flags, TTY) interactive: [a]승인 [e]지정승인 [v]상세 [d]거부 [q]질문 [s]건너뛰기 [A]전체승인
|
|
185
196
|
--list print the pending queue and exit
|
|
186
|
-
--grant <
|
|
187
|
-
--deny <
|
|
188
|
-
--ask <
|
|
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
|
|
382
|
-
|
|
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
|
|
392
|
-
|
|
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
|
|
402
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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))
|