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:
7
.changeset/fn-7099-plan-approval-mode.md
Normal file
7
.changeset/fn-7099-plan-approval-mode.md
Normal 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.
|
||||
@@ -297,7 +297,7 @@ These groups moved out of project settings and into workflow settings (built-in
|
||||
| Group | Keys (examples) |
|
||||
|---|---|
|
||||
| **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) |
|
||||
|
||||
### 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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). |
|
||||
|
||||
@@ -222,9 +222,9 @@ Important execution nuance:
|
||||
## 1) Summary
|
||||
|
||||
- **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`)
|
||||
- **Project settings:** 61 (`ProjectSettings`)
|
||||
- **Project settings:** 62 (`ProjectSettings`)
|
||||
- **SQLite tables in project DB schema (`packages/core/src/db.ts`):** **47** (including migration-created tables)
|
||||
- **Issues identified:** **9**
|
||||
- High: 2
|
||||
@@ -320,6 +320,7 @@ API endpoints reviewed:
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `worktreeInitCommand` | Project | `GET/PUT /api/settings` | Command run on worktree init |
|
||||
| `testCommand` | Project | `GET/PUT /api/settings` | Project test command |
|
||||
|
||||
37
packages/core/src/__tests__/plan-approval.test.ts
Normal file
37
packages/core/src/__tests__/plan-approval.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ export type {
|
||||
} from "./branch-assignment.js";
|
||||
export { customProviderRegistryKey } from "./custom-provider-key.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 type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
|
||||
export * from "./frontend-ux-policy.js";
|
||||
|
||||
21
packages/core/src/plan-approval.ts
Normal file
21
packages/core/src/plan-approval.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
ignoreHiddenOverlapPaths: true,
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
planApprovalMode: "workflow",
|
||||
// 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.
|
||||
autoResolveReviewComments: true,
|
||||
@@ -382,7 +383,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
// planOnlyScopeLeakEnforcement, workflowRevisionForkOnScopeMismatch,
|
||||
// strictScopeEnforcement, buildRetryCount, verificationFixRetries,
|
||||
// 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
|
||||
// undefined so fn_run_verification preserves legacy per-scope defaults until a
|
||||
// project opts into a single default budget.
|
||||
|
||||
@@ -3911,6 +3911,11 @@ export interface ProjectSettings {
|
||||
* remain in triage with status "awaiting-approval" until a user approves
|
||||
* or rejects the plan. Default: false. */
|
||||
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.
|
||||
* - true (default): spawn short-lived `executor-FN-XXXX` ephemeral workers per task
|
||||
* - false: disable ephemeral workers; scheduler auto-assigns dispatchable tasks
|
||||
|
||||
@@ -725,6 +725,7 @@ export function SettingsModal({
|
||||
ignoreHiddenOverlapPaths: true,
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
planApprovalMode: "workflow",
|
||||
mergeStrategy: "direct",
|
||||
maxAutoMergeRetries: 3,
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
|
||||
@@ -1257,6 +1257,23 @@ describe("SettingsModal", () => {
|
||||
expect(payload.pushAfterMerge).toBe(true);
|
||||
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)", () => {
|
||||
|
||||
@@ -95,6 +95,7 @@ export const defaultSettings = {
|
||||
ignoreHiddenOverlapPaths: true,
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
planApprovalMode: "workflow",
|
||||
mergeStrategy: "direct",
|
||||
merger: { mode: "deterministic" },
|
||||
directMergeCommitStrategy: "auto",
|
||||
|
||||
@@ -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>
|
||||
</details>
|
||||
</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">
|
||||
<label htmlFor="maxAutoMergeRetries">{t("settings.merge.autoMergeConflictRetries", "Auto-merge conflict retries")}</label>
|
||||
{/*
|
||||
|
||||
@@ -15,14 +15,16 @@ function jsonResponse(body: unknown, ok = true): Response {
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function makeProps(): MergeSectionProps {
|
||||
function makeProps(overrides: Partial<MergeSectionProps["form"]> = {}): MergeSectionProps {
|
||||
return {
|
||||
scopeBanner: null,
|
||||
form: {
|
||||
autoMerge: true,
|
||||
planApprovalMode: "workflow",
|
||||
merger: { mode: "ai" },
|
||||
testMode: false,
|
||||
mergeStrategy: "direct",
|
||||
...overrides,
|
||||
} as MergeSectionProps["form"],
|
||||
setForm: vi.fn(),
|
||||
integrationBranchOptions: ["main"],
|
||||
@@ -38,6 +40,25 @@ describe("MergeSection legacy auto-merge stamp cleanup", () => {
|
||||
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 () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
candidates: [
|
||||
|
||||
@@ -1749,6 +1749,64 @@ describe("requirePlanApproval setting", () => {
|
||||
const settings = await store.getSettings();
|
||||
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", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
sortTasksByPriorityThenAgeAndId,
|
||||
compareTaskIdNumeric,
|
||||
resolveAgentMemoryInclusionMode,
|
||||
resolvePlanApprovalRequired,
|
||||
extractIntentSignature,
|
||||
findNearDuplicates,
|
||||
isNearDuplicateCanonicalInactive,
|
||||
@@ -491,6 +492,7 @@ export class TriageProcessor {
|
||||
// Merge per-task effective workflow settings (U3, KTD-3) so requirePlanApproval
|
||||
// resolves from the workflow. Behavior-inert when nothing is customized.
|
||||
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 written = await readFile(promptPath, "utf-8").catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -504,7 +506,7 @@ export class TriageProcessor {
|
||||
}
|
||||
|
||||
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 — moved to todo",
|
||||
});
|
||||
@@ -2420,7 +2422,11 @@ export class TriageProcessor {
|
||||
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" };
|
||||
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
|
||||
approvalUpdates.title = promptDeclaredTitle;
|
||||
|
||||
@@ -6067,6 +6067,11 @@
|
||||
"personalAccessToken": "Personal access token",
|
||||
"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.",
|
||||
"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",
|
||||
"pushRemote": "Push Remote",
|
||||
"pushToRemoteAfterMerge": " Push to remote after merge ",
|
||||
|
||||
5
packages/i18n/src/resources.d.ts
vendored
5
packages/i18n/src/resources.d.ts
vendored
@@ -6071,6 +6071,11 @@ export default interface Resources {
|
||||
"personalAccessToken": "Personal access token",
|
||||
"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.",
|
||||
"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",
|
||||
"pushRemote": "Push Remote",
|
||||
"pushToRemoteAfterMerge": " Push to remote after merge ",
|
||||
|
||||
Reference in New Issue
Block a user