@holmes-lab/holmes-kit 0.3.7 → 0.3.9

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.
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PUSH_HOOK_SIGNATURE = void 0;
4
+ exports.judgePush = judgePush;
5
+ exports.parsePushLines = parsePushLines;
6
+ /** Identifies OUR installed hook — the single truth doctor and re-install both key on. */
7
+ exports.PUSH_HOOK_SIGNATURE = 'HOLMES-KIT PUSH GATE v1';
8
+ /** Prose that CLAIMS a test success — a surfacing pattern, never a judgment input. */
9
+ const SUCCESS_CLAIM_RE = /초록|green|pass(es|ed)?\b|✅/i;
10
+ // @implements A-SPEC-509.1
11
+ function judgePush(input) {
12
+ if (!input.hasGovernance)
13
+ return { allow: true, notices: [] }; // un-adopted: never coerce adoption
14
+ const notices = [];
15
+ const ev = input.evidence;
16
+ // Claim cross-check first — it rides allow AND deny alike.
17
+ for (const c of input.claimCommits) {
18
+ if (!SUCCESS_CLAIM_RE.test(c.subject))
19
+ continue;
20
+ if (ev && ev.head === c.sha)
21
+ continue; // the ledger backs this claim
22
+ notices.push(`[Holmes-Kit] 커밋 ${c.sha.slice(0, 7)}의 메시지가 테스트 성공을 주장하지만 test_run 원장이 그 커밋을 뒷받침하지 않습니다: "${flatSubject(c.subject)}"`);
23
+ }
24
+ if (!ev) {
25
+ // Adopted but no ledger yet: pass honestly, say how to activate. A broken ledger folds here
26
+ // too — the gate is a default-safener, not a security boundary, so it must not dead-lock a
27
+ // repo whose ledger file was corrupted.
28
+ notices.push('[Holmes-Kit] test-evidence 원장이 없어 push 증빙 대조를 건너뜁니다 — test_run을 실행하면 다음 push부터 대조가 활성화됩니다');
29
+ return { allow: true, notices };
30
+ }
31
+ const executed = Object.values(ev.executedByAspec).reduce((a, b) => a + b, 0);
32
+ if (ev.head === input.pushedHead && ev.passed === true && executed > 0) {
33
+ return { allow: true, notices };
34
+ }
35
+ const why = ev.head !== input.pushedHead
36
+ ? `원장 head ${ev.head.slice(0, 7)} ≠ push head ${input.pushedHead.slice(0, 7)} — 증빙 이후 커밋이 있습니다`
37
+ : ev.passed !== true
38
+ ? `원장이 붉은 실행(passed: false)입니다 — 붉은 스위트는 기준선이 될 수 없습니다`
39
+ : `원장이 아무것도 실행하지 않았습니다(executed 0) — no-op 초록은 증빙이 아닙니다`;
40
+ return {
41
+ allow: false,
42
+ reason: `[Holmes-Kit push 게이트] ${why}. 복구: 현재 HEAD에서 test_run(또는 전체 스위트)을 초록으로 실행해 원장을 갱신한 뒤 다시 push하십시오. 의도적 우회는 git push --no-verify.`,
43
+ notices,
44
+ };
45
+ }
46
+ /** One control-character-free line of a commit subject for interpolation into gate text. */
47
+ function flatSubject(s) {
48
+ return s.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, 80);
49
+ }
50
+ /**
51
+ * The pre-push stdin format: `<local ref> <local sha> <remote ref> <remote sha>` per line.
52
+ * A deletion push (all-zero local sha) deletes a remote ref — there is nothing to vouch for.
53
+ */
54
+ function parsePushLines(stdinText) {
55
+ const out = [];
56
+ for (const line of stdinText.split('\n')) {
57
+ const parts = line.trim().split(/\s+/);
58
+ if (parts.length !== 4)
59
+ continue;
60
+ const [, localSha, , remoteSha] = parts;
61
+ if (!/^[0-9a-f]{40}$/.test(localSha))
62
+ continue;
63
+ if (/^0{40}$/.test(localSha))
64
+ continue; // deletion: not judged
65
+ out.push({ localSha, remoteSha });
66
+ }
67
+ return out;
68
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The de-amplified scope judgment — PURE (no fs, no git; the hook gathers, this decides).
3
+ *
4
+ * Field basis (2026-08-23, jarvis): one test-class addition to an ALREADY-ANCHORED file cost
5
+ * >=3 human interventions, because the old model re-checked Files-to-Touch on every edit of an
6
+ * anchored file (double registration: the anchor said "this file belongs to X" and FtT had to
7
+ * say it again), while its matcher quietly widened every FILE token to the file's whole parent
8
+ * directory (`src/a/x.ts` covered all of `src/a/`). Both directions were amplification.
9
+ *
10
+ * The model here splits TENURE from ADMISSION:
11
+ * - TENURE: a file already anchored ON DISK is in its specs' scope — editing it is free. The
12
+ * entitlement comes from the fact that the file passed admission when it first joined (gated
13
+ * writes are the only way anchors legitimately reach disk), or was deliberately grandfathered
14
+ * at adoption (REQ-508's explicit acceptance of the 252-file reality).
15
+ * - ADMISSION: joining a file to a spec — a new file's payload anchors, or a re-anchor bringing
16
+ * an id the disk does not carry — must pass a gate: an FtT glob match, or the test-file
17
+ * dispensation (coverage growth is what ART-4 ASKS for), or the caller's out-of-band
18
+ * code-write approval (judged by the hook, not here).
19
+ *
20
+ * Anchor DELETION never blocks (un-anchoring may be legitimate refactoring) but is surfaced as
21
+ * a warning — silent RTM loss is the failure mode C12 taught us to fear.
22
+ */
23
+ /** Repo test conventions — the single census every dispensation consumer shares. */
24
+ export declare function isTestPath(relPath: string): boolean;
25
+ /**
26
+ * One FtT token against one repo-relative path. `**` crosses separators, `*` does not; a literal
27
+ * token with an extension names EXACTLY that file (the parent-directory widening is gone); a
28
+ * literal directory token covers its subtree. The regex build is linear — each glob char maps to
29
+ * one bounded fragment, so no ReDoS surface.
30
+ */
31
+ export declare function matchesFtt(token: string, relPath: string): boolean;
32
+ /** Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none. */
33
+ export declare function fttPathTokens(fttText: string): string[];
34
+ export interface ScopeInput {
35
+ relPath: string;
36
+ isNewFile: boolean;
37
+ /** All @implements A-SPEC ids found ON DISK (string-literal-stripped) — the full set, not the first. */
38
+ diskAnchorIds: string[];
39
+ /** All A-SPEC ids the write payload carries (content / new_string, stripped). */
40
+ payloadAnchorIds: string[];
41
+ approvedIds: ReadonlySet<string>;
42
+ fttTokensOf: (id: string) => string[];
43
+ /** Disk anchors this edit deliberately removes (hook-computed: anchors(old)−anchors(new)). */
44
+ removedAnchorIds?: string[];
45
+ }
46
+ export interface ScopeVerdict {
47
+ decision: 'in-scope' | 'admission-required';
48
+ failedAdmissions: Array<{
49
+ id: string;
50
+ reason: string;
51
+ }>;
52
+ warnings: string[];
53
+ }
54
+ export declare function judgeScope(input: ScopeInput): ScopeVerdict;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ // @implements A-SPEC-508.1
3
+ /**
4
+ * The de-amplified scope judgment — PURE (no fs, no git; the hook gathers, this decides).
5
+ *
6
+ * Field basis (2026-08-23, jarvis): one test-class addition to an ALREADY-ANCHORED file cost
7
+ * >=3 human interventions, because the old model re-checked Files-to-Touch on every edit of an
8
+ * anchored file (double registration: the anchor said "this file belongs to X" and FtT had to
9
+ * say it again), while its matcher quietly widened every FILE token to the file's whole parent
10
+ * directory (`src/a/x.ts` covered all of `src/a/`). Both directions were amplification.
11
+ *
12
+ * The model here splits TENURE from ADMISSION:
13
+ * - TENURE: a file already anchored ON DISK is in its specs' scope — editing it is free. The
14
+ * entitlement comes from the fact that the file passed admission when it first joined (gated
15
+ * writes are the only way anchors legitimately reach disk), or was deliberately grandfathered
16
+ * at adoption (REQ-508's explicit acceptance of the 252-file reality).
17
+ * - ADMISSION: joining a file to a spec — a new file's payload anchors, or a re-anchor bringing
18
+ * an id the disk does not carry — must pass a gate: an FtT glob match, or the test-file
19
+ * dispensation (coverage growth is what ART-4 ASKS for), or the caller's out-of-band
20
+ * code-write approval (judged by the hook, not here).
21
+ *
22
+ * Anchor DELETION never blocks (un-anchoring may be legitimate refactoring) but is surfaced as
23
+ * a warning — silent RTM loss is the failure mode C12 taught us to fear.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.isTestPath = isTestPath;
27
+ exports.matchesFtt = matchesFtt;
28
+ exports.fttPathTokens = fttPathTokens;
29
+ exports.judgeScope = judgeScope;
30
+ /** Repo test conventions — the single census every dispensation consumer shares. */
31
+ function isTestPath(relPath) {
32
+ const base = relPath.split('/').at(-1) ?? '';
33
+ if (/\.(test|spec)\.[^.]+$/.test(base))
34
+ return true; // x.test.ts / x.spec.tsx
35
+ if (/^test_[^/]*\.py$/.test(base))
36
+ return true; // pytest collection default
37
+ if (/_test\.(py|go)$/.test(base))
38
+ return true; // pytest alt / go test
39
+ const segs = relPath.split('/').slice(0, -1);
40
+ return segs.some((s) => s === 'tests' || s === '__tests__' || s === 'test');
41
+ }
42
+ /**
43
+ * One FtT token against one repo-relative path. `**` crosses separators, `*` does not; a literal
44
+ * token with an extension names EXACTLY that file (the parent-directory widening is gone); a
45
+ * literal directory token covers its subtree. The regex build is linear — each glob char maps to
46
+ * one bounded fragment, so no ReDoS surface.
47
+ */
48
+ function matchesFtt(token, relPath) {
49
+ const tok = token.replace(/\/+$/, '');
50
+ if (tok === '')
51
+ return false;
52
+ if (tok.includes('*')) {
53
+ const rx = tok.split(/(\*\*|\*)/).map((part) => {
54
+ if (part === '**')
55
+ return '.*';
56
+ if (part === '*')
57
+ return '[^/]*';
58
+ return part.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
59
+ }).join('');
60
+ return new RegExp(`^${rx}$`).test(relPath);
61
+ }
62
+ if (/\.[A-Za-z0-9]+$/.test(tok))
63
+ return relPath === tok; // file literal: that file only
64
+ return relPath === tok || relPath.startsWith(tok + '/'); // dir literal: its subtree
65
+ }
66
+ /** Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none. */
67
+ function fttPathTokens(fttText) {
68
+ return fttText.match(/[\w@.*-]+(?:\/[\w@.*-]+)+/g) ?? [];
69
+ }
70
+ // @implements A-SPEC-508.1
71
+ function judgeScope(input) {
72
+ const disk = new Set(input.diskAnchorIds);
73
+ const warnings = (input.removedAnchorIds ?? [])
74
+ .filter((id) => disk.has(id))
75
+ .map((id) => `이 편집은 ${id} 앵커를 제거합니다 — RTM 추적 연결이 끊깁니다(커버리지·그래프·게이트가 이 파일을 더는 ${id}에 귀속시키지 않음). 의도한 재구성이면 진행하고, 아니면 앵커를 보존하십시오`);
76
+ // NEW CLAIMS are the only thing admission ever judges: a new file's anchors, or a re-anchor
77
+ // bringing an id the disk does not carry. An existing file that claims nothing new is in scope
78
+ // by tenure — including the vacuous cases (no anchors at all, draft-only anchors): the gates
79
+ // that own those questions (No-Spec-No-Code, stale-seal) run on their own lanes.
80
+ const newClaims = input.payloadAnchorIds.filter((id) => !disk.has(id));
81
+ if (!input.isNewFile && newClaims.length === 0) {
82
+ return { decision: 'in-scope', failedAdmissions: [], warnings };
83
+ }
84
+ const claims = input.isNewFile ? input.payloadAnchorIds : newClaims;
85
+ const failedAdmissions = [];
86
+ for (const id of claims) {
87
+ const tokens = input.fttTokensOf(id);
88
+ if (tokens.length === 0)
89
+ continue; // prose-only imposes nothing
90
+ if (tokens.some((t) => matchesFtt(t, input.relPath)))
91
+ continue; // FtT glob admits
92
+ if (isTestPath(input.relPath) && input.approvedIds.has(id))
93
+ continue; // coverage dispensation
94
+ failedAdmissions.push({
95
+ id,
96
+ reason: input.isNewFile
97
+ ? `${id}의 'Files to Touch' 밖이고 테스트 특례에도 해당하지 않습니다`
98
+ : `재앵커(이관): ${id}는 이 파일의 디스크 앵커가 아니며, 'Files to Touch' 관문과 테스트 특례 모두 통과하지 못했습니다`,
99
+ });
100
+ }
101
+ return {
102
+ decision: failedAdmissions.length > 0 ? 'admission-required' : 'in-scope',
103
+ failedAdmissions,
104
+ warnings,
105
+ };
106
+ }
@@ -138,6 +138,7 @@ export declare function evaluateHook(input: {
138
138
  file_path?: string;
139
139
  notebook_path?: string;
140
140
  content?: string;
141
+ old_string?: string;
141
142
  new_string?: string;
142
143
  command?: string;
143
144
  };
@@ -1218,11 +1218,10 @@ function evaluateHook(input, specsDir, opts) {
1218
1218
  const payloadAnchor = ANCHOR_RE.exec([input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n'));
1219
1219
  const diskAnchor = ANCHOR_RE.exec(onDiskContent);
1220
1220
  const m = diskAnchor ?? payloadAnchor;
1221
- // RE-ANCHORING must be scope-checked against the NEW spec (review C6: on-disk precedence meant an
1222
- // edit that rewrites the anchor line was judged under the OLD spec, so a file could be re-attributed
1223
- // to any spec without that spec's Files-to-Touch ever being consulted which then mis-credits
1224
- // coverage evidence). Both the current and the incoming anchor must accept the path.
1225
- const anchorsToScope = [m?.[1], payloadAnchor?.[1] !== m?.[1] ? payloadAnchor?.[1] : undefined].filter(Boolean);
1221
+ // RE-ANCHORING scope (review C6) moved into judgeScope below (S-508.1): every payload anchor the
1222
+ // disk does not carry is an ADMISSION, judged against the incoming spec's gate the C6 property
1223
+ // (a rewrite of the anchor line is judged under the NEW spec) is preserved there, now for the
1224
+ // FULL anchor set instead of the first match.
1226
1225
  // @implements A-SPEC-132
1227
1226
  // Stale-aware gate (rev.1): a target A-SPEC whose seal is PRESENT and BROKEN (edited after
1228
1227
  // approval) or STALE (a parent moved) is not a trustworthy approval, so code must not be written
@@ -1307,33 +1306,46 @@ function evaluateHook(input, specsDir, opts) {
1307
1306
  + (lost ? `\n${governance_history_1.GOVERNANCE_LOST_HINT}` : ''),
1308
1307
  };
1309
1308
  }
1310
- // Marker self-attestation mitigation: an @implements anchor is a claim the writer makes. When the
1311
- // anchored A-SPEC's `Files to Touch` names CONCRETE paths, the file must fall under one of them —
1312
- // so anchoring evil.ts (or an unrelated edit) to a random approved spec fails when that spec scoped
1313
- // its files. Applies to EVERY governed code Write/Edit, not just new files: the earlier new-file-only
1314
- // scoping left "create it some other way, then edit freely" open, and a stale anchor blessed
1315
- // unrelated edits forever. The legitimate flow is to extend the spec's Files-to-Touch FIRST
1316
- // (editing an approved spec's sections is allowed; only the approval transition is gated).
1317
- // Still bounded: only when concrete paths are listed (a prose/TODO section imposes nothing), and a
1318
- // valid out-of-band approval overrides. Verified 0/93 violations across this repo's anchored files.
1319
- for (const anchorId of anchorsToScope) {
1320
- const aspec = specs.find((s) => s.id === anchorId);
1321
- const ftt = aspec?.sections['Files to Touch'] ?? '';
1322
- const listed = ftt.match(/[\w@.-]+(?:\/[\w@.*-]+)+/g) ?? []; // concrete path-like tokens only
1323
- if (listed.length === 0)
1324
- continue; // prose-only section imposes nothing
1325
- const rel = relPath;
1326
- const covered = listed.some((t) => {
1327
- const tok = t.replace(/\/?\*+$/, ''); // dir/** -> dir
1328
- const dir = /\.[A-Za-z0-9]+$/.test(tok) ? tok.replace(/\/[^/]*$/, '') : tok; // file -> its dir
1329
- return rel === tok || rel.startsWith(tok + '/') || rel.startsWith(dir + '/');
1330
- });
1331
- if (!covered && !codeWriteCovered) { // @implements A-SPEC-133 — same code-write coverage as the stale gate
1332
- return {
1333
- permissionDecision: 'deny',
1334
- permissionDecisionReason: `[Holmes-Kit] ${rel} is anchored to ${anchorId} but falls outside its 'Files to Touch' scope — extend that section first (spec edit is allowed) or supply out-of-band approval`,
1335
- };
1336
- }
1309
+ // @implements A-SPEC-508.1
1310
+ // De-amplified scope: TENURE vs ADMISSION (S-508.1 replaced the per-edit FtT re-check that made
1311
+ // one test-class addition cost >=3 human interventions, and the file-token→parent-directory
1312
+ // widening that quietly covered whole sibling trees). A file already anchored on disk is in its
1313
+ // specs' scope editing it is free and silent. Only NEW CLAIMS (a new file's anchors, or a
1314
+ // re-anchor bringing an id the disk does not carry) pass admission: FtT glob, or the test-file
1315
+ // dispensation (ART-4 asks for coverage the gate must not refuse it), or the out-of-band
1316
+ // code-write approval. Anchor deletion never blocks but rides the allow as a warning silent
1317
+ // RTM loss is the C12 failure mode, made audible.
1318
+ const { judgeScope, fttPathTokens: fttTokens } = require('../guardrail/scope-judgment');
1319
+ const { anchorSpecIds, stripStringLiterals } = require('../rtm/anchor-ids');
1320
+ const oldStr = input.tool_input.old_string ?? '';
1321
+ const newPayload = [input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n');
1322
+ // Deletion intent: a WRITE replaces the whole file, so disk→content is the comparison; an EDIT
1323
+ // only proves a deletion when its old_string carried the anchor (a fragment NOT containing an
1324
+ // anchor says nothing about it — no evidence, no warning). Discriminate by TOOL NAME, not by
1325
+ // which payload fields exist: the antigravity adapter aliases `content` alongside `new_string`
1326
+ // for its fragment-replace tool, and field-based detection read that as a whole-file wipe of
1327
+ // every disk anchor (14 false warnings, caught by the A-SPEC-338 spawn-parity pin).
1328
+ const isWholeFileWrite = input.tool_name.toLowerCase() === 'write';
1329
+ const beforeIds = anchorSpecIds(stripStringLiterals(isWholeFileWrite ? onDiskContent : oldStr));
1330
+ const afterIds = new Set(anchorSpecIds(stripStringLiterals(newPayload)));
1331
+ const scopeVerdict = judgeScope({
1332
+ relPath,
1333
+ isNewFile: onDiskContent === '' && !fs.existsSync(p),
1334
+ diskAnchorIds: anchorSpecIds(stripStringLiterals(onDiskContent)),
1335
+ payloadAnchorIds: anchorSpecIds(stripStringLiterals(newPayload)),
1336
+ approvedIds: new Set(specs.filter((s) => s.status === 'approved').map((s) => s.id)),
1337
+ fttTokensOf: (id) => fttTokens(specs.find((s) => s.id === id)?.sections['Files to Touch'] ?? ''),
1338
+ removedAnchorIds: beforeIds.filter((id) => !afterIds.has(id)),
1339
+ });
1340
+ const scopeWarnings = scopeVerdict.warnings.map((w) => `[Holmes-Kit] ${w}`).join('\n');
1341
+ if (scopeVerdict.decision === 'admission-required' && !codeWriteCovered) { // @implements A-SPEC-133 — same code-write coverage as the stale gate
1342
+ const failed = scopeVerdict.failedAdmissions
1343
+ .map((f) => `${relPath} is anchored to ${f.id} but falls outside its 'Files to Touch' scope (${f.reason})`).join('; ');
1344
+ return {
1345
+ permissionDecision: 'deny',
1346
+ permissionDecisionReason: `[Holmes-Kit] ${failed} — extend that section first (spec edit is allowed), add it as a test under the anchored spec, or supply out-of-band approval`
1347
+ + (scopeWarnings ? `\n${scopeWarnings}` : ''),
1348
+ };
1337
1349
  }
1338
1350
  // @implements A-SPEC-278
1339
1351
  // Pre-edit impact evidence, deliberately the LAST gate on this path.
@@ -1361,9 +1373,19 @@ function evaluateHook(input, specsDir, opts) {
1361
1373
  declaredDigest: process.env.HOLMES_EVIDENCE?.trim() || null,
1362
1374
  currentHead: evidenceHead,
1363
1375
  });
1364
- if (evidenceDecision)
1376
+ if (evidenceDecision) {
1377
+ // @implements A-SPEC-508.1 — an allow-with-reason from the evidence gate must not swallow
1378
+ // the anchor-deletion warning: both ride the same reason field, newline-joined.
1379
+ if (scopeWarnings && evidenceDecision.permissionDecision === 'allow') {
1380
+ return { ...evidenceDecision, permissionDecisionReason: [evidenceDecision.permissionDecisionReason, scopeWarnings].filter(Boolean).join('\n') };
1381
+ }
1365
1382
  return evidenceDecision;
1383
+ }
1366
1384
  }
1385
+ // @implements A-SPEC-508.1 — anchor-deletion warnings ride the allow (allow-with-reason, the
1386
+ // same channel A-SPEC-278 uses): the write proceeds, but the RTM loss is on the record.
1387
+ if (scopeWarnings)
1388
+ return { permissionDecision: 'allow', permissionDecisionReason: scopeWarnings };
1367
1389
  return { permissionDecision: 'allow' };
1368
1390
  }
1369
1391
  // Synchronous spec loader for hook context (fail-open: directory read errors are skipped)
@@ -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,