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:
5
.changeset/FN-5899-mission-goal-tooling.md
Normal file
5
.changeset/FN-5899-mission-goal-tooling.md
Normal 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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user