@fusengine/harness 0.1.41 → 0.1.43

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.
Files changed (38) hide show
  1. package/README.md +47 -0
  2. package/dist/adapters/claude/index.mjs +1 -1
  3. package/dist/adapters/cline/index.mjs +1 -1
  4. package/dist/adapters/codex/index.mjs +1 -1
  5. package/dist/adapters/cursor/index.d.mts +4 -2
  6. package/dist/adapters/cursor/index.mjs +1 -1
  7. package/dist/adapters/gemini/index.mjs +1 -1
  8. package/dist/cache/index.mjs +1 -1
  9. package/dist/{claude-BnFrSfMo.mjs → claude-CeRYMOaG.mjs} +1 -1
  10. package/dist/cli/bin.mjs +97 -4
  11. package/dist/cli/index.mjs +1 -1
  12. package/dist/config/index.d.mts +1 -1
  13. package/dist/detect/index.d.mts +2 -2
  14. package/dist/{evaluate-B1n1ti0N.mjs → evaluate-CeivW6G0.mjs} +4 -4
  15. package/dist/freshness/index.mjs +1 -1
  16. package/dist/{freshness-43gxYpiX.mjs → freshness-BV3PQkDB.mjs} +1 -1
  17. package/dist/{handle-VhWQxvyN.mjs → handle-BTHcKWQ5.mjs} +1007 -1012
  18. package/dist/{harness-DwJskkz_.d.mts → harness-BPPu5CrN.d.mts} +4 -8
  19. package/dist/{index-DTrjSNmI.d.mts → index-DN4cZDbU.d.mts} +7 -5
  20. package/dist/{index-COBvvc3L.d.mts → index-DXQfL1u8.d.mts} +1 -1
  21. package/dist/index.d.mts +4 -4
  22. package/dist/index.mjs +6 -6
  23. package/dist/init/index.d.mts +1 -1
  24. package/dist/{mcp-store-CDUVqtJ0.mjs → mcp-store-CnsW9oFj.mjs} +1 -1
  25. package/dist/memory/index.mjs +1 -1
  26. package/dist/policy/index.d.mts +2 -2
  27. package/dist/policy/index.mjs +2 -2
  28. package/dist/{registry-BkoEbdec.mjs → registry-CymilZiZ.mjs} +3 -2
  29. package/dist/{run-D-Ydrw3D.mjs → run-jgivVDv6.mjs} +1 -1
  30. package/dist/runtime/index.d.mts +13 -12
  31. package/dist/runtime/index.mjs +3 -2
  32. package/dist/state/index.mjs +1 -1
  33. package/dist/{state-BthKK4Jj.mjs → state-9lWvvWk8.mjs} +1 -1
  34. package/dist/{store-3WBr37Xz.mjs → store-CdWOQ9zD.mjs} +50 -3
  35. package/dist/tracking/index.mjs +1 -1
  36. package/dist/{validate-B3wkqEoN.mjs → validate-xi-zc-22.mjs} +1 -1
  37. package/package.json +1 -1
  38. package/dist/{json-io-CvSumjtz.mjs → json-io-DisYd2fb.mjs} +1 -1
@@ -1,67 +1,24 @@
1
- import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
1
+ import { a as parseEnvInt, r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
2
  import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
3
3
  import { t as HOME_DIR } from "./dotenv-DGyLln7U.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
5
  import { t as detectHarness } from "./harness-C8Nxxyn_.mjs";
6
- import { A as evaluateApex, E as detectCreationIntent, L as requiredArchSkill, P as detectModularArchitecture, S as frameworkSolidGate, T as capVerbosity, _ as buildClaudeMdContext, b as skillTriggerGate, c as parseBodyDesc, d as PROJECT_INDICATORS, l as parseField, n as missingSeoElements, o as parseEnrichment, p as buildApexTaskInjection, r as descFromText, s as parseEntry, t as isHtmlLike, u as EXCLUDE_DIRS$1 } from "./validate-B3wkqEoN.mjs";
7
- import { N as detectFramework, j as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-B1n1ti0N.mjs";
6
+ import { A as evaluateApex, E as detectCreationIntent, L as requiredArchSkill, P as detectModularArchitecture, S as frameworkSolidGate, T as capVerbosity, _ as buildClaudeMdContext, b as skillTriggerGate, c as parseBodyDesc, d as PROJECT_INDICATORS, l as parseField, n as missingSeoElements, o as parseEnrichment, p as buildApexTaskInjection, r as descFromText, s as parseEntry, t as isHtmlLike, u as EXCLUDE_DIRS$1 } from "./validate-xi-zc-22.mjs";
7
+ import { N as detectFramework, j as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-CeivW6G0.mjs";
8
8
  import { r as isDocConsulted } from "./doc-helpers-BhzDmJ18.mjs";
9
9
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
10
- import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-BkoEbdec.mjs";
11
- import { c as extractText, i as cacheLookupSubstring, l as loadIndex, n as webfetchCacheWrite, r as cacheLookup, t as mcpCacheWrite } from "./mcp-store-CDUVqtJ0.mjs";
12
- import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-CvSumjtz.mjs";
10
+ import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
11
+ import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-CymilZiZ.mjs";
12
+ import { c as extractText, i as cacheLookupSubstring, l as loadIndex, n as webfetchCacheWrite, r as cacheLookup, t as mcpCacheWrite } from "./mcp-store-CnsW9oFj.mjs";
13
13
  import { t as loadRefs } from "./loader-Bn-DbZmt.mjs";
14
- import { c as recordDoc, d as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as recordTrivialEdit } from "./store-3WBr37Xz.mjs";
15
- import { c as collectFiles, d as sleep, f as spawnCapture, l as pathExists, n as contextResponse, p as writeText, t as blockResponse, u as readText } from "./claude-BnFrSfMo.mjs";
14
+ import { _ as saveSessionState, c as recordDoc, d as trivialCount, f as claudeHome, g as sanitizeSessionId, h as loadSessionState, i as agentsFresh, l as recordRefRead, m as fusengineCache, n as saveTrack, o as recordAgent, p as fuseHarnessHome, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as recordTrivialEdit, y as sessionsDir } from "./store-CdWOQ9zD.mjs";
15
+ import { c as collectFiles, d as sleep, f as spawnCapture, l as pathExists, n as contextResponse, p as writeText, t as blockResponse, u as readText } from "./claude-CeRYMOaG.mjs";
16
16
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
17
17
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
- import { createHash } from "node:crypto";
20
19
  import { mkdir, rmdir } from "node:fs/promises";
20
+ import { createHash } from "node:crypto";
21
21
  import { execFileSync } from "node:child_process";
22
- //#region src/runtime/home-state.ts
23
- /** Home `~/.claude` dir (single source for every home-based hook path). */
24
- function claudeHome(home = homedir()) {
25
- return join(home, ".claude");
26
- }
27
- /** `~/.claude/fusengine-cache` base dir for legacy session/cache state. */
28
- function fusengineCache(home = homedir()) {
29
- return join(claudeHome(home), "fusengine-cache");
30
- }
31
- /** `~/.claude/fusengine-cache/sessions` — per-session JSON state dir. */
32
- function sessionsDir(home = homedir()) {
33
- return join(fusengineCache(home), "sessions");
34
- }
35
- const SID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
36
- /** Validate a session id (1-128 url-safe chars); null when invalid. */
37
- function sanitizeSessionId(sid) {
38
- const s = String(sid ?? "").trim();
39
- return SID_RE.test(s) ? s : null;
40
- }
41
- /** Unified per-session state file path: `sessions/session-<sid>.json`. */
42
- function sessionStatePath(sid, home = homedir()) {
43
- return join(sessionsDir(home), `session-${sid}.json`);
44
- }
45
- /** Load a session-state dict, or `{}` when missing/corrupt (mirrors Python). */
46
- function loadSessionState(sid, home = homedir()) {
47
- const path = sessionStatePath(sid, home);
48
- try {
49
- if (!existsSync(path)) return {};
50
- const data = JSON.parse(readFileSync(path, "utf-8"));
51
- return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
52
- } catch {
53
- return {};
54
- }
55
- }
56
- /** Atomically persist a session-state dict (0o600 via atomicWrite, indent 2). */
57
- function saveSessionState(sid, state, home = homedir()) {
58
- mkdirSync(sessionsDir(home), {
59
- recursive: true,
60
- mode: 448
61
- });
62
- atomicWrite(sessionStatePath(sid, home), JSON.stringify(state, null, 2));
63
- }
64
- //#endregion
65
22
  //#region src/runtime/lifecycle/security/skill-state.ts
66
23
  /**
67
24
  * Shared security-tracker state: per-UTC-day JSON under
@@ -97,390 +54,162 @@ function loadSecurityState(now = Date.now(), home = homedir()) {
97
54
  }
98
55
  /** Persist today's security state (indent 2, no trailing newline). */
99
56
  function saveSecurityState(state, now = Date.now(), home = homedir()) {
100
- mkdirSync(securityStateDir(home), { recursive: true });
101
- writeFileSync(securityStatePath(now, home), JSON.stringify(state, null, 2), "utf-8");
57
+ atomicWrite(securityStatePath(now, home), JSON.stringify(state, null, 2));
102
58
  }
103
59
  //#endregion
104
- //#region src/freshness/explore-tools.ts
105
- /** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
106
- const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
107
- /** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
108
- const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
109
- "mcp__context7__query-docs",
110
- "mcp__context7__resolve-library-id",
111
- "mcp__exa__web_search_exa",
112
- "mcp__exa__get_code_context_exa",
113
- "mcp__exa__deep_researcher_start",
114
- "WebSearch",
115
- "WebFetch"
116
- ]);
117
- /** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
118
- const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
119
- "grep",
120
- "rg",
121
- "find",
122
- "ls",
123
- "fd",
124
- "ast-grep",
125
- "tree",
126
- "cat",
127
- "head",
128
- "tail"
129
- ]);
130
- /** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
131
- const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
132
- /**
133
- * First non-assignment shell token's basename, or "" — mirrors Python
134
- * `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
135
- * @param cmd - Raw Bash `command` string.
136
- * @returns The executable basename, or "" when none.
137
- */
138
- function bashExecutable(cmd) {
139
- for (const token of cmd.trim().split(/\s+/)) {
140
- if (!token) continue;
141
- const last = token.split("/").pop() ?? token;
142
- if (!last.includes("=")) return last;
143
- }
144
- return "";
60
+ //#region src/runtime/normalize.ts
61
+ function str(v) {
62
+ return typeof v === "string" ? v : void 0;
145
63
  }
146
64
  /**
147
- * Classify a direct tool use into an APEX phase, or `null` when it is neither
148
- * exploration nor research. Mirrors Python `track-subagent-research._classify`.
149
- * @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
150
- * @param input - Tool input payload.
151
- * @returns The credited phase + cache flag, or `null`.
65
+ * Normalize a harness hook payload into a uniform event. Handles Cline's nested
66
+ * `preToolUse`/`postToolUse` shape and the top-level `tool_name`/`tool_input`
67
+ * shape used by Claude, Codex, Gemini, and Cursor.
152
68
  */
153
- function classifyExplore(tool, input) {
154
- if (RESEARCH_TOOLS.has(tool)) return {
155
- phase: "research-expert",
156
- cacheHit: false
157
- };
158
- if (EXPLORE_TOOLS.has(tool)) return {
159
- phase: "explore-codebase",
160
- cacheHit: false
161
- };
162
- if (tool === "Read") {
163
- const path = String(input?.file_path ?? input?.path ?? "");
164
- if (path && CACHE_READ_RE.test(path)) return {
165
- phase: "research-expert",
166
- cacheHit: true
167
- };
168
- return null;
169
- }
170
- if (tool === "Bash") {
171
- const cmd = String(input?.command ?? "").trim();
172
- if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
173
- phase: "explore-codebase",
174
- cacheHit: false
69
+ function normalizeEvent(id, payload) {
70
+ if (id === "cline") {
71
+ const post = payload.postToolUse;
72
+ const node = post ?? payload.preToolUse ?? {};
73
+ const params = node.parameters ?? {};
74
+ return {
75
+ phase: post ? "post" : "pre",
76
+ tool: str(node.toolName) ?? "",
77
+ input: params,
78
+ sessionId: str(payload.taskId) ?? "",
79
+ filePath: str(params.path),
80
+ content: str(params.content),
81
+ command: str(params.command)
175
82
  };
176
83
  }
177
- return null;
178
- }
179
- //#endregion
180
- //#region src/runtime/activity.ts
181
- /** Min response length (chars) for a lead agent call to count as `sufficient`. */
182
- const AGENT_QUALITY_MIN = 500;
183
- /** Min response length (chars) for direct exploration/research (parity Python `> 50`). */
184
- const EXPLORE_QUALITY_MIN = 50;
185
- /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
186
- const READ_TOOLS = /* @__PURE__ */ new Set([
187
- "Read",
188
- "read_file",
189
- "read_many_files"
190
- ]);
191
- /** Grade a response length against a threshold; `undefined` when length is unknown. */
192
- function qualityFor(len, min) {
193
- return len === void 0 ? void 0 : len > min ? "sufficient" : "insufficient";
194
- }
195
- /** Build an `agent` activity, attaching `quality` only when graded. */
196
- function agentActivity(name, ts, quality) {
197
- return quality ? {
198
- kind: "agent",
199
- name,
200
- ts,
201
- quality
202
- } : {
203
- kind: "agent",
204
- name,
205
- ts
84
+ const event = str(payload.hook_event_name) ?? "";
85
+ const input = payload.tool_input ?? payload;
86
+ return {
87
+ phase: /post|after/i.test(event) ? "post" : "pre",
88
+ tool: str(payload.tool_name) ?? "",
89
+ input,
90
+ sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
91
+ filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
92
+ content: str(input.content) ?? str(input.new_string),
93
+ command: str(input.command) ?? str(payload.command),
94
+ agentType: str(payload.agent_type) ?? str(input.subagent_type)
206
95
  };
207
96
  }
97
+ //#endregion
98
+ //#region src/runtime/paths.ts
208
99
  /**
209
- * The documentation source a tool satisfies for the doc-consultation gate, or
210
- * undefined. Context7/Exa are the primary sources; fuse-browser is the fallback
211
- * when Exa is down (`browser_fetch`/`browser_crawl`/`browser_serp_batch`); the
212
- * built-in WebSearch/WebFetch also count.
213
- * @param tool - The harness tool name.
100
+ * @module paths
101
+ * Runtime path helpers per-project, out-of-tree harness state.
102
+ *
103
+ * Default base: ~/.fuse-harness/state/<projectHash>/
104
+ * where projectHash = 8-char MD5 of CLAUDE_PROJECT_DIR (or cwd).
105
+ * Persistent and not world-writable (unlike /tmp), and OUTSIDE the repo so the
106
+ * agent has no "legitimate" reason to write it (the protected-path guard denies
107
+ * it, and the gate verifies freshness from the transcript, not this file).
108
+ *
109
+ * @packageDocumentation
214
110
  */
215
- function docSourceOf$1(tool) {
216
- if (/context7/i.test(tool)) return "context7";
217
- if (/exa/i.test(tool)) return "exa";
218
- if (/browser_(fetch|crawl|serp)/i.test(tool)) return "fuse-browser";
219
- if (tool === "WebSearch") return "websearch";
220
- if (tool === "WebFetch") return "webfetch";
111
+ /** Resolve the project root from the environment or fall back to cwd. */
112
+ function resolveProjectDir() {
113
+ return process.env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
221
114
  }
222
115
  /**
223
- * Map a live tool-use to the activity to record, or null when nothing is
224
- * tracked. Works across harnessestool names are globally distinct:
225
- * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
226
- * - `Task`/`Agent` + `subagent_type` (Claude/Cursor) `agent` (bare agent name)
227
- * - direct exploration/research (Glob/Grep, explore Bash, web, MCP cache reads)
228
- * → `agent` credited to the matching REQUIRED_AGENTS phase
229
- * - a read tool opening a `.md` reference → `ref`
116
+ * Compute a stable 8-char hex hash for a project directory path.
117
+ * Delegates to `hashText` (MD5, non-cryptographic used as a stable dir key only).
118
+ *
119
+ * @param projectDir - Absolute path to the project root; defaults to CLAUDE_PROJECT_DIR/cwd.
120
+ * @returns 8-char lowercase hex string.
230
121
  */
231
- function activityFor(event) {
232
- const out = [];
233
- const docSource = docSourceOf$1(event.tool);
234
- if (docSource) out.push({
235
- kind: "doc",
236
- framework: event.framework,
237
- sessionId: event.sessionId,
238
- source: docSource
239
- });
240
- if (event.tool === "Task" || event.tool === "Agent") {
241
- const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
242
- if (name) out.push(agentActivity(name, event.now, qualityFor(event.responseLength, AGENT_QUALITY_MIN)));
243
- return out;
244
- }
245
- const hit = classifyExplore(event.tool, event.input);
246
- if (hit) {
247
- const quality = hit.cacheHit ? "sufficient" : qualityFor(event.responseLength, EXPLORE_QUALITY_MIN);
248
- out.push(agentActivity(hit.phase, event.now, quality));
249
- } else if (READ_TOOLS.has(event.tool)) {
250
- const path = String(event.input?.file_path ?? event.input?.path ?? "");
251
- if (path.endsWith(".md")) out.push({
252
- kind: "ref",
253
- path
254
- });
255
- }
256
- return out;
257
- }
258
- //#endregion
259
- //#region src/runtime/mcp-key.ts
260
- /** Cache-key + tool-class helpers for the MCP/WebFetch interception pipeline. */
261
- /** Default freshness for cached MCP doc results (48h). */
262
- const MCP_TTL_MS = 1728e5;
263
- /** WebFetch freshness — pages go stale faster than docs (24h, parity with Python). */
264
- const WEBFETCH_TTL_MS = 864e5;
265
- /** WebFetch prompt slice folded into its cache key (parity with Python prompt[:500]). */
266
- const PROMPT_TRUNC = 500;
267
- /** True for the WebFetch tool (exact name). */
268
- function isWebFetch(tool) {
269
- return tool === "WebFetch";
270
- }
271
- /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
272
- function isMcpTool(tool) {
273
- return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
122
+ function projectHash$1(projectDir) {
123
+ return hashText(projectDir ?? resolveProjectDir());
274
124
  }
275
- /** The query/url that keys the cache. */
276
- function queryOf(input) {
277
- const q = input.query ?? input.url ?? input.libraryId ?? "";
278
- return typeof q === "string" ? q : JSON.stringify(q);
125
+ /**
126
+ * Canonical base directory for per-project harness state.
127
+ * Resolves to: ~/.fuse-harness/state/<projectHash>/
128
+ *
129
+ * @param projectDir - Optional override for hashing; defaults to CLAUDE_PROJECT_DIR/cwd.
130
+ * @returns Absolute directory path (not yet created on disk).
131
+ */
132
+ function defaultStateDir(projectDir) {
133
+ return join(fuseHarnessHome(), "state", projectHash$1(projectDir));
279
134
  }
280
135
  /**
281
- * The string that keys the cache for `tool`. WebFetch folds `url + "\n" +
282
- * prompt[:500]` (parity with Python) so distinct prompts on the same URL never
283
- * collide; every other tool keys on {@link queryOf}.
136
+ * Absolute path to a session's track JSON file.
137
+ *
138
+ * The session identifier is sanitised to `[A-Za-z0-9_-]` before use in the filename.
139
+ *
140
+ * @param sessionId - Claude session identifier (raw value accepted; sanitised internally).
141
+ * @param baseDir - Override the base directory. Omit in production; pass an explicit
142
+ * temp path in unit tests to avoid touching $HOME.
143
+ * @returns Absolute path, e.g. ~/.fuse-harness/state/a1b2c3d4/track-abc123.json
284
144
  */
285
- function cacheQueryOf(tool, input) {
286
- if (!isWebFetch(tool)) return queryOf(input);
287
- return `${typeof input.url === "string" ? input.url.trim() : ""}\n${(typeof input.prompt === "string" ? input.prompt : "").slice(0, PROMPT_TRUNC)}`;
145
+ function trackFile(sessionId, baseDir) {
146
+ return join(baseDir ?? defaultStateDir(), `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
288
147
  }
289
148
  //#endregion
290
- //#region src/runtime/mcp.ts
291
- function denyWith(id, content) {
292
- if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
293
- hookEventName: "PreToolUse",
294
- permissionDecision: "deny",
295
- permissionDecisionReason: content
296
- } });
297
- if (id === "gemini-cli") return JSON.stringify({
298
- decision: "deny",
299
- reason: content
300
- });
301
- return "";
302
- }
303
- function mutateWith(id, input) {
304
- if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
305
- hookEventName: "PreToolUse",
306
- permissionDecision: "allow",
307
- updatedInput: input
308
- } });
309
- if (id === "gemini-cli") return JSON.stringify({ hookSpecificOutput: { tool_input: input } });
310
- return "";
311
- }
312
- /** The doc provider a served cache-hit satisfies (`exa`/`context7`), else undefined. */
313
- function docSourceOf(tool) {
314
- if (/exa/i.test(tool)) return "exa";
315
- if (/context7/i.test(tool)) return "context7";
149
+ //#region src/policy/design/transitions.ts
150
+ /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
151
+ function detectMode(prompt, designSystemExists) {
152
+ const p = prompt.toLowerCase();
153
+ if ([
154
+ "component",
155
+ "composant",
156
+ "snippet"
157
+ ].some((k) => p.includes(k))) return "component";
158
+ return designSystemExists ? "page" : "full";
316
159
  }
317
- /**
318
- * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
319
- * else cap exa verbosity (allow + mutated input), else null to allow normally.
320
- * Harnesses without input-mutation/cache support fall through to null.
321
- */
322
- function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
323
- if (!isMcpTool(tool)) return null;
324
- const web = isWebFetch(tool);
325
- const ttl = web ? WEBFETCH_TTL_MS : ttlMs;
326
- const key = cacheQueryOf(tool, input);
327
- const cached = cacheLookup(dir, tool, key, ttl, now) ?? (web ? null : cacheLookupSubstring(dir, key, ttl, now));
328
- if (cached) {
329
- const served = denyWith(id, cached);
330
- if (served) return {
331
- stdout: served,
332
- docSource: docSourceOf(tool)
333
- };
334
- }
335
- const capped = capVerbosity(tool, input);
336
- if (capped) {
337
- const mutated = mutateWith(id, capped);
338
- if (mutated) return { stdout: mutated };
160
+ /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
161
+ function recordScreenshot(state, needed) {
162
+ const screenshotsCount = state.screenshotsCount + 1;
163
+ const next = {
164
+ ...state,
165
+ screenshotsCount
166
+ };
167
+ if (screenshotsCount >= needed && state.currentPhase < 2) {
168
+ next.currentPhase = 2;
169
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([
170
+ ...state.phasesCompleted,
171
+ "identity",
172
+ "research"
173
+ ])];
339
174
  }
340
- return null;
341
- }
342
- /**
343
- * Post-event: persist the MCP/WebFetch response (extracted to markdown). MCP docs
344
- * go through {@link mcpCacheWrite} (compact + Jaccard-dedup + `index.json`);
345
- * WebFetch uses {@link webfetchCacheWrite} (compact, exact key, no index).
346
- * @param now - Current epoch ms (timestamp source; defaults to `Date.now()`).
347
- */
348
- function mcpPostStore(tool, input, response, dir, now = Date.now()) {
349
- if (!isMcpTool(tool)) return;
350
- const text = extractText(response);
351
- const key = cacheQueryOf(tool, input);
352
- if (isWebFetch(tool)) webfetchCacheWrite(dir, tool, key, text, now);
353
- else mcpCacheWrite(dir, tool, key, text, now);
354
- }
355
- //#endregion
356
- //#region src/runtime/normalize.ts
357
- function str(v) {
358
- return typeof v === "string" ? v : void 0;
175
+ return next;
359
176
  }
360
- /**
361
- * Normalize a harness hook payload into a uniform event. Handles Cline's nested
362
- * `preToolUse`/`postToolUse` shape and the top-level `tool_name`/`tool_input`
363
- * shape used by Claude, Codex, Gemini, and Cursor.
364
- */
365
- function normalizeEvent(id, payload) {
366
- if (id === "cline") {
367
- const post = payload.postToolUse;
368
- const node = post ?? payload.preToolUse ?? {};
369
- const params = node.parameters ?? {};
370
- return {
371
- phase: post ? "post" : "pre",
372
- tool: str(node.toolName) ?? "",
373
- input: params,
374
- sessionId: str(payload.taskId) ?? "",
375
- filePath: str(params.path),
376
- content: str(params.content),
377
- command: str(params.command)
378
- };
379
- }
380
- const event = str(payload.hook_event_name) ?? "";
381
- const input = payload.tool_input ?? payload;
177
+ /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
178
+ function recordNavigate(state) {
382
179
  return {
383
- phase: /post|after/i.test(event) ? "post" : "pre",
384
- tool: str(payload.tool_name) ?? "",
385
- input,
386
- sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
387
- filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
388
- content: str(input.content) ?? str(input.new_string),
389
- command: str(input.command) ?? str(payload.command),
390
- agentType: str(payload.agent_type) ?? str(input.subagent_type)
180
+ ...state,
181
+ scrolledSinceNav: false
391
182
  };
392
183
  }
393
- //#endregion
394
- //#region src/runtime/paths.ts
395
- /**
396
- * @module paths
397
- * Runtime path helpers — per-project, out-of-tree harness state.
398
- *
399
- * Default base: ~/.claude/fuse-harness/state/<projectHash>/
400
- * where projectHash = 8-char MD5 of CLAUDE_PROJECT_DIR (or cwd).
401
- * Persistent and not world-writable (unlike /tmp), and OUTSIDE the repo so the
402
- * agent has no "legitimate" reason to write it (the protected-path guard denies
403
- * it, and the gate verifies freshness from the transcript, not this file).
404
- *
405
- * @packageDocumentation
406
- */
407
- /** Resolve the project root from the environment or fall back to cwd. */
408
- function resolveProjectDir() {
409
- return process.env["CLAUDE_PROJECT_DIR"] ?? process.cwd();
410
- }
411
- /**
412
- * Compute a stable 8-char hex hash for a project directory path.
413
- * Delegates to `hashText` (MD5, non-cryptographic — used as a stable dir key only).
414
- *
415
- * @param projectDir - Absolute path to the project root; defaults to CLAUDE_PROJECT_DIR/cwd.
416
- * @returns 8-char lowercase hex string.
417
- */
418
- function projectHash$1(projectDir) {
419
- return hashText(projectDir ?? resolveProjectDir());
420
- }
421
- /**
422
- * Canonical base directory for per-project harness state.
423
- * Resolves to: ~/.claude/fuse-harness/state/<projectHash>/
424
- *
425
- * @param projectDir - Optional override for hashing; defaults to CLAUDE_PROJECT_DIR/cwd.
426
- * @returns Absolute directory path (not yet created on disk).
427
- */
428
- function defaultStateDir(projectDir) {
429
- return join(homedir(), ".claude", "fuse-harness", "state", projectHash$1(projectDir));
430
- }
431
- /**
432
- * Absolute path to a session's track JSON file.
433
- *
434
- * The session identifier is sanitised to `[A-Za-z0-9_-]` before use in the filename.
435
- *
436
- * @param sessionId - Claude session identifier (raw value accepted; sanitised internally).
437
- * @param baseDir - Override the base directory. Omit in production; pass an explicit
438
- * temp path in unit tests to avoid touching $HOME.
439
- * @returns Absolute path, e.g. ~/.claude/fuse-harness/state/a1b2c3d4/track-abc123.json
440
- */
441
- function trackFile(sessionId, baseDir) {
442
- return join(baseDir ?? defaultStateDir(), `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
184
+ /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
185
+ function recordScroll(state) {
186
+ return {
187
+ ...state,
188
+ scrolledSinceNav: true
189
+ };
443
190
  }
444
- //#endregion
445
- //#region src/runtime/record.ts
446
- /** Apply an activity to a session's track and persist it (PostToolUse path). */
447
- async function recordActivity(file, activity) {
448
- const track = await loadTrack(file);
449
- 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) : recordRefRead(track, activity.path));
191
+ /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
192
+ function recordValidDesignSystem(state) {
193
+ return {
194
+ ...state,
195
+ designSystemExists: true,
196
+ designSystemValid: true,
197
+ currentPhase: Math.max(state.currentPhase, 3),
198
+ phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
199
+ };
450
200
  }
451
- //#endregion
452
- //#region src/runtime/respond.ts
453
201
  /**
454
- * Map a portable {@link Prompt} to a harness's native hook response. `block`
455
- * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
456
- * `ask` — they only honor deny.)
202
+ * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
203
+ * allowed); reading the inspiration catalog satisfies the browse prerequisite.
457
204
  */
458
- function respond(id, prompt) {
459
- const message = formatPrompt(prompt);
460
- const deny = prompt.kind === "block";
461
- switch (id) {
462
- case "claude-code":
463
- case "codex": return JSON.stringify({ hookSpecificOutput: {
464
- hookEventName: "PreToolUse",
465
- permissionDecision: deny ? "deny" : "ask",
466
- permissionDecisionReason: message
467
- } });
468
- case "gemini-cli": return JSON.stringify(deny ? {
469
- decision: "deny",
470
- reason: message
471
- } : { hookSpecificOutput: { additionalContext: message } });
472
- case "cursor": return JSON.stringify({
473
- permission: deny ? "deny" : "ask",
474
- continue: false,
475
- userMessage: message,
476
- agentMessage: message
477
- });
478
- case "cline": return JSON.stringify(deny ? {
479
- cancel: true,
480
- errorMessage: message
481
- } : { contextModification: message });
482
- default: return "";
205
+ function recordRead(state, filePath) {
206
+ const next = { ...state };
207
+ if (filePath.includes("identity-system")) {
208
+ next.currentPhase = Math.max(state.currentPhase, 1);
209
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
483
210
  }
211
+ if (filePath.includes("design-inspiration")) next.inspirationRead = true;
212
+ return next;
484
213
  }
485
214
  //#endregion
486
215
  //#region src/policy/design/state.ts
@@ -503,8 +232,7 @@ function loadDesignState(cacheDir, agentId) {
503
232
  }
504
233
  /** Persist the design state under its agent id. */
505
234
  function saveDesignState(cacheDir, state) {
506
- mkdirSync(cacheDir, { recursive: true });
507
- writeFileSync(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
235
+ atomicWrite(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
508
236
  }
509
237
  /** Build the initial state for a design agent starting a run. */
510
238
  function initDesignState(agentId, mode, designSystemExists) {
@@ -543,82 +271,16 @@ function cleanupDesignStates(cacheDir, agentId, stamp, now) {
543
271
  }
544
272
  }
545
273
  //#endregion
546
- //#region src/policy/design/transitions.ts
547
- /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
548
- function detectMode(prompt, designSystemExists) {
549
- const p = prompt.toLowerCase();
550
- if ([
551
- "component",
552
- "composant",
553
- "snippet"
554
- ].some((k) => p.includes(k))) return "component";
555
- return designSystemExists ? "page" : "full";
556
- }
557
- /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
558
- function recordScreenshot(state, needed) {
559
- const screenshotsCount = state.screenshotsCount + 1;
560
- const next = {
561
- ...state,
562
- screenshotsCount
563
- };
564
- if (screenshotsCount >= needed && state.currentPhase < 2) {
565
- next.currentPhase = 2;
566
- next.phasesCompleted = [.../* @__PURE__ */ new Set([
567
- ...state.phasesCompleted,
568
- "identity",
569
- "research"
570
- ])];
571
- }
572
- return next;
573
- }
574
- /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
575
- function recordNavigate(state) {
576
- return {
577
- ...state,
578
- scrolledSinceNav: false
579
- };
580
- }
581
- /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
582
- function recordScroll(state) {
583
- return {
584
- ...state,
585
- scrolledSinceNav: true
586
- };
587
- }
588
- /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
589
- function recordValidDesignSystem(state) {
590
- return {
591
- ...state,
592
- designSystemExists: true,
593
- designSystemValid: true,
594
- currentPhase: Math.max(state.currentPhase, 3),
595
- phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
596
- };
597
- }
598
- /**
599
- * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
600
- * allowed); reading the inspiration catalog satisfies the browse prerequisite.
601
- */
602
- function recordRead(state, filePath) {
603
- const next = { ...state };
604
- if (filePath.includes("identity-system")) {
605
- next.currentPhase = Math.max(state.currentPhase, 1);
606
- next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
607
- }
608
- if (filePath.includes("design-inspiration")) next.inspirationRead = true;
609
- return next;
610
- }
611
- //#endregion
612
- //#region src/policy/design/flag.ts
613
- const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
614
- /** The active design agent id (the flag), or "" when no design agent is running. */
615
- function activeDesignAgent(cacheDir) {
616
- const path = flagPath(cacheDir);
617
- if (!existsSync(path)) return "";
618
- try {
619
- return readFileSync(path, "utf8").trim();
620
- } catch {
621
- return "";
274
+ //#region src/policy/design/flag.ts
275
+ const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
276
+ /** The active design agent id (the flag), or "" when no design agent is running. */
277
+ function activeDesignAgent(cacheDir) {
278
+ const path = flagPath(cacheDir);
279
+ if (!existsSync(path)) return "";
280
+ try {
281
+ return readFileSync(path, "utf8").trim();
282
+ } catch {
283
+ return "";
622
284
  }
623
285
  }
624
286
  /** Mark a design agent active (writes its id to the flag file). */
@@ -633,402 +295,98 @@ function clearActiveDesignAgent(cacheDir) {
633
295
  } catch {}
634
296
  }
635
297
  //#endregion
636
- //#region src/policy/design/content-checks.ts
637
- /** Accessibility warnings: icon buttons need aria-label, images need alt. */
638
- function checkAccessibility(content) {
639
- const w = [];
640
- if (!/<(button|a|input|img)/.test(content)) return w;
641
- if (/<button[^>]*>/.test(content) && !/aria-label|aria-labelledby/.test(content) && /<button[^>]*>[^<]*<[^>]*Icon/.test(content)) w.push("Accessibility: icon buttons need an aria-label.");
642
- for (const m of content.matchAll(/<img[^>]*?>/g)) if (!m[0].includes("alt=")) {
643
- w.push("Accessibility: images need an alt attribute.");
644
- break;
645
- }
646
- return w;
647
- }
648
- /** Anti-pattern warnings: colored left borders, AI-slop gradients, emoji-as-icons. */
649
- function checkPatterns(content) {
650
- const w = [];
651
- if (/border-l-[0-9]+ border-l-(blue|green|red|purple)/.test(content)) w.push("Design: avoid colored left borders — use shadow/gradient.");
652
- if (/from-purple|to-purple|via-purple|from-pink.*to-purple/.test(content)) w.push("Design: avoid purple/pink gradients (AI slop) — use brand colors.");
653
- if (/>[^\x00-\x7F]+</.test(content)) w.push("Design: avoid emojis as icons — use a real icon set.");
654
- return w;
655
- }
656
- /** Forbidden-font warnings (CSS font-family + Google Fonts import). */
657
- function checkFonts(content) {
658
- const w = [];
659
- if (/font-family:\s*['"]?(Roboto|Inter|Arial|Open Sans|Lato)\b/i.test(content)) w.push("Font: forbidden family (Roboto/Inter/Arial/Open Sans/Lato) — use identity fonts.");
660
- if (/@import.*fonts\.googleapis.*family=(Roboto|Inter)\b/.test(content)) w.push("Font: Google Fonts import for a forbidden family.");
661
- return w;
662
- }
663
- /** Hard-coded-color warnings (hex in className or inline style). */
664
- function checkColors(content) {
665
- const w = [];
666
- if (/className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/.test(content)) w.push("Color: hard-coded hex in className — use CSS variables.");
667
- if (/(?:color|background(?:-color)?|fill|stroke):\s*['"]?#[0-9a-fA-F]{3,8}/.test(content)) w.push("Color: hard-coded hex in style — use var(--color-*).");
668
- return w;
669
- }
670
- /** Run all design content checks → non-blocking warnings (empty = clean). */
671
- function runDesignChecks(content) {
672
- return [
673
- ...checkAccessibility(content),
674
- ...checkPatterns(content),
675
- ...checkFonts(content),
676
- ...checkColors(content)
677
- ];
678
- }
679
- //#endregion
680
- //#region src/policy/design/skill-gate.ts
681
- /** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
682
- const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
683
- /** Generated/vendored paths that never need a design skill (match at start or mid-path). */
684
- const EXEMPT_RE = /(^|\/)(node_modules|dist|build)\/|\.claude\//;
685
- /** UI-intent path fragments (ports the Python `UI_PATH_PATTERNS`). */
686
- const UI_PATH_RE = /(components|ui|styles|page|layout|content|view|feature|section|hero|footer|header|sidebar|nav|modal|dialog)/;
687
- /** Tailwind utility usage in JSX `className` (ports the Python `has_jsx_tailwind`). */
688
- const TAILWIND_RE = /className\s*=.*(?:flex|grid|p-|m-|bg-|text-|rounded|shadow|border|gap-|w-|h-)/;
689
- /** A read reference path that counts as "a design skill was consulted". */
690
- const DESIGN_SKILL_RE = /skills\/([0-9]-)?(designing-systems|generating-components|solid-react|solid-nextjs|design|impeccable|frontend-design)/i;
691
- /** True when this Write/Edit targets a UI file in a UI-ish path or with Tailwind classes. */
692
- function isUiWrite(tool, filePath, content) {
693
- if (tool !== "Write" && tool !== "Edit") return false;
694
- if (!UI_FILE_RE.test(filePath) || EXEMPT_RE.test(filePath)) return false;
695
- return UI_PATH_RE.test(filePath) || TAILWIND_RE.test(content);
696
- }
697
- /** True when a design skill reference was read this session (scans the `refsRead` track). */
698
- function designSkillRead(refsRead) {
699
- return refsRead.some((p) => DESIGN_SKILL_RE.test(p));
700
- }
701
- const block$1 = (reason, action) => ({
702
- kind: "block",
703
- title: "Design skill",
704
- reason,
705
- actions: [action]
706
- });
298
+ //#region src/runtime/design-lifecycle.ts
707
299
  /**
708
- * Gate a UI write: require a design-skill read AND any doc source. Returns a
709
- * blocking {@link Prompt}, or `null` to allow. NEVER requires Gemini.
710
- * @param tool - the tool name ("Write" | "Edit" | ...).
711
- * @param filePath - the written file path.
712
- * @param content - the written content (snippet on Edit, full file on Write).
713
- * @param ev - the session evidence (refsRead + docConsulted).
300
+ * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
301
+ * raise the active flag on start, archive/cleanup + clear the flag on stop.
302
+ * Returns true when it handled the event (caller should respond and stop).
714
303
  */
715
- function uiDesignSkillGate(tool, filePath, content, ev) {
716
- if (!isUiWrite(tool, filePath, content)) return null;
717
- if (!designSkillRead(ev.refsRead)) return block$1("BLOCKED: design skill not consulted before writing UI. Read a design SKILL.md (solid-react, generating-components, or designing-systems), then retry.", "Read a design skill reference, then retry");
718
- if (!ev.docConsulted) return block$1("BLOCKED: no documentation consulted. Use ANY ONE of mcp__context7__query-docs, mcp__exa__web_search_exa, WebSearch, or WebFetch. Gemini is NOT required.", "Consult Context7, Exa, or web docs (Gemini not required), then retry");
719
- return null;
304
+ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
305
+ const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
306
+ if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
307
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
308
+ if (event === "SubagentStart") {
309
+ if (!agentId) return false;
310
+ const dsExists = existsSync(join(cwd, "design-system.md"));
311
+ saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
312
+ setActiveDesignAgent(cacheDir, agentId);
313
+ return true;
314
+ }
315
+ if (event === "SubagentStop") {
316
+ cleanupDesignStates(cacheDir, agentId, stamp, now);
317
+ clearActiveDesignAgent(cacheDir);
318
+ return true;
319
+ }
320
+ return false;
720
321
  }
721
322
  //#endregion
722
- //#region src/policy/design/skill-evidence.ts
323
+ //#region src/runtime/inject-context.ts
723
324
  /**
724
- * @module design/skill-evidence
725
- * IO side of the UI design-skill gate: read the verified session track and
726
- * derive {@link DesignEvidence} (read references + doc-consulted) for
727
- * {@link uiDesignSkillGate}. Kept separate from the pure gate logic (SRP).
728
- * @packageDocumentation
325
+ * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
326
+ * preamble as a Claude `additionalContext` response, or "" when nothing to emit.
327
+ * @param prompt - The raw user prompt.
328
+ * @param cwd - Project root (for project-type detection).
329
+ * @returns The native hook stdout (possibly empty).
729
330
  */
331
+ function promptSubmitContext(prompt, cwd) {
332
+ const ctx = buildClaudeMdContext(prompt, cwd);
333
+ return ctx ? contextResponse("UserPromptSubmit", ctx) : "";
334
+ }
730
335
  /**
731
- * Read the verified session track and derive {@link DesignEvidence}. Fail-closed
732
- * on a missing/corrupt/tampered track (no evidence the gate blocks).
733
- * @param sessionId - the Claude session id.
734
- * @param cwd - the project root (selects the per-project state dir).
735
- * @param baseDir - override the track base dir (tests); defaults to the project state dir.
336
+ * PreToolUse Task context injection: render the APEX sub-agent context as a
337
+ * Claude `additionalContext` response when `.claude/apex/` exists, else "".
338
+ * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
339
+ * @returns The native hook stdout (possibly empty).
736
340
  */
737
- function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
738
- let track = null;
341
+ function taskContext(cwd) {
342
+ const ctx = buildApexTaskInjection(process.env.CLAUDE_PROJECT_DIR ?? cwd);
343
+ return ctx ? contextResponse("PreToolUse", ctx) : "";
344
+ }
345
+ //#endregion
346
+ //#region src/runtime/dev-context.ts
347
+ /** Run a git subcommand in `cwd`, returning trimmed stdout or "" on error. */
348
+ function git(cwd, args) {
739
349
  try {
740
- track = verifyTrack(JSON.parse(readFileSync(trackFile(sessionId, baseDir), "utf8")));
350
+ return execFileSync("git", args, {
351
+ cwd,
352
+ encoding: "utf-8",
353
+ stdio: "pipe",
354
+ timeout: 5e3
355
+ }).trim();
741
356
  } catch {
742
- track = null;
357
+ return "";
743
358
  }
744
- return {
745
- refsRead: track?.refsRead ?? [],
746
- docConsulted: track ? isDocConsulted(track.authorizations, sessionId) : false
747
- };
748
359
  }
749
- //#endregion
750
- //#region src/runtime/design-helpers.ts
360
+ /** Build the git portion of the dev context (branch + up to 5 changed files). */
361
+ function gitContext(cwd) {
362
+ if (!existsSync(join(cwd, ".git"))) return [];
363
+ const parts = [`Git branch: ${git(cwd, ["branch", "--show-current"]) || "unknown"}`];
364
+ const status = git(cwd, ["status", "--porcelain"]);
365
+ if (status) parts.push("Modified files:\n" + status.split("\n").slice(0, 5).join("\n"));
366
+ return parts;
367
+ }
368
+ /** Build the project-type portion (mirrors load-dev-context.py exactly). */
369
+ function projectContext(cwd) {
370
+ const parts = [];
371
+ const has = (f) => existsSync(join(cwd, f));
372
+ if ([
373
+ "next.config.js",
374
+ "next.config.ts",
375
+ "next.config.mjs"
376
+ ].some(has)) parts.push("Project: Next.js");
377
+ else if (has("package.json")) parts.push("Project: Node.js");
378
+ if (has("composer.json") && has("artisan")) parts.push("Project: Laravel");
379
+ if (has("Package.swift")) parts.push("Project: Swift");
380
+ return parts;
381
+ }
751
382
  /**
752
- * @module design-helpers
753
- * Effectful helpers for the design pipeline gate: locate design-system.md and
754
- * apply PostToolUse fuse-browser transitions to the design state. Split out of
755
- * `design.ts` to keep that file within the SOLID size budget (SRP).
756
- * @packageDocumentation
383
+ * Build the SessionStart dev-context block (git + project type), or "" when
384
+ * nothing applies. Ports `core-guards/scripts/session-start/load-dev-context.py`.
385
+ * @param cwd - Project root to inspect.
386
+ * @returns The joined additionalContext text (possibly empty).
757
387
  */
758
- const NAV$1 = "mcp__fuse-browser__browser_navigate";
759
- const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
760
- const SCROLL = "mcp__fuse-browser__browser_scroll";
761
- const GEMINI$1 = "mcp__gemini-design__create_frontend";
762
- /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
763
- function findDesignSystem(cwd) {
764
- let dir = cwd;
765
- for (let i = 0; i < 6; i++) {
766
- const p = join(dir, "design-system.md");
767
- if (existsSync(p)) try {
768
- return readFileSync(p, "utf8");
769
- } catch {
770
- return "";
771
- }
772
- const parent = dirname(dir);
773
- if (parent === dir) break;
774
- dir = parent;
775
- }
776
- return "";
777
- }
778
- /** Apply a PostToolUse fuse-browser transition to the design state. */
779
- function recordPost(event, cacheDir, state) {
780
- if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
781
- else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
782
- else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
783
- else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
784
- ...state,
785
- geminiCalls: state.geminiCalls + 1
786
- });
787
- else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
788
- else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
789
- }
790
- //#endregion
791
- //#region src/policy/design/gates.ts
792
- const ALLOWED_WRITE = /\.(html|css|md|json)$/;
793
- const EXEMPT_DIRS = [
794
- "node_modules/",
795
- "dist/",
796
- "build/",
797
- ".claude/"
798
- ];
799
- const FORBIDDEN_FONTS = [
800
- "Inter",
801
- "Roboto",
802
- "Arial",
803
- "Open Sans"
804
- ];
805
- const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
806
- const KNOWN_DOMAINS = [
807
- "framer.website",
808
- "webflow.io",
809
- "awwwards.com",
810
- "godly.website",
811
- "lapa.ninja",
812
- "onepagelove.com",
813
- "saasframe.io",
814
- "bestwebsite.gallery",
815
- "landingfolio.com"
816
- ];
817
- const deny = (reason) => ({
818
- kind: "block",
819
- title: "Design pipeline",
820
- reason,
821
- actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"]
822
- });
823
- /** Block the design agent from writing anything but .html/.css/.md/.json. */
824
- function htmlCssOnlyGate(filePath) {
825
- if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
826
- return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
827
- }
828
- /** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
829
- function stateFileGate(filePath) {
830
- return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are read-only; the hooks update them as you progress.") : null;
831
- }
832
- /** Gate writing design-system.md: requires phase ≥ 2 and the per-mode screenshot quota. */
833
- function designSystemWriteGate(filePath, state) {
834
- if (!filePath.endsWith("design-system.md")) return null;
835
- if (state.currentPhase < 2) return deny(`BLOCKED: cannot write design-system.md at phase ${state.currentPhase}. Read identity + inspiration, then browse & screenshot first.`);
836
- const needed = MIN_SCREENSHOTS[state.mode];
837
- if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
838
- return null;
839
- }
840
- /** Return the requirements missing from a design-system.md (empty = valid). */
841
- function validateDesignSystem(content) {
842
- const missing = [];
843
- if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
844
- if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
845
- if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
846
- if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
847
- return missing;
848
- }
849
- /** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
850
- function geminiCreateGate(state) {
851
- if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
852
- if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
853
- return null;
854
- }
855
- /** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
856
- function browserNavigateGate(state, url) {
857
- if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
858
- if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
859
- if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
860
- return null;
861
- }
862
- /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
863
- function screenshotScrollGate(state) {
864
- return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
865
- }
866
- /** The Gemini design gates are OPT-IN: off unless `FUSE_DESIGN_GEMINI` is `1`/`true`. */
867
- function geminiEnabled() {
868
- const v = process.env.FUSE_DESIGN_GEMINI;
869
- return v === "1" || v === "true";
870
- }
871
- //#endregion
872
- //#region src/runtime/design.ts
873
- const NAV = "mcp__fuse-browser__browser_navigate";
874
- const SHOT = "mcp__fuse-browser__browser_screenshot";
875
- const GEMINI = "mcp__gemini-design__create_frontend";
876
- /**
877
- * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
878
- * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
879
- */
880
- function designGate(payload, event, cacheDir, cwd) {
881
- if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
882
- const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
883
- if (skillBlock) return skillBlock;
884
- }
885
- const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
886
- const active = activeDesignAgent(cacheDir);
887
- if (active && agentId && agentId !== active) return null;
888
- const id = active || agentId;
889
- if (!id) return null;
890
- let state = loadDesignState(cacheDir, id);
891
- if (!state) {
892
- if (!active) return null;
893
- const dsExists = findDesignSystem(cwd) !== "";
894
- state = initDesignState(id, dsExists ? "page" : "full", dsExists);
895
- saveDesignState(cacheDir, state);
896
- }
897
- if (event.phase === "post") {
898
- recordPost(event, cacheDir, state);
899
- if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
900
- const warnings = runDesignChecks(event.content ?? "");
901
- if (warnings.length) return {
902
- kind: "inform",
903
- title: "Design review",
904
- reason: warnings.join(" "),
905
- actions: []
906
- };
907
- }
908
- return null;
909
- }
910
- if (event.tool === "Write" || event.tool === "Edit") {
911
- const fp = event.filePath ?? "";
912
- const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
913
- if (base) return base;
914
- if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
915
- kind: "block",
916
- title: "Design pipeline",
917
- reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
918
- actions: ["Call mcp__gemini-design__create_frontend first"]
919
- };
920
- return null;
921
- }
922
- if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
923
- if (event.tool === SHOT) return screenshotScrollGate(state);
924
- if (event.tool === GEMINI) {
925
- if (!geminiEnabled()) return null;
926
- const block = geminiCreateGate(state);
927
- if (block) return block;
928
- const missing = validateDesignSystem(findDesignSystem(cwd));
929
- if (missing.length) return {
930
- kind: "block",
931
- title: "Design pipeline",
932
- reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
933
- actions: ["Fix design-system.md, then retry create_frontend"]
934
- };
935
- saveDesignState(cacheDir, recordValidDesignSystem(state));
936
- }
937
- return null;
938
- }
939
- //#endregion
940
- //#region src/runtime/design-lifecycle.ts
941
- /**
942
- * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
943
- * raise the active flag on start, archive/cleanup + clear the flag on stop.
944
- * Returns true when it handled the event (caller should respond and stop).
945
- */
946
- function designLifecycle(payload, cacheDir, cwd, stamp, now) {
947
- const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
948
- if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
949
- const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
950
- if (event === "SubagentStart") {
951
- if (!agentId) return false;
952
- const dsExists = existsSync(join(cwd, "design-system.md"));
953
- saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
954
- setActiveDesignAgent(cacheDir, agentId);
955
- return true;
956
- }
957
- if (event === "SubagentStop") {
958
- cleanupDesignStates(cacheDir, agentId, stamp, now);
959
- clearActiveDesignAgent(cacheDir);
960
- return true;
961
- }
962
- return false;
963
- }
964
- //#endregion
965
- //#region src/runtime/inject-context.ts
966
- /**
967
- * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
968
- * preamble as a Claude `additionalContext` response, or "" when nothing to emit.
969
- * @param prompt - The raw user prompt.
970
- * @param cwd - Project root (for project-type detection).
971
- * @returns The native hook stdout (possibly empty).
972
- */
973
- function promptSubmitContext(prompt, cwd) {
974
- const ctx = buildClaudeMdContext(prompt, cwd);
975
- return ctx ? contextResponse("UserPromptSubmit", ctx) : "";
976
- }
977
- /**
978
- * PreToolUse Task context injection: render the APEX sub-agent context as a
979
- * Claude `additionalContext` response when `.claude/apex/` exists, else "".
980
- * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
981
- * @returns The native hook stdout (possibly empty).
982
- */
983
- function taskContext(cwd) {
984
- const ctx = buildApexTaskInjection(process.env.CLAUDE_PROJECT_DIR ?? cwd);
985
- return ctx ? contextResponse("PreToolUse", ctx) : "";
986
- }
987
- //#endregion
988
- //#region src/runtime/dev-context.ts
989
- /** Run a git subcommand in `cwd`, returning trimmed stdout or "" on error. */
990
- function git(cwd, args) {
991
- try {
992
- return execFileSync("git", args, {
993
- cwd,
994
- encoding: "utf-8",
995
- stdio: "pipe",
996
- timeout: 5e3
997
- }).trim();
998
- } catch {
999
- return "";
1000
- }
1001
- }
1002
- /** Build the git portion of the dev context (branch + up to 5 changed files). */
1003
- function gitContext(cwd) {
1004
- if (!existsSync(join(cwd, ".git"))) return [];
1005
- const parts = [`Git branch: ${git(cwd, ["branch", "--show-current"]) || "unknown"}`];
1006
- const status = git(cwd, ["status", "--porcelain"]);
1007
- if (status) parts.push("Modified files:\n" + status.split("\n").slice(0, 5).join("\n"));
1008
- return parts;
1009
- }
1010
- /** Build the project-type portion (mirrors load-dev-context.py exactly). */
1011
- function projectContext(cwd) {
1012
- const parts = [];
1013
- const has = (f) => existsSync(join(cwd, f));
1014
- if ([
1015
- "next.config.js",
1016
- "next.config.ts",
1017
- "next.config.mjs"
1018
- ].some(has)) parts.push("Project: Next.js");
1019
- else if (has("package.json")) parts.push("Project: Node.js");
1020
- if (has("composer.json") && has("artisan")) parts.push("Project: Laravel");
1021
- if (has("Package.swift")) parts.push("Project: Swift");
1022
- return parts;
1023
- }
1024
- /**
1025
- * Build the SessionStart dev-context block (git + project type), or "" when
1026
- * nothing applies. Ports `core-guards/scripts/session-start/load-dev-context.py`.
1027
- * @param cwd - Project root to inspect.
1028
- * @returns The joined additionalContext text (possibly empty).
1029
- */
1030
- function devContext(cwd) {
1031
- return [...gitContext(cwd), ...projectContext(cwd)].join("\n");
388
+ function devContext(cwd) {
389
+ return [...gitContext(cwd), ...projectContext(cwd)].join("\n");
1032
390
  }
1033
391
  //#endregion
1034
392
  //#region src/runtime/fs-cleanup.ts
@@ -1102,7 +460,7 @@ function pruneEmpty(dir, stopAt) {
1102
460
  }
1103
461
  //#endregion
1104
462
  //#region src/runtime/lifecycle/session-start.ts
1105
- /** TTLs (seconds) for purgeable fusengine-cache subtrees (cleanup-old-caches.py). */
463
+ /** TTLs (seconds) for purgeable cache subtrees (cleanup-old-caches.py). */
1106
464
  const PURGEABLE = {
1107
465
  sessions: 48 * 3600,
1108
466
  webfetch: 24 * 3600,
@@ -1456,7 +814,7 @@ function saveApexState(cwd, now = Date.now()) {
1456
814
  /**
1457
815
  * Handle SessionEnd: remove stale `*.tmp` (>1h) under `session-tmp/` and stale
1458
816
  * legacy `claude_solid_reads_*` / `claude_session_changes_*` files (>2h) under
1459
- * `fusengine-cache`. Ports `session-end/cleanup-session.py`. No stdout.
817
+ * `cache`. Ports `session-end/cleanup-session.py`. No stdout.
1460
818
  * @param home - Home dir (defaults to `~`).
1461
819
  * @param now - Clock (defaults to `Date.now()`).
1462
820
  */
@@ -2348,7 +1706,7 @@ ${agents}
2348
1706
  /**
2349
1707
  * Shared cache-path + age helpers for the ai-pilot scope.
2350
1708
  * Reuses the harness home-state (`fusengineCache`) so the ai-pilot caches live
2351
- * under the same `~/.claude/fusengine-cache` tree as the core MCP cache — no
1709
+ * under the same `~/.fuse-harness/cache` tree as the core MCP cache — no
2352
1710
  * second cache layer.
2353
1711
  */
2354
1712
  /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
@@ -2359,11 +1717,11 @@ function hashText16(text) {
2359
1717
  function projectHash(projectPath) {
2360
1718
  return hashText16(projectPath);
2361
1719
  }
2362
- /** `~/.claude/fusengine-cache` base dir (shared with the core MCP cache). */
1720
+ /** `~/.fuse-harness/cache` base dir (shared with the core MCP cache). */
2363
1721
  function cacheBaseDir(home = homedir()) {
2364
1722
  return fusengineCache(home);
2365
1723
  }
2366
- /** Per-type, per-project cache dir: `fusengine-cache/<type>/<projectHash>`. */
1724
+ /** Per-type, per-project cache dir: `cache/<type>/<projectHash>`. */
2367
1725
  function cacheDirFor(type, projectPath, home = homedir()) {
2368
1726
  return join(cacheBaseDir(home), type, projectHash(projectPath));
2369
1727
  }
@@ -2985,7 +2343,7 @@ async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
2985
2343
  //#region src/runtime/lifecycle/aipilot/promote-global-lessons.ts
2986
2344
  /**
2987
2345
  * Promote high-frequency project lessons (occurrence count >= 3) to the global,
2988
- * stack-scoped cache (`fusengine-cache/lessons/_global/<stack>.json`, cap 25).
2346
+ * stack-scoped cache (`cache/lessons/_global/<stack>.json`, cap 25).
2989
2347
  * Ports the ai-pilot `promote-global-lessons.ts` (a spawned subprocess) as an
2990
2348
  * in-process call from `cacheSniperLessons`. Pure side-effect.
2991
2349
  */
@@ -3726,7 +3084,7 @@ function appendMemoryLog(name, line, rotateAt = 0, keep = 0, home = homedir()) {
3726
3084
  appendFileSync(file, `${line}\n`, "utf-8");
3727
3085
  if (rotateAt > 0) {
3728
3086
  const lines = readFileSync(file, "utf-8").split("\n").filter((l) => l.length > 0);
3729
- if (lines.length > rotateAt) writeFileSync(file, `${lines.slice(-keep).join("\n")}\n`, "utf-8");
3087
+ if (lines.length > rotateAt) atomicWrite(file, `${lines.slice(-keep).join("\n")}\n`);
3730
3088
  }
3731
3089
  } catch {}
3732
3090
  }
@@ -3968,7 +3326,8 @@ const EXEMPT_PATTERNS = [
3968
3326
  /\.claude-plugin\//,
3969
3327
  /CHANGELOG\.md$/,
3970
3328
  /marketplace\.json$/,
3971
- /\/\.claude\/(apex|memory|logs|fusengine-cache)\//
3329
+ /\/\.claude\/(apex|memory|logs)\//,
3330
+ /\/\.fuse-harness\//
3972
3331
  ];
3973
3332
  /**
3974
3333
  * True when `filePath` is a code file the APEX freshness/doc/SOLID gates should
@@ -3999,72 +3358,6 @@ function existingLineCount(path) {
3999
3358
  }
4000
3359
  }
4001
3360
  //#endregion
4002
- //#region src/freshness/agent-evidence.ts
4003
- /**
4004
- * Platform-authored transcript evidence for APEX agent freshness.
4005
- * Parses the Claude Code session JSONL transcript to find genuine `Task`/`Agent`
4006
- * tool_use entries — forging this requires writing into the transcript file
4007
- * which the Claude Code platform controls, unlike the self-recorded track.
4008
- */
4009
- /** Parse a raw `timestamp` field to epoch ms; `undefined` when absent or invalid. */
4010
- function parseTs(raw) {
4011
- if (raw === void 0) return void 0;
4012
- if (typeof raw === "number") return raw;
4013
- const ms = Date.parse(raw);
4014
- return Number.isFinite(ms) ? ms : void 0;
4015
- }
4016
- /**
4017
- * Return `true` ONLY when, for EVERY name in `names`, the Claude Code
4018
- * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task` or
4019
- * `Agent` tool whose `subagent_type` (or `name`) input field matches — after
4020
- * stripping any plugin prefix — with the entry timestamp within `windowMs` of `now`.
4021
- *
4022
- * **Timestamp note:** when a transcript entry carries no `timestamp` field it
4023
- * is counted as within-window (we cannot prove staleness). This is
4024
- * intentionally lenient to stay robust across transcript-format evolution; the
4025
- * tamper-resistance guarantee derives from the platform authoring the file —
4026
- * not from the timestamp alone.
4027
- *
4028
- * @param transcriptPath - Absolute path to the session `.jsonl` transcript
4029
- * (hook payload field: `transcript_path`). Returns `false` when `undefined`.
4030
- * @param names - Required agent `subagent_type` values — ALL must appear.
4031
- * @param windowMs - Freshness window in milliseconds.
4032
- * @param now - Current epoch ms (pass `Date.now()` at the call-site).
4033
- * @returns `true` when ALL agents have real, within-window transcript evidence.
4034
- */
4035
- function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
4036
- if (!transcriptPath || names.length === 0) return false;
4037
- let text;
4038
- try {
4039
- text = readText(transcriptPath);
4040
- } catch {
4041
- return false;
4042
- }
4043
- const cutoff = now - windowMs;
4044
- const found = /* @__PURE__ */ new Set();
4045
- for (const line of text.split("\n")) {
4046
- if (!line.trim()) continue;
4047
- let entry;
4048
- try {
4049
- entry = JSON.parse(line);
4050
- } catch {
4051
- continue;
4052
- }
4053
- const ts = parseTs(entry.timestamp);
4054
- if (ts !== void 0 && ts <= cutoff) continue;
4055
- const content = entry.message?.content;
4056
- if (!Array.isArray(content)) continue;
4057
- for (const block of content) {
4058
- if (block?.type !== "tool_use" || block.name !== "Task" && block.name !== "Agent") continue;
4059
- const raw = block.input?.subagent_type ?? block.input?.name;
4060
- const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
4061
- if (agent !== void 0 && names.includes(agent)) found.add(agent);
4062
- }
4063
- if (found.size === names.length) return true;
4064
- }
4065
- return names.every((n) => found.has(n));
4066
- }
4067
- //#endregion
4068
3361
  //#region src/runtime/dry-patterns.ts
4069
3362
  /** Short identifiers never worth a duplication check (control flow, tiny names). */
4070
3363
  const DRY_KEYWORDS = /* @__PURE__ */ new Set([
@@ -4306,7 +3599,7 @@ const PHP_BLOCKED_IN_APP = [
4306
3599
  "/app/Notifications/",
4307
3600
  "/app/Policies/"
4308
3601
  ];
4309
- const block = (reason) => ({
3602
+ const block$1 = (reason) => ({
4310
3603
  kind: "block",
4311
3604
  title: "Modular architecture",
4312
3605
  reason,
@@ -4316,30 +3609,30 @@ const block = (reason) => ({
4316
3609
  function nextModular(filePath, content, cwd) {
4317
3610
  const rel = relative(cwd, filePath);
4318
3611
  const bn = basename(filePath);
4319
- if ((rel.startsWith("app/") || rel.startsWith("src/app/")) && !NEXT_CONVENTION.test(bn) && !NEXT_STATIC.test(bn)) return block(`BLOCKED: modular Next.js — '${bn}' is not an app/ convention file. Move business logic to modules/[feature]/.`);
3612
+ if ((rel.startsWith("app/") || rel.startsWith("src/app/")) && !NEXT_CONVENTION.test(bn) && !NEXT_STATIC.test(bn)) return block$1(`BLOCKED: modular Next.js — '${bn}' is not an app/ convention file. Move business logic to modules/[feature]/.`);
4320
3613
  const mod = filePath.match(/\/modules\/([^/]+)\//);
4321
3614
  if (!mod) return null;
4322
3615
  const current = mod[1] ?? "";
4323
3616
  for (const m of content.matchAll(/from\s+['"][@.][^'"]*?\/modules\/([^/]+)\//g)) {
4324
3617
  const imported = m[1] ?? "";
4325
3618
  if (current === "cores") {
4326
- if (imported !== "cores" && imported !== "core") return block(`BLOCKED: modules/cores/ must not import from modules/${imported}/.`);
4327
- } else if (imported !== current && imported !== "cores" && imported !== "core") return block(`BLOCKED: cross-module import — '${current}' imports '${imported}'. Only modules/cores/ is shared.`);
3619
+ if (imported !== "cores" && imported !== "core") return block$1(`BLOCKED: modules/cores/ must not import from modules/${imported}/.`);
3620
+ } else if (imported !== current && imported !== "cores" && imported !== "core") return block$1(`BLOCKED: cross-module import — '${current}' imports '${imported}'. Only modules/cores/ is shared.`);
4328
3621
  }
4329
3622
  return null;
4330
3623
  }
4331
3624
  /** Laravel FuseCore architecture: `app/` domain ban + module.json + cross-module `use` rules. */
4332
3625
  function fusecore(filePath, content, cwd) {
4333
- for (const b of PHP_BLOCKED_IN_APP) if (filePath.includes(b)) return block(`BLOCKED: FuseCore — domain code in '${b}' must move to FuseCore/{Module}/App/.`);
3626
+ for (const b of PHP_BLOCKED_IN_APP) if (filePath.includes(b)) return block$1(`BLOCKED: FuseCore — domain code in '${b}' must move to FuseCore/{Module}/App/.`);
4334
3627
  const mod = filePath.match(/\/FuseCore\/([A-Za-z]+)\//);
4335
3628
  if (!mod) return null;
4336
3629
  const name = mod[1] ?? "";
4337
- if (!existsSync(join(cwd, "FuseCore", name, "module.json"))) return block(`BLOCKED: FuseCore module '${name}' is missing module.json — create it first.`);
3630
+ if (!existsSync(join(cwd, "FuseCore", name, "module.json"))) return block$1(`BLOCKED: FuseCore module '${name}' is missing module.json — create it first.`);
4338
3631
  for (const m of content.matchAll(/use\s+FuseCore\\(\w+)\\/g)) {
4339
3632
  const imported = m[1] ?? "";
4340
3633
  if (name === "Core") {
4341
- if (imported !== "Core") return block(`BLOCKED: FuseCore\\Core\\ must not use FuseCore\\${imported}\\.`);
4342
- } else if (imported !== name && imported !== "Core") return block(`BLOCKED: cross-module use — '${name}' uses '${imported}'. Only FuseCore\\Core\\ is shared.`);
3634
+ if (imported !== "Core") return block$1(`BLOCKED: FuseCore\\Core\\ must not use FuseCore\\${imported}\\.`);
3635
+ } else if (imported !== name && imported !== "Core") return block$1(`BLOCKED: cross-module use — '${name}' uses '${imported}'. Only FuseCore\\Core\\ is shared.`);
4343
3636
  }
4344
3637
  return null;
4345
3638
  }
@@ -4392,6 +3685,110 @@ function frameworkSkillGate(input, refsRead, existingLines) {
4392
3685
  return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
4393
3686
  }
4394
3687
  //#endregion
3688
+ //#region src/freshness/agent-evidence.ts
3689
+ /**
3690
+ * Platform-authored transcript evidence for APEX agent freshness.
3691
+ * Parses the Claude Code session JSONL transcript to find genuine `Task`/`Agent`
3692
+ * tool_use entries — forging this requires writing into the transcript file
3693
+ * which the Claude Code platform controls, unlike the self-recorded track.
3694
+ */
3695
+ /** Parse a raw `timestamp` field to epoch ms; `undefined` when absent or invalid. */
3696
+ function parseTs(raw) {
3697
+ if (raw === void 0) return void 0;
3698
+ if (typeof raw === "number") return raw;
3699
+ const ms = Date.parse(raw);
3700
+ return Number.isFinite(ms) ? ms : void 0;
3701
+ }
3702
+ /**
3703
+ * Return `true` ONLY when, for EVERY name in `names`, the Claude Code
3704
+ * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task` or
3705
+ * `Agent` tool whose `subagent_type` (or `name`) input field matches — after
3706
+ * stripping any plugin prefix — with the entry timestamp within `windowMs` of `now`.
3707
+ *
3708
+ * **Timestamp note:** when a transcript entry carries no `timestamp` field it
3709
+ * is counted as within-window (we cannot prove staleness). This is
3710
+ * intentionally lenient to stay robust across transcript-format evolution; the
3711
+ * tamper-resistance guarantee derives from the platform authoring the file —
3712
+ * not from the timestamp alone.
3713
+ *
3714
+ * @param transcriptPath - Absolute path to the session `.jsonl` transcript
3715
+ * (hook payload field: `transcript_path`). Returns `false` when `undefined`.
3716
+ * @param names - Required agent `subagent_type` values — ALL must appear.
3717
+ * @param windowMs - Freshness window in milliseconds.
3718
+ * @param now - Current epoch ms (pass `Date.now()` at the call-site).
3719
+ * @returns `true` when ALL agents have real, within-window transcript evidence.
3720
+ */
3721
+ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
3722
+ if (!transcriptPath || names.length === 0) return false;
3723
+ let text;
3724
+ try {
3725
+ text = readText(transcriptPath);
3726
+ } catch {
3727
+ return false;
3728
+ }
3729
+ const cutoff = now - windowMs;
3730
+ const found = /* @__PURE__ */ new Set();
3731
+ for (const line of text.split("\n")) {
3732
+ if (!line.trim()) continue;
3733
+ let entry;
3734
+ try {
3735
+ entry = JSON.parse(line);
3736
+ } catch {
3737
+ continue;
3738
+ }
3739
+ const ts = parseTs(entry.timestamp);
3740
+ if (ts !== void 0 && ts <= cutoff) continue;
3741
+ const content = entry.message?.content;
3742
+ if (!Array.isArray(content)) continue;
3743
+ for (const block of content) {
3744
+ if (block?.type !== "tool_use" || block.name !== "Task" && block.name !== "Agent") continue;
3745
+ const raw = block.input?.subagent_type ?? block.input?.name;
3746
+ const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
3747
+ if (agent !== void 0 && names.includes(agent)) found.add(agent);
3748
+ }
3749
+ if (found.size === names.length) return true;
3750
+ }
3751
+ return names.every((n) => found.has(n));
3752
+ }
3753
+ //#endregion
3754
+ //#region src/runtime/gate-apex.ts
3755
+ /**
3756
+ * The APEX-scoped portion of {@link gate}: a trivial-edit fast path, then the
3757
+ * freshness/doc/SOLID gates fed from the session track. Freshness prefers
3758
+ * platform-authored transcript evidence (an agent cannot forge a `Task` in the
3759
+ * runtime-owned transcript); the self-recorded track is only a fallback when no
3760
+ * transcript path is available (tests / other harnesses).
3761
+ * @param input - The tool-use to gate.
3762
+ * @param track - The loaded session track.
3763
+ * @param window - Freshness window in ms.
3764
+ * @returns The first blocking prompt, or null to fall through to the DRY check.
3765
+ */
3766
+ async function apexScopedGate(input, track, window) {
3767
+ const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
3768
+ if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
3769
+ await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
3770
+ return null;
3771
+ }
3772
+ const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
3773
+ const ctx = {
3774
+ sessionId: input.sessionId,
3775
+ framework: input.framework,
3776
+ filePath: input.filePath ?? "",
3777
+ content: input.content ?? "",
3778
+ authorizations: track.authorizations,
3779
+ refs: input.refs,
3780
+ refsRead: track.refsRead,
3781
+ agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
3782
+ brainstormRequired: track.brainstormRequired,
3783
+ brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
3784
+ };
3785
+ try {
3786
+ return evaluateApex(ctx);
3787
+ } catch {
3788
+ return FAIL_CLOSED;
3789
+ }
3790
+ }
3791
+ //#endregion
4395
3792
  //#region src/runtime/gate.ts
4396
3793
  /** Prior agents the freshness gate requires before a code edit. */
4397
3794
  const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
@@ -4421,41 +3818,465 @@ async function gate(input) {
4421
3818
  } catch {
4422
3819
  return FAIL_CLOSED;
4423
3820
  }
4424
- if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
4425
- const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
4426
- if (modular) return modular;
4427
- if (!input.filePath) return null;
4428
- const window = input.windowMs ?? 12e4;
4429
- const track = await loadTrack(input.trackFile);
4430
- const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
4431
- if (solidOrSkill) return solidOrSkill;
4432
- if (isApexScoped(input.filePath)) {
4433
- const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
4434
- if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
4435
- await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
4436
- return null;
3821
+ if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
3822
+ const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
3823
+ if (modular) return modular;
3824
+ if (!input.filePath) return null;
3825
+ const window = input.windowMs ?? 12e4;
3826
+ const track = await loadTrack(input.trackFile);
3827
+ const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
3828
+ if (solidOrSkill) return solidOrSkill;
3829
+ if (isApexScoped(input.filePath)) {
3830
+ const apex = await apexScopedGate(input, track, window);
3831
+ if (apex) return apex;
3832
+ }
3833
+ return dryGate(input.tool, input.filePath, input.content, input.cwd);
3834
+ }
3835
+ //#endregion
3836
+ //#region src/runtime/mcp-key.ts
3837
+ /** Cache-key + tool-class helpers for the MCP/WebFetch interception pipeline. */
3838
+ /** Default cached-MCP freshness in seconds (48h). */
3839
+ const MCP_TTL_DEFAULT_SEC = 172800;
3840
+ /** Default WebFetch freshness in seconds (24h, parity with Python). */
3841
+ const WEBFETCH_TTL_DEFAULT_SEC = 86400;
3842
+ /** Cached-MCP freshness (ms) from `FUSE_MCP_TTL_SEC` (default 48h). */
3843
+ const MCP_TTL_MS = parseEnvInt(process.env.FUSE_MCP_TTL_SEC, MCP_TTL_DEFAULT_SEC) * 1e3;
3844
+ /** WebFetch freshness (ms) from `FUSE_WEBFETCH_TTL_SEC` (default 24h; pages stale faster than docs). */
3845
+ const WEBFETCH_TTL_MS = parseEnvInt(process.env.FUSE_WEBFETCH_TTL_SEC, WEBFETCH_TTL_DEFAULT_SEC) * 1e3;
3846
+ /** WebFetch prompt slice folded into its cache key (parity with Python prompt[:500]). */
3847
+ const PROMPT_TRUNC = 500;
3848
+ /** True for the WebFetch tool (exact name). */
3849
+ function isWebFetch(tool) {
3850
+ return tool === "WebFetch";
3851
+ }
3852
+ /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
3853
+ function isMcpTool(tool) {
3854
+ return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
3855
+ }
3856
+ /** The query/url that keys the cache. */
3857
+ function queryOf(input) {
3858
+ const q = input.query ?? input.url ?? input.libraryId ?? "";
3859
+ return typeof q === "string" ? q : JSON.stringify(q);
3860
+ }
3861
+ /**
3862
+ * The string that keys the cache for `tool`. WebFetch folds `url + "\n" +
3863
+ * prompt[:500]` (parity with Python) so distinct prompts on the same URL never
3864
+ * collide; every other tool keys on {@link queryOf}.
3865
+ */
3866
+ function cacheQueryOf(tool, input) {
3867
+ if (!isWebFetch(tool)) return queryOf(input);
3868
+ return `${typeof input.url === "string" ? input.url.trim() : ""}\n${(typeof input.prompt === "string" ? input.prompt : "").slice(0, PROMPT_TRUNC)}`;
3869
+ }
3870
+ //#endregion
3871
+ //#region src/runtime/mcp.ts
3872
+ function denyWith(id, content) {
3873
+ if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
3874
+ hookEventName: "PreToolUse",
3875
+ permissionDecision: "deny",
3876
+ permissionDecisionReason: content
3877
+ } });
3878
+ if (id === "gemini-cli") return JSON.stringify({
3879
+ decision: "deny",
3880
+ reason: content
3881
+ });
3882
+ return "";
3883
+ }
3884
+ function mutateWith(id, input) {
3885
+ if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
3886
+ hookEventName: "PreToolUse",
3887
+ permissionDecision: "allow",
3888
+ updatedInput: input
3889
+ } });
3890
+ if (id === "gemini-cli") return JSON.stringify({ hookSpecificOutput: { tool_input: input } });
3891
+ return "";
3892
+ }
3893
+ /** The doc provider a served cache-hit satisfies (`exa`/`context7`), else undefined. */
3894
+ function docSourceOf$1(tool) {
3895
+ if (/exa/i.test(tool)) return "exa";
3896
+ if (/context7/i.test(tool)) return "context7";
3897
+ }
3898
+ /**
3899
+ * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
3900
+ * else cap exa verbosity (allow + mutated input), else null to allow normally.
3901
+ * Harnesses without input-mutation/cache support fall through to null.
3902
+ */
3903
+ function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
3904
+ if (!isMcpTool(tool)) return null;
3905
+ const web = isWebFetch(tool);
3906
+ const ttl = web ? WEBFETCH_TTL_MS : ttlMs;
3907
+ const key = cacheQueryOf(tool, input);
3908
+ const cached = cacheLookup(dir, tool, key, ttl, now) ?? (web ? null : cacheLookupSubstring(dir, key, ttl, now));
3909
+ if (cached) {
3910
+ const served = denyWith(id, cached);
3911
+ if (served) return {
3912
+ stdout: served,
3913
+ docSource: docSourceOf$1(tool)
3914
+ };
3915
+ }
3916
+ const capped = capVerbosity(tool, input);
3917
+ if (capped) {
3918
+ const mutated = mutateWith(id, capped);
3919
+ if (mutated) return { stdout: mutated };
3920
+ }
3921
+ return null;
3922
+ }
3923
+ /**
3924
+ * Post-event: persist the MCP/WebFetch response (extracted to markdown). MCP docs
3925
+ * go through {@link mcpCacheWrite} (compact + Jaccard-dedup + `index.json`);
3926
+ * WebFetch uses {@link webfetchCacheWrite} (compact, exact key, no index).
3927
+ * @param now - Current epoch ms (timestamp source; defaults to `Date.now()`).
3928
+ */
3929
+ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
3930
+ if (!isMcpTool(tool)) return;
3931
+ const text = extractText(response);
3932
+ const key = cacheQueryOf(tool, input);
3933
+ if (isWebFetch(tool)) webfetchCacheWrite(dir, tool, key, text, now);
3934
+ else mcpCacheWrite(dir, tool, key, text, now);
3935
+ }
3936
+ //#endregion
3937
+ //#region src/runtime/record.ts
3938
+ /** Apply an activity to a session's track and persist it (PostToolUse path). */
3939
+ async function recordActivity(file, activity) {
3940
+ const track = await loadTrack(file);
3941
+ 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) : recordRefRead(track, activity.path));
3942
+ }
3943
+ //#endregion
3944
+ //#region src/runtime/respond.ts
3945
+ /**
3946
+ * Map a portable {@link Prompt} to a harness's native hook response. `block`
3947
+ * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
3948
+ * `ask` — they only honor deny.)
3949
+ */
3950
+ function respond(id, prompt) {
3951
+ const message = formatPrompt(prompt);
3952
+ const deny = prompt.kind === "block";
3953
+ switch (id) {
3954
+ case "claude-code":
3955
+ case "codex": return JSON.stringify({ hookSpecificOutput: {
3956
+ hookEventName: "PreToolUse",
3957
+ permissionDecision: deny ? "deny" : "ask",
3958
+ permissionDecisionReason: message
3959
+ } });
3960
+ case "gemini-cli": return JSON.stringify(deny ? {
3961
+ decision: "deny",
3962
+ reason: message
3963
+ } : { hookSpecificOutput: { additionalContext: message } });
3964
+ case "cursor": return JSON.stringify({
3965
+ permission: deny ? "deny" : "ask",
3966
+ continue: false,
3967
+ userMessage: message,
3968
+ agentMessage: message
3969
+ });
3970
+ case "cline": return JSON.stringify(deny ? {
3971
+ cancel: true,
3972
+ errorMessage: message
3973
+ } : { contextModification: message });
3974
+ default: return "";
3975
+ }
3976
+ }
3977
+ //#endregion
3978
+ //#region src/policy/design/content-checks.ts
3979
+ /** Accessibility warnings: icon buttons need aria-label, images need alt. */
3980
+ function checkAccessibility(content) {
3981
+ const w = [];
3982
+ if (!/<(button|a|input|img)/.test(content)) return w;
3983
+ if (/<button[^>]*>/.test(content) && !/aria-label|aria-labelledby/.test(content) && /<button[^>]*>[^<]*<[^>]*Icon/.test(content)) w.push("Accessibility: icon buttons need an aria-label.");
3984
+ for (const m of content.matchAll(/<img[^>]*?>/g)) if (!m[0].includes("alt=")) {
3985
+ w.push("Accessibility: images need an alt attribute.");
3986
+ break;
3987
+ }
3988
+ return w;
3989
+ }
3990
+ /** Anti-pattern warnings: colored left borders, AI-slop gradients, emoji-as-icons. */
3991
+ function checkPatterns(content) {
3992
+ const w = [];
3993
+ if (/border-l-[0-9]+ border-l-(blue|green|red|purple)/.test(content)) w.push("Design: avoid colored left borders — use shadow/gradient.");
3994
+ if (/from-purple|to-purple|via-purple|from-pink.*to-purple/.test(content)) w.push("Design: avoid purple/pink gradients (AI slop) — use brand colors.");
3995
+ if (/>[^\x00-\x7F]+</.test(content)) w.push("Design: avoid emojis as icons — use a real icon set.");
3996
+ return w;
3997
+ }
3998
+ /** Forbidden-font warnings (CSS font-family + Google Fonts import). */
3999
+ function checkFonts(content) {
4000
+ const w = [];
4001
+ if (/font-family:\s*['"]?(Roboto|Inter|Arial|Open Sans|Lato)\b/i.test(content)) w.push("Font: forbidden family (Roboto/Inter/Arial/Open Sans/Lato) — use identity fonts.");
4002
+ if (/@import.*fonts\.googleapis.*family=(Roboto|Inter)\b/.test(content)) w.push("Font: Google Fonts import for a forbidden family.");
4003
+ return w;
4004
+ }
4005
+ /** Hard-coded-color warnings (hex in className or inline style). */
4006
+ function checkColors(content) {
4007
+ const w = [];
4008
+ if (/className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/.test(content)) w.push("Color: hard-coded hex in className — use CSS variables.");
4009
+ if (/(?:color|background(?:-color)?|fill|stroke):\s*['"]?#[0-9a-fA-F]{3,8}/.test(content)) w.push("Color: hard-coded hex in style — use var(--color-*).");
4010
+ return w;
4011
+ }
4012
+ /** Run all design content checks → non-blocking warnings (empty = clean). */
4013
+ function runDesignChecks(content) {
4014
+ return [
4015
+ ...checkAccessibility(content),
4016
+ ...checkPatterns(content),
4017
+ ...checkFonts(content),
4018
+ ...checkColors(content)
4019
+ ];
4020
+ }
4021
+ //#endregion
4022
+ //#region src/policy/design/skill-gate.ts
4023
+ /** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
4024
+ const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
4025
+ /** Generated/vendored paths that never need a design skill (match at start or mid-path). */
4026
+ const EXEMPT_RE = /(^|\/)(node_modules|dist|build)\/|\.claude\//;
4027
+ /** UI-intent path fragments (ports the Python `UI_PATH_PATTERNS`). */
4028
+ const UI_PATH_RE = /(components|ui|styles|page|layout|content|view|feature|section|hero|footer|header|sidebar|nav|modal|dialog)/;
4029
+ /** Tailwind utility usage in JSX `className` (ports the Python `has_jsx_tailwind`). */
4030
+ const TAILWIND_RE = /className\s*=.*(?:flex|grid|p-|m-|bg-|text-|rounded|shadow|border|gap-|w-|h-)/;
4031
+ /** A read reference path that counts as "a design skill was consulted". */
4032
+ const DESIGN_SKILL_RE = /skills\/([0-9]-)?(designing-systems|generating-components|solid-react|solid-nextjs|design|impeccable|frontend-design)/i;
4033
+ /** True when this Write/Edit targets a UI file in a UI-ish path or with Tailwind classes. */
4034
+ function isUiWrite(tool, filePath, content) {
4035
+ if (tool !== "Write" && tool !== "Edit") return false;
4036
+ if (!UI_FILE_RE.test(filePath) || EXEMPT_RE.test(filePath)) return false;
4037
+ return UI_PATH_RE.test(filePath) || TAILWIND_RE.test(content);
4038
+ }
4039
+ /** True when a design skill reference was read this session (scans the `refsRead` track). */
4040
+ function designSkillRead(refsRead) {
4041
+ return refsRead.some((p) => DESIGN_SKILL_RE.test(p));
4042
+ }
4043
+ const block = (reason, action) => ({
4044
+ kind: "block",
4045
+ title: "Design skill",
4046
+ reason,
4047
+ actions: [action]
4048
+ });
4049
+ /**
4050
+ * Gate a UI write: require a design-skill read AND any doc source. Returns a
4051
+ * blocking {@link Prompt}, or `null` to allow. NEVER requires Gemini.
4052
+ * @param tool - the tool name ("Write" | "Edit" | ...).
4053
+ * @param filePath - the written file path.
4054
+ * @param content - the written content (snippet on Edit, full file on Write).
4055
+ * @param ev - the session evidence (refsRead + docConsulted).
4056
+ */
4057
+ function uiDesignSkillGate(tool, filePath, content, ev) {
4058
+ if (!isUiWrite(tool, filePath, content)) return null;
4059
+ if (!designSkillRead(ev.refsRead)) return block("BLOCKED: design skill not consulted before writing UI. Read a design SKILL.md (solid-react, generating-components, or designing-systems), then retry.", "Read a design skill reference, then retry");
4060
+ if (!ev.docConsulted) return block("BLOCKED: no documentation consulted. Use ANY ONE of mcp__context7__query-docs, mcp__exa__web_search_exa, WebSearch, or WebFetch. Gemini is NOT required.", "Consult Context7, Exa, or web docs (Gemini not required), then retry");
4061
+ return null;
4062
+ }
4063
+ //#endregion
4064
+ //#region src/policy/design/skill-evidence.ts
4065
+ /**
4066
+ * @module design/skill-evidence
4067
+ * IO side of the UI design-skill gate: read the verified session track and
4068
+ * derive {@link DesignEvidence} (read references + doc-consulted) for
4069
+ * {@link uiDesignSkillGate}. Kept separate from the pure gate logic (SRP).
4070
+ * @packageDocumentation
4071
+ */
4072
+ /**
4073
+ * Read the verified session track and derive {@link DesignEvidence}. Fail-closed
4074
+ * on a missing/corrupt/tampered track (no evidence → the gate blocks).
4075
+ * @param sessionId - the Claude session id.
4076
+ * @param cwd - the project root (selects the per-project state dir).
4077
+ * @param baseDir - override the track base dir (tests); defaults to the project state dir.
4078
+ */
4079
+ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
4080
+ let track = null;
4081
+ try {
4082
+ track = verifyTrack(JSON.parse(readFileSync(trackFile(sessionId, baseDir), "utf8")));
4083
+ } catch {
4084
+ track = null;
4085
+ }
4086
+ return {
4087
+ refsRead: track?.refsRead ?? [],
4088
+ docConsulted: track ? isDocConsulted(track.authorizations, sessionId) : false
4089
+ };
4090
+ }
4091
+ //#endregion
4092
+ //#region src/runtime/design-helpers.ts
4093
+ /**
4094
+ * @module design-helpers
4095
+ * Effectful helpers for the design pipeline gate: locate design-system.md and
4096
+ * apply PostToolUse fuse-browser transitions to the design state. Split out of
4097
+ * `design.ts` to keep that file within the SOLID size budget (SRP).
4098
+ * @packageDocumentation
4099
+ */
4100
+ const NAV$1 = "mcp__fuse-browser__browser_navigate";
4101
+ const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
4102
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
4103
+ const GEMINI$1 = "mcp__gemini-design__create_frontend";
4104
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
4105
+ function findDesignSystem(cwd) {
4106
+ let dir = cwd;
4107
+ for (let i = 0; i < 6; i++) {
4108
+ const p = join(dir, "design-system.md");
4109
+ if (existsSync(p)) try {
4110
+ return readFileSync(p, "utf8");
4111
+ } catch {
4112
+ return "";
4113
+ }
4114
+ const parent = dirname(dir);
4115
+ if (parent === dir) break;
4116
+ dir = parent;
4117
+ }
4118
+ return "";
4119
+ }
4120
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
4121
+ function recordPost(event, cacheDir, state) {
4122
+ if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
4123
+ else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
4124
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
4125
+ else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
4126
+ ...state,
4127
+ geminiCalls: state.geminiCalls + 1
4128
+ });
4129
+ else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
4130
+ else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
4131
+ }
4132
+ //#endregion
4133
+ //#region src/policy/design/gates.ts
4134
+ const ALLOWED_WRITE = /\.(html|css|md|json)$/;
4135
+ const EXEMPT_DIRS = [
4136
+ "node_modules/",
4137
+ "dist/",
4138
+ "build/",
4139
+ ".claude/"
4140
+ ];
4141
+ const FORBIDDEN_FONTS = [
4142
+ "Inter",
4143
+ "Roboto",
4144
+ "Arial",
4145
+ "Open Sans"
4146
+ ];
4147
+ const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
4148
+ const KNOWN_DOMAINS = [
4149
+ "framer.website",
4150
+ "webflow.io",
4151
+ "awwwards.com",
4152
+ "godly.website",
4153
+ "lapa.ninja",
4154
+ "onepagelove.com",
4155
+ "saasframe.io",
4156
+ "bestwebsite.gallery",
4157
+ "landingfolio.com"
4158
+ ];
4159
+ const deny = (reason) => ({
4160
+ kind: "block",
4161
+ title: "Design pipeline",
4162
+ reason,
4163
+ actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"]
4164
+ });
4165
+ /** Block the design agent from writing anything but .html/.css/.md/.json. */
4166
+ function htmlCssOnlyGate(filePath) {
4167
+ if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
4168
+ return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
4169
+ }
4170
+ /** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
4171
+ function stateFileGate(filePath) {
4172
+ return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are read-only; the hooks update them as you progress.") : null;
4173
+ }
4174
+ /** Gate writing design-system.md: requires phase ≥ 2 and the per-mode screenshot quota. */
4175
+ function designSystemWriteGate(filePath, state) {
4176
+ if (!filePath.endsWith("design-system.md")) return null;
4177
+ if (state.currentPhase < 2) return deny(`BLOCKED: cannot write design-system.md at phase ${state.currentPhase}. Read identity + inspiration, then browse & screenshot first.`);
4178
+ const needed = MIN_SCREENSHOTS[state.mode];
4179
+ if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
4180
+ return null;
4181
+ }
4182
+ /** Return the requirements missing from a design-system.md (empty = valid). */
4183
+ function validateDesignSystem(content) {
4184
+ const missing = [];
4185
+ if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
4186
+ if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
4187
+ if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
4188
+ if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
4189
+ return missing;
4190
+ }
4191
+ /** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
4192
+ function geminiCreateGate(state) {
4193
+ if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
4194
+ if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
4195
+ return null;
4196
+ }
4197
+ /** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
4198
+ function browserNavigateGate(state, url) {
4199
+ if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
4200
+ if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
4201
+ if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
4202
+ return null;
4203
+ }
4204
+ /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
4205
+ function screenshotScrollGate(state) {
4206
+ return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
4207
+ }
4208
+ /** The Gemini design gates are OPT-IN: off unless `FUSE_DESIGN_GEMINI` is `1`/`true`. */
4209
+ function geminiEnabled() {
4210
+ const v = process.env.FUSE_DESIGN_GEMINI;
4211
+ return v === "1" || v === "true";
4212
+ }
4213
+ //#endregion
4214
+ //#region src/runtime/design.ts
4215
+ const NAV = "mcp__fuse-browser__browser_navigate";
4216
+ const SHOT = "mcp__fuse-browser__browser_screenshot";
4217
+ const GEMINI = "mcp__gemini-design__create_frontend";
4218
+ /**
4219
+ * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
4220
+ * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
4221
+ */
4222
+ function designGate(payload, event, cacheDir, cwd) {
4223
+ if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
4224
+ const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
4225
+ if (skillBlock) return skillBlock;
4226
+ }
4227
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
4228
+ const active = activeDesignAgent(cacheDir);
4229
+ if (active && agentId && agentId !== active) return null;
4230
+ const id = active || agentId;
4231
+ if (!id) return null;
4232
+ let state = loadDesignState(cacheDir, id);
4233
+ if (!state) {
4234
+ if (!active) return null;
4235
+ const dsExists = findDesignSystem(cwd) !== "";
4236
+ state = initDesignState(id, dsExists ? "page" : "full", dsExists);
4237
+ saveDesignState(cacheDir, state);
4238
+ }
4239
+ if (event.phase === "post") {
4240
+ recordPost(event, cacheDir, state);
4241
+ if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
4242
+ const warnings = runDesignChecks(event.content ?? "");
4243
+ if (warnings.length) return {
4244
+ kind: "inform",
4245
+ title: "Design review",
4246
+ reason: warnings.join(" "),
4247
+ actions: []
4248
+ };
4437
4249
  }
4438
- const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
4439
- const ctx = {
4440
- sessionId: input.sessionId,
4441
- framework: input.framework,
4442
- filePath: input.filePath,
4443
- content: input.content ?? "",
4444
- authorizations: track.authorizations,
4445
- refs: input.refs,
4446
- refsRead: track.refsRead,
4447
- agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
4448
- brainstormRequired: track.brainstormRequired,
4449
- brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
4250
+ return null;
4251
+ }
4252
+ if (event.tool === "Write" || event.tool === "Edit") {
4253
+ const fp = event.filePath ?? "";
4254
+ const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
4255
+ if (base) return base;
4256
+ if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
4257
+ kind: "block",
4258
+ title: "Design pipeline",
4259
+ reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
4260
+ actions: ["Call mcp__gemini-design__create_frontend first"]
4450
4261
  };
4451
- try {
4452
- const apex = evaluateApex(ctx);
4453
- if (apex) return apex;
4454
- } catch {
4455
- return FAIL_CLOSED;
4456
- }
4262
+ return null;
4457
4263
  }
4458
- return dryGate(input.tool, input.filePath, input.content, input.cwd);
4264
+ if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
4265
+ if (event.tool === SHOT) return screenshotScrollGate(state);
4266
+ if (event.tool === GEMINI) {
4267
+ if (!geminiEnabled()) return null;
4268
+ const block = geminiCreateGate(state);
4269
+ if (block) return block;
4270
+ const missing = validateDesignSystem(findDesignSystem(cwd));
4271
+ if (missing.length) return {
4272
+ kind: "block",
4273
+ title: "Design pipeline",
4274
+ reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
4275
+ actions: ["Fix design-system.md, then retry create_frontend"]
4276
+ };
4277
+ saveDesignState(cacheDir, recordValidDesignSystem(state));
4278
+ }
4279
+ return null;
4459
4280
  }
4460
4281
  //#endregion
4461
4282
  //#region src/runtime/handle-pre.ts
@@ -4522,6 +4343,203 @@ async function handlePre(ctx) {
4522
4343
  };
4523
4344
  }
4524
4345
  //#endregion
4346
+ //#region src/freshness/explore-tools.ts
4347
+ /** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
4348
+ const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
4349
+ /** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
4350
+ const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
4351
+ "mcp__context7__query-docs",
4352
+ "mcp__context7__resolve-library-id",
4353
+ "mcp__exa__web_search_exa",
4354
+ "mcp__exa__get_code_context_exa",
4355
+ "mcp__exa__deep_researcher_start",
4356
+ "WebSearch",
4357
+ "WebFetch"
4358
+ ]);
4359
+ /** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
4360
+ const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
4361
+ "grep",
4362
+ "rg",
4363
+ "find",
4364
+ "ls",
4365
+ "fd",
4366
+ "ast-grep",
4367
+ "tree",
4368
+ "cat",
4369
+ "head",
4370
+ "tail"
4371
+ ]);
4372
+ /** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
4373
+ const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
4374
+ /**
4375
+ * First non-assignment shell token's basename, or "" — mirrors Python
4376
+ * `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
4377
+ * @param cmd - Raw Bash `command` string.
4378
+ * @returns The executable basename, or "" when none.
4379
+ */
4380
+ function bashExecutable(cmd) {
4381
+ for (const token of cmd.trim().split(/\s+/)) {
4382
+ if (!token) continue;
4383
+ const last = token.split("/").pop() ?? token;
4384
+ if (!last.includes("=")) return last;
4385
+ }
4386
+ return "";
4387
+ }
4388
+ /**
4389
+ * Classify a direct tool use into an APEX phase, or `null` when it is neither
4390
+ * exploration nor research. Mirrors Python `track-subagent-research._classify`.
4391
+ * @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
4392
+ * @param input - Tool input payload.
4393
+ * @returns The credited phase + cache flag, or `null`.
4394
+ */
4395
+ function classifyExplore(tool, input) {
4396
+ if (RESEARCH_TOOLS.has(tool)) return {
4397
+ phase: "research-expert",
4398
+ cacheHit: false
4399
+ };
4400
+ if (EXPLORE_TOOLS.has(tool)) return {
4401
+ phase: "explore-codebase",
4402
+ cacheHit: false
4403
+ };
4404
+ if (tool === "Read") {
4405
+ const path = String(input?.file_path ?? input?.path ?? "");
4406
+ if (path && CACHE_READ_RE.test(path)) return {
4407
+ phase: "research-expert",
4408
+ cacheHit: true
4409
+ };
4410
+ return null;
4411
+ }
4412
+ if (tool === "Bash") {
4413
+ const cmd = String(input?.command ?? "").trim();
4414
+ if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
4415
+ phase: "explore-codebase",
4416
+ cacheHit: false
4417
+ };
4418
+ }
4419
+ return null;
4420
+ }
4421
+ //#endregion
4422
+ //#region src/runtime/activity.ts
4423
+ /** Min response length (chars) for a lead agent call to count as `sufficient`. */
4424
+ const AGENT_QUALITY_MIN = 500;
4425
+ /** Min response length (chars) for direct exploration/research (parity Python `> 50`). */
4426
+ const EXPLORE_QUALITY_MIN = 50;
4427
+ /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
4428
+ const READ_TOOLS = /* @__PURE__ */ new Set([
4429
+ "Read",
4430
+ "read_file",
4431
+ "read_many_files"
4432
+ ]);
4433
+ /** Grade a response length against a threshold; `undefined` when length is unknown. */
4434
+ function qualityFor(len, min) {
4435
+ return len === void 0 ? void 0 : len > min ? "sufficient" : "insufficient";
4436
+ }
4437
+ /** Build an `agent` activity, attaching `quality` only when graded. */
4438
+ function agentActivity(name, ts, quality) {
4439
+ return quality ? {
4440
+ kind: "agent",
4441
+ name,
4442
+ ts,
4443
+ quality
4444
+ } : {
4445
+ kind: "agent",
4446
+ name,
4447
+ ts
4448
+ };
4449
+ }
4450
+ /**
4451
+ * The documentation source a tool satisfies for the doc-consultation gate, or
4452
+ * undefined. Context7/Exa are the primary sources; fuse-browser is the fallback
4453
+ * when Exa is down (`browser_fetch`/`browser_crawl`/`browser_serp_batch`); the
4454
+ * built-in WebSearch/WebFetch also count.
4455
+ * @param tool - The harness tool name.
4456
+ */
4457
+ function docSourceOf(tool) {
4458
+ if (/context7/i.test(tool)) return "context7";
4459
+ if (/exa/i.test(tool)) return "exa";
4460
+ if (/browser_(fetch|crawl|serp)/i.test(tool)) return "fuse-browser";
4461
+ if (tool === "WebSearch") return "websearch";
4462
+ if (tool === "WebFetch") return "webfetch";
4463
+ }
4464
+ /**
4465
+ * Map a live tool-use to the activity to record, or null when nothing is
4466
+ * tracked. Works across harnesses — tool names are globally distinct:
4467
+ * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
4468
+ * - `Task`/`Agent` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
4469
+ * - direct exploration/research (Glob/Grep, explore Bash, web, MCP cache reads)
4470
+ * → `agent` credited to the matching REQUIRED_AGENTS phase
4471
+ * - a read tool opening a `.md` reference → `ref`
4472
+ */
4473
+ function activityFor(event) {
4474
+ const out = [];
4475
+ const docSource = docSourceOf(event.tool);
4476
+ if (docSource) out.push({
4477
+ kind: "doc",
4478
+ framework: event.framework,
4479
+ sessionId: event.sessionId,
4480
+ source: docSource
4481
+ });
4482
+ if (event.tool === "Task" || event.tool === "Agent") {
4483
+ const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
4484
+ if (name) out.push(agentActivity(name, event.now, qualityFor(event.responseLength, AGENT_QUALITY_MIN)));
4485
+ return out;
4486
+ }
4487
+ const hit = classifyExplore(event.tool, event.input);
4488
+ if (hit) {
4489
+ const quality = hit.cacheHit ? "sufficient" : qualityFor(event.responseLength, EXPLORE_QUALITY_MIN);
4490
+ out.push(agentActivity(hit.phase, event.now, quality));
4491
+ } else if (READ_TOOLS.has(event.tool)) {
4492
+ const path = String(event.input?.file_path ?? event.input?.path ?? "");
4493
+ if (path.endsWith(".md")) out.push({
4494
+ kind: "ref",
4495
+ path
4496
+ });
4497
+ }
4498
+ return out;
4499
+ }
4500
+ //#endregion
4501
+ //#region src/runtime/handle-post.ts
4502
+ /**
4503
+ * Run the PostToolUse pipeline: store the MCP response, emit a design warning,
4504
+ * record the activity into the session track, apply per-scope side-effects (SEO
4505
+ * deny, aipilot task cache), then inject the post-edit context.
4506
+ * @param ctx - The resolved context (same shape as the pre pipeline).
4507
+ * @returns The native hook outcome.
4508
+ */
4509
+ async function handlePost(ctx) {
4510
+ const { id, payload, event, framework, mcpDir, file, opts } = ctx;
4511
+ const response = payload.tool_response ?? payload.tool_output;
4512
+ mcpPostStore(event.tool, event.input, response, mcpDir);
4513
+ const designWarn = designGate(payload, event, mcpDir, opts.cwd);
4514
+ const activities = activityFor({
4515
+ tool: event.tool,
4516
+ input: event.input,
4517
+ sessionId: event.sessionId,
4518
+ framework,
4519
+ now: opts.now,
4520
+ responseLength: extractText(response).length
4521
+ });
4522
+ for (const activity of activities) await recordActivity(file, activity);
4523
+ postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
4524
+ const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
4525
+ if (seoDeny) return {
4526
+ stdout: seoDeny,
4527
+ exit: 0
4528
+ };
4529
+ if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate")) {
4530
+ const out = await aipilotPostToolUse(payload, opts.cwd);
4531
+ if (out) return {
4532
+ stdout: out,
4533
+ exit: 0
4534
+ };
4535
+ }
4536
+ const extra = postEditContext(opts.scope ?? "core", event, opts.now);
4537
+ return {
4538
+ stdout: designWarn ? respond(id, designWarn) : extra,
4539
+ exit: 0
4540
+ };
4541
+ }
4542
+ //#endregion
4525
4543
  //#region src/runtime/handle-scope-async.ts
4526
4544
  /**
4527
4545
  * Pre-pipeline async scope interception for {@link handleHook}. The aipilot and
@@ -4582,38 +4600,15 @@ async function handleHook(id, payload, opts) {
4582
4600
  exit: 0
4583
4601
  };
4584
4602
  }
4585
- if (event.phase === "post") {
4586
- const response = payload.tool_response ?? payload.tool_output;
4587
- mcpPostStore(event.tool, event.input, response, mcpDir);
4588
- const designWarn = designGate(payload, event, mcpDir, opts.cwd);
4589
- const activities = activityFor({
4590
- tool: event.tool,
4591
- input: event.input,
4592
- sessionId: event.sessionId,
4593
- framework,
4594
- now: opts.now,
4595
- responseLength: extractText(response).length
4596
- });
4597
- for (const activity of activities) await recordActivity(file, activity);
4598
- postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
4599
- const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
4600
- if (seoDeny) return {
4601
- stdout: seoDeny,
4602
- exit: 0
4603
- };
4604
- if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate")) {
4605
- const out = await aipilotPostToolUse(payload, opts.cwd);
4606
- if (out) return {
4607
- stdout: out,
4608
- exit: 0
4609
- };
4610
- }
4611
- const extra = postEditContext(opts.scope ?? "core", event, opts.now);
4612
- return {
4613
- stdout: designWarn ? respond(id, designWarn) : extra,
4614
- exit: 0
4615
- };
4616
- }
4603
+ if (event.phase === "post") return handlePost({
4604
+ id,
4605
+ payload,
4606
+ event,
4607
+ framework,
4608
+ mcpDir,
4609
+ file,
4610
+ opts
4611
+ });
4617
4612
  return handlePre({
4618
4613
  id,
4619
4614
  payload,
@@ -4625,4 +4620,4 @@ async function handleHook(id, payload, opts) {
4625
4620
  });
4626
4621
  }
4627
4622
  //#endregion
4628
- export { sessionStartCore as $, writePluginMap as A, fusengineCache as At, trackSessionChanges as B, aipilotPostToolUse as C, isoUtc as Ct, lessonsStateFileFor as D, securityStatePath as Dt, lessonsFileFor as E, securityStateDir as Et, mergeLines as F, sessionsDir as Ft, validateTeammateOutput as G, cleanupSession as H, countFiles as I, detectSolidProfile as J, trackAgentMemory as K, getFileDesc as L, isProject as M, sanitizeSessionId as Mt, writeTree as N, saveSessionState as Nt, cartoSessionStart as O, todayUtc as Ot, loadEnriched as P, sessionStatePath as Pt, runSessionStartCleanups as Q, listChildren as R, dispatchLifecycle as S, activityFor as St, dispatchLessons as T, saveSecurityState as Tt, saveApexState as U, validateRulesLoaded as V, logToolFailure as W, injectRules as X, solidDetectStart as Y, readRules as Z, postTrackingSideEffects as _, MCP_TTL_MS as _t, TRIVIAL_BUDGET as a, gitContext as at, trackSkillRead as b, isMcpTool as bt, detectDuplication as c, taskContext as ct, lifecycleStdout as d, defaultStateDir as dt, pruneEmptyDirs as et, postEditContext as f, projectHash$1 as ft, securityAdvisory as g, mcpPreIntercept as gt, dispatchMemory as h, mcpPostStore as ht, REQUIRED_AGENTS as i, devContext as it, generateProjectMap as j, loadSessionState as jt, generateEcosystemMap as k, claudeHome as kt, dryGate as l, respond as lt, seoPostToolUseResponse as m, normalizeEvent as mt, handlePre as n, removeOldFiles as nt, gate as o, projectContext as ot, seoPostToolUse as p, trackFile as pt, subagentCacheContext as q, DEFAULT_WINDOW_MS as r, trimLogFile as rt, preCommitGate as s, promptSubmitContext as st, handleHook as t, purgeTtlTree as tt, extractSymbols as u, recordActivity as ut, trackWatchResearch as v, WEBFETCH_TTL_MS as vt, dispatchAipilot as w, loadSecurityState as wt, trackEnrichment as x, queryOf as xt, trackMcpResearch as y, cacheQueryOf as yt, postEditTypescript as z };
4623
+ export { saveApexState as $, trackSkillRead as A, writePluginMap as B, seoPostToolUse as C, isoUtc as Ct, postTrackingSideEffects as D, securityStatePath as Dt, securityAdvisory as E, securityStateDir as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, aipilotPostToolUse as N, trackWatchResearch as O, todayUtc as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, normalizeEvent as St, dispatchMemory as T, saveSecurityState as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, promptSubmitContext as _t, recordActivity as a, solidDetectStart as at, extractSymbols as b, projectHash$1 as bt, MCP_TTL_MS as c, runSessionStartCleanups as ct, isMcpTool as d, purgeTtlTree as dt, logToolFailure as et, queryOf as f, removeOldFiles as ft, gate as g, projectContext as gt, TRIVIAL_BUDGET as h, gitContext as ht, respond as i, detectSolidProfile as it, trackEnrichment as j, trackMcpResearch as k, WEBFETCH_TTL_MS as l, sessionStartCore as lt, REQUIRED_AGENTS as m, devContext as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, injectRules as ot, DEFAULT_WINDOW_MS as p, trimLogFile as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, readRules as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, pruneEmptyDirs as ut, detectDuplication as v, taskContext as vt, seoPostToolUseResponse as w, loadSecurityState as wt, lifecycleStdout as x, trackFile as xt, dryGate as y, defaultStateDir as yt, generateEcosystemMap as z };