feat(FN-3159): move roadmap ownership from core to fusion-plugin-roadmap pl

Moves roadmap ownership from `@fusion/core` to `fusion-plugin-roadmap`, deleting ~1,800 lines of roadmap store, types, ordering, and handoff logic from core and replacing it with ~760 lines of new route handlers in the plugin. The dashboard's roadmap routes and suggestions modules are substantially

Fusion-Task-Id: FN-3159
This commit is contained in:
Fusion
2026-05-08 09:35:45 -07:00
committed by gsxdsm
parent 0746260544
commit 6fffa830bf
45 changed files with 3285 additions and 3505 deletions

View File

@@ -0,0 +1,4 @@
import type { PluginRouteDefinition } from "@fusion/core";
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
export { SUGGESTION_TIMEOUT_MS };
//# sourceMappingURL=roadmap-routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAiG9F,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}

View File

@@ -0,0 +1,361 @@
import { RoadmapStore } from "../store/roadmap-store.js";
import { generateFeatureSuggestions, generateMilestoneSuggestions, ParseError as SuggestionParseError, ServiceUnavailableError as SuggestionServiceUnavailableError, validateFeatureSuggestionInput, validateSuggestionInput, ValidationError as SuggestionValidationError, } from "./roadmap-suggestions.js";
const roadmapStoreCache = new WeakMap();
function getRoadmapStore(ctx) {
const taskStoreWithRoadmaps = ctx.taskStore;
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
return taskStoreWithRoadmaps.getRoadmapStore();
}
const key = ctx.taskStore;
const cached = roadmapStoreCache.get(key);
if (cached)
return cached;
const store = new RoadmapStore(ctx.taskStore.getDatabase());
roadmapStoreCache.set(key, store);
return store;
}
function asRequest(req) {
return req;
}
function badRequest(message) {
return { status: 400, body: { error: message } };
}
function notFound(message) {
return { status: 404, body: { error: message } };
}
function serverError(message) {
return { status: 500, body: { error: message } };
}
function noContent() {
return { status: 204 };
}
function routeHandler(handler) {
return async (req, ctx) => {
const roadmapStore = getRoadmapStore(ctx);
try {
return await handler(asRequest(req), ctx, roadmapStore);
}
catch (error) {
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
return notFound(error.message);
}
return serverError(error instanceof Error ? error.message : "Internal server error");
}
};
}
function validateTitle(title) {
if (!title || typeof title !== "string" || !title.trim()) {
throw new Error("title is required");
}
if (title.length > 200) {
throw new Error("title must not exceed 200 characters");
}
return title.trim();
}
function validateDescription(desc) {
if (desc === undefined || desc === null)
return undefined;
if (typeof desc !== "string") {
throw new Error("description must be a string");
}
if (desc.length > 5000) {
throw new Error("description must not exceed 5000 characters");
}
return desc.trim() || undefined;
}
function validateStringArray(arr, fieldName) {
if (!Array.isArray(arr)) {
throw new Error(`${fieldName} must be an array`);
}
if (!arr.every((item) => typeof item === "string")) {
throw new Error(`${fieldName} must be an array of strings`);
}
return arr;
}
export function createRoadmapPluginRoutes() {
return [
{
method: "GET",
path: "/roadmaps",
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
},
{
method: "POST",
path: "/roadmaps",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
try {
return {
status: 201,
body: roadmapStore.createRoadmap({
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
};
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "GET",
path: "/roadmaps/:roadmapId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
}),
},
{
method: "PATCH",
path: "/roadmaps/:roadmapId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
try {
return roadmapStore.updateRoadmap(req.params.roadmapId, {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteRoadmap(req.params.roadmapId);
return noContent();
}) },
{
method: "POST",
path: "/roadmaps/:roadmapId/milestones",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
try {
return {
status: 201,
body: roadmapStore.createMilestone(req.params.roadmapId, {
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
};
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "POST",
path: "/roadmaps/:roadmapId/milestones/reorder",
handler: routeHandler((req, _ctx, roadmapStore) => {
try {
const body = req.body;
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
return noContent();
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "PATCH",
path: "/roadmaps/milestones/:milestoneId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
try {
return roadmapStore.updateMilestone(req.params.milestoneId, {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteMilestone(req.params.milestoneId);
return noContent();
}) },
{
method: "POST",
path: "/roadmaps/milestones/:milestoneId/features",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
try {
return {
status: 201,
body: roadmapStore.createFeature(req.params.milestoneId, {
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
};
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "POST",
path: "/roadmaps/milestones/:milestoneId/features/reorder",
handler: routeHandler((req, _ctx, roadmapStore) => {
try {
const body = req.body;
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
if (!milestone)
return notFound(`Milestone ${req.params.milestoneId} not found`);
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
return noContent();
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "PATCH",
path: "/roadmaps/features/:featureId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
try {
return roadmapStore.updateFeature(req.params.featureId, {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
}
catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteFeature(req.params.featureId);
return noContent();
}) },
{
method: "POST",
path: "/roadmaps/features/:featureId/move",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body;
if (!body?.targetMilestoneId)
return badRequest("targetMilestoneId is required");
if (typeof body.targetIndex !== "number")
return badRequest("targetIndex must be a number");
const feature = roadmapStore.getFeature(req.params.featureId);
if (!feature)
return notFound(`Feature ${req.params.featureId} not found`);
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
if (!fromMilestone)
return notFound(`Source milestone ${feature.milestoneId} not found`);
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
if (!toMilestone)
return notFound(`Target milestone ${body.targetMilestoneId} not found`);
roadmapStore.moveFeature({
roadmapId: fromMilestone.roadmapId,
featureId: req.params.featureId,
fromMilestoneId: feature.milestoneId,
toMilestoneId: body.targetMilestoneId,
targetOrderIndex: body.targetIndex,
});
return noContent();
}),
},
{
method: "POST",
path: "/roadmaps/:roadmapId/suggestions/milestones",
handler: routeHandler(async (req, ctx, roadmapStore) => {
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
if (!roadmap)
return notFound(`Roadmap ${req.params.roadmapId} not found`);
try {
validateSuggestionInput(req.body);
}
catch (error) {
if (error instanceof SuggestionValidationError)
return badRequest(error.message);
throw error;
}
try {
const body = req.body;
const suggestions = await generateMilestoneSuggestions(body.goalPrompt, body.count, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
return { suggestions };
}
catch (error) {
if (error instanceof SuggestionParseError)
return serverError(error.message);
if (error instanceof SuggestionServiceUnavailableError) {
return { status: 503, body: { error: error.message } };
}
throw error;
}
}),
},
{
method: "POST",
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
handler: routeHandler(async (req, ctx, roadmapStore) => {
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
if (!milestone)
return notFound(`Milestone ${req.params.milestoneId} not found`);
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap)
return notFound(`Roadmap ${milestone.roadmapId} not found`);
try {
validateFeatureSuggestionInput(req.body);
}
catch (error) {
if (error instanceof SuggestionValidationError)
return badRequest(error.message);
throw error;
}
try {
const body = req.body;
const suggestions = await generateFeatureSuggestions({
roadmapTitle: roadmap.title,
roadmapDescription: roadmap.description,
milestoneTitle: milestone.title,
milestoneDescription: milestone.description,
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
}, body.count, body.prompt, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
return { suggestions };
}
catch (error) {
if (error instanceof SuggestionParseError)
return serverError(error.message);
if (error instanceof SuggestionServiceUnavailableError) {
return { status: 503, body: { error: error.message } };
}
throw error;
}
}),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/export",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/handoff",
handler: routeHandler((req, _ctx, roadmapStore) => ({
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
})),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/handoff/mission",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
},
];
}
export { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js";
//# sourceMappingURL=roadmap-routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,381 @@
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
import type { Request } from "express";
import { RoadmapStore } from "../store/roadmap-store.js";
import {
generateFeatureSuggestions,
generateMilestoneSuggestions,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
validateFeatureSuggestionInput,
validateSuggestionInput,
ValidationError as SuggestionValidationError,
} from "./roadmap-suggestions.js";
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
function getRoadmapStore(ctx: PluginContext): RoadmapStore {
const taskStoreWithRoadmaps = ctx.taskStore as PluginContext["taskStore"] & {
getRoadmapStore?: () => RoadmapStore;
};
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
return taskStoreWithRoadmaps.getRoadmapStore();
}
const key = ctx.taskStore as object;
const cached = roadmapStoreCache.get(key);
if (cached) return cached;
const store = new RoadmapStore(ctx.taskStore.getDatabase());
roadmapStoreCache.set(key, store);
return store;
}
function asRequest(req: unknown): Request {
return req as Request;
}
function badRequest(message: string): PluginRouteResponse {
return { status: 400, body: { error: message } };
}
function notFound(message: string): PluginRouteResponse {
return { status: 404, body: { error: message } };
}
function serverError(message: string): PluginRouteResponse {
return { status: 500, body: { error: message } };
}
function noContent(): PluginRouteResponse {
return { status: 204 };
}
function routeHandler<T>(handler: (req: Request, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise<T | PluginRouteResponse> | T | PluginRouteResponse) {
return async (req: unknown, ctx: PluginContext): Promise<T | PluginRouteResponse> => {
const roadmapStore = getRoadmapStore(ctx);
try {
return await handler(asRequest(req), ctx, roadmapStore);
} catch (error) {
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
return notFound(error.message);
}
return serverError(error instanceof Error ? error.message : "Internal server error");
}
};
}
function validateTitle(title: unknown): string {
if (!title || typeof title !== "string" || !title.trim()) {
throw new Error("title is required");
}
if (title.length > 200) {
throw new Error("title must not exceed 200 characters");
}
return title.trim();
}
function validateDescription(desc: unknown): string | undefined {
if (desc === undefined || desc === null) return undefined;
if (typeof desc !== "string") {
throw new Error("description must be a string");
}
if (desc.length > 5000) {
throw new Error("description must not exceed 5000 characters");
}
return desc.trim() || undefined;
}
function validateStringArray(arr: unknown, fieldName: string): string[] {
if (!Array.isArray(arr)) {
throw new Error(`${fieldName} must be an array`);
}
if (!arr.every((item) => typeof item === "string")) {
throw new Error(`${fieldName} must be an array of strings`);
}
return arr;
}
export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
return [
{
method: "GET",
path: "/roadmaps",
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
},
{
method: "POST",
path: "/roadmaps",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title: string; description?: string };
try {
return {
status: 201,
body: roadmapStore.createRoadmap({
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
};
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "GET",
path: "/roadmaps/:roadmapId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
}),
},
{
method: "PATCH",
path: "/roadmaps/:roadmapId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title?: string; description?: string };
try {
return roadmapStore.updateRoadmap(req.params.roadmapId, {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteRoadmap(req.params.roadmapId);
return noContent();
}) },
{
method: "POST",
path: "/roadmaps/:roadmapId/milestones",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title: string; description?: string };
try {
return {
status: 201,
body: roadmapStore.createMilestone(req.params.roadmapId, {
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
};
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "POST",
path: "/roadmaps/:roadmapId/milestones/reorder",
handler: routeHandler((req, _ctx, roadmapStore) => {
try {
const body = req.body as { orderedMilestoneIds: string[] };
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
return noContent();
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "PATCH",
path: "/roadmaps/milestones/:milestoneId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title?: string; description?: string };
try {
return roadmapStore.updateMilestone(req.params.milestoneId, {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteMilestone(req.params.milestoneId);
return noContent();
}) },
{
method: "POST",
path: "/roadmaps/milestones/:milestoneId/features",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title: string; description?: string };
try {
return {
status: 201,
body: roadmapStore.createFeature(req.params.milestoneId, {
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
};
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "POST",
path: "/roadmaps/milestones/:milestoneId/features/reorder",
handler: routeHandler((req, _ctx, roadmapStore) => {
try {
const body = req.body as { orderedFeatureIds: string[] };
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
return noContent();
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{
method: "PATCH",
path: "/roadmaps/features/:featureId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title?: string; description?: string };
try {
return roadmapStore.updateFeature(req.params.featureId, {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
}
}),
},
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteFeature(req.params.featureId);
return noContent();
}) },
{
method: "POST",
path: "/roadmaps/features/:featureId/move",
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { targetMilestoneId: string; targetIndex: number };
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
const feature = roadmapStore.getFeature(req.params.featureId);
if (!feature) return notFound(`Feature ${req.params.featureId} not found`);
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
if (!toMilestone) return notFound(`Target milestone ${body.targetMilestoneId} not found`);
roadmapStore.moveFeature({
roadmapId: fromMilestone.roadmapId,
featureId: req.params.featureId,
fromMilestoneId: feature.milestoneId,
toMilestoneId: body.targetMilestoneId,
targetOrderIndex: body.targetIndex,
});
return noContent();
}),
},
{
method: "POST",
path: "/roadmaps/:roadmapId/suggestions/milestones",
handler: routeHandler(async (req, ctx, roadmapStore) => {
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`);
try {
validateSuggestionInput(req.body);
} catch (error) {
if (error instanceof SuggestionValidationError) return badRequest(error.message);
throw error;
}
try {
const body = req.body as { goalPrompt: string; count?: number };
const suggestions = await generateMilestoneSuggestions(
body.goalPrompt,
body.count,
ctx.taskStore.getRootDir(),
undefined,
undefined,
ctx.createAiSession,
);
return { suggestions };
} catch (error) {
if (error instanceof SuggestionParseError) return serverError(error.message);
if (error instanceof SuggestionServiceUnavailableError) {
return { status: 503, body: { error: error.message } };
}
throw error;
}
}),
},
{
method: "POST",
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
handler: routeHandler(async (req, ctx, roadmapStore) => {
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
try {
validateFeatureSuggestionInput(req.body);
} catch (error) {
if (error instanceof SuggestionValidationError) return badRequest(error.message);
throw error;
}
try {
const body = req.body as { prompt?: string; count?: number };
const suggestions = await generateFeatureSuggestions(
{
roadmapTitle: roadmap.title,
roadmapDescription: roadmap.description,
milestoneTitle: milestone.title,
milestoneDescription: milestone.description,
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
},
body.count,
body.prompt,
ctx.taskStore.getRootDir(),
undefined,
undefined,
ctx.createAiSession,
);
return { suggestions };
} catch (error) {
if (error instanceof SuggestionParseError) return serverError(error.message);
if (error instanceof SuggestionServiceUnavailableError) {
return { status: 503, body: { error: error.message } };
}
throw error;
}
}),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/export",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/handoff",
handler: routeHandler((req, _ctx, roadmapStore) => ({
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
})),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/handoff/mission",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
},
];
}
export { SUGGESTION_TIMEOUT_MS };

View File

@@ -0,0 +1,68 @@
import type { CreateAiSessionFactory } from "@fusion/core";
type LegacyCreateFnAgent = (options: {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
defaultProvider?: string;
defaultModelId?: string;
onThinking?: () => void;
onText?: () => void;
}) => Promise<{
session: {
prompt(text: string): Promise<void>;
state: {
messages: Array<{
role: string;
content?: string | Array<{
type: string;
text: string;
}>;
}>;
};
dispose?: () => void;
};
}>;
export interface GenerateMilestoneSuggestionsInput {
goalPrompt: string;
count?: number;
}
export interface MilestoneSuggestion {
title: string;
description?: string;
}
export declare const MILESTONE_SUGGESTION_SYSTEM_PROMPT = "You are a milestone planning assistant for a product roadmap system.\n\nYour job is to suggest logical milestones that would help achieve a user's roadmap goal.\n\n## Guidelines\n\n1. **Think about phases**: Break the goal into logical phases\n2. **Use clear titles**: Milestone titles should be concise and descriptive\n3. **Add context**: Include a brief description explaining what this milestone encompasses\n4. **Order matters**: List milestones in the order they should be completed\n5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks\n\n## Output Format\n\nRespond with ONLY a valid JSON array of milestone suggestions.";
export declare const SUGGESTION_TIMEOUT_MS = 120000;
export declare function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput;
export declare function generateMilestoneSuggestions(goalPrompt: string, count?: number, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<MilestoneSuggestion[]>;
export interface GenerateFeatureSuggestionsInput {
prompt?: string;
count?: number;
}
export interface FeatureSuggestion {
title: string;
description?: string;
}
export interface FeatureSuggestionContext {
roadmapTitle: string;
roadmapDescription?: string;
milestoneTitle: string;
milestoneDescription?: string;
existingFeatureTitles: string[];
}
export declare const FEATURE_SUGGESTION_SYSTEM_PROMPT = "You are a feature planning assistant for a product roadmap system.";
export declare function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput;
export declare function generateFeatureSuggestions(context: FeatureSuggestionContext, count?: number, prompt?: string, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<FeatureSuggestion[]>;
export declare class ValidationError extends Error {
constructor(message: string);
}
export declare class ParseError extends Error {
constructor(message: string);
}
export declare class ServiceUnavailableError extends Error {
constructor(message: string);
}
export declare function __resetSuggestionState(): void;
export declare function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void;
export declare function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void;
export {};
//# sourceMappingURL=roadmap-suggestions.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"roadmap-suggestions.d.ts","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,300 @@
let injectedCreateAiSession;
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
2. **Use clear titles**: Milestone titles should be concise and descriptive
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.`;
const MAX_GOAL_PROMPT_LENGTH = 4000;
export const SUGGESTION_TIMEOUT_MS = 120_000;
const DEFAULT_SUGGESTION_COUNT = 5;
const MAX_SUGGESTION_COUNT = 10;
const MIN_SUGGESTION_COUNT = 1;
const MAX_PARSE_RETRIES = 1;
export function validateSuggestionInput(input) {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be an object");
}
const { goalPrompt, count } = input;
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`);
}
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}`);
}
}
}
function extractJsonCandidate(text) {
if (!text || !text.trim())
return null;
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
const source = codeBlockMatch?.[1]?.trim() || text.trim();
const startIndex = source.indexOf("[");
if (startIndex < 0)
return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = startIndex; index < source.length; index++) {
const char = source[index];
if (inString) {
if (escaped) {
escaped = false;
}
else if (char === "\\") {
escaped = true;
}
else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "[")
depth++;
if (char === "]") {
depth--;
if (depth === 0) {
return source.slice(startIndex, index + 1).trim();
}
}
}
return source.slice(startIndex).trim();
}
function repairJson(text) {
let repaired = text.replace(/,\s*([}\]])/g, "$1");
let depthBraces = 0;
let depthBrackets = 0;
for (const ch of repaired) {
if (ch === "{")
depthBraces++;
if (ch === "}")
depthBraces--;
if (ch === "[")
depthBrackets++;
if (ch === "]")
depthBrackets--;
}
repaired += "]".repeat(Math.max(0, depthBrackets));
repaired += "}".repeat(Math.max(0, depthBraces));
return repaired;
}
function parseMilestoneSuggestions(text) {
const candidate = extractJsonCandidate(text);
if (!candidate)
throw new ParseError("AI returned no valid JSON. Please try again.");
let parsed;
try {
parsed = JSON.parse(candidate);
}
catch {
parsed = JSON.parse(repairJson(candidate));
}
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of milestone suggestions");
}
const suggestions = [];
for (const item of parsed) {
if (!item || typeof item !== "object")
continue;
const row = item;
if (typeof row.title !== "string" || !row.title.trim())
continue;
suggestions.push({
title: row.title.trim(),
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
});
}
if (suggestions.length === 0)
throw new ParseError("AI returned no valid milestone suggestions");
return suggestions;
}
function pickFactory(explicit) {
return explicit ?? injectedCreateAiSession;
}
async function runPrompt(createAiSession, options, prompt) {
const agent = await createAiSession(options);
await agent.session.prompt(prompt);
const lastMessage = agent.session.state.messages
.filter((m) => m.role === "assistant")
.pop();
let text = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string")
text = lastMessage.content;
else {
text = lastMessage.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
}
}
return { text, dispose: agent.session.dispose };
}
export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_SUGGESTION_COUNT, rootDir, modelProvider, modelId, createAiSession) {
const factory = pickFactory(createAiSession);
if (!factory)
throw new ServiceUnavailableError("AI service is not available");
if (!rootDir)
throw new Error("rootDir is required for AI-powered suggestion generation");
const result = await Promise.race([
(async () => {
let dispose;
try {
let response = await runPrompt(factory, {
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
dispose = response.dispose;
let suggestions;
let lastError;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseMilestoneSuggestions(response.text);
break;
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === MAX_PARSE_RETRIES)
break;
response = await runPrompt(factory, {
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
dispose = response.dispose;
}
}
if (!suggestions) {
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
}
return suggestions.slice(0, count);
}
finally {
dispose?.();
}
})(),
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
]);
return result;
}
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
const MAX_FEATURE_PROMPT_LENGTH = 2000;
export function validateFeatureSuggestionInput(input) {
if (!input || typeof input !== "object" || Array.isArray(input))
throw new ValidationError("Request body must be an object");
const { prompt, count } = input;
if (prompt !== undefined) {
if (typeof prompt !== "string")
throw new ValidationError("prompt must be a string");
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH)
throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
}
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}`);
}
}
}
function buildMilestoneContextString(context) {
const lines = [];
lines.push(`Roadmap: ${context.roadmapTitle}`);
if (context.roadmapDescription)
lines.push(`Description: ${context.roadmapDescription}`);
lines.push("", `Milestone: ${context.milestoneTitle}`);
if (context.milestoneDescription)
lines.push(`Description: ${context.milestoneDescription}`);
if (context.existingFeatureTitles.length > 0) {
lines.push("", "Existing features in this milestone:");
for (const title of context.existingFeatureTitles)
lines.push(` - ${title}`);
}
return lines.join("\n");
}
function parseFeatureSuggestions(text) {
return parseMilestoneSuggestions(text);
}
export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGESTION_COUNT, prompt, rootDir, modelProvider, modelId, createAiSession) {
const factory = pickFactory(createAiSession);
if (!factory)
throw new ServiceUnavailableError("AI service is not available");
if (!rootDir)
throw new Error("rootDir is required for AI-powered suggestion generation");
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
const userMessage = prompt?.trim()
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
: `Please suggest ${count} features for the milestone described above.`;
const result = await Promise.race([
(async () => {
const { text, dispose } = await runPrompt(factory, {
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
}, userMessage);
try {
return parseFeatureSuggestions(text).slice(0, count);
}
finally {
dispose?.();
}
})(),
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
]);
return result;
}
export class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
export class ParseError extends Error {
constructor(message) {
super(message);
this.name = "ParseError";
}
}
export class ServiceUnavailableError extends Error {
constructor(message) {
super(message);
this.name = "ServiceUnavailableError";
}
}
export function __resetSuggestionState() {
injectedCreateAiSession = undefined;
}
export function __setCreateFnAgent(mock) {
if (!mock) {
injectedCreateAiSession = undefined;
return;
}
injectedCreateAiSession = async (options) => mock(options);
}
export function __setCreateAiSessionFactory(mock) {
injectedCreateAiSession = mock;
}
//# sourceMappingURL=roadmap-suggestions.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,381 @@
import type { CreateAiSessionFactory } from "@fusion/core";
type LegacyCreateFnAgent = (options: {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
defaultProvider?: string;
defaultModelId?: string;
onThinking?: () => void;
onText?: () => void;
}) => Promise<{
session: {
prompt(text: string): Promise<void>;
state: { messages: Array<{ role: string; content?: string | Array<{ type: string; text: string }> }> };
dispose?: () => void;
};
}>;
let injectedCreateAiSession: CreateAiSessionFactory | undefined;
export interface GenerateMilestoneSuggestionsInput {
goalPrompt: string;
count?: number;
}
export interface MilestoneSuggestion {
title: string;
description?: string;
}
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
2. **Use clear titles**: Milestone titles should be concise and descriptive
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.`;
const MAX_GOAL_PROMPT_LENGTH = 4000;
export const SUGGESTION_TIMEOUT_MS = 120_000;
const DEFAULT_SUGGESTION_COUNT = 5;
const MAX_SUGGESTION_COUNT = 10;
const MIN_SUGGESTION_COUNT = 1;
const MAX_PARSE_RETRIES = 1;
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>;
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`);
}
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}`);
}
}
}
function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null;
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
const source = codeBlockMatch?.[1]?.trim() || text.trim();
const startIndex = source.indexOf("[");
if (startIndex < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = startIndex; index < source.length; index++) {
const char = source[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "[") depth++;
if (char === "]") {
depth--;
if (depth === 0) {
return source.slice(startIndex, index + 1).trim();
}
}
}
return source.slice(startIndex).trim();
}
function repairJson(text: string): string {
let repaired = text.replace(/,\s*([}\]])/g, "$1");
let depthBraces = 0;
let depthBrackets = 0;
for (const ch of repaired) {
if (ch === "{") depthBraces++;
if (ch === "}") depthBraces--;
if (ch === "[") depthBrackets++;
if (ch === "]") depthBrackets--;
}
repaired += "]".repeat(Math.max(0, depthBrackets));
repaired += "}".repeat(Math.max(0, depthBraces));
return repaired;
}
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 {
parsed = JSON.parse(repairJson(candidate));
}
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of milestone suggestions");
}
const suggestions: MilestoneSuggestion[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
if (typeof row.title !== "string" || !row.title.trim()) continue;
suggestions.push({
title: row.title.trim(),
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
});
}
if (suggestions.length === 0) throw new ParseError("AI returned no valid milestone suggestions");
return suggestions;
}
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
function pickFactory(explicit?: CreateAiSessionFactory): CreateAiSessionFactory | undefined {
return explicit ?? injectedCreateAiSession;
}
async function runPrompt(
createAiSession: CreateAiSessionFactory,
options: Parameters<CreateAiSessionFactory>[0],
prompt: string,
): Promise<{ text: string; dispose?: () => void }> {
const agent = await createAiSession(options);
await agent.session.prompt(prompt);
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m) => m.role === "assistant")
.pop();
let text = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") text = lastMessage.content;
else {
text = lastMessage.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
}
return { text, dispose: (agent.session as { dispose?: () => void }).dispose };
}
export async function generateMilestoneSuggestions(
goalPrompt: string,
count: number = DEFAULT_SUGGESTION_COUNT,
rootDir?: string,
modelProvider?: string,
modelId?: string,
createAiSession?: CreateAiSessionFactory,
): Promise<MilestoneSuggestion[]> {
const factory = pickFactory(createAiSession);
if (!factory) throw new ServiceUnavailableError("AI service is not available");
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
const result = await Promise.race([
(async () => {
let dispose: (() => void) | undefined;
try {
let response = await runPrompt(factory, {
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
dispose = response.dispose;
let suggestions: MilestoneSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseMilestoneSuggestions(response.text);
break;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === MAX_PARSE_RETRIES) break;
response = await runPrompt(factory, {
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
dispose = response.dispose;
}
}
if (!suggestions) {
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
}
return suggestions.slice(0, count);
} finally {
dispose?.();
}
})(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
),
]);
return result;
}
export interface GenerateFeatureSuggestionsInput {
prompt?: string;
count?: number;
}
export interface FeatureSuggestion {
title: string;
description?: string;
}
export interface FeatureSuggestionContext {
roadmapTitle: string;
roadmapDescription?: string;
milestoneTitle: string;
milestoneDescription?: string;
existingFeatureTitles: string[];
}
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
const MAX_FEATURE_PROMPT_LENGTH = 2000;
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
if (!input || typeof input !== "object" || Array.isArray(input)) throw new ValidationError("Request body must be an object");
const { prompt, count } = input as Record<string, unknown>;
if (prompt !== undefined) {
if (typeof prompt !== "string") throw new ValidationError("prompt must be a string");
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
}
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}`);
}
}
}
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
const lines: string[] = [];
lines.push(`Roadmap: ${context.roadmapTitle}`);
if (context.roadmapDescription) lines.push(`Description: ${context.roadmapDescription}`);
lines.push("", `Milestone: ${context.milestoneTitle}`);
if (context.milestoneDescription) lines.push(`Description: ${context.milestoneDescription}`);
if (context.existingFeatureTitles.length > 0) {
lines.push("", "Existing features in this milestone:");
for (const title of context.existingFeatureTitles) lines.push(` - ${title}`);
}
return lines.join("\n");
}
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
return parseMilestoneSuggestions(text);
}
export async function generateFeatureSuggestions(
context: FeatureSuggestionContext,
count: number = DEFAULT_SUGGESTION_COUNT,
prompt?: string,
rootDir?: string,
modelProvider?: string,
modelId?: string,
createAiSession?: CreateAiSessionFactory,
): Promise<FeatureSuggestion[]> {
const factory = pickFactory(createAiSession);
if (!factory) throw new ServiceUnavailableError("AI service is not available");
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
const userMessage = prompt?.trim()
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
: `Please suggest ${count} features for the milestone described above.`;
const result = await Promise.race([
(async () => {
const { text, dispose } = await runPrompt(factory, {
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
}, userMessage);
try {
return parseFeatureSuggestions(text).slice(0, count);
} finally {
dispose?.();
}
})(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
),
]);
return result;
}
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";
}
}
export function __resetSuggestionState(): void {
injectedCreateAiSession = undefined;
}
export function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void {
if (!mock) {
injectedCreateAiSession = undefined;
return;
}
injectedCreateAiSession = async (options) => mock(options);
}
export function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void {
injectedCreateAiSession = mock;
}