Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.86] - 2026-07-29

### Fixed

- **Design pipeline dropped screenshot and corpus-read counts it should have credited** (`src/runtime/design-helpers.ts`, `src/policy/design/screenshot-tools.ts`, `corpus-resolve.ts`, `corpus-fs.ts`, `corpus-resolve-cache.ts`, `gates.ts`, `allowed-write.ts`, `corpus.ts`, `src/policy/detect-framework.ts`) — a real `design-expert` run had captured 3 screenshots and read 5 corpus files, yet the pipeline reported `screenshotsCount:0, corpusReads:[], currentPhase:1`, so the phase-2 gate never opened. Root cause: corpus resolution only recognized the `marketplaces/<mkt>/plugins/design-expert` tree, not the `cache/<mkt>/<plugin>/<version>/` tree Claude Code also serves (where the plugin is named `fuse-design`) — now detected structurally by corpus suffix, never a hardcoded name, with marketplace priority and `maxSemver` selection. Alongside: `browser_shots_batch`/`browser_site_shots` now credit the screenshot quota (previously only `browser_screenshot` did); the write gate now allows `motion*.js` by basename (all eleven corpus references ship one); reference `design-system.md` sheets now credit as a new `"sheet"` `CorpusKind` (without counting toward the `tokens-*` threshold that the `full` quota requires); `.css` files are classified `tailwind` only when their content matches known Tailwind markers, not by extension alone.

### Changed

- **`full` corpus quota raised to README + 3 tokens** (was 2) (`src/policy/design/corpus.ts`) — owner-arbitrated; `page` and `component` quotas unchanged.

## [0.1.85] - 2026-07-29

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fusengine/harness",
"version": "0.1.85",
"version": "0.1.86",
"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",
Expand Down
28 changes: 28 additions & 0 deletions src/policy/design/allowed-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @module allowed-write
* The design-agent write allowlist, split out of `gates.ts` to keep that file
* within the SOLID size budget (SRP).
* @packageDocumentation
*/
import { basename } from "node:path";

/** Extensions the design agent may always write: .html/.css/.md/.json. */
export const ALLOWED_WRITE: RegExp = /\.(html|css|md|json)$/;

/**
* C4: `motion*.js` ONLY, matched on the BASENAME — every one of the 11
* corpus references ships its animation as a separate `motion.js` /
* `motion-nav.js` / `motion-scroll.js` file, so banning every `.js` forced
* the agent to inline scripts the corpus explicitly teaches out-of-band.
* Deliberately NOT `\.js$` in general (that would open arbitrary app code,
* the exact thing this gate exists to block) — after the `motion` prefix,
* either nothing (`motion.js`) or `.`/`-` then more (`motion-nav.js`) must
* lead into `.js`; `app.js` and `motionless-app.js` — a bare `^motion`
* prefix would wrongly pass this — do not match.
*/
const MOTION_JS: RegExp = /^motion([.-].*)?\.js$/;

/** True when `filePath` is a write the design agent may make: .html/.css/.md/.json, or a `motion*.js` file. */
export function isAllowedWrite(filePath: string): boolean {
return ALLOWED_WRITE.test(filePath) || MOTION_JS.test(basename(filePath));
}
21 changes: 21 additions & 0 deletions src/policy/design/corpus-fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @module corpus-fs
* Shared fs primitives for corpus/plugins root resolution, split out of
* `corpus-resolve.ts` so `corpus-resolve-cache.ts` can reuse them without a
* circular import (both modules feed `corpus-resolve.ts`'s `probeClaude`).
* @packageDocumentation
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";

/** The refs-design/ path suffix common to every design-corpus-bearing plugin tree. */
export const CORPUS_SUFFIX: string = join("skills", "design-web", "references", "refs-design");

/** Immediate child dir names of `dir`, alphabetically sorted, or [] when unreadable. */
export function children(dir: string): string[] {
try {
return readdirSync(dir).sort();
} catch {
return [];
}
}
49 changes: 49 additions & 0 deletions src/policy/design/corpus-resolve-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @module corpus-resolve-cache
* Claude Code's per-plugin VERSIONED CACHE tree, split out of
* `corpus-resolve.ts` to keep that file within the SOLID size budget (SRP).
*
* Claude Code caches every installed marketplace plugin at
* `~/.claude/plugins/cache/<mkt>/<plugin>/<version>/` — a tree SEPARATE from
* `~/.claude/plugins/marketplaces/<mkt>/plugins/<plugin>/` (the marketplace's
* own checkout), one version dir per `plugin.json` version bump, orphaned
* versions pruned ~14 days after being superseded (docs: "Plugin caching and
* file resolution"). The design corpus can ship under EITHER tree, and under
* a DIFFERENT plugin id in each: a machine observed 2026-07-29 has the corpus
* at `cache/fusengine-plugins/fuse-design/2.2.3/...` while the marketplace
* checkout is `marketplaces/fusengine-plugins/plugins/design-expert/...`.
* Identification here is therefore STRUCTURAL, never by plugin name: a
* candidate `<mkt>/<plugin>/<version>/` dir qualifies iff it itself contains
* `CORPUS_SUFFIX` — matching a hardcoded name would silently miss a
* renamed/relocated corpus (the exact defect this module fixes).
* @packageDocumentation
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { maxSemver } from "../../util/semver";
import { CORPUS_SUFFIX, children } from "./corpus-fs";

/** Real (non-prerelease) semver dirs, same filter `probeCodex` uses. */
const SEMVER_RE = /^\d+\.\d+(\.\d+)?$/;

/**
* Claude: `<home>/.claude/plugins/cache/<mkt>/<plugin>/<highest STABLE semver
* containing refs-design>` ("" when no plugin under any marketplace cache
* ships the corpus). Marketplaces and plugin ids are both walked — neither is
* assumed — so a corpus shipped under any plugin name is found.
*/
export function probeClaudeCache(home: string): string {
const cacheRoot = join(home, ".claude", "plugins", "cache");
for (const mkt of children(cacheRoot)) {
const mktDir = join(cacheRoot, mkt);
for (const plugin of children(mktDir)) {
const pluginDir = join(mktDir, plugin);
const versions = children(pluginDir).filter(
(v) => SEMVER_RE.test(v) && existsSync(join(pluginDir, v, CORPUS_SUFFIX)),
);
const latest = maxSemver(versions);
if (latest) return join(pluginDir, latest);
}
}
return "";
}
28 changes: 14 additions & 14 deletions src/policy/design/corpus-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,17 @@
* 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 { existsSync } 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";
import { CORPUS_SUFFIX, children } from "./corpus-fs";
import { probeClaudeCache } from "./corpus-resolve-cache";

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: `<home>/.claude/plugins/marketplaces/<mkt>/plugins/design-expert` ("" when absent). */
function probeClaude(home: string): string {
/** Claude marketplace checkout: `<home>/.claude/plugins/marketplaces/<mkt>/plugins/design-expert` ("" when absent). */
function probeClaudeMarketplace(home: string): string {
const markets = join(home, ".claude", "plugins", "marketplaces");
for (const m of children(markets)) {
const de = join(markets, m, "plugins", "design-expert");
Expand All @@ -34,6 +25,15 @@ function probeClaude(home: string): string {
return "";
}

/**
* Claude: the marketplace checkout WINS when present (a single unambiguous
* live git checkout); the versioned plugin CACHE tree is the fallback, since
* Claude Code keeps orphaned prior-version cache dirs ~14 days after a bump.
*/
function probeClaude(home: string): string {
return probeClaudeMarketplace(home) || probeClaudeCache(home);
}

/** Codex: `<CODEX_HOME|~/.codex>/plugins/cache/<mkt>/design-expert/<highest STABLE semver>` ("" when absent). */
function probeCodex(env: Record<string, string | undefined>, home: string): string {
const cache = join(env.CODEX_HOME ?? join(home, ".codex"), "plugins", "cache");
Expand Down
26 changes: 21 additions & 5 deletions src/policy/design/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ 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";
/**
* What a read under the corpus root is: the index, a tokens-* procedure
* file, or a per-reference `design-system.md` DIRECTION sheet (C6) — the
* fiche a reference ships alongside its `tokens-*.md`: register, tone,
* macrostructure, signature element (the PROCEDURES live in tokens-*;
* design-system.md is what you read to CHOOSE a reference in the first
* place). "sheet" is a new variant, not folded into "index" — the two are
* read for opposite reasons (corpus-wide index vs one reference's direction).
*/
export type CorpusKind = "index" | "tokens" | "sheet";

const TOKENS_RE = /^tokens-.+\.md$/;
const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim;
Expand All @@ -21,15 +29,23 @@ const CORPUS_LINE_RE = /^[-*]\s*Corpus:\s*(.+)$/gim;
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";
if (!filePath.startsWith(`${corpusRoot}/`)) return null;
if (TOKENS_RE.test(basename(filePath))) return "tokens";
if (basename(filePath) === "design-system.md") return "sheet";
return null;
}

/** Per-mode corpus-read threshold (component >= 1 file, page >= 2 files, full = index + 2 tokens). */
/**
* Per-mode corpus-read threshold (component >= 1 file, page >= 2 files,
* full = index + 3 tokens — C5: raised from 2, since 2 references still let
* an agent pattern-match by tonal affinity without ever opening the one
* reference that actually answered the brief). `"sheet"` reads (C6,
* `design-system.md`) never count toward this — only `TOKENS_RE` matches do.
*/
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;
return reads.includes("README.md") && reads.filter((r) => TOKENS_RE.test(basename(r))).length >= 3;
}

/** True when the content carries a `- Corpus: ref/section` citation line (form only). */
Expand Down
4 changes: 2 additions & 2 deletions src/policy/design/gates-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function designSystemWriteGate(filePath: string, state: DesignState, corp
"RECOVERY: 1) Read identity templates from skills/design-system/ " +
"2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) with the Read tool " +
"4) Screenshot real sector sites with mcp__fuse-browser__browser_screenshot on a LIVE session " +
"(note: browser_shots_batch/browser_site_shots do NOT advance the phase) " +
"(browser_shots_batch/browser_site_shots also count, 1 credit per call) " +
"5) Then write design-system.md",
);
}
Expand All @@ -25,7 +25,7 @@ export function designSystemWriteGate(filePath: string, state: DesignState, corp
`BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. ` +
`RECOVERY: 1) Read the refs-design corpus (README.md + tokens-*.md) if not done ` +
`2) Take ${needed - state.screenshotsCount} more screenshots of REAL sector sites with ` +
"mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots do NOT count) " +
"mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots also count, 1 credit per call) " +
"3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md",
);
}
Expand Down
9 changes: 5 additions & 4 deletions src/policy/design/gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { loadDesignState, type DesignState } from "./state";
import { activeDesignAgent } from "./flag";
import { isUiWrite } from "./skill-gate";
import { SKILL_TRIGGERS } from "./skill-triggers";
import { ALLOWED_WRITE, isAllowedWrite } from "./allowed-write";

export { validateDesignSystem } from "./design-system-rules";
export { ALLOWED_WRITE, isAllowedWrite } from "./allowed-write";

export const ALLOWED_WRITE: RegExp = /\.(html|css|md|json)$/;
export const EXEMPT_DIRS: readonly string[] = ["node_modules/", "dist/", "build/", ".claude/"];
const NAV = "mcp__fuse-browser__browser_navigate";
const SHOT = "mcp__fuse-browser__browser_screenshot";
Expand All @@ -18,11 +19,11 @@ export const deny = (reason: string): Prompt => ({
actions: ["Follow the design pipeline phases (0→identity, 1→inspiration, 2→screenshots, 3→design-system, 4→generate) in order"],
});

/** Block the design agent from writing anything but .html/.css/.md/.json. */
/** Block the design agent from writing anything but .html/.css/.md/.json (+ motion*.js). */
export function htmlCssOnlyGate(filePath: string): Prompt | null {
if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || ALLOWED_WRITE.test(filePath)) return null;
if (EXEMPT_DIRS.some((d) => filePath.includes(d)) || isAllowedWrite(filePath)) return null;
return deny(
"BLOCKED: design-expert can only write .html, .css, .md, and .json files. " +
"BLOCKED: design-expert can only write .html, .css, .json, .md, and motion*.js files. " +
"Framework files (.tsx, .astro, .vue, .swift, .php) must be written by the domain expert " +
"(astro-expert, react-expert, etc.) AFTER design validation.",
);
Expand Down
24 changes: 24 additions & 0 deletions src/policy/design/screenshot-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* @module screenshot-tools
* Screenshot-credit tool set (C1), split out of `runtime/design-helpers.ts` to
* keep that file within the SOLID size budget (SRP).
* @packageDocumentation
*/

/**
* Screenshot-credit tools: the single-shot capture PLUS the two fuse-browser
* batch capture primitives (`browser_shots_batch`, `browser_site_shots`) — a
* batch call that captures N urls/viewports must credit the quota too, or
* capturing efficiently would make the quota infranchissable. LIMITATION
* (deliberate, documented): this repo's cross-harness `NormalizedEvent` (see
* `runtime/normalize.ts`) never surfaces `tool_response` for any tool — by
* design, since its shape is undocumented for MCP tools and harness-specific
* for built-ins — so no per-call item count reaches this gate. A batch call
* therefore credits EXACTLY 1, same as a unit screenshot, never the N it may
* have captured.
*/
export const SHOT_TOOLS: ReadonlySet<string> = new Set([
"mcp__fuse-browser__browser_screenshot",
"mcp__fuse-browser__browser_shots_batch",
"mcp__fuse-browser__browser_site_shots",
]);
21 changes: 20 additions & 1 deletion src/policy/detect-framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ const NEXT_CONTENT = /use client|use server|NextRequest|NextResponse|from ['"]ne
const NEXT_ROUTE = /(page|layout|loading|error|route|middleware)\.(ts|tsx|js|jsx)$/;
/** TanStack Start markers (file routes, server fns, start/router imports). */
const TANSTACK_CONTENT = /createFileRoute|createServerFn|from ['"]@tanstack\/react-(start|router)/;
/**
* C7: Tailwind CSS-file signals, verified against official docs (v3 + v4) —
* `@tailwind` (v3 layer directive, removed in v4), `@apply` (both versions),
* `@config` (v3 native / v4 compat, JS config path), `@source` (v4 content
* globbing), `@theme` (v4 design-token block), `theme(` (the function, both
* versions' syntax). Deliberately NOT a bare utility-class-in-selector scan
* (e.g. `.flex{}`) — an ordinary hand-written class named `.flex` is exactly
* the false-positive shape this fix removes; only genuine Tailwind-specific
* at-rules/functions count.
*/
const TAILWIND_CSS_CONTENT = /@tailwind\b|@apply\b|@config\b|@source\b|@theme\b|\btheme\(/;

/** Derive the raw signal an extension + content carry (no filesystem access). */
function fileSignal(filePath: string, content: string): FileSignal {
Expand All @@ -27,7 +38,15 @@ function fileSignal(filePath: string, content: string): FileSignal {
if (/\.go$/.test(filePath)) return { definitive: "go" };
if (/\.rb$/.test(filePath)) return { definitive: "ruby" };
if (/\.rs$/.test(filePath)) return { definitive: "rust" };
if (/\.css$/.test(filePath) || /@tailwind|@apply/.test(content)) return { definitive: "tailwind" };
// C7: a .css file is "tailwind" only when its CONTENT shows it — bare
// extension used to classify EVERY .css as tailwind, forcing 6 unrelated
// skill reads on hand-written CSS. Empty content (file-size-scope.ts's
// caller) can never prove Tailwind usage, so it falls through to the
// JS-family checks below (all false for .css) and returns "generic" — the
// function's existing fail-open contract, not a special case. That caller
// never reaches here in practice: .css is already outside its
// FILE_SIZE_CODE_EXT scope, so this is inert there either way.
if (/\.css$/.test(filePath) && TAILWIND_CSS_CONTENT.test(content)) return { definitive: "tailwind" };
// JS branch: content signals ride MASKED content (a `// migrated from
// createServerFn pattern` comment must not flip nextjs → tanstack-start).
const masked = maskCommentsAndStrings(content, "c");
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/design-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import type { NormalizedEvent } from "./normalize";
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 { SHOT_TOOLS } from "../policy/design/screenshot-tools";
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";
const SCROLL = "mcp__fuse-browser__browser_scroll";
const GEMINI = "mcp__gemini-design__create_frontend";

Expand All @@ -42,7 +42,7 @@ 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, corpusRoot = "", corpusRequired = false, cwd = ""): void {
if (event.tool === SHOT) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired));
if (SHOT_TOOLS.has(event.tool)) 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 });
Expand Down
Loading