@holmes-lab/holmes-kit 0.1.17 → 0.2.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/approve-context.d.ts +2 -0
  4. package/dist/holmes/cli/approve-context.js +180 -0
  5. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  6. package/dist/holmes/cli/approve-ref.js +40 -0
  7. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  8. package/dist/holmes/cli/approve-watch.js +94 -0
  9. package/dist/holmes/cli/approve.d.ts +61 -13
  10. package/dist/holmes/cli/approve.js +374 -36
  11. package/dist/holmes/cli/doctor.js +262 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  13. package/dist/holmes/cli/gitignore-merge.js +17 -1
  14. package/dist/holmes/cli/index.d.ts +23 -0
  15. package/dist/holmes/cli/index.js +496 -21
  16. package/dist/holmes/cli/init.js +14 -0
  17. package/dist/holmes/cli/mcp-launcher.js +6 -1
  18. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  19. package/dist/holmes/cli/screen-safe.js +760 -0
  20. package/dist/holmes/governance/approval-queue.js +56 -4
  21. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  22. package/dist/holmes/governance/ledger-rechain.js +95 -0
  23. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  24. package/dist/holmes/governance/provenance-chain.js +91 -16
  25. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  26. package/dist/holmes/governance/provenance-ledger.js +10 -0
  27. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  28. package/dist/holmes/guardrail/risk-gate.js +10 -0
  29. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  30. package/dist/holmes/mcp/elicit-approval.js +79 -0
  31. package/dist/holmes/mcp/handlers.d.ts +7 -2
  32. package/dist/holmes/mcp/handlers.js +190 -24
  33. package/dist/holmes/mcp/server.js +39 -2
  34. package/dist/holmes/server/dashboard.js +2 -2
  35. package/dist/holmes/spec/id-collision.d.ts +39 -0
  36. package/dist/holmes/spec/id-collision.js +86 -0
  37. package/dist/holmes/spec/spec-store.js +9 -1
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
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
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.2.0] - 2026-08-27
9
+
10
+ The approval decision surface, world-tier: the operator stays at one screen, and a grant is exactly what they saw.
11
+
12
+ ### Added
13
+ - **`approve --watch` — a resident decision surface (REQ-262)**: `holmes-kit approve --watch` stays open and polls the approval queue, so a blocked request appears the moment it arrives, one key decides it, and an empty queue idles instead of exiting (Ctrl-C quits). The operator no longer re-runs `approve` per request or transcribes a long id into another window. Harness-independent — the queue is a file, so a request from Claude Code, Codex CLI or Antigravity is decided on the same screen with the same keys. `--poll-ms <ms>` tunes the idle cadence; a non-TTY invocation is refused with guidance to use `--list`. The reaction deadline (a request surfaces within one poll cycle) is a tested judgment, not prose.
14
+ - **In-session approval via MCP elicitation (REQ-263)**: when a client advertises the `elicitation` capability, an `spec_approve` / `review-resolve` that would be refused for want of an approval becomes a question inside the session — one human answer completes it, no out-of-band `--grant`. The policy (which kinds are elicitable) and protocol (question form, response interpretation) are pure and fail-closed: an unsupported client, a declined/cancelled dialog, a malformed response, or a timeout all degrade to the exact same refusal-and-queue text as before. A single-use approval leaves nothing reusable behind.
15
+ - **`ledger rechain` — provenance that survives rebase and merge (REQ-256)**: a new subcommand re-links the provenance hash-chain against an explicit mainline after history is rewritten, ships `merge=union` attributes so a merge does not fork the chain, and buffers appends made mid-rebase into a sidecar that flushes, under the same lock, on the first append after the rebase ends. A `git rebase`/`merge` of a governed branch no longer strands the ledger.
16
+ - **`doctor` detects distributed spec-id preemption (REQ-254)**: two new read-only checks — a WARN when the same spec id carries mismatched content digests (with the conflicting file paths), and a WARN when a bare id and its dotted-suffix family coexist (with the file lists). A legitimate duplicate (same id, same digest) and a clean store stay silent.
17
+
18
+ ### Fixed
19
+ - **The decision screen cannot be forged, and it shows every byte it is deciding on (REQ-262)**: a decision row now carries only characters whose terminal width is known — every control, combining, zero-width, bidi, variation-selector, tab or ambiguous-width character an agent might place in a command or a spec is rendered as its own `\uXXXX` escape. Nothing on the screen can repaint it, hide behind an ANSI conceal, misalign a row, or pose as a line the template wrote; a removed character is shown, not silently dropped. The `[v]` detail view stays readable (folded under a template-owned margin) so a Korean command or spec body reads as itself.
20
+ - **A derived grant matches the command literally — `rm -rf *` no longer authorizes `rm -rf /anything` (REQ-262)**: a grant minted from an approved queue target is compared to the action by string equality, so a `*` or a trailing `/` in the target is that character, not a wildcard or a directory prefix. The screen deliberately shows no scope grammar, so the operator could not have known the `*` they saw was a glob; now it isn't one. A present-but-malformed narrowing flag fails closed.
21
+ - **A closed output pipe is the end of a pipeline, not a crash (REQ-262)**: `approve`, `approve --watch` and `approve --list` piped to a reader that stops early (`| head`) now exit quietly with the conventional status instead of printing a Node stack trace. The stdout guard now installs on every shipped invocation — it previously lived in an entry block the `holmes-kit` binary never runs, so it was dead on the shipped package.
22
+
23
+ ### Unverified (named)
24
+ - Windows execution and CI remain program-level gaps this release does not close.
25
+
26
+ ## [0.1.18] - 2026-08-23
27
+
28
+ ### Fixed
29
+ - **The npx MCP wiring actually starts the server (REQ-259)**: `init` on an npm-installed package wrote `npx -y @holmes-lab/holmes-kit@<ver> holmes-mcp`, which npx resolves to the package-name-matching *default* binary (`holmes-kit`) with `holmes-mcp` as its argument — exit 1, so **every npm-installed wiring since the 0.1.15 npx pin launched a dead MCP server** (`CONNECTION_CLOSED` in the harness). The wiring now rides `-p` (`npx -y -p <pkg>@<ver> holmes-mcp`), the launcher test spawns the generated form against a locally packed tarball (with the pre-fix form kept as a failing control), and `doctor` gains **`mcp wiring spawn`** — it runs the target's `.mcp.json` command verbatim inside a 30s bound and names a dead wiring FAIL with the re-init remediation. Existing projects: re-run `holmes-kit init` (doctor will point at the dead wiring).
30
+ - **`serverInfo.version` reports the real package version** — it was hardcoded to `0.1.0`, blinding client-side drift diagnosis.
31
+
32
+ ### Changed
33
+ - **`approve` without a TTY names the next command (REQ-260)**: the implicit non-TTY fallback (e.g. Claude Code's `!` shell) used to print the pending list and exit 0 silently; it now appends runnable `--grant` / `--deny` / `--ask` examples carrying the first real pending id. Explicit `--list` output is unchanged for scripts.
34
+
8
35
  ## [0.1.17] - 2026-08-23
9
36
 
10
37
  The two `main` lines — the Windows-compatibility line (12 commits, verified on Windows 11 and
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- e6199ca-mt5qbc5d
1
+ f7a048d-mtbm5atb
@@ -0,0 +1,2 @@
1
+ import { PendingRequest } from '../governance/approval-queue';
2
+ export declare function renderDecisionContext(root: string, entry: PendingRequest): string;
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.renderDecisionContext = renderDecisionContext;
37
+ // @implements A-SPEC-262.1
38
+ const fs = __importStar(require("node:fs"));
39
+ const path = __importStar(require("node:path"));
40
+ const screen_safe_1 = require("./screen-safe");
41
+ /**
42
+ * What is actually being approved.
43
+ *
44
+ * REQ-262 measured the harm: the queue carries kind/target/why, and a human approved three A-SPECs
45
+ * without ever seeing their contents — a rubber stamp, not a decision. This renderer fetches the
46
+ * SUBJECT for the screen: a spec's body, a finding's record, or (for shell/config-write shapes) the
47
+ * target verbatim, which IS the subject the queue holds.
48
+ *
49
+ * READ-ONLY, and no failure may kill the surface: a context that cannot be read reports WHY in one
50
+ * line and the decision stays possible. If missing context blocked deciding, this screen would
51
+ * become a second gate — and the gate is not what REQ-246 built here.
52
+ */
53
+ const MAX_BODY = 2400;
54
+ // Clipping is code-point safe and painting bytes never reach the screen (round-1): a UTF-16 slice
55
+ // cut a surrogate pair in half, and an ANSI sequence inside a spec body could repaint the terminal
56
+ // the human is deciding on.
57
+ // Round-5: every context row is folded to a terminal row — a body is shown, not flooded.
58
+ // Round-6: and folded UNDER A MARGIN, so no body byte can start a line and pose as a template row.
59
+ const BODY_INDENT = ' \u2502 ';
60
+ // Round-10: the clip ran AFTER escaping, so it counted escape-expanded units — 500 non-breaking
61
+ // spaces (503 source characters) reported "603자 잘림", a statement false on its face on the screen
62
+ // whose job is saying how much is not shown, and it cut MAX_BODY's real budget by six. It also let
63
+ // the cut land INSIDE an escape, leaving a bare `00` that was the tail of `\u3000` (round-1's
64
+ // severed-surrogate lesson, one representation later). The source is clipped first; the notice then
65
+ // counts what it always should have.
66
+ const clip = (s) => (0, screen_safe_1.wrapColumns)((0, screen_safe_1.stripControl)((0, screen_safe_1.clipCodePoints)(s, MAX_BODY)), 76, BODY_INDENT);
67
+ function specContext(root, id) {
68
+ const specsDir = path.join(root, '.ax', 'specs');
69
+ // Found BY ID, never by guessing a filename — which folder holds which type is the store's
70
+ // business, not this screen's. The store's own `list()` is async and this screen is synchronous
71
+ // (a screen must not force its caller to become async), so the walk mirrors the store's tolerance:
72
+ // skip what cannot be read, never throw.
73
+ //
74
+ // @implements A-SPEC-262.1 — EVERY match is collected, not the first. Round-1: `.ax/specs` is not
75
+ // a protected directory and draft authoring is deliberately free, so an agent could drop a second
76
+ // file carrying the same id; the walk returned whichever readdir reached first and the human read
77
+ // a benign DECOY body while spec_approve sealed the real spec. Two files claiming one id is an
78
+ // ambiguity this screen refuses to resolve — the same doctrine spec_approve applies to duplicates
79
+ // — and the source path is always named so the human knows which file they read.
80
+ const found = [];
81
+ const { parseSpec } = require('../spec/spec-parser');
82
+ const walk = (d) => {
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(d, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ for (const e of entries) {
91
+ const p = path.join(d, e.name);
92
+ if (e.isDirectory()) {
93
+ walk(p);
94
+ continue;
95
+ }
96
+ if (!e.name.endsWith('.md'))
97
+ continue;
98
+ try {
99
+ if (!fs.statSync(p).isFile())
100
+ continue; // never open a FIFO (the 2026-08-24 hang lesson)
101
+ const spec = parseSpec(fs.readFileSync(p, 'utf8'));
102
+ if (spec.id === id)
103
+ found.push({ spec, file: path.relative(specsDir, p).split(path.sep).join('/') });
104
+ }
105
+ catch { /* unreadable file: the store's own reports own that */ }
106
+ }
107
+ };
108
+ walk(specsDir);
109
+ // The absolute specs path alone can exceed a row on a real machine, so it gets one of its own.
110
+ if (found.length === 0) {
111
+ return `맥락을 읽을 수 없습니다: 스펙 ${(0, screen_safe_1.rowField)(id, 24)} 를 찾지 못했습니다\n ${(0, screen_safe_1.rowField)(specsDir, 76)}`;
112
+ }
113
+ if (found.length > 1) {
114
+ return [
115
+ `맥락이 모호합니다: ${(0, screen_safe_1.rowField)(id, 40)} 를 주장하는 파일이 ${found.length}건입니다:`,
116
+ ...found.map((f) => ` ${(0, screen_safe_1.rowField)(f.file, 72)}`).sort(),
117
+ '어느 것이 승인 대상인지 확정되기 전에는 본문을 보여 주지 않습니다',
118
+ ].join('\n')
119
+ + `\n (holmes-kit doctor 의 'spec id 선점 충돌'을 확인하십시오)`;
120
+ }
121
+ const { spec, file } = found[0];
122
+ const body = [spec.preamble ?? '', ...Object.entries(spec.sections).map(([h, t]) => `## ${h}\n${t}`)]
123
+ .filter((s2) => s2.trim() !== '').join('\n\n');
124
+ return clip(`■ ${(0, screen_safe_1.flattenField)(spec.id, 40)} — ${(0, screen_safe_1.flattenField)(spec.title, 120)}\n [현재 상태: ${(0, screen_safe_1.flattenField)(spec.status, 20)}] (${(0, screen_safe_1.flattenField)(file, 60)})\n\n${body}`);
125
+ }
126
+ function findingContext(root, id) {
127
+ const file = path.join(root, '.ax', 'ledger', 'findings.jsonl');
128
+ let raw;
129
+ try {
130
+ raw = fs.readFileSync(file, 'utf8');
131
+ }
132
+ // Round-6: the one producer here with neither a sanitiser nor a fold, while its sibling three
133
+ // lines up (`specContext` not-found) had both. Measured at 555 columns — seven rows — for an
134
+ // ordinary missing ledger on an ordinary path. The errno message is not agent-controlled today,
135
+ // but the path is composed from `root`, and "not agent-controlled today" is how round-2's misses
136
+ // were justified as well.
137
+ catch (e) {
138
+ return [
139
+ '맥락을 읽을 수 없습니다: 발견 원장을 읽지 못했습니다',
140
+ ` ${(0, screen_safe_1.rowField)(file, 76)}`,
141
+ ` ${(0, screen_safe_1.rowField)(String(e instanceof Error ? e.message : e).split('\n')[0], 76)}`,
142
+ ].join('\n');
143
+ }
144
+ let latest;
145
+ for (const line of raw.split('\n')) {
146
+ if (!line.trim())
147
+ continue;
148
+ try {
149
+ const f = JSON.parse(line);
150
+ if (f.id === id)
151
+ latest = f; // last wins: the ledger is append-only history
152
+ }
153
+ catch { /* a torn line is the ledger's own report, not this screen's */ }
154
+ }
155
+ // Round-3: this was the one sibling site that skipped flattenField, and it is outside clip() —
156
+ // a review-resolve target that is not in the ledger repainted the whole interactive screen.
157
+ if (!latest)
158
+ return `맥락을 읽을 수 없습니다: 발견 ${(0, screen_safe_1.rowField)(id, 24)} 의 기록이 원장에 없습니다`;
159
+ return clip(`■ 발견 ${(0, screen_safe_1.flattenField)(id, 60)}\n [${(0, screen_safe_1.flattenField)(String(latest.severity ?? '?'), 20)} · ${(0, screen_safe_1.flattenField)(String(latest.status ?? '?'), 20)}${latest.category ? ' · ' + (0, screen_safe_1.flattenField)(String(latest.category), 40) : ''}]\n\n${(0, screen_safe_1.flattenField)(String(latest.summary ?? '(요약 없음)'), 2000)}`);
160
+ }
161
+ function renderDecisionContext(root, entry) {
162
+ try {
163
+ if (entry.kind === 'spec-approve')
164
+ return specContext(root, entry.target);
165
+ if (entry.kind === 'review-resolve')
166
+ return findingContext(root, entry.target);
167
+ // shell / config-write / anything else: the queue's target IS the subject.
168
+ // A shell/config-write target is ONE command: flattened, so it cannot forge lines on the screen.
169
+ // [v] is the escape hatch for a target the list row had to clip, so its cap is generous — a
170
+ // decision surface that cannot show the subject anywhere has failed at its one job (round-2).
171
+ // Round-9: this branch went through `flattenField`, which does not escape the space characters
172
+ // that are not spaces — so `[v]`, the view a clipped row SENDS the operator to, printed
173
+ // `rm -rf\u00a0/` as `rm -rf /`. It uses the same body pipeline as every other subject now.
174
+ return `■ ${(0, screen_safe_1.rowField)(entry.kind, 40)}\n\n${clip(entry.target)}`;
175
+ }
176
+ catch (e) {
177
+ // Belt and braces: no context failure may kill the decision surface.
178
+ return `맥락을 읽을 수 없습니다:\n ${(0, screen_safe_1.rowField)(String(e instanceof Error ? e.message : e).split('\n')[0], 76)}`;
179
+ }
180
+ }
@@ -0,0 +1,27 @@
1
+ import { PendingRequest } from '../governance/approval-queue';
2
+ /**
3
+ * Transcription-zero references for the decision surface.
4
+ *
5
+ * REQ-262 measured the harm: a human retyping `--grant req-f5bae240c` into another window truncated
6
+ * the id and the decision died. Git's short hashes are the lesson — people type the least the screen
7
+ * already shows. Three grammars, non-overlapping by construction:
8
+ *
9
+ * - a bare number is the 1-BASED INDEX of the printed list (`[1] …`),
10
+ * - an exact id is itself,
11
+ * - anything else is a PREFIX, and it must be unique.
12
+ *
13
+ * The number and the prefix cannot collide: queue ids are `req-<12hex>` (approvalRequestId), so no
14
+ * id can start with a digit. A numeric miss is therefore NOT retried as a prefix — that fallback
15
+ * would manufacture the very ambiguity this grammar exists to avoid.
16
+ *
17
+ * An ambiguous prefix is REFUSED BY NAME (every candidate listed): on a decision surface "probably
18
+ * this one" is not a decision (REQ-262 Constraints). PURE — no I/O, no clock.
19
+ */
20
+ export type RefResolution = {
21
+ ok: true;
22
+ entry: PendingRequest;
23
+ } | {
24
+ ok: false;
25
+ reason: string;
26
+ };
27
+ export declare function resolveRequestRef(pending: PendingRequest[], ref: string): RefResolution;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveRequestRef = resolveRequestRef;
4
+ const screen_safe_1 = require("./screen-safe");
5
+ function resolveRequestRef(pending, ref) {
6
+ const r = ref.trim();
7
+ if (r === '')
8
+ return { ok: false, reason: '참조가 비어 있습니다 — 목록의 번호나 id 접두를 주십시오' };
9
+ if (pending.length === 0)
10
+ return { ok: false, reason: '승인 대기 중인 요청이 없습니다' };
11
+ if (/^\d+$/.test(r)) {
12
+ const n = Number(r);
13
+ if (n < 1 || n > pending.length) {
14
+ return { ok: false, reason: `번호 ${(0, screen_safe_1.rowField)(r, 20)} 는 목록 밖입니다 — 유효 범위는 1..${pending.length} 입니다` };
15
+ }
16
+ return { ok: true, entry: pending[n - 1] };
17
+ }
18
+ // Exact BEFORE prefix: when one id is a prefix of another, the exact spelling is not ambiguous —
19
+ // it is the most precise thing the human could have typed.
20
+ const exact = pending.find((p) => p.id === r);
21
+ if (exact)
22
+ return { ok: true, entry: exact };
23
+ const matches = pending.filter((p) => p.id.startsWith(r));
24
+ if (matches.length === 1)
25
+ return { ok: true, entry: matches[0] };
26
+ if (matches.length === 0) {
27
+ return { ok: false, reason: `참조 '${(0, screen_safe_1.rowField)(r, 60)}' 에 맞는 대기 요청이 없습니다 — 목록의 번호나 id 접두를 확인하십시오` };
28
+ }
29
+ return {
30
+ ok: false,
31
+ // The ids are AGENT-CHOSEN text (round-2): this refusal is printed on the operator's terminal.
32
+ // Round-5: the candidates rode on the refusal's own line, so enough of them (or one forged wide
33
+ // id) wrapped it past a row and pushed the question off the top of the screen. The refusal keeps
34
+ // one row; each candidate gets its own, which is also how the operator copies one.
35
+ reason: [
36
+ `참조 '${(0, screen_safe_1.rowField)(r, 30)}' 가 ${matches.length}건에 맞습니다 — 더 길게 주십시오:`,
37
+ ...matches.map((m) => ` ${(0, screen_safe_1.rowField)(m.id, 60)}`),
38
+ ].join('\n'),
39
+ };
40
+ }
@@ -0,0 +1,29 @@
1
+ import { ApproveIO } from './approve';
2
+ /** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
3
+ export declare const WATCH_IDLE = "\u25C6 \uC2B9\uC778 \uB300\uAE30 \uC911\u2026 (Ctrl-C \uB85C \uC885\uB8CC)";
4
+ /** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
5
+ export declare const DEFAULT_POLL_MS = 500;
6
+ export interface WatchOpts {
7
+ /** Idle poll cadence in ms. */
8
+ pollMs?: number;
9
+ /** Ends the resident loop and any in-flight idle wait / keypress wait. */
10
+ signal?: AbortSignal;
11
+ /**
12
+ * Wait one idle poll cycle. Injected so tests drive the loop deterministically without wall time.
13
+ * Must resolve early if `signal` aborts, so an idle watch shuts down at once.
14
+ */
15
+ waitIdle?: (ms: number, signal?: AbortSignal) => Promise<void>;
16
+ }
17
+ /** Exported for test: the shipped idle wait must resolve at once on abort, not after the timer. */
18
+ export declare function defaultWaitIdle(ms: number, signal?: AbortSignal): Promise<void>;
19
+ /**
20
+ * Run the resident decision surface until `signal` aborts.
21
+ *
22
+ * Each pass reads the queue once. A non-empty queue is handed to runInteractive (S-262.1 verbatim —
23
+ * same one-key flow, same escaping); when it returns, the snapshot is spent and the loop re-reads,
24
+ * so a request that arrived during a decision is presented on the next pass (one cycle late, never
25
+ * lost). An empty queue announces idle once and waits a poll cycle. Abort — in the idle wait or in
26
+ * runInteractive's keypress wait (which rejects AbortError, wired by the caller) — ends the loop and
27
+ * resolves; pending items stay in the queue.
28
+ */
29
+ export declare function runWatch(root: string, io: ApproveIO, actor: string, opts?: WatchOpts): Promise<void>;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_POLL_MS = exports.WATCH_IDLE = void 0;
4
+ exports.defaultWaitIdle = defaultWaitIdle;
5
+ exports.runWatch = runWatch;
6
+ // @implements A-SPEC-262.2
7
+ //
8
+ // The RESIDENCE of the decision surface. S-262.1 made the surface's CONTENT (transcript-zero
9
+ // references, context, one-key/batch); this is a thin polling loop over that content, so a request
10
+ // appears the moment it arrives, decisions continue, and an empty queue idles instead of exiting.
11
+ //
12
+ // It adds NO rendering and NO terminal-control sequences — the sixteen rounds of escaping work in
13
+ // screen-safe.ts stay untouched. runWatch only reads the queue, delegates a non-empty queue to the
14
+ // existing runInteractive, and waits between polls when the queue is empty.
15
+ const approval_queue_1 = require("../governance/approval-queue");
16
+ const approve_1 = require("./approve");
17
+ /** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
18
+ exports.WATCH_IDLE = '◆ 승인 대기 중… (Ctrl-C 로 종료)';
19
+ /** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
20
+ exports.DEFAULT_POLL_MS = 500;
21
+ /** Exported for test: the shipped idle wait must resolve at once on abort, not after the timer. */
22
+ function defaultWaitIdle(ms, signal) {
23
+ return new Promise((resolve) => {
24
+ if (signal?.aborted)
25
+ return resolve();
26
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
27
+ const onAbort = () => { clearTimeout(timer); resolve(); };
28
+ signal?.addEventListener('abort', onAbort, { once: true });
29
+ });
30
+ }
31
+ /**
32
+ * Run the resident decision surface until `signal` aborts.
33
+ *
34
+ * Each pass reads the queue once. A non-empty queue is handed to runInteractive (S-262.1 verbatim —
35
+ * same one-key flow, same escaping); when it returns, the snapshot is spent and the loop re-reads,
36
+ * so a request that arrived during a decision is presented on the next pass (one cycle late, never
37
+ * lost). An empty queue announces idle once and waits a poll cycle. Abort — in the idle wait or in
38
+ * runInteractive's keypress wait (which rejects AbortError, wired by the caller) — ends the loop and
39
+ * resolves; pending items stay in the queue.
40
+ */
41
+ async function runWatch(root, io, actor, opts = {}) {
42
+ const pollMs = opts.pollMs ?? exports.DEFAULT_POLL_MS;
43
+ const signal = opts.signal;
44
+ const waitIdle = opts.waitIdle ?? defaultWaitIdle;
45
+ let idleAnnounced = false;
46
+ // The pending ids the operator was last SHOWN. An item they skipped stays in the queue, so without
47
+ // this the loop would re-present it every poll — a flood, and (with no wait between) a busy spin
48
+ // that starves the event loop. We re-present only when the pending set has something NEW in it;
49
+ // skipped items wait, silently, until they change or a new request joins them.
50
+ let lastPresented = new Set();
51
+ while (!signal?.aborted) {
52
+ const state = (0, approval_queue_1.readQueue)(root);
53
+ // Prune ids that are no longer pending BEFORE deciding what is new (round-1 F1). Without this,
54
+ // `lastPresented` kept the id of an item that was decided and removed, so when that id came
55
+ // BACK — the agent retrying a denied action, which is the very thing a resident operator watches
56
+ // for — it read as already-seen and was never re-presented, as long as some skipped sibling kept
57
+ // the queue non-empty (the empty-queue reset never fired). A departed id is new again on return.
58
+ const pendingIds = new Set(state.pending.map((p) => p.id));
59
+ lastPresented = new Set([...lastPresented].filter((id) => pendingIds.has(id)));
60
+ const hasNew = state.pending.some((p) => !lastPresented.has(p.id));
61
+ if (state.pending.length > 0 && hasNew) {
62
+ idleAnnounced = false;
63
+ const shown = new Set(state.pending.map((p) => p.id)); // what this pass presents
64
+ try {
65
+ await (0, approve_1.runInteractive)(root, io, actor);
66
+ }
67
+ catch (e) {
68
+ // The operator leaving mid-decision (Ctrl-C / EOF) surfaces as AbortError through the
69
+ // caller's `ask`. That ends the watch quietly — it is not an error to report.
70
+ if (e?.name === 'AbortError')
71
+ return;
72
+ throw e;
73
+ }
74
+ // Remember only the shown ids that are STILL pending after the pass — the ones the operator
75
+ // SKIPPED. A decided item (granted/denied) has left the queue, so it is NOT remembered, and if
76
+ // its id returns (a retry after denial) it reads as new and is re-presented (round-1 F1). A
77
+ // request that arrived DURING the pass is not in `shown`, so it too is new next pass — one
78
+ // cycle late, not lost. Skipped items are in both sets, so they are not re-flooded.
79
+ const afterIds = new Set((0, approval_queue_1.readQueue)(root).pending.map((p) => p.id));
80
+ lastPresented = new Set([...shown].filter((id) => afterIds.has(id)));
81
+ }
82
+ else if (state.pending.length === 0) {
83
+ if (!idleAnnounced) {
84
+ io.print(exports.WATCH_IDLE);
85
+ idleAnnounced = true;
86
+ }
87
+ // No reset of `lastPresented` here (round-3): the top-of-pass prune above already intersects
88
+ // it with the current pending set every pass, so on an empty queue it becomes empty before it
89
+ // is ever read — a reset here was dead code (proven behavior-neutral: a poison value survived
90
+ // the whole suite). A later re-arrival is still genuinely new, guaranteed by that prune.
91
+ }
92
+ await waitIdle(pollMs, signal); // ALWAYS yield a poll cycle — no tight re-presentation
93
+ }
94
+ }
@@ -1,4 +1,4 @@
1
- import { QueueState } from '../governance/approval-queue';
1
+ import { QueueState, PendingRequest } from '../governance/approval-queue';
2
2
  export type DecisionResult = {
3
3
  ok: true;
4
4
  expires?: string;
@@ -6,19 +6,11 @@ export type DecisionResult = {
6
6
  ok: false;
7
7
  reason: string;
8
8
  };
9
- /**
10
- * Approve: mint the narrow grant this request was filed for.
11
- *
12
- * @implements A-SPEC-246
13
- * A TTL that is not a positive integer is refused — a grant with no meaningful expiry violates the
14
- * narrowness rule the whole file channel rests on (REQ-245).
15
- */
16
9
  export declare function grantRequest(root: string, id: string, opts: {
17
10
  actor: string;
18
11
  ttlMinutes?: number;
19
12
  rationale?: string;
20
13
  }): DecisionResult;
21
- /** Deny, with a reason the agent will see in its next refusal. */
22
14
  export declare function denyRequest(root: string, id: string, reason: string, actor: string): DecisionResult;
23
15
  /** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
24
16
  export declare function holdRequest(root: string, id: string, question: string, actor: string): DecisionResult;
@@ -30,17 +22,73 @@ export declare function holdRequest(root: string, id: string, question: string,
30
22
  * teaches syntax, humans start typing it.
31
23
  */
32
24
  export declare function renderPending(state: QueueState): string;
25
+ /**
26
+ * @implements A-SPEC-262.1
27
+ * What a decision line must say. Round-1: `✓ 승인 — <expires> 까지 유효` named NOTHING, so an index
28
+ * that resolved against a shifted queue granted a different request than the operator read, with no
29
+ * way to see it from the screen. Every decision now echoes the request it acted on.
30
+ */
31
+ /**
32
+ * @implements A-SPEC-262.1
33
+ * The kind cell and the subject, fitted to what is LEFT OF THE ROW after the caller's own prefix.
34
+ *
35
+ * Round-8: the budgets were global constants derived once, from `✓ 승인 — ` — and `✓ 거부 기록됨 — `
36
+ * is seven columns wider, so every denial echo ran to 88 columns and wrapped. Round-7 had widened
37
+ * the kind cell without re-measuring any line that carries it. A constant cannot know which prefix
38
+ * it will be printed behind, so the line computes its own room and the prefix is passed in. The
39
+ * floor keeps a forged kind from eating the subject entirely.
40
+ */
41
+ export declare function subjectCells(prefix: string, p: PendingRequest): string;
42
+ /**
43
+ * @implements A-SPEC-262.1
44
+ * How many columns the SUBJECT gets behind `prefix` — the one place that knows, so a caller asking
45
+ * "will this fit?" and the caller that renders it cannot disagree.
46
+ *
47
+ * Round-9: `[A]`'s filter asked `isClipped(target, ROW_COLS)` with the constant 50 while the batch
48
+ * row it gates has 69 columns for `kind: 'shell'`. Every target between 51 and 69 columns was
49
+ * therefore refused as "not fitting a row" and told so on screen, while the list had already printed
50
+ * it whole — round-8's defect in the other direction, because round-8 fixed the renderer and left
51
+ * the predicate on the constant.
52
+ */
53
+ export declare function subjectRoom(prefix: string, p: PendingRequest): number;
54
+ export declare function decisionSubject(p: PendingRequest, prefix?: string): string;
55
+ /**
56
+ * @implements A-SPEC-262.1
57
+ * The traceability cell, on a row of its own. Round-5: an 80-column row cannot hold the subject AND
58
+ * the id AND the expiry — the one-line form measured 81 columns and wrapped, and a wrapped decision
59
+ * line is how a queue scrolls its own evidence away. Splitting costs a row and keeps both facts
60
+ * whole; neither may be dropped, since the subject is what the human read and the id is what the
61
+ * grant is keyed by.
62
+ */
63
+ export declare function decisionDetail(p: PendingRequest, tail?: string): string;
64
+ /**
65
+ * The non-TTY fallback's next-command hint. Pure.
66
+ *
67
+ * @implements A-SPEC-260
68
+ * Without a TTY the interactive loop cannot run, and the silent list-then-exit-0 left the operator
69
+ * with no idea what to type (measured 2026-08-23 — Claude Code's `!` runs with a non-TTY stdin).
70
+ * The examples carry the FIRST pending id read from the queue, never a placeholder the operator
71
+ * must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
72
+ * advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
73
+ */
74
+ export declare function renderNonTtyHint(state: QueueState): string;
33
75
  export interface ApproveIO {
34
76
  print: (s: string) => void;
35
77
  /** Ask one question, resolve with the raw answer. Injected so tests script the conversation. */
36
78
  ask: (prompt: string) => Promise<string>;
37
79
  }
38
80
  /**
39
- * The interactive loop — one entry at a time, one letter per decision.
81
+ * The interactive loop — one entry at a time, ONE KEY per decision.
40
82
  *
41
83
  * @implements A-SPEC-246
42
- * [a] mints the mechanical grant (optional rationale + TTL, enter accepts defaults), [d] requires a
43
- * reason because an unreasoned denial is invisible to the agent in any useful way, [q] holds with a
44
- * question the agent will carry to the user, [s] leaves the entry for later.
84
+ * @implements A-SPEC-262.1
85
+ * REQ-262 measured what the first cut cost: `[a]` then asked for a rationale and a TTL, so three
86
+ * approvals were nine inputs and the human left the surface to type elsewhere. Approving is now a
87
+ * single key with the defaults; the narrowing prompts moved to `[e]`, which is where someone who
88
+ * actually wants to narrow will look. `[v]` renders the SUBJECT (the spec body, the finding, the
89
+ * command) and re-asks the same item — looking is not deciding. `[A]` takes every remaining item,
90
+ * and it is only offered after `renderPending` has already put each item's summary on the screen
91
+ * (REQ-262 Constraints: nothing is approved unseen). `[d]` still demands a reason, because an
92
+ * unreasoned denial is invisible to the agent; `[q]` holds with a question; `[s]` leaves it.
45
93
  */
46
94
  export declare function runInteractive(root: string, io: ApproveIO, actor: string): Promise<void>;