@fusengine/harness 0.1.42 → 0.1.44

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 (43) 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/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
  9. package/dist/cli/bin.mjs +233 -11
  10. package/dist/cli/index.mjs +1 -1
  11. package/dist/config/index.d.mts +1 -1
  12. package/dist/config/index.mjs +1 -2
  13. package/dist/{doc-helpers-D14nkD5D.d.mts → doc-helpers-BNfYWvYv.d.mts} +9 -1
  14. package/dist/{doc-helpers-BhzDmJ18.mjs → doc-helpers-CWZegVdR.mjs} +14 -5
  15. package/dist/{dotenv-DGyLln7U.mjs → dotenv-B9nM4cuQ.mjs} +26 -1
  16. package/dist/evaluate-d7Pp8XJH.mjs +784 -0
  17. package/dist/freshness/index.d.mts +1 -1
  18. package/dist/freshness/index.mjs +1 -1
  19. package/dist/{handle-BTHcKWQ5.mjs → handle-CtMMVoxT.mjs} +789 -357
  20. package/dist/home-state-mKZxP4oZ.mjs +52 -0
  21. package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -1
  22. package/dist/{index-DXQfL1u8.d.mts → index-BXPySPxE.d.mts} +7 -1
  23. package/dist/{index-CwOdFBOr.d.mts → index-BxjzFraL.d.mts} +3 -1
  24. package/dist/{index-DN4cZDbU.d.mts → index-CVhw7eA0.d.mts} +111 -23
  25. package/dist/index.d.mts +5 -5
  26. package/dist/index.mjs +7 -8
  27. package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
  28. package/dist/policy/index.d.mts +2 -2
  29. package/dist/policy/index.mjs +3 -3
  30. package/dist/refs/index.mjs +2 -2
  31. package/dist/{router-BfX0hJg8.mjs → router-PKVNBHge.mjs} +11 -1
  32. package/dist/{run-jgivVDv6.mjs → run-DLXtA5DH.mjs} +1 -1
  33. package/dist/runtime/index.d.mts +71 -14
  34. package/dist/runtime/index.mjs +3 -3
  35. package/dist/{session-state-Dzq6yrw7.d.mts → session-state-D4F_Dub6.d.mts} +1 -1
  36. package/dist/state/index.d.mts +1 -1
  37. package/dist/{store-CdWOQ9zD.mjs → store-CNjFenWe.mjs} +3 -50
  38. package/dist/tracking/index.d.mts +1 -1
  39. package/dist/tracking/index.mjs +1 -1
  40. package/dist/{validate-xi-zc-22.mjs → validate-Ca7NSp-r.mjs} +495 -83
  41. package/package.json +1 -1
  42. package/dist/evaluate-CeivW6G0.mjs +0 -477
  43. package/dist/ttl-BG55s6HZ.mjs +0 -20
@@ -1,18 +1,17 @@
1
- import { a as parseEnvInt, r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
1
+ import { a as parseEnvInt, i as splitTarget, 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";
4
3
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.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-xi-zc-22.mjs";
7
- import { N as detectFramework, j as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-CeivW6G0.mjs";
8
- import { r as isDocConsulted } from "./doc-helpers-BhzDmJ18.mjs";
9
- import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
4
+ import { A as detectCreationIntent, B as detectProjectType$1, C as skillTriggerGate, E as frameworkSolidGate, H as requiredArchSkill, L as detectFramework, N as docConsultedGate, P as evaluateApex, _ as scanPlugin, b as findMarketplacePlugins, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, x as readPluginMeta, y as parseField, z as detectModularArchitecture } from "./validate-Ca7NSp-r.mjs";
5
+ import { F as countFrameworkCodeLines, I as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-d7Pp8XJH.mjs";
10
6
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
7
+ import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-mKZxP4oZ.mjs";
8
+ import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
9
+ import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
11
10
  import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-CymilZiZ.mjs";
12
11
  import { c as extractText, i as cacheLookupSubstring, l as loadIndex, n as webfetchCacheWrite, r as cacheLookup, t as mcpCacheWrite } from "./mcp-store-CnsW9oFj.mjs";
13
- import { t as loadRefs } from "./loader-Bn-DbZmt.mjs";
14
- import { _ as saveSessionState, c as recordDoc, d as trivialCount, f as claudeHome, g as sanitizeSessionId, h as loadSessionState, i as agentsFresh, l as recordRefRead, m as fusengineCache, n as saveTrack, o as recordAgent, p as fuseHarnessHome, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as recordTrivialEdit, y as sessionsDir } from "./store-CdWOQ9zD.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-CeRYMOaG.mjs";
12
+ import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
13
+ 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-CNjFenWe.mjs";
14
+ import { d as readText, f as sleep, l as collectFiles, m as writeText, n as contextResponse, p as spawnCapture, r as denyResponse, s as systemMessage, t as blockResponse, u as pathExists } from "./claude-DLh0fHWM.mjs";
16
15
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
17
16
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
18
17
  import { homedir } from "node:os";
@@ -300,11 +299,23 @@ function clearActiveDesignAgent(cacheDir) {
300
299
  * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
301
300
  * raise the active flag on start, archive/cleanup + clear the flag on stop.
302
301
  * Returns true when it handled the event (caller should respond and stop).
302
+ *
303
+ * `SubagentStop` clears the flag whenever `agent_id` matches the currently
304
+ * active one, INDEPENDENTLY of `agent_type` on the stop payload — a stop event
305
+ * is not guaranteed to echo the same `agent_type` string the start event used,
306
+ * and gating the clear on it left the flag stuck (blocking every subsequent
307
+ * top-level Write/Edit in the session — see MEMORY/LESSON.md).
303
308
  */
304
309
  function designLifecycle(payload, cacheDir, cwd, stamp, now) {
305
310
  const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
306
- if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
307
311
  const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
312
+ if (event === "SubagentStop") {
313
+ if (!agentId || agentId !== activeDesignAgent(cacheDir)) return false;
314
+ cleanupDesignStates(cacheDir, agentId, stamp, now);
315
+ clearActiveDesignAgent(cacheDir);
316
+ return true;
317
+ }
318
+ if (!(typeof payload.agent_type === "string" ? payload.agent_type : "").includes("design")) return false;
308
319
  if (event === "SubagentStart") {
309
320
  if (!agentId) return false;
310
321
  const dsExists = existsSync(join(cwd, "design-system.md"));
@@ -312,11 +323,6 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
312
323
  setActiveDesignAgent(cacheDir, agentId);
313
324
  return true;
314
325
  }
315
- if (event === "SubagentStop") {
316
- cleanupDesignStates(cacheDir, agentId, stamp, now);
317
- clearActiveDesignAgent(cacheDir);
318
- return true;
319
- }
320
326
  return false;
321
327
  }
322
328
  //#endregion
@@ -637,6 +643,176 @@ function solidDetectStart(env = process.env) {
637
643
  return profile.type !== "unknown" ? `SOLID: ${profile.type} project (max ${profile.limit} lines)` : "";
638
644
  }
639
645
  //#endregion
646
+ //#region src/runtime/lifecycle/check-file-size.ts
647
+ /**
648
+ * PostToolUse (matcher "Write|Edit") for the solid scope: warn when a file
649
+ * exceeds the adaptive `SOLID_FILE_LIMIT` (set by `solidDetectStart` at
650
+ * SessionStart, keyed off the detected project type). Ports `check-file-size.py`.
651
+ * Non-blocking (`additionalContext`), unlike the fixed-ceiling core-guards gate.
652
+ */
653
+ /** Per-language "blank or comment" line pattern (parity `count_loc`'s `code_comment` map). */
654
+ const COMMENT_RE = {
655
+ ts: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
656
+ tsx: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
657
+ js: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
658
+ jsx: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
659
+ go: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
660
+ rs: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
661
+ java: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
662
+ swift: /^\s*$|^\s*\/\/|^\s*\/\*|^\s*\*/,
663
+ php: /^\s*$|^\s*\/\/|^\s*#|^\s*\/\*|^\s*\*/,
664
+ py: /^\s*$|^\s*#|^\s*"""|^\s*'''/
665
+ };
666
+ /** Count lines of code excluding comments/blanks for `filePath`'s extension (raw length when unmapped). */
667
+ function countLoc(filePath, content) {
668
+ const pattern = COMMENT_RE[extname(filePath).slice(1)];
669
+ const lines = content.split("\n");
670
+ return pattern ? lines.filter((l) => !pattern.test(l)).length : lines.length;
671
+ }
672
+ /**
673
+ * Warn when a written/edited file exceeds `SOLID_FILE_LIMIT` (default 100).
674
+ * Inert when `SOLID_PROJECT_TYPE` is absent/"unknown" (parity: the Python hook
675
+ * no-ops outside a detected project).
676
+ * @param tool - The tool name (only "Write"/"Edit" are checked).
677
+ * @param filePath - The written file's absolute path.
678
+ * @param env - Environment (defaults to `process.env`).
679
+ * @returns The PostToolUse `additionalContext` response, or `""` when clean/inert.
680
+ */
681
+ function checkFileSize(tool, filePath, env = process.env) {
682
+ const ptype = env.SOLID_PROJECT_TYPE ?? "";
683
+ if (!ptype || ptype === "unknown") return "";
684
+ if (tool !== "Write" && tool !== "Edit") return "";
685
+ if (!filePath || !pathExists(filePath)) return "";
686
+ let content;
687
+ try {
688
+ content = readText(filePath);
689
+ } catch {
690
+ return "";
691
+ }
692
+ const limit = parseEnvInt(env.SOLID_FILE_LIMIT, 100);
693
+ const loc = countLoc(filePath, content);
694
+ if (loc <= limit) return "";
695
+ return contextResponse("PostToolUse", `SOLID: ${filePath.split("/").pop() ?? filePath} has ${loc} lines (limit: ${limit}). Consider splitting into smaller modules.`);
696
+ }
697
+ //#endregion
698
+ //#region src/runtime/lifecycle/validate-solid.ts
699
+ /**
700
+ * PreToolUse (matcher "Write|Edit") for the solid scope: deny a Go top-level
701
+ * interface or a Python `ABC` subclass defined outside its language's
702
+ * `/interfaces/` directory. Ports `solid/scripts/validate-solid.py`'s
703
+ * `check_go`/`check_python` only — its `check_nextjs`/`check_laravel`/
704
+ * `check_swift` siblings are NOT ported here: they are a strict subset of the
705
+ * already-ported `framework-solid-gates.ts` (react/nextjs/laravel/swift),
706
+ * which fire unconditionally on file extension/content rather than being
707
+ * gated behind `SOLID_PROJECT_TYPE`. Inert when `SOLID_PROJECT_TYPE` is
708
+ * absent/"unknown"/nextjs/laravel/swift/rust (parity: the Python hook only
709
+ * has a `go`/`python` validator).
710
+ */
711
+ /** Top-level Go interface declaration, e.g. `type Foo interface {`. */
712
+ const GO_INTERFACE_RE = /^type.*interface \{/m;
713
+ /** Python class whose header mentions `ABC` (naive, parity with the Python regex). */
714
+ const PY_ABC_RE = /class.*ABC/;
715
+ /** Go: deny a top-level interface declared outside `/interfaces/`. */
716
+ function checkGo(filePath, content) {
717
+ if (!filePath.endsWith(".go") || filePath.includes("/interfaces/")) return null;
718
+ return GO_INTERFACE_RE.test(content) ? "SOLID: Interfaces must be in internal/interfaces/" : null;
719
+ }
720
+ /** Python: deny an ABC subclass declared outside `/interfaces/`. */
721
+ function checkPython(filePath, content) {
722
+ if (!filePath.endsWith(".py") || filePath.includes("/interfaces/")) return null;
723
+ return PY_ABC_RE.test(content) ? "SOLID: Abstract classes must be in src/interfaces/" : null;
724
+ }
725
+ /** `SOLID_PROJECT_TYPE` values this gate validates (parity subset — see module doc). */
726
+ const VALIDATORS = {
727
+ go: checkGo,
728
+ python: checkPython
729
+ };
730
+ /**
731
+ * Deny a Write/Edit that violates the Go/Python interface-location SOLID rule.
732
+ * @param tool - The tool name (only "Write"/"Edit" are checked).
733
+ * @param filePath - The written file's absolute path.
734
+ * @param content - The written/edited content (`new_string` snippet on Edit).
735
+ * @param env - Environment (defaults to `process.env`).
736
+ * @returns The PreToolUse deny response, or `""` when clean/inert.
737
+ */
738
+ function validateSolidGate(tool, filePath, content, env = process.env) {
739
+ const ptype = env.SOLID_PROJECT_TYPE ?? "";
740
+ if (!ptype || ptype === "unknown") return "";
741
+ if (tool !== "Write" && tool !== "Edit") return "";
742
+ if (!filePath) return "";
743
+ const violation = VALIDATORS[ptype]?.(filePath, content) ?? null;
744
+ return violation ? denyResponse("PreToolUse", violation) : "";
745
+ }
746
+ //#endregion
747
+ //#region src/runtime/lifecycle/validate-tailwind.ts
748
+ /**
749
+ * PostToolUse (matcher "Write|Edit") for the tailwindcss scope: warn
750
+ * (non-blocking) on a deprecated `@tailwind` directive, excessive `@apply`
751
+ * usage, and an overlong `className` string on a just-written CSS/TSX/JSX
752
+ * file inside a detected Tailwind project. Ports `validate-tailwind.py`.
753
+ */
754
+ /** Tailwind config file names checked at each ancestor directory. */
755
+ const TAILWIND_CONFIGS = [
756
+ "tailwind.config.js",
757
+ "tailwind.config.ts",
758
+ "tailwind.config.mjs",
759
+ "tailwind.config.cjs"
760
+ ];
761
+ /** Walk up to 20 levels from `filePath`'s directory to the nearest ancestor holding `package.json` or a Tailwind config (parity `find_project_root`). */
762
+ function findProjectRoot(filePath) {
763
+ let dir = dirname(filePath);
764
+ for (let i = 0; i < 20; i++) {
765
+ if (pathExists(join(dir, "package.json")) || TAILWIND_CONFIGS.some((cfg) => pathExists(join(dir, cfg)))) return dir;
766
+ const parent = dirname(dir);
767
+ if (parent === dir) break;
768
+ dir = parent;
769
+ }
770
+ return null;
771
+ }
772
+ /** True when `filePath` belongs to a Tailwind project (v3 config, or `tailwindcss` in package.json deps). Parity `is_tailwind_project`. */
773
+ function isTailwindProject(filePath) {
774
+ const root = findProjectRoot(filePath);
775
+ if (!root) return false;
776
+ if (TAILWIND_CONFIGS.some((cfg) => pathExists(join(root, cfg)))) return true;
777
+ const pkgPath = join(root, "package.json");
778
+ if (!pathExists(pkgPath)) return false;
779
+ try {
780
+ const pkg = JSON.parse(readText(pkgPath));
781
+ return Boolean(pkg.dependencies?.tailwindcss) || Boolean(pkg.devDependencies?.tailwindcss);
782
+ } catch {
783
+ return false;
784
+ }
785
+ }
786
+ /**
787
+ * Validate Tailwind best practices on a just-written file.
788
+ * @param tool - The tool name (only "Write"/"Edit" are checked).
789
+ * @param filePath - The written file's absolute path.
790
+ * @returns The PostToolUse `additionalContext` response, or `""` when clean/inert.
791
+ */
792
+ function validateTailwind(tool, filePath) {
793
+ if (tool !== "Write" && tool !== "Edit") return "";
794
+ if (!/\.(css|tsx|jsx)$/.test(filePath)) return "";
795
+ if (!pathExists(filePath) || !isTailwindProject(filePath)) return "";
796
+ let content;
797
+ try {
798
+ content = readText(filePath);
799
+ } catch {
800
+ return "";
801
+ }
802
+ const warnings = [];
803
+ if (filePath.endsWith(".css")) {
804
+ if (/@tailwind (base|components|utilities)/.test(content)) warnings.push("Tailwind v4: @tailwind directives are deprecated - use @import 'tailwindcss'.");
805
+ const applyCount = (content.match(/@apply/g) ?? []).length;
806
+ if (applyCount > 10) warnings.push(`Excessive @apply usage (${applyCount}) - prefer utility classes directly.`);
807
+ }
808
+ if (/\.(tsx|jsx)$/.test(filePath)) {
809
+ const longClasses = (content.match(/className="[^"]{150,}"/g) ?? []).length;
810
+ if (longClasses > 0) warnings.push(`Very long className (${longClasses} lines) - extract to @utility or use cn().`);
811
+ }
812
+ if (warnings.length === 0) return "";
813
+ return contextResponse("PostToolUse", warnings.join(" "));
814
+ }
815
+ //#endregion
640
816
  //#region src/runtime/lifecycle/subagent-cache.ts
641
817
  const DEFAULT_TTL_MIN = 30;
642
818
  /** Resolve cache TTL (minutes) from `FUSENGINE_CACHE_TTL_MIN` or default. */
@@ -942,7 +1118,7 @@ function postEditTypescript(filePath) {
942
1118
  //#endregion
943
1119
  //#region src/runtime/lifecycle/task-completed.ts
944
1120
  /** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
945
- const CODE_EXTENSIONS = /* @__PURE__ */ new Set([
1121
+ const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
946
1122
  ".ts",
947
1123
  ".tsx",
948
1124
  ".js",
@@ -972,7 +1148,7 @@ const CODE_EXTENSIONS = /* @__PURE__ */ new Set([
972
1148
  function collectViolations(files, max) {
973
1149
  const violations = [];
974
1150
  for (const fp of files) {
975
- if (!CODE_EXTENSIONS.has(extname(fp)) || !existsSync(fp)) continue;
1151
+ if (!CODE_EXTENSIONS$2.has(extname(fp)) || !existsSync(fp)) continue;
976
1152
  try {
977
1153
  const lines = countLines(readFileSync(fp, "utf-8"));
978
1154
  if (lines > max) violations.push(`${basename(fp)}: ${lines} lines (max ${max})`);
@@ -1190,176 +1366,6 @@ function generateProjectMap(cwd, outputDir) {
1190
1366
  return "";
1191
1367
  }
1192
1368
  //#endregion
1193
- //#region src/runtime/lifecycle/cartographer/detect.ts
1194
- /**
1195
- * Plugin discovery (fs). Ports `detect_plugins.py`: marketplace `plugins` dir
1196
- * resolution + `plugin.json` meta reading.
1197
- */
1198
- /** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
1199
- function sortedNames$1(dir) {
1200
- try {
1201
- return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
1202
- } catch {
1203
- return [];
1204
- }
1205
- }
1206
- /**
1207
- * Read `[version, name]` from `<pluginPath>/.claude-plugin/plugin.json`.
1208
- * @param pluginPath - Absolute plugin directory.
1209
- * @returns The `[version, name]` pair (both "" when absent/unreadable).
1210
- */
1211
- function readPluginMeta(pluginPath) {
1212
- const pj = join(pluginPath, ".claude-plugin", "plugin.json");
1213
- if (!existsSync(pj)) return ["", ""];
1214
- try {
1215
- const meta = JSON.parse(readFileSync(pj, "utf-8"));
1216
- return [meta.version ?? "", meta.name ?? ""];
1217
- } catch {
1218
- return ["", ""];
1219
- }
1220
- }
1221
- /**
1222
- * Auto-detect the marketplace `plugins` dir that contains `cartographer`,
1223
- * falling back to the first marketplace with a `plugins` dir, else `cwd`.
1224
- * Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
1225
- * derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
1226
- * shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
1227
- * @param home - Home directory (defaults to `~`).
1228
- * @param id - Detected harness id (defaults to runtime detection).
1229
- * @returns The resolved plugins directory.
1230
- */
1231
- function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
1232
- const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
1233
- const markets = sortedNames$1(mp);
1234
- for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
1235
- for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
1236
- return process.cwd();
1237
- }
1238
- //#endregion
1239
- //#region src/runtime/lifecycle/cartographer/scan-hooks.ts
1240
- /**
1241
- * Hook scanning (fs). Ports `_scan_hooks` from `scan_plugins.py`: reads a
1242
- * plugin's `hooks/hooks.json` and reduces it to a single `("hooks", "<events>", "")`
1243
- * row. The `hooks` value (or the whole document) is either a MAP of event →
1244
- * entries (keys are the events) or a LIST of entries each carrying an `event`.
1245
- */
1246
- /** True for a non-null plain object (excludes arrays), mirroring Python `isinstance(x, dict)`. */
1247
- function isPlainObject(v) {
1248
- return typeof v === "object" && v !== null && !Array.isArray(v);
1249
- }
1250
- /** Sort unique non-empty strings alpha (byte-order). */
1251
- function sortedEvents(events) {
1252
- return [...new Set([...events].filter((e) => e))].sort((a, b) => a.localeCompare(b, "en"));
1253
- }
1254
- /** Derive hook event names from the `hooks.json` data (map keys, or list `event` fields). */
1255
- function hookEvents(hooksData) {
1256
- if (isPlainObject(hooksData)) return sortedEvents(Object.keys(hooksData).filter((k) => !k.startsWith("_")));
1257
- if (Array.isArray(hooksData)) return sortedEvents(hooksData.map((h) => isPlainObject(h) ? String(h.event ?? "") : ""));
1258
- return [];
1259
- }
1260
- /**
1261
- * Scan `hooks/hooks.json` into a single `("hooks", "<events>", "")` row.
1262
- * @param root - Absolute plugin directory.
1263
- * @returns The single hooks row, or `[]` when absent/empty/unreadable.
1264
- */
1265
- function scanHooks(root) {
1266
- const file = join(root, "hooks", "hooks.json");
1267
- if (!existsSync(file)) return [];
1268
- try {
1269
- const raw = JSON.parse(readFileSync(file, "utf-8"));
1270
- const events = hookEvents(isPlainObject(raw) ? raw.hooks ?? raw : {});
1271
- return events.length ? [[
1272
- "hooks",
1273
- events.join(", "),
1274
- ""
1275
- ]] : [];
1276
- } catch {
1277
- return [];
1278
- }
1279
- }
1280
- //#endregion
1281
- //#region src/runtime/lifecycle/cartographer/scan.ts
1282
- /**
1283
- * Plugin scanning (fs). Ports `scan_plugins.py`: turns a plugin's
1284
- * agents/skills/commands/hooks into ordered `[type, name, desc]` rows.
1285
- */
1286
- /** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
1287
- function sortedNames(dir) {
1288
- try {
1289
- return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
1290
- } catch {
1291
- return [];
1292
- }
1293
- }
1294
- /** Read a `.md` frontmatter field from a file path, "" when missing/unreadable. */
1295
- function fileField(path, field) {
1296
- try {
1297
- return parseField(readFileSync(path, "utf-8"), field);
1298
- } catch {
1299
- return "";
1300
- }
1301
- }
1302
- /** Scan `agents/*.md` → `("agent", name, desc[:50])` rows. */
1303
- function scanAgents(root) {
1304
- const dir = join(root, "agents");
1305
- return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => {
1306
- const f = join(dir, n);
1307
- return [
1308
- "agent",
1309
- fileField(f, "name") || n.replace(/\.md$/, ""),
1310
- fileField(f, "description").slice(0, 50)
1311
- ];
1312
- });
1313
- }
1314
- /** Scan `skills/<dir>/SKILL.md` → `("skill", dir, desc)` rows. */
1315
- function scanSkills(root) {
1316
- const dir = join(root, "skills");
1317
- const rows = [];
1318
- for (const name of sortedNames(dir)) {
1319
- try {
1320
- if (!statSync(join(dir, name)).isDirectory()) continue;
1321
- } catch {
1322
- continue;
1323
- }
1324
- const skillMd = join(dir, name, "SKILL.md");
1325
- let desc = "";
1326
- if (existsSync(skillMd)) {
1327
- desc = fileField(skillMd, "description");
1328
- if (!desc) try {
1329
- desc = parseBodyDesc(readFileSync(skillMd, "utf-8"));
1330
- } catch {}
1331
- }
1332
- rows.push([
1333
- "skill",
1334
- name,
1335
- desc || "(no description)"
1336
- ]);
1337
- }
1338
- return rows;
1339
- }
1340
- /** Scan `commands/*.md` → `("command", "/name", desc[:50])` rows. */
1341
- function scanCommands(root) {
1342
- const dir = join(root, "commands");
1343
- return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => [
1344
- "command",
1345
- `/${n.replace(/\.md$/, "")}`,
1346
- fileField(join(dir, n), "description").slice(0, 50)
1347
- ]);
1348
- }
1349
- /**
1350
- * Scan a single plugin directory into ordered `[type, name, desc]` rows.
1351
- * @param pluginDir - Absolute plugin directory.
1352
- * @returns The agents + skills + commands + hooks rows.
1353
- */
1354
- function scanPlugin(pluginDir) {
1355
- return [
1356
- ...scanAgents(pluginDir),
1357
- ...scanSkills(pluginDir),
1358
- ...scanCommands(pluginDir),
1359
- ...scanHooks(pluginDir)
1360
- ];
1361
- }
1362
- //#endregion
1363
1369
  //#region src/policy/cartographer/build-tree.ts
1364
1370
  const SECTION_ORDER = [
1365
1371
  "agent",
@@ -1701,14 +1707,6 @@ ${agents}
1701
1707
  ### 6. When Done
1702
1708
  - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`);
1703
1709
  }
1704
- //#endregion
1705
- //#region src/runtime/lifecycle/aipilot/cache-base.ts
1706
- /**
1707
- * Shared cache-path + age helpers for the ai-pilot scope.
1708
- * Reuses the harness home-state (`fusengineCache`) so the ai-pilot caches live
1709
- * under the same `~/.fuse-harness/cache` tree as the core MCP cache — no
1710
- * second cache layer.
1711
- */
1712
1710
  /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
1713
1711
  function hashText16(text) {
1714
1712
  return createHash("sha256").update(text).digest("hex").slice(0, 16);
@@ -1839,7 +1837,7 @@ async function cacheAnalyticsSave(home = homedir(), now = Date.now()) {
1839
1837
  * cached architecture report when fresh + config-matching, else inject save
1840
1838
  * instructions. Ports `explore-cache-check.ts`.
1841
1839
  */
1842
- const TTL_SECONDS$2 = 86400;
1840
+ const TTL_SECONDS$1 = 86400;
1843
1841
  const CONFIG_FILES = [
1844
1842
  "package.json",
1845
1843
  "tsconfig.json",
@@ -1887,7 +1885,7 @@ async function injectExploreCache(cwd, home = homedir(), now = Date.now()) {
1887
1885
  const snapshot = pathExists(snapFile) ? readText(snapFile) : "";
1888
1886
  if (meta?.timestamp && snapshot) {
1889
1887
  const age = cacheAge(meta.timestamp, now);
1890
- if (age < TTL_SECONDS$2 && meta.config_hash === cfgHash) {
1888
+ if (age < TTL_SECONDS$1 && meta.config_hash === cfgHash) {
1891
1889
  context = `## CACHED ARCHITECTURE AVAILABLE (age: ${Math.floor(age / 60)}min)\nUSE this cached report. Do NOT run full exploration. Return it immediately.\n\n${snapshot}`;
1892
1890
  logCacheEvent("explore", "hit", pHash, {}, home);
1893
1891
  }
@@ -1905,7 +1903,6 @@ async function injectExploreCache(cwd, home = homedir(), now = Date.now()) {
1905
1903
  * cached documentation summaries. Ports `doc-cache-inject.ts`. Doc *saving*
1906
1904
  * happens on SubagentStop (`cache-doc.ts`).
1907
1905
  */
1908
- const TTL_SECONDS$1 = 604800;
1909
1906
  const MAX_SIZE = 8192;
1910
1907
  /** Concatenate fresh, dedup-by-hash cached doc bodies. */
1911
1908
  async function buildDocsContext(entries, docsDir, now) {
@@ -1916,7 +1913,7 @@ async function buildDocsContext(entries, docsDir, now) {
1916
1913
  for (const entry of entries) {
1917
1914
  if (!entry.timestamp) continue;
1918
1915
  const age = cacheAge(entry.timestamp, now);
1919
- if (age >= TTL_SECONDS$1) continue;
1916
+ if (age >= 604800) continue;
1920
1917
  if (age > maxAge) maxAge = age;
1921
1918
  if (!entry.hash || seen.has(entry.hash)) continue;
1922
1919
  seen.add(entry.hash);
@@ -2213,8 +2210,8 @@ function projectRootFromPaths(filePaths) {
2213
2210
  }
2214
2211
  return bestRoot;
2215
2212
  }
2216
- /** Extract all absolute file paths from tool_use entries in a JSONL transcript. */
2217
- async function transcriptFilePaths(transcriptPath) {
2213
+ /** Extract absolute file paths from tool_use entries in a JSONL transcript, optionally restricted to `toolNames` (default: any tool). */
2214
+ async function transcriptFilePaths(transcriptPath, toolNames) {
2218
2215
  const text = readText(transcriptPath);
2219
2216
  const paths = /* @__PURE__ */ new Set();
2220
2217
  for (const line of text.split("\n").filter(Boolean)) try {
@@ -2222,6 +2219,7 @@ async function transcriptFilePaths(transcriptPath) {
2222
2219
  if (!Array.isArray(content)) continue;
2223
2220
  for (const block of content) {
2224
2221
  if (block?.type !== "tool_use") continue;
2222
+ if (toolNames && !toolNames.includes(block.name)) continue;
2225
2223
  const fp = block.input?.file_path ?? block.input?.path ?? "";
2226
2224
  if (typeof fp === "string" && fp.startsWith("/")) paths.add(fp);
2227
2225
  }
@@ -2677,6 +2675,149 @@ async function syncTaskTracking(payload, cwd) {
2677
2675
  }
2678
2676
  }
2679
2677
  //#endregion
2678
+ //#region src/runtime/lifecycle/aipilot/doc-cache-gate.ts
2679
+ /**
2680
+ * @module aipilot/doc-cache-gate
2681
+ * PreToolUse gate for the ai-pilot scope: DENY a Context7/Exa doc call when the
2682
+ * per-project doc cache already holds that library fresh (< 7d). Ports the
2683
+ * source `doc-cache-gate.ts`. The inject side (`inject-doc.ts`) serves the same
2684
+ * cache on SubagentStart; this side avoids the redundant live re-query.
2685
+ * @packageDocumentation
2686
+ */
2687
+ const GATED_TOOLS = /context7__query-docs|exa__get_code_context|exa__web_search/;
2688
+ /** The library key a gated tool call targets (Context7 libraryId / Exa query), or "". */
2689
+ function libraryOf(payload) {
2690
+ const tool = String(payload.tool_name ?? "");
2691
+ const input = payload.tool_input ?? {};
2692
+ if (tool.includes("context7")) {
2693
+ const libraryId = typeof input.libraryId === "string" ? input.libraryId : "";
2694
+ const query = typeof input.query === "string" ? input.query : "";
2695
+ return libraryId && query ? libraryId : "";
2696
+ }
2697
+ if (tool.includes("exa")) return typeof input.query === "string" ? input.query : "";
2698
+ return "";
2699
+ }
2700
+ /**
2701
+ * DENY a redundant doc call when the library is cached fresh, else `null`.
2702
+ * @param payload - The raw PreToolUse hook payload.
2703
+ * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
2704
+ * @param now - Clock (defaults to `Date.now()`).
2705
+ * @param home - Home dir (defaults to `~`).
2706
+ * @returns A native deny response, or `null` to fall through to the live call.
2707
+ */
2708
+ async function docCacheGate(payload, cwd, now = Date.now(), home = homedir()) {
2709
+ const tool = String(payload.tool_name ?? "");
2710
+ if (!GATED_TOOLS.test(tool)) return null;
2711
+ const library = libraryOf(payload);
2712
+ if (!library) return null;
2713
+ const projPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
2714
+ const cacheDir = cacheDirFor("doc", projPath, home);
2715
+ const entry = (await readJsonFile(join(cacheDir, "index.json")))?.docs?.find((d) => d.library === library);
2716
+ if (!entry?.hash || !entry.timestamp) return null;
2717
+ const docFile = join(cacheDir, "docs", `${entry.hash}.md`);
2718
+ if (!pathExists(docFile)) return null;
2719
+ const age = cacheAge(entry.timestamp, now);
2720
+ if (age >= 604800) return null;
2721
+ logCacheEvent("doc", "blocked", projectHash(projPath), { library }, home);
2722
+ return denyResponse("PreToolUse", `Doc already cached at ${docFile} (age: ${Math.floor(age / 3600)}h). Use the Read tool to access it instead of re-querying.`);
2723
+ }
2724
+ //#endregion
2725
+ //#region src/runtime/lifecycle/aipilot/solid-compliance.ts
2726
+ /**
2727
+ * PostToolUse (matcher "Write|Edit") for the ai-pilot scope: validate SOLID
2728
+ * file-size and interface-location compliance right after a code write.
2729
+ * Ports `check-solid-compliance.py`.
2730
+ */
2731
+ const CODE_EXTENSIONS$1 = /\.(ts|tsx|js|jsx|py|php|swift|go|rs|rb|java|astro)$/;
2732
+ const COMPONENT_DIR = /(components|pages|views)\//;
2733
+ const BACKEND_DIR = /(Controllers|Models|Services)\//;
2734
+ const TS_INTERFACE_RE = /^(export )?(interface|type) [A-Z]/m;
2735
+ const PHP_INTERFACE_RE = /^interface /m;
2736
+ /** Count non-empty, non-comment (`//`/`#`/`*`-prefixed) lines. Shared with {@link checkSolidFromTranscript}. */
2737
+ function countCodeLines(content) {
2738
+ let count = 0;
2739
+ for (const raw of content.split("\n")) {
2740
+ const s = raw.trim();
2741
+ if (!s || s.startsWith("//") || s.startsWith("#") || s.startsWith("*")) continue;
2742
+ count++;
2743
+ }
2744
+ return count;
2745
+ }
2746
+ /**
2747
+ * Validate SOLID compliance for a just-written file.
2748
+ * @param payload - The raw PostToolUse hook payload (`tool_name`, `tool_input.file_path`).
2749
+ * @returns The PostToolUse `additionalContext` response, or `""` when clean/not applicable.
2750
+ */
2751
+ function checkSolidCompliance(payload) {
2752
+ const tool = String(payload.tool_name ?? "");
2753
+ if (tool !== "Write" && tool !== "Edit") return "";
2754
+ const input = payload.tool_input ?? {};
2755
+ const filePath = typeof input.file_path === "string" ? input.file_path : "";
2756
+ if (!filePath || !CODE_EXTENSIONS$1.test(filePath) || !pathExists(filePath)) return "";
2757
+ let content;
2758
+ try {
2759
+ content = readText(filePath);
2760
+ } catch {
2761
+ return "";
2762
+ }
2763
+ const violations = [];
2764
+ const max = resolveMaxLines();
2765
+ const split = splitTarget(max);
2766
+ const lc = countCodeLines(content);
2767
+ if (lc > max) violations.push(`FILE SIZE: ${lc} lines (max: ${max})`);
2768
+ else if (lc > split) violations.push(`FILE SIZE WARNING: ${lc} lines (split at ${split})`);
2769
+ if (COMPONENT_DIR.test(filePath)) {
2770
+ if (TS_INTERFACE_RE.test(content)) violations.push("INTERFACE LOCATION: Move to src/interfaces/");
2771
+ } else if (BACKEND_DIR.test(filePath)) {
2772
+ if (PHP_INTERFACE_RE.test(content)) violations.push("INTERFACE LOCATION: Move to app/Contracts/");
2773
+ }
2774
+ if (violations.length === 0) return "";
2775
+ return contextResponse("PostToolUse", `SOLID COMPLIANCE CHECK: ${filePath.split("/").pop() ?? filePath}\n\n${violations.join("\n")}\nINSTRUCTION: Fix violations before continuing.\nRun sniper agent for full validation.`);
2776
+ }
2777
+ //#endregion
2778
+ //#region src/runtime/lifecycle/aipilot/solid-transcript.ts
2779
+ /**
2780
+ * SubagentStop (matcher "") for the ai-pilot scope: check SOLID file-size and
2781
+ * interface-location compliance for every file a sub-agent wrote/edited, per
2782
+ * the transcript. Ports `check-solid-from-transcript.py`. Fires unconditionally
2783
+ * for every sub-agent, unlike the sniper/research-expert-scoped caches here.
2784
+ */
2785
+ const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
2786
+ const INTERFACE_PATTERN = /^(export )?(interface|type) [A-Z]/m;
2787
+ const INTERFACE_DIRS = [
2788
+ "components/",
2789
+ "pages/",
2790
+ "views/",
2791
+ "app/"
2792
+ ];
2793
+ /**
2794
+ * Check every Write/Edit target in a subagent transcript against the SOLID
2795
+ * line-count ceiling and interface-location convention.
2796
+ * @param transcript - Path to the agent JSONL transcript (`agent_transcript_path`).
2797
+ * @returns The SubagentStop `additionalContext` response, or `""` when clean.
2798
+ */
2799
+ async function checkSolidFromTranscript(transcript) {
2800
+ if (!transcript || !pathExists(transcript)) return "";
2801
+ const files = await transcriptFilePaths(transcript, ["Write", "Edit"]);
2802
+ const max = resolveMaxLines();
2803
+ const violations = [];
2804
+ for (const fp of files.sort()) {
2805
+ if (!pathExists(fp) || !CODE_EXTENSIONS.test(fp)) continue;
2806
+ const name = fp.split("/").pop() ?? fp;
2807
+ let content;
2808
+ try {
2809
+ content = readText(fp);
2810
+ } catch {
2811
+ continue;
2812
+ }
2813
+ const lc = countCodeLines(content);
2814
+ if (lc > max) violations.push(`SOLID: ${name} = ${lc} lines (max ${max})`);
2815
+ if (INTERFACE_DIRS.some((prefix) => fp.includes(prefix)) && INTERFACE_PATTERN.test(content)) violations.push(`SOLID: ${name}: move interfaces to interfaces/`);
2816
+ }
2817
+ if (violations.length === 0) return "";
2818
+ return contextResponse("SubagentStop", `## SOLID VIOLATIONS DETECTED (subagent output)\n${violations.join("\n")}\nRun sniper to fix these issues.`);
2819
+ }
2820
+ //#endregion
2680
2821
  //#region src/runtime/lifecycle/aipilot/dispatch-aipilot.ts
2681
2822
  /**
2682
2823
  * ai-pilot scope dispatcher: routes Claude lifecycle events to the ported
@@ -2726,19 +2867,16 @@ async function onSubagentStart(payload, cwd, now) {
2726
2867
  const agent = agentTypeOf(payload);
2727
2868
  return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now), await typeSpecificCache(agent, cwd, now));
2728
2869
  }
2729
- /** SubagentStop routing: transcript-driven cache writers (side-effects). */
2870
+ /** SubagentStop routing: transcript-driven cache writers, then the universal SOLID check. */
2730
2871
  async function onSubagentStop(payload, cwd) {
2731
2872
  const agent = agentTypeOf(payload);
2732
2873
  const transcript = transcriptOf(payload);
2733
- if (agent.includes("research-expert")) {
2734
- await cacheDocFromTranscript(transcript, cwd);
2735
- return "";
2736
- }
2874
+ if (agent.includes("research-expert")) await cacheDocFromTranscript(transcript, cwd);
2737
2875
  if (agent.includes("sniper")) {
2738
2876
  await cacheSniperLessons(transcript, cwd);
2739
2877
  await cacheTestResults(transcript, cwd);
2740
2878
  }
2741
- return "";
2879
+ return checkSolidFromTranscript(transcript);
2742
2880
  }
2743
2881
  /**
2744
2882
  * Dispatch an ai-pilot-scope lifecycle event. Returns the native stdout, or
@@ -2751,11 +2889,12 @@ async function dispatchAipilot(event, payload, cwd, now) {
2751
2889
  await cacheAnalyticsSave(void 0, now);
2752
2890
  return "";
2753
2891
  }
2892
+ if (event === "PreToolUse") return docCacheGate(payload, cwd, now);
2754
2893
  return null;
2755
2894
  }
2756
- /** PostToolUse (TaskCreate/TaskUpdate) sync for the ai-pilot scope. */
2895
+ /** PostToolUse (Write/Edit SOLID check, else TaskCreate/TaskUpdate sync) for the ai-pilot scope. */
2757
2896
  async function aipilotPostToolUse(payload, cwd) {
2758
- return syncTaskTracking(payload, cwd);
2897
+ return checkSolidCompliance(payload) || await syncTaskTracking(payload, cwd);
2759
2898
  }
2760
2899
  //#endregion
2761
2900
  //#region src/runtime/lifecycle/dispatch.ts
@@ -3278,11 +3417,76 @@ function seoPostToolUseResponse(payload) {
3278
3417
  return block ? blockResponse(block) : null;
3279
3418
  }
3280
3419
  //#endregion
3420
+ //#region src/runtime/lifecycle/auto-document-reads.ts
3421
+ /**
3422
+ * PostToolUse (matcher "Read") for the core scope: auto-document important
3423
+ * reads (SKILL.md/README/CLAUDE.md/docs/references) into
3424
+ * `.claude/apex/docs/task-<current>-<framework>.md`, once per file per task.
3425
+ * Ports `post-tool-use/auto-document-reads.py`.
3426
+ */
3427
+ /** Read-tool file paths worth auto-documenting. */
3428
+ const DOC_PATTERNS = [
3429
+ /(SKILL\.md|README\.md|CLAUDE\.md)$/,
3430
+ /\/docs\/.*\.md$/,
3431
+ /\/references\/.*\.md$/,
3432
+ /skills\/[^/]+\/SKILL\.md$/
3433
+ ];
3434
+ /** First matching marker (in order) labels the doc-type; "File" otherwise. */
3435
+ const TYPE_MAP = [
3436
+ ["SKILL.md", "Skill"],
3437
+ ["README.md", "README"],
3438
+ ["CLAUDE.md", "Rules"],
3439
+ ["/references/", "Reference"],
3440
+ ["/docs/", "Doc"]
3441
+ ];
3442
+ /** The doc-type label for a matched file path. */
3443
+ function docTypeOf(filePath) {
3444
+ return TYPE_MAP.find(([marker]) => filePath.includes(marker))?.[1] ?? "File";
3445
+ }
3446
+ /**
3447
+ * Auto-document a Read of a SKILL.md/README/CLAUDE.md/docs/references file
3448
+ * into `.claude/apex/docs/task-<current>-<framework>.md` (skipped when the
3449
+ * file is already logged, or no project root is found from `filePath`).
3450
+ * @param filePath - The path passed to the Read tool.
3451
+ * @param now - Clock (defaults to `Date.now()`).
3452
+ * @returns The native `systemMessage` stdout, or "" when nothing was logged.
3453
+ */
3454
+ async function autoDocumentRead(filePath, now = Date.now()) {
3455
+ if (!filePath || !DOC_PATTERNS.some((p) => p.test(filePath))) return "";
3456
+ const root = projectRootOrNull(dirname(filePath));
3457
+ if (!root) return "";
3458
+ const framework = detectProjectType$1(root);
3459
+ const current = (await readJsonFile(join(root, ".claude", "apex", "task.json")))?.current_task ?? "1";
3460
+ const docDir = join(root, ".claude", "apex", "docs");
3461
+ const docFile = join(docDir, `task-${current}-${framework}.md`);
3462
+ const ts = new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z");
3463
+ const fname = basename(filePath);
3464
+ const docType = docTypeOf(filePath);
3465
+ try {
3466
+ mkdirSync(docDir, { recursive: true });
3467
+ if (!existsSync(docFile)) writeFileSync(docFile, `# Task ${current} - ${framework.charAt(0).toUpperCase() + framework.slice(1)} Documentation\n## Consulted: ${ts} | Source: skill:Read\n## Key Info\n\n`, "utf-8");
3468
+ if (readFileSync(docFile, "utf-8").includes(`\`${fname}\``)) return "";
3469
+ appendFileSync(docFile, `- **[${docType}]** \`${fname}\` - ${ts}\n`, "utf-8");
3470
+ } catch {
3471
+ return "";
3472
+ }
3473
+ return systemMessage(`📖 [${docType}] ${fname} logged`);
3474
+ }
3475
+ //#endregion
3281
3476
  //#region src/runtime/lifecycle-bridge.ts
3282
3477
  /** Raw event name from a payload (Cline lacks one; lifecycle is Claude-only). */
3283
3478
  function rawEvent(payload) {
3284
3479
  return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
3285
3480
  }
3481
+ /** Extract the `additionalContext` body from a `contextResponse(...)` stdout string ("" when empty/unparseable). */
3482
+ function additionalContextOf(stdout) {
3483
+ if (!stdout) return "";
3484
+ try {
3485
+ return JSON.parse(stdout).hookSpecificOutput?.additionalContext ?? "";
3486
+ } catch {
3487
+ return "";
3488
+ }
3489
+ }
3286
3490
  /**
3287
3491
  * Run the ported lifecycle/session/context hooks (SessionStart, SubagentStart/
3288
3492
  * Stop, TeammateIdle, PostToolUseFailure, PreCompact, SessionEnd,
@@ -3304,18 +3508,25 @@ function lifecycleStdout(payload, cwd, scope, now) {
3304
3508
  });
3305
3509
  }
3306
3510
  /**
3307
- * Post-edit additions for core-scope PostToolUse Write/Edit: track cumulative
3308
- * session changes (sniper reminder) + report eslint/prettier issues. Returns the
3309
- * combined extra stdout (track-changes wins; lint appended only when no track
3310
- * output), or "" when nothing to emit.
3511
+ * Post-edit additions for core-scope PostToolUse: auto-document a Read of a
3512
+ * SKILL.md/README/docs file; else (Write/Edit) track cumulative session
3513
+ * changes (sniper reminder) AND report eslint/prettier issues parity
3514
+ * core-guards hooks.json, which runs both as independent PostToolUse commands
3515
+ * rather than short-circuiting one on the other. Returns the combined extra
3516
+ * stdout, or "" when nothing to emit.
3311
3517
  * @param scope - The invoking plugin scope.
3312
3518
  * @param event - The normalized event.
3313
3519
  * @param now - Clock.
3314
3520
  * @returns The extra stdout (possibly empty).
3315
3521
  */
3316
- function postEditContext(scope, event, now) {
3317
- if (scope !== "core" || event.tool !== "Write" && event.tool !== "Edit" || !event.filePath) return "";
3318
- return trackSessionChanges(event.sessionId, event.filePath, void 0, now) || postEditTypescript(event.filePath);
3522
+ async function postEditContext(scope, event, now) {
3523
+ if (scope !== "core" || !event.filePath) return "";
3524
+ if (event.tool === "Read") return autoDocumentRead(event.filePath, now);
3525
+ if (event.tool !== "Write" && event.tool !== "Edit") return "";
3526
+ const sniper = trackSessionChanges(event.sessionId, event.filePath, void 0, now);
3527
+ const lint = postEditTypescript(event.filePath);
3528
+ if (!sniper || !lint) return sniper || lint;
3529
+ return contextResponse("PostToolUse", `${additionalContextOf(sniper)}\n\n${additionalContextOf(lint)}`);
3319
3530
  }
3320
3531
  //#endregion
3321
3532
  //#region src/runtime/gate-helpers.ts
@@ -3341,20 +3552,21 @@ function isApexScoped(filePath) {
3341
3552
  return !EXEMPT_PATTERNS.some((p) => p.test(filePath));
3342
3553
  }
3343
3554
  /**
3344
- * Code-only line count of the existing on-disk file (undefined if
3345
- * absent/unreadable). Uses {@link countLines} (skips blank/comment lines) so a
3346
- * partial Edit judges the full file by the SAME metric as the incoming snippet —
3347
- * a raw `split("\n").length` would over-count JSDoc/blank lines (and add a
3348
- * trailing-newline off-by-one), falsely blocking well-documented files.
3555
+ * Read `path` once and derive both line-count metrics, so the generic
3556
+ * core-guards ceiling (`raw`) and the framework SOLID gates (`code`) each get
3557
+ * the metric their own Python origin measures, without reading the file twice.
3349
3558
  * @param path - Absolute path of the file being edited (or undefined).
3350
- * @returns Code-only line count, or undefined when the file is absent/unreadable.
3351
3559
  */
3352
- function existingLineCount(path) {
3353
- if (!path) return void 0;
3560
+ function existingLineCounts(path) {
3561
+ if (!path || !existsSync(path)) return {};
3354
3562
  try {
3355
- return existsSync(path) ? countLines(readFileSync(path, "utf8")) : void 0;
3563
+ const content = readFileSync(path, "utf8");
3564
+ return {
3565
+ raw: countLines(content),
3566
+ code: countFrameworkCodeLines(content)
3567
+ };
3356
3568
  } catch {
3357
- return;
3569
+ return {};
3358
3570
  }
3359
3571
  }
3360
3572
  //#endregion
@@ -3486,15 +3698,28 @@ function detectDuplication(filePath, content, cwd) {
3486
3698
  duplicates
3487
3699
  };
3488
3700
  }
3489
- /** Blocking prompt when a Write/Edit re-declares 2+ existing symbols, else null. */
3701
+ /**
3702
+ * Prompt when a Write/Edit re-declares an existing symbol: a single match is a
3703
+ * softer non-blocking "inform" (could be a false positive / same-name coincidence),
3704
+ * escalating to a hard "block" once 2+ existing declarations are found. Returns
3705
+ * `null` when no duplicate symbol is found at all.
3706
+ */
3490
3707
  function dryGate(tool, filePath, content, cwd) {
3491
3708
  if (!cwd || tool !== "Write" && tool !== "Edit" || !content) return null;
3492
3709
  const dup = detectDuplication(filePath, content, cwd);
3493
- if (dup.duplicates.length < 2) return null;
3710
+ if (dup.duplicates.length === 0) return null;
3711
+ const names = dup.names.slice(0, 5).join(", ");
3712
+ const files = dup.duplicates.slice(0, 3).join(", ");
3713
+ if (dup.duplicates.length === 1) return {
3714
+ kind: "inform",
3715
+ title: "Possible duplicate code (DRY)",
3716
+ reason: `[${names}] already declared in: ${files}. Consider importing and reusing instead of re-declaring.`,
3717
+ actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
3718
+ };
3494
3719
  return {
3495
3720
  kind: "block",
3496
3721
  title: "Duplicate code (DRY)",
3497
- reason: `[${dup.names.slice(0, 5).join(", ")}] already declared in: ${dup.duplicates.slice(0, 3).join(", ")}. Import and reuse instead of re-declaring.`,
3722
+ reason: `[${names}] already declared in: ${files}. Import and reuse instead of re-declaring.`,
3498
3723
  actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
3499
3724
  };
3500
3725
  }
@@ -3649,17 +3874,22 @@ function modularGate(tool, filePath, content, cwd) {
3649
3874
  //#region src/runtime/framework-skill-gate.ts
3650
3875
  /**
3651
3876
  * Effective line count for the SOLID size check. On an Edit, `content` is only
3652
- * the `new_string` snippet, so judge the larger of the snippet and the full
3653
- * on-disk file (`existingLines`)mirroring the base file-size guard and the
3654
- * Python `get_full_file_content`. On Write, `content` IS the full file, so the
3655
- * snippet count stands (undefined the gate falls back to `countLines`).
3877
+ * the `new_string` snippet, so for Next.js mirroring the base file-size guard
3878
+ * and the Python `get_full_file_content` — judge the larger of the snippet and
3879
+ * the full on-disk file (`existingCodeLines`). Other frameworks (react/laravel/
3880
+ * swift) judge the edited snippet alone, never the on-disk full-file max their
3881
+ * Python source never imports `get_full_file_content`, only nextjs does. On
3882
+ * Write, `content` IS the full file, so the snippet count stands regardless of
3883
+ * framework (undefined → the gate falls back to `countFrameworkCodeLines`).
3656
3884
  * @param tool - the tool name ("Edit" | "Write" | ...).
3885
+ * @param framework - the detected framework; only "nextjs" applies the full-file max.
3657
3886
  * @param content - the written content (snippet on Edit, full file on Write).
3658
- * @param existingLines - full on-disk line count, when known.
3887
+ * @param existingCodeLines - full on-disk code-only line count, when known.
3659
3888
  */
3660
- function effectiveLines(tool, content, existingLines) {
3661
- if (tool !== "Edit" || existingLines === void 0) return void 0;
3662
- return Math.max(countLines(content), existingLines);
3889
+ function effectiveLines(tool, framework, content, existingCodeLines) {
3890
+ if (tool !== "Edit" || existingCodeLines === void 0) return void 0;
3891
+ if (framework !== "nextjs") return countFrameworkCodeLines(content);
3892
+ return Math.max(countFrameworkCodeLines(content), existingCodeLines);
3663
3893
  }
3664
3894
  /**
3665
3895
  * Framework-aware SOLID + sub-skill gate, run on the Write/Edit path once a
@@ -3672,17 +3902,149 @@ function effectiveLines(tool, content, existingLines) {
3672
3902
  *
3673
3903
  * @param input - the gated tool-use (filePath + content + framework + cwd).
3674
3904
  * @param refsRead - in-session read reference paths (from the loaded track).
3675
- * @param existingLines - full on-disk line count (so an Edit on an oversized
3676
- * file still fires the framework SOLID size rule). Omit on Write.
3905
+ * @param existingCodeLines - full on-disk code-only line count (so an Edit on
3906
+ * an oversized file still fires the framework SOLID size rule). Omit on Write.
3677
3907
  * @returns the first blocking {@link Prompt}, or `null` to allow.
3678
3908
  */
3679
- function frameworkSkillGate(input, refsRead, existingLines) {
3909
+ function frameworkSkillGate(input, refsRead, existingCodeLines) {
3680
3910
  if (!input.filePath) return null;
3681
3911
  const content = input.content ?? "";
3682
- const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, content, existingLines));
3912
+ const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, input.framework, content, existingCodeLines));
3683
3913
  if (solid) return solid;
3684
3914
  const forced = input.cwd ? requiredArchSkill(input.cwd) : null;
3685
- return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
3915
+ return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd, input.filePath);
3916
+ }
3917
+ //#endregion
3918
+ //#region src/policy/shadcn-skill-gate.ts
3919
+ /** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
3920
+ const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
3921
+ /**
3922
+ * Vendored/build paths exempt from the gate. Uses `(^|\/)` (not just a
3923
+ * leading `/`) so a relative root-start path like `node_modules/x` is also
3924
+ * exempt — mirrors the same deliberate widening already applied in
3925
+ * `design/skill-gate.ts`'s `EXEMPT_RE`, not the Python's stricter `/x/`-only match.
3926
+ */
3927
+ const EXEMPT_RE$1 = /(^|\/)(node_modules|dist|build)\//;
3928
+ /** Path fragments that put a write in shadcn scope (source: `(components|ui|shadcn|components\.json)`). */
3929
+ const SHADCN_PATH_RE = /(components|ui|shadcn|components\.json)/;
3930
+ /** A read reference proving one of the two named base skills was consulted (Phase 1). */
3931
+ const SHADCN_BASE_SKILL_RE = /skills\/(shadcn-detection|shadcn-components)\//;
3932
+ /**
3933
+ * True when this Write/Edit is shadcn-scoped: a `.tsx/.jsx/.css/.scss/.json`
3934
+ * file under a `components|ui|shadcn|components.json` path, outside vendored
3935
+ * dirs. Ports `check-skill-loaded.py`'s file-path filter (lines 35-42).
3936
+ */
3937
+ function isShadcnWrite(tool, filePath) {
3938
+ if (tool !== "Write" && tool !== "Edit") return false;
3939
+ if (!SHADCN_FILE_RE.test(filePath) || EXEMPT_RE$1.test(filePath)) return false;
3940
+ return SHADCN_PATH_RE.test(filePath);
3941
+ }
3942
+ /** True when `shadcn-detection/` or `shadcn-components/` was read this session (Phase 1 base skill). */
3943
+ function shadcnBaseSkillRead(refsRead) {
3944
+ return refsRead.some((p) => SHADCN_BASE_SKILL_RE.test(p));
3945
+ }
3946
+ /**
3947
+ * Gate a shadcn-scoped write. Phase 1 requires a base skill read
3948
+ * (shadcn-detection or shadcn-components); Phase 2 requires the domain
3949
+ * sub-skill(s) matching the written content, via {@link skillTriggerGate}
3950
+ * keyed on the "shadcn" framework (registered in `SKILL_TRIGGERS`); Phase 3
3951
+ * requires doc research, delegated to the existing {@link docConsultedGate}
3952
+ * (Context7 AND Exa, or a web fallback alone — not the Python's strict AND).
3953
+ * Returns a blocking {@link Prompt}, or `null` to allow.
3954
+ */
3955
+ function shadcnSkillGate(tool, filePath, content, ev) {
3956
+ if (!isShadcnWrite(tool, filePath)) return null;
3957
+ if (!shadcnBaseSkillRead(ev.refsRead)) return {
3958
+ kind: "block",
3959
+ title: "shadcn/ui skill",
3960
+ reason: "BLOCKED: shadcn skill not consulted. Read shadcn-detection/SKILL.md or shadcn-components/SKILL.md, then retry.",
3961
+ actions: ["Read skills/shadcn-detection/ or skills/shadcn-components/, then retry"]
3962
+ };
3963
+ const domainBlock = skillTriggerGate("shadcn", content, ev.refsRead);
3964
+ if (domainBlock) return domainBlock;
3965
+ return docConsultedGate({
3966
+ sessionId: ev.sessionId,
3967
+ framework: "shadcn",
3968
+ filePath,
3969
+ content,
3970
+ authorizations: ev.authorizations
3971
+ });
3972
+ }
3973
+ //#endregion
3974
+ //#region src/freshness/explore-tools.ts
3975
+ /** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
3976
+ const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
3977
+ /** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
3978
+ const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
3979
+ "mcp__context7__query-docs",
3980
+ "mcp__context7__resolve-library-id",
3981
+ "mcp__exa__web_search_exa",
3982
+ "mcp__exa__get_code_context_exa",
3983
+ "mcp__exa__deep_researcher_start",
3984
+ "WebSearch",
3985
+ "WebFetch"
3986
+ ]);
3987
+ /** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
3988
+ const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
3989
+ "grep",
3990
+ "rg",
3991
+ "find",
3992
+ "ls",
3993
+ "fd",
3994
+ "ast-grep",
3995
+ "tree",
3996
+ "cat",
3997
+ "head",
3998
+ "tail"
3999
+ ]);
4000
+ /** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
4001
+ const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
4002
+ /**
4003
+ * First non-assignment shell token's basename, or "" — mirrors Python
4004
+ * `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
4005
+ * @param cmd - Raw Bash `command` string.
4006
+ * @returns The executable basename, or "" when none.
4007
+ */
4008
+ function bashExecutable(cmd) {
4009
+ for (const token of cmd.trim().split(/\s+/)) {
4010
+ if (!token) continue;
4011
+ const last = token.split("/").pop() ?? token;
4012
+ if (!last.includes("=")) return last;
4013
+ }
4014
+ return "";
4015
+ }
4016
+ /**
4017
+ * Classify a direct tool use into an APEX phase, or `null` when it is neither
4018
+ * exploration nor research. Mirrors Python `track-subagent-research._classify`.
4019
+ * @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
4020
+ * @param input - Tool input payload.
4021
+ * @returns The credited phase + cache flag, or `null`.
4022
+ */
4023
+ function classifyExplore(tool, input) {
4024
+ if (RESEARCH_TOOLS.has(tool)) return {
4025
+ phase: "research-expert",
4026
+ cacheHit: false
4027
+ };
4028
+ if (EXPLORE_TOOLS.has(tool)) return {
4029
+ phase: "explore-codebase",
4030
+ cacheHit: false
4031
+ };
4032
+ if (tool === "Read") {
4033
+ const path = String(input?.file_path ?? input?.path ?? "");
4034
+ if (path && CACHE_READ_RE.test(path)) return {
4035
+ phase: "research-expert",
4036
+ cacheHit: true
4037
+ };
4038
+ return null;
4039
+ }
4040
+ if (tool === "Bash") {
4041
+ const cmd = String(input?.command ?? "").trim();
4042
+ if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
4043
+ phase: "explore-codebase",
4044
+ cacheHit: false
4045
+ };
4046
+ }
4047
+ return null;
3686
4048
  }
3687
4049
  //#endregion
3688
4050
  //#region src/freshness/agent-evidence.ts
@@ -3701,9 +4063,13 @@ function parseTs(raw) {
3701
4063
  }
3702
4064
  /**
3703
4065
  * Return `true` ONLY when, for EVERY name in `names`, the Claude Code
3704
- * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task` or
3705
- * `Agent` tool whose `subagent_type` (or `name`) input field matches — after
3706
- * stripping any plugin prefix — with the entry timestamp within `windowMs` of `now`.
4066
+ * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task`/
4067
+ * `Agent` tool whose `subagent_type` (or `name`) matches — after stripping any
4068
+ * plugin prefix — OR a direct exploration/research tool_use (Glob/Grep, an
4069
+ * explore Bash command, mcp__context7/mcp__exa, WebSearch/WebFetch) classified
4070
+ * via {@link classifyExplore} into that name, issued by ANY sub-agent or the
4071
+ * lead within this same transcript — with the entry timestamp within
4072
+ * `windowMs` of `now`.
3707
4073
  *
3708
4074
  * **Timestamp note:** when a transcript entry carries no `timestamp` field it
3709
4075
  * is counted as within-window (we cannot prove staleness). This is
@@ -3741,10 +4107,15 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
3741
4107
  const content = entry.message?.content;
3742
4108
  if (!Array.isArray(content)) continue;
3743
4109
  for (const block of content) {
3744
- if (block?.type !== "tool_use" || block.name !== "Task" && block.name !== "Agent") continue;
3745
- const raw = block.input?.subagent_type ?? block.input?.name;
3746
- const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
3747
- if (agent !== void 0 && names.includes(agent)) found.add(agent);
4110
+ if (block?.type !== "tool_use") continue;
4111
+ if (block.name === "Task" || block.name === "Agent") {
4112
+ const raw = block.input?.subagent_type ?? block.input?.name;
4113
+ const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
4114
+ if (agent !== void 0 && names.includes(agent)) found.add(agent);
4115
+ continue;
4116
+ }
4117
+ const hit = classifyExplore(block.name ?? "", block.input);
4118
+ if (hit && names.includes(hit.phase)) found.add(hit.phase);
3748
4119
  }
3749
4120
  if (found.size === names.length) return true;
3750
4121
  }
@@ -3765,7 +4136,7 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
3765
4136
  */
3766
4137
  async function apexScopedGate(input, track, window) {
3767
4138
  const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
3768
- if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
4139
+ if (input.tool === "Edit" && !input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
3769
4140
  await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
3770
4141
  return null;
3771
4142
  }
@@ -3779,7 +4150,9 @@ async function apexScopedGate(input, track, window) {
3779
4150
  refs: input.refs,
3780
4151
  refsRead: track.refsRead,
3781
4152
  agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
3782
- brainstormRequired: track.brainstormRequired,
4153
+ missingAgents: REQUIRED_AGENTS.filter((name) => !freshnessFor([name])),
4154
+ windowMs: window,
4155
+ brainstormRequired: input.tool === "Edit" ? false : track.brainstormRequired,
3783
4156
  brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
3784
4157
  };
3785
4158
  try {
@@ -3792,7 +4165,12 @@ async function apexScopedGate(input, track, window) {
3792
4165
  //#region src/runtime/gate.ts
3793
4166
  /** Prior agents the freshness gate requires before a code edit. */
3794
4167
  const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
3795
- /** Default freshness window for {@link REQUIRED_AGENTS} (2 min — matches the plugin's `FUSE_ENFORCE_TTL_SEC` default). */
4168
+ /**
4169
+ * Default freshness window for {@link REQUIRED_AGENTS}, in ms. Matches the
4170
+ * plugin's `FUSE_ENFORCE_TTL_SEC` default (120s). Only a fallback for direct
4171
+ * programmatic callers that omit `windowMs` (e.g. tests) — the real CLI path
4172
+ * always supplies `windowMs` from `resolveTtlSec()` (`src/config/ttl.ts`).
4173
+ */
3796
4174
  const DEFAULT_WINDOW_MS = 12e4;
3797
4175
  /** Trivial edits allowed within the window before the full APEX gates apply. */
3798
4176
  const TRIVIAL_BUDGET = 4;
@@ -3804,7 +4182,7 @@ const TRIVIAL_BUDGET = 4;
3804
4182
  async function gate(input) {
3805
4183
  const precommit = preCommitGate(input.tool, input.command, input.cwd);
3806
4184
  if (precommit) return precommit;
3807
- const existingLines = existingLineCount(input.filePath);
4185
+ const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
3808
4186
  let quick;
3809
4187
  try {
3810
4188
  quick = evaluate({
@@ -3822,10 +4200,19 @@ async function gate(input) {
3822
4200
  const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
3823
4201
  if (modular) return modular;
3824
4202
  if (!input.filePath) return null;
4203
+ const filePath = input.filePath;
3825
4204
  const window = input.windowMs ?? 12e4;
3826
4205
  const track = await loadTrack(input.trackFile);
3827
- const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingLines);
4206
+ const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
3828
4207
  if (solidOrSkill) return solidOrSkill;
4208
+ if (isShadcnWrite(input.tool, filePath)) {
4209
+ const shadcnBlock = shadcnSkillGate(input.tool, filePath, input.content ?? "", {
4210
+ refsRead: track.refsRead,
4211
+ authorizations: track.authorizations,
4212
+ sessionId: input.sessionId
4213
+ });
4214
+ if (shadcnBlock) return shadcnBlock;
4215
+ }
3829
4216
  if (isApexScoped(input.filePath)) {
3830
4217
  const apex = await apexScopedGate(input, track, window);
3831
4218
  if (apex) return apex;
@@ -3943,31 +4330,51 @@ async function recordActivity(file, activity) {
3943
4330
  //#endregion
3944
4331
  //#region src/runtime/respond.ts
3945
4332
  /**
3946
- * Map a portable {@link Prompt} to a harness's native hook response. `block`
3947
- * denies; anything else asks/injects context. (Codex/Cursor parse but ignore
3948
- * `ask` they only honor deny.)
4333
+ * Map a portable {@link Prompt} to a harness's native hook response, honoring
4334
+ * all 3 prompt kinds (`block`/`ask`/`inform`) instead of collapsing `ask` and
4335
+ * `inform` together. Reuses the Claude adapter's `denyResponse`/
4336
+ * `contextResponse` builders for claude-code/codex (same hook shape) to avoid
4337
+ * duplicating the JSON.
4338
+ * - claude-code/codex: `block` -> deny, `ask` -> interactive
4339
+ * `permissionDecision:"ask"`, `inform` -> non-blocking `additionalContext`.
4340
+ * - gemini-cli/cline: their real hook schemas have no interactive "ask"
4341
+ * state (deny is the only blocking outcome), so `ask` and `inform` already
4342
+ * both resolve to non-blocking context injection — unchanged.
4343
+ * - cursor: `ask` keeps its current best-effort `permission:"ask"` shape
4344
+ * (unverified against Cursor's exact hook docs); only `inform` is fixed to
4345
+ * a non-blocking `permission:"allow"` note, since it was wrongly conflated
4346
+ * with `ask` before.
3949
4347
  */
3950
4348
  function respond(id, prompt) {
3951
4349
  const message = formatPrompt(prompt);
3952
- const deny = prompt.kind === "block";
4350
+ const { kind } = prompt;
3953
4351
  switch (id) {
3954
4352
  case "claude-code":
3955
- case "codex": return JSON.stringify({ hookSpecificOutput: {
3956
- hookEventName: "PreToolUse",
3957
- permissionDecision: deny ? "deny" : "ask",
3958
- permissionDecisionReason: message
3959
- } });
3960
- case "gemini-cli": return JSON.stringify(deny ? {
4353
+ case "codex":
4354
+ if (kind === "block") return denyResponse("PreToolUse", message);
4355
+ if (kind === "inform") return contextResponse("PreToolUse", message);
4356
+ return JSON.stringify({ hookSpecificOutput: {
4357
+ hookEventName: "PreToolUse",
4358
+ permissionDecision: "ask",
4359
+ permissionDecisionReason: message
4360
+ } });
4361
+ case "gemini-cli": return JSON.stringify(kind === "block" ? {
3961
4362
  decision: "deny",
3962
4363
  reason: message
3963
4364
  } : { hookSpecificOutput: { additionalContext: message } });
3964
- case "cursor": return JSON.stringify({
3965
- permission: deny ? "deny" : "ask",
3966
- continue: false,
3967
- userMessage: message,
3968
- agentMessage: message
3969
- });
3970
- case "cline": return JSON.stringify(deny ? {
4365
+ case "cursor":
4366
+ if (kind === "inform") return JSON.stringify({
4367
+ permission: "allow",
4368
+ userMessage: message,
4369
+ agentMessage: message
4370
+ });
4371
+ return JSON.stringify({
4372
+ permission: kind === "block" ? "deny" : "ask",
4373
+ continue: false,
4374
+ userMessage: message,
4375
+ agentMessage: message
4376
+ });
4377
+ case "cline": return JSON.stringify(kind === "block" ? {
3971
4378
  cancel: true,
3972
4379
  errorMessage: message
3973
4380
  } : { contextModification: message });
@@ -4019,6 +4426,65 @@ function runDesignChecks(content) {
4019
4426
  ];
4020
4427
  }
4021
4428
  //#endregion
4429
+ //#region src/policy/design/skill-triggers.ts
4430
+ /** Domain skill triggers (ported 1:1 from `design_skill_triggers.py`). */
4431
+ const SKILL_TRIGGERS = [
4432
+ {
4433
+ skill: "3-generating-components",
4434
+ readFragment: "generating-components",
4435
+ patterns: [
4436
+ /className\s*=\s*["{]/i,
4437
+ /<(div|section|main|header|footer|nav|aside)\s/i,
4438
+ /flex|grid|gap-|p-|m-|bg-|text-|rounded|shadow|border/i,
4439
+ /(Button|Card|Dialog|Sheet|Input|Select|Table)\b/i,
4440
+ /cva|class-variance-authority|variants\b/i,
4441
+ /VariantProps|variant.*:\s*\{/i,
4442
+ /children|slots|asChild|Slot|render.?prop/i,
4443
+ /forwardRef|React\.cloneElement|compound/i
4444
+ ]
4445
+ },
4446
+ {
4447
+ skill: "1-designing-systems",
4448
+ readFragment: "designing-systems",
4449
+ patterns: [
4450
+ /--(\w+-)+color:|:root\s*\{|@theme\b/i,
4451
+ /design.?system|token|palette|typography.?scale/i,
4452
+ /--(\w+)-(foreground|background|primary|muted|accent):/i,
4453
+ /cssVariables|themeConfig|colorScheme/i,
4454
+ /dark:|prefers-color-scheme|next-themes|useTheme/i,
4455
+ /ThemeProvider|data-theme|color-scheme/i,
4456
+ /sm:|md:|lg:|xl:|2xl:/i,
4457
+ /@container|container-type|@media/i,
4458
+ /clamp\(|fluid|min-width:/i
4459
+ ]
4460
+ },
4461
+ {
4462
+ skill: "4-adding-animations",
4463
+ readFragment: "adding-animations",
4464
+ patterns: [
4465
+ /motion\.|framer-motion|animate|variants/i,
4466
+ /whileHover|whileTap|AnimatePresence|transition/i,
4467
+ /@keyframes\b|animation:\s/i,
4468
+ /backdrop-blur|bg-.*\/([\d]+)|glass/i,
4469
+ /backdrop-filter:\s*blur/i,
4470
+ /hover:|focus:|active:|disabled:|focus-visible:/i,
4471
+ /data-\[state=|data-\[disabled\]/i,
4472
+ /bg-gradient|from-|via-|to-/i,
4473
+ /radial-gradient|conic-gradient|bg-\[url/i,
4474
+ /blur-.*xl|opacity-|mix-blend/i
4475
+ ]
4476
+ },
4477
+ {
4478
+ skill: "5-design-audit",
4479
+ readFragment: "design-audit",
4480
+ patterns: [/aria-|role=|sr-only|tabIndex|alt=/i, /WCAG|a11y|contrast|screen.?reader/i]
4481
+ }
4482
+ ];
4483
+ /** Return the domain skills required by `content` but NOT present in `refsRead`. */
4484
+ function missingDomainSkills(content, refsRead) {
4485
+ return SKILL_TRIGGERS.filter((t) => t.patterns.some((re) => re.test(content))).filter((t) => !refsRead.some((p) => p.includes(t.readFragment))).map((t) => t.skill);
4486
+ }
4487
+ //#endregion
4022
4488
  //#region src/policy/design/skill-gate.ts
4023
4489
  /** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
4024
4490
  const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
@@ -4057,7 +4523,9 @@ const block = (reason, action) => ({
4057
4523
  function uiDesignSkillGate(tool, filePath, content, ev) {
4058
4524
  if (!isUiWrite(tool, filePath, content)) return null;
4059
4525
  if (!designSkillRead(ev.refsRead)) return block("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");
4060
- if (!ev.docConsulted) return block("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");
4526
+ const missing = missingDomainSkills(content, ev.refsRead);
4527
+ if (missing.length) return block(`BLOCKED: code uses ${missing.join(", ")} but the matching design skill(s) were not consulted. Read the SKILL.md for: ${missing.join(", ")}, then retry.`, `Read design skill(s): ${missing.join(", ")}, then retry`);
4528
+ if (!ev.docConsulted) return block("BLOCKED: no documentation consulted. Use BOTH mcp__context7__query-docs AND mcp__exa__web_search_exa, or a web fallback alone (WebSearch/WebFetch). Gemini is NOT required.", "Consult Context7+Exa, or web docs alone (Gemini not required), then retry");
4061
4529
  return null;
4062
4530
  }
4063
4531
  //#endregion
@@ -4179,6 +4647,25 @@ function designSystemWriteGate(filePath, state) {
4179
4647
  if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
4180
4648
  return null;
4181
4649
  }
4650
+ /**
4651
+ * Ports browser_helpers.is_exempt: design-system.md is NOT exempt (it must meet
4652
+ * the quota); other `.md` files and vendored dirs ARE exempt from the check.
4653
+ */
4654
+ function screenshotExempt(filePath) {
4655
+ if (filePath.endsWith("design-system.md")) return false;
4656
+ return filePath.endsWith(".md") || EXEMPT_DIRS.some((d) => filePath.includes(d));
4657
+ }
4658
+ /**
4659
+ * Block ANY non-exempt write (html/css/design-system.md) until the per-mode
4660
+ * screenshot quota is met. Ports check-browser-browsing.py's "Browse N sites
4661
+ * BEFORE writing" deny for the active design agent.
4662
+ */
4663
+ function preScreenshotWriteGate(filePath, state) {
4664
+ if (screenshotExempt(filePath)) return null;
4665
+ const needed = MIN_SCREENSHOTS[state.mode];
4666
+ if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Browse ${needed - state.screenshotsCount} more site(s) BEFORE writing. Read design-inspiration.md, then browser_open + browser_navigate + browser_screenshot.`);
4667
+ return null;
4668
+ }
4182
4669
  /** Return the requirements missing from a design-system.md (empty = valid). */
4183
4670
  function validateDesignSystem(content) {
4184
4671
  const missing = [];
@@ -4225,10 +4712,10 @@ function designGate(payload, event, cacheDir, cwd) {
4225
4712
  if (skillBlock) return skillBlock;
4226
4713
  }
4227
4714
  const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
4715
+ if (!agentId) return null;
4228
4716
  const active = activeDesignAgent(cacheDir);
4229
- if (active && agentId && agentId !== active) return null;
4230
- const id = active || agentId;
4231
- if (!id) return null;
4717
+ if (active && agentId !== active) return null;
4718
+ const id = agentId;
4232
4719
  let state = loadDesignState(cacheDir, id);
4233
4720
  if (!state) {
4234
4721
  if (!active) return null;
@@ -4251,7 +4738,7 @@ function designGate(payload, event, cacheDir, cwd) {
4251
4738
  }
4252
4739
  if (event.tool === "Write" || event.tool === "Edit") {
4253
4740
  const fp = event.filePath ?? "";
4254
- const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
4741
+ const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? preScreenshotWriteGate(fp, state) ?? designSystemWriteGate(fp, state);
4255
4742
  if (base) return base;
4256
4743
  if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
4257
4744
  kind: "block",
@@ -4311,6 +4798,13 @@ async function handlePre(ctx) {
4311
4798
  stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
4312
4799
  exit: 0
4313
4800
  };
4801
+ if (opts.scope === "solid" && event.filePath) {
4802
+ const solidDeny = validateSolidGate(event.tool, event.filePath, event.content ?? "");
4803
+ if (solidDeny) return {
4804
+ stdout: solidDeny,
4805
+ exit: 0
4806
+ };
4807
+ }
4314
4808
  if (event.tool === "Task") {
4315
4809
  const taskCtx = taskContext(opts.cwd);
4316
4810
  if (taskCtx) return {
@@ -4343,82 +4837,6 @@ async function handlePre(ctx) {
4343
4837
  };
4344
4838
  }
4345
4839
  //#endregion
4346
- //#region src/freshness/explore-tools.ts
4347
- /** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
4348
- const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
4349
- /** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
4350
- const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
4351
- "mcp__context7__query-docs",
4352
- "mcp__context7__resolve-library-id",
4353
- "mcp__exa__web_search_exa",
4354
- "mcp__exa__get_code_context_exa",
4355
- "mcp__exa__deep_researcher_start",
4356
- "WebSearch",
4357
- "WebFetch"
4358
- ]);
4359
- /** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
4360
- const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
4361
- "grep",
4362
- "rg",
4363
- "find",
4364
- "ls",
4365
- "fd",
4366
- "ast-grep",
4367
- "tree",
4368
- "cat",
4369
- "head",
4370
- "tail"
4371
- ]);
4372
- /** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
4373
- const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
4374
- /**
4375
- * First non-assignment shell token's basename, or "" — mirrors Python
4376
- * `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
4377
- * @param cmd - Raw Bash `command` string.
4378
- * @returns The executable basename, or "" when none.
4379
- */
4380
- function bashExecutable(cmd) {
4381
- for (const token of cmd.trim().split(/\s+/)) {
4382
- if (!token) continue;
4383
- const last = token.split("/").pop() ?? token;
4384
- if (!last.includes("=")) return last;
4385
- }
4386
- return "";
4387
- }
4388
- /**
4389
- * Classify a direct tool use into an APEX phase, or `null` when it is neither
4390
- * exploration nor research. Mirrors Python `track-subagent-research._classify`.
4391
- * @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
4392
- * @param input - Tool input payload.
4393
- * @returns The credited phase + cache flag, or `null`.
4394
- */
4395
- function classifyExplore(tool, input) {
4396
- if (RESEARCH_TOOLS.has(tool)) return {
4397
- phase: "research-expert",
4398
- cacheHit: false
4399
- };
4400
- if (EXPLORE_TOOLS.has(tool)) return {
4401
- phase: "explore-codebase",
4402
- cacheHit: false
4403
- };
4404
- if (tool === "Read") {
4405
- const path = String(input?.file_path ?? input?.path ?? "");
4406
- if (path && CACHE_READ_RE.test(path)) return {
4407
- phase: "research-expert",
4408
- cacheHit: true
4409
- };
4410
- return null;
4411
- }
4412
- if (tool === "Bash") {
4413
- const cmd = String(input?.command ?? "").trim();
4414
- if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
4415
- phase: "explore-codebase",
4416
- cacheHit: false
4417
- };
4418
- }
4419
- return null;
4420
- }
4421
- //#endregion
4422
4840
  //#region src/runtime/activity.ts
4423
4841
  /** Min response length (chars) for a lead agent call to count as `sufficient`. */
4424
4842
  const AGENT_QUALITY_MIN = 500;
@@ -4526,14 +4944,28 @@ async function handlePost(ctx) {
4526
4944
  stdout: seoDeny,
4527
4945
  exit: 0
4528
4946
  };
4529
- if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate")) {
4947
+ if (opts.scope === "solid" && event.filePath) {
4948
+ const solidWarn = checkFileSize(event.tool, event.filePath);
4949
+ if (solidWarn) return {
4950
+ stdout: solidWarn,
4951
+ exit: 0
4952
+ };
4953
+ }
4954
+ if (opts.scope === "tailwindcss" && event.filePath) {
4955
+ const tailwindWarn = validateTailwind(event.tool, event.filePath);
4956
+ if (tailwindWarn) return {
4957
+ stdout: tailwindWarn,
4958
+ exit: 0
4959
+ };
4960
+ }
4961
+ if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate" || event.tool === "Write" || event.tool === "Edit")) {
4530
4962
  const out = await aipilotPostToolUse(payload, opts.cwd);
4531
4963
  if (out) return {
4532
4964
  stdout: out,
4533
4965
  exit: 0
4534
4966
  };
4535
4967
  }
4536
- const extra = postEditContext(opts.scope ?? "core", event, opts.now);
4968
+ const extra = await postEditContext(opts.scope ?? "core", event, opts.now);
4537
4969
  return {
4538
4970
  stdout: designWarn ? respond(id, designWarn) : extra,
4539
4971
  exit: 0
@@ -4578,7 +5010,7 @@ async function handleHook(id, payload, opts) {
4578
5010
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
4579
5011
  const mcpDir = layout.cacheDir;
4580
5012
  const framework = detectFramework(event.filePath ?? "", event.content ?? "");
4581
- if (designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
5013
+ if (id === "claude-code" && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
4582
5014
  stdout: "",
4583
5015
  exit: 0
4584
5016
  };
@@ -4620,4 +5052,4 @@ async function handleHook(id, payload, opts) {
4620
5052
  });
4621
5053
  }
4622
5054
  //#endregion
4623
- export { saveApexState as $, trackSkillRead as A, writePluginMap as B, seoPostToolUse as C, isoUtc as Ct, postTrackingSideEffects as D, securityStatePath as Dt, securityAdvisory as E, securityStateDir as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, aipilotPostToolUse as N, trackWatchResearch as O, todayUtc as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, normalizeEvent as St, dispatchMemory as T, saveSecurityState as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, promptSubmitContext as _t, recordActivity as a, solidDetectStart as at, extractSymbols as b, projectHash$1 as bt, MCP_TTL_MS as c, runSessionStartCleanups as ct, isMcpTool as d, purgeTtlTree as dt, logToolFailure as et, queryOf as f, removeOldFiles as ft, gate as g, projectContext as gt, TRIVIAL_BUDGET as h, gitContext as ht, respond as i, detectSolidProfile as it, trackEnrichment as j, trackMcpResearch as k, WEBFETCH_TTL_MS as l, sessionStartCore as lt, REQUIRED_AGENTS as m, devContext as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, injectRules as ot, DEFAULT_WINDOW_MS as p, trimLogFile as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, readRules as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, pruneEmptyDirs as ut, detectDuplication as v, taskContext as vt, seoPostToolUseResponse as w, loadSecurityState as wt, lifecycleStdout as x, trackFile as xt, dryGate as y, defaultStateDir as yt, generateEcosystemMap as z };
5055
+ export { saveApexState as $, trackSkillRead as A, securityStateDir as At, writePluginMap as B, seoPostToolUse as C, defaultStateDir as Ct, postTrackingSideEffects as D, isoUtc as Dt, securityAdvisory as E, normalizeEvent as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, todayUtc as Mt, aipilotPostToolUse as N, trackWatchResearch as O, loadSecurityState as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, taskContext as St, dispatchMemory as T, trackFile as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, trimLogFile as _t, recordActivity as a, validateSolidGate as at, extractSymbols as b, projectContext as bt, MCP_TTL_MS as c, detectSolidProfile as ct, isMcpTool as d, readRules as dt, logToolFailure as et, queryOf as f, runSessionStartCleanups as ft, gate as g, removeOldFiles as gt, TRIVIAL_BUDGET as h, purgeTtlTree as ht, respond as i, validateTailwind as it, trackEnrichment as j, securityStatePath as jt, trackMcpResearch as k, saveSecurityState as kt, WEBFETCH_TTL_MS as l, solidDetectStart as lt, REQUIRED_AGENTS as m, pruneEmptyDirs as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, checkFileSize as ot, DEFAULT_WINDOW_MS as p, sessionStartCore as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, countLoc as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, injectRules as ut, detectDuplication as v, devContext as vt, seoPostToolUseResponse as w, projectHash$1 as wt, lifecycleStdout as x, promptSubmitContext as xt, dryGate as y, gitContext as yt, generateEcosystemMap as z };