@fusengine/harness 0.1.37 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/adapters/claude/index.d.mts +2 -2
  2. package/dist/adapters/claude/index.mjs +2 -2
  3. package/dist/adapters/cline/index.mjs +1 -1
  4. package/dist/adapters/codex/index.d.mts +1 -1
  5. package/dist/adapters/codex/index.mjs +1 -1
  6. package/dist/adapters/cursor/index.mjs +1 -1
  7. package/dist/adapters/gemini/index.mjs +1 -1
  8. package/dist/cache/index.d.mts +2 -2
  9. package/dist/cache/index.mjs +3 -3
  10. package/dist/cache-la_KkjCS.mjs +1 -0
  11. package/dist/{claude-3PqBGt_7.mjs → claude-BnFrSfMo.mjs} +17 -3
  12. package/dist/cli/bin.mjs +4 -3
  13. package/dist/cli/index.mjs +1 -1
  14. package/dist/config/index.d.mts +2 -2
  15. package/dist/config/index.mjs +3 -2
  16. package/dist/{doc-helpers-Dd_x1-tZ.mjs → doc-helpers-B4XYL1v8.mjs} +8 -6
  17. package/dist/{doc-helpers-CG1nuf-c.d.mts → doc-helpers-D14nkD5D.d.mts} +5 -3
  18. package/dist/{dotenv-TLNMiSjP.mjs → dotenv-DGyLln7U.mjs} +1 -20
  19. package/dist/{evaluate-zyxeVZPB.mjs → evaluate-B1n1ti0N.mjs} +89 -50
  20. package/dist/freshness/index.d.mts +1 -1
  21. package/dist/freshness/index.mjs +1 -1
  22. package/dist/{handle-DL2sZiWH.mjs → handle-gYSq5znf.mjs} +587 -153
  23. package/dist/index-BX-xcvhY.d.mts +75 -0
  24. package/dist/{index-DLUfyYmU.d.mts → index-COBvvc3L.d.mts} +3 -1
  25. package/dist/{index-FrWgmkbP.d.mts → index-CwOdFBOr.d.mts} +11 -2
  26. package/dist/{index-CPoF_hLP.d.mts → index-D7GpOmkl.d.mts} +1 -1
  27. package/dist/{index-QzK2dv0V.d.mts → index-DTrjSNmI.d.mts} +38 -13
  28. package/dist/{index-DL8MxjuP.d.mts → index-mmRF3KNp.d.mts} +4 -4
  29. package/dist/index.d.mts +7 -7
  30. package/dist/index.mjs +9 -9
  31. package/dist/{loader-CyAoJv2W.mjs → loader-Bn-DbZmt.mjs} +1 -1
  32. package/dist/mcp-store-CDUVqtJ0.mjs +238 -0
  33. package/dist/policy/index.d.mts +2 -2
  34. package/dist/policy/index.mjs +3 -3
  35. package/dist/refs/index.d.mts +1 -1
  36. package/dist/refs/index.mjs +2 -2
  37. package/dist/{router-D8cVrI-s.mjs → router-BfX0hJg8.mjs} +13 -20
  38. package/dist/{run-CUL70W0k.mjs → run-D-Ydrw3D.mjs} +1 -1
  39. package/dist/runtime/index.d.mts +32 -12
  40. package/dist/runtime/index.mjs +2 -2
  41. package/dist/{session-state-DxNkIBZ2.d.mts → session-state-Dzq6yrw7.d.mts} +1 -1
  42. package/dist/state/index.d.mts +1 -1
  43. package/dist/{store-7j02oGjt.mjs → store-3WBr37Xz.mjs} +1 -1
  44. package/dist/tracking/index.d.mts +1 -1
  45. package/dist/tracking/index.mjs +1 -1
  46. package/dist/ttl-BG55s6HZ.mjs +20 -0
  47. package/dist/{validate-DLrWtaDR.mjs → validate-BCRqp9XG.mjs} +15 -9
  48. package/package.json +1 -1
  49. package/dist/cache-C9z9LclL.mjs +0 -56
  50. package/dist/index-DPkCX_AR.d.mts +0 -40
  51. package/dist/store-PrNPm6So.mjs +0 -89
@@ -1,15 +1,18 @@
1
1
  import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
2
  import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
3
+ import { t as HOME_DIR } from "./dotenv-DGyLln7U.mjs";
3
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
4
- import { A as evaluateApex, E as detectCreationIntent, L as requiredArchSkill, P as detectModularArchitecture, S as frameworkSolidGate, T as capVerbosity, _ as buildClaudeMdContext, b as skillTriggerGate, c as parseBodyDesc, d as PROJECT_INDICATORS, l as parseField, n as missingSeoElements, o as parseEnrichment, p as buildApexTaskInjection, r as descFromText, s as parseEntry, t as isHtmlLike, u as EXCLUDE_DIRS$1 } from "./validate-DLrWtaDR.mjs";
5
- import { j as detectFramework, k as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-zyxeVZPB.mjs";
5
+ import { t as detectHarness } from "./harness-C8Nxxyn_.mjs";
6
+ import { A as evaluateApex, E as detectCreationIntent, L as requiredArchSkill, P as detectModularArchitecture, S as frameworkSolidGate, T as capVerbosity, _ as buildClaudeMdContext, b as skillTriggerGate, c as parseBodyDesc, d as PROJECT_INDICATORS, l as parseField, n as missingSeoElements, o as parseEnrichment, p as buildApexTaskInjection, r as descFromText, s as parseEntry, t as isHtmlLike, u as EXCLUDE_DIRS$1 } from "./validate-BCRqp9XG.mjs";
7
+ import { N as detectFramework, j as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-B1n1ti0N.mjs";
8
+ import { r as isDocConsulted } from "./doc-helpers-B4XYL1v8.mjs";
6
9
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
7
10
  import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-BkoEbdec.mjs";
8
- import { a as extractText, o as loadIndex, r as cacheStore, t as cacheLookup } from "./store-PrNPm6So.mjs";
11
+ import { c as extractText, i as cacheLookupSubstring, l as loadIndex, n as webfetchCacheWrite, r as cacheLookup, t as mcpCacheWrite } from "./mcp-store-CDUVqtJ0.mjs";
9
12
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-CvSumjtz.mjs";
10
- import { t as loadRefs } from "./loader-CyAoJv2W.mjs";
11
- 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-7j02oGjt.mjs";
12
- import { c as pathExists, d as spawnCapture, f as writeText, l as readText, n as denyResponse, s as collectFiles, t as contextResponse, u as sleep } from "./claude-3PqBGt_7.mjs";
13
+ import { t as loadRefs } from "./loader-Bn-DbZmt.mjs";
14
+ import { c as recordDoc, d as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as recordTrivialEdit } from "./store-3WBr37Xz.mjs";
15
+ import { c as collectFiles, d as sleep, f as spawnCapture, l as pathExists, n as contextResponse, p as writeText, t as blockResponse, u as readText } from "./claude-BnFrSfMo.mjs";
13
16
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
14
17
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
15
18
  import { homedir } from "node:os";
@@ -98,20 +101,117 @@ function saveSecurityState(state, now = Date.now(), home = homedir()) {
98
101
  writeFileSync(securityStatePath(now, home), JSON.stringify(state, null, 2), "utf-8");
99
102
  }
100
103
  //#endregion
104
+ //#region src/freshness/explore-tools.ts
105
+ /** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
106
+ const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
107
+ /** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
108
+ const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
109
+ "mcp__context7__query-docs",
110
+ "mcp__context7__resolve-library-id",
111
+ "mcp__exa__web_search_exa",
112
+ "mcp__exa__get_code_context_exa",
113
+ "mcp__exa__deep_researcher_start",
114
+ "WebSearch",
115
+ "WebFetch"
116
+ ]);
117
+ /** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
118
+ const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
119
+ "grep",
120
+ "rg",
121
+ "find",
122
+ "ls",
123
+ "fd",
124
+ "ast-grep",
125
+ "tree",
126
+ "cat",
127
+ "head",
128
+ "tail"
129
+ ]);
130
+ /** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
131
+ const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
132
+ /**
133
+ * First non-assignment shell token's basename, or "" — mirrors Python
134
+ * `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
135
+ * @param cmd - Raw Bash `command` string.
136
+ * @returns The executable basename, or "" when none.
137
+ */
138
+ function bashExecutable(cmd) {
139
+ for (const token of cmd.trim().split(/\s+/)) {
140
+ if (!token) continue;
141
+ const last = token.split("/").pop() ?? token;
142
+ if (!last.includes("=")) return last;
143
+ }
144
+ return "";
145
+ }
146
+ /**
147
+ * Classify a direct tool use into an APEX phase, or `null` when it is neither
148
+ * exploration nor research. Mirrors Python `track-subagent-research._classify`.
149
+ * @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
150
+ * @param input - Tool input payload.
151
+ * @returns The credited phase + cache flag, or `null`.
152
+ */
153
+ function classifyExplore(tool, input) {
154
+ if (RESEARCH_TOOLS.has(tool)) return {
155
+ phase: "research-expert",
156
+ cacheHit: false
157
+ };
158
+ if (EXPLORE_TOOLS.has(tool)) return {
159
+ phase: "explore-codebase",
160
+ cacheHit: false
161
+ };
162
+ if (tool === "Read") {
163
+ const path = String(input?.file_path ?? input?.path ?? "");
164
+ if (path && CACHE_READ_RE.test(path)) return {
165
+ phase: "research-expert",
166
+ cacheHit: true
167
+ };
168
+ return null;
169
+ }
170
+ if (tool === "Bash") {
171
+ const cmd = String(input?.command ?? "").trim();
172
+ if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
173
+ phase: "explore-codebase",
174
+ cacheHit: false
175
+ };
176
+ }
177
+ return null;
178
+ }
179
+ //#endregion
101
180
  //#region src/runtime/activity.ts
102
181
  /** Min response length (chars) for a lead agent call to count as `sufficient`. */
103
182
  const AGENT_QUALITY_MIN = 500;
183
+ /** Min response length (chars) for direct exploration/research (parity Python `> 50`). */
184
+ const EXPLORE_QUALITY_MIN = 50;
104
185
  /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
105
186
  const READ_TOOLS = /* @__PURE__ */ new Set([
106
187
  "Read",
107
188
  "read_file",
108
189
  "read_many_files"
109
190
  ]);
191
+ /** Grade a response length against a threshold; `undefined` when length is unknown. */
192
+ function qualityFor(len, min) {
193
+ return len === void 0 ? void 0 : len > min ? "sufficient" : "insufficient";
194
+ }
195
+ /** Build an `agent` activity, attaching `quality` only when graded. */
196
+ function agentActivity(name, ts, quality) {
197
+ return quality ? {
198
+ kind: "agent",
199
+ name,
200
+ ts,
201
+ quality
202
+ } : {
203
+ kind: "agent",
204
+ name,
205
+ ts
206
+ };
207
+ }
110
208
  /**
111
209
  * Map a live tool-use to the activity to record, or null when nothing is
112
210
  * tracked. Works across harnesses — tool names are globally distinct:
113
211
  * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
114
- * - `Task` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
212
+ * - `Task`/`Agent` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
213
+ * - direct exploration/research (Glob/Grep, explore Bash, web, MCP cache reads)
214
+ * → `agent` credited to the matching REQUIRED_AGENTS phase
115
215
  * - a read tool opening a `.md` reference → `ref`
116
216
  */
117
217
  function activityFor(event) {
@@ -121,20 +221,15 @@ function activityFor(event) {
121
221
  sessionId: event.sessionId,
122
222
  source: /exa/i.test(event.tool) ? "exa" : "context7"
123
223
  };
124
- if (event.tool === "Task") {
125
- const name = String(event.input?.subagent_type ?? "").split(":").pop() ?? "";
224
+ if (event.tool === "Task" || event.tool === "Agent") {
225
+ const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
126
226
  if (!name) return null;
127
- const quality = event.responseLength === void 0 ? void 0 : event.responseLength > AGENT_QUALITY_MIN ? "sufficient" : "insufficient";
128
- return quality ? {
129
- kind: "agent",
130
- name,
131
- ts: event.now,
132
- quality
133
- } : {
134
- kind: "agent",
135
- name,
136
- ts: event.now
137
- };
227
+ return agentActivity(name, event.now, qualityFor(event.responseLength, AGENT_QUALITY_MIN));
228
+ }
229
+ const hit = classifyExplore(event.tool, event.input);
230
+ if (hit) {
231
+ const quality = hit.cacheHit ? "sufficient" : qualityFor(event.responseLength, EXPLORE_QUALITY_MIN);
232
+ return agentActivity(hit.phase, event.now, quality);
138
233
  }
139
234
  if (READ_TOOLS.has(event.tool)) {
140
235
  const path = String(event.input?.file_path ?? event.input?.path ?? "");
@@ -146,9 +241,18 @@ function activityFor(event) {
146
241
  return null;
147
242
  }
148
243
  //#endregion
149
- //#region src/runtime/mcp.ts
150
- /** Default freshness for cached MCP/WebFetch results (48h). */
244
+ //#region src/runtime/mcp-key.ts
245
+ /** Cache-key + tool-class helpers for the MCP/WebFetch interception pipeline. */
246
+ /** Default freshness for cached MCP doc results (48h). */
151
247
  const MCP_TTL_MS = 1728e5;
248
+ /** WebFetch freshness — pages go stale faster than docs (24h, parity with Python). */
249
+ const WEBFETCH_TTL_MS = 864e5;
250
+ /** WebFetch prompt slice folded into its cache key (parity with Python prompt[:500]). */
251
+ const PROMPT_TRUNC = 500;
252
+ /** True for the WebFetch tool (exact name). */
253
+ function isWebFetch(tool) {
254
+ return tool === "WebFetch";
255
+ }
152
256
  /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
153
257
  function isMcpTool(tool) {
154
258
  return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
@@ -158,6 +262,17 @@ function queryOf(input) {
158
262
  const q = input.query ?? input.url ?? input.libraryId ?? "";
159
263
  return typeof q === "string" ? q : JSON.stringify(q);
160
264
  }
265
+ /**
266
+ * The string that keys the cache for `tool`. WebFetch folds `url + "\n" +
267
+ * prompt[:500]` (parity with Python) so distinct prompts on the same URL never
268
+ * collide; every other tool keys on {@link queryOf}.
269
+ */
270
+ function cacheQueryOf(tool, input) {
271
+ if (!isWebFetch(tool)) return queryOf(input);
272
+ return `${typeof input.url === "string" ? input.url.trim() : ""}\n${(typeof input.prompt === "string" ? input.prompt : "").slice(0, PROMPT_TRUNC)}`;
273
+ }
274
+ //#endregion
275
+ //#region src/runtime/mcp.ts
161
276
  function denyWith(id, content) {
162
277
  if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
163
278
  hookEventName: "PreToolUse",
@@ -191,7 +306,10 @@ function docSourceOf(tool) {
191
306
  */
192
307
  function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
193
308
  if (!isMcpTool(tool)) return null;
194
- const cached = cacheLookup(dir, tool, queryOf(input), ttlMs, now);
309
+ const web = isWebFetch(tool);
310
+ const ttl = web ? WEBFETCH_TTL_MS : ttlMs;
311
+ const key = cacheQueryOf(tool, input);
312
+ const cached = cacheLookup(dir, tool, key, ttl, now) ?? (web ? null : cacheLookupSubstring(dir, key, ttl, now));
195
313
  if (cached) {
196
314
  const served = denyWith(id, cached);
197
315
  if (served) return {
@@ -206,10 +324,18 @@ function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
206
324
  }
207
325
  return null;
208
326
  }
209
- /** Post-event: store the MCP/WebFetch response (extracted to markdown) in the cache. */
210
- function mcpPostStore(tool, input, response, dir) {
327
+ /**
328
+ * Post-event: persist the MCP/WebFetch response (extracted to markdown). MCP docs
329
+ * go through {@link mcpCacheWrite} (compact + Jaccard-dedup + `index.json`);
330
+ * WebFetch uses {@link webfetchCacheWrite} (compact, exact key, no index).
331
+ * @param now - Current epoch ms (timestamp source; defaults to `Date.now()`).
332
+ */
333
+ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
211
334
  if (!isMcpTool(tool)) return;
212
- cacheStore(dir, tool, queryOf(input), extractText(response));
335
+ const text = extractText(response);
336
+ const key = cacheQueryOf(tool, input);
337
+ if (isWebFetch(tool)) webfetchCacheWrite(dir, tool, key, text, now);
338
+ else mcpCacheWrite(dir, tool, key, text, now);
213
339
  }
214
340
  //#endregion
215
341
  //#region src/runtime/normalize.ts
@@ -536,6 +662,117 @@ function runDesignChecks(content) {
536
662
  ];
537
663
  }
538
664
  //#endregion
665
+ //#region src/policy/design/skill-gate.ts
666
+ /** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
667
+ const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
668
+ /** Generated/vendored paths that never need a design skill (match at start or mid-path). */
669
+ const EXEMPT_RE = /(^|\/)(node_modules|dist|build)\/|\.claude\//;
670
+ /** UI-intent path fragments (ports the Python `UI_PATH_PATTERNS`). */
671
+ const UI_PATH_RE = /(components|ui|styles|page|layout|content|view|feature|section|hero|footer|header|sidebar|nav|modal|dialog)/;
672
+ /** Tailwind utility usage in JSX `className` (ports the Python `has_jsx_tailwind`). */
673
+ const TAILWIND_RE = /className\s*=.*(?:flex|grid|p-|m-|bg-|text-|rounded|shadow|border|gap-|w-|h-)/;
674
+ /** A read reference path that counts as "a design skill was consulted". */
675
+ const DESIGN_SKILL_RE = /skills\/([0-9]-)?(designing-systems|generating-components|solid-react|solid-nextjs|design|impeccable|frontend-design)/i;
676
+ /** True when this Write/Edit targets a UI file in a UI-ish path or with Tailwind classes. */
677
+ function isUiWrite(tool, filePath, content) {
678
+ if (tool !== "Write" && tool !== "Edit") return false;
679
+ if (!UI_FILE_RE.test(filePath) || EXEMPT_RE.test(filePath)) return false;
680
+ return UI_PATH_RE.test(filePath) || TAILWIND_RE.test(content);
681
+ }
682
+ /** True when a design skill reference was read this session (scans the `refsRead` track). */
683
+ function designSkillRead(refsRead) {
684
+ return refsRead.some((p) => DESIGN_SKILL_RE.test(p));
685
+ }
686
+ const block$1 = (reason, action) => ({
687
+ kind: "block",
688
+ title: "Design skill",
689
+ reason,
690
+ actions: [action]
691
+ });
692
+ /**
693
+ * Gate a UI write: require a design-skill read AND any doc source. Returns a
694
+ * blocking {@link Prompt}, or `null` to allow. NEVER requires Gemini.
695
+ * @param tool - the tool name ("Write" | "Edit" | ...).
696
+ * @param filePath - the written file path.
697
+ * @param content - the written content (snippet on Edit, full file on Write).
698
+ * @param ev - the session evidence (refsRead + docConsulted).
699
+ */
700
+ function uiDesignSkillGate(tool, filePath, content, ev) {
701
+ if (!isUiWrite(tool, filePath, content)) return null;
702
+ if (!designSkillRead(ev.refsRead)) return block$1("BLOCKED: design skill not consulted before writing UI. Read a design SKILL.md (solid-react, generating-components, or designing-systems), then retry.", "Read a design skill reference, then retry");
703
+ if (!ev.docConsulted) return block$1("BLOCKED: no documentation consulted. Use ANY ONE of mcp__context7__query-docs, mcp__exa__web_search_exa, WebSearch, or WebFetch. Gemini is NOT required.", "Consult Context7, Exa, or web docs (Gemini not required), then retry");
704
+ return null;
705
+ }
706
+ //#endregion
707
+ //#region src/policy/design/skill-evidence.ts
708
+ /**
709
+ * @module design/skill-evidence
710
+ * IO side of the UI design-skill gate: read the verified session track and
711
+ * derive {@link DesignEvidence} (read references + doc-consulted) for
712
+ * {@link uiDesignSkillGate}. Kept separate from the pure gate logic (SRP).
713
+ * @packageDocumentation
714
+ */
715
+ /**
716
+ * Read the verified session track and derive {@link DesignEvidence}. Fail-closed
717
+ * on a missing/corrupt/tampered track (no evidence → the gate blocks).
718
+ * @param sessionId - the Claude session id.
719
+ * @param cwd - the project root (selects the per-project state dir).
720
+ * @param baseDir - override the track base dir (tests); defaults to the project state dir.
721
+ */
722
+ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
723
+ let track = null;
724
+ try {
725
+ track = verifyTrack(JSON.parse(readFileSync(trackFile(sessionId, baseDir), "utf8")));
726
+ } catch {
727
+ track = null;
728
+ }
729
+ return {
730
+ refsRead: track?.refsRead ?? [],
731
+ docConsulted: track ? isDocConsulted(track.authorizations, sessionId) : false
732
+ };
733
+ }
734
+ //#endregion
735
+ //#region src/runtime/design-helpers.ts
736
+ /**
737
+ * @module design-helpers
738
+ * Effectful helpers for the design pipeline gate: locate design-system.md and
739
+ * apply PostToolUse fuse-browser transitions to the design state. Split out of
740
+ * `design.ts` to keep that file within the SOLID size budget (SRP).
741
+ * @packageDocumentation
742
+ */
743
+ const NAV$1 = "mcp__fuse-browser__browser_navigate";
744
+ const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
745
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
746
+ const GEMINI$1 = "mcp__gemini-design__create_frontend";
747
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
748
+ function findDesignSystem(cwd) {
749
+ let dir = cwd;
750
+ for (let i = 0; i < 6; i++) {
751
+ const p = join(dir, "design-system.md");
752
+ if (existsSync(p)) try {
753
+ return readFileSync(p, "utf8");
754
+ } catch {
755
+ return "";
756
+ }
757
+ const parent = dirname(dir);
758
+ if (parent === dir) break;
759
+ dir = parent;
760
+ }
761
+ return "";
762
+ }
763
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
764
+ function recordPost(event, cacheDir, state) {
765
+ if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
766
+ else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
767
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
768
+ else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
769
+ ...state,
770
+ geminiCalls: state.geminiCalls + 1
771
+ });
772
+ else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
773
+ else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
774
+ }
775
+ //#endregion
539
776
  //#region src/policy/design/gates.ts
540
777
  const ALLOWED_WRITE = /\.(html|css|md|json)$/;
541
778
  const EXEMPT_DIRS = [
@@ -620,48 +857,28 @@ function geminiEnabled() {
620
857
  //#region src/runtime/design.ts
621
858
  const NAV = "mcp__fuse-browser__browser_navigate";
622
859
  const SHOT = "mcp__fuse-browser__browser_screenshot";
623
- const SCROLL = "mcp__fuse-browser__browser_scroll";
624
860
  const GEMINI = "mcp__gemini-design__create_frontend";
625
- /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
626
- function findDesignSystem(cwd) {
627
- let dir = cwd;
628
- for (let i = 0; i < 6; i++) {
629
- const p = join(dir, "design-system.md");
630
- if (existsSync(p)) try {
631
- return readFileSync(p, "utf8");
632
- } catch {
633
- return "";
634
- }
635
- const parent = dirname(dir);
636
- if (parent === dir) break;
637
- dir = parent;
638
- }
639
- return "";
640
- }
641
- /** Apply a PostToolUse fuse-browser transition to the design state. */
642
- function recordPost(event, cacheDir, state) {
643
- if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
644
- else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state));
645
- else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
646
- else if (event.tool === GEMINI) saveDesignState(cacheDir, {
647
- ...state,
648
- geminiCalls: state.geminiCalls + 1
649
- });
650
- else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
651
- else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
652
- }
653
861
  /**
654
862
  * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
655
863
  * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
656
864
  */
657
865
  function designGate(payload, event, cacheDir, cwd) {
866
+ if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
867
+ const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
868
+ if (skillBlock) return skillBlock;
869
+ }
658
870
  const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
659
871
  const active = activeDesignAgent(cacheDir);
660
872
  if (active && agentId && agentId !== active) return null;
661
873
  const id = active || agentId;
662
874
  if (!id) return null;
663
- const state = loadDesignState(cacheDir, id);
664
- if (!state) return null;
875
+ let state = loadDesignState(cacheDir, id);
876
+ if (!state) {
877
+ if (!active) return null;
878
+ const dsExists = findDesignSystem(cwd) !== "";
879
+ state = initDesignState(id, dsExists ? "page" : "full", dsExists);
880
+ saveDesignState(cacheDir, state);
881
+ }
665
882
  if (event.phase === "post") {
666
883
  recordPost(event, cacheDir, state);
667
884
  if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
@@ -1129,23 +1346,24 @@ function recordHistory(home, agentId, agentType, ts) {
1129
1346
  function trackAgentMemory(data, home = homedir(), now = Date.now()) {
1130
1347
  mkdirSync(sessionsDir(home), { recursive: true });
1131
1348
  const agentType = String(data.agent_type ?? data.subagent_type ?? "unknown");
1132
- const sessionId = String(data.session_id ?? "unknown");
1349
+ const sessionId = sanitizeSessionId(data.session_id) ?? "unknown";
1133
1350
  const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1134
1351
  recordHistory(home, String(data.agent_id ?? "unknown"), agentType, ts);
1135
1352
  if (SKIP_AGENTS.test(agentType)) return JSON.stringify({ message: `Agent ${agentType} completed` });
1136
- const stateFile = join(sessionsDir(home), `session-${sessionId}-changes.json`);
1137
- if (existsSync(stateFile)) try {
1138
- const state = JSON.parse(readFileSync(stateFile, "utf-8"));
1139
- const count = state.cumulativeCodeFiles ?? 0;
1140
- if (count > 0) {
1141
- const files = (state.modifiedFiles ?? []).join(", ");
1142
- writeFileSync(stateFile, JSON.stringify({
1143
- ...state,
1353
+ const state = loadSessionState(sessionId, home);
1354
+ const changes = state.changes;
1355
+ const count = changes?.cumulativeCodeFiles ?? 0;
1356
+ if (count > 0) {
1357
+ const files = (changes?.modifiedFiles ?? []).join(", ");
1358
+ saveSessionState(sessionId, {
1359
+ ...state,
1360
+ changes: {
1361
+ ...changes,
1144
1362
  cumulativeCodeFiles: 0
1145
- }), "utf-8");
1146
- return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${count} code file(s): ${files}. Run sniper agent now.`);
1147
- }
1148
- } catch {}
1363
+ }
1364
+ }, home);
1365
+ return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${count} code file(s): ${files}. Run sniper agent now.`);
1366
+ }
1149
1367
  return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
1150
1368
  }
1151
1369
  //#endregion
@@ -1160,14 +1378,9 @@ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
1160
1378
  */
1161
1379
  function validateTeammateOutput(data, home = homedir()) {
1162
1380
  const teammate = String(data.teammate_name ?? "unknown");
1163
- const sessionId = String(data.session_id ?? "unknown");
1164
- const stateFile = join(sessionsDir(home), `session-${sessionId}-changes.json`);
1165
- if (!existsSync(stateFile)) return "";
1166
- try {
1167
- const state = JSON.parse(readFileSync(stateFile, "utf-8"));
1168
- const count = state.cumulativeCodeFiles ?? 0;
1169
- 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.`);
1170
- } catch {}
1381
+ const changes = loadSessionState(sanitizeSessionId(data.session_id) ?? "unknown", home).changes;
1382
+ const count = changes?.cumulativeCodeFiles ?? 0;
1383
+ if (count > 0) return contextResponse("TeammateIdle", `Teammate '${teammate}' going idle after modifying ${count} code file(s): ${(changes?.modifiedFiles ?? []).slice(0, 5).join(", ")}. Consider running sniper validation.`);
1171
1384
  return "";
1172
1385
  }
1173
1386
  //#endregion
@@ -1260,7 +1473,7 @@ function validateRulesLoaded(data, home = homedir()) {
1260
1473
  //#endregion
1261
1474
  //#region src/runtime/lifecycle/track-changes.ts
1262
1475
  /** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */
1263
- const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
1476
+ const CODE_EXT$1 = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
1264
1477
  /**
1265
1478
  * Handle PostToolUse Write/Edit: track the cumulative set of modified code
1266
1479
  * files per session and emit the mandatory "SNIPER VALIDATION REQUIRED"
@@ -1272,7 +1485,7 @@ const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelt
1272
1485
  * @returns The native hook stdout (possibly empty when not a code file).
1273
1486
  */
1274
1487
  function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Date.now()) {
1275
- if (!filePath || !CODE_EXT.test(filePath)) return "";
1488
+ if (!filePath || !CODE_EXT$1.test(filePath)) return "";
1276
1489
  const sid = sanitizeSessionId(sessionIdRaw) ?? "unknown";
1277
1490
  const state = loadSessionState(sid, home);
1278
1491
  const prev = state.changes ?? {
@@ -1354,6 +1567,66 @@ function postEditTypescript(filePath) {
1354
1567
  return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1355
1568
  }
1356
1569
  //#endregion
1570
+ //#region src/runtime/lifecycle/task-completed.ts
1571
+ /** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
1572
+ const CODE_EXTENSIONS = /* @__PURE__ */ new Set([
1573
+ ".ts",
1574
+ ".tsx",
1575
+ ".js",
1576
+ ".jsx",
1577
+ ".py",
1578
+ ".go",
1579
+ ".rs",
1580
+ ".java",
1581
+ ".php",
1582
+ ".cpp",
1583
+ ".c",
1584
+ ".rb",
1585
+ ".swift",
1586
+ ".kt",
1587
+ ".dart",
1588
+ ".vue",
1589
+ ".svelte",
1590
+ ".astro"
1591
+ ]);
1592
+ /**
1593
+ * Re-count physical lines of every modified code file and collect SOLID
1594
+ * violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
1595
+ * @param files - Candidate modified file paths.
1596
+ * @param max - The SOLID line ceiling.
1597
+ * @returns The list of violation strings (empty when all files comply).
1598
+ */
1599
+ function collectViolations(files, max) {
1600
+ const violations = [];
1601
+ for (const fp of files) {
1602
+ if (!CODE_EXTENSIONS.has(extname(fp)) || !existsSync(fp)) continue;
1603
+ try {
1604
+ const lines = countLines(readFileSync(fp, "utf-8"));
1605
+ if (lines > max) violations.push(`${basename(fp)}: ${lines} lines (max ${max})`);
1606
+ } catch {}
1607
+ }
1608
+ return violations;
1609
+ }
1610
+ /**
1611
+ * Handle TaskCompleted: re-measure the session's modified code files and emit a
1612
+ * `SOLID VIOLATION` additionalContext listing any file over the line ceiling.
1613
+ * Ports `task-completed/validate-task-solid.py`.
1614
+ * @param payload - The TaskCompleted hook payload (`task_id`, `task_subject`, `session_id`).
1615
+ * @param home - Home dir (defaults to `~`).
1616
+ * @returns The native hook stdout, or `""` when there are no violations.
1617
+ */
1618
+ function validateTaskSolid(payload, home = homedir()) {
1619
+ const sid = sanitizeSessionId(payload.session_id ?? "unknown");
1620
+ if (!sid) return "";
1621
+ const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
1622
+ if (files.length === 0) return "";
1623
+ const max = resolveMaxLines();
1624
+ const violations = collectViolations(files, max);
1625
+ if (violations.length === 0) return "";
1626
+ const taskId = String(payload.task_id ?? "");
1627
+ return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
1628
+ }
1629
+ //#endregion
1357
1630
  //#region src/runtime/lifecycle/cartographer/fs-util.ts
1358
1631
  /**
1359
1632
  * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
@@ -1575,18 +1848,63 @@ function readPluginMeta(pluginPath) {
1575
1848
  /**
1576
1849
  * Auto-detect the marketplace `plugins` dir that contains `cartographer`,
1577
1850
  * falling back to the first marketplace with a `plugins` dir, else `cwd`.
1578
- * Ports `find_marketplace_plugins`.
1851
+ * Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
1852
+ * derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
1853
+ * shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
1579
1854
  * @param home - Home directory (defaults to `~`).
1855
+ * @param id - Detected harness id (defaults to runtime detection).
1580
1856
  * @returns The resolved plugins directory.
1581
1857
  */
1582
- function findMarketplacePlugins(home = homedir()) {
1583
- const mp = join(home, ".claude", "plugins", "marketplaces");
1858
+ function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
1859
+ const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
1584
1860
  const markets = sortedNames$1(mp);
1585
1861
  for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
1586
1862
  for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
1587
1863
  return process.cwd();
1588
1864
  }
1589
1865
  //#endregion
1866
+ //#region src/runtime/lifecycle/cartographer/scan-hooks.ts
1867
+ /**
1868
+ * Hook scanning (fs). Ports `_scan_hooks` from `scan_plugins.py`: reads a
1869
+ * plugin's `hooks/hooks.json` and reduces it to a single `("hooks", "<events>", "")`
1870
+ * row. The `hooks` value (or the whole document) is either a MAP of event →
1871
+ * entries (keys are the events) or a LIST of entries each carrying an `event`.
1872
+ */
1873
+ /** True for a non-null plain object (excludes arrays), mirroring Python `isinstance(x, dict)`. */
1874
+ function isPlainObject(v) {
1875
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1876
+ }
1877
+ /** Sort unique non-empty strings alpha (byte-order). */
1878
+ function sortedEvents(events) {
1879
+ return [...new Set([...events].filter((e) => e))].sort((a, b) => a.localeCompare(b, "en"));
1880
+ }
1881
+ /** Derive hook event names from the `hooks.json` data (map keys, or list `event` fields). */
1882
+ function hookEvents(hooksData) {
1883
+ if (isPlainObject(hooksData)) return sortedEvents(Object.keys(hooksData).filter((k) => !k.startsWith("_")));
1884
+ if (Array.isArray(hooksData)) return sortedEvents(hooksData.map((h) => isPlainObject(h) ? String(h.event ?? "") : ""));
1885
+ return [];
1886
+ }
1887
+ /**
1888
+ * Scan `hooks/hooks.json` into a single `("hooks", "<events>", "")` row.
1889
+ * @param root - Absolute plugin directory.
1890
+ * @returns The single hooks row, or `[]` when absent/empty/unreadable.
1891
+ */
1892
+ function scanHooks(root) {
1893
+ const file = join(root, "hooks", "hooks.json");
1894
+ if (!existsSync(file)) return [];
1895
+ try {
1896
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
1897
+ const events = hookEvents(isPlainObject(raw) ? raw.hooks ?? raw : {});
1898
+ return events.length ? [[
1899
+ "hooks",
1900
+ events.join(", "),
1901
+ ""
1902
+ ]] : [];
1903
+ } catch {
1904
+ return [];
1905
+ }
1906
+ }
1907
+ //#endregion
1590
1908
  //#region src/runtime/lifecycle/cartographer/scan.ts
1591
1909
  /**
1592
1910
  * Plugin scanning (fs). Ports `scan_plugins.py`: turns a plugin's
@@ -1655,24 +1973,6 @@ function scanCommands(root) {
1655
1973
  fileField(join(dir, n), "description").slice(0, 50)
1656
1974
  ]);
1657
1975
  }
1658
- /** Scan `hooks/hooks.json` → a single `("hooks", "<events>", "")` row, or none. */
1659
- function scanHooks(root) {
1660
- const file = join(root, "hooks", "hooks.json");
1661
- if (!existsSync(file)) return [];
1662
- try {
1663
- const raw = JSON.parse(readFileSync(file, "utf-8"));
1664
- const data = raw && typeof raw === "object" ? raw : {};
1665
- const hooks = data.hooks && typeof data.hooks === "object" ? data.hooks : data;
1666
- const events = Object.keys(hooks).filter((k) => !k.startsWith("_")).sort((a, b) => a.localeCompare(b, "en"));
1667
- return events.length ? [[
1668
- "hooks",
1669
- events.join(", "),
1670
- ""
1671
- ]] : [];
1672
- } catch {
1673
- return [];
1674
- }
1675
- }
1676
1976
  /**
1677
1977
  * Scan a single plugin directory into ordered `[type, name, desc]` rows.
1678
1978
  * @param pluginDir - Absolute plugin directory.
@@ -1853,6 +2153,15 @@ function generateEcosystemMap(now, pluginsDirOverride) {
1853
2153
  * navigation context from the latter as additionalContext.
1854
2154
  */
1855
2155
  /**
2156
+ * Resolve the marketplace plugins dir from `CLAUDE_PLUGIN_ROOT`, mirroring the
2157
+ * Python hook which passes `${CLAUDE_PLUGIN_ROOT}/..` to `generate_map.py`.
2158
+ * @returns The plugins dir (env `/..`), or `undefined` to fall back to auto-detect.
2159
+ */
2160
+ function pluginsDirFromEnv() {
2161
+ const root = process.env.CLAUDE_PLUGIN_ROOT;
2162
+ return root ? resolve(root, "..") : void 0;
2163
+ }
2164
+ /**
1856
2165
  * Regenerate the project map + plugin ecosystem map for `cwd` on SessionStart.
1857
2166
  * Emits the ecosystem navigation context as additionalContext (or "").
1858
2167
  * @param cwd - The working directory.
@@ -1861,7 +2170,7 @@ function generateEcosystemMap(now, pluginsDirOverride) {
1861
2170
  */
1862
2171
  function cartoSessionStart(cwd, now = Date.now()) {
1863
2172
  generateProjectMap(cwd);
1864
- const ctx = generateEcosystemMap(now);
2173
+ const ctx = generateEcosystemMap(now, pluginsDirFromEnv());
1865
2174
  return ctx ? contextResponse("SessionStart", ctx) : "";
1866
2175
  }
1867
2176
  //#endregion
@@ -2658,12 +2967,101 @@ async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
2658
2967
  await writeJsonFile(indexFile, index, true);
2659
2968
  }
2660
2969
  //#endregion
2970
+ //#region src/runtime/lifecycle/aipilot/promote-global-lessons.ts
2971
+ /**
2972
+ * Promote high-frequency project lessons (occurrence count >= 3) to the global,
2973
+ * stack-scoped cache (`fusengine-cache/lessons/_global/<stack>.json`, cap 25).
2974
+ * Ports the ai-pilot `promote-global-lessons.ts` (a spawned subprocess) as an
2975
+ * in-process call from `cacheSniperLessons`. Pure side-effect.
2976
+ */
2977
+ const MIN_COUNT = 3;
2978
+ const MAX_GLOBAL = 25;
2979
+ /** Group lessons by `error_type:pattern`. */
2980
+ function groupByKey(lessons) {
2981
+ const groups = /* @__PURE__ */ new Map();
2982
+ for (const l of lessons) {
2983
+ const key = `${l.error_type}:${l.pattern}`;
2984
+ (groups.get(key) ?? groups.set(key, []).get(key)).push(l);
2985
+ }
2986
+ return [...groups.values()];
2987
+ }
2988
+ /** Read every project lesson file's `errors` into one flat (un-deduped) list. */
2989
+ async function aggregateErrors(cacheDir) {
2990
+ let files;
2991
+ try {
2992
+ files = readdirSync(cacheDir).filter((f) => f.endsWith(".json"));
2993
+ } catch {
2994
+ return [];
2995
+ }
2996
+ const all = [];
2997
+ for (const f of files) {
2998
+ const data = await readJsonFile(join(cacheDir, f));
2999
+ if (data?.errors) all.push(...data.errors);
3000
+ }
3001
+ return all;
3002
+ }
3003
+ /** Keep groups seen >= MIN_COUNT times, collapsed to a single lesson each. */
3004
+ function filterFrequent(lessons) {
3005
+ return groupByKey(lessons).filter((e) => e.length >= MIN_COUNT).map((entries) => {
3006
+ const first = entries[0];
3007
+ return {
3008
+ error_type: first.error_type,
3009
+ pattern: first.pattern,
3010
+ fix: first.fix,
3011
+ last_seen: first.last_seen,
3012
+ count: entries.length,
3013
+ files: [...new Set(entries.flatMap((e) => e.files))],
3014
+ code: { line: [...new Set(entries.flatMap((e) => e.code?.line ?? []))].slice(0, 5) }
3015
+ };
3016
+ });
3017
+ }
3018
+ /** Merge tagged candidates into existing global lessons; dedup, sum, sort, cap. */
3019
+ function mergeGlobal(existing, candidates, projHash) {
3020
+ const tagged = candidates.map((c) => ({
3021
+ ...c,
3022
+ source_projects: [projHash]
3023
+ }));
3024
+ return groupByKey([...existing, ...tagged]).map((entries) => {
3025
+ const first = entries[0];
3026
+ return {
3027
+ error_type: first.error_type,
3028
+ pattern: first.pattern,
3029
+ fix: first.fix,
3030
+ last_seen: first.last_seen,
3031
+ count: entries.reduce((sum, e) => sum + e.count, 0),
3032
+ files: [...new Set(entries.flatMap((e) => e.files))],
3033
+ code: { line: [...new Set(entries.flatMap((e) => e.code?.line ?? []))].slice(0, 5) },
3034
+ source_projects: [...new Set(entries.flatMap((e) => e.source_projects ?? []))]
3035
+ };
3036
+ }).sort((a, b) => b.count - a.count).slice(0, MAX_GLOBAL);
3037
+ }
3038
+ /**
3039
+ * Promote high-frequency lessons from `cacheDir` to the global stack file.
3040
+ * @param cacheDir - Per-project lessons cache dir to scan.
3041
+ * @param stack - Detected project stack (`nextjs`, `laravel`, …, `universal`).
3042
+ * @param projHash - Source project hash (tags promoted entries).
3043
+ * @param home - Home dir (defaults to `~`).
3044
+ */
3045
+ async function promoteGlobalLessons(cacheDir, stack, projHash, home = homedir()) {
3046
+ const candidates = filterFrequent(await aggregateErrors(cacheDir));
3047
+ if (candidates.length === 0) return;
3048
+ const globalDir = join(cacheBaseDir(home), "lessons", "_global");
3049
+ mkdirSync(globalDir, { recursive: true });
3050
+ const globalFile = join(globalDir, `${stack}.json`);
3051
+ const merged = mergeGlobal(await readJsonFile(globalFile) ?? [], candidates, projHash);
3052
+ await writeJsonFile(globalFile, merged, true);
3053
+ logCacheEvent("lessons", "promoted", projHash, {
3054
+ count: merged.length,
3055
+ stack
3056
+ }, home);
3057
+ }
3058
+ //#endregion
2661
3059
  //#region src/runtime/lifecycle/aipilot/cache-lessons.ts
2662
3060
  /**
2663
3061
  * SubagentStop (matcher "sniper") for the ai-pilot scope: capture error
2664
3062
  * patterns + corrected code from the sniper transcript as lessons. Ports
2665
- * `cache-sniper-lessons.ts` (the dead `promote-global-lessons.ts` spawn is
2666
- * dropped that helper no longer exists). Pure side-effect.
3063
+ * `cache-sniper-lessons.ts`, including the high-frequency promotion to the
3064
+ * global stack cache (`promoteGlobalLessons`). Pure side-effect.
2667
3065
  */
2668
3066
  /**
2669
3067
  * SubagentStop sniper: extract lessons from the transcript and persist them.
@@ -2700,6 +3098,7 @@ async function cacheSniperLessons(transcript, cwd, home = homedir()) {
2700
3098
  timestamp,
2701
3099
  errors
2702
3100
  }, true);
3101
+ await promoteGlobalLessons(cacheDir, detectStack(projectPath), pHash, home);
2703
3102
  logCacheEvent("lessons", "hit", pHash, { count: edits.length }, home);
2704
3103
  }
2705
3104
  //#endregion
@@ -2924,12 +3323,12 @@ function contextTextOf(response) {
2924
3323
  }
2925
3324
  }
2926
3325
  /**
2927
- * Combine two SubagentStart responses into one (Claude concatenates multiple
2928
- * hooks' additionalContext; collapsing two matcher-"" scripts into one dispatch
2929
- * call must do that join itself). Returns "" when both are empty.
3326
+ * Combine N SubagentStart responses into one (Claude concatenates every hook's
3327
+ * additionalContext; collapsing the matcher-"" + type-specific scripts into one
3328
+ * dispatch call must do that join itself). Returns "" when all are empty.
2930
3329
  */
2931
- function combineContext(a, b) {
2932
- const parts = [contextTextOf(a), contextTextOf(b)].filter(Boolean);
3330
+ function combineContext(...responses) {
3331
+ const parts = responses.map(contextTextOf).filter(Boolean);
2933
3332
  return parts.length ? contextResponse("SubagentStart", parts.join("\n\n")) : "";
2934
3333
  }
2935
3334
  /** The transcript path a SubagentStop payload reports (Stop-only field). */
@@ -2937,13 +3336,22 @@ function transcriptOf(payload) {
2937
3336
  const t = payload.agent_transcript_path;
2938
3337
  return typeof t === "string" ? t : void 0;
2939
3338
  }
2940
- /** SubagentStart routing: cache injectors keyed on agent_type. */
2941
- async function onSubagentStart(payload, cwd, now) {
2942
- const agent = agentTypeOf(payload);
3339
+ /** Type-specific SubagentStart cache for an agent, or "" when none applies. */
3340
+ async function typeSpecificCache(agent, cwd, now) {
2943
3341
  if (agent.includes("explore-codebase")) return injectExploreCache(cwd, void 0, now);
2944
3342
  if (agent.includes("research-expert")) return injectDocCache(cwd, void 0, now);
2945
3343
  if (agent.includes("sniper")) return injectTestCache(cwd, void 0, now);
2946
- return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now));
3344
+ return "";
3345
+ }
3346
+ /**
3347
+ * SubagentStart routing. Parity with the Python ai-pilot hooks: the two
3348
+ * matcher-"" entries (APEX context + lessons "known issues") fire for EVERY
3349
+ * sub-agent, then the type-specific cache (explore/doc/test) is concatenated on
3350
+ * top. Sniper must still receive the lessons block — hence no early return.
3351
+ */
3352
+ async function onSubagentStart(payload, cwd, now) {
3353
+ const agent = agentTypeOf(payload);
3354
+ return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now), await typeSpecificCache(agent, cwd, now));
2947
3355
  }
2948
3356
  /** SubagentStop routing: transcript-driven cache writers (side-effects). */
2949
3357
  async function onSubagentStop(payload, cwd) {
@@ -3014,6 +3422,7 @@ function dispatchLifecycle(input) {
3014
3422
  case "InstructionsLoaded":
3015
3423
  validateRulesLoaded(input.payload);
3016
3424
  return "";
3425
+ case "TaskCompleted": return validateTaskSolid(input.payload);
3017
3426
  default: return null;
3018
3427
  }
3019
3428
  }
@@ -3465,11 +3874,11 @@ async function dispatchMemory(event, payload, cwd, now) {
3465
3874
  * missing. Opt-in only — silent when no marker / non-HTML / file unreadable.
3466
3875
  */
3467
3876
  /**
3468
- * Validate the edited file's SEO completeness. Returns a deny message (for a
3469
- * `permissionDecision: deny` response) when HTML-like, opted-in, and missing
3877
+ * Validate the edited file's SEO completeness. Returns a block message (for a
3878
+ * top-level `decision: block` response) when HTML-like, opted-in, and missing
3470
3879
  * elements; otherwise `null` (allow).
3471
3880
  * @param payload - The raw PostToolUse payload.
3472
- * @returns The deny reason string, or `null` to allow.
3881
+ * @returns The block reason string, or `null` to allow.
3473
3882
  */
3474
3883
  function seoPostToolUse(payload) {
3475
3884
  const path = payload.tool_input?.file_path;
@@ -3484,14 +3893,16 @@ function seoPostToolUse(payload) {
3484
3893
  }
3485
3894
  }
3486
3895
  /**
3487
- * SEO PostToolUse as a ready native response: a `permissionDecision: deny`
3896
+ * SEO PostToolUse as a ready native response: a top-level `decision: block`
3488
3897
  * string when the edited file is missing SEO elements, else `null` (allow).
3898
+ * PostToolUse ignores `permissionDecision` (PreToolUse-only), so this must use
3899
+ * the `decision`/`reason` keys to actually feed the failure back to Claude.
3489
3900
  * @param payload - The raw PostToolUse payload.
3490
- * @returns The deny response string, or `null` to allow.
3901
+ * @returns The block response string, or `null` to allow.
3491
3902
  */
3492
3903
  function seoPostToolUseResponse(payload) {
3493
- const deny = seoPostToolUse(payload);
3494
- return deny ? denyResponse("PostToolUse", deny) : null;
3904
+ const block = seoPostToolUse(payload);
3905
+ return block ? blockResponse(block) : null;
3495
3906
  }
3496
3907
  //#endregion
3497
3908
  //#region src/runtime/lifecycle-bridge.ts
@@ -3535,6 +3946,26 @@ function postEditContext(scope, event, now) {
3535
3946
  }
3536
3947
  //#endregion
3537
3948
  //#region src/runtime/gate-helpers.ts
3949
+ /** Source extensions the APEX gates apply to (parity: require-apex-agents.py CODE_EXT). */
3950
+ const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
3951
+ /** Paths exempt from the APEX gates even when they are code (parity: require-apex-agents.py EXEMPT_PATTERNS). */
3952
+ const EXEMPT_PATTERNS = [
3953
+ /\.claude-plugin\//,
3954
+ /CHANGELOG\.md$/,
3955
+ /marketplace\.json$/,
3956
+ /\/\.claude\/(apex|memory|logs|fusengine-cache)\//
3957
+ ];
3958
+ /**
3959
+ * True when `filePath` is a code file the APEX freshness/doc/SOLID gates should
3960
+ * police — i.e. it matches a tracked source extension AND no exempt pattern.
3961
+ * Mirrors require-apex-agents.py so non-code (and exempt) paths skip those gates.
3962
+ * @param filePath - Absolute path of the edit target (or undefined).
3963
+ * @returns Whether the APEX gates apply to this path.
3964
+ */
3965
+ function isApexScoped(filePath) {
3966
+ if (!filePath || !CODE_EXT.test(filePath)) return false;
3967
+ return !EXEMPT_PATTERNS.some((p) => p.test(filePath));
3968
+ }
3538
3969
  /**
3539
3970
  * Code-only line count of the existing on-disk file (undefined if
3540
3971
  * absent/unreadable). Uses {@link countLines} (skips blank/comment lines) so a
@@ -3556,7 +3987,7 @@ function existingLineCount(path) {
3556
3987
  //#region src/freshness/agent-evidence.ts
3557
3988
  /**
3558
3989
  * Platform-authored transcript evidence for APEX agent freshness.
3559
- * Parses the Claude Code session JSONL transcript to find genuine Task
3990
+ * Parses the Claude Code session JSONL transcript to find genuine `Task`/`Agent`
3560
3991
  * tool_use entries — forging this requires writing into the transcript file
3561
3992
  * which the Claude Code platform controls, unlike the self-recorded track.
3562
3993
  */
@@ -3569,9 +4000,9 @@ function parseTs(raw) {
3569
4000
  }
3570
4001
  /**
3571
4002
  * Return `true` ONLY when, for EVERY name in `names`, the Claude Code
3572
- * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task`
3573
- * tool whose `subagent_type` (or `name`) input field matches, with the entry
3574
- * timestamp within `windowMs` of `now`.
4003
+ * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task` or
4004
+ * `Agent` tool whose `subagent_type` (or `name`) input field matches after
4005
+ * stripping any plugin prefix — with the entry timestamp within `windowMs` of `now`.
3575
4006
  *
3576
4007
  * **Timestamp note:** when a transcript entry carries no `timestamp` field it
3577
4008
  * is counted as within-window (we cannot prove staleness). This is
@@ -3609,9 +4040,10 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
3609
4040
  const content = entry.message?.content;
3610
4041
  if (!Array.isArray(content)) continue;
3611
4042
  for (const block of content) {
3612
- if (block?.type !== "tool_use" || block.name !== "Task") continue;
3613
- const agent = block.input?.subagent_type ?? block.input?.name;
3614
- if (typeof agent === "string" && names.includes(agent)) found.add(agent);
4043
+ if (block?.type !== "tool_use" || block.name !== "Task" && block.name !== "Agent") continue;
4044
+ const raw = block.input?.subagent_type ?? block.input?.name;
4045
+ const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
4046
+ if (agent !== void 0 && names.includes(agent)) found.add(agent);
3615
4047
  }
3616
4048
  if (found.size === names.length) return true;
3617
4049
  }
@@ -3958,6 +4390,8 @@ const TRIVIAL_BUDGET = 4;
3958
4390
  * track. Returns the first blocking prompt, or null to allow.
3959
4391
  */
3960
4392
  async function gate(input) {
4393
+ const precommit = preCommitGate(input.tool, input.command, input.cwd);
4394
+ if (precommit) return precommit;
3961
4395
  const existingLines = existingLineCount(input.filePath);
3962
4396
  let quick;
3963
4397
  try {
@@ -3973,8 +4407,6 @@ async function gate(input) {
3973
4407
  return FAIL_CLOSED;
3974
4408
  }
3975
4409
  if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
3976
- const precommit = preCommitGate(input.tool, input.command, input.cwd);
3977
- if (precommit) return precommit;
3978
4410
  const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
3979
4411
  if (modular) return modular;
3980
4412
  if (!input.filePath) return null;
@@ -3982,29 +4414,31 @@ async function gate(input) {
3982
4414
  const track = await loadTrack(input.trackFile);
3983
4415
  const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
3984
4416
  if (solidOrSkill) return solidOrSkill;
3985
- const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
3986
- if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
3987
- await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
3988
- return null;
3989
- }
3990
- const freshnessFor = (names) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, window, input.now) : agentsFresh(track, names, window, input.now);
3991
- const ctx = {
3992
- sessionId: input.sessionId,
3993
- framework: input.framework,
3994
- filePath: input.filePath,
3995
- content: input.content ?? "",
3996
- authorizations: track.authorizations,
3997
- refs: input.refs,
3998
- refsRead: track.refsRead,
3999
- agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
4000
- brainstormRequired: track.brainstormRequired,
4001
- brainstormFresh: freshnessFor(["brainstorming"])
4002
- };
4003
- try {
4004
- const apex = evaluateApex(ctx);
4005
- if (apex) return apex;
4006
- } catch {
4007
- return FAIL_CLOSED;
4417
+ if (isApexScoped(input.filePath)) {
4418
+ const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
4419
+ if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
4420
+ await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
4421
+ return null;
4422
+ }
4423
+ const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
4424
+ const ctx = {
4425
+ sessionId: input.sessionId,
4426
+ framework: input.framework,
4427
+ filePath: input.filePath,
4428
+ content: input.content ?? "",
4429
+ authorizations: track.authorizations,
4430
+ refs: input.refs,
4431
+ refsRead: track.refsRead,
4432
+ agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
4433
+ brainstormRequired: track.brainstormRequired,
4434
+ brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
4435
+ };
4436
+ try {
4437
+ const apex = evaluateApex(ctx);
4438
+ if (apex) return apex;
4439
+ } catch {
4440
+ return FAIL_CLOSED;
4441
+ }
4008
4442
  }
4009
4443
  return dryGate(input.tool, input.filePath, input.content, input.cwd);
4010
4444
  }
@@ -4176,4 +4610,4 @@ async function handleHook(id, payload, opts) {
4176
4610
  });
4177
4611
  }
4178
4612
  //#endregion
4179
- export { sessionStartCore as $, writePluginMap as A, sanitizeSessionId as At, trackSessionChanges as B, aipilotPostToolUse as C, saveSecurityState as Ct, lessonsStateFileFor as D, claudeHome as Dt, lessonsFileFor as E, todayUtc as Et, mergeLines as F, validateTeammateOutput as G, cleanupSession as H, countFiles as I, detectSolidProfile as J, trackAgentMemory as K, getFileDesc as L, isProject as M, sessionStatePath as Mt, writeTree as N, sessionsDir as Nt, cartoSessionStart as O, fusengineCache as Ot, loadEnriched as P, runSessionStartCleanups as Q, listChildren as R, dispatchLifecycle as S, loadSecurityState as St, dispatchLessons as T, securityStatePath as Tt, saveApexState as U, validateRulesLoaded as V, logToolFailure as W, injectRules as X, solidDetectStart as Y, readRules as Z, postTrackingSideEffects as _, mcpPostStore as _t, TRIVIAL_BUDGET as a, gitContext as at, trackSkillRead as b, activityFor as bt, detectDuplication as c, taskContext as ct, lifecycleStdout as d, defaultStateDir as dt, pruneEmptyDirs as et, postEditContext as f, projectHash$1 as ft, securityAdvisory as g, isMcpTool as gt, dispatchMemory as h, MCP_TTL_MS as ht, REQUIRED_AGENTS as i, devContext as it, generateProjectMap as j, saveSessionState as jt, generateEcosystemMap as k, loadSessionState as kt, dryGate as l, respond as lt, seoPostToolUseResponse as m, normalizeEvent as mt, handlePre as n, removeOldFiles as nt, gate as o, projectContext as ot, seoPostToolUse as p, trackFile as pt, subagentCacheContext as q, DEFAULT_WINDOW_MS as r, trimLogFile as rt, preCommitGate as s, promptSubmitContext as st, handleHook as t, purgeTtlTree as tt, extractSymbols as u, recordActivity as ut, trackWatchResearch as v, mcpPreIntercept as vt, dispatchAipilot as w, securityStateDir as wt, trackEnrichment as x, isoUtc as xt, trackMcpResearch as y, queryOf as yt, postEditTypescript as z };
4613
+ export { sessionStartCore as $, writePluginMap as A, fusengineCache as At, trackSessionChanges as B, aipilotPostToolUse as C, isoUtc as Ct, lessonsStateFileFor as D, securityStatePath as Dt, lessonsFileFor as E, securityStateDir as Et, mergeLines as F, sessionsDir as Ft, validateTeammateOutput as G, cleanupSession as H, countFiles as I, detectSolidProfile as J, trackAgentMemory as K, getFileDesc as L, isProject as M, sanitizeSessionId as Mt, writeTree as N, saveSessionState as Nt, cartoSessionStart as O, todayUtc as Ot, loadEnriched as P, sessionStatePath as Pt, runSessionStartCleanups as Q, listChildren as R, dispatchLifecycle as S, activityFor as St, dispatchLessons as T, saveSecurityState as Tt, saveApexState as U, validateRulesLoaded as V, logToolFailure as W, injectRules as X, solidDetectStart as Y, readRules as Z, postTrackingSideEffects as _, MCP_TTL_MS as _t, TRIVIAL_BUDGET as a, gitContext as at, trackSkillRead as b, isMcpTool as bt, detectDuplication as c, taskContext as ct, lifecycleStdout as d, defaultStateDir as dt, pruneEmptyDirs as et, postEditContext as f, projectHash$1 as ft, securityAdvisory as g, mcpPreIntercept as gt, dispatchMemory as h, mcpPostStore as ht, REQUIRED_AGENTS as i, devContext as it, generateProjectMap as j, loadSessionState as jt, generateEcosystemMap as k, claudeHome as kt, dryGate as l, respond as lt, seoPostToolUseResponse as m, normalizeEvent as mt, handlePre as n, removeOldFiles as nt, gate as o, projectContext as ot, seoPostToolUse as p, trackFile as pt, subagentCacheContext as q, DEFAULT_WINDOW_MS as r, trimLogFile as rt, preCommitGate as s, promptSubmitContext as st, handleHook as t, purgeTtlTree as tt, extractSymbols as u, recordActivity as ut, trackWatchResearch as v, WEBFETCH_TTL_MS as vt, dispatchAipilot as w, loadSecurityState as wt, trackEnrichment as x, queryOf as xt, trackMcpResearch as y, cacheQueryOf as yt, postEditTypescript as z };