@fusengine/harness 0.1.68 → 0.1.69

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
@@ -125,7 +125,7 @@ also works elsewhere.
125
125
  | Harness | PreToolUse coverage | Lifecycle (Session/Subagent/Stop/Compact/…) | Known limit |
126
126
  |---|---|---|---|
127
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`. |
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. On the PostToolUse side, an allowed patch is fanned into one synthetic per-file event so the SOLID/tracking/post-edit handlers see every touched file (`runtime/post-fanout.ts`), and a non-blocking security advisory fires on the first qualifying file (`securityAdvisoryForPatch`). | Not wired by `harness init codex` (PreToolUse `Bash\|apply_patch` + PostToolUse only, `src/init/templates.ts:29-38`) — `Stop` (session cleanup + the SOLID/receipt completion check, since Codex never emits `SessionEnd`/`TaskCompleted`, `runtime/lifecycle/stop-core.ts`) and `SessionStart` (plugin agents/commands cache resync, `runtime/lifecycle/codex-resync/`) ARE implemented but, same caveat as claude-code's extra lifecycle above, only fire once the codex-plugins marketplace's own `hooks.json` wires them. | 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`. Codex has no native `PostToolUseFailure`; a failure is inferred from the ordinary `PostToolUse` result shape (non-zero exit or an explicit error field only — never a guess) and journaled into the one-shot failure tally (`src/tracking/codex-post-failure.ts`). |
129
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
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
131
  | **cline** | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (`cline/index.ts:24-36`) | none | Same as gemini-cli: stateless guard only. |
@@ -198,6 +198,72 @@ Features shipped since 0.1.44, each with its own test:
198
198
  - **Hook simulator** — 18 end-to-end scenarios (payload in, expected verdict out)
199
199
  replayed against the real CLI in both `src` and built `dist` modes in CI
200
200
  (`test/sim/README.md`, `test/sim/scenarios/`).
201
+ - **Codex `multi_agent_v2` custom-agent evidence** — a `spawn_agent` call
202
+ carrying `tool_input.agent_type` is recorded as `subagent-<agent_type>` in the
203
+ same SessionTrack as Claude Task evidence, so a Codex explore-codebase/sniper
204
+ satisfies the APEX freshness gates exactly like a Claude subagent
205
+ (`src/tracking/session-state.ts`, sim scenario 32b).
206
+ - **File-size gate judges the real Edit outcome, not the on-disk count** — an
207
+ Edit on a file already over `FUSE_SOLID_MAX_LINES` used to be denied
208
+ unconditionally (the on-disk count always won); the gate now computes the
209
+ actual post-edit line count and allows a shrinking Edit while still denying a
210
+ computable growth past the limit (`src/policy/file-size.ts`, sim scenario 33).
211
+ - **Codex `apply_patch` fan-out to post-handlers** — each file in a patch
212
+ envelope becomes a synthetic per-file PostToolUse event (`add`→`Write`,
213
+ `update`→`Edit`, `delete` no-ops on the Write/Edit-only handlers) instead of
214
+ the whole-patch shape whose own `filePath`/`content` are always undefined
215
+ (`src/runtime/post-fanout.ts`, `test/apply-patch-post.test.ts`).
216
+ - **Non-blocking multi-file security advisory** — the same PreToolUse "read the
217
+ security skill" nudge a single Write/Edit gets is now evaluated per file
218
+ across a Codex `apply_patch` envelope, firing on the first qualifying
219
+ add/update code file (delete ignored outright) — `additionalContext` only,
220
+ never a deny (`src/runtime/lifecycle/security/check-skill.ts::securityAdvisoryForPatch`,
221
+ `test/security-advisory-patch.test.ts`).
222
+ - **Codex SessionStart agent/command resync** — the plugin agents/commands
223
+ cache under `~/.codex/{agents,prompts}` is re-materialized only when its
224
+ sha256 fingerprint changed (or a command symlink dangles) since the last
225
+ apply, otherwise a cheap no-op; agents are copied (Codex won't load
226
+ symlinked TOMLs), commands are symlinked; a best-effort inter-process lock
227
+ avoids a torn cache under concurrent session starts (`src/runtime/lifecycle/codex-resync/`,
228
+ `test/resync-agents.test.ts`).
229
+ - **Codex failure tracking inferred from PostToolUse** — Codex reports every
230
+ outcome, success or failure, on the same `PostToolUse` event; a failure is
231
+ classified from an explicit signal only (non-zero exit, an error field —
232
+ never "can't tell") and journaled into the existing one-shot failure tally
233
+ (`src/tracking/codex-post-failure.ts`, `test/codex-post-failure.test.ts`).
234
+ - **Shell-credited ref reads** — a `.md` SOLID/skill reference read through a
235
+ read-only shell command (`cat`/`head`/`tail`/`sed`/`rg`/`less`/`more`/`bat`,
236
+ including inside a `sh -c` wrapper) now credits the same ref-read evidence a
237
+ native `Read` would, so a Codex teammate's shell-first consultation still
238
+ satisfies `solidReadGate`; `sed -i`/`--in-place` and any non-whitelisted
239
+ command are never credited (`src/policy/shell-read-refs.ts`,
240
+ `test/shell-read-refs.test.ts`).
241
+
242
+ ### Notification sounds
243
+
244
+ A native OS sound plays on two lifecycle events: the core-scope `Stop` under
245
+ Codex (`stopCore`, since Claude Code already voices its own turn-end via a
246
+ native `afplay` hook — this harness deliberately does not double it) and
247
+ `TeammateIdle` under Claude Code when there's an actionable notice (a missing
248
+ deliverable or a sniper reminder). **On by default**; opt out entirely with
249
+ `FUSE_HARNESS_SOUND=0`.
250
+
251
+ Resolution per event kind (`stop`/`permission`/`human`) cascades: (1) a
252
+ per-kind env override path (`FUSE_HARNESS_SOUND_STOP` / `_PERMISSION` /
253
+ `_HUMAN`) if it exists on disk; (2) the package's own bundled
254
+ `assets/song/{finish,permission-need,need-human}.mp3` (located by walking up
255
+ to the running `package.json`, so it survives the flat/hashed `dist/` build);
256
+ (3) `$CLAUDE_PLUGIN_ROOT/song/<file>.mp3` as a last-resort fallback. No file
257
+ resolves → silent no-op. The `permission` kind and its `.mp3`/env var exist in
258
+ the module for symmetry but have no current call site — permission prompts
259
+ stay native-only on every harness today.
260
+
261
+ Playback is native per platform (`afplay` on darwin, `paplay` on linux,
262
+ PowerShell's `SoundPlayer` on win32) and **absolutely fail-open**: an
263
+ unsupported platform, a missing player binary, an undecodable file, or a
264
+ non-zero exit is swallowed — a broken or absent player can never break a hook
265
+ (`src/runtime/notifications.ts`, `src/runtime/notification-sound.ts`,
266
+ `test/notifications.test.ts`).
201
267
 
202
268
  ### Environment
203
269
 
@@ -214,6 +280,8 @@ Features shipped since 0.1.44, each with its own test:
214
280
  | `FUSE_WEBFETCH_TTL_SEC` | WebFetch cache freshness, seconds (default 24h — pages stale faster than docs). |
215
281
  | `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
282
  | `CLAUDE_PROJECT_DIR` | Overrides the project root used to hash the out-of-tree state dir (`src/runtime/paths.ts:20`). |
283
+ | `FUSE_HARNESS_SOUND` | **On by default.** Set to `0` to disable every lifecycle notification sound. |
284
+ | `FUSE_HARNESS_SOUND_STOP` / `_PERMISSION` / `_HUMAN` | Per-kind override: an absolute path to a sound file to play instead of the bundled `assets/song/*.mp3` for that kind (see [Notification sounds](#notification-sounds)). |
217
285
 
218
286
  ## Library usage
219
287
 
@@ -286,9 +354,10 @@ Run `bun run docs:api` for the generated typedoc API reference.
286
354
 
287
355
  ## Known limitations
288
356
 
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)).
357
+ - **Codex `apply_patch` enforcement depends on Codex itself.** The harness
358
+ parses the patch and emits the right verdict per file, but Codex does not
359
+ always enforce a correct deny on its end (openai/codex#27833, see
360
+ [Compatibility](#compatibility)) — enforcement beyond the verdict is theirs.
292
361
  - **Cursor file edits are advisory-only.** `afterFileEdit` fires after the edit
293
362
  already happened — a platform limit, not something this harness can work around.
294
363
  - **Hook fan-out is mitigated, not eliminated.** The ~11-sibling-plugin burst is
@@ -306,7 +375,7 @@ Run `bun run docs:api` for the generated typedoc API reference.
306
375
  ## Develop
307
376
 
308
377
  ```sh
309
- bun test # 484 tests (94 files)
378
+ bun test # 662 tests (120 files)
310
379
  bunx tsc --noEmit # typecheck (isolatedDeclarations)
311
380
  bun run build # dist + .d.mts via tsdown (for Node/bundler consumers)
312
381
  bun run docs:api # generate the typedoc API reference
Binary file
Binary file
Binary file
@@ -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-BmchLlp7.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-CHpb1U0A.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-CRZ_Hq33.mjs";
1
+ import { t as evaluate } from "../../evaluate-Dx6q1kmQ.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cline/index.ts
4
4
  /**
@@ -1,8 +1,8 @@
1
1
  import { f as countLines } from "../../home-state-D0RLWP8J.mjs";
2
- import { t as evaluate } from "../../evaluate-CRZ_Hq33.mjs";
2
+ import { t as evaluate } from "../../evaluate-Dx6q1kmQ.mjs";
3
3
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
4
4
  import { n as parseApplyPatch, t as isBypassPermissions } from "../../permission-mode-BN3MNgbm.mjs";
5
- import { c as readClaudeInput, i as denyResponse, r as contextResponse, s as informResponse, v as commandToString } from "../../claude-BmchLlp7.mjs";
5
+ import { c as readClaudeInput, i as denyResponse, r as contextResponse, s as informResponse, v as commandToString } from "../../claude-CHpb1U0A.mjs";
6
6
  //#region src/adapters/codex/index.ts
7
7
  /**
8
8
  * OpenAI Codex CLI adapter (hook-mode). Codex's `PreToolUse` hook (since 2026)
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CRZ_Hq33.mjs";
1
+ import { t as evaluate } from "../../evaluate-Dx6q1kmQ.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cursor/index.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CRZ_Hq33.mjs";
1
+ import { t as evaluate } from "../../evaluate-Dx6q1kmQ.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-dbvFJRuY.mjs";
1
+ import { n as readHermesInput, r as toHermesResponse, t as guard } from "../../hermes-CddsFoPN.mjs";
2
2
  export { guard, readHermesInput, toHermesResponse };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "./evaluate-CRZ_Hq33.mjs";
1
+ import { t as evaluate } from "./evaluate-Dx6q1kmQ.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,10 +2,10 @@
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-BJH3Wx_b.mjs";
5
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-B9Br7SEr.mjs";
6
6
  import { n as writeInitFile, t as initFor } from "../run-Do2JltgU.mjs";
7
- import { J as runningVersion, Lt as todayUtc, Y as versionBanner, q as runDoctor, t as handleHook } from "../handle-D8rvbAfw.mjs";
8
- import { p as readStdin$1 } from "../claude-BmchLlp7.mjs";
7
+ import { $ as versionBanner, Q as runningVersion, Rt as todayUtc, Z as runDoctor, t as handleHook } from "../handle-CVk0NlTg.mjs";
8
+ import { p as readStdin$1 } from "../claude-CHpb1U0A.mjs";
9
9
  import { delimiter, join } from "node:path";
10
10
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
@@ -1,2 +1,2 @@
1
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-BJH3Wx_b.mjs";
1
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-B9Br7SEr.mjs";
2
2
  export { checkStaged, stagedContent, stagedFiles };
@@ -870,6 +870,43 @@ function buildNeverApprovalPrompt(cmd) {
870
870
  };
871
871
  }
872
872
  //#endregion
873
+ //#region src/policy/git-gates.ts
874
+ /**
875
+ * Evaluate the two git confirmation gates — destructive block ({@link
876
+ * GIT_BLOCKED}) and confirmation ask ({@link GIT_ASK}) — against `command`.
877
+ * Skipped entirely when `ralphSafe` is true: Ralph mode already silent-
878
+ * allowed the command upstream (parity with evaluate.ts's pre-existing
879
+ * `!ralphSafe &&` guards). Returns the matching gate's {@link PolicyResult},
880
+ * or `null` when neither gate matches.
881
+ */
882
+ function evaluateGitGates(command, ralphSafe) {
883
+ if (ralphSafe || !command) return null;
884
+ if (matchPatterns(command, GIT_BLOCKED)) {
885
+ const reason = `Destructive git command: ${command}`;
886
+ return {
887
+ decision: "deny",
888
+ message: reason,
889
+ prompt: {
890
+ kind: "block",
891
+ title: "Destructive git command",
892
+ reason,
893
+ actions: ["Use a non-destructive alternative (e.g. --force-with-lease; avoid --hard / -D)"]
894
+ }
895
+ };
896
+ }
897
+ if (matchPatterns(command, GIT_ASK)) return {
898
+ decision: "deny",
899
+ message: `Git operation requires confirmation: ${command}`,
900
+ prompt: {
901
+ kind: "ask",
902
+ title: "Confirm git operation",
903
+ reason: `Authorize: ${command.trim()}`,
904
+ actions: ["Approve if this git operation is intended"]
905
+ }
906
+ };
907
+ return null;
908
+ }
909
+ //#endregion
873
910
  //#region src/policy/evaluate.ts
874
911
  /**
875
912
  * Evaluate a single tool-use against the bundled policies, returning a pure
@@ -893,29 +930,8 @@ function evaluate(ctx) {
893
930
  prompt
894
931
  };
895
932
  }
896
- if (!ralphSafe && ctx.command && matchPatterns(ctx.command, GIT_BLOCKED)) {
897
- const reason = `Destructive git command: ${ctx.command}`;
898
- return {
899
- decision: "deny",
900
- message: reason,
901
- prompt: {
902
- kind: "block",
903
- title: "Destructive git command",
904
- reason,
905
- actions: ["Use a non-destructive alternative (e.g. --force-with-lease; avoid --hard / -D)"]
906
- }
907
- };
908
- }
909
- if (!ralphSafe && ctx.command && matchPatterns(ctx.command, GIT_ASK)) return {
910
- decision: "deny",
911
- message: `Git operation requires confirmation: ${ctx.command}`,
912
- prompt: {
913
- kind: "ask",
914
- title: "Confirm git operation",
915
- reason: `Authorize: ${ctx.command.trim()}`,
916
- actions: ["Approve if this git operation is intended"]
917
- }
918
- };
933
+ const gitGate = evaluateGitGates(ctx.command, ralphSafe);
934
+ if (gitGate) return gitGate;
919
935
  if (ctx.filePath && isFileSizeScoped(ctx.filePath) && ctx.agentType !== "Explore" && ctx.agentType !== "Plan") {
920
936
  const incoming = ctx.content !== void 0 ? countLines(ctx.content) : 0;
921
937
  const existing = ctx.existingLines ?? 0;