@fusengine/harness 0.1.42 → 0.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
- package/dist/cli/bin.mjs +233 -11
- 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-CtMMVoxT.mjs} +789 -357
- package/dist/home-state-mKZxP4oZ.mjs +52 -0
- package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -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 +5 -5
- package/dist/index.mjs +7 -8
- package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
- 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-Ca7NSp-r.mjs} +495 -83
- 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 skillTriggerGate, E as frameworkSolidGate, H as requiredArchSkill, L as detectFramework, N as docConsultedGate, P as evaluateApex, _ as scanPlugin, b as findMarketplacePlugins, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, x as readPluginMeta, y as parseField, z as detectModularArchitecture } from "./validate-Ca7NSp-r.mjs";
|
|
5
|
+
import { F as countFrameworkCodeLines, I as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-d7Pp8XJH.mjs";
|
|
10
6
|
import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
|
|
7
|
+
import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-mKZxP4oZ.mjs";
|
|
8
|
+
import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
|
|
9
|
+
import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
|
|
11
10
|
import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-CymilZiZ.mjs";
|
|
12
11
|
import { c as extractText, i as cacheLookupSubstring, l as loadIndex, n as webfetchCacheWrite, r as cacheLookup, t as mcpCacheWrite } from "./mcp-store-CnsW9oFj.mjs";
|
|
13
|
-
import { t as loadRefs } from "./loader-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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,28 @@ 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
|
+
const files = dup.duplicates.slice(0, 3).join(", ");
|
|
3713
|
+
if (dup.duplicates.length === 1) return {
|
|
3714
|
+
kind: "inform",
|
|
3715
|
+
title: "Possible duplicate code (DRY)",
|
|
3716
|
+
reason: `[${names}] already declared in: ${files}. Consider importing and reusing instead of re-declaring.`,
|
|
3717
|
+
actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
|
|
3718
|
+
};
|
|
3494
3719
|
return {
|
|
3495
3720
|
kind: "block",
|
|
3496
3721
|
title: "Duplicate code (DRY)",
|
|
3497
|
-
reason: `[${
|
|
3722
|
+
reason: `[${names}] already declared in: ${files}. Import and reuse instead of re-declaring.`,
|
|
3498
3723
|
actions: ["Import the existing symbol instead of re-declaring it", "Extend the existing module"]
|
|
3499
3724
|
};
|
|
3500
3725
|
}
|
|
@@ -3649,17 +3874,22 @@ function modularGate(tool, filePath, content, cwd) {
|
|
|
3649
3874
|
//#region src/runtime/framework-skill-gate.ts
|
|
3650
3875
|
/**
|
|
3651
3876
|
* Effective line count for the SOLID size check. On an Edit, `content` is only
|
|
3652
|
-
* the `new_string` snippet, so
|
|
3653
|
-
*
|
|
3654
|
-
*
|
|
3655
|
-
*
|
|
3877
|
+
* the `new_string` snippet, so for Next.js — mirroring the base file-size guard
|
|
3878
|
+
* and the Python `get_full_file_content` — judge the larger of the snippet and
|
|
3879
|
+
* the full on-disk file (`existingCodeLines`). Other frameworks (react/laravel/
|
|
3880
|
+
* swift) judge the edited snippet alone, never the on-disk full-file max — their
|
|
3881
|
+
* Python source never imports `get_full_file_content`, only nextjs does. On
|
|
3882
|
+
* Write, `content` IS the full file, so the snippet count stands regardless of
|
|
3883
|
+
* framework (undefined → the gate falls back to `countFrameworkCodeLines`).
|
|
3656
3884
|
* @param tool - the tool name ("Edit" | "Write" | ...).
|
|
3885
|
+
* @param framework - the detected framework; only "nextjs" applies the full-file max.
|
|
3657
3886
|
* @param content - the written content (snippet on Edit, full file on Write).
|
|
3658
|
-
* @param
|
|
3887
|
+
* @param existingCodeLines - full on-disk code-only line count, when known.
|
|
3659
3888
|
*/
|
|
3660
|
-
function effectiveLines(tool, content,
|
|
3661
|
-
if (tool !== "Edit" ||
|
|
3662
|
-
return
|
|
3889
|
+
function effectiveLines(tool, framework, content, existingCodeLines) {
|
|
3890
|
+
if (tool !== "Edit" || existingCodeLines === void 0) return void 0;
|
|
3891
|
+
if (framework !== "nextjs") return countFrameworkCodeLines(content);
|
|
3892
|
+
return Math.max(countFrameworkCodeLines(content), existingCodeLines);
|
|
3663
3893
|
}
|
|
3664
3894
|
/**
|
|
3665
3895
|
* Framework-aware SOLID + sub-skill gate, run on the Write/Edit path once a
|
|
@@ -3672,17 +3902,149 @@ function effectiveLines(tool, content, existingLines) {
|
|
|
3672
3902
|
*
|
|
3673
3903
|
* @param input - the gated tool-use (filePath + content + framework + cwd).
|
|
3674
3904
|
* @param refsRead - in-session read reference paths (from the loaded track).
|
|
3675
|
-
* @param
|
|
3676
|
-
* file still fires the framework SOLID size rule). Omit on Write.
|
|
3905
|
+
* @param existingCodeLines - full on-disk code-only line count (so an Edit on
|
|
3906
|
+
* an oversized file still fires the framework SOLID size rule). Omit on Write.
|
|
3677
3907
|
* @returns the first blocking {@link Prompt}, or `null` to allow.
|
|
3678
3908
|
*/
|
|
3679
|
-
function frameworkSkillGate(input, refsRead,
|
|
3909
|
+
function frameworkSkillGate(input, refsRead, existingCodeLines) {
|
|
3680
3910
|
if (!input.filePath) return null;
|
|
3681
3911
|
const content = input.content ?? "";
|
|
3682
|
-
const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, content,
|
|
3912
|
+
const solid = frameworkSolidGate(input.filePath, content, effectiveLines(input.tool, input.framework, content, existingCodeLines));
|
|
3683
3913
|
if (solid) return solid;
|
|
3684
3914
|
const forced = input.cwd ? requiredArchSkill(input.cwd) : null;
|
|
3685
|
-
return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd);
|
|
3915
|
+
return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd, input.filePath);
|
|
3916
|
+
}
|
|
3917
|
+
//#endregion
|
|
3918
|
+
//#region src/policy/shadcn-skill-gate.ts
|
|
3919
|
+
/** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
|
|
3920
|
+
const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
|
|
3921
|
+
/**
|
|
3922
|
+
* Vendored/build paths exempt from the gate. Uses `(^|\/)` (not just a
|
|
3923
|
+
* leading `/`) so a relative root-start path like `node_modules/x` is also
|
|
3924
|
+
* exempt — mirrors the same deliberate widening already applied in
|
|
3925
|
+
* `design/skill-gate.ts`'s `EXEMPT_RE`, not the Python's stricter `/x/`-only match.
|
|
3926
|
+
*/
|
|
3927
|
+
const EXEMPT_RE$1 = /(^|\/)(node_modules|dist|build)\//;
|
|
3928
|
+
/** Path fragments that put a write in shadcn scope (source: `(components|ui|shadcn|components\.json)`). */
|
|
3929
|
+
const SHADCN_PATH_RE = /(components|ui|shadcn|components\.json)/;
|
|
3930
|
+
/** A read reference proving one of the two named base skills was consulted (Phase 1). */
|
|
3931
|
+
const SHADCN_BASE_SKILL_RE = /skills\/(shadcn-detection|shadcn-components)\//;
|
|
3932
|
+
/**
|
|
3933
|
+
* True when this Write/Edit is shadcn-scoped: a `.tsx/.jsx/.css/.scss/.json`
|
|
3934
|
+
* file under a `components|ui|shadcn|components.json` path, outside vendored
|
|
3935
|
+
* dirs. Ports `check-skill-loaded.py`'s file-path filter (lines 35-42).
|
|
3936
|
+
*/
|
|
3937
|
+
function isShadcnWrite(tool, filePath) {
|
|
3938
|
+
if (tool !== "Write" && tool !== "Edit") return false;
|
|
3939
|
+
if (!SHADCN_FILE_RE.test(filePath) || EXEMPT_RE$1.test(filePath)) return false;
|
|
3940
|
+
return SHADCN_PATH_RE.test(filePath);
|
|
3941
|
+
}
|
|
3942
|
+
/** True when `shadcn-detection/` or `shadcn-components/` was read this session (Phase 1 base skill). */
|
|
3943
|
+
function shadcnBaseSkillRead(refsRead) {
|
|
3944
|
+
return refsRead.some((p) => SHADCN_BASE_SKILL_RE.test(p));
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* Gate a shadcn-scoped write. Phase 1 requires a base skill read
|
|
3948
|
+
* (shadcn-detection or shadcn-components); Phase 2 requires the domain
|
|
3949
|
+
* sub-skill(s) matching the written content, via {@link skillTriggerGate}
|
|
3950
|
+
* keyed on the "shadcn" framework (registered in `SKILL_TRIGGERS`); Phase 3
|
|
3951
|
+
* requires doc research, delegated to the existing {@link docConsultedGate}
|
|
3952
|
+
* (Context7 AND Exa, or a web fallback alone — not the Python's strict AND).
|
|
3953
|
+
* Returns a blocking {@link Prompt}, or `null` to allow.
|
|
3954
|
+
*/
|
|
3955
|
+
function shadcnSkillGate(tool, filePath, content, ev) {
|
|
3956
|
+
if (!isShadcnWrite(tool, filePath)) return null;
|
|
3957
|
+
if (!shadcnBaseSkillRead(ev.refsRead)) return {
|
|
3958
|
+
kind: "block",
|
|
3959
|
+
title: "shadcn/ui skill",
|
|
3960
|
+
reason: "BLOCKED: shadcn skill not consulted. Read shadcn-detection/SKILL.md or shadcn-components/SKILL.md, then retry.",
|
|
3961
|
+
actions: ["Read skills/shadcn-detection/ or skills/shadcn-components/, then retry"]
|
|
3962
|
+
};
|
|
3963
|
+
const domainBlock = skillTriggerGate("shadcn", content, ev.refsRead);
|
|
3964
|
+
if (domainBlock) return domainBlock;
|
|
3965
|
+
return docConsultedGate({
|
|
3966
|
+
sessionId: ev.sessionId,
|
|
3967
|
+
framework: "shadcn",
|
|
3968
|
+
filePath,
|
|
3969
|
+
content,
|
|
3970
|
+
authorizations: ev.authorizations
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
3973
|
+
//#endregion
|
|
3974
|
+
//#region src/freshness/explore-tools.ts
|
|
3975
|
+
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
3976
|
+
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
3977
|
+
/** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
|
|
3978
|
+
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
3979
|
+
"mcp__context7__query-docs",
|
|
3980
|
+
"mcp__context7__resolve-library-id",
|
|
3981
|
+
"mcp__exa__web_search_exa",
|
|
3982
|
+
"mcp__exa__get_code_context_exa",
|
|
3983
|
+
"mcp__exa__deep_researcher_start",
|
|
3984
|
+
"WebSearch",
|
|
3985
|
+
"WebFetch"
|
|
3986
|
+
]);
|
|
3987
|
+
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
3988
|
+
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
3989
|
+
"grep",
|
|
3990
|
+
"rg",
|
|
3991
|
+
"find",
|
|
3992
|
+
"ls",
|
|
3993
|
+
"fd",
|
|
3994
|
+
"ast-grep",
|
|
3995
|
+
"tree",
|
|
3996
|
+
"cat",
|
|
3997
|
+
"head",
|
|
3998
|
+
"tail"
|
|
3999
|
+
]);
|
|
4000
|
+
/** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
|
|
4001
|
+
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
4002
|
+
/**
|
|
4003
|
+
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
4004
|
+
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
4005
|
+
* @param cmd - Raw Bash `command` string.
|
|
4006
|
+
* @returns The executable basename, or "" when none.
|
|
4007
|
+
*/
|
|
4008
|
+
function bashExecutable(cmd) {
|
|
4009
|
+
for (const token of cmd.trim().split(/\s+/)) {
|
|
4010
|
+
if (!token) continue;
|
|
4011
|
+
const last = token.split("/").pop() ?? token;
|
|
4012
|
+
if (!last.includes("=")) return last;
|
|
4013
|
+
}
|
|
4014
|
+
return "";
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* Classify a direct tool use into an APEX phase, or `null` when it is neither
|
|
4018
|
+
* exploration nor research. Mirrors Python `track-subagent-research._classify`.
|
|
4019
|
+
* @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
|
|
4020
|
+
* @param input - Tool input payload.
|
|
4021
|
+
* @returns The credited phase + cache flag, or `null`.
|
|
4022
|
+
*/
|
|
4023
|
+
function classifyExplore(tool, input) {
|
|
4024
|
+
if (RESEARCH_TOOLS.has(tool)) return {
|
|
4025
|
+
phase: "research-expert",
|
|
4026
|
+
cacheHit: false
|
|
4027
|
+
};
|
|
4028
|
+
if (EXPLORE_TOOLS.has(tool)) return {
|
|
4029
|
+
phase: "explore-codebase",
|
|
4030
|
+
cacheHit: false
|
|
4031
|
+
};
|
|
4032
|
+
if (tool === "Read") {
|
|
4033
|
+
const path = String(input?.file_path ?? input?.path ?? "");
|
|
4034
|
+
if (path && CACHE_READ_RE.test(path)) return {
|
|
4035
|
+
phase: "research-expert",
|
|
4036
|
+
cacheHit: true
|
|
4037
|
+
};
|
|
4038
|
+
return null;
|
|
4039
|
+
}
|
|
4040
|
+
if (tool === "Bash") {
|
|
4041
|
+
const cmd = String(input?.command ?? "").trim();
|
|
4042
|
+
if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
|
|
4043
|
+
phase: "explore-codebase",
|
|
4044
|
+
cacheHit: false
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
4047
|
+
return null;
|
|
3686
4048
|
}
|
|
3687
4049
|
//#endregion
|
|
3688
4050
|
//#region src/freshness/agent-evidence.ts
|
|
@@ -3701,9 +4063,13 @@ function parseTs(raw) {
|
|
|
3701
4063
|
}
|
|
3702
4064
|
/**
|
|
3703
4065
|
* Return `true` ONLY when, for EVERY name in `names`, the Claude Code
|
|
3704
|
-
* transcript at `transcriptPath` contains a genuine `tool_use` of the `Task
|
|
3705
|
-
* `Agent` tool whose `subagent_type` (or `name`)
|
|
3706
|
-
*
|
|
4066
|
+
* transcript at `transcriptPath` contains a genuine `tool_use` of the `Task`/
|
|
4067
|
+
* `Agent` tool whose `subagent_type` (or `name`) matches — after stripping any
|
|
4068
|
+
* plugin prefix — OR a direct exploration/research tool_use (Glob/Grep, an
|
|
4069
|
+
* explore Bash command, mcp__context7/mcp__exa, WebSearch/WebFetch) classified
|
|
4070
|
+
* via {@link classifyExplore} into that name, issued by ANY sub-agent or the
|
|
4071
|
+
* lead within this same transcript — with the entry timestamp within
|
|
4072
|
+
* `windowMs` of `now`.
|
|
3707
4073
|
*
|
|
3708
4074
|
* **Timestamp note:** when a transcript entry carries no `timestamp` field it
|
|
3709
4075
|
* is counted as within-window (we cannot prove staleness). This is
|
|
@@ -3741,10 +4107,15 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
3741
4107
|
const content = entry.message?.content;
|
|
3742
4108
|
if (!Array.isArray(content)) continue;
|
|
3743
4109
|
for (const block of content) {
|
|
3744
|
-
if (block?.type !== "tool_use"
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
4110
|
+
if (block?.type !== "tool_use") continue;
|
|
4111
|
+
if (block.name === "Task" || block.name === "Agent") {
|
|
4112
|
+
const raw = block.input?.subagent_type ?? block.input?.name;
|
|
4113
|
+
const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
|
|
4114
|
+
if (agent !== void 0 && names.includes(agent)) found.add(agent);
|
|
4115
|
+
continue;
|
|
4116
|
+
}
|
|
4117
|
+
const hit = classifyExplore(block.name ?? "", block.input);
|
|
4118
|
+
if (hit && names.includes(hit.phase)) found.add(hit.phase);
|
|
3748
4119
|
}
|
|
3749
4120
|
if (found.size === names.length) return true;
|
|
3750
4121
|
}
|
|
@@ -3765,7 +4136,7 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
3765
4136
|
*/
|
|
3766
4137
|
async function apexScopedGate(input, track, window) {
|
|
3767
4138
|
const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
|
|
3768
|
-
if (!input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
|
|
4139
|
+
if (input.tool === "Edit" && !input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
|
|
3769
4140
|
await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
|
|
3770
4141
|
return null;
|
|
3771
4142
|
}
|
|
@@ -3779,7 +4150,9 @@ async function apexScopedGate(input, track, window) {
|
|
|
3779
4150
|
refs: input.refs,
|
|
3780
4151
|
refsRead: track.refsRead,
|
|
3781
4152
|
agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
|
|
3782
|
-
|
|
4153
|
+
missingAgents: REQUIRED_AGENTS.filter((name) => !freshnessFor([name])),
|
|
4154
|
+
windowMs: window,
|
|
4155
|
+
brainstormRequired: input.tool === "Edit" ? false : track.brainstormRequired,
|
|
3783
4156
|
brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
|
|
3784
4157
|
};
|
|
3785
4158
|
try {
|
|
@@ -3792,7 +4165,12 @@ async function apexScopedGate(input, track, window) {
|
|
|
3792
4165
|
//#region src/runtime/gate.ts
|
|
3793
4166
|
/** Prior agents the freshness gate requires before a code edit. */
|
|
3794
4167
|
const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
|
|
3795
|
-
/**
|
|
4168
|
+
/**
|
|
4169
|
+
* Default freshness window for {@link REQUIRED_AGENTS}, in ms. Matches the
|
|
4170
|
+
* plugin's `FUSE_ENFORCE_TTL_SEC` default (120s). Only a fallback for direct
|
|
4171
|
+
* programmatic callers that omit `windowMs` (e.g. tests) — the real CLI path
|
|
4172
|
+
* always supplies `windowMs` from `resolveTtlSec()` (`src/config/ttl.ts`).
|
|
4173
|
+
*/
|
|
3796
4174
|
const DEFAULT_WINDOW_MS = 12e4;
|
|
3797
4175
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
3798
4176
|
const TRIVIAL_BUDGET = 4;
|
|
@@ -3804,7 +4182,7 @@ const TRIVIAL_BUDGET = 4;
|
|
|
3804
4182
|
async function gate(input) {
|
|
3805
4183
|
const precommit = preCommitGate(input.tool, input.command, input.cwd);
|
|
3806
4184
|
if (precommit) return precommit;
|
|
3807
|
-
const existingLines =
|
|
4185
|
+
const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
|
|
3808
4186
|
let quick;
|
|
3809
4187
|
try {
|
|
3810
4188
|
quick = evaluate({
|
|
@@ -3822,10 +4200,19 @@ async function gate(input) {
|
|
|
3822
4200
|
const modular = modularGate(input.tool, input.filePath, input.content, input.cwd);
|
|
3823
4201
|
if (modular) return modular;
|
|
3824
4202
|
if (!input.filePath) return null;
|
|
4203
|
+
const filePath = input.filePath;
|
|
3825
4204
|
const window = input.windowMs ?? 12e4;
|
|
3826
4205
|
const track = await loadTrack(input.trackFile);
|
|
3827
|
-
const solidOrSkill = frameworkSkillGate(input, track.refsRead,
|
|
4206
|
+
const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
|
|
3828
4207
|
if (solidOrSkill) return solidOrSkill;
|
|
4208
|
+
if (isShadcnWrite(input.tool, filePath)) {
|
|
4209
|
+
const shadcnBlock = shadcnSkillGate(input.tool, filePath, input.content ?? "", {
|
|
4210
|
+
refsRead: track.refsRead,
|
|
4211
|
+
authorizations: track.authorizations,
|
|
4212
|
+
sessionId: input.sessionId
|
|
4213
|
+
});
|
|
4214
|
+
if (shadcnBlock) return shadcnBlock;
|
|
4215
|
+
}
|
|
3829
4216
|
if (isApexScoped(input.filePath)) {
|
|
3830
4217
|
const apex = await apexScopedGate(input, track, window);
|
|
3831
4218
|
if (apex) return apex;
|
|
@@ -3943,31 +4330,51 @@ async function recordActivity(file, activity) {
|
|
|
3943
4330
|
//#endregion
|
|
3944
4331
|
//#region src/runtime/respond.ts
|
|
3945
4332
|
/**
|
|
3946
|
-
* Map a portable {@link Prompt} to a harness's native hook response
|
|
3947
|
-
*
|
|
3948
|
-
* `
|
|
4333
|
+
* Map a portable {@link Prompt} to a harness's native hook response, honoring
|
|
4334
|
+
* all 3 prompt kinds (`block`/`ask`/`inform`) instead of collapsing `ask` and
|
|
4335
|
+
* `inform` together. Reuses the Claude adapter's `denyResponse`/
|
|
4336
|
+
* `contextResponse` builders for claude-code/codex (same hook shape) to avoid
|
|
4337
|
+
* duplicating the JSON.
|
|
4338
|
+
* - claude-code/codex: `block` -> deny, `ask` -> interactive
|
|
4339
|
+
* `permissionDecision:"ask"`, `inform` -> non-blocking `additionalContext`.
|
|
4340
|
+
* - gemini-cli/cline: their real hook schemas have no interactive "ask"
|
|
4341
|
+
* state (deny is the only blocking outcome), so `ask` and `inform` already
|
|
4342
|
+
* both resolve to non-blocking context injection — unchanged.
|
|
4343
|
+
* - cursor: `ask` keeps its current best-effort `permission:"ask"` shape
|
|
4344
|
+
* (unverified against Cursor's exact hook docs); only `inform` is fixed to
|
|
4345
|
+
* a non-blocking `permission:"allow"` note, since it was wrongly conflated
|
|
4346
|
+
* with `ask` before.
|
|
3949
4347
|
*/
|
|
3950
4348
|
function respond(id, prompt) {
|
|
3951
4349
|
const message = formatPrompt(prompt);
|
|
3952
|
-
const
|
|
4350
|
+
const { kind } = prompt;
|
|
3953
4351
|
switch (id) {
|
|
3954
4352
|
case "claude-code":
|
|
3955
|
-
case "codex":
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
4353
|
+
case "codex":
|
|
4354
|
+
if (kind === "block") return denyResponse("PreToolUse", message);
|
|
4355
|
+
if (kind === "inform") return contextResponse("PreToolUse", message);
|
|
4356
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
4357
|
+
hookEventName: "PreToolUse",
|
|
4358
|
+
permissionDecision: "ask",
|
|
4359
|
+
permissionDecisionReason: message
|
|
4360
|
+
} });
|
|
4361
|
+
case "gemini-cli": return JSON.stringify(kind === "block" ? {
|
|
3961
4362
|
decision: "deny",
|
|
3962
4363
|
reason: message
|
|
3963
4364
|
} : { hookSpecificOutput: { additionalContext: message } });
|
|
3964
|
-
case "cursor":
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
4365
|
+
case "cursor":
|
|
4366
|
+
if (kind === "inform") return JSON.stringify({
|
|
4367
|
+
permission: "allow",
|
|
4368
|
+
userMessage: message,
|
|
4369
|
+
agentMessage: message
|
|
4370
|
+
});
|
|
4371
|
+
return JSON.stringify({
|
|
4372
|
+
permission: kind === "block" ? "deny" : "ask",
|
|
4373
|
+
continue: false,
|
|
4374
|
+
userMessage: message,
|
|
4375
|
+
agentMessage: message
|
|
4376
|
+
});
|
|
4377
|
+
case "cline": return JSON.stringify(kind === "block" ? {
|
|
3971
4378
|
cancel: true,
|
|
3972
4379
|
errorMessage: message
|
|
3973
4380
|
} : { contextModification: message });
|
|
@@ -4019,6 +4426,65 @@ function runDesignChecks(content) {
|
|
|
4019
4426
|
];
|
|
4020
4427
|
}
|
|
4021
4428
|
//#endregion
|
|
4429
|
+
//#region src/policy/design/skill-triggers.ts
|
|
4430
|
+
/** Domain skill triggers (ported 1:1 from `design_skill_triggers.py`). */
|
|
4431
|
+
const SKILL_TRIGGERS = [
|
|
4432
|
+
{
|
|
4433
|
+
skill: "3-generating-components",
|
|
4434
|
+
readFragment: "generating-components",
|
|
4435
|
+
patterns: [
|
|
4436
|
+
/className\s*=\s*["{]/i,
|
|
4437
|
+
/<(div|section|main|header|footer|nav|aside)\s/i,
|
|
4438
|
+
/flex|grid|gap-|p-|m-|bg-|text-|rounded|shadow|border/i,
|
|
4439
|
+
/(Button|Card|Dialog|Sheet|Input|Select|Table)\b/i,
|
|
4440
|
+
/cva|class-variance-authority|variants\b/i,
|
|
4441
|
+
/VariantProps|variant.*:\s*\{/i,
|
|
4442
|
+
/children|slots|asChild|Slot|render.?prop/i,
|
|
4443
|
+
/forwardRef|React\.cloneElement|compound/i
|
|
4444
|
+
]
|
|
4445
|
+
},
|
|
4446
|
+
{
|
|
4447
|
+
skill: "1-designing-systems",
|
|
4448
|
+
readFragment: "designing-systems",
|
|
4449
|
+
patterns: [
|
|
4450
|
+
/--(\w+-)+color:|:root\s*\{|@theme\b/i,
|
|
4451
|
+
/design.?system|token|palette|typography.?scale/i,
|
|
4452
|
+
/--(\w+)-(foreground|background|primary|muted|accent):/i,
|
|
4453
|
+
/cssVariables|themeConfig|colorScheme/i,
|
|
4454
|
+
/dark:|prefers-color-scheme|next-themes|useTheme/i,
|
|
4455
|
+
/ThemeProvider|data-theme|color-scheme/i,
|
|
4456
|
+
/sm:|md:|lg:|xl:|2xl:/i,
|
|
4457
|
+
/@container|container-type|@media/i,
|
|
4458
|
+
/clamp\(|fluid|min-width:/i
|
|
4459
|
+
]
|
|
4460
|
+
},
|
|
4461
|
+
{
|
|
4462
|
+
skill: "4-adding-animations",
|
|
4463
|
+
readFragment: "adding-animations",
|
|
4464
|
+
patterns: [
|
|
4465
|
+
/motion\.|framer-motion|animate|variants/i,
|
|
4466
|
+
/whileHover|whileTap|AnimatePresence|transition/i,
|
|
4467
|
+
/@keyframes\b|animation:\s/i,
|
|
4468
|
+
/backdrop-blur|bg-.*\/([\d]+)|glass/i,
|
|
4469
|
+
/backdrop-filter:\s*blur/i,
|
|
4470
|
+
/hover:|focus:|active:|disabled:|focus-visible:/i,
|
|
4471
|
+
/data-\[state=|data-\[disabled\]/i,
|
|
4472
|
+
/bg-gradient|from-|via-|to-/i,
|
|
4473
|
+
/radial-gradient|conic-gradient|bg-\[url/i,
|
|
4474
|
+
/blur-.*xl|opacity-|mix-blend/i
|
|
4475
|
+
]
|
|
4476
|
+
},
|
|
4477
|
+
{
|
|
4478
|
+
skill: "5-design-audit",
|
|
4479
|
+
readFragment: "design-audit",
|
|
4480
|
+
patterns: [/aria-|role=|sr-only|tabIndex|alt=/i, /WCAG|a11y|contrast|screen.?reader/i]
|
|
4481
|
+
}
|
|
4482
|
+
];
|
|
4483
|
+
/** Return the domain skills required by `content` but NOT present in `refsRead`. */
|
|
4484
|
+
function missingDomainSkills(content, refsRead) {
|
|
4485
|
+
return SKILL_TRIGGERS.filter((t) => t.patterns.some((re) => re.test(content))).filter((t) => !refsRead.some((p) => p.includes(t.readFragment))).map((t) => t.skill);
|
|
4486
|
+
}
|
|
4487
|
+
//#endregion
|
|
4022
4488
|
//#region src/policy/design/skill-gate.ts
|
|
4023
4489
|
/** UI source files the gate polices (.html/.css are handled by the pipeline gate, not here). */
|
|
4024
4490
|
const UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/;
|
|
@@ -4057,7 +4523,9 @@ const block = (reason, action) => ({
|
|
|
4057
4523
|
function uiDesignSkillGate(tool, filePath, content, ev) {
|
|
4058
4524
|
if (!isUiWrite(tool, filePath, content)) return null;
|
|
4059
4525
|
if (!designSkillRead(ev.refsRead)) return block("BLOCKED: design skill not consulted before writing UI. Read a design SKILL.md (solid-react, generating-components, or designing-systems), then retry.", "Read a design skill reference, then retry");
|
|
4060
|
-
|
|
4526
|
+
const missing = missingDomainSkills(content, ev.refsRead);
|
|
4527
|
+
if (missing.length) return block(`BLOCKED: code uses ${missing.join(", ")} but the matching design skill(s) were not consulted. Read the SKILL.md for: ${missing.join(", ")}, then retry.`, `Read design skill(s): ${missing.join(", ")}, then retry`);
|
|
4528
|
+
if (!ev.docConsulted) return block("BLOCKED: no documentation consulted. Use BOTH mcp__context7__query-docs AND mcp__exa__web_search_exa, or a web fallback alone (WebSearch/WebFetch). Gemini is NOT required.", "Consult Context7+Exa, or web docs alone (Gemini not required), then retry");
|
|
4061
4529
|
return null;
|
|
4062
4530
|
}
|
|
4063
4531
|
//#endregion
|
|
@@ -4179,6 +4647,25 @@ function designSystemWriteGate(filePath, state) {
|
|
|
4179
4647
|
if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Take ${needed - state.screenshotsCount} more (fullPage).`);
|
|
4180
4648
|
return null;
|
|
4181
4649
|
}
|
|
4650
|
+
/**
|
|
4651
|
+
* Ports browser_helpers.is_exempt: design-system.md is NOT exempt (it must meet
|
|
4652
|
+
* the quota); other `.md` files and vendored dirs ARE exempt from the check.
|
|
4653
|
+
*/
|
|
4654
|
+
function screenshotExempt(filePath) {
|
|
4655
|
+
if (filePath.endsWith("design-system.md")) return false;
|
|
4656
|
+
return filePath.endsWith(".md") || EXEMPT_DIRS.some((d) => filePath.includes(d));
|
|
4657
|
+
}
|
|
4658
|
+
/**
|
|
4659
|
+
* Block ANY non-exempt write (html/css/design-system.md) until the per-mode
|
|
4660
|
+
* screenshot quota is met. Ports check-browser-browsing.py's "Browse N sites
|
|
4661
|
+
* BEFORE writing" deny for the active design agent.
|
|
4662
|
+
*/
|
|
4663
|
+
function preScreenshotWriteGate(filePath, state) {
|
|
4664
|
+
if (screenshotExempt(filePath)) return null;
|
|
4665
|
+
const needed = MIN_SCREENSHOTS[state.mode];
|
|
4666
|
+
if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} fuse-browser screenshots for mode '${state.mode}'. Browse ${needed - state.screenshotsCount} more site(s) BEFORE writing. Read design-inspiration.md, then browser_open + browser_navigate + browser_screenshot.`);
|
|
4667
|
+
return null;
|
|
4668
|
+
}
|
|
4182
4669
|
/** Return the requirements missing from a design-system.md (empty = valid). */
|
|
4183
4670
|
function validateDesignSystem(content) {
|
|
4184
4671
|
const missing = [];
|
|
@@ -4225,10 +4712,10 @@ function designGate(payload, event, cacheDir, cwd) {
|
|
|
4225
4712
|
if (skillBlock) return skillBlock;
|
|
4226
4713
|
}
|
|
4227
4714
|
const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
|
|
4715
|
+
if (!agentId) return null;
|
|
4228
4716
|
const active = activeDesignAgent(cacheDir);
|
|
4229
|
-
if (active && agentId
|
|
4230
|
-
const id =
|
|
4231
|
-
if (!id) return null;
|
|
4717
|
+
if (active && agentId !== active) return null;
|
|
4718
|
+
const id = agentId;
|
|
4232
4719
|
let state = loadDesignState(cacheDir, id);
|
|
4233
4720
|
if (!state) {
|
|
4234
4721
|
if (!active) return null;
|
|
@@ -4251,7 +4738,7 @@ function designGate(payload, event, cacheDir, cwd) {
|
|
|
4251
4738
|
}
|
|
4252
4739
|
if (event.tool === "Write" || event.tool === "Edit") {
|
|
4253
4740
|
const fp = event.filePath ?? "";
|
|
4254
|
-
const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
|
|
4741
|
+
const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? preScreenshotWriteGate(fp, state) ?? designSystemWriteGate(fp, state);
|
|
4255
4742
|
if (base) return base;
|
|
4256
4743
|
if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
|
|
4257
4744
|
kind: "block",
|
|
@@ -4311,6 +4798,13 @@ async function handlePre(ctx) {
|
|
|
4311
4798
|
stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
|
|
4312
4799
|
exit: 0
|
|
4313
4800
|
};
|
|
4801
|
+
if (opts.scope === "solid" && event.filePath) {
|
|
4802
|
+
const solidDeny = validateSolidGate(event.tool, event.filePath, event.content ?? "");
|
|
4803
|
+
if (solidDeny) return {
|
|
4804
|
+
stdout: solidDeny,
|
|
4805
|
+
exit: 0
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4314
4808
|
if (event.tool === "Task") {
|
|
4315
4809
|
const taskCtx = taskContext(opts.cwd);
|
|
4316
4810
|
if (taskCtx) return {
|
|
@@ -4343,82 +4837,6 @@ async function handlePre(ctx) {
|
|
|
4343
4837
|
};
|
|
4344
4838
|
}
|
|
4345
4839
|
//#endregion
|
|
4346
|
-
//#region src/freshness/explore-tools.ts
|
|
4347
|
-
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
4348
|
-
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
4349
|
-
/** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
|
|
4350
|
-
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
4351
|
-
"mcp__context7__query-docs",
|
|
4352
|
-
"mcp__context7__resolve-library-id",
|
|
4353
|
-
"mcp__exa__web_search_exa",
|
|
4354
|
-
"mcp__exa__get_code_context_exa",
|
|
4355
|
-
"mcp__exa__deep_researcher_start",
|
|
4356
|
-
"WebSearch",
|
|
4357
|
-
"WebFetch"
|
|
4358
|
-
]);
|
|
4359
|
-
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
4360
|
-
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
4361
|
-
"grep",
|
|
4362
|
-
"rg",
|
|
4363
|
-
"find",
|
|
4364
|
-
"ls",
|
|
4365
|
-
"fd",
|
|
4366
|
-
"ast-grep",
|
|
4367
|
-
"tree",
|
|
4368
|
-
"cat",
|
|
4369
|
-
"head",
|
|
4370
|
-
"tail"
|
|
4371
|
-
]);
|
|
4372
|
-
/** Cached MCP result reads count as research (parity `CACHE_READ_RE` + doc-helpers). */
|
|
4373
|
-
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
4374
|
-
/**
|
|
4375
|
-
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
4376
|
-
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
4377
|
-
* @param cmd - Raw Bash `command` string.
|
|
4378
|
-
* @returns The executable basename, or "" when none.
|
|
4379
|
-
*/
|
|
4380
|
-
function bashExecutable(cmd) {
|
|
4381
|
-
for (const token of cmd.trim().split(/\s+/)) {
|
|
4382
|
-
if (!token) continue;
|
|
4383
|
-
const last = token.split("/").pop() ?? token;
|
|
4384
|
-
if (!last.includes("=")) return last;
|
|
4385
|
-
}
|
|
4386
|
-
return "";
|
|
4387
|
-
}
|
|
4388
|
-
/**
|
|
4389
|
-
* Classify a direct tool use into an APEX phase, or `null` when it is neither
|
|
4390
|
-
* exploration nor research. Mirrors Python `track-subagent-research._classify`.
|
|
4391
|
-
* @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
|
|
4392
|
-
* @param input - Tool input payload.
|
|
4393
|
-
* @returns The credited phase + cache flag, or `null`.
|
|
4394
|
-
*/
|
|
4395
|
-
function classifyExplore(tool, input) {
|
|
4396
|
-
if (RESEARCH_TOOLS.has(tool)) return {
|
|
4397
|
-
phase: "research-expert",
|
|
4398
|
-
cacheHit: false
|
|
4399
|
-
};
|
|
4400
|
-
if (EXPLORE_TOOLS.has(tool)) return {
|
|
4401
|
-
phase: "explore-codebase",
|
|
4402
|
-
cacheHit: false
|
|
4403
|
-
};
|
|
4404
|
-
if (tool === "Read") {
|
|
4405
|
-
const path = String(input?.file_path ?? input?.path ?? "");
|
|
4406
|
-
if (path && CACHE_READ_RE.test(path)) return {
|
|
4407
|
-
phase: "research-expert",
|
|
4408
|
-
cacheHit: true
|
|
4409
|
-
};
|
|
4410
|
-
return null;
|
|
4411
|
-
}
|
|
4412
|
-
if (tool === "Bash") {
|
|
4413
|
-
const cmd = String(input?.command ?? "").trim();
|
|
4414
|
-
if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
|
|
4415
|
-
phase: "explore-codebase",
|
|
4416
|
-
cacheHit: false
|
|
4417
|
-
};
|
|
4418
|
-
}
|
|
4419
|
-
return null;
|
|
4420
|
-
}
|
|
4421
|
-
//#endregion
|
|
4422
4840
|
//#region src/runtime/activity.ts
|
|
4423
4841
|
/** Min response length (chars) for a lead agent call to count as `sufficient`. */
|
|
4424
4842
|
const AGENT_QUALITY_MIN = 500;
|
|
@@ -4526,14 +4944,28 @@ async function handlePost(ctx) {
|
|
|
4526
4944
|
stdout: seoDeny,
|
|
4527
4945
|
exit: 0
|
|
4528
4946
|
};
|
|
4529
|
-
if (opts.scope === "
|
|
4947
|
+
if (opts.scope === "solid" && event.filePath) {
|
|
4948
|
+
const solidWarn = checkFileSize(event.tool, event.filePath);
|
|
4949
|
+
if (solidWarn) return {
|
|
4950
|
+
stdout: solidWarn,
|
|
4951
|
+
exit: 0
|
|
4952
|
+
};
|
|
4953
|
+
}
|
|
4954
|
+
if (opts.scope === "tailwindcss" && event.filePath) {
|
|
4955
|
+
const tailwindWarn = validateTailwind(event.tool, event.filePath);
|
|
4956
|
+
if (tailwindWarn) return {
|
|
4957
|
+
stdout: tailwindWarn,
|
|
4958
|
+
exit: 0
|
|
4959
|
+
};
|
|
4960
|
+
}
|
|
4961
|
+
if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate" || event.tool === "Write" || event.tool === "Edit")) {
|
|
4530
4962
|
const out = await aipilotPostToolUse(payload, opts.cwd);
|
|
4531
4963
|
if (out) return {
|
|
4532
4964
|
stdout: out,
|
|
4533
4965
|
exit: 0
|
|
4534
4966
|
};
|
|
4535
4967
|
}
|
|
4536
|
-
const extra = postEditContext(opts.scope ?? "core", event, opts.now);
|
|
4968
|
+
const extra = await postEditContext(opts.scope ?? "core", event, opts.now);
|
|
4537
4969
|
return {
|
|
4538
4970
|
stdout: designWarn ? respond(id, designWarn) : extra,
|
|
4539
4971
|
exit: 0
|
|
@@ -4578,7 +5010,7 @@ async function handleHook(id, payload, opts) {
|
|
|
4578
5010
|
const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
|
|
4579
5011
|
const mcpDir = layout.cacheDir;
|
|
4580
5012
|
const framework = detectFramework(event.filePath ?? "", event.content ?? "");
|
|
4581
|
-
if (designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
|
|
5013
|
+
if (id === "claude-code" && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
|
|
4582
5014
|
stdout: "",
|
|
4583
5015
|
exit: 0
|
|
4584
5016
|
};
|
|
@@ -4620,4 +5052,4 @@ async function handleHook(id, payload, opts) {
|
|
|
4620
5052
|
});
|
|
4621
5053
|
}
|
|
4622
5054
|
//#endregion
|
|
4623
|
-
export { saveApexState as $, trackSkillRead as A, writePluginMap as B, seoPostToolUse as C,
|
|
5055
|
+
export { saveApexState as $, trackSkillRead as A, securityStateDir as At, writePluginMap as B, seoPostToolUse as C, defaultStateDir as Ct, postTrackingSideEffects as D, isoUtc as Dt, securityAdvisory as E, normalizeEvent as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, todayUtc as Mt, aipilotPostToolUse as N, trackWatchResearch as O, loadSecurityState as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, taskContext as St, dispatchMemory as T, trackFile as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, trimLogFile as _t, recordActivity as a, validateSolidGate as at, extractSymbols as b, projectContext as bt, MCP_TTL_MS as c, detectSolidProfile as ct, isMcpTool as d, readRules as dt, logToolFailure as et, queryOf as f, runSessionStartCleanups as ft, gate as g, removeOldFiles as gt, TRIVIAL_BUDGET as h, purgeTtlTree as ht, respond as i, validateTailwind as it, trackEnrichment as j, securityStatePath as jt, trackMcpResearch as k, saveSecurityState as kt, WEBFETCH_TTL_MS as l, solidDetectStart as lt, REQUIRED_AGENTS as m, pruneEmptyDirs as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, checkFileSize as ot, DEFAULT_WINDOW_MS as p, sessionStartCore as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, countLoc as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, injectRules as ut, detectDuplication as v, devContext as vt, seoPostToolUseResponse as w, projectHash$1 as wt, lifecycleStdout as x, promptSubmitContext as xt, dryGate as y, gitContext as yt, generateEcosystemMap as z };
|