@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
|
@@ -132,19 +132,19 @@ const appendEvent = (root, event) => {
|
|
|
132
132
|
*/
|
|
133
133
|
// Folded to rows at the source (round-5): a refusal that wraps is a refusal whose first clause can
|
|
134
134
|
// scroll away, and this one names the reason a decision was withheld.
|
|
135
|
-
const TAMPERED = ['
|
|
136
|
-
'
|
|
135
|
+
const TAMPERED = ['queue record is not self-consistent — id is not derived from kind/target',
|
|
136
|
+
' this line is forged or corrupted, so this request is not decided'].join('\n');
|
|
137
137
|
function recordIsSelfConsistent(entry) {
|
|
138
138
|
return entry.id === (0, approval_queue_1.approvalRequestId)(entry.kind, entry.target);
|
|
139
139
|
}
|
|
140
140
|
function grantRequest(root, id, opts) {
|
|
141
141
|
const ttl = opts.ttlMinutes ?? DEFAULT_TTL_MINUTES;
|
|
142
142
|
if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_MINUTES) {
|
|
143
|
-
return { ok: false, reason: `TTL
|
|
143
|
+
return { ok: false, reason: `TTL must be an integer number of minutes in 1..${MAX_TTL_MINUTES} — received: ${(0, screen_safe_1.rowField)(String(ttl), 40)}` };
|
|
144
144
|
}
|
|
145
145
|
const { entry } = findPending(root, id);
|
|
146
146
|
if (!entry)
|
|
147
|
-
return { ok: false, reason:
|
|
147
|
+
return { ok: false, reason: `no pending request ${(0, screen_safe_1.rowField)(id, 40)} — check with ${(0, npx_bin_1.npxBin)()} holmes-kit approve --list` };
|
|
148
148
|
if (!recordIsSelfConsistent(entry))
|
|
149
149
|
return { ok: false, reason: TAMPERED };
|
|
150
150
|
// A "does this match what the operator saw" parameter was written here too and then removed: the
|
|
@@ -176,7 +176,7 @@ function grantRequest(root, id, opts) {
|
|
|
176
176
|
try {
|
|
177
177
|
fs.mkdirSync(dir, { recursive: true });
|
|
178
178
|
if (!isWritablePath(tmp) || !isWritablePath(path.join(dir, `${id}.json`))) {
|
|
179
|
-
return { ok: false, reason: '
|
|
179
|
+
return { ok: false, reason: 'the grant-file slot is not a regular file — check the queue directory' };
|
|
180
180
|
}
|
|
181
181
|
fs.writeFileSync(tmp, JSON.stringify(grant, null, 2));
|
|
182
182
|
}
|
|
@@ -184,27 +184,27 @@ function grantRequest(root, id, opts) {
|
|
|
184
184
|
// Round-7: budgeted at 200 columns on a one-row screen — measured 232 columns, three terminal
|
|
185
185
|
// rows, and three of them in a row scrolled the [A] confirmation off a 24-row terminal. The same
|
|
186
186
|
// class round-6 closed in findingContext, three files over.
|
|
187
|
-
return { ok: false, reason:
|
|
187
|
+
return { ok: false, reason: `cannot write the grant file:\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
|
|
188
188
|
}
|
|
189
189
|
if (!appendEvent(root, { event: 'granted', id, actor: opts.actor, expires })) {
|
|
190
190
|
try {
|
|
191
191
|
fs.rmSync(tmp, { force: true });
|
|
192
192
|
}
|
|
193
193
|
catch { /* best effort: the temp name is not a grant */ }
|
|
194
|
-
return { ok: false, reason: '
|
|
194
|
+
return { ok: false, reason: 'could not write the approval record (queue event), so no grant was issued — check write permission on .ax/approvals' };
|
|
195
195
|
}
|
|
196
196
|
try {
|
|
197
197
|
fs.renameSync(tmp, path.join(dir, `${id}.json`));
|
|
198
198
|
}
|
|
199
199
|
catch (e) {
|
|
200
|
-
return { ok: false, reason:
|
|
200
|
+
return { ok: false, reason: `could not move the grant file into place — the approval record is already in the queue, so run this again\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
|
|
201
201
|
}
|
|
202
202
|
return { ok: true, expires };
|
|
203
203
|
}
|
|
204
204
|
function denyRequest(root, id, reason, actor) {
|
|
205
205
|
const { entry } = findPending(root, id);
|
|
206
206
|
if (!entry)
|
|
207
|
-
return { ok: false, reason:
|
|
207
|
+
return { ok: false, reason: `no pending request ${(0, screen_safe_1.rowField)(id, 40)}` };
|
|
208
208
|
// @implements A-SPEC-262.1 — round-4: the derivation gate guarded grant only, so a tampered record
|
|
209
209
|
// let the operator "deny" one subject while the denial was keyed to another id entirely.
|
|
210
210
|
if (!recordIsSelfConsistent(entry))
|
|
@@ -213,21 +213,21 @@ function denyRequest(root, id, reason, actor) {
|
|
|
213
213
|
// decision — round-5: ✓ and exit 0 were printed over a read-only queue while nothing was recorded,
|
|
214
214
|
// and the agent, never seeing the denial, retried forever.
|
|
215
215
|
if (!appendEvent(root, { event: 'denied', id, reason, actor })) {
|
|
216
|
-
return { ok: false, reason: '
|
|
216
|
+
return { ok: false, reason: 'could not write the denial record (queue event) — check write permission on .ax/approvals. Nothing was recorded' };
|
|
217
217
|
}
|
|
218
218
|
return { ok: true };
|
|
219
219
|
}
|
|
220
|
-
/** Hold with a question —
|
|
220
|
+
/** Hold with a question — approve/deny later. The agent carries the question to the user. */
|
|
221
221
|
function holdRequest(root, id, question, actor) {
|
|
222
222
|
const { entry } = findPending(root, id);
|
|
223
223
|
if (!entry)
|
|
224
|
-
return { ok: false, reason:
|
|
224
|
+
return { ok: false, reason: `no pending request ${(0, screen_safe_1.rowField)(id, 40)}` };
|
|
225
225
|
// @implements A-SPEC-262.1 — round-4: the derivation gate guarded grant only, so a tampered record
|
|
226
226
|
// let the operator "deny" one subject while the denial was keyed to another id entirely.
|
|
227
227
|
if (!recordIsSelfConsistent(entry))
|
|
228
228
|
return { ok: false, reason: TAMPERED };
|
|
229
229
|
if (!appendEvent(root, { event: 'held', id, question, actor })) {
|
|
230
|
-
return { ok: false, reason: '
|
|
230
|
+
return { ok: false, reason: 'could not write the hold record (queue event) — check write permission on .ax/approvals. Nothing was recorded' };
|
|
231
231
|
}
|
|
232
232
|
return { ok: true };
|
|
233
233
|
}
|
|
@@ -258,12 +258,12 @@ function renderPending(state, view) {
|
|
|
258
258
|
// what an agent would see after destroying it. approval-queue.ts promises the opposite:
|
|
259
259
|
// "an empty-looking queue must be distinguishable from a corrupted one".
|
|
260
260
|
if (state.malformedLines > 0) {
|
|
261
|
-
return ['
|
|
262
|
-
`(
|
|
261
|
+
return ['No requests awaiting approval.',
|
|
262
|
+
`(${state.malformedLines} unreadable line(s) in the queue — check for corruption)`].join('\n');
|
|
263
263
|
}
|
|
264
|
-
return '
|
|
264
|
+
return 'No requests awaiting approval.';
|
|
265
265
|
}
|
|
266
|
-
const lines = [`◆
|
|
266
|
+
const lines = [`◆ ${state.pending.length} request(s) awaiting approval`, ''];
|
|
267
267
|
// @implements A-SPEC-507.1 — the LIST VIEW sorts newest-activity-first and folds stale entries
|
|
268
268
|
// (C4: 51 rows, mostly week-old residue, buried the one live approval at [32]). Only when a view
|
|
269
269
|
// is passed: callers without one keep the legacy insertion order byte-for-byte.
|
|
@@ -275,15 +275,15 @@ function renderPending(state, view) {
|
|
|
275
275
|
// was blocked on). Round-1 forged two extra rows and hid the real ones behind an ANSI conceal,
|
|
276
276
|
// and the operator granted a `curl … | sh` they never saw. The template owns the line structure.
|
|
277
277
|
lines.push(`[${i + 1}] ${subjectCells(`[${i + 1}] `, p)}`);
|
|
278
|
-
lines.push(`
|
|
278
|
+
lines.push(` reason: ${(0, screen_safe_1.rowField)(p.why, screen_safe_1.ROW_COLS)}`);
|
|
279
279
|
// Round-6: this row composed THREE separately-budgeted fields plus twenty columns of template,
|
|
280
280
|
// and round-5's whole-line accounting only covered the line PREFIXES. Measured at 117 columns
|
|
281
281
|
// for an ordinary hold — no forgery, no wide characters, just a question of average length. A
|
|
282
282
|
// hold's question is the actionable part, so it gets a row instead of being squeezed onto one.
|
|
283
|
-
const since = p.firstTs ? ` · ${(0, screen_safe_1.rowField)(p.firstTs, 28)}
|
|
284
|
-
lines.push(`
|
|
283
|
+
const since = p.firstTs ? ` · since ${(0, screen_safe_1.rowField)(p.firstTs, 28)}` : '';
|
|
284
|
+
lines.push(` blocked ${p.count}x${since}${p.hold ? ' (on hold)' : ''}`);
|
|
285
285
|
if (p.hold)
|
|
286
|
-
lines.push(`
|
|
286
|
+
lines.push(` question: ${(0, screen_safe_1.rowField)(p.question ?? '(no question)', screen_safe_1.ROW_COLS)}`);
|
|
287
287
|
lines.push(` id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
|
|
288
288
|
lines.push('');
|
|
289
289
|
});
|
|
@@ -291,22 +291,22 @@ function renderPending(state, view) {
|
|
|
291
291
|
if (view.showAll) {
|
|
292
292
|
// Folding is not concealment: --all shows every expired entry under a template-owned prefix.
|
|
293
293
|
for (const p of state.expired) {
|
|
294
|
-
lines.push(`[
|
|
295
|
-
lines.push(`
|
|
294
|
+
lines.push(`[expired] ${subjectCells('[expired] ', p)}`);
|
|
295
|
+
lines.push(` last activity: ${(0, screen_safe_1.rowField)(p.lastTs, 28)} · id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
|
|
296
296
|
lines.push('');
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
else {
|
|
300
|
-
lines.push(`⏳
|
|
300
|
+
lines.push(`⏳ ${state.expired.length} folded as expired (no activity for over ${view.staleHours}h) — show with --all`);
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
if (state.malformedLines > 0)
|
|
304
|
-
lines.push(`(
|
|
304
|
+
lines.push(`(${state.malformedLines} unreadable line(s) in the queue — check for corruption)`);
|
|
305
305
|
return lines.join('\n');
|
|
306
306
|
}
|
|
307
307
|
/**
|
|
308
308
|
* @implements A-SPEC-262.1
|
|
309
|
-
* What a decision line must say. Round-1: `✓
|
|
309
|
+
* What a decision line must say. Round-1: `✓ approved — valid until <expires>` named NOTHING, so an index
|
|
310
310
|
* that resolved against a shifted queue granted a different request than the operator read, with no
|
|
311
311
|
* way to see it from the screen. Every decision now echoes the request it acted on.
|
|
312
312
|
*/
|
|
@@ -314,7 +314,7 @@ function renderPending(state, view) {
|
|
|
314
314
|
* @implements A-SPEC-262.1
|
|
315
315
|
* The kind cell and the subject, fitted to what is LEFT OF THE ROW after the caller's own prefix.
|
|
316
316
|
*
|
|
317
|
-
* Round-8: the budgets were global constants derived once, from `✓
|
|
317
|
+
* Round-8: the budgets were global constants derived once, from `✓ approved — ` — and `✓ denied — `
|
|
318
318
|
* is seven columns wider, so every denial echo ran to 88 columns and wrapped. Round-7 had widened
|
|
319
319
|
* the kind cell without re-measuring any line that carries it. A constant cannot know which prefix
|
|
320
320
|
* it will be printed behind, so the line computes its own room and the prefix is passed in. The
|
|
@@ -342,7 +342,7 @@ function subjectRoom(prefix, p) {
|
|
|
342
342
|
const kind = (0, screen_safe_1.rowField)(p.kind, screen_safe_1.KIND_COLS);
|
|
343
343
|
return screen_safe_1.ROW_MAX - (0, screen_safe_1.displayWidth)(prefix) - (0, screen_safe_1.displayWidth)(kind) - 1;
|
|
344
344
|
}
|
|
345
|
-
function decisionSubject(p, prefix = '\u2713
|
|
345
|
+
function decisionSubject(p, prefix = '\u2713 approved \u2014 ') {
|
|
346
346
|
return subjectCells(prefix, p);
|
|
347
347
|
}
|
|
348
348
|
/**
|
|
@@ -391,14 +391,14 @@ function renderNonTtyHint(state) {
|
|
|
391
391
|
// distinguished from this by any input — an arm no test can reach is not a guarantee, it is a
|
|
392
392
|
// second thing to keep true.
|
|
393
393
|
const wellFormed = /^req-[0-9a-f]{12}$/.test(p.id);
|
|
394
|
-
const id = `${(0, screen_safe_1.safeRef)((0, screen_safe_1.rowField)(p.id, 40))}${wellFormed ? '' : ' #
|
|
394
|
+
const id = `${(0, screen_safe_1.safeRef)((0, screen_safe_1.rowField)(p.id, 40))}${wellFormed ? '' : ' # forged id — cannot decide'}`;
|
|
395
395
|
return [
|
|
396
|
-
'
|
|
397
|
-
'
|
|
398
|
-
' holmes-kit approve --grant 1 #
|
|
396
|
+
'Interactive decisions work only in a TTY.',
|
|
397
|
+
'In this shell, use the non-interactive commands below:',
|
|
398
|
+
' holmes-kit approve --grant 1 # the first item above (referenceable by number)',
|
|
399
399
|
` holmes-kit approve --grant ${id}`,
|
|
400
|
-
` holmes-kit approve --deny ${id} --reason "
|
|
401
|
-
` holmes-kit approve --ask ${id} --question "
|
|
400
|
+
` holmes-kit approve --deny ${id} --reason "<reason>"`,
|
|
401
|
+
` holmes-kit approve --ask ${id} --question "<question>"`,
|
|
402
402
|
].join('\n');
|
|
403
403
|
}
|
|
404
404
|
/**
|
|
@@ -426,8 +426,8 @@ async function runInteractive(root, io, actor) {
|
|
|
426
426
|
io.print(`\u2717 ${r.reason}`);
|
|
427
427
|
return false;
|
|
428
428
|
}
|
|
429
|
-
io.print(`\u2713
|
|
430
|
-
io.print(decisionDetail(p,
|
|
429
|
+
io.print(`\u2713 approved \u2014 ${decisionSubject(p, '\u2713 approved \u2014 ')}`);
|
|
430
|
+
io.print(decisionDetail(p, `valid until ${r.expires}`));
|
|
431
431
|
return true;
|
|
432
432
|
};
|
|
433
433
|
const queue = [...state.pending];
|
|
@@ -439,7 +439,7 @@ async function runInteractive(root, io, actor) {
|
|
|
439
439
|
io.print(`\n\u2500 ${subjectCells('\u2500 ', p)}`);
|
|
440
440
|
// The raw answer is kept: [A] and [a] are different keys, so lowercasing before the branch
|
|
441
441
|
// would erase the batch decision.
|
|
442
|
-
const raw = (await io.ask('[a]
|
|
442
|
+
const raw = (await io.ask('[a]approve [e]reason\u00b7ttl [v]details [d]deny [q]hold [s]skip [A]all > ')).trim();
|
|
443
443
|
if (raw === 'A') {
|
|
444
444
|
// @implements A-SPEC-262.1 — CONFIRMED, with the count and the list. Round-1: this was the one
|
|
445
445
|
// decision in the loop with no second prompt, and it differs from [a] only by Shift — a
|
|
@@ -464,23 +464,23 @@ async function runInteractive(root, io, actor) {
|
|
|
464
464
|
const batch = showable.slice(0, BATCH_MAX);
|
|
465
465
|
const deferred = showable.length - batch.length;
|
|
466
466
|
if (batch.length === 0) {
|
|
467
|
-
io.print('\u2717
|
|
467
|
+
io.print('\u2717 remaining items do not fit on one row \u2014 decide individually with [v]');
|
|
468
468
|
i--;
|
|
469
469
|
continue;
|
|
470
470
|
}
|
|
471
|
-
io.print(`\u25c6
|
|
471
|
+
io.print(`\u25c6 approving the next ${batch.length}:`);
|
|
472
472
|
for (const q of batch) {
|
|
473
473
|
io.print(` - ${subjectCells(' - ', q)}`);
|
|
474
474
|
}
|
|
475
475
|
if (oversize > 0) {
|
|
476
|
-
io.print(` (
|
|
476
|
+
io.print(` (${oversize} excluded for not fitting on one row \u2014 decide individually with [v])`);
|
|
477
477
|
}
|
|
478
478
|
if (deferred > 0) {
|
|
479
|
-
io.print(` (
|
|
479
|
+
io.print(` (${deferred} more will be asked on the next [A])`);
|
|
480
480
|
}
|
|
481
|
-
const ok = (await io.ask(
|
|
481
|
+
const ok = (await io.ask(`Approve these ${batch.length}? [y/N] > `)).trim().toLowerCase();
|
|
482
482
|
if (ok !== 'y' && ok !== 'yes') {
|
|
483
|
-
io.print('\u2717
|
|
483
|
+
io.print('\u2717 batch approval cancelled \u2014 continue item by item');
|
|
484
484
|
i--;
|
|
485
485
|
continue;
|
|
486
486
|
}
|
|
@@ -507,12 +507,12 @@ async function runInteractive(root, io, actor) {
|
|
|
507
507
|
grantDefault(p);
|
|
508
508
|
}
|
|
509
509
|
else if (answer === 'e') {
|
|
510
|
-
const rationale = (await io.ask(
|
|
511
|
-
const ttlRaw = (await io.ask(
|
|
510
|
+
const rationale = (await io.ask(`reason (enter = "${DEFAULT_RATIONALE}") > `)).trim();
|
|
511
|
+
const ttlRaw = (await io.ask(`ttl (minutes, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
|
|
512
512
|
const ttl = ttlRaw === '' ? DEFAULT_TTL_MINUTES : Number(ttlRaw);
|
|
513
513
|
const r = grantRequest(root, p.id, { actor, rationale, ttlMinutes: ttl });
|
|
514
514
|
// @implements A-SPEC-262.1 — round-6: this branch alone RETURNED on failure, so a mistyped TTL
|
|
515
|
-
// ("
|
|
515
|
+
// ("30min") ended the whole session with exit 0 and every remaining item undecided — measured on
|
|
516
516
|
// a real pty, and the operator cannot tell it from a normal end of queue. The same `return`
|
|
517
517
|
// fired on the round-4 tamper refusal, which means a single forged queue line placed first
|
|
518
518
|
// could abandon the session when the operator pressed the MOST careful key. A refused decision
|
|
@@ -522,8 +522,8 @@ async function runInteractive(root, io, actor) {
|
|
|
522
522
|
i--;
|
|
523
523
|
continue;
|
|
524
524
|
}
|
|
525
|
-
io.print(`\u2713
|
|
526
|
-
io.print(decisionDetail(p,
|
|
525
|
+
io.print(`\u2713 approved \u2014 ${decisionSubject(p, '\u2713 approved \u2014 ')}`);
|
|
526
|
+
io.print(decisionDetail(p, `valid until ${r.expires}`));
|
|
527
527
|
}
|
|
528
528
|
else if (answer === 'v') {
|
|
529
529
|
const { renderDecisionContext } = require('./approve-context');
|
|
@@ -532,29 +532,29 @@ async function runInteractive(root, io, actor) {
|
|
|
532
532
|
continue;
|
|
533
533
|
}
|
|
534
534
|
else if (answer === 'd') {
|
|
535
|
-
const reason = (await io.ask('
|
|
535
|
+
const reason = (await io.ask('denial reason (delivered to the agent) > ')).trim();
|
|
536
536
|
if (reason === '') {
|
|
537
|
-
io.print('\u2717
|
|
537
|
+
io.print('\u2717 a denial with no reason is invisible to the agent \u2014 skipping');
|
|
538
538
|
continue;
|
|
539
539
|
}
|
|
540
540
|
const r = denyRequest(root, p.id, reason, actor);
|
|
541
541
|
if (r.ok) {
|
|
542
|
-
io.print(`\u2713
|
|
543
|
-
io.print(decisionDetail(p, '
|
|
542
|
+
io.print(`\u2713 denied \u2014 ${decisionSubject(p, '\u2713 denied \u2014 ')}`);
|
|
543
|
+
io.print(decisionDetail(p, 'the denial was recorded'));
|
|
544
544
|
}
|
|
545
545
|
else
|
|
546
546
|
io.print(`\u2717 ${r.reason}`);
|
|
547
547
|
}
|
|
548
548
|
else if (answer === 'q') {
|
|
549
|
-
const question = (await io.ask('
|
|
549
|
+
const question = (await io.ask('question (the agent carries it to the user) > ')).trim();
|
|
550
550
|
if (question === '') {
|
|
551
|
-
io.print('\u2717
|
|
551
|
+
io.print('\u2717 empty question \u2014 skipping');
|
|
552
552
|
continue;
|
|
553
553
|
}
|
|
554
554
|
const r = holdRequest(root, p.id, question, actor);
|
|
555
555
|
if (r.ok) {
|
|
556
|
-
io.print(`\u2713
|
|
557
|
-
io.print(decisionDetail(p, '
|
|
556
|
+
io.print(`\u2713 held \u2014 ${decisionSubject(p, '\u2713 held \u2014 ')}`);
|
|
557
|
+
io.print(decisionDetail(p, 'the question rides on the next denial message'));
|
|
558
558
|
}
|
|
559
559
|
else
|
|
560
560
|
io.print(`\u2717 ${r.reason}`);
|
|
@@ -564,7 +564,7 @@ async function runInteractive(root, io, actor) {
|
|
|
564
564
|
// double-tap `aa` of the new one-key flow, a bare Enter) silently advanced, so the operator
|
|
565
565
|
// saw the next item's prompt and read it as "approved, next please" while the item stayed
|
|
566
566
|
// pending and the agent stayed blocked. Only [s] skips; everything else asks again.
|
|
567
|
-
io.print(`\u2717
|
|
567
|
+
io.print(`\u2717 unknown key${raw === '' ? '' : `: '${(0, screen_safe_1.rowField)(raw, 20)}'`} \u2014 please enter again`);
|
|
568
568
|
i--;
|
|
569
569
|
continue;
|
|
570
570
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Approval } from '../guardrail/risk-gate';
|
|
2
|
+
/** `--for` accepts `2h` / `90m` / a bare number of hours. Minutes, capped at 7 days (like grants);
|
|
3
|
+
* null on a malformed value; default 2h when omitted. */
|
|
4
|
+
export declare function parseForMinutes(s: string | undefined): number | null;
|
|
5
|
+
export interface AutonomyIO {
|
|
6
|
+
now: string;
|
|
7
|
+
isTTY: boolean;
|
|
8
|
+
approval?: Approval;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
export interface AutonomyResult {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
exitCode: 0 | 1 | 2;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The `holmes-kit autonomy status|on|off` core. Pure over injected io (clock/TTY/approval/env) so
|
|
18
|
+
* every branch is testable. Enabling (`on`) is protected exactly like `init --autonomy`: the raw-TTY
|
|
19
|
+
* terminal is the human-presence proof, and a non-TTY caller (an agent's Bash) must carry an
|
|
20
|
+
* out-of-band HOLMES_APPROVAL covering kind `autonomy-grant`. Disabling is always free.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runAutonomy(root: string, sub: string | undefined, forStr: string | undefined, io: AutonomyIO): AutonomyResult;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseForMinutes = parseForMinutes;
|
|
37
|
+
exports.runAutonomy = runAutonomy;
|
|
38
|
+
// @implements A-SPEC-553.1
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
42
|
+
const autonomy_1 = require("../governance/autonomy");
|
|
43
|
+
const ledger_store_1 = require("../governance/ledger-store");
|
|
44
|
+
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
45
|
+
/** `--for` accepts `2h` / `90m` / a bare number of hours. Minutes, capped at 7 days (like grants);
|
|
46
|
+
* null on a malformed value; default 2h when omitted. */
|
|
47
|
+
function parseForMinutes(s) {
|
|
48
|
+
if (s === undefined)
|
|
49
|
+
return 120;
|
|
50
|
+
const m = /^\s*(\d+)\s*(h|m)?\s*$/.exec(s);
|
|
51
|
+
if (!m)
|
|
52
|
+
return null;
|
|
53
|
+
const n = parseInt(m[1], 10);
|
|
54
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
55
|
+
return null;
|
|
56
|
+
const mins = m[2] === 'm' ? n : n * 60;
|
|
57
|
+
return mins <= 7 * 24 * 60 ? mins : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The `holmes-kit autonomy status|on|off` core. Pure over injected io (clock/TTY/approval/env) so
|
|
61
|
+
* every branch is testable. Enabling (`on`) is protected exactly like `init --autonomy`: the raw-TTY
|
|
62
|
+
* terminal is the human-presence proof, and a non-TTY caller (an agent's Bash) must carry an
|
|
63
|
+
* out-of-band HOLMES_APPROVAL covering kind `autonomy-grant`. Disabling is always free.
|
|
64
|
+
*/
|
|
65
|
+
function runAutonomy(root, sub, forStr, io) {
|
|
66
|
+
const env = io.env ?? process.env;
|
|
67
|
+
const markerPath = path.join(root, ...autonomy_1.SESSION_AUTONOMY_MARKER);
|
|
68
|
+
if (sub === undefined || sub === 'status') {
|
|
69
|
+
const envOn = typeof env[autonomy_1.AUTONOMY_ENV] === 'string' && env[autonomy_1.AUTONOMY_ENV] !== '';
|
|
70
|
+
if (envOn)
|
|
71
|
+
return { ok: true, exitCode: 0, message: 'autonomous spec approval: ON (project default, .mcp.json) — governance-critical still ask you' };
|
|
72
|
+
if ((0, autonomy_1.sessionAutonomyActive)(root, io.now)) {
|
|
73
|
+
let expires = '';
|
|
74
|
+
try {
|
|
75
|
+
expires = String(JSON.parse(fs.readFileSync(markerPath, 'utf8')).expires);
|
|
76
|
+
}
|
|
77
|
+
catch { /* best effort */ }
|
|
78
|
+
return { ok: true, exitCode: 0, message: `autonomous spec approval: ON (this session, until ${expires}) — governance-critical still ask you` };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, exitCode: 0, message: 'autonomous spec approval: OFF — every spec approval is human (holmes-kit autonomy on to self-drive this session)' };
|
|
81
|
+
}
|
|
82
|
+
if (sub === 'off') {
|
|
83
|
+
// @implements A-SPEC-558.1 — `off` is the emergency de-escalation control; it must fail CLOSED.
|
|
84
|
+
// If the marker cannot be removed it is still ON, so never report OFF: re-check after rmSync and
|
|
85
|
+
// report failure honestly if it survives.
|
|
86
|
+
let existed = false;
|
|
87
|
+
let removeError;
|
|
88
|
+
try {
|
|
89
|
+
existed = fs.existsSync(markerPath);
|
|
90
|
+
fs.rmSync(markerPath, { force: true });
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
removeError = e instanceof Error ? e.message : String(e);
|
|
94
|
+
}
|
|
95
|
+
let stillThere = false;
|
|
96
|
+
try {
|
|
97
|
+
stillThere = fs.existsSync(markerPath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
stillThere = true;
|
|
101
|
+
}
|
|
102
|
+
if (stillThere) {
|
|
103
|
+
return { ok: false, exitCode: 1, message: `could not end session autonomy — the marker at ${markerPath} could not be removed`
|
|
104
|
+
+ `${removeError ? ` (${removeError})` : ''}. Autonomous spec approval is still ON.` };
|
|
105
|
+
}
|
|
106
|
+
if (existed) {
|
|
107
|
+
try {
|
|
108
|
+
new ledger_store_1.FileLedgerStore(path.join(root, '.ax', 'ledger')).append({
|
|
109
|
+
ts: io.now, actor: io.approval?.actor ?? 'operator', kind: 'autonomy-revoked',
|
|
110
|
+
summary: 'session autonomy ended', rationale: 'holmes-kit autonomy off',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch { /* audit is best-effort; the marker is gone either way */ }
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, exitCode: 0, message: 'autonomous spec approval: OFF — every spec approval is human again' };
|
|
116
|
+
}
|
|
117
|
+
if (sub === 'on') {
|
|
118
|
+
if (!io.isTTY && !(0, risk_gate_1.approvalCovers)(io.approval, { kind: 'autonomy-grant', target: root }, io.now)) {
|
|
119
|
+
return { ok: false, exitCode: 2, message: 'Enabling session autonomy needs a human: run it at a terminal, or supply an out-of-band approval.\n'
|
|
120
|
+
+ `Set HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why>"}' (scoped approvals need kind "autonomy-grant") and re-run.` };
|
|
121
|
+
}
|
|
122
|
+
const mins = parseForMinutes(forStr);
|
|
123
|
+
if (mins === null)
|
|
124
|
+
return { ok: false, exitCode: 2, message: '--for must look like 2h or 90m (1 minute .. 7 days)' };
|
|
125
|
+
const expires = new Date(Date.parse(io.now) + mins * 60_000).toISOString();
|
|
126
|
+
const grantedBy = io.approval?.actor ?? 'operator(tty)';
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
129
|
+
fs.writeFileSync(markerPath, JSON.stringify({ enabled: true, expires, grantedBy, rationale: `session autonomy for ${mins}m` }, null, 2) + '\n');
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { ok: false, exitCode: 1, message: `could not write the autonomy marker: ${e instanceof Error ? e.message : String(e)}` };
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
new ledger_store_1.FileLedgerStore(path.join(root, '.ax', 'ledger')).append({
|
|
136
|
+
ts: io.now, actor: grantedBy, kind: 'autonomy-granted', summary: `session autonomy until ${expires}`,
|
|
137
|
+
rationale: `${mins} minutes`, ...(io.approval ? { authorization: (0, provenance_chain_1.authorizationRef)(io.approval.actor, io.approval.token) } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch { /* the marker IS the audit record (grantedBy/expires); the ledger entry is additive */ }
|
|
141
|
+
return { ok: true, exitCode: 0, message: `autonomous spec approval: ON for this session, until ${expires}.\n`
|
|
142
|
+
+ 'Low-risk specs self-approve; governance-critical, high-risk and irreversible still ask you. End early: holmes-kit autonomy off.' };
|
|
143
|
+
}
|
|
144
|
+
return { ok: false, exitCode: 2, message: 'usage: holmes-kit autonomy [status|on [--for 2h]|off]' };
|
|
145
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @implements A-SPEC-548.1
|
|
3
|
+
* Pure: the maker's colophon printed by the hidden `holmes-kit elementary` / `whodunit` easter egg.
|
|
4
|
+
* English, on the detective theme. Undocumented — a mark for those who go looking.
|
|
5
|
+
*/
|
|
6
|
+
export declare function colophon(): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-548.1
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.colophon = colophon;
|
|
5
|
+
/**
|
|
6
|
+
* @implements A-SPEC-548.1
|
|
7
|
+
* Pure: the maker's colophon printed by the hidden `holmes-kit elementary` / `whodunit` easter egg.
|
|
8
|
+
* English, on the detective theme. Undocumented — a mark for those who go looking.
|
|
9
|
+
*/
|
|
10
|
+
function colophon() {
|
|
11
|
+
return [
|
|
12
|
+
'',
|
|
13
|
+
' holmes-kit — deterministic governance for agentic software engineering',
|
|
14
|
+
' "No Spec, No Code."',
|
|
15
|
+
'',
|
|
16
|
+
' Every spec sealed, every edit traced back to the requirement that asked for it.',
|
|
17
|
+
' When you have eliminated the untraceable, whatever remains must be the code.',
|
|
18
|
+
' Elementary, my dear Watson.',
|
|
19
|
+
'',
|
|
20
|
+
' Crafted by SungNam Park · sungnam.park.korea@gmail.com',
|
|
21
|
+
' https://www.npmjs.com/package/@holmes-lab/holmes-kit',
|
|
22
|
+
'',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
@@ -118,7 +118,7 @@ export declare function formatChecks(checks: Check[]): string;
|
|
|
118
118
|
* doctor flags the old location. But the second half was real and sat one layer further in:
|
|
119
119
|
*
|
|
120
120
|
* PASS mcp wiring spawn — node wiring resolves: …/holmes-mcp.js <- existence only
|
|
121
|
-
* PASS codex wiring — MCP
|
|
121
|
+
* PASS codex wiring — MCP wiring resolves to this install <- path resolution only
|
|
122
122
|
*
|
|
123
123
|
* The one check that drives a real `initialize -> tools/list` ran for `.mcp.json` ONLY, and only when
|
|
124
124
|
* the command was not `node` — which is what init writes for all three harnesses. So on the common
|
|
@@ -143,7 +143,7 @@ export declare function semanticTierVerdict(t: SemanticTier): {
|
|
|
143
143
|
};
|
|
144
144
|
/**
|
|
145
145
|
* @implements A-SPEC-477
|
|
146
|
-
* Detects the "
|
|
146
|
+
* Detects the "stopgap": a semantic key pattern inside the PROJECT's .env. The value is read
|
|
147
147
|
* only to detect the pattern and is never returned or printed.
|
|
148
148
|
*/
|
|
149
149
|
export declare function detectTreeKeyTemporary(root: string): {
|