FN-7099: add project plan approval mode

Add a project-wide plan approval mode that can override workflow approval gates.

- Add core plan approval resolution for workflow, auto-approve-all, and require-all modes.
- Apply the resolved approval mode in triage recovery and planning finalization.
- Expose the mode in settings UI with translations, docs, tests, and a changeset.

Files changed:
 .changeset/fn-7099-plan-approval-mode.md           |  7 +++
 docs/settings-reference.md                         |  3 +-
 docs/storage.md                                    |  5 +-
 packages/core/src/__tests__/plan-approval.test.ts  | 37 ++++++++++++++
 packages/core/src/index.ts                         |  2 +
 packages/core/src/plan-approval.ts                 | 21 ++++++++
 packages/core/src/settings-schema.ts               |  3 +-
 packages/core/src/types.ts                         |  5 ++
 .../dashboard/app/components/SettingsModal.tsx     |  1 +
 .../SettingsModal.scheduling-merge.test.tsx        | 17 +++++++
 .../__tests__/SettingsModal.test-harness.tsx       |  1 +
 .../components/settings/sections/MergeSection.tsx  | 19 +++++++
 .../MergeSection.legacy-automerge-cleanup.test.tsx | 23 ++++++++-
 packages/engine/src/__tests__/triage.test.ts       | 58 ++++++++++++++++++++++
 packages/engine/src/triage.ts                      | 10 +++-
 packages/i18n/locales/en/app.json                  |  5 ++
 packages/i18n/src/resources.d.ts                   |  5 ++
 17 files changed, 215 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7099
Fusion-Task-Lineage: 8b276000-1df3-41e4-a94d-724aac559954
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 00:19:15 -07:00
parent 9e2fb5d62c
commit 28cdd1c215
17 changed files with 215 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a per-project plan-approval mode to auto-approve or require approval for all tasks.
category: feature
dev: New project setting `planApprovalMode` ("workflow" | "auto-approve-all" | "require-all"); overrides the per-workflow `requirePlanApproval` via `resolvePlanApprovalRequired` at the triage gating sites.

View File

@@ -297,7 +297,7 @@ These groups moved out of project settings and into workflow settings (built-in
| Group | Keys (examples) | | Group | Keys (examples) |
|---|---| |---|---|
| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | | **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` |
| **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | | **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`; project override: `planApprovalMode` |
| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) | | **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) |
### Workflow-native triage policy settings ### Workflow-native triage policy settings
@@ -378,6 +378,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `ignoreHiddenOverlapPaths` | `boolean` | `true` | Exclude hidden dot paths from overlap serialization by default. A hidden path is any normalized project-relative path with a segment beginning with `.`, such as `.fusion/tasks/FN-1/PROMPT.md`, `.changeset/fix.md`, `.github/workflows/ci.yml`, `.env`, or `packages/.cache/out.js`. Set to `false` to restore legacy strict counting of dot paths. Explicit `overlapIgnorePaths` entries still apply in addition to this default filter, and still apply when hidden-path filtering is disabled. | | `ignoreHiddenOverlapPaths` | `boolean` | `true` | Exclude hidden dot paths from overlap serialization by default. A hidden path is any normalized project-relative path with a segment beginning with `.`, such as `.fusion/tasks/FN-1/PROMPT.md`, `.changeset/fix.md`, `.github/workflows/ci.yml`, `.env`, or `packages/.cache/out.js`. Set to `false` to restore legacy strict counting of dot paths. Explicit `overlapIgnorePaths` entries still apply in addition to this default filter, and still apply when hidden-path filtering is disabled. |
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | | `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every approved spec to todo without manual approval; `"require-all"` parks every approved spec at `status: "awaiting-approval"` regardless of workflow settings. |
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. | | `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. | | `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |

View File

@@ -222,9 +222,9 @@ Important execution nuance:
## 1) Summary ## 1) Summary
- **localStorage keys in runtime dashboard code:** **20** - **localStorage keys in runtime dashboard code:** **20**
- **Backend settings keys defined in `@fusion/core`:** **78** total - **Backend settings keys defined in `@fusion/core`:** **79** total
- **Global settings:** 17 (`GlobalSettings`) - **Global settings:** 17 (`GlobalSettings`)
- **Project settings:** 61 (`ProjectSettings`) - **Project settings:** 62 (`ProjectSettings`)
- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **47** (including migration-created tables) - **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **47** (including migration-created tables)
- **Issues identified:** **9** - **Issues identified:** **9**
- High: 2 - High: 2
@@ -320,6 +320,7 @@ API endpoints reviewed:
| `groupOverlappingFiles` | Project | `GET/PUT /api/settings` | Serialize overlapping file work | | `groupOverlappingFiles` | Project | `GET/PUT /api/settings` | Serialize overlapping file work |
| `overlapIgnorePaths` | Project | `GET/PUT /api/settings` | Project-relative file/directory paths ignored by overlap blocking | | `overlapIgnorePaths` | Project | `GET/PUT /api/settings` | Project-relative file/directory paths ignored by overlap blocking |
| `autoMerge` | Project | `GET/PUT /api/settings` | Enable auto merge | | `autoMerge` | Project | `GET/PUT /api/settings` | Enable auto merge |
| `planApprovalMode` | Project | `GET/PUT /api/settings` | Project-wide plan approval override: `workflow`, `auto-approve-all`, or `require-all` |
| `mergeStrategy` | Project | `GET/PUT /api/settings` | Direct vs PR merge strategy | | `mergeStrategy` | Project | `GET/PUT /api/settings` | Direct vs PR merge strategy |
| `worktreeInitCommand` | Project | `GET/PUT /api/settings` | Command run on worktree init | | `worktreeInitCommand` | Project | `GET/PUT /api/settings` | Command run on worktree init |
| `testCommand` | Project | `GET/PUT /api/settings` | Project test command | | `testCommand` | Project | `GET/PUT /api/settings` | Project test command |

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { resolvePlanApprovalRequired, type PlanApprovalMode } from "../plan-approval.js";
const workflowValues = [true, false, undefined] as const;
describe("resolvePlanApprovalRequired", () => {
it.each(workflowValues)("defers to requirePlanApproval when mode is workflow and workflow value is %s", (requirePlanApproval) => {
expect(resolvePlanApprovalRequired({ planApprovalMode: "workflow", requirePlanApproval })).toBe(Boolean(requirePlanApproval));
});
it.each(workflowValues)("defers to requirePlanApproval when mode is undefined and workflow value is %s", (requirePlanApproval) => {
expect(resolvePlanApprovalRequired({ requirePlanApproval })).toBe(Boolean(requirePlanApproval));
});
it.each(workflowValues)("auto-approve-all bypasses approval when workflow value is %s", (requirePlanApproval) => {
expect(resolvePlanApprovalRequired({ planApprovalMode: "auto-approve-all", requirePlanApproval })).toBe(false);
});
it.each(workflowValues)("require-all requires approval when workflow value is %s", (requirePlanApproval) => {
expect(resolvePlanApprovalRequired({ planApprovalMode: "require-all", requirePlanApproval })).toBe(true);
});
it("falls back to workflow behavior for unknown persisted modes", () => {
expect(
resolvePlanApprovalRequired({
planApprovalMode: "future-mode" as PlanApprovalMode,
requirePlanApproval: true,
}),
).toBe(true);
expect(
resolvePlanApprovalRequired({
planApprovalMode: "future-mode" as PlanApprovalMode,
requirePlanApproval: false,
}),
).toBe(false);
});
});

View File

@@ -17,6 +17,8 @@ export type {
} from "./branch-assignment.js"; } from "./branch-assignment.js";
export { customProviderRegistryKey } from "./custom-provider-key.js"; export { customProviderRegistryKey } from "./custom-provider-key.js";
export { redactSecrets } from "./redact-secrets.js"; export { redactSecrets } from "./redact-secrets.js";
export { resolvePlanApprovalRequired } from "./plan-approval.js";
export type { PlanApprovalMode } from "./plan-approval.js";
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js"; export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
export * from "./frontend-ux-policy.js"; export * from "./frontend-ux-policy.js";

View File

@@ -0,0 +1,21 @@
import type { ProjectSettings } from "./types.js";
export type PlanApprovalMode = NonNullable<ProjectSettings["planApprovalMode"]>;
/**
* FNXC:PlanApproval 2026-06-26-00:00:
* Per-project planApprovalMode controls the planning approval gate for every task in the project: require-all always parks approved specs for manual approval, auto-approve-all always bypasses the gate, and workflow/undefined preserves the workflow-resolved requirePlanApproval value.
*/
export function resolvePlanApprovalRequired(
settings: Pick<ProjectSettings, "planApprovalMode" | "requirePlanApproval">,
): boolean {
switch (settings.planApprovalMode) {
case "require-all":
return true;
case "auto-approve-all":
return false;
case "workflow":
default:
return Boolean(settings.requirePlanApproval);
}
}

View File

@@ -283,6 +283,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
ignoreHiddenOverlapPaths: true, ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [], overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
planApprovalMode: "workflow",
// U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` — // U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` —
// with this on but auto-merge off, review threads are resolved but the PR is not merged. // with this on but auto-merge off, review threads are resolved but the PR is not merged.
autoResolveReviewComments: true, autoResolveReviewComments: true,
@@ -382,7 +383,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
// planOnlyScopeLeakEnforcement, workflowRevisionForkOnScopeMismatch, // planOnlyScopeLeakEnforcement, workflowRevisionForkOnScopeMismatch,
// strictScopeEnforcement, buildRetryCount, verificationFixRetries, // strictScopeEnforcement, buildRetryCount, verificationFixRetries,
// requirePlanApproval) MOVED to workflow settings (U4) — see // requirePlanApproval) MOVED to workflow settings (U4) — see
// MOVED_SETTINGS_KEYS. `buildTimeoutMs` and `verificationCommandTimeoutMs` // MOVED_SETTINGS_KEYS. `planApprovalMode`, `buildTimeoutMs`, and `verificationCommandTimeoutMs`
// are NOT moved and stay plain project settings. Keep verificationCommandTimeoutMs // are NOT moved and stay plain project settings. Keep verificationCommandTimeoutMs
// undefined so fn_run_verification preserves legacy per-scope defaults until a // undefined so fn_run_verification preserves legacy per-scope defaults until a
// project opts into a single default budget. // project opts into a single default budget.

View File

@@ -3911,6 +3911,11 @@ export interface ProjectSettings {
* remain in triage with status "awaiting-approval" until a user approves * remain in triage with status "awaiting-approval" until a user approves
* or rejects the plan. Default: false. */ * or rejects the plan. Default: false. */
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
/**
* FNXC:PlanApproval 2026-06-26-00:00:
* Per-project setting to control plan approval for every task: workflow defers to the per-workflow requirePlanApproval setting, auto-approve-all bypasses approval for all tasks, and require-all parks every approved spec for manual approval.
*/
planApprovalMode?: "workflow" | "auto-approve-all" | "require-all";
/** Controls task-worker execution mode. /** Controls task-worker execution mode.
* - true (default): spawn short-lived `executor-FN-XXXX` ephemeral workers per task * - true (default): spawn short-lived `executor-FN-XXXX` ephemeral workers per task
* - false: disable ephemeral workers; scheduler auto-assigns dispatchable tasks * - false: disable ephemeral workers; scheduler auto-assigns dispatchable tasks

View File

@@ -725,6 +725,7 @@ export function SettingsModal({
ignoreHiddenOverlapPaths: true, ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [], overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
planApprovalMode: "workflow",
mergeStrategy: "direct", mergeStrategy: "direct",
maxAutoMergeRetries: 3, maxAutoMergeRetries: 3,
mergeIntegrationWorktree: "reuse-task-worktree", mergeIntegrationWorktree: "reuse-task-worktree",

View File

@@ -1257,6 +1257,23 @@ describe("SettingsModal", () => {
expect(payload.pushAfterMerge).toBe(true); expect(payload.pushAfterMerge).toBe(true);
expect(payload.pushRemote).toBe("upstream main"); expect(payload.pushRemote).toBe("upstream main");
}); });
it("round-trips plan approval mode through project settings save", async () => {
const select = screen.getByLabelText("Plan approval mode");
expect(select).toHaveValue("workflow");
await settingsModalUser.selectOptions(select, "require-all");
await settingsModalUser.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.planApprovalMode).toBe("require-all");
expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1);
expect(mockUpdateGlobalSettings.mock.calls[0][0]).not.toHaveProperty("planApprovalMode");
});
}); });
describe("verificationFixRetries (moved to workflow settings)", () => { describe("verificationFixRetries (moved to workflow settings)", () => {

View File

@@ -95,6 +95,7 @@ export const defaultSettings = {
ignoreHiddenOverlapPaths: true, ignoreHiddenOverlapPaths: true,
overlapIgnorePaths: [], overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
planApprovalMode: "workflow",
mergeStrategy: "direct", mergeStrategy: "direct",
merger: { mode: "deterministic" }, merger: { mode: "deterministic" },
directMergeCommitStrategy: "auto", directMergeCommitStrategy: "auto",

View File

@@ -92,6 +92,25 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
<small>{t("settings.merge.whenEnabledTasksThatPassReviewAreAutomatically", "When enabled, tasks that pass review are automatically merged into the main branch")}</small> <small>{t("settings.merge.whenEnabledTasksThatPassReviewAreAutomatically", "When enabled, tasks that pass review are automatically merged into the main branch")}</small>
</details> </details>
</div> </div>
<div className="form-group">
{/*
FNXC:PlanApproval 2026-06-26-00:00:
Operators need one project-scoped control beside review/merge policy to force all tasks to auto-approve or require manual plan approval without editing each workflow.
*/}
<label htmlFor="planApprovalMode">{t("settings.merge.planApprovalMode", "Plan approval mode")}</label>
<select id="planApprovalMode" className="select" value={form.planApprovalMode ?? "workflow"} onChange={(e) => {
const nextMode = e.target.value as Settings["planApprovalMode"];
setForm((f) => ({ ...f, planApprovalMode: nextMode }));
}} data-testid="plan-approval-mode-select">
<option value="workflow">{t("settings.merge.planApprovalModeWorkflow", "Use workflow setting")}</option>
<option value="auto-approve-all">{t("settings.merge.planApprovalModeAutoApproveAll", "Auto-approve all tasks")}</option>
<option value="require-all">{t("settings.merge.planApprovalModeRequireAll", "Require approval for all tasks")}</option>
</select>
<details className="settings-option-details">
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.planApprovalModeHelp", "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.")}</small>
</details>
</div>
<div className="form-group"> <div className="form-group">
<label htmlFor="maxAutoMergeRetries">{t("settings.merge.autoMergeConflictRetries", "Auto-merge conflict retries")}</label> <label htmlFor="maxAutoMergeRetries">{t("settings.merge.autoMergeConflictRetries", "Auto-merge conflict retries")}</label>
{/* {/*

View File

@@ -15,14 +15,16 @@ function jsonResponse(body: unknown, ok = true): Response {
} as Response; } as Response;
} }
function makeProps(): MergeSectionProps { function makeProps(overrides: Partial<MergeSectionProps["form"]> = {}): MergeSectionProps {
return { return {
scopeBanner: null, scopeBanner: null,
form: { form: {
autoMerge: true, autoMerge: true,
planApprovalMode: "workflow",
merger: { mode: "ai" }, merger: { mode: "ai" },
testMode: false, testMode: false,
mergeStrategy: "direct", mergeStrategy: "direct",
...overrides,
} as MergeSectionProps["form"], } as MergeSectionProps["form"],
setForm: vi.fn(), setForm: vi.fn(),
integrationBranchOptions: ["main"], integrationBranchOptions: ["main"],
@@ -38,6 +40,25 @@ describe("MergeSection legacy auto-merge stamp cleanup", () => {
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
}); });
it("renders and updates the project-scoped plan approval mode select", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ candidates: [], count: 0 })));
const props = makeProps({ planApprovalMode: "require-all" });
render(<MergeSection {...props} />);
const select = screen.getByTestId("plan-approval-mode-select") as HTMLSelectElement;
expect(select.value).toBe("require-all");
expect(screen.getByRole("option", { name: "Use workflow setting" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Auto-approve all tasks" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Require approval for all tasks" })).toBeInTheDocument();
fireEvent.change(select, { target: { value: "auto-approve-all" } });
expect(props.setForm).toHaveBeenCalledWith(expect.any(Function));
const updater = vi.mocked(props.setForm).mock.calls.at(-1)?.[0] as (state: MergeSectionProps["form"]) => MergeSectionProps["form"];
expect(updater(props.form).planApprovalMode).toBe("auto-approve-all");
});
it("renders the store-provided candidate list without client-side filtering", async () => { it("renders the store-provided candidate list without client-side filtering", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
candidates: [ candidates: [

View File

@@ -1749,6 +1749,64 @@ describe("requirePlanApproval setting", () => {
const settings = await store.getSettings(); const settings = await store.getSettings();
expect(settings.requirePlanApproval).toBeUndefined(); expect(settings.requirePlanApproval).toBeUndefined();
}); });
async function finalizeWithSettings(settings: Settings) {
const task = createTriageTask({
id: "FN-APPROVAL",
title: "Approval mode task",
status: "planning",
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
});
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
"# Task: FN-APPROVAL - Approval mode task\n\n**Size:** M\n\n## Review Level: 1\n\n## File Scope\n\n- packages/engine/src/triage.ts\n",
settings,
);
return store;
}
it("auto-approve-all moves to todo even when workflow requires plan approval", async () => {
const store = await finalizeWithSettings({
requirePlanApproval: true,
planApprovalMode: "auto-approve-all",
} as Settings);
expect(store.moveTask).toHaveBeenCalledWith("FN-APPROVAL", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-APPROVAL", expect.objectContaining({ status: "awaiting-approval" }));
});
it("require-all parks for manual approval even when workflow disables plan approval", async () => {
const store = await finalizeWithSettings({
requirePlanApproval: false,
planApprovalMode: "require-all",
} as Settings);
expect(store.updateTask).toHaveBeenCalledWith("FN-APPROVAL", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.moveTask).not.toHaveBeenCalled();
});
it.each([
{ mode: "workflow" as const, requirePlanApproval: true, expectedApproval: true },
{ mode: undefined, requirePlanApproval: false, expectedApproval: false },
])("defers to workflow requirePlanApproval when mode is $mode", async ({ mode, requirePlanApproval, expectedApproval }) => {
const store = await finalizeWithSettings({
requirePlanApproval,
planApprovalMode: mode,
} as Settings);
if (expectedApproval) {
expect(store.updateTask).toHaveBeenCalledWith("FN-APPROVAL", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.moveTask).not.toHaveBeenCalled();
} else {
expect(store.moveTask).toHaveBeenCalledWith("FN-APPROVAL", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-APPROVAL", expect.objectContaining({ status: "awaiting-approval" }));
}
});
}); });
describe("approved triage recovery", () => { describe("approved triage recovery", () => {

View File

@@ -23,6 +23,7 @@ import {
sortTasksByPriorityThenAgeAndId, sortTasksByPriorityThenAgeAndId,
compareTaskIdNumeric, compareTaskIdNumeric,
resolveAgentMemoryInclusionMode, resolveAgentMemoryInclusionMode,
resolvePlanApprovalRequired,
extractIntentSignature, extractIntentSignature,
findNearDuplicates, findNearDuplicates,
isNearDuplicateCanonicalInactive, isNearDuplicateCanonicalInactive,
@@ -491,6 +492,7 @@ export class TriageProcessor {
// Merge per-task effective workflow settings (U3, KTD-3) so requirePlanApproval // Merge per-task effective workflow settings (U3, KTD-3) so requirePlanApproval
// resolves from the workflow. Behavior-inert when nothing is customized. // resolves from the workflow. Behavior-inert when nothing is customized.
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings()); const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
const approvalRequired = resolvePlanApprovalRequired(settings);
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"); const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
const written = await readFile(promptPath, "utf-8").catch((err: unknown) => { const written = await readFile(promptPath, "utf-8").catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -504,7 +506,7 @@ export class TriageProcessor {
} }
await this.finalizeApprovedTask(task, written, settings, { await this.finalizeApprovedTask(task, written, settings, {
recoveryLogAction: settings.requirePlanApproval recoveryLogAction: approvalRequired
? "Auto-recovered approved specification stuck in planning — awaiting manual approval" ? "Auto-recovered approved specification stuck in planning — awaiting manual approval"
: "Auto-recovered approved specification stuck in planning — moved to todo", : "Auto-recovered approved specification stuck in planning — moved to todo",
}); });
@@ -2420,7 +2422,11 @@ export class TriageProcessor {
return; return;
} }
if (settings.requirePlanApproval) { /*
FNXC:PlanApproval 2026-06-26-00:00:
Project planApprovalMode has precedence over the workflow-resolved requirePlanApproval value so operators can force auto-approval or manual approval for every task in this project.
*/
if (resolvePlanApprovalRequired(settings)) {
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" }; const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) { if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
approvalUpdates.title = promptDeclaredTitle; approvalUpdates.title = promptDeclaredTitle;

View File

@@ -6067,6 +6067,11 @@
"personalAccessToken": "Personal access token", "personalAccessToken": "Personal access token",
"pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ", "pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ",
"positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.", "positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
"planApprovalMode": "Plan approval mode",
"planApprovalModeAutoApproveAll": "Auto-approve all tasks",
"planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.",
"planApprovalModeRequireAll": "Require approval for all tasks",
"planApprovalModeWorkflow": "Use workflow setting",
"postMergeAuditMode": "Post-merge audit mode", "postMergeAuditMode": "Post-merge audit mode",
"pushRemote": "Push Remote", "pushRemote": "Push Remote",
"pushToRemoteAfterMerge": " Push to remote after merge ", "pushToRemoteAfterMerge": " Push to remote after merge ",

View File

@@ -6071,6 +6071,11 @@ export default interface Resources {
"personalAccessToken": "Personal access token", "personalAccessToken": "Personal access token",
"pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ", "pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ",
"positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.", "positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
"planApprovalMode": "Plan approval mode",
"planApprovalModeAutoApproveAll": "Auto-approve all tasks",
"planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.",
"planApprovalModeRequireAll": "Require approval for all tasks",
"planApprovalModeWorkflow": "Use workflow setting",
"postMergeAuditMode": "Post-merge audit mode", "postMergeAuditMode": "Post-merge audit mode",
"pushRemote": "Push Remote", "pushRemote": "Push Remote",
"pushToRemoteAfterMerge": " Push to remote after merge ", "pushToRemoteAfterMerge": " Push to remote after merge ",