feat(KB-635): integrate mission workflows across CLI, extension, and engine

- Add mission CLI commands and pi extension tools for creating missions, milestones, slices, features, and task links
- Extend core mission storage and schema migrations with auto-advance support, task slice linkage, and comment normalization coverage
- Wire mission-aware scheduler and executor behavior so linked features progress with task execution and completed slices can auto-activate follow-on work
- Add regression tests for mission CLI parsing, extension behaviors, scheduler mission semantics, and executor integration
This commit is contained in:
gsxdsm
2026-04-01 13:45:56 -07:00
parent a63ee7443a
commit e8b6b98d57
13 changed files with 984 additions and 229 deletions

View File

@@ -407,7 +407,7 @@ describe("kb pi extension", () => {
const tool = api.tools.get("kb_mission_create")!;
const result = await tool.execute(
"call-1",
{ title: "Test Mission", description: "Test description" },
{ title: "Test Mission", description: "Test description", autoAdvance: true },
undefined,
undefined,
makeCtx(tmpDir),
@@ -415,8 +415,10 @@ describe("kb pi extension", () => {
expect(result.details.missionId).toBeDefined();
expect(result.details.title).toBe("Test Mission");
expect(result.details.autoAdvance).toBe(true);
expect(result.content[0].text).toContain("Created");
expect(result.content[0].text).toContain("Test Mission");
expect(result.content[0].text).toContain("Auto-advance: enabled");
});
});
@@ -443,6 +445,7 @@ describe("kb pi extension", () => {
expect(result.details.count).toBeGreaterThanOrEqual(1);
expect(result.content[0].text).toContain("Missions");
expect(result.content[0].text).toContain("Summary:");
});
});
@@ -512,33 +515,272 @@ describe("kb pi extension", () => {
});
});
describe("kb_milestone_add", () => {
it("creates a milestone in the mission store", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const result = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone", description: "Phase 1" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getMilestone(result.details.milestoneId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Milestone");
expect(persisted?.description).toBe("Phase 1");
});
});
describe("kb_slice_add", () => {
it("creates a slice in the mission store", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
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 result = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice", description: "Work unit" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getSlice(result.details.sliceId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Slice");
expect(persisted?.description).toBe("Work unit");
});
});
describe("kb_feature_add", () => {
it("creates a feature in the mission store", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const featureTool = api.tools.get("kb_feature_add")!;
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 result = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Deliverable", acceptanceCriteria: "Must pass" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(result.details.featureId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Feature");
expect(persisted?.acceptanceCriteria).toBe("Must pass");
});
});
describe("kb_slice_activate", () => {
it("returns error when slice is already active", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const activateTool = api.tools.get("kb_slice_activate")!;
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),
);
await activateTool.execute("sl2", { id: slice.details.sliceId }, undefined, undefined, makeCtx(tmpDir));
const result = await activateTool.execute("sl3", { id: slice.details.sliceId }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not pending");
});
it("activates slice and updates status", async () => {
// This test would need a full mission hierarchy setup
// For now, verify the tool exists and has correct parameters
const tool = api.tools.get("kb_slice_activate")!;
expect(tool).toBeDefined();
expect(tool.parameters.properties.id).toBeDefined();
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const activateTool = api.tools.get("kb_slice_activate")!;
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 result = await activateTool.execute(
"sl2",
{ id: slice.details.sliceId },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getSlice(slice.details.sliceId);
expect(result.content[0].text).toContain("Activated");
expect(result.details.status).toBe("active");
expect(persisted?.status).toBe("active");
});
});
describe("kb_feature_link_task", () => {
it("returns error when task is missing", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const featureTool = api.tools.get("kb_feature_add")!;
const linkTool = api.tools.get("kb_feature_link_task")!;
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 result = await linkTool.execute(
"l0",
{ featureId: feature.details.featureId, taskId: "FN-999" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task FN-999 not found");
});
it("links feature to task", async () => {
// Create a task first
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const featureTool = api.tools.get("kb_feature_add")!;
const createTaskTool = api.tools.get("kb_task_create")!;
const linkTool = api.tools.get("kb_feature_link_task")!;
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 taskResult = await createTaskTool.execute(
"c1",
"t1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
// Verify the tool exists with correct parameters
const tool = api.tools.get("kb_feature_link_task")!;
expect(tool).toBeDefined();
expect(tool.parameters.properties.featureId).toBeDefined();
expect(tool.parameters.properties.taskId).toBeDefined();
const result = await linkTool.execute(
"l1",
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const missionStore = store.getMissionStore();
const persisted = missionStore.getFeature(feature.details.featureId);
const linkedTask = await store.getTask(taskResult.details.taskId);
expect(result.content[0].text).toContain(taskResult.details.taskId);
expect(result.details.taskId).toBe(taskResult.details.taskId);
expect(persisted?.taskId).toBe(taskResult.details.taskId);
expect(persisted?.status).toBe("triaged");
expect(linkedTask.sliceId).toBe(slice.details.sliceId);
});
});
});

View File

@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const commandMocks = vi.hoisted(() => ({
runDashboard: vi.fn(),
runTaskCreate: vi.fn(),
runTaskList: vi.fn(),
runTaskMove: vi.fn(),
runTaskMerge: vi.fn(),
runTaskUpdate: vi.fn(),
runTaskLog: vi.fn(),
runTaskLogs: vi.fn(),
runTaskShow: vi.fn(),
runTaskAttach: vi.fn(),
runTaskPause: vi.fn(),
runTaskUnpause: vi.fn(),
runTaskImportFromGitHub: vi.fn(),
runTaskDuplicate: vi.fn(),
runTaskArchive: vi.fn(),
runTaskUnarchive: vi.fn(),
runTaskRefine: vi.fn(),
runTaskPlan: vi.fn(),
runTaskDelete: vi.fn(),
runTaskRetry: vi.fn(),
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
runTaskPrCreate: vi.fn(),
runSettingsShow: vi.fn(),
runSettingsSet: vi.fn(),
runSettingsExport: vi.fn(),
runSettingsImport: vi.fn(),
runGitStatus: vi.fn(),
runGitFetch: vi.fn(),
runGitPull: vi.fn(),
runGitPush: vi.fn(),
runBackupCreate: vi.fn(),
runBackupList: vi.fn(),
runBackupRestore: vi.fn(),
runBackupCleanup: vi.fn(),
runMissionCreate: vi.fn(),
runMissionList: vi.fn(),
runMissionShow: vi.fn(),
runMissionDelete: vi.fn(),
runMissionActivateSlice: vi.fn(),
runProjectList: vi.fn(),
runProjectAdd: vi.fn(),
runProjectRemove: vi.fn(),
runProjectInfo: vi.fn(),
}));
vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
vi.mock("./commands/task.js", () => ({
runTaskCreate: commandMocks.runTaskCreate,
runTaskList: commandMocks.runTaskList,
runTaskMove: commandMocks.runTaskMove,
runTaskMerge: commandMocks.runTaskMerge,
runTaskUpdate: commandMocks.runTaskUpdate,
runTaskLog: commandMocks.runTaskLog,
runTaskLogs: commandMocks.runTaskLogs,
runTaskShow: commandMocks.runTaskShow,
runTaskAttach: commandMocks.runTaskAttach,
runTaskPause: commandMocks.runTaskPause,
runTaskUnpause: commandMocks.runTaskUnpause,
runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub,
runTaskDuplicate: commandMocks.runTaskDuplicate,
runTaskArchive: commandMocks.runTaskArchive,
runTaskUnarchive: commandMocks.runTaskUnarchive,
runTaskRefine: commandMocks.runTaskRefine,
runTaskPlan: commandMocks.runTaskPlan,
runTaskDelete: commandMocks.runTaskDelete,
runTaskRetry: commandMocks.runTaskRetry,
runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer,
runTaskPrCreate: commandMocks.runTaskPrCreate,
}));
vi.mock("./commands/settings.js", () => ({
runSettingsShow: commandMocks.runSettingsShow,
runSettingsSet: commandMocks.runSettingsSet,
}));
vi.mock("./commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport }));
vi.mock("./commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport }));
vi.mock("./commands/git.js", () => ({
runGitStatus: commandMocks.runGitStatus,
runGitFetch: commandMocks.runGitFetch,
runGitPull: commandMocks.runGitPull,
runGitPush: commandMocks.runGitPush,
}));
vi.mock("./commands/backup.js", () => ({
runBackupCreate: commandMocks.runBackupCreate,
runBackupList: commandMocks.runBackupList,
runBackupRestore: commandMocks.runBackupRestore,
runBackupCleanup: commandMocks.runBackupCleanup,
}));
vi.mock("./commands/mission.js", () => ({
runMissionCreate: commandMocks.runMissionCreate,
runMissionList: commandMocks.runMissionList,
runMissionShow: commandMocks.runMissionShow,
runMissionDelete: commandMocks.runMissionDelete,
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
}));
vi.mock("./commands/project.js", () => ({
runProjectList: commandMocks.runProjectList,
runProjectAdd: commandMocks.runProjectAdd,
runProjectRemove: commandMocks.runProjectRemove,
runProjectInfo: commandMocks.runProjectInfo,
}));
const originalArgv = process.argv;
const originalExit = process.exit;
const originalEnvProject = process.env.FN_PROJECT;
let importCounter = 0;
async function runBin(args: string[]) {
process.argv = ["node", "bin.ts", ...args];
importCounter += 1;
if (importCounter === 1) {
await import("./bin.ts?test=1");
} else if (importCounter === 2) {
await import("./bin.ts?test=2");
} else if (importCounter === 3) {
await import("./bin.ts?test=3");
} else if (importCounter === 4) {
await import("./bin.ts?test=4");
} else {
await import("./bin.ts?test=5");
}
}
describe("bin mission command integration", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.FN_PROJECT;
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
});
afterEach(() => {
process.argv = originalArgv;
process.exit = originalExit;
if (originalEnvProject === undefined) {
delete process.env.FN_PROJECT;
} else {
process.env.FN_PROJECT = originalEnvProject;
}
});
it("routes mission create with multi-word description and project flag", async () => {
await runBin(["mission", "create", "Test Mission", "Detailed", "mission", "description", "--project", "demo"]);
expect(commandMocks.runMissionCreate).toHaveBeenCalledWith(
"Test Mission",
"Detailed mission description",
"demo",
);
});
it("routes mission list alias", async () => {
await runBin(["mission", "ls"]);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
});
it("routes mission show alias", async () => {
await runBin(["mission", "info", "M-001"]);
expect(commandMocks.runMissionShow).toHaveBeenCalledWith("M-001", undefined);
});
it("routes mission delete with force flag", async () => {
await runBin(["mission", "delete", "M-001", "--force"]);
expect(commandMocks.runMissionDelete).toHaveBeenCalledWith("M-001", true, undefined);
});
it("routes mission activate-slice", async () => {
await runBin(["mission", "activate-slice", "SL-001"]);
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
});
});

View File

@@ -101,10 +101,10 @@ Usage:
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn mission create [title] [description] Create a new mission
fn mission create [title] [description...] Create a new mission
fn mission list List all missions
fn mission show <id> Show mission with hierarchy
fn mission delete <id> [--force] Delete mission
fn mission delete <id> [--force] Delete a mission
fn mission activate-slice <slice-id> Activate a pending slice
Options:
@@ -711,21 +711,8 @@ async function main() {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const titleParts: string[] = [];
for (let i = 2; i < args.length; i++) {
titleParts.push(args[i]);
}
const fullInput = titleParts.join(" ");
// Split on first space to separate title and description if provided
const firstSpaceIdx = fullInput.indexOf(" ");
let title: string | undefined;
let description: string | undefined;
if (firstSpaceIdx > 0) {
title = fullInput.slice(0, firstSpaceIdx);
description = fullInput.slice(firstSpaceIdx + 1).trim();
} else {
title = fullInput || undefined;
}
const title = args[2];
const description = args.length > 3 ? args.slice(3).join(" ") : undefined;
await runMissionCreate(title, description, projectName);
break;
}

View File

@@ -1,10 +1,10 @@
import { MissionStore, type Mission, type Slice, type MissionWithHierarchy, type MilestoneWithSlices, type SliceWithFeatures, type MissionCreateInput, type MilestoneCreateInput, type SliceCreateInput, type FeatureCreateInput } from "@fusion/core";
import { type MissionStatus, type MilestoneStatus, type SliceStatus, type FeatureStatus } from "@fusion/core";
import { createInterface } from "node:readline/promises";
import { getStore } from "../project-resolver.js";
// ── Status Labels for Display ───────────────────────────────────────────────
const MISSION_STATUS_LABELS: Record<string, string> = {
const MISSION_STATUS_LABELS: Record<MissionStatus, string> = {
planning: "Planning",
active: "Active",
blocked: "Blocked",
@@ -12,26 +12,54 @@ const MISSION_STATUS_LABELS: Record<string, string> = {
archived: "Archived",
};
const MILESTONE_STATUS_LABELS: Record<string, string> = {
const MILESTONE_STATUS_LABELS: Record<MilestoneStatus, string> = {
planning: "Planning",
active: "Active",
blocked: "Blocked",
complete: "Complete",
};
const SLICE_STATUS_LABELS: Record<string, string> = {
const SLICE_STATUS_LABELS: Record<SliceStatus, string> = {
pending: "Pending",
active: "Active",
complete: "Complete",
};
const FEATURE_STATUS_LABELS: Record<string, string> = {
const FEATURE_STATUS_LABELS: Record<FeatureStatus, string> = {
defined: "Defined",
triaged: "Triaged",
"in-progress": "In Progress",
done: "Done",
};
async function promptForTitleAndDescription(
titleArg: string | undefined,
titlePrompt: string,
descriptionPrompt: string,
): Promise<{ title: string; description?: string }> {
let title = titleArg;
let description: string | undefined;
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question(titlePrompt);
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question(descriptionPrompt);
rl.close();
}
return {
title: title.trim(),
description: description?.trim() || undefined,
};
}
// ── Mission Commands ─────────────────────────────────────────────────────────
/**
@@ -39,30 +67,20 @@ const FEATURE_STATUS_LABELS: Record<string, string> = {
* If arguments are omitted, prompts interactively.
*/
export async function runMissionCreate(titleArg?: string, descriptionArg?: string, projectName?: string) {
let title = titleArg;
let description = descriptionArg;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Mission title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Mission description (optional): ");
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(
titleArg,
"Mission title: ",
"Mission description (optional): ",
);
const mission = missionStore.createMission({
title: title.trim(),
description: description?.trim() || undefined,
title,
description,
});
console.log();
@@ -254,54 +272,35 @@ export async function runMissionActivateSlice(id: string, projectName?: string)
console.log();
}
// ── Milestone Commands ──────────────────────────────────────────────────────
/**
* Add a milestone to a mission.
*/
export async function runMilestoneAdd(
missionId: string,
titleArg?: string,
descriptionArg?: string,
projectName?: string
projectName?: string,
) {
if (!missionId) {
console.error("Usage: fn mission add-milestone <mission-id> [title] [description]");
process.exit(1);
}
let title = titleArg;
let description = descriptionArg;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Milestone title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Milestone description (optional): ");
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if mission exists
const mission = missionStore.getMission(missionId);
if (!mission) {
console.error(`✗ Mission ${missionId} not found`);
process.exit(1);
}
const milestone = missionStore.addMilestone(missionId, {
title: title.trim(),
description: description?.trim() || undefined,
});
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(
titleArg,
"Milestone title: ",
"Milestone description (optional): ",
);
const milestone = missionStore.addMilestone(missionId, { title, description });
console.log();
console.log(` ✓ Added ${milestone.id}: "${milestone.title}" to ${missionId}`);
@@ -309,54 +308,35 @@ export async function runMilestoneAdd(
console.log();
}
// ── Slice Commands ───────────────────────────────────────────────────────────
/**
* Add a slice to a milestone.
*/
export async function runSliceAdd(
milestoneId: string,
titleArg?: string,
descriptionArg?: string,
projectName?: string
projectName?: string,
) {
if (!milestoneId) {
console.error("Usage: fn mission add-slice <milestone-id> [title] [description]");
process.exit(1);
}
let title = titleArg;
let description = descriptionArg;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Slice title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Slice description (optional): ");
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if milestone exists
const milestone = missionStore.getMilestone(milestoneId);
if (!milestone) {
console.error(`✗ Milestone ${milestoneId} not found`);
process.exit(1);
}
const slice = missionStore.addSlice(milestoneId, {
title: title.trim(),
description: description?.trim() || undefined,
});
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(
titleArg,
"Slice title: ",
"Slice description (optional): ",
);
const slice = missionStore.addSlice(milestoneId, { title, description });
console.log();
console.log(` ✓ Added ${slice.id}: "${slice.title}" to ${milestoneId}`);
@@ -364,57 +344,50 @@ export async function runSliceAdd(
console.log();
}
// ── Feature Commands ─────────────────────────────────────────────────────────
/**
* Add a feature to a slice.
*/
export async function runFeatureAdd(
sliceId: string,
titleArg?: string,
descriptionArg?: string,
acceptanceCriteriaArg?: string,
projectName?: string
projectName?: string,
) {
if (!sliceId) {
console.error("Usage: fn mission add-feature <slice-id> [title] [description] [--acceptance-criteria <criteria>]");
process.exit(1);
}
let title = titleArg;
let description = descriptionArg;
let acceptanceCriteria = acceptanceCriteriaArg;
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const slice = missionStore.getSlice(sliceId);
if (!slice) {
console.error(`✗ Slice ${sliceId} not found`);
process.exit(1);
}
let title = titleArg;
let description = descriptionArg?.trim() || undefined;
let acceptanceCriteria = acceptanceCriteriaArg?.trim() || undefined;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Feature title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Feature description (optional): ");
acceptanceCriteria = await rl.question("Acceptance criteria (optional): ");
description = (await rl.question("Feature description (optional): ")).trim() || undefined;
acceptanceCriteria = (await rl.question("Acceptance criteria (optional): ")).trim() || undefined;
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if slice exists
const slice = missionStore.getSlice(sliceId);
if (!slice) {
console.error(`✗ Slice ${sliceId} not found`);
process.exit(1);
}
const feature = missionStore.addFeature(sliceId, {
title: title.trim(),
description: description?.trim() || undefined,
acceptanceCriteria: acceptanceCriteria?.trim() || undefined,
description,
acceptanceCriteria,
});
console.log();
@@ -426,9 +399,6 @@ export async function runFeatureAdd(
console.log();
}
/**
* Link a feature to a task.
*/
export async function runFeatureLinkTask(featureId: string, taskId: string, projectName?: string) {
if (!featureId || !taskId) {
console.error("Usage: fn mission link-feature <feature-id> <task-id>");
@@ -437,15 +407,13 @@ export async function runFeatureLinkTask(featureId: string, taskId: string, proj
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if feature exists
const feature = missionStore.getFeature(featureId);
if (!feature) {
console.error(`✗ Feature ${featureId} not found`);
process.exit(1);
}
// Check if task exists
try {
await store.getTask(taskId);
} catch {
@@ -454,8 +422,10 @@ export async function runFeatureLinkTask(featureId: string, taskId: string, proj
}
const updated = missionStore.linkFeatureToTask(featureId, taskId);
console.log();
console.log(` ✓ Linked ${updated.id}: "${updated.title}" → ${taskId}`);
console.log(` Status: ${FEATURE_STATUS_LABELS[updated.status]}`);
console.log();
}

View File

@@ -997,6 +997,9 @@ export default function kbExtension(pi: ExtensionAPI) {
description: Type.Optional(
Type.String({ description: "Detailed mission objectives and context" })
),
autoAdvance: Type.Optional(
Type.Boolean({ description: "Automatically activate the next pending slice when the current slice completes" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -1008,14 +1011,25 @@ export default function kbExtension(pi: ExtensionAPI) {
description: params.description?.trim(),
});
if (params.autoAdvance !== undefined) {
missionStore.updateMission(mission.id, { autoAdvance: params.autoAdvance });
}
const createdMission = missionStore.getMission(mission.id)!;
return {
content: [
{
type: "text",
text: `Created ${mission.id}: ${mission.title}\nStatus: ${mission.status}`,
text: `Created ${createdMission.id}: ${createdMission.title}\nStatus: ${createdMission.status}${createdMission.autoAdvance ? "\nAuto-advance: enabled" : ""}`,
},
],
details: { missionId: mission.id, title: mission.title, status: mission.status },
details: {
missionId: createdMission.id,
title: createdMission.title,
status: createdMission.status,
autoAdvance: createdMission.autoAdvance ?? false,
},
};
},
});
@@ -1047,12 +1061,24 @@ export default function kbExtension(pi: ExtensionAPI) {
};
}
const summary = {
planning: missions.filter((mission) => mission.status === "planning").length,
active: missions.filter((mission) => mission.status === "active").length,
blocked: missions.filter((mission) => mission.status === "blocked").length,
complete: missions.filter((mission) => mission.status === "complete").length,
archived: missions.filter((mission) => mission.status === "archived").length,
};
const lines: string[] = [];
lines.push(`Missions (${missions.length}):\n`);
lines.push(`Missions (${missions.length})`);
lines.push(
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}\n`,
);
for (const mission of missions) {
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : "○";
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status})`);
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
const autoAdvance = mission.autoAdvance ? " · auto-advance" : "";
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status}${autoAdvance})`);
}
return {
@@ -1401,6 +1427,7 @@ export default function kbExtension(pi: ExtensionAPI) {
}
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
return {
content: [