feat(KB-635): add Mission system integration across CLI, engine, and dashboard

- Add mission CLI commands: create, list, show, delete, activate-slice
- Add pi extension tools for mission management
- Add MissionStore helper methods and Mission types
- Add scheduler mission awareness for automatic slice activation
- Add engine integration wiring for mission-aware task execution
- Add dashboard mission routes and API endpoints
- Link tasks to missions via sliceId field with autoAdvance support
This commit is contained in:
gsxdsm
2026-04-01 07:49:21 -07:00
parent 021055c82a
commit 360af7d863
11 changed files with 1032 additions and 50 deletions

View File

@@ -1675,14 +1675,14 @@ describe("buildExecutionPrompt", () => {
});
it("includes only the 10 most recent comments", () => {
const allComments = Array.from({ length: 15 }, (_, i) => ({
const comments = Array.from({ length: 15 }, (_, i) => ({
id: `${i}`,
text: `Comment ${i}`,
createdAt: new Date().toISOString(),
author: "user",
}));
const task = createMockTaskDetail({ comments: allComments });
const task = createMockTaskDetail({ comments });
const result = buildExecutionPrompt(task);
// Should include comments 5-14 (the 10 most recent), not 0-4

View File

@@ -1830,7 +1830,7 @@ git log --oneline
"",
"## Steering Comments",
"",
"The following steering comments were added by the user during execution. Consider adjusting your approach or replanning remaining steps based on this feedback.",
"The following comments were added by the user during execution. Consider adjusting your approach or replanning remaining steps based on this feedback.",
"",
];
for (const comment of recentComments) {

View File

@@ -455,4 +455,197 @@ describe("Scheduler", () => {
expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage");
});
});
describe("mission integration", () => {
// Helper to create mock MissionStore
function createMockMissionStore(overrides = {}) {
return {
getFeatureByTaskId: vi.fn(),
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
getSlice: vi.fn(),
getMilestone: vi.fn(),
computeSliceStatus: vi.fn(),
getMission: vi.fn(),
findNextPendingSlice: vi.fn(),
activateSlice: vi.fn(),
...overrides,
};
}
it("activateNextPendingSlice returns null when no missionStore", async () => {
const store = createMockStore();
const scheduler = new Scheduler(store);
const result = await scheduler.activateNextPendingSlice("M-001");
expect(result).toBeNull();
});
it("triggers feature in-progress update when task with sliceId moves to in-progress", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event by calling the registered handler
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
expect(movedHandler).toBeDefined();
// Simulate task moving to in-progress with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "in-progress" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
});
it("does not update feature status when already past triaged", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "in-progress" }),
updateFeatureStatus: vi.fn(),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "in-progress" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
});
it("triggers feature done update when task with sliceId moves to done", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
computeSliceStatus: vi.fn().mockReturnValue("active"),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event by calling the registered handler
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
expect(movedHandler).toBeDefined();
// Simulate task moving to done with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
});
it("auto-advances when slice completes and autoAdvance is enabled", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
computeSliceStatus: vi.fn().mockReturnValue("complete"),
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: true }),
findNextPendingSlice: vi.fn().mockReturnValue({ id: "SL-002" }),
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.computeSliceStatus).toHaveBeenCalledWith("SL-001");
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
});
it("does not auto-advance when autoAdvance is disabled", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
computeSliceStatus: vi.fn().mockReturnValue("complete"),
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: false }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.findNextPendingSlice).not.toHaveBeenCalled();
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
});
it("handles task with sliceId but no linked feature gracefully", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue(undefined),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
});
it("activateNextPendingSlice finds and activates correct slice", async () => {
const nextSlice = { id: "SL-002", status: "pending" };
const mockMissionStore = createMockMissionStore({
findNextPendingSlice: vi.fn().mockReturnValue(nextSlice),
activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.activateNextPendingSlice("M-001");
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
expect(result).toEqual({ id: "SL-002", status: "active" });
});
it("activateNextPendingSlice returns null when no pending slices", async () => {
const mockMissionStore = createMockMissionStore({
findNextPendingSlice: vi.fn().mockReturnValue(undefined),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.activateNextPendingSlice("M-001");
expect(result).toBeNull();
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { resolveDependencyOrder, type TaskStore, type Task } from "@fusion/core";
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type FeatureStatus } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -60,6 +60,8 @@ export interface SchedulerOptions {
onBlocked?: (task: Task, blockedBy: string[]) => void;
/** Optional PR monitor for tracking in-review PRs */
prMonitor?: PrMonitor;
/** Optional MissionStore for slice activation and auto-advance */
missionStore?: MissionStore;
}
/**
@@ -122,23 +124,36 @@ export class Scheduler {
/**
* PR Monitoring: Start monitoring when a task moves to "in-review",
* stop monitoring when it moves out.
*
* Also handles mission auto-advance: when a linked task completes,
* update feature status and potentially activate next pending slice.
*/
this.store.on("task:moved", ({ task, to }) => {
if (!this.options.prMonitor) return;
// PR Monitoring
if (this.options.prMonitor) {
if (to === "in-review" && task.prInfo) {
// Start monitoring existing PR
const repo = getCurrentGitHubRepo(this.store.getRootDir());
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
} else if (task.column === "in-review" && to !== "in-review") {
// Task moved out of in-review, stop monitoring
this.options.prMonitor.stopMonitoring(task.id);
if (to === "in-review" && task.prInfo) {
// Start monitoring existing PR
const repo = getCurrentGitHubRepo(this.store.getRootDir());
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
// If task has a closed/merged PR, check for unaddressed feedback
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
}
}
} else if (task.column === "in-review" && to !== "in-review") {
// Task moved out of in-review, stop monitoring
this.options.prMonitor.stopMonitoring(task.id);
}
// If task has a closed/merged PR, check for unaddressed feedback
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
// Mission progress tracking: when task with sliceId moves to "in-progress" or "done"
if (task.sliceId && this.options.missionStore) {
if (to === "in-progress") {
void this.handleMissionTaskStart(task.id, task.sliceId);
} else if (to === "done") {
void this.handleMissionTaskCompletion(task.id, task.sliceId);
}
}
});
@@ -479,4 +494,118 @@ export class Scheduler {
this.scheduling = false;
}
}
/**
* Handle mission task start.
* When a task with a sliceId moves to "in-progress", update the linked
* feature status to "in-progress" to reflect active work.
*/
private async handleMissionTaskStart(taskId: string, sliceId: string): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;
try {
// Find the feature linked to this task
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) {
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
return;
}
// Only update if feature is still in "triaged" status
if (feature.status === "triaged") {
await missionStore.updateFeatureStatus(feature.id, "in-progress");
schedulerLog.log(`Feature ${feature.id} marked in-progress (task ${taskId} started)`);
}
} catch (err) {
schedulerLog.error(`Error handling mission task start for ${taskId}:`, err);
}
}
/**
* Handle mission task completion.
* When a task with a sliceId moves to "done", update the linked feature
* status and check if the slice is complete. If autoAdvance is enabled
* on the mission, activate the next pending slice.
*/
private async handleMissionTaskCompletion(taskId: string, sliceId: string): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;
try {
// Find the feature linked to this task
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) {
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
return;
}
// Update feature status to done
await missionStore.updateFeatureStatus(feature.id, "done");
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
// Get the slice to check its status
const slice = missionStore.getSlice(sliceId);
if (!slice) {
schedulerLog.warn(`Slice ${sliceId} not found for task ${taskId}`);
return;
}
// Get the milestone to find the mission
const milestone = missionStore.getMilestone(slice.milestoneId);
if (!milestone) {
schedulerLog.warn(`Milestone ${slice.milestoneId} not found for slice ${sliceId}`);
return;
}
// Recompute and check if slice is now complete
const newSliceStatus = missionStore.computeSliceStatus(sliceId);
if (newSliceStatus === "complete") {
schedulerLog.log(`Slice ${sliceId} completed (all features done)`);
// Check if mission has autoAdvance enabled
const mission = missionStore.getMission(milestone.missionId);
if (mission?.autoAdvance) {
// Activate next pending slice
const nextSlice = await this.activateNextPendingSlice(mission.id);
if (nextSlice) {
schedulerLog.log(`Auto-advanced: activated slice ${nextSlice.id} for mission ${mission.id}`);
}
}
}
} catch (err) {
schedulerLog.error(`Error handling mission task completion for ${taskId}:`, err);
}
}
/**
* Activate the next pending slice in a mission.
* Finds the first milestone with pending slices and activates
* the first pending slice in that milestone.
*
* @param missionId - Mission ID
* @returns The activated slice, or null if no pending slices
*/
async activateNextPendingSlice(missionId: string): Promise<import("@fusion/core").Slice | null> {
if (!this.options.missionStore) return null;
const missionStore = this.options.missionStore;
try {
const nextSlice = missionStore.findNextPendingSlice(missionId);
if (!nextSlice) {
schedulerLog.log(`Mission ${missionId}: no pending slices to activate`);
return null;
}
const activated = missionStore.activateSlice(nextSlice.id);
schedulerLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
return activated;
} catch (err) {
schedulerLog.error(`Error activating next slice for mission ${missionId}:`, err);
return null;
}
}
}