FN-5897: surface linked goals in mission read paths

Expose mission-linked goals across mission detail surfaces.

- load linked goal records into mission hierarchy reads in core and return them from the mission detail API
- show linked goals in mission detail views, add goal-chip navigation, and anchor highlighted goal cards in GoalsView
- extend CLI mission output, docs, tests, and add a published changeset for the new read-path support

Files changed:
 .changeset/fn-5897-linked-goals-read-paths.md      |  5 +++
 docs/missions.md                                   |  6 ++-
 packages/cli/src/__tests__/extension.test.ts       | 48 +++++++++++++++++++++-
 packages/cli/src/extension.ts                      | 10 +++++
 packages/core/src/__tests__/mission-store.test.ts  | 14 +++++++
 packages/core/src/mission-store.ts                 | 29 +++++++++++++
 packages/core/src/mission-types.ts                 |  4 ++
 packages/dashboard/app/App.tsx                     | 16 +++++++-
 packages/dashboard/app/components/GoalsView.css    |  6 +++
 packages/dashboard/app/components/GoalsView.tsx    | 42 +++++++++++++++++--
 packages/dashboard/app/components/MissionManager.css | 44 ++++++++++++++++++++
 packages/dashboard/app/components/MissionManager.tsx | 31 +++++++++++++-
 packages/dashboard/app/components/__tests__/GoalsView.test.tsx | 31 ++++++++++++++
 packages/dashboard/app/components/__tests__/MissionManager.test.tsx | 45 ++++++++++++++++++++
 packages/dashboard/app/components/mission-types.ts |  2 +
 packages/dashboard/src/__tests__/mission-e2e.test.ts |  4 ++
 packages/dashboard/src/mission-routes.ts           |  5 ++-
 17 files changed, 332 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-5897

Fusion-Task-Lineage: 5176270d-9885-447a-845b-4e0d69d531a0
This commit is contained in:
gsxdsm
2026-06-02 17:58:41 -07:00
parent 6c7ed1e1fc
commit abbeaec0a8
17 changed files with 332 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js";
import { GoalStore } from "../goal-store.js";
import { Database } from "../db.js";
import type { MissionFeature } from "../mission-types.js";
import { mkdtempSync } from "node:fs";
@@ -30,6 +31,7 @@ describe("MissionStore", () => {
let fusionDir: string;
let db: Database;
let store: MissionStore;
let goalStore: GoalStore;
beforeEach(() => {
tmpDir = makeTmpDir();
@@ -40,6 +42,7 @@ describe("MissionStore", () => {
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new MissionStore(fusionDir, db);
goalStore = new GoalStore(fusionDir, db);
});
afterEach(async () => {
@@ -1838,6 +1841,8 @@ describe("MissionStore", () => {
title: "Hierarchy Test",
description: "Testing full tree loading",
});
const linkedGoal = goalStore.createGoal({ title: "Ship linked goal visibility" });
store.linkGoal(mission.id, linkedGoal.id);
const m1 = store.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = store.addMilestone(mission.id, { title: "Milestone 2" });
const s1 = store.addSlice(m1.id, { title: "Slice 1" });
@@ -1849,6 +1854,7 @@ describe("MissionStore", () => {
expect(withHierarchy.id).toBe(mission.id);
expect(withHierarchy.title).toBe("Hierarchy Test");
expect(withHierarchy.linkedGoals).toEqual([linkedGoal]);
expect(withHierarchy.milestones).toHaveLength(2);
const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!;
@@ -1859,6 +1865,14 @@ describe("MissionStore", () => {
expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f1.id)).toBeDefined();
expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f2.id)).toBeDefined();
});
it("returns an empty linkedGoals array when no goals are linked", () => {
const mission = store.createMission({ title: "Hierarchy without goals" });
const withHierarchy = store.getMissionWithHierarchy(mission.id)!;
expect(withHierarchy.linkedGoals).toEqual([]);
});
});
// ── Transaction Tests ────────────────────────────────────────────────

View File

@@ -14,6 +14,7 @@
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { fromJson, toJson, toJsonNullable } from "./db.js";
import type { Goal, GoalStatus } from "./goal-types.js";
import type {
Mission,
MissionBranchStrategy,
@@ -261,6 +262,15 @@ interface MissionGoalRow {
createdAt: string;
}
interface GoalRow {
id: string;
title: string;
description: string | null;
status: GoalStatus;
createdAt: string;
updatedAt: string;
}
/** Database row shape for the mission_contract_assertions table. */
interface AssertionRow {
id: string;
@@ -462,6 +472,17 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
}
private rowToGoal(row: GoalRow): Goal {
return {
id: row.id,
title: row.title,
description: row.description ?? undefined,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Convert a database row to a MissionContractAssertion object.
*/
@@ -678,6 +699,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const mission = this.getMission(id);
if (!mission) return undefined;
const linkedGoals = this.listGoalIdsForMission(id)
.map((goalId) => this.db
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
.get(goalId) as GoalRow | undefined)
.filter((row): row is GoalRow => Boolean(row))
.map((row) => this.rowToGoal(row));
const milestones = this.listMilestones(id);
const milestonesWithSlices = milestones.map((milestone) => {
const slices = this.listSlices(milestone.id);
@@ -693,6 +721,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return {
...mission,
linkedGoals,
milestones: milestonesWithSlices,
};
}

View File

@@ -9,6 +9,8 @@
* The hierarchy: Mission → Milestone → Slice → Feature → (optional) Task
*/
import type { Goal } from "./goal-types.js";
// ── Status Enums ─────────────────────────────────────────────────────
/** Status values for a Mission's lifecycle */
@@ -457,6 +459,8 @@ export interface SliceWithFeatures extends Slice {
* Mission → Milestones → Slices → Features
*/
export interface MissionWithHierarchy extends Mission {
/** Goals linked to this mission */
linkedGoals?: Goal[];
/** Milestones belonging to this mission, each with their slices */
milestones: Array<MilestoneWithSlices & {
/** Slices with their features loaded */