@fusengine/harness 0.1.57 → 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
@@ -125,8 +125,8 @@ 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** | Reuses the Claude reader/response shapeCodex's hook wire format matches Claude's (`src/adapters/codex/index.ts:1-8`). Bash is gated reliably. | Not wired by `harness init codex` (PreToolUse `Bash\|apply_patch` + PostToolUse only, `src/init/templates.ts:29-38`) | The SOLID/file-size gate covers `apply_patch` edits at **0%** it keys off `tool_input.file_path` (Write/Edit shape), which Codex's diff-carrying `apply_patch` call never supplies (`codex/index.ts:6-8`). Codex also **parses but does not honor** `permissionDecision:"ask"` — deny-only (`codex/index.ts:6`). |
129
- | **cursor** | `beforeShellExecution` can deny/ask (shell only, `cursor/index.ts:16-21`) | none | `afterFileEdit` is **observe-only** — "Cursor cannot block here" (`cursor/index.ts:23-24`); a file-edit violation is logged, never prevented (platform limit, not a bug here). |
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
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. |
132
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`). |
@@ -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 { J as runningVersion, Lt as todayUtc, Y as versionBanner, q as runDoctor, t as handleHook } from "../handle-DY79I4a8.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
  {
@@ -4,7 +4,7 @@ import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
5
  import { A as detectCreationIntent, E as isExcludedSwiftPath, F as docConsultedGate, H as detectProjectType$1, I as evaluateApex, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, S as usesTailwindUtilities, T as isExcludedJsPath, V as detectModularArchitecture, W as requiredArchSkill, _ as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "./validate-DCQ8dkdL.mjs";
6
6
  import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-D0RLWP8J.mjs";
7
- import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-CNAzgxnN.mjs";
7
+ import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-BgFLlqrs.mjs";
8
8
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
9
9
  import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
10
10
  import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhItkBzk.mjs";
@@ -13,8 +13,9 @@ import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as se
13
13
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
14
14
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
15
15
  import { a as writeLastNonce, c as recordAgent, d as recordRefRead, f as recordTarget, h as apexAuthorizationGate, i as verifyTrack, l as recordBrainstormRequired, m as trivialCount, n as saveTrack, o as agentsFresh, p as recordTrivialEdit, r as signTrack, s as emptyTrack, t as loadTrack, u as recordDoc } from "./store-CQ4roWrU.mjs";
16
- import { d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-BxC9semG.mjs";
17
- import { r as toHermesResponse } from "./hermes-DWXCRFZU.mjs";
16
+ import { t as parseApplyPatch } from "./apply-patch-CIS2EZ_q.mjs";
17
+ import { d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-BLuab-tR.mjs";
18
+ import { r as toHermesResponse } from "./hermes-CyhCYqD7.mjs";
18
19
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
19
20
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
21
  import { homedir } from "node:os";
@@ -86,15 +87,31 @@ function normalizeEvent(id, payload) {
86
87
  }
87
88
  const event = str(payload.hook_event_name) ?? "";
88
89
  const input = payload.tool_input ?? payload;
89
- return {
90
+ const tool = str(payload.tool_name) ?? "";
91
+ const base = {
90
92
  phase: /post|after/i.test(event) ? "post" : "pre",
91
- tool: str(payload.tool_name) ?? "",
93
+ tool,
92
94
  input,
93
95
  sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
96
+ agentType: str(payload.agent_type) ?? str(input.subagent_type)
97
+ };
98
+ if (tool === "apply_patch") {
99
+ const files = parseApplyPatch(str(input.command) ?? str(payload.command) ?? "").map((f) => ({
100
+ filePath: f.path,
101
+ content: f.content,
102
+ op: f.op
103
+ }));
104
+ return {
105
+ ...base,
106
+ phase: "pre",
107
+ files: files.length > 0 ? files : void 0
108
+ };
109
+ }
110
+ return {
111
+ ...base,
94
112
  filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
95
113
  content: str(input.content) ?? str(input.new_string),
96
- command: str(input.command) ?? str(payload.command),
97
- agentType: str(payload.agent_type) ?? str(input.subagent_type)
114
+ command: str(input.command) ?? str(payload.command)
98
115
  };
99
116
  }
100
117
  //#endregion
@@ -329,6 +346,95 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
329
346
  }
330
347
  return false;
331
348
  }
349
+ //#endregion
350
+ //#region src/runtime/inject-dedup-exclusive.ts
351
+ /**
352
+ * @module inject-dedup-exclusive
353
+ * Cross-process-exact-once cooldown gate, via EXCLUSIVE file creation.
354
+ *
355
+ * {@link module:inject-dedup.oncePerWindow}'s shared-JSON read-modify-write is
356
+ * best-effort under true concurrency: the ~11-process plugin hook fan-out for
357
+ * one Claude event can lose an update and let 2-3 siblings all observe "not
358
+ * seen yet" (lesson 2026-07-05 16:00 — the `saveTrack` lost-update race, same
359
+ * shape). `writeFileSync(path, data, { flag: "wx" })` sidesteps this: the OS
360
+ * guarantees exclusive creation is atomic, so of N concurrent siblings calling
361
+ * this for the SAME key, exactly one observes success and the rest get
362
+ * `EEXIST` — never a double-win, with no lock file or retry loop needed.
363
+ *
364
+ * One marker file per key (not a shared map) is the tradeoff for that
365
+ * guarantee. A bounded sweep on every call deletes markers older than
366
+ * `windowMs` so the directory never grows unbounded under many distinct keys.
367
+ *
368
+ * Reserve this for HIGH-CONCURRENCY callers on a short burst window (same
369
+ * tool-use/lifecycle event fanned out to every installed plugin) — e.g. the
370
+ * sniper reminder ({@link module:lifecycle/track-changes}) and compliance
371
+ * notices ({@link module:notices}). Low-frequency, long-window callers (e.g.
372
+ * the 30-min lessons Stop-reminder cooldown) are fine on the JSON mode: a
373
+ * single real Stop event per session is not concurrent with itself the way
374
+ * one PostToolUse's ~11 sibling hooks are within the same 2s burst.
375
+ * @packageDocumentation
376
+ */
377
+ /** Subdirectory (under the state dir) holding one marker file per dedup key. */
378
+ const EXCLUSIVE_SUBDIR = "inject-dedup-locks";
379
+ /**
380
+ * Filesystem-safe, collision-resistant basename for `key`. Full MD5 hex
381
+ * (unlike {@link module:util/json-io.hashText}'s 8-char truncation) — a
382
+ * collision here would silently merge two unrelated keys' exclusivity.
383
+ */
384
+ function lockFileName(key) {
385
+ return `${createHash("md5").update(key).digest("hex")}.lock`;
386
+ }
387
+ /**
388
+ * Delete marker files older than `windowMs` in `dir` — O(n) per call, bounds
389
+ * directory growth. Compares against the creation timestamp STORED IN the
390
+ * marker's content (written by {@link onceExclusive} below), not the file's
391
+ * fs `mtime`: callers may pass a fake logical `now` (tests), which would never
392
+ * agree with the real OS clock backing `mtime`.
393
+ */
394
+ function sweepExclusiveDir(dir, now, windowMs) {
395
+ let entries;
396
+ try {
397
+ entries = readdirSync(dir);
398
+ } catch {
399
+ return;
400
+ }
401
+ for (const entry of entries) {
402
+ const path = join(dir, entry);
403
+ try {
404
+ const createdAt = Number(readFileSync(path, "utf8"));
405
+ if (!Number.isFinite(createdAt) || now - createdAt >= windowMs) unlinkSync(path);
406
+ } catch {}
407
+ }
408
+ }
409
+ /**
410
+ * Cooldown gate via exclusive marker-file creation. Returns `true` exactly
411
+ * once per `key` within `windowMs` across ALL concurrent processes sharing
412
+ * `opts.dir` (the caller MAY emit), `false` for every other concurrent or
413
+ * subsequent call inside the same window (the caller SHOULD suppress).
414
+ *
415
+ * Fails open on any unwritable state dir or unexpected fs error: the emission
416
+ * is allowed rather than silently dropped.
417
+ * @param key - Stable identity of the block (same semantics as {@link module:inject-dedup.oncePerWindow}).
418
+ * @param windowMs - Suppression window in ms (also the sweep threshold).
419
+ * @param opts - Optional clock + state-dir overrides (for tests).
420
+ * @returns `true` to proceed/emit, `false` to suppress.
421
+ */
422
+ function onceExclusive(key, windowMs, opts = {}) {
423
+ const now = opts.now ?? Date.now();
424
+ const dir = join(opts.dir ?? defaultStateDir(), EXCLUSIVE_SUBDIR);
425
+ try {
426
+ mkdirSync(dir, { recursive: true });
427
+ } catch {
428
+ return true;
429
+ }
430
+ sweepExclusiveDir(dir, now, windowMs);
431
+ try {
432
+ writeFileSync(join(dir, lockFileName(key)), String(now), { flag: "wx" });
433
+ return true;
434
+ } catch (err) {
435
+ return err.code !== "EEXIST";
436
+ }
437
+ }
332
438
  /** Sidecar basename under the per-project state dir. */
333
439
  const SIDECAR$2 = "inject-dedup.json";
334
440
  /** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
@@ -373,6 +479,24 @@ function oncePerWindow(key, windowMs, opts = {}) {
373
479
  return true;
374
480
  }
375
481
  //#endregion
482
+ //#region src/runtime/fragment-registry.ts
483
+ let registry = [];
484
+ /** Clear the registry. Call once before dispatching a hook event. */
485
+ function resetFragmentRegistry() {
486
+ registry = [];
487
+ }
488
+ /** Record one fragment's post-cap size. Called by {@link module:inject-budget.capFragment}. */
489
+ function recordFragment(label, chars) {
490
+ registry.push({
491
+ label,
492
+ chars
493
+ });
494
+ }
495
+ /** Snapshot of every fragment recorded since the last {@link resetFragmentRegistry}. */
496
+ function fragmentRegistry() {
497
+ return [...registry];
498
+ }
499
+ //#endregion
376
500
  //#region src/runtime/inject-budget.ts
377
501
  /**
378
502
  * @module inject-budget
@@ -411,7 +535,10 @@ const FRAGMENT_CHAR_CAP = 8e3;
411
535
  * @returns `text` unchanged, or a truncated copy ending in the cut notice — always ≤ the cap.
412
536
  */
413
537
  function capFragment(label, text) {
414
- if (text.length <= 8e3) return text;
538
+ if (text.length <= 8e3) {
539
+ recordFragment(label, text.length);
540
+ return text;
541
+ }
415
542
  const totalLen = text.length;
416
543
  const safeLabel = label.length <= 80 ? label : `${label.slice(0, 77)}...`;
417
544
  const suffixFor = (keptLen) => `\n[truncated ${safeLabel}: kept ${keptLen} of ${totalLen} chars — source file unchanged]`;
@@ -420,7 +547,9 @@ function capFragment(label, text) {
420
547
  const slice = text.slice(0, budget);
421
548
  const lastNl = slice.lastIndexOf("\n");
422
549
  const kept = (lastNl > 0 ? slice.slice(0, lastNl) : slice).trimEnd();
423
- return kept + suffixFor(kept.length);
550
+ const result = kept + suffixFor(kept.length);
551
+ recordFragment(label, result.length);
552
+ return result;
424
553
  }
425
554
  /**
426
555
  * One-line numeric recap of what a batch of fragments actually injected —
@@ -1389,13 +1518,15 @@ const BURST_DEDUP_MS = 2e3;
1389
1518
  * nothing surfaced skill credits, freshness, or the sniper reminder to the human.
1390
1519
  *
1391
1520
  * The text builders are pure. `refCreditNoticeFor` is the one exception — like
1392
- * `pre-allow.ts`/`track-changes.ts` already do, it calls the existing
1393
- * {@link oncePerWindow} file-backed cooldown gate directly, so a caller can drop
1394
- * it straight into a PostToolUse loop without re-deriving the dedup. Rendering
1395
- * onto a harness's native stdout always goes through the existing adapter
1396
- * helpers (`respond`/`attachSystemMessage`); a harness with no `systemMessage`
1397
- * channel (e.g. cline) silently drops the notice there (documented no-op, never
1398
- * a crash) nothing in this module renders directly.
1521
+ * `pre-allow.ts` uses the JSON `oncePerWindow`, this one uses the exclusive
1522
+ * {@link onceExclusive} mode instead: it fires on the same short burst window
1523
+ * as `track-changes.ts`'s sniper reminder, fanned out across every installed
1524
+ * plugin's PostToolUse hook for ONE real edit, so the same lost-update race
1525
+ * applies (lesson 2026-07-05 16:00). Rendering onto a harness's native stdout
1526
+ * always goes through the existing adapter helpers (`respond`/
1527
+ * `attachSystemMessage`); a harness with no `systemMessage` channel (e.g.
1528
+ * cline) silently drops the notice there (documented no-op, never a crash) —
1529
+ * nothing in this module renders directly.
1399
1530
  */
1400
1531
  /** One compliance line: `✓ <gate> — <detail>` (detail omitted when empty). */
1401
1532
  function complianceNotice(gate, detail) {
@@ -1439,7 +1570,7 @@ function refCreditNoticeFor(activities, sessionId, now, dir) {
1439
1570
  if (a.kind !== "ref" || !a.path) continue;
1440
1571
  const notice = refCreditedNotice(a.path);
1441
1572
  if (!notice) continue;
1442
- if (!oncePerWindow(`ref-credited:${sessionId}:${a.path}`, 2e3, {
1573
+ if (!onceExclusive(`ref-credited:${sessionId}:${a.path}`, 2e3, {
1443
1574
  now,
1444
1575
  dir
1445
1576
  })) continue;
@@ -1482,7 +1613,7 @@ function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Dat
1482
1613
  lastCheck: new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z")
1483
1614
  };
1484
1615
  saveSessionState(sid, state, home);
1485
- if (!oncePerWindow(`sniper:${sid}:${filePath}`, 2e3, {
1616
+ if (!onceExclusive(`sniper:${sid}:${filePath}`, 2e3, {
1486
1617
  now,
1487
1618
  dir: sessionsDir(home)
1488
1619
  })) return "";
@@ -6125,6 +6256,93 @@ function reconcileRefReadsFromTranscript(track, transcriptPath, now) {
6125
6256
  return next;
6126
6257
  }
6127
6258
  //#endregion
6259
+ //#region src/freshness/ref-journal.ts
6260
+ /**
6261
+ * Append-only journal of `.md` reference reads — the FRESH, race-immune companion
6262
+ * to {@link reconcileRefReadsFromTranscript}.
6263
+ *
6264
+ * WHY a third source (track + transcript were not enough for teammates): the live
6265
+ * session track write is a non-atomic load→mutate→save, so under the multi-plugin
6266
+ * hook fan-out (one process per plugin, ×N) a lone `refsRead` write is clobbered
6267
+ * (lost update). The transcript reconcile recovers the LEAD's lost reads because,
6268
+ * by the time the lead edits, its Read has flushed to the platform transcript. But
6269
+ * the platform flushes that JSONL to disk with a MULTI-MINUTE lag (measured ~230s,
6270
+ * well past the 120s freshness TTL): a background TEAMMATE reads the exact listed
6271
+ * ref then edits within seconds — far faster than the flush — so the transcript on
6272
+ * disk does NOT yet contain the teammate's read, reconcile misses it, and (its live
6273
+ * track write having been lost to the fan-out) solidReadGate blocks despite a
6274
+ * genuine read. That is the "teammate solidRead" gap.
6275
+ *
6276
+ * This journal closes it: every credited `.md` read is appended (one JSON line) the
6277
+ * instant PostToolUse fires — O_APPEND is per-write atomic, so concurrent fan-out
6278
+ * processes each add their own line and none is lost, and there is no flush lag. The
6279
+ * gate folds it back BEFORE any refsRead consumer, alongside the transcript.
6280
+ *
6281
+ * ANTI-FORGERY: it lives in the out-of-tree state dir the protected-path guard
6282
+ * denies agents from writing (SAME boundary as the signed track); the only writer is
6283
+ * our PostToolUse on a real `.md` Read. COST: the gate reads it once per edit, bounded
6284
+ * by {@link appendRefRead}'s trim cap — cheaper than the multi-MB transcript parse.
6285
+ */
6286
+ /** Journal filename inside the per-session state dir. */
6287
+ const JOURNAL = "refs-read.log";
6288
+ /**
6289
+ * Append a credited `.md` read to the state-dir journal (O_APPEND, atomic under
6290
+ * the fan-out). No-op for non-`.md` paths (parity with the reconcile filter).
6291
+ * Fully fail-open: a mkdir/append error is swallowed so recording never blocks the
6292
+ * PostToolUse path.
6293
+ * @param dir - Per-session state dir (`dirname(trackFile)`).
6294
+ * @param path - The read file's absolute path.
6295
+ * @param ts - The tool event's epoch-ms timestamp.
6296
+ */
6297
+ function appendRefRead(dir, path, ts) {
6298
+ if (!path.endsWith(".md")) return;
6299
+ try {
6300
+ mkdirSync(dir, { recursive: true });
6301
+ const file = join(dir, JOURNAL);
6302
+ appendFileSync(file, JSON.stringify({
6303
+ p: path,
6304
+ t: ts
6305
+ }) + "\n", "utf-8");
6306
+ trimLogFile(file, 128 * 1024, 1e3);
6307
+ } catch {}
6308
+ }
6309
+ /**
6310
+ * Fold every `.md` read in the state-dir journal into `track` (immutably), each
6311
+ * stamped with its journalled timestamp and never rolling back a MORE-recent
6312
+ * existing stamp — identical merge semantics to
6313
+ * {@link reconcileRefReadsFromTranscript}, but from the fresh append-only journal
6314
+ * instead of the lagged transcript. Fail-open: an absent/unreadable journal returns
6315
+ * `track` unchanged (same reference).
6316
+ * @param track - The current (possibly race-damaged) session track.
6317
+ * @param dir - Per-session state dir (`dirname(trackFile)`).
6318
+ * @param now - Fallback epoch-ms for entries with an invalid timestamp.
6319
+ * @returns The track with journalled `.md` reads merged into `refsRead`/`refsReadAt`.
6320
+ */
6321
+ function reconcileRefReadsFromJournal(track, dir, now) {
6322
+ let text;
6323
+ try {
6324
+ text = readText(join(dir, JOURNAL));
6325
+ } catch {
6326
+ return track;
6327
+ }
6328
+ let next = track;
6329
+ for (const line of text.split("\n")) {
6330
+ if (!line.trim()) continue;
6331
+ let entry;
6332
+ try {
6333
+ entry = JSON.parse(line);
6334
+ } catch {
6335
+ continue;
6336
+ }
6337
+ const path = typeof entry.p === "string" ? entry.p : "";
6338
+ if (!path.endsWith(".md")) continue;
6339
+ const ts = typeof entry.t === "number" && Number.isFinite(entry.t) ? entry.t : now;
6340
+ const prev = next.refsReadAt?.[path];
6341
+ if (prev === void 0 || prev < ts) next = recordRefRead(next, path, ts);
6342
+ }
6343
+ return next;
6344
+ }
6345
+ //#endregion
6128
6346
  //#region src/policy/shadcn-skill-gate.ts
6129
6347
  /** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
6130
6348
  const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
@@ -6586,7 +6804,7 @@ async function runGates(input) {
6586
6804
  if (modular) return modular;
6587
6805
  if (!input.filePath) return null;
6588
6806
  const filePath = input.filePath;
6589
- const track = reconcileRefReadsFromTranscript(await loadTrack(input.trackFile), input.transcriptPath, input.now);
6807
+ const track = reconcileRefReadsFromJournal(reconcileRefReadsFromTranscript(await loadTrack(input.trackFile), input.transcriptPath, input.now), dirname(input.trackFile), input.now);
6590
6808
  const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
6591
6809
  if (solidOrSkill) return solidOrSkill;
6592
6810
  if (isShadcnWrite(input.tool, filePath)) {
@@ -6729,6 +6947,7 @@ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
6729
6947
  async function recordActivity(file, activity) {
6730
6948
  const track = await loadTrack(file);
6731
6949
  await saveTrack(file, activity.kind === "agent" ? recordAgent(track, activity.name, activity.ts, activity.quality) : activity.kind === "doc" ? recordDoc(track, activity.framework, activity.sessionId, activity.source, activity.ts) : recordRefRead(track, activity.path, activity.ts));
6950
+ if (activity.kind === "ref") appendRefRead(dirname(file), activity.path, activity.ts ?? Date.now());
6732
6951
  }
6733
6952
  //#endregion
6734
6953
  //#region src/runtime/respond.ts
@@ -6769,6 +6988,7 @@ function respond(id, prompt) {
6769
6988
  case "codex":
6770
6989
  if (kind === "block") return denyResponse("PreToolUse", message);
6771
6990
  if (kind === "inform") return userMessage ? informResponse("PreToolUse", userMessage, reason ? message : "") : contextResponse("PreToolUse", message);
6991
+ if (id === "codex") return denyResponse("PreToolUse", `[downgraded from ask — Codex has no interactive approval]\n${message}`);
6772
6992
  return JSON.stringify({ hookSpecificOutput: {
6773
6993
  hookEventName: "PreToolUse",
6774
6994
  permissionDecision: "ask",
@@ -7314,6 +7534,47 @@ async function allowOutcome(id, event, payload, mcpDir, cwd, evidence) {
7314
7534
  };
7315
7535
  }
7316
7536
  //#endregion
7537
+ //#region src/runtime/apply-patch-gate.ts
7538
+ /**
7539
+ * OR the static per-file verdict for a Codex `apply_patch` envelope: run the
7540
+ * file-level gates (protected-path, SOLID file-size, DRY) that key off
7541
+ * `filePath`/`content` over EACH touched file and return the first blocking
7542
+ * {@link Prompt}. One violating hunk blocks the whole patch — the parity the
7543
+ * single-file `Write`/`Edit` path already has, extended to the multi-file
7544
+ * primitive.
7545
+ *
7546
+ * Only the read-only/pure gates run here (no session-state writes), so the
7547
+ * `~11×` hook fan-out stays idempotent — the stateful APEX freshness/skill
7548
+ * gates are tool-level and never policed `apply_patch` (its `filePath` was
7549
+ * always undefined), so they are intentionally out of scope.
7550
+ *
7551
+ * File-size tool mapping mirrors Claude: an `add` is judged like a `Write`
7552
+ * (full new content), an `update` like an `Edit` (partial content, compared
7553
+ * against the on-disk count so an already-oversized file still blocks).
7554
+ * @param files - Per-file changes from {@link NormalizedFile}.
7555
+ * @param cwd - Project root for the DRY codebase grep.
7556
+ * @returns The first blocking prompt, or null when every file passes.
7557
+ */
7558
+ function applyPatchGate(files, cwd) {
7559
+ for (const f of files) {
7560
+ const tool = f.op === "add" ? "Write" : "Edit";
7561
+ const protectedDeny = protectedPathGate(tool, f.filePath);
7562
+ if (protectedDeny) return protectedDeny;
7563
+ if (f.op === "delete") continue;
7564
+ const { raw: existingLines } = existingLineCounts(f.filePath);
7565
+ const quick = evaluate({
7566
+ tool,
7567
+ filePath: f.filePath,
7568
+ content: f.content,
7569
+ existingLines
7570
+ });
7571
+ if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
7572
+ const dry = dryGate(tool, f.filePath, f.content, cwd);
7573
+ if (dry) return dry;
7574
+ }
7575
+ return null;
7576
+ }
7577
+ //#endregion
7317
7578
  //#region src/runtime/handle-pre.ts
7318
7579
  /**
7319
7580
  * Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX
@@ -7357,6 +7618,13 @@ async function handlePre(ctx) {
7357
7618
  exit: 0
7358
7619
  };
7359
7620
  }
7621
+ if (event.files && event.files.length > 0) {
7622
+ const patchPrompt = applyPatchGate(event.files, opts.cwd);
7623
+ if (patchPrompt) return {
7624
+ stdout: respond(id, patchPrompt),
7625
+ exit: 0
7626
+ };
7627
+ }
7360
7628
  const prompt = await gate({
7361
7629
  sessionId: event.sessionId,
7362
7630
  framework,
@@ -7670,6 +7938,51 @@ async function asyncScopeStdout(scope, event, payload, cwd, now) {
7670
7938
  return null;
7671
7939
  }
7672
7940
  //#endregion
7941
+ //#region src/runtime/inject-budget-recap.ts
7942
+ /**
7943
+ * @module inject-budget-recap
7944
+ * ONE event-level recap of every {@link module:inject-budget.capFragment}
7945
+ * -tracked fragment injected for a SessionStart/SubagentStart event — the
7946
+ * aggregated view the per-fragment caps never gave: each injection point
7947
+ * (dev-context, snapshot sections, lessons, apex-subagent, ...) caps and
7948
+ * reports itself in ISOLATION, with no total across the whole event.
7949
+ *
7950
+ * Rides the user-visible `systemMessage` channel, Claude-Code-only (gate at
7951
+ * the call site — mirrors the existing `id === "claude-code"` pattern for
7952
+ * `designLifecycle` in handle.ts): other adapters do not all re-export
7953
+ * `attachSystemMessage`, and stamping a Claude-shaped envelope onto another
7954
+ * harness's stdout shape would be silently wrong there.
7955
+ *
7956
+ * Deduped via {@link module:inject-dedup.onceExclusive}, not the JSON
7957
+ * `oncePerWindow`: SessionStart/SubagentStart fan out across every installed
7958
+ * plugin exactly like PostToolUse does (see burst-window.ts) — a shared-JSON
7959
+ * read-modify-write here would risk the same lost-update race already fixed
7960
+ * for the sniper reminder (lesson 2026-07-05 16:00).
7961
+ * @packageDocumentation
7962
+ */
7963
+ /**
7964
+ * Attach the aggregated recap onto `stdout` when 2+ fragments were recorded
7965
+ * for `rawEvent`. A lone fragment already carries its own visibility (its
7966
+ * producer's own per-fragment report, when it has one) — no recap is added
7967
+ * for that case, so the common single-fragment event stays noise-free.
7968
+ * @param stdout - The already-rendered hook stdout for this event.
7969
+ * @param rawEvent - The raw hook event name (only SessionStart/SubagentStart qualify; others pass through unchanged).
7970
+ * @param sessionId - Current session id (dedup scope).
7971
+ * @param cwd - Project root (state-dir scope for the dedup marker).
7972
+ * @param now - Clock.
7973
+ * @returns `stdout` with a `systemMessage` recap attached, or `stdout` unchanged.
7974
+ */
7975
+ function attachBudgetRecap(stdout, rawEvent, sessionId, cwd, now) {
7976
+ if (rawEvent !== "SessionStart" && rawEvent !== "SubagentStart") return stdout;
7977
+ const fragments = fragmentRegistry();
7978
+ if (fragments.length <= 1) return stdout;
7979
+ if (!onceExclusive(`budget:${sessionId}:${rawEvent}`, 2e3, {
7980
+ now,
7981
+ dir: defaultStateDir(cwd)
7982
+ })) return stdout;
7983
+ return attachSystemMessage(stdout, budgetReport(fragments));
7984
+ }
7985
+ //#endregion
7673
7986
  //#region src/runtime/handle.ts
7674
7987
  /** Raw Claude hook event name from a payload (empty when absent). */
7675
7988
  function rawEventName(payload) {
@@ -7683,6 +7996,7 @@ function rawEventName(payload) {
7683
7996
  */
7684
7997
  async function handleHook(id, payload, opts) {
7685
7998
  const event = normalizeEvent(id, payload);
7999
+ resetFragmentRegistry();
7686
8000
  const layout = projectLayout(opts.cwd);
7687
8001
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
7688
8002
  const mcpDir = layout.cacheDir;
@@ -7698,7 +8012,7 @@ async function handleHook(id, payload, opts) {
7698
8012
  };
7699
8013
  const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now);
7700
8014
  if (life !== null) return {
7701
- stdout: life,
8015
+ stdout: id === "claude-code" ? attachBudgetRecap(life, rawEventName(payload), event.sessionId, opts.cwd, opts.now) : life,
7702
8016
  exit: 0
7703
8017
  };
7704
8018
  const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
@@ -1,6 +1,6 @@
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
- import { c as readClaudeInput } from "./claude-BxC9semG.mjs";
3
+ import { c as readClaudeInput } from "./claude-BLuab-tR.mjs";
4
4
  //#region src/adapters/hermes/index.ts
5
5
  /**
6
6
  * Hermes Agent adapter (hook-mode). Nous Research's hermes-agent (2026) pipes a
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFi
6
6
  import { n as detectMode, r as modeFor, t as detectHarness } from "./harness-Cb9xR8dC.mjs";
7
7
  import { A as detectCreationIntent, B as DEV_KEYWORDS, C as SKILL_TRIGGERS, D as MAX_EXA_RESULTS, F as docConsultedGate, H as detectProjectType, I as evaluateApex, L as freshnessGate, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, O as MAX_TOKENS, P as brainstormGate, R as solidReadGate, S as usesTailwindUtilities, U as isApexCommand, V as detectModularArchitecture, W as requiredArchSkill, a as firstHeading, b as detectRequiredSkills, c as EXCLUDE_DIRS, d as buildApexTaskInjection, f as loadApexTaskState, g as detectClaudeMdProjectType, h as buildClaudeMdContext, i as firstComment, j as APEX_GATES, k as capVerbosity, l as PROJECT_INDICATORS, m as buildApexInstruction, n as missingSeoElements, o as parseEnrichment, p as DEV_VERBS, r as descFromText, s as parseEntry, t as isHtmlLike, u as buildApexTaskContext, v as parseBodyDesc, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "./validate-DCQ8dkdL.mjs";
8
8
  import { d as countFrameworkCodeLines, f as countLines, l as PLUGINS_DIR, p as evaluateFileSize, u as SOLID_REF } from "./home-state-D0RLWP8J.mjs";
9
- import { A as PROJECT_INSTALL, C as PROTECTED_GIT_RE, D as securityGuard, E as CRITICAL_PATTERNS, M as SYSTEM_INSTALL, N as isRalphMode, O as GIT_ASK, P as matchPatterns, S as PROTECTED_FRAGMENTS, T as ASK_PATTERNS, _ as CODE_MUTATORS, a as registerGuard, b as SAFE_PREFIXES, c as GO_DECL_RE, d as PY_MODEL_RE, f as SWIFT_PROTO_RE, g as ASK_WRITERS, h as bashWriteGuard, i as clearUserGuards, j as RALPH_SAFE, k as GIT_BLOCKED, l as JAVA_DECL_RE, m as interfaceSeparationGuard, n as FAIL_CLOSED, o as runGuards, p as TS_DECL_RE, r as GUARDS, s as installGuard, t as evaluate, u as PHP_DECL_RE, v as CODE_REDIRECT, w as protectedPathGuard, x as SESSION_STATE_FRAGMENT, y as FILE_REDIRECT } from "./evaluate-CNAzgxnN.mjs";
9
+ import { A as PROJECT_INSTALL, C as PROTECTED_GIT_RE, D as securityGuard, E as CRITICAL_PATTERNS, M as SYSTEM_INSTALL, N as isRalphMode, O as GIT_ASK, P as matchPatterns, S as PROTECTED_FRAGMENTS, T as ASK_PATTERNS, _ as CODE_MUTATORS, a as registerGuard, b as SAFE_PREFIXES, c as GO_DECL_RE, d as PY_MODEL_RE, f as SWIFT_PROTO_RE, g as ASK_WRITERS, h as bashWriteGuard, i as clearUserGuards, j as RALPH_SAFE, k as GIT_BLOCKED, l as JAVA_DECL_RE, m as interfaceSeparationGuard, n as FAIL_CLOSED, o as runGuards, p as TS_DECL_RE, r as GUARDS, s as installGuard, t as evaluate, u as PHP_DECL_RE, v as CODE_REDIRECT, w as protectedPathGuard, x as SESSION_STATE_FRAGMENT, y as FILE_REDIRECT } from "./evaluate-BgFLlqrs.mjs";
10
10
  import { i as resolveSessions, n as formatDocSatisfactionStatus, r as isDocConsulted, t as formatDocDeny } from "./doc-helpers-CWZegVdR.mjs";
11
11
  import { i as parseFrontmatter, n as scoreReferences, r as globToRe, t as routeReferences } from "./router-PKVNBHge.mjs";
12
12
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
@@ -1,5 +1,5 @@
1
1
  import { A as detectCreationIntent, B as DEV_KEYWORDS, C as SKILL_TRIGGERS, D as MAX_EXA_RESULTS, F as docConsultedGate, H as detectProjectType, I as evaluateApex, L as freshnessGate, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, O as MAX_TOKENS, P as brainstormGate, R as solidReadGate, S as usesTailwindUtilities, U as isApexCommand, V as detectModularArchitecture, W as requiredArchSkill, a as firstHeading, b as detectRequiredSkills, c as EXCLUDE_DIRS, d as buildApexTaskInjection, f as loadApexTaskState, g as detectClaudeMdProjectType, h as buildClaudeMdContext, i as firstComment, j as APEX_GATES, k as capVerbosity, l as PROJECT_INDICATORS, m as buildApexInstruction, n as missingSeoElements, o as parseEnrichment, p as DEV_VERBS, r as descFromText, s as parseEntry, t as isHtmlLike, u as buildApexTaskContext, v as parseBodyDesc, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "../validate-DCQ8dkdL.mjs";
2
2
  import { d as countFrameworkCodeLines, f as countLines, l as PLUGINS_DIR, p as evaluateFileSize, u as SOLID_REF } from "../home-state-D0RLWP8J.mjs";
3
- import { A as PROJECT_INSTALL, C as PROTECTED_GIT_RE, D as securityGuard, E as CRITICAL_PATTERNS, M as SYSTEM_INSTALL, N as isRalphMode, O as GIT_ASK, P as matchPatterns, S as PROTECTED_FRAGMENTS, T as ASK_PATTERNS, _ as CODE_MUTATORS, a as registerGuard, b as SAFE_PREFIXES, c as GO_DECL_RE, d as PY_MODEL_RE, f as SWIFT_PROTO_RE, g as ASK_WRITERS, h as bashWriteGuard, i as clearUserGuards, j as RALPH_SAFE, k as GIT_BLOCKED, l as JAVA_DECL_RE, m as interfaceSeparationGuard, n as FAIL_CLOSED, o as runGuards, p as TS_DECL_RE, r as GUARDS, s as installGuard, t as evaluate, u as PHP_DECL_RE, v as CODE_REDIRECT, w as protectedPathGuard, x as SESSION_STATE_FRAGMENT, y as FILE_REDIRECT } from "../evaluate-CNAzgxnN.mjs";
3
+ import { A as PROJECT_INSTALL, C as PROTECTED_GIT_RE, D as securityGuard, E as CRITICAL_PATTERNS, M as SYSTEM_INSTALL, N as isRalphMode, O as GIT_ASK, P as matchPatterns, S as PROTECTED_FRAGMENTS, T as ASK_PATTERNS, _ as CODE_MUTATORS, a as registerGuard, b as SAFE_PREFIXES, c as GO_DECL_RE, d as PY_MODEL_RE, f as SWIFT_PROTO_RE, g as ASK_WRITERS, h as bashWriteGuard, i as clearUserGuards, j as RALPH_SAFE, k as GIT_BLOCKED, l as JAVA_DECL_RE, m as interfaceSeparationGuard, n as FAIL_CLOSED, o as runGuards, p as TS_DECL_RE, r as GUARDS, s as installGuard, t as evaluate, u as PHP_DECL_RE, v as CODE_REDIRECT, w as protectedPathGuard, x as SESSION_STATE_FRAGMENT, y as FILE_REDIRECT } from "../evaluate-BgFLlqrs.mjs";
4
4
  import "../policy-la_KkjCS.mjs";
5
5
  export { APEX_GATES, ASK_PATTERNS, ASK_WRITERS, CODE_MUTATORS, CODE_REDIRECT, CRITICAL_PATTERNS, DEV_KEYWORDS, DEV_VERBS, EXCLUDE_DIRS, FAIL_CLOSED, FILE_REDIRECT, GIT_ASK, GIT_BLOCKED, GO_DECL_RE, GUARDS, JAVA_DECL_RE, MAX_EXA_RESULTS, MAX_TOKENS, PHP_DECL_RE, PLUGINS_DIR, POST_AUTH_GATES, PRE_AUTH_GATES, PROJECT_INDICATORS, PROJECT_INSTALL, PROTECTED_FRAGMENTS, PROTECTED_GIT_RE, PY_MODEL_RE, RALPH_SAFE, SAFE_PREFIXES, SESSION_STATE_FRAGMENT, SKILL_TRIGGERS, SOLID_REF, SWIFT_PROTO_RE, SYSTEM_INSTALL, TS_DECL_RE, bashWriteGuard, brainstormGate, buildApexInstruction, buildApexTaskContext, buildApexTaskInjection, buildClaudeMdContext, capVerbosity, clearUserGuards, countFrameworkCodeLines, countLines, descFromText, detectClaudeMdProjectType, detectCreationIntent, detectFramework, detectModularArchitecture, detectProjectType, detectRequiredSkills, docConsultedGate, evaluate, evaluateApex, evaluateFileSize, firstComment, firstHeading, frameworkSolidGate, freshnessGate, installGuard, interfaceSeparationGuard, isApexCommand, isHtmlLike, isRalphMode, loadApexTaskState, matchPatterns, missingSeoElements, parseBodyDesc, parseEnrichment, parseEntry, parseField, protectedPathGuard, registerGuard, requiredArchSkill, runGuards, securityGuard, skillTriggerGate, solidReadGate, usesTailwindUtilities };
@@ -1,5 +1,5 @@
1
1
  import { t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
2
- import { t as evaluate } from "./evaluate-CNAzgxnN.mjs";
2
+ import { t as evaluate } from "./evaluate-BgFLlqrs.mjs";
3
3
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
4
4
  import { execSync } from "node:child_process";
5
5
  //#region src/cli/run.ts
@@ -150,6 +150,12 @@ declare function dryGate(tool: string, filePath: string, content: string | undef
150
150
  declare function preCommitGate(tool: string, command: string | undefined, cwd: string | undefined): Prompt | null;
151
151
  //#endregion
152
152
  //#region src/runtime/normalize.d.ts
153
+ /** One file fanned out of a multi-file edit primitive (Codex `apply_patch`). */
154
+ interface NormalizedFile {
155
+ filePath: string;
156
+ content: string;
157
+ op: "add" | "update" | "delete";
158
+ }
153
159
  /** A hook event normalized across harnesses. */
154
160
  interface NormalizedEvent {
155
161
  phase: "pre" | "post";
@@ -161,6 +167,13 @@ interface NormalizedEvent {
161
167
  command?: string;
162
168
  /** Subagent type, if the tool-use came from one (Explore/Plan are file-size-exempt). */
163
169
  agentType?: string;
170
+ /**
171
+ * Per-file changes when the tool is a multi-file edit primitive (Codex
172
+ * `apply_patch`). Present ONLY for `apply_patch`; the file gates OR each
173
+ * entry's verdict so one violating hunk blocks the whole envelope. Left
174
+ * undefined for every other tool/harness (single-file `filePath`/`content`).
175
+ */
176
+ files?: NormalizedFile[];
164
177
  }
165
178
  /**
166
179
  * Normalize a harness hook payload into a uniform event. Handles Cline's nested
@@ -830,4 +843,4 @@ interface PreContext {
830
843
  */
831
844
  declare function handlePre(ctx: PreContext): Promise<HandleOutcome>;
832
845
  //#endregion
833
- export { Activity, DEFAULT_WINDOW_MS, DuplicationVerdict, type GateInput, HandleOptions, HandleOutcome, LifecycleInput, MCP_TTL_MS, McpIntercept, NormalizedEvent, PluginScope, PreContext, REQUIRED_AGENTS, SolidProfile, TRIVIAL_BUDGET, ToolEvent, WEBFETCH_TTL_MS, activityFor, aipilotPostToolUse, cacheQueryOf, cartoSessionStart, checkFileSize, claudeHome, claudeMdKey, cleanupSession, countFiles, countLoc, defaultStateDir, detectDuplication, detectSolidProfile, devContext, dispatchAipilot, dispatchLessons, dispatchLifecycle, dispatchMemory, dryGate, extractSymbols, fuseHarnessHome, fusengineCache, gate, generateEcosystemMap, generateProjectMap, getFileDesc, gitContext, handleHook, handlePre, harnessStateDir, injectRules, isMcpTool, isProject, isoUtc, lessonsArchiveFileFor, lessonsFileFor, lessonsStateFileFor, lifecycleStdout, listChildren, loadEnriched, loadSecurityState, loadSessionState, logToolFailure, mcpPostStore, mcpPreIntercept, mergeLines, normalizeEvent, postEditContext, postEditTypescript, postTrackingSideEffects, preCommitGate, projectContext, projectHash, promptSubmitContext, pruneEmptyDirs, purgeTtlTree, queryOf, readRules, recordActivity, removeOldFiles, respond, runSessionStartCleanups, sanitizeSessionId, saveApexState, saveSecurityState, saveSessionState, securityAdvisory, securityStateDir, securityStatePath, seoPostToolUse, seoPostToolUseResponse, sessionStartCore, sessionStatePath, sessionsDir, solidDetectStart, subagentCacheContext, taskContext, todayUtc, trackAgentMemory, trackEnrichment, trackFile, trackMcpResearch, trackSessionChanges, trackSkillRead, trackWatchResearch, trimLogFile, validateRulesLoaded, validateSolidGate, validateTailwind, validateTeammateOutput, writePluginMap, writeTree };
846
+ export { Activity, DEFAULT_WINDOW_MS, DuplicationVerdict, type GateInput, HandleOptions, HandleOutcome, LifecycleInput, MCP_TTL_MS, McpIntercept, NormalizedEvent, NormalizedFile, PluginScope, PreContext, REQUIRED_AGENTS, SolidProfile, TRIVIAL_BUDGET, ToolEvent, WEBFETCH_TTL_MS, activityFor, aipilotPostToolUse, cacheQueryOf, cartoSessionStart, checkFileSize, claudeHome, claudeMdKey, cleanupSession, countFiles, countLoc, defaultStateDir, detectDuplication, detectSolidProfile, devContext, dispatchAipilot, dispatchLessons, dispatchLifecycle, dispatchMemory, dryGate, extractSymbols, fuseHarnessHome, fusengineCache, gate, generateEcosystemMap, generateProjectMap, getFileDesc, gitContext, handleHook, handlePre, harnessStateDir, injectRules, isMcpTool, isProject, isoUtc, lessonsArchiveFileFor, lessonsFileFor, lessonsStateFileFor, lifecycleStdout, listChildren, loadEnriched, loadSecurityState, loadSessionState, logToolFailure, mcpPostStore, mcpPreIntercept, mergeLines, normalizeEvent, postEditContext, postEditTypescript, postTrackingSideEffects, preCommitGate, projectContext, projectHash, promptSubmitContext, pruneEmptyDirs, purgeTtlTree, queryOf, readRules, recordActivity, removeOldFiles, respond, runSessionStartCleanups, sanitizeSessionId, saveApexState, saveSecurityState, saveSessionState, securityAdvisory, securityStateDir, securityStatePath, seoPostToolUse, seoPostToolUseResponse, sessionStartCore, sessionStatePath, sessionsDir, solidDetectStart, subagentCacheContext, taskContext, todayUtc, trackAgentMemory, trackEnrichment, trackFile, trackMcpResearch, trackSessionChanges, trackSkillRead, trackWatchResearch, trimLogFile, validateRulesLoaded, validateSolidGate, validateTailwind, validateTeammateOutput, writePluginMap, writeTree };
@@ -1,6 +1,6 @@
1
1
  import { r as projectLayout } from "../layout-C0jaaCQC.mjs";
2
2
  import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-D0RLWP8J.mjs";
3
- import { $ as postEditTypescript, A as trackSkillRead, At as trackFile, B as isProject, C as seoPostToolUse, Ct as gitContext, D as postTrackingSideEffects, Dt as taskContext, E as securityAdvisory, Et as promptSubmitContext, F as dispatchLessons, Ft as securityStateDir, G as getFileDesc, H as loadEnriched, I as cartoSessionStart, It as securityStatePath, K as listChildren, L as generateEcosystemMap, Lt as todayUtc, M as dispatchLifecycle, Mt as isoUtc, N as aipilotPostToolUse, Nt as loadSecurityState, O as trackWatchResearch, Ot as defaultStateDir, P as dispatchAipilot, Pt as saveSecurityState, Q as lessonsStateFileFor, R as writePluginMap, S as postEditContext, St as devContext, T as dispatchMemory, Tt as claudeMdKey, U as mergeLines, V as writeTree, W as countFiles, X as lessonsArchiveFileFor, Z as lessonsFileFor, _ as preCommitGate, _t as sessionStartCore, a as recordActivity, at as validateTeammateOutput, b as extractSymbols, bt as removeOldFiles, c as MCP_TTL_MS, ct as validateTailwind, d as isMcpTool, dt as countLoc, et as trackSessionChanges, f as queryOf, ft as detectSolidProfile, g as gate, gt as runSessionStartCleanups, h as TRIVIAL_BUDGET, ht as readRules, i as respond, it as logToolFailure, j as trackEnrichment, jt as normalizeEvent, k as trackMcpResearch, kt as projectHash, l as WEBFETCH_TTL_MS, lt as validateSolidGate, m as REQUIRED_AGENTS, mt as injectRules, n as activityFor, nt as cleanupSession, o as mcpPostStore, ot as trackAgentMemory, p as DEFAULT_WINDOW_MS, pt as solidDetectStart, r as handlePre, rt as saveApexState, s as mcpPreIntercept, st as subagentCacheContext, t as handleHook, tt as validateRulesLoaded, u as cacheQueryOf, ut as checkFileSize, v as detectDuplication, vt as pruneEmptyDirs, w as seoPostToolUseResponse, wt as projectContext, x as lifecycleStdout, xt as trimLogFile, y as dryGate, yt as purgeTtlTree, z as generateProjectMap } from "../handle-DY79I4a8.mjs";
3
+ import { $ as postEditTypescript, A as trackSkillRead, At as trackFile, B as isProject, C as seoPostToolUse, Ct as gitContext, D as postTrackingSideEffects, Dt as taskContext, E as securityAdvisory, Et as promptSubmitContext, F as dispatchLessons, Ft as securityStateDir, G as getFileDesc, H as loadEnriched, I as cartoSessionStart, It as securityStatePath, K as listChildren, L as generateEcosystemMap, Lt as todayUtc, M as dispatchLifecycle, Mt as isoUtc, N as aipilotPostToolUse, Nt as loadSecurityState, O as trackWatchResearch, Ot as defaultStateDir, P as dispatchAipilot, Pt as saveSecurityState, Q as lessonsStateFileFor, R as writePluginMap, S as postEditContext, St as devContext, T as dispatchMemory, Tt as claudeMdKey, U as mergeLines, V as writeTree, W as countFiles, X as lessonsArchiveFileFor, Z as lessonsFileFor, _ as preCommitGate, _t as sessionStartCore, a as recordActivity, at as validateTeammateOutput, b as extractSymbols, bt as removeOldFiles, c as MCP_TTL_MS, ct as validateTailwind, d as isMcpTool, dt as countLoc, et as trackSessionChanges, f as queryOf, ft as detectSolidProfile, g as gate, gt as runSessionStartCleanups, h as TRIVIAL_BUDGET, ht as readRules, i as respond, it as logToolFailure, j as trackEnrichment, jt as normalizeEvent, k as trackMcpResearch, kt as projectHash, l as WEBFETCH_TTL_MS, lt as validateSolidGate, m as REQUIRED_AGENTS, mt as injectRules, n as activityFor, nt as cleanupSession, o as mcpPostStore, ot as trackAgentMemory, p as DEFAULT_WINDOW_MS, pt as solidDetectStart, r as handlePre, rt as saveApexState, s as mcpPreIntercept, st as subagentCacheContext, t as handleHook, tt as validateRulesLoaded, u as cacheQueryOf, ut as checkFileSize, v as detectDuplication, vt as pruneEmptyDirs, w as seoPostToolUseResponse, wt as projectContext, x as lifecycleStdout, xt as trimLogFile, y as dryGate, yt as purgeTtlTree, z as generateProjectMap } from "../handle-BdOh1jPG.mjs";
4
4
  //#region src/runtime/storage.ts
5
5
  /**
6
6
  * The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",