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:
@@ -220,6 +220,9 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
"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",
|
||||
@@ -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", () => {
|
||||
it("returns error when slice is already active", async () => {
|
||||
const missionTool = api.tools.get("fn_mission_create")!;
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user