@fusengine/harness 0.1.81 → 0.1.84

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.
@@ -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";
@@ -12,13 +13,13 @@ import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
12
13
  import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-IhHk2KlT.mjs";
13
14
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
14
15
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
15
- import { _ as trivialCount, a as LOCK_TTL_MS, c as writeLastNonce, d as recordAgent, f as recordBrainstormRequired, g as recordTrivialEdit, h as recordTarget, i as LOCK_FAILED, l as agentsFresh, m as recordRefRead, o as signTrack, p as recordDoc, r as withTrack, s as verifyTrack, t as loadTrack, u as emptyTrack, v as apexAuthorizationGate } from "./store-yr_QFsZY.mjs";
16
+ import { C as apexAuthorizationGate, S as trivialCount, _ as recordBrainstormRequired, a as journalLogPath, b as recordTarget, c as appendEvent, d as signTrack, f as verifyTrack, g as recordAgent, h as emptyTrack, i as withTrack, l as withTrackLockSync, m as agentsFresh, o as readTrackSync, p as writeLastNonce, r as trackJournalEnabled, s as diffTrackEvents, t as loadTrack, u as LOCK_FAILED, v as recordDoc, x as recordTrivialEdit, y as recordRefRead } from "./store-Dw08Amqh.mjs";
16
17
  import { n as parseApplyPatch, t as isBypassPermissions } from "./permission-mode-BN3MNgbm.mjs";
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 { appendFileSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
21
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
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";
24
25
  import { createHash } from "node:crypto";
@@ -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. */
@@ -1248,15 +1461,13 @@ function freshPassingReceipt(track, windowMs, now) {
1248
1461
  return hits.length ? hits.reduce((a, b) => b.ts > a.ts ? b : a) : null;
1249
1462
  }
1250
1463
  /**
1251
- * Sync variant reading the signed track file directly — for the sync gates
1252
- * (TaskCompleted / SubagentStop). Returns the newest passing receipt, or `null`
1253
- * on any read/verify failure (fail-closed: no proof ⇒ treated as unverified).
1464
+ * Sync variant reading the track directly (snapshot ⊕ journal) — for the sync
1465
+ * gates (TaskCompleted / SubagentStop). Returns the newest passing receipt, or
1466
+ * `null` on any read/verify failure (fail-closed: no proof ⇒ unverified).
1254
1467
  */
1255
1468
  function freshReceiptFromFile(file, windowMs, now) {
1256
1469
  try {
1257
- if (!existsSync(file)) return null;
1258
- const track = verifyTrack(JSON.parse(readFileSync(file, "utf-8")));
1259
- return track ? freshPassingReceipt(track, windowMs, now) : null;
1470
+ return freshPassingReceipt(readTrackSync(file, trackJournalEnabled()), windowMs, now);
1260
1471
  } catch {
1261
1472
  return null;
1262
1473
  }
@@ -1754,36 +1965,6 @@ function postEditTypescript(filePath) {
1754
1965
  return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1755
1966
  }
1756
1967
  //#endregion
1757
- //#region src/util/semver.ts
1758
- /**
1759
- * Dependency-free semver ordering. `Array.prototype.sort()` compares strings
1760
- * lexicographically — `"1.0.9" > "1.0.23"` — so versioned directories (plugin
1761
- * caches) must be ordered with a numeric, segment-by-segment comparison.
1762
- */
1763
- /**
1764
- * Compare two dotted version strings numerically, segment by segment
1765
- * (shorter version wins when a prefix is equal: `1.0` < `1.0.1`). A segment
1766
- * that is not a plain integer (prerelease tag like `0-beta`) falls back to
1767
- * lexicographic comparison of that segment — never NaN, so `sort()` stays
1768
- * defined; identical strings compare 0.
1769
- * @returns Negative when `a < b`, positive when `a > b`, 0 when equal.
1770
- */
1771
- function compareSemver(a, b) {
1772
- const pa = a.split("."), pb = b.split(".");
1773
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1774
- const sa = pa[i] ?? "0", sb = pb[i] ?? "0";
1775
- if (sa === sb) continue;
1776
- const na = Number(sa), nb = Number(sb);
1777
- if (Number.isInteger(na) && Number.isInteger(nb) && na !== nb) return na - nb;
1778
- return sa < sb ? -1 : 1;
1779
- }
1780
- return 0;
1781
- }
1782
- /** The highest version of the list, or null when empty. */
1783
- function maxSemver(versions) {
1784
- return versions.length === 0 ? null : versions.reduce((a, b) => compareSemver(a, b) >= 0 ? a : b);
1785
- }
1786
- //#endregion
1787
1968
  //#region src/runtime/lifecycle/rules-root.ts
1788
1969
  /**
1789
1970
  * Dynamic rules-plugin root resolution. The historical `CLAUDE_PLUGIN_ROOT ??
@@ -2115,62 +2296,6 @@ function harvestAgentEvidence(transcriptPath, track, now) {
2115
2296
  return uses.reduce((acc, u) => applyToolUse(acc, u, now), track);
2116
2297
  }
2117
2298
  //#endregion
2118
- //#region src/tracking/track-lock-sync.ts
2119
- /**
2120
- * Synchronous twin of {@link withTrackLock} for lifecycle paths that CANNOT
2121
- * float async work (the SubagentStop dispatch runs synchronously in a
2122
- * short-lived hook process — an async write could be dropped before exit,
2123
- * see `evidence-harvest-io.ts`). Same semantics: `wx` lockfile, bounded total
2124
- * retry, named skipped write on contention, stale reclamation. The wait uses
2125
- * `Atomics.wait` (allowed on the main thread in both Node and Bun).
2126
- */
2127
- /** Block the thread for `ms` (bounded by the caller's total budget). */
2128
- function sleepSync(ms) {
2129
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2130
- }
2131
- /** True when the lockfile is older than the TTL (orphaned by a dead process). */
2132
- function isStale(lock) {
2133
- try {
2134
- return Date.now() - statSync(lock).mtimeMs > LOCK_TTL_MS;
2135
- } catch {
2136
- return false;
2137
- }
2138
- }
2139
- /**
2140
- * Run `fn` under the track lock of `dir`, synchronously.
2141
- * @param dir - The track state directory (lockfile lives inside).
2142
- * @param fn - The protected read-modify-write.
2143
- * @returns `fn`'s result, or {@link LOCK_FAILED} when the lock stayed busy.
2144
- */
2145
- function withTrackLockSync(dir, fn) {
2146
- mkdirSync(dir, { recursive: true });
2147
- const lock = join(dir, "track.lock");
2148
- const deadline = Date.now() + 400;
2149
- for (;;) try {
2150
- closeSync(openSync(lock, "wx"));
2151
- break;
2152
- } catch {
2153
- if (isStale(lock)) {
2154
- try {
2155
- unlinkSync(lock);
2156
- } catch {}
2157
- continue;
2158
- }
2159
- if (Date.now() >= deadline) {
2160
- process.stderr.write(`harness: track lock busy, write skipped (${lock})\n`);
2161
- return LOCK_FAILED;
2162
- }
2163
- sleepSync(8);
2164
- }
2165
- try {
2166
- return fn();
2167
- } finally {
2168
- try {
2169
- unlinkSync(lock);
2170
- } catch {}
2171
- }
2172
- }
2173
- //#endregion
2174
2299
  //#region src/freshness/evidence-harvest-io.ts
2175
2300
  /**
2176
2301
  * SubagentStop wiring for {@link harvestAgentEvidence}. The core SubagentStop
@@ -2204,6 +2329,14 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
2204
2329
  const transcriptPath = typeof payload.agent_transcript_path === "string" ? payload.agent_transcript_path : void 0;
2205
2330
  if (!transcriptPath) return;
2206
2331
  const file = trackFile(typeof payload.session_id === "string" ? payload.session_id : "unknown", baseDir);
2332
+ if (trackJournalEnabled()) {
2333
+ const track = readTrackSync(file, true);
2334
+ const next = harvestAgentEvidence(transcriptPath, track, now);
2335
+ if (next === track) return;
2336
+ const log = journalLogPath(file);
2337
+ for (const ev of diffTrackEvents(track, next, now)) appendEvent(log, ev.field, ev.op, ev.value, ev.ts);
2338
+ return;
2339
+ }
2207
2340
  if (withTrackLockSync(dirname(file), () => {
2208
2341
  const track = loadTrackSync(file);
2209
2342
  const next = harvestAgentEvidence(transcriptPath, track, now);
@@ -7752,15 +7885,9 @@ function uiDesignSkillGate(tool, filePath, content, ev) {
7752
7885
  //#endregion
7753
7886
  //#region src/policy/design/skill-evidence.ts
7754
7887
  /**
7755
- * @module design/skill-evidence
7756
- * IO side of the UI design-skill gate: read the verified session track and
7757
- * derive {@link DesignEvidence} (read references + doc-consulted) for
7758
- * {@link uiDesignSkillGate}. Kept separate from the pure gate logic (SRP).
7759
- * @packageDocumentation
7760
- */
7761
- /**
7762
- * Read the verified session track and derive {@link DesignEvidence}. Fail-closed
7763
- * on a missing/corrupt/tampered track (no evidence → the gate blocks).
7888
+ * Read the verified session track (snapshot ⊕ journal) and derive
7889
+ * {@link DesignEvidence}. Fail-closed on a missing/corrupt/tampered track (no
7890
+ * evidence the gate blocks).
7764
7891
  * @param sessionId - the Claude session id.
7765
7892
  * @param cwd - the project root (selects the per-project state dir).
7766
7893
  * @param baseDir - override the track base dir (tests); defaults to the project state dir.
@@ -7768,7 +7895,7 @@ function uiDesignSkillGate(tool, filePath, content, ev) {
7768
7895
  function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
7769
7896
  let track = null;
7770
7897
  try {
7771
- track = verifyTrack(JSON.parse(readFileSync(trackFile(sessionId, baseDir), "utf8")));
7898
+ track = readTrackSync(trackFile(sessionId, baseDir), trackJournalEnabled());
7772
7899
  } catch {
7773
7900
  track = null;
7774
7901
  }
@@ -7778,45 +7905,39 @@ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
7778
7905
  };
7779
7906
  }
7780
7907
  //#endregion
7781
- //#region src/runtime/design-helpers.ts
7908
+ //#region src/policy/design/design-system-rules.ts
7782
7909
  /**
7783
- * @module design-helpers
7784
- * Effectful helpers for the design pipeline gate: locate design-system.md and
7785
- * apply PostToolUse fuse-browser transitions to the design state. Split out of
7786
- * `design.ts` to keep that file within the SOLID size budget (SRP).
7787
- * @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.
7788
7913
  */
7789
- const NAV$2 = "mcp__fuse-browser__browser_navigate";
7790
- const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
7791
- const SCROLL = "mcp__fuse-browser__browser_scroll";
7792
- const GEMINI$2 = "mcp__gemini-design__create_frontend";
7793
- /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
7794
- function findDesignSystem(cwd) {
7795
- let dir = cwd;
7796
- for (let i = 0; i < 6; i++) {
7797
- const p = join(dir, "design-system.md");
7798
- if (existsSync(p)) try {
7799
- return readFileSync(p, "utf8");
7800
- } catch {
7801
- return "";
7802
- }
7803
- const parent = dirname(dir);
7804
- if (parent === dir) break;
7805
- dir = parent;
7806
- }
7807
- return "";
7808
- }
7809
- /** Apply a PostToolUse fuse-browser transition to the design state. */
7810
- function recordPost(event, cacheDir, state) {
7811
- if (event.tool === SHOT$2) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
7812
- else if (event.tool === NAV$2) saveDesignState(cacheDir, recordNavigate(state));
7813
- else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
7814
- else if (event.tool === GEMINI$2) saveDesignState(cacheDir, {
7815
- ...state,
7816
- geminiCalls: state.geminiCalls + 1
7817
- });
7818
- else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? ""));
7819
- 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 (`--*font*`/`--*ff*`/`--*type*`), `@import` URLs, and
7921
+ * Markdown table cells (`| Body | Inter |`). Explicitly OUT of scope (chosen,
7922
+ * not overlooked): unquoted prose in bullet lines — distinguishing "usage"
7923
+ * from "mention" there is prose parsing, not a regex's job.
7924
+ */
7925
+ 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;
7926
+ const FONT_IMPORT_RE = /family=[^&"')]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i;
7927
+ const FONT_TABLE_RE = /\|\s*\*{0,2}(?:Inter|Roboto|Arial|Open Sans)\*{0,2}\s*\|/;
7928
+ /**
7929
+ * Return the requirements missing from a design-system.md (empty = valid).
7930
+ * The corpus citation satisfies the source requirement ONLY when the corpus
7931
+ * is actually delivered (`corpusCitationOk`) — absent corpus, the URL is
7932
+ * mandatory again, exactly the pre-doctrine behavior (fallback never weaker).
7933
+ */
7934
+ function validateDesignSystem(content, corpusCitationOk = false) {
7935
+ const missing = [];
7936
+ if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
7937
+ if (!/https?:\/\//.test(content) && !(corpusCitationOk && hasCorpusCitation(content))) missing.push("reference URL (https://…) or Corpus citation");
7938
+ if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
7939
+ if (FONT_DECL_RE.test(content) || FONT_IMPORT_RE.test(content) || FONT_TABLE_RE.test(content)) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
7940
+ return missing;
7820
7941
  }
7821
7942
  //#endregion
7822
7943
  //#region src/policy/design/gates.ts
@@ -7827,16 +7948,9 @@ const EXEMPT_DIRS = [
7827
7948
  "build/",
7828
7949
  ".claude/"
7829
7950
  ];
7830
- const FORBIDDEN_FONTS = [
7831
- "Inter",
7832
- "Roboto",
7833
- "Arial",
7834
- "Open Sans"
7835
- ];
7836
- const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
7837
- const NAV$1 = "mcp__fuse-browser__browser_navigate";
7838
- const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
7839
- const GEMINI$1 = "mcp__gemini-design__create_frontend";
7951
+ const NAV$2 = "mcp__fuse-browser__browser_navigate";
7952
+ const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
7953
+ const GEMINI$2 = "mcp__gemini-design__create_frontend";
7840
7954
  const deny = (reason) => ({
7841
7955
  kind: "block",
7842
7956
  title: "Design pipeline",
@@ -7852,15 +7966,6 @@ function htmlCssOnlyGate(filePath) {
7852
7966
  function stateFileGate(filePath) {
7853
7967
  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;
7854
7968
  }
7855
- /** Return the requirements missing from a design-system.md (empty = valid). */
7856
- function validateDesignSystem(content) {
7857
- const missing = [];
7858
- if (!content.includes("## Design Reference")) missing.push("## Design Reference section");
7859
- if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)");
7860
- if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0");
7861
- if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)");
7862
- return missing;
7863
- }
7864
7969
  /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */
7865
7970
  function screenshotScrollGate(state) {
7866
7971
  return state.scrolledSinceNav ? null : deny("BLOCKED: scroll the page before a screenshot — browser_scroll to:'end', wait, scroll back, then fullPage screenshot.");
@@ -7888,10 +7993,11 @@ function designPassNotice(i, cacheDir) {
7888
7993
  }
7889
7994
  const state = i.agentId && activeDesignAgent(cacheDir) === i.agentId ? loadDesignState(cacheDir, i.agentId) : null;
7890
7995
  if (state && i.phase === "pre") {
7996
+ if (i.corpusMissing) lines.push("design-corpus: refs-design corpus not found — corpus requirement waived (screenshot-only fallback, install defect)");
7891
7997
  if (isWrite && fp && !EXEMPT_DIRS.some((d) => fp.includes(d)) && ALLOWED_WRITE.test(fp)) lines.push(`enforce-html-css-only: allowed: ${basename(fp)}`);
7892
- if (i.tool === NAV$1) lines.push(`check-inspiration-read: pass (${i.url})`);
7893
- if (i.tool === GEMINI$1 && geminiEnabled()) lines.push("validate-design-system: design-system.md ok");
7894
- if (isWrite || i.tool === NAV$1 || i.tool === SHOT$1 || i.tool === GEMINI$1) lines.push(`pipeline-gate: phase ${state.currentPhase} ok`);
7998
+ if (i.tool === NAV$2) lines.push(`check-inspiration-read: pass (${i.url})`);
7999
+ if (i.tool === GEMINI$2 && geminiEnabled()) lines.push("validate-design-system: design-system.md ok");
8000
+ if (isWrite || i.tool === NAV$2 || i.tool === SHOT$2 || i.tool === GEMINI$2) lines.push(`pipeline-gate: phase ${state.currentPhase} ok`);
7895
8001
  }
7896
8002
  if (state && i.phase === "post" && isWrite) {
7897
8003
  if (basename(fp) === "design-system.md") lines.push("validate-design: design-system.md → phase 3");
@@ -7905,41 +8011,292 @@ function designPassNotice(i, cacheDir) {
7905
8011
  } : null;
7906
8012
  }
7907
8013
  //#endregion
8014
+ //#region src/policy/design/template-urls.ts
8015
+ /**
8016
+ * Template-platform detection for the phase-1 navigate gate
8017
+ * (design-inspiration.md §FORBIDDEN Navigation Targets). Two rules, because
8018
+ * neither alone matches the doctrine:
8019
+ * - template SHOPS are banned outright, on the host (subdomains included);
8020
+ * - the path rule (/templates, /themes) applies ONLY to real product
8021
+ * platforms that also host a template catalog — never to the whole web,
8022
+ * or sothebys.com/en/marketplace (register research) would be denied.
8023
+ * The host is normalized (lowercase, port, userinfo, trailing dot stripped).
8024
+ * HONEST SCOPE: this is a LEXICAL filter — it follows no redirect, is not and
8025
+ * will never be exhaustive, and it is NOT the taste gate: the source of taste
8026
+ * is the refs-design corpus. This list exists to catch the reflex, not to map
8027
+ * the template economy. Frozen by owner decision (C5): no host or pattern may
8028
+ * be added or removed without re-opening that decision.
8029
+ * Award galleries are deliberately absent: they pass, as outbound-link finders.
8030
+ */
8031
+ /** Template shops: banned outright, any path (subdomains included). */
8032
+ const TEMPLATE_HOSTS = [
8033
+ "themeforest.net",
8034
+ "themes.shopify.com",
8035
+ "marketplace.atlassian.com",
8036
+ "templatemonster.com",
8037
+ "creativemarket.com",
8038
+ "ui8.net",
8039
+ "html5up.net",
8040
+ "colorlib.com",
8041
+ "bootstrapmade.com",
8042
+ "startbootstrap.com",
8043
+ "cruip.com",
8044
+ "uideck.com",
8045
+ "envato.com",
8046
+ "tailwindui.com"
8047
+ ];
8048
+ /** Real-product platforms whose template CATALOGS are banned via the path rule. */
8049
+ const PLATFORM_HOSTS = [
8050
+ "framer.com",
8051
+ "webflow.com",
8052
+ "vercel.com",
8053
+ "astro.build",
8054
+ "squarespace.com",
8055
+ "wix.com"
8056
+ ];
8057
+ /**
8058
+ * Template-catalog path segments — evaluated ONLY on PLATFORM_HOSTS, and
8059
+ * deliberately WITHOUT `/marketplace`: those platforms run real, non-template
8060
+ * marketplaces (vercel.com/marketplace is infra). Atlassian/Shopify template
8061
+ * stores are already covered by the full host ban above.
8062
+ */
8063
+ const TEMPLATE_PATH_RE = /^\/(templates|themes)(\/|$)/i;
8064
+ /** Host part of `url`, normalized: lowercase, no port, no userinfo, no trailing dot. */
8065
+ function hostOf(url) {
8066
+ return (/^https?:\/\/(?:[^@/?#]*@)?([^/:?#]+)/i.exec(url)?.[1] ?? "").replace(/\.$/, "").toLowerCase();
8067
+ }
8068
+ const under = (host, domain) => host === domain || host.endsWith(`.${domain}`);
8069
+ /** True when `url` points at a banned template source (host rule, then platform path rule). */
8070
+ function isTemplateUrl(url) {
8071
+ const host = hostOf(url);
8072
+ if (!host) return false;
8073
+ if (TEMPLATE_HOSTS.some((d) => under(host, d))) return true;
8074
+ if (!PLATFORM_HOSTS.some((d) => under(host, d))) return false;
8075
+ const path = /^https?:\/\/(?:[^@/?#]*@)?[^/?#]*(\/[^?#]*)/i.exec(url)?.[1] ?? "/";
8076
+ return TEMPLATE_PATH_RE.test(path);
8077
+ }
8078
+ //#endregion
7908
8079
  //#region src/policy/design/gates-pipeline.ts
7909
8080
  const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;
7910
- const KNOWN_DOMAINS = [
7911
- "framer.website",
7912
- "webflow.io",
7913
- "awwwards.com",
7914
- "godly.website",
7915
- "lapa.ninja",
7916
- "onepagelove.com",
7917
- "saasframe.io",
7918
- "bestwebsite.gallery",
7919
- "landingfolio.com"
7920
- ];
7921
8081
  /** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */
7922
- function designSystemWriteGate(filePath, state) {
8082
+ function designSystemWriteGate(filePath, state, corpusRequired = false) {
7923
8083
  if (!filePath.endsWith("design-system.md")) return null;
7924
- 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`);
7925
- const needed = MIN_SCREENSHOTS[state.mode];
7926
- 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`);
8084
+ 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`);
8085
+ const needed = quotaFor(state.mode, corpusRequired);
8086
+ 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`);
7927
8087
  return null;
7928
8088
  }
7929
8089
  /** Gate Gemini create_frontend: requires phase >= 3 and a validated design system. */
7930
8090
  function geminiCreateGate(state) {
7931
- 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");
7932
- 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");
8091
+ 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");
8092
+ 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");
7933
8093
  return null;
7934
8094
  }
7935
- /** Gate fuse-browser navigate: phase >= 1, inspiration read, URL in the catalog. */
8095
+ /**
8096
+ * Gate fuse-browser navigate: phase >= 1, inspiration read, no template source in phase 1.
8097
+ * The allowlist→denylist inversion is the DELIBERATE doctrine change (not a
8098
+ * weakening): the gate was never the proof — the mandatory screenshot is,
8099
+ * unchanged and unforgeable in both configurations. Corpus-absent has its
8100
+ * counterpart already (pre-doctrine quotas + visible warning); what the
8101
+ * denylist cannot cover (sector-browse quality) belongs to the doc, per the
8102
+ * rule "the gate requires what is verifiable, the doc prescribes zeal".
8103
+ */
7936
8104
  function browserNavigateGate(state, url) {
7937
8105
  if (state.currentPhase < 1) return deny(`BLOCKED: Phase 0 not done. READ: ${SKILLS}/design-system/SKILL.md first.`);
7938
- if (!state.inspirationRead) return deny(`BLOCKED: Read inspiration catalog first. READ: ${SKILLS}/design-web/references/design-inspiration.md + design-inspiration-urls.md`);
7939
- 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(", ")}`);
8106
+ 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`);
8107
+ 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)`);
8108
+ return null;
8109
+ }
8110
+ //#endregion
8111
+ //#region src/runtime/design-files-gate.ts
8112
+ /**
8113
+ * Literal substitution, first occurrence or all — NEVER String.replace's
8114
+ * replacement-string semantics: an agent-controlled new_string of `$&`, `` $` ``,
8115
+ * `$'` or `$$` must be written (and validated) literally, not interpreted as
8116
+ * the matched text (that made the gate validate a document the tool would
8117
+ * never write — the `$&` bypass). Used on both sides: PRE reconstruction and
8118
+ * POST reverse-reconstruction.
8119
+ */
8120
+ function substituteLiteral(s, from, to, all) {
8121
+ return all ? s.split(from).join(to) : s.replace(from, () => to);
8122
+ }
8123
+ /** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */
8124
+ function designFilesGate(files, state, pluginsRoot, corpusRoot, corpusRequired, cwd) {
8125
+ for (const f of files) {
8126
+ const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath);
8127
+ if (hit) return hit;
8128
+ if (f.op === "delete") continue;
8129
+ if (!f.filePath.endsWith("design-system.md")) continue;
8130
+ const gate = designSystemWriteGate(f.filePath, state, corpusRequired) ?? (f.op === "add" ? designSystemContentGate({
8131
+ filePath: f.filePath,
8132
+ tool: "Write",
8133
+ content: f.content,
8134
+ oldString: void 0,
8135
+ replaceAll: false,
8136
+ state,
8137
+ corpusRoot,
8138
+ corpusRequired
8139
+ }) : null);
8140
+ if (gate) return gate;
8141
+ }
7940
8142
  return null;
7941
8143
  }
7942
8144
  //#endregion
8145
+ //#region src/runtime/design-content-gate.ts
8146
+ /**
8147
+ * @module design-content-gate
8148
+ * PRE content gate for design-system.md — the nominal path (validateDesignSystem
8149
+ * used to run only behind the opt-in Gemini branch, so designSystemValid meant
8150
+ * "a write happened", not "the content is valid"). Split out of
8151
+ * `design-helpers.ts` to keep both files within the SOLID size budget (SRP).
8152
+ * @packageDocumentation
8153
+ */
8154
+ /**
8155
+ * Every content violation of a design-system.md text, GRANULAR (one entry per
8156
+ * unmet requirement and per unread cited reference — so a worsening check can
8157
+ * diff two violation sets): the four hard requirements, plus the citation↔read
8158
+ * jointure when the corpus is delivered.
8159
+ */
8160
+ function designSystemProblems(content, reads, corpusRequired) {
8161
+ const problems = validateDesignSystem(content, corpusRequired);
8162
+ if (corpusRequired) {
8163
+ for (const ref of citedCorpusRefs(content)) if (!citationJoinsReads(ref, reads)) problems.push(`cited corpus reference never read: ${ref}`);
8164
+ }
8165
+ return problems;
8166
+ }
8167
+ /** Content check for a design-system.md already on disk (the Gemini create_frontend path). */
8168
+ function checkDesignSystemContent(content, reads, corpusRequired) {
8169
+ const problems = designSystemProblems(content, reads, corpusRequired);
8170
+ return problems.length ? deny(`BLOCKED: design-system.md too generic. Missing: ${problems.join(", ")}.`) : null;
8171
+ }
8172
+ /**
8173
+ * The content a Write/Edit would LEAVE in design-system.md, and the on-disk
8174
+ * original when the file exists (for BOTH tools). Edit reconstructs via
8175
+ * literal substitution (first occurrence, or all under replace_all — the Edit
8176
+ * tool's own semantics, mirroring edit-outcome.ts). Null when unverifiable
8177
+ * (unreadable file or stale old_string) — fail-closed, such an Edit fails at
8178
+ * tool level anyway.
8179
+ */
8180
+ function effectiveContents(i) {
8181
+ if (i.tool !== "Edit") {
8182
+ let original = null;
8183
+ try {
8184
+ if (existsSync(i.filePath)) original = readFileSync(i.filePath, "utf8");
8185
+ } catch {
8186
+ original = null;
8187
+ }
8188
+ return {
8189
+ result: i.content,
8190
+ original
8191
+ };
8192
+ }
8193
+ let disk;
8194
+ try {
8195
+ disk = readFileSync(i.filePath, "utf8");
8196
+ } catch {
8197
+ return null;
8198
+ }
8199
+ if (!i.oldString || !disk.includes(i.oldString)) return null;
8200
+ return {
8201
+ result: substituteLiteral(disk, i.oldString, i.content, i.replaceAll),
8202
+ original: disk
8203
+ };
8204
+ }
8205
+ /**
8206
+ * Block only the violations the change INTRODUCES (`problems(result) ⊄
8207
+ * problems(original)`): a legacy design-system.md that predates the doctrine
8208
+ * stays editable — and rewritable — including byte-identically or toward
8209
+ * conformance; a change that adds a NEW problem (or writes an invalid file
8210
+ * from scratch) is blocked.
8211
+ */
8212
+ function designSystemContentGate(i) {
8213
+ if (!i.filePath.endsWith("design-system.md")) return null;
8214
+ const pair = effectiveContents(i);
8215
+ 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.");
8216
+ const result = designSystemProblems(pair.result, i.state.corpusReads, i.corpusRequired);
8217
+ if (!result.length) return null;
8218
+ const original = pair.original !== null ? designSystemProblems(pair.original, i.state.corpusReads, i.corpusRequired) : [];
8219
+ const introduced = result.filter((p) => !original.includes(p));
8220
+ if (!introduced.length) return null;
8221
+ 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.`);
8222
+ }
8223
+ //#endregion
8224
+ //#region src/runtime/design-helpers.ts
8225
+ /**
8226
+ * @module design-helpers
8227
+ * Effectful helpers for the design pipeline gate: locate design-system.md and
8228
+ * apply PostToolUse fuse-browser transitions to the design state. Split out of
8229
+ * `design.ts` to keep that file within the SOLID size budget (SRP).
8230
+ * @packageDocumentation
8231
+ */
8232
+ const NAV$1 = "mcp__fuse-browser__browser_navigate";
8233
+ const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
8234
+ const SCROLL = "mcp__fuse-browser__browser_scroll";
8235
+ const GEMINI$1 = "mcp__gemini-design__create_frontend";
8236
+ /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
8237
+ function findDesignSystem(cwd) {
8238
+ let dir = cwd;
8239
+ for (let i = 0; i < 6; i++) {
8240
+ const p = join(dir, "design-system.md");
8241
+ if (existsSync(p)) try {
8242
+ return readFileSync(p, "utf8");
8243
+ } catch {
8244
+ return "";
8245
+ }
8246
+ const parent = dirname(dir);
8247
+ if (parent === dir) break;
8248
+ dir = parent;
8249
+ }
8250
+ return "";
8251
+ }
8252
+ /** Apply a PostToolUse fuse-browser transition to the design state. */
8253
+ function recordPost(event, cacheDir, state, corpusRoot = "", corpusRequired = false, cwd = "") {
8254
+ if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired));
8255
+ else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
8256
+ else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
8257
+ else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
8258
+ ...state,
8259
+ geminiCalls: state.geminiCalls + 1
8260
+ });
8261
+ else if (event.tool === "Read") {
8262
+ const fp = event.filePath ?? "";
8263
+ if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired));
8264
+ else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired));
8265
+ } else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) {
8266
+ const fp = event.filePath ?? "";
8267
+ let content;
8268
+ try {
8269
+ content = readFileSync(fp, "utf8");
8270
+ } catch {
8271
+ return;
8272
+ }
8273
+ const problems = designSystemProblems(content, state.corpusReads, corpusRequired);
8274
+ if (!problems.length) {
8275
+ saveDesignState(cacheDir, recordValidDesignSystem(state));
8276
+ return;
8277
+ }
8278
+ if (event.tool === "Edit" && event.oldString && event.content && content.includes(event.content)) {
8279
+ const original = substituteLiteral(content, event.content, event.oldString, event.input.replace_all === true);
8280
+ if (problems.filter((p) => !designSystemProblems(original, state.corpusReads, corpusRequired).includes(p)).length) saveDesignState(cacheDir, {
8281
+ ...state,
8282
+ designSystemValid: false
8283
+ });
8284
+ }
8285
+ } else if (event.files) {
8286
+ const ds = event.files.find((f) => f.op !== "delete" && f.filePath.endsWith("design-system.md"));
8287
+ if (ds) {
8288
+ const abs = isAbsolute(ds.filePath) ? ds.filePath : join(cwd, ds.filePath);
8289
+ let content;
8290
+ try {
8291
+ content = readFileSync(abs, "utf8");
8292
+ } catch {
8293
+ return;
8294
+ }
8295
+ if (!designSystemProblems(content, state.corpusReads, corpusRequired).length) saveDesignState(cacheDir, recordValidDesignSystem(state));
8296
+ }
8297
+ }
8298
+ }
8299
+ //#endregion
7943
8300
  //#region src/runtime/design.ts
7944
8301
  const NAV = "mcp__fuse-browser__browser_navigate";
7945
8302
  const SHOT = "mcp__fuse-browser__browser_screenshot";
@@ -7948,7 +8305,7 @@ const GEMINI = "mcp__gemini-design__create_frontend";
7948
8305
  * Design-pipeline gate (effectful: reads/writes the design state + design-system.md).
7949
8306
  * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires.
7950
8307
  */
7951
- function designGate(payload, event, cacheDir, cwd) {
8308
+ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRootOverride) {
7952
8309
  if (event.phase !== "post" && (event.tool === "Write" || event.tool === "Edit")) {
7953
8310
  const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
7954
8311
  if (skillBlock) return skillBlock;
@@ -7965,8 +8322,11 @@ function designGate(payload, event, cacheDir, cwd) {
7965
8322
  state = initDesignState(id, dsExists ? "page" : "full", dsExists);
7966
8323
  saveDesignState(cacheDir, state);
7967
8324
  }
8325
+ const corpusRoot = corpusRootOverride ?? resolveCorpusRoot();
8326
+ const corpusRequired = corpusRoot !== "";
8327
+ const pluginsRoot = pluginsRootOverride ?? resolvePluginsRoot();
7968
8328
  if (event.phase === "post") {
7969
- recordPost(event, cacheDir, state);
8329
+ recordPost(event, cacheDir, state, corpusRoot, corpusRequired, cwd);
7970
8330
  if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) {
7971
8331
  const warnings = runDesignChecks(event.content ?? "");
7972
8332
  if (warnings.length) return {
@@ -7980,7 +8340,16 @@ function designGate(payload, event, cacheDir, cwd) {
7980
8340
  }
7981
8341
  if (event.tool === "Write" || event.tool === "Edit") {
7982
8342
  const fp = event.filePath ?? "";
7983
- const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state);
8343
+ const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state, corpusRequired) ?? designSystemContentGate({
8344
+ filePath: fp,
8345
+ tool: event.tool,
8346
+ content: event.content ?? "",
8347
+ oldString: event.oldString,
8348
+ replaceAll: event.input.replace_all === true,
8349
+ state,
8350
+ corpusRoot,
8351
+ corpusRequired
8352
+ });
7984
8353
  if (base) return base;
7985
8354
  if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) return {
7986
8355
  kind: "block",
@@ -7990,6 +8359,7 @@ function designGate(payload, event, cacheDir, cwd) {
7990
8359
  };
7991
8360
  return null;
7992
8361
  }
8362
+ if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd);
7993
8363
  if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
7994
8364
  if (event.tool === SHOT) return screenshotScrollGate(state);
7995
8365
  if (event.tool === GEMINI) {
@@ -8000,16 +8370,11 @@ function designGate(payload, event, cacheDir, cwd) {
8000
8370
  if (ds === "") return {
8001
8371
  kind: "block",
8002
8372
  title: "Design pipeline",
8003
- 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.",
8373
+ 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.",
8004
8374
  actions: ["Create design-system.md via the pipeline, then retry create_frontend"]
8005
8375
  };
8006
- const missing = validateDesignSystem(ds);
8007
- if (missing.length) return {
8008
- kind: "block",
8009
- title: "Design pipeline",
8010
- reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`,
8011
- actions: ["Fix design-system.md, then retry create_frontend"]
8012
- };
8376
+ const contentBlock = checkDesignSystemContent(ds, state.corpusReads, corpusRequired);
8377
+ if (contentBlock) return contentBlock;
8013
8378
  saveDesignState(cacheDir, recordValidDesignSystem(state));
8014
8379
  }
8015
8380
  return null;
@@ -8252,14 +8617,16 @@ async function freshEvidenceNotice(event, evidence, cwd) {
8252
8617
  * @param evidence - Session track + clock for the "evidence fresh" notice (omit to disable it).
8253
8618
  * @returns The native hook outcome (empty stdout when nothing to emit).
8254
8619
  */
8255
- async function allowOutcome(id, event, payload, mcpDir, cwd, evidence) {
8620
+ async function allowOutcome(id, event, payload, mcpDir, cwd, evidence, corpusRoot) {
8621
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
8256
8622
  const notice = designPassNotice({
8257
- agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
8623
+ agentId,
8258
8624
  tool: event.tool,
8259
8625
  filePath: event.filePath ?? "",
8260
8626
  content: event.content ?? "",
8261
8627
  url: typeof event.input.url === "string" ? event.input.url : "",
8262
- phase: "pre"
8628
+ phase: "pre",
8629
+ corpusMissing: agentId !== "" && (corpusRoot ?? resolveCorpusRoot()) === ""
8263
8630
  }, mcpDir);
8264
8631
  const lesson = lessonFor(event.tool, event.input, {
8265
8632
  file: lessonsFileFor(projectRoot(cwd)),
@@ -8359,7 +8726,7 @@ async function handlePre(ctx) {
8359
8726
  exit: 0
8360
8727
  };
8361
8728
  }
8362
- const designBlock = designGate(payload, event, mcpDir, opts.cwd);
8729
+ const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
8363
8730
  if (designBlock) return {
8364
8731
  stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now),
8365
8732
  exit: 0
@@ -8410,7 +8777,7 @@ async function handlePre(ctx) {
8410
8777
  trackFile: file,
8411
8778
  windowMs: opts.windowMs,
8412
8779
  now: opts.now
8413
- });
8780
+ }, opts.corpusRoot);
8414
8781
  }
8415
8782
  //#endregion
8416
8783
  //#region src/freshness/query-framework.ts
@@ -8918,7 +9285,7 @@ async function handlePost(ctx) {
8918
9285
  const { id, payload, event, framework, mcpDir, file, opts } = ctx;
8919
9286
  const response = payload.tool_response ?? payload.tool_output;
8920
9287
  mcpPostStore(event.tool, event.input, response, mcpDir);
8921
- const designWarn = designGate(payload, event, mcpDir, opts.cwd);
9288
+ const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
8922
9289
  const activities = activityFor({
8923
9290
  tool: event.tool,
8924
9291
  input: event.input,