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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
179
packages/cli/src/bin.test.ts
Normal file
179
packages/cli/src/bin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user