@fusengine/harness 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/adapters/claude/index.d.mts +2 -2
  2. package/dist/adapters/claude/index.mjs +2 -2
  3. package/dist/adapters/cline/index.mjs +1 -1
  4. package/dist/adapters/codex/index.d.mts +1 -1
  5. package/dist/adapters/codex/index.mjs +1 -1
  6. package/dist/adapters/cursor/index.mjs +1 -1
  7. package/dist/adapters/gemini/index.mjs +1 -1
  8. package/dist/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
  9. package/dist/cli/bin.mjs +233 -11
  10. package/dist/cli/index.mjs +1 -1
  11. package/dist/config/index.d.mts +1 -1
  12. package/dist/config/index.mjs +1 -2
  13. package/dist/{doc-helpers-D14nkD5D.d.mts → doc-helpers-BNfYWvYv.d.mts} +9 -1
  14. package/dist/{doc-helpers-BhzDmJ18.mjs → doc-helpers-CWZegVdR.mjs} +14 -5
  15. package/dist/{dotenv-DGyLln7U.mjs → dotenv-B9nM4cuQ.mjs} +26 -1
  16. package/dist/evaluate-d7Pp8XJH.mjs +784 -0
  17. package/dist/freshness/index.d.mts +1 -1
  18. package/dist/freshness/index.mjs +1 -1
  19. package/dist/{handle-BTHcKWQ5.mjs → handle-CtMMVoxT.mjs} +789 -357
  20. package/dist/home-state-mKZxP4oZ.mjs +52 -0
  21. package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -1
  22. package/dist/{index-DXQfL1u8.d.mts → index-BXPySPxE.d.mts} +7 -1
  23. package/dist/{index-CwOdFBOr.d.mts → index-BxjzFraL.d.mts} +3 -1
  24. package/dist/{index-DN4cZDbU.d.mts → index-CVhw7eA0.d.mts} +111 -23
  25. package/dist/index.d.mts +5 -5
  26. package/dist/index.mjs +7 -8
  27. package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
  28. package/dist/policy/index.d.mts +2 -2
  29. package/dist/policy/index.mjs +3 -3
  30. package/dist/refs/index.mjs +2 -2
  31. package/dist/{router-BfX0hJg8.mjs → router-PKVNBHge.mjs} +11 -1
  32. package/dist/{run-jgivVDv6.mjs → run-DLXtA5DH.mjs} +1 -1
  33. package/dist/runtime/index.d.mts +71 -14
  34. package/dist/runtime/index.mjs +3 -3
  35. package/dist/{session-state-Dzq6yrw7.d.mts → session-state-D4F_Dub6.d.mts} +1 -1
  36. package/dist/state/index.d.mts +1 -1
  37. package/dist/{store-CdWOQ9zD.mjs → store-CNjFenWe.mjs} +3 -50
  38. package/dist/tracking/index.d.mts +1 -1
  39. package/dist/tracking/index.mjs +1 -1
  40. package/dist/{validate-xi-zc-22.mjs → validate-Ca7NSp-r.mjs} +495 -83
  41. package/package.json +1 -1
  42. package/dist/evaluate-CeivW6G0.mjs +0 -477
  43. package/dist/ttl-BG55s6HZ.mjs +0 -20
@@ -1,9 +1,11 @@
1
1
  import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
- import { j as countLines } from "./evaluate-CeivW6G0.mjs";
3
- import { r as isDocConsulted, t as formatDocDeny } from "./doc-helpers-BhzDmJ18.mjs";
4
- import { t as routeReferences } from "./router-BfX0hJg8.mjs";
5
- import { join } from "node:path";
6
- import { existsSync, readFileSync, readdirSync } from "node:fs";
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("tailwind.config.js") || has("tailwind.config.ts")) return "tailwind";
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/apex.ts
68
- /** Gate: any one documentation source (Context7, Exa, or web) must have been consulted this session. */
69
- const docConsultedGate = (ctx) => isDocConsulted(ctx.authorizations, ctx.sessionId) ? null : {
70
- kind: "block",
71
- title: "APEX: documentation not consulted",
72
- reason: formatDocDeny(ctx.framework),
73
- actions: ["Use ANY ONE of: mcp__context7__query-docs, mcp__exa__web_search_exa, WebSearch, or WebFetch"]
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
- if (!routed) return null;
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: `Read these before editing ${ctx.filePath}:`,
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) => ctx.agentsFresh === false ? {
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: explore + research required",
94
- reason: `Run explore-codebase and research-expert (within the freshness window) before editing ${ctx.framework}.`,
95
- actions: ["Launch the explore-codebase agent", "Launch the research-expert agent"]
96
- } : null;
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 ?? countLines(content);
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 ?? countLines(content);
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 ?? countLines(content);
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 ?? countLines(content);
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
@@ -655,6 +823,24 @@ function detectRequiredSkills(framework, content) {
655
823
  return required;
656
824
  }
657
825
  /**
826
+ * `.tsx`/`.jsx` — the only extensions `check-tailwind-skill.py` actually gates
827
+ * (its regex also lists `.css`/`.html`, but both hit an early return right
828
+ * after, in the Python source).
829
+ */
830
+ const TAILWIND_FILE = /\.(tsx|jsx)$/;
831
+ /** Ported verbatim from `check-tailwind-skill.py`'s `TW_PATTERN`. */
832
+ const TAILWIND_CONTENT = /(className|class).*['"].*\b(flex|grid|p-|m-|w-|h-|text-|bg-|border-)/;
833
+ /**
834
+ * True when `filePath`/`content` match the Python Tailwind gate's trigger
835
+ * condition. React/Next.js components embed Tailwind utility classes in
836
+ * `className` — this check fires IN ADDITION TO the primary framework gate,
837
+ * never instead of it: {@link detectFramework} keeps returning "react"/
838
+ * "nextjs" for these files (framework SOLID rules stay correct).
839
+ */
840
+ function usesTailwindUtilities(filePath, content) {
841
+ return TAILWIND_FILE.test(filePath) && TAILWIND_CONTENT.test(content);
842
+ }
843
+ /**
658
844
  * Block when a required sub-skill's `skills/<name>/` path is absent from
659
845
  * `refsRead`. Mirrors `specific_skill_consulted`, which confirms a skill was
660
846
  * read by checking the tracking file contains `skills/<name>/`.
@@ -664,10 +850,15 @@ function detectRequiredSkills(framework, content) {
664
850
  * @param forcedSkill - a skill the detected modular architecture forces (optional).
665
851
  * @param cwd - project root; when set and not a shadcn project, `*-shadcn`
666
852
  * requirements are skipped (ports the Python `is_shadcn_project` filter).
853
+ * @param filePath - the file being written; when it's a `.tsx`/`.jsx` file
854
+ * with Tailwind utility classes in `className`, the "tailwind" domain
855
+ * skills are merged in alongside `framework`'s own (ports the separate
856
+ * `check-tailwind-skill.py` gate, independent of react/nextjs).
667
857
  * @returns a `block` Prompt naming the missing sub-skills, or `null` when satisfied.
668
858
  */
669
- function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd) {
859
+ function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd, filePath) {
670
860
  let required = detectRequiredSkills(framework, content);
861
+ if (framework !== "tailwind" && filePath && usesTailwindUtilities(filePath, content)) required = [...required, ...detectRequiredSkills("tailwind", content)];
671
862
  if (forcedSkill && !required.includes(forcedSkill)) required.push(forcedSkill);
672
863
  if (cwd && !isShadcnProject(cwd)) required = required.filter((s) => !s.endsWith("-shadcn"));
673
864
  const missing = required.filter((s) => !refsRead.some((r) => r.includes(`skills/${s}/`)));
@@ -680,6 +871,279 @@ function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd) {
680
871
  };
681
872
  }
682
873
  //#endregion
874
+ //#region src/runtime/lifecycle/cartographer/detect.ts
875
+ /**
876
+ * Plugin discovery (fs). Ports `detect_plugins.py`: marketplace `plugins` dir
877
+ * resolution + `plugin.json` meta reading.
878
+ */
879
+ /** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
880
+ function sortedNames$1(dir) {
881
+ try {
882
+ return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
883
+ } catch {
884
+ return [];
885
+ }
886
+ }
887
+ /**
888
+ * Read `[version, name]` from `<pluginPath>/.claude-plugin/plugin.json`.
889
+ * @param pluginPath - Absolute plugin directory.
890
+ * @returns The `[version, name]` pair (both "" when absent/unreadable).
891
+ */
892
+ function readPluginMeta(pluginPath) {
893
+ const pj = join(pluginPath, ".claude-plugin", "plugin.json");
894
+ if (!existsSync(pj)) return ["", ""];
895
+ try {
896
+ const meta = JSON.parse(readFileSync(pj, "utf-8"));
897
+ return [meta.version ?? "", meta.name ?? ""];
898
+ } catch {
899
+ return ["", ""];
900
+ }
901
+ }
902
+ /**
903
+ * Auto-detect the marketplace `plugins` dir that contains `cartographer`,
904
+ * falling back to the first marketplace with a `plugins` dir, else `cwd`.
905
+ * Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
906
+ * derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
907
+ * shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
908
+ * @param home - Home directory (defaults to `~`).
909
+ * @param id - Detected harness id (defaults to runtime detection).
910
+ * @returns The resolved plugins directory.
911
+ */
912
+ function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
913
+ const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
914
+ const markets = sortedNames$1(mp);
915
+ for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
916
+ for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
917
+ return process.cwd();
918
+ }
919
+ //#endregion
920
+ //#region src/policy/cartographer/frontmatter.ts
921
+ /**
922
+ * Frontmatter parsing — pure text helpers (no fs). Ports `parse_frontmatter.py`.
923
+ */
924
+ const BLOCK_SCALARS = /* @__PURE__ */ new Set([
925
+ "|",
926
+ ">",
927
+ "|+",
928
+ "|-",
929
+ ">+",
930
+ ">-"
931
+ ]);
932
+ /** Escape regex metacharacters in an arbitrary field name. */
933
+ function escapeRe(s) {
934
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
935
+ }
936
+ /**
937
+ * Extract a single frontmatter field's value from `text`. Strips surrounding
938
+ * quotes; skips YAML block-scalar markers. Returns "" when absent.
939
+ * @param text - The full document text.
940
+ * @param field - The frontmatter key to read.
941
+ * @returns The field value, or "".
942
+ */
943
+ function parseField(text, field) {
944
+ const fm = /^---\s*\n([\s\S]*?)\n---/.exec(text);
945
+ if (!fm || fm[1] === void 0) return "";
946
+ const lineRe = new RegExp(`^${escapeRe(field)}\\s*:\\s*(.+)$`);
947
+ for (const line of fm[1].split("\n")) {
948
+ const m = lineRe.exec(line);
949
+ if (!m || m[1] === void 0) continue;
950
+ const val = m[1].trim().replace(/^["']|["']$/g, "");
951
+ if (BLOCK_SCALARS.has(val)) continue;
952
+ return val;
953
+ }
954
+ return "";
955
+ }
956
+ /**
957
+ * Derive a short description from the body following the frontmatter: the first
958
+ * non-empty trimmed line, sliced to `maxLen`. Returns "" when none.
959
+ * @param text - The full document text.
960
+ * @param maxLen - Maximum length of the returned description.
961
+ * @returns The body-derived description, or "".
962
+ */
963
+ function parseBodyDesc(text, maxLen = 60) {
964
+ const m = /^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)/.exec(text);
965
+ if (!m || m[1] === void 0) return "";
966
+ for (const raw of m[1].split("\n")) {
967
+ const line = raw.trim();
968
+ if (line) return line.slice(0, maxLen);
969
+ }
970
+ return "";
971
+ }
972
+ //#endregion
973
+ //#region src/runtime/lifecycle/cartographer/scan-hooks.ts
974
+ /**
975
+ * Hook scanning (fs). Ports `_scan_hooks` from `scan_plugins.py`: reads a
976
+ * plugin's `hooks/hooks.json` and reduces it to a single `("hooks", "<events>", "")`
977
+ * row. The `hooks` value (or the whole document) is either a MAP of event →
978
+ * entries (keys are the events) or a LIST of entries each carrying an `event`.
979
+ */
980
+ /** True for a non-null plain object (excludes arrays), mirroring Python `isinstance(x, dict)`. */
981
+ function isPlainObject(v) {
982
+ return typeof v === "object" && v !== null && !Array.isArray(v);
983
+ }
984
+ /** Sort unique non-empty strings alpha (byte-order). */
985
+ function sortedEvents(events) {
986
+ return [...new Set([...events].filter((e) => e))].sort((a, b) => a.localeCompare(b, "en"));
987
+ }
988
+ /** Derive hook event names from the `hooks.json` data (map keys, or list `event` fields). */
989
+ function hookEvents(hooksData) {
990
+ if (isPlainObject(hooksData)) return sortedEvents(Object.keys(hooksData).filter((k) => !k.startsWith("_")));
991
+ if (Array.isArray(hooksData)) return sortedEvents(hooksData.map((h) => isPlainObject(h) ? String(h.event ?? "") : ""));
992
+ return [];
993
+ }
994
+ /**
995
+ * Scan `hooks/hooks.json` into a single `("hooks", "<events>", "")` row.
996
+ * @param root - Absolute plugin directory.
997
+ * @returns The single hooks row, or `[]` when absent/empty/unreadable.
998
+ */
999
+ function scanHooks(root) {
1000
+ const file = join(root, "hooks", "hooks.json");
1001
+ if (!existsSync(file)) return [];
1002
+ try {
1003
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
1004
+ const events = hookEvents(isPlainObject(raw) ? raw.hooks ?? raw : {});
1005
+ return events.length ? [[
1006
+ "hooks",
1007
+ events.join(", "),
1008
+ ""
1009
+ ]] : [];
1010
+ } catch {
1011
+ return [];
1012
+ }
1013
+ }
1014
+ //#endregion
1015
+ //#region src/runtime/lifecycle/cartographer/scan.ts
1016
+ /**
1017
+ * Plugin scanning (fs). Ports `scan_plugins.py`: turns a plugin's
1018
+ * agents/skills/commands/hooks into ordered `[type, name, desc]` rows.
1019
+ */
1020
+ /** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
1021
+ function sortedNames(dir) {
1022
+ try {
1023
+ return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
1024
+ } catch {
1025
+ return [];
1026
+ }
1027
+ }
1028
+ /** Read a `.md` frontmatter field from a file path, "" when missing/unreadable. */
1029
+ function fileField(path, field) {
1030
+ try {
1031
+ return parseField(readFileSync(path, "utf-8"), field);
1032
+ } catch {
1033
+ return "";
1034
+ }
1035
+ }
1036
+ /** Scan `agents/*.md` → `("agent", name, desc[:50])` rows. */
1037
+ function scanAgents(root) {
1038
+ const dir = join(root, "agents");
1039
+ return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => {
1040
+ const f = join(dir, n);
1041
+ return [
1042
+ "agent",
1043
+ fileField(f, "name") || n.replace(/\.md$/, ""),
1044
+ fileField(f, "description").slice(0, 50)
1045
+ ];
1046
+ });
1047
+ }
1048
+ /** Scan `skills/<dir>/SKILL.md` → `("skill", dir, desc)` rows. */
1049
+ function scanSkills(root) {
1050
+ const dir = join(root, "skills");
1051
+ const rows = [];
1052
+ for (const name of sortedNames(dir)) {
1053
+ try {
1054
+ if (!statSync(join(dir, name)).isDirectory()) continue;
1055
+ } catch {
1056
+ continue;
1057
+ }
1058
+ const skillMd = join(dir, name, "SKILL.md");
1059
+ let desc = "";
1060
+ if (existsSync(skillMd)) {
1061
+ desc = fileField(skillMd, "description");
1062
+ if (!desc) try {
1063
+ desc = parseBodyDesc(readFileSync(skillMd, "utf-8"));
1064
+ } catch {}
1065
+ }
1066
+ rows.push([
1067
+ "skill",
1068
+ name,
1069
+ desc || "(no description)"
1070
+ ]);
1071
+ }
1072
+ return rows;
1073
+ }
1074
+ /** Scan `commands/*.md` → `("command", "/name", desc[:50])` rows. */
1075
+ function scanCommands(root) {
1076
+ const dir = join(root, "commands");
1077
+ return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => [
1078
+ "command",
1079
+ `/${n.replace(/\.md$/, "")}`,
1080
+ fileField(join(dir, n), "description").slice(0, 50)
1081
+ ]);
1082
+ }
1083
+ /**
1084
+ * Scan a single plugin directory into ordered `[type, name, desc]` rows.
1085
+ * @param pluginDir - Absolute plugin directory.
1086
+ * @returns The agents + skills + commands + hooks rows.
1087
+ */
1088
+ function scanPlugin(pluginDir) {
1089
+ return [
1090
+ ...scanAgents(pluginDir),
1091
+ ...scanSkills(pluginDir),
1092
+ ...scanCommands(pluginDir),
1093
+ ...scanHooks(pluginDir)
1094
+ ];
1095
+ }
1096
+ //#endregion
1097
+ //#region src/policy/expert-agents.ts
1098
+ /**
1099
+ * Dynamic expert-agent resolution (fs). Replaces a hardcoded agent-id table:
1100
+ * scans installed marketplace plugins for an `agents/*.md` whose frontmatter
1101
+ * `name` matches the detected {@link ProjectType}, returning the real
1102
+ * `<plugin>:<agent>` id — never a fictional one absent from disk.
1103
+ *
1104
+ * Reuses the cartographer's own fs primitives ({@link findMarketplacePlugins},
1105
+ * {@link readPluginMeta}, {@link scanAgents}) — the same shallow,
1106
+ * non-recursive scan already run uncached on every SessionStart
1107
+ * (`generateEcosystemMap`), scoped here to just `agents/*.md` (skips
1108
+ * skills/commands/hooks) to keep the per-prompt cost minimal.
1109
+ */
1110
+ /** Sorted plugin-dir names directly under `pluginsDir` (dirs only, alpha), or `[]`. */
1111
+ function pluginDirs(pluginsDir) {
1112
+ let names;
1113
+ try {
1114
+ names = readdirSync(pluginsDir);
1115
+ } catch {
1116
+ return [];
1117
+ }
1118
+ return names.filter((n) => !n.startsWith(".") && !n.startsWith("_")).filter((n) => {
1119
+ try {
1120
+ return statSync(join(pluginsDir, n)).isDirectory();
1121
+ } catch {
1122
+ return false;
1123
+ }
1124
+ }).sort((a, b) => a.localeCompare(b, "en"));
1125
+ }
1126
+ /**
1127
+ * Resolve the real, installed expert-agent id for `type`.
1128
+ * @param type - Detected project type.
1129
+ * @param pluginsDirOverride - Override for the marketplace plugins dir
1130
+ * (tests); defaults to the auto-detected marketplace root.
1131
+ * @returns `<plugin>:<agent>` for the first installed plugin whose agent name
1132
+ * starts with `type`, or "general-purpose" when none is installed.
1133
+ */
1134
+ function getExpertAgent(type, pluginsDirOverride) {
1135
+ const pluginsDir = pluginsDirOverride ?? findMarketplacePlugins();
1136
+ for (const dir of pluginDirs(pluginsDir)) {
1137
+ const pluginPath = join(pluginsDir, dir);
1138
+ const agent = scanAgents(pluginPath).map(([, name]) => name).find((name) => name.toLowerCase().startsWith(type.toLowerCase()));
1139
+ if (agent) {
1140
+ const [, pkgName] = readPluginMeta(pluginPath);
1141
+ return `${pkgName || dir}:${agent}`;
1142
+ }
1143
+ }
1144
+ return "general-purpose";
1145
+ }
1146
+ //#endregion
683
1147
  //#region src/policy/claude-md-context.ts
684
1148
  /** Dev-verb regex (FR/EN) that triggers the APEX preamble (case-insensitive). */
685
1149
  const DEV_VERBS = /(cr[ée]er|impl[ée]menter|ajouter|d[ée]velopper|construire|build|refactor|migrer|implement|create|add|develop)/i;
@@ -712,12 +1176,13 @@ function detectClaudeMdProjectType(cwd) {
712
1176
  * @returns The APEX instruction text.
713
1177
  */
714
1178
  function buildApexInstruction(projectType, maxLines) {
1179
+ const expertAgent = getExpertAgent(projectType);
715
1180
  return `INSTRUCTION: This is a development task. Use APEX methodology:
716
1181
 
717
1182
  **TRACKING FILE**: [project]/.claude/apex/task.json (auto-created on first Write/Edit)
718
1183
 
719
1184
  1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):
720
- - explore-codebase + research-expert + ${projectType}-expert (framework expertise)\n - Project type detected: ${projectType}\n\n2. **PLAN**: Use TaskCreate to break down tasks (<${maxLines} lines per file)\n\n3. **EXECUTE**: ${projectType}-expert, 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.`;
1185
+ - 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
1186
  }
722
1187
  /**
723
1188
  * Build the UserPromptSubmit injection text: read `~/.claude/CLAUDE.md` and,
@@ -905,59 +1370,6 @@ const EXCLUDE_DIRS = /* @__PURE__ */ new Set([
905
1370
  ".swiftpm"
906
1371
  ]);
907
1372
  //#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
1373
  //#region src/policy/cartographer/entry.ts
962
1374
  const ENTRY_RE = /^(.*?)\[([^\]]+)\]\(([^)]+)\)\s*(?:—|-{1,2})\s*(.*)$/;
963
1375
  const ENRICH_RE = /^(?:.*?)\[([^\]]+)\]\(([^)]+)\)\s*(?:—|-{1,2})\s*(.+)$/;
@@ -1085,4 +1497,4 @@ function missingSeoElements(html) {
1085
1497
  return missing;
1086
1498
  }
1087
1499
  //#endregion
1088
- export { evaluateApex as A, MAX_EXA_RESULTS as C, APEX_GATES as D, detectCreationIntent as E, detectProjectType as F, isApexCommand as I, requiredArchSkill as L, solidReadGate as M, DEV_KEYWORDS as N, brainstormGate as O, detectModularArchitecture as P, frameworkSolidGate as S, capVerbosity as T, buildClaudeMdContext as _, firstHeading as a, skillTriggerGate as b, parseBodyDesc as c, PROJECT_INDICATORS as d, buildApexTaskContext as f, buildApexInstruction as g, DEV_VERBS as h, firstComment as i, freshnessGate as j, docConsultedGate as k, parseField as l, loadApexTaskState as m, missingSeoElements as n, parseEnrichment as o, buildApexTaskInjection as p, descFromText as r, parseEntry as s, isHtmlLike as t, EXCLUDE_DIRS as u, detectClaudeMdProjectType as v, MAX_TOKENS as w, SKILL_TRIGGERS as x, detectRequiredSkills as y };
1500
+ export { detectCreationIntent as A, detectProjectType as B, skillTriggerGate 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, detectRequiredSkills as S, SKILL_TRIGGERS as T, isApexCommand as V, scanPlugin as _, firstHeading as a, findMarketplacePlugins 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, usesTailwindUtilities as w, readPluginMeta as x, parseField as y, detectModularArchitecture as z };