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:
5
.changeset/fn-6237-frontend-ux-policy.md
Normal file
5
.changeset/fn-6237-frontend-ux-policy.md
Normal 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.
|
||||
119
packages/core/src/__tests__/frontend-ux-policy.test.ts
Normal file
119
packages/core/src/__tests__/frontend-ux-policy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
148
packages/core/src/frontend-ux-policy.ts
Normal file
148
packages/core/src/frontend-ux-policy.ts
Normal 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(/^\.\//, "");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user