FN-5899: add mission-goal linking commands and tools
Add mission↔goal linkage support across the API, CLI, and pi-extension surfaces. - add mission goal list/link/unlink REST endpoints and dashboard route coverage - add `fn mission goals`, `fn mission link-goal`, and `fn mission unlink-goal` CLI commands with tests - add `fn_mission_list_goals`, `fn_mission_link_goal`, and `fn_mission_unlink_goal` extension tools plus skill/docs updates - add a changeset for the published CLI package release Files changed: .changeset/FN-5899-mission-goal-tooling.md | 5 + docs/cli-reference.md | 11 +- docs/missions.md | 27 ++++ packages/cli/skill/fusion/SKILL.md | 2 +- packages/cli/skill/fusion/references/extension-tools.md | 26 ++++ packages/cli/skill/fusion/references/fusion-capabilities.md | 3 + packages/cli/src/__tests__/bin.test.ts | 21 +++ packages/cli/src/__tests__/extension-mission-goal-tools.test.ts | 140 +++++++++++++++++ packages/cli/src/bin.ts | 32 +++- packages/cli/src/commands/__tests__/mission.test.ts | 99 +++++++++++- packages/cli/src/commands/mission.ts | 102 +++++++++++++- packages/cli/src/extension.ts | 170 +++++++++++++++++++++ packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts | 151 ++++++++++++++++++ packages/dashboard/src/mission-routes.ts | 153 ++++++++++++++++++- 14 files changed, 930 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-5899 Fusion-Task-Lineage: c63b0f4b-c77e-4229-ade4-537a4dda0f5f
This commit is contained in:
@@ -29,7 +29,7 @@ Mission → Milestone → Slice → Feature → Task
|
||||
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
|
||||
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
|
||||
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
|
||||
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update`
|
||||
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update`
|
||||
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show`
|
||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
|
||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||
|
||||
@@ -196,6 +196,32 @@ Show mission details with full hierarchy: milestones → slices → features.
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Mission ID (e.g., M-001) |
|
||||
|
||||
### fn_mission_list_goals
|
||||
|
||||
List goals linked to a mission.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `missionId` | string | ✓ | Mission ID (e.g., M-001) |
|
||||
|
||||
### fn_mission_link_goal
|
||||
|
||||
Link a goal to a mission.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `missionId` | string | ✓ | Mission ID (e.g., M-001) |
|
||||
| `goalId` | string | ✓ | Goal ID (e.g., G-001) |
|
||||
|
||||
### fn_mission_unlink_goal
|
||||
|
||||
Unlink a goal from a mission.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `missionId` | string | ✓ | Mission ID (e.g., M-001) |
|
||||
| `goalId` | string | ✓ | Goal ID (e.g., G-001) |
|
||||
|
||||
### fn_mission_backfill_assertions
|
||||
|
||||
Backfill mission assertions by deriving and linking one store-managed assertion for each feature without linked assertions. Supports dry-run mode.
|
||||
|
||||
@@ -48,6 +48,9 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_goal_archive` | Archive a goal by ID. |
|
||||
| `fn_goal_show` | Show full details for a single goal by ID. |
|
||||
| `fn_mission_show` | Show mission details with full hierarchy: milestones → slices → features. |
|
||||
| `fn_mission_list_goals` | List goals linked to a mission. |
|
||||
| `fn_mission_link_goal` | Link a goal to a mission. |
|
||||
| `fn_mission_unlink_goal` | Unlink a goal from a mission. |
|
||||
| `fn_mission_backfill_assertions` | Backfill mission assertions by deriving and linking one store-managed assertion for each feature without linked assertions. Supports dry-run mode. |
|
||||
| `fn_mission_delete` | Delete a mission and all its milestones, slices, and features. Cannot be undone. |
|
||||
| `fn_mission_update` | Update an existing mission's title or description. Partial patches leave untouched fields intact. |
|
||||
|
||||
@@ -59,6 +59,9 @@ const commandMocks = vi.hoisted(() => ({
|
||||
runMissionShow: vi.fn(),
|
||||
runMissionDelete: vi.fn(),
|
||||
runMissionActivateSlice: vi.fn(),
|
||||
runMissionLinkGoal: vi.fn(),
|
||||
runMissionUnlinkGoal: vi.fn(),
|
||||
runMissionGoals: vi.fn(),
|
||||
runGoalsList: vi.fn(),
|
||||
runGoalsCreate: vi.fn(),
|
||||
runGoalsArchive: vi.fn(),
|
||||
@@ -200,6 +203,9 @@ vi.mock("../commands/mission.js", () => ({
|
||||
runMissionShow: commandMocks.runMissionShow,
|
||||
runMissionDelete: commandMocks.runMissionDelete,
|
||||
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
|
||||
runMissionLinkGoal: commandMocks.runMissionLinkGoal,
|
||||
runMissionUnlinkGoal: commandMocks.runMissionUnlinkGoal,
|
||||
runMissionGoals: commandMocks.runMissionGoals,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/goals.js", () => ({
|
||||
@@ -635,6 +641,21 @@ describe("bin command routing and fallbacks", () => {
|
||||
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
|
||||
});
|
||||
|
||||
it("routes mission goals", async () => {
|
||||
await runBin(["mission", "goals", "M-001"]);
|
||||
expect(commandMocks.runMissionGoals).toHaveBeenCalledWith("M-001", undefined);
|
||||
});
|
||||
|
||||
it("routes mission link-goal", async () => {
|
||||
await runBin(["mission", "link-goal", "M-001", "G-001", "--project", "demo"]);
|
||||
expect(commandMocks.runMissionLinkGoal).toHaveBeenCalledWith("M-001", "G-001", "demo");
|
||||
});
|
||||
|
||||
it("routes mission unlink-goal", async () => {
|
||||
await runBin(["mission", "unlink-goal", "M-001", "G-001"]);
|
||||
expect(commandMocks.runMissionUnlinkGoal).toHaveBeenCalledWith("M-001", "G-001", undefined);
|
||||
});
|
||||
|
||||
it("routes goals list with default status", async () => {
|
||||
await runBin(["goals", "list"]);
|
||||
expect(commandMocks.runGoalsList).toHaveBeenCalledWith(undefined, { status: "active" });
|
||||
|
||||
140
packages/cli/src/__tests__/extension-mission-goal-tools.test.ts
Normal file
140
packages/cli/src/__tests__/extension-mission-goal-tools.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import kbExtension from "../extension.js";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
|
||||
const api = {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
};
|
||||
|
||||
return api as any;
|
||||
}
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
describe("extension mission goal tools", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-mission-goal-tools-"));
|
||||
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers mission goal tools with schemas", () => {
|
||||
expect(api.tools.get("fn_mission_list_goals")?.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["missionId"],
|
||||
});
|
||||
expect(api.tools.get("fn_mission_link_goal")?.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["missionId", "goalId"],
|
||||
});
|
||||
expect(api.tools.get("fn_mission_unlink_goal")?.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["missionId", "goalId"],
|
||||
});
|
||||
});
|
||||
|
||||
it("links, lists, and unlinks goals correctly", async () => {
|
||||
const missionCreate = api.tools.get("fn_mission_create");
|
||||
const goalCreate = api.tools.get("fn_goal_create");
|
||||
const linkGoal = api.tools.get("fn_mission_link_goal");
|
||||
const listGoals = api.tools.get("fn_mission_list_goals");
|
||||
const unlinkGoal = api.tools.get("fn_mission_unlink_goal");
|
||||
expect(missionCreate && goalCreate && linkGoal && listGoals && unlinkGoal).toBeTruthy();
|
||||
|
||||
const missionResult = await missionCreate!.execute("mission-create", { title: "Mission Alpha" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const goalAResult = await goalCreate!.execute("goal-a", { title: "Goal A" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const goalBResult = await goalCreate!.execute("goal-b", { title: "Goal B", description: "Second goal" }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
const missionId = missionResult.details.missionId as string;
|
||||
const goalAId = goalAResult.details.goalId as string;
|
||||
const goalBId = goalBResult.details.goalId as string;
|
||||
|
||||
await linkGoal!.execute("link-a", { missionId, goalId: goalAId }, undefined, undefined, makeCtx(tmpDir));
|
||||
await linkGoal!.execute("link-b", { missionId, goalId: goalBId }, undefined, undefined, makeCtx(tmpDir));
|
||||
const relink = await linkGoal!.execute("link-b-again", { missionId, goalId: goalBId }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(relink.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalAId, goalBId]);
|
||||
|
||||
const listed = await listGoals!.execute("list", { missionId }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listed.isError).toBeUndefined();
|
||||
expect(listed.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalAId, goalBId]);
|
||||
expect(listed.content[0].text).toContain(`${goalAId} [active] Goal A`);
|
||||
expect(listed.content[0].text).toContain(`${goalBId} [active] Goal B — Second goal`);
|
||||
|
||||
const unlinked = await unlinkGoal!.execute("unlink-a", { missionId, goalId: goalAId }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(unlinked.isError).toBeUndefined();
|
||||
expect(unlinked.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalBId]);
|
||||
|
||||
const unlinkAgain = await unlinkGoal!.execute("unlink-a-again", { missionId, goalId: goalAId }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(unlinkAgain.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalBId]);
|
||||
});
|
||||
|
||||
it("returns stable missing mission and goal errors", async () => {
|
||||
const missionCreate = api.tools.get("fn_mission_create");
|
||||
const goalCreate = api.tools.get("fn_goal_create");
|
||||
const linkGoal = api.tools.get("fn_mission_link_goal");
|
||||
const listGoals = api.tools.get("fn_mission_list_goals");
|
||||
const unlinkGoal = api.tools.get("fn_mission_unlink_goal");
|
||||
expect(missionCreate && goalCreate && linkGoal && listGoals && unlinkGoal).toBeTruthy();
|
||||
|
||||
const missionResult = await missionCreate!.execute("mission-create", { title: "Mission Alpha" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const goalResult = await goalCreate!.execute("goal-a", { title: "Goal A" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const missionId = missionResult.details.missionId as string;
|
||||
const goalId = goalResult.details.goalId as string;
|
||||
|
||||
const missingMissionList = await listGoals!.execute("missing-mission-list", { missionId: "M-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(missingMissionList.isError).toBe(true);
|
||||
expect(missingMissionList.details).toEqual({ code: "MISSION_NOT_FOUND", missionId: "M-404" });
|
||||
|
||||
const missingGoalLink = await linkGoal!.execute("missing-goal-link", { missionId, goalId: "G-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(missingGoalLink.isError).toBe(true);
|
||||
expect(missingGoalLink.details).toEqual({ code: "GOAL_NOT_FOUND", goalId: "G-404" });
|
||||
|
||||
const missingGoalUnlink = await unlinkGoal!.execute("missing-goal-unlink", { missionId, goalId: "G-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(missingGoalUnlink.isError).toBe(true);
|
||||
expect(missingGoalUnlink.details).toEqual({ code: "GOAL_NOT_FOUND", goalId: "G-404" });
|
||||
|
||||
const missingMissionLink = await linkGoal!.execute("missing-mission-link", { missionId: "M-404", goalId }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(missingMissionLink.isError).toBe(true);
|
||||
expect(missingMissionLink.details).toEqual({ code: "MISSION_NOT_FOUND", missionId: "M-404" });
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ async function loadCommandHandlers() {
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js");
|
||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
|
||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice, runMissionLinkGoal, runMissionUnlinkGoal, runMissionGoals } = await import("./commands/mission.js");
|
||||
const { runGoalsList, runGoalsCreate, runGoalsArchive, runGoalsCitations } = await import("./commands/goals.js");
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||
const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
|
||||
@@ -196,6 +196,9 @@ async function loadCommandHandlers() {
|
||||
runMissionShow,
|
||||
runMissionDelete,
|
||||
runMissionActivateSlice,
|
||||
runMissionLinkGoal,
|
||||
runMissionUnlinkGoal,
|
||||
runMissionGoals,
|
||||
runGoalsList,
|
||||
runGoalsCreate,
|
||||
runGoalsArchive,
|
||||
@@ -322,6 +325,11 @@ PR:
|
||||
fn mission create [title] [desc] Create a new mission
|
||||
fn mission list | ls List missions
|
||||
fn mission show | info <id> Show mission details
|
||||
fn mission goals <id> List linked goals for a mission
|
||||
fn mission link-goal <mission-id> <goal-id>
|
||||
Link a goal to a mission
|
||||
fn mission unlink-goal <mission-id> <goal-id>
|
||||
Unlink a goal from a mission
|
||||
fn mission delete <id> [--force] Delete a mission
|
||||
fn mission activate-slice <id> Mark a slice active
|
||||
fn goals list [--status STATE] List goals (default: active)
|
||||
@@ -627,6 +635,9 @@ async function main() {
|
||||
runMissionShow,
|
||||
runMissionDelete,
|
||||
runMissionActivateSlice,
|
||||
runMissionLinkGoal,
|
||||
runMissionUnlinkGoal,
|
||||
runMissionGoals,
|
||||
runGoalsList,
|
||||
runGoalsCreate,
|
||||
runGoalsArchive,
|
||||
@@ -1376,6 +1387,23 @@ async function main() {
|
||||
await runMissionShow(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "goals": {
|
||||
const id = args[2];
|
||||
await runMissionGoals(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "link-goal": {
|
||||
const missionId = args[2];
|
||||
const goalId = args[3];
|
||||
await runMissionLinkGoal(missionId, goalId, projectName);
|
||||
break;
|
||||
}
|
||||
case "unlink-goal": {
|
||||
const missionId = args[2];
|
||||
const goalId = args[3];
|
||||
await runMissionUnlinkGoal(missionId, goalId, projectName);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const id = args[2];
|
||||
const force = args.includes("--force");
|
||||
@@ -1389,7 +1417,7 @@ async function main() {
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: mission ${subcommand || ""}`);
|
||||
console.log("Try: fn mission create | list | show | delete | activate-slice");
|
||||
console.log("Try: fn mission create | list | show | goals | link-goal | unlink-goal | delete | activate-slice");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -38,6 +38,9 @@ const {
|
||||
runMissionShow,
|
||||
runMissionDelete,
|
||||
runMissionActivateSlice,
|
||||
runMissionLinkGoal,
|
||||
runMissionUnlinkGoal,
|
||||
runMissionGoals,
|
||||
runMilestoneAdd,
|
||||
runSliceAdd,
|
||||
runFeatureAdd,
|
||||
@@ -145,6 +148,9 @@ function createMockMissionStore(overrides = {}) {
|
||||
taskId,
|
||||
})),
|
||||
deleteMission: vi.fn(),
|
||||
linkGoal: vi.fn().mockReturnValue({ missionId: "M-001", goalId: "G-001", createdAt: "2026-04-01T00:00:00Z" }),
|
||||
unlinkGoal: vi.fn().mockReturnValue(true),
|
||||
listGoalIdsForMission: vi.fn().mockReturnValue(["G-001"]),
|
||||
activateSlice: vi.fn().mockReturnValue({
|
||||
id: "SL-001",
|
||||
title: "Test Slice",
|
||||
@@ -165,10 +171,17 @@ function createMockDatabase(drafts: Array<{ id: string; title: string; status: s
|
||||
|
||||
function mockResolvedProjectStore(
|
||||
missionStore: ReturnType<typeof createMockMissionStore>,
|
||||
overrides: Partial<{ getTask: ReturnType<typeof vi.fn>; getDatabase: ReturnType<typeof createMockDatabase> }> = {},
|
||||
overrides: Partial<{ getTask: ReturnType<typeof vi.fn>; getDatabase: ReturnType<typeof createMockDatabase>; getGoalStore: () => { getGoal: ReturnType<typeof vi.fn> } }> = {},
|
||||
) {
|
||||
vi.mocked(getStore).mockResolvedValue({
|
||||
getMissionStore: () => missionStore,
|
||||
getGoalStore: () => ({
|
||||
getGoal: vi.fn().mockImplementation((id: string) => ({
|
||||
id,
|
||||
title: `Goal ${id}`,
|
||||
status: "active",
|
||||
})),
|
||||
}),
|
||||
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
|
||||
getDatabase: () => createMockDatabase(),
|
||||
...overrides,
|
||||
@@ -868,6 +881,90 @@ describe("mission commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mission goal commands", () => {
|
||||
it("links a goal to a mission", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listGoalIdsForMission: vi.fn().mockReturnValue(["G-001"]),
|
||||
});
|
||||
mockResolvedProjectStore(mockMissionStore);
|
||||
|
||||
await runMissionLinkGoal("M-001", "G-001");
|
||||
|
||||
expect(mockMissionStore.linkGoal).toHaveBeenCalledWith("M-001", "G-001");
|
||||
});
|
||||
|
||||
it("unlinks a goal from a mission", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listGoalIdsForMission: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
mockResolvedProjectStore(mockMissionStore);
|
||||
|
||||
await runMissionUnlinkGoal("M-001", "G-001");
|
||||
|
||||
expect(mockMissionStore.unlinkGoal).toHaveBeenCalledWith("M-001", "G-001");
|
||||
});
|
||||
|
||||
it("lists linked goals", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listGoalIdsForMission: vi.fn().mockReturnValue(["G-001", "G-002"]),
|
||||
});
|
||||
mockResolvedProjectStore(mockMissionStore, {
|
||||
getGoalStore: () => ({
|
||||
getGoal: vi.fn().mockImplementation((id: string) => ({
|
||||
id,
|
||||
title: `Goal ${id}`,
|
||||
status: "active",
|
||||
description: id === "G-002" ? "Second goal" : undefined,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
try {
|
||||
await runMissionGoals("M-001");
|
||||
expect(consoleCapture.logs.some((line) => line.includes("Linked goals for M-001"))).toBe(true);
|
||||
expect(consoleCapture.logs.some((line) => line.includes("G-001 [active] Goal G-001"))).toBe(true);
|
||||
expect(consoleCapture.logs.some((line) => line.includes("G-002 [active] Goal G-002 — Second goal"))).toBe(true);
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("operates end-to-end against a real temp-project store", async () => {
|
||||
const { TaskStore } = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
const { mkdtempSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "kb-mission-cli-goals-"));
|
||||
const globalDir = join(rootDir, ".fusion-global-settings");
|
||||
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
const mission = store.getMissionStore().createMission({ title: "CLI Mission" });
|
||||
const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
|
||||
const goalB = store.getGoalStore().createGoal({ title: "Goal B", description: "Second goal" });
|
||||
vi.mocked(getStore).mockResolvedValue(store as any);
|
||||
|
||||
const consoleCapture = captureConsole();
|
||||
try {
|
||||
await runMissionLinkGoal(mission.id, goalA.id);
|
||||
await runMissionLinkGoal(mission.id, goalB.id);
|
||||
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalA.id, goalB.id]);
|
||||
|
||||
await runMissionGoals(mission.id);
|
||||
expect(consoleCapture.logs.some((line) => line.includes(`${goalA.id} [active] Goal A`))).toBe(true);
|
||||
expect(consoleCapture.logs.some((line) => line.includes(`${goalB.id} [active] Goal B — Second goal`))).toBe(true);
|
||||
|
||||
await runMissionUnlinkGoal(mission.id, goalA.id);
|
||||
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id]);
|
||||
} finally {
|
||||
consoleCapture.restore();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFeatureLinkTask", () => {
|
||||
it("links a feature to a task", async () => {
|
||||
const mockMissionStore = createMockMissionStore();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MilestoneStatus, type SliceStatus, type FeatureStatus } from "@fusion/core";
|
||||
import { type Goal, type MilestoneStatus, type SliceStatus, type FeatureStatus } from "@fusion/core";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { getStore } from "../project-resolver.js";
|
||||
|
||||
@@ -33,6 +33,14 @@ const FEATURE_STATUS_LABELS: Record<FeatureStatus, string> = {
|
||||
blocked: "Blocked",
|
||||
};
|
||||
|
||||
function resolveLinkedGoals(store: Awaited<ReturnType<typeof getStore>>, missionId: string): Array<Goal | { id: string; missing: true }> {
|
||||
const goalStore = store.getGoalStore();
|
||||
return store
|
||||
.getMissionStore()
|
||||
.listGoalIdsForMission(missionId)
|
||||
.map((goalId) => goalStore.getGoal(goalId) ?? { id: goalId, missing: true as const });
|
||||
}
|
||||
|
||||
async function promptForTitleAndDescription(
|
||||
titleArg: string | undefined,
|
||||
titlePrompt: string,
|
||||
@@ -442,6 +450,98 @@ export async function runFeatureAdd(
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runMissionLinkGoal(missionId: string, goalId: string, projectName?: string) {
|
||||
if (!missionId || !goalId) {
|
||||
console.error("Usage: fn mission link-goal <mission-id> <goal-id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
if (!missionStore.getMission(missionId)) {
|
||||
console.error(`✗ Mission ${missionId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const goal = store.getGoalStore().getGoal(goalId);
|
||||
if (!goal) {
|
||||
console.error(`✗ Goal ${goalId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
missionStore.linkGoal(missionId, goalId);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Linked ${goal.id}: ${goal.title} → ${missionId}`);
|
||||
console.log(` Linked goals: ${missionStore.listGoalIdsForMission(missionId).length}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runMissionUnlinkGoal(missionId: string, goalId: string, projectName?: string) {
|
||||
if (!missionId || !goalId) {
|
||||
console.error("Usage: fn mission unlink-goal <mission-id> <goal-id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
if (!missionStore.getMission(missionId)) {
|
||||
console.error(`✗ Mission ${missionId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const goal = store.getGoalStore().getGoal(goalId);
|
||||
if (!goal) {
|
||||
console.error(`✗ Goal ${goalId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
missionStore.unlinkGoal(missionId, goalId);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Unlinked ${goal.id}: ${goal.title} from ${missionId}`);
|
||||
console.log(` Linked goals: ${missionStore.listGoalIdsForMission(missionId).length}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runMissionGoals(missionId: string, projectName?: string) {
|
||||
if (!missionId) {
|
||||
console.error("Usage: fn mission goals <mission-id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore({ project: projectName });
|
||||
const missionStore = store.getMissionStore();
|
||||
const mission = missionStore.getMission(missionId);
|
||||
|
||||
if (!mission) {
|
||||
console.error(`✗ Mission ${missionId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const linkedGoals = resolveLinkedGoals(store, missionId);
|
||||
|
||||
console.log();
|
||||
console.log(` Linked goals for ${mission.id}: ${mission.title}`);
|
||||
if (linkedGoals.length === 0) {
|
||||
console.log(" No linked goals.");
|
||||
console.log();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const goal of linkedGoals) {
|
||||
if ("missing" in goal) {
|
||||
console.log(` - ${goal.id} [missing]`);
|
||||
continue;
|
||||
}
|
||||
const description = goal.description ? ` — ${goal.description}` : "";
|
||||
console.log(` - ${goal.id} [${goal.status}] ${goal.title}${description}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runFeatureLinkTask(featureId: string, taskId: string, projectName?: string) {
|
||||
if (!featureId || !taskId) {
|
||||
console.error("Usage: fn mission link-feature <feature-id> <task-id>");
|
||||
|
||||
@@ -2675,6 +2675,176 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_mission_list_goals ─────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_mission_list_goals",
|
||||
label: "fn: List Mission Goals",
|
||||
description: "List goals linked to a mission.",
|
||||
promptSnippet: "List goals linked to a mission",
|
||||
promptGuidelines: [
|
||||
"Use after fn_mission_list or fn_mission_show when you need goal linkage details",
|
||||
"Returns linked goals in mission-link order",
|
||||
"Prefer this before linking or unlinking to avoid duplicate work",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
missionId: Type.String({ description: "Mission ID (e.g., M-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
const goalStore = store.getGoalStore();
|
||||
const mission = missionStore.getMission(params.missionId);
|
||||
|
||||
if (!mission) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
|
||||
isError: true,
|
||||
details: { code: "MISSION_NOT_FOUND", missionId: params.missionId },
|
||||
};
|
||||
}
|
||||
|
||||
const goals = missionStore
|
||||
.listGoalIdsForMission(params.missionId)
|
||||
.map((goalId) => goalStore.getGoal(goalId))
|
||||
.filter((goal): goal is NonNullable<typeof goal> => Boolean(goal));
|
||||
|
||||
const lines = [`Linked goals for ${mission.id}: ${mission.title}`];
|
||||
if (goals.length === 0) {
|
||||
lines.push("No linked goals.");
|
||||
} else {
|
||||
for (const goal of goals) {
|
||||
const description = goal.description ? ` — ${goal.description}` : "";
|
||||
lines.push(`- ${goal.id} [${goal.status}] ${goal.title}${description}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: {
|
||||
missionId: mission.id,
|
||||
missionTitle: mission.title,
|
||||
goals,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_mission_link_goal ───────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_mission_link_goal",
|
||||
label: "fn: Link Mission Goal",
|
||||
description: "Link a goal to a mission.",
|
||||
promptSnippet: "Link a goal to a mission",
|
||||
promptGuidelines: [
|
||||
"Use after confirming both the mission and goal IDs",
|
||||
"Idempotent: linking an already-linked goal is safe",
|
||||
"Use fn_mission_list_goals afterward to verify the resulting set",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
missionId: Type.String({ description: "Mission ID (e.g., M-001)" }),
|
||||
goalId: Type.String({ description: "Goal ID (e.g., G-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
const goalStore = store.getGoalStore();
|
||||
const mission = missionStore.getMission(params.missionId);
|
||||
if (!mission) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
|
||||
isError: true,
|
||||
details: { code: "MISSION_NOT_FOUND", missionId: params.missionId },
|
||||
};
|
||||
}
|
||||
|
||||
const goal = goalStore.getGoal(params.goalId);
|
||||
if (!goal) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Goal ${params.goalId} not found` }],
|
||||
isError: true,
|
||||
details: { code: "GOAL_NOT_FOUND", goalId: params.goalId },
|
||||
};
|
||||
}
|
||||
|
||||
missionStore.linkGoal(params.missionId, params.goalId);
|
||||
const goals = missionStore
|
||||
.listGoalIdsForMission(params.missionId)
|
||||
.map((goalId) => goalStore.getGoal(goalId))
|
||||
.filter((linkedGoal): linkedGoal is NonNullable<typeof linkedGoal> => Boolean(linkedGoal));
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Linked ${goal.id}: ${goal.title} → ${mission.id}` }],
|
||||
details: {
|
||||
missionId: mission.id,
|
||||
missionTitle: mission.title,
|
||||
goal,
|
||||
goals,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_mission_unlink_goal ─────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_mission_unlink_goal",
|
||||
label: "fn: Unlink Mission Goal",
|
||||
description: "Unlink a goal from a mission.",
|
||||
promptSnippet: "Unlink a goal from a mission",
|
||||
promptGuidelines: [
|
||||
"Use when a goal no longer belongs on a mission",
|
||||
"Idempotent: unlinking an absent link is safe",
|
||||
"Returns the remaining linked goals for quick verification",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
missionId: Type.String({ description: "Mission ID (e.g., M-001)" }),
|
||||
goalId: Type.String({ description: "Goal ID (e.g., G-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
const goalStore = store.getGoalStore();
|
||||
const mission = missionStore.getMission(params.missionId);
|
||||
if (!mission) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
|
||||
isError: true,
|
||||
details: { code: "MISSION_NOT_FOUND", missionId: params.missionId },
|
||||
};
|
||||
}
|
||||
|
||||
const goal = goalStore.getGoal(params.goalId);
|
||||
if (!goal) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Goal ${params.goalId} not found` }],
|
||||
isError: true,
|
||||
details: { code: "GOAL_NOT_FOUND", goalId: params.goalId },
|
||||
};
|
||||
}
|
||||
|
||||
missionStore.unlinkGoal(params.missionId, params.goalId);
|
||||
const goals = missionStore
|
||||
.listGoalIdsForMission(params.missionId)
|
||||
.map((goalId) => goalStore.getGoal(goalId))
|
||||
.filter((linkedGoal): linkedGoal is NonNullable<typeof linkedGoal> => Boolean(linkedGoal));
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Unlinked ${goal.id}: ${goal.title} from ${mission.id}` }],
|
||||
details: {
|
||||
missionId: mission.id,
|
||||
missionTitle: mission.title,
|
||||
goal,
|
||||
goals,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_mission_backfill_assertions ─────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user