@fusengine/harness 0.1.43 → 0.1.45
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.
- package/dist/adapters/claude/index.d.mts +2 -2
- package/dist/adapters/claude/index.mjs +2 -2
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.d.mts +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/index.mjs +2 -2
- package/dist/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
- package/dist/cli/bin.mjs +227 -7
- package/dist/cli/index.mjs +1 -1
- package/dist/config/index.d.mts +1 -1
- package/dist/config/index.mjs +1 -2
- package/dist/{doc-helpers-D14nkD5D.d.mts → doc-helpers-BNfYWvYv.d.mts} +9 -1
- package/dist/{doc-helpers-BhzDmJ18.mjs → doc-helpers-CWZegVdR.mjs} +14 -5
- package/dist/{dotenv-DGyLln7U.mjs → dotenv-B9nM4cuQ.mjs} +26 -1
- package/dist/evaluate-d7Pp8XJH.mjs +784 -0
- package/dist/freshness/index.d.mts +1 -1
- package/dist/freshness/index.mjs +1 -1
- package/dist/{handle-BTHcKWQ5.mjs → handle-DJaaD2BT.mjs} +849 -395
- package/dist/home-state-mKZxP4oZ.mjs +52 -0
- package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -1
- package/dist/{index-BX-xcvhY.d.mts → index-BUwEmIK-.d.mts} +10 -1
- package/dist/{index-DXQfL1u8.d.mts → index-BXPySPxE.d.mts} +7 -1
- package/dist/{index-CwOdFBOr.d.mts → index-BxjzFraL.d.mts} +3 -1
- package/dist/{index-DN4cZDbU.d.mts → index-CVhw7eA0.d.mts} +111 -23
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +8 -9
- package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
- package/dist/{mcp-store-CnsW9oFj.mjs → mcp-store-BkBDmuxN.mjs} +22 -5
- package/dist/policy/index.d.mts +2 -2
- package/dist/policy/index.mjs +3 -3
- package/dist/refs/index.mjs +2 -2
- package/dist/{router-BfX0hJg8.mjs → router-PKVNBHge.mjs} +11 -1
- package/dist/{run-jgivVDv6.mjs → run-DLXtA5DH.mjs} +1 -1
- package/dist/runtime/index.d.mts +71 -14
- package/dist/runtime/index.mjs +3 -3
- package/dist/{session-state-Dzq6yrw7.d.mts → session-state-D4F_Dub6.d.mts} +1 -1
- package/dist/state/index.d.mts +1 -1
- package/dist/{store-CdWOQ9zD.mjs → store-CNjFenWe.mjs} +3 -50
- package/dist/tracking/index.d.mts +1 -1
- package/dist/tracking/index.mjs +1 -1
- package/dist/{validate-xi-zc-22.mjs → validate-DcQaKzau.mjs} +538 -84
- package/package.json +1 -1
- package/dist/evaluate-CeivW6G0.mjs +0 -477
- 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
|
|
6
|
-
import {
|
|
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 findMarketplacePlugins, E as frameworkSolidGate, H as requiredArchSkill, L as detectFramework, N as docConsultedGate, P as evaluateApex, _ as scanPlugin, 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, w as readPluginMeta, x as skillTriggerGate, y as parseField, z as detectModularArchitecture } from "./validate-DcQaKzau.mjs";
|
|
5
|
+
import { F as countFrameworkCodeLines, I as countLines, N as PLUGINS_DIR, 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
|
-
import {
|
|
13
|
-
import { t as loadRefs } from "./loader-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.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$
|
|
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$
|
|
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 >=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3308
|
-
*
|
|
3309
|
-
*
|
|
3310
|
-
*
|
|
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" ||
|
|
3318
|
-
|
|
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
|
-
*
|
|
3345
|
-
*
|
|
3346
|
-
*
|
|
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
|
|
3353
|
-
if (!path) return
|
|
3560
|
+
function existingLineCounts(path) {
|
|
3561
|
+
if (!path || !existsSync(path)) return {};
|
|
3354
3562
|
try {
|
|
3355
|
-
|
|
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,29 @@ function detectDuplication(filePath, content, cwd) {
|
|
|
3486
3698
|
duplicates
|
|
3487
3699
|
};
|
|
3488
3700
|
}
|
|
3489
|
-
/**
|
|
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
|
|
3710
|
+
if (dup.duplicates.length === 0) return null;
|
|
3711
|
+
const names = dup.names.slice(0, 5).join(", ");
|
|
3712
|
+
let files = dup.duplicates.slice(0, 3).join(", ");
|
|
3713
|
+
if (dup.duplicates.length > 3) files += ` (+${dup.duplicates.length - 3} more)`;
|
|
3714
|
+
if (dup.duplicates.length === 1) return {
|
|
3715
|
+
kind: "inform",
|
|
3716
|
+
title: "Possible duplicate code (DRY)",
|
|
3717
|
+
reason: `[${names}] already declared in: ${files}. Consider importing and reusing instead of re-declaring.`,
|
|
3718
|
+
actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
|
|
3719
|
+
};
|
|
3494
3720
|
return {
|
|
3495
3721
|
kind: "block",
|
|
3496
3722
|
title: "Duplicate code (DRY)",
|
|
3497
|
-
reason: `[${
|
|
3723
|
+
reason: `[${names}] already declared in: ${files}. Import and reuse instead of re-declaring.`,
|
|
3498
3724
|
actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
|
|
3499
3725
|
};
|
|
3500
3726
|
}
|
|
@@ -3609,15 +3835,15 @@ const block$1 = (reason) => ({
|
|
|
3609
3835
|
function nextModular(filePath, content, cwd) {
|
|
3610
3836
|
const rel = relative(cwd, filePath);
|
|
3611
3837
|
const bn = basename(filePath);
|
|
3612
|
-
if ((rel.startsWith("app/") || rel.startsWith("src/app/")) && !NEXT_CONVENTION.test(bn) && !NEXT_STATIC.test(bn)) return block$1(`BLOCKED: modular Next.js — '${bn}' is not an app/ convention file. Move business logic to modules/[feature]/.`);
|
|
3838
|
+
if ((rel.startsWith("app/") || rel.startsWith("src/app/")) && !NEXT_CONVENTION.test(bn) && !NEXT_STATIC.test(bn)) return block$1(`BLOCKED: modular Next.js — '${bn}' is not an app/ convention file. Move business logic to modules/[feature]/. Only page.tsx, layout.tsx, route.ts allowed in app/.`);
|
|
3613
3839
|
const mod = filePath.match(/\/modules\/([^/]+)\//);
|
|
3614
3840
|
if (!mod) return null;
|
|
3615
3841
|
const current = mod[1] ?? "";
|
|
3616
3842
|
for (const m of content.matchAll(/from\s+['"][@.][^'"]*?\/modules\/([^/]+)\//g)) {
|
|
3617
3843
|
const imported = m[1] ?? "";
|
|
3618
3844
|
if (current === "cores") {
|
|
3619
|
-
if (imported !== "cores" && imported !== "core") return block$1(`BLOCKED: modules/cores/ must not import from modules/${imported}
|
|
3620
|
-
} else if (imported !== current && imported !== "cores" && imported !== "core") return block$1(`BLOCKED: cross-module import — '${current}' imports '${imported}'. Only modules/cores/ is shared.`);
|
|
3845
|
+
if (imported !== "cores" && imported !== "core") return block$1(`BLOCKED: modules/cores/ must not import from modules/${imported}/. Cores must be independent.`);
|
|
3846
|
+
} else if (imported !== current && imported !== "cores" && imported !== "core") return block$1(`BLOCKED: cross-module import — '${current}' imports from '${imported}'. Only modules/cores/ is shared.`);
|
|
3621
3847
|
}
|
|
3622
3848
|
return null;
|
|
3623
3849
|
}
|
|
@@ -3649,17 +3875,22 @@ function modularGate(tool, filePath, content, cwd) {
|
|
|
3649
3875
|
//#region src/runtime/framework-skill-gate.ts
|
|
3650
3876
|
/**
|
|
3651
3877
|
* Effective line count for the SOLID size check. On an Edit, `content` is only
|
|
3652
|
-
* the `new_string` snippet, so
|
|
3653
|
-
*
|
|
3654
|
-
*
|
|
3655
|
-
*
|
|
3878
|
+
* the `new_string` snippet, so for Next.js — mirroring the base file-size guard
|
|
3879
|
+
* and the Python `get_full_file_content` — judge the larger of the snippet and
|
|
3880
|
+
* the full on-disk file (`existingCodeLines`). Other frameworks (react/laravel/
|
|
3881
|
+
* swift) judge the edited snippet alone, never the on-disk full-file max — their
|
|
3882
|
+
* Python source never imports `get_full_file_content`, only nextjs does. On
|
|
3883
|
+
* Write, `content` IS the full file, so the snippet count stands regardless of
|
|
3884
|
+
* framework (undefined → the gate falls back to `countFrameworkCodeLines`).
|
|
3656
3885
|
* @param tool - the tool name ("Edit" | "Write" | ...).
|
|
3886
|
+
* @param framework - the detected framework; only "nextjs" applies the full-file max.
|
|
3657
3887
|
* @param content - the written content (snippet on Edit, full file on Write).
|
|
3658
|
-
* @param
|
|
3888
|
+
* @param existingCodeLines - full on-disk code-only line count, when known.
|
|
3659
3889
|
*/
|
|
3660
|
-
function effectiveLines(tool, content,
|
|
3661
|
-
if (tool !== "Edit" ||
|
|
3662
|
-
return
|
|
3890
|
+
function effectiveLines(tool, framework, content, existingCodeLines) {
|
|
3891
|
+
if (tool !== "Edit" || existingCodeLines === void 0) return void 0;
|
|
3892
|
+
if (framework !== "nextjs") return countFrameworkCodeLines(content);
|
|
3893
|
+
return Math.max(countFrameworkCodeLines(content), existingCodeLines);
|
|
3663
3894
|
}
|
|
3664
3895
|
/**
|
|
3665
3896
|
* Framework-aware SOLID + sub-skill gate, run on the Write/Edit path once a
|
|
@@ -3672,17 +3903,156 @@ function effectiveLines(tool, content, existingLines) {
|
|
|
3672
3903
|
*
|
|
3673
3904
|
* @param input - the gated tool-use (filePath + content + framework + cwd).
|
|
3674
3905
|
* @param refsRead - in-session read reference paths (from the loaded track).
|
|
3675
|
-
* @param
|
|
3676
|
-
* file still fires the framework SOLID size rule). Omit on Write.
|
|
3906
|
+
* @param existingCodeLines - full on-disk code-only line count (so an Edit on
|
|
3907
|
+
* an oversized file still fires the framework SOLID size rule). Omit on Write.
|
|
3677
3908
|
* @returns the first blocking {@link Prompt}, or `null` to allow.
|
|
3678
3909
|
*/
|
|
3679
|
-
function frameworkSkillGate(input, refsRead,
|
|
3910
|
+
function frameworkSkillGate(input, refsRead, existingCodeLines) {
|
|
3680
3911
|
if (!input.filePath) return null;
|
|
3681
3912
|
const content = input.content ?? "";
|
|
3682
|
-
const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, content,
|
|
3913
|
+
const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, input.framework, content, existingCodeLines));
|
|
3683
3914
|
if (solid) return solid;
|
|
3684
3915
|
const forced = input.cwd ? requiredArchSkill(input.cwd) : null;
|
|
3685
|
-
return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
|
|
3916
|
+
return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd, input.filePath);
|
|
3917
|
+
}
|
|
3918
|
+
//#endregion
|
|
3919
|
+
//#region src/policy/shadcn-skill-gate.ts
|
|
3920
|
+
/** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
|
|
3921
|
+
const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
|
|
3922
|
+
/**
|
|
3923
|
+
* Vendored/build paths exempt from the gate. Uses `(^|\/)` (not just a
|
|
3924
|
+
* leading `/`) so a relative root-start path like `node_modules/x` is also
|
|
3925
|
+
* exempt — mirrors the same deliberate widening already applied in
|
|
3926
|
+
* `design/skill-gate.ts`'s `EXEMPT_RE`, not the Python's stricter `/x/`-only match.
|
|
3927
|
+
*/
|
|
3928
|
+
const EXEMPT_RE$1 = /(^|\/)(node_modules|dist|build)\//;
|
|
3929
|
+
/** Path fragments that put a write in shadcn scope (source: `(components|ui|shadcn|components\.json)`). */
|
|
3930
|
+
const SHADCN_PATH_RE = /(components|ui|shadcn|components\.json)/;
|
|
3931
|
+
/** A read reference proving one of the two named base skills was consulted (Phase 1). */
|
|
3932
|
+
const SHADCN_BASE_SKILL_RE = /skills\/(shadcn-detection|shadcn-components)\//;
|
|
3933
|
+
/** Session `authorizations` source proving a live `mcp__shadcn__*` call was made this session (Phase 1, option 3). */
|
|
3934
|
+
const SHADCN_MCP_SOURCE_RE = /shadcn-mcp/i;
|
|
3935
|
+
/**
|
|
3936
|
+
* True when this Write/Edit is shadcn-scoped: a `.tsx/.jsx/.css/.scss/.json`
|
|
3937
|
+
* file under a `components|ui|shadcn|components.json` path, outside vendored
|
|
3938
|
+
* dirs. Ports `check-skill-loaded.py`'s file-path filter (lines 35-42).
|
|
3939
|
+
*/
|
|
3940
|
+
function isShadcnWrite(tool, filePath) {
|
|
3941
|
+
if (tool !== "Write" && tool !== "Edit") return false;
|
|
3942
|
+
if (!SHADCN_FILE_RE.test(filePath) || EXEMPT_RE$1.test(filePath)) return false;
|
|
3943
|
+
return SHADCN_PATH_RE.test(filePath);
|
|
3944
|
+
}
|
|
3945
|
+
/** True when `shadcn-detection/` or `shadcn-components/` was read this session (Phase 1 base skill). */
|
|
3946
|
+
function shadcnBaseSkillRead(refsRead) {
|
|
3947
|
+
return refsRead.some((p) => SHADCN_BASE_SKILL_RE.test(p));
|
|
3948
|
+
}
|
|
3949
|
+
/** True when a `mcp__shadcn__*` tool call was recorded this session (Phase 1's 3rd unblock option). */
|
|
3950
|
+
function shadcnMcpConsulted(authorizations, sessionId) {
|
|
3951
|
+
if (!authorizations) return false;
|
|
3952
|
+
return Object.values(authorizations).some((a) => a.doc_sessions?.includes(sessionId) && (a.sources ?? (a.source ? [a.source] : [])).some((s) => SHADCN_MCP_SOURCE_RE.test(s)));
|
|
3953
|
+
}
|
|
3954
|
+
/**
|
|
3955
|
+
* Gate a shadcn-scoped write. Phase 1 requires a base skill read
|
|
3956
|
+
* (shadcn-detection or shadcn-components); Phase 2 requires the domain
|
|
3957
|
+
* sub-skill(s) matching the written content, via {@link skillTriggerGate}
|
|
3958
|
+
* keyed on the "shadcn" framework (registered in `SKILL_TRIGGERS`); Phase 3
|
|
3959
|
+
* requires doc research, delegated to the existing {@link docConsultedGate}
|
|
3960
|
+
* (Context7 AND Exa, or a web fallback alone — not the Python's strict AND).
|
|
3961
|
+
* Returns a blocking {@link Prompt}, or `null` to allow.
|
|
3962
|
+
*/
|
|
3963
|
+
function shadcnSkillGate(tool, filePath, content, ev) {
|
|
3964
|
+
if (!isShadcnWrite(tool, filePath)) return null;
|
|
3965
|
+
if (!shadcnBaseSkillRead(ev.refsRead) && !shadcnMcpConsulted(ev.authorizations, ev.sessionId)) return {
|
|
3966
|
+
kind: "block",
|
|
3967
|
+
title: "shadcn/ui skill",
|
|
3968
|
+
reason: "BLOCKED: shadcn skill not consulted. Read shadcn-detection/SKILL.md or shadcn-components/SKILL.md, or use mcp__shadcn__search_items_in_registries, then retry.",
|
|
3969
|
+
actions: ["Read skills/shadcn-detection/ or skills/shadcn-components/, or call mcp__shadcn__search_items_in_registries, then retry"]
|
|
3970
|
+
};
|
|
3971
|
+
const domainBlock = skillTriggerGate("shadcn", content, ev.refsRead);
|
|
3972
|
+
if (domainBlock) return domainBlock;
|
|
3973
|
+
return docConsultedGate({
|
|
3974
|
+
sessionId: ev.sessionId,
|
|
3975
|
+
framework: "shadcn",
|
|
3976
|
+
filePath,
|
|
3977
|
+
content,
|
|
3978
|
+
authorizations: ev.authorizations
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3981
|
+
//#endregion
|
|
3982
|
+
//#region src/freshness/explore-tools.ts
|
|
3983
|
+
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
3984
|
+
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
3985
|
+
/** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
|
|
3986
|
+
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
3987
|
+
"mcp__context7__query-docs",
|
|
3988
|
+
"mcp__context7__resolve-library-id",
|
|
3989
|
+
"mcp__exa__web_search_exa",
|
|
3990
|
+
"mcp__exa__get_code_context_exa",
|
|
3991
|
+
"mcp__exa__deep_researcher_start",
|
|
3992
|
+
"WebSearch",
|
|
3993
|
+
"WebFetch"
|
|
3994
|
+
]);
|
|
3995
|
+
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
3996
|
+
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
3997
|
+
"grep",
|
|
3998
|
+
"rg",
|
|
3999
|
+
"find",
|
|
4000
|
+
"ls",
|
|
4001
|
+
"fd",
|
|
4002
|
+
"ast-grep",
|
|
4003
|
+
"tree",
|
|
4004
|
+
"cat",
|
|
4005
|
+
"head",
|
|
4006
|
+
"tail"
|
|
4007
|
+
]);
|
|
4008
|
+
/** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
|
|
4009
|
+
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
4010
|
+
/**
|
|
4011
|
+
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
4012
|
+
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
4013
|
+
* @param cmd - Raw Bash `command` string.
|
|
4014
|
+
* @returns The executable basename, or "" when none.
|
|
4015
|
+
*/
|
|
4016
|
+
function bashExecutable(cmd) {
|
|
4017
|
+
for (const token of cmd.trim().split(/\s+/)) {
|
|
4018
|
+
if (!token) continue;
|
|
4019
|
+
const last = token.split("/").pop() ?? token;
|
|
4020
|
+
if (!last.includes("=")) return last;
|
|
4021
|
+
}
|
|
4022
|
+
return "";
|
|
4023
|
+
}
|
|
4024
|
+
/**
|
|
4025
|
+
* Classify a direct tool use into an APEX phase, or `null` when it is neither
|
|
4026
|
+
* exploration nor research. Mirrors Python `track-subagent-research._classify`.
|
|
4027
|
+
* @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
|
|
4028
|
+
* @param input - Tool input payload.
|
|
4029
|
+
* @returns The credited phase + cache flag, or `null`.
|
|
4030
|
+
*/
|
|
4031
|
+
function classifyExplore(tool, input) {
|
|
4032
|
+
if (RESEARCH_TOOLS.has(tool)) return {
|
|
4033
|
+
phase: "research-expert",
|
|
4034
|
+
cacheHit: false
|
|
4035
|
+
};
|
|
4036
|
+
if (EXPLORE_TOOLS.has(tool)) return {
|
|
4037
|
+
phase: "explore-codebase",
|
|
4038
|
+
cacheHit: false
|
|
4039
|
+
};
|
|
4040
|
+
if (tool === "Read") {
|
|
4041
|
+
const path = String(input?.file_path ?? input?.path ?? "");
|
|
4042
|
+
if (path && CACHE_READ_RE.test(path)) return {
|
|
4043
|
+
phase: "research-expert",
|
|
4044
|
+
cacheHit: true
|
|
4045
|
+
};
|
|
4046
|
+
return null;
|
|
4047
|
+
}
|
|
4048
|
+
if (tool === "Bash") {
|
|
4049
|
+
const cmd = String(input?.command ?? "").trim();
|
|
4050
|
+
if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
|
|
4051
|
+
phase: "explore-codebase",
|
|
4052
|
+
cacheHit: false
|
|
4053
|
+
};
|
|
4054
|
+
}
|
|
4055
|
+
return null;
|
|
3686
4056
|
}
|
|
3687
4057
|
//#endregion
|
|
3688
4058
|
//#region src/freshness/agent-evidence.ts
|
|
@@ -3701,9 +4071,13 @@ function parseTs(raw) {
|
|
|
3701
4071
|
}
|
|
3702
4072
|
/**
|
|
3703
4073
|
* Return `true` ONLY when, for EVERY name in `names`, the Claude Code
|
|
3704
|
-
* transcript at `transcriptPath` contains a genuine `tool_use` of the `Task
|
|
3705
|
-
* `Agent` tool whose `subagent_type` (or `name`)
|
|
3706
|
-
*
|
|
4074
|
+
* transcript at `transcriptPath` contains a genuine `tool_use` of the `Task`/
|
|
4075
|
+
* `Agent` tool whose `subagent_type` (or `name`) matches — after stripping any
|
|
4076
|
+
* plugin prefix — OR a direct exploration/research tool_use (Glob/Grep, an
|
|
4077
|
+
* explore Bash command, mcp__context7/mcp__exa, WebSearch/WebFetch) classified
|
|
4078
|
+
* via {@link classifyExplore} into that name, issued by ANY sub-agent or the
|
|
4079
|
+
* lead within this same transcript — with the entry timestamp within
|
|
4080
|
+
* `windowMs` of `now`.
|
|
3707
4081
|
*
|
|
3708
4082
|
* **Timestamp note:** when a transcript entry carries no `timestamp` field it
|
|
3709
4083
|
* is counted as within-window (we cannot prove staleness). This is
|
|
@@ -3741,10 +4115,15 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
3741
4115
|
const content = entry.message?.content;
|
|
3742
4116
|
if (!Array.isArray(content)) continue;
|
|
3743
4117
|
for (const block of content) {
|
|
3744
|
-
if (block?.type !== "tool_use"
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
4118
|
+
if (block?.type !== "tool_use") continue;
|
|
4119
|
+
if (block.name === "Task" || block.name === "Agent") {
|
|
4120
|
+
const raw = block.input?.subagent_type ?? block.input?.name;
|
|
4121
|
+
const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
|
|
4122
|
+
if (agent !== void 0 && names.includes(agent)) found.add(agent);
|
|
4123
|
+
continue;
|
|
4124
|
+
}
|
|
4125
|
+
const hit = classifyExplore(block.name ?? "", block.input);
|
|
4126
|
+
if (hit && names.includes(hit.phase)) found.add(hit.phase);
|
|
3748
4127
|
}
|
|
3749
4128
|
if (found.size === names.length) return true;
|
|
3750
4129
|
}
|
|
@@ -3765,7 +4144,7 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
3765
4144
|
*/
|
|
3766
4145
|
async function apexScopedGate(input, track, window) {
|
|
3767
4146
|
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) {
|
|
4147
|
+
if (input.tool === "Edit" && !input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
|
|
3769
4148
|
await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
|
|
3770
4149
|
return null;
|
|
3771
4150
|
}
|
|
@@ -3779,7 +4158,9 @@ async function apexScopedGate(input, track, window) {
|
|
|
3779
4158
|
refs: input.refs,
|
|
3780
4159
|
refsRead: track.refsRead,
|
|
3781
4160
|
agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
|
|
3782
|
-
|
|
4161
|
+
missingAgents: REQUIRED_AGENTS.filter((name) => !freshnessFor([name])),
|
|
4162
|
+
windowMs: window,
|
|
4163
|
+
brainstormRequired: input.tool === "Edit" ? false : track.brainstormRequired,
|
|
3783
4164
|
brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
|
|
3784
4165
|
};
|
|
3785
4166
|
try {
|
|
@@ -3792,7 +4173,12 @@ async function apexScopedGate(input, track, window) {
|
|
|
3792
4173
|
//#region src/runtime/gate.ts
|
|
3793
4174
|
/** Prior agents the freshness gate requires before a code edit. */
|
|
3794
4175
|
const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
|
|
3795
|
-
/**
|
|
4176
|
+
/**
|
|
4177
|
+
* Default freshness window for {@link REQUIRED_AGENTS}, in ms. Matches the
|
|
4178
|
+
* plugin's `FUSE_ENFORCE_TTL_SEC` default (120s). Only a fallback for direct
|
|
4179
|
+
* programmatic callers that omit `windowMs` (e.g. tests) — the real CLI path
|
|
4180
|
+
* always supplies `windowMs` from `resolveTtlSec()` (`src/config/ttl.ts`).
|
|
4181
|
+
*/
|
|
3796
4182
|
const DEFAULT_WINDOW_MS = 12e4;
|
|
3797
4183
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
3798
4184
|
const TRIVIAL_BUDGET = 4;
|
|
@@ -3804,7 +4190,7 @@ const TRIVIAL_BUDGET = 4;
|
|
|
3804
4190
|
async function gate(input) {
|
|
3805
4191
|
const precommit = preCommitGate(input.tool, input.command, input.cwd);
|
|
3806
4192
|
if (precommit) return precommit;
|
|
3807
|
-
const existingLines =
|
|
4193
|
+
const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
|
|
3808
4194
|
let quick;
|
|
3809
4195
|
try {
|
|
3810
4196
|
quick = evaluate({
|
|
@@ -3822,10 +4208,19 @@ async function gate(input) {
|
|
|
3822
4208
|
const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
|
|
3823
4209
|
if (modular) return modular;
|
|
3824
4210
|
if (!input.filePath) return null;
|
|
4211
|
+
const filePath = input.filePath;
|
|
3825
4212
|
const window = input.windowMs ?? 12e4;
|
|
3826
4213
|
const track = await loadTrack(input.trackFile);
|
|
3827
|
-
const solidOrSkill = frameworkSkillGate(input, track.refsRead,
|
|
4214
|
+
const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
|
|
3828
4215
|
if (solidOrSkill) return solidOrSkill;
|
|
4216
|
+
if (isShadcnWrite(input.tool, filePath)) {
|
|
4217
|
+
const shadcnBlock = shadcnSkillGate(input.tool, filePath, input.content ?? "", {
|
|
4218
|
+
refsRead: track.refsRead,
|
|
4219
|
+
authorizations: track.authorizations,
|
|
4220
|
+
sessionId: input.sessionId
|
|
4221
|
+
});
|
|
4222
|
+
if (shadcnBlock) return shadcnBlock;
|
|
4223
|
+
}
|
|
3829
4224
|
if (isApexScoped(input.filePath)) {
|
|
3830
4225
|
const apex = await apexScopedGate(input, track, window);
|
|
3831
4226
|
if (apex) return apex;
|
|
@@ -3896,6 +4291,16 @@ function docSourceOf$1(tool) {
|
|
|
3896
4291
|
if (/context7/i.test(tool)) return "context7";
|
|
3897
4292
|
}
|
|
3898
4293
|
/**
|
|
4294
|
+
* Wrap a cache hit's body in the harness-facing `CACHE HIT` notice — parity with
|
|
4295
|
+
* `webfetch-cache-lookup.py` / `mcp-cache-lookup.py` (KB = body chars/1024 + 1,
|
|
4296
|
+
* age hours = ageMs/3_600_000, both floored).
|
|
4297
|
+
*/
|
|
4298
|
+
function cacheHitText(web, hit) {
|
|
4299
|
+
const kb = Math.floor(hit.body.length / 1024) + 1;
|
|
4300
|
+
const hours = Math.floor(hit.ageMs / 36e5);
|
|
4301
|
+
return web ? `CACHE HIT WebFetch (~${kb}KB economise, cached il y a ${hours}h):\n\n${hit.body}\n\nPour forcer un nouveau fetch, modifie l'URL ou la query.` : `CACHE HIT (~${kb}KB economise, cached il y a ${hours}h): ${hit.body}\n\nReformule pour forcer un re-call.`;
|
|
4302
|
+
}
|
|
4303
|
+
/**
|
|
3899
4304
|
* Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
|
|
3900
4305
|
* else cap exa verbosity (allow + mutated input), else null to allow normally.
|
|
3901
4306
|
* Harnesses without input-mutation/cache support fall through to null.
|
|
@@ -3905,9 +4310,9 @@ function mcpPreIntercept(id, tool, input, dir, ttlMs, now) {
|
|
|
3905
4310
|
const web = isWebFetch(tool);
|
|
3906
4311
|
const ttl = web ? WEBFETCH_TTL_MS : ttlMs;
|
|
3907
4312
|
const key = cacheQueryOf(tool, input);
|
|
3908
|
-
const
|
|
3909
|
-
if (
|
|
3910
|
-
const served = denyWith(id,
|
|
4313
|
+
const hit = cacheLookupMeta(dir, tool, key, ttl, now) ?? (web ? null : cacheLookupSubstringMeta(dir, key, ttl, now));
|
|
4314
|
+
if (hit) {
|
|
4315
|
+
const served = denyWith(id, cacheHitText(web, hit));
|
|
3911
4316
|
if (served) return {
|
|
3912
4317
|
stdout: served,
|
|
3913
4318
|
docSource: docSourceOf$1(tool)
|
|
@@ -3943,31 +4348,51 @@ async function recordActivity(file, activity) {
|
|
|
3943
4348
|
//#endregion
|
|
3944
4349
|
//#region src/runtime/respond.ts
|
|
3945
4350
|
/**
|
|
3946
|
-
* Map a portable {@link Prompt} to a harness's native hook response
|
|
3947
|
-
*
|
|
3948
|
-
* `
|
|
4351
|
+
* Map a portable {@link Prompt} to a harness's native hook response, honoring
|
|
4352
|
+
* all 3 prompt kinds (`block`/`ask`/`inform`) instead of collapsing `ask` and
|
|
4353
|
+
* `inform` together. Reuses the Claude adapter's `denyResponse`/
|
|
4354
|
+
* `contextResponse` builders for claude-code/codex (same hook shape) to avoid
|
|
4355
|
+
* duplicating the JSON.
|
|
4356
|
+
* - claude-code/codex: `block` -> deny, `ask` -> interactive
|
|
4357
|
+
* `permissionDecision:"ask"`, `inform` -> non-blocking `additionalContext`.
|
|
4358
|
+
* - gemini-cli/cline: their real hook schemas have no interactive "ask"
|
|
4359
|
+
* state (deny is the only blocking outcome), so `ask` and `inform` already
|
|
4360
|
+
* both resolve to non-blocking context injection — unchanged.
|
|
4361
|
+
* - cursor: `ask` keeps its current best-effort `permission:"ask"` shape
|
|
4362
|
+
* (unverified against Cursor's exact hook docs); only `inform` is fixed to
|
|
4363
|
+
* a non-blocking `permission:"allow"` note, since it was wrongly conflated
|
|
4364
|
+
* with `ask` before.
|
|
3949
4365
|
*/
|
|
3950
4366
|
function respond(id, prompt) {
|
|
3951
4367
|
const message = formatPrompt(prompt);
|
|
3952
|
-
const
|
|
4368
|
+
const { kind } = prompt;
|
|
3953
4369
|
switch (id) {
|
|
3954
4370
|
case "claude-code":
|
|
3955
|
-
case "codex":
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
4371
|
+
case "codex":
|
|
4372
|
+
if (kind === "block") return denyResponse("PreToolUse", message);
|
|
4373
|
+
if (kind === "inform") return contextResponse("PreToolUse", message);
|
|
4374
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
4375
|
+
hookEventName: "PreToolUse",
|
|
4376
|
+
permissionDecision: "ask",
|
|
4377
|
+
permissionDecisionReason: message
|
|
4378
|
+
} });
|
|
4379
|
+
case "gemini-cli": return JSON.stringify(kind === "block" ? {
|
|
3961
4380
|
decision: "deny",
|
|
3962
4381
|
reason: message
|
|
3963
4382
|
} : { hookSpecificOutput: { additionalContext: message } });
|
|
3964
|
-
case "cursor":
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
4383
|
+
case "cursor":
|
|
4384
|
+
if (kind === "inform") return JSON.stringify({
|
|
4385
|
+
permission: "allow",
|
|
4386
|
+
userMessage: message,
|
|
4387
|
+
agentMessage: message
|
|
4388
|
+
});
|
|
4389
|
+
return JSON.stringify({
|
|
4390
|
+
permission: kind === "block" ? "deny" : "ask",
|
|
4391
|
+
continue: false,
|
|
4392
|
+
userMessage: message,
|
|
4393
|
+
agentMessage: message
|
|
4394
|
+
});
|
|
4395
|
+
case "cline": return JSON.stringify(kind === "block" ? {
|
|
3971
4396
|
cancel: true,
|
|
3972
4397
|
errorMessage: message
|
|
3973
4398
|
} : { contextModification: message });
|
|
@@ -4019,6 +4444,65 @@ function runDesignChecks(content) {
|
|
|
4019
4444
|
];
|
|
4020
4445
|
}
|
|
4021
4446
|
//#endregion
|
|
4447
|
+
//#region src/policy/design/skill-triggers.ts
|
|
4448
|
+
/** Domain skill triggers (ported 1:1 from `design_skill_triggers.py`). */
|
|
4449
|
+
const SKILL_TRIGGERS = [
|
|
4450
|
+
{
|
|
4451
|
+
skill: "3-generating-components",
|
|
4452
|
+
readFragment: "generating-components",
|
|
4453
|
+
patterns: [
|
|
4454
|
+
/className\s*=\s*["{]/i,
|
|
4455
|
+
/<(div|section|main|header|footer|nav|aside)\s/i,
|
|
4456
|
+
/flex|grid|gap-|p-|m-|bg-|text-|rounded|shadow|border/i,
|
|
4457
|
+
/(Button|Card|Dialog|Sheet|Input|Select|Table)\b/i,
|
|
4458
|
+
/cva|class-variance-authority|variants\b/i,
|
|
4459
|
+
/VariantProps|variant.*:\s*\{/i,
|
|
4460
|
+
/children|slots|asChild|Slot|render.?prop/i,
|
|
4461
|
+
/forwardRef|React\.cloneElement|compound/i
|
|
4462
|
+
]
|
|
4463
|
+
},
|
|
4464
|
+
{
|
|
4465
|
+
skill: "1-designing-systems",
|
|
4466
|
+
readFragment: "designing-systems",
|
|
4467
|
+
patterns: [
|
|
4468
|
+
/--(\w+-)+color:|:root\s*\{|@theme\b/i,
|
|
4469
|
+
/design.?system|token|palette|typography.?scale/i,
|
|
4470
|
+
/--(\w+)-(foreground|background|primary|muted|accent):/i,
|
|
4471
|
+
/cssVariables|themeConfig|colorScheme/i,
|
|
4472
|
+
/dark:|prefers-color-scheme|next-themes|useTheme/i,
|
|
4473
|
+
/ThemeProvider|data-theme|color-scheme/i,
|
|
4474
|
+
/sm:|md:|lg:|xl:|2xl:/i,
|
|
4475
|
+
/@container|container-type|@media/i,
|
|
4476
|
+
/clamp\(|fluid|min-width:/i
|
|
4477
|
+
]
|
|
4478
|
+
},
|
|
4479
|
+
{
|
|
4480
|
+
skill: "4-adding-animations",
|
|
4481
|
+
readFragment: "adding-animations",
|
|
4482
|
+
patterns: [
|
|
4483
|
+
/motion\.|framer-motion|animate|variants/i,
|
|
4484
|
+
/whileHover|whileTap|AnimatePresence|transition/i,
|
|
4485
|
+
/@keyframes\b|animation:\s/i,
|
|
4486
|
+
/backdrop-blur|bg-.*\/([\d]+)|glass/i,
|
|
4487
|
+
/backdrop-filter:\s*blur/i,
|
|
4488
|
+
/hover:|focus:|active:|disabled:|focus-visible:/i,
|
|
4489
|
+
/data-\[state=|data-\[disabled\]/i,
|
|
4490
|
+
/bg-gradient|from-|via-|to-/i,
|
|
4491
|
+
/radial-gradient|conic-gradient|bg-\[url/i,
|
|
4492
|
+
/blur-.*xl|opacity-|mix-blend/i
|
|
4493
|
+
]
|
|
4494
|
+
},
|
|
4495
|
+
{
|
|
4496
|
+
skill: "5-design-audit",
|
|
4497
|
+
readFragment: "design-audit",
|
|
4498
|
+
patterns: [/aria-|role=|sr-only|tabIndex|alt=/i, /WCAG|a11y|contrast|screen.?reader/i]
|
|
4499
|
+
}
|
|
4500
|
+
];
|
|
4501
|
+
/** Return the domain skills required by `content` but NOT present in `refsRead`. */
|
|
4502
|
+
function missingDomainSkills(content, refsRead) {
|
|
4503
|
+
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);
|
|
4504
|
+
}
|
|
4505
|
+
//#endregion
|
|
4022
4506
|
//#region src/policy/design/skill-gate.ts
|
|
4023
4507
|
/** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
|
|
4024
4508
|
const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
|
|
@@ -4057,7 +4541,9 @@ const block = (reason, action) => ({
|
|
|
4057
4541
|
function uiDesignSkillGate(tool, filePath, content, ev) {
|
|
4058
4542
|
if (!isUiWrite(tool, filePath, content)) return null;
|
|
4059
4543
|
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
|
-
|
|
4544
|
+
const missing = missingDomainSkills(content, ev.refsRead);
|
|
4545
|
+
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`);
|
|
4546
|
+
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
4547
|
return null;
|
|
4062
4548
|
}
|
|
4063
4549
|
//#endregion
|
|
@@ -4145,17 +4631,6 @@ const FORBIDDEN_FONTS = [
|
|
|
4145
4631
|
"Open Sans"
|
|
4146
4632
|
];
|
|
4147
4633
|
const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
|
|
4148
|
-
const KNOWN_DOMAINS = [
|
|
4149
|
-
"framer.website",
|
|
4150
|
-
"webflow.io",
|
|
4151
|
-
"awwwards.com",
|
|
4152
|
-
"godly.website",
|
|
4153
|
-
"lapa.ninja",
|
|
4154
|
-
"onepagelove.com",
|
|
4155
|
-
"saasframe.io",
|
|
4156
|
-
"bestwebsite.gallery",
|
|
4157
|
-
"landingfolio.com"
|
|
4158
|
-
];
|
|
4159
4634
|
const deny = (reason) => ({
|
|
4160
4635
|
kind: "block",
|
|
4161
4636
|
title: "Design pipeline",
|
|
@@ -4165,18 +4640,29 @@ const deny = (reason) => ({
|
|
|
4165
4640
|
/** Block the design agent from writing anything but .html/.css/.md/.json. */
|
|
4166
4641
|
function htmlCssOnlyGate(filePath) {
|
|
4167
4642
|
if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
|
|
4168
|
-
return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files.");
|
|
4643
|
+
return deny("BLOCKED: design-expert can only write .html, .css, .md, and .json files. Framework files (.tsx, .astro, .vue, .swift, .php) must be written by the domain expert (astro-expert, react-expert, etc.) AFTER design validation.");
|
|
4169
4644
|
}
|
|
4170
4645
|
/** Block edits to the harness-managed `.design-state-*` files (read-only to the agent). */
|
|
4171
4646
|
function stateFileGate(filePath) {
|
|
4172
|
-
return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are
|
|
4647
|
+
return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are READ-ONLY for you. Hooks update them automatically as you progress. Do NOT try to modify them — it will not unblock you. Follow the pipeline: Phase 0→1→2→3→4→5→6 in order.") : null;
|
|
4173
4648
|
}
|
|
4174
|
-
/**
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4649
|
+
/**
|
|
4650
|
+
* Ports browser_helpers.is_exempt: design-system.md is NOT exempt (it must meet
|
|
4651
|
+
* the quota); other `.md` files and vendored dirs ARE exempt from the check.
|
|
4652
|
+
*/
|
|
4653
|
+
function screenshotExempt(filePath) {
|
|
4654
|
+
if (filePath.endsWith("design-system.md")) return false;
|
|
4655
|
+
return filePath.endsWith(".md") || EXEMPT_DIRS.some((d) => filePath.includes(d));
|
|
4656
|
+
}
|
|
4657
|
+
/**
|
|
4658
|
+
* Block ANY non-exempt write (html/css/design-system.md) until the per-mode
|
|
4659
|
+
* screenshot quota is met. Ports check-browser-browsing.py's "Browse N sites
|
|
4660
|
+
* BEFORE writing" deny for the active design agent.
|
|
4661
|
+
*/
|
|
4662
|
+
function preScreenshotWriteGate(filePath, state) {
|
|
4663
|
+
if (screenshotExempt(filePath)) return null;
|
|
4178
4664
|
const needed = MIN_SCREENSHOTS[state.mode];
|
|
4179
|
-
if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'.
|
|
4665
|
+
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.`);
|
|
4180
4666
|
return null;
|
|
4181
4667
|
}
|
|
4182
4668
|
/** Return the requirements missing from a design-system.md (empty = valid). */
|
|
@@ -4188,19 +4674,6 @@ function validateDesignSystem(content) {
|
|
|
4188
4674
|
if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
|
|
4189
4675
|
return missing;
|
|
4190
4676
|
}
|
|
4191
|
-
/** Gate Gemini create_frontend: requires phase ≥ 3 and a validated design system. */
|
|
4192
|
-
function geminiCreateGate(state) {
|
|
4193
|
-
if (state.currentPhase < 3) return deny("BLOCKED: cannot call create_frontend before phase 3. Finish screenshots and write a valid design-system.md.");
|
|
4194
|
-
if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated (needs ## Design Reference, OKLCH, typography, reference URL).");
|
|
4195
|
-
return null;
|
|
4196
|
-
}
|
|
4197
|
-
/** Gate fuse-browser navigate: phase ≥ 1, inspiration read, URL in the catalog. */
|
|
4198
|
-
function browserNavigateGate(state, url) {
|
|
4199
|
-
if (state.currentPhase < 1) return deny("BLOCKED: read identity templates + design-inspiration.md before browsing.");
|
|
4200
|
-
if (!state.inspirationRead) return deny("BLOCKED: design-inspiration.md not read yet — read it then pick catalog URLs.");
|
|
4201
|
-
if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' is not in the catalog. Use design-inspiration-urls.md domains.`);
|
|
4202
|
-
return null;
|
|
4203
|
-
}
|
|
4204
4677
|
/** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
|
|
4205
4678
|
function screenshotScrollGate(state) {
|
|
4206
4679
|
return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
|
|
@@ -4211,6 +4684,41 @@ function geminiEnabled() {
|
|
|
4211
4684
|
return v === "1" || v === "true";
|
|
4212
4685
|
}
|
|
4213
4686
|
//#endregion
|
|
4687
|
+
//#region src/policy/design/gates-pipeline.ts
|
|
4688
|
+
const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;
|
|
4689
|
+
const KNOWN_DOMAINS = [
|
|
4690
|
+
"framer.website",
|
|
4691
|
+
"webflow.io",
|
|
4692
|
+
"awwwards.com",
|
|
4693
|
+
"godly.website",
|
|
4694
|
+
"lapa.ninja",
|
|
4695
|
+
"onepagelove.com",
|
|
4696
|
+
"saasframe.io",
|
|
4697
|
+
"bestwebsite.gallery",
|
|
4698
|
+
"landingfolio.com"
|
|
4699
|
+
];
|
|
4700
|
+
/** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */
|
|
4701
|
+
function designSystemWriteGate(filePath, state) {
|
|
4702
|
+
if (!filePath.endsWith("design-system.md")) return null;
|
|
4703
|
+
if (state.currentPhase < 2) return deny(`BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. RECOVERY: 1) Read identity templates from skills/0-identity-system/ 2) Read design-inspiration.md 3) Browse and screenshot sites 4) Then write design-system.md`);
|
|
4704
|
+
const needed = MIN_SCREENSHOTS[state.mode];
|
|
4705
|
+
if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. RECOVERY: 1) Take ${needed - state.screenshotsCount} more fuse-browser screenshots 2) Use browser_open + browser_navigate + browser_screenshot fullPage:true 3) Then write design-system.md`);
|
|
4706
|
+
return null;
|
|
4707
|
+
}
|
|
4708
|
+
/** Gate Gemini create_frontend: requires phase >= 3 and a validated design system. */
|
|
4709
|
+
function geminiCreateGate(state) {
|
|
4710
|
+
if (state.currentPhase < 3) return deny("BLOCKED: Cannot call Gemini create_frontend before phase 3. RECOVERY: 1) Complete screenshot browsing phase 2) Write a valid design-system.md 3) Then call mcp__gemini-design__create_frontend");
|
|
4711
|
+
if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated. RECOVERY: 1) Ensure design-system.md has ## Design Reference, OKLCH tokens, typography pair, reference URL 2) Then retry mcp__gemini-design__create_frontend");
|
|
4712
|
+
return null;
|
|
4713
|
+
}
|
|
4714
|
+
/** Gate fuse-browser navigate: phase >= 1, inspiration read, URL in the catalog. */
|
|
4715
|
+
function browserNavigateGate(state, url) {
|
|
4716
|
+
if (state.currentPhase < 1) return deny(`BLOCKED: Phase 0 not done. READ: ${SKILLS}/0-identity-system/SKILL.md first.`);
|
|
4717
|
+
if (!state.inspirationRead) return deny(`BLOCKED: Read inspiration catalog first. READ: ${SKILLS}/1-designing-systems/references/design-inspiration.md + design-inspiration-urls.md`);
|
|
4718
|
+
if (url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' not in catalog. Use URLs from design-inspiration-urls.md. Domains: ${KNOWN_DOMAINS.join(", ")}`);
|
|
4719
|
+
return null;
|
|
4720
|
+
}
|
|
4721
|
+
//#endregion
|
|
4214
4722
|
//#region src/runtime/design.ts
|
|
4215
4723
|
const NAV = "mcp__fuse-browser__browser_navigate";
|
|
4216
4724
|
const SHOT = "mcp__fuse-browser__browser_screenshot";
|
|
@@ -4225,10 +4733,10 @@ function designGate(payload, event, cacheDir, cwd) {
|
|
|
4225
4733
|
if (skillBlock) return skillBlock;
|
|
4226
4734
|
}
|
|
4227
4735
|
const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
|
|
4736
|
+
if (!agentId) return null;
|
|
4228
4737
|
const active = activeDesignAgent(cacheDir);
|
|
4229
|
-
if (active && agentId
|
|
4230
|
-
const id =
|
|
4231
|
-
if (!id) return null;
|
|
4738
|
+
if (active && agentId !== active) return null;
|
|
4739
|
+
const id = agentId;
|
|
4232
4740
|
let state = loadDesignState(cacheDir, id);
|
|
4233
4741
|
if (!state) {
|
|
4234
4742
|
if (!active) return null;
|
|
@@ -4251,7 +4759,7 @@ function designGate(payload, event, cacheDir, cwd) {
|
|
|
4251
4759
|
}
|
|
4252
4760
|
if (event.tool === "Write" || event.tool === "Edit") {
|
|
4253
4761
|
const fp = event.filePath ?? "";
|
|
4254
|
-
const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
|
|
4762
|
+
const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? preScreenshotWriteGate(fp, state) ?? designSystemWriteGate(fp, state);
|
|
4255
4763
|
if (base) return base;
|
|
4256
4764
|
if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
|
|
4257
4765
|
kind: "block",
|
|
@@ -4311,6 +4819,13 @@ async function handlePre(ctx) {
|
|
|
4311
4819
|
stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
|
|
4312
4820
|
exit: 0
|
|
4313
4821
|
};
|
|
4822
|
+
if (opts.scope === "solid" && event.filePath) {
|
|
4823
|
+
const solidDeny = validateSolidGate(event.tool, event.filePath, event.content ?? "");
|
|
4824
|
+
if (solidDeny) return {
|
|
4825
|
+
stdout: solidDeny,
|
|
4826
|
+
exit: 0
|
|
4827
|
+
};
|
|
4828
|
+
}
|
|
4314
4829
|
if (event.tool === "Task") {
|
|
4315
4830
|
const taskCtx = taskContext(opts.cwd);
|
|
4316
4831
|
if (taskCtx) return {
|
|
@@ -4343,82 +4858,6 @@ async function handlePre(ctx) {
|
|
|
4343
4858
|
};
|
|
4344
4859
|
}
|
|
4345
4860
|
//#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
4861
|
//#region src/runtime/activity.ts
|
|
4423
4862
|
/** Min response length (chars) for a lead agent call to count as `sufficient`. */
|
|
4424
4863
|
const AGENT_QUALITY_MIN = 500;
|
|
@@ -4460,6 +4899,7 @@ function docSourceOf(tool) {
|
|
|
4460
4899
|
if (/browser_(fetch|crawl|serp)/i.test(tool)) return "fuse-browser";
|
|
4461
4900
|
if (tool === "WebSearch") return "websearch";
|
|
4462
4901
|
if (tool === "WebFetch") return "webfetch";
|
|
4902
|
+
if (/^mcp__shadcn__/i.test(tool)) return "shadcn-mcp";
|
|
4463
4903
|
}
|
|
4464
4904
|
/**
|
|
4465
4905
|
* Map a live tool-use to the activity to record, or null when nothing is
|
|
@@ -4526,14 +4966,28 @@ async function handlePost(ctx) {
|
|
|
4526
4966
|
stdout: seoDeny,
|
|
4527
4967
|
exit: 0
|
|
4528
4968
|
};
|
|
4529
|
-
if (opts.scope === "
|
|
4969
|
+
if (opts.scope === "solid" && event.filePath) {
|
|
4970
|
+
const solidWarn = checkFileSize(event.tool, event.filePath);
|
|
4971
|
+
if (solidWarn) return {
|
|
4972
|
+
stdout: solidWarn,
|
|
4973
|
+
exit: 0
|
|
4974
|
+
};
|
|
4975
|
+
}
|
|
4976
|
+
if (opts.scope === "tailwindcss" && event.filePath) {
|
|
4977
|
+
const tailwindWarn = validateTailwind(event.tool, event.filePath);
|
|
4978
|
+
if (tailwindWarn) return {
|
|
4979
|
+
stdout: tailwindWarn,
|
|
4980
|
+
exit: 0
|
|
4981
|
+
};
|
|
4982
|
+
}
|
|
4983
|
+
if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate" || event.tool === "Write" || event.tool === "Edit")) {
|
|
4530
4984
|
const out = await aipilotPostToolUse(payload, opts.cwd);
|
|
4531
4985
|
if (out) return {
|
|
4532
4986
|
stdout: out,
|
|
4533
4987
|
exit: 0
|
|
4534
4988
|
};
|
|
4535
4989
|
}
|
|
4536
|
-
const extra = postEditContext(opts.scope ?? "core", event, opts.now);
|
|
4990
|
+
const extra = await postEditContext(opts.scope ?? "core", event, opts.now);
|
|
4537
4991
|
return {
|
|
4538
4992
|
stdout: designWarn ? respond(id, designWarn) : extra,
|
|
4539
4993
|
exit: 0
|
|
@@ -4578,7 +5032,7 @@ async function handleHook(id, payload, opts) {
|
|
|
4578
5032
|
const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
|
|
4579
5033
|
const mcpDir = layout.cacheDir;
|
|
4580
5034
|
const framework = detectFramework(event.filePath ?? "", event.content ?? "");
|
|
4581
|
-
if (designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
|
|
5035
|
+
if (id === "claude-code" && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
|
|
4582
5036
|
stdout: "",
|
|
4583
5037
|
exit: 0
|
|
4584
5038
|
};
|
|
@@ -4620,4 +5074,4 @@ async function handleHook(id, payload, opts) {
|
|
|
4620
5074
|
});
|
|
4621
5075
|
}
|
|
4622
5076
|
//#endregion
|
|
4623
|
-
export { saveApexState as $, trackSkillRead as A, writePluginMap as B, seoPostToolUse as C,
|
|
5077
|
+
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 };
|