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