@fusengine/harness 0.1.28 → 0.1.29

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.
@@ -0,0 +1,676 @@
1
+ import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
+ import { k as countLines } from "./evaluate-j3gRJ_ng.mjs";
3
+ import { r as isDocConsulted, t as formatDocDeny } from "./doc-helpers-Dd_x1-tZ.mjs";
4
+ import { t as routeReferences } from "./router-D8cVrI-s.mjs";
5
+ import { join } from "node:path";
6
+ import { existsSync } from "node:fs";
7
+ //#region src/policy/detect-project.ts
8
+ /** Keywords that signal a development task (APEX trigger). */
9
+ const DEV_KEYWORDS = /\b(implement|create|build|fix|add|refactor|develop|feature|bug|update|modify|change|write|code)\b/i;
10
+ /** True when the prompt invokes the /apex command. */
11
+ function isApexCommand(prompt) {
12
+ return /(?:^|\s)\/apex|\/fuse-ai-pilot:apex/i.test(prompt);
13
+ }
14
+ /**
15
+ * Detect a project-internal modular architecture (a sub-architecture the
16
+ * framework-level {@link detectProjectType} doesn't capture): Fusengine's
17
+ * FuseCore (Laravel) or a `modules/`-based Next.js layout.
18
+ */
19
+ function detectModularArchitecture(dir) {
20
+ const has = (f) => existsSync(join(dir, f));
21
+ if (has("FuseCore") && has("artisan")) return "fusecore";
22
+ if (has("modules") && (has("next.config.js") || has("next.config.ts") || has("next.config.mjs"))) return "nextjs-modular";
23
+ return null;
24
+ }
25
+ /**
26
+ * Resolve the skill a detected modular architecture forces.
27
+ *
28
+ * Ports the Python `check-nextjs-skill.py` / `check-laravel-skill.py` gates:
29
+ * when the project is detected on disk as a modular architecture, a specific
30
+ * skill is required ('solid-nextjs' for nextjs-modular, 'fusecore' for
31
+ * fusecore). Returns `null` when no modular architecture is detected.
32
+ *
33
+ * @param cwd - Project root directory to scan.
34
+ * @returns The forced skill name, or `null` when none applies.
35
+ */
36
+ function requiredArchSkill(cwd) {
37
+ switch (detectModularArchitecture(cwd)) {
38
+ case "nextjs-modular": return "solid-nextjs";
39
+ case "fusecore": return "fusecore";
40
+ default: return null;
41
+ }
42
+ }
43
+ /** Detect the project type by scanning config files in `dir`. */
44
+ function detectProjectType(dir) {
45
+ const has = (f) => existsSync(join(dir, f));
46
+ if (has("next.config.js") || has("next.config.ts") || has("next.config.mjs")) return "nextjs";
47
+ if (has("nuxt.config.ts") || has("nuxt.config.js")) return "nuxt";
48
+ if (has("angular.json")) return "angular";
49
+ if (has("svelte.config.js") || has("svelte.config.ts")) return "svelte";
50
+ if (has("vite.config.ts") && has("src/App.vue")) return "vue";
51
+ if (has("vite.config.ts") || has("vite.config.js")) return "react";
52
+ if (has("tailwind.config.js") || has("tailwind.config.ts")) return "tailwind";
53
+ if (has("composer.json") && has("artisan")) return "laravel";
54
+ if (has("Gemfile") && has("config/routes.rb")) return "rails";
55
+ if (has("requirements.txt") || has("pyproject.toml") || has("setup.py")) return has("manage.py") ? "django" : "python";
56
+ if (has("go.mod")) return "go";
57
+ if (has("Cargo.toml")) return "rust";
58
+ if (has("Package.swift")) return "swift";
59
+ if (has("pom.xml") || has("build.gradle") || has("build.gradle.kts")) return "java";
60
+ if (has("build.sbt")) return "scala";
61
+ if (has("mix.exs")) return "elixir";
62
+ if (has("Gemfile")) return "ruby";
63
+ return "generic";
64
+ }
65
+ //#endregion
66
+ //#region src/policy/apex.ts
67
+ /** Gate: Context7 + Exa must have been consulted this session. */
68
+ const docConsultedGate = (ctx) => isDocConsulted(ctx.authorizations, ctx.sessionId) ? null : {
69
+ kind: "block",
70
+ title: "APEX: documentation not consulted",
71
+ reason: formatDocDeny(ctx.framework),
72
+ actions: ["Call mcp__context7__query-docs", "Call mcp__exa__web_search_exa"]
73
+ };
74
+ /** Gate: the routed SOLID references for this edit must have been read. */
75
+ const solidReadGate = (ctx) => {
76
+ if (!ctx.refs?.length) return null;
77
+ const routed = routeReferences(ctx.refs, ctx.filePath, ctx.content);
78
+ if (!routed) return null;
79
+ const read = new Set(ctx.refsRead ?? []);
80
+ const missing = routed.required.map((r) => r.meta.filePath).filter((p) => !read.has(p));
81
+ if (missing.length === 0) return null;
82
+ return {
83
+ kind: "block",
84
+ title: `APEX: read SOLID references for ${ctx.framework}`,
85
+ reason: `Read these before editing ${ctx.filePath}:`,
86
+ actions: missing
87
+ };
88
+ };
89
+ /** Gate: the required prior agents (explore + research) must have run within the window. */
90
+ const freshnessGate = (ctx) => ctx.agentsFresh === false ? {
91
+ kind: "block",
92
+ title: "APEX: explore + research required",
93
+ reason: `Run explore-codebase and research-expert (within the freshness window) before editing ${ctx.framework}.`,
94
+ actions: ["Launch the explore-codebase agent", "Launch the research-expert agent"]
95
+ } : null;
96
+ /** Gate: brainstorming must precede creating new files when flagged. */
97
+ const brainstormGate = (ctx) => ctx.brainstormRequired && ctx.brainstormFresh === false ? {
98
+ kind: "block",
99
+ title: "APEX: brainstorm first",
100
+ reason: `Creation intent detected — brainstorm before creating new ${ctx.framework} files.`,
101
+ actions: ["Launch the brainstorming agent"]
102
+ } : null;
103
+ /** Default APEX gate chain (brainstorm, freshness, docs, SOLID refs). */
104
+ const APEX_GATES = [
105
+ brainstormGate,
106
+ freshnessGate,
107
+ docConsultedGate,
108
+ solidReadGate
109
+ ];
110
+ /**
111
+ * Run the APEX gates (chain-of-responsibility): the first failing gate's prompt
112
+ * wins; null means every gate passed (allow).
113
+ */
114
+ function evaluateApex(ctx, gates = APEX_GATES) {
115
+ return gates.reduce((hit, gate) => hit ?? gate(ctx), null);
116
+ }
117
+ //#endregion
118
+ //#region src/policy/creation-intent.ts
119
+ const CREATE_RE = /\b(?:create|implement|add|build|new|feature|component|generate|make|develop|scaffold)\b/i;
120
+ const SKIP_RE = /\b(?:fix|bug|debug|update|refactor|rename|move|delete|remove|commit|push|edit|modify|change)\b/i;
121
+ /**
122
+ * True when a prompt expresses creation intent (a new feature/component) and is
123
+ * not a fix/refactor — the signal that brainstorming should precede creation.
124
+ * The harness calls this on UserPromptSubmit, then `recordBrainstormRequired`.
125
+ */
126
+ function detectCreationIntent(prompt) {
127
+ return CREATE_RE.test(prompt) && !SKIP_RE.test(prompt);
128
+ }
129
+ //#endregion
130
+ //#region src/policy/verbosity.ts
131
+ /** Exa MCP tools whose result count + token budget are capped. */
132
+ const EXA_TOOLS = /exa__web_search|exa__get_code_context|exa_web_search|exa_get_code_context/i;
133
+ /** Context7 doc tool whose token budget is capped. */
134
+ const CONTEXT7_TOOLS = /context7__query-docs|context7_query-docs|query-docs/i;
135
+ /** Max results an exa MCP call may request. */
136
+ const MAX_EXA_RESULTS = 3;
137
+ /** Max token budget for exa `tokensNum` / context7 `tokens`. */
138
+ const MAX_TOKENS = 2e3;
139
+ /**
140
+ * Cap an MCP call's verbosity — exa `numResults` ≤ 3 (+ `tokensNum` ≤ 2000),
141
+ * Context7 `tokens` ≤ 2000. Returns the capped input (a mutation for the harness
142
+ * to apply) when a change is needed, else null.
143
+ */
144
+ function capVerbosity(tool, input) {
145
+ const out = { ...input };
146
+ let changed = false;
147
+ const cap = (key, max, force) => {
148
+ const v = out[key];
149
+ if (typeof v === "number" && v > max || force && typeof v !== "number") {
150
+ out[key] = max;
151
+ changed = true;
152
+ }
153
+ };
154
+ if (EXA_TOOLS.test(tool)) {
155
+ cap("numResults", 3, true);
156
+ cap("tokensNum", MAX_TOKENS, false);
157
+ } else if (CONTEXT7_TOOLS.test(tool)) cap("tokens", MAX_TOKENS, false);
158
+ return changed ? out : null;
159
+ }
160
+ //#endregion
161
+ //#region src/policy/framework-solid-exclude.ts
162
+ /** Build-output / dependency paths excluded from React/Next.js SOLID gating. */
163
+ const JS_EXCLUDE_RE = /(node_modules|dist|build|\.next)/;
164
+ /** Vendored dependency paths excluded from Laravel/PHP SOLID gating. */
165
+ const PHP_EXCLUDE_RE = /\/vendor\//;
166
+ /** Derived/build artifact paths excluded from Swift SOLID gating. */
167
+ const SWIFT_EXCLUDE_RE = /(\.build|DerivedData|Pods)/;
168
+ /**
169
+ * Whether a JS/TS (React/Next.js) file path is an excluded build artifact.
170
+ * Matches the Python validators' early-return guard to avoid false positives.
171
+ * @param filePath - absolute path of the file under validation
172
+ */
173
+ function isExcludedJsPath(filePath) {
174
+ return JS_EXCLUDE_RE.test(filePath);
175
+ }
176
+ /**
177
+ * Whether a PHP (Laravel) file path is a vendored dependency to skip.
178
+ * @param filePath - absolute path of the file under validation
179
+ */
180
+ function isExcludedPhpPath(filePath) {
181
+ return PHP_EXCLUDE_RE.test(filePath);
182
+ }
183
+ /**
184
+ * Whether a Swift file path is a derived/build artifact to skip.
185
+ * @param filePath - absolute path of the file under validation
186
+ */
187
+ function isExcludedSwiftPath(filePath) {
188
+ return SWIFT_EXCLUDE_RE.test(filePath);
189
+ }
190
+ //#endregion
191
+ //#region src/policy/framework-solid-gates.ts
192
+ /** Custom hook export (React): `export function/const use[A-Z]`. */
193
+ const HOOK_RE = /^export (function|const) use[A-Z]/m;
194
+ /** Top-level TS interface/type declaration. */
195
+ const TS_DECL_RE = /^(export )?(interface|type) [A-Z]/m;
196
+ /** Client-only React hooks that require the `'use client'` directive. */
197
+ const CLIENT_HOOK_RE = /(useState|useEffect|useRef|onClick|onChange)/;
198
+ /** PHP top-level `interface` declaration. */
199
+ const PHP_INTERFACE_RE = /^interface /m;
200
+ /** Swift top-level `protocol` declaration. */
201
+ const SWIFT_PROTOCOL_RE = /^protocol /m;
202
+ /** Swift type declaration (`class`/`struct`) opening a body. */
203
+ const SWIFT_TYPE_RE = /^(class|struct) [^\n{]* \{/m;
204
+ /** React: line limit, interface separation, custom hooks under `/hooks/`. */
205
+ function reactGate(filePath, content, fileLines) {
206
+ const v = [];
207
+ const max = resolveMaxLines();
208
+ const lines = fileLines ?? countLines(content);
209
+ if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Split to hooks/, components/, or utils/.`);
210
+ if (filePath.includes("/components/") && TS_DECL_RE.test(content)) v.push("Interface/type in component. Move to src/interfaces/ or src/types/.");
211
+ if (HOOK_RE.test(content) && !filePath.includes("/hooks/")) v.push("Custom hook defined outside hooks/ directory. Move to hooks/.");
212
+ return v;
213
+ }
214
+ /** Next.js: adaptive line limit, interface separation, `'use client'`. */
215
+ function nextGate(filePath, content, fileLines) {
216
+ const v = [];
217
+ const max = /(page|layout|loading|error|not-found)\.(tsx|ts)$/.test(filePath) ? 150 : 100;
218
+ const lines = fileLines ?? countLines(content);
219
+ if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Split to lib/, hooks/, or components/.`);
220
+ 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/.");
221
+ if (CLIENT_HOOK_RE.test(content)) {
222
+ const head = content.split("\n").slice(0, 5).join("\n");
223
+ if (!head.includes("'use client'") && !head.includes("\"use client\"")) v.push("Client hooks detected but 'use client' directive missing at top.");
224
+ }
225
+ return v;
226
+ }
227
+ /** Laravel/PHP: line limit, interface outside `/Contracts/`, fat controller (>80). */
228
+ function laravelGate(filePath, content, fileLines) {
229
+ const v = [];
230
+ const lines = fileLines ?? countLines(content);
231
+ const max = resolveMaxLines();
232
+ if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Split using Services, Actions, or Traits.`);
233
+ if (PHP_INTERFACE_RE.test(content) && !filePath.includes("/Contracts/")) v.push("Interface defined outside Contracts/. Move to app/Contracts/ or FuseCore/{Module}/App/Contracts/.");
234
+ if (filePath.includes("/Controllers/") && lines > 80) v.push(`Fat controller (${lines} lines). Extract logic to Services or Actions.`);
235
+ return v;
236
+ }
237
+ /** Swift: adaptive limit, protocol separation, @MainActor, Sendable. */
238
+ function swiftGate(filePath, content, fileLines) {
239
+ const v = [];
240
+ const max = /(View|Screen)\.swift$/.test(filePath) ? 150 : 100;
241
+ const lines = fileLines ?? countLines(content);
242
+ if (lines > max) v.push(`File has ${lines} lines (limit: ${max}). Extract to ViewModels, Services, or subviews.`);
243
+ if (SWIFT_PROTOCOL_RE.test(content) && !filePath.includes("/Protocols/")) v.push("Protocol defined outside Protocols/ directory.");
244
+ if (filePath.endsWith("ViewModel.swift") && !content.includes("@MainActor")) v.push("ViewModel missing @MainActor annotation.");
245
+ if (SWIFT_TYPE_RE.test(content) && content.includes("async ") && !content.includes("Sendable")) v.push("Type uses async but doesn't conform to Sendable.");
246
+ return v;
247
+ }
248
+ //#endregion
249
+ //#region src/policy/framework-solid.ts
250
+ /** Next.js detection: directive, runtime types, or a `next` import. */
251
+ const NEXT_RE = /(use client|use server|NextRequest|NextResponse|from ['"]next)/;
252
+ /** Build a SOLID `block` prompt from one or more violation messages. */
253
+ function block(filePath, violations) {
254
+ return {
255
+ kind: "block",
256
+ title: "SOLID violation",
257
+ reason: `SOLID VIOLATION in ${filePath}: ${violations.join(" ")}`,
258
+ actions: violations
259
+ };
260
+ }
261
+ /** Run the JS/TS gate (React or Next.js), honoring build-artifact exclusions. */
262
+ function jsViolations(filePath, content, fileLines) {
263
+ if (isExcludedJsPath(filePath)) return [];
264
+ return NEXT_RE.test(content) ? nextGate(filePath, content, fileLines) : reactGate(filePath, content, fileLines);
265
+ }
266
+ /**
267
+ * Framework-specific SOLID gate. Dispatches by extension/path to the matching
268
+ * validator (React, Next.js, Laravel, Swift) and returns a blocking
269
+ * {@link Prompt} when any BLOCKING rule fires, or `null` when clean. Excluded
270
+ * build/dependency paths (node_modules, dist, build, .next, vendor, .build,
271
+ * DerivedData, Pods) early-return `null` to avoid false positives.
272
+ * @param filePath - absolute path of the file being written/edited
273
+ * @param content - the file (or new) content under validation
274
+ * @param fileLines - full on-disk line count (set on Edit so a partial
275
+ * `new_string` snippet still judges the whole file, mirroring the base
276
+ * file-size guard / Python `get_full_file_content`). Omit on Write.
277
+ */
278
+ function frameworkSolidGate(filePath, content, fileLines) {
279
+ if (!filePath || !content) return null;
280
+ let violations = [];
281
+ if (filePath.endsWith(".php")) {
282
+ if (isExcludedPhpPath(filePath)) return null;
283
+ violations = laravelGate(filePath, content, fileLines);
284
+ } else if (filePath.endsWith(".swift")) {
285
+ if (isExcludedSwiftPath(filePath)) return null;
286
+ violations = swiftGate(filePath, content, fileLines);
287
+ } else if (/\.(tsx|ts|jsx|js)$/.test(filePath)) violations = jsViolations(filePath, content, fileLines);
288
+ return violations.length ? block(filePath, violations) : null;
289
+ }
290
+ //#endregion
291
+ //#region src/policy/skill-patterns/shadcn.ts
292
+ /**
293
+ * shadcn/ui HTML-to-component detection patterns, ported verbatim from the
294
+ * shared `shadcn_patterns.py` (FORM/OVERLAY/DATA/NAV/LAYOUT/FEEDBACK groups).
295
+ * 32 patterns total (3+6+8+5+5+5). Matched case-insensitively (source `re.IGNORECASE`).
296
+ */
297
+ /** Forms: Button, Input, Textarea, Select, Checkbox, Radio, Switch, etc. */
298
+ const FORM = [
299
+ "<(button|input|select|textarea|label|option|optgroup)\\b",
300
+ "type=\"(checkbox|radio|range|file)\"",
301
+ "<input[^>]*maxLength=\"[1-2]\""
302
+ ];
303
+ /** Overlay: Dialog, AlertDialog, Sheet, Popover, Tooltip, ContextMenu, etc. */
304
+ const OVERLAY = [
305
+ "<dialog\\b",
306
+ "role=\"(dialog|alertdialog)\"",
307
+ "(aria-haspopup|aria-expanded|aria-pressed)=\"",
308
+ "(onContextMenu|onMouseEnter.*onMouseLeave)\\b",
309
+ "\\b(confirm|window\\.confirm)\\(",
310
+ "title=\"[^\"]{2,}\""
311
+ ];
312
+ /** Data: Table, Card, Badge, Avatar, Calendar, Chart, Carousel, Pagination. */
313
+ const DATA = [
314
+ "<(table|thead|tbody|tfoot|th|td|tr|caption|colgroup)\\b",
315
+ "<(article|section)\\b[^>]*className",
316
+ "rounded-full[^>]*className|className[^>]*rounded-full",
317
+ "<img\\b[^>]*rounded",
318
+ "(new Date|\\.toLocaleDateString|date-fns|dayjs)\\b",
319
+ "(recharts|chart\\.js|<svg[^>]*viewBox)",
320
+ "(scroll-snap|embla-carousel|useEmbla)\\b",
321
+ "(page=|currentPage|totalPages|pageSize)\\b"
322
+ ];
323
+ /** Navigation: Breadcrumb, NavigationMenu, Menubar, Sidebar, Tabs. */
324
+ const NAV = [
325
+ "<(nav|aside)\\b",
326
+ "<(menu|menuitem)\\b",
327
+ "role=\"(menubar|menu|menuitem|tablist|tab|tabpanel)\"",
328
+ "aria-label=\"(breadcrumb|navigation|sidebar)\"",
329
+ "aria-current=\"(page|step)\""
330
+ ];
331
+ /** Layout: Accordion, Collapsible, Separator, ScrollArea, AspectRatio. */
332
+ const LAYOUT = [
333
+ "<(hr|details|summary)\\b",
334
+ "role=\"separator\"",
335
+ "overflow-(auto|scroll|y-auto|x-auto)",
336
+ "(aspect-ratio|aspect-video|aspect-square)\\b",
337
+ "(resize|cursor-(col|row)-resize)\\b"
338
+ ];
339
+ /** Feedback: Alert, Toast, Progress, Skeleton, Spinner. */
340
+ const FEEDBACK = [
341
+ "role=\"(alert|status|progressbar)\"",
342
+ "aria-live=\"(polite|assertive)\"",
343
+ "<progress\\b",
344
+ "(animate-pulse|animate-spin)\\b",
345
+ "(sonner|react-hot-toast|\\.toast\\()\\b"
346
+ ];
347
+ /** All 32 shadcn detection patterns combined. */
348
+ const SHADCN = [
349
+ ...FORM,
350
+ ...OVERLAY,
351
+ ...DATA,
352
+ ...NAV,
353
+ ...LAYOUT,
354
+ ...FEEDBACK
355
+ ];
356
+ //#endregion
357
+ //#region src/policy/skill-patterns/react.ts
358
+ /**
359
+ * React skill-trigger patterns, ported verbatim from `react_skill_triggers.py`.
360
+ * Matched case-insensitively (source `re.IGNORECASE`).
361
+ */
362
+ /** Map of React sub-skill name → triggering code patterns. */
363
+ const REACT_TRIGGERS = {
364
+ "react-19": [
365
+ "\\buse\\b\\s*\\(",
366
+ "useOptimistic\\b",
367
+ "useActionState\\b",
368
+ "useEffectEvent\\b",
369
+ "<Activity\\b",
370
+ "from\\s+['\"]react['\"]"
371
+ ],
372
+ "react-tanstack-router": [
373
+ "(createRouter|createRoute|createRootRoute)\\b",
374
+ "(useNavigate|useParams|useSearch|useLoaderData)\\b",
375
+ "from\\s+['\"]@tanstack/(react-router|router)",
376
+ "(routeTree|createFileRoute|createLazyFileRoute)\\b"
377
+ ],
378
+ "react-forms": [
379
+ "(useForm|useAppForm|createFormHook|formOptions)\\b",
380
+ "(mergeForm|formApi|FieldApi|FormApi)\\b",
381
+ "form\\.(Field|Subscribe|handleSubmit)\\b",
382
+ "from\\s+['\"]@tanstack/(react-form|zod-form-adapter)"
383
+ ],
384
+ "react-state": [
385
+ "(create|createStore)\\(\\s*\\(\\s*set",
386
+ "from\\s+['\"]zustand(/\\w+)?\"",
387
+ "(useShallow|useStore|skipHydration)\\b",
388
+ "(persist|devtools|immer)\\("
389
+ ],
390
+ "react-testing": [
391
+ "(render|screen|fireEvent|waitFor)\\b",
392
+ "from\\s+['\"]@testing-library/react",
393
+ "(describe|it|expect|vi\\.|jest\\.)\\b",
394
+ "from\\s+['\"]vitest"
395
+ ],
396
+ "react-shadcn": SHADCN,
397
+ "react-i18n": [
398
+ "(useTranslation|Trans)\\b",
399
+ "from\\s+['\"]react-i18next",
400
+ "\\bt\\(\\s*['\"]",
401
+ "i18n\\.(language|changeLanguage)"
402
+ ]
403
+ };
404
+ //#endregion
405
+ //#region src/policy/skill-patterns/nextjs.ts
406
+ /**
407
+ * Next.js skill-trigger patterns, ported verbatim from `nextjs_skill_triggers.py`.
408
+ * Matched case-insensitively (source `re.IGNORECASE`).
409
+ */
410
+ /** Map of Next.js sub-skill name → triggering code patterns. */
411
+ const NEXTJS_TRIGGERS = {
412
+ "better-auth": [
413
+ "(authClient|betterAuth|createAuthClient)\\b",
414
+ "(signIn|signUp|signOut|useSession|getSession)\\b",
415
+ "auth\\.(api|handler)\\b",
416
+ "(prismaAdapter|drizzleAdapter|mongodbAdapter)\\b",
417
+ "(twoFactor|passkey|magicLink|emailOtp|organization)\\b",
418
+ "(apiKey|bearer|jwt|sso|scim|captcha|anonymous)\\b",
419
+ "from\\s+['\"].*better-auth"
420
+ ],
421
+ "nextjs-tanstack-form": [
422
+ "(useForm|useAppForm|createFormHook|formOptions)\\b",
423
+ "(mergeForm|formApi|FieldApi|FormApi)\\b",
424
+ "form\\.(Field|Subscribe|handleSubmit)\\b",
425
+ "(zodValidator|onServerValidate)\\b",
426
+ "from\\s+['\"]@tanstack/(react-form|zod-form-adapter)"
427
+ ],
428
+ "prisma-7": [
429
+ "(PrismaClient|prismaAdapter)\\b",
430
+ "prisma\\.(\\w+\\.\\w+|\\$\\w+)",
431
+ "(globalForPrisma|\\$transaction|\\$queryRaw|\\$executeRaw)\\b",
432
+ "from\\s+['\"](@prisma|\\..*generated.*prisma)"
433
+ ],
434
+ "nextjs-shadcn": SHADCN,
435
+ "nextjs-zustand": [
436
+ "(create|createStore)\\(\\s*\\(\\s*set",
437
+ "from\\s+['\"]zustand(/\\w+)?\"",
438
+ "(useShallow|useStore|skipHydration)\\b",
439
+ "\\.(getState|setState|subscribe)\\(\\)",
440
+ "(persist|devtools|immer)\\("
441
+ ],
442
+ "nextjs-i18n": [
443
+ "(useTranslations|useLocale|useMessages|useFormatter)\\b",
444
+ "(getTranslations|getLocale|getMessages|getFormatter)\\b",
445
+ "(NextIntlClientProvider|defineRouting)\\b",
446
+ "from\\s+['\"]next-intl(/\\w+)?\"",
447
+ "\\bt\\(\\s*['\"]"
448
+ ]
449
+ };
450
+ //#endregion
451
+ //#region src/policy/skill-patterns/laravel.ts
452
+ /**
453
+ * Laravel skill-trigger patterns, ported verbatim from `laravel_patterns.py`
454
+ * (consumed by `laravel_skill_triggers.py`). Matched case-insensitively.
455
+ */
456
+ /** Map of Laravel sub-skill name → triggering PHP code patterns. */
457
+ const LARAVEL_TRIGGERS = {
458
+ "fusecore": [
459
+ "FuseCore\\\\[A-Za-z]+\\\\App\\\\",
460
+ "use HasModule\\b",
461
+ "ModuleServiceProvider\\b",
462
+ "ModuleInterface\\b"
463
+ ],
464
+ "laravel-eloquent": [
465
+ "(extends Model|HasFactory|belongsTo|hasMany|hasOne|morphTo)\\b",
466
+ "\\$this->belongsToMany|->with\\(|->whereHas\\(",
467
+ "(Eloquent|Model)::(find|where|create|update|all)\\b"
468
+ ],
469
+ "laravel-api": [
470
+ "(JsonResource|ResourceCollection|apiResource)\\b",
471
+ "Route::(get|post|put|delete|apiResource)\\(",
472
+ "(response\\(\\)->json|Request \\$request)\\b"
473
+ ],
474
+ "laravel-auth": [
475
+ "(Auth::|auth\\(\\)|Sanctum|Passport|Socialite)\\b",
476
+ "(Gate::|Policy|can\\(|authorize)\\b",
477
+ "(middleware\\(['\"]auth|LoginController|RegisterController)\\b"
478
+ ],
479
+ "laravel-livewire": [
480
+ "(extends Component|Livewire|wire:|#\\[On)\\b",
481
+ "(mount|render|emit|dispatch)\\(\\)",
482
+ "@livewire|<livewire:"
483
+ ],
484
+ "laravel-queues": [
485
+ "(implements ShouldQueue|dispatch\\(|Bus::)\\b",
486
+ "(Queue::|Job|Batch|Chain)\\b",
487
+ "(onQueue|onConnection|tries|backoff)\\b"
488
+ ],
489
+ "laravel-billing": ["(Billable|subscription|Cashier)\\b", "(createSubscription|newSubscription|charge)\\("],
490
+ "laravel-stripe-connect": [
491
+ "(StripeConnect|connectAccount|onboardingUrl)\\b",
492
+ "(paymentIntent|transfer|payout|splitPayment)\\b",
493
+ "Stripe\\\\\\\\(Account|Transfer|PaymentIntent)\\b"
494
+ ],
495
+ "laravel-testing": [
496
+ "(extends TestCase|RefreshDatabase|WithFaker)\\b",
497
+ "(assertStatus|assertJson|assertSee|assertRedirect)\\(",
498
+ "(factory\\(|Pest|it\\(|test\\(|expect\\()\\b"
499
+ ],
500
+ "laravel-migrations": [
501
+ "(Schema::|Blueprint|->table|->create)\\b",
502
+ "(->string|->integer|->boolean|->foreignId|->index)\\(",
503
+ "extends Migration\\b"
504
+ ],
505
+ "laravel-blade": [
506
+ "(@extends|@section|@yield|@component|@slot)\\b",
507
+ "(@if|@foreach|@include|@push|@stack)\\b",
508
+ "(Blade::|x-[a-z])\\b"
509
+ ],
510
+ "laravel-permission": [
511
+ "(hasRole|givePermissionTo|assignRole|spatie)\\b",
512
+ "(Permission|Role)::(create|findByName)\\b",
513
+ "@can\\b|@role\\b|middleware.*role:"
514
+ ],
515
+ "laravel-i18n": ["(__\\(|trans\\(|trans_choice\\(|@lang)\\b", "Lang::|->locale\\(|setLocale\\b"],
516
+ "laravel-vite": ["(@vite|@viteReactRefresh|Vite::)\\b"]
517
+ };
518
+ //#endregion
519
+ //#region src/policy/skill-patterns/swift.ts
520
+ /**
521
+ * Swift / Apple skill-trigger patterns, ported verbatim from
522
+ * `swift_skill_triggers.py`. 9 skills.
523
+ *
524
+ * NOTE: Swift uses case-SENSITIVE matching (source `re.search` WITHOUT
525
+ * `re.IGNORECASE`). The framework name is registered in
526
+ * `CASE_SENSITIVE_FRAMEWORKS` so the gate compiles these without the `i` flag.
527
+ */
528
+ /** Map of Swift sub-skill name → triggering code patterns (case-sensitive). */
529
+ const SWIFT_TRIGGERS = {
530
+ "swiftui-core": [
531
+ "\\bstruct\\s+\\w+\\s*:\\s*View\\b",
532
+ "@State\\b",
533
+ "@Binding\\b",
534
+ "@Observable\\b",
535
+ "@Environment\\b",
536
+ "NavigationStack\\b",
537
+ "\\.sheet\\b",
538
+ "\\.toolbar\\b",
539
+ "\\.task\\b"
540
+ ],
541
+ "swift-core": [
542
+ "\\bactor\\b",
543
+ "\\basync\\s+(let|func|throws)\\b",
544
+ "\\bawait\\b",
545
+ "Task\\s*\\{",
546
+ "TaskGroup\\b",
547
+ "Sendable\\b",
548
+ "@MainActor\\b"
549
+ ],
550
+ "ios": [
551
+ "UIKit|UIViewController|UIView\\b",
552
+ "UIApplication\\b",
553
+ "\\.simulatorId\\b",
554
+ "import\\s+UIKit\\b"
555
+ ],
556
+ "macos": [
557
+ "AppKit|NSViewController|NSWindow\\b",
558
+ "NSApplication\\b",
559
+ "\\.menuBar\\b",
560
+ "import\\s+AppKit\\b"
561
+ ],
562
+ "watchos": [
563
+ "WatchKit|WKInterface|WKExtension\\b",
564
+ "HealthKit|HKWorkout\\b",
565
+ "WatchConnectivity\\b"
566
+ ],
567
+ "visionos": [
568
+ "RealityKit|RealityView|ImmersiveSpace\\b",
569
+ "\\.volumeBaseplateVisibility\\b",
570
+ "SpatialTapGesture\\b"
571
+ ],
572
+ "ipados": [
573
+ "UISplitViewController|UIKeyCommand\\b",
574
+ "\\.horizontalSizeClass\\b",
575
+ "pencilInteraction\\b"
576
+ ],
577
+ "tvos": [
578
+ "TVUIKit|focusable\\b",
579
+ "\\.focusSection\\b",
580
+ "TVMonogram\\b"
581
+ ],
582
+ "build-distribution": [
583
+ "TestFlight|AppStore\\b",
584
+ "\\.entitlements\\b",
585
+ "codesign|notarize|archive\\b"
586
+ ]
587
+ };
588
+ //#endregion
589
+ //#region src/policy/skill-trigger-patterns.ts
590
+ /**
591
+ * Per-framework code-pattern → required sub-skill data, ported verbatim from the
592
+ * fusengine `*_skill_triggers.py` + `*_patterns.py`
593
+ * (react/nextjs/laravel/swift) and the shared `shadcn_patterns.py`.
594
+ *
595
+ * Pattern groups live in `./skill-patterns/*`. Most frameworks match
596
+ * case-insensitively (source `re.IGNORECASE`); `swift` matches case-SENSITIVELY
597
+ * (source `re.search` without the flag) — see {@link CASE_SENSITIVE_FRAMEWORKS}.
598
+ */
599
+ /**
600
+ * Frameworks whose Python source omits `re.IGNORECASE`, so their regexes must
601
+ * be compiled WITHOUT the `i` flag to stay faithful.
602
+ */
603
+ const CASE_SENSITIVE_FRAMEWORKS = /* @__PURE__ */ new Set(["swift"]);
604
+ /** Map of required sub-skill name → triggering code patterns, keyed by framework. */
605
+ const SKILL_TRIGGERS = {
606
+ react: REACT_TRIGGERS,
607
+ nextjs: NEXTJS_TRIGGERS,
608
+ laravel: LARAVEL_TRIGGERS,
609
+ swift: SWIFT_TRIGGERS
610
+ };
611
+ //#endregion
612
+ //#region src/policy/shadcn-project.ts
613
+ /** Directories whose presence indicates shadcn/ui is installed. */
614
+ const UI_DIRS = [
615
+ "src/components/ui",
616
+ "components/ui",
617
+ "src/modules/cores/shadcn/components/ui"
618
+ ];
619
+ /**
620
+ * Whether `cwd` is a shadcn/ui project, ported from the shared Python
621
+ * `is_shadcn_project` (`shadcn_patterns.py`): true when a `components.json`
622
+ * file exists, or any known `components/ui` directory exists under the root.
623
+ * Used to skip `*-shadcn` sub-skill requirements when shadcn is not installed.
624
+ * @param cwd - project root directory to scan.
625
+ * @returns `true` when shadcn/ui is detected on disk.
626
+ */
627
+ function isShadcnProject(cwd) {
628
+ if (existsSync(join(cwd, "components.json"))) return true;
629
+ return UI_DIRS.some((dir) => existsSync(join(cwd, dir)));
630
+ }
631
+ //#endregion
632
+ //#region src/policy/skill-triggers.ts
633
+ /**
634
+ * Detect which sub-skills the written `content` requires for a `framework`.
635
+ * Faithful to the Python `detect_required_skills`: first matching pattern per
636
+ * skill wins. Most frameworks match case-insensitively (source `re.IGNORECASE`);
637
+ * `swift` matches case-sensitively (see {@link CASE_SENSITIVE_FRAMEWORKS}).
638
+ * @param framework - "react" | "nextjs" | "laravel" | "swift".
639
+ * @param content - the code being written.
640
+ * @returns required sub-skill names (empty when framework unknown / no match).
641
+ */
642
+ function detectRequiredSkills(framework, content) {
643
+ const groups = SKILL_TRIGGERS[framework];
644
+ if (!groups) return [];
645
+ const flags = CASE_SENSITIVE_FRAMEWORKS.has(framework) ? "" : "i";
646
+ const required = [];
647
+ for (const [skill, patterns] of Object.entries(groups)) if (patterns.some((p) => new RegExp(p, flags).test(content))) required.push(skill);
648
+ return required;
649
+ }
650
+ /**
651
+ * Block when a required sub-skill's `skills/<name>/` path is absent from
652
+ * `refsRead`. Mirrors `specific_skill_consulted`, which confirms a skill was
653
+ * read by checking the tracking file contains `skills/<name>/`.
654
+ * @param framework - "react" | "nextjs" | "laravel".
655
+ * @param content - the code being written.
656
+ * @param refsRead - in-session read reference paths.
657
+ * @param forcedSkill - a skill the detected modular architecture forces (optional).
658
+ * @param cwd - project root; when set and not a shadcn project, `*-shadcn`
659
+ * requirements are skipped (ports the Python `is_shadcn_project` filter).
660
+ * @returns a `block` Prompt naming the missing sub-skills, or `null` when satisfied.
661
+ */
662
+ function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd) {
663
+ let required = detectRequiredSkills(framework, content);
664
+ if (forcedSkill && !required.includes(forcedSkill)) required.push(forcedSkill);
665
+ if (cwd && !isShadcnProject(cwd)) required = required.filter((s) => !s.endsWith("-shadcn"));
666
+ const missing = required.filter((s) => !refsRead.some((r) => r.includes(`skills/${s}/`)));
667
+ if (missing.length === 0) return null;
668
+ return {
669
+ kind: "block",
670
+ title: "Required sub-skill not consulted",
671
+ reason: `${framework}: code uses APIs covered by ${missing.join(", ")} but its skill reference was not read this session.`,
672
+ actions: missing.map((s) => `Read skills/${s}/ before writing this code`)
673
+ };
674
+ }
675
+ //#endregion
676
+ export { detectProjectType as _, MAX_EXA_RESULTS as a, detectCreationIntent as c, docConsultedGate as d, evaluateApex as f, detectModularArchitecture as g, DEV_KEYWORDS as h, frameworkSolidGate as i, APEX_GATES as l, solidReadGate as m, skillTriggerGate as n, MAX_TOKENS as o, freshnessGate as p, SKILL_TRIGGERS as r, capVerbosity as s, detectRequiredSkills as t, brainstormGate as u, isApexCommand as v, requiredArchSkill as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",