@fusengine/harness 0.1.83 → 0.1.85

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/cli/bin.mjs CHANGED
@@ -5,7 +5,7 @@ import { t as detectHarness } from "../harness-BMuLJ9lm.mjs";
5
5
  import { t as claudeHome } from "../home-state-oUGFB4ds.mjs";
6
6
  import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-D_WchZ3R.mjs";
7
7
  import { n as writeInitFile, t as initFor } from "../run-TAXNRSpD.mjs";
8
- import { $ as runningVersion, Q as runDoctor, Z as notify, et as versionBanner, i as respond, t as handleHook, zt as todayUtc } from "../handle-F35Gx-QS.mjs";
8
+ import { $ as runningVersion, Q as runDoctor, Z as notify, et as versionBanner, i as respond, t as handleHook, zt as todayUtc } from "../handle-DptZbyMP.mjs";
9
9
  import { delimiter, join } from "node:path";
10
10
  import { existsSync, mkdirSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
@@ -2,6 +2,7 @@ import { a as resolveMaxLines, l as parseEnvInt, s as splitTarget } from "./limi
2
2
  import { s as resolveTtlSec } from "./dotenv-DoqNlALG.mjs";
3
3
  import { r as projectLayout } from "./layout-KWoE_Mqn.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
+ import { t as detectHarness } from "./harness-BMuLJ9lm.mjs";
5
6
  import { D as isVendorPath, G as isFileSizeScoped, K as resolveSolidRefFramework, W as computeEditResultLines, b as interfaceDeclLevel, et as detectFramework, g as conventionsMode, lt as requiredArchSkill, n as FAIL_CLOSED, ot as detectModularArchitecture, rt as maskCommentsAndStrings, st as detectProjectType$1, t as evaluate } from "./evaluate-B4-MhOy_.mjs";
6
7
  import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, p as evaluateFileSize, r as fusengineCache, t as claudeHome } from "./home-state-oUGFB4ds.mjs";
7
8
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
@@ -17,7 +18,7 @@ import { n as parseApplyPatch, t as isBypassPermissions } from "./permission-mod
17
18
  import { _ as commandToString, d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText$1, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-D93HY8dq.mjs";
18
19
  import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-ChbcU2uI.mjs";
19
20
  import { r as toHermesResponse } from "./hermes-1PUEoY8q.mjs";
20
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
21
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
21
22
  import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
22
23
  import { homedir } from "node:os";
23
24
  import { mkdir, rmdir } from "node:fs/promises";
@@ -170,86 +171,39 @@ function trackFile(sessionId, baseDir) {
170
171
  return join(baseDir ?? defaultStateDir(), `track-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_") || "default"}.json`);
171
172
  }
172
173
  //#endregion
173
- //#region src/policy/design/transitions.ts
174
- /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
175
- function detectMode(prompt, designSystemExists) {
176
- const p = prompt.toLowerCase();
177
- if ([
178
- "component",
179
- "composant",
180
- "snippet"
181
- ].some((k) => p.includes(k))) return "component";
182
- return designSystemExists ? "page" : "full";
183
- }
184
- /** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */
185
- function recordScreenshot(state, needed) {
186
- const screenshotsCount = state.screenshotsCount + 1;
187
- const next = {
188
- ...state,
189
- screenshotsCount
190
- };
191
- if (screenshotsCount >= needed && state.currentPhase < 2) {
192
- next.currentPhase = 2;
193
- next.phasesCompleted = [.../* @__PURE__ */ new Set([
194
- ...state.phasesCompleted,
195
- "identity",
196
- "research"
197
- ])];
198
- }
199
- return next;
200
- }
201
- /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
202
- function recordNavigate(state) {
203
- return {
204
- ...state,
205
- scrolledSinceNav: false
206
- };
207
- }
208
- /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
209
- function recordScroll(state) {
210
- return {
211
- ...state,
212
- scrolledSinceNav: true
213
- };
214
- }
215
- /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
216
- function recordValidDesignSystem(state) {
217
- return {
218
- ...state,
219
- designSystemExists: true,
220
- designSystemValid: true,
221
- currentPhase: Math.max(state.currentPhase, 3),
222
- phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
223
- };
224
- }
225
- /**
226
- * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
227
- * allowed); reading the inspiration catalog satisfies the browse prerequisite.
228
- */
229
- function recordRead(state, filePath) {
230
- const next = { ...state };
231
- if (filePath.includes("design-system/SKILL.md")) {
232
- next.currentPhase = Math.max(state.currentPhase, 1);
233
- next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
234
- }
235
- if (filePath.includes("design-inspiration")) next.inspirationRead = true;
236
- return next;
237
- }
238
- //#endregion
239
174
  //#region src/policy/design/state.ts
240
- /** Minimum fuse-browser screenshots required before writing design-system.md, per mode. */
175
+ /** Minimum fuse-browser screenshots required before writing design-system.md, per mode (corpus present). */
241
176
  const MIN_SCREENSHOTS = {
177
+ full: 2,
178
+ page: 1,
179
+ component: 1
180
+ };
181
+ /**
182
+ * Screenshot quotas when the refs-design corpus is ABSENT (install defect →
183
+ * fail-open): exactly today's effective behavior, so the fallback can never be
184
+ * weaker than the pre-doctrine gate. Component is 1 explicitly — the effective
185
+ * quota today (the first screenshot trips `1 >= 0`), no longer a hidden quirk.
186
+ */
187
+ const MIN_SCREENSHOTS_NO_CORPUS = {
242
188
  full: 4,
243
189
  page: 2,
244
- component: 0
190
+ component: 1
245
191
  };
192
+ /** Screenshot quota for `mode` — the SINGLE choice point between the two tables, driven by corpus availability. */
193
+ function quotaFor(mode, corpusRequired) {
194
+ return (corpusRequired ? MIN_SCREENSHOTS : MIN_SCREENSHOTS_NO_CORPUS)[mode];
195
+ }
246
196
  const stateFile = (cacheDir, agentId) => join(cacheDir, `.design-state-${agentId}.json`);
247
197
  /** Load the design state for `agentId`, or null when absent/corrupt (fail-open). */
248
198
  function loadDesignState(cacheDir, agentId) {
249
199
  const path = stateFile(cacheDir, agentId);
250
200
  if (!existsSync(path)) return null;
251
201
  try {
252
- return JSON.parse(readFileSync(path, "utf8"));
202
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
203
+ return {
204
+ ...parsed,
205
+ corpusReads: parsed.corpusReads ?? []
206
+ };
253
207
  } catch {
254
208
  return null;
255
209
  }
@@ -268,6 +222,7 @@ function initDesignState(agentId, mode, designSystemExists) {
268
222
  inspirationRead: false,
269
223
  scrolledSinceNav: false,
270
224
  screenshotsCount: 0,
225
+ corpusReads: [],
271
226
  designSystemExists,
272
227
  designSystemValid: false,
273
228
  geminiCalls: 0
@@ -295,6 +250,264 @@ function cleanupDesignStates(cacheDir, agentId, stamp, now) {
295
250
  }
296
251
  }
297
252
  //#endregion
253
+ //#region src/util/semver.ts
254
+ /**
255
+ * Dependency-free semver ordering. `Array.prototype.sort()` compares strings
256
+ * lexicographically — `"1.0.9" > "1.0.23"` — so versioned directories (plugin
257
+ * caches) must be ordered with a numeric, segment-by-segment comparison.
258
+ */
259
+ /**
260
+ * Compare two dotted version strings numerically, segment by segment
261
+ * (shorter version wins when a prefix is equal: `1.0` < `1.0.1`). A segment
262
+ * that is not a plain integer (prerelease tag like `0-beta`) falls back to
263
+ * lexicographic comparison of that segment — never NaN, so `sort()` stays
264
+ * defined; identical strings compare 0.
265
+ * @returns Negative when `a < b`, positive when `a > b`, 0 when equal.
266
+ */
267
+ function compareSemver(a, b) {
268
+ const pa = a.split("."), pb = b.split(".");
269
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
270
+ const sa = pa[i] ?? "0", sb = pb[i] ?? "0";
271
+ if (sa === sb) continue;
272
+ const na = Number(sa), nb = Number(sb);
273
+ if (Number.isInteger(na) && Number.isInteger(nb) && na !== nb) return na - nb;
274
+ return sa < sb ? -1 : 1;
275
+ }
276
+ return 0;
277
+ }
278
+ /** The highest version of the list, or null when empty. */
279
+ function maxSemver(versions) {
280
+ return versions.length === 0 ? null : versions.reduce((a, b) => compareSemver(a, b) >= 0 ? a : b);
281
+ }
282
+ //#endregion
283
+ //#region src/policy/design/corpus-resolve.ts
284
+ /**
285
+ * Corpus/plugins root resolution (fs) and the write guard over it. Resolution
286
+ * is PER RUNTIME (Claude marketplaces, Codex versioned plugin cache — same
287
+ * pattern as rules-root.ts); runtimes without a plugin structure (kimi today)
288
+ * resolve to "" and the corpus gates stay dormant. There is NO cwd fallback
289
+ * anywhere: an agent-controlled directory can never become its own taste
290
+ * reference (self-attested proof — the failure this gate exists to prevent).
291
+ */
292
+ const CORPUS_SUFFIX = join("skills", "design-web", "references", "refs-design");
293
+ /** Immediate child dir names of `dir`, or [] when unreadable. */
294
+ function children$2(dir) {
295
+ try {
296
+ return readdirSync(dir).sort();
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+ /** Claude: `<home>/.claude/plugins/marketplaces/<mkt>/plugins/design-expert` ("" when absent). */
302
+ function probeClaude$1(home) {
303
+ const markets = join(home, ".claude", "plugins", "marketplaces");
304
+ for (const m of children$2(markets)) {
305
+ const de = join(markets, m, "plugins", "design-expert");
306
+ if (existsSync(de)) return de;
307
+ }
308
+ return "";
309
+ }
310
+ /** Codex: `<CODEX_HOME|~/.codex>/plugins/cache/<mkt>/design-expert/<highest STABLE semver>` ("" when absent). */
311
+ function probeCodex$1(env, home) {
312
+ const cache = join(env.CODEX_HOME ?? join(home, ".codex"), "plugins", "cache");
313
+ for (const m of children$2(cache)) {
314
+ const de = join(cache, m, "design-expert");
315
+ const latest = maxSemver(children$2(de).filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v) && existsSync(join(de, v, "skills"))));
316
+ if (latest) return join(de, latest);
317
+ }
318
+ return "";
319
+ }
320
+ /**
321
+ * The design-expert plugin root for the ACTIVE runtime ("" = not installed).
322
+ * `pluginsDirOverride` short-circuits resolution (tests): a PLUGINS dir whose
323
+ * `design-expert` child is used when present, else itself.
324
+ */
325
+ function resolvePluginsRoot(pluginsDirOverride, home = homedir(), id = detectHarness().id, env = process.env) {
326
+ if (pluginsDirOverride) {
327
+ const de = join(pluginsDirOverride, "design-expert");
328
+ return existsSync(de) ? de : pluginsDirOverride;
329
+ }
330
+ if (id === "claude-code") return probeClaude$1(home);
331
+ if (id === "codex") return probeCodex$1(env, home);
332
+ return "";
333
+ }
334
+ /** Resolve the delivered refs-design/ root, or "" when absent/unusable (the fail-open signal). */
335
+ function resolveCorpusRoot(pluginsDirOverride, home = homedir(), id = detectHarness().id, env = process.env) {
336
+ const root = resolvePluginsRoot(pluginsDirOverride, home, id, env);
337
+ if (!root) return "";
338
+ const corpus = join(root, CORPUS_SUFFIX);
339
+ return existsSync(corpus) ? corpus : "";
340
+ }
341
+ /**
342
+ * Deny Write/Edit/apply_patch-file under the resolved plugins root: the corpus
343
+ * is the artefact the pipeline checks reads against, so it must stay
344
+ * agent-proof — including while refs-design/ is still absent. Relative paths
345
+ * (the normal form of Codex patches) are resolved against `cwd` first. SCOPE:
346
+ * no Bash, no symlinks, no sibling marketplace — the gate protects against
347
+ * oversight, not an adversary (the mandatory screenshot stays the unforgeable
348
+ * proof; forging the corpus only lowers a quota).
349
+ */
350
+ function pluginsWriteGuard(filePath, pluginsRoot, cwd = "") {
351
+ const abs = isAbsolute(filePath) ? filePath : cwd ? normalize(join(cwd, filePath)) : filePath;
352
+ if (!pluginsRoot || !abs.startsWith(`${pluginsRoot}/`)) return null;
353
+ return {
354
+ kind: "block",
355
+ title: "Design pipeline",
356
+ reason: "BLOCKED: the design-expert plugin dir (refs-design corpus) is read-only — it is the taste reference your reads are checked against. Never create or modify files there.",
357
+ actions: ["Read the corpus with the Read tool; write your own files in the project"]
358
+ };
359
+ }
360
+ //#endregion
361
+ //#region src/policy/design/corpus.ts
362
+ /**
363
+ * Corpus anchoring for the design pipeline: refs-design/ read classification,
364
+ * per-mode readiness, citation form, citation↔read jointure. Nothing about
365
+ * the corpus content (slugs, section titles, count) is hardcoded — everything
366
+ * is matched by shape or discovered on disk, so a new reference or a renamed
367
+ * section never requires reopening the harness. Pure string logic only; root
368
+ * resolution and the write guard live in `corpus-resolve.ts`.
369
+ */
370
+ const TOKENS_RE = /^tokens-.+\.md$/;
371
+ const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim;
372
+ /** Classify a read path against the DELIVERED corpus root (null = not a corpus read). */
373
+ function classifyCorpusRead(filePath, corpusRoot) {
374
+ if (!corpusRoot) return null;
375
+ if (filePath === join(corpusRoot, "README.md")) return "index";
376
+ if (filePath.startsWith(`${corpusRoot}/`) && TOKENS_RE.test(basename(filePath))) return "tokens";
377
+ return null;
378
+ }
379
+ /** Per-mode corpus-read threshold (component >= 1 file, page >= 2 files, full = index + 2 tokens). */
380
+ function corpusReady(reads, mode) {
381
+ if (mode === "component") return reads.length >= 1;
382
+ if (mode === "page") return reads.length >= 2;
383
+ return reads.includes("README.md") && reads.filter((r) => TOKENS_RE.test(basename(r))).length >= 2;
384
+ }
385
+ /** True when the content carries a `- Corpus: ref/section` citation line (form only). */
386
+ function hasCorpusCitation(content) {
387
+ return citedCorpusRefs(content).length > 0;
388
+ }
389
+ /**
390
+ * Extract the reference slugs cited on EVERY `- Corpus:` line (token before
391
+ * `/`, or a bare slug). Anchored at line start with NO indentation
392
+ * (fail-closed: a citation inside a code block does not count — an indented
393
+ * match would also recognize the examples inside the doctrine doc itself).
394
+ * The regex is fence-blind, documented: a `- Corpus:` line at column 0 inside
395
+ * a fenced block WILL be matched — its refs must join like any other.
396
+ */
397
+ function citedCorpusRefs(content) {
398
+ const refs = [];
399
+ for (const m of content.matchAll(CORPUS_LINE_RE)) {
400
+ const line = m[1] ?? "";
401
+ refs.push(...line.split(",").map((item) => item.trim().match(/^([\w][\w-]*)(?:\/|\s|$)/)?.[1] ?? "").filter(Boolean));
402
+ }
403
+ return refs;
404
+ }
405
+ /**
406
+ * True when the cited ref names a REFERENCE in a recorded corpus read, in
407
+ * either naming convention (`reve` README-style ↔ `reve-recode/` on disk —
408
+ * the `-recode` suffix is normalized away on BOTH sides) and either layout
409
+ * (per-reference directories, or flat `tokens-<ref>.md` files). Comparison is
410
+ * exact after normalization: no prefix matching, so `acme` never joins
411
+ * `acme-corp`, `tokens` never joins every tokens file, and `README` names no
412
+ * reference.
413
+ */
414
+ function citationJoinsReads(ref, reads) {
415
+ const norm = (s) => s.replace(/\.md$/, "").replace(/-recode$/, "");
416
+ const target = norm(ref);
417
+ return reads.some((r) => {
418
+ const segs = r.split("/");
419
+ const file = segs[segs.length - 1] ?? "";
420
+ const names = segs.slice(0, -1).map(norm);
421
+ if (file.startsWith("tokens-")) names.push(norm(file.slice(7)));
422
+ return names.includes(target);
423
+ });
424
+ }
425
+ //#endregion
426
+ //#region src/policy/design/transitions.ts
427
+ /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */
428
+ function detectMode(prompt, designSystemExists) {
429
+ const p = prompt.toLowerCase();
430
+ if ([
431
+ "component",
432
+ "composant",
433
+ "snippet"
434
+ ].some((k) => p.includes(k))) return "component";
435
+ return designSystemExists ? "page" : "full";
436
+ }
437
+ /**
438
+ * Re-evaluate the phase-2 conjunction MONOTONICALLY: corpus reads (when the
439
+ * corpus is delivered) AND the screenshot quota must both hold, and the phase
440
+ * is never written downwards (a Read after phase 3 changes nothing).
441
+ */
442
+ function maybePhase2(state, corpusRequired) {
443
+ if (state.currentPhase >= 2) return state;
444
+ if (!(!corpusRequired || corpusReady(state.corpusReads, state.mode)) || state.screenshotsCount < quotaFor(state.mode, corpusRequired)) return state;
445
+ return {
446
+ ...state,
447
+ currentPhase: 2,
448
+ phasesCompleted: [.../* @__PURE__ */ new Set([
449
+ ...state.phasesCompleted,
450
+ "identity",
451
+ "research"
452
+ ])]
453
+ };
454
+ }
455
+ /** Record a screenshot: bump the count, then re-evaluate the phase-2 conjunction. */
456
+ function recordScreenshot(state, corpusRequired) {
457
+ return maybePhase2({
458
+ ...state,
459
+ screenshotsCount: state.screenshotsCount + 1
460
+ }, corpusRequired);
461
+ }
462
+ /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */
463
+ function recordNavigate(state) {
464
+ return {
465
+ ...state,
466
+ scrolledSinceNav: false
467
+ };
468
+ }
469
+ /** Record a fuse-browser scroll (satisfies the scroll-before-screenshot guard). */
470
+ function recordScroll(state) {
471
+ return {
472
+ ...state,
473
+ scrolledSinceNav: true
474
+ };
475
+ }
476
+ /** Mark the design system validated and advance to phase 3 (after a passing create_frontend check). */
477
+ function recordValidDesignSystem(state) {
478
+ return {
479
+ ...state,
480
+ designSystemExists: true,
481
+ designSystemValid: true,
482
+ currentPhase: Math.max(state.currentPhase, 3),
483
+ phasesCompleted: [.../* @__PURE__ */ new Set([...state.phasesCompleted, "design-system"])]
484
+ };
485
+ }
486
+ /**
487
+ * Record a corpus read: distinct files only (a re-read never inflates the
488
+ * list), then re-evaluate the phase-2 conjunction. `relPath` is relative to
489
+ * the corpus root, keeping the persisted state small and prefix-independent.
490
+ */
491
+ function recordCorpusRead(state, relPath, corpusRequired) {
492
+ return maybePhase2({
493
+ ...state,
494
+ corpusReads: [.../* @__PURE__ */ new Set([...state.corpusReads, relPath])]
495
+ }, corpusRequired);
496
+ }
497
+ /**
498
+ * Record a skill-file Read: reading the identity templates enters phase 1 (browsing
499
+ * allowed); reading the inspiration catalog satisfies the browse prerequisite.
500
+ */
501
+ function recordRead(state, filePath, corpusRequired = false) {
502
+ const next = { ...state };
503
+ if (filePath.includes("design-system/SKILL.md")) {
504
+ next.currentPhase = Math.max(state.currentPhase, 1);
505
+ next.phasesCompleted = [.../* @__PURE__ */ new Set([...state.phasesCompleted, "identity"])];
506
+ }
507
+ if (filePath.includes("design-inspiration")) next.inspirationRead = true;
508
+ return maybePhase2(next, corpusRequired);
509
+ }
510
+ //#endregion
298
511
  //#region src/policy/design/flag.ts
299
512
  const flagPath = (cacheDir) => join(cacheDir, "design-agent-active");
300
513
  /** The active design agent id (the flag), or "" when no design agent is running. */
@@ -1752,36 +1965,6 @@ function postEditTypescript(filePath) {
1752
1965
  return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1753
1966
  }
1754
1967
  //#endregion
1755
- //#region src/util/semver.ts
1756
- /**
1757
- * Dependency-free semver ordering. `Array.prototype.sort()` compares strings
1758
- * lexicographically — `"1.0.9" > "1.0.23"` — so versioned directories (plugin
1759
- * caches) must be ordered with a numeric, segment-by-segment comparison.
1760
- */
1761
- /**
1762
- * Compare two dotted version strings numerically, segment by segment
1763
- * (shorter version wins when a prefix is equal: `1.0` < `1.0.1`). A segment
1764
- * that is not a plain integer (prerelease tag like `0-beta`) falls back to
1765
- * lexicographic comparison of that segment — never NaN, so `sort()` stays
1766
- * defined; identical strings compare 0.
1767
- * @returns Negative when `a < b`, positive when `a > b`, 0 when equal.
1768
- */
1769
- function compareSemver(a, b) {
1770
- const pa = a.split("."), pb = b.split(".");
1771
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1772
- const sa = pa[i] ?? "0", sb = pb[i] ?? "0";
1773
- if (sa === sb) continue;
1774
- const na = Number(sa), nb = Number(sb);
1775
- if (Number.isInteger(na) && Number.isInteger(nb) && na !== nb) return na - nb;
1776
- return sa < sb ? -1 : 1;
1777
- }
1778
- return 0;
1779
- }
1780
- /** The highest version of the list, or null when empty. */
1781
- function maxSemver(versions) {
1782
- return versions.length === 0 ? null : versions.reduce((a, b) => compareSemver(a, b) >= 0 ? a : b);
1783
- }
1784
- //#endregion
1785
1968
  //#region src/runtime/lifecycle/rules-root.ts
1786
1969
  /**
1787
1970
  * Dynamic rules-plugin root resolution. The historical `CLAUDE_PLUGIN_ROOT ??
@@ -7722,45 +7905,47 @@ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
7722
7905
  };
7723
7906
  }
7724
7907
  //#endregion
7725
- //#region src/runtime/design-helpers.ts
7908
+ //#region src/policy/design/design-system-rules.ts
7726
7909
  /**
7727
- * @module design-helpers
7728
- * Effectful helpers for the design pipeline gate: locate design-system.md and
7729
- * apply PostToolUse fuse-browser transitions to the design state. Split out of
7730
- * `design.ts` to keep that file within the SOLID size budget (SRP).
7731
- * @packageDocumentation
7910
+ * Content rules for design-system.md — the four hard requirements the
7911
+ * pipeline validates on the nominal write path (and the opt-in Gemini one).
7912
+ * Split out of `gates.ts` to keep both files within the SOLID size budget.
7732
7913
  */
7733
- const NAV$2 = "mcp__fuse-browser__browser_navigate";
7734
- const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
7735
- const SCROLL = "mcp__fuse-browser__browser_scroll";
7736
- const GEMINI$2 = "mcp__gemini-design__create_frontend";
7737
- /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
7738
- function findDesignSystem(cwd) {
7739
- let dir = cwd;
7740
- for (let i = 0; i < 6; i++) {
7741
- const p = join(dir, "design-system.md");
7742
- if (existsSync(p)) try {
7743
- return readFileSync(p, "utf8");
7744
- } catch {
7745
- return "";
7746
- }
7747
- const parent = dirname(dir);
7748
- if (parent === dir) break;
7749
- dir = parent;
7750
- }
7751
- return "";
7752
- }
7753
- /** Apply a PostToolUse fuse-browser transition to the design state. */
7754
- function recordPost(event, cacheDir, state) {
7755
- if (event.tool === SHOT$2) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
7756
- else if (event.tool === NAV$2) saveDesignState(cacheDir, recordNavigate(state));
7757
- else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
7758
- else if (event.tool === GEMINI$2) saveDesignState(cacheDir, {
7759
- ...state,
7760
- geminiCalls: state.geminiCalls + 1
7761
- });
7762
- else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
7763
- else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) saveDesignState(cacheDir, recordValidDesignSystem(state));
7914
+ const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
7915
+ /**
7916
+ * Forbidden fonts, matched in USAGE contexts — never in free prose: a design
7917
+ * system may SAY "Inter" ("## Interaction states", "we never use Inter"), it
7918
+ * may not USE it as a font. Covered forms: CSS declarations (`font:`,
7919
+ * `font-family:` in any case, multiline values), JS config (`fontFamily:`),
7920
+ * custom properties named in English (`--*font*`/`--*ff*`/`--*type*`, value
7921
+ * unquoted or quoted), ANY custom property whose value quotes a forbidden
7922
+ * name (`--police-texte: "Inter", …` — a non-English property name has no
7923
+ * keyword to key off, so the quote itself is the usage signal: a quoted
7924
+ * token in a `--*` value is a font-family entry, not prose), `@import`
7925
+ * URLs, and Markdown table cells (`| Body | Inter |`). Explicitly OUT of
7926
+ * scope (chosen, not overlooked): unquoted prose in bullet lines —
7927
+ * distinguishing "usage" from "mention" there is prose parsing, not a
7928
+ * regex's job. Accepted trade-off of the quote signal: `--comment: "we
7929
+ * never use Inter"` is flagged too — a quoted mention reads identically to
7930
+ * a quoted usage once the property name carries no keyword to disambiguate.
7931
+ */
7932
+ const FONT_DECL_RE = /(?:font(?:-family)?|fontFamily|--[\w-]*(?:font|ff|type)[\w-]*)\s*:[^;\n]*(?:\n\s*)?[^;\n]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i;
7933
+ const FONT_IMPORT_RE = /family=[^&"')]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i;
7934
+ const FONT_TABLE_RE = /\|\s*\*{0,2}(?:Inter|Roboto|Arial|Open Sans)\*{0,2}\s*\|/;
7935
+ const FONT_QUOTED_PROP_RE = /--[\w-]+\s*:[^;\n]*["'][^;\n]*(?:\n\s*)?[^;\n]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i;
7936
+ /**
7937
+ * Return the requirements missing from a design-system.md (empty = valid).
7938
+ * The corpus citation satisfies the source requirement ONLY when the corpus
7939
+ * is actually delivered (`corpusCitationOk`) — absent corpus, the URL is
7940
+ * mandatory again, exactly the pre-doctrine behavior (fallback never weaker).
7941
+ */
7942
+ function validateDesignSystem(content, corpusCitationOk = false) {
7943
+ const missing = [];
7944
+ if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
7945
+ if (!/https?:\/\//.test(content) && !(corpusCitationOk && hasCorpusCitation(content))) missing.push("reference URL (https://…) or Corpus citation");
7946
+ if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
7947
+ if (FONT_DECL_RE.test(content) || FONT_IMPORT_RE.test(content) || FONT_TABLE_RE.test(content) || FONT_QUOTED_PROP_RE.test(content)) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
7948
+ return missing;
7764
7949
  }
7765
7950
  //#endregion
7766
7951
  //#region src/policy/design/gates.ts
@@ -7771,16 +7956,9 @@ const EXEMPT_DIRS = [
7771
7956
  "build/",
7772
7957
  ".claude/"
7773
7958
  ];
7774
- const FORBIDDEN_FONTS = [
7775
- "Inter",
7776
- "Roboto",
7777
- "Arial",
7778
- "Open Sans"
7779
- ];
7780
- const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
7781
- const NAV$1 = "mcp__fuse-browser__browser_navigate";
7782
- const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
7783
- const GEMINI$1 = "mcp__gemini-design__create_frontend";
7959
+ const NAV$2 = "mcp__fuse-browser__browser_navigate";
7960
+ const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
7961
+ const GEMINI$2 = "mcp__gemini-design__create_frontend";
7784
7962
  const deny = (reason) => ({
7785
7963
  kind: "block",
7786
7964
  title: "Design pipeline",
@@ -7796,15 +7974,6 @@ function htmlCssOnlyGate(filePath) {
7796
7974
  function stateFileGate(filePath) {
7797
7975
  return filePath.includes(".design-state-") ? deny("BLOCKED: .design-state files are READ-ONLY for you. Hooks update them automatically as you progress. Do NOT try to modify them — it will not unblock you. Follow the pipeline: Phase 0→1→2→3→4→5→6 in order.") : null;
7798
7976
  }
7799
- /** Return the requirements missing from a design-system.md (empty = valid). */
7800
- function validateDesignSystem(content) {
7801
- const missing = [];
7802
- if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
7803
- if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
7804
- if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
7805
- if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
7806
- return missing;
7807
- }
7808
7977
  /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
7809
7978
  function screenshotScrollGate(state) {
7810
7979
  return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
@@ -7832,10 +8001,11 @@ function designPassNotice(i, cacheDir) {
7832
8001
  }
7833
8002
  const state = i.agentId && activeDesignAgent(cacheDir) === i.agentId ? loadDesignState(cacheDir, i.agentId) : null;
7834
8003
  if (state && i.phase === "pre") {
8004
+ if (i.corpusMissing) lines.push("design-corpus: refs-design corpus not found — corpus requirement waived (screenshot-only fallback, install defect)");
7835
8005
  if (isWrite && fp && !EXEMPT_DIRS.some((d) => fp.includes(d)) && ALLOWED_WRITE.test(fp)) lines.push(`enforce-html-css-only: allowed: ${basename(fp)}`);
7836
- if (i.tool === NAV$1) lines.push(`check-inspiration-read: pass (${i.url})`);
7837
- if (i.tool === GEMINI$1 && geminiEnabled()) lines.push("validate-design-system: design-system.md ok");
7838
- if (isWrite || i.tool === NAV$1 || i.tool === SHOT$1 || i.tool === GEMINI$1) lines.push(`pipeline-gate: phase ${state.currentPhase} ok`);
8006
+ if (i.tool === NAV$2) lines.push(`check-inspiration-read: pass (${i.url})`);
8007
+ if (i.tool === GEMINI$2 && geminiEnabled()) lines.push("validate-design-system: design-system.md ok");
8008
+ if (isWrite || i.tool === NAV$2 || i.tool === SHOT$2 || i.tool === GEMINI$2) lines.push(`pipeline-gate: phase ${state.currentPhase} ok`);
7839
8009
  }
7840
8010
  if (state && i.phase === "post" && isWrite) {
7841
8011
  if (basename(fp) === "design-system.md") lines.push("validate-design: design-system.md → phase 3");
@@ -7849,41 +8019,292 @@ function designPassNotice(i, cacheDir) {
7849
8019
  } : null;
7850
8020
  }
7851
8021
  //#endregion
8022
+ //#region src/policy/design/template-urls.ts
8023
+ /**
8024
+ * Template-platform detection for the phase-1 navigate gate
8025
+ * (design-inspiration.md §FORBIDDEN Navigation Targets). Two rules, because
8026
+ * neither alone matches the doctrine:
8027
+ * - template SHOPS are banned outright, on the host (subdomains included);
8028
+ * - the path rule (/templates, /themes) applies ONLY to real product
8029
+ * platforms that also host a template catalog — never to the whole web,
8030
+ * or sothebys.com/en/marketplace (register research) would be denied.
8031
+ * The host is normalized (lowercase, port, userinfo, trailing dot stripped).
8032
+ * HONEST SCOPE: this is a LEXICAL filter — it follows no redirect, is not and
8033
+ * will never be exhaustive, and it is NOT the taste gate: the source of taste
8034
+ * is the refs-design corpus. This list exists to catch the reflex, not to map
8035
+ * the template economy. Frozen by owner decision (C5): no host or pattern may
8036
+ * be added or removed without re-opening that decision.
8037
+ * Award galleries are deliberately absent: they pass, as outbound-link finders.
8038
+ */
8039
+ /** Template shops: banned outright, any path (subdomains included). */
8040
+ const TEMPLATE_HOSTS = [
8041
+ "themeforest.net",
8042
+ "themes.shopify.com",
8043
+ "marketplace.atlassian.com",
8044
+ "templatemonster.com",
8045
+ "creativemarket.com",
8046
+ "ui8.net",
8047
+ "html5up.net",
8048
+ "colorlib.com",
8049
+ "bootstrapmade.com",
8050
+ "startbootstrap.com",
8051
+ "cruip.com",
8052
+ "uideck.com",
8053
+ "envato.com",
8054
+ "tailwindui.com"
8055
+ ];
8056
+ /** Real-product platforms whose template CATALOGS are banned via the path rule. */
8057
+ const PLATFORM_HOSTS = [
8058
+ "framer.com",
8059
+ "webflow.com",
8060
+ "vercel.com",
8061
+ "astro.build",
8062
+ "squarespace.com",
8063
+ "wix.com"
8064
+ ];
8065
+ /**
8066
+ * Template-catalog path segments — evaluated ONLY on PLATFORM_HOSTS, and
8067
+ * deliberately WITHOUT `/marketplace`: those platforms run real, non-template
8068
+ * marketplaces (vercel.com/marketplace is infra). Atlassian/Shopify template
8069
+ * stores are already covered by the full host ban above.
8070
+ */
8071
+ const TEMPLATE_PATH_RE = /^\/(templates|themes)(\/|$)/i;
8072
+ /** Host part of `url`, normalized: lowercase, no port, no userinfo, no trailing dot. */
8073
+ function hostOf(url) {
8074
+ return (/^https?:\/\/(?:[^@/?#]*@)?([^/:?#]+)/i.exec(url)?.[1] ?? "").replace(/\.$/, "").toLowerCase();
8075
+ }
8076
+ const under = (host, domain) => host === domain || host.endsWith(`.${domain}`);
8077
+ /** True when `url` points at a banned template source (host rule, then platform path rule). */
8078
+ function isTemplateUrl(url) {
8079
+ const host = hostOf(url);
8080
+ if (!host) return false;
8081
+ if (TEMPLATE_HOSTS.some((d) => under(host, d))) return true;
8082
+ if (!PLATFORM_HOSTS.some((d) => under(host, d))) return false;
8083
+ const path = /^https?:\/\/(?:[^@/?#]*@)?[^/?#]*(\/[^?#]*)/i.exec(url)?.[1] ?? "/";
8084
+ return TEMPLATE_PATH_RE.test(path);
8085
+ }
8086
+ //#endregion
7852
8087
  //#region src/policy/design/gates-pipeline.ts
7853
8088
  const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;
7854
- const KNOWN_DOMAINS = [
7855
- "framer.website",
7856
- "webflow.io",
7857
- "awwwards.com",
7858
- "godly.website",
7859
- "lapa.ninja",
7860
- "onepagelove.com",
7861
- "saasframe.io",
7862
- "bestwebsite.gallery",
7863
- "landingfolio.com"
7864
- ];
7865
8089
  /** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */
7866
- function designSystemWriteGate(filePath, state) {
8090
+ function designSystemWriteGate(filePath, state, corpusRequired = false) {
7867
8091
  if (!filePath.endsWith("design-system.md")) return null;
7868
- if (state.currentPhase < 2) return deny(`BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. RECOVERY: 1) Read identity templates from skills/design-system/ 2) Read design-inspiration.md 3) Browse sites and take a screenshot with mcp__fuse-browser__browser_screenshot on a LIVE session (note: browser_shots_batch/browser_site_shots do NOT advance the phase) 4) Then write design-system.md`);
7869
- const needed = MIN_SCREENSHOTS[state.mode];
7870
- if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. RECOVERY: 1) Take ${needed - state.screenshotsCount} more screenshots with mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots do NOT count) 2) Use browser_open + browser_navigate + browser_screenshot fullPage:true 3) Then write design-system.md`);
8092
+ if (state.currentPhase < 2) return deny(`BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. RECOVERY: 1) Read identity templates from skills/design-system/ 2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) with the Read tool 4) Screenshot real sector sites with mcp__fuse-browser__browser_screenshot on a LIVE session (note: browser_shots_batch/browser_site_shots do NOT advance the phase) 5) Then write design-system.md`);
8093
+ const needed = quotaFor(state.mode, corpusRequired);
8094
+ if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. RECOVERY: 1) Read the refs-design corpus (README.md + tokens-*.md) if not done 2) Take ${needed - state.screenshotsCount} more screenshots of REAL sector sites with mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots do NOT count) 3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md`);
7871
8095
  return null;
7872
8096
  }
7873
8097
  /** Gate Gemini create_frontend: requires phase >= 3 and a validated design system. */
7874
8098
  function geminiCreateGate(state) {
7875
- if (state.currentPhase < 3) return deny("BLOCKED: Cannot call Gemini create_frontend before phase 3. RECOVERY: 1) Complete screenshot browsing phase 2) Write a valid design-system.md 3) Then call mcp__gemini-design__create_frontend");
7876
- if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated. RECOVERY: 1) Ensure design-system.md has ## Design Reference, OKLCH tokens, typography pair, reference URL 2) Then retry mcp__gemini-design__create_frontend");
8099
+ if (state.currentPhase < 3) return deny("BLOCKED: Cannot call Gemini create_frontend before phase 3. RECOVERY: 1) Complete the inspiration phase (refs-design corpus + sector screenshots) 2) Write a valid design-system.md 3) Then call mcp__gemini-design__create_frontend");
8100
+ if (!state.designSystemValid) return deny("BLOCKED: design-system.md not validated. RECOVERY: 1) Ensure design-system.md has ## Design Reference, OKLCH tokens, typography pair, reference URL or Corpus citation 2) Then retry mcp__gemini-design__create_frontend");
7877
8101
  return null;
7878
8102
  }
7879
- /** Gate fuse-browser navigate: phase >= 1, inspiration read, URL in the catalog. */
8103
+ /**
8104
+ * Gate fuse-browser navigate: phase >= 1, inspiration read, no template source in phase 1.
8105
+ * The allowlist→denylist inversion is the DELIBERATE doctrine change (not a
8106
+ * weakening): the gate was never the proof — the mandatory screenshot is,
8107
+ * unchanged and unforgeable in both configurations. Corpus-absent has its
8108
+ * counterpart already (pre-doctrine quotas + visible warning); what the
8109
+ * denylist cannot cover (sector-browse quality) belongs to the doc, per the
8110
+ * rule "the gate requires what is verifiable, the doc prescribes zeal".
8111
+ */
7880
8112
  function browserNavigateGate(state, url) {
7881
8113
  if (state.currentPhase < 1) return deny(`BLOCKED: Phase 0 not done. READ: ${SKILLS}/design-system/SKILL.md first.`);
7882
- if (!state.inspirationRead) return deny(`BLOCKED: Read inspiration catalog first. READ: ${SKILLS}/design-web/references/design-inspiration.md + design-inspiration-urls.md`);
7883
- if (state.currentPhase === 1 && url && !KNOWN_DOMAINS.some((d) => url.includes(d))) return deny(`BLOCKED: '${url}' not in catalog. Use URLs from design-inspiration-urls.md. Domains: ${KNOWN_DOMAINS.join(", ")}`);
8114
+ if (!state.inspirationRead) return deny(`BLOCKED: Read the inspiration doctrine first. READ: ${SKILLS}/design-web/references/design-inspiration.md, then the refs-design corpus (README.md + relevant tokens-*.md) with the Read tool`);
8115
+ if (state.currentPhase === 1 && url && isTemplateUrl(url)) return deny(`BLOCKED: '${url}' is a template source — templates are BUILT to be interchangeable, so extracting taste from them converges every page to the same body. RECOVERY: 1) taste comes from the refs-design corpus (README.md + relevant tokens-*.md) 2) browse 1-2 REAL sites in the client's sector, for register only (award galleries are fine as outbound-link finders)`);
7884
8116
  return null;
7885
8117
  }
7886
8118
  //#endregion
8119
+ //#region src/runtime/design-files-gate.ts
8120
+ /**
8121
+ * Literal substitution, first occurrence or all — NEVER String.replace's
8122
+ * replacement-string semantics: an agent-controlled new_string of `$&`, `` $` ``,
8123
+ * `$'` or `$$` must be written (and validated) literally, not interpreted as
8124
+ * the matched text (that made the gate validate a document the tool would
8125
+ * never write — the `$&` bypass). Used on both sides: PRE reconstruction and
8126
+ * POST reverse-reconstruction.
8127
+ */
8128
+ function substituteLiteral(s, from, to, all) {
8129
+ return all ? s.split(from).join(to) : s.replace(from, () => to);
8130
+ }
8131
+ /** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */
8132
+ function designFilesGate(files, state, pluginsRoot, corpusRoot, corpusRequired, cwd) {
8133
+ for (const f of files) {
8134
+ const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath);
8135
+ if (hit) return hit;
8136
+ if (f.op === "delete") continue;
8137
+ if (!f.filePath.endsWith("design-system.md")) continue;
8138
+ const gate = designSystemWriteGate(f.filePath, state, corpusRequired) ?? (f.op === "add" ? designSystemContentGate({
8139
+ filePath: f.filePath,
8140
+ tool: "Write",
8141
+ content: f.content,
8142
+ oldString: void 0,
8143
+ replaceAll: false,
8144
+ state,
8145
+ corpusRoot,
8146
+ corpusRequired
8147
+ }) : null);
8148
+ if (gate) return gate;
8149
+ }
8150
+ return null;
8151
+ }
8152
+ //#endregion
8153
+ //#region src/runtime/design-content-gate.ts
8154
+ /**
8155
+ * @module design-content-gate
8156
+ * PRE content gate for design-system.md — the nominal path (validateDesignSystem
8157
+ * used to run only behind the opt-in Gemini branch, so designSystemValid meant
8158
+ * "a write happened", not "the content is valid"). Split out of
8159
+ * `design-helpers.ts` to keep both files within the SOLID size budget (SRP).
8160
+ * @packageDocumentation
8161
+ */
8162
+ /**
8163
+ * Every content violation of a design-system.md text, GRANULAR (one entry per
8164
+ * unmet requirement and per unread cited reference — so a worsening check can
8165
+ * diff two violation sets): the four hard requirements, plus the citation↔read
8166
+ * jointure when the corpus is delivered.
8167
+ */
8168
+ function designSystemProblems(content, reads, corpusRequired) {
8169
+ const problems = validateDesignSystem(content, corpusRequired);
8170
+ if (corpusRequired) {
8171
+ for (const ref of citedCorpusRefs(content)) if (!citationJoinsReads(ref, reads)) problems.push(`cited corpus reference never read: ${ref}`);
8172
+ }
8173
+ return problems;
8174
+ }
8175
+ /** Content check for a design-system.md already on disk (the Gemini create_frontend path). */
8176
+ function checkDesignSystemContent(content, reads, corpusRequired) {
8177
+ const problems = designSystemProblems(content, reads, corpusRequired);
8178
+ return problems.length ? deny(`BLOCKED: design-system.md too generic. Missing: ${problems.join(", ")}.`) : null;
8179
+ }
8180
+ /**
8181
+ * The content a Write/Edit would LEAVE in design-system.md, and the on-disk
8182
+ * original when the file exists (for BOTH tools). Edit reconstructs via
8183
+ * literal substitution (first occurrence, or all under replace_all — the Edit
8184
+ * tool's own semantics, mirroring edit-outcome.ts). Null when unverifiable
8185
+ * (unreadable file or stale old_string) — fail-closed, such an Edit fails at
8186
+ * tool level anyway.
8187
+ */
8188
+ function effectiveContents(i) {
8189
+ if (i.tool !== "Edit") {
8190
+ let original = null;
8191
+ try {
8192
+ if (existsSync(i.filePath)) original = readFileSync(i.filePath, "utf8");
8193
+ } catch {
8194
+ original = null;
8195
+ }
8196
+ return {
8197
+ result: i.content,
8198
+ original
8199
+ };
8200
+ }
8201
+ let disk;
8202
+ try {
8203
+ disk = readFileSync(i.filePath, "utf8");
8204
+ } catch {
8205
+ return null;
8206
+ }
8207
+ if (!i.oldString || !disk.includes(i.oldString)) return null;
8208
+ return {
8209
+ result: substituteLiteral(disk, i.oldString, i.content, i.replaceAll),
8210
+ original: disk
8211
+ };
8212
+ }
8213
+ /**
8214
+ * Block only the violations the change INTRODUCES (`problems(result) ⊄
8215
+ * problems(original)`): a legacy design-system.md that predates the doctrine
8216
+ * stays editable — and rewritable — including byte-identically or toward
8217
+ * conformance; a change that adds a NEW problem (or writes an invalid file
8218
+ * from scratch) is blocked.
8219
+ */
8220
+ function designSystemContentGate(i) {
8221
+ if (!i.filePath.endsWith("design-system.md")) return null;
8222
+ const pair = effectiveContents(i);
8223
+ if (pair === null) return deny("BLOCKED: cannot verify the edit result (unreadable file or stale old_string). RECOVERY: re-read design-system.md, then retry.");
8224
+ const result = designSystemProblems(pair.result, i.state.corpusReads, i.corpusRequired);
8225
+ if (!result.length) return null;
8226
+ const original = pair.original !== null ? designSystemProblems(pair.original, i.state.corpusReads, i.corpusRequired) : [];
8227
+ const introduced = result.filter((p) => !original.includes(p));
8228
+ if (!introduced.length) return null;
8229
+ return deny(`BLOCKED: design-system.md too generic. Missing: ${introduced.join(", ")}. RECOVERY: fix the content (read cited refs-design references with the Read tool), then retry.`);
8230
+ }
8231
+ //#endregion
8232
+ //#region src/runtime/design-helpers.ts
8233
+ /**
8234
+ * @module design-helpers
8235
+ * Effectful helpers for the design pipeline gate: locate design-system.md and
8236
+ * apply PostToolUse fuse-browser transitions to the design state. Split out of
8237
+ * `design.ts` to keep that file within the SOLID size budget (SRP).
8238
+ * @packageDocumentation
8239
+ */
8240
+ const NAV$1 = "mcp__fuse-browser__browser_navigate";
8241
+ const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
8242
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
8243
+ const GEMINI$1 = "mcp__gemini-design__create_frontend";
8244
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
8245
+ function findDesignSystem(cwd) {
8246
+ let dir = cwd;
8247
+ for (let i = 0; i < 6; i++) {
8248
+ const p = join(dir, "design-system.md");
8249
+ if (existsSync(p)) try {
8250
+ return readFileSync(p, "utf8");
8251
+ } catch {
8252
+ return "";
8253
+ }
8254
+ const parent = dirname(dir);
8255
+ if (parent === dir) break;
8256
+ dir = parent;
8257
+ }
8258
+ return "";
8259
+ }
8260
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
8261
+ function recordPost(event, cacheDir, state, corpusRoot = "", corpusRequired = false, cwd = "") {
8262
+ if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired));
8263
+ else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
8264
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
8265
+ else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
8266
+ ...state,
8267
+ geminiCalls: state.geminiCalls + 1
8268
+ });
8269
+ else if (event.tool === "Read") {
8270
+ const fp = event.filePath ?? "";
8271
+ if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired));
8272
+ else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired));
8273
+ } else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) {
8274
+ const fp = event.filePath ?? "";
8275
+ let content;
8276
+ try {
8277
+ content = readFileSync(fp, "utf8");
8278
+ } catch {
8279
+ return;
8280
+ }
8281
+ const problems = designSystemProblems(content, state.corpusReads, corpusRequired);
8282
+ if (!problems.length) {
8283
+ saveDesignState(cacheDir, recordValidDesignSystem(state));
8284
+ return;
8285
+ }
8286
+ if (event.tool === "Edit" && event.oldString && event.content && content.includes(event.content)) {
8287
+ const original = substituteLiteral(content, event.content, event.oldString, event.input.replace_all === true);
8288
+ if (problems.filter((p) => !designSystemProblems(original, state.corpusReads, corpusRequired).includes(p)).length) saveDesignState(cacheDir, {
8289
+ ...state,
8290
+ designSystemValid: false
8291
+ });
8292
+ }
8293
+ } else if (event.files) {
8294
+ const ds = event.files.find((f) => f.op !== "delete" && f.filePath.endsWith("design-system.md"));
8295
+ if (ds) {
8296
+ const abs = isAbsolute(ds.filePath) ? ds.filePath : join(cwd, ds.filePath);
8297
+ let content;
8298
+ try {
8299
+ content = readFileSync(abs, "utf8");
8300
+ } catch {
8301
+ return;
8302
+ }
8303
+ if (!designSystemProblems(content, state.corpusReads, corpusRequired).length) saveDesignState(cacheDir, recordValidDesignSystem(state));
8304
+ }
8305
+ }
8306
+ }
8307
+ //#endregion
7887
8308
  //#region src/runtime/design.ts
7888
8309
  const NAV = "mcp__fuse-browser__browser_navigate";
7889
8310
  const SHOT = "mcp__fuse-browser__browser_screenshot";
@@ -7892,7 +8313,7 @@ const GEMINI = "mcp__gemini-design__create_frontend";
7892
8313
  * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
7893
8314
  * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
7894
8315
  */
7895
- function designGate(payload, event, cacheDir, cwd) {
8316
+ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRootOverride) {
7896
8317
  if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
7897
8318
  const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
7898
8319
  if (skillBlock) return skillBlock;
@@ -7909,8 +8330,11 @@ function designGate(payload, event, cacheDir, cwd) {
7909
8330
  state = initDesignState(id, dsExists ? "page" : "full", dsExists);
7910
8331
  saveDesignState(cacheDir, state);
7911
8332
  }
8333
+ const corpusRoot = corpusRootOverride ?? resolveCorpusRoot();
8334
+ const corpusRequired = corpusRoot !== "";
8335
+ const pluginsRoot = pluginsRootOverride ?? resolvePluginsRoot();
7912
8336
  if (event.phase === "post") {
7913
- recordPost(event, cacheDir, state);
8337
+ recordPost(event, cacheDir, state, corpusRoot, corpusRequired, cwd);
7914
8338
  if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
7915
8339
  const warnings = runDesignChecks(event.content ?? "");
7916
8340
  if (warnings.length) return {
@@ -7924,7 +8348,16 @@ function designGate(payload, event, cacheDir, cwd) {
7924
8348
  }
7925
8349
  if (event.tool === "Write" || event.tool === "Edit") {
7926
8350
  const fp = event.filePath ?? "";
7927
- const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
8351
+ const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state, corpusRequired) ?? designSystemContentGate({
8352
+ filePath: fp,
8353
+ tool: event.tool,
8354
+ content: event.content ?? "",
8355
+ oldString: event.oldString,
8356
+ replaceAll: event.input.replace_all === true,
8357
+ state,
8358
+ corpusRoot,
8359
+ corpusRequired
8360
+ });
7928
8361
  if (base) return base;
7929
8362
  if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
7930
8363
  kind: "block",
@@ -7934,6 +8367,7 @@ function designGate(payload, event, cacheDir, cwd) {
7934
8367
  };
7935
8368
  return null;
7936
8369
  }
8370
+ if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd);
7937
8371
  if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
7938
8372
  if (event.tool === SHOT) return screenshotScrollGate(state);
7939
8373
  if (event.tool === GEMINI) {
@@ -7944,16 +8378,11 @@ function designGate(payload, event, cacheDir, cwd) {
7944
8378
  if (ds === "") return {
7945
8379
  kind: "block",
7946
8380
  title: "Design pipeline",
7947
- reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Browse 4 reference sites 4) Write design-system.md, then retry create_frontend.",
8381
+ reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) 4) Screenshot 1-2 real sector sites 5) Write design-system.md, then retry create_frontend.",
7948
8382
  actions: ["Create design-system.md via the pipeline, then retry create_frontend"]
7949
8383
  };
7950
- const missing = validateDesignSystem(ds);
7951
- if (missing.length) return {
7952
- kind: "block",
7953
- title: "Design pipeline",
7954
- reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
7955
- actions: ["Fix design-system.md, then retry create_frontend"]
7956
- };
8384
+ const contentBlock = checkDesignSystemContent(ds, state.corpusReads, corpusRequired);
8385
+ if (contentBlock) return contentBlock;
7957
8386
  saveDesignState(cacheDir, recordValidDesignSystem(state));
7958
8387
  }
7959
8388
  return null;
@@ -8196,14 +8625,16 @@ async function freshEvidenceNotice(event, evidence, cwd) {
8196
8625
  * @param evidence - Session track + clock for the "evidence fresh" notice (omit to disable it).
8197
8626
  * @returns The native hook outcome (empty stdout when nothing to emit).
8198
8627
  */
8199
- async function allowOutcome(id, event, payload, mcpDir, cwd, evidence) {
8628
+ async function allowOutcome(id, event, payload, mcpDir, cwd, evidence, corpusRoot) {
8629
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
8200
8630
  const notice = designPassNotice({
8201
- agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
8631
+ agentId,
8202
8632
  tool: event.tool,
8203
8633
  filePath: event.filePath ?? "",
8204
8634
  content: event.content ?? "",
8205
8635
  url: typeof event.input.url === "string" ? event.input.url : "",
8206
- phase: "pre"
8636
+ phase: "pre",
8637
+ corpusMissing: agentId !== "" && (corpusRoot ?? resolveCorpusRoot()) === ""
8207
8638
  }, mcpDir);
8208
8639
  const lesson = lessonFor(event.tool, event.input, {
8209
8640
  file: lessonsFileFor(projectRoot(cwd)),
@@ -8303,7 +8734,7 @@ async function handlePre(ctx) {
8303
8734
  exit: 0
8304
8735
  };
8305
8736
  }
8306
- const designBlock = designGate(payload, event, mcpDir, opts.cwd);
8737
+ const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
8307
8738
  if (designBlock) return {
8308
8739
  stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now),
8309
8740
  exit: 0
@@ -8354,7 +8785,7 @@ async function handlePre(ctx) {
8354
8785
  trackFile: file,
8355
8786
  windowMs: opts.windowMs,
8356
8787
  now: opts.now
8357
- });
8788
+ }, opts.corpusRoot);
8358
8789
  }
8359
8790
  //#endregion
8360
8791
  //#region src/freshness/query-framework.ts
@@ -8862,7 +9293,7 @@ async function handlePost(ctx) {
8862
9293
  const { id, payload, event, framework, mcpDir, file, opts } = ctx;
8863
9294
  const response = payload.tool_response ?? payload.tool_output;
8864
9295
  mcpPostStore(event.tool, event.input, response, mcpDir);
8865
- const designWarn = designGate(payload, event, mcpDir, opts.cwd);
9296
+ const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
8866
9297
  const activities = activityFor({
8867
9298
  tool: event.tool,
8868
9299
  input: event.input,
@@ -860,6 +860,12 @@ interface HandleOptions {
860
860
  windowMs?: number;
861
861
  /** Which plugin's hooks.json invoked the harness (selects lifecycle behavior). */
862
862
  scope?: PluginScope;
863
+ /**
864
+ * Test/integration seam: the resolved refs-design corpus root ("" = absent).
865
+ * Undefined = resolve from disk (the production behavior); supplied = used
866
+ * as-is, so tests can drive the corpus-present branch deterministically.
867
+ */
868
+ corpusRoot?: string;
863
869
  }
864
870
  /** What the hook bin should print + exit with. */
865
871
  interface HandleOutcome {
@@ -1,6 +1,6 @@
1
1
  import { r as projectLayout } from "../layout-KWoE_Mqn.mjs";
2
2
  import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-oUGFB4ds.mjs";
3
- import { A as trackMcpResearch, At as defaultStateDir, B as generateProjectMap, C as seoPostToolUse, Ct as trimLogFile, D as securityAdvisoryForPatch, Dt as claudeMdKey, E as securityAdvisory, Et as projectContext, F as dispatchAipilot, Ft as loadSecurityState, G as countFiles, H as writeTree, I as dispatchLessons, It as saveSecurityState, J as lessonsArchiveFileFor, K as getFileDesc, L as cartoSessionStart, Lt as securityStateDir, M as trackEnrichment, Mt as trackFile, N as dispatchLifecycle, Nt as normalizeEvent, O as postTrackingSideEffects, Ot as promptSubmitContext, P as aipilotPostToolUse, Pt as isoUtc, R as generateEcosystemMap, Rt as securityStatePath, S as postEditContext, St as removeOldFiles, T as dispatchMemory, Tt as gitContext, U as loadEnriched, V as isProject, W as mergeLines, X as lessonsStateFileFor, Y as lessonsFileFor, _ as preCommitGate, _t as readRules, a as recordActivity, at as saveApexState, b as extractSymbols, bt as pruneEmptyDirs, c as MCP_TTL_MS, ct as trackAgentMemory, d as isMcpTool, dt as validateSolidGate, f as queryOf, ft as checkFileSize, g as gate, gt as injectRules, h as TRIVIAL_BUDGET, ht as solidDetectStart, i as respond, it as cleanupSession, j as trackSkillRead, jt as projectHash, k as trackWatchResearch, kt as taskContext, l as WEBFETCH_TTL_MS, lt as subagentCacheContext, m as REQUIRED_AGENTS, mt as detectSolidProfile, n as activityFor, nt as trackSessionChanges, o as mcpPostStore, ot as logToolFailure, p as DEFAULT_WINDOW_MS, pt as countLoc, q as listChildren, r as handlePre, rt as validateRulesLoaded, s as mcpPreIntercept, st as validateTeammateOutput, t as handleHook, tt as postEditTypescript, u as cacheQueryOf, ut as validateTailwind, v as detectDuplication, vt as runSessionStartCleanups, w as seoPostToolUseResponse, wt as devContext, x as lifecycleStdout, xt as purgeTtlTree, y as dryGate, yt as sessionStartCore, z as writePluginMap, zt as todayUtc } from "../handle-F35Gx-QS.mjs";
3
+ import { A as trackMcpResearch, At as defaultStateDir, B as generateProjectMap, C as seoPostToolUse, Ct as trimLogFile, D as securityAdvisoryForPatch, Dt as claudeMdKey, E as securityAdvisory, Et as projectContext, F as dispatchAipilot, Ft as loadSecurityState, G as countFiles, H as writeTree, I as dispatchLessons, It as saveSecurityState, J as lessonsArchiveFileFor, K as getFileDesc, L as cartoSessionStart, Lt as securityStateDir, M as trackEnrichment, Mt as trackFile, N as dispatchLifecycle, Nt as normalizeEvent, O as postTrackingSideEffects, Ot as promptSubmitContext, P as aipilotPostToolUse, Pt as isoUtc, R as generateEcosystemMap, Rt as securityStatePath, S as postEditContext, St as removeOldFiles, T as dispatchMemory, Tt as gitContext, U as loadEnriched, V as isProject, W as mergeLines, X as lessonsStateFileFor, Y as lessonsFileFor, _ as preCommitGate, _t as readRules, a as recordActivity, at as saveApexState, b as extractSymbols, bt as pruneEmptyDirs, c as MCP_TTL_MS, ct as trackAgentMemory, d as isMcpTool, dt as validateSolidGate, f as queryOf, ft as checkFileSize, g as gate, gt as injectRules, h as TRIVIAL_BUDGET, ht as solidDetectStart, i as respond, it as cleanupSession, j as trackSkillRead, jt as projectHash, k as trackWatchResearch, kt as taskContext, l as WEBFETCH_TTL_MS, lt as subagentCacheContext, m as REQUIRED_AGENTS, mt as detectSolidProfile, n as activityFor, nt as trackSessionChanges, o as mcpPostStore, ot as logToolFailure, p as DEFAULT_WINDOW_MS, pt as countLoc, q as listChildren, r as handlePre, rt as validateRulesLoaded, s as mcpPreIntercept, st as validateTeammateOutput, t as handleHook, tt as postEditTypescript, u as cacheQueryOf, ut as validateTailwind, v as detectDuplication, vt as runSessionStartCleanups, w as seoPostToolUseResponse, wt as devContext, x as lifecycleStdout, xt as purgeTtlTree, y as dryGate, yt as sessionStartCore, z as writePluginMap, zt as todayUtc } from "../handle-DptZbyMP.mjs";
4
4
  //#region src/runtime/storage.ts
5
5
  /**
6
6
  * The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.83",
3
+ "version": "0.1.85",
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",
@@ -155,7 +155,7 @@
155
155
  },
156
156
  "devDependencies": {
157
157
  "@types/bun": "latest",
158
- "tsdown": "^0.22.13",
158
+ "tsdown": "^0.22.14",
159
159
  "typedoc": "^0.28.20",
160
160
  "typescript": "^7.0.2"
161
161
  },