@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.
- package/CHANGELOG.md +27 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-context.d.ts +2 -0
- package/dist/holmes/cli/approve-context.js +180 -0
- package/dist/holmes/cli/approve-ref.d.ts +27 -0
- package/dist/holmes/cli/approve-ref.js +40 -0
- package/dist/holmes/cli/approve-watch.d.ts +29 -0
- package/dist/holmes/cli/approve-watch.js +94 -0
- package/dist/holmes/cli/approve.d.ts +61 -13
- package/dist/holmes/cli/approve.js +374 -36
- package/dist/holmes/cli/doctor.js +262 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
- package/dist/holmes/cli/gitignore-merge.js +17 -1
- package/dist/holmes/cli/index.d.ts +23 -0
- package/dist/holmes/cli/index.js +496 -21
- package/dist/holmes/cli/init.js +14 -0
- package/dist/holmes/cli/mcp-launcher.js +6 -1
- package/dist/holmes/cli/screen-safe.d.ts +94 -0
- package/dist/holmes/cli/screen-safe.js +760 -0
- package/dist/holmes/governance/approval-queue.js +56 -4
- package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
- package/dist/holmes/governance/ledger-rechain.js +95 -0
- package/dist/holmes/governance/provenance-chain.d.ts +33 -6
- package/dist/holmes/governance/provenance-chain.js +91 -16
- package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
- package/dist/holmes/governance/provenance-ledger.js +10 -0
- package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
- package/dist/holmes/guardrail/risk-gate.js +10 -0
- package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
- package/dist/holmes/mcp/elicit-approval.js +79 -0
- package/dist/holmes/mcp/handlers.d.ts +7 -2
- package/dist/holmes/mcp/handlers.js +190 -24
- package/dist/holmes/mcp/server.js +39 -2
- package/dist/holmes/server/dashboard.js +2 -2
- package/dist/holmes/spec/id-collision.d.ts +39 -0
- package/dist/holmes/spec/id-collision.js +86 -0
- package/dist/holmes/spec/spec-store.js +9 -1
- 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
|
-
|
|
1
|
+
f7a048d-mtbm5atb
|
|
@@ -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,
|
|
81
|
+
* The interactive loop — one entry at a time, ONE KEY per decision.
|
|
40
82
|
*
|
|
41
83
|
* @implements A-SPEC-246
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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>;
|