FN-5958: add mission goal linking to create and update flows

Expand mission goal-link management across the REST API, CLI, and pi extension.

- accept optional goalIds during mission create and patch requests, and enforce archived-goal rejection while keeping unlink permissive
- add repeatable --goal support to fn mission create and reuse goal validation for CLI linking
- expose archived-goal errors in pi-extension mission goal tools and document the new behavior

Files changed:
 .changeset/FN-5958-mission-goal-links.md           |  10 +
 docs/cli-reference.md                              |   8 +-
 docs/missions.md                                   |  15 +-
 packages/cli/src/__tests__/bin.test.ts             |  34 ++++
 packages/cli/src/__tests__/extension-mission-goal-tools.test.ts |  36 ++++
 packages/cli/src/bin.ts                            |  24 ++-
 packages/cli/src/commands/mission.ts               |  29 ++-
 packages/cli/src/extension.ts                      |   7 +
 packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts | 201 ++++++++++++++++++++-
 packages/dashboard/src/mission-routes.ts           |  66 +++++--
 10 files changed, 390 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-5958
Fusion-Task-Lineage: cf9d6013-5484-439d-b66d-44cac843c6ef
This commit is contained in:
gsxdsm
2026-06-03 16:14:19 -07:00
parent c82cdb281c
commit 26bc80a0ad
10 changed files with 390 additions and 40 deletions

View File

@@ -615,9 +615,43 @@ describe("bin command routing and fallbacks", () => {
"Detailed mission description",
"demo",
undefined,
[],
);
});
it("routes mission create with repeated --goal flags", async () => {
await runBin([
"mission",
"create",
"Test Mission",
"Detailed",
"mission",
"description",
"--goal",
"G-001",
"--goal",
"G-002",
"--base-branch",
"feature/mission",
]);
expect(commandMocks.runMissionCreate).toHaveBeenCalledWith(
"Test Mission",
"Detailed mission description",
undefined,
"feature/mission",
["G-001", "G-002"],
);
});
it("surfaces non-zero exit from mission create goal validation failures", async () => {
commandMocks.runMissionCreate.mockImplementationOnce(() => {
throw new Error("process.exit:1");
});
await expect(runBin(["mission", "create", "Test Mission", "--goal", "G-ARCHIVED"])).rejects.toThrow("process.exit:1");
});
it.each([
{ args: ["mission", "ls"], includeDrafts: true },
{ args: ["mission", "list", "--no-drafts"], includeDrafts: false },

View File

@@ -108,6 +108,42 @@ describe("extension mission goal tools", () => {
expect(unlinkAgain.details.goals.map((goal: { id: string }) => goal.id)).toEqual([goalBId]);
});
it("rejects archived goals on link and still unlinks archived links", async () => {
const missionCreate = api.tools.get("fn_mission_create");
const goalCreate = api.tools.get("fn_goal_create");
const goalArchive = api.tools.get("fn_goal_archive");
const linkGoal = api.tools.get("fn_mission_link_goal");
const unlinkGoal = api.tools.get("fn_mission_unlink_goal");
expect(missionCreate && goalCreate && goalArchive && linkGoal && unlinkGoal).toBeTruthy();
const missionResult = await missionCreate!.execute("mission-create", { title: "Mission Alpha" }, undefined, undefined, makeCtx(tmpDir));
const activeGoalResult = await goalCreate!.execute("goal-active", { title: "Goal Active" }, undefined, undefined, makeCtx(tmpDir));
const archivedGoalResult = await goalCreate!.execute("goal-archived", { title: "Goal Archived" }, undefined, undefined, makeCtx(tmpDir));
const missionId = missionResult.details.missionId as string;
const activeGoalId = activeGoalResult.details.goalId as string;
const archivedGoalId = archivedGoalResult.details.goalId as string;
await goalArchive!.execute("archive-goal", { id: archivedGoalId }, undefined, undefined, makeCtx(tmpDir));
await linkGoal!.execute("link-active", { missionId, goalId: activeGoalId }, undefined, undefined, makeCtx(tmpDir));
const relink = await linkGoal!.execute("relink-active", { missionId, goalId: activeGoalId }, undefined, undefined, makeCtx(tmpDir));
expect(relink.isError).toBeUndefined();
expect(relink.details.goals.map((goal: { id: string }) => goal.id)).toEqual([activeGoalId]);
const archivedLink = await linkGoal!.execute("link-archived", { missionId, goalId: archivedGoalId }, undefined, undefined, makeCtx(tmpDir));
expect(archivedLink.isError).toBe(true);
expect(archivedLink.details).toEqual({ code: "GOAL_ARCHIVED", goalId: archivedGoalId });
const linkedArchivedResult = await goalCreate!.execute("goal-linked-then-archived", { title: "Goal Linked Then Archived" }, undefined, undefined, makeCtx(tmpDir));
const linkedArchivedGoalId = linkedArchivedResult.details.goalId as string;
await linkGoal!.execute("link-before-archive", { missionId, goalId: linkedArchivedGoalId }, undefined, undefined, makeCtx(tmpDir));
await goalArchive!.execute("archive-linked-goal", { id: linkedArchivedGoalId }, undefined, undefined, makeCtx(tmpDir));
const unlinked = await unlinkGoal!.execute("unlink-archived", { missionId, goalId: linkedArchivedGoalId }, undefined, undefined, makeCtx(tmpDir));
expect(unlinked.isError).toBeUndefined();
expect(unlinked.details.goals.map((goal: { id: string }) => goal.id)).toEqual([activeGoalId]);
});
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");

View File

@@ -327,7 +327,8 @@ PR:
Cancel an active cited-research run
fn research retry <run-id> [--json]
Retry a failed/cancelled cited-research run
fn mission create [title] [desc] Create a new mission
fn mission create [title] [desc] [--goal <id>] [--base-branch <branch>]
Create a new mission (repeat --goal to link goals)
fn mission list | ls List missions
fn mission show | info <id> Show mission details
fn mission goals <id> List linked goals for a mission
@@ -498,6 +499,22 @@ function getFlagValue(args: string[], flag: string): string | undefined {
return value;
}
function getRepeatedFlagValues(args: string[], flag: string): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index++) {
if (args[index] !== flag) {
continue;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
continue;
}
values.push(value);
index += 1;
}
return values;
}
function getFlagValueNumber(args: string[], flag: string): number | undefined {
const value = getFlagValue(args, flag);
if (value === undefined) {
@@ -1388,18 +1405,21 @@ async function main() {
case "create": {
const createArgs = args.slice(2);
let baseBranch: string | undefined;
const goalIds = getRepeatedFlagValues(createArgs, "--goal");
const positional: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--base-branch" && i + 1 < createArgs.length) {
baseBranch = createArgs[i + 1];
i++;
} else if (createArgs[i] === "--goal" && i + 1 < createArgs.length) {
i++;
} else {
positional.push(createArgs[i]);
}
}
const title = positional[0];
const description = positional.length > 1 ? positional.slice(1).join(" ") : undefined;
await runMissionCreate(title, description, projectName, baseBranch);
await runMissionCreate(title, description, projectName, baseBranch, goalIds);
break;
}
case "list":

View File

@@ -75,14 +75,30 @@ async function promptForTitleAndDescription(
* Create a new mission with optional title and description.
* If arguments are omitted, prompts interactively.
*/
function requireCliLinkableGoal(store: Awaited<ReturnType<typeof getStore>>, goalId: string): Goal {
const goal = store.getGoalStore().getGoal(goalId);
if (!goal) {
console.error(`✗ Goal ${goalId} not found`);
process.exit(1);
}
if (goal.status === "archived") {
console.error(`✗ Goal ${goalId} is archived and cannot be linked`);
process.exit(1);
}
return goal;
}
export async function runMissionCreate(
titleArg?: string,
descriptionArg?: string,
projectName?: string,
baseBranch?: string,
goalIds?: string[],
) {
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const uniqueGoalIds = Array.from(new Set(goalIds ?? []));
const linkableGoals = uniqueGoalIds.map((goalId) => requireCliLinkableGoal(store, goalId));
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
@@ -98,12 +114,19 @@ export async function runMissionCreate(
baseBranch: baseBranch?.trim() || undefined,
});
for (const goal of linkableGoals) {
missionStore.linkGoal(mission.id, goal.id);
}
console.log();
console.log(` ✓ Created ${mission.id}: ${mission.title}`);
console.log(` Status: ${MISSION_STATUS_LABELS[mission.status]}`);
if (mission.description) {
console.log(` Description: ${mission.description.slice(0, 80)}${mission.description.length > 80 ? "…" : ""}`);
}
if (linkableGoals.length > 0) {
console.log(` Linked goals: ${linkableGoals.length}`);
}
console.log();
}
@@ -464,11 +487,7 @@ export async function runMissionLinkGoal(missionId: string, goalId: string, proj
process.exit(1);
}
const goal = store.getGoalStore().getGoal(goalId);
if (!goal) {
console.error(`✗ Goal ${goalId} not found`);
process.exit(1);
}
const goal = requireCliLinkableGoal(store, goalId);
missionStore.linkGoal(missionId, goalId);

View File

@@ -2787,6 +2787,13 @@ export default function kbExtension(pi: ExtensionAPI) {
details: { code: "GOAL_NOT_FOUND", goalId: params.goalId },
};
}
if (goal.status === "archived") {
return {
content: [{ type: "text", text: `Goal ${params.goalId} is archived and cannot be linked` }],
isError: true,
details: { code: "GOAL_ARCHIVED", goalId: params.goalId },
};
}
missionStore.linkGoal(params.missionId, params.goalId);
const goals = missionStore