@fusengine/harness 0.1.29 → 0.1.31

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.
@@ -0,0 +1,3356 @@
1
+ import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
+ import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
3
+ 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-BYqhoV4c.mjs";
4
+ import { j as detectFramework, k as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-j3gRJ_ng.mjs";
5
+ import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
6
+ import { a as extractText, o as loadIndex, r as cacheStore, t as cacheLookup } from "./store-PrNPm6So.mjs";
7
+ import { i as writeJsonFile, r as readJsonFile, t as atomicWrite } from "./json-io-CAn72gI4.mjs";
8
+ import { t as loadRefs } from "./loader-CyAoJv2W.mjs";
9
+ 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";
10
+ import { t as contextResponse } from "./claude-B9FYp0Yw.mjs";
11
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
12
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
14
+ import { createHash } from "node:crypto";
15
+ import { mkdir, rmdir } from "node:fs/promises";
16
+ import { execFileSync } from "node:child_process";
17
+ import { Glob } from "bun";
18
+ //#region src/runtime/activity.ts
19
+ /** Min response length (chars) for a lead agent call to count as `sufficient`. */
20
+ const AGENT_QUALITY_MIN = 500;
21
+ /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
22
+ const READ_TOOLS = /* @__PURE__ */ new Set([
23
+ "Read",
24
+ "read_file",
25
+ "read_many_files"
26
+ ]);
27
+ /**
28
+ * Map a live tool-use to the activity to record, or null when nothing is
29
+ * tracked. Works across harnesses — tool names are globally distinct:
30
+ * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
31
+ * - `Task` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
32
+ * - a read tool opening a `.md` reference → `ref`
33
+ */
34
+ function activityFor(event) {
35
+ if (/context7|exa/i.test(event.tool)) return {
36
+ kind: "doc",
37
+ framework: event.framework,
38
+ sessionId: event.sessionId,
39
+ source: /exa/i.test(event.tool) ? "exa" : "context7"
40
+ };
41
+ if (event.tool === "Task") {
42
+ const name = String(event.input?.subagent_type ?? "").split(":").pop() ?? "";
43
+ if (!name) return null;
44
+ const quality = event.responseLength === void 0 ? void 0 : event.responseLength > AGENT_QUALITY_MIN ? "sufficient" : "insufficient";
45
+ return quality ? {
46
+ kind: "agent",
47
+ name,
48
+ ts: event.now,
49
+ quality
50
+ } : {
51
+ kind: "agent",
52
+ name,
53
+ ts: event.now
54
+ };
55
+ }
56
+ if (READ_TOOLS.has(event.tool)) {
57
+ const path = String(event.input?.file_path ?? event.input?.path ?? "");
58
+ if (path.endsWith(".md")) return {
59
+ kind: "ref",
60
+ path
61
+ };
62
+ }
63
+ return null;
64
+ }
65
+ //#endregion
66
+ //#region src/runtime/mcp.ts
67
+ /** Default freshness for cached MCP/WebFetch results (48h). */
68
+ const MCP_TTL_MS = 1728e5;
69
+ /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
70
+ function isMcpTool(tool) {
71
+ return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
72
+ }
73
+ /** The query/url that keys the cache. */
74
+ function queryOf(input) {
75
+ const q = input.query ?? input.url ?? input.libraryId ?? "";
76
+ return typeof q === "string" ? q : JSON.stringify(q);
77
+ }
78
+ function denyWith(id, content) {
79
+ if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
80
+ hookEventName: "PreToolUse",
81
+ permissionDecision: "deny",
82
+ permissionDecisionReason: content
83
+ } });
84
+ if (id === "gemini-cli") return JSON.stringify({
85
+ decision: "deny",
86
+ reason: content
87
+ });
88
+ return "";
89
+ }
90
+ function mutateWith(id, input) {
91
+ if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
92
+ hookEventName: "PreToolUse",
93
+ permissionDecision: "allow",
94
+ updatedInput: input
95
+ } });
96
+ if (id === "gemini-cli") return JSON.stringify({ hookSpecificOutput: { tool_input: input } });
97
+ return "";
98
+ }
99
+ /** The doc provider a served cache-hit satisfies (`exa`/`context7`), else undefined. */
100
+ function docSourceOf(tool) {
101
+ if (/exa/i.test(tool)) return "exa";
102
+ if (/context7/i.test(tool)) return "context7";
103
+ }
104
+ /**
105
+ * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
106
+ * else cap exa verbosity (allow + mutated input), else null to allow normally.
107
+ * Harnesses without input-mutation/cache support fall through to null.
108
+ */
109
+ function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
110
+ if (!isMcpTool(tool)) return null;
111
+ const cached = cacheLookup(dir, tool, queryOf(input), ttlMs, now);
112
+ if (cached) {
113
+ const served = denyWith(id, cached);
114
+ if (served) return {
115
+ stdout: served,
116
+ docSource: docSourceOf(tool)
117
+ };
118
+ }
119
+ const capped = capVerbosity(tool, input);
120
+ if (capped) {
121
+ const mutated = mutateWith(id, capped);
122
+ if (mutated) return { stdout: mutated };
123
+ }
124
+ return null;
125
+ }
126
+ /** Post-event: store the MCP/WebFetch response (extracted to markdown) in the cache. */
127
+ function mcpPostStore(tool, input, response, dir) {
128
+ if (!isMcpTool(tool)) return;
129
+ cacheStore(dir, tool, queryOf(input), extractText(response));
130
+ }
131
+ //#endregion
132
+ //#region src/runtime/normalize.ts
133
+ function str(v) {
134
+ return typeof v === "string" ? v : void 0;
135
+ }
136
+ /**
137
+ * Normalize a harness hook payload into a uniform event. Handles Cline's nested
138
+ * `preToolUse`/`postToolUse` shape and the top-level `tool_name`/`tool_input`
139
+ * shape used by Claude, Codex, Gemini, and Cursor.
140
+ */
141
+ function normalizeEvent(id, payload) {
142
+ if (id === "cline") {
143
+ const post = payload.postToolUse;
144
+ const node = post ?? payload.preToolUse ?? {};
145
+ const params = node.parameters ?? {};
146
+ return {
147
+ phase: post ? "post" : "pre",
148
+ tool: str(node.toolName) ?? "",
149
+ input: params,
150
+ sessionId: str(payload.taskId) ?? "",
151
+ filePath: str(params.path),
152
+ content: str(params.content),
153
+ command: str(params.command)
154
+ };
155
+ }
156
+ const event = str(payload.hook_event_name) ?? "";
157
+ const input = payload.tool_input ?? payload;
158
+ return {
159
+ phase: /post|after/i.test(event) ? "post" : "pre",
160
+ tool: str(payload.tool_name) ?? "",
161
+ input,
162
+ sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
163
+ filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
164
+ content: str(input.content) ?? str(input.new_string),
165
+ command: str(input.command) ?? str(payload.command),
166
+ agentType: str(payload.agent_type) ?? str(input.subagent_type)
167
+ };
168
+ }
169
+ //#endregion
170
+ //#region src/runtime/paths.ts
171
+ /** Path to a session's track file (under a per-tool base dir). */
172
+ function trackFile(sessionId, baseDir = join(tmpdir(), "fuse-harness")) {
173
+ return join(baseDir, `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
174
+ }
175
+ //#endregion
176
+ //#region src/runtime/record.ts
177
+ /** Apply an activity to a session's track and persist it (PostToolUse path). */
178
+ async function recordActivity(file, activity) {
179
+ const track = await loadTrack(file);
180
+ 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));
181
+ }
182
+ //#endregion
183
+ //#region src/runtime/respond.ts
184
+ /**
185
+ * Map a portable {@link Prompt} to a harness's native hook response. `block`
186
+ * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
187
+ * `ask` — they only honor deny.)
188
+ */
189
+ function respond(id, prompt) {
190
+ const message = formatPrompt(prompt);
191
+ const deny = prompt.kind === "block";
192
+ switch (id) {
193
+ case "claude-code":
194
+ case "codex": return JSON.stringify({ hookSpecificOutput: {
195
+ hookEventName: "PreToolUse",
196
+ permissionDecision: deny ? "deny" : "ask",
197
+ permissionDecisionReason: message
198
+ } });
199
+ case "gemini-cli": return JSON.stringify(deny ? {
200
+ decision: "deny",
201
+ reason: message
202
+ } : { hookSpecificOutput: { additionalContext: message } });
203
+ case "cursor": return JSON.stringify({
204
+ permission: deny ? "deny" : "ask",
205
+ continue: false,
206
+ userMessage: message,
207
+ agentMessage: message
208
+ });
209
+ case "cline": return JSON.stringify(deny ? {
210
+ cancel: true,
211
+ errorMessage: message
212
+ } : { contextModification: message });
213
+ default: return "";
214
+ }
215
+ }
216
+ //#endregion
217
+ //#region src/policy/design/state.ts
218
+ /** Minimum fuse-browser screenshots required before writing design-system.md, per mode. */
219
+ const MIN_SCREENSHOTS = {
220
+ full: 4,
221
+ page: 2,
222
+ component: 0
223
+ };
224
+ const stateFile = (cacheDir, agentId) => join(cacheDir, `.design-state-${agentId}.json`);
225
+ /** Load the design state for `agentId`, or null when absent/corrupt (fail-open). */
226
+ function loadDesignState(cacheDir, agentId) {
227
+ const path = stateFile(cacheDir, agentId);
228
+ if (!existsSync(path)) return null;
229
+ try {
230
+ return JSON.parse(readFileSync(path, "utf8"));
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+ /** Persist the design state under its agent id. */
236
+ function saveDesignState(cacheDir, state) {
237
+ mkdirSync(cacheDir, { recursive: true });
238
+ writeFileSync(stateFile(cacheDir, state.agentId), JSON.stringify(state, null, 2));
239
+ }
240
+ /** Build the initial state for a design agent starting a run. */
241
+ function initDesignState(agentId, mode, designSystemExists) {
242
+ return {
243
+ agentId,
244
+ mode,
245
+ currentPhase: 0,
246
+ phasesCompleted: [],
247
+ inspirationRead: false,
248
+ scrolledSinceNav: false,
249
+ screenshotsCount: 0,
250
+ designSystemExists,
251
+ designSystemValid: false,
252
+ geminiCalls: 0
253
+ };
254
+ }
255
+ /** Archive the active state file (timestamp suffix) and drop archives older than 7 days. */
256
+ function cleanupDesignStates(cacheDir, agentId, stamp, now) {
257
+ if (agentId) {
258
+ const src = stateFile(cacheDir, agentId);
259
+ if (existsSync(src)) renameSync(src, join(cacheDir, `.design-state-${agentId}-${stamp}.json`));
260
+ }
261
+ let entries;
262
+ try {
263
+ entries = readdirSync(cacheDir);
264
+ } catch {
265
+ return;
266
+ }
267
+ const cutoff = now - 7 * 864e5;
268
+ for (const name of entries) {
269
+ if (!name.startsWith(".design-state-")) continue;
270
+ const path = join(cacheDir, name);
271
+ try {
272
+ if (statSync(path).mtimeMs < cutoff) rmSync(path);
273
+ } catch {}
274
+ }
275
+ }
276
+ //#endregion
277
+ //#region src/policy/design/transitions.ts
278
+ /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
279
+ function detectMode(prompt, designSystemExists) {
280
+ const p = prompt.toLowerCase();
281
+ if ([
282
+ "component",
283
+ "composant",
284
+ "snippet"
285
+ ].some((k) => p.includes(k))) return "component";
286
+ return designSystemExists ? "page" : "full";
287
+ }
288
+ /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
289
+ function recordScreenshot(state, needed) {
290
+ const screenshotsCount = state.screenshotsCount + 1;
291
+ const next = {
292
+ ...state,
293
+ screenshotsCount
294
+ };
295
+ if (screenshotsCount >= needed && state.currentPhase < 2) {
296
+ next.currentPhase = 2;
297
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([
298
+ ...state.phasesCompleted,
299
+ "identity",
300
+ "research"
301
+ ])];
302
+ }
303
+ return next;
304
+ }
305
+ /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
306
+ function recordNavigate(state) {
307
+ return {
308
+ ...state,
309
+ scrolledSinceNav: false
310
+ };
311
+ }
312
+ /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
313
+ function recordScroll(state) {
314
+ return {
315
+ ...state,
316
+ scrolledSinceNav: true
317
+ };
318
+ }
319
+ /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
320
+ function recordValidDesignSystem(state) {
321
+ return {
322
+ ...state,
323
+ designSystemExists: true,
324
+ designSystemValid: true,
325
+ currentPhase: Math.max(state.currentPhase, 3),
326
+ phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
327
+ };
328
+ }
329
+ /**
330
+ * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
331
+ * allowed); reading the inspiration catalog satisfies the browse prerequisite.
332
+ */
333
+ function recordRead(state, filePath) {
334
+ const next = { ...state };
335
+ if (filePath.includes("identity-system")) {
336
+ next.currentPhase = Math.max(state.currentPhase, 1);
337
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
338
+ }
339
+ if (filePath.includes("design-inspiration")) next.inspirationRead = true;
340
+ return next;
341
+ }
342
+ //#endregion
343
+ //#region src/policy/design/flag.ts
344
+ const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
345
+ /** The active design agent id (the flag), or "" when no design agent is running. */
346
+ function activeDesignAgent(cacheDir) {
347
+ const path = flagPath(cacheDir);
348
+ if (!existsSync(path)) return "";
349
+ try {
350
+ return readFileSync(path, "utf8").trim();
351
+ } catch {
352
+ return "";
353
+ }
354
+ }
355
+ /** Mark a design agent active (writes its id to the flag file). */
356
+ function setActiveDesignAgent(cacheDir, agentId) {
357
+ mkdirSync(cacheDir, { recursive: true });
358
+ writeFileSync(flagPath(cacheDir), agentId);
359
+ }
360
+ /** Clear the active-design-agent flag. */
361
+ function clearActiveDesignAgent(cacheDir) {
362
+ try {
363
+ rmSync(flagPath(cacheDir));
364
+ } catch {}
365
+ }
366
+ //#endregion
367
+ //#region src/policy/design/content-checks.ts
368
+ /** Accessibility warnings: icon buttons need aria-label, images need alt. */
369
+ function checkAccessibility(content) {
370
+ const w = [];
371
+ if (!/<(button|a|input|img)/.test(content)) return w;
372
+ if (/<button[^>]*>/.test(content) && !/aria-label|aria-labelledby/.test(content) && /<button[^>]*>[^<]*<[^>]*Icon/.test(content)) w.push("Accessibility: icon buttons need an aria-label.");
373
+ for (const m of content.matchAll(/<img[^>]*?>/g)) if (!m[0].includes("alt=")) {
374
+ w.push("Accessibility: images need an alt attribute.");
375
+ break;
376
+ }
377
+ return w;
378
+ }
379
+ /** Anti-pattern warnings: colored left borders, AI-slop gradients, emoji-as-icons. */
380
+ function checkPatterns(content) {
381
+ const w = [];
382
+ if (/border-l-[0-9]+ border-l-(blue|green|red|purple)/.test(content)) w.push("Design: avoid colored left borders — use shadow/gradient.");
383
+ 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.");
384
+ if (/>[^\x00-\x7F]+</.test(content)) w.push("Design: avoid emojis as icons — use a real icon set.");
385
+ return w;
386
+ }
387
+ /** Forbidden-font warnings (CSS font-family + Google Fonts import). */
388
+ function checkFonts(content) {
389
+ const w = [];
390
+ 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.");
391
+ if (/@import.*fonts\.googleapis.*family=(Roboto|Inter)\b/.test(content)) w.push("Font: Google Fonts import for a forbidden family.");
392
+ return w;
393
+ }
394
+ /** Hard-coded-color warnings (hex in className or inline style). */
395
+ function checkColors(content) {
396
+ const w = [];
397
+ if (/className="[^"]*#[0-9a-fA-F]{3,8}[^"]*"/.test(content)) w.push("Color: hard-coded hex in className — use CSS variables.");
398
+ 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-*).");
399
+ return w;
400
+ }
401
+ /** Run all design content checks → non-blocking warnings (empty = clean). */
402
+ function runDesignChecks(content) {
403
+ return [
404
+ ...checkAccessibility(content),
405
+ ...checkPatterns(content),
406
+ ...checkFonts(content),
407
+ ...checkColors(content)
408
+ ];
409
+ }
410
+ //#endregion
411
+ //#region src/policy/design/gates.ts
412
+ const ALLOWED_WRITE = /\.(html|css|md|json)$/;
413
+ const EXEMPT_DIRS = [
414
+ "node_modules/",
415
+ "dist/",
416
+ "build/",
417
+ ".claude/"
418
+ ];
419
+ const FORBIDDEN_FONTS = [
420
+ "Inter",
421
+ "Roboto",
422
+ "Arial",
423
+ "Open Sans"
424
+ ];
425
+ const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
426
+ const KNOWN_DOMAINS = [
427
+ "framer.website",
428
+ "webflow.io",
429
+ "awwwards.com",
430
+ "godly.website",
431
+ "lapa.ninja",
432
+ "onepagelove.com",
433
+ "saasframe.io",
434
+ "bestwebsite.gallery",
435
+ "landingfolio.com"
436
+ ];
437
+ const deny = (reason) => ({
438
+ kind: "block",
439
+ title: "Design pipeline",
440
+ reason,
441
+ actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"]
442
+ });
443
+ /** Block the design agent from writing anything but .html/.css/.md/.json. */
444
+ function htmlCssOnlyGate(filePath) {
445
+ if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
446
+ return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
447
+ }
448
+ /** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
449
+ function stateFileGate(filePath) {
450
+ return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are read-only; the hooks update them as you progress.") : null;
451
+ }
452
+ /** Gate writing design-system.md: requires phase ≥ 2 and the per-mode screenshot quota. */
453
+ function designSystemWriteGate(filePath, state) {
454
+ if (!filePath.endsWith("design-system.md")) return null;
455
+ if (state.currentPhase < 2) return deny(`BLOCKED: cannot write design-system.md at phase ${state.currentPhase}. Read identity + inspiration, then browse & screenshot first.`);
456
+ const needed = MIN_SCREENSHOTS[state.mode];
457
+ if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
458
+ return null;
459
+ }
460
+ /** Return the requirements missing from a design-system.md (empty = valid). */
461
+ function validateDesignSystem(content) {
462
+ const missing = [];
463
+ if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
464
+ if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
465
+ if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
466
+ if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
467
+ return missing;
468
+ }
469
+ /** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
470
+ function geminiCreateGate(state) {
471
+ if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
472
+ if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
473
+ return null;
474
+ }
475
+ /** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
476
+ function browserNavigateGate(state, url) {
477
+ if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
478
+ if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
479
+ if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
480
+ return null;
481
+ }
482
+ /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
483
+ function screenshotScrollGate(state) {
484
+ return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
485
+ }
486
+ /** The Gemini design gates are OPT-IN: off unless `FUSE_DESIGN_GEMINI` is `1`/`true`. */
487
+ function geminiEnabled() {
488
+ const v = process.env.FUSE_DESIGN_GEMINI;
489
+ return v === "1" || v === "true";
490
+ }
491
+ //#endregion
492
+ //#region src/runtime/design.ts
493
+ const NAV = "mcp__fuse-browser__browser_navigate";
494
+ const SHOT = "mcp__fuse-browser__browser_screenshot";
495
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
496
+ const GEMINI = "mcp__gemini-design__create_frontend";
497
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
498
+ function findDesignSystem(cwd) {
499
+ let dir = cwd;
500
+ for (let i = 0; i < 6; i++) {
501
+ const p = join(dir, "design-system.md");
502
+ if (existsSync(p)) try {
503
+ return readFileSync(p, "utf8");
504
+ } catch {
505
+ return "";
506
+ }
507
+ const parent = dirname(dir);
508
+ if (parent === dir) break;
509
+ dir = parent;
510
+ }
511
+ return "";
512
+ }
513
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
514
+ function recordPost(event, cacheDir, state) {
515
+ if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
516
+ else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state));
517
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
518
+ else if (event.tool === GEMINI) saveDesignState(cacheDir, {
519
+ ...state,
520
+ geminiCalls: state.geminiCalls + 1
521
+ });
522
+ else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
523
+ else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
524
+ }
525
+ /**
526
+ * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
527
+ * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
528
+ */
529
+ function designGate(payload, event, cacheDir, cwd) {
530
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
531
+ const active = activeDesignAgent(cacheDir);
532
+ if (active && agentId && agentId !== active) return null;
533
+ const id = active || agentId;
534
+ if (!id) return null;
535
+ const state = loadDesignState(cacheDir, id);
536
+ if (!state) return null;
537
+ if (event.phase === "post") {
538
+ recordPost(event, cacheDir, state);
539
+ if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
540
+ const warnings = runDesignChecks(event.content ?? "");
541
+ if (warnings.length) return {
542
+ kind: "inform",
543
+ title: "Design review",
544
+ reason: warnings.join(" "),
545
+ actions: []
546
+ };
547
+ }
548
+ return null;
549
+ }
550
+ if (event.tool === "Write" || event.tool === "Edit") {
551
+ const fp = event.filePath ?? "";
552
+ const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
553
+ if (base) return base;
554
+ if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
555
+ kind: "block",
556
+ title: "Design pipeline",
557
+ reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
558
+ actions: ["Call mcp__gemini-design__create_frontend first"]
559
+ };
560
+ return null;
561
+ }
562
+ if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
563
+ if (event.tool === SHOT) return screenshotScrollGate(state);
564
+ if (event.tool === GEMINI) {
565
+ if (!geminiEnabled()) return null;
566
+ const block = geminiCreateGate(state);
567
+ if (block) return block;
568
+ const missing = validateDesignSystem(findDesignSystem(cwd));
569
+ if (missing.length) return {
570
+ kind: "block",
571
+ title: "Design pipeline",
572
+ reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
573
+ actions: ["Fix design-system.md, then retry create_frontend"]
574
+ };
575
+ saveDesignState(cacheDir, recordValidDesignSystem(state));
576
+ }
577
+ return null;
578
+ }
579
+ //#endregion
580
+ //#region src/runtime/design-lifecycle.ts
581
+ /**
582
+ * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
583
+ * raise the active flag on start, archive/cleanup + clear the flag on stop.
584
+ * Returns true when it handled the event (caller should respond and stop).
585
+ */
586
+ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
587
+ const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
588
+ if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
589
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
590
+ if (event === "SubagentStart") {
591
+ if (!agentId) return false;
592
+ const dsExists = existsSync(join(cwd, "design-system.md"));
593
+ saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
594
+ setActiveDesignAgent(cacheDir, agentId);
595
+ return true;
596
+ }
597
+ if (event === "SubagentStop") {
598
+ cleanupDesignStates(cacheDir, agentId, stamp, now);
599
+ clearActiveDesignAgent(cacheDir);
600
+ return true;
601
+ }
602
+ return false;
603
+ }
604
+ //#endregion
605
+ //#region src/runtime/inject-context.ts
606
+ /**
607
+ * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
608
+ * preamble as a Claude `additionalContext` response, or "" when nothing to emit.
609
+ * @param prompt - The raw user prompt.
610
+ * @param cwd - Project root (for project-type detection).
611
+ * @returns The native hook stdout (possibly empty).
612
+ */
613
+ function promptSubmitContext(prompt, cwd) {
614
+ const ctx = buildClaudeMdContext(prompt, cwd);
615
+ return ctx ? contextResponse("UserPromptSubmit", ctx) : "";
616
+ }
617
+ /**
618
+ * PreToolUse Task context injection: render the APEX sub-agent context as a
619
+ * Claude `additionalContext` response when `.claude/apex/` exists, else "".
620
+ * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
621
+ * @returns The native hook stdout (possibly empty).
622
+ */
623
+ function taskContext(cwd) {
624
+ const ctx = buildApexTaskInjection(process.env.CLAUDE_PROJECT_DIR ?? cwd);
625
+ return ctx ? contextResponse("PreToolUse", ctx) : "";
626
+ }
627
+ //#endregion
628
+ //#region src/runtime/home-state.ts
629
+ /** Home `~/.claude` dir (single source for every home-based hook path). */
630
+ function claudeHome(home = homedir()) {
631
+ return join(home, ".claude");
632
+ }
633
+ /** `~/.claude/fusengine-cache` base dir for legacy session/cache state. */
634
+ function fusengineCache(home = homedir()) {
635
+ return join(claudeHome(home), "fusengine-cache");
636
+ }
637
+ /** `~/.claude/fusengine-cache/sessions` — per-session JSON state dir. */
638
+ function sessionsDir(home = homedir()) {
639
+ return join(fusengineCache(home), "sessions");
640
+ }
641
+ const SID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
642
+ /** Validate a session id (1-128 url-safe chars); null when invalid. */
643
+ function sanitizeSessionId(sid) {
644
+ const s = String(sid ?? "").trim();
645
+ return SID_RE.test(s) ? s : null;
646
+ }
647
+ /** Unified per-session state file path: `sessions/session-<sid>.json`. */
648
+ function sessionStatePath(sid, home = homedir()) {
649
+ return join(sessionsDir(home), `session-${sid}.json`);
650
+ }
651
+ /** Load a session-state dict, or `{}` when missing/corrupt (mirrors Python). */
652
+ function loadSessionState(sid, home = homedir()) {
653
+ const path = sessionStatePath(sid, home);
654
+ try {
655
+ if (!existsSync(path)) return {};
656
+ const data = JSON.parse(readFileSync(path, "utf-8"));
657
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
658
+ } catch {
659
+ return {};
660
+ }
661
+ }
662
+ /** Atomically persist a session-state dict (0o600 via atomicWrite, indent 2). */
663
+ function saveSessionState(sid, state, home = homedir()) {
664
+ mkdirSync(sessionsDir(home), {
665
+ recursive: true,
666
+ mode: 448
667
+ });
668
+ atomicWrite(sessionStatePath(sid, home), JSON.stringify(state, null, 2));
669
+ }
670
+ //#endregion
671
+ //#region src/runtime/dev-context.ts
672
+ /** Run a git subcommand in `cwd`, returning trimmed stdout or "" on error. */
673
+ function git(cwd, args) {
674
+ try {
675
+ return execFileSync("git", args, {
676
+ cwd,
677
+ encoding: "utf-8",
678
+ stdio: "pipe",
679
+ timeout: 5e3
680
+ }).trim();
681
+ } catch {
682
+ return "";
683
+ }
684
+ }
685
+ /** Build the git portion of the dev context (branch + up to 5 changed files). */
686
+ function gitContext(cwd) {
687
+ if (!existsSync(join(cwd, ".git"))) return [];
688
+ const parts = [`Git branch: ${git(cwd, ["branch", "--show-current"]) || "unknown"}`];
689
+ const status = git(cwd, ["status", "--porcelain"]);
690
+ if (status) parts.push("Modified files:\n" + status.split("\n").slice(0, 5).join("\n"));
691
+ return parts;
692
+ }
693
+ /** Build the project-type portion (mirrors load-dev-context.py exactly). */
694
+ function projectContext(cwd) {
695
+ const parts = [];
696
+ const has = (f) => existsSync(join(cwd, f));
697
+ if ([
698
+ "next.config.js",
699
+ "next.config.ts",
700
+ "next.config.mjs"
701
+ ].some(has)) parts.push("Project: Next.js");
702
+ else if (has("package.json")) parts.push("Project: Node.js");
703
+ if (has("composer.json") && has("artisan")) parts.push("Project: Laravel");
704
+ if (has("Package.swift")) parts.push("Project: Swift");
705
+ return parts;
706
+ }
707
+ /**
708
+ * Build the SessionStart dev-context block (git + project type), or "" when
709
+ * nothing applies. Ports `core-guards/scripts/session-start/load-dev-context.py`.
710
+ * @param cwd - Project root to inspect.
711
+ * @returns The joined additionalContext text (possibly empty).
712
+ */
713
+ function devContext(cwd) {
714
+ return [...gitContext(cwd), ...projectContext(cwd)].join("\n");
715
+ }
716
+ //#endregion
717
+ //#region src/runtime/fs-cleanup.ts
718
+ /** Age of a file in seconds (now - mtime). Infinity when unstat-able. */
719
+ function ageSec(path, now) {
720
+ try {
721
+ return (now - statSync(path).mtimeMs) / 1e3;
722
+ } catch {
723
+ return Infinity;
724
+ }
725
+ }
726
+ /** Remove files directly under `dir` matching `test` older than `maxAgeSec`. */
727
+ function removeOldFiles(dir, test, maxAgeSec, now = Date.now()) {
728
+ if (!existsSync(dir)) return;
729
+ for (const name of readdirSync(dir)) {
730
+ const path = join(dir, name);
731
+ if (test(name) && ageSec(path, now) > maxAgeSec) try {
732
+ rmSync(path, { force: true });
733
+ } catch {}
734
+ }
735
+ }
736
+ /** Trim `file` to its last `keepLines` lines when it exceeds `maxBytes`. */
737
+ function trimLogFile(file, maxBytes, keepLines) {
738
+ try {
739
+ if (!existsSync(file) || statSync(file).size <= maxBytes) return;
740
+ writeFileSync(file, readFileSync(file, "utf-8").split("\n").slice(-keepLines).join("\n"), "utf-8");
741
+ } catch {}
742
+ }
743
+ /** Recursively purge files under `root/<top>` older than `ttls[top]` seconds. */
744
+ function purgeTtlTree(root, ttls, now = Date.now()) {
745
+ if (!existsSync(root)) return;
746
+ for (const [top, ttlSec] of Object.entries(ttls)) walkPurge(join(root, top), ttlSec, now);
747
+ }
748
+ function walkPurge(dir, ttlSec, now) {
749
+ if (!existsSync(dir)) return;
750
+ for (const name of readdirSync(dir)) {
751
+ const path = join(dir, name);
752
+ let isDir = false;
753
+ try {
754
+ isDir = statSync(path).isDirectory();
755
+ } catch {
756
+ continue;
757
+ }
758
+ if (isDir) {
759
+ walkPurge(path, ttlSec, now);
760
+ continue;
761
+ }
762
+ if (ageSec(path, now) > ttlSec) try {
763
+ rmSync(path, { force: true });
764
+ } catch {}
765
+ }
766
+ }
767
+ /** Bottom-up removal of empty subdirs under each `root/<top>` (best effort). */
768
+ function pruneEmptyDirs(root, tops) {
769
+ for (const top of tops) {
770
+ const sub = join(root, top);
771
+ if (!existsSync(sub)) continue;
772
+ pruneEmpty(sub, sub);
773
+ }
774
+ }
775
+ function pruneEmpty(dir, stopAt) {
776
+ for (const name of existsSync(dir) ? readdirSync(dir) : []) {
777
+ const path = join(dir, name);
778
+ try {
779
+ if (statSync(path).isDirectory()) pruneEmpty(path, stopAt);
780
+ } catch {}
781
+ }
782
+ if (dir !== stopAt && relative(stopAt, dir).split(sep)[0] !== "..") try {
783
+ rmdirSync(dir);
784
+ } catch {}
785
+ }
786
+ //#endregion
787
+ //#region src/runtime/lifecycle/session-start.ts
788
+ /** TTLs (seconds) for purgeable fusengine-cache subtrees (cleanup-old-caches.py). */
789
+ const PURGEABLE = {
790
+ sessions: 48 * 3600,
791
+ webfetch: 24 * 3600,
792
+ doc: 48 * 3600,
793
+ explore: 48 * 3600
794
+ };
795
+ /** Read `~/.claude/CLAUDE.md`, or "" when missing/unreadable. */
796
+ function claudeMd(home) {
797
+ const path = join(claudeHome(home), "CLAUDE.md");
798
+ try {
799
+ return existsSync(path) ? readFileSync(path, "utf-8") : "";
800
+ } catch {
801
+ return "";
802
+ }
803
+ }
804
+ /** Run the legacy SessionStart cleanups (stale states, caches, log trim). */
805
+ function runSessionStartCleanups(home = homedir(), now = Date.now()) {
806
+ const base = fusengineCache(home);
807
+ removeOldFiles(sessionsDir(home), (n) => n.startsWith("session-") && n.endsWith(".json"), 86400, now);
808
+ const user = process.env.USER ?? "unknown";
809
+ removeOldFiles(base, (n) => n === `changes-${user}.json`, 21600, now);
810
+ trimLogFile(join(claudeHome(home), "logs", "hooks.log"), 10485760, 5e3);
811
+ removeOldFiles(join(claudeHome(home), "logs", "00-apex"), (n) => n.startsWith("ref-cache-") && n.endsWith(".json"), 86400, now);
812
+ purgeTtlTree(base, PURGEABLE, now);
813
+ pruneEmptyDirs(base, Object.keys(PURGEABLE));
814
+ }
815
+ /**
816
+ * Handle core-guards SessionStart: inject CLAUDE.md + dev context as
817
+ * `additionalContext`, then run the cache/state cleanups. Ports the four
818
+ * `session-start/*.py` scripts into one harness call.
819
+ * @param cwd - Project root for dev-context detection.
820
+ * @param home - Home dir (defaults to `~`).
821
+ * @param now - Clock for TTL cleanup (defaults to `Date.now()`).
822
+ * @returns The native hook stdout (possibly empty).
823
+ */
824
+ function sessionStartCore(cwd, home = homedir(), now = Date.now()) {
825
+ const md = claudeMd(home);
826
+ const dev = devContext(cwd);
827
+ runSessionStartCleanups(home, now);
828
+ const ctx = [md, dev].filter(Boolean).join("\n");
829
+ return ctx ? contextResponse("SessionStart", ctx) : "";
830
+ }
831
+ //#endregion
832
+ //#region src/runtime/lifecycle/inject-rules.ts
833
+ /** Read & concatenate all `*.md` files (sorted) under `rulesDir`. */
834
+ function readRules(rulesDir) {
835
+ if (!existsSync(rulesDir)) return "";
836
+ let names;
837
+ try {
838
+ names = readdirSync(rulesDir).filter((n) => n.endsWith(".md")).sort();
839
+ } catch {
840
+ return "";
841
+ }
842
+ const parts = [];
843
+ for (const name of names) try {
844
+ parts.push(readFileSync(join(rulesDir, name), "utf-8"));
845
+ } catch {}
846
+ return parts.join("\n\n");
847
+ }
848
+ /**
849
+ * Build the rules injection for claude-rules (SessionStart + UserPromptSubmit):
850
+ * read `<pluginRoot>/rules/*.md` and emit as `additionalContext`, or "" when no
851
+ * rules. Ports `claude-rules/scripts/inject-rules.py` (which always tags the
852
+ * output `hookEventName: "SessionStart"`, even on UserPromptSubmit).
853
+ * @param pluginRoot - `CLAUDE_PLUGIN_ROOT` of the claude-rules plugin.
854
+ * @returns The native hook stdout (possibly empty).
855
+ */
856
+ function injectRules(pluginRoot) {
857
+ const content = readRules(join(pluginRoot, "rules"));
858
+ return content ? contextResponse("SessionStart", content) : "";
859
+ }
860
+ //#endregion
861
+ //#region src/runtime/lifecycle/solid-detect.ts
862
+ /** Ordered detection table (mirrors solid/scripts/detect-project.py). */
863
+ const CHECKS = [
864
+ {
865
+ file: "package.json",
866
+ grep: "next",
867
+ profile: {
868
+ type: "nextjs",
869
+ limit: 150,
870
+ ifaceDir: "modules/cores/interfaces"
871
+ }
872
+ },
873
+ {
874
+ file: "composer.json",
875
+ grep: "laravel",
876
+ profile: {
877
+ type: "laravel",
878
+ limit: 100,
879
+ ifaceDir: "app/Contracts"
880
+ }
881
+ },
882
+ {
883
+ file: "go.mod",
884
+ grep: null,
885
+ profile: {
886
+ type: "go",
887
+ limit: 100,
888
+ ifaceDir: "internal/interfaces"
889
+ }
890
+ },
891
+ {
892
+ file: "Cargo.toml",
893
+ grep: null,
894
+ profile: {
895
+ type: "rust",
896
+ limit: 100,
897
+ ifaceDir: "src/traits"
898
+ }
899
+ },
900
+ {
901
+ file: "pyproject.toml",
902
+ grep: null,
903
+ profile: {
904
+ type: "python",
905
+ limit: 100,
906
+ ifaceDir: "src/interfaces"
907
+ }
908
+ },
909
+ {
910
+ file: "requirements.txt",
911
+ grep: null,
912
+ profile: {
913
+ type: "python",
914
+ limit: 100,
915
+ ifaceDir: "src/interfaces"
916
+ }
917
+ }
918
+ ];
919
+ /** Detect the SOLID profile for `projectDir`, defaulting to `unknown`. */
920
+ function detectSolidProfile(projectDir) {
921
+ for (const { file, grep, profile } of CHECKS) {
922
+ const path = join(projectDir, file);
923
+ if (!existsSync(path)) continue;
924
+ if (grep !== null) try {
925
+ if (!readFileSync(path, "utf-8").includes(grep)) continue;
926
+ } catch {
927
+ continue;
928
+ }
929
+ return profile;
930
+ }
931
+ if (existsSync(join(projectDir, "Package.swift"))) return {
932
+ type: "swift",
933
+ limit: 150,
934
+ ifaceDir: "Protocols"
935
+ };
936
+ try {
937
+ if (readdirSync(projectDir).some((e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace"))) return {
938
+ type: "swift",
939
+ limit: 150,
940
+ ifaceDir: "Protocols"
941
+ };
942
+ } catch {}
943
+ return {
944
+ type: "unknown",
945
+ limit: 100,
946
+ ifaceDir: ""
947
+ };
948
+ }
949
+ /**
950
+ * Handle solid SessionStart: detect the profile, append SOLID_* exports to
951
+ * `CLAUDE_ENV_FILE`, and return the `SOLID: …` stdout line (or "" for unknown).
952
+ * Ports `solid/scripts/detect-project.py`.
953
+ * @param env - Environment (defaults to `process.env`).
954
+ * @returns The plain-text stdout line (possibly empty).
955
+ */
956
+ function solidDetectStart(env = process.env) {
957
+ const profile = detectSolidProfile(env.CLAUDE_PROJECT_DIR ?? ".");
958
+ const envFile = env.CLAUDE_ENV_FILE ?? "";
959
+ if (envFile) try {
960
+ appendFileSync(envFile, `export SOLID_PROJECT_TYPE=${profile.type}\nexport SOLID_FILE_LIMIT=${profile.limit}\nexport SOLID_INTERFACE_DIR=${profile.ifaceDir}\n`, "utf-8");
961
+ } catch {}
962
+ return profile.type !== "unknown" ? `SOLID: ${profile.type} project (max ${profile.limit} lines)` : "";
963
+ }
964
+ //#endregion
965
+ //#region src/runtime/lifecycle/subagent-cache.ts
966
+ const DEFAULT_TTL_MIN = 30;
967
+ /** Resolve cache TTL (minutes) from `FUSENGINE_CACHE_TTL_MIN` or default. */
968
+ function ttlMinutes(env) {
969
+ const raw = (env.FUSENGINE_CACHE_TTL_MIN ?? "").trim();
970
+ const val = Number.parseInt(raw, 10);
971
+ return Number.isFinite(val) && val > 0 ? val : DEFAULT_TTL_MIN;
972
+ }
973
+ /** True when ISO ts `YYYY-MM-DDTHH:MM:SSZ` is within `ttlMin` minutes of now. */
974
+ function isFresh(ts, ttlMin, now) {
975
+ const parsed = Date.parse(ts);
976
+ if (Number.isNaN(parsed)) return false;
977
+ const ageSec = (now - parsed) / 1e3;
978
+ return ageSec >= 0 && ageSec <= ttlMin * 60;
979
+ }
980
+ /** Sanitize + truncate a cell value (replace `|`/newline, ellipsize). */
981
+ function trunc(text, limit) {
982
+ const t = String(text ?? "").replace(/\|/g, "/").replace(/\n/g, " ");
983
+ return t.length <= limit ? t : t.slice(0, limit - 3) + "...";
984
+ }
985
+ /** Render fresh cache entries as the markdown injection block. */
986
+ function render(entries) {
987
+ const lines = [
988
+ "# MCP Cache disponible cette session",
989
+ "Avant de lancer mcp__context7/exa, verifie si deja cached.",
990
+ "Lis le fichier .md via Read pour recuperer le resultat.",
991
+ "APEX: Read sur cache MCP compte comme research-expert satisfait.",
992
+ "",
993
+ "| Tool | Query | File |",
994
+ "| --- | --- | --- |"
995
+ ];
996
+ for (const e of entries) lines.push(`| ${trunc(e.tool, 40)} | ${trunc(e.query, 60)} | ${trunc(e.file, 50)} |`);
997
+ return lines.join("\n");
998
+ }
999
+ /**
1000
+ * Handle SubagentStart: surface fresh MCP cache entries for the session as
1001
+ * `additionalContext`. Ports `subagent-start/inject-context-cache.py`.
1002
+ * @param sessionIdRaw - Raw session id from the payload.
1003
+ * @param home - Home dir (defaults to `~`).
1004
+ * @param env - Environment (defaults to `process.env`).
1005
+ * @param now - Clock (defaults to `Date.now()`).
1006
+ * @returns The native hook stdout (possibly empty).
1007
+ */
1008
+ function subagentCacheContext(sessionIdRaw, home = homedir(), env = process.env, now = Date.now()) {
1009
+ const sid = sanitizeSessionId(sessionIdRaw === "" || sessionIdRaw == null ? "unknown" : sessionIdRaw);
1010
+ if (!sid) return "";
1011
+ const index = loadIndex(join(sessionsDir(home), sid, "context", "index.json"));
1012
+ if (index.length === 0) return "";
1013
+ const fresh = index.filter((e) => isFresh(String(e.ts ?? ""), ttlMinutes(env), now));
1014
+ return fresh.length ? contextResponse("SubagentStart", render(fresh)) : "";
1015
+ }
1016
+ //#endregion
1017
+ //#region src/runtime/lifecycle/agent-memory.ts
1018
+ /** `~/.claude/memory/agents` — agent completion history dir. */
1019
+ function memoryDir(home) {
1020
+ return join(home, ".claude", "memory", "agents");
1021
+ }
1022
+ const SKIP_AGENTS = /(sniper|sniper-faster|explore-codebase|research-expert|claude-code-guide|Explore|Plan)/;
1023
+ /** Append the agent completion record to `agent-history.jsonl` (best effort). */
1024
+ function recordHistory(home, agentId, agentType, ts) {
1025
+ const dir = memoryDir(home);
1026
+ try {
1027
+ mkdirSync(dir, { recursive: true });
1028
+ appendFileSync(join(dir, "agent-history.jsonl"), JSON.stringify({
1029
+ agentId,
1030
+ agentType,
1031
+ completedAt: ts
1032
+ }) + "\n", "utf-8");
1033
+ } catch {}
1034
+ }
1035
+ /**
1036
+ * Handle SubagentStop: append the completion to agent-history.jsonl and, for a
1037
+ * non-skipped agent that touched code, emit the sniper reminder + reset the
1038
+ * counter. Ports `subagent-stop/track-agent-memory.py`.
1039
+ * @param data - The raw hook payload.
1040
+ * @param home - Home dir (defaults to `~`).
1041
+ * @param now - Clock (defaults to `Date.now()`).
1042
+ * @returns The native hook stdout (always a JSON message).
1043
+ */
1044
+ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
1045
+ mkdirSync(sessionsDir(home), { recursive: true });
1046
+ const agentType = String(data.agent_type ?? data.subagent_type ?? "unknown");
1047
+ const sessionId = String(data.session_id ?? "unknown");
1048
+ const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1049
+ recordHistory(home, String(data.agent_id ?? "unknown"), agentType, ts);
1050
+ if (SKIP_AGENTS.test(agentType)) return JSON.stringify({ message: `Agent ${agentType} completed` });
1051
+ const stateFile = join(sessionsDir(home), `session-${sessionId}-changes.json`);
1052
+ if (existsSync(stateFile)) try {
1053
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
1054
+ const count = state.cumulativeCodeFiles ?? 0;
1055
+ if (count > 0) {
1056
+ const files = (state.modifiedFiles ?? []).join(", ");
1057
+ writeFileSync(stateFile, JSON.stringify({
1058
+ ...state,
1059
+ cumulativeCodeFiles: 0
1060
+ }), "utf-8");
1061
+ return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${count} code file(s): ${files}. Run sniper agent now.`);
1062
+ }
1063
+ } catch {}
1064
+ return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
1065
+ }
1066
+ //#endregion
1067
+ //#region src/runtime/lifecycle/teammate-idle.ts
1068
+ /**
1069
+ * Handle TeammateIdle: when the teammate's session-changes file shows code was
1070
+ * modified, suggest sniper validation as `additionalContext`. Ports
1071
+ * `teammate-idle/validate-teammate-output.py`.
1072
+ * @param data - The raw hook payload.
1073
+ * @param home - Home dir (defaults to `~`).
1074
+ * @returns The native hook stdout (possibly empty).
1075
+ */
1076
+ function validateTeammateOutput(data, home = homedir()) {
1077
+ const teammate = String(data.teammate_name ?? "unknown");
1078
+ const sessionId = String(data.session_id ?? "unknown");
1079
+ const stateFile = join(sessionsDir(home), `session-${sessionId}-changes.json`);
1080
+ if (!existsSync(stateFile)) return "";
1081
+ try {
1082
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
1083
+ const count = state.cumulativeCodeFiles ?? 0;
1084
+ 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.`);
1085
+ } catch {}
1086
+ return "";
1087
+ }
1088
+ //#endregion
1089
+ //#region src/runtime/lifecycle/tool-failure.ts
1090
+ /**
1091
+ * Handle PostToolUseFailure: append a `TOOL_FAILURE` line to
1092
+ * `~/.claude/logs/tool-failures.log`, skipping user interrupts. Ports
1093
+ * `post-tool-use/log-tool-failure.py`. No stdout (logging only).
1094
+ * @param data - The raw hook payload.
1095
+ * @param home - Home dir (defaults to `~`).
1096
+ * @param now - Clock (defaults to `Date.now()`).
1097
+ */
1098
+ function logToolFailure(data, home = homedir(), now = Date.now()) {
1099
+ if (data.is_interrupt === true) return;
1100
+ const tool = String(data.tool_name ?? "unknown");
1101
+ const error = String(data.error ?? "unknown error");
1102
+ const sessionId = String(data.session_id ?? "unknown");
1103
+ const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1104
+ const dir = join(home, ".claude", "logs");
1105
+ try {
1106
+ mkdirSync(dir, { recursive: true });
1107
+ appendFileSync(join(dir, "tool-failures.log"), `[${ts}] TOOL_FAILURE session=${sessionId} tool=${tool} error=${error}\n`, "utf-8");
1108
+ } catch {}
1109
+ }
1110
+ //#endregion
1111
+ //#region src/runtime/lifecycle/pre-compact.ts
1112
+ /** Two-digit zero-pad. */
1113
+ function pad(n) {
1114
+ return String(n).padStart(2, "0");
1115
+ }
1116
+ /** Compact local timestamp `YYYYMMDD-HHMMSS` (mirrors Python strftime). */
1117
+ function stamp(now) {
1118
+ const d = new Date(now);
1119
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
1120
+ }
1121
+ /**
1122
+ * Handle PreCompact: back up `.claude/apex/task.json` to `backups/`, keep only
1123
+ * the 5 newest, and emit a confirmation. Ports `pre-compact/save-apex-state.py`.
1124
+ * @param cwd - Project root.
1125
+ * @param now - Clock (defaults to `Date.now()`).
1126
+ * @returns The native hook stdout (possibly empty when no task.json).
1127
+ */
1128
+ function saveApexState(cwd, now = Date.now()) {
1129
+ const apexDir = join(cwd, ".claude", "apex");
1130
+ const stateFile = join(apexDir, "task.json");
1131
+ if (!existsSync(stateFile)) return "";
1132
+ const backupDir = join(apexDir, "backups");
1133
+ mkdirSync(backupDir, { recursive: true });
1134
+ copyFileSync(stateFile, join(backupDir, `task-${stamp(now)}.json`));
1135
+ const backups = readdirSync(backupDir).filter((n) => n.startsWith("task-") && n.endsWith(".json")).sort().reverse();
1136
+ for (const old of backups.slice(5)) try {
1137
+ rmSync(join(backupDir, old), { force: true });
1138
+ } catch {}
1139
+ return JSON.stringify({ additionalContext: "APEX state saved before compaction. Previous task state preserved in .claude/apex/backups/" });
1140
+ }
1141
+ //#endregion
1142
+ //#region src/runtime/lifecycle/session-end.ts
1143
+ /**
1144
+ * Handle SessionEnd: remove stale `*.tmp` (>1h) under `session-tmp/` and stale
1145
+ * legacy `claude_solid_reads_*` / `claude_session_changes_*` files (>2h) under
1146
+ * `fusengine-cache`. Ports `session-end/cleanup-session.py`. No stdout.
1147
+ * @param home - Home dir (defaults to `~`).
1148
+ * @param now - Clock (defaults to `Date.now()`).
1149
+ */
1150
+ function cleanupSession(home = homedir(), now = Date.now()) {
1151
+ const base = fusengineCache(home);
1152
+ removeOldFiles(join(base, "session-tmp"), (n) => n.endsWith(".tmp"), 3600, now);
1153
+ removeOldFiles(base, (n) => n.startsWith("claude_solid_reads_") || n.startsWith("claude_session_changes_"), 7200, now);
1154
+ }
1155
+ //#endregion
1156
+ //#region src/runtime/lifecycle/instructions-loaded.ts
1157
+ /**
1158
+ * Handle InstructionsLoaded: append `load_reason | memory_type | file_path` to
1159
+ * the per-session debug log. Ports `instructions-loaded/validate-rules-loaded.py`.
1160
+ * No stdout (logging only; InstructionsLoaded has no decision control).
1161
+ * @param data - The raw hook payload.
1162
+ * @param home - Home dir (defaults to `~`).
1163
+ */
1164
+ function validateRulesLoaded(data, home = homedir()) {
1165
+ const filePath = String(data.file_path ?? "");
1166
+ const loadReason = String(data.load_reason ?? "");
1167
+ const memoryType = String(data.memory_type ?? "");
1168
+ const sessionId = String(data.session_id ?? "unknown");
1169
+ const dir = join(home, ".claude", "logs", "instructions-loaded");
1170
+ try {
1171
+ mkdirSync(dir, { recursive: true });
1172
+ appendFileSync(join(dir, `${sessionId}.log`), `${loadReason} | ${memoryType} | ${filePath}\n`, "utf-8");
1173
+ } catch {}
1174
+ }
1175
+ //#endregion
1176
+ //#region src/runtime/lifecycle/track-changes.ts
1177
+ /** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */
1178
+ const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
1179
+ /**
1180
+ * Handle PostToolUse Write/Edit: track the cumulative set of modified code
1181
+ * files per session and emit the mandatory "SNIPER VALIDATION REQUIRED"
1182
+ * additionalContext. Ports `post-tool-use/track-session-changes.py`.
1183
+ * @param sessionIdRaw - Raw session id from the payload.
1184
+ * @param filePath - The edited file path.
1185
+ * @param home - Home dir (defaults to `~`).
1186
+ * @param now - Clock (defaults to `Date.now()`).
1187
+ * @returns The native hook stdout (possibly empty when not a code file).
1188
+ */
1189
+ function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Date.now()) {
1190
+ if (!filePath || !CODE_EXT.test(filePath)) return "";
1191
+ const sid = sanitizeSessionId(sessionIdRaw) ?? "unknown";
1192
+ const state = loadSessionState(sid, home);
1193
+ const prev = state.changes ?? {
1194
+ cumulativeCodeFiles: 0,
1195
+ modifiedFiles: []
1196
+ };
1197
+ const files = [...prev.modifiedFiles];
1198
+ let count = prev.cumulativeCodeFiles;
1199
+ if (!files.includes(filePath)) {
1200
+ count += 1;
1201
+ files.push(filePath);
1202
+ }
1203
+ state.changes = {
1204
+ cumulativeCodeFiles: count,
1205
+ modifiedFiles: files,
1206
+ lastModifiedFile: filePath,
1207
+ lastCheck: new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z")
1208
+ };
1209
+ saveSessionState(sid, state, home);
1210
+ 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.`);
1211
+ }
1212
+ //#endregion
1213
+ //#region src/runtime/lifecycle/post-edit-ts.ts
1214
+ const TS_EXT$1 = /\.(ts|tsx)$/;
1215
+ const TIMEOUT_MS$1 = 1e4;
1216
+ /** True when `bin` is resolvable on PATH (mirrors shutil.which). */
1217
+ function hasBin(bin) {
1218
+ try {
1219
+ execFileSync(process.platform === "win32" ? "where" : "which", [bin], {
1220
+ stdio: "pipe",
1221
+ timeout: 2e3
1222
+ });
1223
+ return true;
1224
+ } catch {
1225
+ return false;
1226
+ }
1227
+ }
1228
+ /** Run `bin args`, returning `{ code, out }` (code 1 on spawn error). */
1229
+ function run(bin, args) {
1230
+ try {
1231
+ return {
1232
+ code: 0,
1233
+ out: execFileSync(bin, args, {
1234
+ encoding: "utf-8",
1235
+ stdio: "pipe",
1236
+ timeout: TIMEOUT_MS$1
1237
+ })
1238
+ };
1239
+ } catch (err) {
1240
+ const e = err;
1241
+ return {
1242
+ code: e.status ?? 1,
1243
+ out: typeof e.stdout === "string" ? e.stdout : e.stdout?.toString() ?? ""
1244
+ };
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Handle PostToolUse for TS/TSX: report eslint/prettier issues (never fixes) as
1249
+ * additionalContext. Ports `post-tool-use/post-edit-typescript.py`.
1250
+ * @param filePath - The edited file path.
1251
+ * @returns The native hook stdout (possibly empty).
1252
+ */
1253
+ function postEditTypescript(filePath) {
1254
+ if (!filePath || !TS_EXT$1.test(filePath) || !existsSync(filePath)) return "";
1255
+ const issues = [];
1256
+ if (hasBin("eslint")) {
1257
+ const r = run("eslint", [
1258
+ "--no-fix",
1259
+ "--format",
1260
+ "compact",
1261
+ filePath
1262
+ ]);
1263
+ if (r.code !== 0 && r.out.trim()) issues.push(`ESLint:\n${r.out.trim()}`);
1264
+ }
1265
+ if (hasBin("prettier")) {
1266
+ if (run("prettier", ["--check", filePath]).code !== 0) issues.push(`Prettier: ${basename(filePath)} needs formatting`);
1267
+ }
1268
+ if (issues.length === 0) return "";
1269
+ return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1270
+ }
1271
+ //#endregion
1272
+ //#region src/runtime/lifecycle/cartographer/fs-util.ts
1273
+ /**
1274
+ * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
1275
+ * `describe.py` (file desc) and `write_recursive.py` (children + counts).
1276
+ */
1277
+ /**
1278
+ * Read a file and derive its one-line description (frontmatter / heading /
1279
+ * comment). "" on any error or when nothing is found.
1280
+ * @param filePath - Absolute path to the file.
1281
+ * @returns The description, or "".
1282
+ */
1283
+ function getFileDesc(filePath) {
1284
+ let text = "";
1285
+ try {
1286
+ text = readFileSync(filePath, "utf-8");
1287
+ } catch {
1288
+ return "";
1289
+ }
1290
+ const suffix = extname(filePath);
1291
+ const mdField = suffix === ".md" ? parseField(text, "description") : "";
1292
+ return descFromText(suffix, text, mdField);
1293
+ }
1294
+ /**
1295
+ * Recursively count files whose relative path parts are all visible (no leading
1296
+ * "." or "_") and none excluded. Best-effort (partial count on errors).
1297
+ * @param dir - Directory to count under.
1298
+ * @param exclude - Directory/name set to skip.
1299
+ * @returns The file count.
1300
+ */
1301
+ function countFiles(dir, exclude) {
1302
+ let total = 0;
1303
+ try {
1304
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1305
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
1306
+ if (e.isDirectory()) total += countFiles(join(dir, e.name), exclude);
1307
+ else if (e.isFile()) total += 1;
1308
+ }
1309
+ } catch {}
1310
+ return total;
1311
+ }
1312
+ /** Absolute children of `source`, split into dirs/files, sorted by full path. */
1313
+ function listChildren(source, exclude) {
1314
+ const dirs = [];
1315
+ const files = [];
1316
+ let entries;
1317
+ try {
1318
+ entries = readdirSync(source, { withFileTypes: true });
1319
+ } catch {
1320
+ return {
1321
+ dirs,
1322
+ files
1323
+ };
1324
+ }
1325
+ for (const e of entries) {
1326
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
1327
+ const abs = join(source, e.name);
1328
+ if (e.isDirectory()) dirs.push(abs);
1329
+ else if (e.isFile()) files.push(abs);
1330
+ }
1331
+ return {
1332
+ dirs: dirs.sort(),
1333
+ files: files.sort()
1334
+ };
1335
+ }
1336
+ //#endregion
1337
+ //#region src/runtime/lifecycle/cartographer/merge.ts
1338
+ /**
1339
+ * Index merge — preserves enriched descriptions across regenerations. Ports
1340
+ * `merge_index.py` (merge_lines + .enriched.json sidecar).
1341
+ */
1342
+ /**
1343
+ * Load the `.enriched.json` sidecar's `entries` map for an output index.
1344
+ * @param outputIndexPath - Path to the index.md being written.
1345
+ * @returns The path→desc enrichment map (possibly empty).
1346
+ */
1347
+ function loadEnriched(outputIndexPath) {
1348
+ const sidecar = join(dirname(outputIndexPath), ".enriched.json");
1349
+ try {
1350
+ if (!existsSync(sidecar)) return {};
1351
+ return JSON.parse(readFileSync(sidecar, "utf-8")).entries ?? {};
1352
+ } catch {
1353
+ return {};
1354
+ }
1355
+ }
1356
+ /**
1357
+ * Merge freshly generated lines with prior descriptions: enriched sidecar wins,
1358
+ * else a longer pre-existing description is preserved.
1359
+ * @param newLines - The freshly generated index lines.
1360
+ * @param outputIndexPath - Path to the existing index.md (if any).
1361
+ * @returns The merged lines.
1362
+ */
1363
+ function mergeLines(newLines, outputIndexPath) {
1364
+ const enriched = loadEnriched(outputIndexPath);
1365
+ const existingDescs = {};
1366
+ if (existsSync(outputIndexPath)) try {
1367
+ for (const line of readFileSync(outputIndexPath, "utf-8").split("\n")) {
1368
+ const e = parseEntry(line);
1369
+ if (e) existingDescs[e.path] = e.desc;
1370
+ }
1371
+ } catch {}
1372
+ return newLines.map((line) => {
1373
+ const e = parseEntry(line);
1374
+ if (!e) return line;
1375
+ if (e.path in enriched) return `${e.prefix}[${e.name}](${e.path}) — ${enriched[e.path]}`;
1376
+ const old = existingDescs[e.path] ?? "";
1377
+ if (old.length > e.desc.length) return `${e.prefix}[${e.name}](${e.path}) — ${old}`;
1378
+ return line;
1379
+ });
1380
+ }
1381
+ //#endregion
1382
+ //#region src/runtime/lifecycle/cartographer/write-tree.ts
1383
+ /**
1384
+ * Recursive index.md tree writer. Ports `write_recursive.py`.
1385
+ */
1386
+ /**
1387
+ * Write `index.md` files mirroring `source` under `output`, recursing into
1388
+ * subdirectories. Directory lines carry a file-count hint; file lines carry a
1389
+ * derived description and link to the real absolute source path.
1390
+ * @param source - Absolute source directory.
1391
+ * @param output - Absolute output directory for the index tree.
1392
+ * @param back - Relative `← back` link target ("" at the root).
1393
+ * @param exclude - Directory/name set to skip.
1394
+ */
1395
+ function writeTree(source, output, back = "", exclude) {
1396
+ const ex = exclude ?? /* @__PURE__ */ new Set();
1397
+ mkdirSync(output, { recursive: true });
1398
+ const { dirs, files } = listChildren(source, ex);
1399
+ const lines = [`# ${basename(source)}\n`];
1400
+ if (back) lines.push(`> [← back](${back})\n`);
1401
+ const total = dirs.length + files.length;
1402
+ let idx = 0;
1403
+ for (const d of dirs) {
1404
+ idx += 1;
1405
+ const conn = idx === total ? "└──" : "├──";
1406
+ const count = countFiles(d, ex);
1407
+ const hint = count ? ` — ${count} files` : "";
1408
+ lines.push(`${conn} [${basename(d)}/](./${basename(d)}/index.md)${hint}`);
1409
+ writeTree(d, join(output, basename(d)), "../index.md", exclude);
1410
+ }
1411
+ for (const f of files) {
1412
+ idx += 1;
1413
+ const conn = idx === total ? "└──" : "├──";
1414
+ const desc = getFileDesc(f);
1415
+ const suffix = desc ? ` — ${desc}` : "";
1416
+ lines.push(`${conn} [${basename(f)}](${f})${suffix}`);
1417
+ }
1418
+ const indexPath = join(output, "index.md");
1419
+ writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
1420
+ }
1421
+ //#endregion
1422
+ //#region src/runtime/lifecycle/cartographer/project-map.ts
1423
+ /**
1424
+ * Project map generation. Ports `generate_project_map.py` (project map only).
1425
+ */
1426
+ /** True when `dir` is a real directory. */
1427
+ function isDirectory(dir) {
1428
+ try {
1429
+ return statSync(dir).isDirectory();
1430
+ } catch {
1431
+ return false;
1432
+ }
1433
+ }
1434
+ /**
1435
+ * True when `dir` looks like a project root (has an indicator file) and is not
1436
+ * the home directory or filesystem root.
1437
+ * @param dir - Directory to test.
1438
+ * @returns Whether `dir` is a project root.
1439
+ */
1440
+ function isProject(dir) {
1441
+ const resolved = resolve(dir);
1442
+ if (resolved === resolve(homedir()) || resolved === "/") return false;
1443
+ for (const f of PROJECT_INDICATORS) if (existsSync(join(dir, f))) return true;
1444
+ return false;
1445
+ }
1446
+ /**
1447
+ * Generate the `.cartographer/project` index tree for `cwd` when it is a real
1448
+ * project directory. Always returns "" (no additionalContext emitted).
1449
+ * @param cwd - The working directory.
1450
+ * @param outputDir - Override for the output tree root.
1451
+ * @returns "" (side-effect only).
1452
+ */
1453
+ function generateProjectMap(cwd, outputDir) {
1454
+ const projectDir = resolve(cwd);
1455
+ const out = outputDir ?? join(projectDir, ".cartographer", "project");
1456
+ if (!isDirectory(projectDir)) return "";
1457
+ if (!isProject(projectDir)) return "";
1458
+ writeTree(projectDir, out, "", EXCLUDE_DIRS$1);
1459
+ return "";
1460
+ }
1461
+ //#endregion
1462
+ //#region src/runtime/lifecycle/cartographer/session-start.ts
1463
+ /**
1464
+ * Cartographer SessionStart handler. Ports the project-map half of
1465
+ * `generate_project_map.py`: regenerates `.cartographer/project` and emits no
1466
+ * additionalContext (the plugin ecosystem map from `generate_map.py` is not
1467
+ * ported and stays as Python).
1468
+ */
1469
+ /**
1470
+ * Regenerate the project map for `cwd` on SessionStart. Returns "" (side-effect
1471
+ * only — no additionalContext).
1472
+ * @param cwd - The working directory.
1473
+ * @returns "" always.
1474
+ */
1475
+ function cartoSessionStart(cwd) {
1476
+ generateProjectMap(cwd);
1477
+ return "";
1478
+ }
1479
+ //#endregion
1480
+ //#region src/runtime/lifecycle/aipilot/inject-apex.ts
1481
+ /**
1482
+ * SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
1483
+ * task context + cartographer paths into every sub-agent. Ports
1484
+ * `inject-subagent-context.ts`. The core-scope SubagentStart already surfaces
1485
+ * the MCP cache table (`subagent-cache.ts`) in a separate hook entry — this
1486
+ * handler only emits the APEX/cartographer block, so there is no double-emit.
1487
+ */
1488
+ /** Last 3 completed task subjects (newest first), or "none". */
1489
+ function completedTasks(tasks) {
1490
+ const done = Object.entries(tasks).filter(([, t]) => t.status === "completed").sort((a, b) => (b[1].completed_at ?? "").localeCompare(a[1].completed_at ?? "")).slice(0, 3).map(([id, t]) => `#${id}: ${t.subject}`);
1491
+ return done.length > 0 ? done.join(", ") : "none";
1492
+ }
1493
+ /** Pending task subjects, or "none". */
1494
+ function pendingTasks(tasks) {
1495
+ const pending = Object.entries(tasks).filter(([, t]) => t.status === "pending").map(([id, t]) => `#${id}: ${t.subject}`);
1496
+ return pending.length > 0 ? pending.join(", ") : "none";
1497
+ }
1498
+ /** Cartographer navigation block, or "" when no plugin map is present. */
1499
+ function cartographerContext() {
1500
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
1501
+ if (!pluginRoot) return "";
1502
+ const pluginsMap = resolve(pluginRoot, "..", ".cartographer", "index.md");
1503
+ if (!existsSync(pluginsMap)) return "";
1504
+ return `\n### 7. Cartographer Maps\nNavigate branches (index.md) -> leaves link to real files:\n- Plugin skills: ${pluginsMap}\n- Project files: .cartographer/project/index.md`;
1505
+ }
1506
+ /**
1507
+ * Build the APEX sub-agent injection for SubagentStart, or "" when the project
1508
+ * has no `.claude/apex/` dir. Reads AGENTS.md (first 4KB) + task.json.
1509
+ * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
1510
+ * @param home - Home dir (unused placeholder; kept for symmetry/testing).
1511
+ * @returns The native hook stdout (possibly empty).
1512
+ */
1513
+ async function injectApexSubagentContext(cwd, home = homedir()) {
1514
+ const apexDir = join(process.env.CLAUDE_PROJECT_DIR ?? cwd, ".claude", "apex");
1515
+ if (!existsSync(apexDir)) return "";
1516
+ const agentsFile = Bun.file(join(apexDir, "AGENTS.md"));
1517
+ const agents = await agentsFile.exists() ? (await agentsFile.text()).slice(0, 4e3) : "";
1518
+ const taskData = await readJsonFile(join(apexDir, "task.json"));
1519
+ return contextResponse("SubagentStart", `## APEX Sub-Agent Instructions
1520
+
1521
+ You are a sub-agent in APEX workflow. Follow these rules:
1522
+
1523
+ ### 1. AGENTS.md Rules
1524
+ ${agents}
1525
+
1526
+ ### 2. Task Context
1527
+ - Last completed: ${taskData ? completedTasks(taskData.tasks) : "none"}
1528
+ - Pending: ${taskData ? pendingTasks(taskData.tasks) : "none"}
1529
+
1530
+ ### 3. Before Starting Work
1531
+ - Use TaskUpdate(taskId, status: in_progress) before starting
1532
+
1533
+ ### 4. SOLID Rules
1534
+ - Files < ${resolveMaxLines()} lines | Interfaces in src/interfaces/ | JSDoc/PHPDoc required
1535
+
1536
+ ### 5. Research Before Code
1537
+ - Use Context7/Exa for docs | Write notes to .claude/apex/docs/
1538
+
1539
+ ### 6. When Done
1540
+ - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`);
1541
+ }
1542
+ //#endregion
1543
+ //#region src/runtime/lifecycle/aipilot/cache-base.ts
1544
+ /**
1545
+ * Shared cache-path + age helpers for the ai-pilot scope.
1546
+ * Reuses the harness home-state (`fusengineCache`) so the ai-pilot caches live
1547
+ * under the same `~/.claude/fusengine-cache` tree as the core MCP cache — no
1548
+ * second cache layer.
1549
+ */
1550
+ /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
1551
+ function hashText16(text) {
1552
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
1553
+ }
1554
+ /** 16-char project hash from its absolute path. */
1555
+ function projectHash(projectPath) {
1556
+ return hashText16(projectPath);
1557
+ }
1558
+ /** `~/.claude/fusengine-cache` base dir (shared with the core MCP cache). */
1559
+ function cacheBaseDir(home = homedir()) {
1560
+ return fusengineCache(home);
1561
+ }
1562
+ /** Per-type, per-project cache dir: `fusengine-cache/<type>/<projectHash>`. */
1563
+ function cacheDirFor(type, projectPath, home = homedir()) {
1564
+ return join(cacheBaseDir(home), type, projectHash(projectPath));
1565
+ }
1566
+ /** Age in seconds from an ISO timestamp (now - ts). */
1567
+ function cacheAge(ts, now = Date.now()) {
1568
+ return Math.floor((now - new Date(ts).getTime()) / 1e3);
1569
+ }
1570
+ /** Full SHA-256 hex checksum of a file's text; "" when unreadable. */
1571
+ async function fileChecksum(path) {
1572
+ try {
1573
+ return createHash("sha256").update(await Bun.file(path).text()).digest("hex");
1574
+ } catch {
1575
+ return "";
1576
+ }
1577
+ }
1578
+ //#endregion
1579
+ //#region src/runtime/lifecycle/aipilot/analytics.ts
1580
+ /**
1581
+ * Cache analytics for the ai-pilot scope: append hit/miss events to
1582
+ * `sessions.jsonl` and aggregate them into `summary.json` on SessionEnd.
1583
+ * Ports `cache-analytics-save.ts` + the `logCacheEvent` helper.
1584
+ */
1585
+ const TOKEN_WEIGHTS = {
1586
+ explore: 15e3,
1587
+ doc: 1e4,
1588
+ lessons: 3e3,
1589
+ tests: 5e3
1590
+ };
1591
+ const CATEGORIES = [
1592
+ "explore",
1593
+ "doc",
1594
+ "lessons",
1595
+ "tests"
1596
+ ];
1597
+ /** Append a single cache event to `analytics/sessions.jsonl` (best effort). */
1598
+ function logCacheEvent(type, action, projHash, extra = {}, home = homedir()) {
1599
+ try {
1600
+ const dir = join(cacheBaseDir(home), "analytics");
1601
+ mkdirSync(dir, { recursive: true });
1602
+ const entry = {
1603
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1604
+ session: String(Math.floor(Date.now() / 1e3)),
1605
+ type,
1606
+ action,
1607
+ project_hash: projHash,
1608
+ ...extra
1609
+ };
1610
+ appendFileSync(join(dir, "sessions.jsonl"), JSON.stringify(entry) + "\n");
1611
+ } catch {}
1612
+ }
1613
+ /** Count entries matching a `type`+`action`. */
1614
+ function countBy(entries, type, action) {
1615
+ return entries.filter((e) => e.type === type && e.action === action).length;
1616
+ }
1617
+ /** Format a hit-rate percentage string. */
1618
+ function hitRate(hits, misses) {
1619
+ const total = hits + misses;
1620
+ return total === 0 ? "0%" : `${Math.floor(hits * 100 / total)}%`;
1621
+ }
1622
+ /** Parse the JSONL session log into typed entries. */
1623
+ function parseEntries(raw) {
1624
+ return raw.split("\n").filter(Boolean).map((line) => {
1625
+ try {
1626
+ return JSON.parse(line);
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }).filter((e) => e !== null);
1631
+ }
1632
+ /**
1633
+ * Aggregate `sessions.jsonl` into `summary.json` and prune entries > 30 days.
1634
+ * Ports `cache-analytics-save.ts`. SessionEnd hook output is ignored by Claude,
1635
+ * so this returns nothing — it is a pure side-effect.
1636
+ * @param home - Home dir (defaults to `~`).
1637
+ * @param now - Clock (defaults to `Date.now()`).
1638
+ */
1639
+ async function cacheAnalyticsSave(home = homedir(), now = Date.now()) {
1640
+ const dir = join(cacheBaseDir(home), "analytics");
1641
+ const sessionsFile = join(dir, "sessions.jsonl");
1642
+ const file = Bun.file(sessionsFile);
1643
+ if (!await file.exists()) return;
1644
+ const raw = await file.text();
1645
+ if (!raw.trim()) return;
1646
+ const entries = parseEntries(raw);
1647
+ if (entries.length === 0) return;
1648
+ const hits = {};
1649
+ const misses = {};
1650
+ for (const cat of CATEGORIES) {
1651
+ hits[cat] = countBy(entries, cat, "hit") + (cat === "doc" ? countBy(entries, cat, "blocked") : 0);
1652
+ misses[cat] = countBy(entries, cat, "miss");
1653
+ }
1654
+ const tokensSaved = CATEGORIES.reduce((sum, c) => sum + (hits[c] ?? 0) * (TOKEN_WEIGHTS[c] ?? 0), 0);
1655
+ const sessionCount = new Set(entries.map((e) => e.session).filter(Boolean)).size;
1656
+ const old = await readJsonFile(join(dir, "summary.json"));
1657
+ const merged = {
1658
+ updated: new Date(now).toISOString(),
1659
+ total_sessions: (old?.total_sessions ?? 0) + sessionCount,
1660
+ cache_hits: {},
1661
+ cache_misses: {},
1662
+ hit_rates: {},
1663
+ estimated_tokens_saved: (old?.estimated_tokens_saved ?? 0) + tokensSaved
1664
+ };
1665
+ for (const cat of CATEGORIES) {
1666
+ merged.cache_hits[cat] = (old?.cache_hits?.[cat] ?? 0) + (hits[cat] ?? 0);
1667
+ merged.cache_misses[cat] = (old?.cache_misses?.[cat] ?? 0) + (misses[cat] ?? 0);
1668
+ merged.hit_rates[cat] = hitRate(merged.cache_hits[cat] ?? 0, merged.cache_misses[cat] ?? 0);
1669
+ }
1670
+ await writeJsonFile(join(dir, "summary.json"), merged, true);
1671
+ const cutoff = (/* @__PURE__ */ new Date(now - 30 * 864e5)).toISOString();
1672
+ const kept = entries.filter((e) => e.ts >= cutoff);
1673
+ await Bun.write(sessionsFile, kept.map((e) => JSON.stringify(e)).join("\n") + "\n");
1674
+ }
1675
+ //#endregion
1676
+ //#region src/runtime/lifecycle/aipilot/inject-explore.ts
1677
+ /**
1678
+ * SubagentStart (matcher "explore-codebase") for the ai-pilot scope: serve a
1679
+ * cached architecture report when fresh + config-matching, else inject save
1680
+ * instructions. Ports `explore-cache-check.ts`.
1681
+ */
1682
+ const TTL_SECONDS$2 = 86400;
1683
+ const CONFIG_FILES = [
1684
+ "package.json",
1685
+ "tsconfig.json",
1686
+ "composer.json",
1687
+ "go.mod",
1688
+ "Cargo.toml",
1689
+ "Package.swift",
1690
+ "biome.json",
1691
+ ".eslintrc.js",
1692
+ ".eslintrc.json"
1693
+ ];
1694
+ /** Compute a config hash from git-tracked config files; "noconfig" on failure. */
1695
+ async function configHash(cwd) {
1696
+ try {
1697
+ const proc = Bun.spawn([
1698
+ "git",
1699
+ "ls-tree",
1700
+ "HEAD",
1701
+ ...CONFIG_FILES
1702
+ ], {
1703
+ cwd,
1704
+ stdout: "pipe",
1705
+ stderr: "ignore"
1706
+ });
1707
+ const output = await new Response(proc.stdout).text();
1708
+ return output.trim() ? hashText16(output) : "noconfig";
1709
+ } catch {
1710
+ return "noconfig";
1711
+ }
1712
+ }
1713
+ /** Build the cache-miss save-instructions block. */
1714
+ function missBlock(cacheDir, metaFile, snapFile, ts, cfgHash, projPath) {
1715
+ return `## EXPLORATION CACHE INSTRUCTIONS\nAfter completing your exploration, save the report for future runs:\n\`\`\`bash\nmkdir -p ${cacheDir}\ncat > ${metaFile} << 'METAEOF'\n{"timestamp":"${ts}","config_hash":"${cfgHash}","project":"${projPath}"}\nMETAEOF\n\`\`\`\nThen write your full exploration report (markdown) to: ${snapFile}`;
1716
+ }
1717
+ /**
1718
+ * SubagentStart for explore-codebase: inject cached architecture or save block.
1719
+ * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
1720
+ * @param home - Home dir (defaults to `~`).
1721
+ * @param now - Clock (defaults to `Date.now()`).
1722
+ * @returns The native hook stdout.
1723
+ */
1724
+ async function injectExploreCache(cwd, home = homedir(), now = Date.now()) {
1725
+ const projPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
1726
+ const pHash = projectHash(projPath);
1727
+ const cacheDir = cacheDirFor("explore", projPath, home);
1728
+ const metaFile = join(cacheDir, "metadata.json");
1729
+ const snapFile = join(cacheDir, "snapshot.md");
1730
+ const cfgHash = await configHash(projPath);
1731
+ let context = "";
1732
+ const meta = await readJsonFile(metaFile);
1733
+ const snapBunFile = Bun.file(snapFile);
1734
+ const snapshot = await snapBunFile.exists() ? await snapBunFile.text() : "";
1735
+ if (meta?.timestamp && snapshot) {
1736
+ const age = cacheAge(meta.timestamp, now);
1737
+ if (age < TTL_SECONDS$2 && meta.config_hash === cfgHash) {
1738
+ context = `## CACHED ARCHITECTURE AVAILABLE (age: ${Math.floor(age / 60)}min)\nUSE this cached report. Do NOT run full exploration. Return it immediately.\n\n${snapshot}`;
1739
+ logCacheEvent("explore", "hit", pHash, {}, home);
1740
+ }
1741
+ }
1742
+ if (!context) {
1743
+ logCacheEvent("explore", "miss", pHash, {}, home);
1744
+ context = missBlock(cacheDir, metaFile, snapFile, new Date(now).toISOString().replace(/\.\d+Z$/, ""), cfgHash, projPath);
1745
+ }
1746
+ return contextResponse("SubagentStart", context);
1747
+ }
1748
+ //#endregion
1749
+ //#region src/runtime/lifecycle/aipilot/inject-doc.ts
1750
+ /**
1751
+ * SubagentStart (matcher "research-expert") for the ai-pilot scope: inject
1752
+ * cached documentation summaries. Ports `doc-cache-inject.ts`. Doc *saving*
1753
+ * happens on SubagentStop (`cache-doc.ts`).
1754
+ */
1755
+ const TTL_SECONDS$1 = 604800;
1756
+ const MAX_SIZE = 8192;
1757
+ /** Concatenate fresh, dedup-by-hash cached doc bodies. */
1758
+ async function buildDocsContext(entries, docsDir, now) {
1759
+ let ctx = "";
1760
+ let count = 0;
1761
+ let maxAge = 0;
1762
+ const seen = /* @__PURE__ */ new Set();
1763
+ for (const entry of entries) {
1764
+ if (!entry.timestamp) continue;
1765
+ const age = cacheAge(entry.timestamp, now);
1766
+ if (age >= TTL_SECONDS$1) continue;
1767
+ if (age > maxAge) maxAge = age;
1768
+ if (!entry.hash || seen.has(entry.hash)) continue;
1769
+ seen.add(entry.hash);
1770
+ const file = Bun.file(join(docsDir, `${entry.hash}.md`));
1771
+ if (!await file.exists()) continue;
1772
+ const content = await file.text();
1773
+ if (!content) continue;
1774
+ ctx += `\n${content}\n`;
1775
+ count++;
1776
+ }
1777
+ return {
1778
+ ctx,
1779
+ count,
1780
+ maxAge
1781
+ };
1782
+ }
1783
+ /**
1784
+ * SubagentStart for research-expert: inject cached doc summaries, or "".
1785
+ * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
1786
+ * @param home - Home dir (defaults to `~`).
1787
+ * @param now - Clock (defaults to `Date.now()`).
1788
+ * @returns The native hook stdout (possibly empty).
1789
+ */
1790
+ async function injectDocCache(cwd, home = homedir(), now = Date.now()) {
1791
+ const projPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
1792
+ const pHash = projectHash(projPath);
1793
+ const cacheDir = cacheDirFor("doc", projPath, home);
1794
+ const index = await readJsonFile(join(cacheDir, "index.json"));
1795
+ if (!index?.docs?.length) return "";
1796
+ const { ctx, count, maxAge } = await buildDocsContext(index.docs, join(cacheDir, "docs"), now);
1797
+ if (count === 0) return "";
1798
+ logCacheEvent("doc", "hit", pHash, { docs_injected: count }, home);
1799
+ return contextResponse("SubagentStart", `${`## CACHED DOCUMENTATION (${count} docs, ${Math.ceil(maxAge / 3600)}h ago)\nUse this knowledge. Only query Context7 for topics NOT covered below.\n`}${ctx}Full docs: ${join(cacheDir, "docs")}/`.slice(0, MAX_SIZE));
1800
+ }
1801
+ //#endregion
1802
+ //#region src/runtime/lifecycle/aipilot/source-scan.ts
1803
+ /**
1804
+ * Source-file scanning + project-stack detection for the ai-pilot scope.
1805
+ * Ported from the ai-pilot plugin's `cache/source-collector.ts` +
1806
+ * the stack detection in `cache/lesson-helpers.ts` (now removed).
1807
+ */
1808
+ /** Source file glob patterns (monorepo-aware; separate to avoid brace-wildcards). */
1809
+ const SRC_PATTERNS = [
1810
+ "src/**/*.{ts,tsx,js,jsx}",
1811
+ "app/**/*.{ts,tsx,js,jsx}",
1812
+ "apps/*/src/**/*.{ts,tsx,js,jsx}",
1813
+ "packages/*/src/**/*.{ts,tsx,js,jsx}"
1814
+ ];
1815
+ /**
1816
+ * Scan source files in `projectPath` (monorepo-aware), capped at `maxFiles`.
1817
+ * @param projectPath - Absolute project root.
1818
+ * @param maxFiles - Max files to collect (default 200).
1819
+ * @returns Absolute paths matching the source patterns.
1820
+ */
1821
+ async function scanSourceFiles(projectPath, maxFiles = 200) {
1822
+ const files = [];
1823
+ for (const pattern of SRC_PATTERNS) {
1824
+ try {
1825
+ for await (const p of new Glob(pattern).scan({
1826
+ cwd: projectPath,
1827
+ absolute: true
1828
+ })) {
1829
+ if (p.includes("node_modules")) continue;
1830
+ files.push(p);
1831
+ if (files.length >= maxFiles) break;
1832
+ }
1833
+ } catch {}
1834
+ if (files.length >= maxFiles) break;
1835
+ }
1836
+ return files;
1837
+ }
1838
+ /** Detect the project stack from config files in the project root. */
1839
+ function detectStack(projectPath) {
1840
+ try {
1841
+ const entries = readdirSync(projectPath);
1842
+ if (entries.some((f) => f.startsWith("next.config"))) return "nextjs";
1843
+ if (entries.includes("composer.json")) return "laravel";
1844
+ if (entries.some((f) => f.endsWith(".xcodeproj")) || entries.includes("Package.swift")) return "swift";
1845
+ if (entries.some((f) => f.startsWith("tailwind.config"))) return "tailwindcss";
1846
+ } catch {}
1847
+ return "universal";
1848
+ }
1849
+ //#endregion
1850
+ //#region src/runtime/lifecycle/aipilot/lessons.ts
1851
+ /**
1852
+ * Lesson aggregation, dedup, merge, and edit categorization for the ai-pilot
1853
+ * scope. Ported from the ai-pilot plugin's `cache/lesson-aggregator.ts` +
1854
+ * `cache/lesson-helpers.ts` (now removed).
1855
+ */
1856
+ /** Deduplicate lessons by `error_type:pattern`, summing counts. */
1857
+ function dedupLessons(lessons) {
1858
+ const groups = /* @__PURE__ */ new Map();
1859
+ for (const l of lessons) {
1860
+ const key = `${l.error_type}:${l.pattern}`;
1861
+ const group = groups.get(key);
1862
+ if (group) group.push(l);
1863
+ else groups.set(key, [l]);
1864
+ }
1865
+ return [...groups.values()].map((entries) => {
1866
+ const first = entries[0];
1867
+ return {
1868
+ error_type: first.error_type,
1869
+ pattern: first.pattern,
1870
+ fix: first.fix,
1871
+ last_seen: first.last_seen,
1872
+ count: entries.reduce((s, e) => s + e.count, 0),
1873
+ files: [...new Set(entries.flatMap((e) => e.files))],
1874
+ code: { line: [...new Set(entries.flatMap((e) => e.code?.line ?? []))].slice(0, 5) }
1875
+ };
1876
+ }).sort((a, b) => b.count - a.count);
1877
+ }
1878
+ /** Aggregate all local lesson JSON files into a flat deduplicated list. */
1879
+ async function aggregateLocalLessons(cacheDir) {
1880
+ if (!existsSync(cacheDir)) return [];
1881
+ const files = readdirSync(cacheDir).filter((f) => f.endsWith(".json"));
1882
+ const all = [];
1883
+ for (const f of files) {
1884
+ const data = await readJsonFile(join(cacheDir, f));
1885
+ if (data?.errors) all.push(...data.errors);
1886
+ }
1887
+ return dedupLessons(all);
1888
+ }
1889
+ /** Load global lessons for a stack (stack-specific + universal). */
1890
+ async function loadGlobalLessons(stack, home = homedir()) {
1891
+ const globalDir = join(cacheBaseDir(home), "lessons", "_global");
1892
+ const result = [];
1893
+ for (const name of [`${stack}.json`, "universal.json"]) {
1894
+ const data = await readJsonFile(join(globalDir, name));
1895
+ if (data) result.push(...data);
1896
+ }
1897
+ return result;
1898
+ }
1899
+ /** Merge local + global lessons, dedup by type+pattern, sort by count desc. */
1900
+ function mergeLessons(local, global) {
1901
+ return dedupLessons([...local, ...global]);
1902
+ }
1903
+ /** Categorize an edit entry by analyzing the new code content. */
1904
+ function categorizeEdit(edit) {
1905
+ const n = edit.newStr.toLowerCase();
1906
+ if (n.includes("use client")) return "missing_directive";
1907
+ if (n.includes("displayname")) return "missing_display_name";
1908
+ if (/onkeydown|tabindex|role=/.test(n)) return "missing_a11y";
1909
+ if (/try|catch/.test(n)) return "missing_error_handling";
1910
+ if (/\?\?|if.*null/.test(n)) return "null_safety";
1911
+ return "code_fix";
1912
+ }
1913
+ //#endregion
1914
+ //#region src/runtime/lifecycle/aipilot/inject-lessons.ts
1915
+ /**
1916
+ * SubagentStart (matcher "") for the ai-pilot scope: inject cached lessons
1917
+ * (known project issues) into every agent. Ports `lessons-cache-inject.ts`.
1918
+ */
1919
+ const MAX_AGE_MS = 30 * 864e5;
1920
+ const MAX_LESSONS = 10;
1921
+ /** Remove JSON files older than 30 days from `dir` (best effort). */
1922
+ function pruneOldFiles(dir, now) {
1923
+ try {
1924
+ const cutoff = now - MAX_AGE_MS;
1925
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".json"))) {
1926
+ const path = `${dir}/${f}`;
1927
+ try {
1928
+ if (statSync(path).mtimeMs < cutoff) unlinkSync(path);
1929
+ } catch {}
1930
+ }
1931
+ } catch {}
1932
+ }
1933
+ /** Render lessons as a numbered known-issues list. */
1934
+ function formatLessonList(lessons) {
1935
+ return lessons.map((l, i) => {
1936
+ const code = l.code?.line?.length ? `\n Code: ${l.code.line.join(" | ").slice(0, 200)}` : "";
1937
+ return `${i + 1}. [${l.count}x] | ${l.pattern ?? "unknown"} -> ${l.fix ?? "see docs"}${code}`;
1938
+ }).join("\n");
1939
+ }
1940
+ /**
1941
+ * SubagentStart lessons injection: aggregate local + global lessons, or "".
1942
+ * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
1943
+ * @param home - Home dir (defaults to `~`).
1944
+ * @param now - Clock (defaults to `Date.now()`).
1945
+ * @returns The native hook stdout (possibly empty).
1946
+ */
1947
+ async function injectLessonsCache(cwd, home = homedir(), now = Date.now()) {
1948
+ const projectPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
1949
+ const pHash = projectHash(projectPath);
1950
+ const cacheDir = cacheDirFor("lessons", projectPath, home);
1951
+ const stack = detectStack(projectPath);
1952
+ pruneOldFiles(cacheDir, now);
1953
+ let localLessons = [];
1954
+ try {
1955
+ localLessons = await aggregateLocalLessons(cacheDir);
1956
+ } catch {}
1957
+ const globalLessons = await loadGlobalLessons(stack, home);
1958
+ if (localLessons.length === 0 && globalLessons.length === 0) return "";
1959
+ const merged = mergeLessons(localLessons, globalLessons).slice(0, MAX_LESSONS);
1960
+ if (merged.length === 0) return "";
1961
+ logCacheEvent("lessons", "hit", pHash, {
1962
+ count: merged.length,
1963
+ stack
1964
+ }, home);
1965
+ return contextResponse("SubagentStart", `## KNOWN PROJECT ISSUES (from previous sniper validations)\nThese errors have been found and fixed before. AVOID them:\n${formatLessonList(merged)}\n\nINSTRUCTION: Check your code against these known issues BEFORE submitting.`);
1966
+ }
1967
+ //#endregion
1968
+ //#region src/runtime/lifecycle/aipilot/inject-test.ts
1969
+ /**
1970
+ * SubagentStart (matcher "sniper") for the ai-pilot scope: tell sniper which
1971
+ * source files changed vs. already-validated. Ports `test-cache-inject.ts`.
1972
+ */
1973
+ const TTL_SECONDS = 172800;
1974
+ /**
1975
+ * SubagentStart for sniper: inject the changed-file list, or "" when nothing
1976
+ * useful to say (no cache / no unchanged files).
1977
+ * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
1978
+ * @param home - Home dir (defaults to `~`).
1979
+ * @param now - Clock (defaults to `Date.now()`).
1980
+ * @returns The native hook stdout (possibly empty).
1981
+ */
1982
+ async function injectTestCache(cwd, home = homedir(), now = Date.now()) {
1983
+ const projectPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
1984
+ const cache = await readJsonFile(join(cacheDirFor("tests", projectPath, home), "results.json"));
1985
+ if (!cache?.files || Object.keys(cache.files).length === 0) return "";
1986
+ const srcFiles = await scanSourceFiles(projectPath);
1987
+ if (srcFiles.length === 0) return "";
1988
+ const changed = [];
1989
+ let unchanged = 0;
1990
+ for (const filepath of srcFiles) {
1991
+ const relPath = filepath.replace(`${projectPath}/`, "");
1992
+ const cached = cache.files[relPath];
1993
+ if (!cached) {
1994
+ changed.push(relPath);
1995
+ continue;
1996
+ }
1997
+ if ((await fileChecksum(filepath)).slice(0, 16) !== cached.checksum) {
1998
+ changed.push(relPath);
1999
+ continue;
2000
+ }
2001
+ if (cached.last_tested && cacheAge(cached.last_tested, now) > TTL_SECONDS) {
2002
+ changed.push(relPath);
2003
+ continue;
2004
+ }
2005
+ unchanged++;
2006
+ }
2007
+ if (unchanged === 0) return "";
2008
+ const changedList = changed.map((f) => `- ${f}`).join("\n");
2009
+ return contextResponse("SubagentStart", `## TEST CACHE (${unchanged}/${srcFiles.length} files already validated)\nOnly run linters on these CHANGED files:\n${changedList}\nSKIP linting on ${unchanged} unchanged files - already PASS.`);
2010
+ }
2011
+ //#endregion
2012
+ //#region src/runtime/lifecycle/aipilot/transcript.ts
2013
+ /**
2014
+ * JSONL transcript parsing helpers for the ai-pilot scope: project-root
2015
+ * detection from file paths, file-path extraction, Edit extraction, and the
2016
+ * assistant report extraction. Ported into the harness from the ai-pilot
2017
+ * plugin's `cache/project-detect.ts` + `cache/lesson-helpers.ts` (now removed).
2018
+ */
2019
+ const ROOT_MARKERS = [
2020
+ ".git",
2021
+ ".hg",
2022
+ "turbo.json",
2023
+ "nx.json",
2024
+ "lerna.json",
2025
+ "pnpm-workspace.yaml"
2026
+ ];
2027
+ const PKG_MARKERS = [
2028
+ "package.json",
2029
+ "composer.json",
2030
+ "Package.swift",
2031
+ "Cargo.toml",
2032
+ "go.mod",
2033
+ "pyproject.toml",
2034
+ "Gemfile",
2035
+ "pom.xml"
2036
+ ];
2037
+ /** Detect the project root by walking up from the first file path to a marker. */
2038
+ function projectRootFromPaths(filePaths) {
2039
+ const firstPath = filePaths[0];
2040
+ if (!firstPath) return null;
2041
+ const lastSlash = firstPath.lastIndexOf("/");
2042
+ if (lastSlash <= 0) return null;
2043
+ let dir = firstPath.substring(0, lastSlash);
2044
+ let bestRoot = null;
2045
+ while (dir && dir !== "/" && dir.length > 1) {
2046
+ try {
2047
+ const entries = readdirSync(dir);
2048
+ if (ROOT_MARKERS.some((m) => entries.includes(m))) return dir;
2049
+ if (PKG_MARKERS.some((m) => entries.includes(m))) bestRoot = dir;
2050
+ } catch {
2051
+ break;
2052
+ }
2053
+ const parentSlash = dir.lastIndexOf("/");
2054
+ if (parentSlash <= 0) break;
2055
+ dir = dir.substring(0, parentSlash);
2056
+ }
2057
+ return bestRoot;
2058
+ }
2059
+ /** Extract all absolute file paths from tool_use entries in a JSONL transcript. */
2060
+ async function transcriptFilePaths(transcriptPath) {
2061
+ const text = await Bun.file(transcriptPath).text();
2062
+ const paths = /* @__PURE__ */ new Set();
2063
+ for (const line of text.split("\n").filter(Boolean)) try {
2064
+ const content = JSON.parse(line)?.message?.content;
2065
+ if (!Array.isArray(content)) continue;
2066
+ for (const block of content) {
2067
+ if (block?.type !== "tool_use") continue;
2068
+ const fp = block.input?.file_path ?? block.input?.path ?? "";
2069
+ if (typeof fp === "string" && fp.startsWith("/")) paths.add(fp);
2070
+ }
2071
+ } catch {}
2072
+ return [...paths];
2073
+ }
2074
+ /** Extract deduplicated Edit tool_use entries (keyed by basename) from a transcript. */
2075
+ async function transcriptEdits(transcriptPath) {
2076
+ const text = await Bun.file(transcriptPath).text();
2077
+ const edits = [];
2078
+ for (const line of text.split("\n").filter(Boolean)) try {
2079
+ const content = JSON.parse(line)?.message?.content;
2080
+ if (!Array.isArray(content)) continue;
2081
+ for (const block of content) if (block?.type === "tool_use" && block.name === "Edit" && block.input?.file_path) edits.push({
2082
+ file: block.input.file_path,
2083
+ oldStr: block.input.old_string ?? "",
2084
+ newStr: block.input.new_string ?? ""
2085
+ });
2086
+ } catch {}
2087
+ const seen = /* @__PURE__ */ new Map();
2088
+ for (const e of edits) seen.set(e.file.split("/").pop() ?? e.file, e);
2089
+ return [...seen.values()];
2090
+ }
2091
+ /** Extract the last assistant text report (first 500 lines) from a transcript. */
2092
+ async function transcriptReport(transcriptPath) {
2093
+ const text = await Bun.file(transcriptPath).text();
2094
+ let lastReport = "";
2095
+ for (const line of text.split("\n").filter(Boolean)) try {
2096
+ const entry = JSON.parse(line);
2097
+ if (entry?.message?.role !== "assistant") continue;
2098
+ for (const block of entry.message.content ?? []) if (block.type === "text" && block.text) lastReport = block.text;
2099
+ } catch {}
2100
+ return lastReport.split("\n").slice(0, 500).join("\n");
2101
+ }
2102
+ //#endregion
2103
+ //#region src/runtime/lifecycle/aipilot/cache-doc.ts
2104
+ /**
2105
+ * SubagentStop (matcher "research-expert") for the ai-pilot scope: extract the
2106
+ * agent's synthesis text from the transcript and cache it as documentation.
2107
+ * Ports `cache-doc-from-transcript.ts`. SubagentStop output here is a pure
2108
+ * side-effect (no stdout needed).
2109
+ */
2110
+ const TOOL_PATTERN = /context7__query-docs|exa__get_code_context|exa__web_search/;
2111
+ const MAX_DOC_SIZE = 20480;
2112
+ const MIN_TEXT_SIZE = 200;
2113
+ const MAX_DOCS = 15;
2114
+ const RETRY_DELAYS = [
2115
+ 500,
2116
+ 1e3,
2117
+ 2e3
2118
+ ];
2119
+ /** Extract the longest assistant synthesis + queried library ids from a transcript. */
2120
+ async function extractSynthesis(path) {
2121
+ const lines = (await Bun.file(path).text()).split("\n").filter(Boolean);
2122
+ const libraries = [];
2123
+ let synthesis = "";
2124
+ for (const line of lines) try {
2125
+ const entry = JSON.parse(line);
2126
+ const role = entry?.type ?? entry?.role;
2127
+ const contents = entry?.message?.content;
2128
+ if (!Array.isArray(contents)) continue;
2129
+ for (const block of contents) {
2130
+ if (block.type === "tool_use" && TOOL_PATTERN.test(block.name ?? "")) {
2131
+ const lib = block.input?.libraryId ?? block.input?.query ?? "";
2132
+ if (lib && !libraries.includes(lib)) libraries.push(lib);
2133
+ }
2134
+ if (role === "assistant" && block.type === "text" && typeof block.text === "string" && block.text.length > synthesis.length) synthesis = block.text;
2135
+ }
2136
+ } catch {}
2137
+ return {
2138
+ text: synthesis,
2139
+ libraries
2140
+ };
2141
+ }
2142
+ /**
2143
+ * SubagentStop research-expert: cache the synthesis text from the transcript.
2144
+ * @param transcript - Path to the agent JSONL transcript.
2145
+ * @param cwd - Fallback project root.
2146
+ * @param home - Home dir (defaults to `~`).
2147
+ */
2148
+ async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
2149
+ if (!transcript || !await Bun.file(transcript).exists()) return;
2150
+ const projPath = projectRootFromPaths(await transcriptFilePaths(transcript)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
2151
+ const cacheDir = cacheDirFor("doc", projPath, home);
2152
+ const docsDir = join(cacheDir, "docs");
2153
+ let result = await extractSynthesis(transcript);
2154
+ for (const delay of RETRY_DELAYS) {
2155
+ if (result.text.length >= MIN_TEXT_SIZE && result.libraries.length > 0) break;
2156
+ await Bun.sleep(delay);
2157
+ result = await extractSynthesis(transcript);
2158
+ }
2159
+ const { text, libraries } = result;
2160
+ if (text.length < MIN_TEXT_SIZE || libraries.length === 0) return;
2161
+ const indexFile = join(cacheDir, "index.json");
2162
+ const index = await readJsonFile(indexFile) ?? {
2163
+ project: projPath,
2164
+ docs: []
2165
+ };
2166
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2167
+ const content = text.slice(0, MAX_DOC_SIZE);
2168
+ const topic = libraries.join(", ");
2169
+ const docHash = hashText16(topic);
2170
+ await Bun.write(join(docsDir, `${docHash}.md`), content);
2171
+ const sizeKb = Math.floor(content.length / 1024);
2172
+ for (const lib of libraries) {
2173
+ index.docs = index.docs.filter((d) => d.library !== lib);
2174
+ index.docs.push({
2175
+ hash: docHash,
2176
+ library: lib,
2177
+ topic,
2178
+ timestamp,
2179
+ size_kb: sizeKb
2180
+ });
2181
+ }
2182
+ if (index.docs.length > MAX_DOCS) index.docs = index.docs.slice(-15);
2183
+ await writeJsonFile(indexFile, index, true);
2184
+ }
2185
+ //#endregion
2186
+ //#region src/runtime/lifecycle/aipilot/cache-lessons.ts
2187
+ /**
2188
+ * SubagentStop (matcher "sniper") for the ai-pilot scope: capture error
2189
+ * patterns + corrected code from the sniper transcript as lessons. Ports
2190
+ * `cache-sniper-lessons.ts` (the dead `promote-global-lessons.ts` spawn is
2191
+ * dropped — that helper no longer exists). Pure side-effect.
2192
+ */
2193
+ /**
2194
+ * SubagentStop sniper: extract lessons from the transcript and persist them.
2195
+ * @param transcript - Path to the agent JSONL transcript.
2196
+ * @param cwd - Fallback project root.
2197
+ * @param home - Home dir (defaults to `~`).
2198
+ */
2199
+ async function cacheSniperLessons(transcript, cwd, home = homedir()) {
2200
+ if (!transcript || !await Bun.file(transcript).exists()) return;
2201
+ const edits = await transcriptEdits(transcript);
2202
+ if (edits.length === 0) return;
2203
+ const projectPath = projectRootFromPaths(edits.map((e) => e.file)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
2204
+ const pHash = projectHash(projectPath);
2205
+ const cacheDir = cacheDirFor("lessons", projectPath, home);
2206
+ mkdirSync(cacheDir, { recursive: true });
2207
+ const report = await transcriptReport(transcript);
2208
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2209
+ const errors = edits.map((edit) => {
2210
+ const basename = edit.file.split("/").pop() ?? edit.file;
2211
+ const descLine = report.split("\n").find((l) => l.toLowerCase().includes(basename.toLowerCase()));
2212
+ const errorType = categorizeEdit(edit);
2213
+ return {
2214
+ error_type: errorType,
2215
+ pattern: descLine ?? `Code correction in ${basename}`,
2216
+ fix: `Fix ${errorType} in ${basename}`,
2217
+ count: 1,
2218
+ last_seen: timestamp,
2219
+ files: [edit.file],
2220
+ code: { line: (edit.newStr ?? "").split("\n").filter(Boolean).slice(0, 10) }
2221
+ };
2222
+ });
2223
+ await writeJsonFile(join(cacheDir, `${timestamp.replace(/:/g, "-")}.json`), {
2224
+ project: projectPath,
2225
+ timestamp,
2226
+ errors
2227
+ }, true);
2228
+ logCacheEvent("lessons", "hit", pHash, { count: edits.length }, home);
2229
+ }
2230
+ //#endregion
2231
+ //#region src/runtime/lifecycle/aipilot/cache-test.ts
2232
+ /**
2233
+ * SubagentStop (matcher "sniper") for the ai-pilot scope: extract linter
2234
+ * results from the transcript and cache per-file checksums. Ports
2235
+ * `cache-test-results.ts`. Pure side-effect.
2236
+ */
2237
+ /** Extract linter-related command/output text from a JSONL transcript. */
2238
+ async function extractLinterOutput(path) {
2239
+ const text = await Bun.file(path).text();
2240
+ const outputs = [];
2241
+ for (const line of text.split("\n").filter(Boolean)) try {
2242
+ const content = JSON.parse(line)?.message?.content;
2243
+ if (!Array.isArray(content)) continue;
2244
+ for (const block of content) {
2245
+ if (block.type === "tool_use" && block.name === "Bash") {
2246
+ const cmd = block.input?.command ?? "";
2247
+ if (/eslint|tsc|biome|npx.*lint/i.test(cmd)) outputs.push(cmd);
2248
+ }
2249
+ if (block.type === "tool_result" || block.type === "text") outputs.push(block.text ?? block.content ?? "");
2250
+ }
2251
+ } catch {}
2252
+ return outputs.join("\n");
2253
+ }
2254
+ /**
2255
+ * SubagentStop sniper: cache linter results per-file with checksums.
2256
+ * @param transcript - Path to the agent JSONL transcript.
2257
+ * @param cwd - Fallback project root.
2258
+ * @param home - Home dir (defaults to `~`).
2259
+ */
2260
+ async function cacheTestResults(transcript, cwd, home = homedir()) {
2261
+ if (!transcript || !await Bun.file(transcript).exists()) return;
2262
+ const projectPath = projectRootFromPaths(await transcriptFilePaths(transcript)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
2263
+ const pHash = projectHash(projectPath);
2264
+ const cacheDir = cacheDirFor("tests", projectPath, home);
2265
+ mkdirSync(cacheDir, { recursive: true });
2266
+ const resultsPath = join(cacheDir, "results.json");
2267
+ const linterOutput = await extractLinterOutput(transcript);
2268
+ if (!linterOutput) return;
2269
+ const srcFiles = await scanSourceFiles(projectPath);
2270
+ if (srcFiles.length === 0) return;
2271
+ const existing = await readJsonFile(resultsPath) ?? {
2272
+ timestamp: "",
2273
+ files: {}
2274
+ };
2275
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2276
+ const newFiles = {};
2277
+ for (const filepath of srcFiles) {
2278
+ const relPath = filepath.replace(`${projectPath}/`, "");
2279
+ const checksum = (await fileChecksum(filepath)).slice(0, 16);
2280
+ if (!checksum) continue;
2281
+ const basename = filepath.split("/").pop() ?? "";
2282
+ newFiles[relPath] = {
2283
+ checksum,
2284
+ eslint: linterOutput.includes(basename) && linterOutput.includes("error") ? "fail" : "pass",
2285
+ tsc: "pass",
2286
+ last_tested: timestamp
2287
+ };
2288
+ }
2289
+ await writeJsonFile(resultsPath, {
2290
+ timestamp,
2291
+ files: {
2292
+ ...existing.files,
2293
+ ...newFiles
2294
+ }
2295
+ }, true);
2296
+ logCacheEvent("tests", "hit", pHash, { count: Object.keys(newFiles).length }, home);
2297
+ }
2298
+ //#endregion
2299
+ //#region src/runtime/lifecycle/aipilot/apex-task-store.ts
2300
+ /**
2301
+ * APEX `task.json` mutation helpers + a directory-based lock for the ai-pilot
2302
+ * scope. Ported from the ai-pilot plugin's `apex/state.ts` + `apex/task-helpers.ts`
2303
+ * (now removed).
2304
+ */
2305
+ /**
2306
+ * Acquire a directory-based lock with a timeout.
2307
+ * @param lockDir - Path to the lock directory.
2308
+ * @param timeoutMs - Max wait in ms (default 5000).
2309
+ * @returns A release function, or null if acquisition timed out.
2310
+ */
2311
+ async function acquireLock(lockDir, timeoutMs = 5e3) {
2312
+ const start = Date.now();
2313
+ while (Date.now() - start < timeoutMs) try {
2314
+ await mkdir(lockDir, { recursive: false });
2315
+ return async () => {
2316
+ try {
2317
+ await rmdir(lockDir);
2318
+ } catch {}
2319
+ };
2320
+ } catch {
2321
+ await Bun.sleep(100);
2322
+ }
2323
+ return null;
2324
+ }
2325
+ /** Add a new task to `task.json`. */
2326
+ async function taskCreate(file, id, subject, desc) {
2327
+ const data = await readJsonFile(file);
2328
+ if (!data) return;
2329
+ data.tasks[id] = {
2330
+ subject,
2331
+ description: desc,
2332
+ status: "pending",
2333
+ phase: "pending",
2334
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
2335
+ doc_consulted: {},
2336
+ files_modified: [],
2337
+ blockedBy: []
2338
+ };
2339
+ await writeJsonFile(file, data);
2340
+ }
2341
+ /** Mark a task as in_progress in `task.json`. */
2342
+ async function taskStart(file, id, subject, desc, blocked) {
2343
+ const data = await readJsonFile(file);
2344
+ if (!data) return;
2345
+ if (!data.tasks[id]) data.tasks[id] = {
2346
+ subject: "",
2347
+ description: "",
2348
+ status: "in_progress",
2349
+ phase: "analyze",
2350
+ doc_consulted: {},
2351
+ files_modified: []
2352
+ };
2353
+ data.current_task = id;
2354
+ const task = data.tasks[id];
2355
+ Object.assign(task, {
2356
+ status: "in_progress",
2357
+ phase: "analyze",
2358
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
2359
+ });
2360
+ if (subject) task.subject = subject;
2361
+ if (desc) task.description = desc;
2362
+ if (blocked) task.blockedBy = blocked.split(",");
2363
+ await writeJsonFile(file, data);
2364
+ }
2365
+ /** Mark a task as completed in `task.json`. */
2366
+ async function taskComplete(file, id) {
2367
+ const data = await readJsonFile(file);
2368
+ if (!data?.tasks[id]) return;
2369
+ Object.assign(data.tasks[id], {
2370
+ status: "completed",
2371
+ phase: "completed",
2372
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
2373
+ });
2374
+ await writeJsonFile(file, data);
2375
+ }
2376
+ //#endregion
2377
+ //#region src/runtime/lifecycle/aipilot/sync-task.ts
2378
+ /**
2379
+ * PostToolUse (matcher "TaskCreate|TaskUpdate") for the ai-pilot scope:
2380
+ * synchronize Claude task tools with `.claude/apex/task.json` and prompt an
2381
+ * auto-commit on completion when git changes are detected. Ports
2382
+ * `sync-task-tracking.ts`.
2383
+ */
2384
+ /** True when the project has uncommitted git changes. */
2385
+ async function hasGitChanges(cwd) {
2386
+ try {
2387
+ const proc = Bun.spawn([
2388
+ "git",
2389
+ "status",
2390
+ "--porcelain"
2391
+ ], {
2392
+ cwd,
2393
+ stdout: "pipe",
2394
+ stderr: "ignore"
2395
+ });
2396
+ return (await new Response(proc.stdout).text()).trim().length > 0;
2397
+ } catch {
2398
+ return false;
2399
+ }
2400
+ }
2401
+ /** Handle a completed task: emit the commit reminder (or the no-change note). */
2402
+ async function onComplete(taskFile, taskId, projectRoot) {
2403
+ await taskComplete(taskFile, taskId);
2404
+ if (!await hasGitChanges(projectRoot)) return contextResponse("PostToolUse", "Task completed. No changes to commit.");
2405
+ return contextResponse("PostToolUse", `Task #${taskId} completed: ${(await readJsonFile(taskFile))?.tasks[taskId]?.subject ?? "Task"}\n\nChanges detected. MANDATORY: Run /fuse-commit-pro:commit to commit with smart detection.`);
2406
+ }
2407
+ /**
2408
+ * PostToolUse TaskCreate/TaskUpdate handler.
2409
+ * @param payload - The raw hook payload (`tool_name`, `tool_input`, `tool_response`).
2410
+ * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
2411
+ * @returns The native hook stdout (possibly empty).
2412
+ */
2413
+ async function syncTaskTracking(payload, cwd) {
2414
+ const toolName = String(payload.tool_name ?? "");
2415
+ if (toolName !== "TaskCreate" && toolName !== "TaskUpdate") return "";
2416
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR ?? cwd;
2417
+ const taskFile = join(projectRoot, ".claude", "apex", "task.json");
2418
+ if (!existsSync(taskFile)) return "";
2419
+ const unlock = await acquireLock(join(projectRoot, ".claude", "apex", ".task.lock"), 1e4);
2420
+ if (!unlock) return "";
2421
+ try {
2422
+ const ti = payload.tool_input ?? {};
2423
+ if (toolName === "TaskCreate") {
2424
+ const existing = (await readJsonFile(taskFile))?.tasks ?? {};
2425
+ await taskCreate(taskFile, payload.tool_response?.id ?? String(Math.max(0, ...Object.keys(existing).map(Number)) + 1), ti.subject ?? "", ti.description ?? "");
2426
+ return "";
2427
+ }
2428
+ const taskId = ti.taskId ?? "";
2429
+ if (!taskId) return "";
2430
+ const newStatus = ti.status ?? "";
2431
+ if (newStatus === "in_progress") {
2432
+ const blocked = ti.addBlockedBy?.join(",") ?? "";
2433
+ await taskStart(taskFile, taskId, ti.subject || void 0, ti.description || void 0, blocked || void 0);
2434
+ }
2435
+ if (newStatus === "completed") return onComplete(taskFile, taskId, projectRoot);
2436
+ return "";
2437
+ } finally {
2438
+ await unlock();
2439
+ }
2440
+ }
2441
+ //#endregion
2442
+ //#region src/runtime/lifecycle/aipilot/dispatch-aipilot.ts
2443
+ /**
2444
+ * ai-pilot scope dispatcher: routes Claude lifecycle events to the ported
2445
+ * cache/injection handlers by (event, agent_type matcher).
2446
+ */
2447
+ /** The agent_type a SubagentStart/Stop payload reports. */
2448
+ function agentTypeOf(payload) {
2449
+ return String(payload.agent_type ?? payload.subagent_type ?? "");
2450
+ }
2451
+ /** Extract the `additionalContext` text from a SubagentStart response, or "". */
2452
+ function contextTextOf(response) {
2453
+ if (!response) return "";
2454
+ try {
2455
+ return JSON.parse(response).hookSpecificOutput?.additionalContext ?? "";
2456
+ } catch {
2457
+ return "";
2458
+ }
2459
+ }
2460
+ /**
2461
+ * Combine two SubagentStart responses into one (Claude concatenates multiple
2462
+ * hooks' additionalContext; collapsing two matcher-"" scripts into one dispatch
2463
+ * call must do that join itself). Returns "" when both are empty.
2464
+ */
2465
+ function combineContext(a, b) {
2466
+ const parts = [contextTextOf(a), contextTextOf(b)].filter(Boolean);
2467
+ return parts.length ? contextResponse("SubagentStart", parts.join("\n\n")) : "";
2468
+ }
2469
+ /** The transcript path a SubagentStop payload reports (Stop-only field). */
2470
+ function transcriptOf(payload) {
2471
+ const t = payload.agent_transcript_path;
2472
+ return typeof t === "string" ? t : void 0;
2473
+ }
2474
+ /** SubagentStart routing: cache injectors keyed on agent_type. */
2475
+ async function onSubagentStart(payload, cwd, now) {
2476
+ const agent = agentTypeOf(payload);
2477
+ if (agent.includes("explore-codebase")) return injectExploreCache(cwd, void 0, now);
2478
+ if (agent.includes("research-expert")) return injectDocCache(cwd, void 0, now);
2479
+ if (agent.includes("sniper")) return injectTestCache(cwd, void 0, now);
2480
+ return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now));
2481
+ }
2482
+ /** SubagentStop routing: transcript-driven cache writers (side-effects). */
2483
+ async function onSubagentStop(payload, cwd) {
2484
+ const agent = agentTypeOf(payload);
2485
+ const transcript = transcriptOf(payload);
2486
+ if (agent.includes("research-expert")) {
2487
+ await cacheDocFromTranscript(transcript, cwd);
2488
+ return "";
2489
+ }
2490
+ if (agent.includes("sniper")) {
2491
+ await cacheSniperLessons(transcript, cwd);
2492
+ await cacheTestResults(transcript, cwd);
2493
+ }
2494
+ return "";
2495
+ }
2496
+ /**
2497
+ * Dispatch an ai-pilot-scope lifecycle event. Returns the native stdout, or
2498
+ * `null` when unhandled (caller falls through to the default pipeline).
2499
+ */
2500
+ async function dispatchAipilot(event, payload, cwd, now) {
2501
+ if (event === "SubagentStart") return onSubagentStart(payload, cwd, now);
2502
+ if (event === "SubagentStop") return onSubagentStop(payload, cwd);
2503
+ if (event === "SessionEnd") {
2504
+ await cacheAnalyticsSave(void 0, now);
2505
+ return "";
2506
+ }
2507
+ return null;
2508
+ }
2509
+ /** PostToolUse (TaskCreate/TaskUpdate) sync for the ai-pilot scope. */
2510
+ async function aipilotPostToolUse(payload, cwd) {
2511
+ return syncTaskTracking(payload, cwd);
2512
+ }
2513
+ //#endregion
2514
+ //#region src/runtime/lifecycle/dispatch.ts
2515
+ /** SessionStart handler keyed on plugin scope. */
2516
+ function sessionStart(input) {
2517
+ if (input.scope === "solid") return solidDetectStart();
2518
+ if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
2519
+ if (input.scope === "carto") return cartoSessionStart(input.cwd);
2520
+ return sessionStartCore(input.cwd, void 0, input.now);
2521
+ }
2522
+ /**
2523
+ * Route a lifecycle/session/context hook event to its ported handler. Returns
2524
+ * the native stdout when handled, or `null` when the event is not a lifecycle
2525
+ * event (so the caller falls through to the PreToolUse/PostToolUse pipeline).
2526
+ * @param input - The dispatch input.
2527
+ * @returns The native hook stdout, or `null` when unhandled.
2528
+ */
2529
+ function dispatchLifecycle(input) {
2530
+ switch (input.event) {
2531
+ case "SessionStart": return sessionStart(input);
2532
+ case "UserPromptSubmit": return input.scope === "rules" ? injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd) : null;
2533
+ case "SubagentStart": return input.scope === "aipilot" ? "" : subagentCacheContext(input.payload.session_id);
2534
+ case "SubagentStop": return input.scope === "aipilot" ? "" : trackAgentMemory(input.payload, void 0, input.now);
2535
+ case "TeammateIdle": return validateTeammateOutput(input.payload);
2536
+ case "PostToolUseFailure":
2537
+ logToolFailure(input.payload, void 0, input.now);
2538
+ return "";
2539
+ case "PreCompact": return saveApexState(input.cwd, input.now);
2540
+ case "SessionEnd":
2541
+ if (input.scope !== "aipilot") cleanupSession(void 0, input.now);
2542
+ return "";
2543
+ case "InstructionsLoaded":
2544
+ validateRulesLoaded(input.payload);
2545
+ return "";
2546
+ default: return null;
2547
+ }
2548
+ }
2549
+ //#endregion
2550
+ //#region src/runtime/lifecycle/cartographer/track-enrichment.ts
2551
+ /**
2552
+ * Enrichment tracker (PostToolUse Edit/Write on `.cartographer/**\/index.md`).
2553
+ * Ports `track-enrichment.py`: persists manual descriptions to a sidecar so the
2554
+ * next regeneration can preserve them.
2555
+ */
2556
+ /**
2557
+ * Record manually-edited descriptions from a cartographer `index.md` into the
2558
+ * adjacent `.enriched.json` sidecar. No-op for unrelated paths. No stdout.
2559
+ * @param filePath - The edited file path.
2560
+ */
2561
+ function trackEnrichment(filePath) {
2562
+ if (!filePath || !filePath.includes(".cartographer") || !filePath.endsWith("index.md")) return;
2563
+ if (!existsSync(filePath)) return;
2564
+ const sidecar = join(dirname(filePath), ".enriched.json");
2565
+ let existing = {
2566
+ version: 1,
2567
+ entries: {}
2568
+ };
2569
+ if (existsSync(sidecar)) try {
2570
+ existing = JSON.parse(readFileSync(sidecar, "utf-8"));
2571
+ } catch {}
2572
+ const entries = existing.entries ??= {};
2573
+ let text = "";
2574
+ try {
2575
+ text = readFileSync(filePath, "utf-8");
2576
+ } catch {
2577
+ return;
2578
+ }
2579
+ for (const line of text.split("\n")) {
2580
+ const e = parseEnrichment(line);
2581
+ if (e) entries[e[0]] = e[1];
2582
+ }
2583
+ try {
2584
+ writeFileSync(sidecar, JSON.stringify(existing, null, 2) + "\n", "utf-8");
2585
+ } catch {}
2586
+ }
2587
+ //#endregion
2588
+ //#region src/runtime/lifecycle/security/skill-state.ts
2589
+ /**
2590
+ * Shared security-tracker state: per-UTC-day JSON under
2591
+ * `~/.claude/logs/00-security`. Ports the state helpers of
2592
+ * `check-security-skill.py` / `track-skill-read.py` / `track-mcp-research.py`.
2593
+ */
2594
+ /** `~/.claude/logs/00-security` state directory. */
2595
+ function securityStateDir(home = homedir()) {
2596
+ return join(claudeHome(home), "logs", "00-security");
2597
+ }
2598
+ /** Current UTC date as `YYYY-MM-DD`. */
2599
+ function todayUtc(now = Date.now()) {
2600
+ return new Date(now).toISOString().slice(0, 10);
2601
+ }
2602
+ /** Current UTC instant as `YYYY-MM-DDTHH:MM:SSZ` (seconds, no millis). */
2603
+ function isoUtc(now = Date.now()) {
2604
+ return new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
2605
+ }
2606
+ /** Today's security-state file path. */
2607
+ function securityStatePath(now = Date.now(), home = homedir()) {
2608
+ return join(securityStateDir(home), `${todayUtc(now)}-state.json`);
2609
+ }
2610
+ /** Load today's security state, or `{}` when missing/corrupt. */
2611
+ function loadSecurityState(now = Date.now(), home = homedir()) {
2612
+ const path = securityStatePath(now, home);
2613
+ try {
2614
+ if (!existsSync(path)) return {};
2615
+ const data = JSON.parse(readFileSync(path, "utf-8"));
2616
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
2617
+ } catch {
2618
+ return {};
2619
+ }
2620
+ }
2621
+ /** Persist today's security state (indent 2, no trailing newline). */
2622
+ function saveSecurityState(state, now = Date.now(), home = homedir()) {
2623
+ mkdirSync(securityStateDir(home), { recursive: true });
2624
+ writeFileSync(securityStatePath(now, home), JSON.stringify(state, null, 2), "utf-8");
2625
+ }
2626
+ //#endregion
2627
+ //#region src/runtime/lifecycle/security/track-skill-read.ts
2628
+ /**
2629
+ * Security skill-read tracker (PostToolUse Read). Ports `track-skill-read.py`:
2630
+ * flips `skill_read` once a security skill reference is read.
2631
+ */
2632
+ const SKILL_RE = /skills\/(security-scan|cve-research|dependency-audit|security-headers|auth-audit)\//;
2633
+ /**
2634
+ * Mark the security skill as read when a Read hits a security skill reference.
2635
+ * No-op for other tools/paths. No stdout.
2636
+ * @param tool - The tool name.
2637
+ * @param filePath - The read file path.
2638
+ * @param now - Clock.
2639
+ * @param home - Home dir.
2640
+ */
2641
+ function trackSkillRead(tool, filePath, now = Date.now(), home = homedir()) {
2642
+ if (tool !== "Read") return;
2643
+ if (!SKILL_RE.test(filePath)) return;
2644
+ const state = loadSecurityState(now, home);
2645
+ state.skill_read = true;
2646
+ (state.reads ??= []).push({
2647
+ timestamp: isoUtc(now),
2648
+ file: filePath
2649
+ });
2650
+ saveSecurityState(state, now, home);
2651
+ }
2652
+ //#endregion
2653
+ //#region src/runtime/lifecycle/security/track-mcp.ts
2654
+ /**
2655
+ * Security MCP-research tracker (PostToolUse context7/exa). Ports
2656
+ * `track-mcp-research.py`: logs documentation queries to today's state.
2657
+ */
2658
+ /**
2659
+ * Append a context7/exa research call to today's security state. No-op for other
2660
+ * tools. No stdout.
2661
+ * @param tool - The tool name.
2662
+ * @param input - The tool input (query/libraryId/libraryName).
2663
+ * @param now - Clock.
2664
+ * @param home - Home dir.
2665
+ */
2666
+ function trackMcpResearch(tool, input, now = Date.now(), home = homedir()) {
2667
+ if (!tool.includes("context7") && !tool.includes("exa")) return;
2668
+ const query = String(input.query ?? input.libraryId ?? input.libraryName ?? "");
2669
+ const state = loadSecurityState(now, home);
2670
+ (state.research ??= []).push({
2671
+ timestamp: isoUtc(now),
2672
+ tool,
2673
+ query
2674
+ });
2675
+ saveSecurityState(state, now, home);
2676
+ }
2677
+ //#endregion
2678
+ //#region src/runtime/lifecycle/changelog-research.ts
2679
+ /**
2680
+ * Changelog research tracker (PostToolUse exa/WebFetch/WebSearch). Ports
2681
+ * `track-watch-research.py`: logs research queries to
2682
+ * `~/.claude/logs/00-changelog/<utc-date>-research.json`.
2683
+ */
2684
+ /**
2685
+ * Append an exa/WebFetch/WebSearch query to today's changelog research log.
2686
+ * No-op for other tools. No stdout (errors swallowed).
2687
+ * @param tool - The tool name.
2688
+ * @param input - The tool input (query/url/prompt).
2689
+ * @param now - Clock.
2690
+ * @param home - Home dir.
2691
+ */
2692
+ function trackWatchResearch(tool, input, now = Date.now(), home = homedir()) {
2693
+ if (!tool.includes("exa") && !tool.includes("WebFetch") && !tool.includes("WebSearch")) return;
2694
+ const query = String(input.query ?? input.url ?? input.prompt ?? "");
2695
+ const dir = join(claudeHome(home), "logs", "00-changelog");
2696
+ try {
2697
+ mkdirSync(dir, { recursive: true });
2698
+ const path = join(dir, `${todayUtc(now)}-research.json`);
2699
+ let state = { queries: [] };
2700
+ if (existsSync(path)) try {
2701
+ state = JSON.parse(readFileSync(path, "utf-8"));
2702
+ } catch {
2703
+ state = { queries: [] };
2704
+ }
2705
+ state.queries.push({
2706
+ timestamp: isoUtc(now),
2707
+ tool,
2708
+ query
2709
+ });
2710
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
2711
+ } catch {}
2712
+ }
2713
+ //#endregion
2714
+ //#region src/runtime/lifecycle/post-tracking.ts
2715
+ /**
2716
+ * Dispatch the appropriate PostToolUse tracker for the invoking scope. Carto
2717
+ * persists manual enrichments; security records skill reads + MCP research;
2718
+ * changelog records watch research. Side-effect only.
2719
+ * @param scope - The invoking plugin scope.
2720
+ * @param event - The normalized event.
2721
+ * @param input - The raw tool input.
2722
+ * @param now - Clock.
2723
+ */
2724
+ function postTrackingSideEffects(scope, event, input, now) {
2725
+ if (scope === "carto" && (event.tool === "Edit" || event.tool === "Write") && event.filePath) {
2726
+ trackEnrichment(event.filePath);
2727
+ return;
2728
+ }
2729
+ if (scope === "security") {
2730
+ trackSkillRead(event.tool, event.filePath ?? "", now);
2731
+ trackMcpResearch(event.tool, input, now);
2732
+ return;
2733
+ }
2734
+ if (scope === "changelog") trackWatchResearch(event.tool, input, now);
2735
+ }
2736
+ //#endregion
2737
+ //#region src/runtime/lifecycle/security/check-skill.ts
2738
+ /**
2739
+ * Security advisory (PreToolUse Write/Edit on code files) — NON-BLOCKING.
2740
+ * Ports `check-security-skill.py`: nudge the agent to read the security skill,
2741
+ * but always allow the edit.
2742
+ */
2743
+ const CODE_RE = /\.(ts|tsx|js|jsx|py|php|swift|go|rs|rb|java)$/;
2744
+ const ADVISORY = "SECURITY: Read security skill references before modifying code. Use: Read skills/security-scan/references/scan-patterns.md";
2745
+ /**
2746
+ * Build a non-blocking PreToolUse `allow` response with a security advisory when
2747
+ * editing a code file before the security skill has been read. "" otherwise.
2748
+ * @param tool - The tool name (`Write`/`Edit`).
2749
+ * @param filePath - The target file path.
2750
+ * @param now - Clock.
2751
+ * @param home - Home dir.
2752
+ * @returns The advisory response JSON, or "".
2753
+ */
2754
+ function securityAdvisory(tool, filePath, now = Date.now(), home = homedir()) {
2755
+ if (tool !== "Write" && tool !== "Edit") return "";
2756
+ if (!CODE_RE.test(filePath)) return "";
2757
+ const path = securityStatePath(now, home);
2758
+ if (existsSync(path)) try {
2759
+ if (JSON.parse(readFileSync(path, "utf-8")).skill_read === true) return "";
2760
+ } catch {}
2761
+ return JSON.stringify({ hookSpecificOutput: {
2762
+ hookEventName: "PreToolUse",
2763
+ permissionDecision: "allow",
2764
+ additionalContext: ADVISORY
2765
+ } });
2766
+ }
2767
+ //#endregion
2768
+ //#region src/runtime/lifecycle-bridge.ts
2769
+ /** Raw event name from a payload (Cline lacks one; lifecycle is Claude-only). */
2770
+ function rawEvent(payload) {
2771
+ return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
2772
+ }
2773
+ /**
2774
+ * Run the ported lifecycle/session/context hooks (SessionStart, SubagentStart/
2775
+ * Stop, TeammateIdle, PostToolUseFailure, PreCompact, SessionEnd,
2776
+ * InstructionsLoaded, rules-scope UserPromptSubmit). Returns the native stdout
2777
+ * when handled, or `null` to fall through to the tool-use pipeline.
2778
+ * @param payload - The raw hook payload.
2779
+ * @param cwd - Project root.
2780
+ * @param scope - The invoking plugin scope (defaults to `core`).
2781
+ * @param now - Clock.
2782
+ * @returns The native stdout, or `null` when unhandled.
2783
+ */
2784
+ function lifecycleStdout(payload, cwd, scope, now) {
2785
+ return dispatchLifecycle({
2786
+ event: rawEvent(payload),
2787
+ payload,
2788
+ cwd,
2789
+ scope,
2790
+ now
2791
+ });
2792
+ }
2793
+ /**
2794
+ * Post-edit additions for core-scope PostToolUse Write/Edit: track cumulative
2795
+ * session changes (sniper reminder) + report eslint/prettier issues. Returns the
2796
+ * combined extra stdout (track-changes wins; lint appended only when no track
2797
+ * output), or "" when nothing to emit.
2798
+ * @param scope - The invoking plugin scope.
2799
+ * @param event - The normalized event.
2800
+ * @param now - Clock.
2801
+ * @returns The extra stdout (possibly empty).
2802
+ */
2803
+ function postEditContext(scope, event, now) {
2804
+ if (scope !== "core" || event.tool !== "Write" && event.tool !== "Edit" || !event.filePath) return "";
2805
+ return trackSessionChanges(event.sessionId, event.filePath, void 0, now) || postEditTypescript(event.filePath);
2806
+ }
2807
+ //#endregion
2808
+ //#region src/runtime/dry-patterns.ts
2809
+ /** Short identifiers never worth a duplication check (control flow, tiny names). */
2810
+ const DRY_KEYWORDS = /* @__PURE__ */ new Set([
2811
+ "if",
2812
+ "for",
2813
+ "while",
2814
+ "switch",
2815
+ "catch",
2816
+ "return",
2817
+ "async",
2818
+ "new",
2819
+ "get",
2820
+ "set",
2821
+ "map",
2822
+ "run",
2823
+ "use",
2824
+ "test",
2825
+ "main"
2826
+ ]);
2827
+ /** Extensions treated as TS/JS-family for symbol extraction. */
2828
+ const TS_EXT = /* @__PURE__ */ new Set([
2829
+ ".ts",
2830
+ ".tsx",
2831
+ ".js",
2832
+ ".jsx",
2833
+ ".astro"
2834
+ ]);
2835
+ /** Declaration patterns whose capture group 1 is the declared symbol name (TS/JS). */
2836
+ const TS_PATTERNS = [
2837
+ /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/g,
2838
+ /(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/g,
2839
+ /class\s+(\w+)\b/g
2840
+ ];
2841
+ /**
2842
+ * Declaration patterns for PHP (capture group 1 = symbol name). The modifier run
2843
+ * is bounded (`{0,6}`) on purpose: an unbounded `(?:…\s+)*` is quadratic (O(n²))
2844
+ * on a long whitespace/keyword run with no trailing `function`, which would block
2845
+ * the hook for seconds on a crafted file. A real PHP signature has at most a few
2846
+ * leading keywords, so the bound is behavior-equivalent and keeps matching linear.
2847
+ */
2848
+ 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];
2849
+ /** Directories grep skips when scanning for existing declarations. */
2850
+ const EXCLUDE_DIRS = [
2851
+ "vendor",
2852
+ "node_modules",
2853
+ ".next",
2854
+ ".git",
2855
+ "dist",
2856
+ "build",
2857
+ "coverage",
2858
+ ".turbo"
2859
+ ];
2860
+ //#endregion
2861
+ //#region src/runtime/dry.ts
2862
+ /** Extract long (>12 char) declared symbol names from new file content. */
2863
+ function extractSymbols(content, ext) {
2864
+ const pats = TS_EXT.has(ext) ? TS_PATTERNS : ext === ".php" ? PHP_PATTERNS : [];
2865
+ const names = /* @__PURE__ */ new Set();
2866
+ for (const re of pats) for (const m of content.matchAll(re)) {
2867
+ const n = m[1];
2868
+ if (n && !DRY_KEYWORDS.has(n) && n.length > 12) names.add(n);
2869
+ }
2870
+ return [...names];
2871
+ }
2872
+ /** `modules/X/...` -> `"X"`, else `""` (module-boundary key). */
2873
+ function moduleOf(path) {
2874
+ const parts = path.split(sep);
2875
+ const i = parts.indexOf("modules");
2876
+ return i >= 0 && i + 1 < parts.length ? parts[i + 1] ?? "" : "";
2877
+ }
2878
+ /**
2879
+ * Grep the codebase for existing declarations of the symbols a write introduces,
2880
+ * honoring module boundaries (cross-`modules/` matches are ignored). Effectful:
2881
+ * shells out to `grep`. Fails open (returns no duplicates) on any grep error,
2882
+ * timeout, or no-match — matching the original Python hook.
2883
+ */
2884
+ function detectDuplication(filePath, content, cwd) {
2885
+ const ext = extname(filePath).toLowerCase();
2886
+ if (!TS_EXT.has(ext) && ext !== ".php") return {
2887
+ names: [],
2888
+ duplicates: []
2889
+ };
2890
+ const names = extractSymbols(content, ext);
2891
+ if (!names.length) return {
2892
+ names,
2893
+ duplicates: []
2894
+ };
2895
+ const include = TS_EXT.has(ext) ? [
2896
+ "--include=*.ts",
2897
+ "--include=*.tsx",
2898
+ "--include=*.js",
2899
+ "--include=*.jsx"
2900
+ ] : ["--include=*.php"];
2901
+ 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`;
2902
+ let out = "";
2903
+ try {
2904
+ out = execFileSync("grep", [
2905
+ "-rEl",
2906
+ ...EXCLUDE_DIRS.map((d) => `--exclude-dir=${d}`),
2907
+ ...include,
2908
+ "--",
2909
+ pattern,
2910
+ cwd
2911
+ ], {
2912
+ encoding: "utf8",
2913
+ timeout: 1500
2914
+ });
2915
+ } catch {
2916
+ return {
2917
+ names,
2918
+ duplicates: []
2919
+ };
2920
+ }
2921
+ const self = resolve(filePath);
2922
+ const targetMod = moduleOf(filePath);
2923
+ const duplicates = [];
2924
+ for (const line of out.split("\n")) {
2925
+ const f = line.trim();
2926
+ if (!f || resolve(f) === self) continue;
2927
+ const dupMod = moduleOf(f);
2928
+ if (targetMod && dupMod && dupMod !== targetMod) continue;
2929
+ duplicates.push(f);
2930
+ }
2931
+ return {
2932
+ names,
2933
+ duplicates
2934
+ };
2935
+ }
2936
+ /** Blocking prompt when a Write/Edit re-declares 2+ existing symbols, else null. */
2937
+ function dryGate(tool, filePath, content, cwd) {
2938
+ if (!cwd || tool !== "Write" && tool !== "Edit" || !content) return null;
2939
+ const dup = detectDuplication(filePath, content, cwd);
2940
+ if (dup.duplicates.length < 2) return null;
2941
+ return {
2942
+ kind: "block",
2943
+ title: "Duplicate code (DRY)",
2944
+ reason: `[${dup.names.slice(0, 5).join(", ")}] already declared in: ${dup.duplicates.slice(0, 3).join(", ")}. Import and reuse instead of re-declaring.`,
2945
+ actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
2946
+ };
2947
+ }
2948
+ //#endregion
2949
+ //#region src/runtime/precommit.ts
2950
+ const TIMEOUT_MS = 3e4;
2951
+ const ESLINT_CONFIGS = [
2952
+ ".eslintrc.json",
2953
+ ".eslintrc.js",
2954
+ "eslint.config.js",
2955
+ "eslint.config.mjs",
2956
+ "eslint.config.ts"
2957
+ ];
2958
+ const PRETTIER_CONFIGS = [
2959
+ ".prettierrc",
2960
+ ".prettierrc.json",
2961
+ "prettier.config.js"
2962
+ ];
2963
+ /** Run a linter; returns its error output, or "" if it passed / spawn-failed / timed out (fail-open). */
2964
+ function runLinter(file, args, label, cwd) {
2965
+ try {
2966
+ execFileSync(file, args, {
2967
+ cwd,
2968
+ timeout: TIMEOUT_MS,
2969
+ stdio: [
2970
+ "ignore",
2971
+ "pipe",
2972
+ "pipe"
2973
+ ]
2974
+ });
2975
+ return "";
2976
+ } catch (e) {
2977
+ const err = e;
2978
+ if (err.status === void 0 || err.status === null) return "";
2979
+ const out = (err.stdout?.toString() ?? err.stderr?.toString() ?? "").trim();
2980
+ return out ? `[${label}]\n${out}` : "";
2981
+ }
2982
+ }
2983
+ /** Run the applicable linters in `cwd`, returning a block of errors per failing tool. */
2984
+ function collectErrors(cwd) {
2985
+ const has = (f) => existsSync(join(cwd, f));
2986
+ const errors = [];
2987
+ if (has("package.json")) {
2988
+ if (ESLINT_CONFIGS.some(has)) {
2989
+ const m = runLinter("bunx", [
2990
+ "eslint",
2991
+ ".",
2992
+ "--max-warnings",
2993
+ "0"
2994
+ ], "ESLint", cwd);
2995
+ if (m) errors.push(m);
2996
+ }
2997
+ if (has("tsconfig.json")) {
2998
+ const m = runLinter("bunx", ["tsc", "--noEmit"], "TypeScript", cwd);
2999
+ if (m) errors.push(m);
3000
+ }
3001
+ if (PRETTIER_CONFIGS.some(has)) {
3002
+ const m = runLinter("bunx", [
3003
+ "prettier",
3004
+ "--check",
3005
+ "."
3006
+ ], "Prettier", cwd);
3007
+ if (m) errors.push(m);
3008
+ }
3009
+ }
3010
+ if (has("requirements.txt") || has("pyproject.toml")) {
3011
+ const m = runLinter("ruff", ["check", "."], "Ruff", cwd);
3012
+ if (m) errors.push(m);
3013
+ }
3014
+ return errors;
3015
+ }
3016
+ /** Block a `git commit` when linters fail (effectful: runs eslint/tsc/prettier/ruff, never auto-fixes). */
3017
+ function preCommitGate(tool, command, cwd) {
3018
+ if (tool !== "Bash" || !command || !cwd) return null;
3019
+ if (!command.startsWith("git") || !command.includes("commit")) return null;
3020
+ const errors = collectErrors(cwd);
3021
+ if (!errors.length) return null;
3022
+ return {
3023
+ kind: "block",
3024
+ title: "Pre-commit checks failed",
3025
+ reason: `COMMIT BLOCKED — fix then retry:\n\n${errors.join("\n\n")}`,
3026
+ actions: ["Fix the linter/type errors above", "Re-run the commit"]
3027
+ };
3028
+ }
3029
+ //#endregion
3030
+ //#region src/runtime/modular.ts
3031
+ 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)$/;
3032
+ const NEXT_STATIC = /\.(css|ico|png|jpg|svg|json)$/;
3033
+ const PHP_BLOCKED_IN_APP = [
3034
+ "/app/Models/",
3035
+ "/app/Services/",
3036
+ "/app/Actions/",
3037
+ "/app/Http/Controllers/",
3038
+ "/app/Http/Requests/",
3039
+ "/app/Http/Resources/",
3040
+ "/app/Contracts/",
3041
+ "/app/DTOs/",
3042
+ "/app/Repositories/",
3043
+ "/app/Events/",
3044
+ "/app/Listeners/",
3045
+ "/app/Jobs/",
3046
+ "/app/Notifications/",
3047
+ "/app/Policies/"
3048
+ ];
3049
+ const block = (reason) => ({
3050
+ kind: "block",
3051
+ title: "Modular architecture",
3052
+ reason,
3053
+ actions: ["Move the code into the correct feature module", "Import only from the shared core module"]
3054
+ });
3055
+ /** Next.js `modules/` architecture: `app/` convention + cross-module import rules. */
3056
+ function nextModular(filePath, content, cwd) {
3057
+ const rel = relative(cwd, filePath);
3058
+ const bn = basename(filePath);
3059
+ 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]/.`);
3060
+ const mod = filePath.match(/\/modules\/([^/]+)\//);
3061
+ if (!mod) return null;
3062
+ const current = mod[1] ?? "";
3063
+ for (const m of content.matchAll(/from\s+['"][@.][^'"]*?\/modules\/([^/]+)\//g)) {
3064
+ const imported = m[1] ?? "";
3065
+ if (current === "cores") {
3066
+ if (imported !== "cores" && imported !== "core") return block(`BLOCKED: modules/cores/ must not import from modules/${imported}/.`);
3067
+ } else if (imported !== current && imported !== "cores" && imported !== "core") return block(`BLOCKED: cross-module import — '${current}' imports '${imported}'. Only modules/cores/ is shared.`);
3068
+ }
3069
+ return null;
3070
+ }
3071
+ /** Laravel FuseCore architecture: `app/` domain ban + module.json + cross-module `use` rules. */
3072
+ function fusecore(filePath, content, cwd) {
3073
+ 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/.`);
3074
+ const mod = filePath.match(/\/FuseCore\/([A-Za-z]+)\//);
3075
+ if (!mod) return null;
3076
+ const name = mod[1] ?? "";
3077
+ if (!existsSync(join(cwd, "FuseCore", name, "module.json"))) return block(`BLOCKED: FuseCore module '${name}' is missing module.json — create it first.`);
3078
+ for (const m of content.matchAll(/use\s+FuseCore\\(\w+)\\/g)) {
3079
+ const imported = m[1] ?? "";
3080
+ if (name === "Core") {
3081
+ if (imported !== "Core") return block(`BLOCKED: FuseCore\\Core\\ must not use FuseCore\\${imported}\\.`);
3082
+ } else if (imported !== name && imported !== "Core") return block(`BLOCKED: cross-module use — '${name}' uses '${imported}'. Only FuseCore\\Core\\ is shared.`);
3083
+ }
3084
+ return null;
3085
+ }
3086
+ /** Enforce the project's modular architecture (Next.js `modules/` or Laravel FuseCore) on a Write/Edit. */
3087
+ function modularGate(tool, filePath, content, cwd) {
3088
+ if (tool !== "Write" && tool !== "Edit" || !filePath || !cwd) return null;
3089
+ if (/\/(node_modules|dist|build|\.next|vendor|storage)\//.test(filePath)) return null;
3090
+ const arch = detectModularArchitecture(cwd);
3091
+ if (arch === "nextjs-modular" && /\.(tsx|ts|jsx|js)$/.test(filePath)) return nextModular(filePath, content ?? "", cwd);
3092
+ if (arch === "fusecore" && filePath.endsWith(".php")) return fusecore(filePath, content ?? "", cwd);
3093
+ return null;
3094
+ }
3095
+ //#endregion
3096
+ //#region src/runtime/framework-skill-gate.ts
3097
+ /**
3098
+ * Effective line count for the SOLID size check. On an Edit, `content` is only
3099
+ * the `new_string` snippet, so judge the larger of the snippet and the full
3100
+ * on-disk file (`existingLines`) — mirroring the base file-size guard and the
3101
+ * Python `get_full_file_content`. On Write, `content` IS the full file, so the
3102
+ * snippet count stands (undefined → the gate falls back to `countLines`).
3103
+ * @param tool - the tool name ("Edit" | "Write" | ...).
3104
+ * @param content - the written content (snippet on Edit, full file on Write).
3105
+ * @param existingLines - full on-disk line count, when known.
3106
+ */
3107
+ function effectiveLines(tool, content, existingLines) {
3108
+ if (tool !== "Edit" || existingLines === void 0) return void 0;
3109
+ return Math.max(countLines(content), existingLines);
3110
+ }
3111
+ /**
3112
+ * Framework-aware SOLID + sub-skill gate, run on the Write/Edit path once a
3113
+ * `filePath` is present. Combines:
3114
+ * - {@link frameworkSolidGate}: framework-specific SOLID rules (line limits,
3115
+ * interface/protocol separation, `'use client'`, @MainActor...).
3116
+ * - {@link skillTriggerGate}: blocks when written APIs need a sub-skill that
3117
+ * was not read this session, also forcing the modular-architecture skill
3118
+ * resolved from disk via {@link requiredArchSkill}.
3119
+ *
3120
+ * @param input - the gated tool-use (filePath + content + framework + cwd).
3121
+ * @param refsRead - in-session read reference paths (from the loaded track).
3122
+ * @param existingLines - full on-disk line count (so an Edit on an oversized
3123
+ * file still fires the framework SOLID size rule). Omit on Write.
3124
+ * @returns the first blocking {@link Prompt}, or `null` to allow.
3125
+ */
3126
+ function frameworkSkillGate(input, refsRead, existingLines) {
3127
+ if (!input.filePath) return null;
3128
+ const content = input.content ?? "";
3129
+ const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, content, existingLines));
3130
+ if (solid) return solid;
3131
+ const forced = input.cwd ? requiredArchSkill(input.cwd) : null;
3132
+ return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
3133
+ }
3134
+ //#endregion
3135
+ //#region src/runtime/gate.ts
3136
+ /** Prior agents the freshness gate requires before a code edit. */
3137
+ const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
3138
+ /** Default freshness window for {@link REQUIRED_AGENTS} (2 min — matches the plugin's `FUSE_ENFORCE_TTL_SEC` default). */
3139
+ const DEFAULT_WINDOW_MS = 12e4;
3140
+ /** Trivial edits allowed within the window before the full APEX gates apply. */
3141
+ const TRIVIAL_BUDGET = 4;
3142
+ /**
3143
+ * Code-only line count of the existing on-disk file (undefined if
3144
+ * absent/unreadable). Uses {@link countLines} (skips blank/comment lines) to
3145
+ * mirror the Python `count_code_lines(get_full_file_content(...))`, so a partial
3146
+ * Edit judges the full file by the SAME metric as the incoming snippet — a raw
3147
+ * `split("\n").length` would over-count JSDoc/blank lines (and add a
3148
+ * trailing-newline off-by-one), falsely blocking well-documented files.
3149
+ */
3150
+ function existingLineCount(path) {
3151
+ if (!path) return void 0;
3152
+ try {
3153
+ return existsSync(path) ? countLines(readFileSync(path, "utf8")) : void 0;
3154
+ } catch {
3155
+ return;
3156
+ }
3157
+ }
3158
+ /**
3159
+ * Full gate: the stateless guards (file-size, git, security...) first, then a
3160
+ * trivial-edit fast path, then the stateful APEX gates fed from the session
3161
+ * track. Returns the first blocking prompt, or null to allow.
3162
+ */
3163
+ async function gate(input) {
3164
+ const existingLines = existingLineCount(input.filePath);
3165
+ let quick;
3166
+ try {
3167
+ quick = evaluate({
3168
+ tool: input.tool,
3169
+ filePath: input.filePath,
3170
+ content: input.content,
3171
+ command: input.command,
3172
+ agentType: input.agentType,
3173
+ existingLines
3174
+ });
3175
+ } catch {
3176
+ return FAIL_CLOSED;
3177
+ }
3178
+ if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
3179
+ const precommit = preCommitGate(input.tool, input.command, input.cwd);
3180
+ if (precommit) return precommit;
3181
+ const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
3182
+ if (modular) return modular;
3183
+ if (!input.filePath) return null;
3184
+ const window = input.windowMs ?? 12e4;
3185
+ const track = await loadTrack(input.trackFile);
3186
+ const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
3187
+ if (solidOrSkill) return solidOrSkill;
3188
+ const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
3189
+ if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
3190
+ await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
3191
+ return null;
3192
+ }
3193
+ const ctx = {
3194
+ sessionId: input.sessionId,
3195
+ framework: input.framework,
3196
+ filePath: input.filePath,
3197
+ content: input.content ?? "",
3198
+ authorizations: track.authorizations,
3199
+ refs: input.refs,
3200
+ refsRead: track.refsRead,
3201
+ agentsFresh: agentsFresh(track, [...REQUIRED_AGENTS], window, input.now),
3202
+ brainstormRequired: track.brainstormRequired,
3203
+ brainstormFresh: agentsFresh(track, ["brainstorming"], window, input.now)
3204
+ };
3205
+ try {
3206
+ const apex = evaluateApex(ctx);
3207
+ if (apex) return apex;
3208
+ } catch {
3209
+ return FAIL_CLOSED;
3210
+ }
3211
+ return dryGate(input.tool, input.filePath, input.content, input.cwd);
3212
+ }
3213
+ //#endregion
3214
+ //#region src/runtime/handle-pre.ts
3215
+ /**
3216
+ * Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX
3217
+ * Task context injection, then the stateless+APEX gate chain. Returns the native
3218
+ * hook outcome (deny/ask/inject or allow).
3219
+ * @param ctx - The resolved pre-context.
3220
+ * @returns The hook outcome.
3221
+ */
3222
+ async function handlePre(ctx) {
3223
+ const { id, payload, event, framework, mcpDir, file, opts } = ctx;
3224
+ const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now);
3225
+ if (intercept !== null) {
3226
+ if (intercept.docSource) await recordActivity(file, {
3227
+ kind: "doc",
3228
+ framework,
3229
+ sessionId: event.sessionId,
3230
+ source: intercept.docSource
3231
+ });
3232
+ return {
3233
+ stdout: intercept.stdout,
3234
+ exit: 0
3235
+ };
3236
+ }
3237
+ const designBlock = designGate(payload, event, mcpDir, opts.cwd);
3238
+ if (designBlock) return {
3239
+ stdout: respond(id, designBlock),
3240
+ exit: 0
3241
+ };
3242
+ if (opts.scope === "security") return {
3243
+ stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
3244
+ exit: 0
3245
+ };
3246
+ if (event.tool === "Task") {
3247
+ const taskCtx = taskContext(opts.cwd);
3248
+ if (taskCtx) return {
3249
+ stdout: taskCtx,
3250
+ exit: 0
3251
+ };
3252
+ }
3253
+ const prompt = await gate({
3254
+ sessionId: event.sessionId,
3255
+ framework,
3256
+ tool: event.tool,
3257
+ filePath: event.filePath,
3258
+ content: event.content,
3259
+ command: event.command,
3260
+ cwd: opts.cwd,
3261
+ refs: opts.refsDir ? await loadRefs(opts.refsDir) : void 0,
3262
+ isReplaceAll: event.input.replace_all === true,
3263
+ agentType: event.agentType,
3264
+ windowMs: opts.windowMs,
3265
+ now: opts.now,
3266
+ trackFile: file
3267
+ });
3268
+ return prompt ? {
3269
+ stdout: respond(id, prompt),
3270
+ exit: 0
3271
+ } : {
3272
+ stdout: "",
3273
+ exit: 0
3274
+ };
3275
+ }
3276
+ //#endregion
3277
+ //#region src/runtime/handle.ts
3278
+ /** Raw Claude hook event name from a payload (empty when absent). */
3279
+ function rawEventName(payload) {
3280
+ return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
3281
+ }
3282
+ /**
3283
+ * The full hook handler: on a PRE event it gates the tool-use (stateless guards
3284
+ * then APEX gates from the session track) and returns the native response; on a
3285
+ * POST event it records the activity into the track. The loop that makes the
3286
+ * package behave like the Claude plugin, on any harness.
3287
+ */
3288
+ async function handleHook(id, payload, opts) {
3289
+ const event = normalizeEvent(id, payload);
3290
+ const layout = projectLayout(opts.cwd);
3291
+ const file = trackFile(event.sessionId, layout.trackDir);
3292
+ const mcpDir = layout.cacheDir;
3293
+ const framework = detectFramework(event.filePath ?? "", event.content ?? "");
3294
+ if (designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
3295
+ stdout: "",
3296
+ exit: 0
3297
+ };
3298
+ if (opts.scope === "aipilot") {
3299
+ const ai = await dispatchAipilot(rawEventName(payload), payload, opts.cwd, opts.now);
3300
+ if (ai !== null) return {
3301
+ stdout: ai,
3302
+ exit: 0
3303
+ };
3304
+ }
3305
+ const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now);
3306
+ if (life !== null) return {
3307
+ stdout: life,
3308
+ exit: 0
3309
+ };
3310
+ const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
3311
+ if (userPrompt !== void 0) {
3312
+ await saveTrack(file, recordBrainstormRequired(await loadTrack(file), detectCreationIntent(userPrompt)));
3313
+ return {
3314
+ stdout: promptSubmitContext(userPrompt, opts.cwd),
3315
+ exit: 0
3316
+ };
3317
+ }
3318
+ if (event.phase === "post") {
3319
+ const response = payload.tool_response ?? payload.tool_output;
3320
+ mcpPostStore(event.tool, event.input, response, mcpDir);
3321
+ const designWarn = designGate(payload, event, mcpDir, opts.cwd);
3322
+ const activity = activityFor({
3323
+ tool: event.tool,
3324
+ input: event.input,
3325
+ sessionId: event.sessionId,
3326
+ framework,
3327
+ now: opts.now,
3328
+ responseLength: extractText(response).length
3329
+ });
3330
+ if (activity) await recordActivity(file, activity);
3331
+ postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now);
3332
+ if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate")) {
3333
+ const out = await aipilotPostToolUse(payload, opts.cwd);
3334
+ if (out) return {
3335
+ stdout: out,
3336
+ exit: 0
3337
+ };
3338
+ }
3339
+ const extra = postEditContext(opts.scope ?? "core", event, opts.now);
3340
+ return {
3341
+ stdout: designWarn ? respond(id, designWarn) : extra,
3342
+ exit: 0
3343
+ };
3344
+ }
3345
+ return handlePre({
3346
+ id,
3347
+ payload,
3348
+ event,
3349
+ framework,
3350
+ mcpDir,
3351
+ file,
3352
+ opts
3353
+ });
3354
+ }
3355
+ //#endregion
3356
+ export { purgeTtlTree as $, isProject as A, cleanupSession as B, todayUtc as C, activityFor as Ct, dispatchAipilot as D, aipilotPostToolUse as E, getFileDesc as F, subagentCacheContext as G, logToolFailure as H, listChildren as I, injectRules as J, detectSolidProfile as K, postEditTypescript as L, loadEnriched as M, mergeLines as N, cartoSessionStart as O, countFiles as P, pruneEmptyDirs as Q, trackSessionChanges as R, securityStatePath as S, queryOf as St, dispatchLifecycle as T, validateTeammateOutput as U, saveApexState as V, trackAgentMemory as W, runSessionStartCleanups as X, readRules as Y, sessionStartCore as Z, trackSkillRead as _, normalizeEvent as _t, TRIVIAL_BUDGET as a, claudeHome as at, saveSecurityState as b, mcpPostStore as bt, detectDuplication as c, sanitizeSessionId as ct, lifecycleStdout as d, sessionsDir as dt, removeOldFiles as et, postEditContext as f, promptSubmitContext as ft, trackMcpResearch as g, trackFile as gt, trackWatchResearch as h, recordActivity as ht, REQUIRED_AGENTS as i, projectContext as it, writeTree as j, generateProjectMap as k, dryGate as l, saveSessionState as lt, postTrackingSideEffects as m, respond as mt, handlePre as n, devContext as nt, gate as o, fusengineCache as ot, securityAdvisory as p, taskContext as pt, solidDetectStart as q, DEFAULT_WINDOW_MS as r, gitContext as rt, preCommitGate as s, loadSessionState as st, handleHook as t, trimLogFile as tt, extractSymbols as u, sessionStatePath as ut, isoUtc as v, MCP_TTL_MS as vt, trackEnrichment as w, securityStateDir as x, mcpPreIntercept as xt, loadSecurityState as y, isMcpTool as yt, validateRulesLoaded as z };