@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.
- package/CHANGELOG.md +62 -0
- package/bin/holmes-pre-push.js +20 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-watch.d.ts +32 -0
- package/dist/holmes/cli/approve-watch.js +104 -1
- package/dist/holmes/cli/doctor.d.ts +6 -0
- package/dist/holmes/cli/doctor.js +37 -0
- package/dist/holmes/cli/index.js +52 -5
- package/dist/holmes/cli/os-notify.d.ts +19 -0
- package/dist/holmes/cli/os-notify.js +48 -0
- package/dist/holmes/governance/push-gate-runner.d.ts +15 -0
- package/dist/holmes/governance/push-gate-runner.js +101 -0
- package/dist/holmes/governance/push-gate.d.ts +39 -0
- package/dist/holmes/governance/push-gate.js +68 -0
- package/dist/holmes/guardrail/scope-judgment.d.ts +54 -0
- package/dist/holmes/guardrail/scope-judgment.js +106 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +1 -0
- package/dist/holmes/hooks/pre-tool-use.js +55 -33
- package/dist/holmes/mcp/elicit-approval.d.ts +52 -19
- package/dist/holmes/mcp/elicit-approval.js +124 -16
- package/dist/holmes/mcp/server.js +8 -6
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
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
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
// (
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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 2 — it 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
|
|
59
|
-
|
|
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:
|
|
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,
|