From 8f6190910d622baf0afb1d01c04ebad07d9a413c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 01:45:08 -0700 Subject: [PATCH] FN-8971: add mission blocked-status clearing tool Add an operator-only CLI tool for repairing stale mission blocked badges. - Register and document fn_mission_clear_blocked with PostgreSQL and status-error handling. - Withhold the repair action from agent principals and deny it in readonly workflow steps. - Classify the tool consistently across engine permission gates and add coverage. - Add a minor CLI changeset for the new operator capability. Files changed: .changeset/fn-8971-mission-clear-blocked-tool.md | 7 +++ docs/missions.md | 7 ++- packages/cli/skill/fusion/SKILL.md | 2 +- .../cli/skill/fusion/references/extension-tools.md | 9 ++++ .../skill/fusion/references/fusion-capabilities.md | 1 + .../__tests__/extension-permission-gates.test.ts | 26 ++++++++++ packages/cli/src/__tests__/extension.test.ts | 56 +++++++++++++++++++++- packages/cli/src/extension.ts | 54 +++++++++++++++++++++ .../src/__tests__/agent-mission-tools.test.ts | 5 +- .../src/__tests__/gating-classifications.test.ts | 8 ++++ .../workflow-step-readonly-allowlist.test.ts | 5 +- .../engine/src/execution/gating-classifications.ts | 7 +++ 12 files changed, 182 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8971 Fusion-Task-Lineage: 650c13b3-c6c4-4dc5-a0b5-9c71d3b94fc5 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8971-mission-clear-blocked-tool.md | 7 +++ docs/missions.md | 7 ++- packages/cli/skill/fusion/SKILL.md | 2 +- .../fusion/references/extension-tools.md | 9 +++ .../fusion/references/fusion-capabilities.md | 1 + .../extension-permission-gates.test.ts | 26 +++++++++ packages/cli/src/__tests__/extension.test.ts | 56 ++++++++++++++++++- packages/cli/src/extension.ts | 54 ++++++++++++++++++ .../src/__tests__/agent-mission-tools.test.ts | 5 +- .../__tests__/gating-classifications.test.ts | 8 +++ .../workflow-step-readonly-allowlist.test.ts | 5 +- .../src/execution/gating-classifications.ts | 7 +++ 12 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-8971-mission-clear-blocked-tool.md diff --git a/.changeset/fn-8971-mission-clear-blocked-tool.md b/.changeset/fn-8971-mission-clear-blocked-tool.md new file mode 100644 index 0000000000..0d6f2f0bcb --- /dev/null +++ b/.changeset/fn-8971-mission-clear-blocked-tool.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add an operator-only tool to clear a stale mission blocked badge from the CLI. +category: feature +dev: Registers fn_mission_clear_blocked in the pi extension (withheld from agent principals), classifies it as task_agent_mutation in gating-classifications.ts, and denies it in readonly workflow steps. diff --git a/docs/missions.md b/docs/missions.md index ffd55b0f6a..2dc019f861 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -216,6 +216,7 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r | `fn_mission_delete` | Delete a mission and its hierarchy. | | `fn_mission_update` | Update mission title/description using partial patches. | | `fn_mission_set_status` | Set mission lifecycle status with an attributed audit event. | +| `fn_mission_clear_blocked` | Clear a stale mission-level `blocked` badge without resuming automation (operator-only). | | `fn_milestone_add` | Add a milestone to a mission. | | `fn_milestone_update` | Update milestone fields using partial patches. | | `fn_slice_add` | Add a slice to a milestone. | @@ -277,6 +278,10 @@ For a status-changing clear, the engine resolves a target status and loop target The store rechecks the fence under its feature-row lock: a live task must still be live and unchanged, while an absent task must remain absent. It retries a stale resolution once. The no-`taskStore` fixture fallback records `groundTruthTaskVerified: false` for a non-null task ID. Callers provide both resolved targets: the store ignores `resolvedLoopState` on a status-only clear and ignores `resolvedStatus` on a loop-only clear, avoiding stale pre-lock branching. The mutation and `feature_validation_repaired` audit event commit in one transaction; clearing resets the implementation retry count, and unlinked features cannot be resumed as `triaged` or `in-progress`. The normal execution loop still cannot escape `blocked` by itself. +### Clear a stale mission blocked badge + +`fn_mission_clear_blocked` repairs only a stale mission-level `blocked` badge. It accepts an audit-logged optional `reason` and reports residual canonical blockers, but does not clear them or resume automation. Use **Resume mission** when automation should be re-armed. The tool is withheld from agent sessions and is available only to a human operator through the CLI/pi extension. + ## Mission delete policy (hard delete with linked-task guard) Mission hierarchy records (`missions`, `milestones`, `slices`, `mission_features`) use hard deletes with FK cascades and do not have `deletedAt` soft-delete columns. @@ -769,7 +774,7 @@ See also: [Multi-Project](./multi-project.md) and [Task Management](./task-manag Mission hierarchy operations are available with the same project-scoped `MissionStore` contract in the pi extension, engine-managed executor/triage/heartbeat agents, and provider-backed dashboard chat. The surface is `fn_mission_list`, `fn_mission_show`, `fn_mission_create`, `fn_mission_update`, `fn_mission_set_status`, `fn_mission_delete`, `fn_milestone_add`, `fn_milestone_update`, `fn_milestone_delete`, `fn_slice_add`, `fn_slice_activate`, `fn_slice_delete`, `fn_feature_add`, `fn_feature_update`, `fn_feature_set_status`, `fn_feature_repair_validation`, `fn_feature_delete`, and `fn_feature_link_task`. -`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations, including `fn_feature_repair_validation` and `fn_mission_reconcile`, mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools. +`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations, including `fn_feature_repair_validation` and `fn_mission_reconcile`, mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools. `fn_mission_clear_blocked` is intentionally absent from agent tool lists: it is classified as `task_agent_mutation` in both gate paths and denied in readonly workflow steps, while the CLI/pi-extension hard-withholds it from agent principals. ## Automatic mission reconciliation diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 3fe86415ac..ff8679eb7e 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -30,7 +30,7 @@ Mission → Milestone → Slice → Feature → Task - **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_logs_read`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_bypass_review`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_browse_gitlab_project_issues`, `fn_task_import_gitlab_project_issues`, `fn_task_browse_gitlab_group_issues`, `fn_task_import_gitlab_group_issues`, `fn_task_browse_gitlab_merge_requests`, `fn_task_import_gitlab_merge_requests`, `fn_task_plan` - **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select`, `fn_workflow_step_resume` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` -- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_set_status`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_set_status`, `fn_mission_reconcile`, `fn_feature_repair_validation`, `fn_feature_update`, `fn_milestone_update` +- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_set_status`, `fn_mission_clear_blocked`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_set_status`, `fn_mission_reconcile`, `fn_feature_repair_validation`, `fn_feature_update`, `fn_milestone_update` - **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` - **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_update`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` - **Skills tools** — `fn_skills_search`, `fn_skills_install` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 8e90c6b7d9..2fe6b5a1a0 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -430,6 +430,15 @@ Set a mission lifecycle status. | `status` | union | ✓ | | | `reason` | string | — | | +### fn_mission_clear_blocked + +Clear a stale mission-level blocked badge without resuming automation. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | string | ✓ | Mission ID (e.g., M-001) | +| `reason` | string | — | Why the badge is stale (audit-logged) | + ### fn_mission_update Update an existing mission's title or description. Partial patches leave untouched fields intact. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 31a1bc5956..05af208a34 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -67,6 +67,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_mission_backfill_assertions` | Backfill mission assertions by deriving and linking one store-managed assertion for each feature without linked assertions. Supports dry-run mode. | | `fn_mission_delete` | Delete a mission and all its milestones, slices, and features. Cannot be undone. | | `fn_mission_set_status` | Set a mission lifecycle status. | +| `fn_mission_clear_blocked` | Clear a stale mission-level blocked badge without resuming automation. | | `fn_mission_update` | Update an existing mission's title or description. Partial patches leave untouched fields intact. | | `fn_milestone_add` | Add a milestone to a mission. Milestones represent phases of work. | | `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. | diff --git a/packages/cli/src/__tests__/extension-permission-gates.test.ts b/packages/cli/src/__tests__/extension-permission-gates.test.ts index a055356c42..184371268c 100644 --- a/packages/cli/src/__tests__/extension-permission-gates.test.ts +++ b/packages/cli/src/__tests__/extension-permission-gates.test.ts @@ -255,6 +255,7 @@ pgDescribe("extension tool permission gates", () => { const calls: Array<[string, Record]> = [ ["fn_task_bypass_review", { id: "FN-1", reason: "nope" }], ["fn_mission_delete", { id: "M-1" }], + ["fn_mission_clear_blocked", { id: "M-1" }], ["fn_milestone_delete", { milestoneId: "MS-1" }], ["fn_slice_delete", { sliceId: "SL-1" }], ["fn_feature_delete", { featureId: "F-1" }], @@ -272,6 +273,31 @@ pgDescribe("extension tool permission gates", () => { } }); + it("fn_mission_clear_blocked: denies agent and ambiguous principals before the store, while operators proceed", async () => { + const cwd = h.rootDir(); + const missionStore = h.store().getMissionStore(); + const mission = await missionStore.createMission({ title: "withheld clear target" }); + await missionStore.updateMission(mission.id, { status: "blocked" }, { actor: { type: "operator", id: "test", source: "test" } }); + const clearMissionBlockedStatus = vi.spyOn(missionStore, "clearMissionBlockedStatus"); + const tool = requireTool(freshApi(), "fn_mission_clear_blocked"); + + const explicitAgent = await tool.execute("agent", { id: mission.id }, undefined, undefined, { cwd, agentId: "agent-rogue" }); + expect(explicitAgent).toMatchObject({ isError: true, details: { deniedFor: "agent-principal" } }); + expect(clearMissionBlockedStatus).not.toHaveBeenCalled(); + + const disposeOne = registerFusionSessionIdentity(cwd, { agentId: "agent-one" }); + const disposeTwo = registerFusionSessionIdentity(cwd, { agentId: "agent-two" }); + const ambiguous = await tool.execute("ambiguous", { id: mission.id }, undefined, undefined, { cwd }); + expect(ambiguous).toMatchObject({ isError: true, details: { deniedFor: "agent-principal" } }); + expect(clearMissionBlockedStatus).not.toHaveBeenCalled(); + disposeOne(); + disposeTwo(); + + const operator = await tool.execute("operator", { id: mission.id }, undefined, undefined, { cwd }); + expect(operator.isError).toBeUndefined(); + expect(clearMissionBlockedStatus).toHaveBeenCalledTimes(1); + }); + // ── Policy-gated list ──────────────────────────────────────────── it("default (unrestricted) preset: agent fn_task_pause proceeds with NO approval row", async () => { diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 7f085f7e62..99028120a3 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -22,7 +22,7 @@ vi.mock("../commands/task.js", () => ({ })); import { __setCachedStoreForTesting, closeCachedStores, resolveTaskListFormatter } from "../extension.js"; -import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core"; +import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, MAX_TASK_LIST_TEXT_CHARS, MissionBlockedClearConflictError, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core"; import type { WorkflowIr } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli"; import { runTaskPlan } from "../commands/task.js"; @@ -297,6 +297,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => { "fn_mission_delete", "fn_mission_update", "fn_mission_set_status", + "fn_mission_clear_blocked", "fn_milestone_add", "fn_slice_add", "fn_feature_add", @@ -2963,6 +2964,59 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { expect(await missionStore.getFeature(feature.id)).toMatchObject({ status: "defined", loopState: "idle" }); }); + describe("fn_mission_clear_blocked", () => { + it("calls the attributed repair primitive and reports residual blockers", async () => { + const clearMissionBlockedStatus = vi.fn().mockResolvedValue({ + mission: { id: "M-1", status: "planning" }, + blockers: [{ source: "lineage", reason: "pending delivery" }], + }); + const missionStore = { + getMission: vi.fn().mockResolvedValue({ id: "M-1", status: "blocked" }), + clearMissionBlockedStatus, + }; + __setCachedStoreForTesting(tmpDir, { getMissionStore: () => missionStore } as never); + + const result = await api.tools.get("fn_mission_clear_blocked")!.execute( + "clear", { id: "M-1", reason: "stale badge" }, undefined, undefined, makeCtx(tmpDir), + ); + + expect(clearMissionBlockedStatus).toHaveBeenCalledWith("M-1", { + actor: { type: "operator", id: "cli-operator", displayName: "CLI operator", source: "pi-extension" }, + reason: "stale badge", + }); + expect(result.details).toMatchObject({ mission: { id: "M-1", status: "planning" }, blockers: [{ source: "lineage" }] }); + expect(result.content[0]?.text).toContain("Cleared blocked status for M-1 → planning"); + expect(result.content[0]?.text).toContain("1 blocker(s) remain; automation stays gated"); + __setCachedStoreForTesting(tmpDir, h.store()); + }); + + it("reports missing, non-blocked, and PostgreSQL-required mission stores without writing", async () => { + const tool = api.tools.get("fn_mission_clear_blocked")!; + const missingStore = { getMission: vi.fn().mockResolvedValue(null), clearMissionBlockedStatus: vi.fn() }; + __setCachedStoreForTesting(tmpDir, { getMissionStore: () => missingStore } as never); + await expect(tool.execute("missing", { id: "M-missing" }, undefined, undefined, makeCtx(tmpDir))).resolves.toMatchObject({ + isError: true, details: { code: "MISSION_NOT_FOUND" }, + }); + expect(missingStore.clearMissionBlockedStatus).not.toHaveBeenCalled(); + + const conflictStore = { + getMission: vi.fn().mockResolvedValue({ id: "M-1", status: "active" }), + clearMissionBlockedStatus: vi.fn().mockRejectedValue(new MissionBlockedClearConflictError("active")), + }; + __setCachedStoreForTesting(tmpDir, { getMissionStore: () => conflictStore } as never); + await expect(tool.execute("conflict", { id: "M-1" }, undefined, undefined, makeCtx(tmpDir))).resolves.toMatchObject({ + isError: true, details: { code: "MISSION_NOT_BLOCKED", status: "active" }, + }); + expect(conflictStore.clearMissionBlockedStatus).toHaveBeenCalledTimes(1); + + __setCachedStoreForTesting(tmpDir, { getMissionStore: () => ({ getMission: vi.fn() }) } as never); + await expect(tool.execute("postgres", { id: "M-1" }, undefined, undefined, makeCtx(tmpDir))).resolves.toMatchObject({ + isError: true, details: { code: "POSTGRES_REQUIRED" }, + }); + __setCachedStoreForTesting(tmpDir, h.store()); + }); + }); + describe("fn_task_list", () => { const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000; diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 848e107278..43afa27f06 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -875,6 +875,7 @@ const WITHHELD_FROM_AGENT_EXTENSION_TOOLS: ReadonlySet = new Set([ "fn_task_bypass_review", "fn_workflow_step_resume", "fn_mission_delete", + "fn_mission_clear_blocked", "fn_milestone_delete", "fn_slice_delete", "fn_feature_delete", @@ -4569,6 +4570,59 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); + /* + * FNXC:MissionBlockedRepair 2026-08-11-03:58: + * Clear repairs a stale blocked badge without re-arming automation. Because it overrides a + * durable pause, stop, or manual PATCH signal, it is operator-only and absent from + * createMissionTools so engine lanes and dashboard chat never receive it. + */ + // ── fn_mission_clear_blocked ───────────────────────────────────── + pi.registerTool({ + name: "fn_mission_clear_blocked", + label: "fn: Clear Mission Blocked Status", + description: "Clear a stale mission-level blocked badge without resuming automation.", + promptSnippet: "Clear a stale mission blocked badge", + promptGuidelines: [ + "Repairs the badge only; it does not resume mission automation", + "Use Resume mission as the only path that re-arms automation", + ], + parameters: Type.Object({ + id: Type.String({ description: "Mission ID (e.g., M-001)" }), + reason: Type.Optional(Type.String({ description: "Why the badge is stale (audit-logged)" })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const withheldDenied = denyWithheldToolForAgentPrincipal("fn_mission_clear_blocked", ctx as ExtensionCallerContext); + if (withheldDenied) return withheldDenied; + const missionStore = (await getStore(ctx.cwd)).getMissionStore(); + if (!("clearMissionBlockedStatus" in missionStore)) { + return { content: [{ type: "text", text: "Clearing mission blocked status requires the PostgreSQL mission store" }], isError: true, details: { code: "POSTGRES_REQUIRED" } }; + } + const mission = await missionStore.getMission(params.id); + if (!mission) { + return { content: [{ type: "text", text: `Mission ${params.id} not found` }], isError: true, details: { code: "MISSION_NOT_FOUND" } }; + } + try { + const { mission: clearedMission, blockers } = await missionStore.clearMissionBlockedStatus(params.id, { + actor: missionTransitionActor(ctx), + reason: params.reason, + }); + const residualWarning = blockers.length > 0 + ? `\n${blockers.length} blocker(s) remain; automation stays gated until they are resolved or the mission is resumed.` + : ""; + return { + content: [{ type: "text", text: `Cleared blocked status for ${params.id} → ${clearedMission.status}${residualWarning}` }], + details: { mission: clearedMission, blockers }, + }; + } catch (error) { + if (error instanceof fusionCore.MissionBlockedClearConflictError) { + return { content: [{ type: "text", text: `Mission ${params.id} is not blocked (status: ${error.status})` }], isError: true, details: { code: "MISSION_NOT_BLOCKED", status: error.status } }; + } + const message = error instanceof Error ? error.message : String(error); + return { content: [{ type: "text", text: message }], isError: true, details: { error: message } }; + } + }, + }); + // ── fn_mission_update ─────────────────────────────────────────── pi.registerTool({ diff --git a/packages/engine/src/__tests__/agent-mission-tools.test.ts b/packages/engine/src/__tests__/agent-mission-tools.test.ts index 2e51964cbd..3ea16b5587 100644 --- a/packages/engine/src/__tests__/agent-mission-tools.test.ts +++ b/packages/engine/src/__tests__/agent-mission-tools.test.ts @@ -11,7 +11,10 @@ which owns live project-scoped task validation and bidirectional task linkage. describe("createMissionTools", () => { it("exposes the complete hierarchy surface with read and mutation names", () => { const store = { getMissionStore: vi.fn() } as never; - expect(createMissionTools(store).map((tool) => tool.name)).toEqual([ + const toolNames = createMissionTools(store).map((tool) => tool.name); + // FNXC:MissionBlockedRepair 2026-08-11-03:58: Operator-only repair stays out of executor, triage, heartbeat, and chat. + expect(toolNames).not.toContain("fn_mission_clear_blocked"); + expect(toolNames).toEqual([ "fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_set_status", "fn_mission_delete", "fn_mission_reconcile", "fn_milestone_add", "fn_milestone_update", "fn_milestone_delete", "fn_slice_add", "fn_slice_activate", "fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_repair_validation", "fn_feature_set_status", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding", diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 9b989c664e..e16feb6d73 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -220,6 +220,14 @@ describe("gating-classifications parity", () => { }); }); + it("classifies mission blocked-badge repair in both gate paths without readonly exemptions", () => { + const toolName = "fn_mission_clear_blocked"; + expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true); + expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has(toolName)).toBe(true); + expect(READONLY_FN_TOOLS.has(toolName)).toBe(false); + expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(false); + }); + it("classifies ideation reads and mutations in both policy paths", () => { for (const toolName of ["fn_ideation_list", "fn_ideation_show"]) { expect(READONLY_FN_TOOLS.has(toolName)).toBe(true); diff --git a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts index 5a82b48b23..dfd13dcee8 100644 --- a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts +++ b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts @@ -44,6 +44,7 @@ describe("workflow-step readonly allowlist policy", () => { "fn_mission_delete", "fn_mission_update", "fn_mission_set_status", + "fn_mission_clear_blocked", "fn_mission_backfill_assertions", "fn_milestone_add", "fn_slice_add", @@ -66,10 +67,12 @@ describe("workflow-step readonly allowlist policy", () => { { name: "fn_task_list" } as any, { name: "edit" } as any, { name: "fn_task_update" } as any, + { name: "fn_mission_clear_blocked" } as any, ]); expect(filtered.allowed.map((tool) => tool.name)).toEqual(["read", "fn_task_list"]); - expect(filtered.denied).toEqual(["edit", "fn_task_update"]); + expect(filtered.denied).toEqual(["edit", "fn_task_update", "fn_mission_clear_blocked"]); + expect(isReadonlyAllowed("fn_mission_clear_blocked")).toBe(false); }); it("allows explicitly approved tool objects without blanket-allowing mcp names", () => { diff --git a/packages/engine/src/execution/gating-classifications.ts b/packages/engine/src/execution/gating-classifications.ts index a231b02c2f..61de95efd2 100644 --- a/packages/engine/src/execution/gating-classifications.ts +++ b/packages/engine/src/execution/gating-classifications.ts @@ -95,6 +95,13 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [ "fn_mission_delete", "fn_mission_update", "fn_mission_set_status", + /* + * FNXC:ToolGovernance 2026-08-11-03:58: + * Mission blocked-badge repair overrides a durable stop signal, so its CLI/pi-extension + * surface is hard-withheld from agent principals. Classify it here so both gate paths + * never fall through as unrecognized and readonly workflow steps record an explicit denial. + */ + "fn_mission_clear_blocked", "fn_mission_reconcile", "fn_mission_backfill_assertions", "fn_milestone_add",