feat(KB-632): add MissionStore with missions database schema

- Add database schema v3 with missions, objectives, rewards, progress tables
- Implement MissionStore with full CRUD, event emission, and progress tracking
- Add comprehensive mission types (MissionDefinition, MissionInstance, Objective, Reward)
- Include 900+ lines of MissionStore unit tests covering all operations
- Export mission types and store from core package index
This commit is contained in:
gsxdsm
2026-03-31 18:28:23 -07:00
parent bee49222e0
commit 486d12827b
7 changed files with 2554 additions and 8 deletions

View File

@@ -63,6 +63,11 @@ describe("Database", () => {
expect(tableNames).toContain("agents");
expect(tableNames).toContain("agentHeartbeats");
expect(tableNames).toContain("__meta");
// Mission hierarchy tables
expect(tableNames).toContain("missions");
expect(tableNames).toContain("milestones");
expect(tableNames).toContain("slices");
expect(tableNames).toContain("mission_features");
});
it("creates all expected indexes", () => {
@@ -80,7 +85,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(2);
expect(db.getSchemaVersion()).toBe(3);
});
it("seeds lastModified", () => {
@@ -103,7 +108,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(2);
expect(db.getSchemaVersion()).toBe(3);
});
it("does not overwrite existing config on re-init", () => {
@@ -677,8 +682,8 @@ describe("schema migrations", () => {
// Now run init() which should trigger migration
db.init();
// Verify version bumped
expect(db.getSchemaVersion()).toBe(2);
// Verify version bumped to 3 (includes both v1→v2 and v2→v3 migrations)
expect(db.getSchemaVersion()).toBe(3);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -703,11 +708,130 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(2);
expect(db.getSchemaVersion()).toBe(3);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(2);
expect(db.getSchemaVersion()).toBe(3);
db.close();
});
it("migrates a v2 database by adding missionId and sliceId columns", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".kb");
// Create a v2 database manually (without missionId and sliceId columns)
const db = new Database(kbDir);
// Create tables without the new columns (matching v2 schema)
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
status TEXT,
size TEXT,
reviewLevel INTEGER,
currentStep INTEGER DEFAULT 0,
worktree TEXT,
blockedBy TEXT,
paused INTEGER DEFAULT 0,
baseBranch TEXT,
modelPresetId TEXT,
modelProvider TEXT,
modelId TEXT,
validatorModelProvider TEXT,
validatorModelId TEXT,
mergeRetries INTEGER,
error TEXT,
summary TEXT,
thinkingLevel TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
columnMovedAt TEXT,
dependencies TEXT DEFAULT '[]',
steps TEXT DEFAULT '[]',
log TEXT DEFAULT '[]',
attachments TEXT DEFAULT '[]',
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
mergeDetails TEXT,
breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
);
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS agentHeartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '2')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
// Insert a task on the v2 schema
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-2', 'test v2', 'triage', '2025-01-01', '2025-01-01')`);
// Now run init() which should trigger v2→v3 migration
db.init();
// Verify version bumped to 3
expect(db.getSchemaVersion()).toBe(3);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((c) => c.name);
expect(colNames).toContain("missionId");
expect(colNames).toContain("sliceId");
// Existing task should still be readable
const task = db.prepare("SELECT * FROM tasks WHERE id = 'KB-2'").get() as any;
expect(task.description).toBe("test v2");
// New columns should have null defaults
expect(task.missionId).toBeNull();
expect(task.sliceId).toBeNull();
// Mission tables should be created
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).all() as { name: string }[];
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("missions");
expect(tableNames).toContain("milestones");
expect(tableNames).toContain("slices");
expect(tableNames).toContain("mission_features");
db.close();
});
@@ -739,7 +863,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(2);
expect(db.getSchemaVersion()).toBe(3);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -57,7 +57,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 3;
const SCHEMA_SQL = `
-- Tasks table with JSON columns for nested data
@@ -184,6 +184,61 @@ CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- Missions table (hierarchical project planning)
CREATE TABLE IF NOT EXISTS missions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL,
interviewState TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
-- Milestones table (phases within a mission)
CREATE TABLE IF NOT EXISTS milestones (
id TEXT PRIMARY KEY,
missionId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL,
orderIndex INTEGER NOT NULL,
interviewState TEXT NOT NULL,
dependencies TEXT DEFAULT '[]',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE
);
-- Slices table (work units within a milestone)
CREATE TABLE IF NOT EXISTS slices (
id TEXT PRIMARY KEY,
milestoneId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL,
orderIndex INTEGER NOT NULL,
activatedAt TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (milestoneId) REFERENCES milestones(id) ON DELETE CASCADE
);
-- Mission features table (features within a slice that can link to tasks)
CREATE TABLE IF NOT EXISTS mission_features (
id TEXT PRIMARY KEY,
sliceId TEXT NOT NULL,
taskId TEXT,
title TEXT NOT NULL,
description TEXT,
acceptanceCriteria TEXT,
status TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (sliceId) REFERENCES slices(id) ON DELETE CASCADE,
FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE SET NULL
);
`;
// ── Database Class ───────────────────────────────────────────────────
@@ -258,6 +313,14 @@ export class Database {
});
}
if (version < 3) {
this.applyMigration(3, () => {
// Add mission hierarchy columns to tasks for linking tasks to slices
this.addColumnIfMissing("tasks", "missionId", "TEXT");
this.addColumnIfMissing("tasks", "sliceId", "TEXT");
});
}
// Future migrations go here:
// if (version < 3) { this.applyMigration(3, () => { ... }); }
}

View File

@@ -51,3 +51,43 @@ export type {
ImportSettingsOptions,
ImportResult,
} from "./settings-export.js";
// ── Mission Hierarchy Types ────────────────────────────────────────────
export {
MISSION_STATUSES,
MILESTONE_STATUSES,
SLICE_STATUSES,
FEATURE_STATUSES,
INTERVIEW_STATES,
} from "./mission-types.js";
export type {
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
Mission,
Milestone,
Slice,
MissionFeature,
MissionCreateInput,
MilestoneCreateInput,
SliceCreateInput,
FeatureCreateInput,
MissionWithHierarchy,
MilestoneWithSlices,
SliceWithFeatures,
MissionEventPayload,
MissionDeletedPayload,
MilestoneEventPayload,
MilestoneDeletedPayload,
SliceEventPayload,
SliceDeletedPayload,
SliceActivatedPayload,
FeatureEventPayload,
FeatureDeletedPayload,
FeatureLinkedPayload,
} from "./mission-types.js";
export { MissionStore } from "./mission-store.js";
export type { MissionStoreEvents } from "./mission-store.js";

View File

@@ -0,0 +1,939 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MissionStore } from "./mission-store.js";
import { Database } from "./db.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-test-"));
}
/** Helper to create a task in the database for foreign key validation */
function createTaskInDb(
database: Database,
taskId: string,
description = "Test task",
): void {
const now = new Date().toISOString();
database.prepare(
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`
).run(taskId, description, "triage", now, now);
}
describe("MissionStore", () => {
let tmpDir: string;
let kbDir: string;
let db: Database;
let store: MissionStore;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".kb");
db = new Database(kbDir);
db.init();
store = new MissionStore(kbDir, db);
});
afterEach(async () => {
try {
db.close();
} catch {
// already closed
}
await rm(tmpDir, { recursive: true, force: true });
});
// ── Mission CRUD Tests ────────────────────────────────────────────────
describe("Mission CRUD", () => {
it("creates a mission with correct defaults", () => {
const mission = store.createMission({
title: "Test Mission",
description: "A test mission",
});
expect(mission.id).toMatch(/^M-/);
expect(mission.title).toBe("Test Mission");
expect(mission.description).toBe("A test mission");
expect(mission.status).toBe("planning");
expect(mission.interviewState).toBe("not_started");
expect(mission.createdAt).toBeTruthy();
expect(mission.updatedAt).toBeTruthy();
});
it("gets a mission by id", () => {
const created = store.createMission({ title: "Get Test" });
const retrieved = store.getMission(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
expect(retrieved!.title).toBe("Get Test");
});
it("returns undefined for non-existent mission", () => {
const result = store.getMission("M-NONEXISTENT");
expect(result).toBeUndefined();
});
it("lists missions ordered by createdAt desc", async () => {
const m1 = store.createMission({ title: "Mission 1" });
await new Promise((r) => setTimeout(r, 10)); // Ensure different timestamps
const m2 = store.createMission({ title: "Mission 2" });
await new Promise((r) => setTimeout(r, 10));
const m3 = store.createMission({ title: "Mission 3" });
const list = store.listMissions();
expect(list).toHaveLength(3);
expect(list[0].id).toBe(m3.id); // Newest first
expect(list[1].id).toBe(m2.id);
expect(list[2].id).toBe(m1.id);
});
it("updates a mission", async () => {
const mission = store.createMission({ title: "Original" });
await new Promise((r) => setTimeout(r, 5)); // Ensure timestamp difference
const updated = store.updateMission(mission.id, {
title: "Updated",
status: "active",
});
expect(updated.title).toBe("Updated");
expect(updated.status).toBe("active");
expect(updated.id).toBe(mission.id);
expect(updated.createdAt).toBe(mission.createdAt);
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan(
new Date(mission.updatedAt).getTime()
);
});
it("throws when updating non-existent mission", () => {
expect(() => {
store.updateMission("M-NONEXISTENT", { title: "Test" });
}).toThrow("Mission M-NONEXISTENT not found");
});
it("deletes a mission", () => {
const mission = store.createMission({ title: "To Delete" });
store.deleteMission(mission.id);
const retrieved = store.getMission(mission.id);
expect(retrieved).toBeUndefined();
});
it("throws when deleting non-existent mission", () => {
expect(() => {
store.deleteMission("M-NONEXISTENT");
}).toThrow("Mission M-NONEXISTENT not found");
});
it("updates interview state", () => {
const mission = store.createMission({ title: "Interview Test" });
const updated = store.updateMissionInterviewState(mission.id, "in_progress");
expect(updated.interviewState).toBe("in_progress");
});
it("emits mission:created event", () => {
const handler = vi.fn();
store.on("mission:created", handler);
const mission = store.createMission({ title: "Event Test" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mission);
});
it("emits mission:updated event", () => {
const handler = vi.fn();
store.on("mission:updated", handler);
const mission = store.createMission({ title: "Event Test" });
const updated = store.updateMission(mission.id, { title: "Updated" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(updated);
});
it("emits mission:deleted event with id", () => {
const handler = vi.fn();
store.on("mission:deleted", handler);
const mission = store.createMission({ title: "Event Test" });
store.deleteMission(mission.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mission.id);
});
});
// ── Milestone CRUD Tests ──────────────────────────────────────────────
describe("Milestone CRUD", () => {
it("adds a milestone to a mission", () => {
const mission = store.createMission({ title: "Parent Mission" });
const milestone = store.addMilestone(mission.id, {
title: "Test Milestone",
description: "A test milestone",
});
expect(milestone.id).toMatch(/^MS-/);
expect(milestone.missionId).toBe(mission.id);
expect(milestone.title).toBe("Test Milestone");
expect(milestone.description).toBe("A test milestone");
expect(milestone.status).toBe("planning");
expect(milestone.orderIndex).toBe(0);
expect(milestone.dependencies).toEqual([]);
});
it("throws when adding milestone to non-existent mission", () => {
expect(() => {
store.addMilestone("M-NONEXISTENT", { title: "Test" });
}).toThrow("Mission M-NONEXISTENT not found");
});
it("auto-increments orderIndex for multiple milestones", () => {
const mission = store.createMission({ title: "Parent" });
const m1 = store.addMilestone(mission.id, { title: "First" });
const m2 = store.addMilestone(mission.id, { title: "Second" });
const m3 = store.addMilestone(mission.id, { title: "Third" });
expect(m1.orderIndex).toBe(0);
expect(m2.orderIndex).toBe(1);
expect(m3.orderIndex).toBe(2);
});
it("gets a milestone by id", () => {
const mission = store.createMission({ title: "Parent" });
const created = store.addMilestone(mission.id, { title: "Get Test" });
const retrieved = store.getMilestone(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
});
it("returns undefined for non-existent milestone", () => {
const result = store.getMilestone("MS-NONEXISTENT");
expect(result).toBeUndefined();
});
it("lists milestones ordered by orderIndex", () => {
const mission = store.createMission({ title: "Parent" });
const m2 = store.addMilestone(mission.id, { title: "Second" });
const m1 = store.addMilestone(mission.id, { title: "First" });
// Reorder to ensure orderIndex differs from creation order
store.reorderMilestones(mission.id, [m2.id, m1.id]);
const list = store.listMilestones(mission.id);
expect(list[0].id).toBe(m2.id);
expect(list[1].id).toBe(m1.id);
});
it("updates a milestone", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Original" });
const updated = store.updateMilestone(milestone.id, {
title: "Updated",
status: "active",
});
expect(updated.title).toBe("Updated");
expect(updated.status).toBe("active");
});
it("deletes a milestone", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "To Delete" });
store.deleteMilestone(milestone.id);
const retrieved = store.getMilestone(milestone.id);
expect(retrieved).toBeUndefined();
});
it("reorders milestones", () => {
const mission = store.createMission({ title: "Parent" });
const m1 = store.addMilestone(mission.id, { title: "First" });
const m2 = store.addMilestone(mission.id, { title: "Second" });
const m3 = store.addMilestone(mission.id, { title: "Third" });
store.reorderMilestones(mission.id, [m3.id, m1.id, m2.id]);
const list = store.listMilestones(mission.id);
expect(list[0].id).toBe(m3.id);
expect(list[1].id).toBe(m1.id);
expect(list[2].id).toBe(m2.id);
expect(list[0].orderIndex).toBe(0);
expect(list[1].orderIndex).toBe(1);
expect(list[2].orderIndex).toBe(2);
});
it("throws when reordering with invalid milestone id", () => {
const mission = store.createMission({ title: "Parent" });
store.addMilestone(mission.id, { title: "Valid" });
expect(() => {
store.reorderMilestones(mission.id, ["MS-NONEXISTENT"]);
}).toThrow("Milestone MS-NONEXISTENT not found");
});
it("emits milestone events", () => {
const createdHandler = vi.fn();
const deletedHandler = vi.fn();
store.on("milestone:created", createdHandler);
store.on("milestone:deleted", deletedHandler);
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Test" });
store.deleteMilestone(milestone.id);
expect(createdHandler).toHaveBeenCalledTimes(1);
expect(createdHandler).toHaveBeenCalledWith(milestone);
expect(deletedHandler).toHaveBeenCalledTimes(1);
expect(deletedHandler).toHaveBeenCalledWith(milestone.id);
});
it("accepts dependencies array", () => {
const mission = store.createMission({ title: "Parent" });
const dep1 = store.addMilestone(mission.id, { title: "Dep 1" });
const milestone = store.addMilestone(mission.id, {
title: "Dependent",
dependencies: [dep1.id],
});
expect(milestone.dependencies).toEqual([dep1.id]);
});
});
// ── Slice CRUD Tests ──────────────────────────────────────────────────
describe("Slice CRUD", () => {
it("adds a slice to a milestone", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, {
title: "Test Slice",
description: "A test slice",
});
expect(slice.id).toMatch(/^SL-/);
expect(slice.milestoneId).toBe(milestone.id);
expect(slice.title).toBe("Test Slice");
expect(slice.status).toBe("pending");
expect(slice.orderIndex).toBe(0);
});
it("throws when adding slice to non-existent milestone", () => {
expect(() => {
store.addSlice("MS-NONEXISTENT", { title: "Test" });
}).toThrow("Milestone MS-NONEXISTENT not found");
});
it("auto-increments orderIndex for slices", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const s1 = store.addSlice(milestone.id, { title: "First" });
const s2 = store.addSlice(milestone.id, { title: "Second" });
expect(s1.orderIndex).toBe(0);
expect(s2.orderIndex).toBe(1);
});
it("gets a slice by id", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const created = store.addSlice(milestone.id, { title: "Get Test" });
const retrieved = store.getSlice(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
});
it("lists slices ordered by orderIndex", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const s1 = store.addSlice(milestone.id, { title: "First" });
const s2 = store.addSlice(milestone.id, { title: "Second" });
// Reorder
store.reorderSlices(milestone.id, [s2.id, s1.id]);
const list = store.listSlices(milestone.id);
expect(list[0].id).toBe(s2.id);
expect(list[1].id).toBe(s1.id);
});
it("updates a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Original" });
const updated = store.updateSlice(slice.id, { title: "Updated" });
expect(updated.title).toBe("Updated");
});
it("deletes a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "To Delete" });
store.deleteSlice(slice.id);
const retrieved = store.getSlice(slice.id);
expect(retrieved).toBeUndefined();
});
it("activates a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "To Activate" });
const activated = store.activateSlice(slice.id);
expect(activated.status).toBe("active");
expect(activated.activatedAt).toBeTruthy();
});
it("emits slice:activated event", () => {
const handler = vi.fn();
store.on("slice:activated", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Test" });
const activated = store.activateSlice(slice.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(activated);
});
it("emits slice:deleted event with id", () => {
const handler = vi.fn();
store.on("slice:deleted", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Test" });
store.deleteSlice(slice.id);
expect(handler).toHaveBeenCalledWith(slice.id);
});
});
// ── Feature CRUD Tests ────────────────────────────────────────────────
describe("Feature CRUD", () => {
it("adds a feature to a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, {
title: "Test Feature",
description: "A test feature",
acceptanceCriteria: "Criteria here",
});
expect(feature.id).toMatch(/^F-/);
expect(feature.sliceId).toBe(slice.id);
expect(feature.title).toBe("Test Feature");
expect(feature.status).toBe("defined");
expect(feature.taskId).toBeUndefined();
});
it("throws when adding feature to non-existent slice", () => {
expect(() => {
store.addFeature("SL-NONEXISTENT", { title: "Test" });
}).toThrow("Slice SL-NONEXISTENT not found");
});
it("gets a feature by id", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const created = store.addFeature(slice.id, { title: "Get Test" });
const retrieved = store.getFeature(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
});
it("lists features for a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const f1 = store.addFeature(slice.id, { title: "Feature 1" });
const f2 = store.addFeature(slice.id, { title: "Feature 2" });
const list = store.listFeatures(slice.id);
expect(list).toHaveLength(2);
expect(list[0].id).toBe(f1.id);
expect(list[1].id).toBe(f2.id);
});
it("updates a feature", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Original" });
const updated = store.updateFeature(feature.id, { title: "Updated" });
expect(updated.title).toBe("Updated");
});
it("deletes a feature", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "To Delete" });
store.deleteFeature(feature.id);
const retrieved = store.getFeature(feature.id);
expect(retrieved).toBeUndefined();
});
it("links a feature to a task", () => {
createTaskInDb(db, "KB-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Linkable" });
const linked = store.linkFeatureToTask(feature.id, "KB-001");
expect(linked.taskId).toBe("KB-001");
expect(linked.status).toBe("triaged");
});
it("emits feature:linked event", () => {
createTaskInDb(db, "KB-001");
const handler = vi.fn();
store.on("feature:linked", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
const linked = store.linkFeatureToTask(feature.id, "KB-001");
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ feature: linked, taskId: "KB-001" });
});
it("unlinks a feature from a task", () => {
createTaskInDb(db, "KB-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Linkable" });
store.linkFeatureToTask(feature.id, "KB-001");
const unlinked = store.unlinkFeatureFromTask(feature.id);
expect(unlinked.taskId).toBeUndefined();
expect(unlinked.status).toBe("defined");
});
it("finds feature by task id", () => {
createTaskInDb(db, "KB-999");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Findable" });
store.linkFeatureToTask(feature.id, "KB-999");
const found = store.getFeatureByTaskId("KB-999");
expect(found).toBeDefined();
expect(found!.id).toBe(feature.id);
});
it("returns undefined when no feature linked to task", () => {
const result = store.getFeatureByTaskId("KB-NONEXISTENT");
expect(result).toBeUndefined();
});
it("emits feature:deleted event with id", () => {
const handler = vi.fn();
store.on("feature:deleted", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
store.deleteFeature(feature.id);
expect(handler).toHaveBeenCalledWith(feature.id);
});
});
// ── Cascade Delete Tests ───────────────────────────────────────────────
describe("Cascade Deletes", () => {
it("deletes mission → milestones → slices → features", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Child" });
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
const feature = store.addFeature(slice.id, { title: "Great-grandchild" });
store.deleteMission(mission.id);
expect(store.getMission(mission.id)).toBeUndefined();
expect(store.getMilestone(milestone.id)).toBeUndefined();
expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("deletes milestone → slices → features", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Child" });
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
const feature = store.addFeature(slice.id, { title: "Great-grandchild" });
store.deleteMilestone(milestone.id);
// Mission should still exist
expect(store.getMission(mission.id)).toBeDefined();
// But everything below should be gone
expect(store.getMilestone(milestone.id)).toBeUndefined();
expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("deletes slice → features", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
store.deleteSlice(slice.id);
// Mission and milestone should still exist
expect(store.getMission(mission.id)).toBeDefined();
expect(store.getMilestone(milestone.id)).toBeDefined();
// But slice and feature should be gone
expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined();
});
});
// ── Status Rollup Tests ───────────────────────────────────────────────
describe("Status Rollup", () => {
describe("computeSliceStatus", () => {
it("returns pending when no features", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Empty Slice" });
const status = store.computeSliceStatus(slice.id);
expect(status).toBe("pending");
});
it("returns complete when all features done", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Complete Slice" });
const f1 = store.addFeature(slice.id, { title: "F1" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f1.id, { status: "done" });
store.updateFeature(f2.id, { status: "done" });
const status = store.computeSliceStatus(slice.id);
expect(status).toBe("complete");
});
it("returns active when any feature has task linked", () => {
createTaskInDb(db, "KB-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Active Slice" });
const feature = store.addFeature(slice.id, { title: "Linked" });
store.linkFeatureToTask(feature.id, "KB-001");
const status = store.computeSliceStatus(slice.id);
expect(status).toBe("active");
});
});
describe("computeMilestoneStatus", () => {
it("returns planning when no slices", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Empty Milestone" });
const status = store.computeMilestoneStatus(milestone.id);
expect(status).toBe("planning");
});
it("returns complete when all slices complete", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Complete Milestone" });
const s1 = store.addSlice(milestone.id, { title: "S1" });
const s2 = store.addSlice(milestone.id, { title: "S2" });
// Make all features done to trigger slice completion
const f1 = store.addFeature(s1.id, { title: "F1" });
const f2 = store.addFeature(s2.id, { title: "F2" });
store.updateFeature(f1.id, { status: "done" });
store.updateFeature(f2.id, { status: "done" });
// Force recompute
store["recomputeSliceStatus"](s1.id);
store["recomputeSliceStatus"](s2.id);
const status = store.computeMilestoneStatus(milestone.id);
expect(status).toBe("complete");
});
it("returns active when any slice is active", () => {
createTaskInDb(db, "KB-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Active Milestone" });
const slice = store.addSlice(milestone.id, { title: "Active Slice" });
const feature = store.addFeature(slice.id, { title: "Linked" });
store.linkFeatureToTask(feature.id, "KB-001");
const status = store.computeMilestoneStatus(milestone.id);
expect(status).toBe("active");
});
});
describe("computeMissionStatus", () => {
it("returns planning when no milestones", () => {
const mission = store.createMission({ title: "Empty Mission" });
const status = store.computeMissionStatus(mission.id);
expect(status).toBe("planning");
});
it("returns complete when all milestones complete", () => {
const mission = store.createMission({ title: "Complete Mission" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
const m2 = store.addMilestone(mission.id, { title: "M2" });
// Complete both milestones
store.updateMilestone(m1.id, { status: "complete" });
store.updateMilestone(m2.id, { status: "complete" });
const status = store.computeMissionStatus(mission.id);
expect(status).toBe("complete");
});
it("returns active when any milestone is active", () => {
const mission = store.createMission({ title: "Active Mission" });
const m1 = store.addMilestone(mission.id, { title: "Active M" });
const m2 = store.addMilestone(mission.id, { title: "Planning M" });
store.updateMilestone(m1.id, { status: "active" });
const status = store.computeMissionStatus(mission.id);
expect(status).toBe("active");
});
});
});
// ── Mission With Hierarchy Tests ──────────────────────────────────────
describe("getMissionWithHierarchy", () => {
it("returns undefined for non-existent mission", () => {
const result = store.getMissionWithHierarchy("M-NONEXISTENT");
expect(result).toBeUndefined();
});
it("returns mission with full hierarchy", () => {
const mission = store.createMission({
title: "Hierarchy Test",
description: "Testing full tree loading",
});
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" });
const s2 = store.addSlice(m1.id, { title: "Slice 2" });
const f1 = store.addFeature(s1.id, { title: "Feature 1" });
const f2 = store.addFeature(s1.id, { title: "Feature 2" });
const withHierarchy = store.getMissionWithHierarchy(mission.id)!;
expect(withHierarchy.id).toBe(mission.id);
expect(withHierarchy.title).toBe("Hierarchy Test");
expect(withHierarchy.milestones).toHaveLength(2);
const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!;
expect(m1Data.slices).toHaveLength(2);
const s1Data = m1Data.slices.find((s) => s.id === s1.id)! as import("./mission-types.js").SliceWithFeatures;
expect(s1Data.features).toHaveLength(2);
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();
});
});
// ── Transaction Tests ────────────────────────────────────────────────
describe("Transaction Handling", () => {
it("rolls back reorder on error", () => {
const mission = store.createMission({ title: "Parent" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
const originalOrder = m1.orderIndex;
expect(() => {
store.reorderMilestones(mission.id, [m1.id, "MS-NONEXISTENT"]);
}).toThrow();
// m1's order should be unchanged due to rollback
const retrieved = store.getMilestone(m1.id);
expect(retrieved!.orderIndex).toBe(originalOrder);
});
it("rolls back slice reorder on error", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const s1 = store.addSlice(milestone.id, { title: "S1" });
const originalOrder = s1.orderIndex;
expect(() => {
store.reorderSlices(milestone.id, [s1.id, "SL-NONEXISTENT"]);
}).toThrow();
const retrieved = store.getSlice(s1.id);
expect(retrieved!.orderIndex).toBe(originalOrder);
});
});
// ── Event Emission Tests ──────────────────────────────────────────────
describe("Event Emissions", () => {
it("emits all mission lifecycle events", () => {
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
store.on("mission:created", created);
store.on("mission:updated", updated);
store.on("mission:deleted", deleted);
const mission = store.createMission({ title: "Test" });
store.updateMission(mission.id, { title: "Updated" });
store.deleteMission(mission.id);
expect(created).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("emits all milestone lifecycle events", () => {
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
store.on("milestone:created", created);
store.on("milestone:updated", updated);
store.on("milestone:deleted", deleted);
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Test" });
store.updateMilestone(milestone.id, { title: "Updated" });
store.deleteMilestone(milestone.id);
expect(created).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("emits all slice lifecycle events", () => {
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
const activated = vi.fn();
store.on("slice:created", created);
store.on("slice:updated", updated);
store.on("slice:deleted", deleted);
store.on("slice:activated", activated);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Test" });
store.activateSlice(slice.id);
store.deleteSlice(slice.id);
expect(created).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledTimes(1); // From activateSlice
expect(activated).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("emits all feature lifecycle events", () => {
createTaskInDb(db, "KB-001");
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
const linked = vi.fn();
store.on("feature:created", created);
store.on("feature:updated", updated);
store.on("feature:deleted", deleted);
store.on("feature:linked", linked);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
store.linkFeatureToTask(feature.id, "KB-001");
store.deleteFeature(feature.id);
expect(created).toHaveBeenCalledTimes(1);
// Updated is called twice: once by linkFeatureToTask, once by delete triggering recompute
expect(updated).toHaveBeenCalled();
expect(linked).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("includes correct data in event payloads", () => {
createTaskInDb(db, "KB-123");
const createdHandler = vi.fn();
const linkedHandler = vi.fn();
store.on("feature:created", createdHandler);
store.on("feature:linked", linkedHandler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
expect(createdHandler).toHaveBeenCalledWith(
expect.objectContaining({
id: feature.id,
title: "Test",
status: "defined",
})
);
store.linkFeatureToTask(feature.id, "KB-123");
expect(linkedHandler).toHaveBeenCalledWith(
expect.objectContaining({
feature: expect.objectContaining({ id: feature.id }),
taskId: "KB-123",
})
);
});
});
});
// vi import for vitest mocking
import { vi } from "vitest";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
/**
* Mission hierarchy types for kb project planning.
*
* A Mission represents a high-level objective that can span multiple milestones.
* Each Milestone represents a phase of work within a mission.
* Each Slice represents a work unit within a milestone that can be activated for implementation.
* Each Feature represents a deliverable within a slice that can be linked to a kb Task.
*
* The hierarchy: Mission → Milestone → Slice → Feature → (optional) Task
*/
// ── Status Enums ─────────────────────────────────────────────────────
/** Status values for a Mission's lifecycle */
export const MISSION_STATUSES = ["planning", "active", "blocked", "complete", "archived"] as const;
export type MissionStatus = (typeof MISSION_STATUSES)[number];
/** Status values for a Milestone within a mission */
export const MILESTONE_STATUSES = ["planning", "active", "blocked", "complete"] as const;
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
/** Status values for a Slice (work unit) */
export const SLICE_STATUSES = ["pending", "active", "complete"] as const;
export type SliceStatus = (typeof SLICE_STATUSES)[number];
/** Status values for a Feature within a slice */
export const FEATURE_STATUSES = ["defined", "triaged", "in-progress", "done"] as const;
export type FeatureStatus = (typeof FEATURE_STATUSES)[number];
/** Interview state for AI-assisted specification */
export const INTERVIEW_STATES = ["not_started", "in_progress", "completed", "needs_update"] as const;
export type InterviewState = (typeof INTERVIEW_STATES)[number];
// ── Core Entity Types ───────────────────────────────────────────────
/**
* A Mission represents a high-level objective or project.
* Missions contain milestones that break down the work into phases.
*/
export interface Mission {
/** Unique identifier (e.g., "M-001") */
id: string;
/** Display name of the mission */
title: string;
/** Detailed description of the mission's objectives */
description?: string;
/** Current lifecycle status */
status: MissionStatus;
/** State of the AI specification interview process */
interviewState: InterviewState;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A Milestone represents a phase of work within a mission.
* Milestones contain slices that represent work units to be executed.
*/
export interface Milestone {
/** Unique identifier (e.g., "MS-001") */
id: string;
/** Parent mission ID */
missionId: string;
/** Display name of the milestone */
title: string;
/** Detailed description of milestone objectives */
description?: string;
/** Current lifecycle status */
status: MilestoneStatus;
/** Order index for sorting within the mission (0-based) */
orderIndex: number;
/** State of the AI specification interview process */
interviewState: InterviewState;
/** IDs of milestones that must complete before this one can start */
dependencies: string[];
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A Slice represents a work unit within a milestone.
* Slices can be activated for implementation, linking to kb tasks.
*/
export interface Slice {
/** Unique identifier (e.g., "SL-001") */
id: string;
/** Parent milestone ID */
milestoneId: string;
/** Display name of the slice */
title: string;
/** Detailed description of work to be done */
description?: string;
/** Current lifecycle status */
status: SliceStatus;
/** Order index for sorting within the milestone (0-based) */
orderIndex: number;
/** ISO-8601 timestamp when the slice was activated (if applicable) */
activatedAt?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A MissionFeature represents a deliverable within a slice.
* Features can be linked to kb Tasks for implementation.
*/
export interface MissionFeature {
/** Unique identifier (e.g., "F-001") */
id: string;
/** Parent slice ID */
sliceId: string;
/** Linked task ID (optional) - set when feature is triaged into a task */
taskId?: string;
/** Display name of the feature */
title: string;
/** Detailed description of the feature */
description?: string;
/** Acceptance criteria for completing the feature */
acceptanceCriteria?: string;
/** Current lifecycle status */
status: FeatureStatus;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
// ── Input Types (for creation) ──────────────────────────────────────
/** Input for creating a new Mission */
export interface MissionCreateInput {
/** Display name of the mission (required) */
title: string;
/** Detailed description of the mission's objectives */
description?: string;
}
/** Input for creating a new Milestone */
export interface MilestoneCreateInput {
/** Display name of the milestone (required) */
title: string;
/** Detailed description of milestone objectives */
description?: string;
/** IDs of milestones that must complete before this one can start */
dependencies?: string[];
}
/** Input for creating a new Slice */
export interface SliceCreateInput {
/** Display name of the slice (required) */
title: string;
/** Detailed description of work to be done */
description?: string;
}
/** Input for creating a new Feature */
export interface FeatureCreateInput {
/** Display name of the feature (required) */
title: string;
/** Detailed description of the feature */
description?: string;
/** Acceptance criteria for completing the feature */
acceptanceCriteria?: string;
}
// ─ Composite Types ─────────────────────────────────────────────────
/**
* A Milestone with its nested slices loaded.
* Used when fetching a single milestone with full hierarchy.
*/
export interface MilestoneWithSlices extends Milestone {
/** Slices belonging to this milestone */
slices: Slice[];
}
/**
* A Slice with its nested features loaded.
* Used when fetching a single slice with full details.
*/
export interface SliceWithFeatures extends Slice {
/** Features belonging to this slice */
features: MissionFeature[];
}
/**
* A Mission with complete hierarchy loaded:
* Mission → Milestones → Slices → Features
*/
export interface MissionWithHierarchy extends Mission {
/** Milestones belonging to this mission, each with their slices */
milestones: Array<MilestoneWithSlices & {
/** Slices with their features loaded */
slices: SliceWithFeatures[];
}>;
}
// ── Event Payload Types ─────────────────────────────────────────────
/** Payload for mission:created and mission:updated events */
export type MissionEventPayload = Mission;
/** Payload for mission:deleted event */
export interface MissionDeletedPayload {
/** ID of the deleted mission */
missionId: string;
}
/** Payload for milestone:created and milestone:updated events */
export type MilestoneEventPayload = Milestone;
/** Payload for milestone:deleted event */
export interface MilestoneDeletedPayload {
/** ID of the deleted milestone */
milestoneId: string;
}
/** Payload for slice:created and slice:updated events */
export type SliceEventPayload = Slice;
/** Payload for slice:deleted event */
export interface SliceDeletedPayload {
/** ID of the deleted slice */
sliceId: string;
}
/** Payload for slice:activated event */
export type SliceActivatedPayload = Slice;
/** Payload for feature:created and feature:updated events */
export type FeatureEventPayload = MissionFeature;
/** Payload for feature:deleted event */
export interface FeatureDeletedPayload {
/** ID of the deleted feature */
featureId: string;
}
/** Payload for feature:linked event */
export interface FeatureLinkedPayload {
/** The feature that was linked */
feature: MissionFeature;
/** ID of the task it was linked to */
taskId: string;
}