FN-5762: add mission hierarchy delete tools with linked-task guards

Add mission delete tooling across core, dashboard routes, and CLI extension surfaces.

- Add `fn_feature_delete`, `fn_slice_delete`, and `fn_milestone_delete` tools to the CLI extension with `force` override support.
- Enforce live-task linkage guards for feature/slice/milestone deletes in mission store logic and expose conflict behavior through mission routes.
- Extend coverage and docs for delete behavior, allowlist/gating metadata, and published Fusion skill/tool references.

Files changed:
 .changeset/fn-5762-feature-delete.md               |   7 +
 docs/missions.md                                   |  15 +++
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |  27 ++++
 .../skill/fusion/references/fusion-capabilities.md |   3 +
 packages/cli/src/__tests__/extension.test.ts       |  34 +++++
 packages/cli/src/extension.ts                      | 106 +++++++++++++++
 packages/core/src/__tests__/mission-store.test.ts  | 146 ++++++++++++++++++++-
 packages/core/src/mission-store.ts                 |  98 ++++++++++++--
 .../dashboard/src/__tests__/mission-e2e.test.ts    |  62 +++++++--
 packages/dashboard/src/mission-routes.ts           |  42 +++++-
 .../workflow-step-readonly-allowlist.test.ts       |   3 +
 packages/engine/src/gating-classifications.ts      |   3 +
 13 files changed, 514 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-5762
Fusion-Task-Lineage: 9c6eceff-dcb4-4a2b-90c6-59cb0228ca6d
This commit is contained in:
gsxdsm
2026-05-30 23:55:05 -07:00
parent 93d3a58cb8
commit 0ffe7f0604
13 changed files with 516 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
Add mission delete tooling for agents: `fn_feature_delete`, `fn_slice_delete`, and `fn_milestone_delete`.
Mission feature/slice/milestone deletes now enforce a linked live-task guard by default and return clear conflict errors. Callers can pass `force: true` to clear mission linkage and proceed with hard deletion.

View File

@@ -82,9 +82,12 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r
| `fn_milestone_update` | Update milestone fields using partial patches. | | `fn_milestone_update` | Update milestone fields using partial patches. |
| `fn_slice_add` | Add a slice to a milestone. | | `fn_slice_add` | Add a slice to a milestone. |
| `fn_slice_activate` | Activate a pending slice for implementation. | | `fn_slice_activate` | Activate a pending slice for implementation. |
| `fn_slice_delete` | Delete a slice (with linked-task guard and optional `force`). |
| `fn_feature_add` | Add a feature to a slice with optional acceptance criteria. | | `fn_feature_add` | Add a feature to a slice with optional acceptance criteria. |
| `fn_feature_delete` | Delete a feature (with linked-task guard and optional `force`). |
| `fn_feature_update` | Update feature fields using partial patches. | | `fn_feature_update` | Update feature fields using partial patches. |
| `fn_feature_link_task` | Link a feature to a task for implementation. | | `fn_feature_link_task` | Link a feature to a task for implementation. |
| `fn_milestone_delete` | Delete a milestone (with linked-task guard and optional `force`). |
### fn_mission_update ### fn_mission_update
@@ -124,6 +127,18 @@ Updates an existing feature's `title`, `description`, or `acceptanceCriteria`. P
Use this to edit existing features without delete-and-re-add cycles. Use this to edit existing features without delete-and-re-add cycles.
## 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.
To keep behavior consistent, Fusion uses **hard delete with guard** for feature/slice/milestone deletes:
- Delete is rejected when the target (or any cascading child feature) is linked to a **live** task (`deletedAt IS NULL` and not archived).
- Callers can pass `force: true` to override the guard. Force clears the mission linkage before deletion, then proceeds with the same hard delete.
- Linked tasks are preserved; only mission hierarchy rows are removed.
This intentionally differs from task soft-delete behavior described in `docs/soft-delete-verification-matrix.md` and avoids a mission-table soft-delete migration.
## Mission Interview and Planning Workflow ## Mission Interview and Planning Workflow
The dashboard supports mission planning workflows where you can: The dashboard supports mission planning workflows where you can:

View File

@@ -29,7 +29,7 @@ Mission → Milestone → Slice → Feature → Task
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) --> <!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan` - **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **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_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `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_update`, `fn_milestone_update`
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` - **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_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` - **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
- **Skills tools** — `fn_skills_search`, `fn_skills_install` - **Skills tools** — `fn_skills_search`, `fn_skills_install`

View File

@@ -245,6 +245,33 @@ Add a feature to a slice. Features are deliverables that can be linked to tasks.
| `description` | string | — | Feature description | | `description` | string | — | Feature description |
| `acceptanceCriteria` | string | — | Acceptance criteria for completing the feature | | `acceptanceCriteria` | string | — | Acceptance criteria for completing the feature |
### fn_feature_delete
Delete a feature. Rejects deletion when linked to a live task unless force=true.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `featureId` | string | ✓ | Feature ID to delete (e.g., F-001) |
| `force` | boolean | — | Override linked-task guard |
### fn_slice_delete
Delete a slice and its features. Rejects deletion when child features link to live tasks unless force=true.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sliceId` | string | ✓ | Slice ID to delete (e.g., SL-001) |
| `force` | boolean | — | Override linked-task guard |
### fn_milestone_delete
Delete a milestone and all descendant slices/features. Rejects deletion when child features link to live tasks unless force=true.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `milestoneId` | string | ✓ | Milestone ID to delete (e.g., MS-001) |
| `force` | boolean | — | Override linked-task guard |
### fn_slice_activate ### fn_slice_activate
Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features. Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features.

View File

@@ -53,6 +53,9 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_milestone_add` | Add a milestone to a mission. Milestones represent phases of work. | | `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. | | `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. |
| `fn_feature_add` | Add a feature to a slice. Features are deliverables that can be linked to tasks. | | `fn_feature_add` | Add a feature to a slice. Features are deliverables that can be linked to tasks. |
| `fn_feature_delete` | Delete a feature. Rejects deletion when linked to a live task unless force=true. |
| `fn_slice_delete` | Delete a slice and its features. Rejects deletion when child features link to live tasks unless force=true. |
| `fn_milestone_delete` | Delete a milestone and all descendant slices/features. Rejects deletion when child features link to live tasks unless force=true. |
| `fn_slice_activate` | Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features. | | `fn_slice_activate` | Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features. |
| `fn_feature_link_task` | Link a feature to a fn task for implementation. Updates the feature status to 'triaged' and associates it with the task. If the target task is not on the active board (for example archived, deleted, or never created), the tool returns a clear validation error indicating that only active tasks can be linked. | | `fn_feature_link_task` | Link a feature to a fn task for implementation. Updates the feature status to 'triaged' and associates it with the task. If the target task is not on the active board (for example archived, deleted, or never created), the tool returns a clear validation error indicating that only active tasks can be linked. |
| `fn_feature_update` | Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. | | `fn_feature_update` | Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. |

View File

@@ -220,6 +220,9 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
"fn_milestone_add", "fn_milestone_add",
"fn_slice_add", "fn_slice_add",
"fn_feature_add", "fn_feature_add",
"fn_feature_delete",
"fn_slice_delete",
"fn_milestone_delete",
"fn_slice_activate", "fn_slice_activate",
"fn_feature_link_task", "fn_feature_link_task",
"fn_feature_update", "fn_feature_update",
@@ -1334,6 +1337,37 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
}); });
}); });
describe("fn_feature_delete", () => {
it("deletes feature, guards linked task, and handles missing feature", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const createTaskTool = api.tools.get("fn_task_create")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const deleteTool = api.tools.get("fn_feature_delete")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute("ms1", { missionId: mission.details.missionId, title: "Milestone" }, undefined, undefined, makeCtx(tmpDir));
const slice = await sliceTool.execute("sl1", { milestoneId: milestone.details.milestoneId, title: "Slice" }, undefined, undefined, makeCtx(tmpDir));
const feature = await featureTool.execute("f1", { sliceId: slice.details.sliceId, title: "Feature" }, undefined, undefined, makeCtx(tmpDir));
const task = await createTaskTool.execute("t1", { description: "Task for feature" }, undefined, undefined, makeCtx(tmpDir));
await linkTool.execute("l1", { featureId: feature.details.featureId, taskId: task.details.taskId }, undefined, undefined, makeCtx(tmpDir));
const guarded = await deleteTool.execute("d1", { featureId: feature.details.featureId }, undefined, undefined, makeCtx(tmpDir));
expect(guarded.isError).toBe(true);
expect(guarded.content[0].text).toContain("linked to task");
const forced = await deleteTool.execute("d2", { featureId: feature.details.featureId, force: true }, undefined, undefined, makeCtx(tmpDir));
expect(forced.isError).not.toBe(true);
expect(forced.content[0].text).toContain("Deleted");
const missing = await deleteTool.execute("d3", { featureId: feature.details.featureId }, undefined, undefined, makeCtx(tmpDir));
expect(missing.isError).toBe(true);
expect(missing.content[0].text).toContain("not found");
});
});
describe("fn_slice_activate", () => { describe("fn_slice_activate", () => {
it("returns error when slice is already active", async () => { it("returns error when slice is already active", async () => {
const missionTool = api.tools.get("fn_mission_create")!; const missionTool = api.tools.get("fn_mission_create")!;

View File

@@ -2919,6 +2919,112 @@ export default function kbExtension(pi: ExtensionAPI) {
}, },
}); });
// ── fn_feature_delete ────────────────────────────────────────────
pi.registerTool({
name: "fn_feature_delete",
label: "fn: Delete Feature",
description: "Delete a feature. Rejects deletion when linked to a live task unless force=true.",
promptSnippet: "Delete a mission feature",
promptGuidelines: [
"Use force=true only when intentionally overriding linked live-task guards",
"Deleting a feature is permanent and cannot be undone",
],
parameters: Type.Object({
featureId: Type.String({ description: "Feature ID to delete (e.g., F-001)" }),
force: Type.Optional(Type.Boolean({ description: "Override linked-task guard" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
try {
missionStore.deleteFeature(params.featureId, params.force === true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: message }],
isError: true,
details: { error: message },
};
}
return {
content: [{ type: "text", text: `Deleted ${params.featureId}` }],
details: { featureId: params.featureId, force: params.force === true },
};
},
});
// ── fn_slice_delete ────────────────────────────────────────────
pi.registerTool({
name: "fn_slice_delete",
label: "fn: Delete Slice",
description: "Delete a slice and its features. Rejects deletion when child features link to live tasks unless force=true.",
promptSnippet: "Delete a mission slice",
parameters: Type.Object({
sliceId: Type.String({ description: "Slice ID to delete (e.g., SL-001)" }),
force: Type.Optional(Type.Boolean({ description: "Override linked-task guard" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
try {
missionStore.deleteSlice(params.sliceId, params.force === true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: message }],
isError: true,
details: { error: message },
};
}
return {
content: [{ type: "text", text: `Deleted ${params.sliceId}` }],
details: { sliceId: params.sliceId, force: params.force === true },
};
},
});
// ── fn_milestone_delete ────────────────────────────────────────────
pi.registerTool({
name: "fn_milestone_delete",
label: "fn: Delete Milestone",
description: "Delete a milestone and all descendant slices/features. Rejects deletion when child features link to live tasks unless force=true.",
promptSnippet: "Delete a mission milestone",
parameters: Type.Object({
milestoneId: Type.String({ description: "Milestone ID to delete (e.g., MS-001)" }),
force: Type.Optional(Type.Boolean({ description: "Override linked-task guard" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
try {
missionStore.deleteMilestone(params.milestoneId, params.force === true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: message }],
isError: true,
details: { error: message },
};
}
return {
content: [{ type: "text", text: `Deleted ${params.milestoneId}` }],
details: { milestoneId: params.milestoneId, force: params.force === true },
};
},
});
// ── fn_slice_activate ──────────────────────────────────────────── // ── fn_slice_activate ────────────────────────────────────────────
pi.registerTool({ pi.registerTool({

View File

@@ -17,11 +17,12 @@ function createTaskInDb(
taskId: string, taskId: string,
description = "Test task", description = "Test task",
status?: string, status?: string,
options?: { column?: string; deletedAt?: string | null },
): void { ): void {
const now = new Date().toISOString(); const now = new Date().toISOString();
database.prepare( database.prepare(
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)` `INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt, "deletedAt") VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(taskId, description, "triage", status ?? null, now, now); ).run(taskId, description, options?.column ?? "triage", status ?? null, now, now, options?.deletedAt ?? null);
} }
describe("MissionStore", () => { describe("MissionStore", () => {
@@ -1211,7 +1212,7 @@ describe("MissionStore", () => {
expect(updated.title).toBe("Updated"); expect(updated.title).toBe("Updated");
}); });
it("deletes a feature", () => { it("deletes a feature when no task is linked", () => {
const mission = store.createMission({ title: "Mission" }); const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" }); const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" }); const slice = store.addSlice(milestone.id, { title: "Slice" });
@@ -1222,6 +1223,79 @@ describe("MissionStore", () => {
expect(retrieved).toBeUndefined(); expect(retrieved).toBeUndefined();
}); });
it("blocks delete when feature is linked to a live task", () => {
createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Guarded" });
store.linkFeatureToTask(feature.id, "FN-001");
expect(() => store.deleteFeature(feature.id)).toThrow(
`Feature ${feature.id} is linked to task FN-001; pass force to delete anyway`,
);
expect(store.getFeature(feature.id)).toBeDefined();
});
it("deletes linked feature with force and keeps task row", () => {
createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Force Delete" });
store.linkFeatureToTask(feature.id, "FN-001");
store.deleteFeature(feature.id, true);
expect(store.getFeature(feature.id)).toBeUndefined();
const taskRow = db.prepare("SELECT id, missionId, sliceId FROM tasks WHERE id = ?").get("FN-001") as {
id: string;
missionId: string | null;
sliceId: string | null;
};
expect(taskRow.id).toBe("FN-001");
expect(taskRow.missionId).toBeNull();
expect(taskRow.sliceId).toBeNull();
});
it("allows delete without force when linked task is archived", () => {
createTaskInDb(db, "FN-ARCHIVE", "Archived", undefined, { column: "archived" });
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Archived Link" });
store.updateFeature(feature.id, { taskId: "FN-ARCHIVE", status: "triaged" });
store.deleteFeature(feature.id);
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("allows delete without force when linked task is soft-deleted", () => {
createTaskInDb(db, "FN-DELETED", "Deleted", undefined, { deletedAt: new Date().toISOString() });
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Deleted Link" });
store.updateFeature(feature.id, { taskId: "FN-DELETED", status: "triaged" });
store.deleteFeature(feature.id);
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("throws not found on second delete", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Idempotent" });
store.deleteFeature(feature.id);
expect(() => store.deleteFeature(feature.id)).toThrow(`Feature ${feature.id} not found`);
});
it("links a feature to a task and persists missionId/sliceId on the task row", () => { it("links a feature to a task and persists missionId/sliceId on the task row", () => {
createTaskInDb(db, "FN-001"); createTaskInDb(db, "FN-001");
@@ -1365,6 +1439,38 @@ describe("MissionStore", () => {
expect(store.getFeature(feature.id)).toBeUndefined(); expect(store.getFeature(feature.id)).toBeUndefined();
}); });
it("blocks milestone delete when child feature links to live task", () => {
createTaskInDb(db, "FN-LIVE");
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Child" });
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
const feature = store.addFeature(slice.id, { title: "Guarded" });
store.linkFeatureToTask(feature.id, "FN-LIVE");
expect(() => store.deleteMilestone(milestone.id)).toThrow("pass force to delete anyway");
expect(store.getMilestone(milestone.id)).toBeDefined();
});
it("force deletes milestone with linked features", () => {
createTaskInDb(db, "FN-LIVE");
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Child" });
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
const feature = store.addFeature(slice.id, { title: "Guarded" });
store.linkFeatureToTask(feature.id, "FN-LIVE");
store.deleteMilestone(milestone.id, true);
expect(store.getMilestone(milestone.id)).toBeUndefined();
const taskRow = db.prepare("SELECT id, missionId, sliceId FROM tasks WHERE id = ?").get("FN-LIVE") as {
id: string;
missionId: string | null;
sliceId: string | null;
};
expect(taskRow.id).toBe("FN-LIVE");
expect(taskRow.missionId).toBeNull();
expect(taskRow.sliceId).toBeNull();
});
it("deletes slice → features", () => { it("deletes slice → features", () => {
const mission = store.createMission({ title: "Mission" }); const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" }); const milestone = store.addMilestone(mission.id, { title: "Milestone" });
@@ -1380,6 +1486,38 @@ describe("MissionStore", () => {
expect(store.getSlice(slice.id)).toBeUndefined(); expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined(); expect(store.getFeature(feature.id)).toBeUndefined();
}); });
it("blocks slice delete when child feature links to live task", () => {
createTaskInDb(db, "FN-SLICE");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Guarded" });
store.linkFeatureToTask(feature.id, "FN-SLICE");
expect(() => store.deleteSlice(slice.id)).toThrow("pass force to delete anyway");
expect(store.getSlice(slice.id)).toBeDefined();
});
it("force deletes slice with linked features", () => {
createTaskInDb(db, "FN-SLICE");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Guarded" });
store.linkFeatureToTask(feature.id, "FN-SLICE");
store.deleteSlice(slice.id, true);
expect(store.getSlice(slice.id)).toBeUndefined();
const taskRow = db.prepare("SELECT id, missionId, sliceId FROM tasks WHERE id = ?").get("FN-SLICE") as {
id: string;
missionId: string | null;
sliceId: string | null;
};
expect(taskRow.id).toBe("FN-SLICE");
expect(taskRow.missionId).toBeNull();
expect(taskRow.sliceId).toBeNull();
});
}); });
// ── Status Rollup Tests ─────────────────────────────────────────────── // ── Status Rollup Tests ───────────────────────────────────────────────
@@ -1836,7 +1974,7 @@ describe("MissionStore", () => {
const slice = store.addSlice(milestone.id, { title: "Slice" }); const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" }); const feature = store.addFeature(slice.id, { title: "Test" });
store.linkFeatureToTask(feature.id, "FN-001"); store.linkFeatureToTask(feature.id, "FN-001");
store.deleteFeature(feature.id); store.deleteFeature(feature.id, true);
expect(created).toHaveBeenCalledTimes(1); expect(created).toHaveBeenCalledTimes(1);
// Updated is called twice: once by linkFeatureToTask, once by delete triggering recompute // Updated is called twice: once by linkFeatureToTask, once by delete triggering recompute

View File

@@ -1391,17 +1391,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* Cascades to delete all slices and features. * Cascades to delete all slices and features.
* *
* @param id - Milestone ID * @param id - Milestone ID
* @param force - Override linked live-task guard for child features
* @throws Error if milestone not found * @throws Error if milestone not found
*/ */
deleteMilestone(id: string): void { deleteMilestone(id: string, force = false): void {
const milestone = this.getMilestone(id); const milestone = this.getMilestone(id);
if (!milestone) { if (!milestone) {
throw new Error(`Milestone ${id} not found`); throw new Error(`Milestone ${id} not found`);
} }
const missionId = milestone.missionId; const missionId = milestone.missionId;
const features = this.listSlices(id).flatMap((slice) => this.listFeatures(slice.id));
const blockingLinks = this.getLiveTaskLinkedFeatures(features);
this.db.prepare("DELETE FROM milestones WHERE id = ?").run(id); if (blockingLinks.length > 0 && !force) {
throw new Error(
`Milestone ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`,
);
}
this.db.transaction(() => {
if (force) {
for (const link of blockingLinks) {
this.db.prepare("UPDATE mission_features SET taskId = NULL, updatedAt = ? WHERE id = ?").run(new Date().toISOString(), link.featureId);
this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(link.taskId);
}
}
this.db.prepare("DELETE FROM milestones WHERE id = ?").run(id);
});
this.db.bumpLastModified(); this.db.bumpLastModified();
this.emit("milestone:deleted", id); this.emit("milestone:deleted", id);
@@ -1621,17 +1639,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* Cascades to delete all features. * Cascades to delete all features.
* *
* @param id - Slice ID * @param id - Slice ID
* @param force - Override linked live-task guard for child features
* @throws Error if slice not found * @throws Error if slice not found
*/ */
deleteSlice(id: string): void { deleteSlice(id: string, force = false): void {
const slice = this.getSlice(id); const slice = this.getSlice(id);
if (!slice) { if (!slice) {
throw new Error(`Slice ${id} not found`); throw new Error(`Slice ${id} not found`);
} }
const milestoneId = slice.milestoneId; const milestoneId = slice.milestoneId;
const features = this.listFeatures(id);
const blockingLinks = this.getLiveTaskLinkedFeatures(features);
this.db.prepare("DELETE FROM slices WHERE id = ?").run(id); if (blockingLinks.length > 0 && !force) {
throw new Error(
`Slice ${id} has features linked to live tasks: ${blockingLinks.map((link) => `${link.featureId}->${link.taskId}`).join(", ")}; pass force to delete anyway`,
);
}
this.db.transaction(() => {
if (force) {
for (const link of blockingLinks) {
this.db.prepare("UPDATE mission_features SET taskId = NULL, updatedAt = ? WHERE id = ?").run(new Date().toISOString(), link.featureId);
this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(link.taskId);
}
}
this.db.prepare("DELETE FROM slices WHERE id = ?").run(id);
});
this.db.bumpLastModified(); this.db.bumpLastModified();
this.emit("slice:deleted", id); this.emit("slice:deleted", id);
@@ -1906,26 +1942,46 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* Delete a feature. * Delete a feature.
* *
* @param id - Feature ID * @param id - Feature ID
* @param force - Override linked live-task guard
* @throws Error if feature not found * @throws Error if feature not found
*/ */
deleteFeature(id: string): void { deleteFeature(id: string, force = false): void {
const feature = this.getFeature(id); const feature = this.getFeature(id);
if (!feature) { if (!feature) {
throw new Error(`Feature ${id} not found`); throw new Error(`Feature ${id} not found`);
} }
const sliceId = feature.sliceId; if (feature.taskId) {
const slice = this.getSlice(sliceId); const linkedTask = this.db.prepare(
const milestoneId = slice?.milestoneId; `SELECT id, "column" FROM tasks WHERE id = ? AND "deletedAt" IS NULL`
if (milestoneId) { ).get(feature.taskId) as { id: string; column: string } | undefined;
const managedAssertion = this.listContractAssertions(milestoneId) const linkedToLiveTask = linkedTask && linkedTask.column !== "archived";
.find((assertion) => assertion.sourceFeatureId === feature.id);
if (managedAssertion) { if (linkedToLiveTask && !force) {
this.deleteContractAssertion(managedAssertion.id); throw new Error(`Feature ${id} is linked to task ${feature.taskId}; pass force to delete anyway`);
} }
} }
this.db.prepare("DELETE FROM mission_features WHERE id = ?").run(id); const sliceId = feature.sliceId;
const slice = this.getSlice(sliceId);
const milestoneId = slice?.milestoneId;
this.db.transaction(() => {
if (force && feature.taskId) {
this.db.prepare("UPDATE mission_features SET taskId = NULL, updatedAt = ? WHERE id = ?").run(new Date().toISOString(), id);
this.db.prepare("UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND \"deletedAt\" IS NULL").run(feature.taskId);
}
if (milestoneId) {
const managedAssertion = this.listContractAssertions(milestoneId)
.find((assertion) => assertion.sourceFeatureId === feature.id);
if (managedAssertion) {
this.deleteContractAssertion(managedAssertion.id);
}
}
this.db.prepare("DELETE FROM mission_features WHERE id = ?").run(id);
});
this.db.bumpLastModified(); this.db.bumpLastModified();
this.emit("feature:deleted", id); this.emit("feature:deleted", id);
@@ -1934,6 +1990,24 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
this.recomputeSliceStatus(sliceId); this.recomputeSliceStatus(sliceId);
} }
private getLiveTaskLinkedFeatures(features: MissionFeature[]): Array<{ featureId: string; taskId: string }> {
const links = features
.filter((feature): feature is MissionFeature & { taskId: string } => Boolean(feature.taskId))
.map((feature) => ({ featureId: feature.id, taskId: feature.taskId }));
if (links.length === 0) {
return [];
}
const placeholders = links.map(() => "?").join(", ");
const liveRows = this.db.prepare(
`SELECT id FROM tasks WHERE id IN (${placeholders}) AND "deletedAt" IS NULL AND "column" != 'archived'`
).all(...links.map((link) => link.taskId)) as Array<{ id: string }>;
const liveTaskIds = new Set(liveRows.map((row) => row.id));
return links.filter((link) => liveTaskIds.has(link.taskId));
}
private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } { private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } {
const acceptanceCriteria = feature.acceptanceCriteria?.trim(); const acceptanceCriteria = feature.acceptanceCriteria?.trim();
if (acceptanceCriteria) { if (acceptanceCriteria) {

View File

@@ -238,8 +238,16 @@ function createMockMissionStore() {
return updated; return updated;
}), }),
deleteMilestone: vi.fn((id: string) => { deleteMilestone: vi.fn((id: string, force?: boolean) => {
if (!milestones.has(id)) throw new Error("Milestone " + id + " not found"); if (!milestones.has(id)) throw new Error("Milestone " + id + " not found");
const blockingFeature = Array.from(features.values()).find((feature) => {
if (!feature.taskId) return false;
const parentSlice = slices.get(feature.sliceId);
return parentSlice?.milestoneId === id;
});
if (blockingFeature && !force) {
throw new Error(`Milestone ${id} has features linked to live tasks: ${blockingFeature.id}->${blockingFeature.taskId}; pass force to delete anyway`);
}
milestones.delete(id); milestones.delete(id);
for (const slice of Array.from(slices.values())) { for (const slice of Array.from(slices.values())) {
if (slice.milestoneId === id) { if (slice.milestoneId === id) {
@@ -252,7 +260,6 @@ function createMockMissionStore() {
} }
} }
}), }),
addSlice: vi.fn((milestoneId: string, input: { title: string; description?: string; verification?: string }) => { addSlice: vi.fn((milestoneId: string, input: { title: string; description?: string; verification?: string }) => {
const slice: Slice = { const slice: Slice = {
id: generateSliceId(), id: generateSliceId(),
@@ -286,8 +293,14 @@ function createMockMissionStore() {
return updated; return updated;
}), }),
deleteSlice: vi.fn((id: string) => { deleteSlice: vi.fn((id: string, force?: boolean) => {
if (!slices.has(id)) throw new Error("Slice " + id + " not found"); if (!slices.has(id)) throw new Error("Slice " + id + " not found");
const blockingFeature = Array.from(features.values()).find(
(feature) => feature.sliceId === id && Boolean(feature.taskId),
);
if (blockingFeature && !force) {
throw new Error(`Slice ${id} has features linked to live tasks: ${blockingFeature.id}->${blockingFeature.taskId}; pass force to delete anyway`);
}
slices.delete(id); slices.delete(id);
for (const feature of Array.from(features.values())) { for (const feature of Array.from(features.values())) {
if (feature.sliceId === id) { if (feature.sliceId === id) {
@@ -295,7 +308,6 @@ function createMockMissionStore() {
} }
} }
}), }),
addFeature: vi.fn((sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => { addFeature: vi.fn((sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => {
const feature: MissionFeature = { const feature: MissionFeature = {
id: generateFeatureId(), id: generateFeatureId(),
@@ -409,8 +421,12 @@ function createMockMissionStore() {
return updated; return updated;
}), }),
deleteFeature: vi.fn((id: string) => { deleteFeature: vi.fn((id: string, force?: boolean) => {
if (!features.has(id)) throw new Error("Feature " + id + " not found"); const feature = features.get(id);
if (!feature) throw new Error("Feature " + id + " not found");
if (feature.taskId && !force) {
throw new Error(`Feature ${id} is linked to task ${feature.taskId}; pass force to delete anyway`);
}
features.delete(id); features.delete(id);
}), }),
@@ -1665,13 +1681,19 @@ describe("Mission API", () => {
expect(missingMilestone.status).toBe(404); expect(missingMilestone.status).toBe(404);
}); });
it("DELETE /api/missions/milestones/:milestoneId validates ID and existence", async () => { it("DELETE /api/missions/milestones/:milestoneId validates ID, existence, and force guard", async () => {
const { app, missionStore } = buildApp(); const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Mission" }); const mission = missionStore.createMission({ title: "Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "To Delete" }); const milestone = missionStore.addMilestone(mission.id, { title: "To Delete" });
const guardedSlice = missionStore.addSlice(milestone.id, { title: "Slice" });
const guardedFeature = missionStore.addFeature(guardedSlice.id, { title: "Feature" });
missionStore.updateFeature(guardedFeature.id, { taskId: "FN-001", status: "triaged" });
const removed = await request(app, "DELETE", `/api/missions/milestones/${milestone.id}`); const conflictResult = await request(app, "DELETE", `/api/missions/milestones/${milestone.id}`);
expect(removed.status).toBe(204); expect(conflictResult.status).toBe(409);
const forced = await request(app, "DELETE", `/api/missions/milestones/${milestone.id}?force=true`);
expect(forced.status).toBe(204);
const missing = await request(app, "DELETE", "/api/missions/milestones/MS-NOT-FOUND"); const missing = await request(app, "DELETE", "/api/missions/milestones/MS-NOT-FOUND");
expect(missing.status).toBe(404); expect(missing.status).toBe(404);
@@ -1820,14 +1842,19 @@ describe("Mission API", () => {
expect(empty.body.error).toContain("No valid fields to update"); expect(empty.body.error).toContain("No valid fields to update");
}); });
it("DELETE /api/missions/slices/:sliceId validates ID and existence", async () => { it("DELETE /api/missions/slices/:sliceId validates ID, existence, and force guard", async () => {
const { app, missionStore } = buildApp(); const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Mission" }); const mission = missionStore.createMission({ title: "Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "To Delete" }); const slice = missionStore.addSlice(milestone.id, { title: "To Delete" });
const guardedFeature = missionStore.addFeature(slice.id, { title: "Feature" });
missionStore.updateFeature(guardedFeature.id, { taskId: "FN-001", status: "triaged" });
const removed = await request(app, "DELETE", `/api/missions/slices/${slice.id}`); const conflictResult = await request(app, "DELETE", `/api/missions/slices/${slice.id}`);
expect(removed.status).toBe(204); expect(conflictResult.status).toBe(409);
const forced = await request(app, "DELETE", `/api/missions/slices/${slice.id}?force=true`);
expect(forced.status).toBe(204);
const missing = await request(app, "DELETE", "/api/missions/slices/SL-NOT-FOUND"); const missing = await request(app, "DELETE", "/api/missions/slices/SL-NOT-FOUND");
expect(missing.status).toBe(404); expect(missing.status).toBe(404);
@@ -1945,16 +1972,23 @@ describe("Mission API", () => {
}); });
}); });
it("DELETE /api/missions/features/:featureId succeeds and rejects invalid ID format", async () => { it("DELETE /api/missions/features/:featureId handles guard, force, and invalid ID format", async () => {
const { app, missionStore } = buildApp(); const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Mission" }); const mission = missionStore.createMission({ title: "Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" });
const feature = missionStore.addFeature(slice.id, { title: "Feature" }); const feature = missionStore.addFeature(slice.id, { title: "Feature" });
missionStore.updateFeature(feature.id, { taskId: "FN-001", status: "triaged" });
const removed = await request(app, "DELETE", `/api/missions/features/${feature.id}`); const guarded = await request(app, "DELETE", `/api/missions/features/${feature.id}`);
expect(guarded.status).toBe(409);
const removed = await request(app, "DELETE", `/api/missions/features/${feature.id}?force=true`);
expect(removed.status).toBe(204); expect(removed.status).toBe(204);
const missing = await request(app, "DELETE", "/api/missions/features/F-NOT-FOUND");
expect(missing.status).toBe(404);
const invalid = await request(app, "DELETE", "/api/missions/features/invalid-id"); const invalid = await request(app, "DELETE", "/api/missions/features/invalid-id");
expect(invalid.status).toBe(400); expect(invalid.status).toBe(400);
expect(invalid.body.error).toContain("Invalid feature ID format"); expect(invalid.body.error).toContain("Invalid feature ID format");

View File

@@ -1309,6 +1309,7 @@ export function createMissionRouter(
"/milestones/:milestoneId", "/milestones/:milestoneId",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { milestoneId } = req.params; const { milestoneId } = req.params;
const force = req.query?.force === "true";
if (!validateMilestoneId(milestoneId)) { if (!validateMilestoneId(milestoneId)) {
throw badRequest("Invalid milestone ID format"); throw badRequest("Invalid milestone ID format");
@@ -1319,7 +1320,18 @@ export function createMissionRouter(
throw notFound("Milestone not found"); throw notFound("Milestone not found");
} }
missionStore.deleteMilestone(milestoneId); try {
missionStore.deleteMilestone(milestoneId, force);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("linked to live tasks")) {
throw conflict(errMsg);
}
if (errMsg.includes("not found")) {
throw notFound("Milestone not found");
}
throw err;
}
res.status(204).send(); res.status(204).send();
}) })
); );
@@ -1547,6 +1559,7 @@ export function createMissionRouter(
"/slices/:sliceId", "/slices/:sliceId",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { sliceId } = req.params; const { sliceId } = req.params;
const force = req.query?.force === "true";
if (!validateSliceId(sliceId)) { if (!validateSliceId(sliceId)) {
throw badRequest("Invalid slice ID format"); throw badRequest("Invalid slice ID format");
@@ -1557,7 +1570,18 @@ export function createMissionRouter(
throw notFound("Slice not found"); throw notFound("Slice not found");
} }
missionStore.deleteSlice(sliceId); try {
missionStore.deleteSlice(sliceId, force);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("linked to live tasks")) {
throw conflict(errMsg);
}
if (errMsg.includes("not found")) {
throw notFound("Slice not found");
}
throw err;
}
res.status(204).send(); res.status(204).send();
}) })
); );
@@ -2419,6 +2443,7 @@ export function createMissionRouter(
"/features/:featureId", "/features/:featureId",
catchTypedHandler(async (req, res) => { catchTypedHandler(async (req, res) => {
const { featureId } = req.params; const { featureId } = req.params;
const force = req.query?.force === "true";
if (!validateFeatureId(featureId)) { if (!validateFeatureId(featureId)) {
throw badRequest("Invalid feature ID format"); throw badRequest("Invalid feature ID format");
@@ -2429,7 +2454,18 @@ export function createMissionRouter(
throw notFound("Feature not found"); throw notFound("Feature not found");
} }
missionStore.deleteFeature(featureId); try {
missionStore.deleteFeature(featureId, force);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("linked to task")) {
throw conflict(errMsg);
}
if (errMsg.includes("not found")) {
throw notFound("Feature not found");
}
throw err;
}
res.status(204).send(); res.status(204).send();
}) })
); );

View File

@@ -46,6 +46,9 @@ describe("workflow-step readonly allowlist policy", () => {
"fn_milestone_add", "fn_milestone_add",
"fn_slice_add", "fn_slice_add",
"fn_feature_add", "fn_feature_add",
"fn_feature_delete",
"fn_slice_delete",
"fn_milestone_delete",
"fn_slice_activate", "fn_slice_activate",
"fn_feature_link_task", "fn_feature_link_task",
"fn_feature_update", "fn_feature_update",

View File

@@ -33,6 +33,9 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_milestone_add", "fn_milestone_add",
"fn_slice_add", "fn_slice_add",
"fn_feature_add", "fn_feature_add",
"fn_feature_delete",
"fn_slice_delete",
"fn_milestone_delete",
"fn_slice_activate", "fn_slice_activate",
"fn_feature_link_task", "fn_feature_link_task",
"fn_feature_update", "fn_feature_update",