@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.
- package/dist/cli/bin.mjs +1 -1
- package/dist/{handle-J5r2f_O_.mjs → handle-DsP67KZ4.mjs} +637 -270
- package/dist/runtime/index.d.mts +6 -0
- package/dist/runtime/index.mjs +1 -1
- package/dist/store-Dw08Amqh.mjs +845 -0
- package/dist/tracking/index.d.mts +13 -12
- package/dist/tracking/index.mjs +2 -2
- package/package.json +2 -2
- package/dist/store-yr_QFsZY.mjs +0 -437
|
@@ -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 {
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
|
1252
|
-
* (TaskCompleted / SubagentStop). Returns the newest passing receipt, or
|
|
1253
|
-
* on any read/verify failure (fail-closed: no proof ⇒
|
|
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
|
-
|
|
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
|
-
*
|
|
7756
|
-
*
|
|
7757
|
-
*
|
|
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 =
|
|
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/
|
|
7908
|
+
//#region src/policy/design/design-system-rules.ts
|
|
7782
7909
|
/**
|
|
7783
|
-
*
|
|
7784
|
-
*
|
|
7785
|
-
*
|
|
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
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
if (
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
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
|
|
7831
|
-
|
|
7832
|
-
|
|
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$
|
|
7893
|
-
if (i.tool === GEMINI$
|
|
7894
|
-
if (isWrite || i.tool === NAV$
|
|
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)
|
|
7925
|
-
const needed =
|
|
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)
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
7939
|
-
if (state.currentPhase === 1 && url &&
|
|
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)
|
|
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
|
|
8007
|
-
if (
|
|
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
|
|
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,
|