diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 48ac9a3f62..6aa5c78b41 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -3206,6 +3206,43 @@ describe("requirePlanApproval setting", () => { ); }); + /* + * FNXC:PlanApproval 2026-07-15-21:10: + * FN-8009 requires the as-approved comparison to cover both deterministic hygiene + * mutations. A legacy operator-approved plan may lack both its original request and + * the frontend checklist; these injections must not turn an unchanged plan into a + * new manual approval request. + */ + it("auto-approves a pre-hygiene plan after Original Description and Frontend UX injection", async () => { + const frontendPlan = "# Task: FN-FRONTEND-HYGIENE - Frontend plan\n\n## Mission\n\nUpdate the dashboard.\n\n## File Scope\n\n- `packages/dashboard/app/TaskCard.tsx`\n"; + const task = createTriageTask({ + id: "FN-FRONTEND-HYGIENE", + description: "The original dashboard request is absent from this planner output.", + status: "planning", + approvedPlanFingerprint: computePlanApprovalFingerprint(frontendPlan), + } as Partial); + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue(task), + } as Partial); + await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true }); + await writeFile(join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), frontendPlan); + const processor = new TriageProcessor(store, rootDir); + + await (processor as unknown as { + finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise; + }).finalizeApprovedTask( + task, + frontendPlan, + { requirePlanApproval: true, planApprovalMode: "require-all" } as Settings, + ); + + expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo"); + expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({ status: "awaiting-approval" })); + const persisted = readFileSync(join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8"); + expect(persisted).toContain("## Original Description"); + expect(persisted).toContain("## Frontend UX Criteria"); + }); + it("still re-asks approval for a CHANGED plan when the prior approval predates prompt hygiene", async () => { // The safety edge: legacy tolerance must not approve a plan the operator never saw. const legacyFingerprint = computePlanApprovalFingerprint(planText); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index e0ccc365f2..129f46eff3 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2951,6 +2951,14 @@ export class TriageProcessor { * already made their independent decisions, so it never weakens either of those gates * or auto-approve-all (which never reaches this branch at all). */ + /* + * FNXC:PlanApproval 2026-07-15-21:05: + * FN-7569 / FN-8009 — compare the normalized as-approved fingerprint after deterministic + * Original Description or Frontend UX hygiene. approve-plan fingerprints the on-disk + * PROMPT.md, while recovery can receive pre-injection text; the shared hasher removes only + * those generated sections so an unchanged plan does not re-park. A genuinely changed plan + * still produces a different fingerprint and requires approval. + */ const priorFingerprint = latestTransitionTask?.approvedPlanFingerprint ?? task.approvedPlanFingerprint; // FNXC:PlanApproval 2026-07-15-20:45: The shared hasher strips deterministic // Original Description / Frontend UX hygiene, so approve-plan's on-disk fingerprint and