From 7eca7e871a097fad5d347b95495c4898d1a12885 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Wed, 29 Jul 2026 10:10:00 +0200 Subject: [PATCH 1/3] chore(deps): bump tsdown to 0.22.14 --- bun.lock | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 8235128..92c2832 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ }, "devDependencies": { "@types/bun": "latest", - "tsdown": "^0.22.13", + "tsdown": "^0.22.14", "typedoc": "^0.28.20", "typescript": "^7.0.2", }, @@ -236,7 +236,7 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "tsdown": ["tsdown@0.22.13", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.12", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.1.2" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.13", "@tsdown/exe": "0.22.13", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-XaYFhtiKRUvTpXv/YAehsHdbEb3LN/iMlzjSINbjlaATtXN2zVPKox2STKhcyFPlh++8Zg7suNN27E679IfAUA=="], + "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -250,7 +250,7 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "verkit": ["verkit@0.1.2", "", {}, "sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg=="], + "verkit": ["verkit@0.3.0", "", {}, "sha512-Njrh4U8UODGajoZ44QS2C/BsoEM9DTI/aCqY5swsizb+/ap0FamvnCMcZAxrR5+aoC0ZqkawEfpC/N2SBc+xeA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], diff --git a/package.json b/package.json index aa4a5db..2229cc0 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ }, "devDependencies": { "@types/bun": "latest", - "tsdown": "^0.22.13", + "tsdown": "^0.22.14", "typedoc": "^0.28.20", "typescript": "^7.0.2" }, From 2107330323fef56073f020fd1846178b0c392f96 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Wed, 29 Jul 2026 10:11:38 +0200 Subject: [PATCH 2/3] feat(design): enforce the local corpus doctrine in the design gates The design-expert plugin produced generic, interchangeable pages because its inspiration phase browsed ~100 Framer/Webflow templates. A local corpus of 11 references (refs-design/) becomes the source of taste. The harness still enforced the OLD doctrine (4 screenshots, an allowlist of template platforms) and therefore blocked anyone following the new one. This makes the harness EXECUTE the new doctrine without weakening the gate. - corpus resolution per runtime: Claude marketplace tree vs Codex plugins/cache////, with semver selection; the process.cwd() fallback is removed entirely - corpus reads recorded as state progression; the citation<->read join tolerates both naming conventions and both layouts - quotas: >=1 screenshot in every mode, always; corpus present -> { full: 2, page: 1, component: 1 } plus the corpus reads - template-platform allowlist inverted into a denylist (template-urls.ts, deliberately frozen - the source of taste is the published corpus) - apply_patch (Codex primitive) brought into the design pipeline: PRE path/phase/quota gates, op:"add" validated as a Write, delete skipped, cwd wired for the POST re-read - pluginsWriteGuard extended to relative paths; corpusRoot and pluginsRoot resolved independently - substitution bypass closed on all four quadrants Corpus absent leaves the requirement waived (pre-doctrine quotas) - the branch stays dormant until refs-design/ ships to the marketplace. 901 -> 982 tests, 166 -> 178 files. Ten adversarial review rounds (fresh-context challenger + factual audit each round); every conserved mechanism mutation-tested, no survivors. --- src/policy/design/corpus-resolve.ts | 99 ++++++++++++++++++++++++ src/policy/design/corpus.ts | 76 ++++++++++++++++++ src/policy/design/design-system-rules.ts | 37 +++++++++ src/policy/design/gates-pipeline.ts | 48 +++++++----- src/policy/design/gates.ts | 17 +--- src/policy/design/state.ts | 27 ++++++- src/policy/design/template-urls.ts | 54 +++++++++++++ src/policy/design/transitions.ts | 40 +++++++--- src/runtime/design-content-gate.ts | 95 +++++++++++++++++++++++ src/runtime/design-files-gate.ts | 65 ++++++++++++++++ src/runtime/design-helpers.ts | 65 ++++++++++++++-- src/runtime/design.ts | 37 +++++---- src/runtime/handle-post.ts | 2 +- src/runtime/handle-pre.ts | 4 +- src/runtime/handle-types.ts | 6 ++ src/runtime/pre-allow.ts | 6 +- test/apexauth-usermessage.test.ts | 5 +- test/design-apply-patch.test.ts | 89 +++++++++++++++++++++ test/design-characterization.test.ts | 73 +++++++++++++++++ test/design-content-gate-legacy.test.ts | 48 ++++++++++++ test/design-content-gate.test.ts | 93 ++++++++++++++++++++++ test/design-corpus-join.test.ts | 54 +++++++++++++ test/design-corpus-resolve.test.ts | 65 ++++++++++++++++ test/design-corpus-transitions.test.ts | 65 ++++++++++++++++ test/design-corpus.test.ts | 70 +++++++++++++++++ test/design-gate-corpus.test.ts | 98 +++++++++++++++++++++++ test/design-gates-corpus.test.ts | 73 +++++++++++++++++ test/design-post-validity.test.ts | 68 ++++++++++++++++ test/design-state-corpus.test.ts | 79 +++++++++++++++++++ 29 files changed, 1482 insertions(+), 76 deletions(-) create mode 100644 src/policy/design/corpus-resolve.ts create mode 100644 src/policy/design/corpus.ts create mode 100644 src/policy/design/design-system-rules.ts create mode 100644 src/policy/design/template-urls.ts create mode 100644 src/runtime/design-content-gate.ts create mode 100644 src/runtime/design-files-gate.ts create mode 100644 test/design-apply-patch.test.ts create mode 100644 test/design-characterization.test.ts create mode 100644 test/design-content-gate-legacy.test.ts create mode 100644 test/design-content-gate.test.ts create mode 100644 test/design-corpus-join.test.ts create mode 100644 test/design-corpus-resolve.test.ts create mode 100644 test/design-corpus-transitions.test.ts create mode 100644 test/design-corpus.test.ts create mode 100644 test/design-gate-corpus.test.ts create mode 100644 test/design-gates-corpus.test.ts create mode 100644 test/design-post-validity.test.ts create mode 100644 test/design-state-corpus.test.ts diff --git a/src/policy/design/corpus-resolve.ts b/src/policy/design/corpus-resolve.ts new file mode 100644 index 0000000..f56ac5c --- /dev/null +++ b/src/policy/design/corpus-resolve.ts @@ -0,0 +1,99 @@ +/** + * Corpus/plugins root resolution (fs) and the write guard over it. Resolution + * is PER RUNTIME (Claude marketplaces, Codex versioned plugin cache — same + * pattern as rules-root.ts); runtimes without a plugin structure (kimi today) + * resolve to "" and the corpus gates stay dormant. There is NO cwd fallback + * anywhere: an agent-controlled directory can never become its own taste + * reference (self-attested proof — the failure this gate exists to prevent). + */ +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, normalize } from "node:path"; +import type { Prompt } from "../../prompt/types"; +import { detectHarness, type HarnessId } from "../../detect/harness"; +import { maxSemver } from "../../util/semver"; + +const CORPUS_SUFFIX = join("skills", "design-web", "references", "refs-design"); + +/** Immediate child dir names of `dir`, or [] when unreadable. */ +function children(dir: string): string[] { + try { + return readdirSync(dir).sort(); + } catch { + return []; + } +} + +/** Claude: `/.claude/plugins/marketplaces//plugins/design-expert` ("" when absent). */ +function probeClaude(home: string): string { + const markets = join(home, ".claude", "plugins", "marketplaces"); + for (const m of children(markets)) { + const de = join(markets, m, "plugins", "design-expert"); + if (existsSync(de)) return de; + } + return ""; +} + +/** Codex: `/plugins/cache//design-expert/` ("" when absent). */ +function probeCodex(env: Record, home: string): string { + const cache = join(env.CODEX_HOME ?? join(home, ".codex"), "plugins", "cache"); + for (const m of children(cache)) { + const de = join(cache, m, "design-expert"); + // Only real version dirs compete — no pre-release, no stray backup/ dir. + const latest = maxSemver(children(de).filter((v) => /^\d+\.\d+(\.\d+)?$/.test(v) && existsSync(join(de, v, "skills")))); + if (latest) return join(de, latest); + } + return ""; +} + +/** + * The design-expert plugin root for the ACTIVE runtime ("" = not installed). + * `pluginsDirOverride` short-circuits resolution (tests): a PLUGINS dir whose + * `design-expert` child is used when present, else itself. + */ +export function resolvePluginsRoot( + pluginsDirOverride?: string, + home: string = homedir(), + id: HarnessId = detectHarness().id, + env: Record = process.env, +): string { + if (pluginsDirOverride) { + const de = join(pluginsDirOverride, "design-expert"); + return existsSync(de) ? de : pluginsDirOverride; + } + if (id === "claude-code") return probeClaude(home); + if (id === "codex") return probeCodex(env, home); + return ""; +} + +/** Resolve the delivered refs-design/ root, or "" when absent/unusable (the fail-open signal). */ +export function resolveCorpusRoot( + pluginsDirOverride?: string, + home: string = homedir(), + id: HarnessId = detectHarness().id, + env: Record = process.env, +): string { + const root = resolvePluginsRoot(pluginsDirOverride, home, id, env); + if (!root) return ""; + const corpus = join(root, CORPUS_SUFFIX); + return existsSync(corpus) ? corpus : ""; +} + +/** + * Deny Write/Edit/apply_patch-file under the resolved plugins root: the corpus + * is the artefact the pipeline checks reads against, so it must stay + * agent-proof — including while refs-design/ is still absent. Relative paths + * (the normal form of Codex patches) are resolved against `cwd` first. SCOPE: + * no Bash, no symlinks, no sibling marketplace — the gate protects against + * oversight, not an adversary (the mandatory screenshot stays the unforgeable + * proof; forging the corpus only lowers a quota). + */ +export function pluginsWriteGuard(filePath: string, pluginsRoot: string, cwd = ""): Prompt | null { + const abs = isAbsolute(filePath) ? filePath : cwd ? normalize(join(cwd, filePath)) : filePath; + if (!pluginsRoot || !abs.startsWith(`${pluginsRoot}/`)) return null; + return { + kind: "block", title: "Design pipeline", + 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.", + actions: ["Read the corpus with the Read tool; write your own files in the project"], + }; +} diff --git a/src/policy/design/corpus.ts b/src/policy/design/corpus.ts new file mode 100644 index 0000000..f8e9b50 --- /dev/null +++ b/src/policy/design/corpus.ts @@ -0,0 +1,76 @@ +/** + * Corpus anchoring for the design pipeline: refs-design/ read classification, + * per-mode readiness, citation form, citation↔read jointure. Nothing about + * the corpus content (slugs, section titles, count) is hardcoded — everything + * is matched by shape or discovered on disk, so a new reference or a renamed + * section never requires reopening the harness. Pure string logic only; root + * resolution and the write guard live in `corpus-resolve.ts`. + */ +import { basename, join } from "node:path"; +import type { DesignMode } from "./state"; + +export { resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard } from "./corpus-resolve"; + +/** What a read under the corpus root is: the index, or a tokens-* procedure file. */ +export type CorpusKind = "index" | "tokens"; + +const TOKENS_RE = /^tokens-.+\.md$/; +const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim; + +/** Classify a read path against the DELIVERED corpus root (null = not a corpus read). */ +export function classifyCorpusRead(filePath: string, corpusRoot: string): CorpusKind | null { + if (!corpusRoot) return null; + if (filePath === join(corpusRoot, "README.md")) return "index"; + if (filePath.startsWith(`${corpusRoot}/`) && TOKENS_RE.test(basename(filePath))) return "tokens"; + return null; +} + +/** Per-mode corpus-read threshold (component >= 1 file, page >= 2 files, full = index + 2 tokens). */ +export function corpusReady(reads: readonly string[], mode: DesignMode): boolean { + if (mode === "component") return reads.length >= 1; + if (mode === "page") return reads.length >= 2; + return reads.includes("README.md") && reads.filter((r) => TOKENS_RE.test(basename(r))).length >= 2; +} + +/** True when the content carries a `- Corpus: ref/section` citation line (form only). */ +export function hasCorpusCitation(content: string): boolean { + return citedCorpusRefs(content).length > 0; +} + +/** + * Extract the reference slugs cited on EVERY `- Corpus:` line (token before + * `/`, or a bare slug). Anchored at line start with NO indentation + * (fail-closed: a citation inside a code block does not count — an indented + * match would also recognize the examples inside the doctrine doc itself). + * The regex is fence-blind, documented: a `- Corpus:` line at column 0 inside + * a fenced block WILL be matched — its refs must join like any other. + */ +export function citedCorpusRefs(content: string): string[] { + const refs: string[] = []; + for (const m of content.matchAll(CORPUS_LINE_RE)) { + const line = m[1] ?? ""; + refs.push(...line.split(",").map((item) => item.trim().match(/^([\w][\w-]*)(?:\/|\s|$)/)?.[1] ?? "").filter(Boolean)); + } + return refs; +} + +/** + * True when the cited ref names a REFERENCE in a recorded corpus read, in + * either naming convention (`reve` README-style ↔ `reve-recode/` on disk — + * the `-recode` suffix is normalized away on BOTH sides) and either layout + * (per-reference directories, or flat `tokens-.md` files). Comparison is + * exact after normalization: no prefix matching, so `acme` never joins + * `acme-corp`, `tokens` never joins every tokens file, and `README` names no + * reference. + */ +export function citationJoinsReads(ref: string, reads: readonly string[]): boolean { + const norm = (s: string): string => s.replace(/\.md$/, "").replace(/-recode$/, ""); + const target = norm(ref); + return reads.some((r) => { + const segs = r.split("/"); + const file = segs[segs.length - 1] ?? ""; + const names = segs.slice(0, -1).map(norm); + if (file.startsWith("tokens-")) names.push(norm(file.slice("tokens-".length))); + return names.includes(target); + }); +} diff --git a/src/policy/design/design-system-rules.ts b/src/policy/design/design-system-rules.ts new file mode 100644 index 0000000..7ca6118 --- /dev/null +++ b/src/policy/design/design-system-rules.ts @@ -0,0 +1,37 @@ +/** + * Content rules for design-system.md — the four hard requirements the + * pipeline validates on the nominal write path (and the opt-in Gemini one). + * Split out of `gates.ts` to keep both files within the SOLID size budget. + */ +import { hasCorpusCitation } from "./corpus"; + +const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/; + +/** + * Forbidden fonts, matched in USAGE contexts — never in free prose: a design + * system may SAY "Inter" ("## Interaction states", "we never use Inter"), it + * may not USE it as a font. Covered forms: CSS declarations (`font:`, + * `font-family:` in any case, multiline values), JS config (`fontFamily:`), + * custom properties (`--*font*`/`--*ff*`/`--*type*`), `@import` URLs, and + * Markdown table cells (`| Body | Inter |`). Explicitly OUT of scope (chosen, + * not overlooked): unquoted prose in bullet lines — distinguishing "usage" + * from "mention" there is prose parsing, not a regex's job. + */ +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; +const FONT_IMPORT_RE = /family=[^&"')]*\b(?:Inter|Roboto|Arial|Open Sans)\b/i; +const FONT_TABLE_RE = /\|\s*\*{0,2}(?:Inter|Roboto|Arial|Open Sans)\*{0,2}\s*\|/; + +/** + * Return the requirements missing from a design-system.md (empty = valid). + * The corpus citation satisfies the source requirement ONLY when the corpus + * is actually delivered (`corpusCitationOk`) — absent corpus, the URL is + * mandatory again, exactly the pre-doctrine behavior (fallback never weaker). + */ +export function validateDesignSystem(content: string, corpusCitationOk = false): string[] { + const missing: string[] = []; + if (!content.includes("## Design Reference")) missing.push("## Design Reference section"); + if (!/https?:\/\//.test(content) && !(corpusCitationOk && hasCorpusCitation(content))) missing.push("reference URL (https://…) or Corpus citation"); + if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0"); + 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)"); + return missing; +} diff --git a/src/policy/design/gates-pipeline.ts b/src/policy/design/gates-pipeline.ts index d463f3a..bcc387d 100644 --- a/src/policy/design/gates-pipeline.ts +++ b/src/policy/design/gates-pipeline.ts @@ -1,35 +1,32 @@ import type { Prompt } from "../../prompt/types"; -import { type DesignState, MIN_SCREENSHOTS } from "./state"; +import { type DesignState, quotaFor } from "./state"; import { PLUGINS_DIR } from "../file-size"; import { deny } from "./gates"; +import { isTemplateUrl } from "./template-urls"; const SKILLS = `${PLUGINS_DIR}/design-expert/skills`; -const KNOWN_DOMAINS: readonly string[] = [ - "framer.website", "webflow.io", "awwwards.com", "godly.website", "lapa.ninja", - "onepagelove.com", "saasframe.io", "bestwebsite.gallery", "landingfolio.com", -]; - /** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */ -export function designSystemWriteGate(filePath: string, state: DesignState): Prompt | null { +export function designSystemWriteGate(filePath: string, state: DesignState, corpusRequired = false): Prompt | null { if (!filePath.endsWith("design-system.md")) return null; if (state.currentPhase < 2) { return deny( `BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. ` + "RECOVERY: 1) Read identity templates from skills/design-system/ " + - "2) Read design-inspiration.md 3) Browse sites and take a screenshot with " + - "mcp__fuse-browser__browser_screenshot on a LIVE session " + + "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) " + - "4) Then write design-system.md", + "5) Then write design-system.md", ); } - const needed = MIN_SCREENSHOTS[state.mode]; + const needed = quotaFor(state.mode, corpusRequired); if (state.screenshotsCount < needed) { return deny( `BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. ` + - `RECOVERY: 1) Take ${needed - state.screenshotsCount} more screenshots with ` + + `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) " + - "2) Use browser_open + browser_navigate + browser_screenshot fullPage:true 3) Then write design-system.md", + "3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md", ); } return null; @@ -40,32 +37,45 @@ export function geminiCreateGate(state: DesignState): Prompt | null { if (state.currentPhase < 3) { return deny( "BLOCKED: Cannot call Gemini create_frontend before phase 3. " + - "RECOVERY: 1) Complete screenshot browsing phase 2) Write a valid design-system.md " + + "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", ); } 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 " + + "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", ); } return null; } -/** Gate fuse-browser navigate: phase >= 1, inspiration read, URL in the catalog. */ +/** + * Gate fuse-browser navigate: phase >= 1, inspiration read, no template source in phase 1. + * The allowlist→denylist inversion is the DELIBERATE doctrine change (not a + * weakening): the gate was never the proof — the mandatory screenshot is, + * unchanged and unforgeable in both configurations. Corpus-absent has its + * counterpart already (pre-doctrine quotas + visible warning); what the + * denylist cannot cover (sector-browse quality) belongs to the doc, per the + * rule "the gate requires what is verifiable, the doc prescribes zeal". + */ export function browserNavigateGate(state: DesignState, url: string): Prompt | null { if (state.currentPhase < 1) { return deny(`BLOCKED: Phase 0 not done. READ: ${SKILLS}/design-system/SKILL.md first.`); } if (!state.inspirationRead) { return deny( - `BLOCKED: Read inspiration catalog first. READ: ${SKILLS}/design-web/references/design-inspiration.md + design-inspiration-urls.md`, + `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`, ); } - if (state.currentPhase === 1 && url && !KNOWN_DOMAINS.some((d) => url.includes(d))) { - return deny(`BLOCKED: '${url}' not in catalog. Use URLs from design-inspiration-urls.md. Domains: ${KNOWN_DOMAINS.join(", ")}`); + 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)", + ); } return null; } diff --git a/src/policy/design/gates.ts b/src/policy/design/gates.ts index 4633b39..34a568d 100644 --- a/src/policy/design/gates.ts +++ b/src/policy/design/gates.ts @@ -5,10 +5,10 @@ import { activeDesignAgent } from "./flag"; import { isUiWrite } from "./skill-gate"; import { SKILL_TRIGGERS } from "./skill-triggers"; +export { validateDesignSystem } from "./design-system-rules"; + export const ALLOWED_WRITE: RegExp = /\.(html|css|md|json)$/; export const EXEMPT_DIRS: readonly string[] = ["node_modules/", "dist/", "build/", ".claude/"]; -const FORBIDDEN_FONTS: readonly string[] = ["Inter", "Roboto", "Arial", "Open Sans"]; -const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/; const NAV = "mcp__fuse-browser__browser_navigate"; const SHOT = "mcp__fuse-browser__browser_screenshot"; const GEMINI = "mcp__gemini-design__create_frontend"; @@ -38,16 +38,6 @@ export function stateFileGate(filePath: string): Prompt | null { : null; } -/** Return the requirements missing from a design-system.md (empty = valid). */ -export function validateDesignSystem(content: string): string[] { - const missing: string[] = []; - if (!content.includes("## Design Reference")) missing.push("## Design Reference section"); - if (!/https?:\/\//.test(content)) missing.push("reference URL (https://…)"); - if (!OKLCH_RE.test(content)) missing.push("oklch() color with chroma > 0"); - if (FORBIDDEN_FONTS.some((f) => content.includes(f))) missing.push("forbidden font (Inter/Roboto/Arial/Open Sans)"); - return missing; -} - /** Gate a screenshot: require a scroll since the last navigate (lazy-load content). */ export function screenshotScrollGate(state: DesignState): Prompt | null { return state.scrolledSinceNav @@ -72,7 +62,7 @@ export interface PassNoticeInput { agentId: string; tool: string; filePath: stri * null when nothing applies. The check-design-skill line fires for ANY agent * on a UI write; the rest only inside the active design-agent context. */ -export function designPassNotice(i: PassNoticeInput, cacheDir: string): Prompt | null { +export function designPassNotice(i: PassNoticeInput & { corpusMissing?: boolean }, cacheDir: string): Prompt | null { const isWrite = i.tool === "Write" || i.tool === "Edit"; const fp = i.filePath; const lines: string[] = []; @@ -82,6 +72,7 @@ export function designPassNotice(i: PassNoticeInput, cacheDir: string): Prompt | } const state = i.agentId && activeDesignAgent(cacheDir) === i.agentId ? loadDesignState(cacheDir, i.agentId) : null; if (state && i.phase === "pre") { + if (i.corpusMissing) lines.push("design-corpus: refs-design corpus not found — corpus requirement waived (screenshot-only fallback, install defect)"); if (isWrite && fp && !EXEMPT_DIRS.some((d) => fp.includes(d)) && ALLOWED_WRITE.test(fp)) lines.push(`enforce-html-css-only: allowed: ${basename(fp)}`); if (i.tool === NAV) lines.push(`check-inspiration-read: pass (${i.url})`); if (i.tool === GEMINI && geminiEnabled()) lines.push("validate-design-system: design-system.md ok"); diff --git a/src/policy/design/state.ts b/src/policy/design/state.ts index 335e448..a5bff58 100644 --- a/src/policy/design/state.ts +++ b/src/policy/design/state.ts @@ -14,13 +14,28 @@ export interface DesignState { inspirationRead: boolean; scrolledSinceNav: boolean; screenshotsCount: number; + /** Distinct refs-design files read (paths relative to the corpus root), deduped. */ + corpusReads: string[]; designSystemExists: boolean; designSystemValid: boolean; geminiCalls: number; } -/** Minimum fuse-browser screenshots required before writing design-system.md, per mode. */ -export const MIN_SCREENSHOTS: Readonly> = { full: 4, page: 2, component: 0 }; +/** Minimum fuse-browser screenshots required before writing design-system.md, per mode (corpus present). */ +export const MIN_SCREENSHOTS: Readonly> = { full: 2, page: 1, component: 1 }; + +/** + * Screenshot quotas when the refs-design corpus is ABSENT (install defect → + * fail-open): exactly today's effective behavior, so the fallback can never be + * weaker than the pre-doctrine gate. Component is 1 explicitly — the effective + * quota today (the first screenshot trips `1 >= 0`), no longer a hidden quirk. + */ +export const MIN_SCREENSHOTS_NO_CORPUS: Readonly> = { full: 4, page: 2, component: 1 }; + +/** Screenshot quota for `mode` — the SINGLE choice point between the two tables, driven by corpus availability. */ +export function quotaFor(mode: DesignMode, corpusRequired: boolean): number { + return (corpusRequired ? MIN_SCREENSHOTS : MIN_SCREENSHOTS_NO_CORPUS)[mode]; +} const stateFile = (cacheDir: string, agentId: string): string => join(cacheDir, `.design-state-${agentId}.json`); @@ -29,7 +44,11 @@ export function loadDesignState(cacheDir: string, agentId: string): DesignState const path = stateFile(cacheDir, agentId); if (!existsSync(path)) return null; try { - return JSON.parse(readFileSync(path, "utf8")) as DesignState; + const parsed = JSON.parse(readFileSync(path, "utf8")) as DesignState; + // Retrocompat: states persisted before corpusReads existed load with an + // empty list (never undefined — an undefined count would silently + // fail-open or fail-closed the corpus conjunction downstream). + return { ...parsed, corpusReads: parsed.corpusReads ?? [] }; } catch { return null; } @@ -44,7 +63,7 @@ export function saveDesignState(cacheDir: string, state: DesignState): void { export function initDesignState(agentId: string, mode: DesignMode, designSystemExists: boolean): DesignState { return { agentId, mode, currentPhase: 0, phasesCompleted: [], inspirationRead: false, - scrolledSinceNav: false, screenshotsCount: 0, designSystemExists, designSystemValid: false, geminiCalls: 0, + scrolledSinceNav: false, screenshotsCount: 0, corpusReads: [], designSystemExists, designSystemValid: false, geminiCalls: 0, }; } diff --git a/src/policy/design/template-urls.ts b/src/policy/design/template-urls.ts new file mode 100644 index 0000000..7e5ff2f --- /dev/null +++ b/src/policy/design/template-urls.ts @@ -0,0 +1,54 @@ +/** + * Template-platform detection for the phase-1 navigate gate + * (design-inspiration.md §FORBIDDEN Navigation Targets). Two rules, because + * neither alone matches the doctrine: + * - template SHOPS are banned outright, on the host (subdomains included); + * - the path rule (/templates, /themes) applies ONLY to real product + * platforms that also host a template catalog — never to the whole web, + * or sothebys.com/en/marketplace (register research) would be denied. + * The host is normalized (lowercase, port, userinfo, trailing dot stripped). + * HONEST SCOPE: this is a LEXICAL filter — it follows no redirect, is not and + * will never be exhaustive, and it is NOT the taste gate: the source of taste + * is the refs-design corpus. This list exists to catch the reflex, not to map + * the template economy. Frozen by owner decision (C5): no host or pattern may + * be added or removed without re-opening that decision. + * Award galleries are deliberately absent: they pass, as outbound-link finders. + */ + +/** Template shops: banned outright, any path (subdomains included). */ +const TEMPLATE_HOSTS: readonly string[] = [ + "themeforest.net", "themes.shopify.com", "marketplace.atlassian.com", + "templatemonster.com", "creativemarket.com", "ui8.net", "html5up.net", + "colorlib.com", "bootstrapmade.com", "startbootstrap.com", "cruip.com", + "uideck.com", "envato.com", "tailwindui.com", +]; + +/** Real-product platforms whose template CATALOGS are banned via the path rule. */ +const PLATFORM_HOSTS: readonly string[] = [ + "framer.com", "webflow.com", "vercel.com", "astro.build", "squarespace.com", "wix.com", +]; + +/** + * Template-catalog path segments — evaluated ONLY on PLATFORM_HOSTS, and + * deliberately WITHOUT `/marketplace`: those platforms run real, non-template + * marketplaces (vercel.com/marketplace is infra). Atlassian/Shopify template + * stores are already covered by the full host ban above. + */ +const TEMPLATE_PATH_RE = /^\/(templates|themes)(\/|$)/i; + +/** Host part of `url`, normalized: lowercase, no port, no userinfo, no trailing dot. */ +function hostOf(url: string): string { + return (/^https?:\/\/(?:[^@/?#]*@)?([^/:?#]+)/i.exec(url)?.[1] ?? "").replace(/\.$/, "").toLowerCase(); +} + +const under = (host: string, domain: string): boolean => host === domain || host.endsWith(`.${domain}`); + +/** True when `url` points at a banned template source (host rule, then platform path rule). */ +export function isTemplateUrl(url: string): boolean { + const host = hostOf(url); + if (!host) return false; + if (TEMPLATE_HOSTS.some((d) => under(host, d))) return true; + if (!PLATFORM_HOSTS.some((d) => under(host, d))) return false; + const path = /^https?:\/\/(?:[^@/?#]*@)?[^/?#]*(\/[^?#]*)/i.exec(url)?.[1] ?? "/"; + return TEMPLATE_PATH_RE.test(path); +} diff --git a/src/policy/design/transitions.ts b/src/policy/design/transitions.ts index 2a8a21a..0e5e849 100644 --- a/src/policy/design/transitions.ts +++ b/src/policy/design/transitions.ts @@ -1,4 +1,5 @@ -import type { DesignState, DesignMode } from "./state"; +import { type DesignState, type DesignMode, quotaFor } from "./state"; +import { corpusReady } from "./corpus"; /** Infer the pipeline mode from the launch prompt + whether a design-system.md already exists. */ export function detectMode(prompt: string, designSystemExists: boolean): DesignMode { @@ -7,15 +8,21 @@ export function detectMode(prompt: string, designSystemExists: boolean): DesignM return designSystemExists ? "page" : "full"; } -/** Record a screenshot: bump the count and advance to phase 2 once the quota is met. */ -export function recordScreenshot(state: DesignState, needed: number): DesignState { - const screenshotsCount = state.screenshotsCount + 1; - const next: DesignState = { ...state, screenshotsCount }; - if (screenshotsCount >= needed && state.currentPhase < 2) { - next.currentPhase = 2; - next.phasesCompleted = [...new Set([...state.phasesCompleted, "identity", "research"])]; - } - return next; +/** + * Re-evaluate the phase-2 conjunction MONOTONICALLY: corpus reads (when the + * corpus is delivered) AND the screenshot quota must both hold, and the phase + * is never written downwards (a Read after phase 3 changes nothing). + */ +function maybePhase2(state: DesignState, corpusRequired: boolean): DesignState { + if (state.currentPhase >= 2) return state; + const corpusOk = !corpusRequired || corpusReady(state.corpusReads, state.mode); + if (!corpusOk || state.screenshotsCount < quotaFor(state.mode, corpusRequired)) return state; + return { ...state, currentPhase: 2, phasesCompleted: [...new Set([...state.phasesCompleted, "identity", "research"])] }; +} + +/** Record a screenshot: bump the count, then re-evaluate the phase-2 conjunction. */ +export function recordScreenshot(state: DesignState, corpusRequired: boolean): DesignState { + return maybePhase2({ ...state, screenshotsCount: state.screenshotsCount + 1 }, corpusRequired); } /** Record a fuse-browser navigate (resets the scroll-before-screenshot guard). */ @@ -37,16 +44,25 @@ export function recordValidDesignSystem(state: DesignState): DesignState { }; } +/** + * Record a corpus read: distinct files only (a re-read never inflates the + * list), then re-evaluate the phase-2 conjunction. `relPath` is relative to + * the corpus root, keeping the persisted state small and prefix-independent. + */ +export function recordCorpusRead(state: DesignState, relPath: string, corpusRequired: boolean): DesignState { + return maybePhase2({ ...state, corpusReads: [...new Set([...state.corpusReads, relPath])] }, corpusRequired); +} + /** * Record a skill-file Read: reading the identity templates enters phase 1 (browsing * allowed); reading the inspiration catalog satisfies the browse prerequisite. */ -export function recordRead(state: DesignState, filePath: string): DesignState { +export function recordRead(state: DesignState, filePath: string, corpusRequired = false): DesignState { const next: DesignState = { ...state }; if (filePath.includes("design-system/SKILL.md")) { next.currentPhase = Math.max(state.currentPhase, 1); next.phasesCompleted = [...new Set([...state.phasesCompleted, "identity"])]; } if (filePath.includes("design-inspiration")) next.inspirationRead = true; - return next; + return maybePhase2(next, corpusRequired); } diff --git a/src/runtime/design-content-gate.ts b/src/runtime/design-content-gate.ts new file mode 100644 index 0000000..7c3a024 --- /dev/null +++ b/src/runtime/design-content-gate.ts @@ -0,0 +1,95 @@ +/** + * @module design-content-gate + * PRE content gate for design-system.md — the nominal path (validateDesignSystem + * used to run only behind the opt-in Gemini branch, so designSystemValid meant + * "a write happened", not "the content is valid"). Split out of + * `design-helpers.ts` to keep both files within the SOLID size budget (SRP). + * @packageDocumentation + */ +import { readFileSync, existsSync } from "node:fs"; +import type { Prompt } from "../prompt/types"; +import type { DesignState } from "../policy/design/state"; +import { deny, validateDesignSystem } from "../policy/design/gates"; +import { citedCorpusRefs, citationJoinsReads } from "../policy/design/corpus"; +import { substituteLiteral } from "./design-files-gate"; + +/** Inputs for {@link designSystemContentGate} — the PRE write event plus resolved corpus context. */ +interface ContentGateInput { + filePath: string; + tool: string; + content: string; + oldString?: string; + replaceAll: boolean; + state: DesignState; + corpusRoot: string; + corpusRequired: boolean; +} + +/** + * Every content violation of a design-system.md text, GRANULAR (one entry per + * unmet requirement and per unread cited reference — so a worsening check can + * diff two violation sets): the four hard requirements, plus the citation↔read + * jointure when the corpus is delivered. + */ +export function designSystemProblems(content: string, reads: readonly string[], corpusRequired: boolean): string[] { + const problems = validateDesignSystem(content, corpusRequired); + if (corpusRequired) { + for (const ref of citedCorpusRefs(content)) { + if (!citationJoinsReads(ref, reads)) problems.push(`cited corpus reference never read: ${ref}`); + } + } + return problems; +} + +/** Content check for a design-system.md already on disk (the Gemini create_frontend path). */ +export function checkDesignSystemContent(content: string, reads: readonly string[], corpusRequired: boolean): Prompt | null { + const problems = designSystemProblems(content, reads, corpusRequired); + return problems.length ? deny(`BLOCKED: design-system.md too generic. Missing: ${problems.join(", ")}.`) : null; +} + +/** + * The content a Write/Edit would LEAVE in design-system.md, and the on-disk + * original when the file exists (for BOTH tools). Edit reconstructs via + * literal substitution (first occurrence, or all under replace_all — the Edit + * tool's own semantics, mirroring edit-outcome.ts). Null when unverifiable + * (unreadable file or stale old_string) — fail-closed, such an Edit fails at + * tool level anyway. + */ +function effectiveContents(i: ContentGateInput): { result: string; original: string | null } | null { + if (i.tool !== "Edit") { + let original: string | null = null; + try { + if (existsSync(i.filePath)) original = readFileSync(i.filePath, "utf8"); + } catch { + original = null; + } + return { result: i.content, original }; + } + let disk: string; + try { + disk = readFileSync(i.filePath, "utf8"); + } catch { + return null; + } + if (!i.oldString || !disk.includes(i.oldString)) return null; + return { result: substituteLiteral(disk, i.oldString, i.content, i.replaceAll), original: disk }; +} + +/** + * Block only the violations the change INTRODUCES (`problems(result) ⊄ + * problems(original)`): a legacy design-system.md that predates the doctrine + * stays editable — and rewritable — including byte-identically or toward + * conformance; a change that adds a NEW problem (or writes an invalid file + * from scratch) is blocked. + */ +export function designSystemContentGate(i: ContentGateInput): Prompt | null { + if (!i.filePath.endsWith("design-system.md")) return null; + const pair = effectiveContents(i); + 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."); + const result = designSystemProblems(pair.result, i.state.corpusReads, i.corpusRequired); + if (!result.length) return null; + const original = pair.original !== null ? designSystemProblems(pair.original, i.state.corpusReads, i.corpusRequired) : []; + const introduced = result.filter((p) => !original.includes(p)); + if (!introduced.length) return null; + 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.`); +} diff --git a/src/runtime/design-files-gate.ts b/src/runtime/design-files-gate.ts new file mode 100644 index 0000000..5a77af7 --- /dev/null +++ b/src/runtime/design-files-gate.ts @@ -0,0 +1,65 @@ +/** + * @module design-files-gate + * Gates for multi-file write primitives (Codex `apply_patch`, fanned into + * `event.files` by normalize.ts): apply_patch IS the Write tool under Codex, + * so the design pipeline must see it. Per op, because `content` means + * different things (apply-patch.ts:10-15): + * - add: content IS the full new document — content-gated like a Write; + * - update: content is the hunk's new side, APPROXIMATE, never the document — + * only content-independent gates run (path, state-file, phase/quota). + * No BLOCKING or DEGRADING validation on this path — promote-only: + * the POST re-reads the real file, a clean content promotes to + * phase 3, a dirty one changes nothing. And it would not bite + * anyway: designSystemValid is consumed ONLY under + * FUSE_DESIGN_GEMINI=1 (off by default — an informational flag), + * and designSystemExists is never read after init; + * - delete: skipped (parity with the sibling applyPatchGate). + * One violating file blocks the whole envelope. KNOWN GAPS (documented, out + * of scope): apply_patch also bypasses uiDesignSkillGate and the Gemini + * create_frontend precondition; htmlCssOnlyGate stays excluded (owner D2); + * and `Design-System.md` (case) escapes every endsWith check, here and on + * Write — a real bypass on case-insensitive macOS, not widened now. + * @packageDocumentation + */ +import type { Prompt } from "../prompt/types"; +import type { NormalizedFile } from "./normalize"; +import type { DesignState } from "../policy/design/state"; +import { pluginsWriteGuard } from "../policy/design/corpus"; +import { stateFileGate } from "../policy/design/gates"; +import { designSystemWriteGate } from "../policy/design/gates-pipeline"; +import { designSystemContentGate } from "./design-content-gate"; + +/** + * Literal substitution, first occurrence or all — NEVER String.replace's + * replacement-string semantics: an agent-controlled new_string of `$&`, `` $` ``, + * `$'` or `$$` must be written (and validated) literally, not interpreted as + * the matched text (that made the gate validate a document the tool would + * never write — the `$&` bypass). Used on both sides: PRE reconstruction and + * POST reverse-reconstruction. + */ +export function substituteLiteral(s: string, from: string, to: string, all: boolean): string { + return all ? s.split(from).join(to) : s.replace(from, () => to); +} + +/** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */ +export function designFilesGate( + files: readonly NormalizedFile[], + state: DesignState, + pluginsRoot: string, + corpusRoot: string, + corpusRequired: boolean, + cwd: string, +): Prompt | null { + for (const f of files) { + const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath); + if (hit) return hit; + if (f.op === "delete") continue; + if (!f.filePath.endsWith("design-system.md")) continue; + const gate = designSystemWriteGate(f.filePath, state, corpusRequired) + ?? (f.op === "add" + ? designSystemContentGate({ filePath: f.filePath, tool: "Write", content: f.content, oldString: undefined, replaceAll: false, state, corpusRoot, corpusRequired }) + : null); + if (gate) return gate; + } + return null; +} diff --git a/src/runtime/design-helpers.ts b/src/runtime/design-helpers.ts index 8dc11f3..a42162b 100644 --- a/src/runtime/design-helpers.ts +++ b/src/runtime/design-helpers.ts @@ -6,10 +6,15 @@ * @packageDocumentation */ import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import type { NormalizedEvent } from "./normalize"; -import { type DesignState, saveDesignState, MIN_SCREENSHOTS } from "../policy/design/state"; -import { recordScreenshot, recordNavigate, recordScroll, recordValidDesignSystem, recordRead } from "../policy/design/transitions"; +import { type DesignState, saveDesignState } from "../policy/design/state"; +import { recordScreenshot, recordCorpusRead, recordNavigate, recordScroll, recordValidDesignSystem, recordRead } from "../policy/design/transitions"; +import { classifyCorpusRead } from "../policy/design/corpus"; +import { designSystemProblems } from "./design-content-gate"; +import { substituteLiteral } from "./design-files-gate"; + +export { designSystemContentGate } from "./design-content-gate"; const NAV = "mcp__fuse-browser__browser_navigate"; const SHOT = "mcp__fuse-browser__browser_screenshot"; @@ -36,13 +41,57 @@ export function findDesignSystem(cwd: string): string { } /** Apply a PostToolUse fuse-browser transition to the design state. */ -export function recordPost(event: NormalizedEvent, cacheDir: string, state: DesignState): void { - if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode])); +export function recordPost(event: NormalizedEvent, cacheDir: string, state: DesignState, corpusRoot = "", corpusRequired = false, cwd = ""): void { + if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired)); else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state)); else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state)); else if (event.tool === GEMINI) saveDesignState(cacheDir, { ...state, geminiCalls: state.geminiCalls + 1 }); - else if (event.tool === "Read") saveDesignState(cacheDir, recordRead(state, event.filePath ?? "")); - else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) { - saveDesignState(cacheDir, recordValidDesignSystem(state)); + else if (event.tool === "Read") { + const fp = event.filePath ?? ""; + // A corpus read counts only when anchored under the delivered root AND the + // file exists (no tool_response reaches this hook — existsSync compensates). + if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) { + saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired)); + } else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired)); + } else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) { + // Write access ≠ validity, same rule both sides: POST validates only a + // zero-problem content and DEGRADES only what PRE would have blocked. + const fp = event.filePath ?? ""; + let content: string; + try { + content = readFileSync(fp, "utf8"); + } catch { + return; + } + const problems = designSystemProblems(content, state.corpusReads, corpusRequired); + if (!problems.length) { + saveDesignState(cacheDir, recordValidDesignSystem(state)); + return; + } + // Edit: degrade only on problems the edit INTRODUCED (original reverse-rebuilt + // with LITERAL substitution; a deletion edit is not reconstructable — D5). + if (event.tool === "Edit" && event.oldString && event.content && content.includes(event.content)) { + const original = substituteLiteral(content, event.content, event.oldString, event.input.replace_all === true); + const introduced = problems.filter((p) => !designSystemProblems(original, state.corpusReads, corpusRequired).includes(p)); + if (introduced.length) saveDesignState(cacheDir, { ...state, designSystemValid: false }); + } + // Write / unreconstructable Edit: the PRE gate enforced not-worsening — never degrade here. + } else if (event.files) { + // Codex apply_patch: PROMOTE-ONLY, never blocking nor degrading — the POST + // re-reads the REAL file (the hunk is never the document): clean → phase 3, + // dirty → nothing. designSystemValid is only consumed under FUSE_DESIGN_GEMINI. + const ds = event.files.find((f) => f.op !== "delete" && f.filePath.endsWith("design-system.md")); + if (ds) { + const abs = isAbsolute(ds.filePath) ? ds.filePath : join(cwd, ds.filePath); + let content: string; + try { + content = readFileSync(abs, "utf8"); + } catch { + return; + } + if (!designSystemProblems(content, state.corpusReads, corpusRequired).length) { + saveDesignState(cacheDir, recordValidDesignSystem(state)); + } + } } } diff --git a/src/runtime/design.ts b/src/runtime/design.ts index 7571eca..aac638b 100644 --- a/src/runtime/design.ts +++ b/src/runtime/design.ts @@ -6,9 +6,12 @@ import { activeDesignAgent } from "../policy/design/flag"; import { runDesignChecks } from "../policy/design/content-checks"; import { uiDesignSkillGate } from "../policy/design/skill-gate"; import { collectDesignEvidence } from "../policy/design/skill-evidence"; -import { findDesignSystem, recordPost } from "./design-helpers"; +import { findDesignSystem, recordPost, designSystemContentGate } from "./design-helpers"; +import { checkDesignSystemContent } from "./design-content-gate"; +import { designFilesGate } from "./design-files-gate"; +import { resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard } from "../policy/design/corpus"; import { - htmlCssOnlyGate, stateFileGate, screenshotScrollGate, validateDesignSystem, + htmlCssOnlyGate, stateFileGate, screenshotScrollGate, geminiEnabled, } from "../policy/design/gates"; import { designSystemWriteGate, geminiCreateGate, browserNavigateGate } from "../policy/design/gates-pipeline"; @@ -21,7 +24,7 @@ const GEMINI = "mcp__gemini-design__create_frontend"; * Design-pipeline gate (effectful: reads/writes the design state + design-system.md). * Returns a Prompt to block, or null when this isn't a design-agent context / nothing fires. */ -export function designGate(payload: Record, event: NormalizedEvent, cacheDir: string, cwd: string): Prompt | null { +export function designGate(payload: Record, event: NormalizedEvent, cacheDir: string, cwd: string, corpusRootOverride?: string, pluginsRootOverride?: string): Prompt | null { // UI design-skill gate (ports check-design-skill.py): fires for ANY agent on a // UI write — requires a design-skill read + ANY doc source (Context7/Exa/web). // Gemini is NEVER required. Runs before the design-agent pipeline state logic. @@ -45,9 +48,15 @@ export function designGate(payload: Record, event: NormalizedEv state = initDesignState(id, dsExists ? "page" : "full", dsExists); saveDesignState(cacheDir, state); } + // Corpus + plugin roots, resolved INDEPENDENTLY (corpusRoot never decides + // pluginsRoot — F4). Corpus absent ⇒ requirement waived (pre-doctrine quotas); + // the plugin-root write guard stays active against refs-design fabrication. + const corpusRoot = corpusRootOverride ?? resolveCorpusRoot(); + const corpusRequired = corpusRoot !== ""; + const pluginsRoot = pluginsRootOverride ?? resolvePluginsRoot(); if (event.phase === "post") { - recordPost(event, cacheDir, state); + recordPost(event, cacheDir, state, corpusRoot, corpusRequired, cwd); if ((event.tool === "Write" || event.tool === "Edit") && /\.(tsx|jsx|css)$/.test(event.filePath ?? "")) { const warnings = runDesignChecks(event.content ?? ""); if (warnings.length) return { kind: "inform", title: "Design review", reason: warnings.join(" "), actions: [] }; @@ -56,17 +65,17 @@ export function designGate(payload: Record, event: NormalizedEv } if (event.tool === "Write" || event.tool === "Edit") { const fp = event.filePath ?? ""; - // Parity: only design-system.md is screenshot-quota-gated (designSystemWriteGate, - // ports the live pipeline_checks.check_design_system_write). The old - // preScreenshotWriteGate ported check-browser-browsing.py, which is DEAD in the - // plugin (not wired in any hooks.json) and wrongly blocked plain .html/.css writes. - const base = stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state); + // Parity: only design-system.md is screenshot-quota-gated (designSystemWriteGate). + const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state, corpusRequired) + ?? designSystemContentGate({ filePath: fp, tool: event.tool, content: event.content ?? "", oldString: event.oldString, replaceAll: event.input.replace_all === true, state, corpusRoot, corpusRequired }); if (base) return base; if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) { return { kind: "block", title: "Design pipeline", reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.", actions: ["Call mcp__gemini-design__create_frontend first"] }; } return null; } + // Codex apply_patch (D2): gate each fanned-out file like a Write. + if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd); if (event.tool === NAV) { return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : ""); } @@ -79,12 +88,12 @@ export function designGate(payload: Record, event: NormalizedEv // Parity validate-design-system.py DENY_NOT_FOUND: a MISSING file gets its own // recovery message, distinct from the "too generic" message for a present-but-thin one. if (ds === "") { - return { kind: "block", title: "Design pipeline", reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Browse 4 reference sites 4) Write design-system.md, then retry create_frontend.", actions: ["Create design-system.md via the pipeline, then retry create_frontend"] }; - } - const missing = validateDesignSystem(ds); - if (missing.length) { - return { kind: "block", title: "Design pipeline", reason: `BLOCKED: design-system.md too generic. Missing: ${missing.join(", ")}.`, actions: ["Fix design-system.md, then retry create_frontend"] }; + return { kind: "block", title: "Design pipeline", 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.", actions: ["Create design-system.md via the pipeline, then retry create_frontend"] }; } + // Same content rules as the nominal write path (jointure included): the + // Gemini branch must not be weaker than the gate the Write just went through. + const contentBlock = checkDesignSystemContent(ds, state.corpusReads, corpusRequired); + if (contentBlock) return contentBlock; saveDesignState(cacheDir, recordValidDesignSystem(state)); } return null; diff --git a/src/runtime/handle-post.ts b/src/runtime/handle-post.ts index 1e2479d..30c31e2 100644 --- a/src/runtime/handle-post.ts +++ b/src/runtime/handle-post.ts @@ -34,7 +34,7 @@ export async function handlePost(ctx: PreContext): Promise { const { id, payload, event, framework, mcpDir, file, opts } = ctx; const response = payload.tool_response ?? payload.tool_output; mcpPostStore(event.tool, event.input, response, mcpDir); - const designWarn = designGate(payload, event, mcpDir, opts.cwd); + const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot); const activities = activityFor({ tool: event.tool, input: event.input, sessionId: event.sessionId, framework, now: opts.now, responseLength: extractText(response).length }); for (const activity of activities) await recordActivity(file, activity); // Session-scoped evidence (parity track-subagent-research.py): sub-agent hooks diff --git a/src/runtime/handle-pre.ts b/src/runtime/handle-pre.ts index a83b396..f387d97 100644 --- a/src/runtime/handle-pre.ts +++ b/src/runtime/handle-pre.ts @@ -40,7 +40,7 @@ export async function handlePre(ctx: PreContext): Promise { return { stdout: intercept.stdout, exit: 0 }; } - const designBlock = designGate(payload, event, mcpDir, opts.cwd); + const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot); if (designBlock) return { stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now), exit: 0 }; // Security scope is advisory-only (ports check-security-skill.py): emit the @@ -96,5 +96,5 @@ export async function handlePre(ctx: PreContext): Promise { // Every gate allowed: hand off to the ALLOW-path assembly (pass notice + // decision-time lesson + evidence-fresh notice). A deny/ask already returned // above, so nothing it emits can block nor override a decision. - return allowOutcome(id, event, payload, mcpDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }); + return allowOutcome(id, event, payload, mcpDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot); } diff --git a/src/runtime/handle-types.ts b/src/runtime/handle-types.ts index 3c94d9b..5fdaac9 100644 --- a/src/runtime/handle-types.ts +++ b/src/runtime/handle-types.ts @@ -10,6 +10,12 @@ export interface HandleOptions { windowMs?: number; /** Which plugin's hooks.json invoked the harness (selects lifecycle behavior). */ scope?: PluginScope; + /** + * Test/integration seam: the resolved refs-design corpus root ("" = absent). + * Undefined = resolve from disk (the production behavior); supplied = used + * as-is, so tests can drive the corpus-present branch deterministically. + */ + corpusRoot?: string; } /** What the hook bin should print + exit with. */ diff --git a/src/runtime/pre-allow.ts b/src/runtime/pre-allow.ts index 272000b..414f92a 100644 --- a/src/runtime/pre-allow.ts +++ b/src/runtime/pre-allow.ts @@ -7,6 +7,7 @@ * Write/Edit that clears the freshness gate. */ import { designPassNotice } from "../policy/design/gates"; +import { resolveCorpusRoot } from "../policy/design/corpus"; import { lessonFor } from "../policy/lessons/lesson-gate"; import { lessonsFileFor } from "./lifecycle/lessons/state"; import { projectRoot } from "../util/project-root"; @@ -71,11 +72,14 @@ export async function allowOutcome( mcpDir: string, cwd: string, evidence?: EvidenceCheck, + corpusRoot?: string, ): Promise { + const agentId = typeof payload.agent_id === "string" ? payload.agent_id : ""; const notice = designPassNotice({ - agentId: typeof payload.agent_id === "string" ? payload.agent_id : "", + agentId, tool: event.tool, filePath: event.filePath ?? "", content: event.content ?? "", url: typeof event.input.url === "string" ? event.input.url : "", phase: "pre", + corpusMissing: agentId !== "" && (corpusRoot ?? resolveCorpusRoot()) === "", }, mcpDir); const lesson = lessonFor(event.tool, event.input, { file: lessonsFileFor(projectRoot(cwd)), once: oncePerWindow }); const evidenceNotice = evidence ? await freshEvidenceNotice(event, evidence, cwd) : null; diff --git a/test/apexauth-usermessage.test.ts b/test/apexauth-usermessage.test.ts index 7f61aae..7e3adc0 100644 --- a/test/apexauth-usermessage.test.ts +++ b/test/apexauth-usermessage.test.ts @@ -91,8 +91,9 @@ test("handleHook e2e: a PASSING design-agent navigate emits the user notice on c const cwd = tmp(); designCache("agE", 1, projectLayout(cwd).cacheDir); const payload = { hook_event_name: "PreToolUse", session_id: "sE", agent_id: "agE", tool_name: "mcp__fuse-browser__browser_navigate", tool_input: { url: "https://godly.website/site" } }; - const out = await handleHook("claude-code", payload, { now: 5000, cwd }); - expect((JSON.parse(out.stdout) as Record).systemMessage).toBe("check-inspiration-read: pass (https://godly.website/site)\npipeline-gate: phase 1 ok"); + // Corpus ABSENT (injected ""): the waived-requirement warning is user-visible — deliberate new line. + const out = await handleHook("claude-code", payload, { now: 5000, cwd, corpusRoot: "" }); + expect((JSON.parse(out.stdout) as Record).systemMessage).toBe("design-corpus: refs-design corpus not found — corpus requirement waived (screenshot-only fallback, install defect)\ncheck-inspiration-read: pass (https://godly.website/site)\npipeline-gate: phase 1 ok"); // Same passing event on a harness without a user channel (cline shape): no crash, plain allow. expect((await handleHook("cline", { preToolUse: { toolName: "x", parameters: {} }, taskId: "tE" }, { now: 5000, cwd })).stdout).toBe(""); }); diff --git a/test/design-apply-patch.test.ts b/test/design-apply-patch.test.ts new file mode 100644 index 0000000..99fb721 --- /dev/null +++ b/test/design-apply-patch.test.ts @@ -0,0 +1,89 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { designGate } from "../src/runtime/design"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; + +/** + * apply_patch through the REAL chain — parseApplyPatch → normalizeEvent → + * designGate. For op:"update" the patch content is a HUNK, never the + * document: the PRE must not content-gate it (validation deferred to POST), + * the POST validates the real file from disk. + */ +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-ap-")); + +function setup(diskContent: string) { + const cache = tmp(); + const proj = tmp(); + const f = join(proj, "design-system.md"); + writeFileSync(f, diskContent); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + return { cache, proj, f }; +} +const ev = (phase: string, patch: string) => + normalizeEvent("codex", { hook_event_name: phase === "pre" ? "PreToolUse" : "PostToolUse", tool_name: "apply_patch", tool_input: { command: patch }, session_id: "s", agent_id: "ag" }); + +test("update patch on a VALID design-system.md: PRE allows (the hunk is not the document), POST validates the real file", () => { + const { cache, proj, f } = setup(URL_DS); + const patch = `*** Begin Patch\n*** Update File: ${f}\n@@\n-Inspiration: https://boulangerie-dupont.fr\n+Inspiration: https://boulangerie-martin.fr\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")).toBeNull(); + // The tool lands: the real file now carries the new URL (still valid). + writeFileSync(f, URL_DS.replace("boulangerie-dupont", "boulangerie-martin")); + expect(designGate({ agent_id: "ag" }, ev("post", patch), cache, proj, "")).toBeNull(); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("POST promotes via a RELATIVE patch path — the cwd wiring is load-bearing, relative is the normal form", () => { + const cache = tmp(); + const proj = tmp(); + writeFileSync(join(proj, "design-system.md"), URL_DS); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + const patch = `*** Begin Patch\n*** Update File: design-system.md\n@@\n-Inspiration: https://boulangerie-dupont.fr\n+Inspiration: https://boulangerie-martin.fr\n*** End Patch`; + writeFileSync(join(proj, "design-system.md"), URL_DS.replace("boulangerie-dupont", "boulangerie-martin")); + expect(designGate({ agent_id: "ag" }, ev("post", patch), cache, proj, "")).toBeNull(); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("a delete op never promotes — even when the file still exists and is clean", () => { + const cache = tmp(); + const proj = tmp(); + writeFileSync(join(proj, "design-system.md"), URL_DS); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + const patch = `*** Begin Patch\n*** Delete File: design-system.md\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("post", patch), cache, proj, "")).toBeNull(); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(false); + expect(s.currentPhase).toBe(2); +}); + +test("delete is skipped even at phase 0 — the write gate never sees it", () => { + const cache = tmp(); + const proj = tmp(); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "full", false)); // phase 0: designSystemWriteGate WOULD block + const patch = `*** Begin Patch\n*** Delete File: ${join(proj, "design-system.md")}\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")).toBeNull(); +}); + +test("add of a generic design-system.md is still blocked at PRE (add content IS the document)", () => { + const { cache, proj } = setup(URL_DS); + const patch = `*** Begin Patch\n*** Add File: ${join(proj, "design-system.md")}\n+TOTAL GARBAGE, zero design content\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")?.kind).toBe("block"); +}); + +test("a multi-file envelope is blocked by its LAST faulty file, not only its first", () => { + const { cache, proj } = setup(URL_DS); + const patch = `*** Begin Patch\n*** Add File: ${join(proj, "ok.css")}\n+div { color: red; }\n*** Add File: ${join(proj, "design-system.md")}\n+TOTAL GARBAGE\n*** End Patch`; + expect(designGate({ agent_id: "ag" }, ev("pre", patch), cache, proj, "")?.kind).toBe("block"); +}); diff --git a/test/design-characterization.test.ts b/test/design-characterization.test.ts new file mode 100644 index 0000000..2b3063d --- /dev/null +++ b/test/design-characterization.test.ts @@ -0,0 +1,73 @@ +import { test, expect } from "bun:test"; +import { browserNavigateGate, designSystemWriteGate, geminiCreateGate } from "../src/policy/design/gates-pipeline"; +import { htmlCssOnlyGate, stateFileGate, screenshotScrollGate, validateDesignSystem } from "../src/policy/design/gates"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; + +/** + * Characterization tests: pin the behavior that must SURVIVE the inspiration-doctrine + * rework untouched. Green before AND after the change — if one turns red, the + * implementation moved something it was not supposed to. + */ +const st = (over: Partial = {}): DesignState => ({ ...initDesignState("a", "full", false), ...over }); +const VALID = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; + +test("navigate gate: phase 0 and inspiration prerequisite still deny", () => { + expect(browserNavigateGate(st({ currentPhase: 0 }), "https://x.com")?.kind).toBe("block"); + expect(browserNavigateGate(st({ currentPhase: 1, inspirationRead: false }), "https://x.com")?.kind).toBe("block"); +}); + +test("screenshot scroll guard unchanged", () => { + expect(screenshotScrollGate(st({ scrolledSinceNav: false }))?.kind).toBe("block"); + expect(screenshotScrollGate(st({ scrolledSinceNav: true }))).toBeNull(); +}); + +test("navigate gate scoping survives the catalog inversion: galleries pass in phase 1, any URL passes from phase 2", () => { + const p1 = st({ currentPhase: 1, inspirationRead: true }); + expect(browserNavigateGate(p1, "https://awwwards.com/websites/x")).toBeNull(); + expect(browserNavigateGate(p1, "https://godly.website")).toBeNull(); + expect(browserNavigateGate(st({ currentPhase: 2, inspirationRead: true }), "https://boulangerie-dupont.fr")).toBeNull(); +}); + +test("state-file and html/css-only guards unchanged", () => { + expect(stateFileGate("/p/.design-state-a.json")?.kind).toBe("block"); + expect(stateFileGate("/p/other.md")).toBeNull(); + expect(htmlCssOnlyGate("src/components/x.tsx")?.kind).toBe("block"); + expect(htmlCssOnlyGate("src/components/x.md")).toBeNull(); +}); + +test("gemini gate: phase >= 3 and validated design system still required", () => { + expect(geminiCreateGate(st({ currentPhase: 2, designSystemValid: true }))?.kind).toBe("block"); + expect(geminiCreateGate(st({ currentPhase: 3, designSystemValid: false }))?.kind).toBe("block"); + expect(geminiCreateGate(st({ currentPhase: 3, designSystemValid: true }))).toBeNull(); +}); + +test("write gate: still blocks at phase < 2 and below quota, allows above", () => { + expect(designSystemWriteGate("other.md", st())).toBeNull(); + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 1 }))?.kind).toBe("block"); + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 2, mode: "full", screenshotsCount: 0 }))?.kind).toBe("block"); + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 2, mode: "full", screenshotsCount: 99 }))).toBeNull(); +}); + +test("validateDesignSystem: section, oklch chroma and forbidden-font checks survive", () => { + expect(validateDesignSystem("no section https://x.com oklch(0.6 0.2 250)")).toContain("## Design Reference section"); + expect(validateDesignSystem("## Design Reference\nhttps://x.com")).toContain("oklch() color with chroma > 0"); + expect(validateDesignSystem(VALID)).toEqual([]); +}); + +test("forbidden fonts: a DECLARATION is caught, prose mention is allowed", () => { + expect(validateDesignSystem(`${VALID}\n--font-body: "Inter", sans-serif;`)).toContain("forbidden font (Inter/Roboto/Arial/Open Sans)"); + expect(validateDesignSystem(`${VALID}\nfont-family: Roboto;`)).toContain("forbidden font (Inter/Roboto/Arial/Open Sans)"); + expect(validateDesignSystem(`${VALID}\n## Interaction states\nWe never use Inter here.`)).toEqual([]); +}); + +test("forbidden fonts: usage forms beyond one-line CSS declarations are caught", () => { + const forms = [ + "font: 16px/1.5 Inter, sans-serif;", + 'fontFamily: "Inter"', + "FONT-FAMILY: Inter;", + "font-family:\n Inter;", + "@import url('https://fonts.googleapis.com/css2?family=Inter');", + "| Body | Inter |", + ]; + for (const form of forms) expect(validateDesignSystem(`${VALID}\n${form}`)).toContain("forbidden font (Inter/Roboto/Arial/Open Sans)"); +}); diff --git a/test/design-content-gate-legacy.test.ts b/test/design-content-gate-legacy.test.ts new file mode 100644 index 0000000..d1175e2 --- /dev/null +++ b/test/design-content-gate-legacy.test.ts @@ -0,0 +1,48 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designSystemContentGate } from "../src/runtime/design-helpers"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; + +/** + * The not-worsening rule (D4): a legacy design-system.md that predates the + * doctrine stays editable AND rewritable — a change is blocked only when it + * INTRODUCES a violation the on-disk file did not already have. + */ +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; +const READS = ["README.md", "umbrel-recode/tokens-umbrel.md", "fora-recode/tokens-fora.md"]; +const state = (over: Partial = {}): DesignState => + ({ ...initDesignState("a", "full", false), currentPhase: 2, corpusReads: READS, ...over }); +const base = { filePath: "", tool: "Write", oldString: undefined, replaceAll: false, corpusRoot: ROOT, corpusRequired: true }; +const legacyFile = (content: string): string => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, content); + return f; +}; + +test("Edit on a legacy invalid file is NOT blocked when it introduces nothing new", () => { + const f = legacyFile("## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: #3366ff;\n--font: \"Fraunces\";"); + const benign = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--b: #123456;", oldString: "--a: #3366ff;", state: state() }); + expect(benign).toBeNull(); +}); + +test("Edit that INTRODUCES new problems on an already-invalid file is blocked", () => { + // Already invalid (no reference source); the edit kills oklch AND adds a forbidden font declaration. + const f = legacyFile("## Design Reference\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--a: #3366ff; --font-x: Inter;", oldString: "--a: oklch(0.62 0.19 250);", state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Write over an existing legacy file follows the same not-worsening rule", () => { + const legacy = "## Design Reference\n--a: #3366ff;\n--font: \"Fraunces\";"; + const f = legacyFile(legacy); + // Byte-identical rewrite: allowed (no new problem). + expect(designSystemContentGate({ ...base, filePath: f, content: legacy, state: state() })).toBeNull(); + // Improved but still imperfect (oklch added, source still missing): allowed. + const improved = legacy.replace("--a: #3366ff;", "--a: oklch(0.62 0.19 250);"); + expect(designSystemContentGate({ ...base, filePath: f, content: improved, state: state() })).toBeNull(); + // Same rewrite plus a NEW violation: blocked. + const degraded = `${legacy}\n--font-x: Inter;`; + expect(designSystemContentGate({ ...base, filePath: f, content: degraded, state: state() })?.kind).toBe("block"); +}); diff --git a/test/design-content-gate.test.ts b/test/design-content-gate.test.ts new file mode 100644 index 0000000..0b7aab1 --- /dev/null +++ b/test/design-content-gate.test.ts @@ -0,0 +1,93 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designSystemContentGate } from "../src/runtime/design-helpers"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; + +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; +const READS = ["README.md", "umbrel-recode/tokens-umbrel.md", "fora-recode/tokens-fora.md"]; +const state = (over: Partial = {}): DesignState => + ({ ...initDesignState("a", "full", false), currentPhase: 2, corpusReads: READS, ...over }); +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const CORPUS_DS = "## Design Reference\n- Corpus: umbrel/## 4. Colors, fora/## 2. Colors\n--a: oklch(0.62 0.19 250);"; +const base = { filePath: "/proj/design-system.md", tool: "Write", oldString: undefined, replaceAll: false, corpusRoot: ROOT, corpusRequired: true }; + +test("non design-system.md paths are not content-gated; a valid Write passes", () => { + expect(designSystemContentGate({ ...base, filePath: "/proj/other.md", content: "", state: state() })).toBeNull(); + expect(designSystemContentGate({ ...base, content: URL_DS, state: state() })).toBeNull(); +}); + +test("Write with invalid content is blocked and names what is missing", () => { + const p = designSystemContentGate({ ...base, content: "# empty\n", state: state() }); + expect(p?.kind).toBe("block"); + expect(p?.reason).toContain("## Design Reference"); +}); + +test("jointure: a corpus citation naming references actually read passes", () => { + expect(designSystemContentGate({ ...base, content: CORPUS_DS, state: state() })).toBeNull(); +}); + +test("jointure: citing a reference never read is blocked and names it", () => { + const p = designSystemContentGate({ ...base, content: CORPUS_DS.replace("fora/", "xai/"), state: state() }); + expect(p?.kind).toBe("block"); + expect(p?.reason).toContain("xai"); +}); + +test("corpus absent: the URL is mandatory again (fallback never weaker), jointure waived with a URL", () => { + // Citation-only document, corpus absent → blocked: the citation door closes with the corpus. + const citationOnly = CORPUS_DS.replace("fora/", "xai/"); + expect(designSystemContentGate({ ...base, content: citationOnly, state: state(), corpusRoot: "", corpusRequired: false })?.kind).toBe("block"); + // URL-sourced document with a bogus citation line, corpus absent → passes: no join check without a corpus. + const withUrl = `${URL_DS}\n- Corpus: totally-made-up/whatever`; + expect(designSystemContentGate({ ...base, content: withUrl, state: state(), corpusRoot: "", corpusRequired: false })).toBeNull(); +}); + +test("Edit that BREAKS a valid file is blocked (worsening)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--a: #3366ff;", oldString: "--a: oklch(0.62 0.19 250);", state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Edit validates the reconstructed result, not the fragment", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + const ok = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "--b: oklch(0.5 0.1 200);", oldString: "--font: \"Fraunces\";", state: state() }); + expect(ok).toBeNull(); + const kill = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "## Renamed", oldString: "## Design Reference", state: state() }); + expect(kill?.kind).toBe("block"); +}); + +test("Edit replace_all substitutes every occurrence before validating", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, `${URL_DS}\n--font-x: "Inter"; --font-y: "Inter";`); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "Fraunces", oldString: "Inter", replaceAll: true, state: state() }); + expect(p).toBeNull(); +}); + +test("Edit new_string with replacement metacharacters is validated LITERALLY ($& is not the match)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + // The real Edit writes "$&" literally — the URL is gone. A gate that uses + // String.replace's replacement semantics would reconstruct the UNCHANGED + // file and allow: the bypass. Literal substitution must see the deletion. + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "$&", oldString: "Inspiration: https://boulangerie-dupont.fr", state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Edit replace_all with a metachar new_string substitutes LITERALLY (replaceAll would no-op on $&)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, `${URL_DS}\nInspiration: https://boulangerie-dupont.fr (again)`); + // Literal: both URLs become the text "$&" → no https:// left → invalid → block. + // replaceAll(old, "$&") would interpret $& as the match → no-op → allow (the bypass). + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "$&", oldString: "https://boulangerie-dupont.fr", replaceAll: true, state: state() }); + expect(p?.kind).toBe("block"); +}); + +test("Edit with a stale old_string is blocked (re-read), never validated blind", () => { + const f = join(mkdtempSync(join(tmpdir(), "fh-ds-")), "design-system.md"); + writeFileSync(f, URL_DS); + const p = designSystemContentGate({ ...base, filePath: f, tool: "Edit", content: "x", oldString: "not in the file", state: state() }); + expect(p?.kind).toBe("block"); +}); diff --git a/test/design-corpus-join.test.ts b/test/design-corpus-join.test.ts new file mode 100644 index 0000000..1eabaf5 --- /dev/null +++ b/test/design-corpus-join.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from "bun:test"; +import { citedCorpusRefs, citationJoinsReads, hasCorpusCitation } from "../src/policy/design/corpus"; + +const REAL = ["umbrel", "linear", "cursor", "harness", "xai", "mainframe", "reve", "endlesstools", "supercommon", "fora", "elysian"]; + +test("jointure: the ELEVEN real references join — both layouts, both naming conventions", () => { + for (const s of REAL) { + const dirRead = s === "elysian" ? "elysian/tokens-elysian.md" : `${s}-recode/tokens-${s}.md`; + expect(citationJoinsReads(s, [dirRead])).toBe(true); // README convention (bare name), dir layout + expect(citationJoinsReads(s, [`tokens-${s}.md`])).toBe(true); // bare name, flat layout + if (s !== "elysian") { + expect(citationJoinsReads(`${s}-recode`, [dirRead])).toBe(true); // dir convention, dir layout + expect(citationJoinsReads(`${s}-recode`, [`tokens-${s}.md`])).toBe(true); // dir convention, flat layout + } + } +}); + +test("jointure: a reference never read never joins — widening must not reopen the forged door", () => { + expect(citationJoinsReads("acme-corp", REAL.map((s) => `tokens-${s}.md`))).toBe(false); + expect(citationJoinsReads("acme", ["acme-corp-recode/tokens-acme-corp.md"])).toBe(false); // near-miss stays out + expect(citationJoinsReads("acme-corp", ["acme-recode/tokens-acme.md"])).toBe(false); // and the other direction +}); + +test("citation form: EVERY Corpus line is parsed — a ref cited on a second line joins too", () => { + const content = "- Corpus: umbrel/## 4\n- Corpus: fora/## 2, reve/## 1"; + expect(citedCorpusRefs(content)).toEqual(["umbrel", "fora", "reve"]); +}); + +test("citation form: bare refs, asterisk bullets, lowercase corpus are recognized", () => { + expect(citedCorpusRefs("- Corpus: reve")).toEqual(["reve"]); + expect(citedCorpusRefs("* Corpus: umbrel/## 4. Colors, fora/## 2. Colors")).toEqual(["umbrel", "fora"]); + expect(citedCorpusRefs("- corpus: reve/## 4. Colors")).toEqual(["reve"]); +}); + +test("citationJoinsReads: filename prefixes and README never join", () => { + const reads = ["README.md", "tokens-a.md", "a-recode/tokens-a.md"]; + expect(citationJoinsReads("tokens", reads)).toBe(false); // would otherwise join EVERY tokens-*.md + expect(citationJoinsReads("README", reads)).toBe(false); // the index names no reference + expect(citationJoinsReads("a", reads)).toBe(true); +}); + +test("citationJoinsReads: -recode absorbed, unread ref rejected, flat layout joins", () => { + const reads = ["README.md", "reve-recode/tokens-reve.md", "elysian/tokens-elysian.md"]; + expect(citationJoinsReads("reve", reads)).toBe(true); + expect(citationJoinsReads("reve-recode", reads)).toBe(true); + expect(citationJoinsReads("xai", reads)).toBe(false); + expect(citationJoinsReads("b", ["tokens-b-recode.md"])).toBe(true); +}); + +test("hasCorpusCitation: a Corpus line with ref/section pairs, nothing else", () => { + expect(hasCorpusCitation("- Corpus: umbrel/## 4. Colors, fora/## 2. Colors")).toBe(true); + expect(hasCorpusCitation("Inspiration: https://example.com")).toBe(false); + expect(hasCorpusCitation("some prose mentioning corpus/ nothing")).toBe(false); +}); diff --git a/test/design-corpus-resolve.test.ts b/test/design-corpus-resolve.test.ts new file mode 100644 index 0000000..ba8f78e --- /dev/null +++ b/test/design-corpus-resolve.test.ts @@ -0,0 +1,65 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCorpusRoot, resolvePluginsRoot } from "../src/policy/design/corpus"; + +/** A fake $HOME, realpath'd (macOS /var → /private/var). */ +const home = (): string => realpathSync(mkdtempSync(join(tmpdir(), "fh-res-"))); +const REFS = join("skills", "design-web", "references", "refs-design"); + +test("claude-code: a corpus under the marketplace tree is resolved", () => { + const h = home(); + const plugin = join(h, ".claude", "plugins", "marketplaces", "fusengine-plugins", "plugins", "design-expert"); + mkdirSync(join(plugin, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "claude-code", {})).toBe(join(plugin, REFS)); + expect(resolvePluginsRoot(undefined, h, "claude-code", {})).toBe(plugin); +}); + +test("codex: a corpus under the versioned plugin cache is resolved — highest semver wins", () => { + const h = home(); + const de = join(h, ".codex", "plugins", "cache", "fusengine-codex", "design-expert"); + mkdirSync(join(de, "2.1.9", "skills"), { recursive: true }); + mkdirSync(join(de, "2.1.38", REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(join(de, "2.1.38", REFS)); + expect(resolvePluginsRoot(undefined, h, "codex", {})).toBe(join(de, "2.1.38")); +}); + +test("codex: CODEX_HOME is honored when set", () => { + const h = home(); + const codexHome = home(); + const de = join(codexHome, "plugins", "cache", "mp", "design-expert", "1.0.0"); + mkdirSync(join(de, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", { CODEX_HOME: codexHome })).toBe(join(de, REFS)); +}); + +test("runtimes without a plugin structure resolve to '' (kimi is not wired)", () => { + const h = home(); + expect(resolveCorpusRoot(undefined, h, "kimi", {})).toBe(""); + expect(resolvePluginsRoot(undefined, h, "kimi", {})).toBe(""); +}); + +test("codex without the plugin cache resolves to '' — never the cwd", () => { + const h = home(); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(""); + expect(resolvePluginsRoot(undefined, h, "codex", {})).toBe(""); +}); + +test("cross-runtime isolation: a corpus only in the OTHER runtime's tree is never resolved", () => { + const h = home(); + // Corpus present ONLY in the Claude tree → codex must NOT fall back to it. + const claudePlugin = join(h, ".claude", "plugins", "marketplaces", "mkt", "plugins", "design-expert"); + mkdirSync(join(claudePlugin, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(""); + // Corpus present ONLY in the Codex cache → claude-code must NOT fall back to it. + const h2 = home(); + mkdirSync(join(h2, ".codex", "plugins", "cache", "mp", "design-expert", "1.0.0", REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h2, "claude-code", {})).toBe(""); +}); + +test("codex version selection: non-semver dirs and pre-releases never beat the stable", () => { + const h = home(); + const de = join(h, ".codex", "plugins", "cache", "mp", "design-expert"); + for (const v of ["2.1.38", "3.0.0-rc1", "backup"]) mkdirSync(join(de, v, REFS), { recursive: true }); + expect(resolveCorpusRoot(undefined, h, "codex", {})).toBe(join(de, "2.1.38", REFS)); +}); diff --git a/test/design-corpus-transitions.test.ts b/test/design-corpus-transitions.test.ts new file mode 100644 index 0000000..b258e8d --- /dev/null +++ b/test/design-corpus-transitions.test.ts @@ -0,0 +1,65 @@ +import { test, expect } from "bun:test"; +import { recordCorpusRead, recordScreenshot, recordRead, recordValidDesignSystem } from "../src/policy/design/transitions"; +import { initDesignState, type DesignState, type DesignMode } from "../src/policy/design/state"; + +const st = (mode: DesignMode, over: Partial = {}): DesignState => + ({ ...initDesignState("a", mode, false), currentPhase: 1, ...over }); + +test("recordCorpusRead: distinct reads only — re-reading the same file does not inflate", () => { + let s = recordCorpusRead(st("component"), "README.md", true); + s = recordCorpusRead(s, "README.md", true); + expect(s.corpusReads).toEqual(["README.md"]); +}); + +test("component: one corpus read alone is not enough — one screenshot is always required", () => { + let s = recordCorpusRead(st("component"), "umbrel-recode/tokens-umbrel.md", true); + expect(s.currentPhase).toBe(1); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(2); +}); + +test("page: two distinct corpus reads plus one screenshot open phase 2", () => { + let s = recordCorpusRead(st("page"), "README.md", true); + s = recordCorpusRead(s, "fora-recode/tokens-fora.md", true); + expect(s.currentPhase).toBe(1); // reads alone never open phase 2 + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(2); +}); + +test("page: one corpus read plus one screenshot is not enough", () => { + let s = recordCorpusRead(st("page"), "README.md", true); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(1); +}); + +test("full: corpus alone is not enough — the 2-screenshot quota still applies", () => { + let s = st("full"); + for (const r of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) s = recordCorpusRead(s, r, true); + expect(s.currentPhase).toBe(1); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(1); + s = recordScreenshot(s, true); + expect(s.currentPhase).toBe(2); +}); + +test("monotonic: a corpus Read after phase 3 never downgrades currentPhase", () => { + let s = recordValidDesignSystem(st("page", { currentPhase: 2 })); + expect(s.currentPhase).toBe(3); + s = recordCorpusRead(s, "README.md", true); + expect(s.currentPhase).toBe(3); +}); + +test("waived vs required is DISCRIMINANT: the same state opens phase 2 only when the corpus requirement is lifted", () => { + const s = st("page", { screenshotsCount: 1 }); + expect(recordScreenshot(s, false).currentPhase).toBe(2); // fallback quota (2) met, corpus waived + expect(recordScreenshot(s, true).currentPhase).toBe(1); // corpus reads missing + const read = recordCorpusRead(recordCorpusRead(s, "README.md", true), "a-recode/tokens-a.md", true); + expect(recordScreenshot(read, true).currentPhase).toBe(2); // corpus + screenshot +}); + +test("waived (corpus absent): screenshots alone decide, exactly like today", () => { + expect(recordScreenshot(st("page", { screenshotsCount: 1 }), false).currentPhase).toBe(2); + expect(recordScreenshot(st("component"), false).currentPhase).toBe(2); + expect(recordScreenshot(st("full", { screenshotsCount: 3 }), false).currentPhase).toBe(2); + expect(recordRead(st("page"), "/x/design-inspiration.md", false).currentPhase).toBe(1); // no free pass +}); diff --git a/test/design-corpus.test.ts b/test/design-corpus.test.ts new file mode 100644 index 0000000..42eb67a --- /dev/null +++ b/test/design-corpus.test.ts @@ -0,0 +1,70 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + classifyCorpusRead, corpusReady, resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard, +} from "../src/policy/design/corpus"; + +const ROOT = "/plugins/design-expert/skills/design-web/references/refs-design"; + +test("classifyCorpusRead: index vs tokens vs outside the delivered corpus", () => { + expect(classifyCorpusRead(`${ROOT}/README.md`, ROOT)).toBe("index"); + expect(classifyCorpusRead(`${ROOT}/umbrel-recode/tokens-umbrel.md`, ROOT)).toBe("tokens"); + expect(classifyCorpusRead(`${ROOT}/elysian/tokens-elysian.md`, ROOT)).toBe("tokens"); + expect(classifyCorpusRead(`${ROOT}/umbrel-recode/index.html`, ROOT)).toBeNull(); + // A refs-design/ dir fabricated outside the delivered plugin never counts. + expect(classifyCorpusRead("/tmp/scratch/refs-design/tokens-fake.md", ROOT)).toBeNull(); + expect(classifyCorpusRead("/proj/docs/refs-design/README.md", ROOT)).toBeNull(); +}); + +test("corpusReady: per-mode thresholds (component >= 1, page >= 2, full = index + 2 tokens)", () => { + const idx = ["README.md"]; + const one = ["README.md", "umbrel-recode/tokens-umbrel.md"]; + const two = [...one, "fora-recode/tokens-fora.md"]; + expect(corpusReady([], "component")).toBe(false); + expect(corpusReady(idx, "component")).toBe(true); + expect(corpusReady(idx, "page")).toBe(false); + expect(corpusReady(one, "page")).toBe(true); + expect(corpusReady(one, "full")).toBe(false); // one tokens file only + expect(corpusReady(two.filter((r) => r !== "README.md"), "full")).toBe(false); // no index + expect(corpusReady(two, "full")).toBe(true); +}); + +test("pluginsWriteGuard: writes under the delivered corpus are denied, anything else passes", () => { + expect(pluginsWriteGuard(`${ROOT}/fake/tokens-fake.md`, ROOT)?.kind).toBe("block"); + expect(pluginsWriteGuard("/proj/refs-design/tokens-fake.md", ROOT)).toBeNull(); + expect(pluginsWriteGuard(`${ROOT}/x.md`, "")).toBeNull(); // no corpus → guard inert +}); + +test("pluginsWriteGuard: RELATIVE paths (the normal form of Codex patches) are resolved against cwd", () => { + const plugins = mkdtempSync(join(tmpdir(), "fh-rel-")); + const rel = "design-expert/skills/design-web/references/refs-design/fake/tokens-fake.md"; + expect(pluginsWriteGuard(rel, join(plugins, "design-expert"), plugins)?.kind).toBe("block"); + expect(pluginsWriteGuard(rel, join(plugins, "design-expert"), "/elsewhere")).toBeNull(); +}); + +test("resolveCorpusRoot: empty when refs-design is absent, real path when present", () => { + const plugins = mkdtempSync(join(tmpdir(), "fh-corpus-")); + const refs = join(plugins, "design-expert", "skills", "design-web", "references", "refs-design"); + expect(resolveCorpusRoot(plugins)).toBe(""); + mkdirSync(refs, { recursive: true }); + expect(resolveCorpusRoot(plugins)).toBe(refs); +}); + +test("resolveCorpusRoot WITHOUT override: a corpus sitting at the cwd is NEVER retained", () => { + // Fake home with NO plugin structure; a valid-looking corpus sits at the cwd. + // There is no cwd fallback anywhere in the resolution: "" must come from the + // refusal, not from chance — the corpus is present and still not retained. + const fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "fh-home-"))); + const proj = join(fakeHome, "project"); + mkdirSync(join(proj, "design-expert", "skills", "design-web", "references", "refs-design"), { recursive: true }); + const prev = process.cwd(); + process.chdir(proj); + try { + expect(resolveCorpusRoot(undefined, fakeHome, "claude-code", {})).toBe(""); + expect(resolvePluginsRoot(undefined, fakeHome, "claude-code", {})).toBe(""); + } finally { + process.chdir(prev); + } +}); diff --git a/test/design-gate-corpus.test.ts b/test/design-gate-corpus.test.ts new file mode 100644 index 0000000..a6ffd43 --- /dev/null +++ b/test/design-gate-corpus.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designGate } from "../src/runtime/design"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +/** Fixture: a delivered corpus (README + two tokens files) and an active design agent. */ +function setup() { + const cache = mkdtempSync(join(tmpdir(), "fh-gc-")); + const root = join(mkdtempSync(join(tmpdir(), "fh-gc-root-")), "refs-design"); + mkdirSync(join(root, "a-recode"), { recursive: true }); + mkdirSync(join(root, "b-recode"), { recursive: true }); + writeFileSync(join(root, "README.md"), "# index"); + writeFileSync(join(root, "a-recode", "tokens-a.md"), "# a"); + writeFileSync(join(root, "b-recode", "tokens-b.md"), "# b"); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 1, inspirationRead: true }); + const ev = (phase: "pre" | "post", tool: string, filePath = "", content = ""): NormalizedEvent => + ({ phase, tool, input: {}, sessionId: "s", filePath, content }); + const gate = (e: NormalizedEvent) => designGate({ agent_id: "ag" }, e, cache, "/proj", root, root); + return { cache, root, ev, gate }; +} + +test("designGate with an injected corpus: reads advance the state, jointure enforced at write", () => { + const { cache, root, ev, gate } = setup(); + // Corpus reads are recorded (distinct, existence-checked); phase waits for the screenshots. + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) { + expect(gate(ev("post", "Read", join(root, f)))).toBeNull(); + } + expect(loadDesignState(cache, "ag")!.corpusReads).toEqual(["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]); + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(1); + // A write of design-system.md is still blocked at phase 1. + expect(gate(ev("pre", "Write", "/proj/design-system.md", "x"))?.kind).toBe("block"); + // Two screenshots complete the conjunction (full mode: corpus + 2 shots). + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(2); +}); + +test("designGate on apply_patch add (event.files): add content IS the document — garbage blocked, plugin root protected", () => { + const { cache, root, ev, gate } = setup(); + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) gate(ev("post", "Read", join(root, f))); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + const patch = (filePath: string, content: string, op: "add" | "update" | "delete"): NormalizedEvent => + ({ phase: "pre", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath, content, op }] }); + const VALID = "## Design Reference\n- Corpus: a/## 1. T, b/## 2. U\n--a: oklch(0.62 0.19 250);"; + // A generic design-system.md via apply_patch add is blocked, exactly like a Write. + expect(gate(patch("/proj/design-system.md", "TOTAL GARBAGE", "add"))?.kind).toBe("block"); + // A patch into the plugin root is blocked, whatever the op. + expect(gate(patch(join(root, "fake", "tokens-fake.md"), "# fake", "add"))?.kind).toBe("block"); + // A valid ADD passes the PRE, and the POST validates the REAL file on disk. + expect(gate(patch("/proj/design-system.md", VALID, "add"))).toBeNull(); + const proj = mkdtempSync(join(tmpdir(), "fh-ap-proj-")); + writeFileSync(join(proj, "design-system.md"), VALID); + const post: NormalizedEvent = { phase: "post", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath: join(proj, "design-system.md"), content: VALID, op: "add" }] }; + gate(post); + const s = loadDesignState(cache, "ag")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("corpusRoot override does NOT decide pluginsRoot — the guard covers the plugin root independently", () => { + const { cache, root, ev } = setup(); + const plugins = mkdtempSync(join(tmpdir(), "fh-pg-")); + const gate = (e: NormalizedEvent) => designGate({ agent_id: "ag" }, e, cache, "/proj", root, plugins); + // A write under the PLUGIN root is blocked even though corpusRoot points elsewhere. + expect(gate(ev("pre", "Write", join(plugins, "design-expert", "skills", "x", "tokens-x.md"), "# x"))?.kind).toBe("block"); + // And the corpus root alone no longer smuggles plugin-root coverage. + expect(gate(ev("pre", "Write", join(root, "fake", "tokens-fake.md"), "# fake"))).toBeNull(); +}); + +test("cwd is WIRED into pluginsWriteGuard: a RELATIVE patch path under the plugin root is blocked", () => { + const cache = mkdtempSync(join(tmpdir(), "fh-cw-")); + const proj = mkdtempSync(join(tmpdir(), "fh-cw-p-")); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 1, inspirationRead: true }); + const ev: NormalizedEvent = { phase: "pre", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath: "fake/tokens-fake.md", content: "# fake", op: "add" }] }; + // pluginsRoot === proj here: the relative patch path resolves INTO it. + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "", proj)?.kind).toBe("block"); +}); + +test("designGate: a write citing READ references passes; citing an unread one is blocked and named", () => { + const { cache, root, ev, gate } = setup(); + for (const f of ["README.md", "a-recode/tokens-a.md", "b-recode/tokens-b.md"]) gate(ev("post", "Read", join(root, f))); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + gate(ev("post", "mcp__fuse-browser__browser_screenshot")); + const ds = (refs: string) => `## Design Reference\n- Corpus: ${refs}\n--a: oklch(0.62 0.19 250);`; + expect(gate(ev("pre", "Write", "/proj/design-system.md", ds("a/## 1. T, b/## 2. U")))).toBeNull(); + const forged = gate(ev("pre", "Write", "/proj/design-system.md", ds("a/## 1. T, xai/## 9. Z"))); + expect(forged?.kind).toBe("block"); + expect(forged?.reason).toContain("xai"); + // The corpus itself is write-protected for the design agent. + expect(gate(ev("pre", "Write", join(root, "fake", "tokens-fake.md"), "# fake"))?.kind).toBe("block"); +}); diff --git a/test/design-gates-corpus.test.ts b/test/design-gates-corpus.test.ts new file mode 100644 index 0000000..a0a1696 --- /dev/null +++ b/test/design-gates-corpus.test.ts @@ -0,0 +1,73 @@ +import { test, expect } from "bun:test"; +import { MIN_SCREENSHOTS, MIN_SCREENSHOTS_NO_CORPUS, initDesignState, type DesignState } from "../src/policy/design/state"; +import { browserNavigateGate } from "../src/policy/design/gates-pipeline"; +import { validateDesignSystem } from "../src/policy/design/gates"; + +const nav = (): DesignState => ({ ...initDesignState("a", "full", false), currentPhase: 1, inspirationRead: true }); + +test("screenshot quotas: corpus-present set lowered, corpus-absent set = today's behavior", () => { + expect(MIN_SCREENSHOTS).toEqual({ full: 2, page: 1, component: 1 }); + expect(MIN_SCREENSHOTS_NO_CORPUS).toEqual({ full: 4, page: 2, component: 1 }); +}); + +test("navigate gate: template platform HOSTS and platform template CATALOGS denied in phase 1", () => { + const denied = [ + "https://themeforest.net/item/x", + "https://themes.shopify.com/", + "https://marketplace.atlassian.com/", + "https://templatemonster.com/x", + "https://framer.com/templates", + "https://webflow.com/templates", + "https://wix.com/templates", + "https://vercel.com/Templates", + "HTTPS://VERCEL.COM/TEMPLATES", + ]; + for (const url of denied) expect(browserNavigateGate(nav(), url)?.kind).toBe("block"); +}); + +test("navigate gate: host normalization — port, case, trailing dot and userinfo do not escape", () => { + const escapes = [ + "https://themeforest.net:443/x", + "https://THEMEFOREST.NET/x", + "https://themeforest.net./x", + "https://user@themeforest.net/x", + ]; + for (const url of escapes) expect(browserNavigateGate(nav(), url)?.kind).toBe("block"); +}); + +test("navigate gate: real sector sites pass — published client domains and real marketplaces included", () => { + const allowed = [ + "https://boulangerie-dupont.fr", + "https://awwwards.com/websites/x", + "https://godly.website", + "https://bestwebsite.gallery", + "https://www.sothebys.com/en/marketplace", + "https://stripe.com/marketplace", + "https://framer.com", + "https://acme-wbs.framer.website/x", + "https://startify-template.webflow.io", + "https://vercel.com/marketplace", + ]; + for (const url of allowed) expect(browserNavigateGate(nav(), url)).toBeNull(); +}); + +test("navigate deny: doctrine marker present, old catalog pointer gone", () => { + const reason = browserNavigateGate(nav(), "https://themeforest.net/item/x")?.reason ?? ""; + expect(reason).toMatch(/corpus|refs-design/i); + expect(reason).not.toContain("design-inspiration-urls.md"); +}); + +test("validateDesignSystem: a corpus citation satisfies the source requirement only when the corpus is delivered", () => { + const ds = "## Design Reference\n- Corpus: umbrel/## 4. Colors, fora/## 2. Colors\n--a: oklch(0.62 0.19 250);"; + expect(validateDesignSystem(ds, true)).toEqual([]); +}); + +test("validateDesignSystem: a forged citation without URL is refused when the corpus is absent", () => { + const forged = "## Design Reference\n- Corpus: totally-made-up/whatever\n--a: oklch(0.62 0.19 250);"; + expect(validateDesignSystem(forged, false).join()).toContain("reference"); +}); + +test("validateDesignSystem: random prose without URL or corpus citation still fails", () => { + const ds = "## Design Reference\nsome prose without any source\n--a: oklch(0.62 0.19 250);"; + expect(validateDesignSystem(ds).join()).toContain("reference"); +}); diff --git a/test/design-post-validity.test.ts b/test/design-post-validity.test.ts new file mode 100644 index 0000000..234381d --- /dev/null +++ b/test/design-post-validity.test.ts @@ -0,0 +1,68 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import { recordPost } from "../src/runtime/design-helpers"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +/** + * POST validity transitions (C1/C3): the POST must not revoke what the PRE + * allowed. Degrade only on problems an Edit INTRODUCED; never on a benign + * legacy edit, never on an unreadable file. + */ +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-pv-")); +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const LEGACY = "## Design Reference\n--a: #3366ff;\n--font: \"Fraunces\";"; + +function setup(content: string): { cache: string; f: string } { + const cache = tmp(); + const f = join(tmp(), "design-system.md"); + writeFileSync(f, content); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 3, designSystemValid: true }); + return { cache, f }; +} +const editEv = (f: string, oldString: string, newString: string): NormalizedEvent => + ({ phase: "post", tool: "Edit", input: { old_string: oldString, new_string: newString }, sessionId: "s", filePath: f, oldString, content: newString }); + +test("C1: a benign Edit on a legacy invalid file does NOT revoke validity (PRE allowed it)", () => { + const { cache, f } = setup(LEGACY.replace("#3366ff", "#445566")); // on-disk = post-edit content + recordPost(editEv(f, "--a: #3366ff;", "--a: #445566;"), cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("C1: an Edit that INTRODUCES a defect revokes validity (phase 3 blocked downstream)", () => { + const { cache, f } = setup(URL_DS.replace("oklch(0.62 0.19 250)", "#3366ff")); + recordPost(editEv(f, "--a: oklch(0.62 0.19 250);", "--a: #3366ff;"), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.designSystemValid).toBe(false); +}); + +test("POST nominal: reverse reconstruction treats $ in old_string as DATA (a $& there must not mask the introduced problem)", () => { + // Original: valid, with a harmless "$&" in a comment. The edit swaps the font + // for a forbidden one. Literal reverse-rebuild: original clean → introduced + // [forbidden font] → revoke. String-replacement would expand $& to the match + // (the Inter line), making the ORIGINAL look invalid too → no revoke (mutation). + const { cache, f } = setup(`${URL_DS}\n--font-x: "Inter"; /* $& */`); + recordPost(editEv(f, `--font-x: "Fraunces"; /* $& */`, `--font-x: "Inter"; /* $& */`), cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.designSystemValid).toBe(false); +}); + +test("POST replace_all: same literal rule on every occurrence", () => { + const { cache, f } = setup(`${URL_DS}\n--font-x: "Inter"; /* $& */\n--font-x: "Inter"; /* $& */`); + const ev: NormalizedEvent = { phase: "post", tool: "Edit", input: { old_string: `--font-x: "Fraunces"; /* $& */`, new_string: `--font-x: "Inter"; /* $& */`, replace_all: true }, sessionId: "s", filePath: f, oldString: `--font-x: "Fraunces"; /* $& */`, content: `--font-x: "Inter"; /* $& */` }; + recordPost(ev, cache, loadDesignState(cache, "a")!, "", false); + expect(loadDesignState(cache, "a")!.designSystemValid).toBe(false); +}); + +test("C3: an unreadable design-system.md leaves the state untouched (unreadable ≠ invalid)", () => { + const cache = tmp(); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 3, designSystemValid: true }); + const gone = join(tmp(), "design-system.md"); // never written + const ev: NormalizedEvent = { phase: "post", tool: "Write", input: {}, sessionId: "s", filePath: gone, content: "x" }; + recordPost(ev, cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); diff --git a/test/design-state-corpus.test.ts b/test/design-state-corpus.test.ts new file mode 100644 index 0000000..6b58509 --- /dev/null +++ b/test/design-state-corpus.test.ts @@ -0,0 +1,79 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import { recordPost } from "../src/runtime/design-helpers"; +import { designPassNotice } from "../src/policy/design/gates"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-rc-")); + +test("retrocompat: a persisted state without corpusReads loads with an empty list", () => { + const cache = tmp(); + const legacy = { + agentId: "a", mode: "page", currentPhase: 1, phasesCompleted: [], inspirationRead: true, + scrolledSinceNav: false, screenshotsCount: 0, designSystemExists: false, designSystemValid: false, geminiCalls: 0, + }; + writeFileSync(join(cache, ".design-state-a.json"), JSON.stringify(legacy)); + expect(loadDesignState(cache, "a")?.corpusReads).toEqual([]); +}); + +test("initDesignState initializes corpusReads explicitly", () => { + expect(initDesignState("a", "full", false).corpusReads).toEqual([]); +}); + +test("recordPost: a corpus Read counts only a file that exists under the corpus root", () => { + const cache = tmp(); + const plugins = tmp(); + const root = join(plugins, "design-expert", "skills", "design-web", "references", "refs-design"); + mkdirSync(join(root, "umbrel-recode"), { recursive: true }); + writeFileSync(join(root, "umbrel-recode", "tokens-umbrel.md"), "# tokens"); + saveDesignState(cache, { ...initDesignState("a", "component", false), currentPhase: 1 }); + const ev = (tool: string, p: string): NormalizedEvent => ({ phase: "post", tool, input: {}, sessionId: "s", filePath: p }); + recordPost(ev("Read", join(root, "umbrel-recode", "tokens-umbrel.md")), cache, loadDesignState(cache, "a")!, root, true); + recordPost(ev("Read", join(root, "ghost-recode", "tokens-ghost.md")), cache, loadDesignState(cache, "a")!, root, true); + let s = loadDesignState(cache, "a")!; + expect(s.corpusReads).toEqual(["umbrel-recode/tokens-umbrel.md"]); + expect(s.currentPhase).toBe(1); // component: the corpus read alone is not enough — one screenshot is always required + recordPost(ev("mcp__fuse-browser__browser_screenshot", ""), cache, s, root, true); + s = loadDesignState(cache, "a")!; + expect(s.currentPhase).toBe(2); +}); + +test("recordPost: a garbage design-system.md write is NOT validated — phase 3 stays closed", () => { + const cache = tmp(); + const f = join(tmp(), "design-system.md"); + writeFileSync(f, "TOTAL GARBAGE, zero design content"); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 2 }); + const ev = (): NormalizedEvent => ({ phase: "post", tool: "Write", input: {}, sessionId: "s", filePath: f }); + recordPost(ev(), cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(false); + expect(s.currentPhase).toBe(2); +}); + +test("recordPost: a valid design-system.md write IS validated and opens phase 3", () => { + const cache = tmp(); + const f = join(tmp(), "design-system.md"); + writeFileSync(f, "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"); + saveDesignState(cache, { ...initDesignState("a", "page", true), currentPhase: 2 }); + const ev = (): NormalizedEvent => ({ phase: "post", tool: "Write", input: {}, sessionId: "s", filePath: f }); + recordPost(ev(), cache, loadDesignState(cache, "a")!, "", false); + const s = loadDesignState(cache, "a")!; + expect(s.designSystemValid).toBe(true); + expect(s.currentPhase).toBe(3); +}); + +test("pass notice: a missing corpus surfaces a user-visible warning (fail-open is not silent)", () => { + const cache = tmp(); + setActiveDesignAgent(cache, "a"); + saveDesignState(cache, initDesignState("a", "full", false)); + const p = designPassNotice( + { agentId: "a", tool: "mcp__fuse-browser__browser_navigate", filePath: "", content: "", url: "https://x.fr", phase: "pre", corpusMissing: true }, + cache, + ); + expect(p?.kind).toBe("inform"); + expect(p?.userMessage).toContain("corpus"); +}); From f5515b6eac17650348a9700669b31db309ea9d1d Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Wed, 29 Jul 2026 10:13:55 +0200 Subject: [PATCH 3/3] chore: release 0.1.84 Bump PATCH and record the design corpus doctrine in the CHANGELOG (feat 2107330): per-runtime corpus resolution, revised quotas, apply_patch in the design pipeline, pluginsWriteGuard on relative paths, substitution bypass closed. --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 767110c..9c536cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.84] - 2026-07-29 + +### Added + +- **Local design corpus as the source of taste** (`src/policy/design/corpus.ts`, `corpus-resolve.ts`, `design-system-rules.ts`, `template-urls.ts`, `src/runtime/design-content-gate.ts`, `design-files-gate.ts`) — the `design-expert` plugin produced generic, interchangeable pages because its inspiration phase browsed ~100 Framer/Webflow templates. The harness now executes the corpus doctrine instead: corpus resolution per runtime (Claude marketplace tree vs Codex `plugins/cache////`, semver-selected, no `process.cwd()` fallback), corpus reads recorded as state progression, and a citation↔read join tolerant of both naming conventions and both layouts. The template-platform allowlist is inverted into a frozen denylist — reviving it would resurrect the doctrine this replaces. + +### Changed + +- **Design quotas** (`src/policy/design/state.ts`, `transitions.ts`, `gates-pipeline.ts`) — at least one screenshot in every mode, always; with the corpus present, `{ full: 2, page: 1, component: 1 }` plus the corpus reads. Corpus absent leaves the requirement waived (pre-doctrine quotas), so the branch stays dormant until `refs-design/` ships to the marketplace. +- **`apply_patch` brought into the design pipeline** (`src/runtime/design.ts`, `design-helpers.ts`, `handle-pre.ts`, `handle-post.ts`) — Codex's patch primitive now passes the PRE path/phase/quota gates; `op:"add"` is validated as a `Write`, `delete` is skipped, and `cwd` is wired so the POST re-reads the real file rather than the hunk. The POST is promote-only: a clean file promotes to phase 3, a dirty one changes nothing. + +### Fixed + +- **`pluginsWriteGuard` bypass on relative paths** (`src/runtime/pre-allow.ts`) — the guard anchored on absolute paths only; `corpusRoot` and `pluginsRoot` are now resolved independently, so a corpus override no longer decides where plugin writes are allowed. +- **Substitution bypass in the design content gate** (`src/runtime/design-files-gate.ts`) — `String.replace` treats `$&`, `` $` ``, `$'` and `$$` as substitution patterns; literal replacement now goes through `replace(from, () => to)` / `split().join()`, closing all four quadrants. + ## [0.1.83] - 2026-07-24 ### Fixed diff --git a/package.json b/package.json index 2229cc0..011c9f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.83", + "version": "0.1.84", "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.", "type": "module", "module": "src/index.ts",