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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<mp>/<plugin>/<version>/`, 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
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -155,7 +155,7 @@
},
"devDependencies": {
"@types/bun": "latest",
"tsdown": "^0.22.13",
"tsdown": "^0.22.14",
"typedoc": "^0.28.20",
"typescript": "^7.0.2"
},
Expand Down
99 changes: 99 additions & 0 deletions src/policy/design/corpus-resolve.ts
Original file line number Diff line number Diff line change
@@ -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: `<home>/.claude/plugins/marketplaces/<mkt>/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: `<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");
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<string, string | undefined> = 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<string, string | undefined> = 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"],
};
}
76 changes: 76 additions & 0 deletions src/policy/design/corpus.ts
Original file line number Diff line number Diff line change
@@ -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-<ref>.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);
});
}
37 changes: 37 additions & 0 deletions src/policy/design/design-system-rules.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading