@fusengine/harness 0.1.39 → 0.1.41

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 (52) hide show
  1. package/README.md +7 -4
  2. package/dist/adapters/claude/index.d.mts +2 -2
  3. package/dist/adapters/claude/index.mjs +2 -2
  4. package/dist/adapters/cline/index.mjs +1 -1
  5. package/dist/adapters/codex/index.d.mts +1 -1
  6. package/dist/adapters/codex/index.mjs +1 -1
  7. package/dist/adapters/cursor/index.mjs +1 -1
  8. package/dist/adapters/gemini/index.mjs +1 -1
  9. package/dist/cache/index.d.mts +2 -2
  10. package/dist/cache/index.mjs +3 -3
  11. package/dist/cache-la_KkjCS.mjs +1 -0
  12. package/dist/{claude-ZgTC5yDS.mjs → claude-BnFrSfMo.mjs} +17 -3
  13. package/dist/cli/bin.mjs +70 -6
  14. package/dist/cli/index.mjs +1 -1
  15. package/dist/config/index.d.mts +2 -2
  16. package/dist/config/index.mjs +3 -2
  17. package/dist/{doc-helpers-Dd_x1-tZ.mjs → doc-helpers-BhzDmJ18.mjs} +8 -6
  18. package/dist/{doc-helpers-CG1nuf-c.d.mts → doc-helpers-D14nkD5D.d.mts} +5 -3
  19. package/dist/{dotenv-TLNMiSjP.mjs → dotenv-DGyLln7U.mjs} +1 -20
  20. package/dist/{evaluate-za4vT-p7.mjs → evaluate-B1n1ti0N.mjs} +89 -49
  21. package/dist/freshness/index.d.mts +1 -1
  22. package/dist/freshness/index.mjs +1 -1
  23. package/dist/{handle-t-lQuQc7.mjs → handle-VhWQxvyN.mjs} +612 -163
  24. package/dist/index-BX-xcvhY.d.mts +75 -0
  25. package/dist/{index-DLUfyYmU.d.mts → index-COBvvc3L.d.mts} +3 -1
  26. package/dist/{index-FrWgmkbP.d.mts → index-CwOdFBOr.d.mts} +11 -2
  27. package/dist/{index-CPoF_hLP.d.mts → index-D7GpOmkl.d.mts} +1 -1
  28. package/dist/{index-QzK2dv0V.d.mts → index-DTrjSNmI.d.mts} +38 -13
  29. package/dist/{index-DL8MxjuP.d.mts → index-mmRF3KNp.d.mts} +4 -4
  30. package/dist/index.d.mts +7 -7
  31. package/dist/index.mjs +9 -9
  32. package/dist/{loader-CyAoJv2W.mjs → loader-Bn-DbZmt.mjs} +1 -1
  33. package/dist/mcp-store-CDUVqtJ0.mjs +238 -0
  34. package/dist/policy/index.d.mts +2 -2
  35. package/dist/policy/index.mjs +3 -3
  36. package/dist/refs/index.d.mts +1 -1
  37. package/dist/refs/index.mjs +2 -2
  38. package/dist/{router-D8cVrI-s.mjs → router-BfX0hJg8.mjs} +13 -20
  39. package/dist/{run-DOARDruz.mjs → run-D-Ydrw3D.mjs} +1 -1
  40. package/dist/runtime/index.d.mts +33 -13
  41. package/dist/runtime/index.mjs +2 -2
  42. package/dist/{session-state-DxNkIBZ2.d.mts → session-state-Dzq6yrw7.d.mts} +1 -1
  43. package/dist/state/index.d.mts +1 -1
  44. package/dist/{store-7j02oGjt.mjs → store-3WBr37Xz.mjs} +1 -1
  45. package/dist/tracking/index.d.mts +1 -1
  46. package/dist/tracking/index.mjs +1 -1
  47. package/dist/ttl-BG55s6HZ.mjs +20 -0
  48. package/dist/{validate-osn-huLZ.mjs → validate-B3wkqEoN.mjs} +15 -9
  49. package/package.json +1 -1
  50. package/dist/cache-C9z9LclL.mjs +0 -56
  51. package/dist/index-DPkCX_AR.d.mts +0 -40
  52. 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-osn-huLZ.mjs";
5
- import { j as detectFramework, k as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-za4vT-p7.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-B3wkqEoN.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-BhzDmJ18.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-ZgTC5yDS.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,57 +101,173 @@ 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
+ }
208
+ /**
209
+ * The documentation source a tool satisfies for the doc-consultation gate, or
210
+ * undefined. Context7/Exa are the primary sources; fuse-browser is the fallback
211
+ * when Exa is down (`browser_fetch`/`browser_crawl`/`browser_serp_batch`); the
212
+ * built-in WebSearch/WebFetch also count.
213
+ * @param tool - The harness tool name.
214
+ */
215
+ function docSourceOf$1(tool) {
216
+ if (/context7/i.test(tool)) return "context7";
217
+ if (/exa/i.test(tool)) return "exa";
218
+ if (/browser_(fetch|crawl|serp)/i.test(tool)) return "fuse-browser";
219
+ if (tool === "WebSearch") return "websearch";
220
+ if (tool === "WebFetch") return "webfetch";
221
+ }
110
222
  /**
111
223
  * Map a live tool-use to the activity to record, or null when nothing is
112
224
  * tracked. Works across harnesses — tool names are globally distinct:
113
225
  * - MCP doc calls (`context7` / `exa`, any separator) → `doc`
114
- * - `Task` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
226
+ * - `Task`/`Agent` + `subagent_type` (Claude/Cursor) → `agent` (bare agent name)
227
+ * - direct exploration/research (Glob/Grep, explore Bash, web, MCP cache reads)
228
+ * → `agent` credited to the matching REQUIRED_AGENTS phase
115
229
  * - a read tool opening a `.md` reference → `ref`
116
230
  */
117
231
  function activityFor(event) {
118
- if (/context7|exa/i.test(event.tool)) return {
232
+ const out = [];
233
+ const docSource = docSourceOf$1(event.tool);
234
+ if (docSource) out.push({
119
235
  kind: "doc",
120
236
  framework: event.framework,
121
237
  sessionId: event.sessionId,
122
- source: /exa/i.test(event.tool) ? "exa" : "context7"
123
- };
124
- if (event.tool === "Task") {
125
- const name = String(event.input?.subagent_type ?? "").split(":").pop() ?? "";
126
- 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
- };
238
+ source: docSource
239
+ });
240
+ if (event.tool === "Task" || event.tool === "Agent") {
241
+ const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
242
+ if (name) out.push(agentActivity(name, event.now, qualityFor(event.responseLength, AGENT_QUALITY_MIN)));
243
+ return out;
138
244
  }
139
- if (READ_TOOLS.has(event.tool)) {
245
+ const hit = classifyExplore(event.tool, event.input);
246
+ if (hit) {
247
+ const quality = hit.cacheHit ? "sufficient" : qualityFor(event.responseLength, EXPLORE_QUALITY_MIN);
248
+ out.push(agentActivity(hit.phase, event.now, quality));
249
+ } else if (READ_TOOLS.has(event.tool)) {
140
250
  const path = String(event.input?.file_path ?? event.input?.path ?? "");
141
- if (path.endsWith(".md")) return {
251
+ if (path.endsWith(".md")) out.push({
142
252
  kind: "ref",
143
253
  path
144
- };
254
+ });
145
255
  }
146
- return null;
256
+ return out;
147
257
  }
148
258
  //#endregion
149
- //#region src/runtime/mcp.ts
150
- /** Default freshness for cached MCP/WebFetch results (48h). */
259
+ //#region src/runtime/mcp-key.ts
260
+ /** Cache-key + tool-class helpers for the MCP/WebFetch interception pipeline. */
261
+ /** Default freshness for cached MCP doc results (48h). */
151
262
  const MCP_TTL_MS = 1728e5;
263
+ /** WebFetch freshness — pages go stale faster than docs (24h, parity with Python). */
264
+ const WEBFETCH_TTL_MS = 864e5;
265
+ /** WebFetch prompt slice folded into its cache key (parity with Python prompt[:500]). */
266
+ const PROMPT_TRUNC = 500;
267
+ /** True for the WebFetch tool (exact name). */
268
+ function isWebFetch(tool) {
269
+ return tool === "WebFetch";
270
+ }
152
271
  /** MCP doc tools + WebFetch whose calls are cached / verbosity-capped. */
153
272
  function isMcpTool(tool) {
154
273
  return /context7|exa|webfetch|web_fetch/i.test(tool) || tool === "WebFetch";
@@ -158,6 +277,17 @@ function queryOf(input) {
158
277
  const q = input.query ?? input.url ?? input.libraryId ?? "";
159
278
  return typeof q === "string" ? q : JSON.stringify(q);
160
279
  }
280
+ /**
281
+ * The string that keys the cache for `tool`. WebFetch folds `url + "\n" +
282
+ * prompt[:500]` (parity with Python) so distinct prompts on the same URL never
283
+ * collide; every other tool keys on {@link queryOf}.
284
+ */
285
+ function cacheQueryOf(tool, input) {
286
+ if (!isWebFetch(tool)) return queryOf(input);
287
+ return `${typeof input.url === "string" ? input.url.trim() : ""}\n${(typeof input.prompt === "string" ? input.prompt : "").slice(0, PROMPT_TRUNC)}`;
288
+ }
289
+ //#endregion
290
+ //#region src/runtime/mcp.ts
161
291
  function denyWith(id, content) {
162
292
  if (id === "claude-code" || id === "codex") return JSON.stringify({ hookSpecificOutput: {
163
293
  hookEventName: "PreToolUse",
@@ -191,7 +321,10 @@ function docSourceOf(tool) {
191
321
  */
192
322
  function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
193
323
  if (!isMcpTool(tool)) return null;
194
- const cached = cacheLookup(dir, tool, queryOf(input), ttlMs, now);
324
+ const web = isWebFetch(tool);
325
+ const ttl = web ? WEBFETCH_TTL_MS : ttlMs;
326
+ const key = cacheQueryOf(tool, input);
327
+ const cached = cacheLookup(dir, tool, key, ttl, now) ?? (web ? null : cacheLookupSubstring(dir, key, ttl, now));
195
328
  if (cached) {
196
329
  const served = denyWith(id, cached);
197
330
  if (served) return {
@@ -206,10 +339,18 @@ function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
206
339
  }
207
340
  return null;
208
341
  }
209
- /** Post-event: store the MCP/WebFetch response (extracted to markdown) in the cache. */
210
- function mcpPostStore(tool, input, response, dir) {
342
+ /**
343
+ * Post-event: persist the MCP/WebFetch response (extracted to markdown). MCP docs
344
+ * go through {@link mcpCacheWrite} (compact + Jaccard-dedup + `index.json`);
345
+ * WebFetch uses {@link webfetchCacheWrite} (compact, exact key, no index).
346
+ * @param now - Current epoch ms (timestamp source; defaults to `Date.now()`).
347
+ */
348
+ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
211
349
  if (!isMcpTool(tool)) return;
212
- cacheStore(dir, tool, queryOf(input), extractText(response));
350
+ const text = extractText(response);
351
+ const key = cacheQueryOf(tool, input);
352
+ if (isWebFetch(tool)) webfetchCacheWrite(dir, tool, key, text, now);
353
+ else mcpCacheWrite(dir, tool, key, text, now);
213
354
  }
214
355
  //#endregion
215
356
  //#region src/runtime/normalize.ts
@@ -536,6 +677,117 @@ function runDesignChecks(content) {
536
677
  ];
537
678
  }
538
679
  //#endregion
680
+ //#region src/policy/design/skill-gate.ts
681
+ /** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
682
+ const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
683
+ /** Generated/vendored paths that never need a design skill (match at start or mid-path). */
684
+ const EXEMPT_RE = /(^|\/)(node_modules|dist|build)\/|\.claude\//;
685
+ /** UI-intent path fragments (ports the Python `UI_PATH_PATTERNS`). */
686
+ const UI_PATH_RE = /(components|ui|styles|page|layout|content|view|feature|section|hero|footer|header|sidebar|nav|modal|dialog)/;
687
+ /** Tailwind utility usage in JSX `className` (ports the Python `has_jsx_tailwind`). */
688
+ const TAILWIND_RE = /className\s*=.*(?:flex|grid|p-|m-|bg-|text-|rounded|shadow|border|gap-|w-|h-)/;
689
+ /** A read reference path that counts as "a design skill was consulted". */
690
+ const DESIGN_SKILL_RE = /skills\/([0-9]-)?(designing-systems|generating-components|solid-react|solid-nextjs|design|impeccable|frontend-design)/i;
691
+ /** True when this Write/Edit targets a UI file in a UI-ish path or with Tailwind classes. */
692
+ function isUiWrite(tool, filePath, content) {
693
+ if (tool !== "Write" && tool !== "Edit") return false;
694
+ if (!UI_FILE_RE.test(filePath) || EXEMPT_RE.test(filePath)) return false;
695
+ return UI_PATH_RE.test(filePath) || TAILWIND_RE.test(content);
696
+ }
697
+ /** True when a design skill reference was read this session (scans the `refsRead` track). */
698
+ function designSkillRead(refsRead) {
699
+ return refsRead.some((p) => DESIGN_SKILL_RE.test(p));
700
+ }
701
+ const block$1 = (reason, action) => ({
702
+ kind: "block",
703
+ title: "Design skill",
704
+ reason,
705
+ actions: [action]
706
+ });
707
+ /**
708
+ * Gate a UI write: require a design-skill read AND any doc source. Returns a
709
+ * blocking {@link Prompt}, or `null` to allow. NEVER requires Gemini.
710
+ * @param tool - the tool name ("Write" | "Edit" | ...).
711
+ * @param filePath - the written file path.
712
+ * @param content - the written content (snippet on Edit, full file on Write).
713
+ * @param ev - the session evidence (refsRead + docConsulted).
714
+ */
715
+ function uiDesignSkillGate(tool, filePath, content, ev) {
716
+ if (!isUiWrite(tool, filePath, content)) return null;
717
+ 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");
718
+ 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");
719
+ return null;
720
+ }
721
+ //#endregion
722
+ //#region src/policy/design/skill-evidence.ts
723
+ /**
724
+ * @module design/skill-evidence
725
+ * IO side of the UI design-skill gate: read the verified session track and
726
+ * derive {@link DesignEvidence} (read references + doc-consulted) for
727
+ * {@link uiDesignSkillGate}. Kept separate from the pure gate logic (SRP).
728
+ * @packageDocumentation
729
+ */
730
+ /**
731
+ * Read the verified session track and derive {@link DesignEvidence}. Fail-closed
732
+ * on a missing/corrupt/tampered track (no evidence → the gate blocks).
733
+ * @param sessionId - the Claude session id.
734
+ * @param cwd - the project root (selects the per-project state dir).
735
+ * @param baseDir - override the track base dir (tests); defaults to the project state dir.
736
+ */
737
+ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
738
+ let track = null;
739
+ try {
740
+ track = verifyTrack(JSON.parse(readFileSync(trackFile(sessionId, baseDir), "utf8")));
741
+ } catch {
742
+ track = null;
743
+ }
744
+ return {
745
+ refsRead: track?.refsRead ?? [],
746
+ docConsulted: track ? isDocConsulted(track.authorizations, sessionId) : false
747
+ };
748
+ }
749
+ //#endregion
750
+ //#region src/runtime/design-helpers.ts
751
+ /**
752
+ * @module design-helpers
753
+ * Effectful helpers for the design pipeline gate: locate design-system.md and
754
+ * apply PostToolUse fuse-browser transitions to the design state. Split out of
755
+ * `design.ts` to keep that file within the SOLID size budget (SRP).
756
+ * @packageDocumentation
757
+ */
758
+ const NAV$1 = "mcp__fuse-browser__browser_navigate";
759
+ const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
760
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
761
+ const GEMINI$1 = "mcp__gemini-design__create_frontend";
762
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
763
+ function findDesignSystem(cwd) {
764
+ let dir = cwd;
765
+ for (let i = 0; i < 6; i++) {
766
+ const p = join(dir, "design-system.md");
767
+ if (existsSync(p)) try {
768
+ return readFileSync(p, "utf8");
769
+ } catch {
770
+ return "";
771
+ }
772
+ const parent = dirname(dir);
773
+ if (parent === dir) break;
774
+ dir = parent;
775
+ }
776
+ return "";
777
+ }
778
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
779
+ function recordPost(event, cacheDir, state) {
780
+ if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
781
+ else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
782
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
783
+ else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
784
+ ...state,
785
+ geminiCalls: state.geminiCalls + 1
786
+ });
787
+ else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
788
+ else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
789
+ }
790
+ //#endregion
539
791
  //#region src/policy/design/gates.ts
540
792
  const ALLOWED_WRITE = /\.(html|css|md|json)$/;
541
793
  const EXEMPT_DIRS = [
@@ -620,48 +872,28 @@ function geminiEnabled() {
620
872
  //#region src/runtime/design.ts
621
873
  const NAV = "mcp__fuse-browser__browser_navigate";
622
874
  const SHOT = "mcp__fuse-browser__browser_screenshot";
623
- const SCROLL = "mcp__fuse-browser__browser_scroll";
624
875
  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
876
  /**
654
877
  * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
655
878
  * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
656
879
  */
657
880
  function designGate(payload, event, cacheDir, cwd) {
881
+ if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
882
+ const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
883
+ if (skillBlock) return skillBlock;
884
+ }
658
885
  const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
659
886
  const active = activeDesignAgent(cacheDir);
660
887
  if (active && agentId && agentId !== active) return null;
661
888
  const id = active || agentId;
662
889
  if (!id) return null;
663
- const state = loadDesignState(cacheDir, id);
664
- if (!state) return null;
890
+ let state = loadDesignState(cacheDir, id);
891
+ if (!state) {
892
+ if (!active) return null;
893
+ const dsExists = findDesignSystem(cwd) !== "";
894
+ state = initDesignState(id, dsExists ? "page" : "full", dsExists);
895
+ saveDesignState(cacheDir, state);
896
+ }
665
897
  if (event.phase === "post") {
666
898
  recordPost(event, cacheDir, state);
667
899
  if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
@@ -1129,23 +1361,24 @@ function recordHistory(home, agentId, agentType, ts) {
1129
1361
  function trackAgentMemory(data, home = homedir(), now = Date.now()) {
1130
1362
  mkdirSync(sessionsDir(home), { recursive: true });
1131
1363
  const agentType = String(data.agent_type ?? data.subagent_type ?? "unknown");
1132
- const sessionId = String(data.session_id ?? "unknown");
1364
+ const sessionId = sanitizeSessionId(data.session_id) ?? "unknown";
1133
1365
  const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
1134
1366
  recordHistory(home, String(data.agent_id ?? "unknown"), agentType, ts);
1135
1367
  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,
1368
+ const state = loadSessionState(sessionId, home);
1369
+ const changes = state.changes;
1370
+ const count = changes?.cumulativeCodeFiles ?? 0;
1371
+ if (count > 0) {
1372
+ const files = (changes?.modifiedFiles ?? []).join(", ");
1373
+ saveSessionState(sessionId, {
1374
+ ...state,
1375
+ changes: {
1376
+ ...changes,
1144
1377
  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 {}
1378
+ }
1379
+ }, home);
1380
+ return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${count} code file(s): ${files}. Run sniper agent now.`);
1381
+ }
1149
1382
  return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
1150
1383
  }
1151
1384
  //#endregion
@@ -1160,14 +1393,9 @@ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
1160
1393
  */
1161
1394
  function validateTeammateOutput(data, home = homedir()) {
1162
1395
  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 {}
1396
+ const changes = loadSessionState(sanitizeSessionId(data.session_id) ?? "unknown", home).changes;
1397
+ const count = changes?.cumulativeCodeFiles ?? 0;
1398
+ 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
1399
  return "";
1172
1400
  }
1173
1401
  //#endregion
@@ -1260,7 +1488,7 @@ function validateRulesLoaded(data, home = homedir()) {
1260
1488
  //#endregion
1261
1489
  //#region src/runtime/lifecycle/track-changes.ts
1262
1490
  /** 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)$/;
1491
+ const CODE_EXT$1 = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
1264
1492
  /**
1265
1493
  * Handle PostToolUse Write/Edit: track the cumulative set of modified code
1266
1494
  * files per session and emit the mandatory "SNIPER VALIDATION REQUIRED"
@@ -1272,7 +1500,7 @@ const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelt
1272
1500
  * @returns The native hook stdout (possibly empty when not a code file).
1273
1501
  */
1274
1502
  function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Date.now()) {
1275
- if (!filePath || !CODE_EXT.test(filePath)) return "";
1503
+ if (!filePath || !CODE_EXT$1.test(filePath)) return "";
1276
1504
  const sid = sanitizeSessionId(sessionIdRaw) ?? "unknown";
1277
1505
  const state = loadSessionState(sid, home);
1278
1506
  const prev = state.changes ?? {
@@ -1354,6 +1582,66 @@ function postEditTypescript(filePath) {
1354
1582
  return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1355
1583
  }
1356
1584
  //#endregion
1585
+ //#region src/runtime/lifecycle/task-completed.ts
1586
+ /** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
1587
+ const CODE_EXTENSIONS = /* @__PURE__ */ new Set([
1588
+ ".ts",
1589
+ ".tsx",
1590
+ ".js",
1591
+ ".jsx",
1592
+ ".py",
1593
+ ".go",
1594
+ ".rs",
1595
+ ".java",
1596
+ ".php",
1597
+ ".cpp",
1598
+ ".c",
1599
+ ".rb",
1600
+ ".swift",
1601
+ ".kt",
1602
+ ".dart",
1603
+ ".vue",
1604
+ ".svelte",
1605
+ ".astro"
1606
+ ]);
1607
+ /**
1608
+ * Re-count physical lines of every modified code file and collect SOLID
1609
+ * violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
1610
+ * @param files - Candidate modified file paths.
1611
+ * @param max - The SOLID line ceiling.
1612
+ * @returns The list of violation strings (empty when all files comply).
1613
+ */
1614
+ function collectViolations(files, max) {
1615
+ const violations = [];
1616
+ for (const fp of files) {
1617
+ if (!CODE_EXTENSIONS.has(extname(fp)) || !existsSync(fp)) continue;
1618
+ try {
1619
+ const lines = countLines(readFileSync(fp, "utf-8"));
1620
+ if (lines > max) violations.push(`${basename(fp)}: ${lines} lines (max ${max})`);
1621
+ } catch {}
1622
+ }
1623
+ return violations;
1624
+ }
1625
+ /**
1626
+ * Handle TaskCompleted: re-measure the session's modified code files and emit a
1627
+ * `SOLID VIOLATION` additionalContext listing any file over the line ceiling.
1628
+ * Ports `task-completed/validate-task-solid.py`.
1629
+ * @param payload - The TaskCompleted hook payload (`task_id`, `task_subject`, `session_id`).
1630
+ * @param home - Home dir (defaults to `~`).
1631
+ * @returns The native hook stdout, or `""` when there are no violations.
1632
+ */
1633
+ function validateTaskSolid(payload, home = homedir()) {
1634
+ const sid = sanitizeSessionId(payload.session_id ?? "unknown");
1635
+ if (!sid) return "";
1636
+ const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
1637
+ if (files.length === 0) return "";
1638
+ const max = resolveMaxLines();
1639
+ const violations = collectViolations(files, max);
1640
+ if (violations.length === 0) return "";
1641
+ const taskId = String(payload.task_id ?? "");
1642
+ return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
1643
+ }
1644
+ //#endregion
1357
1645
  //#region src/runtime/lifecycle/cartographer/fs-util.ts
1358
1646
  /**
1359
1647
  * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
@@ -1575,18 +1863,63 @@ function readPluginMeta(pluginPath) {
1575
1863
  /**
1576
1864
  * Auto-detect the marketplace `plugins` dir that contains `cartographer`,
1577
1865
  * falling back to the first marketplace with a `plugins` dir, else `cwd`.
1578
- * Ports `find_marketplace_plugins`.
1866
+ * Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
1867
+ * derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
1868
+ * shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
1579
1869
  * @param home - Home directory (defaults to `~`).
1870
+ * @param id - Detected harness id (defaults to runtime detection).
1580
1871
  * @returns The resolved plugins directory.
1581
1872
  */
1582
- function findMarketplacePlugins(home = homedir()) {
1583
- const mp = join(home, ".claude", "plugins", "marketplaces");
1873
+ function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
1874
+ const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
1584
1875
  const markets = sortedNames$1(mp);
1585
1876
  for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
1586
1877
  for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
1587
1878
  return process.cwd();
1588
1879
  }
1589
1880
  //#endregion
1881
+ //#region src/runtime/lifecycle/cartographer/scan-hooks.ts
1882
+ /**
1883
+ * Hook scanning (fs). Ports `_scan_hooks` from `scan_plugins.py`: reads a
1884
+ * plugin's `hooks/hooks.json` and reduces it to a single `("hooks", "<events>", "")`
1885
+ * row. The `hooks` value (or the whole document) is either a MAP of event →
1886
+ * entries (keys are the events) or a LIST of entries each carrying an `event`.
1887
+ */
1888
+ /** True for a non-null plain object (excludes arrays), mirroring Python `isinstance(x, dict)`. */
1889
+ function isPlainObject(v) {
1890
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1891
+ }
1892
+ /** Sort unique non-empty strings alpha (byte-order). */
1893
+ function sortedEvents(events) {
1894
+ return [...new Set([...events].filter((e) => e))].sort((a, b) => a.localeCompare(b, "en"));
1895
+ }
1896
+ /** Derive hook event names from the `hooks.json` data (map keys, or list `event` fields). */
1897
+ function hookEvents(hooksData) {
1898
+ if (isPlainObject(hooksData)) return sortedEvents(Object.keys(hooksData).filter((k) => !k.startsWith("_")));
1899
+ if (Array.isArray(hooksData)) return sortedEvents(hooksData.map((h) => isPlainObject(h) ? String(h.event ?? "") : ""));
1900
+ return [];
1901
+ }
1902
+ /**
1903
+ * Scan `hooks/hooks.json` into a single `("hooks", "<events>", "")` row.
1904
+ * @param root - Absolute plugin directory.
1905
+ * @returns The single hooks row, or `[]` when absent/empty/unreadable.
1906
+ */
1907
+ function scanHooks(root) {
1908
+ const file = join(root, "hooks", "hooks.json");
1909
+ if (!existsSync(file)) return [];
1910
+ try {
1911
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
1912
+ const events = hookEvents(isPlainObject(raw) ? raw.hooks ?? raw : {});
1913
+ return events.length ? [[
1914
+ "hooks",
1915
+ events.join(", "),
1916
+ ""
1917
+ ]] : [];
1918
+ } catch {
1919
+ return [];
1920
+ }
1921
+ }
1922
+ //#endregion
1590
1923
  //#region src/runtime/lifecycle/cartographer/scan.ts
1591
1924
  /**
1592
1925
  * Plugin scanning (fs). Ports `scan_plugins.py`: turns a plugin's
@@ -1655,24 +1988,6 @@ function scanCommands(root) {
1655
1988
  fileField(join(dir, n), "description").slice(0, 50)
1656
1989
  ]);
1657
1990
  }
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
1991
  /**
1677
1992
  * Scan a single plugin directory into ordered `[type, name, desc]` rows.
1678
1993
  * @param pluginDir - Absolute plugin directory.
@@ -1853,6 +2168,15 @@ function generateEcosystemMap(now, pluginsDirOverride) {
1853
2168
  * navigation context from the latter as additionalContext.
1854
2169
  */
1855
2170
  /**
2171
+ * Resolve the marketplace plugins dir from `CLAUDE_PLUGIN_ROOT`, mirroring the
2172
+ * Python hook which passes `${CLAUDE_PLUGIN_ROOT}/..` to `generate_map.py`.
2173
+ * @returns The plugins dir (env `/..`), or `undefined` to fall back to auto-detect.
2174
+ */
2175
+ function pluginsDirFromEnv() {
2176
+ const root = process.env.CLAUDE_PLUGIN_ROOT;
2177
+ return root ? resolve(root, "..") : void 0;
2178
+ }
2179
+ /**
1856
2180
  * Regenerate the project map + plugin ecosystem map for `cwd` on SessionStart.
1857
2181
  * Emits the ecosystem navigation context as additionalContext (or "").
1858
2182
  * @param cwd - The working directory.
@@ -1861,7 +2185,7 @@ function generateEcosystemMap(now, pluginsDirOverride) {
1861
2185
  */
1862
2186
  function cartoSessionStart(cwd, now = Date.now()) {
1863
2187
  generateProjectMap(cwd);
1864
- const ctx = generateEcosystemMap(now);
2188
+ const ctx = generateEcosystemMap(now, pluginsDirFromEnv());
1865
2189
  return ctx ? contextResponse("SessionStart", ctx) : "";
1866
2190
  }
1867
2191
  //#endregion
@@ -2658,12 +2982,101 @@ async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
2658
2982
  await writeJsonFile(indexFile, index, true);
2659
2983
  }
2660
2984
  //#endregion
2985
+ //#region src/runtime/lifecycle/aipilot/promote-global-lessons.ts
2986
+ /**
2987
+ * Promote high-frequency project lessons (occurrence count >= 3) to the global,
2988
+ * stack-scoped cache (`fusengine-cache/lessons/_global/<stack>.json`, cap 25).
2989
+ * Ports the ai-pilot `promote-global-lessons.ts` (a spawned subprocess) as an
2990
+ * in-process call from `cacheSniperLessons`. Pure side-effect.
2991
+ */
2992
+ const MIN_COUNT = 3;
2993
+ const MAX_GLOBAL = 25;
2994
+ /** Group lessons by `error_type:pattern`. */
2995
+ function groupByKey(lessons) {
2996
+ const groups = /* @__PURE__ */ new Map();
2997
+ for (const l of lessons) {
2998
+ const key = `${l.error_type}:${l.pattern}`;
2999
+ (groups.get(key) ?? groups.set(key, []).get(key)).push(l);
3000
+ }
3001
+ return [...groups.values()];
3002
+ }
3003
+ /** Read every project lesson file's `errors` into one flat (un-deduped) list. */
3004
+ async function aggregateErrors(cacheDir) {
3005
+ let files;
3006
+ try {
3007
+ files = readdirSync(cacheDir).filter((f) => f.endsWith(".json"));
3008
+ } catch {
3009
+ return [];
3010
+ }
3011
+ const all = [];
3012
+ for (const f of files) {
3013
+ const data = await readJsonFile(join(cacheDir, f));
3014
+ if (data?.errors) all.push(...data.errors);
3015
+ }
3016
+ return all;
3017
+ }
3018
+ /** Keep groups seen >= MIN_COUNT times, collapsed to a single lesson each. */
3019
+ function filterFrequent(lessons) {
3020
+ return groupByKey(lessons).filter((e) => e.length >= MIN_COUNT).map((entries) => {
3021
+ const first = entries[0];
3022
+ return {
3023
+ error_type: first.error_type,
3024
+ pattern: first.pattern,
3025
+ fix: first.fix,
3026
+ last_seen: first.last_seen,
3027
+ count: entries.length,
3028
+ files: [...new Set(entries.flatMap((e) => e.files))],
3029
+ code: { line: [...new Set(entries.flatMap((e) => e.code?.line ?? []))].slice(0, 5) }
3030
+ };
3031
+ });
3032
+ }
3033
+ /** Merge tagged candidates into existing global lessons; dedup, sum, sort, cap. */
3034
+ function mergeGlobal(existing, candidates, projHash) {
3035
+ const tagged = candidates.map((c) => ({
3036
+ ...c,
3037
+ source_projects: [projHash]
3038
+ }));
3039
+ return groupByKey([...existing, ...tagged]).map((entries) => {
3040
+ const first = entries[0];
3041
+ return {
3042
+ error_type: first.error_type,
3043
+ pattern: first.pattern,
3044
+ fix: first.fix,
3045
+ last_seen: first.last_seen,
3046
+ count: entries.reduce((sum, e) => sum + e.count, 0),
3047
+ files: [...new Set(entries.flatMap((e) => e.files))],
3048
+ code: { line: [...new Set(entries.flatMap((e) => e.code?.line ?? []))].slice(0, 5) },
3049
+ source_projects: [...new Set(entries.flatMap((e) => e.source_projects ?? []))]
3050
+ };
3051
+ }).sort((a, b) => b.count - a.count).slice(0, MAX_GLOBAL);
3052
+ }
3053
+ /**
3054
+ * Promote high-frequency lessons from `cacheDir` to the global stack file.
3055
+ * @param cacheDir - Per-project lessons cache dir to scan.
3056
+ * @param stack - Detected project stack (`nextjs`, `laravel`, …, `universal`).
3057
+ * @param projHash - Source project hash (tags promoted entries).
3058
+ * @param home - Home dir (defaults to `~`).
3059
+ */
3060
+ async function promoteGlobalLessons(cacheDir, stack, projHash, home = homedir()) {
3061
+ const candidates = filterFrequent(await aggregateErrors(cacheDir));
3062
+ if (candidates.length === 0) return;
3063
+ const globalDir = join(cacheBaseDir(home), "lessons", "_global");
3064
+ mkdirSync(globalDir, { recursive: true });
3065
+ const globalFile = join(globalDir, `${stack}.json`);
3066
+ const merged = mergeGlobal(await readJsonFile(globalFile) ?? [], candidates, projHash);
3067
+ await writeJsonFile(globalFile, merged, true);
3068
+ logCacheEvent("lessons", "promoted", projHash, {
3069
+ count: merged.length,
3070
+ stack
3071
+ }, home);
3072
+ }
3073
+ //#endregion
2661
3074
  //#region src/runtime/lifecycle/aipilot/cache-lessons.ts
2662
3075
  /**
2663
3076
  * SubagentStop (matcher "sniper") for the ai-pilot scope: capture error
2664
3077
  * 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.
3078
+ * `cache-sniper-lessons.ts`, including the high-frequency promotion to the
3079
+ * global stack cache (`promoteGlobalLessons`). Pure side-effect.
2667
3080
  */
2668
3081
  /**
2669
3082
  * SubagentStop sniper: extract lessons from the transcript and persist them.
@@ -2700,6 +3113,7 @@ async function cacheSniperLessons(transcript, cwd, home = homedir()) {
2700
3113
  timestamp,
2701
3114
  errors
2702
3115
  }, true);
3116
+ await promoteGlobalLessons(cacheDir, detectStack(projectPath), pHash, home);
2703
3117
  logCacheEvent("lessons", "hit", pHash, { count: edits.length }, home);
2704
3118
  }
2705
3119
  //#endregion
@@ -2924,12 +3338,12 @@ function contextTextOf(response) {
2924
3338
  }
2925
3339
  }
2926
3340
  /**
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.
3341
+ * Combine N SubagentStart responses into one (Claude concatenates every hook's
3342
+ * additionalContext; collapsing the matcher-"" + type-specific scripts into one
3343
+ * dispatch call must do that join itself). Returns "" when all are empty.
2930
3344
  */
2931
- function combineContext(a, b) {
2932
- const parts = [contextTextOf(a), contextTextOf(b)].filter(Boolean);
3345
+ function combineContext(...responses) {
3346
+ const parts = responses.map(contextTextOf).filter(Boolean);
2933
3347
  return parts.length ? contextResponse("SubagentStart", parts.join("\n\n")) : "";
2934
3348
  }
2935
3349
  /** The transcript path a SubagentStop payload reports (Stop-only field). */
@@ -2937,13 +3351,22 @@ function transcriptOf(payload) {
2937
3351
  const t = payload.agent_transcript_path;
2938
3352
  return typeof t === "string" ? t : void 0;
2939
3353
  }
2940
- /** SubagentStart routing: cache injectors keyed on agent_type. */
2941
- async function onSubagentStart(payload, cwd, now) {
2942
- const agent = agentTypeOf(payload);
3354
+ /** Type-specific SubagentStart cache for an agent, or "" when none applies. */
3355
+ async function typeSpecificCache(agent, cwd, now) {
2943
3356
  if (agent.includes("explore-codebase")) return injectExploreCache(cwd, void 0, now);
2944
3357
  if (agent.includes("research-expert")) return injectDocCache(cwd, void 0, now);
2945
3358
  if (agent.includes("sniper")) return injectTestCache(cwd, void 0, now);
2946
- return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now));
3359
+ return "";
3360
+ }
3361
+ /**
3362
+ * SubagentStart routing. Parity with the Python ai-pilot hooks: the two
3363
+ * matcher-"" entries (APEX context + lessons "known issues") fire for EVERY
3364
+ * sub-agent, then the type-specific cache (explore/doc/test) is concatenated on
3365
+ * top. Sniper must still receive the lessons block — hence no early return.
3366
+ */
3367
+ async function onSubagentStart(payload, cwd, now) {
3368
+ const agent = agentTypeOf(payload);
3369
+ return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now), await typeSpecificCache(agent, cwd, now));
2947
3370
  }
2948
3371
  /** SubagentStop routing: transcript-driven cache writers (side-effects). */
2949
3372
  async function onSubagentStop(payload, cwd) {
@@ -3014,6 +3437,7 @@ function dispatchLifecycle(input) {
3014
3437
  case "InstructionsLoaded":
3015
3438
  validateRulesLoaded(input.payload);
3016
3439
  return "";
3440
+ case "TaskCompleted": return validateTaskSolid(input.payload);
3017
3441
  default: return null;
3018
3442
  }
3019
3443
  }
@@ -3465,11 +3889,11 @@ async function dispatchMemory(event, payload, cwd, now) {
3465
3889
  * missing. Opt-in only — silent when no marker / non-HTML / file unreadable.
3466
3890
  */
3467
3891
  /**
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
3892
+ * Validate the edited file's SEO completeness. Returns a block message (for a
3893
+ * top-level `decision: block` response) when HTML-like, opted-in, and missing
3470
3894
  * elements; otherwise `null` (allow).
3471
3895
  * @param payload - The raw PostToolUse payload.
3472
- * @returns The deny reason string, or `null` to allow.
3896
+ * @returns The block reason string, or `null` to allow.
3473
3897
  */
3474
3898
  function seoPostToolUse(payload) {
3475
3899
  const path = payload.tool_input?.file_path;
@@ -3484,14 +3908,16 @@ function seoPostToolUse(payload) {
3484
3908
  }
3485
3909
  }
3486
3910
  /**
3487
- * SEO PostToolUse as a ready native response: a `permissionDecision: deny`
3911
+ * SEO PostToolUse as a ready native response: a top-level `decision: block`
3488
3912
  * string when the edited file is missing SEO elements, else `null` (allow).
3913
+ * PostToolUse ignores `permissionDecision` (PreToolUse-only), so this must use
3914
+ * the `decision`/`reason` keys to actually feed the failure back to Claude.
3489
3915
  * @param payload - The raw PostToolUse payload.
3490
- * @returns The deny response string, or `null` to allow.
3916
+ * @returns The block response string, or `null` to allow.
3491
3917
  */
3492
3918
  function seoPostToolUseResponse(payload) {
3493
- const deny = seoPostToolUse(payload);
3494
- return deny ? denyResponse("PostToolUse", deny) : null;
3919
+ const block = seoPostToolUse(payload);
3920
+ return block ? blockResponse(block) : null;
3495
3921
  }
3496
3922
  //#endregion
3497
3923
  //#region src/runtime/lifecycle-bridge.ts
@@ -3535,6 +3961,26 @@ function postEditContext(scope, event, now) {
3535
3961
  }
3536
3962
  //#endregion
3537
3963
  //#region src/runtime/gate-helpers.ts
3964
+ /** Source extensions the APEX gates apply to (parity: require-apex-agents.py CODE_EXT). */
3965
+ const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
3966
+ /** Paths exempt from the APEX gates even when they are code (parity: require-apex-agents.py EXEMPT_PATTERNS). */
3967
+ const EXEMPT_PATTERNS = [
3968
+ /\.claude-plugin\//,
3969
+ /CHANGELOG\.md$/,
3970
+ /marketplace\.json$/,
3971
+ /\/\.claude\/(apex|memory|logs|fusengine-cache)\//
3972
+ ];
3973
+ /**
3974
+ * True when `filePath` is a code file the APEX freshness/doc/SOLID gates should
3975
+ * police — i.e. it matches a tracked source extension AND no exempt pattern.
3976
+ * Mirrors require-apex-agents.py so non-code (and exempt) paths skip those gates.
3977
+ * @param filePath - Absolute path of the edit target (or undefined).
3978
+ * @returns Whether the APEX gates apply to this path.
3979
+ */
3980
+ function isApexScoped(filePath) {
3981
+ if (!filePath || !CODE_EXT.test(filePath)) return false;
3982
+ return !EXEMPT_PATTERNS.some((p) => p.test(filePath));
3983
+ }
3538
3984
  /**
3539
3985
  * Code-only line count of the existing on-disk file (undefined if
3540
3986
  * absent/unreadable). Uses {@link countLines} (skips blank/comment lines) so a
@@ -3556,7 +4002,7 @@ function existingLineCount(path) {
3556
4002
  //#region src/freshness/agent-evidence.ts
3557
4003
  /**
3558
4004
  * Platform-authored transcript evidence for APEX agent freshness.
3559
- * Parses the Claude Code session JSONL transcript to find genuine Task
4005
+ * Parses the Claude Code session JSONL transcript to find genuine `Task`/`Agent`
3560
4006
  * tool_use entries — forging this requires writing into the transcript file
3561
4007
  * which the Claude Code platform controls, unlike the self-recorded track.
3562
4008
  */
@@ -3569,9 +4015,9 @@ function parseTs(raw) {
3569
4015
  }
3570
4016
  /**
3571
4017
  * 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`.
4018
+ * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task` or
4019
+ * `Agent` tool whose `subagent_type` (or `name`) input field matches after
4020
+ * stripping any plugin prefix — with the entry timestamp within `windowMs` of `now`.
3575
4021
  *
3576
4022
  * **Timestamp note:** when a transcript entry carries no `timestamp` field it
3577
4023
  * is counted as within-window (we cannot prove staleness). This is
@@ -3609,9 +4055,10 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
3609
4055
  const content = entry.message?.content;
3610
4056
  if (!Array.isArray(content)) continue;
3611
4057
  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);
4058
+ if (block?.type !== "tool_use" || block.name !== "Task" && block.name !== "Agent") continue;
4059
+ const raw = block.input?.subagent_type ?? block.input?.name;
4060
+ const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
4061
+ if (agent !== void 0 && names.includes(agent)) found.add(agent);
3615
4062
  }
3616
4063
  if (found.size === names.length) return true;
3617
4064
  }
@@ -3958,6 +4405,8 @@ const TRIVIAL_BUDGET = 4;
3958
4405
  * track. Returns the first blocking prompt, or null to allow.
3959
4406
  */
3960
4407
  async function gate(input) {
4408
+ const precommit = preCommitGate(input.tool, input.command, input.cwd);
4409
+ if (precommit) return precommit;
3961
4410
  const existingLines = existingLineCount(input.filePath);
3962
4411
  let quick;
3963
4412
  try {
@@ -3973,8 +4422,6 @@ async function gate(input) {
3973
4422
  return FAIL_CLOSED;
3974
4423
  }
3975
4424
  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
4425
  const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
3979
4426
  if (modular) return modular;
3980
4427
  if (!input.filePath) return null;
@@ -3982,29 +4429,31 @@ async function gate(input) {
3982
4429
  const track = await loadTrack(input.trackFile);
3983
4430
  const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
3984
4431
  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;
4432
+ if (isApexScoped(input.filePath)) {
4433
+ const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
4434
+ if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
4435
+ await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
4436
+ return null;
4437
+ }
4438
+ const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
4439
+ const ctx = {
4440
+ sessionId: input.sessionId,
4441
+ framework: input.framework,
4442
+ filePath: input.filePath,
4443
+ content: input.content ?? "",
4444
+ authorizations: track.authorizations,
4445
+ refs: input.refs,
4446
+ refsRead: track.refsRead,
4447
+ agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
4448
+ brainstormRequired: track.brainstormRequired,
4449
+ brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
4450
+ };
4451
+ try {
4452
+ const apex = evaluateApex(ctx);
4453
+ if (apex) return apex;
4454
+ } catch {
4455
+ return FAIL_CLOSED;
4456
+ }
4008
4457
  }
4009
4458
  return dryGate(input.tool, input.filePath, input.content, input.cwd);
4010
4459
  }
@@ -4137,7 +4586,7 @@ async function handleHook(id, payload, opts) {
4137
4586
  const response = payload.tool_response ?? payload.tool_output;
4138
4587
  mcpPostStore(event.tool, event.input, response, mcpDir);
4139
4588
  const designWarn = designGate(payload, event, mcpDir, opts.cwd);
4140
- const activity = activityFor({
4589
+ const activities = activityFor({
4141
4590
  tool: event.tool,
4142
4591
  input: event.input,
4143
4592
  sessionId: event.sessionId,
@@ -4145,7 +4594,7 @@ async function handleHook(id, payload, opts) {
4145
4594
  now: opts.now,
4146
4595
  responseLength: extractText(response).length
4147
4596
  });
4148
- if (activity) await recordActivity(file, activity);
4597
+ for (const activity of activities) await recordActivity(file, activity);
4149
4598
  postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
4150
4599
  const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
4151
4600
  if (seoDeny) return {
@@ -4176,4 +4625,4 @@ async function handleHook(id, payload, opts) {
4176
4625
  });
4177
4626
  }
4178
4627
  //#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 };
4628
+ 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 };