@holmes-lab/holmes-kit 0.12.1 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +13 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-context.js +10 -10
  5. package/dist/holmes/cli/approve-ref.js +5 -5
  6. package/dist/holmes/cli/approve-watch.d.ts +1 -1
  7. package/dist/holmes/cli/approve-watch.js +6 -6
  8. package/dist/holmes/cli/approve.d.ts +3 -3
  9. package/dist/holmes/cli/approve.js +57 -57
  10. package/dist/holmes/cli/autonomy.d.ts +22 -0
  11. package/dist/holmes/cli/autonomy.js +145 -0
  12. package/dist/holmes/cli/colophon.d.ts +6 -0
  13. package/dist/holmes/cli/colophon.js +24 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -2
  15. package/dist/holmes/cli/doctor.js +104 -87
  16. package/dist/holmes/cli/index.js +122 -63
  17. package/dist/holmes/cli/init.d.ts +2 -0
  18. package/dist/holmes/cli/init.js +31 -19
  19. package/dist/holmes/cli/interactive-prompt.d.ts +8 -0
  20. package/dist/holmes/cli/interactive-prompt.js +23 -0
  21. package/dist/holmes/cli/semantic-key.js +9 -9
  22. package/dist/holmes/cli/settings-merge.d.ts +2 -1
  23. package/dist/holmes/cli/settings-merge.js +15 -3
  24. package/dist/holmes/cli/upgrade.js +7 -7
  25. package/dist/holmes/cpg/proposed-content.js +2 -2
  26. package/dist/holmes/governance/autonomy.d.ts +9 -2
  27. package/dist/holmes/governance/autonomy.js +166 -5
  28. package/dist/holmes/guardrail/blind-spots.js +15 -15
  29. package/dist/holmes/hooks/pre-tool-use.js +111 -42
  30. package/dist/holmes/hooks/session-start.js +74 -34
  31. package/dist/holmes/hooks/stop.d.ts +1 -1
  32. package/dist/holmes/hooks/stop.js +12 -12
  33. package/dist/holmes/mcp/handlers.js +14 -1
  34. package/dist/holmes/mcp/server.js +19 -0
  35. package/dist/holmes/semantic/credentials.js +1 -1
  36. package/dist/holmes/spec/id-collision.js +2 -2
  37. package/dist/holmes/update/refresh.d.ts +49 -0
  38. package/dist/holmes/update/refresh.js +106 -0
  39. package/package.json +2 -2
  40. package/playbooks/publish/PLAYBOOK.md +47 -35
  41. 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 = [' 기록이 자기일관되지 않습니다 — id kind·target 에서 나온 값이 아닙니다.',
136
- ' 위조되었거나 손상된 줄이므로 요청은 결정하지 않습니다'].join('\n');
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 1..${MAX_TTL_MINUTES} 사이의 정수(분)여야 합니다 받은 값: ${(0, screen_safe_1.rowField)(String(ttl), 40)}` };
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: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 없습니다 ${(0, npx_bin_1.npxBin)()} holmes-kit approve --list 로 확인하십시오` };
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: `그랜트 파일을 없습니다:\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
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: '승인 기록( 이벤트) 없어 그랜트를 발급하지 않았습니다 — .ax/approvals 쓰기 권한을 확인하십시오' };
194
+ return { ok: false, reason: 'could not write the approval record (queue event), so no grant was issuedcheck 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: `그랜트 파일을 제자리에 놓지 못했습니다큐에는 승인 기록이 남았으니 다시 실행하십시오\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
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: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다` };
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: '거부 기록( 이벤트) 없었습니다 .ax/approvals 쓰기 권한을 확인하십시오. 아무것도 기록되지 않았습니다' };
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 — 추후 승인/거부. The agent carries the question to the user. */
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: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다` };
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: '보류 기록( 이벤트) 없었습니다 .ax/approvals 쓰기 권한을 확인하십시오. 아무것도 기록되지 않았습니다' };
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
- `(큐에 읽을 수 없는 줄 ${state.malformedLines}손상 여부를 확인하십시오)`].join('\n');
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 = [`◆ 승인 대기 ${state.pending.length}건`, ''];
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(` 이유: ${(0, screen_safe_1.rowField)(p.why, screen_safe_1.ROW_COLS)}`);
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(` 차단 ${p.count}회${since}${p.hold ? ' (보류 )' : ''}`);
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(` 질문: ${(0, screen_safe_1.rowField)(p.question ?? '(질문 없음)', screen_safe_1.ROW_COLS)}`);
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(`[만료] ${subjectCells('[만료] ', p)}`);
295
- lines.push(` 마지막 활동: ${(0, screen_safe_1.rowField)(p.lastTs, 28)} · id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
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(`⏳ 만료로 접힘 ${state.expired.length} (마지막 활동 ${view.staleHours}h 초과) — --all 로 표시`);
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(`(큐에 읽을 수 없는 줄 ${state.malformedLines}손상 여부를 확인하십시오)`);
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: `✓ 승인 — <expires> 까지 유효` named NOTHING, so an index
309
+ * What a decision line must say. Round-1: `✓ approvedvalid 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 `✓ 승인 — ` — and `✓ 거부 기록됨 — `
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 \uc2b9\uc778 \u2014 ') {
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 ? '' : ' # 위조된 id — 결정 불가'}`;
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
- '대화형 결정은 TTY에서만 동작합니다.',
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 \uc2b9\uc778 \u2014 ${decisionSubject(p, '\u2713 \uc2b9\uc778 \u2014 ')}`);
430
- io.print(decisionDetail(p, `${r.expires} \uae4c\uc9c0 \uc720\ud6a8`));
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]\uc2b9\uc778 [e]\uc0ac\uc720\u00b7\uae30\uac04 [v]\uc0c1\uc138 [d]\uac70\ubd80 [q]\ubcf4\ub958 [s]\uac74\ub108\ub700 [A]\uc804\uccb4 > ')).trim();
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 \ub0a8\uc740 \ud56d\ubaa9\uc740 \ud55c \ud589\uc5d0 \uc548 \ub2f4\uaca8 \uc77c\uad04 \ub300\uc0c1\uc774 \uc544\ub2d9\ub2c8\ub2e4 \u2014 [v]\ub85c \uac1c\ubcc4 \uacb0\uc815');
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 \ub2e4\uc74c ${batch.length}\uac74\uc744 \uc2b9\uc778\ud569\ub2c8\ub2e4:`);
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(` (\ud55c \ud589\uc5d0 \uc548 \ub2f4\uae30\ub294 ${oversize}\uac74 \uc81c\uc678 \u2014 [v]\ub85c \uac1c\ubcc4 \uacb0\uc815)`);
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(` (\ub098\uba38\uc9c0 ${deferred}\uac74\uc740 \ub2e4\uc74c [A] \uc5d0\uc11c \uc774\uc5b4\uc11c \ubb3b\uc2b5\ub2c8\ub2e4)`);
479
+ io.print(` (${deferred} more will be asked on the next [A])`);
480
480
  }
481
- const ok = (await io.ask(`\uc774 ${batch.length}\uac74\uc744 \uc2b9\uc778\ud569\ub2c8\uae4c? [y/N] > `)).trim().toLowerCase();
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 \uc77c\uad04 \uc2b9\uc778\uc744 \ucde8\uc18c\ud588\uc2b5\ub2c8\ub2e4 \u2014 \ud56d\ubaa9\ubcc4\ub85c \uacc4\uc18d\ud558\uc2ed\uc2dc\uc624');
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(`\uc0ac\uc720 (enter = "${DEFAULT_RATIONALE}") > `)).trim();
511
- const ttlRaw = (await io.ask(`\uc720\ud6a8 \uc2dc\uac04(\ubd84, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
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
- // ("30분") ended the whole session with exit 0 and every remaining item undecided — measured on
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 \uc2b9\uc778 \u2014 ${decisionSubject(p, '\u2713 \uc2b9\uc778 \u2014 ')}`);
526
- io.print(decisionDetail(p, `${r.expires} \uae4c\uc9c0 \uc720\ud6a8`));
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('\uac70\ubd80 \uc0ac\uc720(\uc5d0\uc774\uc804\ud2b8\uc5d0\uac8c \uc804\ub2ec\ub429\ub2c8\ub2e4) > ')).trim();
535
+ const reason = (await io.ask('denial reason (delivered to the agent) > ')).trim();
536
536
  if (reason === '') {
537
- io.print('\u2717 \uc0ac\uc720 \uc5c6\ub294 \uac70\ubd80\ub294 \uc5d0\uc774\uc804\ud2b8\uac00 \ubcfc \uc218 \uc5c6\uc2b5\ub2c8\ub2e4 \u2014 \uac74\ub108\ub701\ub2c8\ub2e4');
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 \uac70\ubd80 \u2014 ${decisionSubject(p, '\u2713 \uac70\ubd80 \u2014 ')}`);
543
- io.print(decisionDetail(p, '\uac70\ubd80\uac00 \uae30\ub85d\ub410\uc2b5\ub2c8\ub2e4'));
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('\uc9c8\ubb38(\uc5d0\uc774\uc804\ud2b8\uac00 \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc804\ub2ec\ud569\ub2c8\ub2e4) > ')).trim();
549
+ const question = (await io.ask('question (the agent carries it to the user) > ')).trim();
550
550
  if (question === '') {
551
- io.print('\u2717 \ube48 \uc9c8\ubb38 \u2014 \uac74\ub108\ub701\ub2c8\ub2e4');
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 \ubcf4\ub958 \u2014 ${decisionSubject(p, '\u2713 \ubcf4\ub958 \u2014 ')}`);
557
- io.print(decisionDetail(p, '\uc9c8\ubb38\uc740 \ub2e4\uc74c \uac70\ubd80 \ubb38\uba74\uc5d0'));
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 \uc54c \uc218 \uc5c6\ub294 \ud0a4\uc785\ub2c8\ub2e4${raw === '' ? '' : `: '${(0, screen_safe_1.rowField)(raw, 20)}'`} \u2014 \ub2e4\uc2dc \uc785\ub825\ud558\uc2ed\uc2dc\uc624`);
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 배선이 설치본으로 해석됩니다 <- path resolution only
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 "임시 조치": a semantic key pattern inside the PROJECT's .env. The value is read
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): {