@fusengine/harness 0.1.28 → 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.
Files changed (35) hide show
  1. package/dist/adapters/claude/index.mjs +1 -1
  2. package/dist/adapters/cline/index.mjs +1 -1
  3. package/dist/adapters/codex/index.mjs +1 -1
  4. package/dist/adapters/cursor/index.mjs +1 -1
  5. package/dist/adapters/gemini/index.mjs +1 -1
  6. package/dist/cache/index.mjs +2 -2
  7. package/dist/{cache-BzbX-ztL.mjs → cache-C9z9LclL.mjs} +1 -31
  8. package/dist/{claude-phC5Uh_W.mjs → claude-B9FYp0Yw.mjs} +1 -1
  9. package/dist/cli/bin.mjs +14 -4
  10. package/dist/cli/index.mjs +1 -1
  11. package/dist/describe-CPtgUzFS.mjs +1038 -0
  12. package/dist/{evaluate-CFYPF3re.mjs → evaluate-j3gRJ_ng.mjs} +14 -2
  13. package/dist/freshness/index.mjs +1 -1
  14. package/dist/{freshness-CezohJHo.mjs → freshness-otdUpuvP.mjs} +1 -1
  15. package/dist/handle-USWK4NSE.mjs +2300 -0
  16. package/dist/index-mISsk0ff.d.mts +438 -0
  17. package/dist/index.d.mts +2 -2
  18. package/dist/index.mjs +7 -8
  19. package/dist/{json-io-xpTDuvtn.mjs → json-io-CAn72gI4.mjs} +1 -1
  20. package/dist/policy/index.d.mts +2 -2
  21. package/dist/policy/index.mjs +4 -4
  22. package/dist/policy-la_KkjCS.mjs +1 -0
  23. package/dist/{run-B8n-H5hA.mjs → run-CXsV-wIJ.mjs} +1 -1
  24. package/dist/runtime/index.d.mts +454 -8
  25. package/dist/runtime/index.mjs +2 -2
  26. package/dist/state/index.mjs +1 -1
  27. package/dist/{state-Cs0Y0MG_.mjs → state-ByhLeKyD.mjs} +1 -1
  28. package/dist/{store-BnHpq2ZB.mjs → store-D-ge2ZPI.mjs} +1 -1
  29. package/dist/{store-DeIsfMg5.mjs → store-PrNPm6So.mjs} +30 -1
  30. package/dist/tracking/index.mjs +1 -1
  31. package/package.json +10 -3
  32. package/dist/handle-DnOw05K8.mjs +0 -347
  33. package/dist/index-DNAzITvw.d.mts +0 -227
  34. package/dist/policy-EuVJ_5hS.mjs +0 -33
  35. package/dist/verbosity-CXpf3aQQ.mjs +0 -98
@@ -0,0 +1,2300 @@
1
+ import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
2
+ import { C as capVerbosity, F as requiredArchSkill, M as detectModularArchitecture, O as evaluateApex, a as parseEntry, b as frameworkSolidGate, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, i as parseEnrichment, l as PROJECT_INDICATORS, s as parseField, t as descFromText, v as skillTriggerGate, w as detectCreationIntent } from "./describe-CPtgUzFS.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, o as loadIndex, r as cacheStore, t as cacheLookup } from "./store-PrNPm6So.mjs";
6
+ import { t as atomicWrite } from "./json-io-CAn72gI4.mjs";
7
+ import { t as loadRefs } from "./loader-CyAoJv2W.mjs";
8
+ 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-D-ge2ZPI.mjs";
9
+ import { t as contextResponse } from "./claude-B9FYp0Yw.mjs";
10
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
11
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { execFileSync } from "node:child_process";
14
+ //#region src/runtime/activity.ts
15
+ /** Min response length (chars) for a lead agent call to count as `sufficient`. */
16
+ const AGENT_QUALITY_MIN = 500;
17
+ /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
18
+ const READ_TOOLS = /* @__PURE__ */ new Set([
19
+ "Read",
20
+ "read_file",
21
+ "read_many_files"
22
+ ]);
23
+ /**
24
+ * Map a live tool-use to the activity to record, or null when nothing is
25
+ * tracked. Works across harnesses — tool names are globally distinct:
26
+ * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
27
+ * - `Task` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
28
+ * - a read tool opening a `.md` reference → `ref`
29
+ */
30
+ function activityFor(event) {
31
+ if (/context7|exa/i.test(event.tool)) return {
32
+ kind: "doc",
33
+ framework: event.framework,
34
+ sessionId: event.sessionId,
35
+ source: /exa/i.test(event.tool) ? "exa" : "context7"
36
+ };
37
+ if (event.tool === "Task") {
38
+ const name = String(event.input?.subagent_type ?? "").split(":").pop() ?? "";
39
+ if (!name) return null;
40
+ const quality = event.responseLength === void 0 ? void 0 : event.responseLength > AGENT_QUALITY_MIN ? "sufficient" : "insufficient";
41
+ return quality ? {
42
+ kind: "agent",
43
+ name,
44
+ ts: event.now,
45
+ quality
46
+ } : {
47
+ kind: "agent",
48
+ name,
49
+ ts: event.now
50
+ };
51
+ }
52
+ if (READ_TOOLS.has(event.tool)) {
53
+ const path = String(event.input?.file_path ?? event.input?.path ?? "");
54
+ if (path.endsWith(".md")) return {
55
+ kind: "ref",
56
+ path
57
+ };
58
+ }
59
+ return null;
60
+ }
61
+ //#endregion
62
+ //#region src/runtime/mcp.ts
63
+ /** Default freshness for cached MCP/WebFetch results (48h). */
64
+ const MCP_TTL_MS = 1728e5;
65
+ /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
66
+ function isMcpTool(tool) {
67
+ return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
68
+ }
69
+ /** The query/url that keys the cache. */
70
+ function queryOf(input) {
71
+ const q = input.query ?? input.url ?? input.libraryId ?? "";
72
+ return typeof q === "string" ? q : JSON.stringify(q);
73
+ }
74
+ function denyWith(id, content) {
75
+ if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
76
+ hookEventName: "PreToolUse",
77
+ permissionDecision: "deny",
78
+ permissionDecisionReason: content
79
+ } });
80
+ if (id === "gemini-cli") return JSON.stringify({
81
+ decision: "deny",
82
+ reason: content
83
+ });
84
+ return "";
85
+ }
86
+ function mutateWith(id, input) {
87
+ if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
88
+ hookEventName: "PreToolUse",
89
+ permissionDecision: "allow",
90
+ updatedInput: input
91
+ } });
92
+ if (id === "gemini-cli") return JSON.stringify({ hookSpecificOutput: { tool_input: input } });
93
+ return "";
94
+ }
95
+ /** The doc provider a served cache-hit satisfies (`exa`/`context7`), else undefined. */
96
+ function docSourceOf(tool) {
97
+ if (/exa/i.test(tool)) return "exa";
98
+ if (/context7/i.test(tool)) return "context7";
99
+ }
100
+ /**
101
+ * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
102
+ * else cap exa verbosity (allow + mutated input), else null to allow normally.
103
+ * Harnesses without input-mutation/cache support fall through to null.
104
+ */
105
+ function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
106
+ if (!isMcpTool(tool)) return null;
107
+ const cached = cacheLookup(dir, tool, queryOf(input), ttlMs, now);
108
+ if (cached) {
109
+ const served = denyWith(id, cached);
110
+ if (served) return {
111
+ stdout: served,
112
+ docSource: docSourceOf(tool)
113
+ };
114
+ }
115
+ const capped = capVerbosity(tool, input);
116
+ if (capped) {
117
+ const mutated = mutateWith(id, capped);
118
+ if (mutated) return { stdout: mutated };
119
+ }
120
+ return null;
121
+ }
122
+ /** Post-event: store the MCP/WebFetch response (extracted to markdown) in the cache. */
123
+ function mcpPostStore(tool, input, response, dir) {
124
+ if (!isMcpTool(tool)) return;
125
+ cacheStore(dir, tool, queryOf(input), extractText(response));
126
+ }
127
+ //#endregion
128
+ //#region src/runtime/normalize.ts
129
+ function str(v) {
130
+ return typeof v === "string" ? v : void 0;
131
+ }
132
+ /**
133
+ * Normalize a harness hook payload into a uniform event. Handles Cline's nested
134
+ * `preToolUse`/`postToolUse` shape and the top-level `tool_name`/`tool_input`
135
+ * shape used by Claude, Codex, Gemini, and Cursor.
136
+ */
137
+ function normalizeEvent(id, payload) {
138
+ if (id === "cline") {
139
+ const post = payload.postToolUse;
140
+ const node = post ?? payload.preToolUse ?? {};
141
+ const params = node.parameters ?? {};
142
+ return {
143
+ phase: post ? "post" : "pre",
144
+ tool: str(node.toolName) ?? "",
145
+ input: params,
146
+ sessionId: str(payload.taskId) ?? "",
147
+ filePath: str(params.path),
148
+ content: str(params.content),
149
+ command: str(params.command)
150
+ };
151
+ }
152
+ const event = str(payload.hook_event_name) ?? "";
153
+ const input = payload.tool_input ?? payload;
154
+ return {
155
+ phase: /post|after/i.test(event) ? "post" : "pre",
156
+ tool: str(payload.tool_name) ?? "",
157
+ input,
158
+ sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
159
+ filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
160
+ content: str(input.content) ?? str(input.new_string),
161
+ command: str(input.command) ?? str(payload.command),
162
+ agentType: str(payload.agent_type) ?? str(input.subagent_type)
163
+ };
164
+ }
165
+ //#endregion
166
+ //#region src/runtime/paths.ts
167
+ /** Path to a session's track file (under a per-tool base dir). */
168
+ function trackFile(sessionId, baseDir = join(tmpdir(), "fuse-harness")) {
169
+ return join(baseDir, `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
170
+ }
171
+ //#endregion
172
+ //#region src/runtime/record.ts
173
+ /** Apply an activity to a session's track and persist it (PostToolUse path). */
174
+ async function recordActivity(file, activity) {
175
+ const track = await loadTrack(file);
176
+ 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));
177
+ }
178
+ //#endregion
179
+ //#region src/runtime/respond.ts
180
+ /**
181
+ * Map a portable {@link Prompt} to a harness's native hook response. `block`
182
+ * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
183
+ * `ask` — they only honor deny.)
184
+ */
185
+ function respond(id, prompt) {
186
+ const message = formatPrompt(prompt);
187
+ const deny = prompt.kind === "block";
188
+ switch (id) {
189
+ case "claude-code":
190
+ case "codex": return JSON.stringify({ hookSpecificOutput: {
191
+ hookEventName: "PreToolUse",
192
+ permissionDecision: deny ? "deny" : "ask",
193
+ permissionDecisionReason: message
194
+ } });
195
+ case "gemini-cli": return JSON.stringify(deny ? {
196
+ decision: "deny",
197
+ reason: message
198
+ } : { hookSpecificOutput: { additionalContext: message } });
199
+ case "cursor": return JSON.stringify({
200
+ permission: deny ? "deny" : "ask",
201
+ continue: false,
202
+ userMessage: message,
203
+ agentMessage: message
204
+ });
205
+ case "cline": return JSON.stringify(deny ? {
206
+ cancel: true,
207
+ errorMessage: message
208
+ } : { contextModification: message });
209
+ default: return "";
210
+ }
211
+ }
212
+ //#endregion
213
+ //#region src/policy/design/state.ts
214
+ /** Minimum fuse-browser screenshots required before writing design-system.md, per mode. */
215
+ const MIN_SCREENSHOTS = {
216
+ full: 4,
217
+ page: 2,
218
+ component: 0
219
+ };
220
+ const stateFile = (cacheDir, agentId) => join(cacheDir, `.design-state-${agentId}.json`);
221
+ /** Load the design state for `agentId`, or null when absent/corrupt (fail-open). */
222
+ function loadDesignState(cacheDir, agentId) {
223
+ const path = stateFile(cacheDir, agentId);
224
+ if (!existsSync(path)) return null;
225
+ try {
226
+ return JSON.parse(readFileSync(path, "utf8"));
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+ /** Persist the design state under its agent id. */
232
+ function saveDesignState(cacheDir, state) {
233
+ mkdirSync(cacheDir, { recursive: true });
234
+ writeFileSync(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
235
+ }
236
+ /** Build the initial state for a design agent starting a run. */
237
+ function initDesignState(agentId, mode, designSystemExists) {
238
+ return {
239
+ agentId,
240
+ mode,
241
+ currentPhase: 0,
242
+ phasesCompleted: [],
243
+ inspirationRead: false,
244
+ scrolledSinceNav: false,
245
+ screenshotsCount: 0,
246
+ designSystemExists,
247
+ designSystemValid: false,
248
+ geminiCalls: 0
249
+ };
250
+ }
251
+ /** Archive the active state file (timestamp suffix) and drop archives older than 7 days. */
252
+ function cleanupDesignStates(cacheDir, agentId, stamp, now) {
253
+ if (agentId) {
254
+ const src = stateFile(cacheDir, agentId);
255
+ if (existsSync(src)) renameSync(src, join(cacheDir, `.design-state-${agentId}-${stamp}.json`));
256
+ }
257
+ let entries;
258
+ try {
259
+ entries = readdirSync(cacheDir);
260
+ } catch {
261
+ return;
262
+ }
263
+ const cutoff = now - 7 * 864e5;
264
+ for (const name of entries) {
265
+ if (!name.startsWith(".design-state-")) continue;
266
+ const path = join(cacheDir, name);
267
+ try {
268
+ if (statSync(path).mtimeMs < cutoff) rmSync(path);
269
+ } catch {}
270
+ }
271
+ }
272
+ //#endregion
273
+ //#region src/policy/design/transitions.ts
274
+ /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
275
+ function detectMode(prompt, designSystemExists) {
276
+ const p = prompt.toLowerCase();
277
+ if ([
278
+ "component",
279
+ "composant",
280
+ "snippet"
281
+ ].some((k) => p.includes(k))) return "component";
282
+ return designSystemExists ? "page" : "full";
283
+ }
284
+ /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
285
+ function recordScreenshot(state, needed) {
286
+ const screenshotsCount = state.screenshotsCount + 1;
287
+ const next = {
288
+ ...state,
289
+ screenshotsCount
290
+ };
291
+ if (screenshotsCount >= needed && state.currentPhase < 2) {
292
+ next.currentPhase = 2;
293
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([
294
+ ...state.phasesCompleted,
295
+ "identity",
296
+ "research"
297
+ ])];
298
+ }
299
+ return next;
300
+ }
301
+ /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
302
+ function recordNavigate(state) {
303
+ return {
304
+ ...state,
305
+ scrolledSinceNav: false
306
+ };
307
+ }
308
+ /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
309
+ function recordScroll(state) {
310
+ return {
311
+ ...state,
312
+ scrolledSinceNav: true
313
+ };
314
+ }
315
+ /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
316
+ function recordValidDesignSystem(state) {
317
+ return {
318
+ ...state,
319
+ designSystemExists: true,
320
+ designSystemValid: true,
321
+ currentPhase: Math.max(state.currentPhase, 3),
322
+ phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
323
+ };
324
+ }
325
+ /**
326
+ * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
327
+ * allowed); reading the inspiration catalog satisfies the browse prerequisite.
328
+ */
329
+ function recordRead(state, filePath) {
330
+ const next = { ...state };
331
+ if (filePath.includes("identity-system")) {
332
+ next.currentPhase = Math.max(state.currentPhase, 1);
333
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
334
+ }
335
+ if (filePath.includes("design-inspiration")) next.inspirationRead = true;
336
+ return next;
337
+ }
338
+ //#endregion
339
+ //#region src/policy/design/flag.ts
340
+ const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
341
+ /** The active design agent id (the flag), or "" when no design agent is running. */
342
+ function activeDesignAgent(cacheDir) {
343
+ const path = flagPath(cacheDir);
344
+ if (!existsSync(path)) return "";
345
+ try {
346
+ return readFileSync(path, "utf8").trim();
347
+ } catch {
348
+ return "";
349
+ }
350
+ }
351
+ /** Mark a design agent active (writes its id to the flag file). */
352
+ function setActiveDesignAgent(cacheDir, agentId) {
353
+ mkdirSync(cacheDir, { recursive: true });
354
+ writeFileSync(flagPath(cacheDir), agentId);
355
+ }
356
+ /** Clear the active-design-agent flag. */
357
+ function clearActiveDesignAgent(cacheDir) {
358
+ try {
359
+ rmSync(flagPath(cacheDir));
360
+ } catch {}
361
+ }
362
+ //#endregion
363
+ //#region src/policy/design/content-checks.ts
364
+ /** Accessibility warnings: icon buttons need aria-label, images need alt. */
365
+ function checkAccessibility(content) {
366
+ const w = [];
367
+ if (!/<(button|a|input|img)/.test(content)) return w;
368
+ if (/<button[^>]*>/.test(content) && !/aria-label|aria-labelledby/.test(content) && /<button[^>]*>[^<]*<[^>]*Icon/.test(content)) w.push("Accessibility: icon buttons need an aria-label.");
369
+ for (const m of content.matchAll(/<img[^>]*?>/g)) if (!m[0].includes("alt=")) {
370
+ w.push("Accessibility: images need an alt attribute.");
371
+ break;
372
+ }
373
+ return w;
374
+ }
375
+ /** Anti-pattern warnings: colored left borders, AI-slop gradients, emoji-as-icons. */
376
+ function checkPatterns(content) {
377
+ const w = [];
378
+ if (/border-l-[0-9]+ border-l-(blue|green|red|purple)/.test(content)) w.push("Design: avoid colored left borders — use shadow/gradient.");
379
+ 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.");
380
+ if (/>[^\x00-\x7F]+</.test(content)) w.push("Design: avoid emojis as icons — use a real icon set.");
381
+ return w;
382
+ }
383
+ /** Forbidden-font warnings (CSS font-family + Google Fonts import). */
384
+ function checkFonts(content) {
385
+ const w = [];
386
+ 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.");
387
+ if (/@import.*fonts\.googleapis.*family=(Roboto|Inter)\b/.test(content)) w.push("Font: Google Fonts import for a forbidden family.");
388
+ return w;
389
+ }
390
+ /** Hard-coded-color warnings (hex in className or inline style). */
391
+ function checkColors(content) {
392
+ const w = [];
393
+ if (/className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/.test(content)) w.push("Color: hard-coded hex in className — use CSS variables.");
394
+ 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-*).");
395
+ return w;
396
+ }
397
+ /** Run all design content checks → non-blocking warnings (empty = clean). */
398
+ function runDesignChecks(content) {
399
+ return [
400
+ ...checkAccessibility(content),
401
+ ...checkPatterns(content),
402
+ ...checkFonts(content),
403
+ ...checkColors(content)
404
+ ];
405
+ }
406
+ //#endregion
407
+ //#region src/policy/design/gates.ts
408
+ const ALLOWED_WRITE = /\.(html|css|md|json)$/;
409
+ const EXEMPT_DIRS = [
410
+ "node_modules/",
411
+ "dist/",
412
+ "build/",
413
+ ".claude/"
414
+ ];
415
+ const FORBIDDEN_FONTS = [
416
+ "Inter",
417
+ "Roboto",
418
+ "Arial",
419
+ "Open Sans"
420
+ ];
421
+ const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
422
+ const KNOWN_DOMAINS = [
423
+ "framer.website",
424
+ "webflow.io",
425
+ "awwwards.com",
426
+ "godly.website",
427
+ "lapa.ninja",
428
+ "onepagelove.com",
429
+ "saasframe.io",
430
+ "bestwebsite.gallery",
431
+ "landingfolio.com"
432
+ ];
433
+ const deny = (reason) => ({
434
+ kind: "block",
435
+ title: "Design pipeline",
436
+ reason,
437
+ actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"]
438
+ });
439
+ /** Block the design agent from writing anything but .html/.css/.md/.json. */
440
+ function htmlCssOnlyGate(filePath) {
441
+ if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
442
+ return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
443
+ }
444
+ /** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
445
+ function stateFileGate(filePath) {
446
+ return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are read-only; the hooks update them as you progress.") : null;
447
+ }
448
+ /** Gate writing design-system.md: requires phase ≥ 2 and the per-mode screenshot quota. */
449
+ function designSystemWriteGate(filePath, state) {
450
+ if (!filePath.endsWith("design-system.md")) return null;
451
+ if (state.currentPhase < 2) return deny(`BLOCKED: cannot write design-system.md at phase ${state.currentPhase}. Read identity + inspiration, then browse & screenshot first.`);
452
+ const needed = MIN_SCREENSHOTS[state.mode];
453
+ if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
454
+ return null;
455
+ }
456
+ /** Return the requirements missing from a design-system.md (empty = valid). */
457
+ function validateDesignSystem(content) {
458
+ const missing = [];
459
+ if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
460
+ if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
461
+ if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
462
+ if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
463
+ return missing;
464
+ }
465
+ /** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
466
+ function geminiCreateGate(state) {
467
+ if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
468
+ if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
469
+ return null;
470
+ }
471
+ /** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
472
+ function browserNavigateGate(state, url) {
473
+ if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
474
+ if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
475
+ if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
476
+ return null;
477
+ }
478
+ /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
479
+ function screenshotScrollGate(state) {
480
+ return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
481
+ }
482
+ /** The Gemini design gates are OPT-IN: off unless `FUSE_DESIGN_GEMINI` is `1`/`true`. */
483
+ function geminiEnabled() {
484
+ const v = process.env.FUSE_DESIGN_GEMINI;
485
+ return v === "1" || v === "true";
486
+ }
487
+ //#endregion
488
+ //#region src/runtime/design.ts
489
+ const NAV = "mcp__fuse-browser__browser_navigate";
490
+ const SHOT = "mcp__fuse-browser__browser_screenshot";
491
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
492
+ const GEMINI = "mcp__gemini-design__create_frontend";
493
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
494
+ function findDesignSystem(cwd) {
495
+ let dir = cwd;
496
+ for (let i = 0; i < 6; i++) {
497
+ const p = join(dir, "design-system.md");
498
+ if (existsSync(p)) try {
499
+ return readFileSync(p, "utf8");
500
+ } catch {
501
+ return "";
502
+ }
503
+ const parent = dirname(dir);
504
+ if (parent === dir) break;
505
+ dir = parent;
506
+ }
507
+ return "";
508
+ }
509
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
510
+ function recordPost(event, cacheDir, state) {
511
+ if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
512
+ else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state));
513
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
514
+ else if (event.tool === GEMINI) saveDesignState(cacheDir, {
515
+ ...state,
516
+ geminiCalls: state.geminiCalls + 1
517
+ });
518
+ else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
519
+ else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
520
+ }
521
+ /**
522
+ * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
523
+ * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
524
+ */
525
+ function designGate(payload, event, cacheDir, cwd) {
526
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
527
+ const active = activeDesignAgent(cacheDir);
528
+ if (active && agentId && agentId !== active) return null;
529
+ const id = active || agentId;
530
+ if (!id) return null;
531
+ const state = loadDesignState(cacheDir, id);
532
+ if (!state) return null;
533
+ if (event.phase === "post") {
534
+ recordPost(event, cacheDir, state);
535
+ if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
536
+ const warnings = runDesignChecks(event.content ?? "");
537
+ if (warnings.length) return {
538
+ kind: "inform",
539
+ title: "Design review",
540
+ reason: warnings.join(" "),
541
+ actions: []
542
+ };
543
+ }
544
+ return null;
545
+ }
546
+ if (event.tool === "Write" || event.tool === "Edit") {
547
+ const fp = event.filePath ?? "";
548
+ const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
549
+ if (base) return base;
550
+ if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
551
+ kind: "block",
552
+ title: "Design pipeline",
553
+ reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
554
+ actions: ["Call mcp__gemini-design__create_frontend first"]
555
+ };
556
+ return null;
557
+ }
558
+ if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
559
+ if (event.tool === SHOT) return screenshotScrollGate(state);
560
+ if (event.tool === GEMINI) {
561
+ if (!geminiEnabled()) return null;
562
+ const block = geminiCreateGate(state);
563
+ if (block) return block;
564
+ const missing = validateDesignSystem(findDesignSystem(cwd));
565
+ if (missing.length) return {
566
+ kind: "block",
567
+ title: "Design pipeline",
568
+ reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
569
+ actions: ["Fix design-system.md, then retry create_frontend"]
570
+ };
571
+ saveDesignState(cacheDir, recordValidDesignSystem(state));
572
+ }
573
+ return null;
574
+ }
575
+ //#endregion
576
+ //#region src/runtime/design-lifecycle.ts
577
+ /**
578
+ * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
579
+ * raise the active flag on start, archive/cleanup + clear the flag on stop.
580
+ * Returns true when it handled the event (caller should respond and stop).
581
+ */
582
+ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
583
+ const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
584
+ if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
585
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
586
+ if (event === "SubagentStart") {
587
+ if (!agentId) return false;
588
+ const dsExists = existsSync(join(cwd, "design-system.md"));
589
+ saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
590
+ setActiveDesignAgent(cacheDir, agentId);
591
+ return true;
592
+ }
593
+ if (event === "SubagentStop") {
594
+ cleanupDesignStates(cacheDir, agentId, stamp, now);
595
+ clearActiveDesignAgent(cacheDir);
596
+ return true;
597
+ }
598
+ return false;
599
+ }
600
+ //#endregion
601
+ //#region src/runtime/inject-context.ts
602
+ /**
603
+ * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
604
+ * preamble as a Claude `additionalContext` response, or "" when nothing to emit.
605
+ * @param prompt - The raw user prompt.
606
+ * @param cwd - Project root (for project-type detection).
607
+ * @returns The native hook stdout (possibly empty).
608
+ */
609
+ function promptSubmitContext(prompt, cwd) {
610
+ const ctx = buildClaudeMdContext(prompt, cwd);
611
+ return ctx ? contextResponse("UserPromptSubmit", ctx) : "";
612
+ }
613
+ /**
614
+ * PreToolUse Task context injection: render the APEX sub-agent context as a
615
+ * Claude `additionalContext` response when `.claude/apex/` exists, else "".
616
+ * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
617
+ * @returns The native hook stdout (possibly empty).
618
+ */
619
+ function taskContext(cwd) {
620
+ const ctx = buildApexTaskInjection(process.env.CLAUDE_PROJECT_DIR ?? cwd);
621
+ return ctx ? contextResponse("PreToolUse", ctx) : "";
622
+ }
623
+ //#endregion
624
+ //#region src/runtime/home-state.ts
625
+ /** Home `~/.claude` dir (single source for every home-based hook path). */
626
+ function claudeHome(home = homedir()) {
627
+ return join(home, ".claude");
628
+ }
629
+ /** `~/.claude/fusengine-cache` base dir for legacy session/cache state. */
630
+ function fusengineCache(home = homedir()) {
631
+ return join(claudeHome(home), "fusengine-cache");
632
+ }
633
+ /** `~/.claude/fusengine-cache/sessions` — per-session JSON state dir. */
634
+ function sessionsDir(home = homedir()) {
635
+ return join(fusengineCache(home), "sessions");
636
+ }
637
+ const SID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
638
+ /** Validate a session id (1-128 url-safe chars); null when invalid. */
639
+ function sanitizeSessionId(sid) {
640
+ const s = String(sid ?? "").trim();
641
+ return SID_RE.test(s) ? s : null;
642
+ }
643
+ /** Unified per-session state file path: `sessions/session-<sid>.json`. */
644
+ function sessionStatePath(sid, home = homedir()) {
645
+ return join(sessionsDir(home), `session-${sid}.json`);
646
+ }
647
+ /** Load a session-state dict, or `{}` when missing/corrupt (mirrors Python). */
648
+ function loadSessionState(sid, home = homedir()) {
649
+ const path = sessionStatePath(sid, home);
650
+ try {
651
+ if (!existsSync(path)) return {};
652
+ const data = JSON.parse(readFileSync(path, "utf-8"));
653
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
654
+ } catch {
655
+ return {};
656
+ }
657
+ }
658
+ /** Atomically persist a session-state dict (0o600 via atomicWrite, indent 2). */
659
+ function saveSessionState(sid, state, home = homedir()) {
660
+ mkdirSync(sessionsDir(home), {
661
+ recursive: true,
662
+ mode: 448
663
+ });
664
+ atomicWrite(sessionStatePath(sid, home), JSON.stringify(state, null, 2));
665
+ }
666
+ //#endregion
667
+ //#region src/runtime/dev-context.ts
668
+ /** Run a git subcommand in `cwd`, returning trimmed stdout or "" on error. */
669
+ function git(cwd, args) {
670
+ try {
671
+ return execFileSync("git", args, {
672
+ cwd,
673
+ encoding: "utf-8",
674
+ stdio: "pipe",
675
+ timeout: 5e3
676
+ }).trim();
677
+ } catch {
678
+ return "";
679
+ }
680
+ }
681
+ /** Build the git portion of the dev context (branch + up to 5 changed files). */
682
+ function gitContext(cwd) {
683
+ if (!existsSync(join(cwd, ".git"))) return [];
684
+ const parts = [`Git branch: ${git(cwd, ["branch", "--show-current"]) || "unknown"}`];
685
+ const status = git(cwd, ["status", "--porcelain"]);
686
+ if (status) parts.push("Modified files:\n" + status.split("\n").slice(0, 5).join("\n"));
687
+ return parts;
688
+ }
689
+ /** Build the project-type portion (mirrors load-dev-context.py exactly). */
690
+ function projectContext(cwd) {
691
+ const parts = [];
692
+ const has = (f) => existsSync(join(cwd, f));
693
+ if ([
694
+ "next.config.js",
695
+ "next.config.ts",
696
+ "next.config.mjs"
697
+ ].some(has)) parts.push("Project: Next.js");
698
+ else if (has("package.json")) parts.push("Project: Node.js");
699
+ if (has("composer.json") && has("artisan")) parts.push("Project: Laravel");
700
+ if (has("Package.swift")) parts.push("Project: Swift");
701
+ return parts;
702
+ }
703
+ /**
704
+ * Build the SessionStart dev-context block (git + project type), or "" when
705
+ * nothing applies. Ports `core-guards/scripts/session-start/load-dev-context.py`.
706
+ * @param cwd - Project root to inspect.
707
+ * @returns The joined additionalContext text (possibly empty).
708
+ */
709
+ function devContext(cwd) {
710
+ return [...gitContext(cwd), ...projectContext(cwd)].join("\n");
711
+ }
712
+ //#endregion
713
+ //#region src/runtime/fs-cleanup.ts
714
+ /** Age of a file in seconds (now - mtime). Infinity when unstat-able. */
715
+ function ageSec(path, now) {
716
+ try {
717
+ return (now - statSync(path).mtimeMs) / 1e3;
718
+ } catch {
719
+ return Infinity;
720
+ }
721
+ }
722
+ /** Remove files directly under `dir` matching `test` older than `maxAgeSec`. */
723
+ function removeOldFiles(dir, test, maxAgeSec, now = Date.now()) {
724
+ if (!existsSync(dir)) return;
725
+ for (const name of readdirSync(dir)) {
726
+ const path = join(dir, name);
727
+ if (test(name) && ageSec(path, now) > maxAgeSec) try {
728
+ rmSync(path, { force: true });
729
+ } catch {}
730
+ }
731
+ }
732
+ /** Trim `file` to its last `keepLines` lines when it exceeds `maxBytes`. */
733
+ function trimLogFile(file, maxBytes, keepLines) {
734
+ try {
735
+ if (!existsSync(file) || statSync(file).size <= maxBytes) return;
736
+ writeFileSync(file, readFileSync(file, "utf-8").split("\n").slice(-keepLines).join("\n"), "utf-8");
737
+ } catch {}
738
+ }
739
+ /** Recursively purge files under `root/<top>` older than `ttls[top]` seconds. */
740
+ function purgeTtlTree(root, ttls, now = Date.now()) {
741
+ if (!existsSync(root)) return;
742
+ for (const [top, ttlSec] of Object.entries(ttls)) walkPurge(join(root, top), ttlSec, now);
743
+ }
744
+ function walkPurge(dir, ttlSec, now) {
745
+ if (!existsSync(dir)) return;
746
+ for (const name of readdirSync(dir)) {
747
+ const path = join(dir, name);
748
+ let isDir = false;
749
+ try {
750
+ isDir = statSync(path).isDirectory();
751
+ } catch {
752
+ continue;
753
+ }
754
+ if (isDir) {
755
+ walkPurge(path, ttlSec, now);
756
+ continue;
757
+ }
758
+ if (ageSec(path, now) > ttlSec) try {
759
+ rmSync(path, { force: true });
760
+ } catch {}
761
+ }
762
+ }
763
+ /** Bottom-up removal of empty subdirs under each `root/<top>` (best effort). */
764
+ function pruneEmptyDirs(root, tops) {
765
+ for (const top of tops) {
766
+ const sub = join(root, top);
767
+ if (!existsSync(sub)) continue;
768
+ pruneEmpty(sub, sub);
769
+ }
770
+ }
771
+ function pruneEmpty(dir, stopAt) {
772
+ for (const name of existsSync(dir) ? readdirSync(dir) : []) {
773
+ const path = join(dir, name);
774
+ try {
775
+ if (statSync(path).isDirectory()) pruneEmpty(path, stopAt);
776
+ } catch {}
777
+ }
778
+ if (dir !== stopAt && relative(stopAt, dir).split(sep)[0] !== "..") try {
779
+ rmdirSync(dir);
780
+ } catch {}
781
+ }
782
+ //#endregion
783
+ //#region src/runtime/lifecycle/session-start.ts
784
+ /** TTLs (seconds) for purgeable fusengine-cache subtrees (cleanup-old-caches.py). */
785
+ const PURGEABLE = {
786
+ sessions: 48 * 3600,
787
+ webfetch: 24 * 3600,
788
+ doc: 48 * 3600,
789
+ explore: 48 * 3600
790
+ };
791
+ /** Read `~/.claude/CLAUDE.md`, or "" when missing/unreadable. */
792
+ function claudeMd(home) {
793
+ const path = join(claudeHome(home), "CLAUDE.md");
794
+ try {
795
+ return existsSync(path) ? readFileSync(path, "utf-8") : "";
796
+ } catch {
797
+ return "";
798
+ }
799
+ }
800
+ /** Run the legacy SessionStart cleanups (stale states, caches, log trim). */
801
+ function runSessionStartCleanups(home = homedir(), now = Date.now()) {
802
+ const base = fusengineCache(home);
803
+ removeOldFiles(sessionsDir(home), (n) => n.startsWith("session-") && n.endsWith(".json"), 86400, now);
804
+ const user = process.env.USER ?? "unknown";
805
+ removeOldFiles(base, (n) => n === `changes-${user}.json`, 21600, now);
806
+ trimLogFile(join(claudeHome(home), "logs", "hooks.log"), 10485760, 5e3);
807
+ removeOldFiles(join(claudeHome(home), "logs", "00-apex"), (n) => n.startsWith("ref-cache-") && n.endsWith(".json"), 86400, now);
808
+ purgeTtlTree(base, PURGEABLE, now);
809
+ pruneEmptyDirs(base, Object.keys(PURGEABLE));
810
+ }
811
+ /**
812
+ * Handle core-guards SessionStart: inject CLAUDE.md + dev context as
813
+ * `additionalContext`, then run the cache/state cleanups. Ports the four
814
+ * `session-start/*.py` scripts into one harness call.
815
+ * @param cwd - Project root for dev-context detection.
816
+ * @param home - Home dir (defaults to `~`).
817
+ * @param now - Clock for TTL cleanup (defaults to `Date.now()`).
818
+ * @returns The native hook stdout (possibly empty).
819
+ */
820
+ function sessionStartCore(cwd, home = homedir(), now = Date.now()) {
821
+ const md = claudeMd(home);
822
+ const dev = devContext(cwd);
823
+ runSessionStartCleanups(home, now);
824
+ const ctx = [md, dev].filter(Boolean).join("\n");
825
+ return ctx ? contextResponse("SessionStart", ctx) : "";
826
+ }
827
+ //#endregion
828
+ //#region src/runtime/lifecycle/inject-rules.ts
829
+ /** Read & concatenate all `*.md` files (sorted) under `rulesDir`. */
830
+ function readRules(rulesDir) {
831
+ if (!existsSync(rulesDir)) return "";
832
+ let names;
833
+ try {
834
+ names = readdirSync(rulesDir).filter((n) => n.endsWith(".md")).sort();
835
+ } catch {
836
+ return "";
837
+ }
838
+ const parts = [];
839
+ for (const name of names) try {
840
+ parts.push(readFileSync(join(rulesDir, name), "utf-8"));
841
+ } catch {}
842
+ return parts.join("\n\n");
843
+ }
844
+ /**
845
+ * Build the rules injection for claude-rules (SessionStart + UserPromptSubmit):
846
+ * read `<pluginRoot>/rules/*.md` and emit as `additionalContext`, or "" when no
847
+ * rules. Ports `claude-rules/scripts/inject-rules.py` (which always tags the
848
+ * output `hookEventName: "SessionStart"`, even on UserPromptSubmit).
849
+ * @param pluginRoot - `CLAUDE_PLUGIN_ROOT` of the claude-rules plugin.
850
+ * @returns The native hook stdout (possibly empty).
851
+ */
852
+ function injectRules(pluginRoot) {
853
+ const content = readRules(join(pluginRoot, "rules"));
854
+ return content ? contextResponse("SessionStart", content) : "";
855
+ }
856
+ //#endregion
857
+ //#region src/runtime/lifecycle/solid-detect.ts
858
+ /** Ordered detection table (mirrors solid/scripts/detect-project.py). */
859
+ const CHECKS = [
860
+ {
861
+ file: "package.json",
862
+ grep: "next",
863
+ profile: {
864
+ type: "nextjs",
865
+ limit: 150,
866
+ ifaceDir: "modules/cores/interfaces"
867
+ }
868
+ },
869
+ {
870
+ file: "composer.json",
871
+ grep: "laravel",
872
+ profile: {
873
+ type: "laravel",
874
+ limit: 100,
875
+ ifaceDir: "app/Contracts"
876
+ }
877
+ },
878
+ {
879
+ file: "go.mod",
880
+ grep: null,
881
+ profile: {
882
+ type: "go",
883
+ limit: 100,
884
+ ifaceDir: "internal/interfaces"
885
+ }
886
+ },
887
+ {
888
+ file: "Cargo.toml",
889
+ grep: null,
890
+ profile: {
891
+ type: "rust",
892
+ limit: 100,
893
+ ifaceDir: "src/traits"
894
+ }
895
+ },
896
+ {
897
+ file: "pyproject.toml",
898
+ grep: null,
899
+ profile: {
900
+ type: "python",
901
+ limit: 100,
902
+ ifaceDir: "src/interfaces"
903
+ }
904
+ },
905
+ {
906
+ file: "requirements.txt",
907
+ grep: null,
908
+ profile: {
909
+ type: "python",
910
+ limit: 100,
911
+ ifaceDir: "src/interfaces"
912
+ }
913
+ }
914
+ ];
915
+ /** Detect the SOLID profile for `projectDir`, defaulting to `unknown`. */
916
+ function detectSolidProfile(projectDir) {
917
+ for (const { file, grep, profile } of CHECKS) {
918
+ const path = join(projectDir, file);
919
+ if (!existsSync(path)) continue;
920
+ if (grep !== null) try {
921
+ if (!readFileSync(path, "utf-8").includes(grep)) continue;
922
+ } catch {
923
+ continue;
924
+ }
925
+ return profile;
926
+ }
927
+ if (existsSync(join(projectDir, "Package.swift"))) return {
928
+ type: "swift",
929
+ limit: 150,
930
+ ifaceDir: "Protocols"
931
+ };
932
+ try {
933
+ if (readdirSync(projectDir).some((e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace"))) return {
934
+ type: "swift",
935
+ limit: 150,
936
+ ifaceDir: "Protocols"
937
+ };
938
+ } catch {}
939
+ return {
940
+ type: "unknown",
941
+ limit: 100,
942
+ ifaceDir: ""
943
+ };
944
+ }
945
+ /**
946
+ * Handle solid SessionStart: detect the profile, append SOLID_* exports to
947
+ * `CLAUDE_ENV_FILE`, and return the `SOLID: …` stdout line (or "" for unknown).
948
+ * Ports `solid/scripts/detect-project.py`.
949
+ * @param env - Environment (defaults to `process.env`).
950
+ * @returns The plain-text stdout line (possibly empty).
951
+ */
952
+ function solidDetectStart(env = process.env) {
953
+ const profile = detectSolidProfile(env.CLAUDE_PROJECT_DIR ?? ".");
954
+ const envFile = env.CLAUDE_ENV_FILE ?? "";
955
+ if (envFile) try {
956
+ appendFileSync(envFile, `export SOLID_PROJECT_TYPE=${profile.type}\nexport SOLID_FILE_LIMIT=${profile.limit}\nexport SOLID_INTERFACE_DIR=${profile.ifaceDir}\n`, "utf-8");
957
+ } catch {}
958
+ return profile.type !== "unknown" ? `SOLID: ${profile.type} project (max ${profile.limit} lines)` : "";
959
+ }
960
+ //#endregion
961
+ //#region src/runtime/lifecycle/subagent-cache.ts
962
+ const DEFAULT_TTL_MIN = 30;
963
+ /** Resolve cache TTL (minutes) from `FUSENGINE_CACHE_TTL_MIN` or default. */
964
+ function ttlMinutes(env) {
965
+ const raw = (env.FUSENGINE_CACHE_TTL_MIN ?? "").trim();
966
+ const val = Number.parseInt(raw, 10);
967
+ return Number.isFinite(val) && val > 0 ? val : DEFAULT_TTL_MIN;
968
+ }
969
+ /** True when ISO ts `YYYY-MM-DDTHH:MM:SSZ` is within `ttlMin` minutes of now. */
970
+ function isFresh(ts, ttlMin, now) {
971
+ const parsed = Date.parse(ts);
972
+ if (Number.isNaN(parsed)) return false;
973
+ const ageSec = (now - parsed) / 1e3;
974
+ return ageSec >= 0 && ageSec <= ttlMin * 60;
975
+ }
976
+ /** Sanitize + truncate a cell value (replace `|`/newline, ellipsize). */
977
+ function trunc(text, limit) {
978
+ const t = String(text ?? "").replace(/\|/g, "/").replace(/\n/g, " ");
979
+ return t.length <= limit ? t : t.slice(0, limit - 3) + "...";
980
+ }
981
+ /** Render fresh cache entries as the markdown injection block. */
982
+ function render(entries) {
983
+ const lines = [
984
+ "# MCP Cache disponible cette session",
985
+ "Avant de lancer mcp__context7/exa, verifie si deja cached.",
986
+ "Lis le fichier .md via Read pour recuperer le resultat.",
987
+ "APEX: Read sur cache MCP compte comme research-expert satisfait.",
988
+ "",
989
+ "| Tool | Query | File |",
990
+ "| --- | --- | --- |"
991
+ ];
992
+ for (const e of entries) lines.push(`| ${trunc(e.tool, 40)} | ${trunc(e.query, 60)} | ${trunc(e.file, 50)} |`);
993
+ return lines.join("\n");
994
+ }
995
+ /**
996
+ * Handle SubagentStart: surface fresh MCP cache entries for the session as
997
+ * `additionalContext`. Ports `subagent-start/inject-context-cache.py`.
998
+ * @param sessionIdRaw - Raw session id from the payload.
999
+ * @param home - Home dir (defaults to `~`).
1000
+ * @param env - Environment (defaults to `process.env`).
1001
+ * @param now - Clock (defaults to `Date.now()`).
1002
+ * @returns The native hook stdout (possibly empty).
1003
+ */
1004
+ function subagentCacheContext(sessionIdRaw, home = homedir(), env = process.env, now = Date.now()) {
1005
+ const sid = sanitizeSessionId(sessionIdRaw === "" || sessionIdRaw == null ? "unknown" : sessionIdRaw);
1006
+ if (!sid) return "";
1007
+ const index = loadIndex(join(sessionsDir(home), sid, "context", "index.json"));
1008
+ if (index.length === 0) return "";
1009
+ const fresh = index.filter((e) => isFresh(String(e.ts ?? ""), ttlMinutes(env), now));
1010
+ return fresh.length ? contextResponse("SubagentStart", render(fresh)) : "";
1011
+ }
1012
+ //#endregion
1013
+ //#region src/runtime/lifecycle/agent-memory.ts
1014
+ /** `~/.claude/memory/agents` — agent completion history dir. */
1015
+ function memoryDir(home) {
1016
+ return join(home, ".claude", "memory", "agents");
1017
+ }
1018
+ const SKIP_AGENTS = /(sniper|sniper-faster|explore-codebase|research-expert|claude-code-guide|Explore|Plan)/;
1019
+ /** Append the agent completion record to `agent-history.jsonl` (best effort). */
1020
+ function recordHistory(home, agentId, agentType, ts) {
1021
+ const dir = memoryDir(home);
1022
+ try {
1023
+ mkdirSync(dir, { recursive: true });
1024
+ appendFileSync(join(dir, "agent-history.jsonl"), JSON.stringify({
1025
+ agentId,
1026
+ agentType,
1027
+ completedAt: ts
1028
+ }) + "\n", "utf-8");
1029
+ } catch {}
1030
+ }
1031
+ /**
1032
+ * Handle SubagentStop: append the completion to agent-history.jsonl and, for a
1033
+ * non-skipped agent that touched code, emit the sniper reminder + reset the
1034
+ * counter. Ports `subagent-stop/track-agent-memory.py`.
1035
+ * @param data - The raw hook payload.
1036
+ * @param home - Home dir (defaults to `~`).
1037
+ * @param now - Clock (defaults to `Date.now()`).
1038
+ * @returns The native hook stdout (always a JSON message).
1039
+ */
1040
+ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
1041
+ mkdirSync(sessionsDir(home), { recursive: true });
1042
+ const agentType = String(data.agent_type ?? data.subagent_type ?? "unknown");
1043
+ const sessionId = String(data.session_id ?? "unknown");
1044
+ const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1045
+ recordHistory(home, String(data.agent_id ?? "unknown"), agentType, ts);
1046
+ if (SKIP_AGENTS.test(agentType)) return JSON.stringify({ message: `Agent ${agentType} completed` });
1047
+ const stateFile = join(sessionsDir(home), `session-${sessionId}-changes.json`);
1048
+ if (existsSync(stateFile)) try {
1049
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
1050
+ const count = state.cumulativeCodeFiles ?? 0;
1051
+ if (count > 0) {
1052
+ const files = (state.modifiedFiles ?? []).join(", ");
1053
+ writeFileSync(stateFile, JSON.stringify({
1054
+ ...state,
1055
+ cumulativeCodeFiles: 0
1056
+ }), "utf-8");
1057
+ return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${count} code file(s): ${files}. Run sniper agent now.`);
1058
+ }
1059
+ } catch {}
1060
+ return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
1061
+ }
1062
+ //#endregion
1063
+ //#region src/runtime/lifecycle/teammate-idle.ts
1064
+ /**
1065
+ * Handle TeammateIdle: when the teammate's session-changes file shows code was
1066
+ * modified, suggest sniper validation as `additionalContext`. Ports
1067
+ * `teammate-idle/validate-teammate-output.py`.
1068
+ * @param data - The raw hook payload.
1069
+ * @param home - Home dir (defaults to `~`).
1070
+ * @returns The native hook stdout (possibly empty).
1071
+ */
1072
+ function validateTeammateOutput(data, home = homedir()) {
1073
+ const teammate = String(data.teammate_name ?? "unknown");
1074
+ const sessionId = String(data.session_id ?? "unknown");
1075
+ const stateFile = join(sessionsDir(home), `session-${sessionId}-changes.json`);
1076
+ if (!existsSync(stateFile)) return "";
1077
+ try {
1078
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
1079
+ const count = state.cumulativeCodeFiles ?? 0;
1080
+ if (count > 0) return contextResponse("TeammateIdle", `Teammate '${teammate}' going idle after modifying ${count} code file(s): ${(state.modifiedFiles ?? []).slice(0, 5).join(", ")}. Consider running sniper validation.`);
1081
+ } catch {}
1082
+ return "";
1083
+ }
1084
+ //#endregion
1085
+ //#region src/runtime/lifecycle/tool-failure.ts
1086
+ /**
1087
+ * Handle PostToolUseFailure: append a `TOOL_FAILURE` line to
1088
+ * `~/.claude/logs/tool-failures.log`, skipping user interrupts. Ports
1089
+ * `post-tool-use/log-tool-failure.py`. No stdout (logging only).
1090
+ * @param data - The raw hook payload.
1091
+ * @param home - Home dir (defaults to `~`).
1092
+ * @param now - Clock (defaults to `Date.now()`).
1093
+ */
1094
+ function logToolFailure(data, home = homedir(), now = Date.now()) {
1095
+ if (data.is_interrupt === true) return;
1096
+ const tool = String(data.tool_name ?? "unknown");
1097
+ const error = String(data.error ?? "unknown error");
1098
+ const sessionId = String(data.session_id ?? "unknown");
1099
+ const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1100
+ const dir = join(home, ".claude", "logs");
1101
+ try {
1102
+ mkdirSync(dir, { recursive: true });
1103
+ appendFileSync(join(dir, "tool-failures.log"), `[${ts}] TOOL_FAILURE session=${sessionId} tool=${tool} error=${error}\n`, "utf-8");
1104
+ } catch {}
1105
+ }
1106
+ //#endregion
1107
+ //#region src/runtime/lifecycle/pre-compact.ts
1108
+ /** Two-digit zero-pad. */
1109
+ function pad(n) {
1110
+ return String(n).padStart(2, "0");
1111
+ }
1112
+ /** Compact local timestamp `YYYYMMDD-HHMMSS` (mirrors Python strftime). */
1113
+ function stamp(now) {
1114
+ const d = new Date(now);
1115
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
1116
+ }
1117
+ /**
1118
+ * Handle PreCompact: back up `.claude/apex/task.json` to `backups/`, keep only
1119
+ * the 5 newest, and emit a confirmation. Ports `pre-compact/save-apex-state.py`.
1120
+ * @param cwd - Project root.
1121
+ * @param now - Clock (defaults to `Date.now()`).
1122
+ * @returns The native hook stdout (possibly empty when no task.json).
1123
+ */
1124
+ function saveApexState(cwd, now = Date.now()) {
1125
+ const apexDir = join(cwd, ".claude", "apex");
1126
+ const stateFile = join(apexDir, "task.json");
1127
+ if (!existsSync(stateFile)) return "";
1128
+ const backupDir = join(apexDir, "backups");
1129
+ mkdirSync(backupDir, { recursive: true });
1130
+ copyFileSync(stateFile, join(backupDir, `task-${stamp(now)}.json`));
1131
+ const backups = readdirSync(backupDir).filter((n) => n.startsWith("task-") && n.endsWith(".json")).sort().reverse();
1132
+ for (const old of backups.slice(5)) try {
1133
+ rmSync(join(backupDir, old), { force: true });
1134
+ } catch {}
1135
+ return JSON.stringify({ additionalContext: "APEX state saved before compaction. Previous task state preserved in .claude/apex/backups/" });
1136
+ }
1137
+ //#endregion
1138
+ //#region src/runtime/lifecycle/session-end.ts
1139
+ /**
1140
+ * Handle SessionEnd: remove stale `*.tmp` (>1h) under `session-tmp/` and stale
1141
+ * legacy `claude_solid_reads_*` / `claude_session_changes_*` files (>2h) under
1142
+ * `fusengine-cache`. Ports `session-end/cleanup-session.py`. No stdout.
1143
+ * @param home - Home dir (defaults to `~`).
1144
+ * @param now - Clock (defaults to `Date.now()`).
1145
+ */
1146
+ function cleanupSession(home = homedir(), now = Date.now()) {
1147
+ const base = fusengineCache(home);
1148
+ removeOldFiles(join(base, "session-tmp"), (n) => n.endsWith(".tmp"), 3600, now);
1149
+ removeOldFiles(base, (n) => n.startsWith("claude_solid_reads_") || n.startsWith("claude_session_changes_"), 7200, now);
1150
+ }
1151
+ //#endregion
1152
+ //#region src/runtime/lifecycle/instructions-loaded.ts
1153
+ /**
1154
+ * Handle InstructionsLoaded: append `load_reason | memory_type | file_path` to
1155
+ * the per-session debug log. Ports `instructions-loaded/validate-rules-loaded.py`.
1156
+ * No stdout (logging only; InstructionsLoaded has no decision control).
1157
+ * @param data - The raw hook payload.
1158
+ * @param home - Home dir (defaults to `~`).
1159
+ */
1160
+ function validateRulesLoaded(data, home = homedir()) {
1161
+ const filePath = String(data.file_path ?? "");
1162
+ const loadReason = String(data.load_reason ?? "");
1163
+ const memoryType = String(data.memory_type ?? "");
1164
+ const sessionId = String(data.session_id ?? "unknown");
1165
+ const dir = join(home, ".claude", "logs", "instructions-loaded");
1166
+ try {
1167
+ mkdirSync(dir, { recursive: true });
1168
+ appendFileSync(join(dir, `${sessionId}.log`), `${loadReason} | ${memoryType} | ${filePath}\n`, "utf-8");
1169
+ } catch {}
1170
+ }
1171
+ //#endregion
1172
+ //#region src/runtime/lifecycle/track-changes.ts
1173
+ /** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */
1174
+ const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
1175
+ /**
1176
+ * Handle PostToolUse Write/Edit: track the cumulative set of modified code
1177
+ * files per session and emit the mandatory "SNIPER VALIDATION REQUIRED"
1178
+ * additionalContext. Ports `post-tool-use/track-session-changes.py`.
1179
+ * @param sessionIdRaw - Raw session id from the payload.
1180
+ * @param filePath - The edited file path.
1181
+ * @param home - Home dir (defaults to `~`).
1182
+ * @param now - Clock (defaults to `Date.now()`).
1183
+ * @returns The native hook stdout (possibly empty when not a code file).
1184
+ */
1185
+ function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Date.now()) {
1186
+ if (!filePath || !CODE_EXT.test(filePath)) return "";
1187
+ const sid = sanitizeSessionId(sessionIdRaw) ?? "unknown";
1188
+ const state = loadSessionState(sid, home);
1189
+ const prev = state.changes ?? {
1190
+ cumulativeCodeFiles: 0,
1191
+ modifiedFiles: []
1192
+ };
1193
+ const files = [...prev.modifiedFiles];
1194
+ let count = prev.cumulativeCodeFiles;
1195
+ if (!files.includes(filePath)) {
1196
+ count += 1;
1197
+ files.push(filePath);
1198
+ }
1199
+ state.changes = {
1200
+ cumulativeCodeFiles: count,
1201
+ modifiedFiles: files,
1202
+ lastModifiedFile: filePath,
1203
+ lastCheck: new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z")
1204
+ };
1205
+ saveSessionState(sid, state, home);
1206
+ return contextResponse("PostToolUse", `SNIPER VALIDATION REQUIRED: Code file '${basename(filePath)}' was modified. You MUST now run the sniper agent (fuse-ai-pilot:sniper) to validate this modification before continuing. This is mandatory per CLAUDE.md rules.`);
1207
+ }
1208
+ //#endregion
1209
+ //#region src/runtime/lifecycle/post-edit-ts.ts
1210
+ const TS_EXT$1 = /\.(ts|tsx)$/;
1211
+ const TIMEOUT_MS$1 = 1e4;
1212
+ /** True when `bin` is resolvable on PATH (mirrors shutil.which). */
1213
+ function hasBin(bin) {
1214
+ try {
1215
+ execFileSync(process.platform === "win32" ? "where" : "which", [bin], {
1216
+ stdio: "pipe",
1217
+ timeout: 2e3
1218
+ });
1219
+ return true;
1220
+ } catch {
1221
+ return false;
1222
+ }
1223
+ }
1224
+ /** Run `bin args`, returning `{ code, out }` (code 1 on spawn error). */
1225
+ function run(bin, args) {
1226
+ try {
1227
+ return {
1228
+ code: 0,
1229
+ out: execFileSync(bin, args, {
1230
+ encoding: "utf-8",
1231
+ stdio: "pipe",
1232
+ timeout: TIMEOUT_MS$1
1233
+ })
1234
+ };
1235
+ } catch (err) {
1236
+ const e = err;
1237
+ return {
1238
+ code: e.status ?? 1,
1239
+ out: typeof e.stdout === "string" ? e.stdout : e.stdout?.toString() ?? ""
1240
+ };
1241
+ }
1242
+ }
1243
+ /**
1244
+ * Handle PostToolUse for TS/TSX: report eslint/prettier issues (never fixes) as
1245
+ * additionalContext. Ports `post-tool-use/post-edit-typescript.py`.
1246
+ * @param filePath - The edited file path.
1247
+ * @returns The native hook stdout (possibly empty).
1248
+ */
1249
+ function postEditTypescript(filePath) {
1250
+ if (!filePath || !TS_EXT$1.test(filePath) || !existsSync(filePath)) return "";
1251
+ const issues = [];
1252
+ if (hasBin("eslint")) {
1253
+ const r = run("eslint", [
1254
+ "--no-fix",
1255
+ "--format",
1256
+ "compact",
1257
+ filePath
1258
+ ]);
1259
+ if (r.code !== 0 && r.out.trim()) issues.push(`ESLint:\n${r.out.trim()}`);
1260
+ }
1261
+ if (hasBin("prettier")) {
1262
+ if (run("prettier", ["--check", filePath]).code !== 0) issues.push(`Prettier: ${basename(filePath)} needs formatting`);
1263
+ }
1264
+ if (issues.length === 0) return "";
1265
+ return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1266
+ }
1267
+ //#endregion
1268
+ //#region src/runtime/lifecycle/cartographer/fs-util.ts
1269
+ /**
1270
+ * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
1271
+ * `describe.py` (file desc) and `write_recursive.py` (children + counts).
1272
+ */
1273
+ /**
1274
+ * Read a file and derive its one-line description (frontmatter / heading /
1275
+ * comment). "" on any error or when nothing is found.
1276
+ * @param filePath - Absolute path to the file.
1277
+ * @returns The description, or "".
1278
+ */
1279
+ function getFileDesc(filePath) {
1280
+ let text = "";
1281
+ try {
1282
+ text = readFileSync(filePath, "utf-8");
1283
+ } catch {
1284
+ return "";
1285
+ }
1286
+ const suffix = extname(filePath);
1287
+ const mdField = suffix === ".md" ? parseField(text, "description") : "";
1288
+ return descFromText(suffix, text, mdField);
1289
+ }
1290
+ /**
1291
+ * Recursively count files whose relative path parts are all visible (no leading
1292
+ * "." or "_") and none excluded. Best-effort (partial count on errors).
1293
+ * @param dir - Directory to count under.
1294
+ * @param exclude - Directory/name set to skip.
1295
+ * @returns The file count.
1296
+ */
1297
+ function countFiles(dir, exclude) {
1298
+ let total = 0;
1299
+ try {
1300
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1301
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
1302
+ if (e.isDirectory()) total += countFiles(join(dir, e.name), exclude);
1303
+ else if (e.isFile()) total += 1;
1304
+ }
1305
+ } catch {}
1306
+ return total;
1307
+ }
1308
+ /** Absolute children of `source`, split into dirs/files, sorted by full path. */
1309
+ function listChildren(source, exclude) {
1310
+ const dirs = [];
1311
+ const files = [];
1312
+ let entries;
1313
+ try {
1314
+ entries = readdirSync(source, { withFileTypes: true });
1315
+ } catch {
1316
+ return {
1317
+ dirs,
1318
+ files
1319
+ };
1320
+ }
1321
+ for (const e of entries) {
1322
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
1323
+ const abs = join(source, e.name);
1324
+ if (e.isDirectory()) dirs.push(abs);
1325
+ else if (e.isFile()) files.push(abs);
1326
+ }
1327
+ return {
1328
+ dirs: dirs.sort(),
1329
+ files: files.sort()
1330
+ };
1331
+ }
1332
+ //#endregion
1333
+ //#region src/runtime/lifecycle/cartographer/merge.ts
1334
+ /**
1335
+ * Index merge — preserves enriched descriptions across regenerations. Ports
1336
+ * `merge_index.py` (merge_lines + .enriched.json sidecar).
1337
+ */
1338
+ /**
1339
+ * Load the `.enriched.json` sidecar's `entries` map for an output index.
1340
+ * @param outputIndexPath - Path to the index.md being written.
1341
+ * @returns The path→desc enrichment map (possibly empty).
1342
+ */
1343
+ function loadEnriched(outputIndexPath) {
1344
+ const sidecar = join(dirname(outputIndexPath), ".enriched.json");
1345
+ try {
1346
+ if (!existsSync(sidecar)) return {};
1347
+ return JSON.parse(readFileSync(sidecar, "utf-8")).entries ?? {};
1348
+ } catch {
1349
+ return {};
1350
+ }
1351
+ }
1352
+ /**
1353
+ * Merge freshly generated lines with prior descriptions: enriched sidecar wins,
1354
+ * else a longer pre-existing description is preserved.
1355
+ * @param newLines - The freshly generated index lines.
1356
+ * @param outputIndexPath - Path to the existing index.md (if any).
1357
+ * @returns The merged lines.
1358
+ */
1359
+ function mergeLines(newLines, outputIndexPath) {
1360
+ const enriched = loadEnriched(outputIndexPath);
1361
+ const existingDescs = {};
1362
+ if (existsSync(outputIndexPath)) try {
1363
+ for (const line of readFileSync(outputIndexPath, "utf-8").split("\n")) {
1364
+ const e = parseEntry(line);
1365
+ if (e) existingDescs[e.path] = e.desc;
1366
+ }
1367
+ } catch {}
1368
+ return newLines.map((line) => {
1369
+ const e = parseEntry(line);
1370
+ if (!e) return line;
1371
+ if (e.path in enriched) return `${e.prefix}[${e.name}](${e.path}) — ${enriched[e.path]}`;
1372
+ const old = existingDescs[e.path] ?? "";
1373
+ if (old.length > e.desc.length) return `${e.prefix}[${e.name}](${e.path}) — ${old}`;
1374
+ return line;
1375
+ });
1376
+ }
1377
+ //#endregion
1378
+ //#region src/runtime/lifecycle/cartographer/write-tree.ts
1379
+ /**
1380
+ * Recursive index.md tree writer. Ports `write_recursive.py`.
1381
+ */
1382
+ /**
1383
+ * Write `index.md` files mirroring `source` under `output`, recursing into
1384
+ * subdirectories. Directory lines carry a file-count hint; file lines carry a
1385
+ * derived description and link to the real absolute source path.
1386
+ * @param source - Absolute source directory.
1387
+ * @param output - Absolute output directory for the index tree.
1388
+ * @param back - Relative `← back` link target ("" at the root).
1389
+ * @param exclude - Directory/name set to skip.
1390
+ */
1391
+ function writeTree(source, output, back = "", exclude) {
1392
+ const ex = exclude ?? /* @__PURE__ */ new Set();
1393
+ mkdirSync(output, { recursive: true });
1394
+ const { dirs, files } = listChildren(source, ex);
1395
+ const lines = [`# ${basename(source)}\n`];
1396
+ if (back) lines.push(`> [← back](${back})\n`);
1397
+ const total = dirs.length + files.length;
1398
+ let idx = 0;
1399
+ for (const d of dirs) {
1400
+ idx += 1;
1401
+ const conn = idx === total ? "└──" : "├──";
1402
+ const count = countFiles(d, ex);
1403
+ const hint = count ? ` — ${count} files` : "";
1404
+ lines.push(`${conn} [${basename(d)}/](./${basename(d)}/index.md)${hint}`);
1405
+ writeTree(d, join(output, basename(d)), "../index.md", exclude);
1406
+ }
1407
+ for (const f of files) {
1408
+ idx += 1;
1409
+ const conn = idx === total ? "└──" : "├──";
1410
+ const desc = getFileDesc(f);
1411
+ const suffix = desc ? ` — ${desc}` : "";
1412
+ lines.push(`${conn} [${basename(f)}](${f})${suffix}`);
1413
+ }
1414
+ const indexPath = join(output, "index.md");
1415
+ writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
1416
+ }
1417
+ //#endregion
1418
+ //#region src/runtime/lifecycle/cartographer/project-map.ts
1419
+ /**
1420
+ * Project map generation. Ports `generate_project_map.py` (project map only).
1421
+ */
1422
+ /** True when `dir` is a real directory. */
1423
+ function isDirectory(dir) {
1424
+ try {
1425
+ return statSync(dir).isDirectory();
1426
+ } catch {
1427
+ return false;
1428
+ }
1429
+ }
1430
+ /**
1431
+ * True when `dir` looks like a project root (has an indicator file) and is not
1432
+ * the home directory or filesystem root.
1433
+ * @param dir - Directory to test.
1434
+ * @returns Whether `dir` is a project root.
1435
+ */
1436
+ function isProject(dir) {
1437
+ const resolved = resolve(dir);
1438
+ if (resolved === resolve(homedir()) || resolved === "/") return false;
1439
+ for (const f of PROJECT_INDICATORS) if (existsSync(join(dir, f))) return true;
1440
+ return false;
1441
+ }
1442
+ /**
1443
+ * Generate the `.cartographer/project` index tree for `cwd` when it is a real
1444
+ * project directory. Always returns "" (no additionalContext emitted).
1445
+ * @param cwd - The working directory.
1446
+ * @param outputDir - Override for the output tree root.
1447
+ * @returns "" (side-effect only).
1448
+ */
1449
+ function generateProjectMap(cwd, outputDir) {
1450
+ const projectDir = resolve(cwd);
1451
+ const out = outputDir ?? join(projectDir, ".cartographer", "project");
1452
+ if (!isDirectory(projectDir)) return "";
1453
+ if (!isProject(projectDir)) return "";
1454
+ writeTree(projectDir, out, "", EXCLUDE_DIRS$1);
1455
+ return "";
1456
+ }
1457
+ //#endregion
1458
+ //#region src/runtime/lifecycle/cartographer/session-start.ts
1459
+ /**
1460
+ * Cartographer SessionStart handler. Ports the project-map half of
1461
+ * `generate_project_map.py`: regenerates `.cartographer/project` and emits no
1462
+ * additionalContext (the plugin ecosystem map from `generate_map.py` is not
1463
+ * ported and stays as Python).
1464
+ */
1465
+ /**
1466
+ * Regenerate the project map for `cwd` on SessionStart. Returns "" (side-effect
1467
+ * only — no additionalContext).
1468
+ * @param cwd - The working directory.
1469
+ * @returns "" always.
1470
+ */
1471
+ function cartoSessionStart(cwd) {
1472
+ generateProjectMap(cwd);
1473
+ return "";
1474
+ }
1475
+ //#endregion
1476
+ //#region src/runtime/lifecycle/dispatch.ts
1477
+ /** SessionStart handler keyed on plugin scope. */
1478
+ function sessionStart(input) {
1479
+ if (input.scope === "solid") return solidDetectStart();
1480
+ if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
1481
+ if (input.scope === "carto") return cartoSessionStart(input.cwd);
1482
+ return sessionStartCore(input.cwd, void 0, input.now);
1483
+ }
1484
+ /**
1485
+ * Route a lifecycle/session/context hook event to its ported handler. Returns
1486
+ * the native stdout when handled, or `null` when the event is not a lifecycle
1487
+ * event (so the caller falls through to the PreToolUse/PostToolUse pipeline).
1488
+ * @param input - The dispatch input.
1489
+ * @returns The native hook stdout, or `null` when unhandled.
1490
+ */
1491
+ function dispatchLifecycle(input) {
1492
+ switch (input.event) {
1493
+ case "SessionStart": return sessionStart(input);
1494
+ case "UserPromptSubmit": return input.scope === "rules" ? injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd) : null;
1495
+ case "SubagentStart": return subagentCacheContext(input.payload.session_id);
1496
+ case "SubagentStop": return trackAgentMemory(input.payload, void 0, input.now);
1497
+ case "TeammateIdle": return validateTeammateOutput(input.payload);
1498
+ case "PostToolUseFailure":
1499
+ logToolFailure(input.payload, void 0, input.now);
1500
+ return "";
1501
+ case "PreCompact": return saveApexState(input.cwd, input.now);
1502
+ case "SessionEnd":
1503
+ cleanupSession(void 0, input.now);
1504
+ return "";
1505
+ case "InstructionsLoaded":
1506
+ validateRulesLoaded(input.payload);
1507
+ return "";
1508
+ default: return null;
1509
+ }
1510
+ }
1511
+ //#endregion
1512
+ //#region src/runtime/lifecycle/cartographer/track-enrichment.ts
1513
+ /**
1514
+ * Enrichment tracker (PostToolUse Edit/Write on `.cartographer/**\/index.md`).
1515
+ * Ports `track-enrichment.py`: persists manual descriptions to a sidecar so the
1516
+ * next regeneration can preserve them.
1517
+ */
1518
+ /**
1519
+ * Record manually-edited descriptions from a cartographer `index.md` into the
1520
+ * adjacent `.enriched.json` sidecar. No-op for unrelated paths. No stdout.
1521
+ * @param filePath - The edited file path.
1522
+ */
1523
+ function trackEnrichment(filePath) {
1524
+ if (!filePath || !filePath.includes(".cartographer") || !filePath.endsWith("index.md")) return;
1525
+ if (!existsSync(filePath)) return;
1526
+ const sidecar = join(dirname(filePath), ".enriched.json");
1527
+ let existing = {
1528
+ version: 1,
1529
+ entries: {}
1530
+ };
1531
+ if (existsSync(sidecar)) try {
1532
+ existing = JSON.parse(readFileSync(sidecar, "utf-8"));
1533
+ } catch {}
1534
+ const entries = existing.entries ??= {};
1535
+ let text = "";
1536
+ try {
1537
+ text = readFileSync(filePath, "utf-8");
1538
+ } catch {
1539
+ return;
1540
+ }
1541
+ for (const line of text.split("\n")) {
1542
+ const e = parseEnrichment(line);
1543
+ if (e) entries[e[0]] = e[1];
1544
+ }
1545
+ try {
1546
+ writeFileSync(sidecar, JSON.stringify(existing, null, 2) + "\n", "utf-8");
1547
+ } catch {}
1548
+ }
1549
+ //#endregion
1550
+ //#region src/runtime/lifecycle/security/skill-state.ts
1551
+ /**
1552
+ * Shared security-tracker state: per-UTC-day JSON under
1553
+ * `~/.claude/logs/00-security`. Ports the state helpers of
1554
+ * `check-security-skill.py` / `track-skill-read.py` / `track-mcp-research.py`.
1555
+ */
1556
+ /** `~/.claude/logs/00-security` state directory. */
1557
+ function securityStateDir(home = homedir()) {
1558
+ return join(claudeHome(home), "logs", "00-security");
1559
+ }
1560
+ /** Current UTC date as `YYYY-MM-DD`. */
1561
+ function todayUtc(now = Date.now()) {
1562
+ return new Date(now).toISOString().slice(0, 10);
1563
+ }
1564
+ /** Current UTC instant as `YYYY-MM-DDTHH:MM:SSZ` (seconds, no millis). */
1565
+ function isoUtc(now = Date.now()) {
1566
+ return new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1567
+ }
1568
+ /** Today's security-state file path. */
1569
+ function securityStatePath(now = Date.now(), home = homedir()) {
1570
+ return join(securityStateDir(home), `${todayUtc(now)}-state.json`);
1571
+ }
1572
+ /** Load today's security state, or `{}` when missing/corrupt. */
1573
+ function loadSecurityState(now = Date.now(), home = homedir()) {
1574
+ const path = securityStatePath(now, home);
1575
+ try {
1576
+ if (!existsSync(path)) return {};
1577
+ const data = JSON.parse(readFileSync(path, "utf-8"));
1578
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
1579
+ } catch {
1580
+ return {};
1581
+ }
1582
+ }
1583
+ /** Persist today's security state (indent 2, no trailing newline). */
1584
+ function saveSecurityState(state, now = Date.now(), home = homedir()) {
1585
+ mkdirSync(securityStateDir(home), { recursive: true });
1586
+ writeFileSync(securityStatePath(now, home), JSON.stringify(state, null, 2), "utf-8");
1587
+ }
1588
+ //#endregion
1589
+ //#region src/runtime/lifecycle/security/track-skill-read.ts
1590
+ /**
1591
+ * Security skill-read tracker (PostToolUse Read). Ports `track-skill-read.py`:
1592
+ * flips `skill_read` once a security skill reference is read.
1593
+ */
1594
+ const SKILL_RE = /skills\/(security-scan|cve-research|dependency-audit|security-headers|auth-audit)\//;
1595
+ /**
1596
+ * Mark the security skill as read when a Read hits a security skill reference.
1597
+ * No-op for other tools/paths. No stdout.
1598
+ * @param tool - The tool name.
1599
+ * @param filePath - The read file path.
1600
+ * @param now - Clock.
1601
+ * @param home - Home dir.
1602
+ */
1603
+ function trackSkillRead(tool, filePath, now = Date.now(), home = homedir()) {
1604
+ if (tool !== "Read") return;
1605
+ if (!SKILL_RE.test(filePath)) return;
1606
+ const state = loadSecurityState(now, home);
1607
+ state.skill_read = true;
1608
+ (state.reads ??= []).push({
1609
+ timestamp: isoUtc(now),
1610
+ file: filePath
1611
+ });
1612
+ saveSecurityState(state, now, home);
1613
+ }
1614
+ //#endregion
1615
+ //#region src/runtime/lifecycle/security/track-mcp.ts
1616
+ /**
1617
+ * Security MCP-research tracker (PostToolUse context7/exa). Ports
1618
+ * `track-mcp-research.py`: logs documentation queries to today's state.
1619
+ */
1620
+ /**
1621
+ * Append a context7/exa research call to today's security state. No-op for other
1622
+ * tools. No stdout.
1623
+ * @param tool - The tool name.
1624
+ * @param input - The tool input (query/libraryId/libraryName).
1625
+ * @param now - Clock.
1626
+ * @param home - Home dir.
1627
+ */
1628
+ function trackMcpResearch(tool, input, now = Date.now(), home = homedir()) {
1629
+ if (!tool.includes("context7") && !tool.includes("exa")) return;
1630
+ const query = String(input.query ?? input.libraryId ?? input.libraryName ?? "");
1631
+ const state = loadSecurityState(now, home);
1632
+ (state.research ??= []).push({
1633
+ timestamp: isoUtc(now),
1634
+ tool,
1635
+ query
1636
+ });
1637
+ saveSecurityState(state, now, home);
1638
+ }
1639
+ //#endregion
1640
+ //#region src/runtime/lifecycle/changelog-research.ts
1641
+ /**
1642
+ * Changelog research tracker (PostToolUse exa/WebFetch/WebSearch). Ports
1643
+ * `track-watch-research.py`: logs research queries to
1644
+ * `~/.claude/logs/00-changelog/<utc-date>-research.json`.
1645
+ */
1646
+ /**
1647
+ * Append an exa/WebFetch/WebSearch query to today's changelog research log.
1648
+ * No-op for other tools. No stdout (errors swallowed).
1649
+ * @param tool - The tool name.
1650
+ * @param input - The tool input (query/url/prompt).
1651
+ * @param now - Clock.
1652
+ * @param home - Home dir.
1653
+ */
1654
+ function trackWatchResearch(tool, input, now = Date.now(), home = homedir()) {
1655
+ if (!tool.includes("exa") && !tool.includes("WebFetch") && !tool.includes("WebSearch")) return;
1656
+ const query = String(input.query ?? input.url ?? input.prompt ?? "");
1657
+ const dir = join(claudeHome(home), "logs", "00-changelog");
1658
+ try {
1659
+ mkdirSync(dir, { recursive: true });
1660
+ const path = join(dir, `${todayUtc(now)}-research.json`);
1661
+ let state = { queries: [] };
1662
+ if (existsSync(path)) try {
1663
+ state = JSON.parse(readFileSync(path, "utf-8"));
1664
+ } catch {
1665
+ state = { queries: [] };
1666
+ }
1667
+ state.queries.push({
1668
+ timestamp: isoUtc(now),
1669
+ tool,
1670
+ query
1671
+ });
1672
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
1673
+ } catch {}
1674
+ }
1675
+ //#endregion
1676
+ //#region src/runtime/lifecycle/post-tracking.ts
1677
+ /**
1678
+ * Dispatch the appropriate PostToolUse tracker for the invoking scope. Carto
1679
+ * persists manual enrichments; security records skill reads + MCP research;
1680
+ * changelog records watch research. Side-effect only.
1681
+ * @param scope - The invoking plugin scope.
1682
+ * @param event - The normalized event.
1683
+ * @param input - The raw tool input.
1684
+ * @param now - Clock.
1685
+ */
1686
+ function postTrackingSideEffects(scope, event, input, now) {
1687
+ if (scope === "carto" && (event.tool === "Edit" || event.tool === "Write") && event.filePath) {
1688
+ trackEnrichment(event.filePath);
1689
+ return;
1690
+ }
1691
+ if (scope === "security") {
1692
+ trackSkillRead(event.tool, event.filePath ?? "", now);
1693
+ trackMcpResearch(event.tool, input, now);
1694
+ return;
1695
+ }
1696
+ if (scope === "changelog") trackWatchResearch(event.tool, input, now);
1697
+ }
1698
+ //#endregion
1699
+ //#region src/runtime/lifecycle/security/check-skill.ts
1700
+ /**
1701
+ * Security advisory (PreToolUse Write/Edit on code files) — NON-BLOCKING.
1702
+ * Ports `check-security-skill.py`: nudge the agent to read the security skill,
1703
+ * but always allow the edit.
1704
+ */
1705
+ const CODE_RE = /\.(ts|tsx|js|jsx|py|php|swift|go|rs|rb|java)$/;
1706
+ const ADVISORY = "SECURITY: Read security skill references before modifying code. Use: Read skills/security-scan/references/scan-patterns.md";
1707
+ /**
1708
+ * Build a non-blocking PreToolUse `allow` response with a security advisory when
1709
+ * editing a code file before the security skill has been read. "" otherwise.
1710
+ * @param tool - The tool name (`Write`/`Edit`).
1711
+ * @param filePath - The target file path.
1712
+ * @param now - Clock.
1713
+ * @param home - Home dir.
1714
+ * @returns The advisory response JSON, or "".
1715
+ */
1716
+ function securityAdvisory(tool, filePath, now = Date.now(), home = homedir()) {
1717
+ if (tool !== "Write" && tool !== "Edit") return "";
1718
+ if (!CODE_RE.test(filePath)) return "";
1719
+ const path = securityStatePath(now, home);
1720
+ if (existsSync(path)) try {
1721
+ if (JSON.parse(readFileSync(path, "utf-8")).skill_read === true) return "";
1722
+ } catch {}
1723
+ return JSON.stringify({ hookSpecificOutput: {
1724
+ hookEventName: "PreToolUse",
1725
+ permissionDecision: "allow",
1726
+ additionalContext: ADVISORY
1727
+ } });
1728
+ }
1729
+ //#endregion
1730
+ //#region src/runtime/lifecycle-bridge.ts
1731
+ /** Raw event name from a payload (Cline lacks one; lifecycle is Claude-only). */
1732
+ function rawEvent(payload) {
1733
+ return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
1734
+ }
1735
+ /**
1736
+ * Run the ported lifecycle/session/context hooks (SessionStart, SubagentStart/
1737
+ * Stop, TeammateIdle, PostToolUseFailure, PreCompact, SessionEnd,
1738
+ * InstructionsLoaded, rules-scope UserPromptSubmit). Returns the native stdout
1739
+ * when handled, or `null` to fall through to the tool-use pipeline.
1740
+ * @param payload - The raw hook payload.
1741
+ * @param cwd - Project root.
1742
+ * @param scope - The invoking plugin scope (defaults to `core`).
1743
+ * @param now - Clock.
1744
+ * @returns The native stdout, or `null` when unhandled.
1745
+ */
1746
+ function lifecycleStdout(payload, cwd, scope, now) {
1747
+ return dispatchLifecycle({
1748
+ event: rawEvent(payload),
1749
+ payload,
1750
+ cwd,
1751
+ scope,
1752
+ now
1753
+ });
1754
+ }
1755
+ /**
1756
+ * Post-edit additions for core-scope PostToolUse Write/Edit: track cumulative
1757
+ * session changes (sniper reminder) + report eslint/prettier issues. Returns the
1758
+ * combined extra stdout (track-changes wins; lint appended only when no track
1759
+ * output), or "" when nothing to emit.
1760
+ * @param scope - The invoking plugin scope.
1761
+ * @param event - The normalized event.
1762
+ * @param now - Clock.
1763
+ * @returns The extra stdout (possibly empty).
1764
+ */
1765
+ function postEditContext(scope, event, now) {
1766
+ if (scope !== "core" || event.tool !== "Write" && event.tool !== "Edit" || !event.filePath) return "";
1767
+ return trackSessionChanges(event.sessionId, event.filePath, void 0, now) || postEditTypescript(event.filePath);
1768
+ }
1769
+ //#endregion
1770
+ //#region src/runtime/dry-patterns.ts
1771
+ /** Short identifiers never worth a duplication check (control flow, tiny names). */
1772
+ const DRY_KEYWORDS = /* @__PURE__ */ new Set([
1773
+ "if",
1774
+ "for",
1775
+ "while",
1776
+ "switch",
1777
+ "catch",
1778
+ "return",
1779
+ "async",
1780
+ "new",
1781
+ "get",
1782
+ "set",
1783
+ "map",
1784
+ "run",
1785
+ "use",
1786
+ "test",
1787
+ "main"
1788
+ ]);
1789
+ /** Extensions treated as TS/JS-family for symbol extraction. */
1790
+ const TS_EXT = /* @__PURE__ */ new Set([
1791
+ ".ts",
1792
+ ".tsx",
1793
+ ".js",
1794
+ ".jsx",
1795
+ ".astro"
1796
+ ]);
1797
+ /** Declaration patterns whose capture group 1 is the declared symbol name (TS/JS). */
1798
+ const TS_PATTERNS = [
1799
+ /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/g,
1800
+ /(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/g,
1801
+ /class\s+(\w+)\b/g
1802
+ ];
1803
+ /**
1804
+ * Declaration patterns for PHP (capture group 1 = symbol name). The modifier run
1805
+ * is bounded (`{0,6}`) on purpose: an unbounded `(?:…\s+)*` is quadratic (O(n²))
1806
+ * on a long whitespace/keyword run with no trailing `function`, which would block
1807
+ * the hook for seconds on a crafted file. A real PHP signature has at most a few
1808
+ * leading keywords, so the bound is behavior-equivalent and keeps matching linear.
1809
+ */
1810
+ 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];
1811
+ /** Directories grep skips when scanning for existing declarations. */
1812
+ const EXCLUDE_DIRS = [
1813
+ "vendor",
1814
+ "node_modules",
1815
+ ".next",
1816
+ ".git",
1817
+ "dist",
1818
+ "build",
1819
+ "coverage",
1820
+ ".turbo"
1821
+ ];
1822
+ //#endregion
1823
+ //#region src/runtime/dry.ts
1824
+ /** Extract long (>12 char) declared symbol names from new file content. */
1825
+ function extractSymbols(content, ext) {
1826
+ const pats = TS_EXT.has(ext) ? TS_PATTERNS : ext === ".php" ? PHP_PATTERNS : [];
1827
+ const names = /* @__PURE__ */ new Set();
1828
+ for (const re of pats) for (const m of content.matchAll(re)) {
1829
+ const n = m[1];
1830
+ if (n && !DRY_KEYWORDS.has(n) && n.length > 12) names.add(n);
1831
+ }
1832
+ return [...names];
1833
+ }
1834
+ /** `modules/X/...` -> `"X"`, else `""` (module-boundary key). */
1835
+ function moduleOf(path) {
1836
+ const parts = path.split(sep);
1837
+ const i = parts.indexOf("modules");
1838
+ return i >= 0 && i + 1 < parts.length ? parts[i + 1] ?? "" : "";
1839
+ }
1840
+ /**
1841
+ * Grep the codebase for existing declarations of the symbols a write introduces,
1842
+ * honoring module boundaries (cross-`modules/` matches are ignored). Effectful:
1843
+ * shells out to `grep`. Fails open (returns no duplicates) on any grep error,
1844
+ * timeout, or no-match — matching the original Python hook.
1845
+ */
1846
+ function detectDuplication(filePath, content, cwd) {
1847
+ const ext = extname(filePath).toLowerCase();
1848
+ if (!TS_EXT.has(ext) && ext !== ".php") return {
1849
+ names: [],
1850
+ duplicates: []
1851
+ };
1852
+ const names = extractSymbols(content, ext);
1853
+ if (!names.length) return {
1854
+ names,
1855
+ duplicates: []
1856
+ };
1857
+ const include = TS_EXT.has(ext) ? [
1858
+ "--include=*.ts",
1859
+ "--include=*.tsx",
1860
+ "--include=*.js",
1861
+ "--include=*.jsx"
1862
+ ] : ["--include=*.php"];
1863
+ 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`;
1864
+ let out = "";
1865
+ try {
1866
+ out = execFileSync("grep", [
1867
+ "-rEl",
1868
+ ...EXCLUDE_DIRS.map((d) => `--exclude-dir=${d}`),
1869
+ ...include,
1870
+ "--",
1871
+ pattern,
1872
+ cwd
1873
+ ], {
1874
+ encoding: "utf8",
1875
+ timeout: 1500
1876
+ });
1877
+ } catch {
1878
+ return {
1879
+ names,
1880
+ duplicates: []
1881
+ };
1882
+ }
1883
+ const self = resolve(filePath);
1884
+ const targetMod = moduleOf(filePath);
1885
+ const duplicates = [];
1886
+ for (const line of out.split("\n")) {
1887
+ const f = line.trim();
1888
+ if (!f || resolve(f) === self) continue;
1889
+ const dupMod = moduleOf(f);
1890
+ if (targetMod && dupMod && dupMod !== targetMod) continue;
1891
+ duplicates.push(f);
1892
+ }
1893
+ return {
1894
+ names,
1895
+ duplicates
1896
+ };
1897
+ }
1898
+ /** Blocking prompt when a Write/Edit re-declares 2+ existing symbols, else null. */
1899
+ function dryGate(tool, filePath, content, cwd) {
1900
+ if (!cwd || tool !== "Write" && tool !== "Edit" || !content) return null;
1901
+ const dup = detectDuplication(filePath, content, cwd);
1902
+ if (dup.duplicates.length < 2) return null;
1903
+ return {
1904
+ kind: "block",
1905
+ title: "Duplicate code (DRY)",
1906
+ reason: `[${dup.names.slice(0, 5).join(", ")}] already declared in: ${dup.duplicates.slice(0, 3).join(", ")}. Import and reuse instead of re-declaring.`,
1907
+ actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
1908
+ };
1909
+ }
1910
+ //#endregion
1911
+ //#region src/runtime/precommit.ts
1912
+ const TIMEOUT_MS = 3e4;
1913
+ const ESLINT_CONFIGS = [
1914
+ ".eslintrc.json",
1915
+ ".eslintrc.js",
1916
+ "eslint.config.js",
1917
+ "eslint.config.mjs",
1918
+ "eslint.config.ts"
1919
+ ];
1920
+ const PRETTIER_CONFIGS = [
1921
+ ".prettierrc",
1922
+ ".prettierrc.json",
1923
+ "prettier.config.js"
1924
+ ];
1925
+ /** Run a linter; returns its error output, or "" if it passed / spawn-failed / timed out (fail-open). */
1926
+ function runLinter(file, args, label, cwd) {
1927
+ try {
1928
+ execFileSync(file, args, {
1929
+ cwd,
1930
+ timeout: TIMEOUT_MS,
1931
+ stdio: [
1932
+ "ignore",
1933
+ "pipe",
1934
+ "pipe"
1935
+ ]
1936
+ });
1937
+ return "";
1938
+ } catch (e) {
1939
+ const err = e;
1940
+ if (err.status === void 0 || err.status === null) return "";
1941
+ const out = (err.stdout?.toString() ?? err.stderr?.toString() ?? "").trim();
1942
+ return out ? `[${label}]\n${out}` : "";
1943
+ }
1944
+ }
1945
+ /** Run the applicable linters in `cwd`, returning a block of errors per failing tool. */
1946
+ function collectErrors(cwd) {
1947
+ const has = (f) => existsSync(join(cwd, f));
1948
+ const errors = [];
1949
+ if (has("package.json")) {
1950
+ if (ESLINT_CONFIGS.some(has)) {
1951
+ const m = runLinter("bunx", [
1952
+ "eslint",
1953
+ ".",
1954
+ "--max-warnings",
1955
+ "0"
1956
+ ], "ESLint", cwd);
1957
+ if (m) errors.push(m);
1958
+ }
1959
+ if (has("tsconfig.json")) {
1960
+ const m = runLinter("bunx", ["tsc", "--noEmit"], "TypeScript", cwd);
1961
+ if (m) errors.push(m);
1962
+ }
1963
+ if (PRETTIER_CONFIGS.some(has)) {
1964
+ const m = runLinter("bunx", [
1965
+ "prettier",
1966
+ "--check",
1967
+ "."
1968
+ ], "Prettier", cwd);
1969
+ if (m) errors.push(m);
1970
+ }
1971
+ }
1972
+ if (has("requirements.txt") || has("pyproject.toml")) {
1973
+ const m = runLinter("ruff", ["check", "."], "Ruff", cwd);
1974
+ if (m) errors.push(m);
1975
+ }
1976
+ return errors;
1977
+ }
1978
+ /** Block a `git commit` when linters fail (effectful: runs eslint/tsc/prettier/ruff, never auto-fixes). */
1979
+ function preCommitGate(tool, command, cwd) {
1980
+ if (tool !== "Bash" || !command || !cwd) return null;
1981
+ if (!command.startsWith("git") || !command.includes("commit")) return null;
1982
+ const errors = collectErrors(cwd);
1983
+ if (!errors.length) return null;
1984
+ return {
1985
+ kind: "block",
1986
+ title: "Pre-commit checks failed",
1987
+ reason: `COMMIT BLOCKED — fix then retry:\n\n${errors.join("\n\n")}`,
1988
+ actions: ["Fix the linter/type errors above", "Re-run the commit"]
1989
+ };
1990
+ }
1991
+ //#endregion
1992
+ //#region src/runtime/modular.ts
1993
+ 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)$/;
1994
+ const NEXT_STATIC = /\.(css|ico|png|jpg|svg|json)$/;
1995
+ const PHP_BLOCKED_IN_APP = [
1996
+ "/app/Models/",
1997
+ "/app/Services/",
1998
+ "/app/Actions/",
1999
+ "/app/Http/Controllers/",
2000
+ "/app/Http/Requests/",
2001
+ "/app/Http/Resources/",
2002
+ "/app/Contracts/",
2003
+ "/app/DTOs/",
2004
+ "/app/Repositories/",
2005
+ "/app/Events/",
2006
+ "/app/Listeners/",
2007
+ "/app/Jobs/",
2008
+ "/app/Notifications/",
2009
+ "/app/Policies/"
2010
+ ];
2011
+ const block = (reason) => ({
2012
+ kind: "block",
2013
+ title: "Modular architecture",
2014
+ reason,
2015
+ actions: ["Move the code into the correct feature module", "Import only from the shared core module"]
2016
+ });
2017
+ /** Next.js `modules/` architecture: `app/` convention + cross-module import rules. */
2018
+ function nextModular(filePath, content, cwd) {
2019
+ const rel = relative(cwd, filePath);
2020
+ const bn = basename(filePath);
2021
+ 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]/.`);
2022
+ const mod = filePath.match(/\/modules\/([^/]+)\//);
2023
+ if (!mod) return null;
2024
+ const current = mod[1] ?? "";
2025
+ for (const m of content.matchAll(/from\s+['"][@.][^'"]*?\/modules\/([^/]+)\//g)) {
2026
+ const imported = m[1] ?? "";
2027
+ if (current === "cores") {
2028
+ if (imported !== "cores" && imported !== "core") return block(`BLOCKED: modules/cores/ must not import from modules/${imported}/.`);
2029
+ } else if (imported !== current && imported !== "cores" && imported !== "core") return block(`BLOCKED: cross-module import — '${current}' imports '${imported}'. Only modules/cores/ is shared.`);
2030
+ }
2031
+ return null;
2032
+ }
2033
+ /** Laravel FuseCore architecture: `app/` domain ban + module.json + cross-module `use` rules. */
2034
+ function fusecore(filePath, content, cwd) {
2035
+ 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/.`);
2036
+ const mod = filePath.match(/\/FuseCore\/([A-Za-z]+)\//);
2037
+ if (!mod) return null;
2038
+ const name = mod[1] ?? "";
2039
+ if (!existsSync(join(cwd, "FuseCore", name, "module.json"))) return block(`BLOCKED: FuseCore module '${name}' is missing module.json — create it first.`);
2040
+ for (const m of content.matchAll(/use\s+FuseCore\\(\w+)\\/g)) {
2041
+ const imported = m[1] ?? "";
2042
+ if (name === "Core") {
2043
+ if (imported !== "Core") return block(`BLOCKED: FuseCore\\Core\\ must not use FuseCore\\${imported}\\.`);
2044
+ } else if (imported !== name && imported !== "Core") return block(`BLOCKED: cross-module use — '${name}' uses '${imported}'. Only FuseCore\\Core\\ is shared.`);
2045
+ }
2046
+ return null;
2047
+ }
2048
+ /** Enforce the project's modular architecture (Next.js `modules/` or Laravel FuseCore) on a Write/Edit. */
2049
+ function modularGate(tool, filePath, content, cwd) {
2050
+ if (tool !== "Write" && tool !== "Edit" || !filePath || !cwd) return null;
2051
+ if (/\/(node_modules|dist|build|\.next|vendor|storage)\//.test(filePath)) return null;
2052
+ const arch = detectModularArchitecture(cwd);
2053
+ if (arch === "nextjs-modular" && /\.(tsx|ts|jsx|js)$/.test(filePath)) return nextModular(filePath, content ?? "", cwd);
2054
+ if (arch === "fusecore" && filePath.endsWith(".php")) return fusecore(filePath, content ?? "", cwd);
2055
+ return null;
2056
+ }
2057
+ //#endregion
2058
+ //#region src/runtime/framework-skill-gate.ts
2059
+ /**
2060
+ * Effective line count for the SOLID size check. On an Edit, `content` is only
2061
+ * the `new_string` snippet, so judge the larger of the snippet and the full
2062
+ * on-disk file (`existingLines`) — mirroring the base file-size guard and the
2063
+ * Python `get_full_file_content`. On Write, `content` IS the full file, so the
2064
+ * snippet count stands (undefined → the gate falls back to `countLines`).
2065
+ * @param tool - the tool name ("Edit" | "Write" | ...).
2066
+ * @param content - the written content (snippet on Edit, full file on Write).
2067
+ * @param existingLines - full on-disk line count, when known.
2068
+ */
2069
+ function effectiveLines(tool, content, existingLines) {
2070
+ if (tool !== "Edit" || existingLines === void 0) return void 0;
2071
+ return Math.max(countLines(content), existingLines);
2072
+ }
2073
+ /**
2074
+ * Framework-aware SOLID + sub-skill gate, run on the Write/Edit path once a
2075
+ * `filePath` is present. Combines:
2076
+ * - {@link frameworkSolidGate}: framework-specific SOLID rules (line limits,
2077
+ * interface/protocol separation, `'use client'`, @MainActor...).
2078
+ * - {@link skillTriggerGate}: blocks when written APIs need a sub-skill that
2079
+ * was not read this session, also forcing the modular-architecture skill
2080
+ * resolved from disk via {@link requiredArchSkill}.
2081
+ *
2082
+ * @param input - the gated tool-use (filePath + content + framework + cwd).
2083
+ * @param refsRead - in-session read reference paths (from the loaded track).
2084
+ * @param existingLines - full on-disk line count (so an Edit on an oversized
2085
+ * file still fires the framework SOLID size rule). Omit on Write.
2086
+ * @returns the first blocking {@link Prompt}, or `null` to allow.
2087
+ */
2088
+ function frameworkSkillGate(input, refsRead, existingLines) {
2089
+ if (!input.filePath) return null;
2090
+ const content = input.content ?? "";
2091
+ const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, content, existingLines));
2092
+ if (solid) return solid;
2093
+ const forced = input.cwd ? requiredArchSkill(input.cwd) : null;
2094
+ return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
2095
+ }
2096
+ //#endregion
2097
+ //#region src/runtime/gate.ts
2098
+ /** Prior agents the freshness gate requires before a code edit. */
2099
+ const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
2100
+ /** Default freshness window for {@link REQUIRED_AGENTS} (2 min — matches the plugin's `FUSE_ENFORCE_TTL_SEC` default). */
2101
+ const DEFAULT_WINDOW_MS = 12e4;
2102
+ /** Trivial edits allowed within the window before the full APEX gates apply. */
2103
+ const TRIVIAL_BUDGET = 4;
2104
+ /**
2105
+ * Code-only line count of the existing on-disk file (undefined if
2106
+ * absent/unreadable). Uses {@link countLines} (skips blank/comment lines) to
2107
+ * mirror the Python `count_code_lines(get_full_file_content(...))`, so a partial
2108
+ * Edit judges the full file by the SAME metric as the incoming snippet — a raw
2109
+ * `split("\n").length` would over-count JSDoc/blank lines (and add a
2110
+ * trailing-newline off-by-one), falsely blocking well-documented files.
2111
+ */
2112
+ function existingLineCount(path) {
2113
+ if (!path) return void 0;
2114
+ try {
2115
+ return existsSync(path) ? countLines(readFileSync(path, "utf8")) : void 0;
2116
+ } catch {
2117
+ return;
2118
+ }
2119
+ }
2120
+ /**
2121
+ * Full gate: the stateless guards (file-size, git, security...) first, then a
2122
+ * trivial-edit fast path, then the stateful APEX gates fed from the session
2123
+ * track. Returns the first blocking prompt, or null to allow.
2124
+ */
2125
+ async function gate(input) {
2126
+ const existingLines = existingLineCount(input.filePath);
2127
+ let quick;
2128
+ try {
2129
+ quick = evaluate({
2130
+ tool: input.tool,
2131
+ filePath: input.filePath,
2132
+ content: input.content,
2133
+ command: input.command,
2134
+ agentType: input.agentType,
2135
+ existingLines
2136
+ });
2137
+ } catch {
2138
+ return FAIL_CLOSED;
2139
+ }
2140
+ if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
2141
+ const precommit = preCommitGate(input.tool, input.command, input.cwd);
2142
+ if (precommit) return precommit;
2143
+ const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
2144
+ if (modular) return modular;
2145
+ if (!input.filePath) return null;
2146
+ const window = input.windowMs ?? 12e4;
2147
+ const track = await loadTrack(input.trackFile);
2148
+ const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
2149
+ if (solidOrSkill) return solidOrSkill;
2150
+ const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
2151
+ if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
2152
+ await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
2153
+ return null;
2154
+ }
2155
+ const ctx = {
2156
+ sessionId: input.sessionId,
2157
+ framework: input.framework,
2158
+ filePath: input.filePath,
2159
+ content: input.content ?? "",
2160
+ authorizations: track.authorizations,
2161
+ refs: input.refs,
2162
+ refsRead: track.refsRead,
2163
+ agentsFresh: agentsFresh(track, [...REQUIRED_AGENTS], window, input.now),
2164
+ brainstormRequired: track.brainstormRequired,
2165
+ brainstormFresh: agentsFresh(track, ["brainstorming"], window, input.now)
2166
+ };
2167
+ try {
2168
+ const apex = evaluateApex(ctx);
2169
+ if (apex) return apex;
2170
+ } catch {
2171
+ return FAIL_CLOSED;
2172
+ }
2173
+ return dryGate(input.tool, input.filePath, input.content, input.cwd);
2174
+ }
2175
+ //#endregion
2176
+ //#region src/runtime/handle-pre.ts
2177
+ /**
2178
+ * Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX
2179
+ * Task context injection, then the stateless+APEX gate chain. Returns the native
2180
+ * hook outcome (deny/ask/inject or allow).
2181
+ * @param ctx - The resolved pre-context.
2182
+ * @returns The hook outcome.
2183
+ */
2184
+ async function handlePre(ctx) {
2185
+ const { id, payload, event, framework, mcpDir, file, opts } = ctx;
2186
+ const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now);
2187
+ if (intercept !== null) {
2188
+ if (intercept.docSource) await recordActivity(file, {
2189
+ kind: "doc",
2190
+ framework,
2191
+ sessionId: event.sessionId,
2192
+ source: intercept.docSource
2193
+ });
2194
+ return {
2195
+ stdout: intercept.stdout,
2196
+ exit: 0
2197
+ };
2198
+ }
2199
+ const designBlock = designGate(payload, event, mcpDir, opts.cwd);
2200
+ if (designBlock) return {
2201
+ stdout: respond(id, designBlock),
2202
+ exit: 0
2203
+ };
2204
+ if (opts.scope === "security") return {
2205
+ stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
2206
+ exit: 0
2207
+ };
2208
+ if (event.tool === "Task") {
2209
+ const taskCtx = taskContext(opts.cwd);
2210
+ if (taskCtx) return {
2211
+ stdout: taskCtx,
2212
+ exit: 0
2213
+ };
2214
+ }
2215
+ const prompt = await gate({
2216
+ sessionId: event.sessionId,
2217
+ framework,
2218
+ tool: event.tool,
2219
+ filePath: event.filePath,
2220
+ content: event.content,
2221
+ command: event.command,
2222
+ cwd: opts.cwd,
2223
+ refs: opts.refsDir ? await loadRefs(opts.refsDir) : void 0,
2224
+ isReplaceAll: event.input.replace_all === true,
2225
+ agentType: event.agentType,
2226
+ windowMs: opts.windowMs,
2227
+ now: opts.now,
2228
+ trackFile: file
2229
+ });
2230
+ return prompt ? {
2231
+ stdout: respond(id, prompt),
2232
+ exit: 0
2233
+ } : {
2234
+ stdout: "",
2235
+ exit: 0
2236
+ };
2237
+ }
2238
+ //#endregion
2239
+ //#region src/runtime/handle.ts
2240
+ /**
2241
+ * The full hook handler: on a PRE event it gates the tool-use (stateless guards
2242
+ * then APEX gates from the session track) and returns the native response; on a
2243
+ * POST event it records the activity into the track. The loop that makes the
2244
+ * package behave like the Claude plugin, on any harness.
2245
+ */
2246
+ async function handleHook(id, payload, opts) {
2247
+ const event = normalizeEvent(id, payload);
2248
+ const layout = projectLayout(opts.cwd);
2249
+ const file = trackFile(event.sessionId, layout.trackDir);
2250
+ const mcpDir = layout.cacheDir;
2251
+ const framework = detectFramework(event.filePath ?? "", event.content ?? "");
2252
+ if (designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
2253
+ stdout: "",
2254
+ exit: 0
2255
+ };
2256
+ const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now);
2257
+ if (life !== null) return {
2258
+ stdout: life,
2259
+ exit: 0
2260
+ };
2261
+ const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
2262
+ if (userPrompt !== void 0) {
2263
+ await saveTrack(file, recordBrainstormRequired(await loadTrack(file), detectCreationIntent(userPrompt)));
2264
+ return {
2265
+ stdout: promptSubmitContext(userPrompt, opts.cwd),
2266
+ exit: 0
2267
+ };
2268
+ }
2269
+ if (event.phase === "post") {
2270
+ const response = payload.tool_response ?? payload.tool_output;
2271
+ mcpPostStore(event.tool, event.input, response, mcpDir);
2272
+ const designWarn = designGate(payload, event, mcpDir, opts.cwd);
2273
+ const activity = activityFor({
2274
+ tool: event.tool,
2275
+ input: event.input,
2276
+ sessionId: event.sessionId,
2277
+ framework,
2278
+ now: opts.now,
2279
+ responseLength: extractText(response).length
2280
+ });
2281
+ if (activity) await recordActivity(file, activity);
2282
+ postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now);
2283
+ const extra = postEditContext(opts.scope ?? "core", event, opts.now);
2284
+ return {
2285
+ stdout: designWarn ? respond(id, designWarn) : extra,
2286
+ exit: 0
2287
+ };
2288
+ }
2289
+ return handlePre({
2290
+ id,
2291
+ payload,
2292
+ event,
2293
+ framework,
2294
+ mcpDir,
2295
+ file,
2296
+ opts
2297
+ });
2298
+ }
2299
+ //#endregion
2300
+ export { trimLogFile as $, loadEnriched as A, logToolFailure as B, todayUtc as C, generateProjectMap as D, cartoSessionStart as E, postEditTypescript as F, solidDetectStart as G, trackAgentMemory as H, trackSessionChanges as I, runSessionStartCleanups as J, injectRules as K, validateRulesLoaded as L, countFiles as M, getFileDesc as N, isProject as O, listChildren as P, removeOldFiles as Q, cleanupSession as R, securityStatePath as S, dispatchLifecycle as T, subagentCacheContext as U, validateTeammateOutput as V, detectSolidProfile as W, pruneEmptyDirs as X, sessionStartCore as Y, purgeTtlTree as Z, trackSkillRead as _, isMcpTool as _t, TRIVIAL_BUDGET as a, loadSessionState as at, saveSecurityState as b, queryOf as bt, detectDuplication as c, sessionStatePath as ct, lifecycleStdout as d, taskContext as dt, devContext as et, postEditContext as f, respond as ft, trackMcpResearch as g, MCP_TTL_MS as gt, trackWatchResearch as h, normalizeEvent as ht, REQUIRED_AGENTS as i, fusengineCache as it, mergeLines as j, writeTree as k, dryGate as l, sessionsDir as lt, postTrackingSideEffects as m, trackFile as mt, handlePre as n, projectContext as nt, gate as o, sanitizeSessionId as ot, securityAdvisory as p, recordActivity as pt, readRules as q, DEFAULT_WINDOW_MS as r, claudeHome as rt, preCommitGate as s, saveSessionState as st, handleHook as t, gitContext as tt, extractSymbols as u, promptSubmitContext as ut, isoUtc as v, mcpPostStore as vt, trackEnrichment as w, securityStateDir as x, activityFor as xt, loadSecurityState as y, mcpPreIntercept as yt, saveApexState as z };