feat(KB-635): add mission management CLI commands and engine integration
- Add comprehensive mission CLI commands: create, list, show, start, abort, delete, and next - Add Pi extension tools for mission operations: list_missions, show_mission, start_mission, abort_mission - Implement mission-aware scheduler with task start handling and autoAdvance support - Add sliceId to Task type and autoAdvance flag to Mission type for slice sequencing - Add MissionStore helper methods for mission lifecycle operations - Include comprehensive tests for CLI commands and Pi extension mission tools
This commit is contained in:
515
packages/cli/src/commands/mission.test.ts
Normal file
515
packages/cli/src/commands/mission.test.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:readline/promises before importing the module under test
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @fusion/core before importing the module under test
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
MissionStore: vi.fn(),
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
triage: "Triage",
|
||||
todo: "Todo",
|
||||
"in-progress": "In Progress",
|
||||
"in-review": "In Review",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock project-resolver
|
||||
vi.mock("../project-resolver.js", () => ({
|
||||
getStore: vi.fn().mockResolvedValue({
|
||||
getMissionStore: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { getStore } from "../project-resolver.js";
|
||||
|
||||
// Import after mocks
|
||||
const {
|
||||
runMissionCreate,
|
||||
runMissionList,
|
||||
runMissionShow,
|
||||
runMissionDelete,
|
||||
runMissionActivateSlice,
|
||||
} = await import("./mission.js");
|
||||
|
||||
// Helper to mock console output
|
||||
function captureConsole() {
|
||||
const logs: string[] = [];
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map(String).join(" "));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
logs.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
return {
|
||||
logs,
|
||||
restore() {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock MissionStore
|
||||
function createMockMissionStore(overrides = {}) {
|
||||
return {
|
||||
createMission: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
title: "Test Mission",
|
||||
status: "planning",
|
||||
description: "Test description",
|
||||
}),
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", title: "Mission 1", status: "active" },
|
||||
{ id: "M-002", title: "Mission 2", status: "planning" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
title: "Test Mission",
|
||||
status: "active",
|
||||
description: "Test description",
|
||||
milestones: [
|
||||
{
|
||||
id: "MS-001",
|
||||
title: "Milestone 1",
|
||||
status: "active",
|
||||
slices: [
|
||||
{
|
||||
id: "SL-001",
|
||||
title: "Slice 1",
|
||||
status: "active",
|
||||
features: [
|
||||
{ id: "F-001", title: "Feature 1", status: "done", taskId: "FN-001" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
getMission: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
title: "Test Mission",
|
||||
status: "active",
|
||||
}),
|
||||
deleteMission: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue({
|
||||
id: "SL-001",
|
||||
title: "Test Slice",
|
||||
status: "pending",
|
||||
}),
|
||||
activateSlice: vi.fn().mockReturnValue({
|
||||
id: "SL-001",
|
||||
title: "Test Slice",
|
||||
status: "active",
|
||||
activatedAt: "2026-04-01T00:00:00Z",
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mission commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("runMissionCreate", () => {
|
||||
it("creates mission with correct data", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
await runMissionCreate("Test Mission", "Test description");
|
||||
|
||||
expect(mockMissionStore.createMission).toHaveBeenCalledWith({
|
||||
title: "Test Mission",
|
||||
description: "Test description",
|
||||
});
|
||||
expect(consoleCapture.logs).toContain(" ✓ Created M-001: Test Mission");
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates mission with title only (no description)", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
await runMissionCreate("Test Mission", undefined);
|
||||
|
||||
expect(mockMissionStore.createMission).toHaveBeenCalledWith({
|
||||
title: "Test Mission",
|
||||
description: undefined,
|
||||
});
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prompts interactively when title not provided", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn()
|
||||
.mockResolvedValueOnce("Interactive Title")
|
||||
.mockResolvedValueOnce("Interactive Description"),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
await runMissionCreate(undefined, undefined);
|
||||
|
||||
expect(createInterface).toHaveBeenCalled();
|
||||
expect(mockRl.question).toHaveBeenCalledWith("Mission title: ");
|
||||
expect(mockMissionStore.createMission).toHaveBeenCalledWith({
|
||||
title: "Interactive Title",
|
||||
description: "Interactive Description",
|
||||
});
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits with error when interactive title is empty", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn().mockResolvedValueOnce(""), // Empty title
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runMissionCreate(undefined, undefined);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Title is required");
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMissionList", () => {
|
||||
it("displays missions in formatted output", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
// Override process.exit for this test
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
|
||||
try {
|
||||
await runMissionList();
|
||||
} catch (e) {
|
||||
// Expected process.exit(0)
|
||||
}
|
||||
|
||||
expect(mockMissionStore.listMissions).toHaveBeenCalled();
|
||||
expect(consoleCapture.logs.some(log => log.includes("Mission 1"))).toBe(true);
|
||||
expect(consoleCapture.logs.some(log => log.includes("Mission 2"))).toBe(true);
|
||||
|
||||
mockExit.mockRestore();
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows empty message when no missions", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
|
||||
try {
|
||||
await runMissionList();
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(consoleCapture.logs.some(log => log.includes("No missions yet"))).toBe(true);
|
||||
|
||||
mockExit.mockRestore();
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMissionShow", () => {
|
||||
it("displays hierarchy correctly", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
await runMissionShow("M-001");
|
||||
|
||||
expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001");
|
||||
expect(consoleCapture.logs.some(log => log.includes("Test Mission"))).toBe(true);
|
||||
expect(consoleCapture.logs.some(log => log.includes("Milestone 1"))).toBe(true);
|
||||
expect(consoleCapture.logs.some(log => log.includes("Slice 1"))).toBe(true);
|
||||
expect(consoleCapture.logs.some(log => log.includes("Feature 1"))).toBe(true);
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits with error when mission not found", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runMissionShow("M-999");
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Mission M-999 not found");
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
|
||||
it("exits with error when id not provided", async () => {
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runMissionShow("");
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Usage: fn mission show <id>");
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMissionDelete", () => {
|
||||
it("requires confirmation without --force", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn().mockResolvedValueOnce("n"), // User says no
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
await runMissionDelete("M-001", false);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockRl.question).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Are you sure you want to delete")
|
||||
);
|
||||
expect(mockMissionStore.deleteMission).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
mockExit.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes mission with --force", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
await runMissionDelete("M-001", true);
|
||||
|
||||
expect(mockMissionStore.deleteMission).toHaveBeenCalledWith("M-001");
|
||||
expect(consoleCapture.logs.some(log => log.includes("Deleted M-001"))).toBe(true);
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits with error when mission not found", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getMission: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runMissionDelete("M-999", true);
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("✗ Mission M-999 not found");
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMissionActivateSlice", () => {
|
||||
it("calls MissionStore.activateSlice()", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
|
||||
try {
|
||||
await runMissionActivateSlice("SL-001");
|
||||
|
||||
expect(mockMissionStore.getSlice).toHaveBeenCalledWith("SL-001");
|
||||
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-001");
|
||||
expect(consoleCapture.logs.some(log => log.includes("Activated SL-001"))).toBe(true);
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("exits with error when slice not found", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getSlice: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runMissionActivateSlice("SL-999");
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("✗ Slice SL-999 not found");
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
|
||||
it("exits with error when slice is not pending", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", status: "active" }),
|
||||
});
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => mockMissionStore,
|
||||
} as any);
|
||||
|
||||
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await runMissionActivateSlice("SL-001");
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("✗ Slice SL-001 is not pending (status: active)");
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
461
packages/cli/src/commands/mission.ts
Normal file
461
packages/cli/src/commands/mission.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import { MissionStore, type Mission, type Slice, type MissionWithHierarchy, type MilestoneWithSlices, type SliceWithFeatures, type MissionCreateInput, type MilestoneCreateInput, type SliceCreateInput, type FeatureCreateInput } 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> = {
|
||||
planning: "Planning",
|
||||
active: "Active",
|
||||
blocked: "Blocked",
|
||||
complete: "Complete",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const MILESTONE_STATUS_LABELS: Record<string, string> = {
|
||||
planning: "Planning",
|
||||
active: "Active",
|
||||
blocked: "Blocked",
|
||||
complete: "Complete",
|
||||
};
|
||||
|
||||
const SLICE_STATUS_LABELS: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
active: "Active",
|
||||
complete: "Complete",
|
||||
};
|
||||
|
||||
const FEATURE_STATUS_LABELS: Record<string, string> = {
|
||||
defined: "Defined",
|
||||
triaged: "Triaged",
|
||||
"in-progress": "In Progress",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
// ── Mission Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new mission with optional title and description.
|
||||
* 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 mission = missionStore.createMission({
|
||||
title: title.trim(),
|
||||
description: description?.trim() || undefined,
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Created ${mission.id}: ${mission.title}`);
|
||||
console.log(` Status: ${MISSION_STATUS_LABELS[mission.status]}`);
|
||||
if (mission.description) {
|
||||
console.log(` Description: ${mission.description.slice(0, 80)}${mission.description.length > 80 ? "…" : ""}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all missions with status summary.
|
||||
*/
|
||||
export async function runMissionList(projectName?: string) {
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const missions = missionStore.listMissions();
|
||||
|
||||
if (missions.length === 0) {
|
||||
console.log("\n No missions yet. Create one with: fn mission create\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
// Group by status
|
||||
const byStatus: Record<string, typeof missions> = {};
|
||||
for (const mission of missions) {
|
||||
if (!byStatus[mission.status]) {
|
||||
byStatus[mission.status] = [];
|
||||
}
|
||||
byStatus[mission.status].push(mission);
|
||||
}
|
||||
|
||||
// Display by status in order
|
||||
const statusOrder = ["planning", "active", "blocked", "complete", "archived"];
|
||||
for (const status of statusOrder) {
|
||||
const statusMissions = byStatus[status];
|
||||
if (!statusMissions || statusMissions.length === 0) continue;
|
||||
|
||||
const label = MISSION_STATUS_LABELS[status];
|
||||
const dot = status === "active" ? "●" : status === "blocked" ? "⚠" : status === "complete" ? "✓" : "○";
|
||||
|
||||
console.log(` ${dot} ${label} (${statusMissions.length})`);
|
||||
for (const m of statusMissions) {
|
||||
const desc = m.description ? ` — ${m.description.slice(0, 50)}${m.description.length > 50 ? "…" : ""}` : "";
|
||||
console.log(` ${m.id} ${m.title}${desc}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display mission details with full hierarchy:
|
||||
* Mission → Milestones → Slices → Features
|
||||
*/
|
||||
export async function runMissionShow(id: string, projectName?: string) {
|
||||
if (!id) {
|
||||
console.error("Usage: fn mission show <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const mission = missionStore.getMissionWithHierarchy(id);
|
||||
if (!mission) {
|
||||
console.error(`Mission ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${mission.id}: ${mission.title}`);
|
||||
console.log(` Status: ${MISSION_STATUS_LABELS[mission.status]}`);
|
||||
if (mission.description) {
|
||||
console.log(` Description: ${mission.description}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (mission.milestones.length === 0) {
|
||||
console.log(" No milestones yet.");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(" Milestones:");
|
||||
for (const milestone of mission.milestones) {
|
||||
const statusIcon = milestone.status === "complete" ? "✓" : milestone.status === "active" ? "●" : "○";
|
||||
console.log(` ${statusIcon} ${milestone.id}: ${milestone.title} (${MILESTONE_STATUS_LABELS[milestone.status]})`);
|
||||
|
||||
if (milestone.slices.length === 0) {
|
||||
console.log(" No slices");
|
||||
} else {
|
||||
for (const slice of milestone.slices) {
|
||||
const sliceIcon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○";
|
||||
const activated = slice.activatedAt ? ` [activated: ${new Date(slice.activatedAt).toLocaleDateString()}]` : "";
|
||||
console.log(` ${sliceIcon} ${slice.id}: ${slice.title} (${SLICE_STATUS_LABELS[slice.status]})${activated}`);
|
||||
|
||||
if (slice.features.length === 0) {
|
||||
console.log(" No features");
|
||||
} else {
|
||||
for (const feature of slice.features) {
|
||||
const featureIcon = feature.status === "done" ? "✓" : feature.status === "in-progress" ? "▸" : feature.status === "triaged" ? "●" : "○";
|
||||
const taskLink = feature.taskId ? ` → ${feature.taskId}` : "";
|
||||
console.log(` ${featureIcon} ${feature.id}: ${feature.title} (${FEATURE_STATUS_LABELS[feature.status]})${taskLink}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a mission with optional force flag to skip confirmation.
|
||||
*/
|
||||
export async function runMissionDelete(id: string, force?: boolean, projectName?: string) {
|
||||
if (!id) {
|
||||
console.error("Usage: fn mission delete <id> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
// Check if mission exists
|
||||
const mission = missionStore.getMission(id);
|
||||
if (!mission) {
|
||||
console.error(`✗ Mission ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prompt for confirmation unless force is used
|
||||
if (!force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await rl.question(`Are you sure you want to delete ${id}: "${mission.title}"? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
if (trimmed !== "y" && trimmed !== "yes") {
|
||||
console.log("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
missionStore.deleteMission(id);
|
||||
console.log();
|
||||
console.log(` ✓ Deleted ${id}: "${mission.title}"`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a pending slice by ID.
|
||||
*/
|
||||
export async function runMissionActivateSlice(id: string, projectName?: string) {
|
||||
if (!id) {
|
||||
console.error("Usage: fn mission activate-slice <slice-id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
// Check if slice exists
|
||||
const slice = missionStore.getSlice(id);
|
||||
if (!slice) {
|
||||
console.error(`✗ Slice ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (slice.status !== "pending") {
|
||||
console.error(`✗ Slice ${id} is not pending (status: ${slice.status})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const activated = missionStore.activateSlice(id);
|
||||
console.log();
|
||||
console.log(` ✓ Activated ${activated.id}: "${activated.title}"`);
|
||||
console.log(` Status: ${SLICE_STATUS_LABELS[activated.status]}`);
|
||||
if (activated.activatedAt) {
|
||||
console.log(` Activated at: ${new Date(activated.activatedAt).toLocaleString()}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Milestone Commands ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a milestone to a mission.
|
||||
*/
|
||||
export async function runMilestoneAdd(
|
||||
missionId: string,
|
||||
titleArg?: string,
|
||||
descriptionArg?: 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,
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Added ${milestone.id}: "${milestone.title}" to ${missionId}`);
|
||||
console.log(` Status: ${MILESTONE_STATUS_LABELS[milestone.status]}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Slice Commands ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a slice to a milestone.
|
||||
*/
|
||||
export async function runSliceAdd(
|
||||
milestoneId: string,
|
||||
titleArg?: string,
|
||||
descriptionArg?: 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,
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Added ${slice.id}: "${slice.title}" to ${milestoneId}`);
|
||||
console.log(` Status: ${SLICE_STATUS_LABELS[slice.status]}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Feature Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a feature to a slice.
|
||||
*/
|
||||
export async function runFeatureAdd(
|
||||
sliceId: string,
|
||||
titleArg?: string,
|
||||
descriptionArg?: string,
|
||||
acceptanceCriteriaArg?: 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;
|
||||
|
||||
// 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): ");
|
||||
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,
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Added ${feature.id}: "${feature.title}" to ${sliceId}`);
|
||||
console.log(` Status: ${FEATURE_STATUS_LABELS[feature.status]}`);
|
||||
if (feature.acceptanceCriteria) {
|
||||
console.log(` Acceptance: ${feature.acceptanceCriteria.slice(0, 60)}${feature.acceptanceCriteria.length > 60 ? "…" : ""}`);
|
||||
}
|
||||
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>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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 {
|
||||
console.error(`✗ Task ${taskId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user