@holmes-lab/holmes-kit 0.3.7 → 0.3.8

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 CHANGED
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.3.8] - 2026-09-01
9
+
10
+ Approval-UX slice pair (REQ-497 완결): the resident CLI surface decides on ONE raw keypress with
11
+ an OS arrival ping, and the in-session dialog lane gets a one-choice form, a finite re-display
12
+ and per-path deadlines.
13
+
14
+ ### Added
15
+
16
+ - **`approve --watch` single-key TUI + OS notification** (A-SPEC-497.2): a pending item is
17
+ decided by one raw keypress — `a` approve / `d` deny / `q` one-line question / `s` skip — no
18
+ Enter, no deadline (a human terminal can wait forever). Unknown keys decide nothing
19
+ (fail-closed) and Ctrl-C leaves without deciding. Each NEW arrival fires one OS-native
20
+ notification (macOS `osascript`, Windows WinRT toast; flattened text, every failure swallowed —
21
+ a dead notifier changes nothing about the queue or the gates).
22
+ - **Elicitation one-choice form, finite re-display, per-path deadlines** (A-SPEC-497.3): the
23
+ in-session approval dialog asks the DECISION alone (reason moves to a second dialog shown only
24
+ after deny/question — a question requires text, a deny stands without it); an expired dialog is
25
+ re-presented exactly once with a visible "재표시 2/2" counter (stale widgets survive on screen —
26
+ measured); and the deadline differs by path — lock-free `spec-approve` gets 240s, lock-holding
27
+ `review-resolve` keeps 120s with no re-display (starvation ceiling preserved). Decline, cancel
28
+ and any answer are never re-asked. Multi-cycle expiry measured end-to-end over the real MCP
29
+ protocol: two 240s presentations, summed 480s expired refusal, outer call alive.
30
+
31
+ ### Changed
32
+
33
+ - The `--watch` input path replaces the readline line-reader with a raw-mode key stream; the
34
+ non-watch interactive surface is unchanged. The elicitation stage-1 form no longer carries a
35
+ `reason` field (moved to stage 2); silent-path refusal text stays byte-identical
36
+ (A-SPEC-263.1), and the expiry notice format is unchanged (summed seconds).
37
+
7
38
  <!-- @implements A-SPEC-209 -->
8
39
  ## [0.3.7] - 2026-09-01
9
40
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- eb3164c-mthu5jwj
1
+ 809f8c3-mthx43ms
@@ -1,4 +1,28 @@
1
+ import { readQueue, PendingRequest } from '../governance/approval-queue';
1
2
  import { ApproveIO } from './approve';
3
+ /** The raw-mode key source a single-key watch listens to (process.stdin in the shipped CLI). */
4
+ export interface KeyStream {
5
+ setRawMode?: (raw: boolean) => unknown;
6
+ resume?: () => unknown;
7
+ pause?: () => unknown;
8
+ on(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
9
+ removeListener(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
10
+ }
11
+ /**
12
+ * ONE raw keypress decides an item — a/d/q/s; q collects one follow-up line (the question).
13
+ * Unknown keys decide NOTHING (a typo must not become an approval — fail-closed), and Ctrl-C
14
+ * rejects with AbortError: leaving is not deciding. No deadline anywhere: the MAIN LANE's whole
15
+ * point is that a human terminal can wait forever (H-SPEC-497).
16
+ */
17
+ export declare function decideByKey(stream: KeyStream, _item: Pick<PendingRequest, 'id'>): Promise<'granted' | 'denied' | 'skipped' | {
18
+ question: string;
19
+ }>;
20
+ /**
21
+ * Present each pending item and apply the single-key decision. The item summary goes through the
22
+ * SAME escaped cells the list uses (agent-controlled text never owns a line), and a denial gets a
23
+ * canned-but-visible reason — the agent must still see that a human said no.
24
+ */
25
+ export declare function presentByKey(root: string, io: ApproveIO, actor: string, stream: KeyStream, state: ReturnType<typeof readQueue>): Promise<void>;
2
26
  /** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
3
27
  export declare const WATCH_IDLE = "\u25C6 \uC2B9\uC778 \uB300\uAE30 \uC911\u2026 (Ctrl-C \uB85C \uC885\uB8CC)";
4
28
  /** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
@@ -13,6 +37,14 @@ export interface WatchOpts {
13
37
  * Must resolve early if `signal` aborts, so an idle watch shuts down at once.
14
38
  */
15
39
  waitIdle?: (ms: number, signal?: AbortSignal) => Promise<void>;
40
+ /**
41
+ * @implements A-SPEC-497.2
42
+ * When present, NEW items are presented via the single-key surface (presentByKey) instead of
43
+ * runInteractive — the shipped CLI passes raw-mode stdin here. Absent: legacy path, byte-for-byte.
44
+ */
45
+ keyStream?: KeyStream;
46
+ /** Called once per NEW pending item (lastPresented dedup) — a throwing notifier is swallowed. */
47
+ notify?: (item: PendingRequest) => void;
16
48
  }
17
49
  /** Exported for test: the shipped idle wait must resolve at once on abort, not after the timer. */
18
50
  export declare function defaultWaitIdle(ms: number, signal?: AbortSignal): Promise<void>;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_POLL_MS = exports.WATCH_IDLE = void 0;
4
+ exports.decideByKey = decideByKey;
5
+ exports.presentByKey = presentByKey;
4
6
  exports.defaultWaitIdle = defaultWaitIdle;
5
7
  exports.runWatch = runWatch;
6
8
  // @implements A-SPEC-262.2
@@ -14,6 +16,92 @@ exports.runWatch = runWatch;
14
16
  // existing runInteractive, and waits between polls when the queue is empty.
15
17
  const approval_queue_1 = require("../governance/approval-queue");
16
18
  const approve_1 = require("./approve");
19
+ // @implements A-SPEC-497.2
20
+ /**
21
+ * ONE raw keypress decides an item — a/d/q/s; q collects one follow-up line (the question).
22
+ * Unknown keys decide NOTHING (a typo must not become an approval — fail-closed), and Ctrl-C
23
+ * rejects with AbortError: leaving is not deciding. No deadline anywhere: the MAIN LANE's whole
24
+ * point is that a human terminal can wait forever (H-SPEC-497).
25
+ */
26
+ function decideByKey(stream, _item) {
27
+ return new Promise((resolve, reject) => {
28
+ let questionMode = false;
29
+ let line = '';
30
+ const onData = (chunk) => {
31
+ const s = chunk.toString();
32
+ for (const ch of s) {
33
+ if (ch === '') {
34
+ cleanup();
35
+ const e = new Error('operator left');
36
+ e.name = 'AbortError';
37
+ reject(e);
38
+ return;
39
+ }
40
+ if (questionMode) {
41
+ if (ch === '\r' || ch === '\n') {
42
+ cleanup();
43
+ resolve({ question: line });
44
+ return;
45
+ }
46
+ if (ch === '' || ch === '\b') {
47
+ line = line.slice(0, -1);
48
+ continue;
49
+ }
50
+ line += ch;
51
+ continue;
52
+ }
53
+ if (ch === 'a') {
54
+ cleanup();
55
+ resolve('granted');
56
+ return;
57
+ }
58
+ if (ch === 'd') {
59
+ cleanup();
60
+ resolve('denied');
61
+ return;
62
+ }
63
+ if (ch === 's') {
64
+ cleanup();
65
+ resolve('skipped');
66
+ return;
67
+ }
68
+ if (ch === 'q') {
69
+ questionMode = true;
70
+ continue;
71
+ }
72
+ // any other key: not a decision — keep listening
73
+ }
74
+ };
75
+ const cleanup = () => { stream.removeListener('data', onData); };
76
+ stream.on('data', onData);
77
+ });
78
+ }
79
+ // @implements A-SPEC-497.2
80
+ /**
81
+ * Present each pending item and apply the single-key decision. The item summary goes through the
82
+ * SAME escaped cells the list uses (agent-controlled text never owns a line), and a denial gets a
83
+ * canned-but-visible reason — the agent must still see that a human said no.
84
+ */
85
+ async function presentByKey(root, io, actor, stream, state) {
86
+ for (const p of state.pending) {
87
+ io.print(`\n─ ${(0, approve_1.subjectCells)('─ ', p)}`);
88
+ io.print('[a]승인 [d]거부 [q]질문 [s]건너뜀 > ');
89
+ const d = await decideByKey(stream, p);
90
+ if (d === 'granted') {
91
+ const r = (0, approve_1.grantRequest)(root, p.id, { actor });
92
+ io.print(r.ok ? `✓ 승인 — ${(0, approve_1.subjectCells)('✓ 승인 — ', p)}` : `✗ ${r.reason}`);
93
+ }
94
+ else if (d === 'denied') {
95
+ const r = (0, approve_1.denyRequest)(root, p.id, '운영자가 watch에서 단일 키로 거부(사유 즉답 생략)', actor);
96
+ io.print(r.ok ? `✗ 거부 — ${(0, approve_1.subjectCells)('✗ 거부 — ', p)}` : `✗ ${r.reason}`);
97
+ }
98
+ else if (typeof d === 'object') {
99
+ const r = (0, approve_1.holdRequest)(root, p.id, d.question, actor);
100
+ io.print(r.ok ? `? 보류 — 질문이 다음 거부 문면에 실립니다` : `✗ ${r.reason}`);
101
+ }
102
+ // skipped: leave it pending, silently
103
+ }
104
+ }
17
105
  /** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
18
106
  exports.WATCH_IDLE = '◆ 승인 대기 중… (Ctrl-C 로 종료)';
19
107
  /** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
@@ -60,9 +148,24 @@ async function runWatch(root, io, actor, opts = {}) {
60
148
  const hasNew = state.pending.some((p) => !lastPresented.has(p.id));
61
149
  if (state.pending.length > 0 && hasNew) {
62
150
  idleAnnounced = false;
151
+ // @implements A-SPEC-497.2 — one notification per NEW item, and never a lever: a throwing
152
+ // notifier is swallowed (the same doctrine as the queue writer's own failures).
153
+ if (opts.notify) {
154
+ for (const p of state.pending) {
155
+ if (lastPresented.has(p.id))
156
+ continue;
157
+ try {
158
+ opts.notify(p);
159
+ }
160
+ catch { /* fire-and-forget */ }
161
+ }
162
+ }
63
163
  const shown = new Set(state.pending.map((p) => p.id)); // what this pass presents
64
164
  try {
65
- await (0, approve_1.runInteractive)(root, io, actor);
165
+ if (opts.keyStream)
166
+ await presentByKey(root, io, actor, opts.keyStream, state);
167
+ else
168
+ await (0, approve_1.runInteractive)(root, io, actor);
66
169
  }
67
170
  catch (e) {
68
171
  // The operator leaving mid-decision (Ctrl-C / EOF) surfaces as AbortError through the
@@ -814,17 +814,27 @@ async function main(argv) {
814
814
  process.stderr.write('✗ 대화형 상주 모드(--watch)는 TTY 가 필요합니다 — 스크립트에서는 --list 를 쓰십시오\n');
815
815
  return 1;
816
816
  }
817
+ // @implements A-SPEC-497.2 — the resident surface decides on ONE raw keypress (a/d/q/s, no
818
+ // Enter, no deadline) and pings the OS on each new arrival. Raw mode is restored on the way
819
+ // out whatever happens; Ctrl-C arrives as \x03 through decideByKey and ends the loop.
817
820
  const { runWatch } = require('./approve-watch');
818
- const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
821
+ const { notifyQueueArrival } = require('./os-notify');
819
822
  const ac = new AbortController();
820
- rl.once('close', () => ac.abort());
821
823
  const onSig = () => ac.abort();
822
824
  process.once('SIGINT', onSig);
825
+ const stdin = process.stdin;
823
826
  try {
827
+ stdin.setRawMode?.(true);
828
+ stdin.resume();
824
829
  await runWatch(root, {
825
830
  print: (t) => process.stdout.write(t + '\n'),
826
- ask: (q) => rl.question(q, { signal: ac.signal }),
827
- }, actor, { signal: ac.signal, ...(pollMs !== undefined ? { pollMs } : {}) });
831
+ ask: async () => '', // unused on the key surface; kept for the ApproveIO shape
832
+ }, actor, {
833
+ signal: ac.signal,
834
+ keyStream: stdin,
835
+ notify: (p) => notifyQueueArrival(p),
836
+ ...(pollMs !== undefined ? { pollMs } : {}),
837
+ });
828
838
  }
829
839
  catch (e) {
830
840
  if (e?.name !== 'AbortError')
@@ -832,7 +842,8 @@ async function main(argv) {
832
842
  }
833
843
  finally {
834
844
  process.removeListener('SIGINT', onSig);
835
- rl.close();
845
+ stdin.setRawMode?.(false);
846
+ stdin.pause();
836
847
  }
837
848
  return 0;
838
849
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * OS-native arrival notification for the approval queue — the MAIN LANE's "you are needed" ping.
3
+ * Fire-and-forget by doctrine: a notification failure must change NOTHING about the watch loop,
4
+ * the gate verdicts or the queue (the same swallow rule enqueueApprovalRequest lives by — the
5
+ * moment a dead notifier could change behavior, killing it becomes a lever).
6
+ *
7
+ * kind/target are AGENT-CONTROLLED text, so both are flattened before they reach a notification
8
+ * body: no control bytes, no forged lines in the notification center.
9
+ */
10
+ import { ChildProcess } from 'node:child_process';
11
+ import type { PendingRequest } from '../governance/approval-queue';
12
+ export interface NotifyDeps {
13
+ platform: NodeJS.Platform;
14
+ spawn: (cmd: string, args: string[], opts?: {
15
+ stdio: 'ignore';
16
+ detached?: boolean;
17
+ }) => Pick<ChildProcess, 'unref' | 'on'>;
18
+ }
19
+ export declare function notifyQueueArrival(item: Pick<PendingRequest, 'kind' | 'target'>, deps?: NotifyDeps): void;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.notifyQueueArrival = notifyQueueArrival;
4
+ // @implements A-SPEC-497.2
5
+ /**
6
+ * OS-native arrival notification for the approval queue — the MAIN LANE's "you are needed" ping.
7
+ * Fire-and-forget by doctrine: a notification failure must change NOTHING about the watch loop,
8
+ * the gate verdicts or the queue (the same swallow rule enqueueApprovalRequest lives by — the
9
+ * moment a dead notifier could change behavior, killing it becomes a lever).
10
+ *
11
+ * kind/target are AGENT-CONTROLLED text, so both are flattened before they reach a notification
12
+ * body: no control bytes, no forged lines in the notification center.
13
+ */
14
+ const node_child_process_1 = require("node:child_process");
15
+ const screen_safe_1 = require("./screen-safe");
16
+ function notifyQueueArrival(item, deps = { platform: process.platform, spawn: (cmd, args) => (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: false }) }) {
17
+ try {
18
+ const kind = (0, screen_safe_1.flattenField)(item.kind, 40);
19
+ const target = (0, screen_safe_1.flattenField)(item.target, 80);
20
+ const body = `${kind} — ${target} · 결정: holmes approve`;
21
+ let cmd;
22
+ let args;
23
+ if (deps.platform === 'darwin') {
24
+ // AppleScript strings escape only backslash and double-quote; flatten already removed the rest.
25
+ const esc = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+ cmd = 'osascript';
27
+ args = ['-e', `display notification "${esc}" with title "Holmes-Kit 승인 요청"`];
28
+ }
29
+ else if (deps.platform === 'win32') {
30
+ // Toast via the WinRT bridge; single-quoted PowerShell literal with quotes doubled.
31
+ const esc = body.replace(/'/g, "''");
32
+ cmd = 'powershell.exe';
33
+ args = ['-NoProfile', '-Command',
34
+ `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; ` +
35
+ `$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); ` +
36
+ `$t = $x.GetElementsByTagName('text'); $t.Item(0).AppendChild($x.CreateTextNode('Holmes-Kit 승인 요청')) > $null; ` +
37
+ `$t.Item(1).AppendChild($x.CreateTextNode('${esc}')) > $null; ` +
38
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('holmes-kit').Show([Windows.UI.Notifications.ToastNotification]::new($x))`];
39
+ }
40
+ else {
41
+ return; // unsupported platform: silent pass-through, by contract
42
+ }
43
+ const child = deps.spawn(cmd, args, { stdio: 'ignore', detached: false });
44
+ child.on?.('error', () => { });
45
+ child.unref?.();
46
+ }
47
+ catch { /* a notification must never take the loop down with it */ }
48
+ }
@@ -48,42 +48,75 @@ export type ElicitOutcome = {
48
48
  kind: 'silent';
49
49
  };
50
50
  export type Elicitor = (req: ElicitApprovalRequest) => Promise<ElicitOutcome>;
51
- /** The one timeout truth: the SDK option, the dialog forewarning and `waitedMs` all derive from it. */
51
+ /**
52
+ * The lock-holding path's timeout truth (and the stage-2 reason dialog's): review-resolve holds
53
+ * the findings-ledger lock while the dialog waits, so its ceiling never grows (REQ-497 기아 방지).
54
+ */
52
55
  export declare const ELICIT_TIMEOUT_MS = 120000;
56
+ /**
57
+ * Per-path dialog deadline — the ONE number the ⏱ forewarning, the SDK timeout option and the
58
+ * summed `waitedMs` all derive from. Lock-free kinds (spec-approve) get 240s: nothing starves
59
+ * while that dialog waits, and the operator complaint being fixed was expiry-too-soon. The
60
+ * lock-holding kind keeps 120s (see ELICIT_TIMEOUT_MS above).
61
+ */
62
+ export declare function elicitTimeoutMsFor(kind: string): number;
63
+ /**
64
+ * Finite re-display: first presentation + exactly ONE re-presentation, lock-free kinds only.
65
+ * Only EXPIRY re-presents — decline, cancel and any answer end the flow at once (a human NO is
66
+ * never re-asked). Client measurement 2026-08-31: stale dialogs stay on screen after
67
+ * notifications/cancelled, so the re-display carries a visible attempt counter instead of
68
+ * pretending the old widget is gone.
69
+ */
70
+ export declare const MAX_PRESENTATIONS = 2;
53
71
  /**
54
72
  * Classify an elicitInput rejection. Only the exact SDK timeout code — on a real Error — is
55
73
  * `expired`; a message that merely SAYS "timed out", a near-miss code, or a code on a non-Error
56
74
  * is `silent`, because the expiry face carries friendlier guidance and must not be spoofable.
57
75
  */
58
- export declare function classifyElicitError(e: unknown): ElicitOutcome;
76
+ export declare function classifyElicitError(e: unknown, timeoutMs?: number): ElicitOutcome;
59
77
  /**
60
78
  * The expiry notice that LEADS an expired refusal: what happened (the session dialog expired),
61
79
  * where the request went (the approval queue), and where the decision still lives (the CLI).
62
80
  * Template-owned text with no interpolated attacker data.
63
81
  */
64
82
  export declare function expiredNotice(waitedMs: number): string;
65
- /**
66
- * The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
67
- * is subordinate to the decision — this channel never collects arbitrary input.
68
- */
69
- export declare function buildElicitRequest(req: ElicitApprovalRequest): {
83
+ export interface ElicitForm {
70
84
  message: string;
71
85
  requestedSchema: {
72
86
  type: 'object';
73
- properties: {
74
- decision: {
75
- type: 'string';
76
- enum: string[];
77
- description: string;
78
- };
79
- reason: {
80
- type: 'string';
81
- description: string;
82
- };
83
- };
87
+ properties: Record<string, {
88
+ type: 'string';
89
+ enum?: string[];
90
+ description: string;
91
+ }>;
84
92
  required: string[];
85
93
  };
86
- };
94
+ }
95
+ /**
96
+ * Stage-1 form: the DECISION alone, so approving is one choice (S-497.3's 1-key promise). The
97
+ * reason field moved to stage 2 — it exists only after deny/question (decisions only, REQ-263 Out:
98
+ * this channel never collects arbitrary input). A re-presentation (attempt 2) leads with a
99
+ * template-owned counter line, so the human can tell the live dialog from the stale one their
100
+ * client left on screen (measured 2026-08-31: widgets survive notifications/cancelled).
101
+ */
102
+ export declare function buildDecisionForm(req: ElicitApprovalRequest, attempt: number, timeoutMs: number): ElicitForm;
103
+ /**
104
+ * Stage-2 form: the reason alone, asked only after deny/question. A question without text is
105
+ * useless, so `reason` is required there; a deny stands on its own, so there it is optional.
106
+ */
107
+ export declare function buildReasonForm(decision: 'deny' | 'question', timeoutMs: number): ElicitForm;
108
+ /** The SDK call, one layer thin — the only impure seam. Expiry arrives as a rejection. */
109
+ export type RawElicit = (form: ElicitForm, timeoutMs: number) => Promise<{
110
+ action?: unknown;
111
+ content?: unknown;
112
+ } | null | undefined>;
113
+ /**
114
+ * The two-stage flow. Fates preserved exactly (H-SPEC-497 Interfaces): the return is the same
115
+ * discriminated union handlers already consume, so nothing above this seam changes. Only EXPIRY
116
+ * of stage 1 re-presents (once, lock-free kinds only); a stage-2 failure of ANY kind never erases
117
+ * the stage-1 decision — un-deciding a human is the one thing this channel must not do.
118
+ */
119
+ export declare function runElicitFlow(raw: RawElicit, req: ElicitApprovalRequest): Promise<ElicitOutcome>;
87
120
  /**
88
121
  * Map a client's ElicitResult to a decision — FAIL-CLOSED: only the exact shape
89
122
  * `{action:'accept', content:{decision:'approve'}}` grants; every malformed shape (missing action,
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  // @implements A-SPEC-263.1
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.ELICIT_TIMEOUT_MS = exports.ELICITABLE_KINDS = void 0;
4
+ exports.MAX_PRESENTATIONS = exports.ELICIT_TIMEOUT_MS = exports.ELICITABLE_KINDS = void 0;
5
+ exports.elicitTimeoutMsFor = elicitTimeoutMsFor;
5
6
  exports.classifyElicitError = classifyElicitError;
6
7
  exports.expiredNotice = expiredNotice;
7
- exports.buildElicitRequest = buildElicitRequest;
8
+ exports.buildDecisionForm = buildDecisionForm;
9
+ exports.buildReasonForm = buildReasonForm;
10
+ exports.runElicitFlow = runElicitFlow;
8
11
  exports.interpretElicitResult = interpretElicitResult;
9
12
  /**
10
13
  * The decision comes INTO the session (REQ-263): an approval-shaped refusal (spec_approve without a
@@ -28,8 +31,30 @@ exports.interpretElicitResult = interpretElicitResult;
28
31
  */
29
32
  /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
30
33
  exports.ELICITABLE_KINDS = new Set(['spec-approve', 'review-resolve']);
31
- /** The one timeout truth: the SDK option, the dialog forewarning and `waitedMs` all derive from it. */
34
+ /**
35
+ * The lock-holding path's timeout truth (and the stage-2 reason dialog's): review-resolve holds
36
+ * the findings-ledger lock while the dialog waits, so its ceiling never grows (REQ-497 기아 방지).
37
+ */
32
38
  exports.ELICIT_TIMEOUT_MS = 120_000;
39
+ // @implements A-SPEC-497.3
40
+ /**
41
+ * Per-path dialog deadline — the ONE number the ⏱ forewarning, the SDK timeout option and the
42
+ * summed `waitedMs` all derive from. Lock-free kinds (spec-approve) get 240s: nothing starves
43
+ * while that dialog waits, and the operator complaint being fixed was expiry-too-soon. The
44
+ * lock-holding kind keeps 120s (see ELICIT_TIMEOUT_MS above).
45
+ */
46
+ function elicitTimeoutMsFor(kind) {
47
+ return kind === 'review-resolve' ? exports.ELICIT_TIMEOUT_MS : 240_000;
48
+ }
49
+ // @implements A-SPEC-497.3
50
+ /**
51
+ * Finite re-display: first presentation + exactly ONE re-presentation, lock-free kinds only.
52
+ * Only EXPIRY re-presents — decline, cancel and any answer end the flow at once (a human NO is
53
+ * never re-asked). Client measurement 2026-08-31: stale dialogs stay on screen after
54
+ * notifications/cancelled, so the re-display carries a visible attempt counter instead of
55
+ * pretending the old widget is gone.
56
+ */
57
+ exports.MAX_PRESENTATIONS = 2;
33
58
  // This module is PURE (no SDK import — the doctrine above), so the SDK's ErrorCode.RequestTimeout
34
59
  // lives here as a pinned constant; elicit-expiry.test.ts asserts parity against the real enum.
35
60
  const MCP_REQUEST_TIMEOUT_CODE = -32001;
@@ -38,9 +63,9 @@ const MCP_REQUEST_TIMEOUT_CODE = -32001;
38
63
  * `expired`; a message that merely SAYS "timed out", a near-miss code, or a code on a non-Error
39
64
  * is `silent`, because the expiry face carries friendlier guidance and must not be spoofable.
40
65
  */
41
- function classifyElicitError(e) {
66
+ function classifyElicitError(e, timeoutMs = exports.ELICIT_TIMEOUT_MS) {
42
67
  return e instanceof Error && e.code === MCP_REQUEST_TIMEOUT_CODE
43
- ? { kind: 'expired', waitedMs: exports.ELICIT_TIMEOUT_MS }
68
+ ? { kind: 'expired', waitedMs: timeoutMs }
44
69
  : { kind: 'silent' };
45
70
  }
46
71
  /**
@@ -51,32 +76,115 @@ function classifyElicitError(e) {
51
76
  function expiredNotice(waitedMs) {
52
77
  return `[세션 승인 다이얼로그 만료 — ${Math.round(waitedMs / 1000)}초 무응답] 요청은 승인 큐로 회송되었습니다. 결정: npx holmes-kit approve. `;
53
78
  }
79
+ // FLATTEN attacker-influenced text (round-1): the model chooses spec titles and finding ids, and
80
+ // interpolating them raw let a crafted title inject fake lines ("[시스템] … approve.") into the
81
+ // ONE human-trust surface this feature introduces. Line structure is fixed by this template, never
82
+ // by the data: newlines and control characters collapse to spaces, and each field is length-capped.
83
+ const flat = (s, max) => s.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]+/g, ' ')
84
+ .replace(/\s{2,}/g, ' ').trim().slice(0, max);
85
+ // @implements A-SPEC-497.3
54
86
  /**
55
- * The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
56
- * is subordinate to the decisionthis channel never collects arbitrary input.
87
+ * Stage-1 form: the DECISION alone, so approving is one choice (S-497.3's 1-key promise). The
88
+ * reason field moved to stage 2it exists only after deny/question (decisions only, REQ-263 Out:
89
+ * this channel never collects arbitrary input). A re-presentation (attempt 2) leads with a
90
+ * template-owned counter line, so the human can tell the live dialog from the stale one their
91
+ * client left on screen (measured 2026-08-31: widgets survive notifications/cancelled).
57
92
  */
58
- function buildElicitRequest(req) {
59
- // FLATTEN attacker-influenced text (round-1): the model chooses spec titles and finding ids, and
60
- // interpolating them raw let a crafted title inject fake lines ("[시스템] … approve.") into the
61
- // ONE human-trust surface this feature introduces. Line structure is fixed by this template, never
62
- // by the data: newlines and control characters collapse to spaces, and each field is length-capped.
63
- const flat = (s, max) => s.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]+/g, ' ')
64
- .replace(/\s{2,}/g, ' ').trim().slice(0, max);
93
+ function buildDecisionForm(req, attempt, timeoutMs) {
94
+ const redisplay = attempt > 1 ? `⚠ 재표시 ${attempt}/${exports.MAX_PRESENTATIONS} 이전 다이얼로그는 만료되어 무효입니다\n` : '';
65
95
  return {
66
96
  // @implements A-SPEC-497.1 — the forewarning is the LAST line and template-owned: it survives a
67
97
  // cap-filling summary (appended after the caps) and tells the human, before the clock runs out,
68
98
  // where an undecided request goes and where the decision still lives.
69
- message: `[Holmes-Kit 승인 요청] ${flat(req.kind, 40)} — ${flat(req.target, 80)}\n${flat(req.summary, 200)}\n승인(approve) / 거부(deny) / 질문(question) 을 선택하세요. 사유는 선택입니다.\n⏱ ${exports.ELICIT_TIMEOUT_MS / 1000}초 내 미결정 시 승인 큐로 회송됩니다(운영자: npx holmes-kit approve).`,
99
+ message: `${redisplay}[Holmes-Kit 승인 요청] ${flat(req.kind, 40)} — ${flat(req.target, 80)}\n${flat(req.summary, 200)}\n승인(approve) / 거부(deny) / 질문(question) 을 선택하세요. 사유는 거부·질문을 고르면 이어서 묻습니다.\n⏱ ${timeoutMs / 1000}초 내 미결정 시 승인 큐로 회송됩니다(운영자: npx holmes-kit approve).`,
70
100
  requestedSchema: {
71
101
  type: 'object',
72
102
  properties: {
73
103
  decision: { type: 'string', enum: ['approve', 'deny', 'question'], description: '승인/거부/질문' },
74
- reason: { type: 'string', description: '사유(선택) — 거부·질문이면 에이전트 문면에 실립니다' },
75
104
  },
76
105
  required: ['decision'],
77
106
  },
78
107
  };
79
108
  }
109
+ // @implements A-SPEC-497.3
110
+ /**
111
+ * Stage-2 form: the reason alone, asked only after deny/question. A question without text is
112
+ * useless, so `reason` is required there; a deny stands on its own, so there it is optional.
113
+ */
114
+ function buildReasonForm(decision, timeoutMs) {
115
+ const ask = decision === 'question'
116
+ ? '질문 내용을 입력하세요 — 에이전트 거부 문면에 실립니다.'
117
+ : '거부 사유를 입력하세요(선택) — 에이전트 거부 문면에 실립니다.';
118
+ return {
119
+ message: `[Holmes-Kit] ${ask}\n⏱ ${timeoutMs / 1000}초 내 미입력 시 결정은 그대로 확정됩니다.`,
120
+ requestedSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ reason: { type: 'string', description: decision === 'question' ? '질문 내용' : '거부 사유(선택)' },
124
+ },
125
+ required: decision === 'question' ? ['reason'] : [],
126
+ },
127
+ };
128
+ }
129
+ // @implements A-SPEC-497.3
130
+ /**
131
+ * The two-stage flow. Fates preserved exactly (H-SPEC-497 Interfaces): the return is the same
132
+ * discriminated union handlers already consume, so nothing above this seam changes. Only EXPIRY
133
+ * of stage 1 re-presents (once, lock-free kinds only); a stage-2 failure of ANY kind never erases
134
+ * the stage-1 decision — un-deciding a human is the one thing this channel must not do.
135
+ */
136
+ async function runElicitFlow(raw, req) {
137
+ const timeoutMs = elicitTimeoutMsFor(req.kind);
138
+ const presentations = req.kind === 'review-resolve' ? 1 : exports.MAX_PRESENTATIONS;
139
+ let waitedMs = 0;
140
+ for (let attempt = 1; attempt <= presentations; attempt++) {
141
+ let r;
142
+ try {
143
+ r = await raw(buildDecisionForm(req, attempt, timeoutMs), timeoutMs);
144
+ }
145
+ catch (e) {
146
+ const fate = classifyElicitError(e, timeoutMs);
147
+ if (fate.kind !== 'expired')
148
+ return fate;
149
+ waitedMs += timeoutMs;
150
+ continue; // expiry — and ONLY expiry — earns a re-presentation
151
+ }
152
+ if (r && typeof r === 'object' && r.action === 'decline') {
153
+ return { kind: 'answered', decision: interpretElicitResult({ action: 'decline' }) };
154
+ }
155
+ const decision = stage1DecisionOf(r);
156
+ if (decision === null)
157
+ return { kind: 'silent' }; // cancel / malformed: no decision, no re-ask
158
+ if (decision === 'approve') {
159
+ return { kind: 'answered', decision: interpretElicitResult({ action: 'accept', content: { decision } }) };
160
+ }
161
+ // deny | question — collect the reason in stage 2, then map through the ONE interpreter so the
162
+ // refusal wording keeps a single source of truth.
163
+ const reason = await collectReason(raw, decision);
164
+ return { kind: 'answered', decision: interpretElicitResult({ action: 'accept', content: { decision, reason } }) };
165
+ }
166
+ return { kind: 'expired', waitedMs };
167
+ }
168
+ /** Stage-1 result → decision token, fail-closed: only accept + an in-enum string counts. */
169
+ function stage1DecisionOf(r) {
170
+ if (!r || typeof r !== 'object' || r.action !== 'accept')
171
+ return null;
172
+ const d = r.content && typeof r.content === 'object' ? r.content.decision : undefined;
173
+ return d === 'approve' || d === 'deny' || d === 'question' ? d : null;
174
+ }
175
+ /** Stage-2 reason, or undefined on ANY failure — the stage-1 decision stands either way. */
176
+ async function collectReason(raw, decision) {
177
+ try {
178
+ const r = await raw(buildReasonForm(decision, exports.ELICIT_TIMEOUT_MS), exports.ELICIT_TIMEOUT_MS);
179
+ if (!r || typeof r !== 'object' || r.action !== 'accept')
180
+ return undefined;
181
+ const reason = r.content && typeof r.content === 'object' ? r.content.reason : undefined;
182
+ return typeof reason === 'string' && reason.trim() !== '' ? reason : undefined;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
80
188
  /**
81
189
  * Map a client's ElicitResult to a decision — FAIL-CLOSED: only the exact shape
82
190
  * `{action:'accept', content:{decision:'approve'}}` grants; every malformed shape (missing action,
@@ -18,17 +18,19 @@ const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS
18
18
  // failure — no capability, transport error, malformed answer — folds to `silent`, whose refusal
19
19
  // stays byte-identical to the pre-elicitation one. Diagnostics, if ever needed, go to stderr only
20
20
  // (stdout is the protocol channel).
21
- const { ELICIT_TIMEOUT_MS, buildElicitRequest, classifyElicitError, interpretElicitResult } = require('./elicit-approval');
21
+ // @implements A-SPEC-497.3 the wiring shrinks to ONE impure seam (rawElicit): the two-stage
22
+ // form, the finite re-display and the per-path deadline all live in the pure runElicitFlow, so
23
+ // every branch is test-driven without a client. runElicitFlow classifies its own errors; the outer
24
+ // catch is a belt for faults outside the flow (capability probe, transport construction).
25
+ const { runElicitFlow } = require('./elicit-approval');
22
26
  const elicit = async (req) => {
23
27
  try {
24
28
  if (!server.getClientCapabilities()?.elicitation)
25
29
  return { kind: 'silent' };
26
- const r = await server.elicitInput(buildElicitRequest(req), { timeout: ELICIT_TIMEOUT_MS });
27
- const d = interpretElicitResult(r);
28
- return d === null ? { kind: 'silent' } : { kind: 'answered', decision: d };
30
+ return await runElicitFlow((form, timeoutMs) => server.elicitInput(form, { timeout: timeoutMs }), req);
29
31
  }
30
- catch (e) {
31
- return classifyElicitError(e);
32
+ catch {
33
+ return { kind: 'silent' };
32
34
  }
33
35
  };
34
36
  const handlers = (0, handlers_1.makeHandlers)(store, {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.3.7",
4
+ "version": "0.3.8",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",