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:
153
AGENTS.md
153
AGENTS.md
@@ -2098,6 +2098,159 @@ The `autopilotEnabled` flag is the sole control for autopilot behavior. When ena
|
|||||||
- `POST /api/missions/:missionId/autopilot/start` — Manually start watching (programmatic access)
|
- `POST /api/missions/:missionId/autopilot/start` — Manually start watching (programmatic access)
|
||||||
- `POST /api/missions/:missionId/autopilot/stop` — Manually stop watching (programmatic access)
|
- `POST /api/missions/:missionId/autopilot/stop` — Manually stop watching (programmatic access)
|
||||||
|
|
||||||
|
## Mission Planning Context
|
||||||
|
|
||||||
|
The mission planning context system enables AI-guided planning at multiple levels of the mission hierarchy. When features are triaged to tasks, they receive enriched descriptions containing full mission context, helping AI agents make informed decisions during implementation.
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
The planning system operates at three levels:
|
||||||
|
|
||||||
|
1. **Mission-level interview** — Produces the overall mission specification (via existing `MissionInterviewModal`)
|
||||||
|
2. **Per-milestone interviews** — Refine scope and produce `planningNotes` and `verification` criteria
|
||||||
|
3. **Per-slice interviews** — Further refine scope and produce `planningNotes`, `verification`, and set `planState`
|
||||||
|
|
||||||
|
When features are triaged to tasks via `triageFeature()`, the system automatically enriches task descriptions with the full hierarchy context (mission → milestone → slice → feature), giving implementation agents comprehensive context.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
**New fields on `Milestone`:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `planningNotes` | `string?` | Optional text field for storing interview/planning output |
|
||||||
|
| `verification` | `string?` | Optional text field for storing verification criteria |
|
||||||
|
|
||||||
|
**New fields on `Slice`:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `planningNotes` | `string?` | Optional text field for storing interview/planning output |
|
||||||
|
| `verification` | `string?` | Optional text field for storing verification criteria |
|
||||||
|
| `planState` | `SlicePlanState` | Tracks whether per-slice planning has been done: `"not_started"` \| `"planned"` \| `"needs_update"` |
|
||||||
|
|
||||||
|
**Existing fields enhanced for planning:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `Milestone.interviewState` | `InterviewState` | `"not_started"` \| `"in_progress"` \| `"completed"` \| `"needs_update"` — tracks milestone interview state |
|
||||||
|
| `Slice.status` | `SliceStatus` | `"pending"` \| `"active"` \| `"complete"` — lifecycle status (separate from `planState`) |
|
||||||
|
|
||||||
|
### Interview Flow
|
||||||
|
|
||||||
|
The `MilestoneSliceInterviewModal` component provides the UI for milestone and slice interviews:
|
||||||
|
|
||||||
|
1. User clicks the **Plan** button on a milestone or slice in MissionManager
|
||||||
|
2. Dashboard opens the `MilestoneSliceInterviewModal`
|
||||||
|
3. User chooses from three options:
|
||||||
|
- **Start Interview** — Begins AI-guided Q&A to refine scope
|
||||||
|
- **Use Mission Context** — Skips interview, applies mission-level context directly
|
||||||
|
- **Cancel** — Dismisses without changes
|
||||||
|
|
||||||
|
For AI interviews:
|
||||||
|
4. Session created via `createTargetInterviewSession()`
|
||||||
|
5. AI asks clarifying questions via `submitTargetInterviewResponse()`
|
||||||
|
6. User reviews summary and clicks **Apply** to persist results
|
||||||
|
7. Results stored via `applyTargetInterview()`:
|
||||||
|
- Milestone: `planningNotes`, `verification`, `interviewState: "completed"`
|
||||||
|
- Slice: `planningNotes`, `verification`, `planState: "planned"`
|
||||||
|
|
||||||
|
For skip flow:
|
||||||
|
4. `skipTargetInterview()` called directly
|
||||||
|
5. Planning notes populated with mission context message
|
||||||
|
6. State set: `interviewState: "completed"` (milestone) or `planState: "planned"` (slice)
|
||||||
|
|
||||||
|
### Triage Enrichment
|
||||||
|
|
||||||
|
The `MissionStore.buildEnrichedDescription()` method assembles structured markdown task descriptions:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Mission: Authentication System
|
||||||
|
Build a complete auth system
|
||||||
|
|
||||||
|
## Milestone: Core Auth
|
||||||
|
**Description:** Implement core authentication
|
||||||
|
**Verification:** Users can log in and log out
|
||||||
|
**Planning Notes:** Decided on JWT strategy
|
||||||
|
|
||||||
|
## Slice: Login Page
|
||||||
|
**Description:** Build the login UI
|
||||||
|
**Verification:** Login form accepts valid credentials
|
||||||
|
**Planning Notes:** Use existing design system
|
||||||
|
|
||||||
|
## Feature: Login Form
|
||||||
|
Standard login form with email/password
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
Form validates input and shows errors
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key behaviors:**
|
||||||
|
- Only non-empty fields are included in the output
|
||||||
|
- Custom description overrides bypass enrichment entirely
|
||||||
|
- Enrichment reads current state at triage time (historical tasks keep original context)
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
**Milestone Interview:**
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/api/missions/milestones/:milestoneId/interview/start` | Start milestone interview session |
|
||||||
|
| `POST` | `/api/missions/milestones/:milestoneId/interview/respond` | Submit responses to interview questions |
|
||||||
|
| `GET` | `/api/missions/milestones/:milestoneId/interview/stream` | SSE stream for real-time interview updates |
|
||||||
|
| `POST` | `/api/missions/milestones/:milestoneId/interview/apply` | Apply interview results to milestone |
|
||||||
|
| `POST` | `/api/missions/milestones/:milestoneId/interview/skip` | Skip interview, use mission-level context |
|
||||||
|
|
||||||
|
**Slice Interview:**
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/api/missions/slices/:sliceId/interview/start` | Start slice interview session |
|
||||||
|
| `POST` | `/api/missions/slices/:sliceId/interview/respond` | Submit responses to interview questions |
|
||||||
|
| `GET` | `/api/missions/slices/:sliceId/interview/stream` | SSE stream for real-time interview updates |
|
||||||
|
| `POST` | `/api/missions/slices/:sliceId/interview/apply` | Apply interview results to slice |
|
||||||
|
| `POST` | `/api/missions/slices/:sliceId/interview/skip` | Skip interview, use mission-level context |
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
Migration version 21 adds the new planning fields to the schema:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- milestones table additions
|
||||||
|
ALTER TABLE milestones ADD COLUMN planningNotes TEXT;
|
||||||
|
ALTER TABLE milestones ADD COLUMN verification TEXT;
|
||||||
|
|
||||||
|
-- slices table additions
|
||||||
|
ALTER TABLE slices ADD COLUMN planningNotes TEXT;
|
||||||
|
ALTER TABLE slices ADD COLUMN verification TEXT;
|
||||||
|
ALTER TABLE slices ADD COLUMN planState TEXT NOT NULL DEFAULT 'not_started';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Implementation Files
|
||||||
|
|
||||||
|
- `packages/core/src/mission-types.ts` — Type definitions for `SlicePlanState`, `SLICE_PLAN_STATES`, updated `Milestone` and `Slice` interfaces
|
||||||
|
- `packages/core/src/mission-store.ts` — `buildEnrichedDescription()`, `triageFeature()` with enrichment, `updateMilestone()` and `updateSlice()` with new fields
|
||||||
|
- `packages/dashboard/src/milestone-slice-interview.ts` — Interview engine: `createTargetInterviewSession()`, `applyTargetInterview()`, `skipTargetInterview()`
|
||||||
|
- `packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx` — React component for the interview UI
|
||||||
|
- `packages/dashboard/app/components/MissionManager.tsx` — Plan buttons and planning state indicators
|
||||||
|
|
||||||
|
### Dashboard UI Elements
|
||||||
|
|
||||||
|
**Plan Buttons:**
|
||||||
|
- Appear next to non-complete milestones and slices
|
||||||
|
- Hidden for completed items
|
||||||
|
- Trigger `MilestoneSliceInterviewModal` on click
|
||||||
|
|
||||||
|
**Planning State Indicators:**
|
||||||
|
- Visual badges showing interview/plan state
|
||||||
|
- Color-coded: grey (not started), green (completed), amber (needs update)
|
||||||
|
|
||||||
|
**Triage Preview:**
|
||||||
|
- Shows enriched description before creating task
|
||||||
|
- Allows user to preview context that will be injected
|
||||||
|
- "Create Task" confirms triage, "Cancel" dismisses
|
||||||
|
|
||||||
## Workflow Steps
|
## Workflow Steps
|
||||||
|
|
||||||
Workflow steps are reusable quality gates that run at configurable lifecycle phases. Each step can be configured to run as **pre-merge** (after implementation, before merge — can block) or **post-merge** (after merge success — informational only). They enable post-implementation review, documentation checks, QA validation, deployment notifications, and other automated checks.
|
Workflow steps are reusable quality gates that run at configurable lifecycle phases. Each step can be configured to run as **pre-merge** (after implementation, before merge — can block) or **post-merge** (after merge success — informational only). They enable post-implementation review, documentation checks, QA validation, deployment notifications, and other automated checks.
|
||||||
|
|||||||
543
packages/core/src/mission-planning-context.integration.test.ts
Normal file
543
packages/core/src/mission-planning-context.integration.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -882,13 +882,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
orderIndex,
|
orderIndex,
|
||||||
interviewState: "not_started",
|
interviewState: "not_started",
|
||||||
dependencies: input.dependencies || [],
|
dependencies: input.dependencies || [],
|
||||||
|
planningNotes: input.planningNotes,
|
||||||
|
verification: input.verification,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, createdAt, updatedAt)
|
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
milestone.id,
|
milestone.id,
|
||||||
milestone.missionId,
|
milestone.missionId,
|
||||||
@@ -898,6 +900,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
milestone.orderIndex,
|
milestone.orderIndex,
|
||||||
milestone.interviewState,
|
milestone.interviewState,
|
||||||
toJson(milestone.dependencies),
|
toJson(milestone.dependencies),
|
||||||
|
milestone.planningNotes ?? null,
|
||||||
|
milestone.verification ?? null,
|
||||||
milestone.createdAt,
|
milestone.createdAt,
|
||||||
milestone.updatedAt,
|
milestone.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -1089,13 +1093,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
planState: "not_started",
|
planState: "not_started",
|
||||||
orderIndex,
|
orderIndex,
|
||||||
|
planningNotes: input.planningNotes,
|
||||||
|
verification: input.verification,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
INSERT INTO slices (id, milestoneId, title, description, status, orderIndex, createdAt, updatedAt)
|
INSERT INTO slices (id, milestoneId, title, description, status, orderIndex, planState, planningNotes, verification, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
slice.id,
|
slice.id,
|
||||||
slice.milestoneId,
|
slice.milestoneId,
|
||||||
@@ -1103,6 +1109,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
slice.description ?? null,
|
slice.description ?? null,
|
||||||
slice.status,
|
slice.status,
|
||||||
slice.orderIndex,
|
slice.orderIndex,
|
||||||
|
slice.planState,
|
||||||
|
slice.planningNotes ?? null,
|
||||||
|
slice.verification ?? null,
|
||||||
slice.createdAt,
|
slice.createdAt,
|
||||||
slice.updatedAt,
|
slice.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -1610,6 +1619,90 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
|
|
||||||
// ── Triage Operations ────────────────────────────────────────────────
|
// ── 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.
|
* 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 to the newly created task using `linkFeatureToTask()`.
|
||||||
* The feature status transitions from "defined" to "triaged".
|
* 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.
|
* Requires MissionStore to have been constructed with a TaskStore reference.
|
||||||
*
|
*
|
||||||
* @param featureId - Feature ID to triage
|
* @param featureId - Feature ID to triage
|
||||||
* @param taskTitle - Optional title override (defaults to feature title)
|
* @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
|
* @returns The updated feature with taskId set
|
||||||
* @throws Error if feature not found, already triaged, or TaskStore not available
|
* @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)`);
|
throw new Error(`Feature ${featureId} is already ${feature.status} (status must be "defined" to triage)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build description from feature + acceptance criteria
|
// Build description: use custom description if provided, otherwise use enriched description
|
||||||
const description = taskDescription || [
|
let description: string;
|
||||||
feature.description,
|
if (taskDescription) {
|
||||||
feature.acceptanceCriteria ? `\n**Acceptance Criteria:**\n${feature.acceptanceCriteria}` : "",
|
// Custom description provided - skip enrichment
|
||||||
].filter(Boolean).join("\n\n") || feature.title;
|
description = taskDescription;
|
||||||
|
} else {
|
||||||
|
// Use enriched description with full hierarchy context
|
||||||
|
const enriched = this.buildEnrichedDescription(featureId);
|
||||||
|
description = enriched || feature.title;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the task
|
// Create the task
|
||||||
const task = await this.taskStore.createTask({
|
const task = await this.taskStore.createTask({
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getTargetInterviewSummary,
|
getTargetInterviewSummary,
|
||||||
getRateLimitResetTime,
|
getRateLimitResetTime,
|
||||||
InvalidSessionStateError,
|
InvalidSessionStateError,
|
||||||
|
TargetInvalidSessionStateError,
|
||||||
milestoneSliceInterviewStreamManager,
|
milestoneSliceInterviewStreamManager,
|
||||||
parseTargetInterviewResponse,
|
parseTargetInterviewResponse,
|
||||||
rehydrateFromStore,
|
rehydrateFromStore,
|
||||||
@@ -462,7 +463,7 @@ describe("milestone-slice-interview module", () => {
|
|||||||
|
|
||||||
describe("applyTargetInterview", () => {
|
describe("applyTargetInterview", () => {
|
||||||
// Note: Full end-to-end tests with AI completion are complex due to async agent mocking.
|
// Note: Full end-to-end tests with AI completion are complex due to async agent mocking.
|
||||||
// These tests verify error handling.
|
// These tests verify error handling and integration with MissionStore.
|
||||||
|
|
||||||
it("throws when session not found", () => {
|
it("throws when session not found", () => {
|
||||||
const mockMissionStore = {} as any;
|
const mockMissionStore = {} as any;
|
||||||
@@ -470,6 +471,151 @@ describe("milestone-slice-interview module", () => {
|
|||||||
TargetSessionNotFoundError
|
TargetSessionNotFoundError
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists milestone planning notes and verification to store", async () => {
|
||||||
|
// Create a session and set its summary
|
||||||
|
const sessionId = await createTargetInterviewSession(
|
||||||
|
"127.0.0.1",
|
||||||
|
"milestone",
|
||||||
|
"ms-apply",
|
||||||
|
"Apply Test",
|
||||||
|
"Mission context",
|
||||||
|
"/tmp/project"
|
||||||
|
);
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
// Set the session's summary directly to simulate completed interview
|
||||||
|
const session = getTargetInterviewSession(sessionId);
|
||||||
|
if (session) {
|
||||||
|
(session as any).summary = {
|
||||||
|
description: "Refined milestone description",
|
||||||
|
planningNotes: "Key decisions: JWT tokens, refresh token support",
|
||||||
|
verification: "All auth flows work correctly",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock MissionStore
|
||||||
|
const mockUpdateMilestone = vi.fn().mockReturnValue({ id: "ms-apply" });
|
||||||
|
const mockGetMilestone = vi.fn().mockReturnValue({ id: "ms-apply", title: "Apply Test" });
|
||||||
|
const mockMissionStore = {
|
||||||
|
getMilestone: mockGetMilestone,
|
||||||
|
updateMilestone: mockUpdateMilestone,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Apply the interview results
|
||||||
|
const result = applyTargetInterview(sessionId, mockMissionStore);
|
||||||
|
|
||||||
|
// Verify update was called with correct fields
|
||||||
|
expect(mockUpdateMilestone).toHaveBeenCalledWith("ms-apply", expect.objectContaining({
|
||||||
|
description: "Refined milestone description",
|
||||||
|
planningNotes: "Key decisions: JWT tokens, refresh token support",
|
||||||
|
verification: "All auth flows work correctly",
|
||||||
|
interviewState: "completed",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists slice planning notes and planState to store", async () => {
|
||||||
|
// Create a slice session
|
||||||
|
const sessionId = await createTargetInterviewSession(
|
||||||
|
"127.0.0.1",
|
||||||
|
"slice",
|
||||||
|
"sl-apply",
|
||||||
|
"Apply Slice Test",
|
||||||
|
"Mission | Milestone context",
|
||||||
|
"/tmp/project"
|
||||||
|
);
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
// Set the session's summary directly
|
||||||
|
const session = getTargetInterviewSession(sessionId);
|
||||||
|
if (session) {
|
||||||
|
(session as any).summary = {
|
||||||
|
description: "Refined slice description",
|
||||||
|
planningNotes: "Slice decisions: React Hook Form, Zod validation",
|
||||||
|
verification: "All form validations pass",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock MissionStore
|
||||||
|
const mockUpdateSlice = vi.fn().mockReturnValue({ id: "sl-apply" });
|
||||||
|
const mockGetSlice = vi.fn().mockReturnValue({ id: "sl-apply", title: "Apply Slice Test" });
|
||||||
|
const mockMissionStore = {
|
||||||
|
getSlice: mockGetSlice,
|
||||||
|
updateSlice: mockUpdateSlice,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Apply the interview results
|
||||||
|
const result = applyTargetInterview(sessionId, mockMissionStore);
|
||||||
|
|
||||||
|
// Verify update was called with correct fields
|
||||||
|
expect(mockUpdateSlice).toHaveBeenCalledWith("sl-apply", expect.objectContaining({
|
||||||
|
description: "Refined slice description",
|
||||||
|
planningNotes: "Slice decisions: React Hook Form, Zod validation",
|
||||||
|
verification: "All form validations pass",
|
||||||
|
planState: "planned",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up session after persisting", async () => {
|
||||||
|
// Create a milestone session
|
||||||
|
const sessionId = await createTargetInterviewSession(
|
||||||
|
"127.0.0.1",
|
||||||
|
"milestone",
|
||||||
|
"ms-cleanup",
|
||||||
|
"Cleanup Test",
|
||||||
|
"Context",
|
||||||
|
"/tmp/project"
|
||||||
|
);
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
// Set the session's summary
|
||||||
|
const session = getTargetInterviewSession(sessionId);
|
||||||
|
if (session) {
|
||||||
|
(session as any).summary = {
|
||||||
|
description: "Desc",
|
||||||
|
planningNotes: "Notes",
|
||||||
|
verification: "Verify",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session exists before apply
|
||||||
|
expect(getTargetInterviewSession(sessionId)).toBeDefined();
|
||||||
|
|
||||||
|
// Mock MissionStore
|
||||||
|
const mockUpdateMilestone = vi.fn().mockReturnValue({ id: "ms-cleanup" });
|
||||||
|
const mockGetMilestone = vi.fn().mockReturnValue({ id: "ms-cleanup" });
|
||||||
|
const mockMissionStore = {
|
||||||
|
getMilestone: mockGetMilestone,
|
||||||
|
updateMilestone: mockUpdateMilestone,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Apply the interview results
|
||||||
|
applyTargetInterview(sessionId, mockMissionStore);
|
||||||
|
|
||||||
|
// Verify session was cleaned up
|
||||||
|
expect(getTargetInterviewSession(sessionId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when session has no summary to apply", async () => {
|
||||||
|
// Create a session without setting summary
|
||||||
|
const sessionId = await createTargetInterviewSession(
|
||||||
|
"127.0.0.1",
|
||||||
|
"milestone",
|
||||||
|
"ms-no-summary",
|
||||||
|
"No Summary",
|
||||||
|
"Context",
|
||||||
|
"/tmp/project"
|
||||||
|
);
|
||||||
|
await waitForCurrentQuestion(sessionId);
|
||||||
|
|
||||||
|
const mockMissionStore = {
|
||||||
|
getMilestone: vi.fn().mockReturnValue({ id: "ms-no-summary" }),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(() => applyTargetInterview(sessionId, mockMissionStore)).toThrow(
|
||||||
|
TargetInvalidSessionStateError
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("skipTargetInterview", () => {
|
describe("skipTargetInterview", () => {
|
||||||
|
|||||||
@@ -3545,4 +3545,127 @@ describe("Mission API", () => {
|
|||||||
expect(skipSpy).toHaveBeenCalledWith("slice", slice.id, expect.anything());
|
expect(skipSpy).toHaveBeenCalledWith("slice", slice.id, expect.anything());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Interview Error Mapping Tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("interview error mapping", () => {
|
||||||
|
it("POST milestone interview/respond returns 404 for unknown session", async () => {
|
||||||
|
const { app } = buildApp({});
|
||||||
|
|
||||||
|
const importMock = await import("./milestone-slice-interview.js");
|
||||||
|
vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => {
|
||||||
|
const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js");
|
||||||
|
throw new TargetSessionNotFoundError("Session not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/missions/milestones/MS-TEST1/interview/respond",
|
||||||
|
JSON.stringify({ sessionId: "nonexistent-session", responses: {} }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST slice interview/respond returns 404 for unknown session", async () => {
|
||||||
|
const { app } = buildApp({});
|
||||||
|
|
||||||
|
const importMock = await import("./milestone-slice-interview.js");
|
||||||
|
vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => {
|
||||||
|
const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js");
|
||||||
|
throw new TargetSessionNotFoundError("Session not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/missions/slices/SL-TEST1/interview/respond",
|
||||||
|
JSON.stringify({ sessionId: "nonexistent-session", responses: {} }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST milestone interview/start returns 429 when rate limited", async () => {
|
||||||
|
const { app, missionStore } = buildApp({});
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
const mission = ms.createMission({ title: "Rate Limit Test" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" });
|
||||||
|
|
||||||
|
const importMock = await import("./milestone-slice-interview.js");
|
||||||
|
vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => {
|
||||||
|
const { RateLimitError } = await import("./milestone-slice-interview.js");
|
||||||
|
throw new RateLimitError("Rate limit exceeded", new Date(Date.now() + 3600000));
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/milestones/${milestone.id}/interview/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST slice interview/start returns 429 when rate limited", async () => {
|
||||||
|
const { app, missionStore } = buildApp({});
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
const mission = ms.createMission({ title: "Rate Limit Test" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Rate Limit Slice" });
|
||||||
|
|
||||||
|
const importMock = await import("./milestone-slice-interview.js");
|
||||||
|
vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => {
|
||||||
|
const { RateLimitError } = await import("./milestone-slice-interview.js");
|
||||||
|
throw new RateLimitError("Rate limit exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/slices/${slice.id}/interview/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST milestone interview/skip returns 404 for nonexistent milestone", async () => {
|
||||||
|
const { app } = buildApp({});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/missions/milestones/MS-NONEXISTENT/interview/skip",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST slice interview/skip returns 404 for nonexistent slice", async () => {
|
||||||
|
const { app } = buildApp({});
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/missions/slices/SL-NONEXISTENT/interview/skip",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user