Files
fusion/packages/core/src/mission-store.ts
gsxdsm 486d12827b 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
2026-03-31 18:28:23 -07:00

1123 lines
31 KiB
TypeScript

/**
* MissionStore - Data layer for the Missions hierarchy system.
*
* Manages CRUD operations for missions, milestones, slices, and features.
* Provides status rollup logic and emits events for dashboard reactivity.
*
* Follows the same patterns as TaskStore for consistency:
* - EventEmitter for change notifications
* - SQLite for structured data storage
* - JSON columns for nested arrays
* - Transaction handling for atomic operations
*/
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { fromJson, toJson } from "./db.js";
import type {
Mission,
Milestone,
Slice,
MissionFeature,
MissionCreateInput,
MilestoneCreateInput,
SliceCreateInput,
FeatureCreateInput,
MissionWithHierarchy,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
} from "./mission-types.js";
// ── Event Types ─────────────────────────────────────────────────────
export interface MissionStoreEvents {
/** Emitted when a mission is created */
"mission:created": [Mission];
/** Emitted when a mission is updated */
"mission:updated": [Mission];
/** Emitted when a mission is deleted */
"mission:deleted": [string];
/** Emitted when a milestone is created */
"milestone:created": [Milestone];
/** Emitted when a milestone is updated */
"milestone:updated": [Milestone];
/** Emitted when a milestone is deleted */
"milestone:deleted": [string];
/** Emitted when a slice is created */
"slice:created": [Slice];
/** Emitted when a slice is updated */
"slice:updated": [Slice];
/** Emitted when a slice is deleted */
"slice:deleted": [string];
/** Emitted when a slice is activated for work */
"slice:activated": [Slice];
/** Emitted when a feature is created */
"feature:created": [MissionFeature];
/** Emitted when a feature is updated */
"feature:updated": [MissionFeature];
/** Emitted when a feature is deleted */
"feature:deleted": [string];
/** Emitted when a feature is linked to a task */
"feature:linked": [{ feature: MissionFeature; taskId: string }];
}
// ── MissionStore Class ──────────────────────────────────────────────
export class MissionStore extends EventEmitter<MissionStoreEvents> {
/**
* Creates a new MissionStore instance.
*
* @param kbDir - Path to the .kb directory (e.g., /path/to/project/.kb)
* @param db - Shared Database instance (same instance used by TaskStore)
*/
constructor(
private kbDir: string,
private db: Database,
) {
super();
this.setMaxListeners(100);
}
// ── Row-to-Object Converters ───────────────────────────────────────
/**
* Convert a database row to a Mission object.
*/
private rowToMission(row: any): Mission {
return {
id: row.id,
title: row.title,
description: row.description || undefined,
status: row.status as MissionStatus,
interviewState: row.interviewState as InterviewState,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Convert a database row to a Milestone object.
*/
private rowToMilestone(row: any): Milestone {
return {
id: row.id,
missionId: row.missionId,
title: row.title,
description: row.description || undefined,
status: row.status as MilestoneStatus,
orderIndex: row.orderIndex,
interviewState: row.interviewState as InterviewState,
dependencies: fromJson<string[]>(row.dependencies) || [],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Convert a database row to a Slice object.
*/
private rowToSlice(row: any): Slice {
return {
id: row.id,
milestoneId: row.milestoneId,
title: row.title,
description: row.description || undefined,
status: row.status as SliceStatus,
orderIndex: row.orderIndex,
activatedAt: row.activatedAt || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Convert a database row to a MissionFeature object.
*/
private rowToFeature(row: any): MissionFeature {
return {
id: row.id,
sliceId: row.sliceId,
taskId: row.taskId || undefined,
title: row.title,
description: row.description || undefined,
acceptanceCriteria: row.acceptanceCriteria || undefined,
status: row.status as FeatureStatus,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Mission CRUD Operations ────────────────────────────────────────
/**
* Create a new mission.
* The mission starts in "planning" status with "not_started" interview state.
*
* @param input - Mission creation input
* @returns The created mission
*/
createMission(input: MissionCreateInput): Mission {
const now = new Date().toISOString();
const id = this.generateMissionId();
const mission: Mission = {
id,
title: input.title,
description: input.description,
status: "planning",
interviewState: "not_started",
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
mission.description ?? null,
mission.status,
mission.interviewState,
mission.createdAt,
mission.updatedAt,
);
this.db.bumpLastModified();
this.emit("mission:created", mission);
return mission;
}
/**
* Get a mission by ID.
*
* @param id - Mission ID
* @returns The mission, or undefined if not found
*/
getMission(id: string): Mission | undefined {
const row = this.db.prepare("SELECT * FROM missions WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToMission(row);
}
/**
* Get a mission with its full hierarchy (milestones → slices → features).
*
* @param id - Mission ID
* @returns The mission with hierarchy, or undefined if not found
*/
getMissionWithHierarchy(id: string): MissionWithHierarchy | undefined {
const mission = this.getMission(id);
if (!mission) return undefined;
const milestones = this.listMilestones(id);
const milestonesWithSlices = milestones.map((milestone) => {
const slices = this.listSlices(milestone.id);
const slicesWithFeatures = slices.map((slice) => ({
...slice,
features: this.listFeatures(slice.id),
}));
return {
...milestone,
slices: slicesWithFeatures,
};
});
return {
...mission,
milestones: milestonesWithSlices,
};
}
/**
* List all missions, ordered by creation date (newest first).
*
* @returns Array of missions
*/
listMissions(): Mission[] {
const rows = this.db.prepare("SELECT * FROM missions ORDER BY createdAt DESC").all();
return (rows as any[]).map((row) => this.rowToMission(row));
}
/**
* Update a mission.
*
* @param id - Mission ID
* @param updates - Partial mission updates (cannot update id or createdAt)
* @returns The updated mission
* @throws Error if mission not found
*/
updateMission(id: string, updates: Partial<Mission>): Mission {
const mission = this.getMission(id);
if (!mission) {
throw new Error(`Mission ${id} not found`);
}
const updated: Mission = {
...mission,
...updates,
id, // Prevent changing ID
createdAt: mission.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE missions SET
title = ?,
description = ?,
status = ?,
interviewState = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.status,
updated.interviewState,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("mission:updated", updated);
return updated;
}
/**
* Delete a mission.
* Cascades to delete all milestones, slices, and features.
*
* @param id - Mission ID
* @throws Error if mission not found
*/
deleteMission(id: string): void {
const mission = this.getMission(id);
if (!mission) {
throw new Error(`Mission ${id} not found`);
}
this.db.prepare("DELETE FROM missions WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("mission:deleted", id);
}
/**
* Update the interview state for a mission.
* Convenience method for the specification workflow.
*
* @param id - Mission ID
* @param state - New interview state
* @returns The updated mission
*/
updateMissionInterviewState(id: string, state: InterviewState): Mission {
return this.updateMission(id, { interviewState: state });
}
// ── Milestone Operations ───────────────────────────────────────────
/**
* Add a milestone to a mission.
* Automatically computes the orderIndex (max + 1).
*
* @param missionId - Parent mission ID
* @param input - Milestone creation input
* @returns The created milestone
* @throws Error if mission not found
*/
addMilestone(missionId: string, input: MilestoneCreateInput): Milestone {
const mission = this.getMission(missionId);
if (!mission) {
throw new Error(`Mission ${missionId} not found`);
}
const now = new Date().toISOString();
const id = this.generateMilestoneId();
// Compute next orderIndex
const existingMilestones = this.listMilestones(missionId);
const orderIndex = existingMilestones.length > 0
? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1
: 0;
const milestone: Milestone = {
id,
missionId,
title: input.title,
description: input.description,
status: "planning",
orderIndex,
interviewState: "not_started",
dependencies: input.dependencies || [],
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
milestone.id,
milestone.missionId,
milestone.title,
milestone.description ?? null,
milestone.status,
milestone.orderIndex,
milestone.interviewState,
toJson(milestone.dependencies),
milestone.createdAt,
milestone.updatedAt,
);
this.db.bumpLastModified();
this.emit("milestone:created", milestone);
return milestone;
}
/**
* Get a milestone by ID.
*
* @param id - Milestone ID
* @returns The milestone, or undefined if not found
*/
getMilestone(id: string): Milestone | undefined {
const row = this.db.prepare("SELECT * FROM milestones WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToMilestone(row);
}
/**
* List milestones for a mission, ordered by orderIndex.
*
* @param missionId - Mission ID
* @returns Array of milestones
*/
listMilestones(missionId: string): Milestone[] {
const rows = this.db.prepare(
"SELECT * FROM milestones WHERE missionId = ? ORDER BY orderIndex ASC"
).all(missionId);
return (rows as any[]).map((row) => this.rowToMilestone(row));
}
/**
* Update a milestone.
*
* @param id - Milestone ID
* @param updates - Partial milestone updates
* @returns The updated milestone
* @throws Error if milestone not found
*/
updateMilestone(id: string, updates: Partial<Milestone>): Milestone {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
const updated: Milestone = {
...milestone,
...updates,
id,
missionId: milestone.missionId, // Prevent moving to different mission
createdAt: milestone.createdAt,
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE milestones SET
title = ?,
description = ?,
status = ?,
orderIndex = ?,
interviewState = ?,
dependencies = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.status,
updated.orderIndex,
updated.interviewState,
toJson(updated.dependencies),
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("milestone:updated", updated);
// Recompute mission status after milestone update
this.recomputeMissionStatus(updated.missionId);
return updated;
}
/**
* Delete a milestone.
* Cascades to delete all slices and features.
*
* @param id - Milestone ID
* @throws Error if milestone not found
*/
deleteMilestone(id: string): void {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
const missionId = milestone.missionId;
this.db.prepare("DELETE FROM milestones WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("milestone:deleted", id);
// Recompute mission status after deletion
this.recomputeMissionStatus(missionId);
}
/**
* Reorder milestones within a mission.
* Updates the orderIndex for each milestone in the provided order.
*
* @param missionId - Mission ID
* @param orderedIds - Milestone IDs in the desired order
* @throws Error if any milestone is not found or belongs to a different mission
*/
reorderMilestones(missionId: string, orderedIds: string[]): void {
this.db.transaction(() => {
for (let i = 0; i < orderedIds.length; i++) {
const id = orderedIds[i];
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
if (milestone.missionId !== missionId) {
throw new Error(`Milestone ${id} does not belong to mission ${missionId}`);
}
this.db.prepare(
"UPDATE milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?"
).run(i, new Date().toISOString(), id);
}
});
this.db.bumpLastModified();
}
/**
* Update the interview state for a milestone.
*
* @param id - Milestone ID
* @param state - New interview state
* @returns The updated milestone
*/
updateMilestoneInterviewState(id: string, state: InterviewState): Milestone {
return this.updateMilestone(id, { interviewState: state });
}
// ── Slice Operations ───────────────────────────────────────────────
/**
* Add a slice to a milestone.
* Automatically computes the orderIndex (max + 1).
* Initial status is "pending".
*
* @param milestoneId - Parent milestone ID
* @param input - Slice creation input
* @returns The created slice
* @throws Error if milestone not found
*/
addSlice(milestoneId: string, input: SliceCreateInput): Slice {
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
const now = new Date().toISOString();
const id = this.generateSliceId();
// Compute next orderIndex
const existingSlices = this.listSlices(milestoneId);
const orderIndex = existingSlices.length > 0
? Math.max(...existingSlices.map((s) => s.orderIndex)) + 1
: 0;
const slice: Slice = {
id,
milestoneId,
title: input.title,
description: input.description,
status: "pending",
orderIndex,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO slices (id, milestoneId, title, description, status, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
slice.id,
slice.milestoneId,
slice.title,
slice.description ?? null,
slice.status,
slice.orderIndex,
slice.createdAt,
slice.updatedAt,
);
this.db.bumpLastModified();
this.emit("slice:created", slice);
return slice;
}
/**
* Get a slice by ID.
*
* @param id - Slice ID
* @returns The slice, or undefined if not found
*/
getSlice(id: string): Slice | undefined {
const row = this.db.prepare("SELECT * FROM slices WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToSlice(row);
}
/**
* List slices for a milestone, ordered by orderIndex.
*
* @param milestoneId - Milestone ID
* @returns Array of slices
*/
listSlices(milestoneId: string): Slice[] {
const rows = this.db.prepare(
"SELECT * FROM slices WHERE milestoneId = ? ORDER BY orderIndex ASC"
).all(milestoneId);
return (rows as any[]).map((row) => this.rowToSlice(row));
}
/**
* Update a slice.
*
* @param id - Slice ID
* @param updates - Partial slice updates
* @returns The updated slice
* @throws Error if slice not found
*/
updateSlice(id: string, updates: Partial<Slice>): Slice {
const slice = this.getSlice(id);
if (!slice) {
throw new Error(`Slice ${id} not found`);
}
const updated: Slice = {
...slice,
...updates,
id,
milestoneId: slice.milestoneId,
createdAt: slice.createdAt,
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE slices SET
title = ?,
description = ?,
status = ?,
orderIndex = ?,
activatedAt = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.status,
updated.orderIndex,
updated.activatedAt ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("slice:updated", updated);
// Recompute milestone status after slice update
this.recomputeMilestoneStatus(updated.milestoneId);
return updated;
}
/**
* Delete a slice.
* Cascades to delete all features.
*
* @param id - Slice ID
* @throws Error if slice not found
*/
deleteSlice(id: string): void {
const slice = this.getSlice(id);
if (!slice) {
throw new Error(`Slice ${id} not found`);
}
const milestoneId = slice.milestoneId;
this.db.prepare("DELETE FROM slices WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("slice:deleted", id);
// Recompute milestone status after deletion
this.recomputeMilestoneStatus(milestoneId);
}
/**
* Reorder slices within a milestone.
*
* @param milestoneId - Milestone ID
* @param orderedIds - Slice IDs in the desired order
* @throws Error if any slice is not found or belongs to a different milestone
*/
reorderSlices(milestoneId: string, orderedIds: string[]): void {
this.db.transaction(() => {
for (let i = 0; i < orderedIds.length; i++) {
const id = orderedIds[i];
const slice = this.getSlice(id);
if (!slice) {
throw new Error(`Slice ${id} not found`);
}
if (slice.milestoneId !== milestoneId) {
throw new Error(`Slice ${id} does not belong to milestone ${milestoneId}`);
}
this.db.prepare(
"UPDATE slices SET orderIndex = ?, updatedAt = ? WHERE id = ?"
).run(i, new Date().toISOString(), id);
}
});
this.db.bumpLastModified();
}
/**
* Activate a slice for implementation.
* Sets status to "active" and records activation time.
*
* @param id - Slice ID
* @returns The activated slice
* @throws Error if slice not found
*/
activateSlice(id: string): Slice {
const now = new Date().toISOString();
const updated = this.updateSlice(id, {
status: "active",
activatedAt: now,
});
this.emit("slice:activated", updated);
return updated;
}
// ── Feature Operations ─────────────────────────────────────────────
/**
* Add a feature to a slice.
* Initial status is "defined".
*
* @param sliceId - Parent slice ID
* @param input - Feature creation input
* @returns The created feature
* @throws Error if slice not found
*/
addFeature(sliceId: string, input: FeatureCreateInput): MissionFeature {
const slice = this.getSlice(sliceId);
if (!slice) {
throw new Error(`Slice ${sliceId} not found`);
}
const now = new Date().toISOString();
const id = this.generateFeatureId();
const feature: MissionFeature = {
id,
sliceId,
title: input.title,
description: input.description,
acceptanceCriteria: input.acceptanceCriteria,
status: "defined",
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO mission_features (id, sliceId, title, description, acceptanceCriteria, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
feature.id,
feature.sliceId,
feature.title,
feature.description ?? null,
feature.acceptanceCriteria ?? null,
feature.status,
feature.createdAt,
feature.updatedAt,
);
this.db.bumpLastModified();
this.emit("feature:created", feature);
return feature;
}
/**
* Get a feature by ID.
*
* @param id - Feature ID
* @returns The feature, or undefined if not found
*/
getFeature(id: string): MissionFeature | undefined {
const row = this.db.prepare("SELECT * FROM mission_features WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToFeature(row);
}
/**
* List features for a slice, ordered by creation date.
*
* @param sliceId - Slice ID
* @returns Array of features
*/
listFeatures(sliceId: string): MissionFeature[] {
const rows = this.db.prepare(
"SELECT * FROM mission_features WHERE sliceId = ? ORDER BY createdAt ASC"
).all(sliceId);
return (rows as any[]).map((row) => this.rowToFeature(row));
}
/**
* Update a feature.
*
* @param id - Feature ID
* @param updates - Partial feature updates
* @returns The updated feature
* @throws Error if feature not found
*/
updateFeature(id: string, updates: Partial<MissionFeature>): MissionFeature {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
const updated: MissionFeature = {
...feature,
...updates,
id,
sliceId: feature.sliceId,
createdAt: feature.createdAt,
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE mission_features SET
title = ?,
description = ?,
acceptanceCriteria = ?,
status = ?,
taskId = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.acceptanceCriteria ?? null,
updated.status,
updated.taskId ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("feature:updated", updated);
// Recompute slice status if task linkage changed
if (updates.taskId !== undefined && updates.taskId !== feature.taskId) {
this.recomputeSliceStatus(updated.sliceId);
}
return updated;
}
/**
* Delete a feature.
*
* @param id - Feature ID
* @throws Error if feature not found
*/
deleteFeature(id: string): void {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
const sliceId = feature.sliceId;
this.db.prepare("DELETE FROM mission_features WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("feature:deleted", id);
// Recompute slice status after deletion
this.recomputeSliceStatus(sliceId);
}
/**
* Link a feature to a task.
* Updates the feature's taskId and emits feature:linked event.
*
* @param featureId - Feature ID
* @param taskId - Task ID to link to
* @returns The updated feature
* @throws Error if feature not found
*/
linkFeatureToTask(featureId: string, taskId: string): MissionFeature {
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const updated = this.updateFeature(featureId, {
taskId,
status: "triaged",
});
this.emit("feature:linked", { feature: updated, taskId });
// Recompute slice status
this.recomputeSliceStatus(updated.sliceId);
return updated;
}
/**
* Unlink a feature from its task.
* Clears the feature's taskId.
*
* @param featureId - Feature ID
* @returns The updated feature
* @throws Error if feature not found
*/
unlinkFeatureFromTask(featureId: string): MissionFeature {
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const updated = this.updateFeature(featureId, {
taskId: undefined,
status: "defined",
});
// Recompute slice status
this.recomputeSliceStatus(updated.sliceId);
return updated;
}
/**
* Find a feature by its linked task ID.
*
* @param taskId - Task ID
* @returns The feature, or undefined if no feature is linked to this task
*/
getFeatureByTaskId(taskId: string): MissionFeature | undefined {
const row = this.db.prepare("SELECT * FROM mission_features WHERE taskId = ?").get(taskId);
if (!row) return undefined;
return this.rowToFeature(row);
}
// ── Status Rollup Logic ───────────────────────────────────────────
/**
* Compute the status of a slice based on its features.
* - If no features: "pending"
* - If all features linked to done tasks: "complete"
* - If any feature linked to in-progress task: "active"
* - If any feature linked to triaged (ready) task: "active"
* - Otherwise: "pending"
*
* @param sliceId - Slice ID
* @returns The computed slice status
*/
computeSliceStatus(sliceId: string): SliceStatus {
const features = this.listFeatures(sliceId);
if (features.length === 0) {
return "pending";
}
// Check if all features are done (linked to done tasks)
const allDone = features.every((f) => f.status === "done");
if (allDone) {
return "complete";
}
// Check if any feature is in-progress or triaged (has a task link)
const anyActive = features.some((f) =>
f.status === "in-progress" || f.status === "triaged" || f.taskId !== undefined
);
if (anyActive) {
return "active";
}
return "pending";
}
/**
* Compute the status of a milestone based on its slices.
* - If any slice "active": "active"
* - If all slices "complete": "complete"
* - If any slice "active" or "complete" but not all complete: "active"
* - Otherwise: "planning"
* Note: "blocked" is manually set, not auto-computed.
*
* @param milestoneId - Milestone ID
* @returns The computed milestone status
*/
computeMilestoneStatus(milestoneId: string): MilestoneStatus {
const slices = this.listSlices(milestoneId);
if (slices.length === 0) {
return "planning";
}
const hasActive = slices.some((s) => s.status === "active");
const allComplete = slices.every((s) => s.status === "complete");
if (allComplete) {
return "complete";
}
if (hasActive) {
return "active";
}
const hasProgress = slices.some((s) => s.status === "active" || s.status === "complete");
if (hasProgress) {
return "active";
}
return "planning";
}
/**
* Compute the status of a mission based on its milestones.
* - If any milestone "active": "active"
* - If all milestones "complete": "complete"
* - If any milestone "active" or "complete" but not all complete: "active"
* - Otherwise: "planning"
* Note: "blocked" and "archived" are manually set.
*
* @param missionId - Mission ID
* @returns The computed mission status
*/
computeMissionStatus(missionId: string): MissionStatus {
const milestones = this.listMilestones(missionId);
if (milestones.length === 0) {
return "planning";
}
const hasActive = milestones.some((m) => m.status === "active");
const allComplete = milestones.every((m) => m.status === "complete");
if (allComplete) {
return "complete";
}
if (hasActive) {
return "active";
}
const hasProgress = milestones.some((m) => m.status === "active" || m.status === "complete");
if (hasProgress) {
return "active";
}
return "planning";
}
/**
* Recompute and update the slice status.
* Called automatically after feature changes.
*/
private recomputeSliceStatus(sliceId: string): void {
const newStatus = this.computeSliceStatus(sliceId);
const slice = this.getSlice(sliceId);
if (slice && slice.status !== newStatus) {
this.updateSlice(sliceId, { status: newStatus });
// Don't emit here - updateSlice already emits and triggers milestone recompute
}
}
/**
* Recompute and update the milestone status.
* Called automatically after slice changes.
*/
private recomputeMilestoneStatus(milestoneId: string): void {
const newStatus = this.computeMilestoneStatus(milestoneId);
const milestone = this.getMilestone(milestoneId);
if (milestone && milestone.status !== newStatus) {
this.updateMilestone(milestoneId, { status: newStatus });
// Don't emit here - updateMilestone already emits and triggers mission recompute
}
}
/**
* Recompute and update the mission status.
* Called automatically after milestone changes.
*/
private recomputeMissionStatus(missionId: string): void {
const newStatus = this.computeMissionStatus(missionId);
const mission = this.getMission(missionId);
if (mission && mission.status !== newStatus) {
this.updateMission(missionId, { status: newStatus });
// Don't emit here - updateMission already emits
}
}
// ── ID Generators ───────────────────────────────────────────────────
private generateMissionId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `M-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateMilestoneId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `MS-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateSliceId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `SL-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateFeatureId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `F-${timestamp.toString(36).toUpperCase()}-${random}`;
}
}