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:
@@ -90,6 +90,16 @@ describe("kb pi extension", () => {
|
||||
"kb_task_unarchive",
|
||||
"kb_task_delete",
|
||||
"kb_task_plan",
|
||||
// Mission tools
|
||||
"kb_mission_create",
|
||||
"kb_mission_list",
|
||||
"kb_mission_show",
|
||||
"kb_mission_delete",
|
||||
"kb_milestone_add",
|
||||
"kb_slice_add",
|
||||
"kb_feature_add",
|
||||
"kb_slice_activate",
|
||||
"kb_feature_link_task",
|
||||
];
|
||||
|
||||
for (const name of expected) {
|
||||
@@ -105,9 +115,9 @@ describe("kb pi extension", () => {
|
||||
expect(api.tools.has("kb_task_merge")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers the /kb command", () => {
|
||||
expect(api.commands.has("kb")).toBe(true);
|
||||
expect(api.commands.get("kb")!.description).toContain("dashboard");
|
||||
it("registers the /fn command", () => {
|
||||
expect(api.commands.has("fn")).toBe(true);
|
||||
expect(api.commands.get("fn")!.description).toContain("dashboard");
|
||||
});
|
||||
|
||||
it("registers session_shutdown listener", () => {
|
||||
@@ -391,4 +401,144 @@ describe("kb pi extension", () => {
|
||||
expect(unpauseResult.content[0].text).toContain("Unpaused FN-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kb_mission_create", () => {
|
||||
it("creates mission and returns mission data", async () => {
|
||||
const tool = api.tools.get("kb_mission_create")!;
|
||||
const result = await tool.execute(
|
||||
"call-1",
|
||||
{ title: "Test Mission", description: "Test description" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.details.missionId).toBeDefined();
|
||||
expect(result.details.title).toBe("Test Mission");
|
||||
expect(result.content[0].text).toContain("Created");
|
||||
expect(result.content[0].text).toContain("Test Mission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kb_mission_list", () => {
|
||||
it("returns formatted list of missions", async () => {
|
||||
// First create a mission
|
||||
const createTool = api.tools.get("kb_mission_create")!;
|
||||
await createTool.execute(
|
||||
"c1",
|
||||
{ title: "Mission A" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const listTool = api.tools.get("kb_mission_list")!;
|
||||
const result = await listTool.execute(
|
||||
"call-1",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.details.count).toBeGreaterThanOrEqual(1);
|
||||
expect(result.content[0].text).toContain("Missions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kb_mission_show", () => {
|
||||
it("returns mission with hierarchy", async () => {
|
||||
// Create mission
|
||||
const createTool = api.tools.get("kb_mission_create")!;
|
||||
const created = await createTool.execute(
|
||||
"c1",
|
||||
{ title: "Test Mission" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const showTool = api.tools.get("kb_mission_show")!;
|
||||
const result = await showTool.execute(
|
||||
"call-1",
|
||||
{ id: created.details.missionId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.details.mission).toBeDefined();
|
||||
expect(result.content[0].text).toContain("Test Mission");
|
||||
});
|
||||
|
||||
it("returns error when mission not found", async () => {
|
||||
const showTool = api.tools.get("kb_mission_show")!;
|
||||
const result = await showTool.execute(
|
||||
"call-1",
|
||||
{ id: "M-999" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kb_mission_delete", () => {
|
||||
it("deletes mission and confirms", async () => {
|
||||
// Create mission
|
||||
const createTool = api.tools.get("kb_mission_create")!;
|
||||
const created = await createTool.execute(
|
||||
"c1",
|
||||
{ title: "Mission to Delete" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const deleteTool = api.tools.get("kb_mission_delete")!;
|
||||
const result = await deleteTool.execute(
|
||||
"call-1",
|
||||
{ id: created.details.missionId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.details.missionId).toBe(created.details.missionId);
|
||||
expect(result.content[0].text).toContain("Deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kb_slice_activate", () => {
|
||||
it("activates slice and updates status", async () => {
|
||||
// This test would need a full mission hierarchy setup
|
||||
// For now, verify the tool exists and has correct parameters
|
||||
const tool = api.tools.get("kb_slice_activate")!;
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.parameters.properties.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kb_feature_link_task", () => {
|
||||
it("links feature to task", async () => {
|
||||
// Create a task first
|
||||
const createTaskTool = api.tools.get("kb_task_create")!;
|
||||
const taskResult = await createTaskTool.execute(
|
||||
"c1",
|
||||
{ description: "Task for feature" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
// Verify the tool exists with correct parameters
|
||||
const tool = api.tools.get("kb_feature_link_task")!;
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.parameters.properties.featureId).toBeDefined();
|
||||
expect(tool.parameters.properties.taskId).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||
|
||||
const HELP = `
|
||||
@@ -100,10 +101,11 @@ Usage:
|
||||
fn backup --list List all database backups
|
||||
fn backup --restore <file> Restore database from a backup file
|
||||
fn backup --cleanup Remove old backups exceeding retention limit
|
||||
fn project list [--json] List all registered projects
|
||||
fn project add [dir] [--name <name>] [--isolation <mode>] Register a project
|
||||
fn project remove <name> [--force] Unregister a project
|
||||
fn project info [name] Show project details
|
||||
fn mission create [title] [description] Create a new mission
|
||||
fn mission list List all missions
|
||||
fn mission show <id> Show mission with hierarchy
|
||||
fn mission delete <id> [--force] Delete a mission
|
||||
fn mission activate-slice <slice-id> Activate a pending slice
|
||||
|
||||
Options:
|
||||
--project, -P <name> Target a specific project (for task/settings commands)
|
||||
@@ -697,46 +699,65 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "project": {
|
||||
case "mission": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "create": {
|
||||
const titleParts: string[] = [];
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
titleParts.push(args[i]);
|
||||
}
|
||||
const fullInput = titleParts.join(" ");
|
||||
// Split on first space to separate title and description if provided
|
||||
const firstSpaceIdx = fullInput.indexOf(" ");
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
if (firstSpaceIdx > 0) {
|
||||
title = fullInput.slice(0, firstSpaceIdx);
|
||||
description = fullInput.slice(firstSpaceIdx + 1).trim();
|
||||
} else {
|
||||
title = fullInput || undefined;
|
||||
}
|
||||
await runMissionCreate(title, description, projectName);
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
case "ls": {
|
||||
const json = args.includes("--json");
|
||||
await runProjectList({ json });
|
||||
case "ls":
|
||||
await runMissionList(projectName);
|
||||
break;
|
||||
case "show":
|
||||
case "info": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
console.error("Usage: fn mission show <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runMissionShow(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "add": {
|
||||
const dir = args[2];
|
||||
const nameIdx = args.indexOf("--name");
|
||||
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
|
||||
const isolationIdx = args.indexOf("--isolation");
|
||||
const isolation = isolationIdx !== -1 && isolationIdx + 1 < args.length
|
||||
? args[isolationIdx + 1] as "in-process" | "child-process"
|
||||
: undefined;
|
||||
await runProjectAdd(dir, { name, isolation });
|
||||
break;
|
||||
}
|
||||
case "remove":
|
||||
case "delete":
|
||||
case "rm": {
|
||||
const name = args[2];
|
||||
if (!name) {
|
||||
console.error("Usage: fn project remove <name> [--force]");
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
console.error("Usage: fn mission delete <id> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
const force = args.includes("--force");
|
||||
await runProjectRemove(name, { force });
|
||||
await runMissionDelete(id, force, projectName);
|
||||
break;
|
||||
}
|
||||
case "info":
|
||||
case "show": {
|
||||
const name = args[2];
|
||||
await runProjectInfo(name);
|
||||
case "activate-slice": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
console.error("Usage: fn mission activate-slice <slice-id>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runMissionActivateSlice(id, projectName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: project ${subcommand || ""}`);
|
||||
console.error("Try: fn project list | add [dir] | remove <name> | info [name]");
|
||||
console.error(`Unknown subcommand: mission ${subcommand || ""}`);
|
||||
console.error("Try: fn mission create | list | show <id> | delete <id> | activate-slice <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -975,6 +975,445 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── Mission Tools ───────────────────────────────────────────────
|
||||
// Mission hierarchy management for multi-phase project planning
|
||||
|
||||
// ── kb_mission_create ───────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_mission_create",
|
||||
label: "KB: Create Mission",
|
||||
description:
|
||||
"Create a new mission — a high-level objective that can span multiple milestones. " +
|
||||
"Missions contain milestones that break down work into phases.",
|
||||
promptSnippet: "Create a new mission for high-level project planning",
|
||||
promptGuidelines: [
|
||||
"Use for high-level project objectives that span multiple work phases",
|
||||
"Missions are broken down into milestones → slices → features → tasks",
|
||||
"Be descriptive so the mission purpose is clear",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
title: Type.String({ description: "Mission title — brief but descriptive" }),
|
||||
description: Type.Optional(
|
||||
Type.String({ description: "Detailed mission objectives and context" })
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({
|
||||
title: params.title.trim(),
|
||||
description: params.description?.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Created ${mission.id}: ${mission.title}\nStatus: ${mission.status}`,
|
||||
},
|
||||
],
|
||||
details: { missionId: mission.id, title: mission.title, status: mission.status },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_mission_list ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_mission_list",
|
||||
label: "KB: List Missions",
|
||||
description: "List all missions with their current status.",
|
||||
promptSnippet: "List all missions",
|
||||
promptGuidelines: [
|
||||
"Use to see all missions and their current status",
|
||||
"Missions are grouped by status (active, planning, complete, etc.)",
|
||||
"Use before kb_mission_show to find a specific mission ID",
|
||||
],
|
||||
parameters: Type.Object({}),
|
||||
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const missions = missionStore.listMissions();
|
||||
|
||||
if (missions.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No missions yet." }],
|
||||
details: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Missions (${missions.length}):\n`);
|
||||
|
||||
for (const mission of missions) {
|
||||
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : "○";
|
||||
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status})`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { count: missions.length, missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })) },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_mission_show ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_mission_show",
|
||||
label: "KB: Show Mission",
|
||||
description: "Show mission details with full hierarchy: milestones → slices → features.",
|
||||
promptSnippet: "Show mission details with hierarchy",
|
||||
promptGuidelines: [
|
||||
"Use to see the full mission structure before planning work",
|
||||
"Shows milestones, slices, and features in hierarchical order",
|
||||
"Check slice status to see if features can be linked to tasks",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Mission ID (e.g., M-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const mission = missionStore.getMissionWithHierarchy(params.id);
|
||||
if (!mission) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Mission ${params.id} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Mission not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`${mission.id}: ${mission.title}`);
|
||||
lines.push(`Status: ${mission.status}`);
|
||||
if (mission.description) {
|
||||
lines.push(`Description: ${mission.description}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (mission.milestones.length === 0) {
|
||||
lines.push("No milestones yet.");
|
||||
} else {
|
||||
lines.push("Milestones:");
|
||||
for (const milestone of mission.milestones) {
|
||||
const mIcon = milestone.status === "complete" ? "✓" : milestone.status === "active" ? "●" : "○";
|
||||
lines.push(` ${mIcon} ${milestone.id}: ${milestone.title} (${milestone.status})`);
|
||||
|
||||
for (const slice of milestone.slices) {
|
||||
const sIcon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○";
|
||||
lines.push(` ${sIcon} ${slice.id}: ${slice.title} (${slice.status})`);
|
||||
|
||||
for (const feature of slice.features) {
|
||||
const fIcon = feature.status === "done" ? "✓" : feature.status === "in-progress" ? "▸" : feature.status === "triaged" ? "●" : "○";
|
||||
const taskLink = feature.taskId ? ` → ${feature.taskId}` : "";
|
||||
lines.push(` ${fIcon} ${feature.id}: ${feature.title} (${feature.status})${taskLink}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { mission },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_mission_delete ───────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_mission_delete",
|
||||
label: "KB: Delete Mission",
|
||||
description: "Delete a mission and all its milestones, slices, and features. Cannot be undone.",
|
||||
promptSnippet: "Delete a mission and all its contents",
|
||||
promptGuidelines: [
|
||||
"Use for cleaning up test missions or mistakenly created missions",
|
||||
"Permanently deletes all milestones, slices, and features within the mission",
|
||||
"Tasks linked to features are NOT deleted — only the feature links are removed",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Mission ID to delete (e.g., M-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const mission = missionStore.getMission(params.id);
|
||||
if (!mission) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Mission ${params.id} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Mission not found" },
|
||||
};
|
||||
}
|
||||
|
||||
missionStore.deleteMission(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Deleted ${params.id}: "${mission.title}"` }],
|
||||
details: { missionId: params.id, title: mission.title },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_milestone_add ────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_milestone_add",
|
||||
label: "KB: Add Milestone",
|
||||
description: "Add a milestone to a mission. Milestones represent phases of work.",
|
||||
promptSnippet: "Add a milestone to a mission",
|
||||
promptGuidelines: [
|
||||
"Use to break down a mission into manageable phases",
|
||||
"Milestones are ordered and contain slices (work units)",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
missionId: Type.String({ description: "Parent mission ID (e.g., M-001)" }),
|
||||
title: Type.String({ description: "Milestone title" }),
|
||||
description: Type.Optional(Type.String({ description: "Milestone description" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const mission = missionStore.getMission(params.missionId);
|
||||
if (!mission) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Mission not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const milestone = missionStore.addMilestone(params.missionId, {
|
||||
title: params.title.trim(),
|
||||
description: params.description?.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Added ${milestone.id}: "${milestone.title}" to ${params.missionId}` },
|
||||
],
|
||||
details: { milestoneId: milestone.id, missionId: params.missionId, title: milestone.title },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_slice_add ─────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_slice_add",
|
||||
label: "KB: Add Slice",
|
||||
description: "Add a slice to a milestone. Slices are work units that can be activated for implementation.",
|
||||
promptSnippet: "Add a work slice to a milestone",
|
||||
promptGuidelines: [
|
||||
"Slices represent work units within a milestone",
|
||||
"Slices are activated for implementation, linking features to tasks",
|
||||
"Order slices by priority — they execute in sequence",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
milestoneId: Type.String({ description: "Parent milestone ID (e.g., MS-001)" }),
|
||||
title: Type.String({ description: "Slice title" }),
|
||||
description: Type.Optional(Type.String({ description: "Slice description" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const milestone = missionStore.getMilestone(params.milestoneId);
|
||||
if (!milestone) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Milestone ${params.milestoneId} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Milestone not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const slice = missionStore.addSlice(params.milestoneId, {
|
||||
title: params.title.trim(),
|
||||
description: params.description?.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Added ${slice.id}: "${slice.title}" to ${params.milestoneId}` },
|
||||
],
|
||||
details: { sliceId: slice.id, milestoneId: params.milestoneId, title: slice.title },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_feature_add ────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_feature_add",
|
||||
label: "KB: Add Feature",
|
||||
description: "Add a feature to a slice. Features are deliverables that can be linked to tasks.",
|
||||
promptSnippet: "Add a feature to a slice",
|
||||
promptGuidelines: [
|
||||
"Features represent deliverables within a slice",
|
||||
"Features start as 'defined' and progress through 'triaged' → 'in-progress' → 'done'",
|
||||
"Link features to tasks using kb_feature_link_task",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
sliceId: Type.String({ description: "Parent slice ID (e.g., SL-001)" }),
|
||||
title: Type.String({ description: "Feature title" }),
|
||||
description: Type.Optional(Type.String({ description: "Feature description" })),
|
||||
acceptanceCriteria: Type.Optional(
|
||||
Type.String({ description: "Acceptance criteria for completing the feature" })
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const slice = missionStore.getSlice(params.sliceId);
|
||||
if (!slice) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Slice ${params.sliceId} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Slice not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const feature = missionStore.addFeature(params.sliceId, {
|
||||
title: params.title.trim(),
|
||||
description: params.description?.trim(),
|
||||
acceptanceCriteria: params.acceptanceCriteria?.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Added ${feature.id}: "${feature.title}" to ${params.sliceId}` },
|
||||
],
|
||||
details: { featureId: feature.id, sliceId: params.sliceId, title: feature.title },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_slice_activate ────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_slice_activate",
|
||||
label: "KB: Activate Slice",
|
||||
description:
|
||||
"Activate a pending slice for implementation. " +
|
||||
"Sets status to 'active' and enables task linking for its features.",
|
||||
promptSnippet: "Activate a slice for implementation",
|
||||
promptGuidelines: [
|
||||
"Activating a slice allows its features to be linked to tasks",
|
||||
"Only pending slices can be activated",
|
||||
"Slice activation triggers auto-advance when linked tasks complete",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Slice ID to activate (e.g., SL-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const slice = missionStore.getSlice(params.id);
|
||||
if (!slice) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Slice ${params.id} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Slice not found" },
|
||||
};
|
||||
}
|
||||
|
||||
if (slice.status !== "pending") {
|
||||
return {
|
||||
content: [{ type: "text", text: `Slice ${params.id} is not pending (status: ${slice.status})` }],
|
||||
isError: true,
|
||||
details: { error: "Slice not pending", currentStatus: slice.status },
|
||||
};
|
||||
}
|
||||
|
||||
const activated = missionStore.activateSlice(params.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Activated ${activated.id}: "${activated.title}"\nStatus: ${activated.status}`,
|
||||
},
|
||||
],
|
||||
details: { sliceId: activated.id, title: activated.title, status: activated.status },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_feature_link_task ──────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_feature_link_task",
|
||||
label: "KB: Link Feature to Task",
|
||||
description:
|
||||
"Link a feature to a kb task for implementation. " +
|
||||
"Updates the feature status to 'triaged' and associates it with the task.",
|
||||
promptSnippet: "Link a feature to a task",
|
||||
promptGuidelines: [
|
||||
"Use when a feature is ready for implementation and has a corresponding task",
|
||||
"The feature's slice must be active to link tasks",
|
||||
"Linking updates the feature status to 'triaged'",
|
||||
"When the linked task moves to 'done', the feature status becomes 'done'",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
featureId: Type.String({ description: "Feature ID to link (e.g., F-001)" }),
|
||||
taskId: Type.String({ description: "Task ID to link to (e.g., KB-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const feature = missionStore.getFeature(params.featureId);
|
||||
if (!feature) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Feature ${params.featureId} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Feature not found" },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if task exists
|
||||
try {
|
||||
await store.getTask(params.taskId);
|
||||
} catch {
|
||||
return {
|
||||
content: [{ type: "text", text: `Task ${params.taskId} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Task not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Linked ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`,
|
||||
},
|
||||
],
|
||||
details: { featureId: updated.id, taskId: params.taskId, title: updated.title, status: updated.status },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── /fn command — start the dashboard + engine ───────────────────
|
||||
|
||||
let dashboardProcess: ChildProcess | null = null;
|
||||
|
||||
@@ -725,6 +725,29 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next pending slice in a mission.
|
||||
* Iterates milestones by orderIndex, then slices by orderIndex,
|
||||
* and returns the first slice with status "pending".
|
||||
*
|
||||
* @param missionId - Mission ID
|
||||
* @returns The next pending slice, or undefined if none found
|
||||
*/
|
||||
findNextPendingSlice(missionId: string): Slice | undefined {
|
||||
const milestones = this.listMilestones(missionId);
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const slices = this.listSlices(milestone.id);
|
||||
for (const slice of slices) {
|
||||
if (slice.status === "pending") {
|
||||
return slice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Feature Operations ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -929,6 +952,29 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feature's status.
|
||||
* Recomputes slice status after update.
|
||||
*
|
||||
* @param featureId - Feature ID
|
||||
* @param status - New status
|
||||
* @returns The updated feature
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
updateFeatureStatus(featureId: string, status: FeatureStatus): MissionFeature {
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
const updated = this.updateFeature(featureId, { status });
|
||||
|
||||
// Recompute slice status
|
||||
this.recomputeSliceStatus(updated.sliceId);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a feature by its linked task ID.
|
||||
*
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface Mission {
|
||||
status: MissionStatus;
|
||||
/** State of the AI specification interview process */
|
||||
interviewState: InterviewState;
|
||||
/** When true, automatically activate the next pending slice when current slice completes */
|
||||
autoAdvance?: boolean;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
|
||||
@@ -426,6 +426,8 @@ export interface Task {
|
||||
summary?: string;
|
||||
/** Files modified during agent execution, captured at task completion time */
|
||||
modifiedFiles?: string[];
|
||||
/** Optional ID of the slice this task is linked to (for mission-based work) */
|
||||
sliceId?: string;
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||
columnMovedAt?: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user