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