@holmes-lab/holmes-kit 0.12.2 → 0.13.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.
- package/CHANGELOG.md +80 -0
- package/README.md +12 -3
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-context.js +10 -10
- package/dist/holmes/cli/approve-ref.js +5 -5
- package/dist/holmes/cli/approve-watch.d.ts +1 -1
- package/dist/holmes/cli/approve-watch.js +6 -6
- package/dist/holmes/cli/approve.d.ts +3 -3
- package/dist/holmes/cli/approve.js +57 -57
- package/dist/holmes/cli/autonomy.d.ts +22 -0
- package/dist/holmes/cli/autonomy.js +145 -0
- package/dist/holmes/cli/colophon.d.ts +6 -0
- package/dist/holmes/cli/colophon.js +24 -0
- package/dist/holmes/cli/doctor.d.ts +2 -2
- package/dist/holmes/cli/doctor.js +104 -87
- package/dist/holmes/cli/index.js +122 -63
- package/dist/holmes/cli/init.d.ts +2 -0
- package/dist/holmes/cli/init.js +31 -19
- package/dist/holmes/cli/interactive-prompt.d.ts +8 -0
- package/dist/holmes/cli/interactive-prompt.js +23 -0
- package/dist/holmes/cli/semantic-key.js +9 -9
- package/dist/holmes/cli/settings-merge.d.ts +2 -1
- package/dist/holmes/cli/settings-merge.js +15 -3
- package/dist/holmes/cli/upgrade.js +7 -7
- package/dist/holmes/cpg/proposed-content.js +2 -2
- package/dist/holmes/governance/autonomy.d.ts +9 -2
- package/dist/holmes/governance/autonomy.js +166 -5
- package/dist/holmes/guardrail/blind-spots.js +15 -15
- package/dist/holmes/hooks/pre-tool-use.js +111 -42
- package/dist/holmes/hooks/session-start.js +17 -0
- package/dist/holmes/hooks/stop.d.ts +1 -1
- package/dist/holmes/hooks/stop.js +12 -12
- package/dist/holmes/mcp/handlers.js +14 -1
- package/dist/holmes/semantic/credentials.js +1 -1
- package/dist/holmes/spec/id-collision.js +2 -2
- package/package.json +2 -2
- package/playbooks/publish/PLAYBOOK.md +47 -35
- package/playbooks/remediation/PLAYBOOK.md +1 -1
package/dist/holmes/cli/index.js
CHANGED
|
@@ -45,6 +45,7 @@ const os = __importStar(require("node:os"));
|
|
|
45
45
|
const path = __importStar(require("node:path"));
|
|
46
46
|
const init_1 = require("./init");
|
|
47
47
|
const doctor_1 = require("./doctor");
|
|
48
|
+
const colophon_1 = require("./colophon");
|
|
48
49
|
/**
|
|
49
50
|
* `holmes-kit` CLI entry — argv dispatch, hand-rolled (no new dependency).
|
|
50
51
|
*
|
|
@@ -71,7 +72,8 @@ function packageRoot() {
|
|
|
71
72
|
* REQ-144's "cannot decide is not permission", applied to CLI arguments.
|
|
72
73
|
*/
|
|
73
74
|
const KNOWN_FLAGS = {
|
|
74
|
-
init: ['help', 'target', 'mode', 'specs-dir', 'settings', 'matcher', 'dry-run', 'no-mcp', 'remove', 'force', 'agent', 'mcp-launcher'],
|
|
75
|
+
init: ['help', 'target', 'mode', 'specs-dir', 'settings', 'matcher', 'dry-run', 'no-mcp', 'remove', 'force', 'agent', 'mcp-launcher', 'autonomy', 'no-autonomy'],
|
|
76
|
+
autonomy: ['help', 'target', 'for'],
|
|
75
77
|
doctor: ['help', 'target', 'json'],
|
|
76
78
|
skills: ['help', 'target'],
|
|
77
79
|
ci: ['help', 'target', 'specs-dir', 'json'],
|
|
@@ -91,16 +93,16 @@ const KNOWN_FLAGS = {
|
|
|
91
93
|
// the whole-CLI usage over it. This set is that exemption, kept declarative so a future self-help
|
|
92
94
|
// command is one edit, not a dispatch-order gamble.
|
|
93
95
|
const SELF_HELP_COMMANDS = new Set(['approve']);
|
|
94
|
-
const APPROVE_USAGE = `holmes-kit approve —
|
|
96
|
+
const APPROVE_USAGE = `holmes-kit approve — decide pending approval requests (HITL)
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
--watch
|
|
98
|
-
--list
|
|
99
|
-
--grant
|
|
100
|
-
|
|
101
|
-
--deny <id> --reason
|
|
102
|
-
--ask <id> --question
|
|
103
|
-
--target <dir>
|
|
98
|
+
interactive (TTY): per item [a] approve [e] approve with reason/duration [v] details [d] deny [q] ask [s] skip [A] approve all remaining
|
|
99
|
+
--watch resident mode — decide as requests arrive, wait when the queue is empty (Ctrl-C to exit, --poll-ms <ms>)
|
|
100
|
+
--list print the pending list only
|
|
101
|
+
--grant <ref> issue a narrow grant for that request (--ttl <minutes>, --rationale <text>)
|
|
102
|
+
ref = list number (1,2,…) | id | id prefix — no need to retype a long id
|
|
103
|
+
--deny <id> --reason <text> deny — the reason rides the agent's next refusal
|
|
104
|
+
--ask <id> --question <text> leave a question and hold — the agent relays it to the user
|
|
105
|
+
--target <dir> project root (default: current directory)
|
|
104
106
|
`;
|
|
105
107
|
class UnknownFlagError extends Error {
|
|
106
108
|
}
|
|
@@ -110,7 +112,7 @@ class UnknownFlagError extends Error {
|
|
|
110
112
|
* what you'd do" spelling performed a REAL write (round-3 HIGH; same class inverted --remove and
|
|
111
113
|
* --force). A boolean flag must never consume a token.
|
|
112
114
|
*/
|
|
113
|
-
const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'watch']);
|
|
115
|
+
const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'watch', 'autonomy', 'no-autonomy']);
|
|
114
116
|
function parseFlags(argv, known) {
|
|
115
117
|
const flags = {};
|
|
116
118
|
const positionals = [];
|
|
@@ -190,6 +192,10 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
|
|
|
190
192
|
--dry-run print the diff, write nothing
|
|
191
193
|
--remove remove holmes-kit wiring (holmes-owned entries only)
|
|
192
194
|
--force re-wire an already-wired target (requires HOLMES_APPROVAL)
|
|
195
|
+
--autonomy grant autonomous spec approval (low-risk only; governance-critical
|
|
196
|
+
still ask you). Non-interactive: requires HOLMES_APPROVAL. At a TTY,
|
|
197
|
+
init asks instead. Off is default; turn off with --no-autonomy.
|
|
198
|
+
--no-autonomy keep every spec approval human (default)
|
|
193
199
|
|
|
194
200
|
holmes-kit ledger rechain re-chain forked ledger files against the committed mainline
|
|
195
201
|
--target <dir> project to repair (default: cwd)
|
|
@@ -198,7 +204,7 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
|
|
|
198
204
|
--dry-run report what would be re-chained, write nothing
|
|
199
205
|
|
|
200
206
|
holmes-kit approve review pending approval requests (HITL)
|
|
201
|
-
(no flags, TTY) interactive: [a]
|
|
207
|
+
(no flags, TTY) interactive: [a]approve [e]approve-with-terms [v]details [d]deny [q]ask [s]skip [A]approve-all
|
|
202
208
|
--list print the pending queue and exit
|
|
203
209
|
--grant <ref> [--ttl <min>] [--rationale <text>] mint the narrow grant for one request
|
|
204
210
|
--deny <ref> --reason <text> record a denial the agent will see
|
|
@@ -206,9 +212,15 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
|
|
|
206
212
|
<ref> list index (1,2,…), full id, or a unique id prefix
|
|
207
213
|
--target <dir> project root (default: cwd)
|
|
208
214
|
|
|
209
|
-
holmes-kit
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
holmes-kit autonomy status|on|off grant/end per-session autonomous spec approval
|
|
216
|
+
on [--for 2h] self-drive low-risk specs this session (governance-critical still
|
|
217
|
+
ask you); at a TTY it just asks, non-interactive needs HOLMES_APPROVAL
|
|
218
|
+
off end it now (always free)
|
|
219
|
+
--target <dir> project root (default: cwd)
|
|
220
|
+
|
|
221
|
+
holmes-kit semantic-key set|unset|status manage the cloud semantic tier opt-in key
|
|
222
|
+
set reads from stdin only (the value never appears in argv or output).
|
|
223
|
+
Setting the key is consent to egress (spec prose, paths, symbol names sent externally).
|
|
212
224
|
|
|
213
225
|
holmes-kit doctor [flags] diagnose the install
|
|
214
226
|
--target <dir> also verify that target's wiring points at this install
|
|
@@ -261,7 +273,8 @@ async function main(argv) {
|
|
|
261
273
|
// Positionals these commands do not take are refused, not dropped: `skills refresh <dir>`
|
|
262
274
|
// silently wrote into CWD while the named target stayed stale (round-3 HIGH).
|
|
263
275
|
const expected = cmd === 'skills' ? ['refresh'] : cmd === 'ledger' ? ['rechain']
|
|
264
|
-
: cmd === 'semantic-key' ? ['set', 'unset', 'status']
|
|
276
|
+
: cmd === 'semantic-key' ? ['set', 'unset', 'status']
|
|
277
|
+
: cmd === 'autonomy' ? ['status', 'on', 'off'] : [];
|
|
265
278
|
const unexpected = positionals.filter((p) => !expected.includes(p));
|
|
266
279
|
if (unexpected.length > 0) {
|
|
267
280
|
const hint = fs.existsSync(unexpected[0]) ? ` — did you mean --target ${unexpected[0]}?` : '';
|
|
@@ -291,11 +304,32 @@ async function main(argv) {
|
|
|
291
304
|
process.stdout.write(`${v}\n`);
|
|
292
305
|
return 0;
|
|
293
306
|
}
|
|
307
|
+
// @implements A-SPEC-548.1 — a hidden maker's mark on the detective theme. Undocumented (never in
|
|
308
|
+
// USAGE); a colophon for those who go looking.
|
|
309
|
+
if (cmd === 'elementary' || cmd === 'whodunit') {
|
|
310
|
+
process.stdout.write((0, colophon_1.colophon)());
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
294
313
|
if (cmd === 'semantic-key') {
|
|
295
314
|
// @implements A-SPEC-477 — set|unset|status; the key rides stdin, never argv.
|
|
296
315
|
const { runSemanticKey } = await Promise.resolve().then(() => __importStar(require('./semantic-key')));
|
|
297
316
|
return runSemanticKey(positionals[0]);
|
|
298
317
|
}
|
|
318
|
+
// @implements A-SPEC-553.1 — session autonomy envelope: status|on [--for 2h]|off. Enabling is a
|
|
319
|
+
// foreground human act (raw-TTY proof, or an out-of-band HOLMES_APPROVAL for the non-interactive
|
|
320
|
+
// path); the marker lives in the agent-write-protected .ax/state/, so a session cannot self-grant.
|
|
321
|
+
if (cmd === 'autonomy') {
|
|
322
|
+
const { runAutonomy } = await Promise.resolve().then(() => __importStar(require('./autonomy')));
|
|
323
|
+
const root = path.resolve(typeof flags.target === 'string' ? flags.target : process.cwd());
|
|
324
|
+
const forStr = typeof flags.for === 'string' ? flags.for : undefined;
|
|
325
|
+
const r = runAutonomy(root, positionals[0], forStr, {
|
|
326
|
+
now: new Date().toISOString(),
|
|
327
|
+
isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY,
|
|
328
|
+
approval: readApproval(),
|
|
329
|
+
});
|
|
330
|
+
process[r.ok ? 'stdout' : 'stderr'].write(`${r.message}\n`);
|
|
331
|
+
return r.exitCode;
|
|
332
|
+
}
|
|
299
333
|
if (cmd === 'doctor') {
|
|
300
334
|
// A nonexistent target read as "healthy but unwired" and both advised remedies then refused it
|
|
301
335
|
// (round-6) — the typo is the diagnosis, so say it.
|
|
@@ -335,7 +369,7 @@ async function main(argv) {
|
|
|
335
369
|
if (r.written.length)
|
|
336
370
|
process.stdout.write(`Refreshed ${r.written.length} recovery skill(s) under ${path.join(target, '.claude', 'skills')}: ${r.written.map(invocableSkillName).join(', ')}\n`);
|
|
337
371
|
if (r.written.length === 0 && r.skipped.length === 0 && r.failed.length === 0) {
|
|
338
|
-
//
|
|
372
|
+
// this package shipped no playbooks — 'Refreshed 0' reads like success.
|
|
339
373
|
process.stderr.write(`This holmes-kit package ships no playbooks — nothing to refresh under ${path.join(target, '.claude', 'skills')}. Reinstall or upgrade holmes-kit itself.\n`);
|
|
340
374
|
return 1;
|
|
341
375
|
}
|
|
@@ -414,14 +448,14 @@ async function main(argv) {
|
|
|
414
448
|
// Name a wrong --target as a wrong PATH, not as 'not a git repo' (round-12: the git error was
|
|
415
449
|
// misdiagnosed) — the same class doctor's --target guard fixes.
|
|
416
450
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
417
|
-
process.stderr.write(`--target ${root}
|
|
451
|
+
process.stderr.write(`--target ${root} is not an existing directory.\n`);
|
|
418
452
|
return 2;
|
|
419
453
|
}
|
|
420
454
|
try {
|
|
421
455
|
execFileSync('git', ['-C', root, 'rev-parse', '--git-dir'], { stdio: 'pipe', env: gitEnv });
|
|
422
456
|
}
|
|
423
457
|
catch {
|
|
424
|
-
process.stderr.write('git
|
|
458
|
+
process.stderr.write('not a git repository — without a canonical ref there is no rechaining: a rechain is a wholesale re-signing, and only the committed history can say what is canonical.\n');
|
|
425
459
|
return 1;
|
|
426
460
|
}
|
|
427
461
|
// The ref is validated BEFORE the walk (round-2 finding: a typo'd --ref silently degraded the
|
|
@@ -432,7 +466,7 @@ async function main(argv) {
|
|
|
432
466
|
execFileSync('git', ['-C', root, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { stdio: 'pipe', env: gitEnv });
|
|
433
467
|
}
|
|
434
468
|
catch {
|
|
435
|
-
process.stderr.write(`--ref ${ref}
|
|
469
|
+
process.stderr.write(`--ref ${ref} cannot be resolved to a commit — without a canonical ref there is no rechaining.\n`);
|
|
436
470
|
return 1;
|
|
437
471
|
}
|
|
438
472
|
const ledgerDir = path.join(root, '.ax', 'ledger');
|
|
@@ -444,7 +478,7 @@ async function main(argv) {
|
|
|
444
478
|
? fs.readdirSync(ledgerDir).filter(isLedgerFilename)
|
|
445
479
|
: [];
|
|
446
480
|
if (files.length === 0) {
|
|
447
|
-
process.stdout.write('
|
|
481
|
+
process.stdout.write('no ledger files — nothing to rechain.\n');
|
|
448
482
|
return 0;
|
|
449
483
|
}
|
|
450
484
|
// STRICT parse (round-9): a torn TAIL is tolerated, but mid-file corruption or a non-object line
|
|
@@ -463,7 +497,7 @@ async function main(argv) {
|
|
|
463
497
|
// A rev-list failure (the ref was already rev-parse-verified) is a real git error, not an
|
|
464
498
|
// empty history — surface it rather than degrading every file to a false 'no mainline'
|
|
465
499
|
// (round-12).
|
|
466
|
-
process.stderr.write(`git rev-list
|
|
500
|
+
process.stderr.write(`git rev-list failed — cannot enumerate history: ${e.message.split('\n')[0]}\n`);
|
|
467
501
|
return 1;
|
|
468
502
|
}
|
|
469
503
|
// Resolve the SYMBOLIC ref once (round-13): 'HEAD' is ephemeral, so the audit event records the
|
|
@@ -482,14 +516,14 @@ async function main(argv) {
|
|
|
482
516
|
const audited = [...r.events];
|
|
483
517
|
audited.push(chainNext(audited, {
|
|
484
518
|
ts: new Date().toISOString(), actor: 'holmes-kit', kind: 'ledger-rechained',
|
|
485
|
-
summary: `rechained against ${refLabel}@${mainlineAt.slice(0, 8)} —
|
|
519
|
+
summary: `rechained against ${refLabel}@${mainlineAt.slice(0, 8)} — mainline ${mainline.length} + orphans ${r.orphanCount}`,
|
|
486
520
|
inputs: [refLabel, mainlineAt, String(r.orphanCount)], rationale: 'ledger rechain repair', authorization: '',
|
|
487
521
|
}, key));
|
|
488
|
-
// Verify the AUDITED chain (round-8: never claim "
|
|
522
|
+
// Verify the AUDITED chain (round-8: never claim "verified" without verifying; round-16: verify
|
|
489
523
|
// the chain we will actually write, WITH its keyed audit tail, not the pre-audit prefix).
|
|
490
524
|
const v = verifyChain(audited, key);
|
|
491
525
|
if (!v.ok)
|
|
492
|
-
return { refused:
|
|
526
|
+
return { refused: `the rechained output does not verify (brokenAt=${v.brokenAt ?? '?'}: ${v.detail ?? ''}) — check the canonical ref (--ref) or the key setting.` };
|
|
493
527
|
return { audited, orphanCount: r.orphanCount, alreadyVerified: false, mainlineLen: mainline.length, mainlineAt };
|
|
494
528
|
};
|
|
495
529
|
// "Is this committed version VALIDLY KEYED under the current key?" — the load-bearing distinction
|
|
@@ -505,7 +539,7 @@ async function main(argv) {
|
|
|
505
539
|
working = parse(fs.readFileSync(abs, 'utf8'), name);
|
|
506
540
|
}
|
|
507
541
|
catch (e) {
|
|
508
|
-
process.stdout.write(`✗ ${name}: ${e.message} —
|
|
542
|
+
process.stdout.write(`✗ ${name}: ${e.message} — investigate and repair the corrupt line, then re-run.\n`);
|
|
509
543
|
failed++;
|
|
510
544
|
continue;
|
|
511
545
|
}
|
|
@@ -513,7 +547,7 @@ async function main(argv) {
|
|
|
513
547
|
// keyed ledger as keyless is the exact attack signature the HMAC detector exists for,
|
|
514
548
|
// manufactured by the repair tool).
|
|
515
549
|
if (!key && hasKeyedLookingEvents(working)) {
|
|
516
|
-
process.stdout.write(`✗ ${name}:
|
|
550
|
+
process.stdout.write(`✗ ${name}: there is a non-self-consistent line — either keyed (HMAC) or content-tampered, and without the key the two cannot be told apart. If keyed, set HOLMES_LEDGER_KEY; if tampered, investigate. rechain only repairs forks and does not re-sign this line.\n`);
|
|
517
551
|
failed++;
|
|
518
552
|
continue;
|
|
519
553
|
}
|
|
@@ -601,14 +635,14 @@ async function main(argv) {
|
|
|
601
635
|
// rewrite that erases every keyed commit within WALK_LIMIT (rebase/squash) defeats this
|
|
602
636
|
// git-anchored check — the fundamental limitation named in provenance-chain.ts and spec §22.
|
|
603
637
|
if (mainline !== null && key && sawKeyedCommitted) {
|
|
604
|
-
process.stdout.write(`✗ ${name}:
|
|
638
|
+
process.stdout.write(`✗ ${name}: the committed history was keyed (HMAC) but the current mainline candidate is keyless — adopting a key is irreversible (keyless-rollback). This rewrite would discard the keyed history and re-sign the keyless content with the key, laundering a forgery. verify already flags this as tampering. Point --ref at a healthy keyed canonical, or investigate the tampering.\n`);
|
|
605
639
|
failed++;
|
|
606
640
|
continue;
|
|
607
641
|
}
|
|
608
642
|
if (keyedBlocked) {
|
|
609
643
|
process.stdout.write(key
|
|
610
|
-
? `✗ ${name}:
|
|
611
|
-
: `✗ ${name}:
|
|
644
|
+
? `✗ ${name}: the committed mainline does not verify under the current HOLMES_LEDGER_KEY — either the key is wrong or the history was tampered with. Rechaining past this would re-sign a keyed history with the wrong key. Confirm the correct key.\n`
|
|
645
|
+
: `✗ ${name}: the committed mainline has a non-self-consistent line — it is keyed (HMAC) or content-tampered. If keyed, set HOLMES_LEDGER_KEY; if tampered, investigate. Not rechaining past it.\n`);
|
|
612
646
|
failed++;
|
|
613
647
|
continue;
|
|
614
648
|
}
|
|
@@ -616,14 +650,14 @@ async function main(argv) {
|
|
|
616
650
|
// With no verifying committed mainline nothing can arbitrate. A verifying working file
|
|
617
651
|
// needs no arbitration; a broken one is refused, not guessed at.
|
|
618
652
|
if (verifyChain(working, key).ok) {
|
|
619
|
-
process.stdout.write(`· ${name}:
|
|
653
|
+
process.stdout.write(`· ${name}: no verifying copy in the committed history, working copy verifies — no change\n`);
|
|
620
654
|
continue;
|
|
621
655
|
}
|
|
622
656
|
process.stdout.write(presentButCorrupt
|
|
623
|
-
? `✗ ${name}:
|
|
657
|
+
? `✗ ${name}: the committed ledger copy is corrupt and the working copy fails to verify — repair the corrupt committed copy or point --ref at a healthy canonical\n`
|
|
624
658
|
: presentAnywhere
|
|
625
|
-
? `✗ ${name}: first-parent ${WALK_LIMIT}
|
|
626
|
-
: `✗ ${name}: ${ref}
|
|
659
|
+
? `✗ ${name}: no verifying committed copy within the first-parent ${WALK_LIMIT} commits and the working copy fails to verify — point --ref at a canonical\n`
|
|
660
|
+
: `✗ ${name}: not in the ${ref} history and the working copy fails to verify — point --ref at a canonical\n`);
|
|
627
661
|
failed++;
|
|
628
662
|
continue;
|
|
629
663
|
}
|
|
@@ -643,10 +677,10 @@ async function main(argv) {
|
|
|
643
677
|
continue;
|
|
644
678
|
}
|
|
645
679
|
if (rd.alreadyVerified) {
|
|
646
|
-
process.stdout.write(`· ${name}:
|
|
680
|
+
process.stdout.write(`· ${name}: already verifies — no change\n`);
|
|
647
681
|
continue;
|
|
648
682
|
}
|
|
649
|
-
process.stdout.write(`○ ${name}:
|
|
683
|
+
process.stdout.write(`○ ${name}: would rechain — ${rd.orphanCount} orphan(s) (dry-run, not written)\n`);
|
|
650
684
|
failed++;
|
|
651
685
|
continue; // drift detected → CI gate fails
|
|
652
686
|
}
|
|
@@ -660,12 +694,12 @@ async function main(argv) {
|
|
|
660
694
|
fresh = parse(fs.readFileSync(abs, 'utf8'), name);
|
|
661
695
|
} // read inside the lock (round-8)
|
|
662
696
|
catch (e) {
|
|
663
|
-
return { line: `✗ ${name}: ${e.message} —
|
|
697
|
+
return { line: `✗ ${name}: ${e.message} — investigate and repair the corrupt line, then re-run.`, fail: true };
|
|
664
698
|
}
|
|
665
699
|
// Re-checked INSIDE the hold (TOCTOU): a non-self-consistent line appearing between the
|
|
666
700
|
// snapshot and the lock is either keyed or content-tampered — honest either way (round-8).
|
|
667
701
|
if (!key && hasKeyedLookingEvents(fresh)) {
|
|
668
|
-
return { line: `✗ ${name}:
|
|
702
|
+
return { line: `✗ ${name}: a non-self-consistent line appeared while acquiring the lock — it is keyed (set HOLMES_LEDGER_KEY) or tampered. Not re-signing.`, fail: true };
|
|
669
703
|
}
|
|
670
704
|
// ONE judgment (round-16): rechain, chain the KEYED audit event onto the tail (the ledger's
|
|
671
705
|
// own doctrine — the audit trail must not silently change; the `ledger-rechained` event
|
|
@@ -676,7 +710,7 @@ async function main(argv) {
|
|
|
676
710
|
if ('refused' in plan)
|
|
677
711
|
return { line: `✗ ${name}: ${plan.refused}`, fail: true };
|
|
678
712
|
if (plan.alreadyVerified)
|
|
679
|
-
return { line: `· ${name}:
|
|
713
|
+
return { line: `· ${name}: already verifies — no change` };
|
|
680
714
|
const audited = plan.audited;
|
|
681
715
|
// ATOMIC rewrite: a crash mid-write must leave the old file, never a verifying prefix that
|
|
682
716
|
// the rerun's no-change guard silently accepts.
|
|
@@ -686,17 +720,17 @@ async function main(argv) {
|
|
|
686
720
|
fs.renameSync(tmp, abs);
|
|
687
721
|
// Confirm the ON-DISK result matches what we verified (round-15): the success line must
|
|
688
722
|
// reflect the file, not just the in-memory array — a torn/short write is reported, not
|
|
689
|
-
// silently blessed as '
|
|
723
|
+
// silently blessed as 'verified'.
|
|
690
724
|
if (fs.readFileSync(abs, 'utf8') !== serialized) {
|
|
691
|
-
return { line: `✗ ${name}:
|
|
725
|
+
return { line: `✗ ${name}: the rewritten file differs from what was verified (disk fault?) — re-run.`, fail: true };
|
|
692
726
|
}
|
|
693
|
-
return { line: `✓ ${name}:
|
|
727
|
+
return { line: `✓ ${name}: rechained — mainline ${fixedMainline.length}(${mainlineAt.slice(0, 8)}) + orphans ${plan.orphanCount}, audit event recorded, chain verifies` };
|
|
694
728
|
}, { onStaleBreak: new ProvenanceChain(abs).staleBreakRecorder() });
|
|
695
729
|
}
|
|
696
730
|
catch (e) {
|
|
697
731
|
// Lock contention is a per-file refusal, not a crash.
|
|
698
732
|
if (e instanceof LedgerLockError) {
|
|
699
|
-
process.stdout.write(`✗ ${name}:
|
|
733
|
+
process.stdout.write(`✗ ${name}: could not acquire the ledger lock (another writer is active) — retry shortly.\n`);
|
|
700
734
|
failed++;
|
|
701
735
|
continue;
|
|
702
736
|
}
|
|
@@ -710,7 +744,7 @@ async function main(argv) {
|
|
|
710
744
|
fs.rmSync(abs + '.rechain-tmp', { force: true });
|
|
711
745
|
}
|
|
712
746
|
catch { /* best-effort */ }
|
|
713
|
-
process.stdout.write(`✗ ${name}:
|
|
747
|
+
process.stdout.write(`✗ ${name}: error during rewrite — ${e.message.split('\n')[0]}. This file is skipped (others continue). Investigate the cause, then re-run.\n`);
|
|
714
748
|
failed++;
|
|
715
749
|
continue;
|
|
716
750
|
}
|
|
@@ -762,13 +796,13 @@ async function main(argv) {
|
|
|
762
796
|
return 1;
|
|
763
797
|
}
|
|
764
798
|
const r = grantRequest(root, ref.id, opts);
|
|
765
|
-
process.stdout.write(r.ok ? `✓
|
|
799
|
+
process.stdout.write(r.ok ? `✓ approved — ${ref.subject('✓ approved — ')}\n${ref.detail(`valid until ${r.expires}`)}\n` : `✗ ${r.reason}\n`);
|
|
766
800
|
return r.ok ? 0 : 1;
|
|
767
801
|
}
|
|
768
802
|
if (typeof flags.deny === 'string') {
|
|
769
803
|
const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
|
|
770
804
|
if (reason === '') {
|
|
771
|
-
process.stderr.write('✗ --deny
|
|
805
|
+
process.stderr.write('✗ --deny requires --reason — a denial with no reason is invisible to the agent\n');
|
|
772
806
|
return 1;
|
|
773
807
|
}
|
|
774
808
|
const ref = resolveRef(flags.deny);
|
|
@@ -777,13 +811,13 @@ async function main(argv) {
|
|
|
777
811
|
return 1;
|
|
778
812
|
}
|
|
779
813
|
const r = denyRequest(root, ref.id, reason, actor);
|
|
780
|
-
process.stdout.write(r.ok ? `✓
|
|
814
|
+
process.stdout.write(r.ok ? `✓ denied — ${ref.subject('✓ denied — ')}\n${ref.detail('the denial was recorded')}\n` : `✗ ${r.reason}\n`);
|
|
781
815
|
return r.ok ? 0 : 1;
|
|
782
816
|
}
|
|
783
817
|
if (typeof flags.ask === 'string') {
|
|
784
818
|
const question = typeof flags.question === 'string' ? flags.question.trim() : '';
|
|
785
819
|
if (question === '') {
|
|
786
|
-
process.stderr.write('✗ --ask
|
|
820
|
+
process.stderr.write('✗ --ask requires --question\n');
|
|
787
821
|
return 1;
|
|
788
822
|
}
|
|
789
823
|
const ref = resolveRef(flags.ask);
|
|
@@ -792,7 +826,7 @@ async function main(argv) {
|
|
|
792
826
|
return 1;
|
|
793
827
|
}
|
|
794
828
|
const r = holdRequest(root, ref.id, question, actor);
|
|
795
|
-
process.stdout.write(r.ok ? `✓
|
|
829
|
+
process.stdout.write(r.ok ? `✓ held — ${ref.subject('✓ held — ')}\n${ref.detail('the question rides the next refusal')}\n` : `✗ ${r.reason}\n`);
|
|
796
830
|
return r.ok ? 0 : 1;
|
|
797
831
|
}
|
|
798
832
|
// @implements A-SPEC-507.1 — stale folding is a LIST-VIEW judgment: the clock enters only
|
|
@@ -814,12 +848,12 @@ async function main(argv) {
|
|
|
814
848
|
if (typeof flags['poll-ms'] === 'string') {
|
|
815
849
|
pollMs = Number(flags['poll-ms']);
|
|
816
850
|
if (!Number.isInteger(pollMs) || pollMs <= 0) {
|
|
817
|
-
process.stderr.write(`✗ --poll-ms
|
|
851
|
+
process.stderr.write(`✗ --poll-ms must be a positive integer (milliseconds) — received: ${flags['poll-ms']}\n`);
|
|
818
852
|
return 1;
|
|
819
853
|
}
|
|
820
854
|
}
|
|
821
855
|
if (!process.stdin.isTTY) {
|
|
822
|
-
process.stderr.write('✗
|
|
856
|
+
process.stderr.write('✗ interactive resident mode (--watch) requires a TTY — use --list in scripts\n');
|
|
823
857
|
return 1;
|
|
824
858
|
}
|
|
825
859
|
// @implements A-SPEC-497.2 — the resident surface decides on ONE raw keypress (a/d/q/s, no
|
|
@@ -911,7 +945,7 @@ async function main(argv) {
|
|
|
911
945
|
if (cmd === 'install-push-gate') {
|
|
912
946
|
const target = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
|
|
913
947
|
if (!fs.existsSync(path.join(target, '.git'))) {
|
|
914
|
-
process.stderr.write(`✗ ${target}
|
|
948
|
+
process.stderr.write(`✗ ${target} is not a git repository — the pre-push gate installs into .git/hooks\n`);
|
|
915
949
|
return 1;
|
|
916
950
|
}
|
|
917
951
|
const { PUSH_HOOK_SIGNATURE } = require('../governance/push-gate');
|
|
@@ -922,21 +956,21 @@ async function main(argv) {
|
|
|
922
956
|
const hookPath = path.join(effectiveHooksDir(target), 'pre-push');
|
|
923
957
|
// dist/holmes/cli → three up is the package root, the same derivation server.ts uses.
|
|
924
958
|
const runner = path.join(path.resolve(__dirname, '..', '..', '..'), 'bin', 'holmes-pre-push.js');
|
|
925
|
-
const script = `#!/usr/bin/env node\n// ${PUSH_HOOK_SIGNATURE} — \`npx holmes-kit install-push-gate
|
|
926
|
-
+ `// test-evidence
|
|
959
|
+
const script = `#!/usr/bin/env node\n// ${PUSH_HOOK_SIGNATURE} — installed by \`npx holmes-kit install-push-gate\`.\n`
|
|
960
|
+
+ `// Compares the test-evidence ledger against the push HEAD. A load failure passes (fail-open) — a gate error must not block a push.\n`
|
|
927
961
|
+ `try { require(${JSON.stringify(runner)}); } catch { process.exit(0); }\n`;
|
|
928
962
|
if (fs.existsSync(hookPath)) {
|
|
929
963
|
const current = fs.readFileSync(hookPath, 'utf8');
|
|
930
964
|
if (!current.includes(PUSH_HOOK_SIGNATURE)) {
|
|
931
|
-
process.stderr.write(`✗
|
|
932
|
-
+ `
|
|
965
|
+
process.stderr.write(`✗ an existing pre-push hook is present and holmes-kit did not write it — not overwriting.\n`
|
|
966
|
+
+ ` Manual merge: append this gate to the end of the existing hook as \`node ${runner} || exit 1\`.\n`);
|
|
933
967
|
return 1;
|
|
934
968
|
}
|
|
935
969
|
}
|
|
936
970
|
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
937
971
|
fs.writeFileSync(hookPath, script);
|
|
938
972
|
fs.chmodSync(hookPath, 0o755);
|
|
939
|
-
process.stdout.write(`✓ pre-push
|
|
973
|
+
process.stdout.write(`✓ pre-push evidence gate installed — ${hookPath}\n Passes when: test_run ledger head == push HEAD, green, executed > 0. Bypass: git push --no-verify\n`);
|
|
940
974
|
return 0;
|
|
941
975
|
}
|
|
942
976
|
if (cmd === 'init') {
|
|
@@ -951,8 +985,8 @@ async function main(argv) {
|
|
|
951
985
|
}
|
|
952
986
|
mcpLauncher = flags['mcp-launcher'];
|
|
953
987
|
}
|
|
954
|
-
//
|
|
955
|
-
// --mode
|
|
988
|
+
// Same shape as the sibling-enumeration error (round 7): --settings/--target exit 2 with USAGE,
|
|
989
|
+
// but --mode alone exited a bare 1 — two diagnoses of the same mistake split a script.
|
|
956
990
|
if (mode !== 'guardrail' && mode !== 'governed') {
|
|
957
991
|
process.stderr.write(`unknown --mode ${mode} (guardrail|governed)\n\n${USAGE}`);
|
|
958
992
|
return 2;
|
|
@@ -981,8 +1015,8 @@ async function main(argv) {
|
|
|
981
1015
|
// could change the meaning of the command line is refused rather than escaped.
|
|
982
1016
|
if (path.isAbsolute(specsDir) || specsDir.split(/[\\/]/).includes('..') || specsDir.startsWith('~')
|
|
983
1017
|
|| /["'`$\\;&|<>\n\r]/.test(specsDir)) {
|
|
984
|
-
// '~'
|
|
985
|
-
//
|
|
1018
|
+
// '~' survives only when the shell failed to expand it — left as-is it makes a literal '~'
|
|
1019
|
+
// directory inside the target and wires under that name (round 8).
|
|
986
1020
|
process.stderr.write(`--specs-dir ${specsDir} must be a plain relative path inside the target (no '..', no '~', no quotes or shell metacharacters)\n\n${USAGE}`);
|
|
987
1021
|
return 2;
|
|
988
1022
|
}
|
|
@@ -1014,8 +1048,33 @@ async function main(argv) {
|
|
|
1014
1048
|
agents = ['claude', 'antigravity'];
|
|
1015
1049
|
isAdditive = true;
|
|
1016
1050
|
}
|
|
1051
|
+
// @implements A-SPEC-552.1 — the project autonomy posture. `--autonomy`/`--no-autonomy` are the
|
|
1052
|
+
// non-interactive spellings (enabling then needs an out-of-band approval, enforced in runInit);
|
|
1053
|
+
// otherwise, at a real TTY, ASK — a `y` there is a human answer (autonomyInteractive), so it
|
|
1054
|
+
// needs no token. Non-TTY with no flag leaves it undefined = preserve the current posture.
|
|
1055
|
+
let autonomy;
|
|
1056
|
+
let autonomyInteractive = false;
|
|
1057
|
+
if (flags.autonomy === true) {
|
|
1058
|
+
autonomy = true;
|
|
1059
|
+
}
|
|
1060
|
+
else if (flags['no-autonomy'] === true) {
|
|
1061
|
+
autonomy = false;
|
|
1062
|
+
}
|
|
1063
|
+
else if (process.stdin.isTTY && process.stdout.isTTY && flags['dry-run'] !== true
|
|
1064
|
+
&& flags.remove !== true && flags['no-mcp'] !== true) {
|
|
1065
|
+
const { promptAutonomy } = require('./interactive-prompt');
|
|
1066
|
+
if (await promptAutonomy()) {
|
|
1067
|
+
autonomy = true;
|
|
1068
|
+
autonomyInteractive = true;
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
autonomy = false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1017
1074
|
const opts = {
|
|
1018
1075
|
agents,
|
|
1076
|
+
autonomy,
|
|
1077
|
+
autonomyInteractive,
|
|
1019
1078
|
allowAdditive: isAdditive,
|
|
1020
1079
|
target: path.resolve(typeof flags.target === 'string' ? flags.target : process.cwd()),
|
|
1021
1080
|
packageRoot: packageRoot(),
|
|
@@ -1090,8 +1149,8 @@ async function main(argv) {
|
|
|
1090
1149
|
},
|
|
1091
1150
|
});
|
|
1092
1151
|
}
|
|
1093
|
-
// round-8: `holmes-kit --target x`
|
|
1094
|
-
//
|
|
1152
|
+
// round-8: a flag in the command slot (`holmes-kit --target x` or `holmes-kit --`) exited 1,
|
|
1153
|
+
// while the same token after a subcommand exited 2 — two diagnoses of the same mistake.
|
|
1095
1154
|
process.stderr.write(cmd.startsWith('-')
|
|
1096
1155
|
? `unknown flag in command position: ${cmd}\n\n${USAGE}`
|
|
1097
1156
|
: `unknown command: ${cmd}\n\n${USAGE}`);
|