@fusengine/harness 0.1.40 → 0.1.42

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 (39) hide show
  1. package/README.md +54 -4
  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 +160 -6
  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/{doc-helpers-B4XYL1v8.mjs → doc-helpers-BhzDmJ18.mjs} +2 -2
  15. package/dist/{evaluate-B1n1ti0N.mjs → evaluate-CeivW6G0.mjs} +4 -4
  16. package/dist/freshness/index.mjs +2 -2
  17. package/dist/{freshness-43gxYpiX.mjs → freshness-BV3PQkDB.mjs} +1 -1
  18. package/dist/{handle-gYSq5znf.mjs → handle-BTHcKWQ5.mjs} +892 -882
  19. package/dist/{harness-DwJskkz_.d.mts → harness-BPPu5CrN.d.mts} +4 -8
  20. package/dist/{index-DTrjSNmI.d.mts → index-DN4cZDbU.d.mts} +7 -5
  21. package/dist/{index-COBvvc3L.d.mts → index-DXQfL1u8.d.mts} +1 -1
  22. package/dist/index.d.mts +4 -4
  23. package/dist/index.mjs +7 -7
  24. package/dist/init/index.d.mts +1 -1
  25. package/dist/{mcp-store-CDUVqtJ0.mjs → mcp-store-CnsW9oFj.mjs} +1 -1
  26. package/dist/memory/index.mjs +1 -1
  27. package/dist/policy/index.d.mts +2 -2
  28. package/dist/policy/index.mjs +2 -2
  29. package/dist/{registry-BkoEbdec.mjs → registry-CymilZiZ.mjs} +3 -2
  30. package/dist/{run-D-Ydrw3D.mjs → run-jgivVDv6.mjs} +1 -1
  31. package/dist/runtime/index.d.mts +14 -13
  32. package/dist/runtime/index.mjs +3 -2
  33. package/dist/state/index.mjs +1 -1
  34. package/dist/{state-BthKK4Jj.mjs → state-9lWvvWk8.mjs} +1 -1
  35. package/dist/{store-3WBr37Xz.mjs → store-CdWOQ9zD.mjs} +50 -3
  36. package/dist/tracking/index.mjs +1 -1
  37. package/dist/{validate-BCRqp9XG.mjs → validate-xi-zc-22.mjs} +2 -2
  38. package/package.json +1 -1
  39. 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-BCRqp9XG.mjs";
7
- import { N as detectFramework, j as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-B1n1ti0N.mjs";
8
- import { r as isDocConsulted } from "./doc-helpers-B4XYL1v8.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
+ 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,245 +54,7 @@ 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");
102
- }
103
- //#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 "";
145
- }
146
- /**
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`.
152
- */
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
175
- };
176
- }
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
206
- };
207
- }
208
- /**
209
- * Map a live tool-use to the activity to record, or null when nothing is
210
- * tracked. Works across harnesses — tool names are globally distinct:
211
- * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
212
- * - `Task`/`Agent` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
213
- * - direct exploration/research (Glob/Grep, explore Bash, web, MCP cache reads)
214
- * → `agent` credited to the matching REQUIRED_AGENTS phase
215
- * - a read tool opening a `.md` reference → `ref`
216
- */
217
- function activityFor(event) {
218
- if (/context7|exa/i.test(event.tool)) return {
219
- kind: "doc",
220
- framework: event.framework,
221
- sessionId: event.sessionId,
222
- source: /exa/i.test(event.tool) ? "exa" : "context7"
223
- };
224
- if (event.tool === "Task" || event.tool === "Agent") {
225
- const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
226
- if (!name) return null;
227
- return agentActivity(name, event.now, qualityFor(event.responseLength, AGENT_QUALITY_MIN));
228
- }
229
- const hit = classifyExplore(event.tool, event.input);
230
- if (hit) {
231
- const quality = hit.cacheHit ? "sufficient" : qualityFor(event.responseLength, EXPLORE_QUALITY_MIN);
232
- return agentActivity(hit.phase, event.now, quality);
233
- }
234
- if (READ_TOOLS.has(event.tool)) {
235
- const path = String(event.input?.file_path ?? event.input?.path ?? "");
236
- if (path.endsWith(".md")) return {
237
- kind: "ref",
238
- path
239
- };
240
- }
241
- return null;
242
- }
243
- //#endregion
244
- //#region src/runtime/mcp-key.ts
245
- /** Cache-key + tool-class helpers for the MCP/WebFetch interception pipeline. */
246
- /** Default freshness for cached MCP doc results (48h). */
247
- const MCP_TTL_MS = 1728e5;
248
- /** WebFetch freshness — pages go stale faster than docs (24h, parity with Python). */
249
- const WEBFETCH_TTL_MS = 864e5;
250
- /** WebFetch prompt slice folded into its cache key (parity with Python prompt[:500]). */
251
- const PROMPT_TRUNC = 500;
252
- /** True for the WebFetch tool (exact name). */
253
- function isWebFetch(tool) {
254
- return tool === "WebFetch";
255
- }
256
- /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
257
- function isMcpTool(tool) {
258
- return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
259
- }
260
- /** The query/url that keys the cache. */
261
- function queryOf(input) {
262
- const q = input.query ?? input.url ?? input.libraryId ?? "";
263
- return typeof q === "string" ? q : JSON.stringify(q);
264
- }
265
- /**
266
- * The string that keys the cache for `tool`. WebFetch folds `url + "\n" +
267
- * prompt[:500]` (parity with Python) so distinct prompts on the same URL never
268
- * collide; every other tool keys on {@link queryOf}.
269
- */
270
- function cacheQueryOf(tool, input) {
271
- if (!isWebFetch(tool)) return queryOf(input);
272
- return `${typeof input.url === "string" ? input.url.trim() : ""}\n${(typeof input.prompt === "string" ? input.prompt : "").slice(0, PROMPT_TRUNC)}`;
273
- }
274
- //#endregion
275
- //#region src/runtime/mcp.ts
276
- function denyWith(id, content) {
277
- if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
278
- hookEventName: "PreToolUse",
279
- permissionDecision: "deny",
280
- permissionDecisionReason: content
281
- } });
282
- if (id === "gemini-cli") return JSON.stringify({
283
- decision: "deny",
284
- reason: content
285
- });
286
- return "";
287
- }
288
- function mutateWith(id, input) {
289
- if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
290
- hookEventName: "PreToolUse",
291
- permissionDecision: "allow",
292
- updatedInput: input
293
- } });
294
- if (id === "gemini-cli") return JSON.stringify({ hookSpecificOutput: { tool_input: input } });
295
- return "";
296
- }
297
- /** The doc provider a served cache-hit satisfies (`exa`/`context7`), else undefined. */
298
- function docSourceOf(tool) {
299
- if (/exa/i.test(tool)) return "exa";
300
- if (/context7/i.test(tool)) return "context7";
301
- }
302
- /**
303
- * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
304
- * else cap exa verbosity (allow + mutated input), else null to allow normally.
305
- * Harnesses without input-mutation/cache support fall through to null.
306
- */
307
- function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
308
- if (!isMcpTool(tool)) return null;
309
- const web = isWebFetch(tool);
310
- const ttl = web ? WEBFETCH_TTL_MS : ttlMs;
311
- const key = cacheQueryOf(tool, input);
312
- const cached = cacheLookup(dir, tool, key, ttl, now) ?? (web ? null : cacheLookupSubstring(dir, key, ttl, now));
313
- if (cached) {
314
- const served = denyWith(id, cached);
315
- if (served) return {
316
- stdout: served,
317
- docSource: docSourceOf(tool)
318
- };
319
- }
320
- const capped = capVerbosity(tool, input);
321
- if (capped) {
322
- const mutated = mutateWith(id, capped);
323
- if (mutated) return { stdout: mutated };
324
- }
325
- return null;
326
- }
327
- /**
328
- * Post-event: persist the MCP/WebFetch response (extracted to markdown). MCP docs
329
- * go through {@link mcpCacheWrite} (compact + Jaccard-dedup + `index.json`);
330
- * WebFetch uses {@link webfetchCacheWrite} (compact, exact key, no index).
331
- * @param now - Current epoch ms (timestamp source; defaults to `Date.now()`).
332
- */
333
- function mcpPostStore(tool, input, response, dir, now = Date.now()) {
334
- if (!isMcpTool(tool)) return;
335
- const text = extractText(response);
336
- const key = cacheQueryOf(tool, input);
337
- if (isWebFetch(tool)) webfetchCacheWrite(dir, tool, key, text, now);
338
- else mcpCacheWrite(dir, tool, key, text, now);
57
+ atomicWrite(securityStatePath(now, home), JSON.stringify(state, null, 2));
339
58
  }
340
59
  //#endregion
341
60
  //#region src/runtime/normalize.ts
@@ -381,7 +100,7 @@ function normalizeEvent(id, payload) {
381
100
  * @module paths
382
101
  * Runtime path helpers — per-project, out-of-tree harness state.
383
102
  *
384
- * Default base: ~/.claude/fuse-harness/state/<projectHash>/
103
+ * Default base: ~/.fuse-harness/state/<projectHash>/
385
104
  * where projectHash = 8-char MD5 of CLAUDE_PROJECT_DIR (or cwd).
386
105
  * Persistent and not world-writable (unlike /tmp), and OUTSIDE the repo so the
387
106
  * agent has no "legitimate" reason to write it (the protected-path guard denies
@@ -405,13 +124,13 @@ function projectHash$1(projectDir) {
405
124
  }
406
125
  /**
407
126
  * Canonical base directory for per-project harness state.
408
- * Resolves to: ~/.claude/fuse-harness/state/<projectHash>/
127
+ * Resolves to: ~/.fuse-harness/state/<projectHash>/
409
128
  *
410
129
  * @param projectDir - Optional override for hashing; defaults to CLAUDE_PROJECT_DIR/cwd.
411
130
  * @returns Absolute directory path (not yet created on disk).
412
131
  */
413
132
  function defaultStateDir(projectDir) {
414
- return join(homedir(), ".claude", "fuse-harness", "state", projectHash$1(projectDir));
133
+ return join(fuseHarnessHome(), "state", projectHash$1(projectDir));
415
134
  }
416
135
  /**
417
136
  * Absolute path to a session's track JSON file.
@@ -421,51 +140,76 @@ function defaultStateDir(projectDir) {
421
140
  * @param sessionId - Claude session identifier (raw value accepted; sanitised internally).
422
141
  * @param baseDir - Override the base directory. Omit in production; pass an explicit
423
142
  * temp path in unit tests to avoid touching $HOME.
424
- * @returns Absolute path, e.g. ~/.claude/fuse-harness/state/a1b2c3d4/track-abc123.json
143
+ * @returns Absolute path, e.g. ~/.fuse-harness/state/a1b2c3d4/track-abc123.json
425
144
  */
426
145
  function trackFile(sessionId, baseDir) {
427
146
  return join(baseDir ?? defaultStateDir(), `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
428
147
  }
429
148
  //#endregion
430
- //#region src/runtime/record.ts
431
- /** Apply an activity to a session's track and persist it (PostToolUse path). */
432
- async function recordActivity(file, activity) {
433
- const track = await loadTrack(file);
434
- 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));
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";
435
159
  }
436
- //#endregion
437
- //#region src/runtime/respond.ts
438
- /**
439
- * Map a portable {@link Prompt} to a harness's native hook response. `block`
440
- * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
441
- * `ask` — they only honor deny.)
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
+ ])];
174
+ }
175
+ return next;
176
+ }
177
+ /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
178
+ function recordNavigate(state) {
179
+ return {
180
+ ...state,
181
+ scrolledSinceNav: false
182
+ };
183
+ }
184
+ /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
185
+ function recordScroll(state) {
186
+ return {
187
+ ...state,
188
+ scrolledSinceNav: true
189
+ };
190
+ }
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
+ };
200
+ }
201
+ /**
202
+ * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
203
+ * allowed); reading the inspiration catalog satisfies the browse prerequisite.
442
204
  */
443
- function respond(id, prompt) {
444
- const message = formatPrompt(prompt);
445
- const deny = prompt.kind === "block";
446
- switch (id) {
447
- case "claude-code":
448
- case "codex": return JSON.stringify({ hookSpecificOutput: {
449
- hookEventName: "PreToolUse",
450
- permissionDecision: deny ? "deny" : "ask",
451
- permissionDecisionReason: message
452
- } });
453
- case "gemini-cli": return JSON.stringify(deny ? {
454
- decision: "deny",
455
- reason: message
456
- } : { hookSpecificOutput: { additionalContext: message } });
457
- case "cursor": return JSON.stringify({
458
- permission: deny ? "deny" : "ask",
459
- continue: false,
460
- userMessage: message,
461
- agentMessage: message
462
- });
463
- case "cline": return JSON.stringify(deny ? {
464
- cancel: true,
465
- errorMessage: message
466
- } : { contextModification: message });
467
- 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"])];
468
210
  }
211
+ if (filePath.includes("design-inspiration")) next.inspirationRead = true;
212
+ return next;
469
213
  }
470
214
  //#endregion
471
215
  //#region src/policy/design/state.ts
@@ -488,8 +232,7 @@ function loadDesignState(cacheDir, agentId) {
488
232
  }
489
233
  /** Persist the design state under its agent id. */
490
234
  function saveDesignState(cacheDir, state) {
491
- mkdirSync(cacheDir, { recursive: true });
492
- writeFileSync(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
235
+ atomicWrite(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
493
236
  }
494
237
  /** Build the initial state for a design agent starting a run. */
495
238
  function initDesignState(agentId, mode, designSystemExists) {
@@ -528,72 +271,6 @@ function cleanupDesignStates(cacheDir, agentId, stamp, now) {
528
271
  }
529
272
  }
530
273
  //#endregion
531
- //#region src/policy/design/transitions.ts
532
- /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
533
- function detectMode(prompt, designSystemExists) {
534
- const p = prompt.toLowerCase();
535
- if ([
536
- "component",
537
- "composant",
538
- "snippet"
539
- ].some((k) => p.includes(k))) return "component";
540
- return designSystemExists ? "page" : "full";
541
- }
542
- /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
543
- function recordScreenshot(state, needed) {
544
- const screenshotsCount = state.screenshotsCount + 1;
545
- const next = {
546
- ...state,
547
- screenshotsCount
548
- };
549
- if (screenshotsCount >= needed && state.currentPhase < 2) {
550
- next.currentPhase = 2;
551
- next.phasesCompleted = [.../* @__PURE__ */ new Set([
552
- ...state.phasesCompleted,
553
- "identity",
554
- "research"
555
- ])];
556
- }
557
- return next;
558
- }
559
- /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
560
- function recordNavigate(state) {
561
- return {
562
- ...state,
563
- scrolledSinceNav: false
564
- };
565
- }
566
- /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
567
- function recordScroll(state) {
568
- return {
569
- ...state,
570
- scrolledSinceNav: true
571
- };
572
- }
573
- /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
574
- function recordValidDesignSystem(state) {
575
- return {
576
- ...state,
577
- designSystemExists: true,
578
- designSystemValid: true,
579
- currentPhase: Math.max(state.currentPhase, 3),
580
- phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
581
- };
582
- }
583
- /**
584
- * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
585
- * allowed); reading the inspiration catalog satisfies the browse prerequisite.
586
- */
587
- function recordRead(state, filePath) {
588
- const next = { ...state };
589
- if (filePath.includes("identity-system")) {
590
- next.currentPhase = Math.max(state.currentPhase, 1);
591
- next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
592
- }
593
- if (filePath.includes("design-inspiration")) next.inspirationRead = true;
594
- return next;
595
- }
596
- //#endregion
597
274
  //#region src/policy/design/flag.ts
598
275
  const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
599
276
  /** The active design agent id (the flag), or "" when no design agent is running. */
@@ -618,346 +295,42 @@ function clearActiveDesignAgent(cacheDir) {
618
295
  } catch {}
619
296
  }
620
297
  //#endregion
621
- //#region src/policy/design/content-checks.ts
622
- /** Accessibility warnings: icon buttons need aria-label, images need alt. */
623
- function checkAccessibility(content) {
624
- const w = [];
625
- if (!/<(button|a|input|img)/.test(content)) return w;
626
- if (/<button[^>]*>/.test(content) && !/aria-label|aria-labelledby/.test(content) && /<button[^>]*>[^<]*<[^>]*Icon/.test(content)) w.push("Accessibility: icon buttons need an aria-label.");
627
- for (const m of content.matchAll(/<img[^>]*?>/g)) if (!m[0].includes("alt=")) {
628
- w.push("Accessibility: images need an alt attribute.");
629
- break;
298
+ //#region src/runtime/design-lifecycle.ts
299
+ /**
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).
303
+ */
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;
630
314
  }
631
- return w;
632
- }
633
- /** Anti-pattern warnings: colored left borders, AI-slop gradients, emoji-as-icons. */
634
- function checkPatterns(content) {
635
- const w = [];
636
- if (/border-l-[0-9]+ border-l-(blue|green|red|purple)/.test(content)) w.push("Design: avoid colored left borders — use shadow/gradient.");
637
- 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.");
638
- if (/>[^\x00-\x7F]+</.test(content)) w.push("Design: avoid emojis as icons — use a real icon set.");
639
- return w;
640
- }
641
- /** Forbidden-font warnings (CSS font-family + Google Fonts import). */
642
- function checkFonts(content) {
643
- const w = [];
644
- 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.");
645
- if (/@import.*fonts\.googleapis.*family=(Roboto|Inter)\b/.test(content)) w.push("Font: Google Fonts import for a forbidden family.");
646
- return w;
647
- }
648
- /** Hard-coded-color warnings (hex in className or inline style). */
649
- function checkColors(content) {
650
- const w = [];
651
- if (/className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/.test(content)) w.push("Color: hard-coded hex in className — use CSS variables.");
652
- 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-*).");
653
- return w;
654
- }
655
- /** Run all design content checks → non-blocking warnings (empty = clean). */
656
- function runDesignChecks(content) {
657
- return [
658
- ...checkAccessibility(content),
659
- ...checkPatterns(content),
660
- ...checkFonts(content),
661
- ...checkColors(content)
662
- ];
315
+ if (event === "SubagentStop") {
316
+ cleanupDesignStates(cacheDir, agentId, stamp, now);
317
+ clearActiveDesignAgent(cacheDir);
318
+ return true;
319
+ }
320
+ return false;
663
321
  }
664
322
  //#endregion
665
- //#region src/policy/design/skill-gate.ts
666
- /** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
667
- const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
668
- /** Generated/vendored paths that never need a design skill (match at start or mid-path). */
669
- const EXEMPT_RE = /(^|\/)(node_modules|dist|build)\/|\.claude\//;
670
- /** UI-intent path fragments (ports the Python `UI_PATH_PATTERNS`). */
671
- const UI_PATH_RE = /(components|ui|styles|page|layout|content|view|feature|section|hero|footer|header|sidebar|nav|modal|dialog)/;
672
- /** Tailwind utility usage in JSX `className` (ports the Python `has_jsx_tailwind`). */
673
- const TAILWIND_RE = /className\s*=.*(?:flex|grid|p-|m-|bg-|text-|rounded|shadow|border|gap-|w-|h-)/;
674
- /** A read reference path that counts as "a design skill was consulted". */
675
- const DESIGN_SKILL_RE = /skills\/([0-9]-)?(designing-systems|generating-components|solid-react|solid-nextjs|design|impeccable|frontend-design)/i;
676
- /** True when this Write/Edit targets a UI file in a UI-ish path or with Tailwind classes. */
677
- function isUiWrite(tool, filePath, content) {
678
- if (tool !== "Write" && tool !== "Edit") return false;
679
- if (!UI_FILE_RE.test(filePath) || EXEMPT_RE.test(filePath)) return false;
680
- return UI_PATH_RE.test(filePath) || TAILWIND_RE.test(content);
681
- }
682
- /** True when a design skill reference was read this session (scans the `refsRead` track). */
683
- function designSkillRead(refsRead) {
684
- return refsRead.some((p) => DESIGN_SKILL_RE.test(p));
685
- }
686
- const block$1 = (reason, action) => ({
687
- kind: "block",
688
- title: "Design skill",
689
- reason,
690
- actions: [action]
691
- });
323
+ //#region src/runtime/inject-context.ts
692
324
  /**
693
- * Gate a UI write: require a design-skill read AND any doc source. Returns a
694
- * blocking {@link Prompt}, or `null` to allow. NEVER requires Gemini.
695
- * @param tool - the tool name ("Write" | "Edit" | ...).
696
- * @param filePath - the written file path.
697
- * @param content - the written content (snippet on Edit, full file on Write).
698
- * @param ev - the session evidence (refsRead + docConsulted).
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).
699
330
  */
700
- function uiDesignSkillGate(tool, filePath, content, ev) {
701
- if (!isUiWrite(tool, filePath, content)) return null;
702
- 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");
703
- 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");
704
- return null;
705
- }
706
- //#endregion
707
- //#region src/policy/design/skill-evidence.ts
708
- /**
709
- * @module design/skill-evidence
710
- * IO side of the UI design-skill gate: read the verified session track and
711
- * derive {@link DesignEvidence} (read references + doc-consulted) for
712
- * {@link uiDesignSkillGate}. Kept separate from the pure gate logic (SRP).
713
- * @packageDocumentation
714
- */
715
- /**
716
- * Read the verified session track and derive {@link DesignEvidence}. Fail-closed
717
- * on a missing/corrupt/tampered track (no evidence → the gate blocks).
718
- * @param sessionId - the Claude session id.
719
- * @param cwd - the project root (selects the per-project state dir).
720
- * @param baseDir - override the track base dir (tests); defaults to the project state dir.
721
- */
722
- function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
723
- let track = null;
724
- try {
725
- track = verifyTrack(JSON.parse(readFileSync(trackFile(sessionId, baseDir), "utf8")));
726
- } catch {
727
- track = null;
728
- }
729
- return {
730
- refsRead: track?.refsRead ?? [],
731
- docConsulted: track ? isDocConsulted(track.authorizations, sessionId) : false
732
- };
733
- }
734
- //#endregion
735
- //#region src/runtime/design-helpers.ts
736
- /**
737
- * @module design-helpers
738
- * Effectful helpers for the design pipeline gate: locate design-system.md and
739
- * apply PostToolUse fuse-browser transitions to the design state. Split out of
740
- * `design.ts` to keep that file within the SOLID size budget (SRP).
741
- * @packageDocumentation
742
- */
743
- const NAV$1 = "mcp__fuse-browser__browser_navigate";
744
- const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
745
- const SCROLL = "mcp__fuse-browser__browser_scroll";
746
- const GEMINI$1 = "mcp__gemini-design__create_frontend";
747
- /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
748
- function findDesignSystem(cwd) {
749
- let dir = cwd;
750
- for (let i = 0; i < 6; i++) {
751
- const p = join(dir, "design-system.md");
752
- if (existsSync(p)) try {
753
- return readFileSync(p, "utf8");
754
- } catch {
755
- return "";
756
- }
757
- const parent = dirname(dir);
758
- if (parent === dir) break;
759
- dir = parent;
760
- }
761
- return "";
762
- }
763
- /** Apply a PostToolUse fuse-browser transition to the design state. */
764
- function recordPost(event, cacheDir, state) {
765
- if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
766
- else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
767
- else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
768
- else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
769
- ...state,
770
- geminiCalls: state.geminiCalls + 1
771
- });
772
- else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
773
- else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
774
- }
775
- //#endregion
776
- //#region src/policy/design/gates.ts
777
- const ALLOWED_WRITE = /\.(html|css|md|json)$/;
778
- const EXEMPT_DIRS = [
779
- "node_modules/",
780
- "dist/",
781
- "build/",
782
- ".claude/"
783
- ];
784
- const FORBIDDEN_FONTS = [
785
- "Inter",
786
- "Roboto",
787
- "Arial",
788
- "Open Sans"
789
- ];
790
- const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
791
- const KNOWN_DOMAINS = [
792
- "framer.website",
793
- "webflow.io",
794
- "awwwards.com",
795
- "godly.website",
796
- "lapa.ninja",
797
- "onepagelove.com",
798
- "saasframe.io",
799
- "bestwebsite.gallery",
800
- "landingfolio.com"
801
- ];
802
- const deny = (reason) => ({
803
- kind: "block",
804
- title: "Design pipeline",
805
- reason,
806
- actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"]
807
- });
808
- /** Block the design agent from writing anything but .html/.css/.md/.json. */
809
- function htmlCssOnlyGate(filePath) {
810
- if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
811
- return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
812
- }
813
- /** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
814
- function stateFileGate(filePath) {
815
- return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are read-only; the hooks update them as you progress.") : null;
816
- }
817
- /** Gate writing design-system.md: requires phase ≥ 2 and the per-mode screenshot quota. */
818
- function designSystemWriteGate(filePath, state) {
819
- if (!filePath.endsWith("design-system.md")) return null;
820
- if (state.currentPhase < 2) return deny(`BLOCKED: cannot write design-system.md at phase ${state.currentPhase}. Read identity + inspiration, then browse & screenshot first.`);
821
- const needed = MIN_SCREENSHOTS[state.mode];
822
- if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
823
- return null;
824
- }
825
- /** Return the requirements missing from a design-system.md (empty = valid). */
826
- function validateDesignSystem(content) {
827
- const missing = [];
828
- if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
829
- if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
830
- if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
831
- if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
832
- return missing;
833
- }
834
- /** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
835
- function geminiCreateGate(state) {
836
- if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
837
- if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
838
- return null;
839
- }
840
- /** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
841
- function browserNavigateGate(state, url) {
842
- if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
843
- if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
844
- if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
845
- return null;
846
- }
847
- /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
848
- function screenshotScrollGate(state) {
849
- return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
850
- }
851
- /** The Gemini design gates are OPT-IN: off unless `FUSE_DESIGN_GEMINI` is `1`/`true`. */
852
- function geminiEnabled() {
853
- const v = process.env.FUSE_DESIGN_GEMINI;
854
- return v === "1" || v === "true";
855
- }
856
- //#endregion
857
- //#region src/runtime/design.ts
858
- const NAV = "mcp__fuse-browser__browser_navigate";
859
- const SHOT = "mcp__fuse-browser__browser_screenshot";
860
- const GEMINI = "mcp__gemini-design__create_frontend";
861
- /**
862
- * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
863
- * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
864
- */
865
- function designGate(payload, event, cacheDir, cwd) {
866
- if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
867
- const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
868
- if (skillBlock) return skillBlock;
869
- }
870
- const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
871
- const active = activeDesignAgent(cacheDir);
872
- if (active && agentId && agentId !== active) return null;
873
- const id = active || agentId;
874
- if (!id) return null;
875
- let state = loadDesignState(cacheDir, id);
876
- if (!state) {
877
- if (!active) return null;
878
- const dsExists = findDesignSystem(cwd) !== "";
879
- state = initDesignState(id, dsExists ? "page" : "full", dsExists);
880
- saveDesignState(cacheDir, state);
881
- }
882
- if (event.phase === "post") {
883
- recordPost(event, cacheDir, state);
884
- if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
885
- const warnings = runDesignChecks(event.content ?? "");
886
- if (warnings.length) return {
887
- kind: "inform",
888
- title: "Design review",
889
- reason: warnings.join(" "),
890
- actions: []
891
- };
892
- }
893
- return null;
894
- }
895
- if (event.tool === "Write" || event.tool === "Edit") {
896
- const fp = event.filePath ?? "";
897
- const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
898
- if (base) return base;
899
- if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
900
- kind: "block",
901
- title: "Design pipeline",
902
- reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
903
- actions: ["Call mcp__gemini-design__create_frontend first"]
904
- };
905
- return null;
906
- }
907
- if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
908
- if (event.tool === SHOT) return screenshotScrollGate(state);
909
- if (event.tool === GEMINI) {
910
- if (!geminiEnabled()) return null;
911
- const block = geminiCreateGate(state);
912
- if (block) return block;
913
- const missing = validateDesignSystem(findDesignSystem(cwd));
914
- if (missing.length) return {
915
- kind: "block",
916
- title: "Design pipeline",
917
- reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
918
- actions: ["Fix design-system.md, then retry create_frontend"]
919
- };
920
- saveDesignState(cacheDir, recordValidDesignSystem(state));
921
- }
922
- return null;
923
- }
924
- //#endregion
925
- //#region src/runtime/design-lifecycle.ts
926
- /**
927
- * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
928
- * raise the active flag on start, archive/cleanup + clear the flag on stop.
929
- * Returns true when it handled the event (caller should respond and stop).
930
- */
931
- function designLifecycle(payload, cacheDir, cwd, stamp, now) {
932
- const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
933
- if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
934
- const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
935
- if (event === "SubagentStart") {
936
- if (!agentId) return false;
937
- const dsExists = existsSync(join(cwd, "design-system.md"));
938
- saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
939
- setActiveDesignAgent(cacheDir, agentId);
940
- return true;
941
- }
942
- if (event === "SubagentStop") {
943
- cleanupDesignStates(cacheDir, agentId, stamp, now);
944
- clearActiveDesignAgent(cacheDir);
945
- return true;
946
- }
947
- return false;
948
- }
949
- //#endregion
950
- //#region src/runtime/inject-context.ts
951
- /**
952
- * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
953
- * preamble as a Claude `additionalContext` response, or "" when nothing to emit.
954
- * @param prompt - The raw user prompt.
955
- * @param cwd - Project root (for project-type detection).
956
- * @returns The native hook stdout (possibly empty).
957
- */
958
- function promptSubmitContext(prompt, cwd) {
959
- const ctx = buildClaudeMdContext(prompt, cwd);
960
- return ctx ? contextResponse("UserPromptSubmit", ctx) : "";
331
+ function promptSubmitContext(prompt, cwd) {
332
+ const ctx = buildClaudeMdContext(prompt, cwd);
333
+ return ctx ? contextResponse("UserPromptSubmit", ctx) : "";
961
334
  }
962
335
  /**
963
336
  * PreToolUse Task context injection: render the APEX sub-agent context as a
@@ -1087,7 +460,7 @@ function pruneEmpty(dir, stopAt) {
1087
460
  }
1088
461
  //#endregion
1089
462
  //#region src/runtime/lifecycle/session-start.ts
1090
- /** TTLs (seconds) for purgeable fusengine-cache subtrees (cleanup-old-caches.py). */
463
+ /** TTLs (seconds) for purgeable cache subtrees (cleanup-old-caches.py). */
1091
464
  const PURGEABLE = {
1092
465
  sessions: 48 * 3600,
1093
466
  webfetch: 24 * 3600,
@@ -1441,7 +814,7 @@ function saveApexState(cwd, now = Date.now()) {
1441
814
  /**
1442
815
  * Handle SessionEnd: remove stale `*.tmp` (>1h) under `session-tmp/` and stale
1443
816
  * legacy `claude_solid_reads_*` / `claude_session_changes_*` files (>2h) under
1444
- * `fusengine-cache`. Ports `session-end/cleanup-session.py`. No stdout.
817
+ * `cache`. Ports `session-end/cleanup-session.py`. No stdout.
1445
818
  * @param home - Home dir (defaults to `~`).
1446
819
  * @param now - Clock (defaults to `Date.now()`).
1447
820
  */
@@ -2333,7 +1706,7 @@ ${agents}
2333
1706
  /**
2334
1707
  * Shared cache-path + age helpers for the ai-pilot scope.
2335
1708
  * Reuses the harness home-state (`fusengineCache`) so the ai-pilot caches live
2336
- * 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
2337
1710
  * second cache layer.
2338
1711
  */
2339
1712
  /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
@@ -2344,11 +1717,11 @@ function hashText16(text) {
2344
1717
  function projectHash(projectPath) {
2345
1718
  return hashText16(projectPath);
2346
1719
  }
2347
- /** `~/.claude/fusengine-cache` base dir (shared with the core MCP cache). */
1720
+ /** `~/.fuse-harness/cache` base dir (shared with the core MCP cache). */
2348
1721
  function cacheBaseDir(home = homedir()) {
2349
1722
  return fusengineCache(home);
2350
1723
  }
2351
- /** Per-type, per-project cache dir: `fusengine-cache/<type>/<projectHash>`. */
1724
+ /** Per-type, per-project cache dir: `cache/<type>/<projectHash>`. */
2352
1725
  function cacheDirFor(type, projectPath, home = homedir()) {
2353
1726
  return join(cacheBaseDir(home), type, projectHash(projectPath));
2354
1727
  }
@@ -2970,7 +2343,7 @@ async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
2970
2343
  //#region src/runtime/lifecycle/aipilot/promote-global-lessons.ts
2971
2344
  /**
2972
2345
  * Promote high-frequency project lessons (occurrence count >= 3) to the global,
2973
- * stack-scoped cache (`fusengine-cache/lessons/_global/<stack>.json`, cap 25).
2346
+ * stack-scoped cache (`cache/lessons/_global/<stack>.json`, cap 25).
2974
2347
  * Ports the ai-pilot `promote-global-lessons.ts` (a spawned subprocess) as an
2975
2348
  * in-process call from `cacheSniperLessons`. Pure side-effect.
2976
2349
  */
@@ -3711,7 +3084,7 @@ function appendMemoryLog(name, line, rotateAt = 0, keep = 0, home = homedir()) {
3711
3084
  appendFileSync(file, `${line}\n`, "utf-8");
3712
3085
  if (rotateAt > 0) {
3713
3086
  const lines = readFileSync(file, "utf-8").split("\n").filter((l) => l.length > 0);
3714
- 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`);
3715
3088
  }
3716
3089
  } catch {}
3717
3090
  }
@@ -3953,7 +3326,8 @@ const EXEMPT_PATTERNS = [
3953
3326
  /\.claude-plugin\//,
3954
3327
  /CHANGELOG\.md$/,
3955
3328
  /marketplace\.json$/,
3956
- /\/\.claude\/(apex|memory|logs|fusengine-cache)\//
3329
+ /\/\.claude\/(apex|memory|logs)\//,
3330
+ /\/\.fuse-harness\//
3957
3331
  ];
3958
3332
  /**
3959
3333
  * True when `filePath` is a code file the APEX freshness/doc/SOLID gates should
@@ -3984,72 +3358,6 @@ function existingLineCount(path) {
3984
3358
  }
3985
3359
  }
3986
3360
  //#endregion
3987
- //#region src/freshness/agent-evidence.ts
3988
- /**
3989
- * Platform-authored transcript evidence for APEX agent freshness.
3990
- * Parses the Claude Code session JSONL transcript to find genuine `Task`/`Agent`
3991
- * tool_use entries — forging this requires writing into the transcript file
3992
- * which the Claude Code platform controls, unlike the self-recorded track.
3993
- */
3994
- /** Parse a raw `timestamp` field to epoch ms; `undefined` when absent or invalid. */
3995
- function parseTs(raw) {
3996
- if (raw === void 0) return void 0;
3997
- if (typeof raw === "number") return raw;
3998
- const ms = Date.parse(raw);
3999
- return Number.isFinite(ms) ? ms : void 0;
4000
- }
4001
- /**
4002
- * Return `true` ONLY when, for EVERY name in `names`, the Claude Code
4003
- * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task` or
4004
- * `Agent` tool whose `subagent_type` (or `name`) input field matches — after
4005
- * stripping any plugin prefix — with the entry timestamp within `windowMs` of `now`.
4006
- *
4007
- * **Timestamp note:** when a transcript entry carries no `timestamp` field it
4008
- * is counted as within-window (we cannot prove staleness). This is
4009
- * intentionally lenient to stay robust across transcript-format evolution; the
4010
- * tamper-resistance guarantee derives from the platform authoring the file —
4011
- * not from the timestamp alone.
4012
- *
4013
- * @param transcriptPath - Absolute path to the session `.jsonl` transcript
4014
- * (hook payload field: `transcript_path`). Returns `false` when `undefined`.
4015
- * @param names - Required agent `subagent_type` values — ALL must appear.
4016
- * @param windowMs - Freshness window in milliseconds.
4017
- * @param now - Current epoch ms (pass `Date.now()` at the call-site).
4018
- * @returns `true` when ALL agents have real, within-window transcript evidence.
4019
- */
4020
- function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
4021
- if (!transcriptPath || names.length === 0) return false;
4022
- let text;
4023
- try {
4024
- text = readText(transcriptPath);
4025
- } catch {
4026
- return false;
4027
- }
4028
- const cutoff = now - windowMs;
4029
- const found = /* @__PURE__ */ new Set();
4030
- for (const line of text.split("\n")) {
4031
- if (!line.trim()) continue;
4032
- let entry;
4033
- try {
4034
- entry = JSON.parse(line);
4035
- } catch {
4036
- continue;
4037
- }
4038
- const ts = parseTs(entry.timestamp);
4039
- if (ts !== void 0 && ts <= cutoff) continue;
4040
- const content = entry.message?.content;
4041
- if (!Array.isArray(content)) continue;
4042
- for (const block of content) {
4043
- if (block?.type !== "tool_use" || block.name !== "Task" && block.name !== "Agent") continue;
4044
- const raw = block.input?.subagent_type ?? block.input?.name;
4045
- const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
4046
- if (agent !== void 0 && names.includes(agent)) found.add(agent);
4047
- }
4048
- if (found.size === names.length) return true;
4049
- }
4050
- return names.every((n) => found.has(n));
4051
- }
4052
- //#endregion
4053
3361
  //#region src/runtime/dry-patterns.ts
4054
3362
  /** Short identifiers never worth a duplication check (control flow, tiny names). */
4055
3363
  const DRY_KEYWORDS = /* @__PURE__ */ new Set([
@@ -4291,7 +3599,7 @@ const PHP_BLOCKED_IN_APP = [
4291
3599
  "/app/Notifications/",
4292
3600
  "/app/Policies/"
4293
3601
  ];
4294
- const block = (reason) => ({
3602
+ const block$1 = (reason) => ({
4295
3603
  kind: "block",
4296
3604
  title: "Modular architecture",
4297
3605
  reason,
@@ -4301,30 +3609,30 @@ const block = (reason) => ({
4301
3609
  function nextModular(filePath, content, cwd) {
4302
3610
  const rel = relative(cwd, filePath);
4303
3611
  const bn = basename(filePath);
4304
- 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]/.`);
4305
3613
  const mod = filePath.match(/\/modules\/([^/]+)\//);
4306
3614
  if (!mod) return null;
4307
3615
  const current = mod[1] ?? "";
4308
3616
  for (const m of content.matchAll(/from\s+['"][@.][^'"]*?\/modules\/([^/]+)\//g)) {
4309
3617
  const imported = m[1] ?? "";
4310
3618
  if (current === "cores") {
4311
- if (imported !== "cores" && imported !== "core") return block(`BLOCKED: modules/cores/ must not import from modules/${imported}/.`);
4312
- } 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.`);
4313
3621
  }
4314
3622
  return null;
4315
3623
  }
4316
3624
  /** Laravel FuseCore architecture: `app/` domain ban + module.json + cross-module `use` rules. */
4317
3625
  function fusecore(filePath, content, cwd) {
4318
- 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/.`);
4319
3627
  const mod = filePath.match(/\/FuseCore\/([A-Za-z]+)\//);
4320
3628
  if (!mod) return null;
4321
3629
  const name = mod[1] ?? "";
4322
- 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.`);
4323
3631
  for (const m of content.matchAll(/use\s+FuseCore\\(\w+)\\/g)) {
4324
3632
  const imported = m[1] ?? "";
4325
3633
  if (name === "Core") {
4326
- if (imported !== "Core") return block(`BLOCKED: FuseCore\\Core\\ must not use FuseCore\\${imported}\\.`);
4327
- } 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.`);
4328
3636
  }
4329
3637
  return null;
4330
3638
  }
@@ -4377,6 +3685,110 @@ function frameworkSkillGate(input, refsRead, existingLines) {
4377
3685
  return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
4378
3686
  }
4379
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
4380
3792
  //#region src/runtime/gate.ts
4381
3793
  /** Prior agents the freshness gate requires before a code edit. */
4382
3794
  const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
@@ -4406,41 +3818,465 @@ async function gate(input) {
4406
3818
  } catch {
4407
3819
  return FAIL_CLOSED;
4408
3820
  }
4409
- if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
4410
- const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
4411
- if (modular) return modular;
4412
- if (!input.filePath) return null;
4413
- const window = input.windowMs ?? 12e4;
4414
- const track = await loadTrack(input.trackFile);
4415
- const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
4416
- if (solidOrSkill) return solidOrSkill;
4417
- if (isApexScoped(input.filePath)) {
4418
- const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
4419
- if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
4420
- await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
4421
- 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
+ };
4422
4249
  }
4423
- const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
4424
- const ctx = {
4425
- sessionId: input.sessionId,
4426
- framework: input.framework,
4427
- filePath: input.filePath,
4428
- content: input.content ?? "",
4429
- authorizations: track.authorizations,
4430
- refs: input.refs,
4431
- refsRead: track.refsRead,
4432
- agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
4433
- brainstormRequired: track.brainstormRequired,
4434
- 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"]
4435
4261
  };
4436
- try {
4437
- const apex = evaluateApex(ctx);
4438
- if (apex) return apex;
4439
- } catch {
4440
- return FAIL_CLOSED;
4441
- }
4262
+ return null;
4442
4263
  }
4443
- 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;
4444
4280
  }
4445
4281
  //#endregion
4446
4282
  //#region src/runtime/handle-pre.ts
@@ -4507,6 +4343,203 @@ async function handlePre(ctx) {
4507
4343
  };
4508
4344
  }
4509
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
4510
4543
  //#region src/runtime/handle-scope-async.ts
4511
4544
  /**
4512
4545
  * Pre-pipeline async scope interception for {@link handleHook}. The aipilot and
@@ -4567,38 +4600,15 @@ async function handleHook(id, payload, opts) {
4567
4600
  exit: 0
4568
4601
  };
4569
4602
  }
4570
- if (event.phase === "post") {
4571
- const response = payload.tool_response ?? payload.tool_output;
4572
- mcpPostStore(event.tool, event.input, response, mcpDir);
4573
- const designWarn = designGate(payload, event, mcpDir, opts.cwd);
4574
- const activity = activityFor({
4575
- tool: event.tool,
4576
- input: event.input,
4577
- sessionId: event.sessionId,
4578
- framework,
4579
- now: opts.now,
4580
- responseLength: extractText(response).length
4581
- });
4582
- if (activity) await recordActivity(file, activity);
4583
- postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
4584
- const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
4585
- if (seoDeny) return {
4586
- stdout: seoDeny,
4587
- exit: 0
4588
- };
4589
- if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate")) {
4590
- const out = await aipilotPostToolUse(payload, opts.cwd);
4591
- if (out) return {
4592
- stdout: out,
4593
- exit: 0
4594
- };
4595
- }
4596
- const extra = postEditContext(opts.scope ?? "core", event, opts.now);
4597
- return {
4598
- stdout: designWarn ? respond(id, designWarn) : extra,
4599
- exit: 0
4600
- };
4601
- }
4603
+ if (event.phase === "post") return handlePost({
4604
+ id,
4605
+ payload,
4606
+ event,
4607
+ framework,
4608
+ mcpDir,
4609
+ file,
4610
+ opts
4611
+ });
4602
4612
  return handlePre({
4603
4613
  id,
4604
4614
  payload,
@@ -4610,4 +4620,4 @@ async function handleHook(id, payload, opts) {
4610
4620
  });
4611
4621
  }
4612
4622
  //#endregion
4613
- 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 };