@holmes-lab/holmes-kit 0.3.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ 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.1] - 2026-08-31
9
+
10
+ Approval-dialog expiry made visible (REQ-497 slice .1), plus the first Windows field validation.
11
+
12
+ ### Added
13
+
14
+ - **Elicitation outcome taxonomy** (A-SPEC-497.1): the in-session approval channel now tells its
15
+ three fates apart — answered / expired / silent. An expired dialog (the MCP SDK's 120s
16
+ RequestTimeout, which previously died as an anonymous refusal while the client showed a dead,
17
+ orphaned dialog) now LEADS the refusal with what happened and where the decision still lives:
18
+ `[세션 승인 다이얼로그 만료 — 120초 무응답] 요청은 승인 큐로 회송되었습니다. 결정: npx holmes-kit approve`.
19
+ Applies to both elicitable paths (`spec_approve`, `review_record` critical lifts).
20
+ - **Timeout forewarning in the dialog itself**: every approval dialog's message now ends with a
21
+ template-owned line announcing the 120s deadline and the queue fallback, so an expiry is never
22
+ a surprise. The line survives cap-filling summaries and control-character injection.
23
+ - Windows field validation (user machine, PowerShell, node v24): registry install, doctor
24
+ 11 PASS / 0 FAIL (natives incl. better-sqlite3 + 7 tree-sitter grammars, both gates, MCP
25
+ handshake 30 tools), interactive raw-mode init menu renders and confirms.
26
+
27
+ ### Unchanged by design
28
+
29
+ - The silent fates (no capability, transport error, malformed answer, dismissed window) keep
30
+ their refusal byte-identical to the pre-elicitation text (A-SPEC-263.1's lossless degradation,
31
+ now pinned by a full-value comparison test). The timeout value and its rationale (ledger-lock
32
+ starvation, headless hangs) are untouched — only the message learned to speak.
33
+
34
+ ### Known (registered, not yet fixed)
35
+
36
+ - REQ-498 (draft): init asks the harness menu BEFORE emitting known refusals (governed-without-
37
+ specs, --force-without-approval — two field reproductions); no doctor warning when a stale
38
+ global shim shadows the local version (measured: dual global roots on Windows); npx one-shot
39
+ onboarding undocumented.
40
+
7
41
  <!-- @implements A-SPEC-209 -->
8
42
  ## [0.3.0] - 2026-08-31
9
43
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 503a295-mth8qak7
1
+ 9dd2f60-mthaxwnr
@@ -26,12 +26,42 @@ export interface ElicitApprovalRequest {
26
26
  /** Human-facing one-liner: what is being approved (id, title, sealed-or-not). */
27
27
  summary: string;
28
28
  }
29
- /** The human's decision. `null` from an elicitor means "the channel gave no answer fall back". */
29
+ /** The human's decision. `null` from interpretElicitResult means "no answer in this shape". */
30
30
  export interface ElicitDecision {
31
31
  granted: boolean;
32
32
  reason?: string;
33
33
  }
34
- export type Elicitor = (req: ElicitApprovalRequest) => Promise<ElicitDecision | null>;
34
+ /**
35
+ * The three fates of an elicitation, told apart. The old `Elicitor` returned decision-or-null, and
36
+ * the wiring's `catch { return null }` erased the SDK's RequestTimeout — so an expired dialog (the
37
+ * human saw dead arrows) and an absent capability (the human saw nothing at all) wore the same
38
+ * refusal. Only `expired` earns a name in the message; `silent` stays byte-identical to the
39
+ * pre-elicitation refusal (A-SPEC-263.1's lossless-degradation promise, kept).
40
+ */
41
+ export type ElicitOutcome = {
42
+ kind: 'answered';
43
+ decision: ElicitDecision;
44
+ } | {
45
+ kind: 'expired';
46
+ waitedMs: number;
47
+ } | {
48
+ kind: 'silent';
49
+ };
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. */
52
+ export declare const ELICIT_TIMEOUT_MS = 120000;
53
+ /**
54
+ * Classify an elicitInput rejection. Only the exact SDK timeout code — on a real Error — is
55
+ * `expired`; a message that merely SAYS "timed out", a near-miss code, or a code on a non-Error
56
+ * is `silent`, because the expiry face carries friendlier guidance and must not be spoofable.
57
+ */
58
+ export declare function classifyElicitError(e: unknown): ElicitOutcome;
59
+ /**
60
+ * The expiry notice that LEADS an expired refusal: what happened (the session dialog expired),
61
+ * where the request went (the approval queue), and where the decision still lives (the CLI).
62
+ * Template-owned text with no interpolated attacker data.
63
+ */
64
+ export declare function expiredNotice(waitedMs: number): string;
35
65
  /**
36
66
  * The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
37
67
  * is subordinate to the decision — this channel never collects arbitrary input.
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  // @implements A-SPEC-263.1
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.ELICITABLE_KINDS = void 0;
4
+ exports.ELICIT_TIMEOUT_MS = exports.ELICITABLE_KINDS = void 0;
5
+ exports.classifyElicitError = classifyElicitError;
6
+ exports.expiredNotice = expiredNotice;
5
7
  exports.buildElicitRequest = buildElicitRequest;
6
8
  exports.interpretElicitResult = interpretElicitResult;
7
9
  /**
@@ -26,6 +28,29 @@ exports.interpretElicitResult = interpretElicitResult;
26
28
  */
27
29
  /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
28
30
  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. */
32
+ exports.ELICIT_TIMEOUT_MS = 120_000;
33
+ // This module is PURE (no SDK import — the doctrine above), so the SDK's ErrorCode.RequestTimeout
34
+ // lives here as a pinned constant; elicit-expiry.test.ts asserts parity against the real enum.
35
+ const MCP_REQUEST_TIMEOUT_CODE = -32001;
36
+ /**
37
+ * Classify an elicitInput rejection. Only the exact SDK timeout code — on a real Error — is
38
+ * `expired`; a message that merely SAYS "timed out", a near-miss code, or a code on a non-Error
39
+ * is `silent`, because the expiry face carries friendlier guidance and must not be spoofable.
40
+ */
41
+ function classifyElicitError(e) {
42
+ return e instanceof Error && e.code === MCP_REQUEST_TIMEOUT_CODE
43
+ ? { kind: 'expired', waitedMs: exports.ELICIT_TIMEOUT_MS }
44
+ : { kind: 'silent' };
45
+ }
46
+ /**
47
+ * The expiry notice that LEADS an expired refusal: what happened (the session dialog expired),
48
+ * where the request went (the approval queue), and where the decision still lives (the CLI).
49
+ * Template-owned text with no interpolated attacker data.
50
+ */
51
+ function expiredNotice(waitedMs) {
52
+ return `[세션 승인 다이얼로그 만료 — ${Math.round(waitedMs / 1000)}초 무응답] 요청은 승인 큐로 회송되었습니다. 결정: npx holmes-kit approve. `;
53
+ }
29
54
  /**
30
55
  * The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
31
56
  * is subordinate to the decision — this channel never collects arbitrary input.
@@ -38,7 +63,10 @@ function buildElicitRequest(req) {
38
63
  const flat = (s, max) => s.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]+/g, ' ')
39
64
  .replace(/\s{2,}/g, ' ').trim().slice(0, max);
40
65
  return {
41
- message: `[Holmes-Kit 승인 요청] ${flat(req.kind, 40)} ${flat(req.target, 80)}\n${flat(req.summary, 200)}\n승인(approve) / 거부(deny) / 질문(question) 선택하세요. 사유는 선택입니다.`,
66
+ // @implements A-SPEC-497.1the forewarning is the LAST line and template-owned: it survives a
67
+ // cap-filling summary (appended after the caps) and tells the human, before the clock runs out,
68
+ // 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).`,
42
70
  requestedSchema: {
43
71
  type: 'object',
44
72
  properties: {
@@ -643,18 +643,20 @@ function makeRawHandlers(store, opts) {
643
643
  // @implements A-SPEC-263.1
644
644
  // The in-session approval channel: ask ONLY when (a) an elicitor was injected (the server wires
645
645
  // one iff the client advertised the elicitation capability — handlers never see the server), and
646
- // (b) the kind is in the conservative allow-list. Every failure mode (throw, timeout folded to
647
- // null by the wiring, malformed answers folded to null by interpretElicitResult) returns null,
648
- // which callers treat as "the channel gave no answer" the refusal that follows is byte-identical
649
- // to the pre-elicitation one, so nothing is ever worse than before the channel existed.
646
+ // (b) the kind is in the conservative allow-list.
647
+ // @implements A-SPEC-497.1 the outcome taxonomy rides through unchanged: `answered` carries the
648
+ // human's decision, `expired` carries the timeout so the refusal can say a dialog died, and every
649
+ // other failure mode (no channel, wrong kind, a throwing elicitor) is `silent`, whose refusal
650
+ // stays byte-identical to the pre-elicitation one — nothing is ever worse than before the
651
+ // channel existed.
650
652
  const tryElicit = async (kind, target, summary) => {
651
653
  if (!opts?.elicit || !elicit_approval_1.ELICITABLE_KINDS.has(kind))
652
- return null;
654
+ return { kind: 'silent' };
653
655
  try {
654
656
  return await opts.elicit({ kind, target, summary });
655
657
  }
656
658
  catch {
657
- return null;
659
+ return { kind: 'silent' };
658
660
  }
659
661
  };
660
662
  // A granted answer becomes a synthesized Approval that rides the EXISTING seal path unchanged —
@@ -1126,6 +1128,7 @@ function makeRawHandlers(store, opts) {
1126
1128
  // @implements A-SPEC-263.1 — asked ONLY after the existing channels failed to cover (an open
1127
1129
  // door never summons a human), and only about a spec that exists (a question about a missing
1128
1130
  // id helps no one — the standard refusal handles it).
1131
+ let elicitExpiredMs;
1129
1132
  if (approveResolved === undefined) {
1130
1133
  const target = await store.read(a.id).catch(() => null);
1131
1134
  if (target) {
@@ -1133,20 +1136,27 @@ function makeRawHandlers(store, opts) {
1133
1136
  // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
1134
1137
  // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
1135
1138
  // more consequential act) as a first approval. The cap cuts the title, never the marker.
1136
- const d = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1137
- if (d?.granted) {
1138
- approveResolved = { approval: elicitApproval(d.reason), source: 'elicitation' };
1139
+ const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1140
+ if (out.kind === 'answered' && out.decision.granted) {
1141
+ approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
1139
1142
  }
1140
- else if (d) {
1143
+ else if (out.kind === 'answered') {
1141
1144
  // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
1142
1145
  // entry is filed — a decided request is not a pending one (REQ-246 visibility).
1143
- return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${d.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1146
+ return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1147
+ }
1148
+ else if (out.kind === 'expired') {
1149
+ // @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
1150
+ // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1151
+ // message learned to say what happened.
1152
+ elicitExpiredMs = out.waitedMs;
1144
1153
  }
1145
- // d === null: the channel gave no answer — fall through to the byte-identical refusal.
1154
+ // silent: the channel gave no answer — fall through to the byte-identical refusal.
1146
1155
  }
1147
1156
  }
1148
1157
  if (approveResolved === undefined) {
1149
- return { ok: false, reason: 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
1158
+ return { ok: false, reason: (elicitExpiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(elicitExpiredMs) : '')
1159
+ + 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
1150
1160
  + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인' }) };
1151
1161
  }
1152
1162
  // @implements A-SPEC-188 — destination BEFORE seal.
@@ -2228,6 +2238,10 @@ function makeRawHandlers(store, opts) {
2228
2238
  // the human's "no" must not abort a batch that never needed the question. The deny bites only
2229
2239
  // at the in-lock site, and only if the lift is ACTUALLY needed there.
2230
2240
  const elicitDenials = new Map();
2241
+ // @implements A-SPEC-497.1 — an expiry is carried as data like a denial: it bites only at the
2242
+ // in-lock refusal site, where it prefixes the standard message so the agent can tell the user
2243
+ // a session dialog died (instead of the anonymous "no approval" the old null-fold produced).
2244
+ const elicitExpiries = new Map();
2231
2245
  if (opts?.elicit) {
2232
2246
  try {
2233
2247
  const snapshot = new Map();
@@ -2247,11 +2261,13 @@ function makeRawHandlers(store, opts) {
2247
2261
  }
2248
2262
  if (resolveHandlerApproval(a.root, store, env0, { kind: 'review-resolve', target: f.id }, new Date().toISOString()) !== undefined)
2249
2263
  continue; // an open door never summons a human
2250
- const d = await tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
2251
- if (d?.granted)
2252
- elicitGrants.set(f.id, elicitApproval(d.reason));
2253
- else if (d)
2254
- elicitDenials.set(f.id, d.reason ?? '(사유 없음)');
2264
+ const out = await tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
2265
+ if (out.kind === 'answered' && out.decision.granted)
2266
+ elicitGrants.set(f.id, elicitApproval(out.decision.reason));
2267
+ else if (out.kind === 'answered')
2268
+ elicitDenials.set(f.id, out.decision.reason ?? '(사유 없음)');
2269
+ else if (out.kind === 'expired')
2270
+ elicitExpiries.set(f.id, out.waitedMs);
2255
2271
  }
2256
2272
  }
2257
2273
  catch {
@@ -2325,7 +2341,11 @@ function makeRawHandlers(store, opts) {
2325
2341
  if (denied !== undefined) {
2326
2342
  throw new HandlerRefusal(`review_record: 세션에서 거부됨 — ${denied}. 사유를 해소한 뒤 다시 기록하십시오.`);
2327
2343
  }
2328
- throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 차단당한 쪽이 스스로 이빨을 뽑을 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
2344
+ // @implements A-SPEC-497.1an expired dialog leads the refusal by name; silence
2345
+ // keeps the pre-elicitation face.
2346
+ const expiredMs = elicitExpiries.get(f.id);
2347
+ throw new HandlerRefusal((expiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(expiredMs) : '')
2348
+ + `review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
2329
2349
  + refusalQueueHint(a.root, store, { kind: 'review-resolve', target: f.id, why: '열린 치명 발견의 해소 기록' }));
2330
2350
  }
2331
2351
  // Grant-file consumption is DEFERRED past the loop (round-2): consuming here burned the
@@ -12,20 +12,23 @@ const validate_args_1 = require("./validate-args");
12
12
  // @implements A-SPEC-100.2
13
13
  const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS ?? '.ax/specs');
14
14
  // @implements A-SPEC-263.1 — the elicitation approval channel's wiring. The capability arrives at
15
- // initialize, AFTER this factory runs, so it is consulted lazily at CALL time: no capability (or
16
- // any transport error/timeout) folds to null, which the handlers treat as "the channel gave no
17
- // answer" the refusal that follows is byte-identical to the pre-elicitation one. Diagnostics, if
18
- // ever needed, go to stderr only (stdout is the protocol channel).
19
- const { buildElicitRequest, interpretElicitResult } = require('./elicit-approval');
15
+ // initialize, AFTER this factory runs, so it is consulted lazily at CALL time.
16
+ // @implements A-SPEC-497.1 the three fates, told apart at THIS seam (the only place the SDK error
17
+ // is visible): a RequestTimeout rejection is `expired` and earns a name in the refusal; every other
18
+ // failure — no capability, transport error, malformed answer folds to `silent`, whose refusal
19
+ // stays byte-identical to the pre-elicitation one. Diagnostics, if ever needed, go to stderr only
20
+ // (stdout is the protocol channel).
21
+ const { ELICIT_TIMEOUT_MS, buildElicitRequest, classifyElicitError, interpretElicitResult } = require('./elicit-approval');
20
22
  const elicit = async (req) => {
21
23
  try {
22
24
  if (!server.getClientCapabilities()?.elicitation)
23
- return null;
24
- const r = await server.elicitInput(buildElicitRequest(req), { timeout: 120_000 });
25
- return interpretElicitResult(r);
25
+ 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 };
26
29
  }
27
- catch {
28
- return null;
30
+ catch (e) {
31
+ return classifyElicitError(e);
29
32
  }
30
33
  };
31
34
  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.0",
4
+ "version": "0.3.1",
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",