@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 +34 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
- package/dist/holmes/mcp/elicit-approval.js +30 -2
- package/dist/holmes/mcp/handlers.js +39 -19
- package/dist/holmes/mcp/server.js +13 -10
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
// @implements A-SPEC-497.1 — the 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.
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
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
|
|
654
|
+
return { kind: 'silent' };
|
|
653
655
|
try {
|
|
654
656
|
return await opts.elicit({ kind, target, summary });
|
|
655
657
|
}
|
|
656
658
|
catch {
|
|
657
|
-
return
|
|
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
|
|
1137
|
-
if (
|
|
1138
|
-
approveResolved = { approval: elicitApproval(
|
|
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 (
|
|
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: 세션에서 거부됨 — ${
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
2251
|
-
if (
|
|
2252
|
-
elicitGrants.set(f.id, elicitApproval(
|
|
2253
|
-
else if (
|
|
2254
|
-
elicitDenials.set(f.id,
|
|
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
|
-
|
|
2344
|
+
// @implements A-SPEC-497.1 — an 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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
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
|
|
24
|
-
const r = await server.elicitInput(buildElicitRequest(req), { timeout:
|
|
25
|
-
|
|
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
|
|
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.
|
|
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",
|