FN-6237: apply frontend UX criteria as workflow policy

Move frontend UX criteria out of triage self-instructions and into deterministic workflow policy.

- Add a shared frontend UX policy helper that matches frontend file scopes and injects the byte-equivalent checklist idempotently.
- Apply the policy during triage finalization and persist the updated PROMPT.md when applicable.
- Replace the legacy prompt-based injection instructions with a pointer to the deterministic policy.
- Cover path matching, insertion behavior, and checklist parity with focused core tests.
- Add a minor changeset for the published Fusion package.

Files changed:
 .changeset/fn-6237-frontend-ux-policy.md           |   5 +
 .../core/src/__tests__/frontend-ux-policy.test.ts  | 119 +++++++++++++++++
 packages/core/src/agent-prompts.ts                 |  29 +---
 packages/core/src/frontend-ux-policy.ts            | 148 +++++++++++++++++++++
 packages/core/src/index.ts                         |   1 +
 packages/engine/src/triage.ts                      |  19 ++-
 6 files changed, 291 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-6237

Fusion-Task-Lineage: 7ac95064-9ec6-4760-8609-20c35075aca1
This commit is contained in:
gsxdsm
2026-06-12 09:24:02 -07:00
parent d2ea10445e
commit f7f2cae9d0
6 changed files with 291 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
FRONTEND_UX_CRITERIA_SECTION,
applyFrontendUxCriteria,
matchesFrontendUxPath,
} from "../frontend-ux-policy.js";
import { WORKFLOW_STEP_TEMPLATES } from "../types.js";
const EXACT_FRONTEND_UX_CRITERIA = `## Frontend UX Criteria
- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.)
- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`)
- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors
- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles
- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability
- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three
- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling
- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page
`;
function promptWithFileScope(paths: string[]): string {
return `# Task: FN-0000 - Example
## Mission
Implement the requested change without disturbing surrounding behavior.
## File Scope
${paths.map((path) => `- \`${path}\``).join("\n")}
## Acceptance Criteria
- Works as expected
`;
}
function extractInsertedCriteria(prompt: string): string {
const start = prompt.indexOf("## Frontend UX Criteria");
expect(start).toBeGreaterThanOrEqual(0);
const rest = prompt.slice(start);
expect(rest.slice(FRONTEND_UX_CRITERIA_SECTION.length)).toMatch(/^\n## File Scope/);
return rest.slice(0, FRONTEND_UX_CRITERIA_SECTION.length);
}
describe("frontend UX policy", () => {
it("preserves the byte-exact criteria section fixture", () => {
expect(FRONTEND_UX_CRITERIA_SECTION).toBe(EXACT_FRONTEND_UX_CRITERIA);
expect(FRONTEND_UX_CRITERIA_SECTION.endsWith("\n")).toBe(true);
expect(FRONTEND_UX_CRITERIA_SECTION.endsWith("\n\n")).toBe(false);
});
it.each([
["dashboard package", "packages/dashboard/src/server.ts"],
["app components", "packages/plugin/app/components/Button.tsx"],
["app hooks", "packages/plugin/app/hooks/useThing.ts"],
["app css", "packages/plugin/app/layout.css"],
["app tsx", "packages/plugin/app/routes.tsx"],
])("injects exactly once after Mission for %s scope", (_label, path) => {
const original = promptWithFileScope([path]);
const injected = applyFrontendUxCriteria(original);
expect(injected).toContain(FRONTEND_UX_CRITERIA_SECTION);
expect(extractInsertedCriteria(injected)).toBe(FRONTEND_UX_CRITERIA_SECTION);
expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1);
expect(injected).toMatch(/## Mission\n\nImplement the requested change without disturbing surrounding behavior\.\n\n## Frontend UX Criteria\n\n- \[ \] \*\*Design tokens only\*\*/);
expect(applyFrontendUxCriteria(injected)).toBe(injected);
});
it.each([
["backend", "packages/engine/src/triage.ts"],
["config json", "package.json"],
["eslint config", "eslint.config.mjs"],
["docs", "docs/dashboard-guide.md"],
["dashboard src css excluded from rule 4 but matched by dashboard package", "packages/dashboard/src/styles.css", true],
["component css covered by component rule, not rule 4", "packages/plugin/app/components/Button.css", true],
])("matches the expected frontend classification for %s", (_label, path, expected = false) => {
expect(matchesFrontendUxPath(path)).toBe(expected);
});
it("does not inject for backend-only, config-only, or docs-only file scopes", () => {
for (const path of ["packages/engine/src/triage.ts", "package.json", "eslint.config.mjs", "docs/testing.md"]) {
const original = promptWithFileScope([path]);
expect(applyFrontendUxCriteria(original)).toBe(original);
}
});
it("uses caller-provided file scope paths without reparsing prompt markdown", () => {
const promptWithoutFileScope = `# Task: FN-0000 - Example
## Mission
Implement dashboard UI.
## Acceptance Criteria
- Works as expected
`;
const injected = applyFrontendUxCriteria(promptWithoutFileScope, ["packages/dashboard/app/routes.tsx"]);
expect(injected).toContain(FRONTEND_UX_CRITERIA_SECTION);
expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1);
});
it("keeps checklist tokens aligned with the frontend UX design persona", () => {
const persona = WORKFLOW_STEP_TEMPLATES.find((template) => template.id === "frontend-ux-design");
expect(persona?.name).toBe("Frontend UX Design");
expect(persona?.prompt).toContain("design tokens");
expect(persona?.prompt).toContain("Component Reuse");
expect(persona?.prompt).toContain("Responsive Behavior");
expect(persona?.prompt).toContain("Visual Hierarchy");
expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Design tokens only");
expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Component reuse");
expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Responsive scaffolding");
expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Visual hierarchy preserved");
});
});

View File

@@ -508,34 +508,7 @@ If the task targets a different task ID (audit, forensic walk, historical reconc
- \`.fusion/\` is gitignored, so a fresh worktree from \`main\` does **not** include \`.fusion/tasks/{TARGET_ID}/\` or \`.fusion/fusion.db\`. The running worktree's own \`.fusion/\` (if present) is scratch/session state for the running task only, not source of truth.
- Prefer \`fn_task_get\` / \`fn_task_list\` when the target task ID is known; fall back to project-root filesystem reads only when tools cannot provide needed evidence.
## Frontend UX Criteria Injection
<!-- UX criteria mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts — keep them aligned. -->
If the derived **File Scope** touches any of the following paths:
- \`packages/dashboard/**\`
- \`packages/*/app/components/**\`
- \`packages/*/app/hooks/**\`
- Any \`*.css\` or \`*.tsx\` file inside a dashboard-like package
…then **PREPEND** a \`## Frontend UX Criteria\` section to the generated PROMPT.md, placed immediately after the \`## Mission\` section.
Use this exact checklist (keep it verbatim — do not expand or reorder):
\`\`\`markdown
## Frontend UX Criteria
- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.)
- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`)
- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors
- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles
- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability
- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three
- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling
- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page
\`\`\`
Only inject this section when the task genuinely touches frontend UI. Omit it for backend-only, config-only, or documentation-only tasks.`;;
<!-- Frontend UX criteria are applied deterministically by packages/core/src/frontend-ux-policy.ts and mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts. -->`;;
// FN-6235: single source for the built-in reviewer policy; the engine REVIEWER_SYSTEM_PROMPT duplicate was removed.
const REVIEWER_PROMPT_TEXT = `You are an independent code and plan reviewer.

View File

@@ -0,0 +1,148 @@
/**
* Frontend UX criteria policy for generated task specifications.
*
* The checklist mirrors the `frontend-ux-design` reviewer persona in
* packages/core/src/types.ts. Keep this policy byte-equivalent with the legacy
* triage prompt checklist and idempotent when applied to generated PROMPT.md
* content.
*
* Rule 4 deterministically expands the legacy "Any CSS or TSX file inside a
* dashboard-like package" rule: match files under a package `app/` directory
* ending in `.css` or `.tsx`, excluding the `components/` and `hooks/` subtrees
* already covered by rules 2 and 3.
*/
export const FRONTEND_UX_PATH_GLOBS = [
"packages/dashboard/**",
"packages/*/app/components/**",
"packages/*/app/hooks/**",
"packages/*/app/**/*.css",
"packages/*/app/**/*.tsx",
] as const;
/**
* Byte-exact Frontend UX Criteria section copied from the legacy triage prompt.
* The section intentionally ends with exactly one trailing newline.
*/
export const FRONTEND_UX_CRITERIA_SECTION = `## Frontend UX Criteria
- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.)
- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`)
- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors
- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles
- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability
- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three
- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling
- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page
`;
const FRONTEND_UX_HEADING = "## Frontend UX Criteria";
/**
* Pure deterministic injection helper. When `fileScopePaths` is omitted, the
* helper parses `## File Scope` from the supplied prompt markdown using the same
* section shape as the engine triage parser.
*/
export function applyFrontendUxCriteria(promptMarkdown: string, fileScopePaths?: string[]): string {
if (promptMarkdown.includes(FRONTEND_UX_HEADING)) {
return promptMarkdown;
}
const paths = fileScopePaths ?? parseFileScopeFromPromptMarkdown(promptMarkdown);
if (!paths.some((path) => matchesFrontendUxPath(path))) {
return promptMarkdown;
}
return insertFrontendUxCriteriaAfterMission(promptMarkdown);
}
export function matchesFrontendUxPath(path: string): boolean {
const normalized = normalizePath(path);
if (!normalized) return false;
if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[0])) return true;
if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[1])) return true;
if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[2])) return true;
const isAppCssOrTsx = matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[3])
|| matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[4]);
if (!isAppCssOrTsx) return false;
return !matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[1])
&& !matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[2]);
}
function parseFileScopeFromPromptMarkdown(text: string): string[] {
const match = text.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=^##\s+|$)/m);
if (!match) return [];
const entries: string[] = [];
for (const line of match[1].split("\n")) {
const cleaned = normalizeFileScopeLine(line);
if (cleaned) entries.push(cleaned);
}
return entries;
}
function normalizeFileScopeLine(line: string): string {
let cleaned = line.trim();
if (!cleaned || cleaned.startsWith("<!--")) return "";
cleaned = cleaned.replace(/^[-*]\s+/, "").trim();
cleaned = cleaned.replace(/^`([^`]+)`.*$/, "$1").trim();
cleaned = cleaned.replace(/`/g, "").trim();
return cleaned;
}
function insertFrontendUxCriteriaAfterMission(content: string): string {
const missionMatch = content.match(/^##\s+Mission\s*$/m);
if (!missionMatch || missionMatch.index === undefined) {
return content;
}
const headerEnd = missionMatch.index + missionMatch[0].length;
const rest = content.slice(headerEnd);
const nextHeading = rest.search(/\n##\s/);
const sectionEndAbsolute = nextHeading === -1 ? content.length : headerEnd + nextHeading;
const before = content.slice(0, sectionEndAbsolute).trimEnd();
const after = content.slice(sectionEndAbsolute);
return `${before}\n\n${FRONTEND_UX_CRITERIA_SECTION}${after}`;
}
/** Check if a path matches a glob pattern (simple glob support: * and **). */
function matchGlob(path: string, pattern: string): boolean {
const regexPattern = globToRegexPattern(normalizePath(pattern));
return new RegExp(`^${regexPattern}$`).test(normalizePath(path));
}
function globToRegexPattern(pattern: string): string {
let out = "";
for (let i = 0; i < pattern.length; i += 1) {
const char = pattern[i];
const next = pattern[i + 1];
const afterNext = pattern[i + 2];
if (char === "*" && next === "*" && afterNext === "/") {
out += "(?:.*/)?";
i += 2;
continue;
}
if (char === "*" && next === "*") {
out += ".*";
i += 1;
continue;
}
if (char === "*") {
out += "[^/]*";
continue;
}
out += escapeRegex(char);
}
return out;
}
function escapeRegex(char: string): string {
return /[\\^$+?.()|[\]{}]/.test(char) ? `\\${char}` : char;
}
function normalizePath(path: string): string {
return path.trim().replace(/\\/g, "/").replace(/^\.\//, "");
}

View File

@@ -17,6 +17,7 @@ export type {
} from "./branch-assignment.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { redactSecrets } from "./redact-secrets.js";
export * from "./frontend-ux-policy.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export {

View File

@@ -22,6 +22,7 @@ import {
resolveAgentMemoryInclusionMode,
extractIntentSignature,
findNearDuplicates,
applyFrontendUxCriteria,
type NearDuplicateCandidate,
} from "@fusion/core";
import type { ImageContent } from "@earendil-works/pi-ai";
@@ -64,7 +65,7 @@ import { withRateLimitRetry } from "./rate-limit-retry.js";
import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js";
import type { StuckTaskDetector } from "./stuck-task-detector.js";
import { exec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import {
@@ -2150,7 +2151,7 @@ export class TriageProcessor {
private async finalizeApprovedTask(
task: Task,
written: string,
writtenInput: string,
settings: Settings,
options: {
isReplan?: boolean;
@@ -2158,6 +2159,7 @@ export class TriageProcessor {
recoveryLogAction?: string;
} = {},
): Promise<void> {
let written = writtenInput;
const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i);
if (dupMatch) {
@@ -2257,6 +2259,19 @@ export class TriageProcessor {
} catch {
// Fail open on persisted PROMPT.md parsing and keep using the in-memory parse.
}
const promptWithFrontendUxCriteria = applyFrontendUxCriteria(written, parsedFileScope);
if (promptWithFrontendUxCriteria !== written) {
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
try {
await writeFile(promptPath, promptWithFrontendUxCriteria, "utf-8");
written = promptWithFrontendUxCriteria;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
planLog.warn(`${task.id}: failed to write Frontend UX Criteria to PROMPT.md (${message})`);
}
}
let taskIntentSignature: ReturnType<typeof extractIntentSignature> = {
routePaths: [],
filePaths: [],