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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.106] - 2026-10-11

### Fixed

- **motion: field references in a multi-line awk program (`$3`, `$NF`, `${2}`) are no longer read as currency amounts** (`src/policy/motion/program-bodies.ts`, `sensitive.ts`) — only inside a single-quoted awk/gawk/mawk body in shell command position (not in a quote, `#` comment or heredoc), closed, containing `{`, and only in code position; awk string literals, awk comments and bullet lines keep their amounts; `sh -c`/`bash -c` and double-quoted awk bodies are scanned as before. Known limit: a prose line with an amount placed inside an otherwise-awk body (not valid awk) is not flagged. Tests: `motion-sensitive`, `motion-sensitive-diff`.
- **design: `design-system.md` is resolved from the written file's directory upward** (`src/runtime/design.ts`, `design-helpers.ts`, `design-files-gate.ts`) — real paths, confined to the real cwd, empty/unreadable candidates skipped, then cwd; per-file verdict for Write/Edit/apply_patch; session mode/quota unchanged (still decided from cwd). Tests: `design-system-locate`, `design-init-parity`.
- **design: deny texts list exactly what is missing** (`src/policy/design/gates-pipeline.ts`) — corpus reads counted at the credited root (with its path) and screenshots taken/required (N/M); any fuse-browser capture counts. Blocking conditions and thresholds unchanged. Tests: `design-refusal-text`, `design-html-css-pipeline-gate`.
- **motion: the PreToolUse budget note counts completed renders (incremented by PostToolUse)** — verified, not a bug; test added: `motion-budget-note`.

## [0.1.105] - 2026-10-11

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion docs/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ unreadable list supplies no terms. Currency amounts (`$`, `€`, `£`,
Write/Edit/MultiEdit content under `sourceDir` is scanned in full (no size
cut-off), not the on-disk file or arbitrary Bash-generated content. Shell
positional parameters (`$1`, `${2}`, `awk '{print $1}'`) are not treated as
currency amounts. Detected values deny the write
currency amounts, nor are field references (`$3`, `$NF`, `${2}`) in the code
of a closed, single-quoted multi-line awk/gawk/mawk program in shell command
position; awk string literals and comments are still scanned. Detected values deny the write
and are masked in the report: one-/two-character values are fully masked;
longer ones retain only their first character.
Source: [`sensitive.ts`](../src/policy/motion/sensitive.ts).
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.105",
"version": "0.1.106",
"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
48 changes: 36 additions & 12 deletions src/policy/design/gates-pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,56 @@
import type { Prompt } from "../../prompt/types";
import { type DesignState, quotaFor } from "./state";
import { type DesignMode, type DesignState, quotaFor } from "./state";
import { corpusReady } from "./corpus";
import { PLUGINS_DIR } from "../file-size";
import { deny } from "./gates";
import { isTemplateUrl } from "./template-urls";

const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;

/** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */
export function designSystemWriteGate(filePath: string, state: DesignState, corpusRequired = false): Prompt | null {
/** Per-mode corpus-read requirement spelled out for refusal texts (MIRRORS `corpusReady` — display only, thresholds unchanged). */
const CORPUS_NEED: Readonly<Record<DesignMode, string>> = {
component: "1 corpus file",
page: "2 corpus files",
full: "README.md + 3 tokens-*.md files",
};

/**
* The unmet phase-2 requirements, one entry per gap: corpus reads credited at
* the CREDITED root (with its path) when the corpus is delivered, and the
* screenshot count as taken/required (N/M). A satisfied requirement is simply
* absent from the list, so the refusal names exactly what is missing.
*/
function missingRequirements(state: DesignState, corpusRequired: boolean, corpusRoot: string): string[] {
const missing: string[] = [];
if (corpusRequired && !corpusReady(state.corpusReads, state.mode)) {
missing.push(`corpus reads: ${state.corpusReads.length} credited under '${corpusRoot}' (required: ${CORPUS_NEED[state.mode]})`);
}
const needed = quotaFor(state.mode, corpusRequired);
if (state.screenshotsCount < needed) {
missing.push(`screenshots: ${state.screenshotsCount}/${needed} (any fuse-browser capture counts — browser_screenshot, browser_shots_batch, browser_site_shots)`);
}
return missing;
}

/** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. The refusal lists exactly what is missing. */
export function designSystemWriteGate(filePath: string, state: DesignState, corpusRequired = false, corpusRoot = ""): Prompt | null {
if (!filePath.endsWith("design-system.md")) return null;
if (state.currentPhase < 2) {
const missing = missingRequirements(state, corpusRequired, corpusRoot);
return deny(
`BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. ` +
`BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. Missing: ${missing.join("; ") || "phase-2 requirements"}. ` +
"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 " +
"(browser_shots_batch/browser_site_shots also count, 1 credit per call) " +
"4) Capture real sector sites with any fuse-browser screenshot tool (browser_screenshot, browser_shots_batch, browser_site_shots — 1 credit per call) " +
"5) Then write design-system.md",
);
}
const needed = quotaFor(state.mode, corpusRequired);
if (state.screenshotsCount < needed) {
return deny(
`BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. ` +
`RECOVERY: 1) Read the refs-design corpus (README.md + tokens-*.md) if not done ` +
`2) Take ${needed - state.screenshotsCount} more screenshots of REAL sector sites with ` +
"mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots also count, 1 credit per call) " +
"3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md",
`RECOVERY: take ${needed - state.screenshotsCount} more capture(s) with any fuse-browser screenshot tool ` +
"(browser_screenshot, browser_shots_batch, browser_site_shots — 1 credit per call), then write design-system.md",
);
}
return null;
Expand All @@ -50,8 +74,8 @@ export function htmlCssPipelineGate(filePath: string, state: DesignState, design
return deny(
`BLOCKED: cannot write '${filePath}' before the design-system pipeline is complete (${gaps.join(", ")}). ` +
"RECOVERY: 1) Read identity templates from skills/design-system/ 2) Read design-inspiration.md " +
"3) Read the refs-design corpus with the Read tool 4) Screenshot real sector sites with " +
"mcp__fuse-browser__browser_screenshot on a LIVE session 5) Write a valid design-system.md " +
"3) Read the refs-design corpus with the Read tool 4) Capture real sector sites with any fuse-browser " +
"screenshot tool (browser_screenshot, browser_shots_batch, browser_site_shots) 5) Write a valid design-system.md " +
"6) Then write .html/.css",
);
}
Expand Down
133 changes: 133 additions & 0 deletions src/policy/motion/program-bodies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @module motion/program-bodies
* Locates single-quoted awk/gawk/mawk program bodies that a quote
* opens AND closes within the same source, so the sensitive-data scan can
* strip shell field references (`$3`, `${2}`) strictly inside them. Quoting
* follows POSIX shell rules: a single quote is never escaped; a double quote
* is escaped by an odd backslash run. An unclosed quote yields no span, so
* whatever follows stays fully scanned.
*/

/** Shell command whose quoted operand is a program: awk/gawk/mawk (group 1), or sh/bash with `-c` (possibly clustered, e.g. `-ec`); the command word must be followed by whitespace (`awk-free` is prose). */
const PROGRAM_LINE_RE = /(?:^|[;|&]\s*)\s*(g?awk|mawk)(?=\s)/g;

/** Heredoc opener (`<<EOF`, `<<-'EOF'`, `<< "EOF"`); group 1 is the terminator word. */
const HEREDOC_RE = /<<-?\s*['"]?(\w+)['"]?/y;

/**
* True when offset `end` of `text`, scanned from `from`, is NOT plain shell
* command position: inside a `'`/`"` string (possibly opened on an earlier
* line), a `#` comment (POSIX §2.3) or a heredoc body (§2.7.4). Any doubt
* counts as text: the program word is ignored and nothing is stripped.
*/
function notCommandPosition(text: string, from: number, end: number): boolean {
let quote: string | null = null;
for (let k = from; k < end; k++) {
const ch = text[k];
if (quote !== null) {
if (ch === quote && (quote === "'" || text[k - 1] !== "\\")) quote = null;
continue;
}
if (ch === "'" || ch === '"') { quote = ch; continue; }
if (ch === "#" && (k === 0 || /\s/.test(text[k - 1] ?? ""))) {
const nl = text.indexOf("\n", k);
if (nl === -1 || nl >= end) return true;
k = nl;
continue;
}
if (ch !== "<" || text[k + 1] !== "<") continue;
HEREDOC_RE.lastIndex = k;
const h = HEREDOC_RE.exec(text);
const bodyStart = text.indexOf("\n", k);
if (h === null || bodyStart === -1 || bodyStart >= end) continue;
const t = new RegExp(`^\\t*${h[1]}$`, "m").exec(text.slice(bodyStart + 1));
const bodyEnd = t === null ? text.length : bodyStart + 1 + t.index + t[0].length;
if (bodyEnd >= end) return true;
k = bodyEnd - 1;
}
return quote !== null;
}

/** Field/positional references (`$3`, `${12}`) — or an awk `"…"` string literal, matched first so a `$5` inside a string (`print "Only $5"`) stays text for the amount scan. A following digit/period/comma keeps `$40`, `$1,299.00` intact. */
const FIELD_REF_RE = /"(?:[^"\\\n]|\\.)*"|#[^\n]*|(?<=^|\n)[ \t]*[-*•+][ \t][^\n]*|(?<=(?:[({[,=<>!+\-*/%&|;:?~]|\b(?:print|printf|return|in)\b)\s*)(?:\$\{\d+\}|\$\d(?![\d.,]))/g;

/** Half-open `[start, end)` offsets of a quoted program body. */
export type BodySpan = readonly [number, number];

/** Offset of the closing quote for the quote at `open`, or -1 when the quote never closes. The first matching quote ends the body (POSIX §2.2.2); if a letter or digit follows it, it is prose (`c'est`) or a concatenated word (`'{print}'file`), so the body is treated as unclosed and nothing is stripped — a body never extends past that quote. */
function closeQuoteAt(text: string, open: number): number {
const quote = text[open];
for (let p = open + 1; p < text.length; p++) {
if (text[p] !== quote) continue;
if (quote !== "'") {
let backslashes = 0;
for (let j = p - 1; j >= 0 && text[j] === "\\"; j--) backslashes++;
if (backslashes % 2 === 1) continue;
}
const next = text[p + 1];
if (next !== undefined && /[A-Za-z0-9]/.test(next)) return -1;
return p;
}
return -1;
}

/**
* Closed program-body spans of `text`, in order. Only a quote opened after a
* program command on the same line qualifies; a quote that never closes ends
* the scan without a span (nothing after it can be proven outside a string,
* so nothing there is stripped). Spans already closed stay valid.
*/
export function programBodySpans(text: string): BodySpan[] {
const spans: BodySpan[] = [];
let lineStart = 0;
let from = 0;
let scanFrom = 0;
while (lineStart < text.length) {
const nl = text.indexOf("\n", lineStart);
const lineEnd = nl === -1 ? text.length : nl;
const line = text.slice(lineStart, lineEnd);
PROGRAM_LINE_RE.lastIndex = from;
const m = PROGRAM_LINE_RE.exec(line);
if (m === null) {
lineStart = lineEnd + 1;
from = 0;
continue;
}
let openRel = -1;
for (let k = m.index + m[0].length; k < line.length; k++) {
const ch = line[k];
// A pipe/list operator before the quote means the quote belongs to another command (`| awk | 'note`).
if (ch === "|" || ch === ";" || ch === "&") break;
// Only a single-quoted program qualifies: in `"…"` the shell expands `$5`, so it is never a plain awk field.
if (ch === "'") openRel = k;
if (ch === "'" || ch === '"') break;
}
if (openRel === -1 || notCommandPosition(text, scanFrom, lineStart + m.index)) {
lineStart = lineEnd + 1;
from = 0;
continue;
}
const open = lineStart + openRel;
const close = closeQuoteAt(text, open);
if (close === -1) return spans;
// An awk program has a `{ action }`; a body without one is prose (`; awk 'est … ' etc`), never stripped.
if (text.slice(open + 1, close).includes("{")) spans.push([open + 1, close]);
scanFrom = close + 1;
lineStart = text.lastIndexOf("\n", close - 1) + 1;
from = close - lineStart + 1;
}
return spans;
}

/** `text` with field references replaced by `x` inside each closed program body — a letter, never the empty string, so stripped neighbors can never fuse into a currency match (`€${2}5` becomes `€x5`, not `€5`). */
export function stripProgramBodies(text: string): string {
const spans = programBodySpans(text);
if (spans.length === 0) return text;
let out = "";
let last = 0;
for (const [start, end] of spans) {
out += text.slice(last, start) + text.slice(start, end).replace(FIELD_REF_RE, (ref) => (ref.startsWith("$") ? "x" : ref));
last = end;
}
return out + text.slice(last);
}
10 changes: 9 additions & 1 deletion src/policy/motion/sensitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { stripProgramBodies } from "./program-bodies";
import { motionHome } from "./store";

/** Currency amount: `12 €`, `1 299,50 EUR`, `$1,299.00`, `£ 40`. Horizontal spaces only (incl. NBSP / narrow NBSP). */
Expand All @@ -17,8 +18,15 @@ const CURRENCY_RE = /\b\d[\d \t  .,]{0,24}[ \t  ]?(?:[$€£]|(?:USD|EUR|G
/** Replacement references and positional parameters on recognizable shell-command lines are not prices. */
const REPLACE_LINE_RE = /(?:\breplace(?:All)?\s*\(|\.sub\()/;
const SHELL_LINE_RE = /(?:^|[;|&]\s*)\s*(?:echo|printf|awk|gawk|mawk|sh|bash|zsh|exec|export)\b/;

/**
* Line rules unchanged since dd8a2e1; the only extension is {@link stripProgramBodies},
* which strips field references in code position inside single-quoted awk/gawk/mawk bodies
* opened AND closed by a quote in the same source. Template literals never qualify:
* `` `${a}$1` `` has the same shape as the real price `` `${cur}$5` ``, so both stay blocked.
*/
const stripBackrefs = (text: string): string =>
text.split("\n").map((line) => {
stripProgramBodies(text).split("\n").map((line) => {
// A standalone positional token is code, not a price declaration; a literal integer dollar price is ambiguous.
if (/^\s*\$(?:\d|\{\d+\})\s*$/.test(line)) return "";
if (REPLACE_LINE_RE.test(line)) return line.replace(/\$\d(?![\d.,])/g, "");
Expand Down
13 changes: 7 additions & 6 deletions src/runtime/design-files-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ export function substituteLiteral(s: string, from: string, to: string, all: bool

/**
* Gate every file of a multi-file write primitive; the first violation blocks
* the envelope. `designSystemFileExists` is caller-computed (`findDesignSystem`
* the envelope. `designSystemExistsFor` is caller-injected (`findDesignSystem*`
* lives in `design-helpers.ts`, which itself imports `substituteLiteral` from
* THIS module — importing it back here would cycle) so both write paths
* (Write/Edit in `design.ts`, apply_patch here) consume the SAME disk read.
* THIS module — importing it back here would cycle) and is evaluated PER FILE:
* each patched file gets the same design-system.md verdict it would get
* written alone by Write/Edit — a hit for one file never leaks to the others.
*/
export function designFilesGate(
files: readonly NormalizedFile[],
Expand All @@ -85,7 +86,7 @@ export function designFilesGate(
corpusRoot: string,
corpusRequired: boolean,
cwd: string,
designSystemFileExists: boolean,
designSystemExistsFor: (filePath: string) => boolean,
): Prompt | null {
for (const f of files) {
const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath);
Expand All @@ -95,7 +96,7 @@ export function designFilesGate(
if (htmlCssHit) return htmlCssHit;
// Parity with design.ts's htmlCssPipelineGate call: SAME verdict for the
// SAME file+state on both write paths (Write/Edit vs apply_patch).
const pipelineHit = htmlCssPipelineGate(f.filePath, state, designSystemFileExists);
const pipelineHit = htmlCssPipelineGate(f.filePath, state, designSystemExistsFor(f.filePath));
if (pipelineHit) return pipelineHit;
// apply_patch parity for the Gemini precondition (design.ts:72-74, D2 gap):
// same condition, same Prompt shape as the Write/Edit branch — opt-in via
Expand All @@ -104,7 +105,7 @@ export function designFilesGate(
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"] };
}
if (!f.filePath.endsWith("design-system.md")) continue;
const gate = designSystemWriteGate(f.filePath, state, corpusRequired)
const gate = designSystemWriteGate(f.filePath, state, corpusRequired, corpusRoot)
?? (f.op === "add"
? designSystemContentGate({ filePath: f.filePath, tool: "Write", content: f.content, oldString: undefined, replaceAll: false, state, corpusRoot, corpusRequired })
: null);
Expand Down
Loading
Loading