feat(FN-1369): add mission planning context propagation to tasks

- Add buildEnrichedDescription() for hierarchical mission context during triage
- Propagate mission → milestone → slice → feature context to task descriptions
- Add planningNotes and verification fields to milestones and slices
- Track planState on slices (not_started, planned, needs_update)
- Add apply/skip interview endpoints for milestone and slice planning
- Fix error mapping for rate limit and apply interview routes
- Add comprehensive integration tests for context enrichment
- Add AGENTS.md documentation for Mission Planning Context feature
This commit is contained in:
gsxdsm
2026-04-10 10:44:03 -07:00
parent 153bf9aab4
commit 14efe1c6a4
5 changed files with 1077 additions and 11 deletions

View File

@@ -0,0 +1,543 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "./store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-planning-"));
}
/**
* MissionStore planning context integration tests verify the enriched triage flow
* that adds mission hierarchy context to task descriptions. These scenarios cover:
* - Full hierarchy context enrichment in task descriptions
* - Omission of empty hierarchy sections
* - Custom description override bypassing enrichment
* - Bulk triage with enrichment
* - Enrichment after interview updates
* - Plan state transitions
*/
describe("MissionStore planning context integration", () => {
let rootDir: string;
let taskStore: TaskStore;
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir);
await taskStore.init();
});
afterEach(async () => {
vi.useRealTimers();
await rm(rootDir, { recursive: true, force: true });
});
describe("buildEnrichedDescription", () => {
it("enriches task description with full hierarchy context", async () => {
const missionStore = taskStore.getMissionStore();
// Create full hierarchy with rich context
const mission = missionStore.createMission({
title: "Launch Authentication",
description: "Build a complete auth system",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Core Auth",
description: "Implement core authentication",
verification: "Users can log in and log out",
planningNotes: "Decided on JWT strategy",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Login Page",
description: "Build the login UI",
verification: "Login form accepts valid credentials",
planningNotes: "Use existing design system",
});
const feature = missionStore.addFeature(slice.id, {
title: "Login Form",
description: "Standard login form with email/password",
acceptanceCriteria: "Form validates input and shows errors",
});
// Build enriched description
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeDefined();
// Mission context
expect(enriched).toContain("Launch Authentication");
expect(enriched).toContain("Build a complete auth system");
// Milestone context
expect(enriched).toContain("Core Auth");
expect(enriched).toContain("Implement core authentication");
expect(enriched).toContain("Users can log in and log out");
expect(enriched).toContain("Decided on JWT strategy");
// Slice context
expect(enriched).toContain("Login Page");
expect(enriched).toContain("Build the login UI");
expect(enriched).toContain("Login form accepts valid credentials");
expect(enriched).toContain("Use existing design system");
// Feature context
expect(enriched).toContain("Login Form");
expect(enriched).toContain("Standard login form with email/password");
expect(enriched).toContain("Form validates input and shows errors");
});
it("omits empty hierarchy sections from enriched description", async () => {
const missionStore = taskStore.getMissionStore();
// Create minimal hierarchy
const mission = missionStore.createMission({
title: "Minimal Mission",
description: "Just a title and description",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Minimal Milestone",
// No description, verification, or planningNotes
});
const slice = missionStore.addSlice(milestone.id, {
title: "Minimal Slice",
// No description, verification, or planningNotes
});
const feature = missionStore.addFeature(slice.id, {
title: "Minimal Feature",
description: "Feature with description only",
// No acceptance criteria
});
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeDefined();
// Mission title should be present
expect(enriched).toContain("Minimal Mission");
expect(enriched).toContain("Just a title and description");
// Milestone title should be present but description/verification/notes sections should not be empty
expect(enriched).toContain("Minimal Milestone");
// Should not have empty sections like "Description: undefined"
expect(enriched).not.toMatch(/Description:\s*undefined/);
expect(enriched).not.toMatch(/Verification:\s*undefined/);
expect(enriched).not.toMatch(/Planning Notes:\s*undefined/);
// Feature context
expect(enriched).toContain("Minimal Feature");
expect(enriched).toContain("Feature with description only");
});
it("returns undefined for non-existent feature", async () => {
const missionStore = taskStore.getMissionStore();
const enriched = missionStore.buildEnrichedDescription("non-existent-id");
expect(enriched).toBeUndefined();
});
it("returns undefined when slice is not found", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
// Manually delete the slice to simulate orphan feature
missionStore.deleteSlice(slice.id);
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toBeUndefined();
});
});
describe("triageFeature with enrichment", () => {
it("triageFeature enriches task description with full hierarchy context", async () => {
const missionStore = taskStore.getMissionStore();
// Create full hierarchy
const mission = missionStore.createMission({
title: "Authentication System",
description: "Implement complete auth",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "User Management",
description: "Handle user accounts",
verification: "Users can manage accounts",
planningNotes: "Use PostgreSQL for user data",
});
const slice = missionStore.addSlice(milestone.id, {
title: "User Registration",
description: "Build registration flow",
verification: "Users can register",
planningNotes: "Add email verification",
});
const feature = missionStore.addFeature(slice.id, {
title: "Registration Form",
description: "Create registration form",
acceptanceCriteria: "Form submits successfully",
});
// Triage the feature (no custom description override)
await missionStore.triageFeature(feature.id);
// Get the linked task
const updatedFeature = missionStore.getFeature(feature.id);
expect(updatedFeature?.taskId).toBeDefined();
const task = await taskStore.getTask(updatedFeature!.taskId!);
expect(task.description).toContain("Authentication System");
expect(task.description).toContain("Implement complete auth");
expect(task.description).toContain("User Management");
expect(task.description).toContain("Handle user accounts");
expect(task.description).toContain("Users can manage accounts");
expect(task.description).toContain("Use PostgreSQL for user data");
expect(task.description).toContain("User Registration");
expect(task.description).toContain("Build registration flow");
expect(task.description).toContain("Users can register");
expect(task.description).toContain("Add email verification");
expect(task.description).toContain("Registration Form");
expect(task.description).toContain("Create registration form");
expect(task.description).toContain("Form submits successfully");
});
it("triageFeature with custom description override skips enrichment", async () => {
const missionStore = taskStore.getMissionStore();
// Create full hierarchy
const mission = missionStore.createMission({
title: "Full Mission",
description: "Full mission description",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Full Milestone",
description: "Full milestone description",
verification: "Full verification",
planningNotes: "Full notes",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Full Slice",
description: "Full slice description",
verification: "Full slice verification",
planningNotes: "Full slice notes",
});
const feature = missionStore.addFeature(slice.id, {
title: "Custom Feature",
description: "Custom feature description",
});
// Triage with custom description override
await missionStore.triageFeature(
feature.id,
undefined, // title uses default
"Custom description override", // description override
);
const updatedFeature = missionStore.getFeature(feature.id);
const task = await taskStore.getTask(updatedFeature!.taskId!);
// Custom description should be used exactly
expect(task.description).toBe("Custom description override");
// Mission context should NOT be present
expect(task.description).not.toContain("Full Mission");
expect(task.description).not.toContain("Full mission description");
expect(task.description).not.toContain("Full Milestone");
});
it("triageSlice enriches all feature tasks with hierarchy context", async () => {
const missionStore = taskStore.getMissionStore();
// Create hierarchy with multiple features
const mission = missionStore.createMission({
title: "Multi Feature Mission",
description: "Testing multiple features",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Multi Feature Milestone",
description: "Multiple features milestone",
verification: "All features complete",
planningNotes: "Coordinate development",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Multi Feature Slice",
description: "Multiple features slice",
verification: "Slice verification",
planningNotes: "Slice planning",
});
// Add 3 features
const feature1 = missionStore.addFeature(slice.id, {
title: "Feature One",
description: "First feature description",
acceptanceCriteria: "First criterion",
});
const feature2 = missionStore.addFeature(slice.id, {
title: "Feature Two",
description: "Second feature description",
acceptanceCriteria: "Second criterion",
});
const feature3 = missionStore.addFeature(slice.id, {
title: "Feature Three",
description: "Third feature description",
acceptanceCriteria: "Third criterion",
});
// Triage all features in the slice
await missionStore.triageSlice(slice.id);
// Check all 3 tasks have enriched descriptions
for (const feature of [feature1, feature2, feature3]) {
const updatedFeature = missionStore.getFeature(feature.id);
const task = await taskStore.getTask(updatedFeature!.taskId!);
// All tasks should have hierarchy context
expect(task.description).toContain("Multi Feature Mission");
expect(task.description).toContain("Multi Feature Milestone");
expect(task.description).toContain("Multi Feature Slice");
// Each task should have its own feature-specific content
expect(task.description).toContain(feature.title);
expect(task.description).toContain(feature.description!);
expect(task.description).toContain(feature.acceptanceCriteria!);
}
});
it("enriched description reflects updates after interview", async () => {
const missionStore = taskStore.getMissionStore();
// Create initial hierarchy
const mission = missionStore.createMission({
title: "Evolving Mission",
description: "Initial mission",
});
const milestone = missionStore.addMilestone(mission.id, {
title: "Evolving Milestone",
description: "Initial milestone",
planningNotes: "Initial notes",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Evolving Slice",
description: "Initial slice",
planningNotes: "Initial slice notes",
});
const feature1 = missionStore.addFeature(slice.id, {
title: "Feature Alpha",
description: "First feature",
});
const feature2 = missionStore.addFeature(slice.id, {
title: "Feature Beta",
description: "Second feature",
});
// Triage first feature
await missionStore.triageFeature(feature1.id);
const task1 = await taskStore.getTask(missionStore.getFeature(feature1.id)!.taskId!);
// Verify initial enrichment
expect(task1.description).toContain("Initial notes");
expect(task1.description).toContain("Initial slice notes");
// Update milestone and slice after "interview"
missionStore.updateMilestone(milestone.id, {
planningNotes: "Revised milestone planning: Use JWT tokens, add refresh token support",
});
missionStore.updateSlice(slice.id, {
planningNotes: "Revised slice planning: Use React Hook Form, add validation",
});
// Triage second feature
await missionStore.triageFeature(feature2.id);
const task2 = await taskStore.getTask(missionStore.getFeature(feature2.id)!.taskId!);
// Second task should have updated planning notes
expect(task2.description).toContain("Revised milestone planning");
expect(task2.description).toContain("Revised slice planning");
// First task should still have original notes (historical)
expect(task1.description).toContain("Initial notes");
});
});
describe("planState transitions", () => {
it("defaults planState to not_started for new slices", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Plan State Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
expect(slice.planState).toBe("not_started");
});
it("transitions planState to planned after interview", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Plan State Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
// Simulate interview completion by updating planState
const updated = missionStore.updateSlice(slice.id, {
planState: "planned",
planningNotes: "Interview completed with decisions documented",
verification: "All acceptance criteria met",
});
expect(updated.planState).toBe("planned");
expect(updated.planningNotes).toBe("Interview completed with decisions documented");
expect(updated.verification).toBe("All acceptance criteria met");
});
it("transitions planState to needs_update when revisions needed", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Plan State Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, {
title: "Test Slice",
});
// Slice should default to not_started
expect(slice.planState).toBe("not_started");
// Simulate interview completion by updating planState
let updated = missionStore.updateSlice(slice.id, {
planState: "planned",
planningNotes: "Interview completed with decisions documented",
verification: "All acceptance criteria met",
});
expect(updated.planState).toBe("planned");
// Simulate requesting updates
updated = missionStore.updateSlice(slice.id, {
planState: "needs_update",
});
expect(updated.planState).toBe("needs_update");
});
it("planState changes do not affect milestone or mission status", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Status Test" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, {
title: "Test Slice",
});
// New missions are "planning" status
expect(mission.status).toBe("planning");
// New milestones are "planning" status
expect(milestone.status).toBe("planning");
expect(slice.status).toBe("pending");
expect(slice.status).toBe("pending");
// Change planState multiple times
missionStore.updateSlice(slice.id, { planState: "planned" });
missionStore.updateSlice(slice.id, { planState: "needs_update" });
missionStore.updateSlice(slice.id, { planState: "planned" });
// Status should remain unchanged
const refreshedMission = missionStore.getMission(mission.id);
const refreshedMilestone = missionStore.getMilestone(milestone.id);
const refreshedSlice = missionStore.getSlice(slice.id);
expect(refreshedMission?.status).toBe("planning");
expect(refreshedMilestone?.status).toBe("planning");
expect(refreshedSlice?.status).toBe("pending");
});
});
describe("milestone interview state integration", () => {
it("milestone interviewState transitions work correctly", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Interview Test" });
const milestone = missionStore.addMilestone(mission.id, {
title: "Test Milestone",
});
// interviewState defaults to not_started
expect(milestone.interviewState).toBe("not_started");
// Transition to in_progress
let updated = missionStore.updateMilestone(milestone.id, {
interviewState: "in_progress",
});
expect(updated.interviewState).toBe("in_progress");
// Complete the interview
updated = missionStore.updateMilestone(milestone.id, {
interviewState: "completed",
planningNotes: "Interview completed successfully",
verification: "All requirements captured",
});
expect(updated.interviewState).toBe("completed");
expect(updated.planningNotes).toBe("Interview completed successfully");
expect(updated.verification).toBe("All requirements captured");
// Request update
updated = missionStore.updateMilestone(milestone.id, {
interviewState: "needs_update",
});
expect(updated.interviewState).toBe("needs_update");
});
it("enriched description includes milestone interview state", async () => {
const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({
title: "Interview Context Test",
description: "Mission with interview context",
});
// First create milestone, then update with interview results
const milestone = missionStore.addMilestone(mission.id, {
title: "Interviewed Milestone",
description: "Milestone after interview",
});
// Simulate interview completion
missionStore.updateMilestone(milestone.id, {
interviewState: "completed",
verification: "Verified criteria",
planningNotes: "Key decisions from interview",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Test Slice",
});
const feature = missionStore.addFeature(slice.id, {
title: "Test Feature",
description: "Feature description",
});
const enriched = missionStore.buildEnrichedDescription(feature.id);
expect(enriched).toContain("Interviewed Milestone");
expect(enriched).toContain("Key decisions from interview");
expect(enriched).toContain("Verified criteria");
});
});
});

View File

@@ -882,13 +882,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
orderIndex,
interviewState: "not_started",
dependencies: input.dependencies || [],
planningNotes: input.planningNotes,
verification: input.verification,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
milestone.id,
milestone.missionId,
@@ -898,6 +900,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
milestone.orderIndex,
milestone.interviewState,
toJson(milestone.dependencies),
milestone.planningNotes ?? null,
milestone.verification ?? null,
milestone.createdAt,
milestone.updatedAt,
);
@@ -1089,13 +1093,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: "pending",
planState: "not_started",
orderIndex,
planningNotes: input.planningNotes,
verification: input.verification,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO slices (id, milestoneId, title, description, status, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO slices (id, milestoneId, title, description, status, orderIndex, planState, planningNotes, verification, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
slice.id,
slice.milestoneId,
@@ -1103,6 +1109,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
slice.description ?? null,
slice.status,
slice.orderIndex,
slice.planState,
slice.planningNotes ?? null,
slice.verification ?? null,
slice.createdAt,
slice.updatedAt,
);
@@ -1610,6 +1619,90 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
// ── Triage Operations ────────────────────────────────────────────────
/**
* Build an enriched task description that includes the full mission hierarchy context.
*
* When a feature is triaged to a task, this method constructs a structured markdown
* description that includes context from all levels of the hierarchy:
* - Mission: title and description
* - Milestone: title, description, verification criteria, planning notes
* - Slice: title, description, verification criteria, planning notes
* - Feature: description and acceptance criteria
*
* Only non-empty fields are included in the output. This provides AI agents
* with full context for making informed decisions during task implementation.
*
* @param featureId - Feature ID to build enriched description for
* @returns The enriched description string, or undefined if feature not found
*/
buildEnrichedDescription(featureId: string): string | undefined {
const feature = this.getFeature(featureId);
if (!feature) {
return undefined;
}
const slice = this.getSlice(feature.sliceId);
if (!slice) {
return undefined;
}
const milestone = this.getMilestone(slice.milestoneId);
if (!milestone) {
return undefined;
}
const mission = this.getMission(milestone.missionId);
if (!mission) {
return undefined;
}
const sections: string[] = [];
// Mission context (always included)
sections.push(`## Mission: ${mission.title}`);
if (mission.description) {
sections.push(mission.description);
}
// Milestone context
const milestoneSections: string[] = [`## Milestone: ${milestone.title}`];
if (milestone.description) {
milestoneSections.push(`**Description:** ${milestone.description}`);
}
if (milestone.verification) {
milestoneSections.push(`**Verification:** ${milestone.verification}`);
}
if (milestone.planningNotes) {
milestoneSections.push(`**Planning Notes:** ${milestone.planningNotes}`);
}
sections.push(milestoneSections.join("\n"));
// Slice context
const sliceSections: string[] = [`## Slice: ${slice.title}`];
if (slice.description) {
sliceSections.push(`**Description:** ${slice.description}`);
}
if (slice.verification) {
sliceSections.push(`**Verification:** ${slice.verification}`);
}
if (slice.planningNotes) {
sliceSections.push(`**Planning Notes:** ${slice.planningNotes}`);
}
sections.push(sliceSections.join("\n"));
// Feature context
const featureSections: string[] = [`## Feature: ${feature.title}`];
if (feature.description) {
featureSections.push(feature.description);
}
if (feature.acceptanceCriteria) {
featureSections.push(`**Acceptance Criteria:**\n${feature.acceptanceCriteria}`);
}
sections.push(featureSections.join("\n"));
return sections.join("\n\n");
}
/**
* Triage a feature by creating a new task and linking it.
*
@@ -1617,11 +1710,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* the feature to the newly created task using `linkFeatureToTask()`.
* The feature status transitions from "defined" to "triaged".
*
* When no custom description is provided, the task description is enriched
* with the full mission hierarchy context (mission → milestone → slice → feature).
*
* Requires MissionStore to have been constructed with a TaskStore reference.
*
* @param featureId - Feature ID to triage
* @param taskTitle - Optional title override (defaults to feature title)
* @param taskDescription - Optional description override (defaults to feature description + acceptance criteria)
* @param taskDescription - Optional description override (skips enrichment if provided)
* @returns The updated feature with taskId set
* @throws Error if feature not found, already triaged, or TaskStore not available
*/
@@ -1643,11 +1739,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
throw new Error(`Feature ${featureId} is already ${feature.status} (status must be "defined" to triage)`);
}
// Build description from feature + acceptance criteria
const description = taskDescription || [
feature.description,
feature.acceptanceCriteria ? `\n**Acceptance Criteria:**\n${feature.acceptanceCriteria}` : "",
].filter(Boolean).join("\n\n") || feature.title;
// Build description: use custom description if provided, otherwise use enriched description
let description: string;
if (taskDescription) {
// Custom description provided - skip enrichment
description = taskDescription;
} else {
// Use enriched description with full hierarchy context
const enriched = this.buildEnrichedDescription(featureId);
description = enriched || feature.title;
}
// Create the task
const task = await this.taskStore.createTask({