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:
gsxdsm
2026-06-02 14:47:51 -07:00
parent 30a09e3422
commit 93e8bd9940
14 changed files with 930 additions and 12 deletions

View File

@@ -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`

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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" });

View 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" });
});
});

View File

@@ -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;

View File

@@ -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();

View File

@@ -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>");

View File

@@ -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({