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