@holmes-lab/holmes-kit 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -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/approve.d.ts +13 -1
- package/dist/holmes/cli/approve.js +35 -2
- package/dist/holmes/cli/index.js +25 -8
- package/dist/holmes/cli/os-notify.d.ts +19 -0
- package/dist/holmes/cli/os-notify.js +48 -0
- package/dist/holmes/cpg/consistency-lints.d.ts +26 -0
- package/dist/holmes/cpg/consistency-lints.js +67 -0
- package/dist/holmes/governance/approval-queue.d.ts +16 -2
- package/dist/holmes/governance/approval-queue.js +17 -6
- package/dist/holmes/mcp/elicit-approval.d.ts +52 -19
- package/dist/holmes/mcp/elicit-approval.js +124 -16
- package/dist/holmes/mcp/handlers.d.ts +22 -0
- package/dist/holmes/mcp/handlers.js +32 -1
- package/dist/holmes/mcp/server.js +8 -6
- package/dist/holmes/mcp/tool-schemas.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
<!-- @implements A-SPEC-209 -->
|
|
8
|
+
## [0.3.8] - 2026-09-01
|
|
9
|
+
|
|
10
|
+
Approval-UX slice pair (REQ-497 완결): the resident CLI surface decides on ONE raw keypress with
|
|
11
|
+
an OS arrival ping, and the in-session dialog lane gets a one-choice form, a finite re-display
|
|
12
|
+
and per-path deadlines.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`approve --watch` single-key TUI + OS notification** (A-SPEC-497.2): a pending item is
|
|
17
|
+
decided by one raw keypress — `a` approve / `d` deny / `q` one-line question / `s` skip — no
|
|
18
|
+
Enter, no deadline (a human terminal can wait forever). Unknown keys decide nothing
|
|
19
|
+
(fail-closed) and Ctrl-C leaves without deciding. Each NEW arrival fires one OS-native
|
|
20
|
+
notification (macOS `osascript`, Windows WinRT toast; flattened text, every failure swallowed —
|
|
21
|
+
a dead notifier changes nothing about the queue or the gates).
|
|
22
|
+
- **Elicitation one-choice form, finite re-display, per-path deadlines** (A-SPEC-497.3): the
|
|
23
|
+
in-session approval dialog asks the DECISION alone (reason moves to a second dialog shown only
|
|
24
|
+
after deny/question — a question requires text, a deny stands without it); an expired dialog is
|
|
25
|
+
re-presented exactly once with a visible "재표시 2/2" counter (stale widgets survive on screen —
|
|
26
|
+
measured); and the deadline differs by path — lock-free `spec-approve` gets 240s, lock-holding
|
|
27
|
+
`review-resolve` keeps 120s with no re-display (starvation ceiling preserved). Decline, cancel
|
|
28
|
+
and any answer are never re-asked. Multi-cycle expiry measured end-to-end over the real MCP
|
|
29
|
+
protocol: two 240s presentations, summed 480s expired refusal, outer call alive.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- The `--watch` input path replaces the readline line-reader with a raw-mode key stream; the
|
|
34
|
+
non-watch interactive surface is unchanged. The elicitation stage-1 form no longer carries a
|
|
35
|
+
`reason` field (moved to stage 2); silent-path refusal text stays byte-identical
|
|
36
|
+
(A-SPEC-263.1), and the expiry notice format is unchanged (summed seconds).
|
|
37
|
+
|
|
38
|
+
<!-- @implements A-SPEC-209 -->
|
|
39
|
+
## [0.3.7] - 2026-09-01
|
|
40
|
+
|
|
41
|
+
Field-incident hardening: consistency warning lints over the code graph, and an approval queue
|
|
42
|
+
that stops burying the one approval that matters.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Consistency lints, opt-in** (A-SPEC-506.1): `cpg_scan` accepts `lints: true` and adds a
|
|
47
|
+
warning-signal field (Python, with an honest `limits` envelope): `dynamicRef` — a literal
|
|
48
|
+
`getattr`/`hasattr` name absent from the scanned symbol census (field incident: 49
|
|
49
|
+
owner-approved deletions silently no-op'ed against a nonexistent method) — and
|
|
50
|
+
`asyncBlocking` — a known-blocking call inside an `async def`, unwrapped on its line (field
|
|
51
|
+
incident: scheduler starvation while the same file's other call sites used `to_thread`). No
|
|
52
|
+
gate consumes these; the default scan result is unchanged.
|
|
53
|
+
- **Approval-queue staleness** (A-SPEC-507.1): the `approve` list sorts by latest activity and
|
|
54
|
+
folds entries whose last activity exceeded `--stale-hours` (default 24; `--all` unfolds,
|
|
55
|
+
`--stale-hours 0` disables) — measured: 51 pending rows, mostly week-old residue, buried the
|
|
56
|
+
one live approval at [32]. The ledger deletes nothing; a re-filed request revives itself; and
|
|
57
|
+
gate-path folds take no clock at all (value-identical without the view options).
|
|
58
|
+
|
|
7
59
|
<!-- @implements A-SPEC-209 -->
|
|
8
60
|
## [0.3.6] - 2026-09-01
|
|
9
61
|
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
809f8c3-mthx43ms
|
|
@@ -1,4 +1,28 @@
|
|
|
1
|
+
import { readQueue, PendingRequest } from '../governance/approval-queue';
|
|
1
2
|
import { ApproveIO } from './approve';
|
|
3
|
+
/** The raw-mode key source a single-key watch listens to (process.stdin in the shipped CLI). */
|
|
4
|
+
export interface KeyStream {
|
|
5
|
+
setRawMode?: (raw: boolean) => unknown;
|
|
6
|
+
resume?: () => unknown;
|
|
7
|
+
pause?: () => unknown;
|
|
8
|
+
on(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
|
|
9
|
+
removeListener(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* ONE raw keypress decides an item — a/d/q/s; q collects one follow-up line (the question).
|
|
13
|
+
* Unknown keys decide NOTHING (a typo must not become an approval — fail-closed), and Ctrl-C
|
|
14
|
+
* rejects with AbortError: leaving is not deciding. No deadline anywhere: the MAIN LANE's whole
|
|
15
|
+
* point is that a human terminal can wait forever (H-SPEC-497).
|
|
16
|
+
*/
|
|
17
|
+
export declare function decideByKey(stream: KeyStream, _item: Pick<PendingRequest, 'id'>): Promise<'granted' | 'denied' | 'skipped' | {
|
|
18
|
+
question: string;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Present each pending item and apply the single-key decision. The item summary goes through the
|
|
22
|
+
* SAME escaped cells the list uses (agent-controlled text never owns a line), and a denial gets a
|
|
23
|
+
* canned-but-visible reason — the agent must still see that a human said no.
|
|
24
|
+
*/
|
|
25
|
+
export declare function presentByKey(root: string, io: ApproveIO, actor: string, stream: KeyStream, state: ReturnType<typeof readQueue>): Promise<void>;
|
|
2
26
|
/** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
|
|
3
27
|
export declare const WATCH_IDLE = "\u25C6 \uC2B9\uC778 \uB300\uAE30 \uC911\u2026 (Ctrl-C \uB85C \uC885\uB8CC)";
|
|
4
28
|
/** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
|
|
@@ -13,6 +37,14 @@ export interface WatchOpts {
|
|
|
13
37
|
* Must resolve early if `signal` aborts, so an idle watch shuts down at once.
|
|
14
38
|
*/
|
|
15
39
|
waitIdle?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* @implements A-SPEC-497.2
|
|
42
|
+
* When present, NEW items are presented via the single-key surface (presentByKey) instead of
|
|
43
|
+
* runInteractive — the shipped CLI passes raw-mode stdin here. Absent: legacy path, byte-for-byte.
|
|
44
|
+
*/
|
|
45
|
+
keyStream?: KeyStream;
|
|
46
|
+
/** Called once per NEW pending item (lastPresented dedup) — a throwing notifier is swallowed. */
|
|
47
|
+
notify?: (item: PendingRequest) => void;
|
|
16
48
|
}
|
|
17
49
|
/** Exported for test: the shipped idle wait must resolve at once on abort, not after the timer. */
|
|
18
50
|
export declare function defaultWaitIdle(ms: number, signal?: AbortSignal): Promise<void>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DEFAULT_POLL_MS = exports.WATCH_IDLE = void 0;
|
|
4
|
+
exports.decideByKey = decideByKey;
|
|
5
|
+
exports.presentByKey = presentByKey;
|
|
4
6
|
exports.defaultWaitIdle = defaultWaitIdle;
|
|
5
7
|
exports.runWatch = runWatch;
|
|
6
8
|
// @implements A-SPEC-262.2
|
|
@@ -14,6 +16,92 @@ exports.runWatch = runWatch;
|
|
|
14
16
|
// existing runInteractive, and waits between polls when the queue is empty.
|
|
15
17
|
const approval_queue_1 = require("../governance/approval-queue");
|
|
16
18
|
const approve_1 = require("./approve");
|
|
19
|
+
// @implements A-SPEC-497.2
|
|
20
|
+
/**
|
|
21
|
+
* ONE raw keypress decides an item — a/d/q/s; q collects one follow-up line (the question).
|
|
22
|
+
* Unknown keys decide NOTHING (a typo must not become an approval — fail-closed), and Ctrl-C
|
|
23
|
+
* rejects with AbortError: leaving is not deciding. No deadline anywhere: the MAIN LANE's whole
|
|
24
|
+
* point is that a human terminal can wait forever (H-SPEC-497).
|
|
25
|
+
*/
|
|
26
|
+
function decideByKey(stream, _item) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let questionMode = false;
|
|
29
|
+
let line = '';
|
|
30
|
+
const onData = (chunk) => {
|
|
31
|
+
const s = chunk.toString();
|
|
32
|
+
for (const ch of s) {
|
|
33
|
+
if (ch === '') {
|
|
34
|
+
cleanup();
|
|
35
|
+
const e = new Error('operator left');
|
|
36
|
+
e.name = 'AbortError';
|
|
37
|
+
reject(e);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (questionMode) {
|
|
41
|
+
if (ch === '\r' || ch === '\n') {
|
|
42
|
+
cleanup();
|
|
43
|
+
resolve({ question: line });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '' || ch === '\b') {
|
|
47
|
+
line = line.slice(0, -1);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
line += ch;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === 'a') {
|
|
54
|
+
cleanup();
|
|
55
|
+
resolve('granted');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (ch === 'd') {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve('denied');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (ch === 's') {
|
|
64
|
+
cleanup();
|
|
65
|
+
resolve('skipped');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (ch === 'q') {
|
|
69
|
+
questionMode = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// any other key: not a decision — keep listening
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const cleanup = () => { stream.removeListener('data', onData); };
|
|
76
|
+
stream.on('data', onData);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// @implements A-SPEC-497.2
|
|
80
|
+
/**
|
|
81
|
+
* Present each pending item and apply the single-key decision. The item summary goes through the
|
|
82
|
+
* SAME escaped cells the list uses (agent-controlled text never owns a line), and a denial gets a
|
|
83
|
+
* canned-but-visible reason — the agent must still see that a human said no.
|
|
84
|
+
*/
|
|
85
|
+
async function presentByKey(root, io, actor, stream, state) {
|
|
86
|
+
for (const p of state.pending) {
|
|
87
|
+
io.print(`\n─ ${(0, approve_1.subjectCells)('─ ', p)}`);
|
|
88
|
+
io.print('[a]승인 [d]거부 [q]질문 [s]건너뜀 > ');
|
|
89
|
+
const d = await decideByKey(stream, p);
|
|
90
|
+
if (d === 'granted') {
|
|
91
|
+
const r = (0, approve_1.grantRequest)(root, p.id, { actor });
|
|
92
|
+
io.print(r.ok ? `✓ 승인 — ${(0, approve_1.subjectCells)('✓ 승인 — ', p)}` : `✗ ${r.reason}`);
|
|
93
|
+
}
|
|
94
|
+
else if (d === 'denied') {
|
|
95
|
+
const r = (0, approve_1.denyRequest)(root, p.id, '운영자가 watch에서 단일 키로 거부(사유 즉답 생략)', actor);
|
|
96
|
+
io.print(r.ok ? `✗ 거부 — ${(0, approve_1.subjectCells)('✗ 거부 — ', p)}` : `✗ ${r.reason}`);
|
|
97
|
+
}
|
|
98
|
+
else if (typeof d === 'object') {
|
|
99
|
+
const r = (0, approve_1.holdRequest)(root, p.id, d.question, actor);
|
|
100
|
+
io.print(r.ok ? `? 보류 — 질문이 다음 거부 문면에 실립니다` : `✗ ${r.reason}`);
|
|
101
|
+
}
|
|
102
|
+
// skipped: leave it pending, silently
|
|
103
|
+
}
|
|
104
|
+
}
|
|
17
105
|
/** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
|
|
18
106
|
exports.WATCH_IDLE = '◆ 승인 대기 중… (Ctrl-C 로 종료)';
|
|
19
107
|
/** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
|
|
@@ -60,9 +148,24 @@ async function runWatch(root, io, actor, opts = {}) {
|
|
|
60
148
|
const hasNew = state.pending.some((p) => !lastPresented.has(p.id));
|
|
61
149
|
if (state.pending.length > 0 && hasNew) {
|
|
62
150
|
idleAnnounced = false;
|
|
151
|
+
// @implements A-SPEC-497.2 — one notification per NEW item, and never a lever: a throwing
|
|
152
|
+
// notifier is swallowed (the same doctrine as the queue writer's own failures).
|
|
153
|
+
if (opts.notify) {
|
|
154
|
+
for (const p of state.pending) {
|
|
155
|
+
if (lastPresented.has(p.id))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
opts.notify(p);
|
|
159
|
+
}
|
|
160
|
+
catch { /* fire-and-forget */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
63
163
|
const shown = new Set(state.pending.map((p) => p.id)); // what this pass presents
|
|
64
164
|
try {
|
|
65
|
-
|
|
165
|
+
if (opts.keyStream)
|
|
166
|
+
await presentByKey(root, io, actor, opts.keyStream, state);
|
|
167
|
+
else
|
|
168
|
+
await (0, approve_1.runInteractive)(root, io, actor);
|
|
66
169
|
}
|
|
67
170
|
catch (e) {
|
|
68
171
|
// The operator leaving mid-decision (Ctrl-C / EOF) surfaces as AbortError through the
|
|
@@ -21,7 +21,19 @@ export declare function holdRequest(root: string, id: string, question: string,
|
|
|
21
21
|
* when, and any standing question. Deliberately NO scope grammar anywhere — the moment the screen
|
|
22
22
|
* teaches syntax, humans start typing it.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The list-view stale flags, parsed once: `--stale-hours <h>` (default 24; 0 disables folding —
|
|
26
|
+
* `--ttl` was already taken by the grant-lifetime flag) and `--all` to unfold. Returning null
|
|
27
|
+
* means "no clock": readQueue is then called without opts and the fold stays value-identical.
|
|
28
|
+
*/
|
|
29
|
+
export declare function staleView(flags: Record<string, unknown>): {
|
|
30
|
+
ttlMs: number;
|
|
31
|
+
showAll: boolean;
|
|
32
|
+
} | null;
|
|
33
|
+
export declare function renderPending(state: QueueState, view?: {
|
|
34
|
+
staleHours: number;
|
|
35
|
+
showAll: boolean;
|
|
36
|
+
}): string;
|
|
25
37
|
/**
|
|
26
38
|
* @implements A-SPEC-262.1
|
|
27
39
|
* What a decision line must say. Round-1: `✓ 승인 — <expires> 까지 유효` named NOTHING, so an index
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.grantRequest = grantRequest;
|
|
37
37
|
exports.denyRequest = denyRequest;
|
|
38
38
|
exports.holdRequest = holdRequest;
|
|
39
|
+
exports.staleView = staleView;
|
|
39
40
|
exports.renderPending = renderPending;
|
|
40
41
|
exports.subjectCells = subjectCells;
|
|
41
42
|
exports.subjectRoom = subjectRoom;
|
|
@@ -236,7 +237,20 @@ function holdRequest(root, id, question, actor) {
|
|
|
236
237
|
* when, and any standing question. Deliberately NO scope grammar anywhere — the moment the screen
|
|
237
238
|
* teaches syntax, humans start typing it.
|
|
238
239
|
*/
|
|
239
|
-
|
|
240
|
+
// @implements A-SPEC-507.1
|
|
241
|
+
/**
|
|
242
|
+
* The list-view stale flags, parsed once: `--stale-hours <h>` (default 24; 0 disables folding —
|
|
243
|
+
* `--ttl` was already taken by the grant-lifetime flag) and `--all` to unfold. Returning null
|
|
244
|
+
* means "no clock": readQueue is then called without opts and the fold stays value-identical.
|
|
245
|
+
*/
|
|
246
|
+
function staleView(flags) {
|
|
247
|
+
const raw = flags['stale-hours'];
|
|
248
|
+
const hours = typeof raw === 'string' ? Number(raw) : 24;
|
|
249
|
+
if (!Number.isFinite(hours) || hours <= 0)
|
|
250
|
+
return null;
|
|
251
|
+
return { ttlMs: hours * 3_600_000, showAll: flags.all === true };
|
|
252
|
+
}
|
|
253
|
+
function renderPending(state, view) {
|
|
240
254
|
if (state.pending.length === 0) {
|
|
241
255
|
// Round-7: this early return made the malformed-line row below UNREACHABLE in exactly the case
|
|
242
256
|
// it exists for — a wholly corrupted queue printed "no requests waiting" and exited 0, which is
|
|
@@ -249,7 +263,13 @@ function renderPending(state) {
|
|
|
249
263
|
return '승인 대기 중인 요청이 없습니다.';
|
|
250
264
|
}
|
|
251
265
|
const lines = [`◆ 승인 대기 ${state.pending.length}건`, ''];
|
|
252
|
-
|
|
266
|
+
// @implements A-SPEC-507.1 — the LIST VIEW sorts newest-activity-first and folds stale entries
|
|
267
|
+
// (C4: 51 rows, mostly week-old residue, buried the one live approval at [32]). Only when a view
|
|
268
|
+
// is passed: callers without one keep the legacy insertion order byte-for-byte.
|
|
269
|
+
const shown = view
|
|
270
|
+
? [...state.pending].sort((a, b) => (Date.parse(b.lastTs) || 0) - (Date.parse(a.lastTs) || 0))
|
|
271
|
+
: state.pending;
|
|
272
|
+
shown.forEach((p, i) => {
|
|
253
273
|
// @implements A-SPEC-262.1 — every field here is AGENT-CONTROLLED (the target is the command it
|
|
254
274
|
// was blocked on). Round-1 forged two extra rows and hid the real ones behind an ANSI conceal,
|
|
255
275
|
// and the operator granted a `curl … | sh` they never saw. The template owns the line structure.
|
|
@@ -266,6 +286,19 @@ function renderPending(state) {
|
|
|
266
286
|
lines.push(` id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
|
|
267
287
|
lines.push('');
|
|
268
288
|
});
|
|
289
|
+
if (view && state.expired.length > 0) {
|
|
290
|
+
if (view.showAll) {
|
|
291
|
+
// Folding is not concealment: --all shows every expired entry under a template-owned prefix.
|
|
292
|
+
for (const p of state.expired) {
|
|
293
|
+
lines.push(`[만료] ${subjectCells('[만료] ', p)}`);
|
|
294
|
+
lines.push(` 마지막 활동: ${(0, screen_safe_1.rowField)(p.lastTs, 28)} · id: ${(0, screen_safe_1.rowField)(p.id, 40)}`);
|
|
295
|
+
lines.push('');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
lines.push(`⏳ 만료로 접힘 ${state.expired.length}건 (마지막 활동 ${view.staleHours}h 초과) — --all 로 표시`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
269
302
|
if (state.malformedLines > 0)
|
|
270
303
|
lines.push(`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`);
|
|
271
304
|
return lines.join('\n');
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -787,8 +787,14 @@ async function main(argv) {
|
|
|
787
787
|
process.stdout.write(r.ok ? `✓ 보류 — ${ref.subject('✓ 보류 — ')}\n${ref.detail('질문은 다음 거부 문면에')}\n` : `✗ ${r.reason}\n`);
|
|
788
788
|
return r.ok ? 0 : 1;
|
|
789
789
|
}
|
|
790
|
+
// @implements A-SPEC-507.1 — stale folding is a LIST-VIEW judgment: the clock enters only
|
|
791
|
+
// here, never on gate-path folds. `--stale-hours 0` disables it; `--all` unfolds.
|
|
792
|
+
const { staleView } = require('./approve');
|
|
793
|
+
const stale = staleView(flags);
|
|
794
|
+
const queueOpts = stale ? { now: Date.now(), ttlMs: stale.ttlMs } : undefined;
|
|
795
|
+
const listView = stale ? { staleHours: stale.ttlMs / 3_600_000, showAll: stale.showAll } : undefined;
|
|
790
796
|
if (flags.list) {
|
|
791
|
-
process.stdout.write(renderPending(readQueue(root)) + '\n');
|
|
797
|
+
process.stdout.write(renderPending(readQueue(root, queueOpts), listView) + '\n');
|
|
792
798
|
return 0;
|
|
793
799
|
}
|
|
794
800
|
// @implements A-SPEC-262.2 — the RESIDENT surface. Validate --poll-ms first (so a bad value is
|
|
@@ -808,17 +814,27 @@ async function main(argv) {
|
|
|
808
814
|
process.stderr.write('✗ 대화형 상주 모드(--watch)는 TTY 가 필요합니다 — 스크립트에서는 --list 를 쓰십시오\n');
|
|
809
815
|
return 1;
|
|
810
816
|
}
|
|
817
|
+
// @implements A-SPEC-497.2 — the resident surface decides on ONE raw keypress (a/d/q/s, no
|
|
818
|
+
// Enter, no deadline) and pings the OS on each new arrival. Raw mode is restored on the way
|
|
819
|
+
// out whatever happens; Ctrl-C arrives as \x03 through decideByKey and ends the loop.
|
|
811
820
|
const { runWatch } = require('./approve-watch');
|
|
812
|
-
const
|
|
821
|
+
const { notifyQueueArrival } = require('./os-notify');
|
|
813
822
|
const ac = new AbortController();
|
|
814
|
-
rl.once('close', () => ac.abort());
|
|
815
823
|
const onSig = () => ac.abort();
|
|
816
824
|
process.once('SIGINT', onSig);
|
|
825
|
+
const stdin = process.stdin;
|
|
817
826
|
try {
|
|
827
|
+
stdin.setRawMode?.(true);
|
|
828
|
+
stdin.resume();
|
|
818
829
|
await runWatch(root, {
|
|
819
830
|
print: (t) => process.stdout.write(t + '\n'),
|
|
820
|
-
ask: (
|
|
821
|
-
}, actor, {
|
|
831
|
+
ask: async () => '', // unused on the key surface; kept for the ApproveIO shape
|
|
832
|
+
}, actor, {
|
|
833
|
+
signal: ac.signal,
|
|
834
|
+
keyStream: stdin,
|
|
835
|
+
notify: (p) => notifyQueueArrival(p),
|
|
836
|
+
...(pollMs !== undefined ? { pollMs } : {}),
|
|
837
|
+
});
|
|
822
838
|
}
|
|
823
839
|
catch (e) {
|
|
824
840
|
if (e?.name !== 'AbortError')
|
|
@@ -826,16 +842,17 @@ async function main(argv) {
|
|
|
826
842
|
}
|
|
827
843
|
finally {
|
|
828
844
|
process.removeListener('SIGINT', onSig);
|
|
829
|
-
|
|
845
|
+
stdin.setRawMode?.(false);
|
|
846
|
+
stdin.pause();
|
|
830
847
|
}
|
|
831
848
|
return 0;
|
|
832
849
|
}
|
|
833
850
|
// @implements A-SPEC-260 — the implicit non-TTY fallback tells the operator the next command,
|
|
834
851
|
// with the real pending id filled in; the explicit --list above stays script-clean.
|
|
835
852
|
if (!process.stdin.isTTY) {
|
|
836
|
-
const state = readQueue(root);
|
|
853
|
+
const state = readQueue(root, queueOpts);
|
|
837
854
|
const hint = renderNonTtyHint(state);
|
|
838
|
-
process.stdout.write(renderPending(state) + (hint ? '\n\n' + hint : '') + '\n');
|
|
855
|
+
process.stdout.write(renderPending(state, listView) + (hint ? '\n\n' + hint : '') + '\n');
|
|
839
856
|
return 0;
|
|
840
857
|
}
|
|
841
858
|
// @implements A-SPEC-262.1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-native arrival notification for the approval queue — the MAIN LANE's "you are needed" ping.
|
|
3
|
+
* Fire-and-forget by doctrine: a notification failure must change NOTHING about the watch loop,
|
|
4
|
+
* the gate verdicts or the queue (the same swallow rule enqueueApprovalRequest lives by — the
|
|
5
|
+
* moment a dead notifier could change behavior, killing it becomes a lever).
|
|
6
|
+
*
|
|
7
|
+
* kind/target are AGENT-CONTROLLED text, so both are flattened before they reach a notification
|
|
8
|
+
* body: no control bytes, no forged lines in the notification center.
|
|
9
|
+
*/
|
|
10
|
+
import { ChildProcess } from 'node:child_process';
|
|
11
|
+
import type { PendingRequest } from '../governance/approval-queue';
|
|
12
|
+
export interface NotifyDeps {
|
|
13
|
+
platform: NodeJS.Platform;
|
|
14
|
+
spawn: (cmd: string, args: string[], opts?: {
|
|
15
|
+
stdio: 'ignore';
|
|
16
|
+
detached?: boolean;
|
|
17
|
+
}) => Pick<ChildProcess, 'unref' | 'on'>;
|
|
18
|
+
}
|
|
19
|
+
export declare function notifyQueueArrival(item: Pick<PendingRequest, 'kind' | 'target'>, deps?: NotifyDeps): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.notifyQueueArrival = notifyQueueArrival;
|
|
4
|
+
// @implements A-SPEC-497.2
|
|
5
|
+
/**
|
|
6
|
+
* OS-native arrival notification for the approval queue — the MAIN LANE's "you are needed" ping.
|
|
7
|
+
* Fire-and-forget by doctrine: a notification failure must change NOTHING about the watch loop,
|
|
8
|
+
* the gate verdicts or the queue (the same swallow rule enqueueApprovalRequest lives by — the
|
|
9
|
+
* moment a dead notifier could change behavior, killing it becomes a lever).
|
|
10
|
+
*
|
|
11
|
+
* kind/target are AGENT-CONTROLLED text, so both are flattened before they reach a notification
|
|
12
|
+
* body: no control bytes, no forged lines in the notification center.
|
|
13
|
+
*/
|
|
14
|
+
const node_child_process_1 = require("node:child_process");
|
|
15
|
+
const screen_safe_1 = require("./screen-safe");
|
|
16
|
+
function notifyQueueArrival(item, deps = { platform: process.platform, spawn: (cmd, args) => (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: false }) }) {
|
|
17
|
+
try {
|
|
18
|
+
const kind = (0, screen_safe_1.flattenField)(item.kind, 40);
|
|
19
|
+
const target = (0, screen_safe_1.flattenField)(item.target, 80);
|
|
20
|
+
const body = `${kind} — ${target} · 결정: holmes approve`;
|
|
21
|
+
let cmd;
|
|
22
|
+
let args;
|
|
23
|
+
if (deps.platform === 'darwin') {
|
|
24
|
+
// AppleScript strings escape only backslash and double-quote; flatten already removed the rest.
|
|
25
|
+
const esc = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
26
|
+
cmd = 'osascript';
|
|
27
|
+
args = ['-e', `display notification "${esc}" with title "Holmes-Kit 승인 요청"`];
|
|
28
|
+
}
|
|
29
|
+
else if (deps.platform === 'win32') {
|
|
30
|
+
// Toast via the WinRT bridge; single-quoted PowerShell literal with quotes doubled.
|
|
31
|
+
const esc = body.replace(/'/g, "''");
|
|
32
|
+
cmd = 'powershell.exe';
|
|
33
|
+
args = ['-NoProfile', '-Command',
|
|
34
|
+
`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; ` +
|
|
35
|
+
`$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); ` +
|
|
36
|
+
`$t = $x.GetElementsByTagName('text'); $t.Item(0).AppendChild($x.CreateTextNode('Holmes-Kit 승인 요청')) > $null; ` +
|
|
37
|
+
`$t.Item(1).AppendChild($x.CreateTextNode('${esc}')) > $null; ` +
|
|
38
|
+
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('holmes-kit').Show([Windows.UI.Notifications.ToastNotification]::new($x))`];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
return; // unsupported platform: silent pass-through, by contract
|
|
42
|
+
}
|
|
43
|
+
const child = deps.spawn(cmd, args, { stdio: 'ignore', detached: false });
|
|
44
|
+
child.on?.('error', () => { });
|
|
45
|
+
child.unref?.();
|
|
46
|
+
}
|
|
47
|
+
catch { /* a notification must never take the loop down with it */ }
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consistency lints over what the repo already KNOWS — two field incidents, one gap:
|
|
3
|
+
*
|
|
4
|
+
* P-G `getattr(mem, "delete_fact", None)` referenced a symbol no class defines; the optional
|
|
5
|
+
* default hid the absence and 49 owner-approved deletions silently no-op'ed. The symbol
|
|
6
|
+
* census (CPG scan) had the answer all along — nobody asked it.
|
|
7
|
+
* P-J an `async def` body called sqlite directly and starved the scheduler, while the SAME
|
|
8
|
+
* file's other call sites wrapped the call in `to_thread` — the correct convention was one
|
|
9
|
+
* screen away.
|
|
10
|
+
*
|
|
11
|
+
* WARNING SIGNALS ONLY: no gate consumes these (association proposes, determinism judges — the
|
|
12
|
+
* standing principle). Text heuristics with honest limits (LINT_LIMITS): literal names only,
|
|
13
|
+
* same-line wrapping exemption only, Python only — both incidents were Python; extensions need
|
|
14
|
+
* their own field evidence, like every pattern list in this repository.
|
|
15
|
+
*/
|
|
16
|
+
export declare const LINT_LIMITS: readonly string[];
|
|
17
|
+
/** P-G: literal getattr/hasattr names absent from the repo's symbol census. */
|
|
18
|
+
export declare function dynamicRefFindings(code: string, defined: ReadonlySet<string>): Array<{
|
|
19
|
+
name: string;
|
|
20
|
+
line: number;
|
|
21
|
+
}>;
|
|
22
|
+
/** P-J: known-blocking calls inside an async def's indentation block, unwrapped on their line. */
|
|
23
|
+
export declare function asyncBlockingFindings(code: string): Array<{
|
|
24
|
+
pattern: string;
|
|
25
|
+
line: number;
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-506.1
|
|
3
|
+
/**
|
|
4
|
+
* Consistency lints over what the repo already KNOWS — two field incidents, one gap:
|
|
5
|
+
*
|
|
6
|
+
* P-G `getattr(mem, "delete_fact", None)` referenced a symbol no class defines; the optional
|
|
7
|
+
* default hid the absence and 49 owner-approved deletions silently no-op'ed. The symbol
|
|
8
|
+
* census (CPG scan) had the answer all along — nobody asked it.
|
|
9
|
+
* P-J an `async def` body called sqlite directly and starved the scheduler, while the SAME
|
|
10
|
+
* file's other call sites wrapped the call in `to_thread` — the correct convention was one
|
|
11
|
+
* screen away.
|
|
12
|
+
*
|
|
13
|
+
* WARNING SIGNALS ONLY: no gate consumes these (association proposes, determinism judges — the
|
|
14
|
+
* standing principle). Text heuristics with honest limits (LINT_LIMITS): literal names only,
|
|
15
|
+
* same-line wrapping exemption only, Python only — both incidents were Python; extensions need
|
|
16
|
+
* their own field evidence, like every pattern list in this repository.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.LINT_LIMITS = void 0;
|
|
20
|
+
exports.dynamicRefFindings = dynamicRefFindings;
|
|
21
|
+
exports.asyncBlockingFindings = asyncBlockingFindings;
|
|
22
|
+
exports.LINT_LIMITS = [
|
|
23
|
+
'리터럴 이름 인자만 판정한다 — getattr(obj, 변수)는 판정 불가로 건너뛴다',
|
|
24
|
+
'차단 패턴 목록은 실사고·명백성 기반 최소 집합이다(time.sleep·requests.동사·sqlite3.·subprocess.run·urllib.request.urlopen) — 확장은 새 실측으로만',
|
|
25
|
+
'to_thread/run_in_executor 면제는 같은 행에서만 인식한다 — 여러 줄 래핑은 오탐될 수 있다',
|
|
26
|
+
'.py 텍스트 휴리스틱이다 — 비파이썬 언어와 문자열 속 코드는 보지 않는다',
|
|
27
|
+
];
|
|
28
|
+
const DYNAMIC_REF_RE = /\b(?:getattr|hasattr)\(\s*[^,()]+,\s*['"](\w+)['"]/g;
|
|
29
|
+
/** P-G: literal getattr/hasattr names absent from the repo's symbol census. */
|
|
30
|
+
function dynamicRefFindings(code, defined) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const m of code.matchAll(DYNAMIC_REF_RE)) {
|
|
33
|
+
const name = m[1];
|
|
34
|
+
if (defined.has(name))
|
|
35
|
+
continue;
|
|
36
|
+
out.push({ name, line: code.slice(0, m.index).split('\n').length });
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const BLOCKING = ['time.sleep(', 'requests.get(', 'requests.post(', 'requests.put(',
|
|
41
|
+
'requests.delete(', 'requests.request(', 'sqlite3.', 'subprocess.run(', 'urllib.request.urlopen'];
|
|
42
|
+
const EXEMPT_RE = /to_thread|run_in_executor/;
|
|
43
|
+
/** P-J: known-blocking calls inside an async def's indentation block, unwrapped on their line. */
|
|
44
|
+
function asyncBlockingFindings(code) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const lines = code.split('\n');
|
|
47
|
+
let asyncIndent = null; // indentation of the enclosing `async def`, when inside one
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i];
|
|
50
|
+
const indent = line.length - line.trimStart().length;
|
|
51
|
+
const isBlank = line.trim() === '';
|
|
52
|
+
if (asyncIndent !== null && !isBlank && indent <= asyncIndent)
|
|
53
|
+
asyncIndent = null; // dedent ends the block
|
|
54
|
+
const def = /^(\s*)async\s+def\b/.exec(line);
|
|
55
|
+
if (def) {
|
|
56
|
+
asyncIndent = def[1].length;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (asyncIndent === null || isBlank || EXEMPT_RE.test(line))
|
|
60
|
+
continue;
|
|
61
|
+
for (const pattern of BLOCKING) {
|
|
62
|
+
if (line.includes(pattern))
|
|
63
|
+
out.push({ pattern, line: i + 1 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -31,6 +31,14 @@ export interface PendingRequest {
|
|
|
31
31
|
}
|
|
32
32
|
export interface QueueState {
|
|
33
33
|
pending: PendingRequest[];
|
|
34
|
+
/**
|
|
35
|
+
* @implements A-SPEC-507.1
|
|
36
|
+
* Pending entries whose last activity exceeded the caller's TTL — populated ONLY when the fold
|
|
37
|
+
* was given a clock ({now, ttlMs}), so gate-path folds never depend on when you look. C4
|
|
38
|
+
* measured the harm of agelessness: 51 entries, mostly week-old residue, buried the one live
|
|
39
|
+
* approval at [32]. Expiry is a display-layer judgment; the ledger keeps every event.
|
|
40
|
+
*/
|
|
41
|
+
expired: PendingRequest[];
|
|
34
42
|
/** Broken lines are counted, never swallowed: an empty-looking queue must be distinguishable from a corrupted one. */
|
|
35
43
|
malformedLines: number;
|
|
36
44
|
/**
|
|
@@ -64,7 +72,10 @@ export declare function approvalRequestId(kind: string, target: string): string;
|
|
|
64
72
|
* entry's pending state: an old CLI reading a new queue must not silently mis-report what is
|
|
65
73
|
* waiting.
|
|
66
74
|
*/
|
|
67
|
-
export declare function foldQueue(lines: string[]
|
|
75
|
+
export declare function foldQueue(lines: string[], opts?: {
|
|
76
|
+
now: number;
|
|
77
|
+
ttlMs: number;
|
|
78
|
+
}): QueueState;
|
|
68
79
|
/**
|
|
69
80
|
* Append a request event. Fire-and-forget.
|
|
70
81
|
*
|
|
@@ -79,7 +90,10 @@ export declare function enqueueApprovalRequest(root: string, req: {
|
|
|
79
90
|
why: string;
|
|
80
91
|
}): boolean;
|
|
81
92
|
/** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
|
|
82
|
-
export declare function readQueue(root: string
|
|
93
|
+
export declare function readQueue(root: string, opts?: {
|
|
94
|
+
now: number;
|
|
95
|
+
ttlMs: number;
|
|
96
|
+
}): QueueState;
|
|
83
97
|
/**
|
|
84
98
|
* The refusal-message suffix pointing the operator at the review CLI.
|
|
85
99
|
*
|
|
@@ -83,7 +83,7 @@ function approvalRequestId(kind, target) {
|
|
|
83
83
|
* entry's pending state: an old CLI reading a new queue must not silently mis-report what is
|
|
84
84
|
* waiting.
|
|
85
85
|
*/
|
|
86
|
-
function foldQueue(lines) {
|
|
86
|
+
function foldQueue(lines, opts) {
|
|
87
87
|
const pending = new Map();
|
|
88
88
|
const decisions = {};
|
|
89
89
|
let malformedLines = 0;
|
|
@@ -155,7 +155,18 @@ function foldQueue(lines) {
|
|
|
155
155
|
break;
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
-
|
|
158
|
+
const all = [...pending.values()];
|
|
159
|
+
if (!opts)
|
|
160
|
+
return { pending: all, expired: [], malformedLines, decisions };
|
|
161
|
+
// @implements A-SPEC-507.1 — strict excess only, and an unparseable lastTs stays ACTIVE: a
|
|
162
|
+
// clockless entry must never be silently hidden by a clock it does not carry.
|
|
163
|
+
const expired = [];
|
|
164
|
+
const active = [];
|
|
165
|
+
for (const p of all) {
|
|
166
|
+
const last = Date.parse(p.lastTs);
|
|
167
|
+
(Number.isFinite(last) && last + opts.ttlMs < opts.now ? expired : active).push(p);
|
|
168
|
+
}
|
|
169
|
+
return { pending: active, expired, malformedLines, decisions };
|
|
159
170
|
}
|
|
160
171
|
/**
|
|
161
172
|
* Append a request event. Fire-and-forget.
|
|
@@ -223,7 +234,7 @@ function isPlainFile(file) {
|
|
|
223
234
|
}
|
|
224
235
|
}
|
|
225
236
|
/** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
|
|
226
|
-
function readQueue(root) {
|
|
237
|
+
function readQueue(root, opts) {
|
|
227
238
|
const file = path.join(root, exports.QUEUE_RELPATH);
|
|
228
239
|
try {
|
|
229
240
|
// TYPE BEFORE READ. `readFileSync` on a FIFO blocks inside open(2) forever — no throw, no
|
|
@@ -232,12 +243,12 @@ function readQueue(root) {
|
|
|
232
243
|
// hook, the stop hook and the MCP handlers, not just the CLI. `approve-context.ts` has guarded
|
|
233
244
|
// this since the 2026-08-24 hang; the queue reader, which far more code depends on, did not.
|
|
234
245
|
if (!fs.lstatSync(file).isFile())
|
|
235
|
-
return { pending: [], malformedLines: 1, decisions: {} };
|
|
246
|
+
return { pending: [], expired: [], malformedLines: 1, decisions: {} };
|
|
236
247
|
const raw = fs.readFileSync(file, 'utf8');
|
|
237
|
-
return foldQueue(raw.split('\n'));
|
|
248
|
+
return foldQueue(raw.split('\n'), opts);
|
|
238
249
|
}
|
|
239
250
|
catch {
|
|
240
|
-
return { pending: [], malformedLines: 0, decisions: {} };
|
|
251
|
+
return { pending: [], expired: [], malformedLines: 0, decisions: {} };
|
|
241
252
|
}
|
|
242
253
|
}
|
|
243
254
|
/**
|
|
@@ -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,
|
|
@@ -272,6 +272,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
272
272
|
}>;
|
|
273
273
|
cpg_scan(a: {
|
|
274
274
|
root: string;
|
|
275
|
+
lints?: boolean;
|
|
275
276
|
}): Promise<{
|
|
276
277
|
files: number;
|
|
277
278
|
symbols: number;
|
|
@@ -280,6 +281,27 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
280
281
|
reason: string;
|
|
281
282
|
}[];
|
|
282
283
|
skippedCount: number;
|
|
284
|
+
} | {
|
|
285
|
+
lints: {
|
|
286
|
+
dynamicRef: {
|
|
287
|
+
file: string;
|
|
288
|
+
name: string;
|
|
289
|
+
line: number;
|
|
290
|
+
}[];
|
|
291
|
+
asyncBlocking: {
|
|
292
|
+
file: string;
|
|
293
|
+
pattern: string;
|
|
294
|
+
line: number;
|
|
295
|
+
}[];
|
|
296
|
+
limits: string[];
|
|
297
|
+
};
|
|
298
|
+
files: number;
|
|
299
|
+
symbols: number;
|
|
300
|
+
skipped: {
|
|
301
|
+
file: string;
|
|
302
|
+
reason: string;
|
|
303
|
+
}[];
|
|
304
|
+
skippedCount: number;
|
|
283
305
|
}>;
|
|
284
306
|
/**
|
|
285
307
|
* @implements A-SPEC-138
|
|
@@ -131,6 +131,7 @@ const risk_classifier_1 = require("../guardrail/risk-classifier");
|
|
|
131
131
|
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
132
132
|
const elicit_approval_1 = require("./elicit-approval");
|
|
133
133
|
const anchor_comment_1 = require("../rtm/anchor-comment");
|
|
134
|
+
const consistency_lints_1 = require("../cpg/consistency-lints");
|
|
134
135
|
const approval_queue_1 = require("../governance/approval-queue");
|
|
135
136
|
const approval_grants_1 = require("../governance/approval-grants");
|
|
136
137
|
const spec_digest_1 = require("../spec/spec-digest");
|
|
@@ -1633,7 +1634,37 @@ function makeRawHandlers(store, opts) {
|
|
|
1633
1634
|
// with casualties" by counts alone. Bounded listing (50), complete count.
|
|
1634
1635
|
const { scanned, skipped } = cachedScanWithReport(root);
|
|
1635
1636
|
const symbols = scanned.reduce((n, f) => n + f.symbols.length, 0);
|
|
1636
|
-
|
|
1637
|
+
const base = { files: scanned.length, symbols, skipped: skipped.slice(0, 50), skippedCount: skipped.length };
|
|
1638
|
+
if (!a.lints)
|
|
1639
|
+
return base;
|
|
1640
|
+
// @implements A-SPEC-506.1 — opt-in consistency lints (P-G/P-J field incidents): WARNING
|
|
1641
|
+
// signals only, additive field only, Python only; the default path above is byte-identical.
|
|
1642
|
+
const defined = new Set();
|
|
1643
|
+
for (const f of scanned)
|
|
1644
|
+
for (const s of f.symbols) {
|
|
1645
|
+
defined.add(s.name);
|
|
1646
|
+
const last = s.name.split('.').pop();
|
|
1647
|
+
if (last)
|
|
1648
|
+
defined.add(last);
|
|
1649
|
+
}
|
|
1650
|
+
const dynamicRef = [];
|
|
1651
|
+
const asyncBlocking = [];
|
|
1652
|
+
for (const f of scanned) {
|
|
1653
|
+
if (!f.sourcePath.endsWith('.py'))
|
|
1654
|
+
continue;
|
|
1655
|
+
let code;
|
|
1656
|
+
try {
|
|
1657
|
+
code = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
for (const hit of (0, consistency_lints_1.dynamicRefFindings)(code, defined))
|
|
1663
|
+
dynamicRef.push({ file: f.sourcePath, ...hit });
|
|
1664
|
+
for (const hit of (0, consistency_lints_1.asyncBlockingFindings)(code))
|
|
1665
|
+
asyncBlocking.push({ file: f.sourcePath, ...hit });
|
|
1666
|
+
}
|
|
1667
|
+
return { ...base, lints: { dynamicRef, asyncBlocking, limits: [...consistency_lints_1.LINT_LIMITS] } };
|
|
1637
1668
|
},
|
|
1638
1669
|
/**
|
|
1639
1670
|
* @implements A-SPEC-138
|
|
@@ -18,17 +18,19 @@ const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS
|
|
|
18
18
|
// failure — no capability, transport error, malformed answer — folds to `silent`, whose refusal
|
|
19
19
|
// stays byte-identical to the pre-elicitation one. Diagnostics, if ever needed, go to stderr only
|
|
20
20
|
// (stdout is the protocol channel).
|
|
21
|
-
|
|
21
|
+
// @implements A-SPEC-497.3 — the wiring shrinks to ONE impure seam (rawElicit): the two-stage
|
|
22
|
+
// form, the finite re-display and the per-path deadline all live in the pure runElicitFlow, so
|
|
23
|
+
// every branch is test-driven without a client. runElicitFlow classifies its own errors; the outer
|
|
24
|
+
// catch is a belt for faults outside the flow (capability probe, transport construction).
|
|
25
|
+
const { runElicitFlow } = require('./elicit-approval');
|
|
22
26
|
const elicit = async (req) => {
|
|
23
27
|
try {
|
|
24
28
|
if (!server.getClientCapabilities()?.elicitation)
|
|
25
29
|
return { kind: 'silent' };
|
|
26
|
-
|
|
27
|
-
const d = interpretElicitResult(r);
|
|
28
|
-
return d === null ? { kind: 'silent' } : { kind: 'answered', decision: d };
|
|
30
|
+
return await runElicitFlow((form, timeoutMs) => server.elicitInput(form, { timeout: timeoutMs }), req);
|
|
29
31
|
}
|
|
30
|
-
catch
|
|
31
|
-
return
|
|
32
|
+
catch {
|
|
33
|
+
return { kind: 'silent' };
|
|
32
34
|
}
|
|
33
35
|
};
|
|
34
36
|
const handlers = (0, handlers_1.makeHandlers)(store, {
|
|
@@ -171,10 +171,10 @@ exports.TOOL_SCHEMAS = {
|
|
|
171
171
|
},
|
|
172
172
|
},
|
|
173
173
|
cpg_scan: {
|
|
174
|
-
description: 'Scan the repository tree with tree-sitter across the 8 supported languages; returns { files, symbols } counts plus the skip report ({ skipped, skippedCount }) naming any claimed file the scan could not ingest — an empty report distinguishes a clean tree from one with casualties.',
|
|
174
|
+
description: 'Scan the repository tree with tree-sitter across the 8 supported languages; returns { files, symbols } counts plus the skip report ({ skipped, skippedCount }) naming any claimed file the scan could not ingest — an empty report distinguishes a clean tree from one with casualties. Pass lints:true to ALSO receive opt-in consistency WARNING signals (Python only, additive `lints` field with its own `limits`): dynamicRef (a literal getattr/hasattr name absent from the scanned symbol census) and asyncBlocking (a known-blocking call inside an async def, unwrapped on its line). Signals route a review; no gate consumes them.',
|
|
175
175
|
inputSchema: {
|
|
176
176
|
type: 'object',
|
|
177
|
-
properties: { root: ROOT },
|
|
177
|
+
properties: { root: ROOT, lints: { type: 'boolean', description: 'Include the consistency-lint warning signals (default false — the base result is unchanged without it).' } },
|
|
178
178
|
required: ['root'],
|
|
179
179
|
},
|
|
180
180
|
},
|
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.8",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|