@fusengine/harness 0.1.56 → 0.1.58

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/README.md CHANGED
@@ -1,16 +1,14 @@
1
1
  # @fusengine/harness
2
2
 
3
- A **harness-agnostic enforcement engine** for AI coding agents. It ports the
4
- guard/gate logic of a Claude Code plugin into one reusable, **Bun-native** npm
5
- package that runs on **any** harness Claude Code, OpenAI Codex, Cursor, Cline,
6
- Gemini CLI plus a cli-mode fallback for Aider / Windsurf / OpenHands.
7
-
8
- It splits cleanly into a **pure policy core** (no harness coupling, fully tested)
9
- and **thin per-harness adapters** that map a hook payload to the policy and back
10
- to that harness's native response.
3
+ A **governance layer for AI coding agents**: gates backed by cited evidence
4
+ (not vibes), a decision-time lesson memory, verification receipts, and a
5
+ one-shot gate metric ported from a Claude Code plugin into one **Bun-native**
6
+ npm package. The policy core is harness-agnostic; how much of it actually
7
+ *enforces* depends on the harness's own hook system — see the
8
+ [compatibility matrix](#compatibility) below before assuming parity across harnesses.
11
9
 
12
10
  ```
13
- detect → init (pre+post hooks) → `harness hook` → guards + APEX gates → native deny/ask
11
+ detect → init (writes that harness's hook wiring) → `harness hook` → guards + APEX gates → native deny/ask/context
14
12
  ```
15
13
 
16
14
  ## Install
@@ -25,18 +23,37 @@ bun add @fusengine/harness # Bun reads the TS source directly — no build
25
23
 
26
24
  ```sh
27
25
  cd your-project
28
- harness init # detects the harness, writes its pre+post hooks
29
- # The SOLID-read gate auto-activates from discovered skills (default marketplace: fusengine-plugins).
26
+ harness init # detects the harness, writes its pre+post hook wiring
30
27
  export FUSE_HARNESS_MARKETPLACES=fusengine-plugins # (optional) which marketplaces to auto-scan
31
28
  export FUSE_HARNESS_REFS=.claude/skills # (optional) explicit refs dir, overrides auto-discovery
32
29
  ```
33
30
 
34
- That's it. `init` writes the wiring file for the detected harness
35
- (`.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json`,
36
- `.gemini/settings.json`, or `.clinerules/hooks/PreToolUse`+`PostToolUse`), each
37
- pointing at `harness hook <id>`. From then on every tool-use is gated, and the
38
- session activity (agents run, docs consulted, refs read) is recorded
39
- automatically under `<project>/.harness/` (track, cache, memory).
31
+ `init` writes the wiring file for the detected harness (`.claude/settings.json`,
32
+ `.codex/hooks.json`, `.cursor/hooks.json`, `.gemini/settings.json`, or
33
+ `.clinerules/hooks/PreToolUse`+`PostToolUse`) — for Claude Code, Codex, Cursor,
34
+ Gemini CLI and Cline this covers **PreToolUse + PostToolUse only**
35
+ (`src/init/templates.ts`); Hermes has no `init` runner because its config lives
36
+ outside the project at `~/.hermes/config.yaml` (`src/adapters/hermes/index.ts:3-4`).
37
+ The richer lifecycle (SessionStart, SubagentStart/Stop, Stop, PreCompact/PostCompact,
38
+ TaskCompleted, TeammateIdle, PostToolUseFailure, InstructionsLoaded — 14 distinct
39
+ Claude Code hook events in total, `src/runtime/lifecycle/dispatch.ts` +
40
+ `src/runtime/handle.ts:56-80`) is **implemented** in the runtime but only fires
41
+ when something wires those extra hook events into `.claude/settings.json` — either
42
+ by hand, or via the fusengine-plugins marketplace this repo is itself developed
43
+ under (its ~11 sibling plugins each add their own event, `src/runtime/burst-window.ts:5-11`).
44
+
45
+ **Where state lives** (two separate roots — this changed across 0.1.34→0.1.51,
46
+ don't assume either the old or the "everything in one place" story):
47
+
48
+ - Session track, deny-loop/one-shot sidecars: **out-of-tree**, under
49
+ `~/.fuse-harness/state/<8-char-md5-of-project-path>/` (`src/runtime/paths.ts:5-6,41-43`) —
50
+ deliberately outside the repo so the protected-path guard never has to bless
51
+ writes to its own enforcement state.
52
+ - MCP/WebFetch response cache and the curated `MEMORY/LESSON.md`-equivalent lessons
53
+ file: still **in-tree**, under `<project>/.harness/{cache,memory}`
54
+ (`src/config/layout.ts:15-26`) — `.harness/` is gitignored except
55
+ `memory/LESSON.md`, which is meant to be committed (`STATE_GITIGNORE`,
56
+ `src/config/layout.ts:10`).
40
57
 
41
58
  ### CLI
42
59
 
@@ -66,7 +83,7 @@ Any consumer `hooks.json` / `settings.json` that runs the harness via `bunx`
66
83
 
67
84
  ```jsonc
68
85
  // .claude/settings.json — correct: exact pin
69
- "command": "bunx @fusengine/harness@0.1.41 hook claude"
86
+ "command": "bunx @fusengine/harness@0.1.56 hook claude"
70
87
  // WRONG — may silently keep running a stale global install:
71
88
  "command": "bunx @fusengine/harness hook claude"
72
89
  ```
@@ -75,9 +92,8 @@ Any consumer `hooks.json` / `settings.json` that runs the harness via `bunx`
75
92
  prefer an already-installed **global** copy over npm-latest, and publishing a new
76
93
  version updates neither that global nor the bunx cache (`bun pm cache rm` does
77
94
  **not** remove the global). A hook wired without an exact pin can therefore keep
78
- executing an old harness indefinitely after you publish a fix the failure is
79
- silent because the smoke test (importing the local dist) never exercises the
80
- `bunx`-resolved binary. Pinning `@X.Y.Z` forces the exact version to resolve.
95
+ executing an old harness indefinitely after you publish a fix. Pinning `@X.Y.Z`
96
+ forces the exact version to resolve.
81
97
 
82
98
  Run `harness doctor` to see which version is actually executing and whether npm
83
99
  has a newer one:
@@ -85,11 +101,11 @@ has a newer one:
85
101
  ```sh
86
102
  harness doctor
87
103
  # @fusengine/harness doctor
88
- # running: 0.1.41
104
+ # running: 0.1.56
89
105
  # package: /Users/you/.bun/install/global/node_modules/@fusengine/harness
90
106
  # runtime: /Users/you/.bun/bin/bun
91
- # npm latest: 0.1.42
92
- # ! stale — npm serves 0.1.42. Pin "@fusengine/harness@0.1.42" in hooks.json.
107
+ # npm latest: 0.1.57
108
+ # ! stale — npm serves 0.1.57. Pin "@fusengine/harness@0.1.57" in hooks.json.
93
109
  ```
94
110
 
95
111
  If `doctor` reports a stale global, clear it and re-pin:
@@ -100,29 +116,88 @@ bun remove -g @fusengine/harness && bun pm cache rm
100
116
 
101
117
  [oven-sh/bun#5791]: https://github.com/oven-sh/bun/issues/5791
102
118
 
119
+ ## Compatibility
120
+
121
+ **There is no "runs the same on any harness."** Each row is a real ceiling, not
122
+ a formatting nuance — read it before assuming a gate that works on Claude Code
123
+ also works elsewhere.
124
+
125
+ | Harness | PreToolUse coverage | Lifecycle (Session/Subagent/Stop/Compact/…) | Known limit |
126
+ |---|---|---|---|
127
+ | **claude-code** | Full: `evaluate` + APEX gates via `handleHook` (`src/adapters/claude/index.ts`) | 14 event types implemented (`dispatch.ts`) — fires once wired into `.claude/settings.json` beyond the `init` default | None found; richer lifecycle needs manual/marketplace wiring (see Quickstart) |
128
+ | **codex** | Bash gated reliably; **`apply_patch` edits are now gated too** — the patch text is parsed per file (`adapters/codex/apply-patch.ts`), each hunk runs the file gates and ONE violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenario 22 incl. the multi-file smuggling case). `ask` prompts are **downgraded to explicit deny** (`respond.ts`, sim scenario 23) because Codex fails open on unsupported shapes. | Not wired by `harness init codex` (PreToolUse `Bash\|apply_patch` + PostToolUse only, `src/init/templates.ts:29-38`) | Upstream caveat: Codex itself does not always enforce a correct `apply_patch` deny (openai/codex#27833) — we emit the right verdict; enforcement is theirs. No interactive `ask`. |
129
+ | **cursor** | `beforeShellExecution` can deny/ask (shell only, `cursor/index.ts:16-21`) | none | File edits are **advisory only**: `afterFileEdit` always returns `allow` + a `user_message` correction on violation — a `deny` there has no proven effect (hook was "informational only" at launch, and Cursor's deny-enforcement for file ops is confirmed broken upstream, forum.cursor.com/t/154377). Human sees the message; the model is never re-informed. Platform ceiling, documented in `cursor/index.ts`. |
130
+ | **gemini-cli** | `BeforeTool` denies via `{decision:"deny",reason}` (`gemini/index.ts:22-36`) | none | Thin stateless adapter — no session track, no APEX gates wired through it. |
131
+ | **cline** | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (`cline/index.ts:24-36`) | none | Same as gemini-cli: stateless guard only. |
132
+ | **hermes** | `pre_tool_call` proven: reuses the Claude stdin reader, blocks via `{decision:"block",reason}` (`hermes/index.ts:12-36`) | untested — no lifecycle dispatch wired for Hermes in this repo | `ask`/`inform` degrade to non-blocking `{context}` — Hermes "has no interactive ask state" (`hermes/index.ts:27-28`). |
133
+
103
134
  ## What it enforces
104
135
 
105
- Ten portable guards + the APEX gate chain, all evaluated before a tool runs:
136
+ Guard/gate chain evaluated before a tool runs (`src/policy/guards/index.ts`,
137
+ `src/policy/apex-gates.ts`, `src/policy/evaluate.ts`):
106
138
 
107
139
  | Guard / gate | Fires on |
108
140
  |---|---|
109
- | file-size (SOLID) | a code file over `FUSE_SOLID_MAX_LINES` (default 100) |
110
- | git | destructive git (`push --force`, `reset --hard`, …) |
111
- | bash-write | `python3 -c` / `sed -i` / redirects to code files |
112
- | install | `npm/pip/brew/...` installs (asks) |
113
141
  | security | `rm -rf /`, fork bombs, `curl \| sh`; `sudo` (asks) |
142
+ | protected-path | edits to `.claude/plugins\|logs\|cache`, `.git/`, the harness's own state dirs |
143
+ | bash-write | `python3 -c` / `sed -i` / redirects to code files |
114
144
  | interface-separation | top-level interface/type/protocol in a component/controller |
115
- | protected-path | edits to `.claude/plugins\|logs\|cache`, `.git/` |
145
+ | install | `npm/pip/brew/...` installs (asks) |
146
+ | git | destructive git (`push --force`, `reset --hard`, …) — block; routine git — ask |
147
+ | file-size (SOLID) | a code file over `FUSE_SOLID_MAX_LINES` (default 100) |
148
+ | APEX brainstorm | creating a new file without brainstorming (when flagged) |
116
149
  | APEX freshness | `explore-codebase` + `research-expert` not run within the window |
117
- | APEX doc-consulted | no doc source (Context7 / Exa / fuse-browser / WebSearch / WebFetch) consulted this session |
118
- | APEX solid-read | required SOLID refs (auto-discovered, or `FUSE_HARNESS_REFS`) not read |
119
- | framework sub-skill | framework / shadcn / Tailwind code whose required skill (`skills/<name>/`) wasn't read this session |
150
+ | APEX doc-consulted | Context7 **and** Exa not consulted this session (a web-only fallback also passes) |
151
+ | APEX solid-read | required SOLID refs (auto-discovered, or `FUSE_HARNESS_REFS`) not read within the TTL |
152
+ | framework sub-skill | framework / shadcn / Tailwind code whose required skill wasn't read this session |
120
153
  | Gemini MCP (opt-in) | hand-written Tailwind UI without a `mcp__gemini-design__*` call — only when `FUSE_ENFORCE_GEMINI_MCP` is set |
121
- | brainstorm | creating a new file without brainstorming (when flagged) |
122
154
  | MCP verbosity / cache | caps exa `numResults`; serves a fresh cached MCP/WebFetch result |
123
155
 
124
- A trivial-edit fast path lets a few tiny (< 5-line, non-`replace_all`) edits
125
- through per window without the full APEX gates.
156
+ A trivial-edit fast path lets a few tiny (< 5-line, non-`replace_all`) `Edit`s
157
+ through per window without the full APEX gates (`Write` is never trivial).
158
+
159
+ ## Beyond gating: memory, receipts, one-shot metric
160
+
161
+ Features shipped since 0.1.44, each with its own test:
162
+
163
+ - **Deny-loop breaker** — an identical retried call that was already denied gets
164
+ a rewritten `[REPEAT] … STOP` message forcing a different approach, instead of
165
+ looping silently (`src/policy/deny-loop.ts`, `test/deny-loop.test.ts`).
166
+ - **Burst-window dedup** — every deployed plugin registers its own hook, so one
167
+ real tool call can fan out to ~11 sibling processes; a same-op record within
168
+ 2s is folded into the first instead of re-counted (`src/runtime/burst-window.ts`,
169
+ `test/burst-dedup.test.ts`).
170
+ - **One-shot gate metric** — every gate outcome (deny or its later fix) lands in
171
+ a 7-day sidecar keyed by a content-free op hash, so a deny→allow transition is
172
+ visible (`src/tracking/one-shot.ts`, `test/one-shot.test.ts`).
173
+ - **Verification receipts** — a `tsc`/test run is captured from PostToolUse Bash
174
+ output; `TaskCompleted` **refuses** a "done" over modified code files without a
175
+ fresh passing receipt (`src/tracking/receipts.ts`, `test/receipts.test.ts`).
176
+ - **Decision-time lessons** — a `MEMORY/LESSON.md` bullet tagged with
177
+ `[TRIGGERS tool:… path:… error:… keyword:…]` is injected as `additionalContext`
178
+ the moment a matching call is about to repeat a known mistake, cooldown-guarded
179
+ (`src/policy/lessons/lesson-gate.ts`).
180
+ - **Failure lessons** *(Claude-Code-only — no `PostToolUseFailure` hook on Codex/Hermes,
181
+ `src/runtime/lifecycle/failure-lesson.ts:8-9`)* — a tool failure's error message is
182
+ matched against `error:`-triggered lessons and injected on the spot
183
+ (`test/failure-lesson.test.ts`, `test/sim/scenarios/17-failure-lesson.json`).
184
+ - **TeammateIdle anti-false-done** *(Claude-Code-only, `teammate-idle-check.ts:9-10`)* —
185
+ files a teammate announced as changed are checked against disk; a missing file
186
+ warns the lead before it's treated as done (`test/teammate-idle-check.test.ts`).
187
+ - **PostCompact re-injection** — after context compaction, the reconciliation
188
+ snapshot is re-sent with a "reread files before editing" reminder, deduped per
189
+ session (`src/runtime/lifecycle/post-compact.ts`, `test/post-compact.test.ts`).
190
+ - **Reconciliation snapshot at SessionStart** — git state, running harness
191
+ version + drift vs. npm, `.claude/BOARD.md`, and the one-shot summary, each
192
+ collector isolated so one failure can't blank the rest
193
+ (`src/runtime/lifecycle/snapshot/index.ts`, `test/snapshot.test.ts`).
194
+ - **Injection budget cap** — harness-produced context fragments (lessons,
195
+ snapshot, APEX task context) are capped at ~8000 chars each; owner-authored
196
+ content (CLAUDE.md/rules) is never capped
197
+ (`src/runtime/inject-budget.ts`, `test/inject-budget.test.ts`).
198
+ - **Hook simulator** — 18 end-to-end scenarios (payload in, expected verdict out)
199
+ replayed against the real CLI in both `src` and built `dist` modes in CI
200
+ (`test/sim/README.md`, `test/sim/scenarios/`).
126
201
 
127
202
  ### Environment
128
203
 
@@ -130,11 +205,15 @@ through per window without the full APEX gates.
130
205
  |---|---|
131
206
  | `FUSE_SOLID_MAX_LINES` | SOLID file-size limit (default `100`). |
132
207
  | `FUSE_HARNESS_REFS` | Explicit `path.delimiter`-list of `.md` SOLID-reference dirs → activates `solidReadGate`. Overrides auto-discovery. |
133
- | `FUSE_HARNESS_MARKETPLACES` | Comma-list of marketplace names whose `solid-*` skill refs are auto-discovered when `FUSE_HARNESS_REFS` is unset (default `fusengine-plugins`; an absent marketplace contributes nothing). Standalone `.claude`/`.codex`/`.cursor`/`.agents` skills are always scanned. |
134
- | `FUSE_ENFORCE_TTL_SEC` | APEX freshness window in seconds. |
135
- | `FUSE_LESSONS_THROTTLE_MIN` | Lessons-injection throttle (memory module). |
136
- | `FUSE_ENFORCE_GEMINI_MCP` | **Opt-in (default off).** `1`/`true` blocks hand-written Tailwind UI (`.tsx/.jsx/.vue/.svelte`, ≥3 utility classes) until a `mcp__gemini-design__*` call is made this session. Read fresh per call. |
137
- | `RALPH_MODE` | **Opt-in (default off).** `1`/`true` enables autonomous "Ralph" mode: exempts the safe git commands (`add`/`commit`/`checkout -b`/`status`/`diff`/`log`) from the confirmation ASK and auto-approves project installs. Destructive git (force-push, `reset --hard`) and system installs still gate. |
208
+ | `FUSE_HARNESS_MARKETPLACES` | Comma-list of marketplace names whose `solid-*` skill refs are auto-discovered when `FUSE_HARNESS_REFS` is unset (default `fusengine-plugins`). |
209
+ | `FUSE_ENFORCE_TTL_SEC` | APEX freshness window in seconds (default `120`). |
210
+ | `FUSE_LESSONS_THROTTLE_MIN` | Lessons-injection throttle, minutes (default `5`). |
211
+ | `FUSE_ENFORCE_GEMINI_MCP` | **Opt-in (default off).** Blocks hand-written Tailwind UI (`.tsx/.jsx/.vue/.svelte`) until a `mcp__gemini-design__*` call is made this session. Read fresh per call (`src/policy/gemini-mcp-gate.ts`). |
212
+ | `FUSE_DESIGN_GEMINI` | **Opt-in (default off), a *different* gate from the one above.** Enables the design-pipeline's own Gemini gates (`create_frontend` validation + "generate before hand-writing HTML/CSS") inert unless a design agent is active (`src/policy/design/gates.ts:58-60`, see [docs/design.md](docs/design.md)). |
213
+ | `FUSE_MCP_TTL_SEC` | MCP (Context7/Exa) cache freshness, seconds (default 48h, `src/runtime/mcp-key.ts`). |
214
+ | `FUSE_WEBFETCH_TTL_SEC` | WebFetch cache freshness, seconds (default 24h — pages stale faster than docs). |
215
+ | `RALPH_MODE` | **Opt-in (default off).** Exempts safe git commands (`add`/`commit`/`checkout -b`/`status`/`diff`/`log`) from the confirmation ask and auto-approves project installs. Destructive git (force-push, `reset --hard`) and system installs still gate. |
216
+ | `CLAUDE_PROJECT_DIR` | Overrides the project root used to hash the out-of-tree state dir (`src/runtime/paths.ts:20`). |
138
217
 
139
218
  ## Library usage
140
219
 
@@ -155,7 +234,8 @@ const prompt = await gate({ sessionId, framework: "react", tool: "Write",
155
234
  ```
156
235
 
157
236
  The `Prompt` it returns (`{ kind: "block" | "ask" | "inform", title, reason, actions? }`)
158
- is portable; each adapter maps it to the harness's native shape.
237
+ is portable; each adapter maps it to the harness's native shape — but, per the
238
+ compatibility matrix above, not every harness can act on every `kind`.
159
239
 
160
240
  ### Extend it
161
241
 
@@ -177,15 +257,15 @@ registerGuard(({ tool, command }) =>
177
257
  | Subpath | What |
178
258
  |---------|------|
179
259
  | `./detect` | `detectHarness()` / `detectMode()` — 13 harnesses, `hook` vs `cli`. |
180
- | `./policy` | `evaluate(ctx)`, the 10 guards, `evaluateApex`, framework detection. |
260
+ | `./policy` | `evaluate(ctx)`, the guard chain, `evaluateApex`, framework detection. |
181
261
  | `./runtime` | `handleHook`, `gate`, `recordActivity`, `activityFor`, per-harness storage + MCP intercept. |
182
- | `./tracking` | Session track: `recordAgent/Doc/RefRead`, `agentsFresh`, trivial-edit counter. |
262
+ | `./tracking` | Session track: `recordAgent/Doc/RefRead`, `agentsFresh`, receipts, one-shot metric. |
183
263
  | `./refs` | Frontmatter parse, `loadRefs(dir)`, SOLID ref scoring/routing. |
184
264
  | `./prompt` | The portable `Prompt` type + `formatPrompt`. |
185
265
  | `./cache` | MCP/WebFetch cache: key, lookup/store, compaction, response extraction. |
186
266
  | `./memory` | Per-project "never reproduce" lessons. |
187
267
  | `./config` `./util` `./state` `./statusline` `./freshness` `./init` `./cli` | env config, project-root, locks, statusline, doc-freshness, wiring templates, staged checks. |
188
- | `./adapters/{claude,codex,cursor,cline,gemini}` | Thin per-harness adapters. |
268
+ | `./adapters/{claude,codex,cursor,cline,gemini,hermes}` | Thin per-harness adapters — see [Compatibility](#compatibility) for what each can actually enforce. |
189
269
 
190
270
  ## Documentation
191
271
 
@@ -193,20 +273,40 @@ registerGuard(({ tool, command }) =>
193
273
  |-------|------|
194
274
  | [docs/index.md](https://github.com/fusengine/harness/blob/main/docs/index.md) | architecture overview + map |
195
275
  | [docs/detect.md](https://github.com/fusengine/harness/blob/main/docs/detect.md) | harness detection (`hook` vs `cli`) |
196
- | [docs/policy.md](https://github.com/fusengine/harness/blob/main/docs/policy.md) | `evaluate`, file-size, framework, APEX gates |
276
+ | [docs/policy.md](https://github.com/fusengine/harness/blob/main/docs/policy.md) | `evaluate`, the guard chain, framework, APEX gates |
197
277
  | [docs/guards.md](https://github.com/fusengine/harness/blob/main/docs/guards.md) | the guard chain, `registerGuard`, fail-closed |
198
- | [docs/runtime.md](https://github.com/fusengine/harness/blob/main/docs/runtime.md) | `handleHook`, `gate`, tracking, MCP intercept |
199
- | [docs/config.md](https://github.com/fusengine/harness/blob/main/docs/config.md) | env config (TTL, max-lines, refs dir) |
278
+ | [docs/runtime.md](https://github.com/fusengine/harness/blob/main/docs/runtime.md) | `handleHook`, `gate`, tracking, MCP intercept, state paths |
279
+ | [docs/config.md](https://github.com/fusengine/harness/blob/main/docs/config.md) | env config (TTL, max-lines, refs dir, Gemini opt-ins) |
200
280
  | [docs/modules.md](https://github.com/fusengine/harness/blob/main/docs/modules.md) | cache · refs · state · memory · statusline · util |
201
- | [docs/adapters.md](https://github.com/fusengine/harness/blob/main/docs/adapters.md) | adapters + `harness init`/`hook` wiring |
281
+ | [docs/adapters.md](https://github.com/fusengine/harness/blob/main/docs/adapters.md) | adapters, compatibility, `harness init`/`hook` wiring |
282
+ | [docs/design.md](https://github.com/fusengine/harness/blob/main/docs/design.md) | design-agent pipeline — state machine, gates, opt-in Gemini |
202
283
  | [CHANGELOG.md](https://github.com/fusengine/harness/blob/main/CHANGELOG.md) | release history |
203
284
 
204
285
  Run `bun run docs:api` for the generated typedoc API reference.
205
286
 
287
+ ## Known limitations
288
+
289
+ - **Codex file edits are not gated.** The SOLID/file-size gate keys off
290
+ `tool_input.file_path`, which Codex's `apply_patch` call never supplies — only
291
+ Bash is reliably gated on Codex today (see [Compatibility](#compatibility)).
292
+ - **Cursor file edits are advisory-only.** `afterFileEdit` fires after the edit
293
+ already happened — a platform limit, not something this harness can work around.
294
+ - **Hook fan-out is mitigated, not eliminated.** The ~11-sibling-plugin burst is
295
+ deduped within a 2s window (`BURST_DEDUP_MS`), but that window is a heuristic,
296
+ not a protocol guarantee — an unusually slow fan-out could in theory land
297
+ outside it.
298
+ - **Sidechain hook reliability is a platform issue, worked around, not fixed.**
299
+ Sub-agent `PostToolUse` hooks don't always fire on Claude Code (documented
300
+ platform issues #43612/#27655/#34692); `SubagentStop` transcript harvesting
301
+ (`src/freshness/evidence-harvest-io.ts`) compensates, but only at that
302
+ checkpoint, not continuously.
303
+ - **Hermes coverage beyond `pre_tool_call` is unverified** — no lifecycle events
304
+ have been proven against a live Hermes install in this repo.
305
+
206
306
  ## Develop
207
307
 
208
308
  ```sh
209
- bun test # 117 tests
309
+ bun test # 484 tests (94 files)
210
310
  bunx tsc --noEmit # typecheck (isolatedDeclarations)
211
311
  bun run build # dist + .d.mts via tsdown (for Node/bundler consumers)
212
312
  bun run docs:api # generate the typedoc API reference
@@ -1,2 +1,2 @@
1
- import { a as fileSizeGuard, c as readClaudeInput, i as denyResponse, l as systemMessage, n as blockResponse, o as guard, r as contextResponse, s as informResponse, t as attachSystemMessage, u as toClaudeResponse } from "../../claude-BxC9semG.mjs";
1
+ import { a as fileSizeGuard, c as readClaudeInput, i as denyResponse, l as systemMessage, n as blockResponse, o as guard, r as contextResponse, s as informResponse, t as attachSystemMessage, u as toClaudeResponse } from "../../claude-BLuab-tR.mjs";
2
2
  export { attachSystemMessage, blockResponse, contextResponse, denyResponse, fileSizeGuard, guard, informResponse, readClaudeInput, systemMessage, toClaudeResponse };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CNAzgxnN.mjs";
1
+ import { t as evaluate } from "../../evaluate-BgFLlqrs.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cline/index.ts
4
4
  /**
@@ -1,2 +1,14 @@
1
- import { a as denyResponse, i as contextResponse, l as readClaudeInput, s as guard, t as ClaudeHookInput } from "../../index-DZCLmSoO.mjs";
2
- export { type ClaudeHookInput as CodexHookInput, contextResponse, denyResponse, guard, readClaudeInput as readCodexInput };
1
+ import { t as Prompt } from "../../types-DVbIl9md.mjs";
2
+ import { a as denyResponse, i as contextResponse, l as readClaudeInput, t as ClaudeHookInput } from "../../index-DZCLmSoO.mjs";
3
+
4
+ //#region src/adapters/codex/index.d.ts
5
+ /** Render a portable {@link Prompt} as a Codex hook response, `ask` → explicit deny. */
6
+ declare function toCodexResponse(prompt: Prompt): string;
7
+ /**
8
+ * Run the bundled policy over a Codex payload and return the native response
9
+ * string (deny/additionalContext), or null to allow. `apply_patch` is fanned
10
+ * into per-file checks; every other tool routes through the portable policy.
11
+ */
12
+ declare function guard(input: ClaudeHookInput): string | null;
13
+ //#endregion
14
+ export { type ClaudeHookInput as CodexHookInput, contextResponse, denyResponse, guard, readClaudeInput as readCodexInput, toCodexResponse };
@@ -1,2 +1,66 @@
1
- import { c as readClaudeInput, i as denyResponse, o as guard, r as contextResponse } from "../../claude-BxC9semG.mjs";
2
- export { contextResponse, denyResponse, guard, readClaudeInput as readCodexInput };
1
+ import { f as countLines } from "../../home-state-D0RLWP8J.mjs";
2
+ import { t as evaluate } from "../../evaluate-BgFLlqrs.mjs";
3
+ import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
4
+ import { t as parseApplyPatch } from "../../apply-patch-CIS2EZ_q.mjs";
5
+ import { c as readClaudeInput, i as denyResponse, r as contextResponse } from "../../claude-BLuab-tR.mjs";
6
+ //#region src/adapters/codex/index.ts
7
+ /**
8
+ * OpenAI Codex CLI adapter (hook-mode). Codex's `PreToolUse` hook (since 2026)
9
+ * uses the SAME envelope as Claude Code — `tool_name`/`tool_input` in,
10
+ * `hookSpecificOutput.permissionDecision` out — so it shares Claude's readers.
11
+ * Config lives at `.codex/hooks.json`.
12
+ *
13
+ * Two Codex-specific quirks this adapter closes (audit 2026-07-06):
14
+ * 1. `apply_patch` is Codex's PRIMARY edit primitive; its payload is a freeform
15
+ * patch in `tool_input.command`, with NO `file_path`/`content`. Claude's guard
16
+ * keyed off those fields, so the SOLID/DRY gates saw NOTHING (enforcement 0%).
17
+ * Here the patch is parsed and each file is judged (one violating hunk denies).
18
+ * 2. Codex parses but NEVER honors `permissionDecision: "ask"` (deny-only) — an
19
+ * `ask` silently fails open (verified: `pre_tool_use.rs` test
20
+ * `unsupported_permission_decision_fails_open`). Every `ask` is downgraded to
21
+ * an explicit deny.
22
+ */
23
+ const ASK_PREFIX = "[downgraded from ask — Codex has no interactive approval]";
24
+ /** Render a portable {@link Prompt} as a Codex hook response, `ask` → explicit deny. */
25
+ function toCodexResponse(prompt) {
26
+ const message = formatPrompt(prompt);
27
+ if (prompt.kind === "inform") return contextResponse("PreToolUse", message);
28
+ if (prompt.kind === "ask") return denyResponse("PreToolUse", `${ASK_PREFIX}\n${message}`);
29
+ return denyResponse("PreToolUse", message);
30
+ }
31
+ /** OR the per-file SOLID verdict of an `apply_patch` payload — first block wins. */
32
+ function applyPatchPrompt(command) {
33
+ for (const f of parseApplyPatch(command)) {
34
+ if (f.op === "delete") continue;
35
+ const r = evaluate({
36
+ tool: f.op === "add" ? "Write" : "Edit",
37
+ filePath: f.path,
38
+ content: f.content,
39
+ existingLines: countLines(f.content)
40
+ });
41
+ if (r.decision !== "allow" && r.prompt) return r.prompt;
42
+ }
43
+ return null;
44
+ }
45
+ /** Portable single-tool verdict for non-`apply_patch` Codex tools. */
46
+ function resolvePrompt(input) {
47
+ const i = input.tool_input;
48
+ const r = evaluate({
49
+ tool: input.tool_name ?? "Write",
50
+ filePath: i?.file_path,
51
+ content: i?.content ?? i?.new_string,
52
+ command: i?.command
53
+ });
54
+ return r.decision === "allow" || !r.prompt ? null : r.prompt;
55
+ }
56
+ /**
57
+ * Run the bundled policy over a Codex payload and return the native response
58
+ * string (deny/additionalContext), or null to allow. `apply_patch` is fanned
59
+ * into per-file checks; every other tool routes through the portable policy.
60
+ */
61
+ function guard(input) {
62
+ const prompt = input.tool_name === "apply_patch" ? applyPatchPrompt(input.tool_input?.command ?? "") : resolvePrompt(input);
63
+ return prompt ? toCodexResponse(prompt) : null;
64
+ }
65
+ //#endregion
66
+ export { contextResponse, denyResponse, guard, readClaudeInput as readCodexInput, toCodexResponse };
@@ -15,6 +15,19 @@ interface CursorEditPayload {
15
15
  }[];
16
16
  }
17
17
  /**
18
+ * `afterFileEdit` stdout response. Its schema (cursor.com/docs/hooks#afterFileEdit)
19
+ * is DELIBERATELY narrower than the "before" hooks: `permission` + `user_message`
20
+ * only — there is NO `agent_message` and NO `updated_input`. Since the edit is
21
+ * already on disk when this "after" hook fires, `deny` cannot revert it and the
22
+ * correction reaches only the HUMAN (`user_message`), never the model — so this
23
+ * path is strictly ADVISORY, not an enforceable gate.
24
+ */
25
+ interface CursorEditResponse {
26
+ permission: "allow" | "deny";
27
+ /** User-visible correction — snake_case (#141516); the only channel afterFileEdit exposes. */
28
+ user_message?: string;
29
+ }
30
+ /**
18
31
  * `beforeShellExecution` stdout response. Message keys are snake_case:
19
32
  * Cursor silently ignores camelCase `userMessage`/`agentMessage` (#141516,
20
33
  * regression persists through v2.0.77+ — forum #142589), matching the
@@ -32,9 +45,24 @@ interface CursorResponse {
32
45
  //#region src/adapters/cursor/index.d.ts
33
46
  /** Guard a shell command (git/install policies). */
34
47
  declare function beforeShellExecution(payload: CursorShellPayload): CursorResponse;
35
- /** Observe a file edit (Cursor cannot block here). Returns the verdict for logging. */
36
- declare function afterFileEdit(payload: CursorEditPayload): {
37
- violation: string | null;
38
- };
48
+ /**
49
+ * Advise on a file edit AFTER Cursor has written it — a HUMAN-VISIBLE audit note,
50
+ * never a gate. This is an "after" hook: the edit is already on disk. On a
51
+ * SOLID/DRY violation we surface the correction through `user_message` (the only
52
+ * channel afterFileEdit exposes — no `agent_message`, so the model is never
53
+ * re-informed) while ALWAYS returning `permission: "allow"`.
54
+ *
55
+ * We deliberately never emit `permission: "deny"` here, for two distinct reasons:
56
+ * (1) structural — afterFileEdit was "informational only" at launch (Chacon,
57
+ * Cursor hooks beta 1.7, 2025-09: no channel to stop the agent), and a post-write
58
+ * deny has no documented rollback; (2) empirical — Cursor staff confirm the
59
+ * deny-enforcement path is broken for file operations (forum.cursor.com/t/154377,
60
+ * v2.6.18, 2026-03, open) — proven for file READS, plausibly the same for writes.
61
+ * So a `deny` would be a false blocking signal; `allow` + `user_message` is the
62
+ * only proven-safe shape.
63
+ * @param payload - The `afterFileEdit` stdin payload.
64
+ * @returns Always an allow; carries the user-visible correction on a violation.
65
+ */
66
+ declare function afterFileEdit(payload: CursorEditPayload): CursorEditResponse;
39
67
  //#endregion
40
- export { type CursorEditPayload, type CursorResponse, type CursorShellPayload, afterFileEdit, beforeShellExecution };
68
+ export { type CursorEditPayload, type CursorEditResponse, type CursorResponse, type CursorShellPayload, afterFileEdit, beforeShellExecution };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CNAzgxnN.mjs";
1
+ import { t as evaluate } from "../../evaluate-BgFLlqrs.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cursor/index.ts
4
4
  /**
@@ -23,7 +23,24 @@ function beforeShellExecution(payload) {
23
23
  agent_message: msg
24
24
  };
25
25
  }
26
- /** Observe a file edit (Cursor cannot block here). Returns the verdict for logging. */
26
+ /**
27
+ * Advise on a file edit AFTER Cursor has written it — a HUMAN-VISIBLE audit note,
28
+ * never a gate. This is an "after" hook: the edit is already on disk. On a
29
+ * SOLID/DRY violation we surface the correction through `user_message` (the only
30
+ * channel afterFileEdit exposes — no `agent_message`, so the model is never
31
+ * re-informed) while ALWAYS returning `permission: "allow"`.
32
+ *
33
+ * We deliberately never emit `permission: "deny"` here, for two distinct reasons:
34
+ * (1) structural — afterFileEdit was "informational only" at launch (Chacon,
35
+ * Cursor hooks beta 1.7, 2025-09: no channel to stop the agent), and a post-write
36
+ * deny has no documented rollback; (2) empirical — Cursor staff confirm the
37
+ * deny-enforcement path is broken for file operations (forum.cursor.com/t/154377,
38
+ * v2.6.18, 2026-03, open) — proven for file READS, plausibly the same for writes.
39
+ * So a `deny` would be a false blocking signal; `allow` + `user_message` is the
40
+ * only proven-safe shape.
41
+ * @param payload - The `afterFileEdit` stdin payload.
42
+ * @returns Always an allow; carries the user-visible correction on a violation.
43
+ */
27
44
  function afterFileEdit(payload) {
28
45
  const content = payload.edits?.map((e) => e.new_string).join("\n") ?? "";
29
46
  const r = evaluate({
@@ -31,7 +48,11 @@ function afterFileEdit(payload) {
31
48
  filePath: payload.file_path,
32
49
  content
33
50
  });
34
- return { violation: r.decision === "deny" ? r.message : null };
51
+ if (r.decision !== "deny" || !r.prompt) return { permission: "allow" };
52
+ return {
53
+ permission: "allow",
54
+ user_message: formatPrompt(r.prompt)
55
+ };
35
56
  }
36
57
  //#endregion
37
58
  export { afterFileEdit, beforeShellExecution };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CNAzgxnN.mjs";
1
+ import { t as evaluate } from "../../evaluate-BgFLlqrs.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/gemini/index.ts
4
4
  /**
@@ -1,2 +1,2 @@
1
- import { n as readHermesInput, r as toHermesResponse, t as guard } from "../../hermes-DWXCRFZU.mjs";
1
+ import { n as readHermesInput, r as toHermesResponse, t as guard } from "../../hermes-CyhCYqD7.mjs";
2
2
  export { guard, readHermesInput, toHermesResponse };
@@ -0,0 +1,81 @@
1
+ //#region src/adapters/codex/apply-patch.ts
2
+ const BEGIN = "*** Begin Patch";
3
+ const END = "*** End Patch";
4
+ const ADD = "*** Add File: ";
5
+ const DEL = "*** Delete File: ";
6
+ const UPD = "*** Update File: ";
7
+ const MOVE = "*** Move to: ";
8
+ const EOF = "*** End of File";
9
+ const ENV = "*** Environment ID: ";
10
+ /**
11
+ * Parse a Codex freeform patch into its per-file changes. Lenient on whitespace
12
+ * around structural markers (as Codex's own parser is); returns `[]` when no
13
+ * recognizable hunk is present (malformed input fails open, not closed).
14
+ * @param text - Raw patch body from `tool_input.command`.
15
+ * @returns One {@link PatchedFile} per Add/Update/Delete hunk, in order.
16
+ */
17
+ function parseApplyPatch(text) {
18
+ const files = [];
19
+ let cur = null;
20
+ let buf = [];
21
+ const flush = () => {
22
+ if (cur) {
23
+ cur.content = buf.join("\n");
24
+ files.push(cur);
25
+ }
26
+ cur = null;
27
+ buf = [];
28
+ };
29
+ for (const line of text.split("\n")) {
30
+ const marker = line.trimStart();
31
+ if (marker === BEGIN || marker.startsWith(ENV)) continue;
32
+ if (marker === END) {
33
+ flush();
34
+ continue;
35
+ }
36
+ if (marker.startsWith(ADD)) {
37
+ flush();
38
+ cur = {
39
+ path: marker.slice(14).trim(),
40
+ content: "",
41
+ op: "add"
42
+ };
43
+ continue;
44
+ }
45
+ if (marker.startsWith(DEL)) {
46
+ flush();
47
+ files.push({
48
+ path: marker.slice(17).trim(),
49
+ content: "",
50
+ op: "delete"
51
+ });
52
+ continue;
53
+ }
54
+ if (marker.startsWith(UPD)) {
55
+ flush();
56
+ cur = {
57
+ path: marker.slice(17).trim(),
58
+ content: "",
59
+ op: "update"
60
+ };
61
+ continue;
62
+ }
63
+ if (marker.startsWith(MOVE)) {
64
+ if (cur) cur.path = marker.slice(13).trim();
65
+ continue;
66
+ }
67
+ if (marker === EOF || !cur) continue;
68
+ if (cur.op === "add") {
69
+ if (line.startsWith("+")) buf.push(line.slice(1));
70
+ continue;
71
+ }
72
+ if (line.startsWith("@@")) continue;
73
+ if (line.startsWith("+")) buf.push(line.slice(1));
74
+ else if (line.startsWith("-")) continue;
75
+ else buf.push(line.startsWith(" ") ? line.slice(1) : line);
76
+ }
77
+ flush();
78
+ return files;
79
+ }
80
+ //#endregion
81
+ export { parseApplyPatch as t };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "./evaluate-CNAzgxnN.mjs";
1
+ import { t as evaluate } from "./evaluate-BgFLlqrs.mjs";
2
2
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
package/dist/cli/bin.mjs CHANGED
@@ -2,9 +2,9 @@
2
2
  import { r as loadDotenv, s as resolveTtlSec } from "../dotenv-Jj8aL1FL.mjs";
3
3
  import { t as detectHarness } from "../harness-Cb9xR8dC.mjs";
4
4
  import { t as claudeHome } from "../home-state-D0RLWP8J.mjs";
5
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DZvP_9xB.mjs";
5
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-B4b2_7Qm.mjs";
6
6
  import { n as writeInitFile, t as initFor } from "../run-Do2JltgU.mjs";
7
- import { F as runDoctor, I as runningVersion, L as versionBanner, Lt as todayUtc, t as handleHook } from "../handle-CoyvRORV.mjs";
7
+ import { J as runningVersion, Lt as todayUtc, Y as versionBanner, q as runDoctor, t as handleHook } from "../handle-BdOh1jPG.mjs";
8
8
  import { delimiter, join } from "node:path";
9
9
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
@@ -1,2 +1,2 @@
1
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DZvP_9xB.mjs";
1
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-B4b2_7Qm.mjs";
2
2
  export { checkStaged, stagedContent, stagedFiles };
@@ -436,7 +436,7 @@ const CODE_MUTATORS = [
436
436
  desc: "awk in-place edit"
437
437
  },
438
438
  {
439
- re: /\bpatch\b/,
439
+ re: /(?:^|[\n;&|(])\s*patch(?=\s|<|[;&|)>]|$)/,
440
440
  desc: "patch file modification"
441
441
  },
442
442
  {