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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add mission↔goal linkage tooling across Fusion surfaces: REST mission goal endpoints, `fn mission goals|link-goal|unlink-goal` CLI commands, and `fn_mission_list_goals|fn_mission_link_goal|fn_mission_unlink_goal` pi-extension tools.

View File

@@ -650,14 +650,23 @@ Mission hierarchy operations.
fn mission create "Platform hardening" "Security and reliability initiative" --base-branch develop fn mission create "Platform hardening" "Security and reliability initiative" --base-branch develop
fn mission list fn mission list
fn mission show mission_123 fn mission show mission_123
fn mission goals mission_123
fn mission link-goal mission_123 G-001
fn mission unlink-goal mission_123 G-001
fn mission delete mission_123 --force fn mission delete mission_123 --force
fn mission activate-slice slice_456 fn mission activate-slice slice_456
``` ```
Subcommands: `create`, `list|ls`, `show|info`, `delete`, `activate-slice`. Subcommands: `create`, `list|ls`, `show|info`, `goals`, `link-goal`, `unlink-goal`, `delete`, `activate-slice`.
`fn mission create` supports `--base-branch <branch>` to set a mission-level default integration branch used by mission feature/slice triage when no explicit branch override is provided. `fn mission create` supports `--base-branch <branch>` to set a mission-level default integration branch used by mission feature/slice triage when no explicit branch override is provided.
Mission ↔ goal linkage commands operate on the persisted `mission_goals` relation:
- `fn mission goals <mission-id>` lists the linked goals for a mission.
- `fn mission link-goal <mission-id> <goal-id>` idempotently adds a goal link.
- `fn mission unlink-goal <mission-id> <goal-id>` idempotently removes a goal link.
--- ---
## `fn goals` ## `fn goals`

View File

@@ -92,10 +92,34 @@ Mission, milestone, slice, and feature read-only text surfaces in Mission Manage
fn mission create "Reliability initiative" "Reduce execution failures and improve recovery" fn mission create "Reliability initiative" "Reduce execution failures and improve recovery"
fn mission list fn mission list
fn mission show mission_123 fn mission show mission_123
fn mission goals mission_123
fn mission link-goal mission_123 G-001
fn mission unlink-goal mission_123 G-001
fn mission activate-slice slice_456 fn mission activate-slice slice_456
fn mission delete mission_123 --force fn mission delete mission_123 --force
``` ```
## Mission ↔ Goal operator surfaces
Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-extension tools.
### REST endpoints
| Endpoint | Purpose |
|---|---|
| `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. |
| `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. |
| `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. |
| `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. |
All four endpoints validate mission/goal identifier formats and return `404` for missing mission/goal rows.
### CLI
- `fn mission goals <mission-id>` — list linked goals for a mission.
- `fn mission link-goal <mission-id> <goal-id>` — idempotently link a goal.
- `fn mission unlink-goal <mission-id> <goal-id>` — idempotently unlink a goal.
## Mission Planning Tools (pi extension) ## Mission Planning Tools (pi extension)
The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/references/extension-tools.md`; this section is a user-facing summary of the mission-planning tool surface. The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/references/extension-tools.md`; this section is a user-facing summary of the mission-planning tool surface.
@@ -105,6 +129,9 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r
| `fn_mission_create` | Create a mission with title/description, optional `baseBranch`, and optional auto-advance behavior. | | `fn_mission_create` | Create a mission with title/description, optional `baseBranch`, and optional auto-advance behavior. |
| `fn_mission_list` | List missions and their current status. | | `fn_mission_list` | List missions and their current status. |
| `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including milestone/feature acceptance criteria and slice verification when present. | | `fn_mission_show` | Show mission details with milestone/slice/feature hierarchy, including milestone/feature acceptance criteria and slice verification when present. |
| `fn_mission_list_goals` | List the goals linked to a mission. |
| `fn_mission_link_goal` | Idempotently link a goal to a mission. |
| `fn_mission_unlink_goal` | Idempotently unlink a goal from a mission. |
| `fn_mission_delete` | Delete a mission and its hierarchy. | | `fn_mission_delete` | Delete a mission and its hierarchy. |
| `fn_mission_update` | Update mission title/description using partial patches. | | `fn_mission_update` | Update mission title/description using partial patches. |
| `fn_milestone_add` | Add a milestone to a mission. | | `fn_milestone_add` | Add a milestone to a mission. |

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) --> <!-- 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` - **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` - **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` - **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` - **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` - **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) | | `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 ### 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. 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_archive` | Archive a goal by ID. |
| `fn_goal_show` | Show full details for a single 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_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_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_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. | | `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(), runMissionShow: vi.fn(),
runMissionDelete: vi.fn(), runMissionDelete: vi.fn(),
runMissionActivateSlice: vi.fn(), runMissionActivateSlice: vi.fn(),
runMissionLinkGoal: vi.fn(),
runMissionUnlinkGoal: vi.fn(),
runMissionGoals: vi.fn(),
runGoalsList: vi.fn(), runGoalsList: vi.fn(),
runGoalsCreate: vi.fn(), runGoalsCreate: vi.fn(),
runGoalsArchive: vi.fn(), runGoalsArchive: vi.fn(),
@@ -200,6 +203,9 @@ vi.mock("../commands/mission.js", () => ({
runMissionShow: commandMocks.runMissionShow, runMissionShow: commandMocks.runMissionShow,
runMissionDelete: commandMocks.runMissionDelete, runMissionDelete: commandMocks.runMissionDelete,
runMissionActivateSlice: commandMocks.runMissionActivateSlice, runMissionActivateSlice: commandMocks.runMissionActivateSlice,
runMissionLinkGoal: commandMocks.runMissionLinkGoal,
runMissionUnlinkGoal: commandMocks.runMissionUnlinkGoal,
runMissionGoals: commandMocks.runMissionGoals,
})); }));
vi.mock("../commands/goals.js", () => ({ vi.mock("../commands/goals.js", () => ({
@@ -635,6 +641,21 @@ describe("bin command routing and fallbacks", () => {
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined); 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 () => { it("routes goals list with default status", async () => {
await runBin(["goals", "list"]); await runBin(["goals", "list"]);
expect(commandMocks.runGoalsList).toHaveBeenCalledWith(undefined, { status: "active" }); 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 { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js"); const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-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 { runGoalsList, runGoalsCreate, runGoalsArchive, runGoalsCitations } = await import("./commands/goals.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.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"); const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
@@ -196,6 +196,9 @@ async function loadCommandHandlers() {
runMissionShow, runMissionShow,
runMissionDelete, runMissionDelete,
runMissionActivateSlice, runMissionActivateSlice,
runMissionLinkGoal,
runMissionUnlinkGoal,
runMissionGoals,
runGoalsList, runGoalsList,
runGoalsCreate, runGoalsCreate,
runGoalsArchive, runGoalsArchive,
@@ -322,6 +325,11 @@ PR:
fn mission create [title] [desc] Create a new mission fn mission create [title] [desc] Create a new mission
fn mission list | ls List missions fn mission list | ls List missions
fn mission show | info <id> Show mission details 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 delete <id> [--force] Delete a mission
fn mission activate-slice <id> Mark a slice active fn mission activate-slice <id> Mark a slice active
fn goals list [--status STATE] List goals (default: active) fn goals list [--status STATE] List goals (default: active)
@@ -627,6 +635,9 @@ async function main() {
runMissionShow, runMissionShow,
runMissionDelete, runMissionDelete,
runMissionActivateSlice, runMissionActivateSlice,
runMissionLinkGoal,
runMissionUnlinkGoal,
runMissionGoals,
runGoalsList, runGoalsList,
runGoalsCreate, runGoalsCreate,
runGoalsArchive, runGoalsArchive,
@@ -1376,6 +1387,23 @@ async function main() {
await runMissionShow(id, projectName); await runMissionShow(id, projectName);
break; 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": { case "delete": {
const id = args[2]; const id = args[2];
const force = args.includes("--force"); const force = args.includes("--force");
@@ -1389,7 +1417,7 @@ async function main() {
} }
default: default:
console.error(`Unknown subcommand: mission ${subcommand || ""}`); 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); process.exit(1);
} }
break; break;

View File

@@ -38,6 +38,9 @@ const {
runMissionShow, runMissionShow,
runMissionDelete, runMissionDelete,
runMissionActivateSlice, runMissionActivateSlice,
runMissionLinkGoal,
runMissionUnlinkGoal,
runMissionGoals,
runMilestoneAdd, runMilestoneAdd,
runSliceAdd, runSliceAdd,
runFeatureAdd, runFeatureAdd,
@@ -145,6 +148,9 @@ function createMockMissionStore(overrides = {}) {
taskId, taskId,
})), })),
deleteMission: vi.fn(), 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({ activateSlice: vi.fn().mockReturnValue({
id: "SL-001", id: "SL-001",
title: "Test Slice", title: "Test Slice",
@@ -165,10 +171,17 @@ function createMockDatabase(drafts: Array<{ id: string; title: string; status: s
function mockResolvedProjectStore( function mockResolvedProjectStore(
missionStore: ReturnType<typeof createMockMissionStore>, 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({ vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => missionStore, getMissionStore: () => missionStore,
getGoalStore: () => ({
getGoal: vi.fn().mockImplementation((id: string) => ({
id,
title: `Goal ${id}`,
status: "active",
})),
}),
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }), getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
getDatabase: () => createMockDatabase(), getDatabase: () => createMockDatabase(),
...overrides, ...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", () => { describe("runFeatureLinkTask", () => {
it("links a feature to a task", async () => { it("links a feature to a task", async () => {
const mockMissionStore = createMockMissionStore(); 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 { createInterface } from "node:readline/promises";
import { getStore } from "../project-resolver.js"; import { getStore } from "../project-resolver.js";
@@ -33,6 +33,14 @@ const FEATURE_STATUS_LABELS: Record<FeatureStatus, string> = {
blocked: "Blocked", 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( async function promptForTitleAndDescription(
titleArg: string | undefined, titleArg: string | undefined,
titlePrompt: string, titlePrompt: string,
@@ -442,6 +450,98 @@ export async function runFeatureAdd(
console.log(); 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) { export async function runFeatureLinkTask(featureId: string, taskId: string, projectName?: string) {
if (!featureId || !taskId) { if (!featureId || !taskId) {
console.error("Usage: fn mission link-feature <feature-id> <task-id>"); 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 ───────────────────────────── // ── fn_mission_backfill_assertions ─────────────────────────────
pi.registerTool({ pi.registerTool({

View File

@@ -0,0 +1,151 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, type Goal } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { get, request } from "../test-request.js";
async function createFixture() {
const rootDir = mkdtempSync(join(tmpdir(), "kb-mission-goal-links-"));
const globalDir = join(rootDir, ".fusion-global-settings");
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(store));
return { app, store, rootDir };
}
describe("mission goal linkage routes", () => {
let rootDir: string;
let app: express.Express;
let store: TaskStore;
beforeEach(async () => {
({ app, store, rootDir } = await createFixture());
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
it("lists empty and populated linked goals", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
const goalB = store.getGoalStore().createGoal({ title: "Goal B" });
const empty = await get(app, `/api/missions/${mission.id}/goals`);
expect(empty.status).toBe(200);
expect(empty.body).toEqual({ goals: [] });
store.getMissionStore().linkGoal(mission.id, goalA.id);
store.getMissionStore().linkGoal(mission.id, goalB.id);
const populated = await get(app, `/api/missions/${mission.id}/goals`);
expect(populated.status).toBe(200);
expect((populated.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalA.id, goalB.id]);
});
it("sets the full linked goal set", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goalA = store.getGoalStore().createGoal({ title: "Goal A" });
const goalB = store.getGoalStore().createGoal({ title: "Goal B" });
const goalC = store.getGoalStore().createGoal({ title: "Goal C" });
store.getMissionStore().linkGoal(mission.id, goalA.id);
store.getMissionStore().linkGoal(mission.id, goalB.id);
const response = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: [goalB.id, goalC.id, goalC.id] }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as { goals: Goal[] }).goals.map((goal) => goal.id)).toEqual([goalB.id, goalC.id]);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]);
});
it("adds a linked goal idempotently", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
const first = await request(app, "POST", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(first.status).toBe(200);
expect((first.body as { goals: Goal[] }).goals.map((entry) => entry.id)).toEqual([goal.id]);
const second = await request(app, "POST", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(second.status).toBe(200);
expect((second.body as { goals: Goal[] }).goals.map((entry) => entry.id)).toEqual([goal.id]);
expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goal.id]);
});
it("removes a linked goal idempotently", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
store.getMissionStore().linkGoal(mission.id, goal.id);
const first = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(first.status).toBe(200);
expect(first.body).toEqual({ removed: true, goals: [] });
const second = await request(app, "DELETE", `/api/missions/${mission.id}/goals/${goal.id}`);
expect(second.status).toBe(200);
expect(second.body).toEqual({ removed: true, goals: [] });
});
it("returns 400 for malformed goal ids", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const listBad = await get(app, `/api/missions/not-a-mission/goals`);
expect(listBad.status).toBe(400);
const setBad = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: ["bad-goal-id"] }),
{ "content-type": "application/json" },
);
expect(setBad.status).toBe(400);
const addBad = await request(app, "POST", `/api/missions/${mission.id}/goals/not-a-goal`);
expect(addBad.status).toBe(400);
const deleteBad = await request(app, "DELETE", `/api/missions/${mission.id}/goals/not-a-goal`);
expect(deleteBad.status).toBe(400);
});
it("returns 404 for missing mission or goal", async () => {
const mission = store.getMissionStore().createMission({ title: "Ship mission" });
const goal = store.getGoalStore().createGoal({ title: "Goal A" });
const missingMissionList = await get(app, "/api/missions/M-404/goals");
expect(missingMissionList.status).toBe(404);
const missingMissionAdd = await request(app, "POST", `/api/missions/M-404/goals/${goal.id}`);
expect(missingMissionAdd.status).toBe(404);
const missingGoalAdd = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`);
expect(missingGoalAdd.status).toBe(404);
const missingGoalSet = await request(
app,
"PUT",
`/api/missions/${mission.id}/goals`,
JSON.stringify({ goalIds: [goal.id, "G-404"] }),
{ "content-type": "application/json" },
);
expect(missingGoalSet.status).toBe(404);
const missingGoalDelete = await request(app, "DELETE", `/api/missions/${mission.id}/goals/G-404`);
expect(missingGoalDelete.status).toBe(404);
});
});

View File

@@ -15,6 +15,7 @@
import { Router, type Request, type Response, type NextFunction } from "express"; import { Router, type Request, type Response, type NextFunction } from "express";
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore, resolvePlanningSettingsModel } from "@fusion/core"; import { TaskStore, resolvePlanningSettingsModel } from "@fusion/core";
import type { Goal } from "@fusion/core";
import { getOrCreateProjectStore } from "./project-store-resolver.js"; import { getOrCreateProjectStore } from "./project-store-resolver.js";
import type { import type {
Mission, Mission,
@@ -84,6 +85,10 @@ function validateAssertionId(id: string): boolean {
return /^CA-[A-Z0-9]+-[A-Z0-9]+$/i.test(id); return /^CA-[A-Z0-9]+-[A-Z0-9]+$/i.test(id);
} }
function validateGoalId(id: string): boolean {
return /^G-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
}
function validateTitle(title: unknown): string { function validateTitle(title: unknown): string {
if (!title || typeof title !== "string" || title.trim().length === 0) { if (!title || typeof title !== "string" || title.trim().length === 0) {
throw new Error("Title is required and must be a non-empty string"); throw new Error("Title is required and must be a non-empty string");
@@ -181,6 +186,23 @@ function validateOrderedIds(body: unknown): string[] {
return orderedIds; return orderedIds;
} }
function validateGoalIdsBody(body: unknown): string[] {
if (!body || typeof body !== "object") {
throw badRequest("Request body must contain goalIds array");
}
const { goalIds } = body as Record<string, unknown>;
if (!Array.isArray(goalIds)) {
throw badRequest("goalIds must be an array");
}
if (!goalIds.every((goalId) => typeof goalId === "string")) {
throw badRequest("goalIds must be an array of strings");
}
if (!goalIds.every((goalId) => validateGoalId(goalId))) {
throw badRequest("goalIds must contain valid goal IDs");
}
return goalIds;
}
type TypedRequest = Request<Record<string, string>>; type TypedRequest = Request<Record<string, string>>;
function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) { function catchTypedHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
@@ -254,7 +276,7 @@ export function createMissionRouter(
engineManager?: import("@fusion/engine").ProjectEngineManager, engineManager?: import("@fusion/engine").ProjectEngineManager,
): Router { ): Router {
const router = Router(); const router = Router();
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>(); const requestContext = new AsyncLocalStorage<TaskStore>();
function getProjectIdFromRequest(req: Request): string | undefined { function getProjectIdFromRequest(req: Request): string | undefined {
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) { if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
@@ -266,12 +288,74 @@ export function createMissionRouter(
return undefined; return undefined;
} }
function getScopedStore(): TaskStore {
return requestContext.getStore() ?? store;
}
function getScopedMissionStore() { function getScopedMissionStore() {
const missionStore = requestContext.getStore(); return getScopedStore().getMissionStore();
if (!missionStore) { }
return store.getMissionStore();
function getScopedGoalStore() {
return getScopedStore().getGoalStore();
}
function requireMission(missionId: string) {
if (!validateMissionId(missionId)) {
throw badRequest("Invalid mission ID format");
} }
return missionStore;
const mission = missionStore.getMission(missionId);
if (!mission) {
throw notFound("Mission not found");
}
return mission;
}
function requireGoal(goalId: string): Goal {
if (!validateGoalId(goalId)) {
throw badRequest("Invalid goal ID format");
}
const goal = getScopedGoalStore().getGoal(goalId);
if (!goal) {
throw notFound("Goal not found");
}
return goal;
}
function listLinkedGoalsForMission(missionId: string): Goal[] {
requireMission(missionId);
const goalStore = getScopedGoalStore();
return missionStore
.listGoalIdsForMission(missionId)
.map((goalId) => goalStore.getGoal(goalId))
.filter((goal): goal is Goal => Boolean(goal));
}
function setLinkedGoalsForMission(missionId: string, goalIds: string[]): Goal[] {
requireMission(missionId);
const uniqueGoalIds = Array.from(new Set(goalIds));
uniqueGoalIds.forEach((goalId) => requireGoal(goalId));
const existingGoalIds = new Set(missionStore.listGoalIdsForMission(missionId));
const nextGoalIds = new Set(uniqueGoalIds);
for (const goalId of existingGoalIds) {
if (!nextGoalIds.has(goalId)) {
missionStore.unlinkGoal(missionId, goalId);
}
}
for (const goalId of uniqueGoalIds) {
if (!existingGoalIds.has(goalId)) {
missionStore.linkGoal(missionId, goalId);
}
}
return listLinkedGoalsForMission(missionId);
} }
const missionStore = new Proxy({} as ReturnType<TaskStore["getMissionStore"]>, { const missionStore = new Proxy({} as ReturnType<TaskStore["getMissionStore"]>, {
@@ -286,7 +370,7 @@ export function createMissionRouter(
try { try {
const projectId = getProjectIdFromRequest(req); const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store; const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
requestContext.run(scopedStore.getMissionStore(), next); requestContext.run(scopedStore, next);
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -899,6 +983,63 @@ export function createMissionRouter(
}) })
); );
/**
* GET /api/missions/:missionId/goals
* List linked goals for a mission.
*/
router.get(
"/:missionId/goals",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const goals = listLinkedGoalsForMission(missionId);
res.json({ goals });
})
);
/**
* PUT /api/missions/:missionId/goals
* Replace the full linked-goal set for a mission.
*/
router.put(
"/:missionId/goals",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
const goalIds = validateGoalIdsBody(req.body);
const goals = setLinkedGoalsForMission(missionId, goalIds);
res.json({ goals });
})
);
/**
* POST /api/missions/:missionId/goals/:goalId
* Link a single goal to a mission.
*/
router.post(
"/:missionId/goals/:goalId",
catchTypedHandler(async (req, res) => {
const { missionId, goalId } = req.params;
requireMission(missionId);
const goal = requireGoal(goalId);
missionStore.linkGoal(missionId, goalId);
res.json({ goal, goals: listLinkedGoalsForMission(missionId) });
})
);
/**
* DELETE /api/missions/:missionId/goals/:goalId
* Unlink a single goal from a mission.
*/
router.delete(
"/:missionId/goals/:goalId",
catchTypedHandler(async (req, res) => {
const { missionId, goalId } = req.params;
requireMission(missionId);
requireGoal(goalId);
missionStore.unlinkGoal(missionId, goalId);
res.json({ removed: true, goals: listLinkedGoalsForMission(missionId) });
})
);
/** /**
* POST /api/missions/:missionId/backfill-assertions * POST /api/missions/:missionId/backfill-assertions
* Backfill store-managed assertions for mission features that have none. * Backfill store-managed assertions for mission features that have none.