@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,9 +1,11 @@
|
|
|
1
1
|
import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { c as ttlLabel, t as HOME_DIR } from "./dotenv-B9nM4cuQ.mjs";
|
|
3
|
+
import { t as detectHarness } from "./harness-C8Nxxyn_.mjs";
|
|
4
|
+
import { F as countFrameworkCodeLines, N as PLUGINS_DIR, P as SOLID_REF } from "./evaluate-d7Pp8XJH.mjs";
|
|
5
|
+
import { r as isDocConsulted, t as formatDocDeny } from "./doc-helpers-CWZegVdR.mjs";
|
|
6
|
+
import { t as routeReferences } from "./router-PKVNBHge.mjs";
|
|
7
|
+
import { extname, join } from "node:path";
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
9
|
import { homedir } from "node:os";
|
|
8
10
|
//#region src/policy/detect-project.ts
|
|
9
11
|
/** Keywords that signal a development task (APEX trigger). */
|
|
@@ -41,6 +43,27 @@ function requiredArchSkill(cwd) {
|
|
|
41
43
|
default: return null;
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
/** Config file names that mark a project as using Tailwind (v3 JS config or legacy). */
|
|
47
|
+
const TAILWIND_CONFIG_FILES = [
|
|
48
|
+
"tailwind.config.js",
|
|
49
|
+
"tailwind.config.ts",
|
|
50
|
+
"tailwind.config.mjs",
|
|
51
|
+
"tailwind.config.cjs"
|
|
52
|
+
];
|
|
53
|
+
/** True when `package.json` lists `tailwindcss` as a dep (Tailwind v4 CSS-first, no config file). */
|
|
54
|
+
function hasTailwindDependency(dir) {
|
|
55
|
+
const pkgPath = join(dir, "package.json");
|
|
56
|
+
if (!existsSync(pkgPath)) return false;
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
59
|
+
return "tailwindcss" in {
|
|
60
|
+
...pkg.dependencies,
|
|
61
|
+
...pkg.devDependencies
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
44
67
|
/** Detect the project type by scanning config files in `dir`. */
|
|
45
68
|
function detectProjectType(dir) {
|
|
46
69
|
const has = (f) => existsSync(join(dir, f));
|
|
@@ -50,7 +73,7 @@ function detectProjectType(dir) {
|
|
|
50
73
|
if (has("svelte.config.js") || has("svelte.config.ts")) return "svelte";
|
|
51
74
|
if (has("vite.config.ts") && has("src/App.vue")) return "vue";
|
|
52
75
|
if (has("vite.config.ts") || has("vite.config.js")) return "react";
|
|
53
|
-
if (has
|
|
76
|
+
if (TAILWIND_CONFIG_FILES.some(has) || hasTailwindDependency(dir)) return "tailwind";
|
|
54
77
|
if (has("composer.json") && has("artisan")) return "laravel";
|
|
55
78
|
if (has("Gemfile") && has("config/routes.rb")) return "rails";
|
|
56
79
|
if (has("requirements.txt") || has("pyproject.toml") || has("setup.py")) return has("manage.py") ? "django" : "python";
|
|
@@ -64,36 +87,79 @@ function detectProjectType(dir) {
|
|
|
64
87
|
return "generic";
|
|
65
88
|
}
|
|
66
89
|
//#endregion
|
|
67
|
-
//#region src/policy/
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
//#region src/policy/detect-framework.ts
|
|
91
|
+
/**
|
|
92
|
+
* Detect the framework from a file path extension + content patterns.
|
|
93
|
+
* Aligned with the fusengine require-solid-read detection (distinct from
|
|
94
|
+
* {@link detectProjectType}, which scans config files on disk).
|
|
95
|
+
*/
|
|
96
|
+
function detectFramework(filePath, content) {
|
|
97
|
+
if (/\.(tsx?|jsx?|vue|svelte)$/.test(filePath) || /from ['"]react|useState|className=/.test(content)) {
|
|
98
|
+
if (/(page|layout|loading|error|route)\.(ts|tsx)$/.test(filePath) || /use client|use server/.test(content)) return "nextjs";
|
|
99
|
+
return "react";
|
|
100
|
+
}
|
|
101
|
+
if (/\.swift$/.test(filePath)) return "swift";
|
|
102
|
+
if (/\.php$/.test(filePath)) return "laravel";
|
|
103
|
+
if (/\.java$/.test(filePath)) return "java";
|
|
104
|
+
if (/\.go$/.test(filePath)) return "go";
|
|
105
|
+
if (/\.rb$/.test(filePath)) return "ruby";
|
|
106
|
+
if (/\.rs$/.test(filePath)) return "rust";
|
|
107
|
+
if (/\.css$/.test(filePath) || /@tailwind|@apply/.test(content)) return "tailwind";
|
|
108
|
+
return "generic";
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/policy/apex-gates.ts
|
|
75
112
|
/** Gate: the routed SOLID references for this edit must have been read. */
|
|
76
113
|
const solidReadGate = (ctx) => {
|
|
77
114
|
if (!ctx.refs?.length) return null;
|
|
78
115
|
const routed = routeReferences(ctx.refs, ctx.filePath, ctx.content);
|
|
79
|
-
|
|
116
|
+
const ttl = ttlLabel(Math.round((ctx.windowMs ?? 120 * 1e3) / 1e3));
|
|
117
|
+
if (!routed) {
|
|
118
|
+
const ref = SOLID_REF[ctx.framework] ?? "generic/";
|
|
119
|
+
return {
|
|
120
|
+
kind: "block",
|
|
121
|
+
title: `APEX: no SOLID reference matched ${ctx.filePath}`,
|
|
122
|
+
reason: `${ctx.refs.length} SOLID reference(s) are loaded but none scored for this edit (expires every ${ttl}) — read the framework skill directly instead.`,
|
|
123
|
+
actions: [`Read ${PLUGINS_DIR}/${ref}SKILL.md`]
|
|
124
|
+
};
|
|
125
|
+
}
|
|
80
126
|
const read = new Set(ctx.refsRead ?? []);
|
|
81
127
|
const missing = routed.required.map((r) => r.meta.filePath).filter((p) => !read.has(p));
|
|
82
128
|
if (missing.length === 0) return null;
|
|
129
|
+
const optional = routed.optional.map((r) => r.meta.filePath);
|
|
130
|
+
const reason = [
|
|
131
|
+
`Read these before editing ${ctx.filePath} (expires every ${ttl}):`,
|
|
132
|
+
...optional.length ? [`Optional: ${optional.join(", ")}`] : [],
|
|
133
|
+
`Full skill: ${routed.skillPath}`
|
|
134
|
+
].join("\n");
|
|
83
135
|
return {
|
|
84
136
|
kind: "block",
|
|
85
137
|
title: `APEX: read SOLID references for ${ctx.framework}`,
|
|
86
|
-
reason
|
|
138
|
+
reason,
|
|
87
139
|
actions: missing
|
|
88
140
|
};
|
|
89
141
|
};
|
|
90
142
|
/** Gate: the required prior agents (explore + research) must have run within the window. */
|
|
91
|
-
const freshnessGate = (ctx) =>
|
|
143
|
+
const freshnessGate = (ctx) => {
|
|
144
|
+
if (ctx.agentsFresh !== false) return null;
|
|
145
|
+
const missing = ctx.missingAgents?.length ? ctx.missingAgents : ["explore-codebase", "research-expert"];
|
|
146
|
+
const ttl = ttlLabel(Math.round((ctx.windowMs ?? 120 * 1e3) / 1e3));
|
|
147
|
+
return {
|
|
148
|
+
kind: "block",
|
|
149
|
+
title: "APEX: explore + research required",
|
|
150
|
+
reason: `Run ${missing.join(" and ")} within the freshness window (${ttl} TTL) before editing ${ctx.framework}.`,
|
|
151
|
+
actions: missing.map((name) => `Launch the ${name} agent`)
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/policy/apex.ts
|
|
156
|
+
/** Gate: BOTH Context7 AND Exa (or a web fallback alone) must have been consulted this session. */
|
|
157
|
+
const docConsultedGate = (ctx) => isDocConsulted(ctx.authorizations, ctx.sessionId) ? null : {
|
|
92
158
|
kind: "block",
|
|
93
|
-
title: "APEX:
|
|
94
|
-
reason:
|
|
95
|
-
actions: ["
|
|
96
|
-
}
|
|
159
|
+
title: "APEX: documentation not consulted",
|
|
160
|
+
reason: formatDocDeny(ctx.framework),
|
|
161
|
+
actions: ["Use BOTH mcp__context7__query-docs AND mcp__exa__web_search_exa, or a web fallback alone (WebSearch/WebFetch)"]
|
|
162
|
+
};
|
|
97
163
|
/** Gate: brainstorming must precede creating new files when flagged. */
|
|
98
164
|
const brainstormGate = (ctx) => ctx.brainstormRequired && ctx.brainstormFresh === false ? {
|
|
99
165
|
kind: "block",
|
|
@@ -212,7 +278,7 @@ const SWIFT_TYPE_RE = /^(class|struct) [^\n{]* \{/m;
|
|
|
212
278
|
function reactGate(filePath, content, fileLines) {
|
|
213
279
|
const v = [];
|
|
214
280
|
const max = resolveMaxLines();
|
|
215
|
-
const lines = fileLines ??
|
|
281
|
+
const lines = fileLines ?? countFrameworkCodeLines(content);
|
|
216
282
|
if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Split to hooks/, components/, or utils/.`);
|
|
217
283
|
if (filePath.includes("/components/") && TS_DECL_RE.test(content)) v.push("Interface/type in component. Move to src/interfaces/ or src/types/.");
|
|
218
284
|
if (HOOK_RE.test(content) && !filePath.includes("/hooks/")) v.push("Custom hook defined outside hooks/ directory. Move to hooks/.");
|
|
@@ -222,7 +288,7 @@ function reactGate(filePath, content, fileLines) {
|
|
|
222
288
|
function nextGate(filePath, content, fileLines) {
|
|
223
289
|
const v = [];
|
|
224
290
|
const max = /(page|layout|loading|error|not-found)\.(tsx|ts)$/.test(filePath) ? 150 : 100;
|
|
225
|
-
const lines = fileLines ??
|
|
291
|
+
const lines = fileLines ?? countFrameworkCodeLines(content);
|
|
226
292
|
if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Split to lib/, hooks/, or components/.`);
|
|
227
293
|
if (/\/(app|components|modules)\//.test(filePath) && !filePath.includes("/interfaces/") && TS_DECL_RE.test(content)) v.push("Interface/type in component. Move to modules/[feature]/src/interfaces/.");
|
|
228
294
|
if (CLIENT_HOOK_RE.test(content)) {
|
|
@@ -234,7 +300,7 @@ function nextGate(filePath, content, fileLines) {
|
|
|
234
300
|
/** Laravel/PHP: line limit, interface outside `/Contracts/`, fat controller (>80). */
|
|
235
301
|
function laravelGate(filePath, content, fileLines) {
|
|
236
302
|
const v = [];
|
|
237
|
-
const lines = fileLines ??
|
|
303
|
+
const lines = fileLines ?? countFrameworkCodeLines(content);
|
|
238
304
|
const max = resolveMaxLines();
|
|
239
305
|
if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Split using Services, Actions, or Traits.`);
|
|
240
306
|
if (PHP_INTERFACE_RE.test(content) && !filePath.includes("/Contracts/")) v.push("Interface defined outside Contracts/. Move to app/Contracts/ or FuseCore/{Module}/App/Contracts/.");
|
|
@@ -245,7 +311,7 @@ function laravelGate(filePath, content, fileLines) {
|
|
|
245
311
|
function swiftGate(filePath, content, fileLines) {
|
|
246
312
|
const v = [];
|
|
247
313
|
const max = /(View|Screen)\.swift$/.test(filePath) ? 150 : 100;
|
|
248
|
-
const lines = fileLines ??
|
|
314
|
+
const lines = fileLines ?? countFrameworkCodeLines(content);
|
|
249
315
|
if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Extract to ViewModels, Services, or subviews.`);
|
|
250
316
|
if (SWIFT_PROTOCOL_RE.test(content) && !filePath.includes("/Protocols/")) v.push("Protocol defined outside Protocols/ directory.");
|
|
251
317
|
if (filePath.endsWith("ViewModel.swift") && !content.includes("@MainActor")) v.push("ViewModel missing @MainActor annotation.");
|
|
@@ -593,6 +659,106 @@ const SWIFT_TRIGGERS = {
|
|
|
593
659
|
]
|
|
594
660
|
};
|
|
595
661
|
//#endregion
|
|
662
|
+
//#region src/policy/skill-patterns/tailwind.ts
|
|
663
|
+
/**
|
|
664
|
+
* Tailwind CSS skill-trigger patterns, ported verbatim from
|
|
665
|
+
* `tailwind_skill_triggers.py`. 12 skills.
|
|
666
|
+
*
|
|
667
|
+
* NOTE: Tailwind uses case-SENSITIVE matching (source `re.search` WITHOUT
|
|
668
|
+
* `re.IGNORECASE`). The framework name is registered in
|
|
669
|
+
* `CASE_SENSITIVE_FRAMEWORKS` so the gate compiles these without the `i` flag.
|
|
670
|
+
*/
|
|
671
|
+
/** Map of Tailwind sub-skill name → triggering utility-class patterns (case-sensitive). */
|
|
672
|
+
const TAILWIND_TRIGGERS = {
|
|
673
|
+
"tailwindcss-v4": [
|
|
674
|
+
"@theme\\b",
|
|
675
|
+
"@source\\b",
|
|
676
|
+
"@utility\\b",
|
|
677
|
+
"@variant\\b",
|
|
678
|
+
"@import\\s+['\"]tailwindcss",
|
|
679
|
+
"@config\\b"
|
|
680
|
+
],
|
|
681
|
+
"tailwindcss-layout": [
|
|
682
|
+
"\\b(flex|grid|inline-flex|inline-grid)\\b",
|
|
683
|
+
"(justify|items|place)-(start|end|center|between)",
|
|
684
|
+
"(grid-cols|grid-rows|col-span|row-span)-",
|
|
685
|
+
"(absolute|relative|fixed|sticky)\\b"
|
|
686
|
+
],
|
|
687
|
+
"tailwindcss-typography": [
|
|
688
|
+
"(font-sans|font-serif|font-mono|font-bold|font-semibold)\\b",
|
|
689
|
+
"(text-xs|text-sm|text-base|text-lg|text-xl|text-2xl)\\b",
|
|
690
|
+
"(tracking-|leading-|line-clamp-)\\b"
|
|
691
|
+
],
|
|
692
|
+
"tailwindcss-backgrounds": [
|
|
693
|
+
"(bg-gradient|bg-linear|bg-radial|bg-conic)\\b",
|
|
694
|
+
"(from-|via-|to-)\\w+",
|
|
695
|
+
"bg-\\[url\\b"
|
|
696
|
+
],
|
|
697
|
+
"tailwindcss-borders": ["(rounded-|border-|ring-|outline-|divide-)\\w+", "(border-dashed|border-dotted|border-double)\\b"],
|
|
698
|
+
"tailwindcss-effects": [
|
|
699
|
+
"(shadow-|opacity-|blur-|brightness-|contrast-)\\w+",
|
|
700
|
+
"(backdrop-blur|backdrop-brightness|backdrop-contrast)\\b",
|
|
701
|
+
"(inset-shadow-|mask-)\\w+"
|
|
702
|
+
],
|
|
703
|
+
"tailwindcss-transforms": [
|
|
704
|
+
"(scale-|rotate-|translate-|skew-)\\w+",
|
|
705
|
+
"(transition-|duration-|ease-|delay-)\\w+",
|
|
706
|
+
"(animate-spin|animate-pulse|animate-bounce)\\b"
|
|
707
|
+
],
|
|
708
|
+
"tailwindcss-responsive": [
|
|
709
|
+
"(sm:|md:|lg:|xl:|2xl:)\\w+",
|
|
710
|
+
"(@container|container-type)\\b",
|
|
711
|
+
"(min-\\[|max-\\[)\\d+"
|
|
712
|
+
],
|
|
713
|
+
"tailwindcss-spacing": ["\\b[pm][xytblr]?-\\d+\\b", "(space-x-|space-y-|gap-)\\d+"],
|
|
714
|
+
"tailwindcss-sizing": [
|
|
715
|
+
"\\b[wh]-(full|screen|auto|min|max|fit)\\b",
|
|
716
|
+
"(min-w-|max-w-|min-h-|max-h-)\\w+",
|
|
717
|
+
"(aspect-video|aspect-square)\\b"
|
|
718
|
+
],
|
|
719
|
+
"tailwindcss-interactivity": ["(cursor-|select-|pointer-events-|scroll-)\\w+", "(snap-|overscroll-|touch-)\\w+"],
|
|
720
|
+
"tailwindcss-custom-styles": [
|
|
721
|
+
"@apply\\b",
|
|
722
|
+
"@utility\\s+\\w+",
|
|
723
|
+
"@variant\\s+\\w+",
|
|
724
|
+
"theme\\(\\s*['\"]"
|
|
725
|
+
]
|
|
726
|
+
};
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/policy/skill-patterns/shadcn-subskills.ts
|
|
729
|
+
/**
|
|
730
|
+
* shadcn/ui domain skill-trigger patterns, ported verbatim from
|
|
731
|
+
* `shadcn_skill_triggers.py` (the standalone shadcn-expert plugin, distinct
|
|
732
|
+
* from the `SHADCN` HTML-detection patterns in `./shadcn.ts` consumed by
|
|
733
|
+
* react/nextjs). 5 sub-skills. Matched case-insensitively (source `re.IGNORECASE`).
|
|
734
|
+
*/
|
|
735
|
+
/** Map of shadcn/ui sub-skill name → triggering code patterns. */
|
|
736
|
+
const SHADCN_TRIGGERS = {
|
|
737
|
+
"shadcn-detection": [
|
|
738
|
+
"components\\.json",
|
|
739
|
+
"@radix-ui/",
|
|
740
|
+
"@base-ui/",
|
|
741
|
+
"data-\\[state=",
|
|
742
|
+
"data-\\[disabled\\]"
|
|
743
|
+
],
|
|
744
|
+
"shadcn-components": [
|
|
745
|
+
"from\\s+['\"].*components/ui/",
|
|
746
|
+
"<(Button|Input|Select|Dialog|Card|Table|Tabs|Badge)\\b",
|
|
747
|
+
"(Popover|Tooltip|Sheet|Drawer|Command|Accordion)\\b"
|
|
748
|
+
],
|
|
749
|
+
"shadcn-theming": [
|
|
750
|
+
"--(primary|secondary|muted|accent|destructive|foreground):",
|
|
751
|
+
"(cssVariables|themeConfig|globals\\.css)\\b",
|
|
752
|
+
":root\\s*\\{|\\.dark\\s*\\{"
|
|
753
|
+
],
|
|
754
|
+
"shadcn-registries": [
|
|
755
|
+
"mcp__shadcn__(search|view|get_add_command|list_items)",
|
|
756
|
+
"bunx.*shadcn@latest\\s+add\\b",
|
|
757
|
+
"(registries|@shadcn|@acme)\\b"
|
|
758
|
+
],
|
|
759
|
+
"shadcn-migration": ["(@radix-ui.*@base-ui|@base-ui.*@radix-ui)", "(migrat|convert|switch).*(radix|base.?ui)"]
|
|
760
|
+
};
|
|
761
|
+
//#endregion
|
|
596
762
|
//#region src/policy/skill-trigger-patterns.ts
|
|
597
763
|
/**
|
|
598
764
|
* Per-framework code-pattern → required sub-skill data, ported verbatim from the
|
|
@@ -607,13 +773,15 @@ const SWIFT_TRIGGERS = {
|
|
|
607
773
|
* Frameworks whose Python source omits `re.IGNORECASE`, so their regexes must
|
|
608
774
|
* be compiled WITHOUT the `i` flag to stay faithful.
|
|
609
775
|
*/
|
|
610
|
-
const CASE_SENSITIVE_FRAMEWORKS = /* @__PURE__ */ new Set(["swift"]);
|
|
776
|
+
const CASE_SENSITIVE_FRAMEWORKS = /* @__PURE__ */ new Set(["swift", "tailwind"]);
|
|
611
777
|
/** Map of required sub-skill name → triggering code patterns, keyed by framework. */
|
|
612
778
|
const SKILL_TRIGGERS = {
|
|
613
779
|
react: REACT_TRIGGERS,
|
|
614
780
|
nextjs: NEXTJS_TRIGGERS,
|
|
615
781
|
laravel: LARAVEL_TRIGGERS,
|
|
616
|
-
swift: SWIFT_TRIGGERS
|
|
782
|
+
swift: SWIFT_TRIGGERS,
|
|
783
|
+
tailwind: TAILWIND_TRIGGERS,
|
|
784
|
+
shadcn: SHADCN_TRIGGERS
|
|
617
785
|
};
|
|
618
786
|
//#endregion
|
|
619
787
|
//#region src/policy/shadcn-project.ts
|
|
@@ -636,6 +804,94 @@ function isShadcnProject(cwd) {
|
|
|
636
804
|
return UI_DIRS.some((dir) => existsSync(join(cwd, dir)));
|
|
637
805
|
}
|
|
638
806
|
//#endregion
|
|
807
|
+
//#region src/runtime/lifecycle/cartographer/detect.ts
|
|
808
|
+
/**
|
|
809
|
+
* Plugin discovery (fs). Ports `detect_plugins.py`: marketplace `plugins` dir
|
|
810
|
+
* resolution + `plugin.json` meta reading.
|
|
811
|
+
*/
|
|
812
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
813
|
+
function sortedNames$1(dir) {
|
|
814
|
+
try {
|
|
815
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
816
|
+
} catch {
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Read `[version, name]` from `<pluginPath>/.claude-plugin/plugin.json`.
|
|
822
|
+
* @param pluginPath - Absolute plugin directory.
|
|
823
|
+
* @returns The `[version, name]` pair (both "" when absent/unreadable).
|
|
824
|
+
*/
|
|
825
|
+
function readPluginMeta(pluginPath) {
|
|
826
|
+
const pj = join(pluginPath, ".claude-plugin", "plugin.json");
|
|
827
|
+
if (!existsSync(pj)) return ["", ""];
|
|
828
|
+
try {
|
|
829
|
+
const meta = JSON.parse(readFileSync(pj, "utf-8"));
|
|
830
|
+
return [meta.version ?? "", meta.name ?? ""];
|
|
831
|
+
} catch {
|
|
832
|
+
return ["", ""];
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Auto-detect the marketplace `plugins` dir that contains `cartographer`,
|
|
837
|
+
* falling back to the first marketplace with a `plugins` dir, else `cwd`.
|
|
838
|
+
* Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
|
|
839
|
+
* derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
|
|
840
|
+
* shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
|
|
841
|
+
* @param home - Home directory (defaults to `~`).
|
|
842
|
+
* @param id - Detected harness id (defaults to runtime detection).
|
|
843
|
+
* @returns The resolved plugins directory.
|
|
844
|
+
*/
|
|
845
|
+
function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
|
|
846
|
+
const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
|
|
847
|
+
const markets = sortedNames$1(mp);
|
|
848
|
+
for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
|
|
849
|
+
for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
|
|
850
|
+
return process.cwd();
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/policy/skill-path.ts
|
|
854
|
+
/**
|
|
855
|
+
* Dynamic skill-path resolution (fs). Replaces a hardcoded `PLUGIN_DIR`
|
|
856
|
+
* table (framework -> plugin dir name), which can drift from what's actually
|
|
857
|
+
* installed: scans installed marketplace plugins for a `skills/<name>/SKILL.md`
|
|
858
|
+
* that exists ON DISK, returning the first real absolute path found — never a
|
|
859
|
+
* guessed one.
|
|
860
|
+
*
|
|
861
|
+
* Deliberately lighter than the cartographer's own `scanSkills` (which
|
|
862
|
+
* frontmatter-parses every skill in a plugin): this runs once per missing
|
|
863
|
+
* sub-skill on every gated Write/Edit, so it does a single flat `readdirSync`
|
|
864
|
+
* of the plugins dir + one `existsSync` per candidate — no file reads, no
|
|
865
|
+
* recursion, no `statSync`.
|
|
866
|
+
*/
|
|
867
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
868
|
+
function pluginDirNames(dir) {
|
|
869
|
+
try {
|
|
870
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
871
|
+
} catch {
|
|
872
|
+
return [];
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Resolve the real, installed `SKILL.md` path for `skillName`.
|
|
877
|
+
* @param skillName - the sub-skill directory name (e.g. "solid-react",
|
|
878
|
+
* "shadcn-detection", "react-shadcn").
|
|
879
|
+
* @param pluginsDirOverride - override for the marketplace plugins dir
|
|
880
|
+
* (tests); defaults to the auto-detected marketplace root.
|
|
881
|
+
* @returns the absolute `<pluginDir>/skills/<skillName>/SKILL.md` of the
|
|
882
|
+
* first installed plugin that actually has it, or the generic
|
|
883
|
+
* `${PLUGINS_DIR}/<skillName>` fallback when none is found (CI/test
|
|
884
|
+
* environments without a marketplace).
|
|
885
|
+
*/
|
|
886
|
+
function resolveSkillPath(skillName, pluginsDirOverride) {
|
|
887
|
+
const pluginsDir = pluginsDirOverride ?? findMarketplacePlugins();
|
|
888
|
+
for (const dir of pluginDirNames(pluginsDir)) {
|
|
889
|
+
const candidate = join(pluginsDir, dir, "skills", skillName, "SKILL.md");
|
|
890
|
+
if (existsSync(candidate)) return candidate;
|
|
891
|
+
}
|
|
892
|
+
return `${PLUGINS_DIR}/${skillName}`;
|
|
893
|
+
}
|
|
894
|
+
//#endregion
|
|
639
895
|
//#region src/policy/skill-triggers.ts
|
|
640
896
|
/**
|
|
641
897
|
* Detect which sub-skills the written `content` requires for a `framework`.
|
|
@@ -655,6 +911,24 @@ function detectRequiredSkills(framework, content) {
|
|
|
655
911
|
return required;
|
|
656
912
|
}
|
|
657
913
|
/**
|
|
914
|
+
* `.tsx`/`.jsx` — the only extensions `check-tailwind-skill.py` actually gates
|
|
915
|
+
* (its regex also lists `.css`/`.html`, but both hit an early return right
|
|
916
|
+
* after, in the Python source).
|
|
917
|
+
*/
|
|
918
|
+
const TAILWIND_FILE = /\.(tsx|jsx)$/;
|
|
919
|
+
/** Ported verbatim from `check-tailwind-skill.py`'s `TW_PATTERN`. */
|
|
920
|
+
const TAILWIND_CONTENT = /(className|class).*['"].*\b(flex|grid|p-|m-|w-|h-|text-|bg-|border-)/;
|
|
921
|
+
/**
|
|
922
|
+
* True when `filePath`/`content` match the Python Tailwind gate's trigger
|
|
923
|
+
* condition. React/Next.js components embed Tailwind utility classes in
|
|
924
|
+
* `className` — this check fires IN ADDITION TO the primary framework gate,
|
|
925
|
+
* never instead of it: {@link detectFramework} keeps returning "react"/
|
|
926
|
+
* "nextjs" for these files (framework SOLID rules stay correct).
|
|
927
|
+
*/
|
|
928
|
+
function usesTailwindUtilities(filePath, content) {
|
|
929
|
+
return TAILWIND_FILE.test(filePath) && TAILWIND_CONTENT.test(content);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
658
932
|
* Block when a required sub-skill's `skills/<name>/` path is absent from
|
|
659
933
|
* `refsRead`. Mirrors `specific_skill_consulted`, which confirms a skill was
|
|
660
934
|
* read by checking the tracking file contains `skills/<name>/`.
|
|
@@ -664,10 +938,15 @@ function detectRequiredSkills(framework, content) {
|
|
|
664
938
|
* @param forcedSkill - a skill the detected modular architecture forces (optional).
|
|
665
939
|
* @param cwd - project root; when set and not a shadcn project, `*-shadcn`
|
|
666
940
|
* requirements are skipped (ports the Python `is_shadcn_project` filter).
|
|
941
|
+
* @param filePath - the file being written; when it's a `.tsx`/`.jsx` file
|
|
942
|
+
* with Tailwind utility classes in `className`, the "tailwind" domain
|
|
943
|
+
* skills are merged in alongside `framework`'s own (ports the separate
|
|
944
|
+
* `check-tailwind-skill.py` gate, independent of react/nextjs).
|
|
667
945
|
* @returns a `block` Prompt naming the missing sub-skills, or `null` when satisfied.
|
|
668
946
|
*/
|
|
669
|
-
function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd) {
|
|
947
|
+
function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd, filePath) {
|
|
670
948
|
let required = detectRequiredSkills(framework, content);
|
|
949
|
+
if (framework !== "tailwind" && filePath && usesTailwindUtilities(filePath, content)) required = [...required, ...detectRequiredSkills("tailwind", content)];
|
|
671
950
|
if (forcedSkill && !required.includes(forcedSkill)) required.push(forcedSkill);
|
|
672
951
|
if (cwd && !isShadcnProject(cwd)) required = required.filter((s) => !s.endsWith("-shadcn"));
|
|
673
952
|
const missing = required.filter((s) => !refsRead.some((r) => r.includes(`skills/${s}/`)));
|
|
@@ -676,10 +955,237 @@ function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd) {
|
|
|
676
955
|
kind: "block",
|
|
677
956
|
title: "Required sub-skill not consulted",
|
|
678
957
|
reason: `${framework}: code uses APIs covered by ${missing.join(", ")} but its skill reference was not read this session.`,
|
|
679
|
-
actions: missing.map((s) => `Read
|
|
958
|
+
actions: missing.map((s) => `Read ${resolveSkillPath(s)}`)
|
|
680
959
|
};
|
|
681
960
|
}
|
|
682
961
|
//#endregion
|
|
962
|
+
//#region src/policy/cartographer/frontmatter.ts
|
|
963
|
+
/**
|
|
964
|
+
* Frontmatter parsing — pure text helpers (no fs). Ports `parse_frontmatter.py`.
|
|
965
|
+
*/
|
|
966
|
+
const BLOCK_SCALARS = /* @__PURE__ */ new Set([
|
|
967
|
+
"|",
|
|
968
|
+
">",
|
|
969
|
+
"|+",
|
|
970
|
+
"|-",
|
|
971
|
+
">+",
|
|
972
|
+
">-"
|
|
973
|
+
]);
|
|
974
|
+
/** Escape regex metacharacters in an arbitrary field name. */
|
|
975
|
+
function escapeRe(s) {
|
|
976
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Extract a single frontmatter field's value from `text`. Strips surrounding
|
|
980
|
+
* quotes; skips YAML block-scalar markers. Returns "" when absent.
|
|
981
|
+
* @param text - The full document text.
|
|
982
|
+
* @param field - The frontmatter key to read.
|
|
983
|
+
* @returns The field value, or "".
|
|
984
|
+
*/
|
|
985
|
+
function parseField(text, field) {
|
|
986
|
+
const fm = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
987
|
+
if (!fm || fm[1] === void 0) return "";
|
|
988
|
+
const lineRe = new RegExp(`^${escapeRe(field)}\\s*:\\s*(.+)$`);
|
|
989
|
+
for (const line of fm[1].split("\n")) {
|
|
990
|
+
const m = lineRe.exec(line);
|
|
991
|
+
if (!m || m[1] === void 0) continue;
|
|
992
|
+
const val = m[1].trim().replace(/^["']|["']$/g, "");
|
|
993
|
+
if (BLOCK_SCALARS.has(val)) continue;
|
|
994
|
+
return val;
|
|
995
|
+
}
|
|
996
|
+
return "";
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Derive a short description from the body following the frontmatter: the first
|
|
1000
|
+
* non-empty trimmed line, sliced to `maxLen`. Returns "" when none.
|
|
1001
|
+
* @param text - The full document text.
|
|
1002
|
+
* @param maxLen - Maximum length of the returned description.
|
|
1003
|
+
* @returns The body-derived description, or "".
|
|
1004
|
+
*/
|
|
1005
|
+
function parseBodyDesc(text, maxLen = 60) {
|
|
1006
|
+
const m = /^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)/.exec(text);
|
|
1007
|
+
if (!m || m[1] === void 0) return "";
|
|
1008
|
+
for (const raw of m[1].split("\n")) {
|
|
1009
|
+
const line = raw.trim();
|
|
1010
|
+
if (line) return line.slice(0, maxLen);
|
|
1011
|
+
}
|
|
1012
|
+
return "";
|
|
1013
|
+
}
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/runtime/lifecycle/cartographer/scan-hooks.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* Hook scanning (fs). Ports `_scan_hooks` from `scan_plugins.py`: reads a
|
|
1018
|
+
* plugin's `hooks/hooks.json` and reduces it to a single `("hooks", "<events>", "")`
|
|
1019
|
+
* row. The `hooks` value (or the whole document) is either a MAP of event →
|
|
1020
|
+
* entries (keys are the events) or a LIST of entries each carrying an `event`.
|
|
1021
|
+
*/
|
|
1022
|
+
/** True for a non-null plain object (excludes arrays), mirroring Python `isinstance(x, dict)`. */
|
|
1023
|
+
function isPlainObject(v) {
|
|
1024
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1025
|
+
}
|
|
1026
|
+
/** Sort unique non-empty strings alpha (byte-order). */
|
|
1027
|
+
function sortedEvents(events) {
|
|
1028
|
+
return [...new Set([...events].filter((e) => e))].sort((a, b) => a.localeCompare(b, "en"));
|
|
1029
|
+
}
|
|
1030
|
+
/** Derive hook event names from the `hooks.json` data (map keys, or list `event` fields). */
|
|
1031
|
+
function hookEvents(hooksData) {
|
|
1032
|
+
if (isPlainObject(hooksData)) return sortedEvents(Object.keys(hooksData).filter((k) => !k.startsWith("_")));
|
|
1033
|
+
if (Array.isArray(hooksData)) return sortedEvents(hooksData.map((h) => isPlainObject(h) ? String(h.event ?? "") : ""));
|
|
1034
|
+
return [];
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Scan `hooks/hooks.json` into a single `("hooks", "<events>", "")` row.
|
|
1038
|
+
* @param root - Absolute plugin directory.
|
|
1039
|
+
* @returns The single hooks row, or `[]` when absent/empty/unreadable.
|
|
1040
|
+
*/
|
|
1041
|
+
function scanHooks(root) {
|
|
1042
|
+
const file = join(root, "hooks", "hooks.json");
|
|
1043
|
+
if (!existsSync(file)) return [];
|
|
1044
|
+
try {
|
|
1045
|
+
const raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
1046
|
+
const events = hookEvents(isPlainObject(raw) ? raw.hooks ?? raw : {});
|
|
1047
|
+
return events.length ? [[
|
|
1048
|
+
"hooks",
|
|
1049
|
+
events.join(", "),
|
|
1050
|
+
""
|
|
1051
|
+
]] : [];
|
|
1052
|
+
} catch {
|
|
1053
|
+
return [];
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/runtime/lifecycle/cartographer/scan.ts
|
|
1058
|
+
/**
|
|
1059
|
+
* Plugin scanning (fs). Ports `scan_plugins.py`: turns a plugin's
|
|
1060
|
+
* agents/skills/commands/hooks into ordered `[type, name, desc]` rows.
|
|
1061
|
+
*/
|
|
1062
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
1063
|
+
function sortedNames(dir) {
|
|
1064
|
+
try {
|
|
1065
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
1066
|
+
} catch {
|
|
1067
|
+
return [];
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/** Read a `.md` frontmatter field from a file path, "" when missing/unreadable. */
|
|
1071
|
+
function fileField(path, field) {
|
|
1072
|
+
try {
|
|
1073
|
+
return parseField(readFileSync(path, "utf-8"), field);
|
|
1074
|
+
} catch {
|
|
1075
|
+
return "";
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/** Scan `agents/*.md` → `("agent", name, desc[:50])` rows. */
|
|
1079
|
+
function scanAgents(root) {
|
|
1080
|
+
const dir = join(root, "agents");
|
|
1081
|
+
return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => {
|
|
1082
|
+
const f = join(dir, n);
|
|
1083
|
+
return [
|
|
1084
|
+
"agent",
|
|
1085
|
+
fileField(f, "name") || n.replace(/\.md$/, ""),
|
|
1086
|
+
fileField(f, "description").slice(0, 50)
|
|
1087
|
+
];
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
/** Scan `skills/<dir>/SKILL.md` → `("skill", dir, desc)` rows. */
|
|
1091
|
+
function scanSkills(root) {
|
|
1092
|
+
const dir = join(root, "skills");
|
|
1093
|
+
const rows = [];
|
|
1094
|
+
for (const name of sortedNames(dir)) {
|
|
1095
|
+
try {
|
|
1096
|
+
if (!statSync(join(dir, name)).isDirectory()) continue;
|
|
1097
|
+
} catch {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const skillMd = join(dir, name, "SKILL.md");
|
|
1101
|
+
let desc = "";
|
|
1102
|
+
if (existsSync(skillMd)) {
|
|
1103
|
+
desc = fileField(skillMd, "description");
|
|
1104
|
+
if (!desc) try {
|
|
1105
|
+
desc = parseBodyDesc(readFileSync(skillMd, "utf-8"));
|
|
1106
|
+
} catch {}
|
|
1107
|
+
}
|
|
1108
|
+
rows.push([
|
|
1109
|
+
"skill",
|
|
1110
|
+
name,
|
|
1111
|
+
desc || "(no description)"
|
|
1112
|
+
]);
|
|
1113
|
+
}
|
|
1114
|
+
return rows;
|
|
1115
|
+
}
|
|
1116
|
+
/** Scan `commands/*.md` → `("command", "/name", desc[:50])` rows. */
|
|
1117
|
+
function scanCommands(root) {
|
|
1118
|
+
const dir = join(root, "commands");
|
|
1119
|
+
return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => [
|
|
1120
|
+
"command",
|
|
1121
|
+
`/${n.replace(/\.md$/, "")}`,
|
|
1122
|
+
fileField(join(dir, n), "description").slice(0, 50)
|
|
1123
|
+
]);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Scan a single plugin directory into ordered `[type, name, desc]` rows.
|
|
1127
|
+
* @param pluginDir - Absolute plugin directory.
|
|
1128
|
+
* @returns The agents + skills + commands + hooks rows.
|
|
1129
|
+
*/
|
|
1130
|
+
function scanPlugin(pluginDir) {
|
|
1131
|
+
return [
|
|
1132
|
+
...scanAgents(pluginDir),
|
|
1133
|
+
...scanSkills(pluginDir),
|
|
1134
|
+
...scanCommands(pluginDir),
|
|
1135
|
+
...scanHooks(pluginDir)
|
|
1136
|
+
];
|
|
1137
|
+
}
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/policy/expert-agents.ts
|
|
1140
|
+
/**
|
|
1141
|
+
* Dynamic expert-agent resolution (fs). Replaces a hardcoded agent-id table:
|
|
1142
|
+
* scans installed marketplace plugins for an `agents/*.md` whose frontmatter
|
|
1143
|
+
* `name` matches the detected {@link ProjectType}, returning the real
|
|
1144
|
+
* `<plugin>:<agent>` id — never a fictional one absent from disk.
|
|
1145
|
+
*
|
|
1146
|
+
* Reuses the cartographer's own fs primitives ({@link findMarketplacePlugins},
|
|
1147
|
+
* {@link readPluginMeta}, {@link scanAgents}) — the same shallow,
|
|
1148
|
+
* non-recursive scan already run uncached on every SessionStart
|
|
1149
|
+
* (`generateEcosystemMap`), scoped here to just `agents/*.md` (skips
|
|
1150
|
+
* skills/commands/hooks) to keep the per-prompt cost minimal.
|
|
1151
|
+
*/
|
|
1152
|
+
/** Sorted plugin-dir names directly under `pluginsDir` (dirs only, alpha), or `[]`. */
|
|
1153
|
+
function pluginDirs(pluginsDir) {
|
|
1154
|
+
let names;
|
|
1155
|
+
try {
|
|
1156
|
+
names = readdirSync(pluginsDir);
|
|
1157
|
+
} catch {
|
|
1158
|
+
return [];
|
|
1159
|
+
}
|
|
1160
|
+
return names.filter((n) => !n.startsWith(".") && !n.startsWith("_")).filter((n) => {
|
|
1161
|
+
try {
|
|
1162
|
+
return statSync(join(pluginsDir, n)).isDirectory();
|
|
1163
|
+
} catch {
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
}).sort((a, b) => a.localeCompare(b, "en"));
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Resolve the real, installed expert-agent id for `type`.
|
|
1170
|
+
* @param type - Detected project type.
|
|
1171
|
+
* @param pluginsDirOverride - Override for the marketplace plugins dir
|
|
1172
|
+
* (tests); defaults to the auto-detected marketplace root.
|
|
1173
|
+
* @returns `<plugin>:<agent>` for the first installed plugin whose agent name
|
|
1174
|
+
* starts with `type`, or "general-purpose" when none is installed.
|
|
1175
|
+
*/
|
|
1176
|
+
function getExpertAgent(type, pluginsDirOverride) {
|
|
1177
|
+
const pluginsDir = pluginsDirOverride ?? findMarketplacePlugins();
|
|
1178
|
+
for (const dir of pluginDirs(pluginsDir)) {
|
|
1179
|
+
const pluginPath = join(pluginsDir, dir);
|
|
1180
|
+
const agent = scanAgents(pluginPath).map(([, name]) => name).find((name) => name.toLowerCase().startsWith(type.toLowerCase()));
|
|
1181
|
+
if (agent) {
|
|
1182
|
+
const [, pkgName] = readPluginMeta(pluginPath);
|
|
1183
|
+
return `${pkgName || dir}:${agent}`;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return "general-purpose";
|
|
1187
|
+
}
|
|
1188
|
+
//#endregion
|
|
683
1189
|
//#region src/policy/claude-md-context.ts
|
|
684
1190
|
/** Dev-verb regex (FR/EN) that triggers the APEX preamble (case-insensitive). */
|
|
685
1191
|
const DEV_VERBS = /(cr[ée]er|impl[ée]menter|ajouter|d[ée]velopper|construire|build|refactor|migrer|implement|create|add|develop)/i;
|
|
@@ -712,12 +1218,13 @@ function detectClaudeMdProjectType(cwd) {
|
|
|
712
1218
|
* @returns The APEX instruction text.
|
|
713
1219
|
*/
|
|
714
1220
|
function buildApexInstruction(projectType, maxLines) {
|
|
1221
|
+
const expertAgent = getExpertAgent(projectType);
|
|
715
1222
|
return `INSTRUCTION: This is a development task. Use APEX methodology:
|
|
716
1223
|
|
|
717
1224
|
**TRACKING FILE**: [project]/.claude/apex/task.json (auto-created on first Write/Edit)
|
|
718
1225
|
|
|
719
1226
|
1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):
|
|
720
|
-
- explore-codebase + research-expert + ${
|
|
1227
|
+
- explore-codebase + research-expert + ${expertAgent} (framework expertise)\n - Project type detected: ${projectType}\n\n2. **PLAN**: Use TaskCreate to break down tasks (<${maxLines} lines per file)\n\n3. **EXECUTE**: ${expertAgent}, follow SOLID principles, split at ${maxLines - 10} lines\n\n4. **EXAMINE**: Run sniper agent after ANY modification\n\n**IMPORTANT**: Read .claude/apex/task.json to check documentation status before writing code.`;
|
|
721
1228
|
}
|
|
722
1229
|
/**
|
|
723
1230
|
* Build the UserPromptSubmit injection text: read `~/.claude/CLAUDE.md` and,
|
|
@@ -905,59 +1412,6 @@ const EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
|
905
1412
|
".swiftpm"
|
|
906
1413
|
]);
|
|
907
1414
|
//#endregion
|
|
908
|
-
//#region src/policy/cartographer/frontmatter.ts
|
|
909
|
-
/**
|
|
910
|
-
* Frontmatter parsing — pure text helpers (no fs). Ports `parse_frontmatter.py`.
|
|
911
|
-
*/
|
|
912
|
-
const BLOCK_SCALARS = /* @__PURE__ */ new Set([
|
|
913
|
-
"|",
|
|
914
|
-
">",
|
|
915
|
-
"|+",
|
|
916
|
-
"|-",
|
|
917
|
-
">+",
|
|
918
|
-
">-"
|
|
919
|
-
]);
|
|
920
|
-
/** Escape regex metacharacters in an arbitrary field name. */
|
|
921
|
-
function escapeRe(s) {
|
|
922
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
923
|
-
}
|
|
924
|
-
/**
|
|
925
|
-
* Extract a single frontmatter field's value from `text`. Strips surrounding
|
|
926
|
-
* quotes; skips YAML block-scalar markers. Returns "" when absent.
|
|
927
|
-
* @param text - The full document text.
|
|
928
|
-
* @param field - The frontmatter key to read.
|
|
929
|
-
* @returns The field value, or "".
|
|
930
|
-
*/
|
|
931
|
-
function parseField(text, field) {
|
|
932
|
-
const fm = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
933
|
-
if (!fm || fm[1] === void 0) return "";
|
|
934
|
-
const lineRe = new RegExp(`^${escapeRe(field)}\\s*:\\s*(.+)$`);
|
|
935
|
-
for (const line of fm[1].split("\n")) {
|
|
936
|
-
const m = lineRe.exec(line);
|
|
937
|
-
if (!m || m[1] === void 0) continue;
|
|
938
|
-
const val = m[1].trim().replace(/^["']|["']$/g, "");
|
|
939
|
-
if (BLOCK_SCALARS.has(val)) continue;
|
|
940
|
-
return val;
|
|
941
|
-
}
|
|
942
|
-
return "";
|
|
943
|
-
}
|
|
944
|
-
/**
|
|
945
|
-
* Derive a short description from the body following the frontmatter: the first
|
|
946
|
-
* non-empty trimmed line, sliced to `maxLen`. Returns "" when none.
|
|
947
|
-
* @param text - The full document text.
|
|
948
|
-
* @param maxLen - Maximum length of the returned description.
|
|
949
|
-
* @returns The body-derived description, or "".
|
|
950
|
-
*/
|
|
951
|
-
function parseBodyDesc(text, maxLen = 60) {
|
|
952
|
-
const m = /^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)/.exec(text);
|
|
953
|
-
if (!m || m[1] === void 0) return "";
|
|
954
|
-
for (const raw of m[1].split("\n")) {
|
|
955
|
-
const line = raw.trim();
|
|
956
|
-
if (line) return line.slice(0, maxLen);
|
|
957
|
-
}
|
|
958
|
-
return "";
|
|
959
|
-
}
|
|
960
|
-
//#endregion
|
|
961
1415
|
//#region src/policy/cartographer/entry.ts
|
|
962
1416
|
const ENTRY_RE = /^(.*?)\[([^\]]+)\]\(([^)]+)\)\s*(?:—|-{1,2})\s*(.*)$/;
|
|
963
1417
|
const ENRICH_RE = /^(?:.*?)\[([^\]]+)\]\(([^)]+)\)\s*(?:—|-{1,2})\s*(.+)$/;
|
|
@@ -1085,4 +1539,4 @@ function missingSeoElements(html) {
|
|
|
1085
1539
|
return missing;
|
|
1086
1540
|
}
|
|
1087
1541
|
//#endregion
|
|
1088
|
-
export {
|
|
1542
|
+
export { detectCreationIntent as A, detectProjectType as B, findMarketplacePlugins as C, MAX_EXA_RESULTS as D, frameworkSolidGate as E, freshnessGate as F, requiredArchSkill as H, solidReadGate as I, detectFramework as L, brainstormGate as M, docConsultedGate as N, MAX_TOKENS as O, evaluateApex as P, DEV_KEYWORDS as R, usesTailwindUtilities as S, SKILL_TRIGGERS as T, isApexCommand as V, scanPlugin as _, firstHeading as a, detectRequiredSkills as b, EXCLUDE_DIRS as c, buildApexTaskInjection as d, loadApexTaskState as f, detectClaudeMdProjectType as g, buildClaudeMdContext as h, firstComment as i, APEX_GATES as j, capVerbosity as k, PROJECT_INDICATORS as l, buildApexInstruction as m, missingSeoElements as n, parseEnrichment as o, DEV_VERBS as p, descFromText as r, parseEntry as s, isHtmlLike as t, buildApexTaskContext as u, parseBodyDesc as v, readPluginMeta as w, skillTriggerGate as x, parseField as y, detectModularArchitecture as z };
|