feat(FN-1671): add AI milestone suggestion feature

- Add milestone suggestion generation backend contract with scoring algorithm
- Add milestone suggestion hook actions (generate, accept, dismiss) in useRoadmaps
- Add RoadmapsView UI with suggestion panel, cards, and accept/dismiss buttons
- Document AI milestone suggestion feature in dashboard guide
- Add roadmap-suggestions unit tests covering generation and UX flows
This commit is contained in:
Fusion
2026-04-15 09:49:45 -07:00
committed by gsxdsm
parent 05e928d67f
commit bd7ada7533
8 changed files with 1829 additions and 1 deletions

View File

@@ -4566,6 +4566,33 @@ export function moveRoadmapFeature(
});
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Generate milestone suggestions from a goal prompt */
export function generateMilestoneSuggestions(
roadmapId: string,
goalPrompt: string,
count?: number,
projectId?: string
): Promise<MilestoneSuggestionsResponse> {
return api<MilestoneSuggestionsResponse>(
withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId),
{
method: "POST",
body: JSON.stringify({
goalPrompt: goalPrompt.trim(),
...(count !== undefined ? { count } : {}),
}),
}
);
}
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
export interface AiSessionSummary {

View File

@@ -783,8 +783,17 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
reorderMilestones,
reorderFeatures,
moveFeature,
milestoneSuggestions,
isGeneratingSuggestions,
generateMilestoneSuggestions,
acceptMilestoneSuggestion,
acceptAllMilestoneSuggestions,
clearMilestoneSuggestions,
} = useRoadmaps({ projectId });
// Goal prompt state for milestone suggestion generation
const [goalPrompt, setGoalPrompt] = useState("");
// Inline edit states
const [roadmapEdit, setRoadmapEdit] = useState<InlineEditState>({
roadmapId: null,
@@ -1232,6 +1241,55 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
[deleteFeature, addToast]
);
// Milestone suggestion handlers
const handleGenerateSuggestions = useCallback(
async () => {
if (!goalPrompt.trim()) return;
try {
await generateMilestoneSuggestions(goalPrompt, 5, {
onError: (err) => addToast(err.message, "error"),
});
} catch {
// Error handled in callback
}
},
[goalPrompt, generateMilestoneSuggestions, addToast]
);
const handleAcceptSuggestion = useCallback(
async (index: number) => {
try {
await acceptMilestoneSuggestion(index, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Milestone added", "success");
} catch {
// Error handled in callback
}
},
[acceptMilestoneSuggestion, addToast]
);
const handleAcceptAllSuggestions = useCallback(
async () => {
try {
await acceptAllMilestoneSuggestions({
onError: (err) => addToast(err.message, "error"),
});
addToast(`${milestoneSuggestions.length} milestones added`, "success");
setGoalPrompt("");
} catch {
// Error handled in callback
}
},
[acceptAllMilestoneSuggestions, milestoneSuggestions.length, addToast]
);
const handleClearSuggestions = useCallback(() => {
clearMilestoneSuggestions();
setGoalPrompt("");
}, [clearMilestoneSuggestions]);
const handleCreateFeature = useCallback(
async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
try {
@@ -1396,6 +1454,81 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
)}
</div>
{/* Milestone Suggestions Section */}
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion, index) => (
<div key={index} className="roadmap-suggestion-card" data-testid={`suggestion-card-${index}`}>
<div className="roadmap-suggestion-card-content">
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
{suggestion.description && (
<span className="roadmap-suggestion-card-desc">{suggestion.description}</span>
)}
</div>
<div className="roadmap-suggestion-card-actions">
<button
className="roadmap-suggestion-accept-btn"
onClick={() => handleAcceptSuggestion(index)}
title="Accept this milestone"
aria-label="Accept this milestone"
data-testid={`accept-suggestion-${index}`}
>
<Check size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Milestone lanes */}
<div className="roadmaps-view__milestone-lanes">
{createForm.type === "milestone" && (

View File

@@ -13,6 +13,12 @@ import type {
} from "@fusion/core";
import * as api from "../api";
/** A suggested milestone from AI generation */
export interface MilestoneSuggestion {
title: string;
description?: string;
}
export interface UseRoadmapsOptions {
/** When provided, fetches roadmaps for this project */
projectId?: string;
@@ -70,6 +76,20 @@ export interface UseRoadmapsResult {
/** Move a feature to a different milestone or position */
moveFeature: (featureId: string, targetMilestoneId: string, targetIndex: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
// Milestone suggestion callbacks
/** Current pending milestone suggestions (ephemeral, in-memory only) */
milestoneSuggestions: MilestoneSuggestion[];
/** Whether suggestions are currently being generated */
isGeneratingSuggestions: boolean;
/** Generate milestone suggestions from a goal prompt */
generateMilestoneSuggestions: (goalPrompt: string, count?: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Accept a single milestone suggestion and create it as a milestone */
acceptMilestoneSuggestion: (index: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Accept all milestone suggestions and create them as milestones (sequentially) */
acceptAllMilestoneSuggestions: (opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Clear all pending milestone suggestions */
clearMilestoneSuggestions: () => void;
/** Refresh all roadmaps */
refresh: () => Promise<void>;
}
@@ -84,8 +104,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Ephemeral milestone suggestion state (in-memory only, not persisted)
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
// Track previous projectId to detect changes
const previousProjectIdRef = useRef<string | undefined>(projectId);
// Project context version for stale-response protection
const projectContextVersionRef = useRef(0);
// Refs to access latest state in callbacks
const roadmapsRef = useRef(roadmaps);
const selectedRoadmapIdRef = useRef(selectedRoadmapId);
@@ -99,14 +125,18 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
featuresByMilestoneIdRef.current = featuresByMilestoneId;
projectIdRef.current = projectId;
// Clear selection when project changes
// Clear selection and suggestions when project changes
useEffect(() => {
if (previousProjectIdRef.current !== projectId) {
previousProjectIdRef.current = projectId;
projectContextVersionRef.current++;
setSelectedRoadmapId(null);
setSelectedRoadmap(null);
setMilestones([]);
setFeaturesByMilestoneId({});
// Clear ephemeral suggestion state
setMilestoneSuggestions([]);
setIsGeneratingSuggestions(false);
}
}, [projectId]);
@@ -507,6 +537,188 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
}
}, [fetchSelectedRoadmap, projectId]);
// ── Milestone Suggestion Actions (Ephemeral) ───────────────────────────────────
const generateMilestoneSuggestions = useCallback(async (
goalPrompt: string,
count: number = 5,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
const currentRoadmapId = selectedRoadmapIdRef.current;
if (!currentRoadmapId) {
const error = new Error("No roadmap selected");
opts?.onError?.(error);
throw error;
}
// Capture project context version for stale-response protection
const contextVersionAtStart = projectContextVersionRef.current;
const requestProjectId = projectIdRef.current;
setIsGeneratingSuggestions(true);
try {
const response = await api.generateMilestoneSuggestions(
currentRoadmapId,
goalPrompt,
count,
requestProjectId
);
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed during fetch - discard response
return;
}
setMilestoneSuggestions(response.suggestions);
opts?.onSuccess?.();
} catch (err) {
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed during fetch - discard error
return;
}
const error = err instanceof Error ? err : new Error("Failed to generate suggestions");
opts?.onError?.(error);
throw error;
} finally {
// Only clear loading state if context hasn't changed
if (projectContextVersionRef.current === contextVersionAtStart) {
setIsGeneratingSuggestions(false);
}
}
}, []);
const acceptMilestoneSuggestion = useCallback(async (
index: number,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
const currentRoadmapId = selectedRoadmapIdRef.current;
if (!currentRoadmapId) {
const error = new Error("No roadmap selected");
opts?.onError?.(error);
throw error;
}
// Capture state for stale-response protection
const contextVersionAtStart = projectContextVersionRef.current;
const currentSuggestions = milestoneSuggestions;
if (index < 0 || index >= currentSuggestions.length) {
const error = new Error("Invalid suggestion index");
opts?.onError?.(error);
throw error;
}
const suggestion = currentSuggestions[index];
// Optimistic update: remove from suggestions immediately
setMilestoneSuggestions((prev) => prev.filter((_, i) => i !== index));
try {
await api.createRoadmapMilestone(
currentRoadmapId,
{ title: suggestion.title, description: suggestion.description },
projectIdRef.current
);
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed - re-add to suggestions (optimistic rollback)
setMilestoneSuggestions((prev) => {
const updated = [...prev];
updated.splice(index, 0, suggestion);
return updated;
});
return;
}
// Refresh the roadmap to get the new milestone
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
// Rollback: re-add to suggestions
setMilestoneSuggestions((prev) => {
const updated = [...prev];
updated.splice(index, 0, suggestion);
return updated;
});
const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
opts?.onError?.(error);
throw error;
}
}, [milestoneSuggestions, fetchSelectedRoadmap]);
const acceptAllMilestoneSuggestions = useCallback(async (
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
const currentRoadmapId = selectedRoadmapIdRef.current;
if (!currentRoadmapId) {
const error = new Error("No roadmap selected");
opts?.onError?.(error);
throw error;
}
// Capture current suggestions (they will be cleared sequentially)
const suggestionsToAccept = [...milestoneSuggestions];
if (suggestionsToAccept.length === 0) {
return;
}
// Clear suggestions immediately (optimistic)
setMilestoneSuggestions([]);
// Capture state for stale-response protection
const contextVersionAtStart = projectContextVersionRef.current;
// Accept sequentially to preserve order
for (let i = 0; i < suggestionsToAccept.length; i++) {
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
// Project context changed - stop accepting
break;
}
const suggestion = suggestionsToAccept[i];
try {
await api.createRoadmapMilestone(
currentRoadmapId,
{ title: suggestion.title, description: suggestion.description },
projectIdRef.current
);
} catch (err) {
// On error, stop accepting and report
const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
opts?.onError?.(error);
throw error;
}
}
// Check for stale response
if (projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
// Refresh the roadmap to get all new milestones
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
}, [milestoneSuggestions, fetchSelectedRoadmap]);
const clearMilestoneSuggestions = useCallback(() => {
setMilestoneSuggestions([]);
setIsGeneratingSuggestions(false);
}, []);
const refresh = useCallback(async () => {
await fetchRoadmaps();
if (selectedRoadmapIdRef.current) {
@@ -535,6 +747,12 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
deleteFeature,
reorderFeatures,
moveFeature,
milestoneSuggestions,
isGeneratingSuggestions,
generateMilestoneSuggestions,
acceptMilestoneSuggestion,
acceptAllMilestoneSuggestions,
clearMilestoneSuggestions,
refresh,
};
}

View File

@@ -30971,6 +30971,208 @@ html .column.drag-over * {
font-size: 0.85rem;
}
/* === Roadmap Suggestion Section === */
.roadmap-suggestion-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
}
.roadmap-suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.roadmap-suggestion-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.roadmap-suggestion-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.roadmap-suggestion-input {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-input);
color: var(--text-primary);
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
min-height: 60px;
transition: border-color 0.15s;
}
.roadmap-suggestion-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.roadmap-suggestion-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.roadmap-suggestion-input::placeholder {
color: var(--text-muted);
}
.roadmap-suggestion-actions {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.roadmap-suggestion-generate-btn {
padding: var(--space-sm) var(--space-lg);
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.roadmap-suggestion-generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
.roadmap-suggestion-generate-btn:active:not(:disabled) {
transform: scale(0.98);
}
.roadmap-suggestion-generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roadmap-suggestion-accept-all-btn {
padding: var(--space-sm) var(--space-md);
background: var(--color-success);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.roadmap-suggestion-accept-all-btn:hover {
opacity: 0.9;
}
.roadmap-suggestion-accept-all-btn:active {
transform: scale(0.98);
}
.roadmap-suggestion-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.roadmap-suggestion-clear-btn:hover {
color: var(--color-error);
border-color: var(--color-error);
}
.roadmap-suggestion-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.roadmap-suggestion-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-md);
background: var(--surface-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: border-color 0.15s, box-shadow 0.15s;
}
.roadmap-suggestion-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
.roadmap-suggestion-card-content {
display: flex;
flex-direction: column;
gap: var(--space-xs);
flex: 1;
min-width: 0;
}
.roadmap-suggestion-card-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
}
.roadmap-suggestion-card-desc {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.4;
}
.roadmap-suggestion-card-actions {
display: flex;
gap: var(--space-xs);
flex-shrink: 0;
}
.roadmap-suggestion-accept-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--color-success);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.roadmap-suggestion-accept-btn:hover {
opacity: 0.9;
}
.roadmap-suggestion-accept-btn:active {
transform: scale(0.95);
}
/* Mobile responsive */
@media (max-width: 768px) {
.roadmaps-view__sidebar {
@@ -30987,5 +31189,27 @@ html .column.drag-over * {
width: 100%;
min-width: unset;
}
/* Roadmap suggestion mobile styles */
.roadmap-suggestion-section {
padding: var(--space-md);
margin: var(--space-md);
}
.roadmap-suggestion-actions {
flex-wrap: wrap;
}
.roadmap-suggestion-generate-btn {
flex: 1;
}
.roadmap-suggestion-accept-all-btn {
flex: 1;
}
.roadmap-suggestion-card {
padding: var(--space-sm);
}
}

View File

@@ -0,0 +1,619 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
validateSuggestionInput,
generateMilestoneSuggestions,
ValidationError,
ParseError,
__resetSuggestionState,
__setCreateKbAgent,
} from "./roadmap-suggestions";
describe("roadmap-suggestions", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetSuggestionState();
});
afterEach(() => {
__resetSuggestionState();
});
describe("validateSuggestionInput", () => {
it("accepts valid input with all fields", () => {
const input = {
goalPrompt: "Build a modern e-commerce platform",
count: 5,
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("accepts valid input without optional count", () => {
const input = {
goalPrompt: "Build a modern e-commerce platform",
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("accepts count at minimum boundary (1)", () => {
const input = {
goalPrompt: "Test goal",
count: 1,
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("accepts count at maximum boundary (10)", () => {
const input = {
goalPrompt: "Test goal",
count: 10,
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("rejects null input", () => {
expect(() => validateSuggestionInput(null)).toThrow(ValidationError);
});
it("rejects non-object input", () => {
expect(() => validateSuggestionInput("string")).toThrow(ValidationError);
expect(() => validateSuggestionInput(123)).toThrow(ValidationError);
expect(() => validateSuggestionInput([])).toThrow(ValidationError);
});
it("rejects missing goalPrompt", () => {
expect(() => validateSuggestionInput({})).toThrow(ValidationError);
expect(() => validateSuggestionInput({ count: 5 })).toThrow(ValidationError);
});
it("rejects non-string goalPrompt", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: 123 })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: null })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: [] })
).toThrow(ValidationError);
});
it("rejects empty goalPrompt", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "" })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: " " })
).toThrow(ValidationError);
});
it("rejects goalPrompt exceeding max length", () => {
const longPrompt = "a".repeat(4001);
expect(() =>
validateSuggestionInput({ goalPrompt: longPrompt })
).toThrow(ValidationError);
});
it("accepts goalPrompt at exactly max length", () => {
const maxPrompt = "a".repeat(4000);
expect(() =>
validateSuggestionInput({ goalPrompt: maxPrompt })
).not.toThrow();
});
it("rejects non-integer count", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: 3.5 })
).toThrow(ValidationError);
});
it("rejects count below minimum", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: 0 })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: -1 })
).toThrow(ValidationError);
});
it("rejects count above maximum", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: 11 })
).toThrow(ValidationError);
});
});
describe("generateMilestoneSuggestions", () => {
const rootDir = "/test/project";
it("generates milestone suggestions successfully", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Foundation Setup", "description": "Set up core infrastructure"},\n {"title": "User Authentication", "description": "Implement login and user management"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions(
"Build a modern e-commerce platform",
5,
rootDir
);
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({
title: "Foundation Setup",
description: "Set up core infrastructure",
});
expect(suggestions[1]).toEqual({
title: "User Authentication",
description: "Implement login and user management",
});
});
it("uses default count of 5 when not specified", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Test goal", undefined, rootDir);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: rootDir,
systemPrompt: expect.stringContaining("milestone"),
})
);
});
it("respects the count parameter", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Test goal", 3, rootDir);
expect(mockCreateKbAgent).toHaveBeenCalled();
});
it("includes count in user message", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Build a platform", 5, rootDir);
expect(mockSession.prompt).toHaveBeenCalledWith(
expect.stringContaining("5 milestones")
);
});
it("disposes session after successful generation", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Test", 5, rootDir);
expect(mockSession.dispose).toHaveBeenCalled();
});
it("throws when AI service is unavailable", async () => {
__setCreateKbAgent(undefined);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow("AI service is not available");
});
it("throws when rootDir is missing", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5)
).rejects.toThrow("rootDir is required");
});
it("handles markdown-wrapped JSON response", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '```json\n[\n {"title": "Setup", "description": "Initial setup"}\n]\n```',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].title).toBe("Setup");
});
it("handles plain array response without markdown", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Phase 1", "description": "First phase"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].title).toBe("Phase 1");
});
it("handles suggestions without description", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[{"title": "Phase 1"}, {"title": "Phase 2", "description": "With desc"}]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({ title: "Phase 1", description: undefined });
expect(suggestions[1]).toEqual({ title: "Phase 2", description: "With desc" });
});
it("limits suggestions to requested count", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "One"}, {"title": "Two"}, {"title": "Three"}, {"title": "Four"}, {"title": "Five"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 2, rootDir);
expect(suggestions).toHaveLength(2);
expect(suggestions[0].title).toBe("One");
expect(suggestions[1].title).toBe("Two");
});
it("strips whitespace from titles and descriptions", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": " Trimmed Title ", "description": " With whitespace "}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions[0]).toEqual({
title: "Trimmed Title",
description: "With whitespace",
});
});
it("throws ParseError when AI returns no JSON", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Here are some milestones without JSON",
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow(ParseError);
});
it("throws ParseError when JSON is not an array", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '{"title": "Not an array"}',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow(ParseError);
});
it("throws ParseError when milestone is missing title", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[{"description": "Missing title"}]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow(ParseError);
});
it("supports model override parameters", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions(
"Test",
5,
rootDir,
"openai",
"gpt-4o"
);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
})
);
});
});
});

View File

@@ -0,0 +1,521 @@
/**
* Roadmap Milestone Suggestion Generation Service
*
* Provides AI-powered milestone suggestion generation for roadmaps.
* Users can generate milestone ideas from a goal prompt and accept them
* into their roadmap.
*
* Features:
* - AI agent integration via dynamic import of @fusion/engine
* - Planning-style JSON extraction with repair
* - Input validation (goal prompt max length, count bounds)
* - Read-only endpoint (no persistence of suggestions)
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
*/
import { randomUUID } from "node:crypto";
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createKbAgent: any;
// Track if engine has been initialized (prevents multiple imports)
let engineInitialized = false;
// Flag to indicate if createKbAgent was explicitly set (even to undefined)
let createKbAgentExplicitlySet = false;
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine(): Promise<void> {
if (engineInitialized) return;
// If createKbAgent was explicitly set (even to undefined), don't try to import
if (createKbAgentExplicitlySet) {
engineInitialized = true;
return;
}
if (!createKbAgent) {
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
createKbAgent = engine.createKbAgent;
} catch {
// Allow failure in test environments - agent functionality will be stubbed
createKbAgent = undefined;
}
}
engineInitialized = true;
}
// ── Types ───────────────────────────────────────────────────────────────────
/** Input for generating milestone suggestions */
export interface GenerateMilestoneSuggestionsInput {
/** The goal prompt/description for the roadmap */
goalPrompt: string;
/** Number of milestones to generate (default 5, max 10) */
count?: number;
}
/** A suggested milestone with title and optional description */
export interface MilestoneSuggestion {
title: string;
description?: string;
}
/** System prompt for milestone suggestion generation */
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
## Guidelines
1. **Think about phases**: Break the goal into logical phases (e.g., "Foundation", "Core Features", "Polish", "Launch")
2. **Use clear titles**: Milestone titles should be concise and descriptive (e.g., "Authentication System", "User Dashboard MVP")
3. **Add context**: Include a brief description explaining what this milestone encompasses
4. **Order matters**: List milestones in the order they should be completed
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
## Output Format
Respond with ONLY a valid JSON array of milestone suggestions:
[
{
"title": "Milestone Title",
"description": "Brief description of what this milestone covers (1-2 sentences)"
},
...
]
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
// ── Constants ─────────────────────────────────────────────────────────────
/** Maximum length for goal prompt */
const MAX_GOAL_PROMPT_LENGTH = 4000;
/** Default number of suggestions to generate */
const DEFAULT_SUGGESTION_COUNT = 5;
/** Maximum number of suggestions to generate */
const MAX_SUGGESTION_COUNT = 10;
/** Minimum number of suggestions to generate */
const MIN_SUGGESTION_COUNT = 1;
/** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1;
// ── Validation ─────────────────────────────────────────────────────────────
/**
* Validate the input for generating milestone suggestions.
* Throws with a descriptive error message on validation failure.
*/
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be an object");
}
const { goalPrompt, count } = input as Record<string, unknown>;
// Validate goalPrompt
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
throw new ValidationError("goalPrompt is required and must be a non-empty string");
}
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
throw new ValidationError(
`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`
);
}
// Validate count (optional)
if (count !== undefined) {
if (typeof count !== "number" || !Number.isInteger(count)) {
throw new ValidationError("count must be an integer");
}
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
throw new ValidationError(
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
);
}
}
}
// ── JSON Extraction ────────────────────────────────────────────────────────
/**
* Extract the best JSON candidate from AI response text.
* Handles markdown-wrapped JSON, embedded JSON, and balanced brace extraction.
*/
function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null;
// 1. Try markdown code blocks first (most reliable)
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch?.[1]) {
const candidate = codeBlockMatch[1].trim();
if (candidate.startsWith("[")) return candidate;
}
// 2. Find all top-level bracket-delimited arrays using balanced counting
const candidates: Array<{ start: number; end: number; text: string }> = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === "[") {
let depth = 0;
let inString = false;
let escape = false;
for (let j = i; j < text.length; j++) {
const ch = text[j];
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "[") depth++;
if (ch === "]") depth--;
if (depth === 0) {
const candidate = text.slice(i, j + 1).trim();
// Only accept candidates that parse as valid JSON
try {
JSON.parse(candidate);
candidates.push({ start: i, end: j, text: candidate });
} catch {
// Not valid JSON, skip
}
break;
}
}
}
}
// Pick the largest valid candidate (most likely the full response)
if (candidates.length > 0) {
candidates.sort((a, b) => b.text.length - a.text.length);
return candidates[0].text;
}
// 3. Last resort: try the full trimmed text
const trimmed = text.trim();
if (trimmed.startsWith("[")) return trimmed;
return null;
}
/**
* Attempt to repair common JSON issues:
* - Truncated JSON (missing closing brackets/braces)
* - Trailing commas before closing brackets/braces
* - Missing closing quotes
*/
function repairJson(text: string): string {
let repaired = text;
// Fix trailing commas before } or ]
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
// Count open/close braces and brackets
let openBraces = 0;
let openBrackets = 0;
let inString = false;
let escape = false;
for (const ch of repaired) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") openBraces++;
if (ch === "}") openBraces--;
if (ch === "[") openBrackets++;
if (ch === "]") openBrackets--;
}
// If we're in an unclosed string, close it
if (inString) {
repaired += '"';
}
// Re-count after potential string fix
openBraces = 0;
openBrackets = 0;
inString = false;
escape = false;
for (const ch of repaired) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") openBraces++;
if (ch === "}") openBraces--;
if (ch === "[") openBrackets++;
if (ch === "]") openBrackets--;
}
// Close unclosed brackets and braces
repaired += "]".repeat(Math.max(0, openBrackets));
repaired += "}".repeat(Math.max(0, openBraces));
return repaired;
}
/**
* Parse AI response JSON with robust extraction and recovery.
*/
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
const candidate = extractJsonCandidate(text);
if (!candidate) {
throw new ParseError("AI returned no valid JSON. Please try again.");
}
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
// Attempt repair for truncated/malformed JSON
try {
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
throw new ParseError(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
}
}
// Validate structure: must be an array
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of milestone suggestions");
}
// Validate and normalize each item
const suggestions: MilestoneSuggestion[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
if (!item || typeof item !== "object") {
throw new ParseError(`Item ${i + 1} in AI response is invalid`);
}
const { title, description } = item as Record<string, unknown>;
if (typeof title !== "string" || !title.trim()) {
throw new ParseError(`Item ${i + 1} in AI response is missing a valid title`);
}
suggestions.push({
title: title.trim(),
description: typeof description === "string" && description.trim()
? description.trim()
: undefined,
});
}
if (suggestions.length === 0) {
throw new ParseError("AI returned no milestone suggestions");
}
return suggestions;
}
// ── Generation ─────────────────────────────────────────────────────────────
/**
* Generate milestone suggestions from a goal prompt.
*
* @param goalPrompt - The goal/description for the roadmap
* @param count - Number of suggestions to generate (default 5, max 10)
* @param rootDir - Project root directory for AI context
* @param modelProvider - Optional AI model provider override
* @param modelId - Optional AI model ID override
* @returns Array of milestone suggestions
*/
export async function generateMilestoneSuggestions(
goalPrompt: string,
count: number = DEFAULT_SUGGESTION_COUNT,
rootDir?: string,
modelProvider?: string,
modelId?: string,
): Promise<MilestoneSuggestion[]> {
// Ensure engine is loaded before using createKbAgent
await initEngine();
if (!createKbAgent) {
throw new ServiceUnavailableError("AI service is not available");
}
if (!rootDir) {
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Create a unique session ID for this generation
const sessionId = randomUUID();
let agent: ReturnType<typeof createKbAgent> | undefined;
try {
// Create AI agent with milestone suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for milestone suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Send the goal prompt with count instruction
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: MilestoneSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseMilestoneSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of milestone suggestions in this format: " +
'[{"title": "Milestone Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
export class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ParseError";
}
}
export class ServiceUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ServiceUnavailableError";
}
}
// ── Test Helpers ───────────────────────────────────────────────────────────
/**
* Reset module state. Used for testing only.
*/
export function __resetSuggestionState(): void {
createKbAgent = undefined;
engineInitialized = false;
createKbAgentExplicitlySet = false;
}
/**
* Inject a mock createKbAgent function. Used for testing only.
*/
export function __setCreateKbAgent(mock: typeof createKbAgent): void {
createKbAgent = mock;
createKbAgentExplicitlySet = true;
}

View File

@@ -42,6 +42,13 @@ import {
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
import { writeSSEEvent } from "./sse-buffer.js";
import {
generateMilestoneSuggestions,
validateSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
} from "./roadmap-suggestions.js";
import {
ApiError,
badRequest,
@@ -2715,6 +2722,63 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Roadmap Milestone Suggestions ───────────────────────────────────────
// Generate milestone suggestions from a goal prompt
router.post("/roadmaps/:roadmapId/suggestions/milestones", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
// Check if roadmap exists
const roadmap = roadmapStore.getRoadmap(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
// Validate input
let input: { goalPrompt: string; count?: number };
try {
validateSuggestionInput(req.body);
input = req.body as { goalPrompt: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateMilestoneSuggestions(
input.goalPrompt,
input.count,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to generate milestone suggestions");
}
});
// List all tasks
router.get("/tasks", async (req, res) => {
try {