@fusengine/harness 0.1.29 → 0.1.30

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.
@@ -1,1090 +0,0 @@
1
- import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
2
- import { c as detectCreationIntent, f as evaluateApex, g as detectModularArchitecture, i as frameworkSolidGate, n as skillTriggerGate, s as capVerbosity, y as requiredArchSkill } from "./skill-triggers-BZxov1es.mjs";
3
- import { j as detectFramework, k as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-j3gRJ_ng.mjs";
4
- import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
5
- import { a as extractText, r as cacheStore, t as cacheLookup } from "./store-DeIsfMg5.mjs";
6
- import { t as loadRefs } from "./loader-CyAoJv2W.mjs";
7
- import { a as recordAgent, c as recordRefRead, l as recordTrivialEdit, n as saveTrack, o as recordBrainstormRequired, r as agentsFresh, s as recordDoc, t as loadTrack, u as trivialCount } from "./store-BnHpq2ZB.mjs";
8
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
- import { execFileSync } from "node:child_process";
11
- import { tmpdir } from "node:os";
12
- //#region src/runtime/activity.ts
13
- /** Min response length (chars) for a lead agent call to count as `sufficient`. */
14
- const AGENT_QUALITY_MIN = 500;
15
- /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
16
- const READ_TOOLS = /* @__PURE__ */ new Set([
17
- "Read",
18
- "read_file",
19
- "read_many_files"
20
- ]);
21
- /**
22
- * Map a live tool-use to the activity to record, or null when nothing is
23
- * tracked. Works across harnesses — tool names are globally distinct:
24
- * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
25
- * - `Task` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
26
- * - a read tool opening a `.md` reference → `ref`
27
- */
28
- function activityFor(event) {
29
- if (/context7|exa/i.test(event.tool)) return {
30
- kind: "doc",
31
- framework: event.framework,
32
- sessionId: event.sessionId,
33
- source: /exa/i.test(event.tool) ? "exa" : "context7"
34
- };
35
- if (event.tool === "Task") {
36
- const name = String(event.input?.subagent_type ?? "").split(":").pop() ?? "";
37
- if (!name) return null;
38
- const quality = event.responseLength === void 0 ? void 0 : event.responseLength > AGENT_QUALITY_MIN ? "sufficient" : "insufficient";
39
- return quality ? {
40
- kind: "agent",
41
- name,
42
- ts: event.now,
43
- quality
44
- } : {
45
- kind: "agent",
46
- name,
47
- ts: event.now
48
- };
49
- }
50
- if (READ_TOOLS.has(event.tool)) {
51
- const path = String(event.input?.file_path ?? event.input?.path ?? "");
52
- if (path.endsWith(".md")) return {
53
- kind: "ref",
54
- path
55
- };
56
- }
57
- return null;
58
- }
59
- //#endregion
60
- //#region src/runtime/dry-patterns.ts
61
- /** Short identifiers never worth a duplication check (control flow, tiny names). */
62
- const DRY_KEYWORDS = /* @__PURE__ */ new Set([
63
- "if",
64
- "for",
65
- "while",
66
- "switch",
67
- "catch",
68
- "return",
69
- "async",
70
- "new",
71
- "get",
72
- "set",
73
- "map",
74
- "run",
75
- "use",
76
- "test",
77
- "main"
78
- ]);
79
- /** Extensions treated as TS/JS-family for symbol extraction. */
80
- const TS_EXT = /* @__PURE__ */ new Set([
81
- ".ts",
82
- ".tsx",
83
- ".js",
84
- ".jsx",
85
- ".astro"
86
- ]);
87
- /** Declaration patterns whose capture group 1 is the declared symbol name (TS/JS). */
88
- const TS_PATTERNS = [
89
- /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/g,
90
- /(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/g,
91
- /class\s+(\w+)\b/g
92
- ];
93
- /**
94
- * Declaration patterns for PHP (capture group 1 = symbol name). The modifier run
95
- * is bounded (`{0,6}`) on purpose: an unbounded `(?:…\s+)*` is quadratic (O(n²))
96
- * on a long whitespace/keyword run with no trailing `function`, which would block
97
- * the hook for seconds on a crafted file. A real PHP signature has at most a few
98
- * leading keywords, so the bound is behavior-equivalent and keeps matching linear.
99
- */
100
- const PHP_PATTERNS = [/(?:(?:public|protected|private|static|final|abstract|readonly)\s+){0,6}function\s+(\w+)\s*\(/g, /(?:class|interface|trait)\s+(\w+)\b/g];
101
- /** Directories grep skips when scanning for existing declarations. */
102
- const EXCLUDE_DIRS = [
103
- "vendor",
104
- "node_modules",
105
- ".next",
106
- ".git",
107
- "dist",
108
- "build",
109
- "coverage",
110
- ".turbo"
111
- ];
112
- //#endregion
113
- //#region src/runtime/dry.ts
114
- /** Extract long (>12 char) declared symbol names from new file content. */
115
- function extractSymbols(content, ext) {
116
- const pats = TS_EXT.has(ext) ? TS_PATTERNS : ext === ".php" ? PHP_PATTERNS : [];
117
- const names = /* @__PURE__ */ new Set();
118
- for (const re of pats) for (const m of content.matchAll(re)) {
119
- const n = m[1];
120
- if (n && !DRY_KEYWORDS.has(n) && n.length > 12) names.add(n);
121
- }
122
- return [...names];
123
- }
124
- /** `modules/X/...` -> `"X"`, else `""` (module-boundary key). */
125
- function moduleOf(path) {
126
- const parts = path.split(sep);
127
- const i = parts.indexOf("modules");
128
- return i >= 0 && i + 1 < parts.length ? parts[i + 1] ?? "" : "";
129
- }
130
- /**
131
- * Grep the codebase for existing declarations of the symbols a write introduces,
132
- * honoring module boundaries (cross-`modules/` matches are ignored). Effectful:
133
- * shells out to `grep`. Fails open (returns no duplicates) on any grep error,
134
- * timeout, or no-match — matching the original Python hook.
135
- */
136
- function detectDuplication(filePath, content, cwd) {
137
- const ext = extname(filePath).toLowerCase();
138
- if (!TS_EXT.has(ext) && ext !== ".php") return {
139
- names: [],
140
- duplicates: []
141
- };
142
- const names = extractSymbols(content, ext);
143
- if (!names.length) return {
144
- names,
145
- duplicates: []
146
- };
147
- const include = TS_EXT.has(ext) ? [
148
- "--include=*.ts",
149
- "--include=*.tsx",
150
- "--include=*.js",
151
- "--include=*.jsx"
152
- ] : ["--include=*.php"];
153
- const pattern = `${TS_EXT.has(ext) ? "(function|const|let|class|interface)\\s+" : "(function|class|interface|trait)\\s+"}(${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`;
154
- let out = "";
155
- try {
156
- out = execFileSync("grep", [
157
- "-rEl",
158
- ...EXCLUDE_DIRS.map((d) => `--exclude-dir=${d}`),
159
- ...include,
160
- "--",
161
- pattern,
162
- cwd
163
- ], {
164
- encoding: "utf8",
165
- timeout: 1500
166
- });
167
- } catch {
168
- return {
169
- names,
170
- duplicates: []
171
- };
172
- }
173
- const self = resolve(filePath);
174
- const targetMod = moduleOf(filePath);
175
- const duplicates = [];
176
- for (const line of out.split("\n")) {
177
- const f = line.trim();
178
- if (!f || resolve(f) === self) continue;
179
- const dupMod = moduleOf(f);
180
- if (targetMod && dupMod && dupMod !== targetMod) continue;
181
- duplicates.push(f);
182
- }
183
- return {
184
- names,
185
- duplicates
186
- };
187
- }
188
- /** Blocking prompt when a Write/Edit re-declares 2+ existing symbols, else null. */
189
- function dryGate(tool, filePath, content, cwd) {
190
- if (!cwd || tool !== "Write" && tool !== "Edit" || !content) return null;
191
- const dup = detectDuplication(filePath, content, cwd);
192
- if (dup.duplicates.length < 2) return null;
193
- return {
194
- kind: "block",
195
- title: "Duplicate code (DRY)",
196
- reason: `[${dup.names.slice(0, 5).join(", ")}] already declared in: ${dup.duplicates.slice(0, 3).join(", ")}. Import and reuse instead of re-declaring.`,
197
- actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
198
- };
199
- }
200
- //#endregion
201
- //#region src/runtime/precommit.ts
202
- const TIMEOUT_MS = 3e4;
203
- const ESLINT_CONFIGS = [
204
- ".eslintrc.json",
205
- ".eslintrc.js",
206
- "eslint.config.js",
207
- "eslint.config.mjs",
208
- "eslint.config.ts"
209
- ];
210
- const PRETTIER_CONFIGS = [
211
- ".prettierrc",
212
- ".prettierrc.json",
213
- "prettier.config.js"
214
- ];
215
- /** Run a linter; returns its error output, or "" if it passed / spawn-failed / timed out (fail-open). */
216
- function runLinter(file, args, label, cwd) {
217
- try {
218
- execFileSync(file, args, {
219
- cwd,
220
- timeout: TIMEOUT_MS,
221
- stdio: [
222
- "ignore",
223
- "pipe",
224
- "pipe"
225
- ]
226
- });
227
- return "";
228
- } catch (e) {
229
- const err = e;
230
- if (err.status === void 0 || err.status === null) return "";
231
- const out = (err.stdout?.toString() ?? err.stderr?.toString() ?? "").trim();
232
- return out ? `[${label}]\n${out}` : "";
233
- }
234
- }
235
- /** Run the applicable linters in `cwd`, returning a block of errors per failing tool. */
236
- function collectErrors(cwd) {
237
- const has = (f) => existsSync(join(cwd, f));
238
- const errors = [];
239
- if (has("package.json")) {
240
- if (ESLINT_CONFIGS.some(has)) {
241
- const m = runLinter("bunx", [
242
- "eslint",
243
- ".",
244
- "--max-warnings",
245
- "0"
246
- ], "ESLint", cwd);
247
- if (m) errors.push(m);
248
- }
249
- if (has("tsconfig.json")) {
250
- const m = runLinter("bunx", ["tsc", "--noEmit"], "TypeScript", cwd);
251
- if (m) errors.push(m);
252
- }
253
- if (PRETTIER_CONFIGS.some(has)) {
254
- const m = runLinter("bunx", [
255
- "prettier",
256
- "--check",
257
- "."
258
- ], "Prettier", cwd);
259
- if (m) errors.push(m);
260
- }
261
- }
262
- if (has("requirements.txt") || has("pyproject.toml")) {
263
- const m = runLinter("ruff", ["check", "."], "Ruff", cwd);
264
- if (m) errors.push(m);
265
- }
266
- return errors;
267
- }
268
- /** Block a `git commit` when linters fail (effectful: runs eslint/tsc/prettier/ruff, never auto-fixes). */
269
- function preCommitGate(tool, command, cwd) {
270
- if (tool !== "Bash" || !command || !cwd) return null;
271
- if (!command.startsWith("git") || !command.includes("commit")) return null;
272
- const errors = collectErrors(cwd);
273
- if (!errors.length) return null;
274
- return {
275
- kind: "block",
276
- title: "Pre-commit checks failed",
277
- reason: `COMMIT BLOCKED — fix then retry:\n\n${errors.join("\n\n")}`,
278
- actions: ["Fix the linter/type errors above", "Re-run the commit"]
279
- };
280
- }
281
- //#endregion
282
- //#region src/runtime/modular.ts
283
- const NEXT_CONVENTION = /^(page|layout|loading|error|not-found|route|template|default|global-error|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest|middleware)\.(tsx|ts|js|jsx)$/;
284
- const NEXT_STATIC = /\.(css|ico|png|jpg|svg|json)$/;
285
- const PHP_BLOCKED_IN_APP = [
286
- "/app/Models/",
287
- "/app/Services/",
288
- "/app/Actions/",
289
- "/app/Http/Controllers/",
290
- "/app/Http/Requests/",
291
- "/app/Http/Resources/",
292
- "/app/Contracts/",
293
- "/app/DTOs/",
294
- "/app/Repositories/",
295
- "/app/Events/",
296
- "/app/Listeners/",
297
- "/app/Jobs/",
298
- "/app/Notifications/",
299
- "/app/Policies/"
300
- ];
301
- const block = (reason) => ({
302
- kind: "block",
303
- title: "Modular architecture",
304
- reason,
305
- actions: ["Move the code into the correct feature module", "Import only from the shared core module"]
306
- });
307
- /** Next.js `modules/` architecture: `app/` convention + cross-module import rules. */
308
- function nextModular(filePath, content, cwd) {
309
- const rel = relative(cwd, filePath);
310
- const bn = basename(filePath);
311
- 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]/.`);
312
- const mod = filePath.match(/\/modules\/([^/]+)\//);
313
- if (!mod) return null;
314
- const current = mod[1] ?? "";
315
- for (const m of content.matchAll(/from\s+['"][@.][^'"]*?\/modules\/([^/]+)\//g)) {
316
- const imported = m[1] ?? "";
317
- if (current === "cores") {
318
- if (imported !== "cores" && imported !== "core") return block(`BLOCKED: modules/cores/ must not import from modules/${imported}/.`);
319
- } else if (imported !== current && imported !== "cores" && imported !== "core") return block(`BLOCKED: cross-module import — '${current}' imports '${imported}'. Only modules/cores/ is shared.`);
320
- }
321
- return null;
322
- }
323
- /** Laravel FuseCore architecture: `app/` domain ban + module.json + cross-module `use` rules. */
324
- function fusecore(filePath, content, cwd) {
325
- 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/.`);
326
- const mod = filePath.match(/\/FuseCore\/([A-Za-z]+)\//);
327
- if (!mod) return null;
328
- const name = mod[1] ?? "";
329
- if (!existsSync(join(cwd, "FuseCore", name, "module.json"))) return block(`BLOCKED: FuseCore module '${name}' is missing module.json — create it first.`);
330
- for (const m of content.matchAll(/use\s+FuseCore\\(\w+)\\/g)) {
331
- const imported = m[1] ?? "";
332
- if (name === "Core") {
333
- if (imported !== "Core") return block(`BLOCKED: FuseCore\\Core\\ must not use FuseCore\\${imported}\\.`);
334
- } else if (imported !== name && imported !== "Core") return block(`BLOCKED: cross-module use — '${name}' uses '${imported}'. Only FuseCore\\Core\\ is shared.`);
335
- }
336
- return null;
337
- }
338
- /** Enforce the project's modular architecture (Next.js `modules/` or Laravel FuseCore) on a Write/Edit. */
339
- function modularGate(tool, filePath, content, cwd) {
340
- if (tool !== "Write" && tool !== "Edit" || !filePath || !cwd) return null;
341
- if (/\/(node_modules|dist|build|\.next|vendor|storage)\//.test(filePath)) return null;
342
- const arch = detectModularArchitecture(cwd);
343
- if (arch === "nextjs-modular" && /\.(tsx|ts|jsx|js)$/.test(filePath)) return nextModular(filePath, content ?? "", cwd);
344
- if (arch === "fusecore" && filePath.endsWith(".php")) return fusecore(filePath, content ?? "", cwd);
345
- return null;
346
- }
347
- //#endregion
348
- //#region src/runtime/framework-skill-gate.ts
349
- /**
350
- * Effective line count for the SOLID size check. On an Edit, `content` is only
351
- * the `new_string` snippet, so judge the larger of the snippet and the full
352
- * on-disk file (`existingLines`) — mirroring the base file-size guard and the
353
- * Python `get_full_file_content`. On Write, `content` IS the full file, so the
354
- * snippet count stands (undefined → the gate falls back to `countLines`).
355
- * @param tool - the tool name ("Edit" | "Write" | ...).
356
- * @param content - the written content (snippet on Edit, full file on Write).
357
- * @param existingLines - full on-disk line count, when known.
358
- */
359
- function effectiveLines(tool, content, existingLines) {
360
- if (tool !== "Edit" || existingLines === void 0) return void 0;
361
- return Math.max(countLines(content), existingLines);
362
- }
363
- /**
364
- * Framework-aware SOLID + sub-skill gate, run on the Write/Edit path once a
365
- * `filePath` is present. Combines:
366
- * - {@link frameworkSolidGate}: framework-specific SOLID rules (line limits,
367
- * interface/protocol separation, `'use client'`, @MainActor...).
368
- * - {@link skillTriggerGate}: blocks when written APIs need a sub-skill that
369
- * was not read this session, also forcing the modular-architecture skill
370
- * resolved from disk via {@link requiredArchSkill}.
371
- *
372
- * @param input - the gated tool-use (filePath + content + framework + cwd).
373
- * @param refsRead - in-session read reference paths (from the loaded track).
374
- * @param existingLines - full on-disk line count (so an Edit on an oversized
375
- * file still fires the framework SOLID size rule). Omit on Write.
376
- * @returns the first blocking {@link Prompt}, or `null` to allow.
377
- */
378
- function frameworkSkillGate(input, refsRead, existingLines) {
379
- if (!input.filePath) return null;
380
- const content = input.content ?? "";
381
- const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, content, existingLines));
382
- if (solid) return solid;
383
- const forced = input.cwd ? requiredArchSkill(input.cwd) : null;
384
- return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
385
- }
386
- //#endregion
387
- //#region src/runtime/gate.ts
388
- /** Prior agents the freshness gate requires before a code edit. */
389
- const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
390
- /** Default freshness window for {@link REQUIRED_AGENTS} (2 min — matches the plugin's `FUSE_ENFORCE_TTL_SEC` default). */
391
- const DEFAULT_WINDOW_MS = 12e4;
392
- /** Trivial edits allowed within the window before the full APEX gates apply. */
393
- const TRIVIAL_BUDGET = 4;
394
- /**
395
- * Code-only line count of the existing on-disk file (undefined if
396
- * absent/unreadable). Uses {@link countLines} (skips blank/comment lines) to
397
- * mirror the Python `count_code_lines(get_full_file_content(...))`, so a partial
398
- * Edit judges the full file by the SAME metric as the incoming snippet — a raw
399
- * `split("\n").length` would over-count JSDoc/blank lines (and add a
400
- * trailing-newline off-by-one), falsely blocking well-documented files.
401
- */
402
- function existingLineCount(path) {
403
- if (!path) return void 0;
404
- try {
405
- return existsSync(path) ? countLines(readFileSync(path, "utf8")) : void 0;
406
- } catch {
407
- return;
408
- }
409
- }
410
- /**
411
- * Full gate: the stateless guards (file-size, git, security...) first, then a
412
- * trivial-edit fast path, then the stateful APEX gates fed from the session
413
- * track. Returns the first blocking prompt, or null to allow.
414
- */
415
- async function gate(input) {
416
- const existingLines = existingLineCount(input.filePath);
417
- let quick;
418
- try {
419
- quick = evaluate({
420
- tool: input.tool,
421
- filePath: input.filePath,
422
- content: input.content,
423
- command: input.command,
424
- agentType: input.agentType,
425
- existingLines
426
- });
427
- } catch {
428
- return FAIL_CLOSED;
429
- }
430
- if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
431
- const precommit = preCommitGate(input.tool, input.command, input.cwd);
432
- if (precommit) return precommit;
433
- const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
434
- if (modular) return modular;
435
- if (!input.filePath) return null;
436
- const window = input.windowMs ?? 12e4;
437
- const track = await loadTrack(input.trackFile);
438
- const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
439
- if (solidOrSkill) return solidOrSkill;
440
- const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
441
- if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
442
- await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
443
- return null;
444
- }
445
- const ctx = {
446
- sessionId: input.sessionId,
447
- framework: input.framework,
448
- filePath: input.filePath,
449
- content: input.content ?? "",
450
- authorizations: track.authorizations,
451
- refs: input.refs,
452
- refsRead: track.refsRead,
453
- agentsFresh: agentsFresh(track, [...REQUIRED_AGENTS], window, input.now),
454
- brainstormRequired: track.brainstormRequired,
455
- brainstormFresh: agentsFresh(track, ["brainstorming"], window, input.now)
456
- };
457
- try {
458
- const apex = evaluateApex(ctx);
459
- if (apex) return apex;
460
- } catch {
461
- return FAIL_CLOSED;
462
- }
463
- return dryGate(input.tool, input.filePath, input.content, input.cwd);
464
- }
465
- //#endregion
466
- //#region src/runtime/mcp.ts
467
- /** Default freshness for cached MCP/WebFetch results (48h). */
468
- const MCP_TTL_MS = 1728e5;
469
- /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
470
- function isMcpTool(tool) {
471
- return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
472
- }
473
- /** The query/url that keys the cache. */
474
- function queryOf(input) {
475
- const q = input.query ?? input.url ?? input.libraryId ?? "";
476
- return typeof q === "string" ? q : JSON.stringify(q);
477
- }
478
- function denyWith(id, content) {
479
- if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
480
- hookEventName: "PreToolUse",
481
- permissionDecision: "deny",
482
- permissionDecisionReason: content
483
- } });
484
- if (id === "gemini-cli") return JSON.stringify({
485
- decision: "deny",
486
- reason: content
487
- });
488
- return "";
489
- }
490
- function mutateWith(id, input) {
491
- if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
492
- hookEventName: "PreToolUse",
493
- permissionDecision: "allow",
494
- updatedInput: input
495
- } });
496
- if (id === "gemini-cli") return JSON.stringify({ hookSpecificOutput: { tool_input: input } });
497
- return "";
498
- }
499
- /** The doc provider a served cache-hit satisfies (`exa`/`context7`), else undefined. */
500
- function docSourceOf(tool) {
501
- if (/exa/i.test(tool)) return "exa";
502
- if (/context7/i.test(tool)) return "context7";
503
- }
504
- /**
505
- * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
506
- * else cap exa verbosity (allow + mutated input), else null to allow normally.
507
- * Harnesses without input-mutation/cache support fall through to null.
508
- */
509
- function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
510
- if (!isMcpTool(tool)) return null;
511
- const cached = cacheLookup(dir, tool, queryOf(input), ttlMs, now);
512
- if (cached) {
513
- const served = denyWith(id, cached);
514
- if (served) return {
515
- stdout: served,
516
- docSource: docSourceOf(tool)
517
- };
518
- }
519
- const capped = capVerbosity(tool, input);
520
- if (capped) {
521
- const mutated = mutateWith(id, capped);
522
- if (mutated) return { stdout: mutated };
523
- }
524
- return null;
525
- }
526
- /** Post-event: store the MCP/WebFetch response (extracted to markdown) in the cache. */
527
- function mcpPostStore(tool, input, response, dir) {
528
- if (!isMcpTool(tool)) return;
529
- cacheStore(dir, tool, queryOf(input), extractText(response));
530
- }
531
- //#endregion
532
- //#region src/runtime/normalize.ts
533
- function str(v) {
534
- return typeof v === "string" ? v : void 0;
535
- }
536
- /**
537
- * Normalize a harness hook payload into a uniform event. Handles Cline's nested
538
- * `preToolUse`/`postToolUse` shape and the top-level `tool_name`/`tool_input`
539
- * shape used by Claude, Codex, Gemini, and Cursor.
540
- */
541
- function normalizeEvent(id, payload) {
542
- if (id === "cline") {
543
- const post = payload.postToolUse;
544
- const node = post ?? payload.preToolUse ?? {};
545
- const params = node.parameters ?? {};
546
- return {
547
- phase: post ? "post" : "pre",
548
- tool: str(node.toolName) ?? "",
549
- input: params,
550
- sessionId: str(payload.taskId) ?? "",
551
- filePath: str(params.path),
552
- content: str(params.content),
553
- command: str(params.command)
554
- };
555
- }
556
- const event = str(payload.hook_event_name) ?? "";
557
- const input = payload.tool_input ?? payload;
558
- return {
559
- phase: /post|after/i.test(event) ? "post" : "pre",
560
- tool: str(payload.tool_name) ?? "",
561
- input,
562
- sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
563
- filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
564
- content: str(input.content) ?? str(input.new_string),
565
- command: str(input.command) ?? str(payload.command),
566
- agentType: str(payload.agent_type) ?? str(input.subagent_type)
567
- };
568
- }
569
- //#endregion
570
- //#region src/runtime/paths.ts
571
- /** Path to a session's track file (under a per-tool base dir). */
572
- function trackFile(sessionId, baseDir = join(tmpdir(), "fuse-harness")) {
573
- return join(baseDir, `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
574
- }
575
- //#endregion
576
- //#region src/runtime/record.ts
577
- /** Apply an activity to a session's track and persist it (PostToolUse path). */
578
- async function recordActivity(file, activity) {
579
- const track = await loadTrack(file);
580
- 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));
581
- }
582
- //#endregion
583
- //#region src/runtime/respond.ts
584
- /**
585
- * Map a portable {@link Prompt} to a harness's native hook response. `block`
586
- * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
587
- * `ask` — they only honor deny.)
588
- */
589
- function respond(id, prompt) {
590
- const message = formatPrompt(prompt);
591
- const deny = prompt.kind === "block";
592
- switch (id) {
593
- case "claude-code":
594
- case "codex": return JSON.stringify({ hookSpecificOutput: {
595
- hookEventName: "PreToolUse",
596
- permissionDecision: deny ? "deny" : "ask",
597
- permissionDecisionReason: message
598
- } });
599
- case "gemini-cli": return JSON.stringify(deny ? {
600
- decision: "deny",
601
- reason: message
602
- } : { hookSpecificOutput: { additionalContext: message } });
603
- case "cursor": return JSON.stringify({
604
- permission: deny ? "deny" : "ask",
605
- continue: false,
606
- userMessage: message,
607
- agentMessage: message
608
- });
609
- case "cline": return JSON.stringify(deny ? {
610
- cancel: true,
611
- errorMessage: message
612
- } : { contextModification: message });
613
- default: return "";
614
- }
615
- }
616
- //#endregion
617
- //#region src/policy/design/state.ts
618
- /** Minimum fuse-browser screenshots required before writing design-system.md, per mode. */
619
- const MIN_SCREENSHOTS = {
620
- full: 4,
621
- page: 2,
622
- component: 0
623
- };
624
- const stateFile = (cacheDir, agentId) => join(cacheDir, `.design-state-${agentId}.json`);
625
- /** Load the design state for `agentId`, or null when absent/corrupt (fail-open). */
626
- function loadDesignState(cacheDir, agentId) {
627
- const path = stateFile(cacheDir, agentId);
628
- if (!existsSync(path)) return null;
629
- try {
630
- return JSON.parse(readFileSync(path, "utf8"));
631
- } catch {
632
- return null;
633
- }
634
- }
635
- /** Persist the design state under its agent id. */
636
- function saveDesignState(cacheDir, state) {
637
- mkdirSync(cacheDir, { recursive: true });
638
- writeFileSync(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
639
- }
640
- /** Build the initial state for a design agent starting a run. */
641
- function initDesignState(agentId, mode, designSystemExists) {
642
- return {
643
- agentId,
644
- mode,
645
- currentPhase: 0,
646
- phasesCompleted: [],
647
- inspirationRead: false,
648
- scrolledSinceNav: false,
649
- screenshotsCount: 0,
650
- designSystemExists,
651
- designSystemValid: false,
652
- geminiCalls: 0
653
- };
654
- }
655
- /** Archive the active state file (timestamp suffix) and drop archives older than 7 days. */
656
- function cleanupDesignStates(cacheDir, agentId, stamp, now) {
657
- if (agentId) {
658
- const src = stateFile(cacheDir, agentId);
659
- if (existsSync(src)) renameSync(src, join(cacheDir, `.design-state-${agentId}-${stamp}.json`));
660
- }
661
- let entries;
662
- try {
663
- entries = readdirSync(cacheDir);
664
- } catch {
665
- return;
666
- }
667
- const cutoff = now - 7 * 864e5;
668
- for (const name of entries) {
669
- if (!name.startsWith(".design-state-")) continue;
670
- const path = join(cacheDir, name);
671
- try {
672
- if (statSync(path).mtimeMs < cutoff) rmSync(path);
673
- } catch {}
674
- }
675
- }
676
- //#endregion
677
- //#region src/policy/design/transitions.ts
678
- /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
679
- function detectMode(prompt, designSystemExists) {
680
- const p = prompt.toLowerCase();
681
- if ([
682
- "component",
683
- "composant",
684
- "snippet"
685
- ].some((k) => p.includes(k))) return "component";
686
- return designSystemExists ? "page" : "full";
687
- }
688
- /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
689
- function recordScreenshot(state, needed) {
690
- const screenshotsCount = state.screenshotsCount + 1;
691
- const next = {
692
- ...state,
693
- screenshotsCount
694
- };
695
- if (screenshotsCount >= needed && state.currentPhase < 2) {
696
- next.currentPhase = 2;
697
- next.phasesCompleted = [.../* @__PURE__ */ new Set([
698
- ...state.phasesCompleted,
699
- "identity",
700
- "research"
701
- ])];
702
- }
703
- return next;
704
- }
705
- /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
706
- function recordNavigate(state) {
707
- return {
708
- ...state,
709
- scrolledSinceNav: false
710
- };
711
- }
712
- /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
713
- function recordScroll(state) {
714
- return {
715
- ...state,
716
- scrolledSinceNav: true
717
- };
718
- }
719
- /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
720
- function recordValidDesignSystem(state) {
721
- return {
722
- ...state,
723
- designSystemExists: true,
724
- designSystemValid: true,
725
- currentPhase: Math.max(state.currentPhase, 3),
726
- phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
727
- };
728
- }
729
- /**
730
- * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
731
- * allowed); reading the inspiration catalog satisfies the browse prerequisite.
732
- */
733
- function recordRead(state, filePath) {
734
- const next = { ...state };
735
- if (filePath.includes("identity-system")) {
736
- next.currentPhase = Math.max(state.currentPhase, 1);
737
- next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
738
- }
739
- if (filePath.includes("design-inspiration")) next.inspirationRead = true;
740
- return next;
741
- }
742
- //#endregion
743
- //#region src/policy/design/flag.ts
744
- const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
745
- /** The active design agent id (the flag), or "" when no design agent is running. */
746
- function activeDesignAgent(cacheDir) {
747
- const path = flagPath(cacheDir);
748
- if (!existsSync(path)) return "";
749
- try {
750
- return readFileSync(path, "utf8").trim();
751
- } catch {
752
- return "";
753
- }
754
- }
755
- /** Mark a design agent active (writes its id to the flag file). */
756
- function setActiveDesignAgent(cacheDir, agentId) {
757
- mkdirSync(cacheDir, { recursive: true });
758
- writeFileSync(flagPath(cacheDir), agentId);
759
- }
760
- /** Clear the active-design-agent flag. */
761
- function clearActiveDesignAgent(cacheDir) {
762
- try {
763
- rmSync(flagPath(cacheDir));
764
- } catch {}
765
- }
766
- //#endregion
767
- //#region src/policy/design/content-checks.ts
768
- /** Accessibility warnings: icon buttons need aria-label, images need alt. */
769
- function checkAccessibility(content) {
770
- const w = [];
771
- if (!/<(button|a|input|img)/.test(content)) return w;
772
- if (/<button[^>]*>/.test(content) && !/aria-label|aria-labelledby/.test(content) && /<button[^>]*>[^<]*<[^>]*Icon/.test(content)) w.push("Accessibility: icon buttons need an aria-label.");
773
- for (const m of content.matchAll(/<img[^>]*?>/g)) if (!m[0].includes("alt=")) {
774
- w.push("Accessibility: images need an alt attribute.");
775
- break;
776
- }
777
- return w;
778
- }
779
- /** Anti-pattern warnings: colored left borders, AI-slop gradients, emoji-as-icons. */
780
- function checkPatterns(content) {
781
- const w = [];
782
- if (/border-l-[0-9]+ border-l-(blue|green|red|purple)/.test(content)) w.push("Design: avoid colored left borders — use shadow/gradient.");
783
- 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.");
784
- if (/>[^\x00-\x7F]+</.test(content)) w.push("Design: avoid emojis as icons — use a real icon set.");
785
- return w;
786
- }
787
- /** Forbidden-font warnings (CSS font-family + Google Fonts import). */
788
- function checkFonts(content) {
789
- const w = [];
790
- 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.");
791
- if (/@import.*fonts\.googleapis.*family=(Roboto|Inter)\b/.test(content)) w.push("Font: Google Fonts import for a forbidden family.");
792
- return w;
793
- }
794
- /** Hard-coded-color warnings (hex in className or inline style). */
795
- function checkColors(content) {
796
- const w = [];
797
- if (/className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/.test(content)) w.push("Color: hard-coded hex in className — use CSS variables.");
798
- 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-*).");
799
- return w;
800
- }
801
- /** Run all design content checks → non-blocking warnings (empty = clean). */
802
- function runDesignChecks(content) {
803
- return [
804
- ...checkAccessibility(content),
805
- ...checkPatterns(content),
806
- ...checkFonts(content),
807
- ...checkColors(content)
808
- ];
809
- }
810
- //#endregion
811
- //#region src/policy/design/gates.ts
812
- const ALLOWED_WRITE = /\.(html|css|md|json)$/;
813
- const EXEMPT_DIRS = [
814
- "node_modules/",
815
- "dist/",
816
- "build/",
817
- ".claude/"
818
- ];
819
- const FORBIDDEN_FONTS = [
820
- "Inter",
821
- "Roboto",
822
- "Arial",
823
- "Open Sans"
824
- ];
825
- const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
826
- const KNOWN_DOMAINS = [
827
- "framer.website",
828
- "webflow.io",
829
- "awwwards.com",
830
- "godly.website",
831
- "lapa.ninja",
832
- "onepagelove.com",
833
- "saasframe.io",
834
- "bestwebsite.gallery",
835
- "landingfolio.com"
836
- ];
837
- const deny = (reason) => ({
838
- kind: "block",
839
- title: "Design pipeline",
840
- reason,
841
- actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"]
842
- });
843
- /** Block the design agent from writing anything but .html/.css/.md/.json. */
844
- function htmlCssOnlyGate(filePath) {
845
- if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
846
- return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
847
- }
848
- /** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
849
- function stateFileGate(filePath) {
850
- return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are read-only; the hooks update them as you progress.") : null;
851
- }
852
- /** Gate writing design-system.md: requires phase ≥ 2 and the per-mode screenshot quota. */
853
- function designSystemWriteGate(filePath, state) {
854
- if (!filePath.endsWith("design-system.md")) return null;
855
- if (state.currentPhase < 2) return deny(`BLOCKED: cannot write design-system.md at phase ${state.currentPhase}. Read identity + inspiration, then browse & screenshot first.`);
856
- const needed = MIN_SCREENSHOTS[state.mode];
857
- if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
858
- return null;
859
- }
860
- /** Return the requirements missing from a design-system.md (empty = valid). */
861
- function validateDesignSystem(content) {
862
- const missing = [];
863
- if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
864
- if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
865
- if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
866
- if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
867
- return missing;
868
- }
869
- /** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
870
- function geminiCreateGate(state) {
871
- if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
872
- if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
873
- return null;
874
- }
875
- /** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
876
- function browserNavigateGate(state, url) {
877
- if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
878
- if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
879
- if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
880
- return null;
881
- }
882
- /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
883
- function screenshotScrollGate(state) {
884
- return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
885
- }
886
- /** The Gemini design gates are OPT-IN: off unless `FUSE_DESIGN_GEMINI` is `1`/`true`. */
887
- function geminiEnabled() {
888
- const v = process.env.FUSE_DESIGN_GEMINI;
889
- return v === "1" || v === "true";
890
- }
891
- //#endregion
892
- //#region src/runtime/design.ts
893
- const NAV = "mcp__fuse-browser__browser_navigate";
894
- const SHOT = "mcp__fuse-browser__browser_screenshot";
895
- const SCROLL = "mcp__fuse-browser__browser_scroll";
896
- const GEMINI = "mcp__gemini-design__create_frontend";
897
- /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
898
- function findDesignSystem(cwd) {
899
- let dir = cwd;
900
- for (let i = 0; i < 6; i++) {
901
- const p = join(dir, "design-system.md");
902
- if (existsSync(p)) try {
903
- return readFileSync(p, "utf8");
904
- } catch {
905
- return "";
906
- }
907
- const parent = dirname(dir);
908
- if (parent === dir) break;
909
- dir = parent;
910
- }
911
- return "";
912
- }
913
- /** Apply a PostToolUse fuse-browser transition to the design state. */
914
- function recordPost(event, cacheDir, state) {
915
- if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
916
- else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state));
917
- else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
918
- else if (event.tool === GEMINI) saveDesignState(cacheDir, {
919
- ...state,
920
- geminiCalls: state.geminiCalls + 1
921
- });
922
- else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
923
- else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
924
- }
925
- /**
926
- * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
927
- * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
928
- */
929
- function designGate(payload, event, cacheDir, cwd) {
930
- const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
931
- const active = activeDesignAgent(cacheDir);
932
- if (active && agentId && agentId !== active) return null;
933
- const id = active || agentId;
934
- if (!id) return null;
935
- const state = loadDesignState(cacheDir, id);
936
- if (!state) return null;
937
- if (event.phase === "post") {
938
- recordPost(event, cacheDir, state);
939
- if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
940
- const warnings = runDesignChecks(event.content ?? "");
941
- if (warnings.length) return {
942
- kind: "inform",
943
- title: "Design review",
944
- reason: warnings.join(" "),
945
- actions: []
946
- };
947
- }
948
- return null;
949
- }
950
- if (event.tool === "Write" || event.tool === "Edit") {
951
- const fp = event.filePath ?? "";
952
- const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
953
- if (base) return base;
954
- if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
955
- kind: "block",
956
- title: "Design pipeline",
957
- reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
958
- actions: ["Call mcp__gemini-design__create_frontend first"]
959
- };
960
- return null;
961
- }
962
- if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
963
- if (event.tool === SHOT) return screenshotScrollGate(state);
964
- if (event.tool === GEMINI) {
965
- if (!geminiEnabled()) return null;
966
- const block = geminiCreateGate(state);
967
- if (block) return block;
968
- const missing = validateDesignSystem(findDesignSystem(cwd));
969
- if (missing.length) return {
970
- kind: "block",
971
- title: "Design pipeline",
972
- reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
973
- actions: ["Fix design-system.md, then retry create_frontend"]
974
- };
975
- saveDesignState(cacheDir, recordValidDesignSystem(state));
976
- }
977
- return null;
978
- }
979
- //#endregion
980
- //#region src/runtime/design-lifecycle.ts
981
- /**
982
- * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
983
- * raise the active flag on start, archive/cleanup + clear the flag on stop.
984
- * Returns true when it handled the event (caller should respond and stop).
985
- */
986
- function designLifecycle(payload, cacheDir, cwd, stamp, now) {
987
- const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
988
- if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
989
- const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
990
- if (event === "SubagentStart") {
991
- if (!agentId) return false;
992
- const dsExists = existsSync(join(cwd, "design-system.md"));
993
- saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
994
- setActiveDesignAgent(cacheDir, agentId);
995
- return true;
996
- }
997
- if (event === "SubagentStop") {
998
- cleanupDesignStates(cacheDir, agentId, stamp, now);
999
- clearActiveDesignAgent(cacheDir);
1000
- return true;
1001
- }
1002
- return false;
1003
- }
1004
- //#endregion
1005
- //#region src/runtime/handle.ts
1006
- /**
1007
- * The full hook handler: on a PRE event it gates the tool-use (stateless guards
1008
- * then APEX gates from the session track) and returns the native response; on a
1009
- * POST event it records the activity into the track. The loop that makes the
1010
- * package behave like the Claude plugin, on any harness.
1011
- */
1012
- async function handleHook(id, payload, opts) {
1013
- const event = normalizeEvent(id, payload);
1014
- const layout = projectLayout(opts.cwd);
1015
- const file = trackFile(event.sessionId, layout.trackDir);
1016
- const mcpDir = layout.cacheDir;
1017
- const framework = detectFramework(event.filePath ?? "", event.content ?? "");
1018
- if (designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
1019
- stdout: "",
1020
- exit: 0
1021
- };
1022
- const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
1023
- if (userPrompt !== void 0) {
1024
- await saveTrack(file, recordBrainstormRequired(await loadTrack(file), detectCreationIntent(userPrompt)));
1025
- return {
1026
- stdout: "",
1027
- exit: 0
1028
- };
1029
- }
1030
- if (event.phase === "post") {
1031
- const response = payload.tool_response ?? payload.tool_output;
1032
- mcpPostStore(event.tool, event.input, response, mcpDir);
1033
- const designWarn = designGate(payload, event, mcpDir, opts.cwd);
1034
- const activity = activityFor({
1035
- tool: event.tool,
1036
- input: event.input,
1037
- sessionId: event.sessionId,
1038
- framework,
1039
- now: opts.now,
1040
- responseLength: extractText(response).length
1041
- });
1042
- if (activity) await recordActivity(file, activity);
1043
- return {
1044
- stdout: designWarn ? respond(id, designWarn) : "",
1045
- exit: 0
1046
- };
1047
- }
1048
- const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now);
1049
- if (intercept !== null) {
1050
- if (intercept.docSource) await recordActivity(file, {
1051
- kind: "doc",
1052
- framework,
1053
- sessionId: event.sessionId,
1054
- source: intercept.docSource
1055
- });
1056
- return {
1057
- stdout: intercept.stdout,
1058
- exit: 0
1059
- };
1060
- }
1061
- const designBlock = designGate(payload, event, mcpDir, opts.cwd);
1062
- if (designBlock) return {
1063
- stdout: respond(id, designBlock),
1064
- exit: 0
1065
- };
1066
- const prompt = await gate({
1067
- sessionId: event.sessionId,
1068
- framework,
1069
- tool: event.tool,
1070
- filePath: event.filePath,
1071
- content: event.content,
1072
- command: event.command,
1073
- cwd: opts.cwd,
1074
- refs: opts.refsDir ? await loadRefs(opts.refsDir) : void 0,
1075
- isReplaceAll: event.input.replace_all === true,
1076
- agentType: event.agentType,
1077
- windowMs: opts.windowMs,
1078
- now: opts.now,
1079
- trackFile: file
1080
- });
1081
- return prompt ? {
1082
- stdout: respond(id, prompt),
1083
- exit: 0
1084
- } : {
1085
- stdout: "",
1086
- exit: 0
1087
- };
1088
- }
1089
- //#endregion
1090
- export { dryGate as _, normalizeEvent as a, mcpPostStore as c, DEFAULT_WINDOW_MS as d, REQUIRED_AGENTS as f, detectDuplication as g, preCommitGate as h, trackFile as i, mcpPreIntercept as l, gate as m, respond as n, MCP_TTL_MS as o, TRIVIAL_BUDGET as p, recordActivity as r, isMcpTool as s, handleHook as t, queryOf as u, extractSymbols as v, activityFor as y };