@holmes-lab/holmes-kit 0.14.0 → 0.15.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 +50 -0
- package/README.md +2 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.d.ts +14 -0
- package/dist/holmes/cli/approve.js +60 -3
- package/dist/holmes/cli/gitignore-merge.js +5 -0
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/governance/approval-queue.d.ts +33 -0
- package/dist/holmes/governance/approval-queue.js +94 -7
- package/dist/holmes/mcp/handlers.js +19 -0
- package/dist/holmes/review/test-outcomes.d.ts +17 -2
- package/dist/holmes/review/test-outcomes.js +54 -15
- package/dist/holmes/spec/approval-blockers.js +8 -0
- package/dist/holmes/spec/compat-impact.d.ts +26 -0
- package/dist/holmes/spec/compat-impact.js +140 -0
- package/dist/holmes/spec/spec-types.js +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ 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.15.0] - 2026-09-06
|
|
9
|
+
|
|
10
|
+
The first field report on Holmes-Kit's own operational data (1,412 unique approval requests vs. 8
|
|
11
|
+
decisions — a 99.4% write-only backlog) drove this release: the approval queue becomes a real
|
|
12
|
+
decision inbox, the field data itself gains machine attribution, and "did you consider all three
|
|
13
|
+
harnesses and all three OSes" stops being a habit and becomes a sealing gate.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Compatibility declaration gate (REQ-565)** — sealing an A-SPEC now requires two frontmatter
|
|
17
|
+
declarations, `harness_impact:` and `os_impact:` — either `'none: <real reason>'` or a full
|
|
18
|
+
3-cell mapping (`{claude, codex, agy}` / `{windows, mac, linux}`, verdicts
|
|
19
|
+
`supported|unavailable|n-a: <grounds>`). The duty lives on the **act of approval** (the
|
|
20
|
+
`breaking_change` shape), so already-approved specs are untouched; any spec that changes
|
|
21
|
+
re-approves and acquires the fields. A `none` claim is **cross-checked by machine**: Files to
|
|
22
|
+
Touch entries on the harness surface (`hooks/`, adapters, `cli/init|agents`, `mcp/server`)
|
|
23
|
+
contradict a harness `none`, and file contents carrying OS signals (`process.platform`,
|
|
24
|
+
`'win32'`, `spawn(`, `.ps1`) contradict an os `none` — a 3-cell mapping is exempt, because it
|
|
25
|
+
already faced the axis. `spec_slice_init` scaffolds both fields as TODO so authors meet them;
|
|
26
|
+
the gate refuses the untouched scaffold. The author-facing blocker list (`approvalBlockers`)
|
|
27
|
+
pre-announces everything the act would refuse — gate/act parity held by test.
|
|
28
|
+
- **`holmes-kit approve --refusals [N]` (REQ-563)** — a read-only view of what this machine's gate
|
|
29
|
+
refused lately (newest last), so the operator can find the id a deny message named and decide it.
|
|
30
|
+
- **Per-machine field data (REQ-562)** — test outcomes now record to
|
|
31
|
+
`test-outcomes.<replica>.jsonl` (the provenance replica convention) and merge with the legacy
|
|
32
|
+
file in timestamp order, so RED→GREEN sequences survive multi-machine histories; approval-queue
|
|
33
|
+
events carry a `replica` field (`unknown` for pre-existing events). A repo-local aggregator
|
|
34
|
+
(`src/tools/field-report`, **not shipped in the package**) turns the ledgers into gate-friction,
|
|
35
|
+
queue-health and RED-first metrics.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **The approval queue is a decision inbox again (REQ-563)** — measured: 1,412 unique requests,
|
|
39
|
+
8 decisions, and the two real inbox items buried under 1,404 single-shot gate refusals nobody
|
|
40
|
+
ever meant to decide. Gate refusals (shell and unknown kinds) now land in a **local, per-replica
|
|
41
|
+
refusal log** (`.ax/approvals/refusals.<replica>.jsonl`, added to the managed gitignore — raw
|
|
42
|
+
commands never leave the machine, closing the public-repo exposure where 45% of tracked targets
|
|
43
|
+
carried absolute paths). Decision-seeking kinds (`spec-approve`, `config-write`,
|
|
44
|
+
`autonomy-grant`, `spec-reappraisal`) stay in the tracked queue. `approve <id>` falls back to
|
|
45
|
+
the refusal log, so the out-of-band shell approval path survives end to end (deny → approve →
|
|
46
|
+
allow, verified); re-opening after a decision is count-based (clock-free — a millisecond tie
|
|
47
|
+
was measured losing legitimate retries), and a hold's question now reaches the retry even for
|
|
48
|
+
refusal-log ids.
|
|
49
|
+
- **Fixture pollution of real field data (REQ-563)** — the adapter parity suite ran deny scenarios
|
|
50
|
+
against the repository root: 774 `rm -rf /` requested-events over ten days, the single largest
|
|
51
|
+
item in the first field report. The suite now uses governed tmp roots; measured after: a full
|
|
52
|
+
suite run adds **zero** events to the real ledgers.
|
|
53
|
+
|
|
54
|
+
### Notes
|
|
55
|
+
- Version bump is **minor** with two `gate-behavior` changes aboard; the release classifier
|
|
56
|
+
routes this to HITL by design. The compat gate applies to **newly sealed** A-SPECs only.
|
|
57
|
+
|
|
8
58
|
## [0.14.0] - 2026-09-06
|
|
9
59
|
|
|
10
60
|
Closes the gate's oldest blind spot: it judged *where* a change landed, never *what kind* of change it
|
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
- 🤖 **Autonomous Approval — three layers, always bounded** *(reworked in 0.13.0; foundation 0.8.0)*: for teams that want the agent to self-drive the SDLC, autonomy is a posture the agent holds at two scopes — a **project default** you opt into at `holmes-kit init --autonomy` (persisted as the `HOLMES_AUTONOMOUS_APPROVAL` env in `.mcp.json`), and a **per-session envelope** you grant on the spot with `holmes-kit autonomy on --for 2h` (an expiring marker under the agent-write-protected `.ax/state/`). Under either, the agent seals **low-risk** specs itself (ledgered under an `autonomous:<client>` actor); every **governance-critical, high-risk, or irreversible** decision — `gate-behavior`/breaking A-SPECs, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` — is instead **refused and routed to the out-of-band `holmes-kit approve` queue** for a human, never silently self-approved. The active posture is **surfaced at every session start** so it can't be forgotten *(0.14.0: the hook side now reads the project default out of `.mcp.json` directly, so an `init --autonomy` project sees its banner and escalations without an env round-trip)*, and an agent can never grant it to itself: the env is env-only (blocked like `HOLMES_ROLE`), the session command needs a real TTY or an out-of-band `HOLMES_APPROVAL`, and the marker lives where agents can't write. Off = byte-identical to a fully human-gated project. *(new in 0.10.0)* `HOLMES_ELICIT=off` routes every decision straight to the same queue.
|
|
26
26
|
- 🧭 **Spec-Evolution Trigger** *(new in 0.14.0)*: the gate used to judge only *where* a change lands (file ∈ Files-to-Touch, anchored, approved) — never *what kind* of change it is, so a real architecture swap inside an approved scope passed unreviewed. Now, when a changed in-scope source **newly introduces an external dependency** (a swapped engine, a new runtime), Holmes-Kit raises a **spec-reappraisal**: manual mode warns at the turn boundary, and under autonomy it also files the drift in the out-of-band `holmes-kit approve` queue so the owner sees it — a decided reappraisal is never re-raised for the same drift. Observe-first by design: it never blocks a turn. Detection is TS/JS + Python, string- and comment-safe (prettier multiline imports, CRLF files, docstrings and template literals all judged correctly).
|
|
27
27
|
- 🚢 **Release Autonomy + Docs-Currency Gate** *(new in 0.13.0)*: publishing is irreversible and outward, so `npm publish` stays **human-approved by default** — but a deterministic classifier (`releaseAutonomy`, reusing the same per-spec risk grade) lets a **low-risk** release (patch/minor, every spec auto-grade, autonomy on) self-publish under the ledger, while a **major** bump, any `gate-behavior`/security/architecture spec, or an upstream `REQ`/`H-SPEC` forces HITL. The `holmes-publish` playbook also gains a **docs-currency gate**: before any release it diffs the specs since the last tag and blocks if a user-facing change never reached `README`/`CHANGELOG` — a stale doc is a false claim.
|
|
28
|
+
- 🧭 **Compatibility declaration gate** *(new in 0.15.0)*: Holmes-Kit runs on three agent harnesses (Claude Code, Codex, Antigravity) and three OSes (Windows/macOS/Linux) — and now the **sealing act itself asks whether you considered them**. A new A-SPEC approves only with `harness_impact:` and `os_impact:` declared (`'none: <reason>'` or a full 3-cell mapping with `supported|unavailable|n-a` verdicts); a `none` claim is machine-cross-checked against Files-to-Touch (harness-surface paths, OS-signal file contents), the slice scaffold plants both fields as TODO the gate refuses untouched, and already-sealed specs are untouched — the duty arrives with the next re-approval, exactly like `breaking_change`.
|
|
28
29
|
- 🌐 **English CLI & hook surface** *(new in 0.13.0)*: the operator-facing CLI and hook messages — `doctor` output, the CLI usage/errors, the hook `deny` reasons and ART citations, and the interactive `approve`/`init`/`upgrade`/`semantic-key` prompts — are now English, guarded by a hangul-absence test over the **rendered runtime output** (not just a source scan, which misses `\u`-escaped strings). The MCP tool responses (`spec_create`/`spec_approve`/ledger/review) are still being migrated and are next.
|
|
29
30
|
- 🪧 **Session Banner + Update Notice** *(new in 0.8.0; refresh implemented + made uniform in 0.12.2)*: every session start emits an English intro (version + governance rule + npm URL) to both the human transcript and the agent context (SessionStart hook + MCP `instructions`); when a newer published version is on npm, an install-mode-aware `holmes-kit upgrade` command is appended. The registry refresh (dist-tags query → cached in `~/.holmes/update-check.json`) is detached, TTL-gated, and fail-silent, and now fires from **every harness's MCP-server startup** — not just Claude's SessionStart hook — so Claude / Antigravity / Codex are notified alike. Opts out via `HOLMES_NO_UPDATE_CHECK`/`CI`. Upgrade execution stays your explicit choice (`holmes-kit upgrade`), never a silent auto-install.
|
|
30
31
|
- 🧱 **Deterministic Gate, Hardened** *(new in 0.8.0; further hardened in 0.13.0)*: shell writes are judged at the segment's **effective working directory** (`cd sub && cat > ../src/x.ts` is sealed, legitimate out-of-tree scratch writes are freed); the governing anchor is the **whole set**, not the first match. *(0.13.0)* Two more bypasses are closed: the gate treats a project as **governed when any spec exists** (a fresh project holding only unapproved drafts is no longer an ungoverned free-for-all), and it classifies `cp`/`mv` by their **destination** (a copy/move landing on a source path is sealed even when the source file isn't code). Every gate change ships with two consecutive clean adversarial rounds.
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
- 🐞 **Causal Defect Localization & CPG** *(equalized in 0.5–0.7)*: AST Code Property Graph (CFG/DDG/CDG) & Dataflow Taint reachability across 7 languages (TS/JS, Python, Go, Rust, Java, C/C++, C#) — **42 language×layer cells graded on measured evidence** (11 corpora, 39,344 functions, zero invariant violations; C++ conditional on 67.9% parse coverage, disclosed in the matrix).
|
|
34
35
|
- 📏 **Measured, Not Claimed** *(new in 0.3.x)*: performance is judged against a pre-registered modeled-human band (R 0.67–0.78 / P ≈0.9±). Current official grade: **band entry on recall; division-of-labor precision 0.727 = 81% of the modeled human — reproduced by an independent context-free judge on a fresh blind window.** No superhuman claims until both metrics exceed the band.
|
|
35
36
|
- 🧪 **Self-Healing & Diagnostic Doctor**: Automated integrity checks and self-healing auto-fix remediation (`holmes-kit doctor --fix` & `spec_remediate`) — wiring-handshake checks run on Windows natively as of 0.3.2. As of 0.9.0, doctor also reports holmes-kit's own advertised **MCP schema token cost** (computed live) and warns when `HOLMES_MCP_PROFILE=full` needlessly re-advertises the hook-enforced gate-duplicate tools.
|
|
36
|
-
- 🔔 **Approval UX** *(new in 0.3.1)*: in-session approval dialogs forewarn their 120s deadline and, on expiry, the refusal says exactly where the decision went (`npx holmes-kit approve` out-of-band queue) — no more silently dead dialogs.
|
|
37
|
+
- 🔔 **Approval UX** *(new in 0.3.1; inbox split 0.15.0)*: in-session approval dialogs forewarn their 120s deadline and, on expiry, the refusal says exactly where the decision went (`npx holmes-kit approve` out-of-band queue) — no more silently dead dialogs. Since 0.15.0 the tracked queue holds **decision-seeking requests only**; plain gate refusals live in a local per-machine refusal log (raw commands never leave the machine), browsable with `approve --refusals` and still decidable by id — measured before the split, 1,404 single-shot refusals were burying a 2-item inbox.
|
|
37
38
|
- 🚦 **Push & Server-Side Re-Validation** *(hardened in 0.8.0)*: a local `pre-push` evidence gate (test-run ledger head == push HEAD, green, executed > 0) plus a **server-side CI workflow** that re-runs `npm ci → build → full suite → tarball install probe`, so a `--no-verify` push or a hook-less clone is still caught.
|
|
38
39
|
- 📊 **Automated RTM & Taint Heatmap**: Interactive standalone HTML/SVG report generation (`generateRtmHeatmap`) for spec coverage and security dataflow reachability.
|
|
39
40
|
- 🤖 **CLI-First AI Harness Matrix**: Native process hook gating for Claude Code, Antigravity CLI (AGY), Codex CLI, and Google Antigravity SDK.
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
2a7fb45-mtps37rf
|
|
@@ -83,6 +83,20 @@ export declare function decisionDetail(p: PendingRequest, tail?: string): string
|
|
|
83
83
|
* must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
|
|
84
84
|
* advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* @implements A-SPEC-563.2
|
|
88
|
+
* The read-only refusal view: what THIS machine's gate refused lately, newest last. The raw target
|
|
89
|
+
* appears on the operator's SCREEN only — the refusal log never leaves the machine (A-SPEC-563.1),
|
|
90
|
+
* and this renderer is the reason an operator can find the id the deny message named. Row width
|
|
91
|
+
* follows the decision list's screen-safe discipline.
|
|
92
|
+
*/
|
|
93
|
+
export declare function renderRefusals(refusals: Array<{
|
|
94
|
+
id: string;
|
|
95
|
+
kind: string;
|
|
96
|
+
target: string;
|
|
97
|
+
why: string;
|
|
98
|
+
ts: string;
|
|
99
|
+
}>, limit?: number): string;
|
|
86
100
|
export declare function renderNonTtyHint(state: QueueState): string;
|
|
87
101
|
export interface ApproveIO {
|
|
88
102
|
print: (s: string) => void;
|
|
@@ -42,6 +42,7 @@ exports.subjectCells = subjectCells;
|
|
|
42
42
|
exports.subjectRoom = subjectRoom;
|
|
43
43
|
exports.decisionSubject = decisionSubject;
|
|
44
44
|
exports.decisionDetail = decisionDetail;
|
|
45
|
+
exports.renderRefusals = renderRefusals;
|
|
45
46
|
exports.renderNonTtyHint = renderNonTtyHint;
|
|
46
47
|
exports.runInteractive = runInteractive;
|
|
47
48
|
// @implements A-SPEC-246
|
|
@@ -78,7 +79,44 @@ const MAX_TTL_MINUTES = 7 * 24 * 60;
|
|
|
78
79
|
const DEFAULT_RATIONALE = 'approved via holmes-kit approve';
|
|
79
80
|
const findPending = (root, id) => {
|
|
80
81
|
const state = (0, approval_queue_1.readQueue)(root);
|
|
81
|
-
|
|
82
|
+
const pending = state.pending.find((p) => p.id === id);
|
|
83
|
+
if (pending)
|
|
84
|
+
return { entry: pending, state };
|
|
85
|
+
// @implements A-SPEC-563.2 — the refusal-log fallback. Shell refusals no longer sit in the tracked
|
|
86
|
+
// inbox (A-SPEC-563.1), but the out-of-band approval story must survive: the operator saw the deny
|
|
87
|
+
// in session, the deny named this id, and the RAW target lives in this machine's local refusal log
|
|
88
|
+
// — exactly the "grant is for what the human saw" contract. A decided id never promotes: the
|
|
89
|
+
// granted/denied event in the queue closes the request, same single-use convention as pending.
|
|
90
|
+
try {
|
|
91
|
+
const { readRefusals } = require('../governance/approval-queue');
|
|
92
|
+
let mine = readRefusals(root).filter((r) => r.id === id);
|
|
93
|
+
// A decided id closes the request — UNLESS the gate refused it again afterwards. The fold has
|
|
94
|
+
// the same rule for pending (a later `requested` re-inserts after granted/denied): a human may
|
|
95
|
+
// change their mind, and the retry is what re-opens the question.
|
|
96
|
+
const decided = state.decisions[id];
|
|
97
|
+
if (decided) {
|
|
98
|
+
// Clock-free reopening: the decision recorded HOW MANY occurrences it covered, so a retry is
|
|
99
|
+
// simply everything past that count — millisecond ties (measured flaky under the full suite)
|
|
100
|
+
// cannot lose it. Legacy decisions without the count fall back to the timestamp rule.
|
|
101
|
+
if (typeof decided.covered === 'number')
|
|
102
|
+
mine = mine.slice(decided.covered);
|
|
103
|
+
else
|
|
104
|
+
mine = mine.filter((r) => r.ts > decided.ts);
|
|
105
|
+
}
|
|
106
|
+
if (mine.length > 0) {
|
|
107
|
+
const latest = mine[mine.length - 1];
|
|
108
|
+
return {
|
|
109
|
+
entry: {
|
|
110
|
+
id, kind: latest.kind, target: latest.target, why: latest.why,
|
|
111
|
+
count: mine.length, firstTs: mine[0].ts, lastTs: latest.ts,
|
|
112
|
+
...(latest.replica ? { replica: latest.replica } : {}),
|
|
113
|
+
},
|
|
114
|
+
state,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch { /* a broken refusal log must not break the decision surface */ }
|
|
119
|
+
return { entry: undefined, state };
|
|
82
120
|
};
|
|
83
121
|
/** CLI-side event writer. The CLI runs in the operator's terminal, outside the session gates. */
|
|
84
122
|
/**
|
|
@@ -186,7 +224,7 @@ function grantRequest(root, id, opts) {
|
|
|
186
224
|
// class round-6 closed in findingContext, three files over.
|
|
187
225
|
return { ok: false, reason: `cannot write the grant file:\n ${(0, screen_safe_1.rowField)(e instanceof Error ? e.message : String(e), 76)}` };
|
|
188
226
|
}
|
|
189
|
-
if (!appendEvent(root, { event: 'granted', id, actor: opts.actor, expires })) {
|
|
227
|
+
if (!appendEvent(root, { event: 'granted', id, actor: opts.actor, expires, covered: entry.count })) {
|
|
190
228
|
try {
|
|
191
229
|
fs.rmSync(tmp, { force: true });
|
|
192
230
|
}
|
|
@@ -212,7 +250,7 @@ function denyRequest(root, id, reason, actor) {
|
|
|
212
250
|
// The event IS the decision here (there is no second artefact), so a failed append is a failed
|
|
213
251
|
// decision — round-5: ✓ and exit 0 were printed over a read-only queue while nothing was recorded,
|
|
214
252
|
// and the agent, never seeing the denial, retried forever.
|
|
215
|
-
if (!appendEvent(root, { event: 'denied', id, reason, actor })) {
|
|
253
|
+
if (!appendEvent(root, { event: 'denied', id, reason, actor, covered: entry.count })) {
|
|
216
254
|
return { ok: false, reason: 'could not write the denial record (queue event) — check write permission on .ax/approvals. Nothing was recorded' };
|
|
217
255
|
}
|
|
218
256
|
return { ok: true };
|
|
@@ -366,6 +404,25 @@ function decisionDetail(p, tail = '') {
|
|
|
366
404
|
* must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
|
|
367
405
|
* advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
|
|
368
406
|
*/
|
|
407
|
+
/**
|
|
408
|
+
* @implements A-SPEC-563.2
|
|
409
|
+
* The read-only refusal view: what THIS machine's gate refused lately, newest last. The raw target
|
|
410
|
+
* appears on the operator's SCREEN only — the refusal log never leaves the machine (A-SPEC-563.1),
|
|
411
|
+
* and this renderer is the reason an operator can find the id the deny message named. Row width
|
|
412
|
+
* follows the decision list's screen-safe discipline.
|
|
413
|
+
*/
|
|
414
|
+
function renderRefusals(refusals, limit = 20) {
|
|
415
|
+
if (refusals.length === 0)
|
|
416
|
+
return '◆ no refusals recorded on this machine';
|
|
417
|
+
const recent = refusals.slice(-Math.max(1, limit));
|
|
418
|
+
const lines = [`◆ ${refusals.length} refusal(s) on this machine — showing ${recent.length} (newest last)`, ''];
|
|
419
|
+
for (const r of recent) {
|
|
420
|
+
lines.push(`${(0, screen_safe_1.rowField)(r.id, 20)} ${(0, screen_safe_1.rowField)(r.kind, screen_safe_1.KIND_COLS)} ${(0, screen_safe_1.rowField)(r.target, 40)}`);
|
|
421
|
+
lines.push(` \u2502 ${(0, screen_safe_1.rowField)(r.why, 66)} ${(0, screen_safe_1.rowField)(r.ts, 24)}`);
|
|
422
|
+
}
|
|
423
|
+
lines.push('', `decide one: ${(0, npx_bin_1.npxBin)()} holmes-kit approve <id>`);
|
|
424
|
+
return lines.join('\n');
|
|
425
|
+
}
|
|
369
426
|
function renderNonTtyHint(state) {
|
|
370
427
|
if (state.pending.length === 0)
|
|
371
428
|
return '';
|
|
@@ -36,6 +36,11 @@ exports.IGNORE_LINES = [
|
|
|
36
36
|
// project content — left untracked they show up in `git status` and read as install damage.
|
|
37
37
|
'.ax/state/',
|
|
38
38
|
'*.holmes-bak-*',
|
|
39
|
+
// @implements A-SPEC-563.1 — the per-replica refusal log carries RAW refused commands (absolute
|
|
40
|
+
// paths measured in 45% of them), and the grant contract needs them raw ("the grant is for what
|
|
41
|
+
// the human saw" — round-14, exact match). Raw-and-local is the only shape that serves both ADR-012
|
|
42
|
+
// and approvals: the file never leaves the machine, and `holmes-kit approve` reads it right there.
|
|
43
|
+
'.ax/approvals/refusals*.jsonl',
|
|
39
44
|
// @implements A-SPEC-193 — 하네스 배선물은 이 **설치본의 절대 경로**를 담는다(훅은 hooks.json
|
|
40
45
|
// 이 있는 디렉터리를 cwd 로 돌고, 패키지가 어디 설치됐는지는 기계마다 다르다). 그 경로가
|
|
41
46
|
// VCS 에 들어가면 다른 기계에서 남의 설치를 부르거나 아무것도 부르지 못한다. 팀과 공유하고
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -78,7 +78,7 @@ const KNOWN_FLAGS = {
|
|
|
78
78
|
skills: ['help', 'target'],
|
|
79
79
|
ci: ['help', 'target', 'specs-dir', 'json'],
|
|
80
80
|
serve: ['help', 'target', 'port'],
|
|
81
|
-
approve: ['help', 'target', 'list', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale', 'watch', 'poll-ms'],
|
|
81
|
+
approve: ['help', 'target', 'list', 'refusals', 'grant', 'deny', 'ask', 'reason', 'question', 'ttl', 'rationale', 'watch', 'poll-ms'],
|
|
82
82
|
ledger: ['help', 'target', 'ref', 'dry-run'],
|
|
83
83
|
// @implements A-SPEC-477 — the human's opt-in act for the cloud semantic tier.
|
|
84
84
|
'semantic-key': ['help'],
|
|
@@ -839,6 +839,18 @@ async function main(argv) {
|
|
|
839
839
|
process.stdout.write(renderPending(readQueue(root, queueOpts), listView) + '\n');
|
|
840
840
|
return 0;
|
|
841
841
|
}
|
|
842
|
+
// @implements A-SPEC-563.2 — the read-only refusal view (local log; raw targets stay on-screen).
|
|
843
|
+
if (flags.refusals !== undefined) {
|
|
844
|
+
const { renderRefusals } = require('./approve');
|
|
845
|
+
const { readRefusals } = require('../governance/approval-queue');
|
|
846
|
+
const limit = typeof flags.refusals === 'string' ? Number(flags.refusals) : 20;
|
|
847
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
848
|
+
process.stderr.write(`✗ --refusals expects a positive integer count — received: ${flags.refusals}\n`);
|
|
849
|
+
return 1;
|
|
850
|
+
}
|
|
851
|
+
process.stdout.write(renderRefusals(readRefusals(root), limit) + '\n');
|
|
852
|
+
return 0;
|
|
853
|
+
}
|
|
842
854
|
// @implements A-SPEC-262.2 — the RESIDENT surface. Validate --poll-ms first (so a bad value is
|
|
843
855
|
// named even on a non-TTY), then refuse a non-TTY (a resident interactive loop needs a terminal),
|
|
844
856
|
// then run the loop. SIGINT and readline close both abort it — the operator leaving, either way,
|
|
@@ -18,6 +18,21 @@
|
|
|
18
18
|
* provenance chain already use, so a reader learns no new model.
|
|
19
19
|
*/
|
|
20
20
|
export declare const QUEUE_RELPATH: string;
|
|
21
|
+
export declare const DECISION_KINDS: ReadonlySet<string>;
|
|
22
|
+
export interface RefusalRecord {
|
|
23
|
+
event: 'requested';
|
|
24
|
+
id: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
target: string;
|
|
27
|
+
why: string;
|
|
28
|
+
ts: string;
|
|
29
|
+
replica?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function refusalsFilename(replica: string): string;
|
|
32
|
+
/** Born split: there is no legacy single `refusals.jsonl` — the file did not exist before replicas did. */
|
|
33
|
+
export declare function isRefusalsFilename(name: string): boolean;
|
|
34
|
+
/** All replica refusal logs of this project, merged in ts order (stable). Missing dir → []. */
|
|
35
|
+
export declare function readRefusals(root: string): RefusalRecord[];
|
|
21
36
|
export interface PendingRequest {
|
|
22
37
|
id: string;
|
|
23
38
|
kind: string;
|
|
@@ -28,6 +43,13 @@ export interface PendingRequest {
|
|
|
28
43
|
lastTs: string;
|
|
29
44
|
hold?: boolean;
|
|
30
45
|
question?: string;
|
|
46
|
+
/**
|
|
47
|
+
* @implements A-SPEC-562.2
|
|
48
|
+
* Which machine raised this request (`resolveReplicaId`), or `'unknown'` for the events written
|
|
49
|
+
* before attribution existed. Display and aggregation ONLY — no gate, grant or approval decision
|
|
50
|
+
* reads it, so an absent or wrong value can never change a verdict.
|
|
51
|
+
*/
|
|
52
|
+
replica?: string;
|
|
31
53
|
}
|
|
32
54
|
export interface QueueState {
|
|
33
55
|
pending: PendingRequest[];
|
|
@@ -53,6 +75,17 @@ export interface QueueState {
|
|
|
53
75
|
event: 'granted' | 'denied';
|
|
54
76
|
reason?: string;
|
|
55
77
|
ts: string;
|
|
78
|
+
covered?: number;
|
|
79
|
+
}>;
|
|
80
|
+
/**
|
|
81
|
+
* @implements A-SPEC-563.2
|
|
82
|
+
* Live holds by id, INDEPENDENT of pending: a shell refusal is no longer a pending entry
|
|
83
|
+
* (A-SPEC-563.1), but the operator's hold question must still reach the agent's next retry.
|
|
84
|
+
* A later decision clears the hold — an answered question is not still being asked.
|
|
85
|
+
*/
|
|
86
|
+
holds: Record<string, {
|
|
87
|
+
question?: string;
|
|
88
|
+
ts: string;
|
|
56
89
|
}>;
|
|
57
90
|
}
|
|
58
91
|
/**
|
|
@@ -33,7 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.QUEUE_RELPATH = void 0;
|
|
36
|
+
exports.DECISION_KINDS = exports.QUEUE_RELPATH = void 0;
|
|
37
|
+
exports.refusalsFilename = refusalsFilename;
|
|
38
|
+
exports.isRefusalsFilename = isRefusalsFilename;
|
|
39
|
+
exports.readRefusals = readRefusals;
|
|
37
40
|
exports.approvalRequestId = approvalRequestId;
|
|
38
41
|
exports.foldQueue = foldQueue;
|
|
39
42
|
exports.enqueueApprovalRequest = enqueueApprovalRequest;
|
|
@@ -45,6 +48,7 @@ const npx_bin_1 = require("../project/npx-bin");
|
|
|
45
48
|
const fs = __importStar(require("node:fs"));
|
|
46
49
|
const path = __importStar(require("node:path"));
|
|
47
50
|
const screen_safe_1 = require("../cli/screen-safe");
|
|
51
|
+
const replica_id_1 = require("./replica-id");
|
|
48
52
|
/**
|
|
49
53
|
* The approval request queue — the review list a human batches decisions over.
|
|
50
54
|
*
|
|
@@ -65,6 +69,58 @@ const screen_safe_1 = require("../cli/screen-safe");
|
|
|
65
69
|
* provenance chain already use, so a reader learns no new model.
|
|
66
70
|
*/
|
|
67
71
|
exports.QUEUE_RELPATH = path.join('.ax', 'approvals', 'queue.jsonl');
|
|
72
|
+
// @implements A-SPEC-563.1 — the tracked queue is a DECISION INBOX, not a refusal log. Measured
|
|
73
|
+
// (field report, 2026-09-06): 1,412 unique requests vs 8 decisions — 99.8% of the backlog was
|
|
74
|
+
// single-shot gate refusals nobody ever meant to decide, burying the real inbox (spec-approve 9,
|
|
75
|
+
// config-write 1) and accumulating raw shell commands in a tracked file. Only these kinds seek a
|
|
76
|
+
// decision; everything else routes to the local per-replica refusal log below. Wired RED-first.
|
|
77
|
+
exports.DECISION_KINDS = new Set([
|
|
78
|
+
'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal',
|
|
79
|
+
]);
|
|
80
|
+
const REFUSALS_FILE_RE = /^refusals\.([^.]+)\.jsonl$/;
|
|
81
|
+
function refusalsFilename(replica) {
|
|
82
|
+
return `refusals.${replica}.jsonl`;
|
|
83
|
+
}
|
|
84
|
+
/** Born split: there is no legacy single `refusals.jsonl` — the file did not exist before replicas did. */
|
|
85
|
+
function isRefusalsFilename(name) {
|
|
86
|
+
return REFUSALS_FILE_RE.test(name);
|
|
87
|
+
}
|
|
88
|
+
/** All replica refusal logs of this project, merged in ts order (stable). Missing dir → []. */
|
|
89
|
+
function readRefusals(root) {
|
|
90
|
+
const dir = path.join(root, '.ax', 'approvals');
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = fs.readdirSync(dir).filter(isRefusalsFilename).sort();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
let i = 0;
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
let text;
|
|
102
|
+
try {
|
|
103
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const line of text.split('\n')) {
|
|
109
|
+
const s = line.trim();
|
|
110
|
+
if (!s)
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
const r = JSON.parse(s);
|
|
114
|
+
if (r && typeof r === 'object' && r.event === 'requested' && typeof r.id === 'string'
|
|
115
|
+
&& typeof r.kind === 'string' && typeof r.target === 'string' && typeof r.ts === 'string') {
|
|
116
|
+
out.push({ r: r, i: i++ });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch { /* a corrupt line never breaks the read */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out.sort((a, b) => (a.r.ts < b.r.ts ? -1 : a.r.ts > b.r.ts ? 1 : a.i - b.i)).map((e) => e.r);
|
|
123
|
+
}
|
|
68
124
|
/**
|
|
69
125
|
* Deterministic request id from the action's identity.
|
|
70
126
|
*
|
|
@@ -87,6 +143,7 @@ function approvalRequestId(kind, target) {
|
|
|
87
143
|
function foldQueue(lines, opts) {
|
|
88
144
|
const pending = new Map();
|
|
89
145
|
const decisions = {};
|
|
146
|
+
const holds = {};
|
|
90
147
|
let malformedLines = 0;
|
|
91
148
|
for (const raw of Array.isArray(lines) ? lines : []) {
|
|
92
149
|
const line = String(raw ?? '').trim();
|
|
@@ -127,6 +184,9 @@ function foldQueue(lines, opts) {
|
|
|
127
184
|
count: 1,
|
|
128
185
|
firstTs: ts,
|
|
129
186
|
lastTs: ts,
|
|
187
|
+
// @implements A-SPEC-562.2 — events written before attribution existed have no field;
|
|
188
|
+
// `unknown` names that honestly instead of pretending they came from this machine.
|
|
189
|
+
replica: typeof e.replica === 'string' && e.replica !== '' ? e.replica : 'unknown',
|
|
130
190
|
});
|
|
131
191
|
}
|
|
132
192
|
break;
|
|
@@ -135,10 +195,12 @@ function foldQueue(lines, opts) {
|
|
|
135
195
|
case 'denied':
|
|
136
196
|
if (id) {
|
|
137
197
|
pending.delete(id);
|
|
198
|
+
delete holds[id]; // @implements A-SPEC-563.2 — a decision answers the question
|
|
138
199
|
decisions[id] = {
|
|
139
200
|
event: e.event,
|
|
140
201
|
...(typeof e.reason === 'string' ? { reason: e.reason } : {}),
|
|
141
202
|
ts: typeof e.ts === 'string' ? e.ts : '',
|
|
203
|
+
...(typeof e.covered === 'number' ? { covered: e.covered } : {}),
|
|
142
204
|
};
|
|
143
205
|
}
|
|
144
206
|
break;
|
|
@@ -149,6 +211,10 @@ function foldQueue(lines, opts) {
|
|
|
149
211
|
if (typeof e.question === 'string')
|
|
150
212
|
entry.question = e.question;
|
|
151
213
|
}
|
|
214
|
+
// @implements A-SPEC-563.2 — record the hold by id too: a refusal-log request has no pending
|
|
215
|
+
// entry to hang the question on, and the question must still reach the retry.
|
|
216
|
+
if (id)
|
|
217
|
+
holds[id] = { ...(typeof e.question === 'string' ? { question: e.question } : {}), ts: typeof e.ts === 'string' ? e.ts : '' };
|
|
152
218
|
break;
|
|
153
219
|
}
|
|
154
220
|
default:
|
|
@@ -158,7 +224,7 @@ function foldQueue(lines, opts) {
|
|
|
158
224
|
}
|
|
159
225
|
const all = [...pending.values()];
|
|
160
226
|
if (!opts)
|
|
161
|
-
return { pending: all, expired: [], malformedLines, decisions };
|
|
227
|
+
return { pending: all, expired: [], malformedLines, decisions, holds };
|
|
162
228
|
// @implements A-SPEC-507.1 — strict excess only, and an unparseable lastTs stays ACTIVE: a
|
|
163
229
|
// clockless entry must never be silently hidden by a clock it does not carry.
|
|
164
230
|
const expired = [];
|
|
@@ -167,7 +233,7 @@ function foldQueue(lines, opts) {
|
|
|
167
233
|
const last = Date.parse(p.lastTs);
|
|
168
234
|
(Number.isFinite(last) && last + opts.ttlMs < opts.now ? expired : active).push(p);
|
|
169
235
|
}
|
|
170
|
-
return { pending: active, expired, malformedLines, decisions };
|
|
236
|
+
return { pending: active, expired, malformedLines, decisions, holds };
|
|
171
237
|
}
|
|
172
238
|
/**
|
|
173
239
|
* Append a request event. Fire-and-forget.
|
|
@@ -186,8 +252,27 @@ function enqueueApprovalRequest(root, req) {
|
|
|
186
252
|
// An ungoverned directory gets no queue and no hint; it is not part of the system.
|
|
187
253
|
if (!fs.existsSync(path.join(root, '.ax')))
|
|
188
254
|
return false;
|
|
189
|
-
|
|
255
|
+
// @implements A-SPEC-563.1 — kind routing: only decision-seeking kinds enter the tracked inbox;
|
|
256
|
+
// a gate refusal (shell, or any future kind — fail-safe toward a clean inbox) goes to this
|
|
257
|
+
// machine's LOCAL refusal log, raw target and all, where the approve fallback can still find it.
|
|
258
|
+
let refusalReplica = 'local';
|
|
259
|
+
try {
|
|
260
|
+
refusalReplica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
|
|
261
|
+
}
|
|
262
|
+
catch { /* keep the fallback */ }
|
|
263
|
+
const file = exports.DECISION_KINDS.has(req.kind)
|
|
264
|
+
? path.join(root, exports.QUEUE_RELPATH)
|
|
265
|
+
: path.join(root, '.ax', 'approvals', refusalsFilename(refusalReplica));
|
|
190
266
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
267
|
+
// @implements A-SPEC-562.2 — stamp the machine. Best-effort: a resolver failure omits the field
|
|
268
|
+
// rather than losing the request, because the queue is the channel a refusal depends on.
|
|
269
|
+
let replica;
|
|
270
|
+
try {
|
|
271
|
+
replica = (0, replica_id_1.resolveReplicaId)(root);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
replica = undefined;
|
|
275
|
+
}
|
|
191
276
|
const event = {
|
|
192
277
|
event: 'requested',
|
|
193
278
|
id: approvalRequestId(req.kind, req.target),
|
|
@@ -195,6 +280,7 @@ function enqueueApprovalRequest(root, req) {
|
|
|
195
280
|
target: req.target,
|
|
196
281
|
why: req.why,
|
|
197
282
|
ts: new Date().toISOString(),
|
|
283
|
+
...(replica !== undefined && replica !== '' ? { replica } : {}),
|
|
198
284
|
};
|
|
199
285
|
// TYPE BEFORE WRITE, for the same reason as the read (round-7): `appendFileSync` on a FIFO with
|
|
200
286
|
// no reader blocks in open(2) forever. Guarding only the reader left the GATE wedged — measured:
|
|
@@ -244,12 +330,12 @@ function readQueue(root, opts) {
|
|
|
244
330
|
// hook, the stop hook and the MCP handlers, not just the CLI. `approve-context.ts` has guarded
|
|
245
331
|
// this since the 2026-08-24 hang; the queue reader, which far more code depends on, did not.
|
|
246
332
|
if (!fs.lstatSync(file).isFile())
|
|
247
|
-
return { pending: [], expired: [], malformedLines: 1, decisions: {} };
|
|
333
|
+
return { pending: [], expired: [], malformedLines: 1, decisions: {}, holds: {} };
|
|
248
334
|
const raw = fs.readFileSync(file, 'utf8');
|
|
249
335
|
return foldQueue(raw.split('\n'), opts);
|
|
250
336
|
}
|
|
251
337
|
catch {
|
|
252
|
-
return { pending: [], expired: [], malformedLines: 0, decisions: {} };
|
|
338
|
+
return { pending: [], expired: [], malformedLines: 0, decisions: {}, holds: {} };
|
|
253
339
|
}
|
|
254
340
|
}
|
|
255
341
|
/**
|
|
@@ -271,7 +357,8 @@ function queueHint(root, req) {
|
|
|
271
357
|
// text is read twice: printed in the operator's terminal AND carried in the agent's refusal. Raw,
|
|
272
358
|
// a held request could erase the screen and paint a line claiming the operator had approved. The
|
|
273
359
|
// decision surface's rule applies here too — line structure belongs to the template.
|
|
274
|
-
const held = state.pending.find((p) => p.id === id && p.hold)
|
|
360
|
+
const held = state.pending.find((p) => p.id === id && p.hold)
|
|
361
|
+
?? (state.holds[id] ? { question: state.holds[id].question } : undefined); // @implements A-SPEC-563.2
|
|
275
362
|
// Round-5: folded to terminal rows. This text is read in a terminal by the operator as well, and
|
|
276
363
|
// an unfolded hint carrying 300 columns of agent-authored question wraps unpredictably around
|
|
277
364
|
// whatever the gate printed before it.
|
|
@@ -189,6 +189,7 @@ const approval_grants_1 = require("../governance/approval-grants");
|
|
|
189
189
|
const spec_digest_1 = require("../spec/spec-digest");
|
|
190
190
|
const spec_store_2 = require("../spec/spec-store");
|
|
191
191
|
const breaking_change_1 = require("../spec/breaking-change");
|
|
192
|
+
const compat_impact_1 = require("../spec/compat-impact");
|
|
192
193
|
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
193
194
|
const approval_status_1 = require("../spec/approval-status");
|
|
194
195
|
const ledger_timeline_1 = require("../governance/ledger-timeline");
|
|
@@ -1560,6 +1561,22 @@ function makeRawHandlers(store, opts) {
|
|
|
1560
1561
|
const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
|
|
1561
1562
|
if (breakingIssue)
|
|
1562
1563
|
return { ok: false, reason: breakingIssue };
|
|
1564
|
+
// @implements A-SPEC-565.1 — the compat declaration duty rides the SAME act (REQ-565): sealing
|
|
1565
|
+
// is when "did you consider the three harnesses and the three OSes" is due, and act-time is
|
|
1566
|
+
// what keeps 512 already-approved specs out of retroactive violation (the 38-violation incident
|
|
1567
|
+
// above). The bound reader feeds the OS cross-check from this root's working tree.
|
|
1568
|
+
const compatIssue = (0, compat_impact_1.checkCompatDeclared)(candidate, {
|
|
1569
|
+
// No root → no working tree to read: the OS cross-check skips file-by-file (fail-open),
|
|
1570
|
+
// while the declaration syntax itself is still enforced — the duty never depends on `root`.
|
|
1571
|
+
readFile: (rel) => { try {
|
|
1572
|
+
return a.root ? fs.readFileSync(path.join(a.root, rel), 'utf8') : null;
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
return null;
|
|
1576
|
+
} },
|
|
1577
|
+
});
|
|
1578
|
+
if (compatIssue)
|
|
1579
|
+
return { ok: false, reason: compatIssue };
|
|
1563
1580
|
// @implements A-SPEC-182
|
|
1564
1581
|
// A document whose prose is still the generator's placeholder must not be sealed. Measured
|
|
1565
1582
|
// 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
|
|
@@ -3347,6 +3364,8 @@ independent_test: true
|
|
|
3347
3364
|
depends_on:
|
|
3348
3365
|
- ${hspecId}
|
|
3349
3366
|
breaking_change: 'none'
|
|
3367
|
+
harness_impact: 'none: TODO — 3하네스(claude/codex/agy) 영향 검토 후 기술'
|
|
3368
|
+
os_impact: 'none: TODO — 3OS(windows/mac/linux) 영향 검토 후 기술'
|
|
3350
3369
|
---
|
|
3351
3370
|
|
|
3352
3371
|
## Objective
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { TestOutcome } from './test-runner';
|
|
2
2
|
export declare const OUTCOMES_FILE: string;
|
|
3
|
+
export declare function outcomesFilename(replica: string): string;
|
|
4
|
+
export declare function isOutcomesFilename(name: string): boolean;
|
|
3
5
|
/**
|
|
4
6
|
* A durable, append-only record of a per-A-SPEC test outcome (REQ-534 RED-first evidence). Unlike
|
|
5
7
|
* `test-evidence.json` (overwritten each run), the SEQUENCE matters here — a red-assertion followed
|
|
@@ -14,9 +16,22 @@ export interface OutcomeRecord {
|
|
|
14
16
|
head: string;
|
|
15
17
|
testFileDigest?: string;
|
|
16
18
|
}
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Append outcome records as JSONL lines. Fail-open (recording must never break a run).
|
|
21
|
+
*
|
|
22
|
+
* @implements A-SPEC-562.1 — writes to THIS machine's chain (`test-outcomes.<replica>.jsonl`), never
|
|
23
|
+
* to the shared legacy file, so two machines appending in parallel produce two files git merges
|
|
24
|
+
* without a conflict. The call site is unchanged; only the destination moved.
|
|
25
|
+
*/
|
|
18
26
|
export declare function appendOutcomes(root: string, records: OutcomeRecord[]): boolean;
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers).
|
|
29
|
+
*
|
|
30
|
+
* @implements A-SPEC-562.1 — reads the legacy file AND every replica chain, merged by `ts`. The sort
|
|
31
|
+
* is what keeps ART-8 honest after a merge: the article reads a red-assertion FOLLOWED BY a green, and
|
|
32
|
+
* with two machines that pair can straddle two files. A stable sort keeps same-timestamp records in
|
|
33
|
+
* read order rather than inventing an ordering between them.
|
|
34
|
+
*/
|
|
20
35
|
export declare function readOutcomes(root: string): OutcomeRecord[];
|
|
21
36
|
/**
|
|
22
37
|
* @implements A-SPEC-534.5
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.OUTCOMES_FILE = void 0;
|
|
37
|
+
exports.outcomesFilename = outcomesFilename;
|
|
38
|
+
exports.isOutcomesFilename = isOutcomesFilename;
|
|
37
39
|
exports.appendOutcomes = appendOutcomes;
|
|
38
40
|
exports.readOutcomes = readOutcomes;
|
|
39
41
|
exports.buildOutcomeRecords = buildOutcomeRecords;
|
|
@@ -41,11 +43,30 @@ exports.groupOutcomesByAspec = groupOutcomesByAspec;
|
|
|
41
43
|
// @implements A-SPEC-534.3
|
|
42
44
|
const fs = __importStar(require("node:fs"));
|
|
43
45
|
const path = __importStar(require("node:path"));
|
|
46
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
44
47
|
exports.OUTCOMES_FILE = path.join('.ax', 'ledger', 'test-outcomes.jsonl');
|
|
45
|
-
|
|
48
|
+
// @implements A-SPEC-562.1 — outcomes join the ledger's REPLICA convention (A-SPEC-148): one file per
|
|
49
|
+
// machine, so two machines appending never conflict on merge and a colleague's clone carries both
|
|
50
|
+
// histories. The shape mirrors `isLedgerFilename` exactly — legacy single file OR a dot-free segment —
|
|
51
|
+
// because two rules disagreeing about the same file is worse than either being wrong. Wired below.
|
|
52
|
+
const OUTCOMES_LEGACY = 'test-outcomes.jsonl';
|
|
53
|
+
const OUTCOMES_REPLICA_FILE = /^test-outcomes\.([^.]+)\.jsonl$/;
|
|
54
|
+
function outcomesFilename(replica) {
|
|
55
|
+
return `test-outcomes.${replica}.jsonl`;
|
|
56
|
+
}
|
|
57
|
+
function isOutcomesFilename(name) {
|
|
58
|
+
return name === OUTCOMES_LEGACY || OUTCOMES_REPLICA_FILE.test(name);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Append outcome records as JSONL lines. Fail-open (recording must never break a run).
|
|
62
|
+
*
|
|
63
|
+
* @implements A-SPEC-562.1 — writes to THIS machine's chain (`test-outcomes.<replica>.jsonl`), never
|
|
64
|
+
* to the shared legacy file, so two machines appending in parallel produce two files git merges
|
|
65
|
+
* without a conflict. The call site is unchanged; only the destination moved.
|
|
66
|
+
*/
|
|
46
67
|
function appendOutcomes(root, records) {
|
|
47
68
|
try {
|
|
48
|
-
const file = path.join(root,
|
|
69
|
+
const file = path.join(root, '.ax', 'ledger', outcomesFilename((0, replica_id_1.resolveReplicaId)(root)));
|
|
49
70
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
71
|
fs.appendFileSync(file, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
51
72
|
return true;
|
|
@@ -54,30 +75,48 @@ function appendOutcomes(root, records) {
|
|
|
54
75
|
return false;
|
|
55
76
|
}
|
|
56
77
|
}
|
|
57
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers).
|
|
80
|
+
*
|
|
81
|
+
* @implements A-SPEC-562.1 — reads the legacy file AND every replica chain, merged by `ts`. The sort
|
|
82
|
+
* is what keeps ART-8 honest after a merge: the article reads a red-assertion FOLLOWED BY a green, and
|
|
83
|
+
* with two machines that pair can straddle two files. A stable sort keeps same-timestamp records in
|
|
84
|
+
* read order rather than inventing an ordering between them.
|
|
85
|
+
*/
|
|
58
86
|
function readOutcomes(root) {
|
|
59
|
-
|
|
87
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
88
|
+
let names;
|
|
60
89
|
try {
|
|
61
|
-
|
|
90
|
+
names = fs.readdirSync(dir).filter(isOutcomesFilename).sort();
|
|
62
91
|
}
|
|
63
92
|
catch {
|
|
64
93
|
return [];
|
|
65
94
|
}
|
|
66
95
|
const out = [];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
continue;
|
|
96
|
+
let i = 0;
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
let text;
|
|
71
99
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const line of text.split('\n')) {
|
|
106
|
+
const s = line.trim();
|
|
107
|
+
if (!s)
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const r = JSON.parse(s);
|
|
111
|
+
if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
|
|
112
|
+
&& typeof r.ts === 'string' && typeof r.head === 'string') {
|
|
113
|
+
out.push({ r: r, i: i++ });
|
|
114
|
+
}
|
|
76
115
|
}
|
|
116
|
+
catch { /* skip a corrupt line rather than fail the whole read */ }
|
|
77
117
|
}
|
|
78
|
-
catch { /* skip a corrupt line rather than fail the whole read */ }
|
|
79
118
|
}
|
|
80
|
-
return out;
|
|
119
|
+
return out.sort((a, b) => (a.r.ts < b.r.ts ? -1 : a.r.ts > b.r.ts ? 1 : a.i - b.i)).map((e) => e.r);
|
|
81
120
|
}
|
|
82
121
|
/**
|
|
83
122
|
* @implements A-SPEC-534.5
|
|
@@ -10,6 +10,7 @@ exports.unactionableCriteriaBlocker = unactionableCriteriaBlocker;
|
|
|
10
10
|
const acceptance_quality_1 = require("./acceptance-quality");
|
|
11
11
|
const validator_1 = require("./validator");
|
|
12
12
|
const breaking_change_1 = require("./breaking-change");
|
|
13
|
+
const compat_impact_1 = require("./compat-impact");
|
|
13
14
|
const spec_digest_1 = require("./spec-digest");
|
|
14
15
|
const spec_types_1 = require("./spec-types");
|
|
15
16
|
const draft_1 = require("../reverse/draft");
|
|
@@ -204,6 +205,13 @@ function approvalBlockers(spec, resolve) {
|
|
|
204
205
|
const breaking = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
|
|
205
206
|
if (breaking)
|
|
206
207
|
out.push(breaking);
|
|
208
|
+
// @implements A-SPEC-565.1 — the compat duty pre-announced where breaking_change is: whatever the
|
|
209
|
+
// act refuses with, the gate must already have told the author (A-SPEC-182's parity). No file
|
|
210
|
+
// reader here — the gate context has no root — so the OS *content* cross-check stays act-only;
|
|
211
|
+
// the declaration syntax and the FtT harness-surface contradiction are fully pre-announced.
|
|
212
|
+
const compat = (0, compat_impact_1.checkCompatDeclared)(candidate);
|
|
213
|
+
if (compat)
|
|
214
|
+
out.push(compat);
|
|
207
215
|
const stubs = placeholderSections(spec);
|
|
208
216
|
if (stubs.length > 0) {
|
|
209
217
|
out.push((0, exports.placeholderMessage)(stubs));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Spec } from './spec-parser';
|
|
2
|
+
/**
|
|
3
|
+
* REQ-565's enforcement device: the obligation to declare HARNESS (claude/codex/agy) and OS
|
|
4
|
+
* (windows/mac/linux) impact lives on the ACT of approval — the exact shape ADR-013 gave
|
|
5
|
+
* `breaking_change`, and for the same measured reason (a static `requiredFields` predicate turned
|
|
6
|
+
* 38 already-approved specs into ART-3 violations and bricked the harness; act-time converges
|
|
7
|
+
* instead: any spec that changes re-approves and acquires the fields, a spec that never changes
|
|
8
|
+
* can introduce no new incompatibility).
|
|
9
|
+
*
|
|
10
|
+
* WHY a gate and not a habit, measured twice in one day: an observability design came out
|
|
11
|
+
* Claude-biased (Stop hook + transcript_path is a Claude-only channel — the owner caught it), and
|
|
12
|
+
* the Stop hook's own header records that voluntarily-invoked discipline fired 0/143 times. Memory
|
|
13
|
+
* is rationale storage; control is a gate.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS DOES NOT DO: verify the declarations are TRUE. Truth belongs to the layers that measure
|
|
16
|
+
* it — adapter parity (A-SPEC-336~338), doctor's wiring checks, on-device E2E. This gate makes
|
|
17
|
+
* skipping the thought impossible and makes a false declaration an auditable record.
|
|
18
|
+
*/
|
|
19
|
+
export declare const HARNESS_CELLS: readonly ["claude", "codex", "agy"];
|
|
20
|
+
export declare const OS_CELLS: readonly ["windows", "mac", "linux"];
|
|
21
|
+
export declare const CELL_VERDICTS: readonly ["supported", "unavailable", "n-a"];
|
|
22
|
+
export interface CompatCheckOpts {
|
|
23
|
+
/** Repo-relative reader for the OS cross-check; null = file absent (skip, fail-open). */
|
|
24
|
+
readFile?: (rel: string) => string | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function checkCompatDeclared(spec: Spec, opts?: CompatCheckOpts): string | null;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CELL_VERDICTS = exports.OS_CELLS = exports.HARNESS_CELLS = void 0;
|
|
4
|
+
exports.checkCompatDeclared = checkCompatDeclared;
|
|
5
|
+
/**
|
|
6
|
+
* REQ-565's enforcement device: the obligation to declare HARNESS (claude/codex/agy) and OS
|
|
7
|
+
* (windows/mac/linux) impact lives on the ACT of approval — the exact shape ADR-013 gave
|
|
8
|
+
* `breaking_change`, and for the same measured reason (a static `requiredFields` predicate turned
|
|
9
|
+
* 38 already-approved specs into ART-3 violations and bricked the harness; act-time converges
|
|
10
|
+
* instead: any spec that changes re-approves and acquires the fields, a spec that never changes
|
|
11
|
+
* can introduce no new incompatibility).
|
|
12
|
+
*
|
|
13
|
+
* WHY a gate and not a habit, measured twice in one day: an observability design came out
|
|
14
|
+
* Claude-biased (Stop hook + transcript_path is a Claude-only channel — the owner caught it), and
|
|
15
|
+
* the Stop hook's own header records that voluntarily-invoked discipline fired 0/143 times. Memory
|
|
16
|
+
* is rationale storage; control is a gate.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS DOES NOT DO: verify the declarations are TRUE. Truth belongs to the layers that measure
|
|
19
|
+
* it — adapter parity (A-SPEC-336~338), doctor's wiring checks, on-device E2E. This gate makes
|
|
20
|
+
* skipping the thought impossible and makes a false declaration an auditable record.
|
|
21
|
+
*/
|
|
22
|
+
exports.HARNESS_CELLS = ['claude', 'codex', 'agy'];
|
|
23
|
+
exports.OS_CELLS = ['windows', 'mac', 'linux'];
|
|
24
|
+
exports.CELL_VERDICTS = ['supported', 'unavailable', 'n-a'];
|
|
25
|
+
/** Directory-or-file prefixes that ARE the harness surface. A trailing '/' is a directory boundary;
|
|
26
|
+
* without it the rule names a file stem (`cli/init` catches `cli/init.ts`), and the boundary test
|
|
27
|
+
* below keeps `hooks-util.ts` from matching `hooks/`. */
|
|
28
|
+
const HARNESS_SURFACES = [
|
|
29
|
+
'src/holmes/hooks/',
|
|
30
|
+
'src/holmes/cli/init',
|
|
31
|
+
'src/holmes/cli/agents',
|
|
32
|
+
'src/holmes/cli/interactive-prompt',
|
|
33
|
+
'src/holmes/mcp/server',
|
|
34
|
+
];
|
|
35
|
+
/** Conservative OS-sensitivity signals — content properties, not directories. Grown by measurement,
|
|
36
|
+
* never by guess (the goal records this as an explicitly open list). */
|
|
37
|
+
const OS_SIGNALS = ['process.platform', "'win32'", '"win32"', 'spawn(', 'spawnSync(', 'execFileSync(', '.ps1'];
|
|
38
|
+
const FORMAT_HINT = (field, cells) => `${field} 형식: 'none: <실이유>' 또는 3셀 매핑 { ${cells.map((c) => `${c}: '<supported|unavailable|n-a>: <근거>'`).join(', ')} }`;
|
|
39
|
+
/** A reason that says nothing is not a reason: empty, or still carrying the scaffold's TODO/TBD. */
|
|
40
|
+
const emptyReason = (reason) => reason.trim() === '' || /\bTODO\b|\bTBD\b/i.test(reason);
|
|
41
|
+
/**
|
|
42
|
+
* Validate one axis' declaration. Returns the refusal (naming the field) or the parsed shape:
|
|
43
|
+
* `none` (an irrelevance claim — cross-checkable) or `cells` (the axis was faced — exempt).
|
|
44
|
+
*/
|
|
45
|
+
function parseAxis(field, raw, cells) {
|
|
46
|
+
const err = (why) => ({ kind: 'error', message: `${field} ${why} — ${FORMAT_HINT(field, cells)}` });
|
|
47
|
+
if (raw === undefined || raw === null)
|
|
48
|
+
return err('선언이 없습니다 (REQ-565: 호환 고려는 봉인 의무)');
|
|
49
|
+
if (typeof raw === 'string') {
|
|
50
|
+
const i = raw.indexOf(':');
|
|
51
|
+
const grade = (i === -1 ? raw : raw.slice(0, i)).trim();
|
|
52
|
+
const reason = i === -1 ? '' : raw.slice(i + 1).trim();
|
|
53
|
+
if (grade !== 'none')
|
|
54
|
+
return err(`알 수 없는 문자열 선언 '${grade}'`);
|
|
55
|
+
if (emptyReason(reason))
|
|
56
|
+
return err('의 none 사유가 비었거나 placeholder(TODO/TBD)입니다');
|
|
57
|
+
return { kind: 'none' };
|
|
58
|
+
}
|
|
59
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
60
|
+
const m = raw;
|
|
61
|
+
const keys = Object.keys(m);
|
|
62
|
+
for (const c of cells)
|
|
63
|
+
if (!(c in m))
|
|
64
|
+
return err(`매핑에 '${c}' 셀이 없습니다 (3셀 전부 필수)`);
|
|
65
|
+
for (const k of keys)
|
|
66
|
+
if (!cells.includes(k))
|
|
67
|
+
return err(`매핑에 알 수 없는 셀 '${k}'`);
|
|
68
|
+
for (const c of cells) {
|
|
69
|
+
const v = m[c];
|
|
70
|
+
if (typeof v !== 'string')
|
|
71
|
+
return err(`의 '${c}' 셀이 문자열이 아닙니다`);
|
|
72
|
+
const i = v.indexOf(':');
|
|
73
|
+
const verdict = (i === -1 ? v : v.slice(0, i)).trim();
|
|
74
|
+
const reason = i === -1 ? '' : v.slice(i + 1).trim();
|
|
75
|
+
if (!exports.CELL_VERDICTS.includes(verdict)) {
|
|
76
|
+
return err(`의 '${c}' 셀 어휘 '${verdict}' 는 supported|unavailable|n-a 가 아닙니다`);
|
|
77
|
+
}
|
|
78
|
+
if (emptyReason(reason))
|
|
79
|
+
return err(`의 '${c}' 셀 근거가 비었거나 placeholder 입니다`);
|
|
80
|
+
}
|
|
81
|
+
return { kind: 'cells' };
|
|
82
|
+
}
|
|
83
|
+
return err('의 형태가 문자열도 매핑도 아닙니다');
|
|
84
|
+
}
|
|
85
|
+
/** `src/…` path tokens out of the Files to Touch section — backticks, bullets and commas tolerated.
|
|
86
|
+
* Backslashes normalize to `/` FIRST (adversarial round-1): a Windows author legitimately writes
|
|
87
|
+
* `src\holmes\hooks\stop.ts`, and un-normalized it walked straight past the surface prefixes —
|
|
88
|
+
* an OS-compat gate defeated by an OS path convention would be its own counterexample. */
|
|
89
|
+
function filesToTouch(spec) {
|
|
90
|
+
const body = (spec.sections?.['Files to Touch'] ?? '')
|
|
91
|
+
.replace(/\\/g, '/')
|
|
92
|
+
.replace(/\/{2,}/g, '/'); // round-2: `src\\holmes` normalized to `src//holmes` and slid past the prefix
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const m of body.matchAll(/src\/[\w./-]+/g))
|
|
95
|
+
out.push(m[0]);
|
|
96
|
+
return [...new Set(out)];
|
|
97
|
+
}
|
|
98
|
+
function checkCompatDeclared(spec, opts) {
|
|
99
|
+
if (spec.type !== 'A-SPEC')
|
|
100
|
+
return null;
|
|
101
|
+
const fm = (spec.frontmatter ?? {});
|
|
102
|
+
const harness = parseAxis('harness_impact', fm.harness_impact, exports.HARNESS_CELLS);
|
|
103
|
+
if (harness.kind === 'error')
|
|
104
|
+
return harness.message;
|
|
105
|
+
const os = parseAxis('os_impact', fm.os_impact, exports.OS_CELLS);
|
|
106
|
+
if (os.kind === 'error')
|
|
107
|
+
return os.message;
|
|
108
|
+
// Cross-checks apply ONLY to `none` — a 3-cell mapping already faced the axis, and re-litigating
|
|
109
|
+
// it here would punish exactly the declaration this gate exists to elicit (C1's lesson is that a
|
|
110
|
+
// DECLARATION must not be the only wall; an irrelevance CLAIM is what the machine can contradict).
|
|
111
|
+
const ftt = filesToTouch(spec);
|
|
112
|
+
if (harness.kind === 'none') {
|
|
113
|
+
for (const p of ftt) {
|
|
114
|
+
const hit = HARNESS_SURFACES.find((s) => (s.endsWith('/') ? p.startsWith(s) : p === s || p.startsWith(`${s}.`) || p.startsWith(`${s}/`)));
|
|
115
|
+
if (hit) {
|
|
116
|
+
return `harness_impact 는 none 인데 Files to Touch 의 '${p}' 는 하네스 표면(${hit})입니다 — 모순. 3셀 매핑으로 각 하네스의 영향을 기술하십시오.`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (os.kind === 'none' && opts?.readFile) {
|
|
121
|
+
for (const p of ftt) {
|
|
122
|
+
// round-2: an injected reader that THROWS (permissions, FIFO, anything) must degrade to
|
|
123
|
+
// "unreadable = skip", never crash the approval act — this check is a gate, not a hostage.
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
text = opts.readFile(p);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
text = null;
|
|
130
|
+
}
|
|
131
|
+
if (text === null || text === undefined)
|
|
132
|
+
continue; // a file that does not exist yet has no character
|
|
133
|
+
const sig = OS_SIGNALS.find((s) => text.includes(s));
|
|
134
|
+
if (sig) {
|
|
135
|
+
return `os_impact 는 none 인데 '${p}' 의 내용이 OS 신호 '${sig}' 를 담고 있습니다 — 모순. 3셀 매핑으로 각 OS 의 영향을 기술하십시오.`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
@@ -76,7 +76,9 @@ exports.SPEC_TYPES = {
|
|
|
76
76
|
// blocked every turn, because all 38 governed A-SPECs are already approved. The duty belongs to
|
|
77
77
|
// the ACT of approval (see spec/breaking-change.ts); this line exists so the field is visible
|
|
78
78
|
// where someone looks up "what fields does an A-SPEC have", instead of hiding in a check.
|
|
79
|
-
|
|
79
|
+
// @implements A-SPEC-565.1 — same shape, same reason: the compat duty (REQ-565) lives on the
|
|
80
|
+
// act of approval (spec/compat-impact.ts), and these lines exist for the reader, not the check.
|
|
81
|
+
stubOnlyFields: ['breaking_change', 'harness_impact', 'os_impact'],
|
|
80
82
|
requiredSections: ['Objective', 'Inputs / Outputs', 'Behavior', 'Test Points', 'Files to Touch', 'Done When'],
|
|
81
83
|
},
|
|
82
84
|
'C-SPEC': {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@implements A-SPEC-209",
|
|
3
3
|
"name": "@holmes-lab/holmes-kit",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|