@holmes-lab/holmes-kit 0.1.18 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +5 -1
  4. package/dist/holmes/cli/approve-context.d.ts +2 -0
  5. package/dist/holmes/cli/approve-context.js +180 -0
  6. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  7. package/dist/holmes/cli/approve-ref.js +40 -0
  8. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  9. package/dist/holmes/cli/approve-watch.js +94 -0
  10. package/dist/holmes/cli/approve.d.ts +50 -13
  11. package/dist/holmes/cli/approve.js +354 -38
  12. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  13. package/dist/holmes/cli/codex-toml.js +282 -0
  14. package/dist/holmes/cli/doctor.js +206 -0
  15. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  16. package/dist/holmes/cli/gitignore-merge.js +17 -1
  17. package/dist/holmes/cli/index.d.ts +23 -0
  18. package/dist/holmes/cli/index.js +490 -21
  19. package/dist/holmes/cli/init.js +92 -0
  20. package/dist/holmes/cli/interactive-prompt.js +4 -4
  21. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  22. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  23. package/dist/holmes/cli/screen-safe.js +760 -0
  24. package/dist/holmes/governance/approval-queue.js +56 -4
  25. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  26. package/dist/holmes/governance/ledger-rechain.js +95 -0
  27. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  28. package/dist/holmes/governance/provenance-chain.js +91 -16
  29. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  30. package/dist/holmes/governance/provenance-ledger.js +10 -0
  31. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  32. package/dist/holmes/guardrail/risk-gate.js +10 -0
  33. package/dist/holmes/guardrail/write-target.js +7 -0
  34. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  35. package/dist/holmes/mcp/elicit-approval.js +79 -0
  36. package/dist/holmes/mcp/handlers.d.ts +7 -2
  37. package/dist/holmes/mcp/handlers.js +190 -24
  38. package/dist/holmes/mcp/server.js +26 -1
  39. package/dist/holmes/spec/id-collision.d.ts +39 -0
  40. package/dist/holmes/spec/id-collision.js +86 -0
  41. package/dist/holmes/spec/spec-store.js +9 -1
  42. package/package.json +1 -1
@@ -37,12 +37,17 @@ exports.grantRequest = grantRequest;
37
37
  exports.denyRequest = denyRequest;
38
38
  exports.holdRequest = holdRequest;
39
39
  exports.renderPending = renderPending;
40
+ exports.subjectCells = subjectCells;
41
+ exports.subjectRoom = subjectRoom;
42
+ exports.decisionSubject = decisionSubject;
43
+ exports.decisionDetail = decisionDetail;
40
44
  exports.renderNonTtyHint = renderNonTtyHint;
41
45
  exports.runInteractive = runInteractive;
42
46
  // @implements A-SPEC-246
43
47
  const fs = __importStar(require("node:fs"));
44
48
  const path = __importStar(require("node:path"));
45
49
  const approval_queue_1 = require("../governance/approval-queue");
50
+ const screen_safe_1 = require("./screen-safe");
46
51
  const approval_grants_1 = require("../governance/approval-grants");
47
52
  /**
48
53
  * `holmes-kit approve` — the surface where a human decides.
@@ -62,18 +67,43 @@ const approval_grants_1 = require("../governance/approval-grants");
62
67
  * The moment the human must know scope syntax, this UI has no reason to exist.
63
68
  */
64
69
  const DEFAULT_TTL_MINUTES = 30;
70
+ // @implements A-SPEC-262.1 — round-5: the guard bounded TTL below only. `--ttl 1e12` threw an
71
+ // uncaught RangeError out of the interactive loop (killing every remaining decision on the surface
72
+ // whose purpose is making decisions cheap), and `--ttl 1e10` minted a grant expiring in the year
73
+ // 21039 — permanent authority from a slipped digit, in the channel REQ-245 built on "a grant is
74
+ // only a grant when it is narrow". A week is already generous for a narrow grant.
75
+ const MAX_TTL_MINUTES = 7 * 24 * 60;
65
76
  const DEFAULT_RATIONALE = 'approved via holmes-kit approve';
66
77
  const findPending = (root, id) => {
67
78
  const state = (0, approval_queue_1.readQueue)(root);
68
79
  return { entry: state.pending.find((p) => p.id === id), state };
69
80
  };
70
81
  /** CLI-side event writer. The CLI runs in the operator's terminal, outside the session gates. */
82
+ /**
83
+ * A path this process may write: it exists and is a regular file, or it does not exist yet.
84
+ *
85
+ * Round-8: round-7 gave the queue's reader and its enqueue this guard and stopped there, so the two
86
+ * writes a DECISION makes — the audit append and the grant file — could still be aimed at a FIFO.
87
+ * Measured: a named pipe at the grant temp path left the shipped `approve --grant 1` blocked inside
88
+ * open(2) with no output at all, and that request could never be decided again. `lstat`, so a
89
+ * symlink is refused rather than followed somewhere this project does not own.
90
+ */
91
+ const isWritablePath = (file) => {
92
+ try {
93
+ return fs.lstatSync(file).isFile();
94
+ }
95
+ catch {
96
+ return true;
97
+ } // absent is fine; it gets created
98
+ };
71
99
  const appendEvent = (root, event) => {
72
100
  try {
73
101
  const file = path.join(root, approval_queue_1.QUEUE_RELPATH);
74
102
  if (!fs.existsSync(path.join(root, '.ax')))
75
103
  return false;
76
104
  fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ if (!isWritablePath(file))
106
+ return false;
77
107
  fs.appendFileSync(file, JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n');
78
108
  return true;
79
109
  }
@@ -88,48 +118,115 @@ const appendEvent = (root, event) => {
88
118
  * A TTL that is not a positive integer is refused — a grant with no meaningful expiry violates the
89
119
  * narrowness rule the whole file channel rests on (REQ-245).
90
120
  */
121
+ /**
122
+ * @implements A-SPEC-262.1
123
+ * The id must DERIVE from the record it labels. Round-3: `foldQueue` takes `{id, kind, target}`
124
+ * verbatim, and nothing checked that `id === approvalRequestId(kind, target)` — so a record could
125
+ * pair a benign-looking id with a hostile target and the grant took its scope pattern from the
126
+ * fresh read. No escape byte is needed for that attack; the round-1 echo line even confirms the
127
+ * substituted subject, because the echo is rendered from the snapshot the human saw. Every
128
+ * legitimate entry satisfies this (enqueueApprovalRequest computes the id the same way), so a
129
+ * mismatch is a tampered or corrupt record and is refused rather than acted on.
130
+ */
131
+ // Folded to rows at the source (round-5): a refusal that wraps is a refusal whose first clause can
132
+ // scroll away, and this one names the reason a decision was withheld.
133
+ const TAMPERED = ['큐 기록이 자기일관되지 않습니다 — id 가 kind·target 에서 나온 값이 아닙니다.',
134
+ ' 위조되었거나 손상된 줄이므로 이 요청은 결정하지 않습니다'].join('\n');
135
+ function recordIsSelfConsistent(entry) {
136
+ return entry.id === (0, approval_queue_1.approvalRequestId)(entry.kind, entry.target);
137
+ }
91
138
  function grantRequest(root, id, opts) {
92
139
  const ttl = opts.ttlMinutes ?? DEFAULT_TTL_MINUTES;
93
- if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0) {
94
- return { ok: false, reason: `TTL 은 1 이상의 정수(분)여야 합니다 — 받은 값: ${String(ttl)}` };
140
+ if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_MINUTES) {
141
+ return { ok: false, reason: `TTL 은 1..${MAX_TTL_MINUTES} 사이의 정수(분)여야 합니다 — 받은 값: ${(0, screen_safe_1.rowField)(String(ttl), 40)}` };
95
142
  }
96
143
  const { entry } = findPending(root, id);
97
144
  if (!entry)
98
- return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다 — npx holmes-kit approve --list 로 확인하십시오` };
145
+ return { ok: false, reason: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다 — npx holmes-kit approve --list 로 확인하십시오` };
146
+ if (!recordIsSelfConsistent(entry))
147
+ return { ok: false, reason: TAMPERED };
148
+ // A "does this match what the operator saw" parameter was written here too and then removed: the
149
+ // derivation check above already covers every substitution. A different (kind,target) yields a
150
+ // different id, so the lookup misses; the SAME id can only carry the same subject. An unexercised
151
+ // second guard is not defence in depth, it is a line nobody can prove still works (round-3
152
+ // mutation test: neutering it left the suite green).
99
153
  const expires = new Date(Date.now() + ttl * 60_000).toISOString();
100
154
  const grant = {
101
155
  actor: opts.actor,
102
156
  token: 'grant',
103
157
  rationale: opts.rationale?.trim() ? opts.rationale : DEFAULT_RATIONALE,
104
- scope: [{ kind: entry.kind, pattern: entry.target }],
158
+ // exact (round-14): the queue target is a LITERAL command the operator read on the decision
159
+ // row, not a glob. Without this a `*` in the target became a wildcard, so approving `rm -rf *`
160
+ // authorized `rm -rf /any/path` — the grant covering commands the operator never saw, breaking
161
+ // "the grant is for what the human saw" (round-3) in the scope layer.
162
+ scope: [{ kind: entry.kind, pattern: entry.target, exact: true }],
105
163
  expires,
106
164
  nonce: id,
107
165
  };
166
+ // @implements A-SPEC-262.1 — AUDIT AND AUTHORITY STAND OR FALL TOGETHER.
167
+ // Round-3 wrote the grant first and discarded the append's failure: a live nonce with no record.
168
+ // Round-4 put the audit first and created the mirror: an orphan `granted` event with no grant,
169
+ // which foldQueue treats as decided — the request vanished from `--list` and the log claimed an
170
+ // approval that never happened. The temp file makes the ordering honest: everything that can fail
171
+ // happens BEFORE either half is committed, and the rename is intra-directory.
172
+ const dir = path.join(root, approval_grants_1.GRANTS_RELDIR);
173
+ const tmp = path.join(dir, `.${id}.json.tmp`);
108
174
  try {
109
- const dir = path.join(root, approval_grants_1.GRANTS_RELDIR);
110
175
  fs.mkdirSync(dir, { recursive: true });
111
- fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(grant, null, 2) + '\n');
176
+ if (!isWritablePath(tmp) || !isWritablePath(path.join(dir, `${id}.json`))) {
177
+ return { ok: false, reason: '그랜트 파일 자리가 일반 파일이 아닙니다 — 큐 디렉터리를 확인하십시오' };
178
+ }
179
+ fs.writeFileSync(tmp, JSON.stringify(grant, null, 2));
112
180
  }
113
181
  catch (e) {
114
- return { ok: false, reason: `그랜트 파일을 없습니다: ${e instanceof Error ? e.message : String(e)}` };
182
+ // Round-7: budgeted at 200 columns on a one-row screen measured 232 columns, three terminal
183
+ // rows, and three of them in a row scrolled the [A] confirmation off a 24-row terminal. The same
184
+ // class round-6 closed in findingContext, three files over.
185
+ return { ok: false, reason: `그랜트 파일을 쓸 수 없습니다:\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
186
+ }
187
+ if (!appendEvent(root, { event: 'granted', id, actor: opts.actor, expires })) {
188
+ try {
189
+ fs.rmSync(tmp, { force: true });
190
+ }
191
+ catch { /* best effort: the temp name is not a grant */ }
192
+ return { ok: false, reason: '승인 기록(큐 이벤트)을 쓸 수 없어 그랜트를 발급하지 않았습니다 — .ax/approvals 쓰기 권한을 확인하십시오' };
193
+ }
194
+ try {
195
+ fs.renameSync(tmp, path.join(dir, `${id}.json`));
196
+ }
197
+ catch (e) {
198
+ return { ok: false, reason: `그랜트 파일을 제자리에 놓지 못했습니다 — 큐에는 승인 기록이 남았으니 다시 실행하십시오\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
115
199
  }
116
- appendEvent(root, { event: 'granted', id, actor: opts.actor });
117
200
  return { ok: true, expires };
118
201
  }
119
- /** Deny, with a reason the agent will see in its next refusal. */
120
202
  function denyRequest(root, id, reason, actor) {
121
203
  const { entry } = findPending(root, id);
122
204
  if (!entry)
123
- return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
124
- appendEvent(root, { event: 'denied', id, reason, actor });
205
+ return { ok: false, reason: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다` };
206
+ // @implements A-SPEC-262.1 — round-4: the derivation gate guarded grant only, so a tampered record
207
+ // let the operator "deny" one subject while the denial was keyed to another id entirely.
208
+ if (!recordIsSelfConsistent(entry))
209
+ return { ok: false, reason: TAMPERED };
210
+ // The event IS the decision here (there is no second artefact), so a failed append is a failed
211
+ // decision — round-5: ✓ and exit 0 were printed over a read-only queue while nothing was recorded,
212
+ // and the agent, never seeing the denial, retried forever.
213
+ if (!appendEvent(root, { event: 'denied', id, reason, actor })) {
214
+ return { ok: false, reason: '거부 기록(큐 이벤트)을 쓸 수 없었습니다 — .ax/approvals 쓰기 권한을 확인하십시오. 아무것도 기록되지 않았습니다' };
215
+ }
125
216
  return { ok: true };
126
217
  }
127
218
  /** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
128
219
  function holdRequest(root, id, question, actor) {
129
220
  const { entry } = findPending(root, id);
130
221
  if (!entry)
131
- return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
132
- appendEvent(root, { event: 'held', id, question, actor });
222
+ return { ok: false, reason: `대기 중인 요청 ${(0, screen_safe_1.rowField)(id, 40)} 가 없습니다` };
223
+ // @implements A-SPEC-262.1 — round-4: the derivation gate guarded grant only, so a tampered record
224
+ // let the operator "deny" one subject while the denial was keyed to another id entirely.
225
+ if (!recordIsSelfConsistent(entry))
226
+ return { ok: false, reason: TAMPERED };
227
+ if (!appendEvent(root, { event: 'held', id, question, actor })) {
228
+ return { ok: false, reason: '보류 기록(큐 이벤트)을 쓸 수 없었습니다 — .ax/approvals 쓰기 권한을 확인하십시오. 아무것도 기록되지 않았습니다' };
229
+ }
133
230
  return { ok: true };
134
231
  }
135
232
  /**
@@ -140,21 +237,91 @@ function holdRequest(root, id, question, actor) {
140
237
  * teaches syntax, humans start typing it.
141
238
  */
142
239
  function renderPending(state) {
143
- if (state.pending.length === 0)
240
+ if (state.pending.length === 0) {
241
+ // Round-7: this early return made the malformed-line row below UNREACHABLE in exactly the case
242
+ // it exists for — a wholly corrupted queue printed "no requests waiting" and exited 0, which is
243
+ // what an agent would see after destroying it. approval-queue.ts promises the opposite:
244
+ // "an empty-looking queue must be distinguishable from a corrupted one".
245
+ if (state.malformedLines > 0) {
246
+ return ['승인 대기 중인 요청이 없습니다.',
247
+ `(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`].join('\n');
248
+ }
144
249
  return '승인 대기 중인 요청이 없습니다.';
250
+ }
145
251
  const lines = [`◆ 승인 대기 ${state.pending.length}건`, ''];
146
252
  state.pending.forEach((p, i) => {
147
- lines.push(`[${i + 1}] ${p.kind} ${p.target}`);
148
- lines.push(` 이유: ${p.why}`);
149
- const since = p.firstTs ? ` · ${p.firstTs} 부터` : '';
150
- lines.push(` 차단 ${p.count}회${since}${p.hold ? ` (보류 중 — 질문: ${p.question ?? ''})` : ''}`);
151
- lines.push(` id: ${p.id}`);
253
+ // @implements A-SPEC-262.1 — every field here is AGENT-CONTROLLED (the target is the command it
254
+ // was blocked on). Round-1 forged two extra rows and hid the real ones behind an ANSI conceal,
255
+ // and the operator granted a `curl | sh` they never saw. The template owns the line structure.
256
+ lines.push(`[${i + 1}] ${subjectCells(`[${i + 1}] `, p)}`);
257
+ lines.push(` 이유: ${(0, screen_safe_1.rowField)(p.why, screen_safe_1.ROW_COLS)}`);
258
+ // Round-6: this row composed THREE separately-budgeted fields plus twenty columns of template,
259
+ // and round-5's whole-line accounting only covered the line PREFIXES. Measured at 117 columns
260
+ // for an ordinary hold — no forgery, no wide characters, just a question of average length. A
261
+ // hold's question is the actionable part, so it gets a row instead of being squeezed onto one.
262
+ const since = p.firstTs ? ` · ${(0, screen_safe_1.rowField)(p.firstTs, 28)} 부터` : '';
263
+ lines.push(` 차단 ${p.count}회${since}${p.hold ? ' (보류 중)' : ''}`);
264
+ if (p.hold)
265
+ lines.push(` 질문: ${(0, screen_safe_1.rowField)(p.question ?? '(질문 없음)', screen_safe_1.ROW_COLS)}`);
266
+ lines.push(` id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
152
267
  lines.push('');
153
268
  });
154
269
  if (state.malformedLines > 0)
155
270
  lines.push(`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`);
156
271
  return lines.join('\n');
157
272
  }
273
+ /**
274
+ * @implements A-SPEC-262.1
275
+ * What a decision line must say. Round-1: `✓ 승인 — <expires> 까지 유효` named NOTHING, so an index
276
+ * that resolved against a shifted queue granted a different request than the operator read, with no
277
+ * way to see it from the screen. Every decision now echoes the request it acted on.
278
+ */
279
+ /**
280
+ * @implements A-SPEC-262.1
281
+ * The kind cell and the subject, fitted to what is LEFT OF THE ROW after the caller's own prefix.
282
+ *
283
+ * Round-8: the budgets were global constants derived once, from `✓ 승인 — ` — and `✓ 거부 기록됨 — `
284
+ * is seven columns wider, so every denial echo ran to 88 columns and wrapped. Round-7 had widened
285
+ * the kind cell without re-measuring any line that carries it. A constant cannot know which prefix
286
+ * it will be printed behind, so the line computes its own room and the prefix is passed in. The
287
+ * floor keeps a forged kind from eating the subject entirely.
288
+ */
289
+ function subjectCells(prefix, p) {
290
+ return `${(0, screen_safe_1.rowField)(p.kind, screen_safe_1.KIND_COLS)} ${(0, screen_safe_1.rowField)(p.target, subjectRoom(prefix, p))}`;
291
+ }
292
+ /**
293
+ * @implements A-SPEC-262.1
294
+ * How many columns the SUBJECT gets behind `prefix` — the one place that knows, so a caller asking
295
+ * "will this fit?" and the caller that renders it cannot disagree.
296
+ *
297
+ * Round-9: `[A]`'s filter asked `isClipped(target, ROW_COLS)` with the constant 50 while the batch
298
+ * row it gates has 69 columns for `kind: 'shell'`. Every target between 51 and 69 columns was
299
+ * therefore refused as "not fitting a row" and told so on screen, while the list had already printed
300
+ * it whole — round-8's defect in the other direction, because round-8 fixed the renderer and left
301
+ * the predicate on the constant.
302
+ */
303
+ function subjectRoom(prefix, p) {
304
+ // No floor: round-10 measured the unfloored value at a minimum of 49 across 40,000 hostile kinds
305
+ // and every prefix this screen uses (the widest is ten columns, and the kind cell is capped at
306
+ // twenty), so `Math.max(16, …)` could never fire. An unexercised guard is not defence in depth —
307
+ // it is a second thing to keep true (round-5).
308
+ const kind = (0, screen_safe_1.rowField)(p.kind, screen_safe_1.KIND_COLS);
309
+ return screen_safe_1.ROW_MAX - (0, screen_safe_1.displayWidth)(prefix) - (0, screen_safe_1.displayWidth)(kind) - 1;
310
+ }
311
+ function decisionSubject(p, prefix = '\u2713 \uc2b9\uc778 \u2014 ') {
312
+ return subjectCells(prefix, p);
313
+ }
314
+ /**
315
+ * @implements A-SPEC-262.1
316
+ * The traceability cell, on a row of its own. Round-5: an 80-column row cannot hold the subject AND
317
+ * the id AND the expiry — the one-line form measured 81 columns and wrapped, and a wrapped decision
318
+ * line is how a queue scrolls its own evidence away. Splitting costs a row and keeps both facts
319
+ * whole; neither may be dropped, since the subject is what the human read and the id is what the
320
+ * grant is keyed by.
321
+ */
322
+ function decisionDetail(p, tail = '') {
323
+ return ` ${(0, screen_safe_1.rowField)(p.id, 20)}${tail === '' ? '' : ` \u00b7 ${(0, screen_safe_1.rowField)(tail, 40)}`}`;
324
+ }
158
325
  /**
159
326
  * The non-TTY fallback's next-command hint. Pure.
160
327
  *
@@ -168,55 +335,204 @@ function renderPending(state) {
168
335
  function renderNonTtyHint(state) {
169
336
  if (state.pending.length === 0)
170
337
  return '';
171
- const id = state.pending[0].id;
338
+ // @implements A-SPEC-262.1 — round-2: the id is AGENT-CONTROLLED (foldQueue takes it verbatim and
339
+ // .ax/approvals has no write gate), and these three lines exist to be COPIED INTO A SHELL. A
340
+ // crafted id carrying a concealed `curl … | sh` rode into the operator's clipboard invisibly.
341
+ // Flattened, so what is pasted is what is read. The index is offered first: it needs no id at all.
342
+ const p = state.pending[0];
343
+ // Flattened so it cannot forge a line, then SHELL-QUOTED so pasting it cannot run anything: this
344
+ // block exists to be copied into a shell, and the id is agent-controlled (round-2).
345
+ // Round-5 weighed the row rule against A-SPEC-260's contract here and the contract wins: these
346
+ // three lines are PASTED INTO A SHELL, so they must stay one line AND carry the id in full. A
347
+ // clipped id is not a shorter command, it is a command naming a request that does not exist — or,
348
+ // worse, a different one.
349
+ //
350
+ // Round-9: §13 routed this through `rowField`, which DOUBLES BACKSLASHES — so the pasted command
351
+ // named an id that does not exist, and the refusal then rendered the doubling twice over. A real
352
+ // queue id is `req-` plus twelve hex digits (`recordIsSelfConsistent` refuses to decide anything
353
+ // else), so a well-formed id is printed verbatim and quoted, and anything else is shown in row
354
+ // form with a note: an id that cannot be decided is not a command worth pasting.
355
+ // One expression, not two arms: `rowField` is the identity on a well-formed id (twelve hex digits
356
+ // and a `req-` prefix are all printable ASCII), so a branch that printed it verbatim could not be
357
+ // distinguished from this by any input — an arm no test can reach is not a guarantee, it is a
358
+ // second thing to keep true.
359
+ const wellFormed = /^req-[0-9a-f]{12}$/.test(p.id);
360
+ const id = `${(0, screen_safe_1.safeRef)((0, screen_safe_1.rowField)(p.id, 40))}${wellFormed ? '' : ' # 위조된 id — 결정 불가'}`;
172
361
  return [
173
- '대화형 결정은 TTY에서만 동작합니다 — 이 셸에서는 아래 비대화형 명령을 사용하십시오:',
362
+ '대화형 결정은 TTY에서만 동작합니다.',
363
+ '이 셸에서는 아래 비대화형 명령을 사용하십시오:',
364
+ ' holmes-kit approve --grant 1 # 위 목록의 첫 항목 (번호로 참조 가능)',
174
365
  ` holmes-kit approve --grant ${id}`,
175
366
  ` holmes-kit approve --deny ${id} --reason "<사유>"`,
176
367
  ` holmes-kit approve --ask ${id} --question "<질문>"`,
177
368
  ].join('\n');
178
369
  }
179
370
  /**
180
- * The interactive loop — one entry at a time, one letter per decision.
371
+ * The interactive loop — one entry at a time, ONE KEY per decision.
181
372
  *
182
373
  * @implements A-SPEC-246
183
- * [a] mints the mechanical grant (optional rationale + TTL, enter accepts defaults), [d] requires a
184
- * reason because an unreasoned denial is invisible to the agent in any useful way, [q] holds with a
185
- * question the agent will carry to the user, [s] leaves the entry for later.
374
+ * @implements A-SPEC-262.1
375
+ * REQ-262 measured what the first cut cost: `[a]` then asked for a rationale and a TTL, so three
376
+ * approvals were nine inputs and the human left the surface to type elsewhere. Approving is now a
377
+ * single key with the defaults; the narrowing prompts moved to `[e]`, which is where someone who
378
+ * actually wants to narrow will look. `[v]` renders the SUBJECT (the spec body, the finding, the
379
+ * command) and re-asks the same item — looking is not deciding. `[A]` takes every remaining item,
380
+ * and it is only offered after `renderPending` has already put each item's summary on the screen
381
+ * (REQ-262 Constraints: nothing is approved unseen). `[d]` still demands a reason, because an
382
+ * unreasoned denial is invisible to the agent; `[q]` holds with a question; `[s]` leaves it.
186
383
  */
187
384
  async function runInteractive(root, io, actor) {
188
385
  const state = (0, approval_queue_1.readQueue)(root);
189
386
  io.print(renderPending(state));
190
387
  if (state.pending.length === 0)
191
388
  return;
192
- for (const p of state.pending) {
193
- io.print(`\n─ ${p.kind} ${p.target}`);
194
- const answer = (await io.ask('[a]승인 [d]거부 [q]질문 남기고 보류 [s]건너뛰기 > ')).trim().toLowerCase();
389
+ const grantDefault = (p) => {
390
+ const r = grantRequest(root, p.id, { actor });
391
+ if (!r.ok) {
392
+ io.print(`\u2717 ${r.reason}`);
393
+ return false;
394
+ }
395
+ io.print(`\u2713 \uc2b9\uc778 \u2014 ${decisionSubject(p, '\u2713 \uc2b9\uc778 \u2014 ')}`);
396
+ io.print(decisionDetail(p, `${r.expires} \uae4c\uc9c0 \uc720\ud6a8`));
397
+ return true;
398
+ };
399
+ const queue = [...state.pending];
400
+ for (let i = 0; i < queue.length; i++) {
401
+ const p = queue[i];
402
+ // @implements A-SPEC-262.1 — round-2: THIS line was the seam round-1 missed. renderPending was
403
+ // sanitised; the header printed immediately above the prompt was not, so an erase-and-repaint
404
+ // target wiped the safe list, forged a benign header, and the operator pressed [a] on it.
405
+ io.print(`\n\u2500 ${subjectCells('\u2500 ', p)}`);
406
+ // The raw answer is kept: [A] and [a] are different keys, so lowercasing before the branch
407
+ // would erase the batch decision.
408
+ 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();
409
+ if (raw === 'A') {
410
+ // @implements A-SPEC-262.1 — CONFIRMED, with the count and the list. Round-1: this was the one
411
+ // decision in the loop with no second prompt, and it differs from [a] only by Shift — a
412
+ // Caps-Lock slip granted every item the operator had not looked at. The "summaries are already
413
+ // on screen" justification also fails on a queue longer than a screen, and right after a [v]
414
+ // scrolled them away, so the confirmation RE-PRINTS them.
415
+ // ROW BUDGET (round-5). The bound round-4 added was a code-point budget, and a code point is
416
+ // not a row: `⟪`, `⟫` and every Hangul character in the removal marker are TWO columns, so a
417
+ // 200-code-point line measured 302 columns — four wrapped rows for ONE item, and a full queue
418
+ // of them scrolls the earlier summaries away before the y/N prompt, which is exactly the flood
419
+ // this re-print exists to prevent. Two bounds now, both in the unit the screen uses:
420
+ // · each item must fit WHOLE on one row — an item that would be clipped is not batched at
421
+ // all, because a clipped subject is a subject the operator did not see; it stays pending
422
+ // for an individual decision, where [v] shows it in full.
423
+ // · at most BATCH_MAX items per confirmation, so the list itself always fits a screen. The
424
+ // rest are not lost: [A] again takes the next batch.
425
+ const BATCH_MAX = 20;
426
+ const rest = queue.slice(i);
427
+ const fits = (q) => !(0, screen_safe_1.isClipped)(q.target, subjectRoom(' - ', q));
428
+ const showable = rest.filter(fits);
429
+ const oversize = rest.length - showable.length;
430
+ const batch = showable.slice(0, BATCH_MAX);
431
+ const deferred = showable.length - batch.length;
432
+ if (batch.length === 0) {
433
+ 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');
434
+ i--;
435
+ continue;
436
+ }
437
+ io.print(`\u25c6 \ub2e4\uc74c ${batch.length}\uac74\uc744 \uc2b9\uc778\ud569\ub2c8\ub2e4:`);
438
+ for (const q of batch) {
439
+ io.print(` - ${subjectCells(' - ', q)}`);
440
+ }
441
+ if (oversize > 0) {
442
+ io.print(` (\ud55c \ud589\uc5d0 \uc548 \ub2f4\uae30\ub294 ${oversize}\uac74 \uc81c\uc678 \u2014 [v]\ub85c \uac1c\ubcc4 \uacb0\uc815)`);
443
+ }
444
+ if (deferred > 0) {
445
+ io.print(` (\ub098\uba38\uc9c0 ${deferred}\uac74\uc740 \ub2e4\uc74c [A] \uc5d0\uc11c \uc774\uc5b4\uc11c \ubb3b\uc2b5\ub2c8\ub2e4)`);
446
+ }
447
+ const ok = (await io.ask(`\uc774 ${batch.length}\uac74\uc744 \uc2b9\uc778\ud569\ub2c8\uae4c? [y/N] > `)).trim().toLowerCase();
448
+ if (ok !== 'y' && ok !== 'yes') {
449
+ 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');
450
+ i--;
451
+ continue;
452
+ }
453
+ // @implements A-SPEC-262.1 — round-7: `granted` was built from `batch` BEFORE any grant ran,
454
+ // so an item whose grant FAILED (a read-only grants dir, or the round-4 tamper refusal) was
455
+ // spliced out of the queue and never asked again — the session ended, exit 0, nothing granted
456
+ // and nothing pending re-shown. That is the doctrine round-6 wrote three lines below, broken
457
+ // in the branch round-6 rewrote. Only what actually succeeded leaves the queue.
458
+ const granted = new Set();
459
+ for (const q of batch) {
460
+ io.print(`\n\u2500 ${subjectCells('\u2500 ', q)}`);
461
+ if (grantDefault(q))
462
+ granted.add(q.id);
463
+ }
464
+ // Not `return`: the excluded and deferred items are still undecided, and dropping the operator
465
+ // out of the loop would silently leave them for a future session.
466
+ const remaining = rest.filter((q) => !granted.has(q.id));
467
+ queue.splice(i, rest.length, ...remaining);
468
+ i--;
469
+ continue;
470
+ }
471
+ const answer = raw.toLowerCase();
195
472
  if (answer === 'a') {
196
- const rationale = (await io.ask(`사유 (enter = "${DEFAULT_RATIONALE}") > `)).trim();
197
- const ttlRaw = (await io.ask(`유효 시간(분, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
473
+ grantDefault(p);
474
+ }
475
+ else if (answer === 'e') {
476
+ const rationale = (await io.ask(`\uc0ac\uc720 (enter = "${DEFAULT_RATIONALE}") > `)).trim();
477
+ const ttlRaw = (await io.ask(`\uc720\ud6a8 \uc2dc\uac04(\ubd84, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
198
478
  const ttl = ttlRaw === '' ? DEFAULT_TTL_MINUTES : Number(ttlRaw);
199
479
  const r = grantRequest(root, p.id, { actor, rationale, ttlMinutes: ttl });
200
- io.print(r.ok ? `✓ 승인 ${r.expires} 까지 유효` : `✗ ${r.reason}`);
480
+ // @implements A-SPEC-262.1 round-6: this branch alone RETURNED on failure, so a mistyped TTL
481
+ // ("30분") ended the whole session with exit 0 and every remaining item undecided — measured on
482
+ // a real pty, and the operator cannot tell it from a normal end of queue. The same `return`
483
+ // fired on the round-4 tamper refusal, which means a single forged queue line placed first
484
+ // could abandon the session when the operator pressed the MOST careful key. A refused decision
485
+ // is one item's problem; it re-asks, exactly like an unknown key.
486
+ if (!r.ok) {
487
+ io.print(`\u2717 ${r.reason}`);
488
+ i--;
489
+ continue;
490
+ }
491
+ io.print(`\u2713 \uc2b9\uc778 \u2014 ${decisionSubject(p, '\u2713 \uc2b9\uc778 \u2014 ')}`);
492
+ io.print(decisionDetail(p, `${r.expires} \uae4c\uc9c0 \uc720\ud6a8`));
493
+ }
494
+ else if (answer === 'v') {
495
+ const { renderDecisionContext } = require('./approve-context');
496
+ io.print(renderDecisionContext(root, p));
497
+ i--; // same item, asked again: seeing is not deciding
498
+ continue;
201
499
  }
202
500
  else if (answer === 'd') {
203
- const reason = (await io.ask('거부 사유(에이전트에게 전달됩니다) > ')).trim();
501
+ const reason = (await io.ask('\uac70\ubd80 \uc0ac\uc720(\uc5d0\uc774\uc804\ud2b8\uc5d0\uac8c \uc804\ub2ec\ub429\ub2c8\ub2e4) > ')).trim();
204
502
  if (reason === '') {
205
- io.print(' 사유 없는 거부는 에이전트가 없습니다 건너뜁니다');
503
+ 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');
206
504
  continue;
207
505
  }
208
506
  const r = denyRequest(root, p.id, reason, actor);
209
- io.print(r.ok ? '✓ 거부 기록됨' : `✗ ${r.reason}`);
507
+ if (r.ok) {
508
+ io.print(`\u2713 \uac70\ubd80 \u2014 ${decisionSubject(p, '\u2713 \uac70\ubd80 \u2014 ')}`);
509
+ io.print(decisionDetail(p, '\uac70\ubd80\uac00 \uae30\ub85d\ub410\uc2b5\ub2c8\ub2e4'));
510
+ }
511
+ else
512
+ io.print(`\u2717 ${r.reason}`);
210
513
  }
211
514
  else if (answer === 'q') {
212
- const question = (await io.ask('질문(에이전트가 사용자에게 전달합니다) > ')).trim();
515
+ const question = (await io.ask('\uc9c8\ubb38(\uc5d0\uc774\uc804\ud2b8\uac00 \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc804\ub2ec\ud569\ub2c8\ub2e4) > ')).trim();
213
516
  if (question === '') {
214
- io.print(' 질문 건너뜁니다');
517
+ io.print('\u2717 \ube48 \uc9c8\ubb38 \u2014 \uac74\ub108\ub701\ub2c8\ub2e4');
215
518
  continue;
216
519
  }
217
520
  const r = holdRequest(root, p.id, question, actor);
218
- io.print(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다' : `✗ ${r.reason}`);
521
+ if (r.ok) {
522
+ io.print(`\u2713 \ubcf4\ub958 \u2014 ${decisionSubject(p, '\u2713 \ubcf4\ub958 \u2014 ')}`);
523
+ io.print(decisionDetail(p, '\uc9c8\ubb38\uc740 \ub2e4\uc74c \uac70\ubd80 \ubb38\uba74\uc5d0'));
524
+ }
525
+ else
526
+ io.print(`\u2717 ${r.reason}`);
527
+ }
528
+ else if (answer !== 's') {
529
+ // @implements A-SPEC-262.1 — a typo is NOT a decision. Round-1: any unrecognized input (the
530
+ // double-tap `aa` of the new one-key flow, a bare Enter) silently advanced, so the operator
531
+ // saw the next item's prompt and read it as "approved, next please" while the item stayed
532
+ // pending and the agent stayed blocked. Only [s] skips; everything else asks again.
533
+ 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`);
534
+ i--;
535
+ continue;
219
536
  }
220
- // 's' and anything else: leave it for later.
221
537
  }
222
538
  }
@@ -0,0 +1,26 @@
1
+ import { McpServerEntry } from './mcp-launcher';
2
+ /** The one table holmes-kit owns in a Codex config.toml. */
3
+ export declare const CODEX_TABLE = "mcp_servers.holmes-kit";
4
+ /**
5
+ * Serialize the `[mcp_servers.holmes-kit]` table. `env` is an inline table so the whole entry is ONE
6
+ * contiguous region (no `[mcp_servers.holmes-kit.env]` child header) — that keeps the merge boundary
7
+ * unambiguous: our region runs from the header to the next table header. Ends with a trailing newline.
8
+ */
9
+ export declare function codexMcpBlock(entry: McpServerEntry, specsDir: string): string;
10
+ /**
11
+ * Merge `block` (a full `codexMcpBlock` output) into `existing`. When `existing` is null/empty the
12
+ * block stands alone. When our table is already present its region is REPLACED (no duplicate); when
13
+ * absent the block is appended after a blank-line separator. Every other line is preserved verbatim.
14
+ */
15
+ export declare function mergeCodexToml(existing: string | null, block: string): string;
16
+ /** Strip our region from `existing`, preserving everything else. Absent → returned unchanged. */
17
+ export declare function removeCodexToml(existing: string): string;
18
+ /**
19
+ * Read `{command,args}` from our table in a config.toml — for doctor's drift check. Parses only the
20
+ * shape `codexMcpBlock` writes; anything it cannot read returns null (doctor then WARNs rather than
21
+ * translating an unreadable wiring into a pass).
22
+ */
23
+ export declare function readCodexHolmesEntry(raw: string): {
24
+ command: string;
25
+ args: string[];
26
+ } | null;