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

@@ -17,11 +17,12 @@ function createTaskInDb(
taskId: string,
description = "Test task",
status?: string,
options?: { column?: string; deletedAt?: string | null },
): void {
const now = new Date().toISOString();
database.prepare(
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`
).run(taskId, description, "triage", status ?? null, now, now);
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt, "deletedAt") VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(taskId, description, options?.column ?? "triage", status ?? null, now, now, options?.deletedAt ?? null);
}
describe("MissionStore", () => {
@@ -1211,7 +1212,7 @@ describe("MissionStore", () => {
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 milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
@@ -1222,6 +1223,79 @@ describe("MissionStore", () => {
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", () => {
createTaskInDb(db, "FN-001");
@@ -1365,6 +1439,38 @@ describe("MissionStore", () => {
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", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
@@ -1380,6 +1486,38 @@ describe("MissionStore", () => {
expect(store.getSlice(slice.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 ───────────────────────────────────────────────
@@ -1836,7 +1974,7 @@ describe("MissionStore", () => {
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
store.linkFeatureToTask(feature.id, "FN-001");
store.deleteFeature(feature.id);
store.deleteFeature(feature.id, true);
expect(created).toHaveBeenCalledTimes(1);
// 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.
*
* @param id - Milestone ID
* @param force - Override linked live-task guard for child features
* @throws Error if milestone not found
*/
deleteMilestone(id: string): void {
deleteMilestone(id: string, force = false): void {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
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.emit("milestone:deleted", id);
@@ -1621,17 +1639,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* Cascades to delete all features.
*
* @param id - Slice ID
* @param force - Override linked live-task guard for child features
* @throws Error if slice not found
*/
deleteSlice(id: string): void {
deleteSlice(id: string, force = false): void {
const slice = this.getSlice(id);
if (!slice) {
throw new Error(`Slice ${id} not found`);
}
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.emit("slice:deleted", id);
@@ -1906,26 +1942,46 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* Delete a feature.
*
* @param id - Feature ID
* @param force - Override linked live-task guard
* @throws Error if feature not found
*/
deleteFeature(id: string): void {
deleteFeature(id: string, force = false): void {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
const sliceId = feature.sliceId;
const slice = this.getSlice(sliceId);
const milestoneId = slice?.milestoneId;
if (milestoneId) {
const managedAssertion = this.listContractAssertions(milestoneId)
.find((assertion) => assertion.sourceFeatureId === feature.id);
if (managedAssertion) {
this.deleteContractAssertion(managedAssertion.id);
if (feature.taskId) {
const linkedTask = this.db.prepare(
`SELECT id, "column" FROM tasks WHERE id = ? AND "deletedAt" IS NULL`
).get(feature.taskId) as { id: string; column: string } | undefined;
const linkedToLiveTask = linkedTask && linkedTask.column !== "archived";
if (linkedToLiveTask && !force) {
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.emit("feature:deleted", id);
@@ -1934,6 +1990,24 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
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 } {
const acceptanceCriteria = feature.acceptanceCriteria?.trim();
if (acceptanceCriteria) {