@holmes-lab/holmes-kit 0.1.11 → 0.1.13

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 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.1.13] - 2026-08-23
9
+
10
+ The HITL approval overhaul — a human decides in the CLI, and never types a line of scope
11
+ syntax. Driven by dogfooding feedback from governing a real 46-spec Python repo.
12
+
13
+ ### Added
14
+ - **Approval request queue (REQ-244)**: every refusal whose only fix is an owner approval now
15
+ files a structured request into `.ax/approvals/queue.jsonl`, and the refusal text carries the
16
+ request id. Six gate sites — hard-hitl shell, config-write, role-policy, spec_approve, sealed
17
+ spec_retire, review-resolve. Repeats fold by content-hash id (six blocks, one row). The queue
18
+ is information, never authority: a forged `granted` event opens no gate.
19
+ - **Grant file channel (REQ-245)**: approvals delivered by dropping a file into
20
+ `.ax/approvals/grants/`, read at judgement time — no `.mcp.json` editing, no MCP reconnect. A
21
+ grant is an ordinary Approval, judged by the existing `approvalCovers`; hard-hitl replay is
22
+ refused atomically by the existing ledger nonce consumption. Grants must be narrow (scope +
23
+ expires + nonce), so the committed-token failure the dogfooding found cannot recur. The session
24
+ cannot mint its own — `.ax/approvals` joined the protected directories on both the Write and
25
+ shell surfaces.
26
+ - **`holmes-kit approve` (REQ-246)**: the decision surface. Interactive on a TTY — 승인/거부/질문/
27
+ 건너뛰기 per request — or `--grant`/`--deny`/`--ask` for a CI/remote operator. The grant is
28
+ derived mechanically from the queue entry (kind→scope.kind, target→pattern, id→nonce); the human
29
+ types a decision, an optional rationale, and a TTL, and never scope grammar. Denials and holds
30
+ reach the agent in its next refusal, so a retry loop stops.
31
+ - **Stop-gate pending-awareness + startup token health (REQ-247)**: the stop hook no longer
32
+ re-blocks a debt already queued for the owner — it says so once and lets the turn finish — while
33
+ a single non-waiting or code-resolvable violation still blocks. The MCP server logs the approval
34
+ token's health at startup (valid, invalid with reason, or absent), to stderr only.
35
+
36
+ ### Changed
37
+ - **codex hook coverage is measured, not inferred (REQ-248, backlog)**: codex-cli 0.149.0 hook
38
+ protocol is identical to ours and our shipped hook is IO-compatible, but codex loads hooks
39
+ through its plugin system, not `config.toml` — so `init --agent codex` wires MCP only, and codex
40
+ stays governed by the server-side approval gates until plugin packaging lands.
41
+ `HARNESS_ENFORCES.codex` stays false: enforcement not observed is not written as present.
42
+
43
+ <!-- @implements A-SPEC-209 -->
44
+ ## [0.1.12] - 2026-08-23
45
+
46
+ ### Added
47
+ - **Install Guide (`docs/install-guide.md`, shipped in the package)**: install paths organised by
48
+ account type and privileges — a decision table first, commands second — because the same wrong
49
+ command (`npm install -g` against a protected prefix) was run three times by a real adopter
50
+ before the right one. Troubleshooting is keyed to the exact error text npm prints (`EPERM mkdir`,
51
+ `notarget`, `better-sqlite3` build failures, `spawn sh ENOENT`), and each entry says why
52
+ elevation is the wrong fix.
53
+
54
+ ### Changed
55
+ - **README Quickstart leads with the situation table**, and steps 2–3 use `npx holmes-kit …` —
56
+ the bare `holmes-kit` they previously showed only exists on PATH after a global install, which
57
+ is not the primary path.
58
+
7
59
  <!-- @implements A-SPEC-209 -->
8
60
  ## [0.1.11] - 2026-08-23
9
61
 
package/README.md CHANGED
@@ -51,58 +51,35 @@ Holmes-Kit prioritizes **CLI-based AI Coding Agents** where OS-level process hoo
51
51
 
52
52
  ## ⚡ Quickstart (3-Minute Setup)
53
53
 
54
- ### 1. Install
55
-
56
- Install it into the project you want to govern. This is the path verified end to end the
57
- wiring `init` writes uses absolute paths, so a local install works from any directory:
58
-
59
- ```bash
60
- npm install --save-dev @holmes-lab/holmes-kit
61
- ```
62
-
63
- <details>
64
- <summary>Prefer one install for many projects? (<code>npm install -g</code>)</summary>
65
-
66
- A global install works too, but **do not elevate to get it**. Where npm puts global packages is
67
- your npm configuration, not ours and on Windows it often points inside the Node.js installation
68
- directory:
69
-
70
- ```text
71
- npm error code EPERM
72
- npm error path C:\Program Files\nodejs\node_modules\@holmes-lab
73
- ```
74
-
75
- Running the install as Administrator would "fix" that by executing this package's native
76
- dependency install scripts with system privileges. Point npm at a user-writable prefix instead:
77
-
78
- ```powershell
79
- npm config set prefix "$env:APPDATA\npm" # Windows
80
- # then ensure %APPDATA%\npm is on PATH
81
- ```
82
- ```bash
83
- npm config set prefix "$HOME/.npm-global" # macOS / Linux
84
- export PATH="$HOME/.npm-global/bin:$PATH"
85
- ```
86
-
87
- A Node version manager (`nvm`, `fnm`, `volta`) does the same thing by keeping the whole
88
- toolchain under your home directory.
89
- </details>
90
-
91
- **Prerequisites** — Node.js `>= 20.0.0`, plus a native binary for two kinds of dependency:
92
-
93
- | Dependency | How the binary arrives |
94
- |---|---|
95
- | 8 tree-sitter grammars | Prebuilt binaries ship inside the packages (`darwin-arm64`, `darwin-x64`, `linux-x64`, `win32-x64`) — nothing is compiled |
96
- | `better-sqlite3` | Downloaded at install time by `prebuild-install`, **falling back to compiling** if no prebuild matches your platform and Node ABI |
97
-
98
- Only the second can require a toolchain. If it has to compile, you need a C++ build environment:
99
- Xcode Command Line Tools on macOS, Visual Studio Build Tools on Windows, or
100
- `apk add --no-cache python3 make g++` on Alpine.
54
+ ### 1. Install — find your row first
55
+
56
+ The same wrong command was run three times by a real adopter before the right one; a table beats
57
+ prose read top-to-bottom.
58
+
59
+ | Which situation are you in? | Privileges | Command |
60
+ |---|---|---|
61
+ | **Using it in one project** (most people) | none | `npm install --save-dev @holmes-lab/holmes-kit` |
62
+ | Company-managed PC / restricted account | none | same — no system directory is touched |
63
+ | CI / container | none | same, plus `--prefer-online` right after a release |
64
+ | CLI across many projects (`-g`) | depends | run `npm config get prefix` first — see below |
65
+
66
+ **Before `npm install -g`**: if `npm config get prefix` names a protected directory
67
+ (`C:\Program Files\nodejs`, `/usr/local`), `-g` dies with `EPERM` **before any package file
68
+ arrives** — no package version can fix that, and elevation is the wrong fix (it runs native
69
+ install scripts with system privileges, and it did not even work in the reported case). Move the
70
+ prefix to user space instead — one-time setup, exact commands in the
71
+ **[Install Guide](docs/install-guide.md)**, along with troubleshooting keyed to the exact error
72
+ text (`EPERM mkdir`, `notarget`, `better-sqlite3` build failures).
73
+
74
+ **Prerequisites** — Node.js `>= 20.0.0`. The 8 tree-sitter grammars ship prebuilt binaries for
75
+ macOS/Linux/Windows and compile nothing; `better-sqlite3` downloads a prebuild at install time,
76
+ falling back to compiling only that fallback needs a C++ toolchain (VS Build Tools / Xcode CLT /
77
+ `apk add python3 make g++`).
101
78
 
102
79
  ### 2. Initialize in Your Project
103
80
  ```bash
104
81
  cd /path/to/your/project
105
- holmes-kit init
82
+ npx holmes-kit init # drop the npx prefix if you installed with -g
106
83
  ```
107
84
  *An interactive prompt will ask which AI agent harnesses to wire into your project:*
108
85
  ```text
@@ -114,7 +91,7 @@ holmes-kit init
114
91
 
115
92
  ### 3. Verify Health
116
93
  ```bash
117
- holmes-kit doctor
94
+ npx holmes-kit doctor
118
95
  ```
119
96
  *If everything is green, your project is governed and ready for AI pair-programming!*
120
97
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- c3422e5-mt570gca
1
+ 0c506c6-mt5buls8
@@ -14,8 +14,15 @@ export type Agent = (typeof AGENTS)[number];
14
14
  * - `claude` — PreToolUse/Stop 훅. 이 저장소가 처음부터 배선해 온 하네스.
15
15
  * - `antigravity` — PreToolUse/Stop 훅이 있고 `deny`/`continue` 로 막을 수 있다(설치본의
16
16
  * `agy-customizations` 규약 문서와 실제 대화 기록에서 실측).
17
- * - `codex` — 기계에 설치되어 있지 않아 규약을 **재지 못했다**. 재지 못한 것을 있다고
18
- * 적지 않는다. 도구(MCP)와 지침만 준다.
17
+ * - `codex` — 실측했다(codex-cli 0.149.0, 2026-08-23).규약 자체는 우리 것과 **동일**하다:
18
+ * 바이너리 스키마가 `PreToolUse` / `permissionDecision: allow|deny|ask` / `stop_hook_active` /
19
+ * `hookSpecificOutput` 를 그대로 쓰고, codex 입력 형식으로 우리 `pre-tool-use.js` 를 먹이면 정확히
20
+ * `deny` 를 낸다. 그런데도 `false` 인 이유는 **배선 경로**다: codex 의 훅은 `config.toml` 의
21
+ * `[[hooks]]` 로 로드되지 않는다(잘못된 타입을 넣어도 `--strict-config` 가 통과 — 스키마 밖이다).
22
+ * `hook_runtime` 트레이스는 실제 셸 도구 실행에도 진입하지 않았고, 바이너리 문자열이 훅을
23
+ * `Plugins`/`RemotePlugin` 시스템(`codex plugin`, `--with-hooks` scaffold)으로 로드한다고 말한다.
24
+ * 즉 codex 훅 집행은 **plugin 패키징**이 필요하며 MCP·config 배선으로는 닿지 않는다(REQ-248 로 남김).
25
+ * 그때까지 codex 는 MCP 서버로만 지배되며, 그 경로는 실증됐다(spec_approve 등 서버측 승인 게이트).
19
26
  */
20
27
  export declare const HARNESS_ENFORCES: Record<Agent, boolean>;
21
28
  export interface AgentWiringOptions {
@@ -53,8 +53,15 @@ exports.AGENTS = ['claude', 'antigravity', 'codex'];
53
53
  * - `claude` — PreToolUse/Stop 훅. 이 저장소가 처음부터 배선해 온 하네스.
54
54
  * - `antigravity` — PreToolUse/Stop 훅이 있고 `deny`/`continue` 로 막을 수 있다(설치본의
55
55
  * `agy-customizations` 규약 문서와 실제 대화 기록에서 실측).
56
- * - `codex` — 기계에 설치되어 있지 않아 규약을 **재지 못했다**. 재지 못한 것을 있다고
57
- * 적지 않는다. 도구(MCP)와 지침만 준다.
56
+ * - `codex` — 실측했다(codex-cli 0.149.0, 2026-08-23).규약 자체는 우리 것과 **동일**하다:
57
+ * 바이너리 스키마가 `PreToolUse` / `permissionDecision: allow|deny|ask` / `stop_hook_active` /
58
+ * `hookSpecificOutput` 를 그대로 쓰고, codex 입력 형식으로 우리 `pre-tool-use.js` 를 먹이면 정확히
59
+ * `deny` 를 낸다. 그런데도 `false` 인 이유는 **배선 경로**다: codex 의 훅은 `config.toml` 의
60
+ * `[[hooks]]` 로 로드되지 않는다(잘못된 타입을 넣어도 `--strict-config` 가 통과 — 스키마 밖이다).
61
+ * `hook_runtime` 트레이스는 실제 셸 도구 실행에도 진입하지 않았고, 바이너리 문자열이 훅을
62
+ * `Plugins`/`RemotePlugin` 시스템(`codex plugin`, `--with-hooks` scaffold)으로 로드한다고 말한다.
63
+ * 즉 codex 훅 집행은 **plugin 패키징**이 필요하며 MCP·config 배선으로는 닿지 않는다(REQ-248 로 남김).
64
+ * 그때까지 codex 는 MCP 서버로만 지배되며, 그 경로는 실증됐다(spec_approve 등 서버측 승인 게이트).
58
65
  */
59
66
  exports.HARNESS_ENFORCES = {
60
67
  claude: true,
@@ -0,0 +1,46 @@
1
+ import { QueueState } from '../governance/approval-queue';
2
+ export type DecisionResult = {
3
+ ok: true;
4
+ expires?: string;
5
+ } | {
6
+ ok: false;
7
+ reason: string;
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
+ export declare function grantRequest(root: string, id: string, opts: {
17
+ actor: string;
18
+ ttlMinutes?: number;
19
+ rationale?: string;
20
+ }): DecisionResult;
21
+ /** Deny, with a reason the agent will see in its next refusal. */
22
+ export declare function denyRequest(root: string, id: string, reason: string, actor: string): DecisionResult;
23
+ /** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
24
+ export declare function holdRequest(root: string, id: string, question: string, actor: string): DecisionResult;
25
+ /**
26
+ * The review screen. Pure.
27
+ *
28
+ * What a decision needs and nothing else: what (kind·target), why, how often it was blocked, since
29
+ * when, and any standing question. Deliberately NO scope grammar anywhere — the moment the screen
30
+ * teaches syntax, humans start typing it.
31
+ */
32
+ export declare function renderPending(state: QueueState): string;
33
+ export interface ApproveIO {
34
+ print: (s: string) => void;
35
+ /** Ask one question, resolve with the raw answer. Injected so tests script the conversation. */
36
+ ask: (prompt: string) => Promise<string>;
37
+ }
38
+ /**
39
+ * The interactive loop — one entry at a time, one letter per decision.
40
+ *
41
+ * @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.
45
+ */
46
+ export declare function runInteractive(root: string, io: ApproveIO, actor: string): Promise<void>;
@@ -0,0 +1,200 @@
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.grantRequest = grantRequest;
37
+ exports.denyRequest = denyRequest;
38
+ exports.holdRequest = holdRequest;
39
+ exports.renderPending = renderPending;
40
+ exports.runInteractive = runInteractive;
41
+ // @implements A-SPEC-246
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const approval_queue_1 = require("../governance/approval-queue");
45
+ const approval_grants_1 = require("../governance/approval-grants");
46
+ /**
47
+ * `holmes-kit approve` — the surface where a human decides.
48
+ *
49
+ * @implements A-SPEC-246
50
+ * The queue makes the review list (REQ-244) and grant files deliver approvals with no editing and
51
+ * no reconnect (REQ-245); what was missing is the surface where the decision happens. Without it
52
+ * the operator hand-writes grant JSON — scope grammar, expiry format, nonce rules — the exact
53
+ * "human types values into files" this programme exists to zero out.
54
+ *
55
+ * THE DECISION API IS THE HEART; THE TUI IS A SHELL. `grantRequest`/`denyRequest`/`holdRequest`
56
+ * write the files and events; both the interactive loop and the flags call the same three, so a
57
+ * CI or remote operator without a TTY is never locked out.
58
+ *
59
+ * A GRANT IS DERIVED, NEVER TYPED. kind→scope.kind, target→pattern, id→nonce, now+TTL→expires —
60
+ * all from the queue entry. The human's inputs are the decision, an optional rationale, and a TTL.
61
+ * The moment the human must know scope syntax, this UI has no reason to exist.
62
+ */
63
+ const DEFAULT_TTL_MINUTES = 30;
64
+ const DEFAULT_RATIONALE = 'approved via holmes-kit approve';
65
+ const findPending = (root, id) => {
66
+ const state = (0, approval_queue_1.readQueue)(root);
67
+ return { entry: state.pending.find((p) => p.id === id), state };
68
+ };
69
+ /** CLI-side event writer. The CLI runs in the operator's terminal, outside the session gates. */
70
+ const appendEvent = (root, event) => {
71
+ try {
72
+ const file = path.join(root, approval_queue_1.QUEUE_RELPATH);
73
+ if (!fs.existsSync(path.join(root, '.ax')))
74
+ return false;
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.appendFileSync(file, JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n');
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ };
83
+ /**
84
+ * Approve: mint the narrow grant this request was filed for.
85
+ *
86
+ * @implements A-SPEC-246
87
+ * A TTL that is not a positive integer is refused — a grant with no meaningful expiry violates the
88
+ * narrowness rule the whole file channel rests on (REQ-245).
89
+ */
90
+ function grantRequest(root, id, opts) {
91
+ const ttl = opts.ttlMinutes ?? DEFAULT_TTL_MINUTES;
92
+ if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0) {
93
+ return { ok: false, reason: `TTL 은 1 이상의 정수(분)여야 합니다 — 받은 값: ${String(ttl)}` };
94
+ }
95
+ const { entry } = findPending(root, id);
96
+ if (!entry)
97
+ return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다 — npx holmes-kit approve --list 로 확인하십시오` };
98
+ const expires = new Date(Date.now() + ttl * 60_000).toISOString();
99
+ const grant = {
100
+ actor: opts.actor,
101
+ token: 'grant',
102
+ rationale: opts.rationale?.trim() ? opts.rationale : DEFAULT_RATIONALE,
103
+ scope: [{ kind: entry.kind, pattern: entry.target }],
104
+ expires,
105
+ nonce: id,
106
+ };
107
+ try {
108
+ const dir = path.join(root, approval_grants_1.GRANTS_RELDIR);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(grant, null, 2) + '\n');
111
+ }
112
+ catch (e) {
113
+ return { ok: false, reason: `그랜트 파일을 쓸 수 없습니다: ${e instanceof Error ? e.message : String(e)}` };
114
+ }
115
+ appendEvent(root, { event: 'granted', id, actor: opts.actor });
116
+ return { ok: true, expires };
117
+ }
118
+ /** Deny, with a reason the agent will see in its next refusal. */
119
+ function denyRequest(root, id, reason, actor) {
120
+ const { entry } = findPending(root, id);
121
+ if (!entry)
122
+ return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
123
+ appendEvent(root, { event: 'denied', id, reason, actor });
124
+ return { ok: true };
125
+ }
126
+ /** Hold with a question — 추후 승인/거부. The agent carries the question to the user. */
127
+ function holdRequest(root, id, question, actor) {
128
+ const { entry } = findPending(root, id);
129
+ if (!entry)
130
+ return { ok: false, reason: `대기 중인 요청 ${id} 가 없습니다` };
131
+ appendEvent(root, { event: 'held', id, question, actor });
132
+ return { ok: true };
133
+ }
134
+ /**
135
+ * The review screen. Pure.
136
+ *
137
+ * What a decision needs and nothing else: what (kind·target), why, how often it was blocked, since
138
+ * when, and any standing question. Deliberately NO scope grammar anywhere — the moment the screen
139
+ * teaches syntax, humans start typing it.
140
+ */
141
+ function renderPending(state) {
142
+ if (state.pending.length === 0)
143
+ return '승인 대기 중인 요청이 없습니다.';
144
+ const lines = [`◆ 승인 대기 ${state.pending.length}건`, ''];
145
+ state.pending.forEach((p, i) => {
146
+ lines.push(`[${i + 1}] ${p.kind} ${p.target}`);
147
+ lines.push(` 이유: ${p.why}`);
148
+ const since = p.firstTs ? ` · ${p.firstTs} 부터` : '';
149
+ lines.push(` 차단 ${p.count}회${since}${p.hold ? ` (보류 중 — 질문: ${p.question ?? ''})` : ''}`);
150
+ lines.push(` id: ${p.id}`);
151
+ lines.push('');
152
+ });
153
+ if (state.malformedLines > 0)
154
+ lines.push(`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`);
155
+ return lines.join('\n');
156
+ }
157
+ /**
158
+ * The interactive loop — one entry at a time, one letter per decision.
159
+ *
160
+ * @implements A-SPEC-246
161
+ * [a] mints the mechanical grant (optional rationale + TTL, enter accepts defaults), [d] requires a
162
+ * reason because an unreasoned denial is invisible to the agent in any useful way, [q] holds with a
163
+ * question the agent will carry to the user, [s] leaves the entry for later.
164
+ */
165
+ async function runInteractive(root, io, actor) {
166
+ const state = (0, approval_queue_1.readQueue)(root);
167
+ io.print(renderPending(state));
168
+ if (state.pending.length === 0)
169
+ return;
170
+ for (const p of state.pending) {
171
+ io.print(`\n─ ${p.kind} ${p.target}`);
172
+ const answer = (await io.ask('[a]승인 [d]거부 [q]질문 남기고 보류 [s]건너뛰기 > ')).trim().toLowerCase();
173
+ if (answer === 'a') {
174
+ const rationale = (await io.ask(`사유 (enter = "${DEFAULT_RATIONALE}") > `)).trim();
175
+ const ttlRaw = (await io.ask(`유효 시간(분, enter = ${DEFAULT_TTL_MINUTES}) > `)).trim();
176
+ const ttl = ttlRaw === '' ? DEFAULT_TTL_MINUTES : Number(ttlRaw);
177
+ const r = grantRequest(root, p.id, { actor, rationale, ttlMinutes: ttl });
178
+ io.print(r.ok ? `✓ 승인 — ${r.expires} 까지 유효` : `✗ ${r.reason}`);
179
+ }
180
+ else if (answer === 'd') {
181
+ const reason = (await io.ask('거부 사유(에이전트에게 전달됩니다) > ')).trim();
182
+ if (reason === '') {
183
+ io.print('✗ 사유 없는 거부는 에이전트가 볼 수 없습니다 — 건너뜁니다');
184
+ continue;
185
+ }
186
+ const r = denyRequest(root, p.id, reason, actor);
187
+ io.print(r.ok ? '✓ 거부 기록됨' : `✗ ${r.reason}`);
188
+ }
189
+ else if (answer === 'q') {
190
+ const question = (await io.ask('질문(에이전트가 사용자에게 전달합니다) > ')).trim();
191
+ if (question === '') {
192
+ io.print('✗ 빈 질문 — 건너뜁니다');
193
+ continue;
194
+ }
195
+ const r = holdRequest(root, p.id, question, actor);
196
+ io.print(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다' : `✗ ${r.reason}`);
197
+ }
198
+ // 's' and anything else: leave it for later.
199
+ }
200
+ }
@@ -39,6 +39,7 @@ exports.main = main;
39
39
  // @implements A-SPEC-213
40
40
  // @implements A-SPEC-215
41
41
  const fs = __importStar(require("node:fs"));
42
+ const os = __importStar(require("node:os"));
42
43
  const path = __importStar(require("node:path"));
43
44
  const init_1 = require("./init");
44
45
  const doctor_1 = require("./doctor");
@@ -73,7 +74,17 @@ const KNOWN_FLAGS = {
73
74
  skills: ['help', 'target'],
74
75
  ci: ['help', 'target', 'specs-dir', 'json'],
75
76
  serve: ['help', 'target', 'port'],
77
+ approve: ['help', 'target', 'list', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale'],
76
78
  };
79
+ const APPROVE_USAGE = `holmes-kit approve — 승인 대기 요청의 결재 (HITL)
80
+
81
+ 대화형(TTY): 항목마다 [a]승인 [d]거부 [q]질문 [s]건너뛰기
82
+ --list 대기 목록만 출력
83
+ --grant <id> 해당 요청의 좁은 그랜트 발급 (--ttl <분>, --rationale <문구>)
84
+ --deny <id> --reason <문구> 거부 — 사유가 에이전트의 다음 거부 문면에 실립니다
85
+ --ask <id> --question <문구> 질문 남기고 보류 — 에이전트가 사용자에게 전달합니다
86
+ --target <dir> 프로젝트 루트 (기본: 현재 디렉터리)
87
+ `;
77
88
  class UnknownFlagError extends Error {
78
89
  }
79
90
  /**
@@ -82,7 +93,7 @@ class UnknownFlagError extends Error {
82
93
  * what you'd do" spelling performed a REAL write (round-3 HIGH; same class inverted --remove and
83
94
  * --force). A boolean flag must never consume a token.
84
95
  */
85
- const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json']);
96
+ const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list']);
86
97
  function parseFlags(argv, known) {
87
98
  const flags = {};
88
99
  const positionals = [];
@@ -163,6 +174,14 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
163
174
  --remove remove holmes-kit wiring (holmes-owned entries only)
164
175
  --force re-wire an already-wired target (requires HOLMES_APPROVAL)
165
176
 
177
+ holmes-kit approve review pending approval requests (HITL)
178
+ (no flags, TTY) interactive: [a]승인 [d]거부 [q]질문 [s]건너뛰기 per request
179
+ --list print the pending queue and exit
180
+ --grant <id> [--ttl <min>] [--rationale <text>] mint the narrow grant for one request
181
+ --deny <id> --reason <text> record a denial the agent will see
182
+ --ask <id> --question <text> hold with a question the agent relays
183
+ --target <dir> project root (default: cwd)
184
+
166
185
  holmes-kit doctor [flags] diagnose the install
167
186
  --target <dir> also verify that target's wiring points at this install
168
187
  --json machine-readable output
@@ -328,6 +347,64 @@ async function main(argv) {
328
347
  return res.ok ? 0 : 1;
329
348
  }
330
349
  // @implements A-SPEC-215
350
+ if (cmd === 'approve') {
351
+ // @implements A-SPEC-246 — the surface where a human decides. Interactive on a TTY with no
352
+ // decision flags; every interactive decision has a flag equivalent so a CI or remote operator
353
+ // is never locked out.
354
+ const { grantRequest, denyRequest, holdRequest, renderPending, runInteractive } = require('./approve');
355
+ const { readQueue } = require('../governance/approval-queue');
356
+ const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
357
+ const actor = (() => { try {
358
+ return os.userInfo().username || 'operator';
359
+ }
360
+ catch {
361
+ return 'operator';
362
+ } })();
363
+ if (flags.help) {
364
+ process.stdout.write(APPROVE_USAGE);
365
+ return 0;
366
+ }
367
+ if (typeof flags.grant === 'string') {
368
+ const opts = { actor };
369
+ if (typeof flags.ttl === 'string')
370
+ opts.ttlMinutes = Number(flags.ttl);
371
+ if (typeof flags.rationale === 'string')
372
+ opts.rationale = flags.rationale;
373
+ const r = grantRequest(root, flags.grant, opts);
374
+ process.stdout.write(r.ok ? `✓ 승인 — ${r.expires} 까지 유효\n` : `✗ ${r.reason}\n`);
375
+ return r.ok ? 0 : 1;
376
+ }
377
+ if (typeof flags.deny === 'string') {
378
+ const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
379
+ if (reason === '') {
380
+ process.stderr.write('✗ --deny 는 --reason 이 필요합니다 — 사유 없는 거부는 에이전트가 볼 수 없습니다\n');
381
+ return 1;
382
+ }
383
+ const r = denyRequest(root, flags.deny, reason, actor);
384
+ process.stdout.write(r.ok ? '✓ 거부 기록됨\n' : `✗ ${r.reason}\n`);
385
+ return r.ok ? 0 : 1;
386
+ }
387
+ if (typeof flags.ask === 'string') {
388
+ const question = typeof flags.question === 'string' ? flags.question.trim() : '';
389
+ if (question === '') {
390
+ process.stderr.write('✗ --ask 는 --question 이 필요합니다\n');
391
+ return 1;
392
+ }
393
+ const r = holdRequest(root, flags.ask, question, actor);
394
+ process.stdout.write(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다\n' : `✗ ${r.reason}\n`);
395
+ return r.ok ? 0 : 1;
396
+ }
397
+ if (flags.list || !process.stdin.isTTY) {
398
+ process.stdout.write(renderPending(readQueue(root)) + '\n');
399
+ return 0;
400
+ }
401
+ const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
402
+ void runInteractive(root, {
403
+ print: (t) => process.stdout.write(t + '\n'),
404
+ ask: (q) => rl.question(q),
405
+ }, actor).finally(() => rl.close());
406
+ return 0;
407
+ }
331
408
  if (cmd === 'serve') {
332
409
  if (typeof flags.target === 'string') {
333
410
  const t = path.resolve(flags.target);
@@ -0,0 +1,63 @@
1
+ import { Approval } from '../guardrail/risk-gate';
2
+ /**
3
+ * The grant file channel — approval delivery with no file editing and no reconnect.
4
+ *
5
+ * @implements A-SPEC-245
6
+ * WHY THIS EXISTS. The only delivery channel was the MCP server's environment. Dogfooded cost of ONE
7
+ * human approval (Notion, 2026-08-23, UX-1): edit `.mcp.json` by hand (the hook blocks the agent
8
+ * doing it) → reconnect MCP → retry — three to four round trips. And the edit left the token behind:
9
+ * the same report found HOLMES_APPROVAL committed to the target repository since 8/18. The forgery
10
+ * defence's root of trust, sitting in plaintext in git.
11
+ *
12
+ * WHAT A GRANT IS. An ordinary `Approval` — no new format — read from
13
+ * `.ax/approvals/grants/<nonce>.json` at JUDGEMENT TIME, so nothing needs restarting. Judgement is
14
+ * the ONE existing `approvalCovers`; single-use for hard-hitl rides the ONE existing ledger nonce
15
+ * consumption. This module adds reading a directory, nothing else.
16
+ *
17
+ * WHY GRANTS MUST BE NARROW. A file is only a grant when it carries `scope` AND `expires` AND
18
+ * `nonce`. The unscoped session master key stays env-only: a masterless key rolling around as a
19
+ * file recreates the committed-token incident this channel exists to remove.
20
+ *
21
+ * TRUST MODEL. The session cannot write `.ax/approvals` — the same identity-based Write gate and
22
+ * shell-command gate that protect `.ax/ledger`. A hand that can create a grant file is by
23
+ * definition outside the session: the operator.
24
+ */
25
+ export declare const GRANTS_RELDIR: string;
26
+ /**
27
+ * Read the grant files. Filename order, so selection is deterministic.
28
+ *
29
+ * Ignored files are COUNTED, not silently skipped: "I dropped a grant in and nothing opened" must
30
+ * be diagnosable, and a silent skip makes an inert grant indistinguishable from no grant.
31
+ */
32
+ export declare function readGrants(root: string): {
33
+ grants: Approval[];
34
+ ignored: number;
35
+ };
36
+ /** First grant that covers the action — judged by `approvalCovers` and nothing else. Pure. */
37
+ export declare function pickCoveringGrant(grants: Approval[], action: {
38
+ kind: string;
39
+ target: string;
40
+ }, now: string): Approval | undefined;
41
+ /**
42
+ * Resolve the approval for an action: env first, then the grant files.
43
+ *
44
+ * @implements A-SPEC-245
45
+ * Env-first IS the backward compatibility: an adopter whose flows run on the env token sees not one
46
+ * changed verdict, message, or consumed file from this slice.
47
+ */
48
+ export declare function resolveApproval(root: string, envApproval: Approval | undefined, action: {
49
+ kind: string;
50
+ target: string;
51
+ }, now: string): {
52
+ approval: Approval;
53
+ source: 'env' | 'grant';
54
+ } | undefined;
55
+ /**
56
+ * Best-effort single-use: remove the grant file after the act it authorized succeeded.
57
+ *
58
+ * hard-hitl does not rely on this — its replay is refused atomically by the ledger nonce
59
+ * consumption the grant's mandatory nonce rides through. For the other sites, a failed removal
60
+ * leaves a reuse window whose ceiling is the grant's own `expires`; stated rather than hidden.
61
+ * The nonce is flattened to a basename so a traversal-shaped nonce cannot reach outside the dir.
62
+ */
63
+ export declare function consumeGrantFile(root: string, nonce: string): void;