This is page 1 of 2. Use http://codebase.md/jhawkins11/task-manager-mcp?page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── frontend │ ├── .gitignore │ ├── .npmrc │ ├── components.json │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.cjs │ ├── README.md │ ├── src │ │ ├── app.d.ts │ │ ├── app.html │ │ ├── app.pcss │ │ ├── lib │ │ │ ├── components │ │ │ │ ├── ImportTasksModal.svelte │ │ │ │ ├── QuestionModal.svelte │ │ │ │ ├── TaskFormModal.svelte │ │ │ │ └── ui │ │ │ │ ├── badge │ │ │ │ │ ├── badge.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── button │ │ │ │ │ ├── button.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── card │ │ │ │ │ ├── card-content.svelte │ │ │ │ │ ├── card-description.svelte │ │ │ │ │ ├── card-footer.svelte │ │ │ │ │ ├── card-header.svelte │ │ │ │ │ ├── card-title.svelte │ │ │ │ │ ├── card.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── checkbox │ │ │ │ │ ├── checkbox.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog │ │ │ │ │ ├── dialog-content.svelte │ │ │ │ │ ├── dialog-description.svelte │ │ │ │ │ ├── dialog-footer.svelte │ │ │ │ │ ├── dialog-header.svelte │ │ │ │ │ ├── dialog-overlay.svelte │ │ │ │ │ ├── dialog-portal.svelte │ │ │ │ │ ├── dialog-title.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── input │ │ │ │ │ ├── index.ts │ │ │ │ │ └── input.svelte │ │ │ │ ├── label │ │ │ │ │ ├── index.ts │ │ │ │ │ └── label.svelte │ │ │ │ ├── progress │ │ │ │ │ ├── index.ts │ │ │ │ │ └── progress.svelte │ │ │ │ ├── select │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select-content.svelte │ │ │ │ │ ├── select-group-heading.svelte │ │ │ │ │ ├── select-item.svelte │ │ │ │ │ ├── select-scroll-down-button.svelte │ │ │ │ │ ├── select-scroll-up-button.svelte │ │ │ │ │ ├── select-separator.svelte │ │ │ │ │ └── select-trigger.svelte │ │ │ │ ├── separator │ │ │ │ │ ├── index.ts │ │ │ │ │ └── separator.svelte │ │ │ │ └── textarea │ │ │ │ ├── index.ts │ │ │ │ └── textarea.svelte │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── routes │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ └── +page.svelte │ ├── static │ │ └── favicon.png │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── img │ └── ui.png ├── jest.config.js ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── config │ │ ├── index.ts │ │ ├── migrations.sql │ │ └── schema.sql │ ├── index.ts │ ├── lib │ │ ├── dbUtils.ts │ │ ├── llmUtils.ts │ │ ├── logger.ts │ │ ├── repomixUtils.ts │ │ ├── utils.ts │ │ └── winstonLogger.ts │ ├── models │ │ └── types.ts │ ├── server.ts │ ├── services │ │ ├── aiService.ts │ │ ├── databaseService.ts │ │ ├── planningStateService.ts │ │ └── webSocketService.ts │ └── tools │ ├── adjustPlan.ts │ ├── markTaskComplete.ts │ ├── planFeature.ts │ └── reviewChanges.ts ├── tests │ ├── json-parser.test.ts │ ├── llmUtils.unit.test.ts │ ├── reviewChanges.integration.test.ts │ └── setupEnv.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- ``` engine-strict=true ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` node_modules dist .env repomix-output.txt .mcp logs frontend/node_modules/ frontend/build/ frontend/.svelte-kit/ .DS_Store tsconfig.tsbuildinfo *.db ``` -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- ``` node_modules # Output .output .vercel .netlify .wrangler /.svelte-kit /build # OS .DS_Store Thumbs.db # Env .env .env.* !.env.example !.env.test # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* ``` -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- ```markdown # sv Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). ## Creating a project If you're seeing this, you've probably already done this step. Congrats! ```bash # create a new project in the current directory npx sv create # create a new project in my-app npx sv create my-app ``` ## Developing Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: ```bash npm run dev # or start the server and open the app in a new browser tab npm run dev -- --open ``` ## Building To create a production version of your app: ```bash npm run build ``` You can preview the production build with `npm run preview`. > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Task Manager MCP Server This is an MCP server built to integrate with AI code editors like Cursor. The main goal here is to maximize Cursor's agentic capabilities and Gemini 2.5's excellent architecting capabilities while working around Cursor's extremely limited context window. This was inspired largely by Roo Code's Boomerang mode, but I found it extremely expensive as the only model that works with it's apply bot is Claude 3.7 Sonnet. With this server, you get the best of both worlds: unlimited context window and unlimited usage for the price of Cursor's $20/month subscription. In addition, it includes a Svelte UI that allows you to view the task list and progress, manually adjust the plan, and review the changes. ## Svelte UI  ## Core Features - **Complex Feature Planning:** Give it a feature description, and it uses an LLM with project context via `repomix` to generate a step-by-step coding plan for the AI agent to follow with recursive task breakdown for high-effort tasks. - **Integrated UI Server:** Runs an Express server to serve static frontend files and provides basic API endpoints for the UI. Opens the UI in the default browser after planning is complete or when clarification is needed and displays the task list and progress. - **Unlimited Context Window:** Uses Gemini 2.5's 1 million token context window with `repomix`'s truncation when needed. - **Conversation History:** Keeps track of the conversation history for each feature in a separate JSON file within `.mcp/features/` for each feature, allowing Gemini 2.5 to have context when the user asks for adjustments to the plan. - **Clarification Workflow:** Handles cases where the LLM needs more info, pausing planning and interacting with a connected UI via WebSockets. - **Task CRUD:** Allows for creating, reading, updating, and deleting tasks via the UI. - **Code Review:** Analyzes `git diff HEAD` output using an LLM and creates new tasks if needed. - **Automatic Review (Optional):** If configured (`AUTO_REVIEW_ON_COMPLETION=true`), automatically runs the code review process after the last original task for a feature is completed. - **Plan Adjustment:** Allows for adjusting the plan after it's created via the `adjust_plan` tool. ## Setup ### Prerequisites: - Node.js - npm - Git ### Installation & Build: 1. **Clone:** ```bash git clone https://github.com/jhawkins11/task-manager-mcp.git cd task-manager-mcp ``` 2. **Install Backend Deps:** ```bash npm install ``` 3. **Configure:** You'll configure API keys later directly in Cursor's MCP settings (see Usage section), but you might still want a local `.env` file for manual testing (see Configuration section). 4. **Build:** This command builds the backend and frontend servers and copies the Svelte UI to the `dist/frontend-ui/` directory. ```bash npm run build ``` ### Running the Server (Manually): For local testing _without_ Cursor, you can run the server using Node directly or the npm script. This method **will** use the `.env` file for configuration. **Using Node directly (use absolute path):** ```bash node /full/path/to/your/task-manager-mcp/dist/server.js ``` **Using npm start:** ```bash npm start ``` This starts the MCP server (stdio), WebSocket server, and the HTTP server for the UI. The UI should be accessible at http://localhost:<UI_PORT> (default 3000). ### Configuration (.env file for Manual Running): If running manually (not via Cursor), create a .env file in the project root for API keys and ports. Note: When running via Cursor, these should be set in Cursor's mcp.json configuration instead (see Usage section). ```bash # .env - USED ONLY FOR MANUAL `npm start` or `node dist/server.js` # === OpenRouter (Recommended) === # Get key: https://openrouter.ai/keys OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENROUTER_MODEL=google/gemini-2.5-flash-preview:thinking FALLBACK_OPENROUTER_MODEL=google/gemini-2.5-flash-preview:thinking # === Google AI API (Alternative) === # GEMINI_API_KEY=your_google_ai_api_key # GEMINI_MODEL=gemini-1.5-flash-latest # FALLBACK_GEMINI_MODEL=gemini-1.5-flash-latest # === UI / WebSocket Ports === # Default is 4999 if not set. UI_PORT=4999 WS_PORT=4999 # === Auto Review === # If true, the agent will automatically run the 'review_changes' tool after the last task is completed. # Defaults to false. AUTO_REVIEW_ON_COMPLETION=false ``` ## Avoiding Costs **IMPORTANT:** It's highly recommended to integrate your own Google AI API key to OpenRouter to avoid the free models' rate limits. See below. **Using OpenRouter's Free Tiers:** You can significantly minimize or eliminate costs by using models marked as "Free" on OpenRouter (like google/gemini-2.5-flash-preview:thinking at the time of writing) while connecting your own Google AI API key. Check out this reddit thread for more info: https://www.reddit.com/r/ChatGPTCoding/comments/1jrp1tj/a_simple_guide_to_setting_up_gemini_25_pro_free/ **Fallback Costs:** The server automatically retries with a fallback model if the primary hits a rate limit. The default fallback (FALLBACK_OPENROUTER_MODEL) is often a faster/cheaper model like Gemini Flash, which might still have associated costs depending on OpenRouter's current pricing/tiers. Check their site and adjust the fallback model in your configuration if needed. ## Usage with Cursor (Task Manager Mode) This is the primary way this server is intended to be used. I have not yet tested it with other AI code editors yet. If you try it, please let me know how it goes, and I'll update the README. ### 1. Configure the MCP Server in Cursor: After building the server (`npm run build`), you need to tell Cursor how to run it. Find Cursor's MCP configuration file. This can be: - **Project-specific:** Create/edit a file at `.cursor/mcp.json` inside your project's root directory. - **Global:** Create/edit a file at `~/.cursor/mcp.json` in your user home directory (for use across all projects). Add the following entry to the mcpServers object within that JSON file: ```json { "mcpServers": { "task-manager-mcp": { "command": "node", "args": ["/full/path/to/your/task-manager-mcp/dist/server.js"], "env": { "OPENROUTER_API_KEY": "sk-or-v1-xxxxxxxxxxxxxxxxxxxx" // optional: my recommended model for MCP is Gemini 2.5 Pro Free which is already set by default // "OPENROUTER_MODEL": "google/gemini-2.5-flash-preview:thinking", // also optional // "FALLBACK_OPENROUTER_MODEL": "google/gemini-2.5-flash-preview:thinking", // optional: the default port for the UI is 4999 if not set // "UI_PORT": "4999", // optional: the default port for the WebSocket server is 4999 if not set // "WS_PORT": "4999" // Add GEMINI_API_KEY here instead if using Google directly // Add any other necessary env vars here } } // Add other MCP servers here if you have them } } ``` **IMPORTANT:** - Replace `/full/path/to/your/task-manager-mcp/dist/server.js` with the absolute path to the compiled server script on your machine. - Replace `sk-or-v1-xxxxxxxxxxxxxxxxxxxx` with your actual OpenRouter API key (or set GEMINI_API_KEY if using Google AI directly). - These env variables defined here will be passed to the server process when Cursor starts it, overriding any `.env` file. ### 2. Create a Custom Cursor Mode: 1. Go to Cursor Settings -> Features -> Chat -> Enable Custom modes. 2. Go back to the chat view, click the mode selector (bottom left), and click Add custom mode. 3. Give it a name (e.g., "MCP Planner", "Task Dev"), choose an icon/shortcut. 4. Enable Tools: Make sure the tools exposed by this server (`plan_feature`, `mark_task_complete`, `get_next_task`, `review_changes`, `adjust_plan`) are available and enabled for this mode. You might want to enable other tools like Codebase, Terminal, etc., depending on your workflow. 5. Recommended Instructions for Agent: Paste these rules exactly into the "Custom Instructions" text box: ``` Always use plan_feature mcp tool when getting feature request before doing anything else. ALWAYS!!!!!!!! It will return the first step of the implementation. DO NOT IMPLEMENT MORE THAN WHAT THE TASK STATES. After you're done run mark_task_complete which will give you the next task. If the user says "review" use the review_changes tool. The review_changes tool will generate new tasks for you to follow, just like plan_feature. After a review, follow the same one-at-a-time task completion workflow: complete each review-generated task, mark it complete, and call get_next_task until all are done. If clarification is required at any step, you will not receive the next task and will have to run get_next_task manually after the user answers the clarification question through the UI. IMPORTANT: Your job is to complete the tasks one at a time. DO NOT DO ANY OTHER CHANGES, ONLY WHAT THE CURRENT TASK SAYS TO DO. ``` 6. Save the custom mode. ## Expected Workflow (Using the Custom Mode): 1. Select your new custom mode in Cursor. 2. Give Cursor a feature request (e.g., "add auth using JWT"). 3. Cursor, following the instructions, will call the `plan_feature` tool. 4. The server plans, saves data, and returns a JSON response (inside the text content) to Cursor. - If successful: The response includes `status: "completed"` and the description of the first task in the `message` field. The UI (if running) is launched/updated. - If clarification needed: The response includes `status: "awaiting_clarification"`, the `featureId`, the `uiUrl`, and instructions for the agent to wait and call `get_next_task` later. The UI is launched/updated with the question. 5. Cursor implements only the task described (if provided). 6. If clarification was needed, the user answers in the UI, the server resumes planning, and updates the UI via WebSocket. The agent, following instructions, then calls `get_next_task` with the `featureId`. 7. If a task was completed, Cursor calls `mark_task_complete` (with `taskId` and `featureId`). 8. The server marks the task done and returns the next pending task in the response message. 9. Cursor repeats steps 4-8. 10. If the user asks Cursor to "review", it calls `review_changes`. ## API Endpoints (for UI) The integrated Express server provides these basic endpoints for the frontend: - `GET /api/features`: Returns a list of existing feature IDs. - `GET /api/tasks/:featureId`: Returns the list of tasks for a specific feature. - `GET /api/tasks`: Returns tasks for the most recently created/modified feature. - `GET /api/features/:featureId/pending-question`: Checks if there's an active clarification question for the feature. - `POST /api/tasks`: Creates a new task for a feature. - `PUT /api/tasks/:taskId`: Updates an existing task. - `DELETE /api/tasks/:taskId`: Deletes a task. - _(Static Files)_: Serves files from `dist/frontend-ui/` (e.g., `index.html`). ``` -------------------------------------------------------------------------------- /frontend/src/lib/index.ts: -------------------------------------------------------------------------------- ```typescript ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript // Re-export server for backwards compatibility export * from './server' ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/label/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from "./label.svelte"; export { Root, // Root as Label, }; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from "./checkbox.svelte"; export { Root, // Root as Checkbox, }; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/progress/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from "./progress.svelte"; export { Root, // Root as Progress, }; ``` -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- ``` module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ``` -------------------------------------------------------------------------------- /frontend/src/routes/+layout.server.ts: -------------------------------------------------------------------------------- ```typescript // Enforces static prerendering for the entire site export const prerender = true ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/separator/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from "./separator.svelte"; export { Root, // Root as Separator, }; ``` -------------------------------------------------------------------------------- /tests/setupEnv.ts: -------------------------------------------------------------------------------- ```typescript import dotenv from 'dotenv' import path from 'path' dotenv.config({ path: path.resolve(process.cwd(), '.env') }) ``` -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- ```typescript import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()] }); ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-portal.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Dialog as DialogPrimitive } from "bits-ui"; type $$Props = DialogPrimitive.PortalProps; </script> <DialogPrimitive.Portal {...$$restProps}> <slot /> </DialogPrimitive.Portal> ``` -------------------------------------------------------------------------------- /frontend/src/app.d.ts: -------------------------------------------------------------------------------- ```typescript // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} } } export {}; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-content.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLDivElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <div class={cn("p-6", className)} {...$$restProps}> <slot /> </div> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-footer.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLDivElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <div class={cn("flex items-center p-6 pt-0", className)} {...$$restProps}> <slot /> </div> ``` -------------------------------------------------------------------------------- /frontend/src/app.html: -------------------------------------------------------------------------------- ```html <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> %sveltekit.head% </head> <body data-sveltekit-preload-data="hover"> <div style="display: contents">%sveltekit.body%</div> </body> </html> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-description.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLParagraphElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <p class={cn("text-muted-foreground text-sm", className)} {...$$restProps}> <slot /> </p> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-header.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLDivElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <div class={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...$$restProps}> <slot /> </div> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-group-heading.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Select as SelectPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, ...restProps }: SelectPrimitive.GroupHeadingProps = $props(); </script> <SelectPrimitive.GroupHeading bind:ref class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...restProps} /> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-header.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLDivElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...$$restProps}> <slot /> </div> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLDivElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <div class={cn("bg-card text-card-foreground rounded-lg border shadow-sm", className)} {...$$restProps} > <slot /> </div> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-footer.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLDivElement>; let className: $$Props["class"] = undefined; export { className as class }; </script> <div class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...$$restProps} > <slot /> </div> ``` -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://shadcn-svelte.com/schema.json", "style": "default", "tailwind": { "config": "tailwind.config.js", "css": "src/app.pcss", "baseColor": "slate" }, "aliases": { "components": "$lib/components", "utils": "$lib/utils", "ui": "$lib/components/ui", "hooks": "$lib/hooks" }, "typescript": true, "registry": "https://next.shadcn-svelte.com/registry" } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-description.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Dialog as DialogPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; type $$Props = DialogPrimitive.DescriptionProps; let className: $$Props["class"] = undefined; export { className as class }; </script> <DialogPrimitive.Description class={cn("text-muted-foreground text-sm", className)} {...$$restProps} > <slot /> </DialogPrimitive.Description> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-title.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Dialog as DialogPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; type $$Props = DialogPrimitive.TitleProps; let className: $$Props["class"] = undefined; export { className as class }; </script> <DialogPrimitive.Title class={cn("text-lg font-semibold leading-none tracking-tight", className)} {...$$restProps} > <slot /> </DialogPrimitive.Title> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-separator.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { Separator as SeparatorPrimitive } from "bits-ui"; import { Separator } from "$lib/components/ui/separator/index.js"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, ...restProps }: SeparatorPrimitive.RootProps = $props(); </script> <Separator bind:ref class={cn("bg-muted -mx-1 my-1 h-px", className)} {...restProps} /> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/badge/badge.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { type Variant, badgeVariants } from "./index.js"; import { cn } from "$lib/utils.js"; let className: string | undefined | null = undefined; export let href: string | undefined = undefined; export let variant: Variant = "default"; export { className as class }; </script> <svelte:element this={href ? "a" : "span"} {href} class={cn(badgeVariants({ variant, className }))} {...$$restProps} > <slot /> </svelte:element> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/separator/separator.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Separator as SeparatorPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, orientation = "horizontal", ...restProps }: SeparatorPrimitive.RootProps = $props(); </script> <SeparatorPrimitive.Root bind:ref class={cn( "bg-border shrink-0", orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]", className )} {orientation} {...restProps} /> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/label/label.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Label as LabelPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; type $$Props = LabelPrimitive.Props; type $$Events = LabelPrimitive.Events; let className: $$Props["class"] = undefined; export { className as class }; </script> <LabelPrimitive.Root class={cn( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className )} {...$$restProps} on:mousedown > <slot /> </LabelPrimitive.Root> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-title.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLAttributes } from "svelte/elements"; import type { HeadingLevel } from "./index.js"; import { cn } from "$lib/utils.js"; type $$Props = HTMLAttributes<HTMLHeadingElement> & { tag?: HeadingLevel; }; let className: $$Props["class"] = undefined; export let tag: $$Props["tag"] = "h3"; export { className as class }; </script> <svelte:element this={tag} class={cn("text-lg font-semibold leading-none tracking-tight", className)} {...$$restProps} > <slot /> </svelte:element> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from "./card.svelte"; import Content from "./card-content.svelte"; import Description from "./card-description.svelte"; import Footer from "./card-footer.svelte"; import Header from "./card-header.svelte"; import Title from "./card-title.svelte"; export { Root, Content, Description, Footer, Header, Title, // Root as Card, Content as CardContent, Description as CardDescription, Footer as CardFooter, Header as CardHeader, Title as CardTitle, }; export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; ``` -------------------------------------------------------------------------------- /src/config/migrations.sql: -------------------------------------------------------------------------------- ```sql -- Add from_review column to tasks table if it doesn't exist ALTER TABLE tasks ADD COLUMN from_review INTEGER DEFAULT 0; -- Add task_id column to history_entries table if it doesn't exist ALTER TABLE history_entries ADD COLUMN task_id TEXT; -- Add action and details columns to history_entries table if they don't exist ALTER TABLE history_entries ADD COLUMN action TEXT; ALTER TABLE history_entries ADD COLUMN details TEXT; -- Add project_path column to features table if it doesn't exist ALTER TABLE features ADD COLUMN project_path TEXT; ``` -------------------------------------------------------------------------------- /frontend/svelte.config.js: -------------------------------------------------------------------------------- ```javascript import adapter from '@sveltejs/adapter-static' import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors preprocess: vitePreprocess(), kit: { // Using adapter-static to output a static site build adapter: adapter({ // Output to the default build folder pages: 'build', assets: 'build', precompress: false, }), }, } export default config ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-scroll-up-button.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import ChevronUp from "@lucide/svelte/icons/chevron-up"; import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, ...restProps }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props(); </script> <SelectPrimitive.ScrollUpButton bind:ref class={cn("flex cursor-default items-center justify-center py-1", className)} {...restProps} > <ChevronUp class="size-4" /> </SelectPrimitive.ScrollUpButton> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-scroll-down-button.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import ChevronDown from "@lucide/svelte/icons/chevron-down"; import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, ...restProps }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props(); </script> <SelectPrimitive.ScrollDownButton bind:ref class={cn("flex cursor-default items-center justify-center py-1", className)} {...restProps} > <ChevronDown class="size-4" /> </SelectPrimitive.ScrollDownButton> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-overlay.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Dialog as DialogPrimitive } from "bits-ui"; import { fade } from "svelte/transition"; import { cn } from "$lib/utils.js"; type $$Props = DialogPrimitive.OverlayProps; let className: $$Props["class"] = undefined; export let transition: $$Props["transition"] = fade; export let transitionConfig: $$Props["transitionConfig"] = { duration: 150, }; export { className as class }; </script> <DialogPrimitive.Overlay {transition} {transitionConfig} class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm", className)} {...$$restProps} /> ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript module.exports = { preset: 'ts-jest', testEnvironment: 'node', moduleNameMapper: { '^../services/aiService$': '<rootDir>/src/services/aiService', '^../models/types$': '<rootDir>/src/models/types', '^../lib/logger$': '<rootDir>/src/lib/logger', '^../lib/llmUtils$': '<rootDir>/src/lib/llmUtils', '^../lib/repomixUtils$': '<rootDir>/src/lib/repomixUtils', '^../lib/dbUtils$': '<rootDir>/src/lib/dbUtils', '^../config$': '<rootDir>/src/config', '^../services/databaseService$': '<rootDir>/src/services/databaseService', }, setupFiles: ['<rootDir>/tests/setupEnv.ts'], } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/progress/progress.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Progress as ProgressPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, max = 100, value, ...restProps }: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props(); </script> <ProgressPrimitive.Root bind:ref class={cn("bg-secondary relative h-4 w-full overflow-hidden rounded-full", className)} {value} {max} {...restProps} > <div class="bg-primary h-full w-full flex-1 transition-all" style={`transform: translateX(-${100 - (100 * (value ?? 0)) / (max ?? 1)}%)`} ></div> </ProgressPrimitive.Root> ``` -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- ```json { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "moduleResolution": "bundler" } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes // from the referenced tsconfig.json - TypeScript does not merge them in } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/button/button.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Button as ButtonPrimitive } from "bits-ui"; import { type Events, type Props, buttonVariants } from "./index.js"; import { cn } from "$lib/utils.js"; type $$Props = Props; type $$Events = Events; let className: $$Props["class"] = undefined; export let variant: $$Props["variant"] = "default"; export let size: $$Props["size"] = "default"; export let builders: $$Props["builders"] = []; export { className as class }; </script> <ButtonPrimitive.Root {builders} class={cn(buttonVariants({ variant, size, className }))} type="button" {...$$restProps} on:click on:keydown > <slot /> </ButtonPrimitive.Root> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/textarea/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from './textarea.svelte' type FormTextareaEvent<T extends Event = Event> = T & { currentTarget: EventTarget & HTMLTextAreaElement } type TextareaEvents = { blur: FormTextareaEvent<FocusEvent> change: FormTextareaEvent<Event> click: FormTextareaEvent<MouseEvent> focus: FormTextareaEvent<FocusEvent> keydown: FormTextareaEvent<KeyboardEvent> keypress: FormTextareaEvent<KeyboardEvent> keyup: FormTextareaEvent<KeyboardEvent> mouseover: FormTextareaEvent<MouseEvent> mouseenter: FormTextareaEvent<MouseEvent> mouseleave: FormTextareaEvent<MouseEvent> paste: FormTextareaEvent<ClipboardEvent> input: FormTextareaEvent<InputEvent> } export { Root, // Root as Textarea, type TextareaEvents, type FormTextareaEvent, } export default Root ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/badge/index.ts: -------------------------------------------------------------------------------- ```typescript import { type VariantProps, tv } from "tailwind-variants"; export { default as Badge } from "./badge.svelte"; export const badgeVariants = tv({ base: "focus:ring-ring inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, }); export type Variant = VariantProps<typeof badgeVariants>["variant"]; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/input/index.ts: -------------------------------------------------------------------------------- ```typescript import Root from "./input.svelte"; export type FormInputEvent<T extends Event = Event> = T & { currentTarget: EventTarget & HTMLInputElement; }; export type InputEvents = { blur: FormInputEvent<FocusEvent>; change: FormInputEvent<Event>; click: FormInputEvent<MouseEvent>; focus: FormInputEvent<FocusEvent>; focusin: FormInputEvent<FocusEvent>; focusout: FormInputEvent<FocusEvent>; keydown: FormInputEvent<KeyboardEvent>; keypress: FormInputEvent<KeyboardEvent>; keyup: FormInputEvent<KeyboardEvent>; mouseover: FormInputEvent<MouseEvent>; mouseenter: FormInputEvent<MouseEvent>; mouseleave: FormInputEvent<MouseEvent>; mousemove: FormInputEvent<MouseEvent>; paste: FormInputEvent<ClipboardEvent>; input: FormInputEvent<InputEvent>; wheel: FormInputEvent<WheelEvent>; }; export { Root, // Root as Input, }; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-trigger.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Select as SelectPrimitive, type WithoutChild } from "bits-ui"; import ChevronDown from "@lucide/svelte/icons/chevron-down"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, children, ...restProps }: WithoutChild<SelectPrimitive.TriggerProps> = $props(); </script> <SelectPrimitive.Trigger bind:ref class={cn( "border-input bg-background ring-offset-background data-[placeholder]:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...restProps} > {@render children?.()} <ChevronDown class="size-4 opacity-50" /> </SelectPrimitive.Trigger> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- ```typescript import { Dialog as DialogPrimitive } from "bits-ui"; import Title from "./dialog-title.svelte"; import Portal from "./dialog-portal.svelte"; import Footer from "./dialog-footer.svelte"; import Header from "./dialog-header.svelte"; import Overlay from "./dialog-overlay.svelte"; import Content from "./dialog-content.svelte"; import Description from "./dialog-description.svelte"; const Root = DialogPrimitive.Root; const Trigger = DialogPrimitive.Trigger; const Close = DialogPrimitive.Close; export { Root, Title, Portal, Footer, Header, Trigger, Overlay, Content, Description, Close, // Root as Dialog, Title as DialogTitle, Portal as DialogPortal, Footer as DialogFooter, Header as DialogHeader, Trigger as DialogTrigger, Overlay as DialogOverlay, Content as DialogContent, Description as DialogDescription, Close as DialogClose, }; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/index.ts: -------------------------------------------------------------------------------- ```typescript import { Select as SelectPrimitive } from "bits-ui"; import GroupHeading from "./select-group-heading.svelte"; import Item from "./select-item.svelte"; import Content from "./select-content.svelte"; import Trigger from "./select-trigger.svelte"; import Separator from "./select-separator.svelte"; import ScrollDownButton from "./select-scroll-down-button.svelte"; import ScrollUpButton from "./select-scroll-up-button.svelte"; const Root = SelectPrimitive.Root; const Group = SelectPrimitive.Group; export { Root, Group, GroupHeading, Item, Content, Trigger, Separator, ScrollDownButton, ScrollUpButton, // Root as Select, Group as SelectGroup, GroupHeading as SelectGroupHeading, Item as SelectItem, Content as SelectContent, Trigger as SelectTrigger, Separator as SelectSeparator, ScrollDownButton as SelectScrollDownButton, ScrollUpButton as SelectScrollUpButton, }; ``` -------------------------------------------------------------------------------- /frontend/src/routes/+layout.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import '../app.pcss'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; // Removed prerender export - moved to +layout.server.ts onMount(() => { // Run only in the browser if (browser) { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const updateTheme = (event: MediaQueryListEvent | MediaQueryList) => { if (event.matches) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }; // Initial check updateTheme(mediaQuery); // Listen for changes mediaQuery.addEventListener('change', updateTheme); // Cleanup listener on component destroy return () => { mediaQuery.removeEventListener('change', updateTheme); }; } }); </script> <div class="min-h-screen bg-background text-foreground"> <main class="min-h-screen"> <slot /> </main> </div> ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2020", // Target modern Node.js versions "module": "CommonJS", // Use CommonJS modules "outDir": "./dist", // Output directory for compiled JavaScript "rootDir": "./src", // Source directory for TypeScript files "strict": true, // Enable strict type checking "esModuleInterop": true, // Allows default imports from CommonJS modules "skipLibCheck": true, // Skip type checking of declaration files "forceConsistentCasingInFileNames": true, // Ensure consistent file casing "moduleResolution": "node", // Use Node.js module resolution "resolveJsonModule": true, // Allow importing JSON files "sourceMap": true, // Generate source maps for debugging "incremental": true // Enable incremental compilation }, "include": ["src/**/*"], // Include all files in the src directory "exclude": ["node_modules", "**/*.spec.ts"] // Exclude node_modules and test files } ``` -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- ```json { "name": "frontend", "private": true, "version": "0.0.1", "type": "module", "scripts": { "dev": "vite dev", "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { "@lucide/svelte": "^0.488.0", "@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "autoprefixer": "^10.4.21", "bits-ui": "^0.22.0", "clsx": "^2.1.1", "lucide-svelte": "^0.488.0", "postcss": "^8.5.3", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwind-merge": "^3.2.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^3.4.17", "typescript": "^5.0.0", "vite": "^6.2.5" }, "dependencies": { "shadcn-svelte": "^1.0.0-next.9" } } ``` -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- ```typescript import { logToFile } from './logger' /** * Dynamically imports an ES Module from a CommonJS module. * Handles default exports correctly. * @param modulePath The path or name of the module to import. * @returns The default export of the module. * @throws If the import fails. */ export async function dynamicImportDefault<T = any>( modulePath: string ): Promise<T> { try { // Perform the dynamic import const module = await import(modulePath) // Check for and return the default export if (module.default) { return module.default as T } // If no default export, return the module namespace object itself // (less likely needed for 'open', but good fallback) return module as T } catch (error: any) { await logToFile( `[Utils] Failed to dynamically import '${modulePath}': ${error.message}` ) console.error(`[Utils] Dynamic import error for '${modulePath}':`, error) // Re-throw the error so the calling function knows it failed throw error } } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-item.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import Check from "@lucide/svelte/icons/check"; import { Select as SelectPrimitive, type WithoutChild } from "bits-ui"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, value, label, children: childrenProp, ...restProps }: WithoutChild<SelectPrimitive.ItemProps> = $props(); </script> <SelectPrimitive.Item bind:ref {value} class={cn( "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className )} {...restProps} > {#snippet children({ selected, highlighted })} <span class="absolute left-2 flex size-3.5 items-center justify-center"> {#if selected} <Check class="size-4" /> {/if} </span> {#if childrenProp} {@render childrenProp({ selected, highlighted })} {:else} {label || value} {/if} {/snippet} </SelectPrimitive.Item> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/textarea/textarea.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLTextareaAttributes } from "svelte/elements"; import type { TextareaEvents } from "./index.js"; import { cn } from "$lib/utils.js"; type $$Props = HTMLTextareaAttributes; type $$Events = TextareaEvents; let className: $$Props["class"] = undefined; export let value: $$Props["value"] = undefined; export { className as class }; // Workaround for https://github.com/sveltejs/svelte/issues/9305 // Fixed in Svelte 5, but not backported to 4.x. export let readonly: $$Props["readonly"] = undefined; </script> <textarea class={cn( "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className )} bind:value {readonly} on:blur on:change on:click on:focus on:keydown on:keypress on:keyup on:mouseover on:mouseenter on:mouseleave on:paste on:input {...$$restProps} ></textarea> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/input/input.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import type { HTMLInputAttributes } from "svelte/elements"; import type { InputEvents } from "./index.js"; import { cn } from "$lib/utils.js"; type $$Props = HTMLInputAttributes; type $$Events = InputEvents; let className: $$Props["class"] = undefined; export let value: $$Props["value"] = undefined; export { className as class }; // Workaround for https://github.com/sveltejs/svelte/issues/9305 // Fixed in Svelte 5, but not backported to 4.x. export let readonly: $$Props["readonly"] = undefined; </script> <input class={cn( "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className )} bind:value {readonly} on:blur on:change on:click on:focus on:focusin on:focusout on:keydown on:keypress on:keyup on:mouseover on:mouseenter on:mouseleave on:mousemove on:paste on:input on:wheel|passive {...$$restProps} /> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-content.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Dialog as DialogPrimitive } from "bits-ui"; import X from "lucide-svelte/icons/x"; import * as Dialog from "./index.js"; import { cn, flyAndScale } from "$lib/utils.js"; type $$Props = DialogPrimitive.ContentProps; let className: $$Props["class"] = undefined; export let transition: $$Props["transition"] = flyAndScale; export let transitionConfig: $$Props["transitionConfig"] = { duration: 200, }; export { className as class }; </script> <Dialog.Portal> <Dialog.Overlay /> <DialogPrimitive.Content {transition} {transitionConfig} class={cn( "bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full", className )} {...$$restProps} > <slot /> <DialogPrimitive.Close class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none" > <X class="h-4 w-4" /> <span class="sr-only">Close</span> </DialogPrimitive.Close> </DialogPrimitive.Content> </Dialog.Portal> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/checkbox/checkbox.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import Check from "@lucide/svelte/icons/check"; import Minus from "@lucide/svelte/icons/minus"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), checked = $bindable(false), indeterminate = $bindable(false), class: className, ...restProps }: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props(); </script> <CheckboxPrimitive.Root bind:ref class={cn( "border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer box-content size-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50", className )} bind:checked bind:indeterminate {...restProps} > {#snippet children({ checked, indeterminate })} <div class="flex size-4 items-center justify-center text-current"> {#if indeterminate} <Minus class="size-3.5" /> {:else} <Check class={cn("size-3.5", !checked && "text-transparent")} /> {/if} </div> {/snippet} </CheckboxPrimitive.Root> ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "task-manager-mcp", "version": "1.1.0", "main": "dist/server.js", "scripts": { "build": "npm run build:frontend && npm run build:server", "build:server": "tsc && mkdir -p dist/config && cp src/config/*.sql dist/config/", "build:frontend": "cd frontend && npm run build && cd .. && mkdir -p dist/frontend-ui && cp -r frontend/build/* dist/frontend-ui/", "start": "node dist/server.js", "dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "description": "", "dependencies": { "@google/generative-ai": "^0.24.0", "@modelcontextprotocol/sdk": "^1.9.0", "@openrouter/ai-sdk-provider": "^0.4.5", "@types/express": "^4.17.21", "dotenv": "^16.5.0", "express": "^4.21.2", "open": "^8.4.2", "openai": "^4.94.0", "sqlite3": "^5.1.7", "svelte": "^5.27.1", "tiktoken": "^1.0.20", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "ws": "^8.16.0", "zod": "^3.24.2" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/kit": "^2.20.7", "@types/jest": "^29.5.14", "@types/node": "^22.14.1", "@types/ws": "^8.5.10", "jest": "^29.7.0", "nodemon": "^3.1.9", "ts-jest": "^29.3.2", "ts-node": "^10.9.2", "typescript": "^5.8.3" } } ``` -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- ```typescript import logger from './winstonLogger' import { LOG_LEVEL, LogLevel } from '../config' // Import LOG_LEVEL and LogLevel type // Define log level hierarchy (lower number = higher priority) const levelHierarchy: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3, } const configuredLevel = levelHierarchy[LOG_LEVEL] || levelHierarchy.info // Default to INFO if invalid /** * Logs a message to the debug log file if the provided level meets the configured threshold. * @param message The message to log * @param level The level of the message (default: 'info') */ export async function logToFile( message: string, level: LogLevel = 'info' ): Promise<void> { try { const messageLevel = levelHierarchy[level] || levelHierarchy.info // Only log if the message level is less than or equal to the configured level if (messageLevel <= configuredLevel) { switch (level) { case 'error': logger.error(message) break case 'warn': logger.warn(message) break case 'info': logger.info(message) break case 'debug': default: logger.debug(message) // Default to debug if level not specified or recognized break } } } catch (error) { // Fallback to console if logger fails console.error(`[TaskServer] Error using logger:`, error) console.error( `[TaskServer] Original log message (Level: ${level}): ${message}` ) } } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/button/index.ts: -------------------------------------------------------------------------------- ```typescript import { type VariantProps, tv } from "tailwind-variants"; import type { Button as ButtonPrimitive } from "bits-ui"; import Root from "./button.svelte"; const buttonVariants = tv({ base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border-input bg-background hover:bg-accent hover:text-accent-foreground border", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, }, defaultVariants: { variant: "default", size: "default", }, }); type Variant = VariantProps<typeof buttonVariants>["variant"]; type Size = VariantProps<typeof buttonVariants>["size"]; type Props = ButtonPrimitive.Props & { variant?: Variant; size?: Size; }; type Events = ButtonPrimitive.Events; export { Root, type Props, type Events, // Root as Button, type Props as ButtonProps, type Events as ButtonEvents, buttonVariants, }; ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-content.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { Select as SelectPrimitive, type WithoutChild } from "bits-ui"; import SelectScrollUpButton from "./select-scroll-up-button.svelte"; import SelectScrollDownButton from "./select-scroll-down-button.svelte"; import { cn } from "$lib/utils.js"; let { ref = $bindable(null), class: className, sideOffset = 4, portalProps, children, ...restProps }: WithoutChild<SelectPrimitive.ContentProps> & { portalProps?: SelectPrimitive.PortalProps; } = $props(); </script> <SelectPrimitive.Portal {...portalProps}> <SelectPrimitive.Content bind:ref {sideOffset} class={cn( "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} {...restProps} > <SelectScrollUpButton /> <SelectPrimitive.Viewport class={cn( "h-[var(--bits-select-anchor-height)] w-full min-w-[var(--bits-select-anchor-width)] p-1" )} > {@render children?.()} </SelectPrimitive.Viewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> ``` -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- ```typescript import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { cubicOut } from "svelte/easing"; import type { TransitionConfig } from "svelte/transition"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } type FlyAndScaleParams = { y?: number; x?: number; start?: number; duration?: number; }; export const flyAndScale = ( node: Element, params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } ): TransitionConfig => { const style = getComputedStyle(node); const transform = style.transform === "none" ? "" : style.transform; const scaleConversion = ( valueA: number, scaleA: [number, number], scaleB: [number, number] ) => { const [minA, maxA] = scaleA; const [minB, maxB] = scaleB; const percentage = (valueA - minA) / (maxA - minA); const valueB = percentage * (maxB - minB) + minB; return valueB; }; const styleToString = ( style: Record<string, number | string | undefined> ): string => { return Object.keys(style).reduce((str, key) => { if (style[key] === undefined) return str; return str + `${key}:${style[key]};`; }, ""); }; return { duration: params.duration ?? 200, delay: 0, css: (t) => { const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); return styleToString({ transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, opacity: t }); }, easing: cubicOut }; }; ``` -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- ```javascript import { fontFamily } from 'tailwindcss/defaultTheme' /** @type {import('tailwindcss').Config} */ const config = { darkMode: ['class'], content: ['./src/**/*.{html,js,svelte,ts}'], safelist: ['dark'], theme: { container: { center: true, padding: '2rem', screens: { '2xl': '1400px', }, }, extend: { colors: { border: 'hsl(var(--border) / <alpha-value>)', input: 'hsl(var(--input) / <alpha-value>)', ring: 'hsl(var(--ring) / <alpha-value>)', background: 'hsl(var(--background) / <alpha-value>)', foreground: 'hsl(var(--foreground) / <alpha-value>)', primary: { DEFAULT: 'hsl(var(--primary) / <alpha-value>)', foreground: 'hsl(var(--primary-foreground) / <alpha-value>)', }, secondary: { DEFAULT: 'hsl(var(--secondary) / <alpha-value>)', foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)', }, destructive: { DEFAULT: 'hsl(var(--destructive) / <alpha-value>)', foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)', }, muted: { DEFAULT: 'hsl(var(--muted) / <alpha-value>)', foreground: 'hsl(var(--muted-foreground) / <alpha-value>)', }, accent: { DEFAULT: 'hsl(var(--accent) / <alpha-value>)', foreground: 'hsl(var(--accent-foreground) / <alpha-value>)', }, popover: { DEFAULT: 'hsl(var(--popover) / <alpha-value>)', foreground: 'hsl(var(--popover-foreground) / <alpha-value>)', }, card: { DEFAULT: 'hsl(var(--card) / <alpha-value>)', foreground: 'hsl(var(--card-foreground) / <alpha-value>)', }, }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', }, fontFamily: { sans: [...fontFamily.sans], }, }, }, } export default config ``` -------------------------------------------------------------------------------- /frontend/src/lib/types.ts: -------------------------------------------------------------------------------- ```typescript /** * Task interface mirroring the backend structure */ export interface Task { id: string title: string description?: string status: TaskStatus completed: boolean effort: 'low' | 'medium' | 'high' feature_id?: string parentTaskId?: string createdAt?: string updatedAt?: string children?: Task[] fromReview?: boolean } /** * Feature interface for grouping tasks */ export interface Feature { id: string title: string description: string tasks?: Task[] createdAt?: string updatedAt?: string } /** * Task status enum for type safety */ export enum TaskStatus { PENDING = 'pending', IN_PROGRESS = 'in_progress', COMPLETED = 'completed', DECOMPOSED = 'decomposed', } /** * Task effort enum for type safety */ export enum TaskEffort { LOW = 'low', MEDIUM = 'medium', HIGH = 'high', } // --- Frontend Specific Types --- // Mirror the backend WebSocket message structure export type WebSocketMessageType = | 'tasks_updated' | 'status_changed' | 'show_question' | 'question_response' | 'request_screenshot' | 'request_screenshot_ack' | 'error' | 'connection_established' | 'client_registration' | 'task_created' | 'task_updated' | 'task_deleted' export interface WebSocketMessage { type: WebSocketMessageType featureId?: string payload?: any // Keep payload generic for now, specific handlers will parse } // Interface for clarification question payload export interface ShowQuestionPayload { questionId: string question: string options?: string[] allowsText?: boolean } // Interface for user's response to a clarification question export interface QuestionResponsePayload { questionId: string response: string } // Interface for task created event export interface TaskCreatedPayload { task: Task featureId: string createdAt: string } // Interface for task updated event export interface TaskUpdatedPayload { task: Task featureId: string updatedAt: string } // Interface for task deleted event export interface TaskDeletedPayload { taskId: string featureId: string deletedAt: string } ``` -------------------------------------------------------------------------------- /src/lib/winstonLogger.ts: -------------------------------------------------------------------------------- ```typescript import path from 'path' import winston from 'winston' import 'winston-daily-rotate-file' const logDir = path.join(__dirname, '../../logs') // Define log formats const formats = { console: winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.printf( (info) => `${info.timestamp} ${info.level}: ${info.message}` ) ), file: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.json() ), } // Configure file transport with rotation const fileRotateTransport = new winston.transports.DailyRotateFile({ dirname: logDir, filename: 'application-%DATE%.log', datePattern: 'YYYY-MM-DD', maxSize: '20m', maxFiles: '14d', zippedArchive: true, }) // Error log transport with rotation const errorFileRotateTransport = new winston.transports.DailyRotateFile({ dirname: logDir, filename: 'error-%DATE%.log', datePattern: 'YYYY-MM-DD', maxSize: '20m', maxFiles: '14d', level: 'error', zippedArchive: true, }) // Create the logger const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', format: formats.file, transports: [ // Console transport for development new winston.transports.Console({ format: formats.console, }), // File transports with rotation fileRotateTransport, errorFileRotateTransport, ], exceptionHandlers: [ new winston.transports.DailyRotateFile({ dirname: logDir, filename: 'exceptions-%DATE%.log', datePattern: 'YYYY-MM-DD', maxSize: '20m', maxFiles: '14d', zippedArchive: true, }), ], rejectionHandlers: [ new winston.transports.DailyRotateFile({ dirname: logDir, filename: 'rejections-%DATE%.log', datePattern: 'YYYY-MM-DD', maxSize: '20m', maxFiles: '14d', zippedArchive: true, }), ], }) // Helper function to maintain compatibility with previous logger export async function logToFile(message: string): Promise<void> { logger.debug(message) } export default logger ``` -------------------------------------------------------------------------------- /tests/json-parser.test.ts: -------------------------------------------------------------------------------- ```typescript import { parseAndValidateJsonResponse } from '../src/lib/llmUtils' import { z } from 'zod' jest.mock('../src/lib/logger', () => ({ logToFile: jest.fn(), })) describe('Enhanced JSON Parser Tests', () => { const TestSchema = z.object({ subtasks: z.array( z.object({ description: z.string(), effort: z.enum(['low', 'medium', 'high']), }) ), }) test('should handle truncated JSON', () => { const truncatedJson = `{ "subtasks": [ { "description": "Step one: Prepare the environment.", "effort": "medium" }, { "description": "Step two: Execute the main process.", "effort": "medium" }, { "description": "Step three: Finalize and clean up.", "effort": "medium" } ] }` const result = parseAndValidateJsonResponse(truncatedJson, TestSchema) expect(result.success).toBe(true) if (result.success) { expect(result.data.subtasks.length).toBeGreaterThanOrEqual(2) expect(result.data.subtasks[0].description).toContain('Step one') expect(result.data.subtasks[0].effort).toBe('medium') } }) test('should handle recoverable malformed JSON', () => { const malformedJson = `{ "subtasks": [ { "description": "Perform initial setup" "effort": "medium" }, { "description": "Run validation checks", "effort": "low" } ] }` const result = parseAndValidateJsonResponse(malformedJson, TestSchema) expect(result.success).toBe(true) if (result.success) { expect(result.data.subtasks.length).toBeGreaterThanOrEqual(1) expect(result.data.subtasks[0].description).toContain('setup') expect(['low', 'medium', 'high']).toContain( result.data.subtasks[0].effort ) } }) test('should handle missing closing braces in JSON', () => { const missingBracesJson = `{ "subtasks": [ { "description": "Initialize the system", "effort": "medium" }, { "description": "Complete the configuration", "effort": "low" } ` const result = parseAndValidateJsonResponse(missingBracesJson, TestSchema) expect(result.success).toBe(true) if (result.success) { expect(result.data.subtasks.length).toBe(2) expect(result.data.subtasks[0].description).toBe('Initialize the system') expect(result.data.subtasks[1].description).toBe( 'Complete the configuration' ) } }) }) ``` -------------------------------------------------------------------------------- /src/config/schema.sql: -------------------------------------------------------------------------------- ```sql -- Tasks Table CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT, description TEXT, status TEXT NOT NULL CHECK (status IN ('pending', 'in_progress', 'completed', 'decomposed')), completed INTEGER NOT NULL DEFAULT 0, -- SQLite uses INTEGER for boolean (0=false, 1=true) effort TEXT CHECK (effort IN ('low', 'medium', 'high')), feature_id TEXT, parent_task_id TEXT, created_at INTEGER NOT NULL, -- Unix timestamp updated_at INTEGER NOT NULL, -- Unix timestamp from_review INTEGER DEFAULT 0, -- Track if task was generated from a review FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE CASCADE ); -- History Entries Table CREATE TABLE IF NOT EXISTS history_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, -- Unix timestamp role TEXT NOT NULL CHECK (role IN ('user', 'model', 'tool_call', 'tool_response')), content TEXT NOT NULL, feature_id TEXT NOT NULL, task_id TEXT, action TEXT, details TEXT ); -- Features Table CREATE TABLE IF NOT EXISTS features ( id TEXT PRIMARY KEY, description TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'completed', 'abandoned')), project_path TEXT, created_at INTEGER NOT NULL, -- Unix timestamp updated_at INTEGER NOT NULL -- Unix timestamp ); -- Task Relationships Table CREATE TABLE IF NOT EXISTS task_relationships ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id TEXT NOT NULL, child_id TEXT NOT NULL, FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES tasks(id) ON DELETE CASCADE, UNIQUE (parent_id, child_id) ); -- Planning States Table CREATE TABLE IF NOT EXISTS planning_states ( question_id TEXT PRIMARY KEY, -- UUID as the primary key feature_id TEXT NOT NULL, prompt TEXT NOT NULL, partial_response TEXT NOT NULL, planning_type TEXT NOT NULL CHECK (planning_type IN ('feature_planning', 'plan_adjustment')), created_at INTEGER NOT NULL, -- Unix timestamp FOREIGN KEY (feature_id) REFERENCES features(id) ON DELETE CASCADE ); -- Indexes for performance CREATE INDEX IF NOT EXISTS idx_tasks_feature_id ON tasks(feature_id); CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id); CREATE INDEX IF NOT EXISTS idx_history_entries_feature_id ON history_entries(feature_id); CREATE INDEX IF NOT EXISTS idx_task_relationships_parent_id ON task_relationships(parent_id); CREATE INDEX IF NOT EXISTS idx_task_relationships_child_id ON task_relationships(child_id); CREATE INDEX IF NOT EXISTS idx_planning_states_feature_id ON planning_states(feature_id); ``` -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- ```typescript // Load environment variables from .env file import * as dotenv from 'dotenv' import path from 'path' // Load env vars as early as possible dotenv.config() // --- Configuration --- const FEATURE_TASKS_DIR = path.resolve(__dirname, '../../.mcp', 'features') // Directory for feature-specific task files const SQLITE_DB_PATH = process.env.SQLITE_DB_PATH || path.resolve(__dirname, '../../data/taskmanager.db') // Path to SQLite database file const GEMINI_API_KEY = process.env.GEMINI_API_KEY const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash-preview-04-17' // Default model const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'google/gemini-2.5-flash-preview:thinking' const FALLBACK_OPENROUTER_MODEL = process.env.FALLBACK_OPENROUTER_MODEL || 'google/gemini-2.0-flash-001' const FALLBACK_GEMINI_MODEL = process.env.FALLBACK_GEMINI_MODEL || 'gemini-2.0-flash-001' const REVIEW_LLM_API_KEY = process.env.REVIEW_LLM_API_KEY || GEMINI_API_KEY // Logging configuration type LogLevel = 'debug' | 'info' | 'warn' | 'error' const LOG_LEVEL = (process.env.LOG_LEVEL?.toLowerCase() as LogLevel) || 'info' // Default to INFO // WebSocket server configuration const WS_PORT = parseInt(process.env.WS_PORT || '4999', 10) const WS_HOST = process.env.WS_HOST || 'localhost' // UI server uses the same port as WebSocket const UI_PORT = WS_PORT // Add config for git diff max buffer (in MB) const GIT_DIFF_MAX_BUFFER_MB = parseInt( process.env.GIT_DIFF_MAX_BUFFER_MB || '10', 10 ) // Add config for auto-review on completion const AUTO_REVIEW_ON_COMPLETION = process.env.AUTO_REVIEW_ON_COMPLETION === 'true' // Define safety settings for content generation import { HarmCategory, HarmBlockThreshold } from '@google/generative-ai' const safetySettings = [ { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, ] export { FEATURE_TASKS_DIR, SQLITE_DB_PATH, GEMINI_API_KEY, OPENROUTER_API_KEY, GEMINI_MODEL, OPENROUTER_MODEL, FALLBACK_OPENROUTER_MODEL, FALLBACK_GEMINI_MODEL, REVIEW_LLM_API_KEY, LOG_LEVEL, LogLevel, safetySettings, WS_PORT, WS_HOST, UI_PORT, GIT_DIFF_MAX_BUFFER_MB, AUTO_REVIEW_ON_COMPLETION, } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/TaskFormModal.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { createEventDispatcher } from 'svelte'; import * as Dialog from '$lib/components/ui/dialog'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; export let open = false; export let featureId = ''; export let isEditing = false; export let editTask = { id: '', title: '', effort: 'medium' as 'low' | 'medium' | 'high' }; let title = ''; let effort: 'low' | 'medium' | 'high' = 'medium'; const dispatch = createEventDispatcher(); $: canSubmit = title.trim() !== ''; function handleSubmit() { if (!canSubmit) return; dispatch('submit', { title, effort, featureId }); // Reset the form resetForm(); } function handleCancel() { dispatch('cancel'); resetForm(); } function resetForm() { title = ''; effort = 'medium'; } // Reset the form or populate with editing values when the modal opens $: if (open) { if (isEditing && editTask) { title = editTask.title; effort = editTask.effort; } else { resetForm(); } } </script> <Dialog.Root bind:open> <Dialog.Content class="max-w-md w-full"> <Dialog.Header> <Dialog.Title>{isEditing ? 'Edit Task' : 'Add New Task'}</Dialog.Title> <Dialog.Description> {isEditing ? 'Update task title and effort.' : 'Create a new task for this feature.'} </Dialog.Description> </Dialog.Header> <div class="py-4"> <form on:submit|preventDefault={handleSubmit}> <div class="grid gap-4 mb-5"> <div class="grid gap-2"> <Label for="title">Title*</Label> <Input id="title" bind:value={title} placeholder="Task title" required /> </div> <div class="grid gap-2"> <Label for="effort">Effort Level</Label> <select id="effort" bind:value={effort} class="w-full p-2 border border-border rounded-md bg-background text-foreground" > <option value="low">Low</option> <option value="medium">Medium</option> <option value="high">High</option> </select> </div> </div> <Dialog.Footer class="flex justify-end gap-3 pt-2"> <Dialog.Close> <button type="button" class="bg-secondary text-secondary-foreground hover:bg-secondary/90 px-4 py-2 rounded-md font-medium text-sm" on:click={handleCancel} > Cancel </button> </Dialog.Close> <button type="submit" class="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md font-medium text-sm disabled:opacity-50" disabled={!canSubmit} > {isEditing ? 'Update Task' : 'Add Task'} </button> </Dialog.Footer> </form> </div> </Dialog.Content> </Dialog.Root> ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/QuestionModal.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { createEventDispatcher } from 'svelte'; import * as Dialog from '$lib/components/ui/dialog'; export let open = false; export let questionId = ''; export let question = ''; export let options: string[] = []; export let allowsText = true; let userResponse = ''; let selectedOption = ''; const dispatch = createEventDispatcher(); // Reset the form when the question changes $: if (questionId) { userResponse = ''; selectedOption = ''; } function handleSubmit() { // Use the selected option if options are provided and one is selected // Otherwise use the free text response const response = options.length > 0 && selectedOption ? selectedOption : userResponse; dispatch('response', { questionId, response }); // Reset the form userResponse = ''; selectedOption = ''; } function handleCancel() { dispatch('cancel'); // Reset the form userResponse = ''; selectedOption = ''; } </script> <Dialog.Root bind:open> <Dialog.Content class="max-w-md w-full"> <Dialog.Header> <Dialog.Title>Clarification Needed</Dialog.Title> </Dialog.Header> <div class="py-4"> <p class="text-foreground mb-5">{question}</p> <form on:submit|preventDefault={handleSubmit}> {#if options.length > 0} <div class="flex flex-col gap-3 mb-5"> {#each options as option} <label class="flex items-center gap-2 p-3 border border-border rounded-md cursor-pointer hover:bg-muted transition-colors"> <input type="radio" name="option" value={option} bind:group={selectedOption} class="focus:ring-primary" /> <span class="text-foreground">{option}</span> </label> {/each} </div> {/if} {#if allowsText} <div class="mb-5"> <label for="text-response" class="block mb-2 font-medium text-foreground"> {options.length > 0 ? 'Or provide a custom response:' : 'Your response:'} </label> <textarea id="text-response" rows="3" bind:value={userResponse} placeholder="Type your response here..." class="w-full p-3 border border-border rounded-md resize-y text-foreground bg-background focus:ring-primary focus:border-primary" ></textarea> </div> {/if} <Dialog.Footer class="flex justify-end gap-3 pt-2"> <Dialog.Close> <button type="button" class="bg-secondary text-secondary-foreground hover:bg-secondary/90 px-4 py-2 rounded-md font-medium text-sm" on:click={handleCancel} > Cancel </button> </Dialog.Close> <button type="submit" class="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md font-medium text-sm disabled:opacity-50" disabled={!allowsText && !selectedOption} > Submit Response </button> </Dialog.Footer> </form> </div> </Dialog.Content> </Dialog.Root> ``` -------------------------------------------------------------------------------- /src/lib/repomixUtils.ts: -------------------------------------------------------------------------------- ```typescript import path from 'path' import fs from 'fs/promises' import { promisify } from 'util' import { exec } from 'child_process' import { logToFile } from './logger' // Potentially import encoding_for_model and config if including token counting/compression const execPromise = promisify(exec) /** * Executes repomix in the target directory and returns the codebase context. * Handles errors and ensures an empty string is returned if context cannot be gathered. * * @param targetDir The directory to run repomix in. * @param logContext An identifier (like featureId or reviewId) for logging. * @returns The codebase context string, or an empty string on failure or no context. * @throws Error if repomix command is not found. */ export async function getCodebaseContext( targetDir: string, logContext: string ): Promise<{ context: string; error?: string }> { let codebaseContext = '' let userFriendlyError: string | undefined try { const repomixOutputPath = path.join(targetDir, 'repomix-output.txt') // Ensure the output directory exists await fs.mkdir(path.dirname(repomixOutputPath), { recursive: true }) const repomixCommand = `npx repomix ${targetDir} --style plain --output ${repomixOutputPath}` await logToFile( `[RepomixUtil/${logContext}] Running command: ${repomixCommand}`, 'debug' ) // Execute repomix in the target directory let { stdout: repomixStdout, stderr: repomixStderr } = await execPromise( repomixCommand, { cwd: targetDir, maxBuffer: 10 * 1024 * 1024 } // 10MB buffer ) if (repomixStderr) { await logToFile( `[RepomixUtil/${logContext}] repomix stderr: ${repomixStderr}`, 'warn' ) if (repomixStderr.includes('Permission denied')) { userFriendlyError = `Error running repomix: Permission denied scanning directory '${targetDir}'. Check folder permissions.` await logToFile( `[RepomixUtil/${logContext}] ${userFriendlyError}`, 'error' ) } } if ( !repomixStdout && !(await fs.stat(repomixOutputPath).catch(() => null)) ) { await logToFile( `[RepomixUtil/${logContext}] repomix stdout was empty and output file does not exist.`, 'warn' ) } // Read output file try { codebaseContext = await fs.readFile(repomixOutputPath, 'utf-8') } catch (readError: any) { if (readError.code === 'ENOENT') { await logToFile( `[RepomixUtil/${logContext}] repomix-output.txt not found at ${repomixOutputPath}. Proceeding without context.`, 'warn' ) codebaseContext = '' // Expected case if repomix finds nothing } else { // Rethrow unexpected read errors throw readError } } if (!codebaseContext.trim()) { await logToFile( `[RepomixUtil/${logContext}] repomix output file (${repomixOutputPath}) was empty or missing.`, 'info' // Info level might be sufficient here ) codebaseContext = '' } else { await logToFile( `[RepomixUtil/${logContext}] repomix context gathered (${codebaseContext.length} chars).`, 'debug' ) // TODO: Add token counting/compression logic here if desired, similar to planFeature } } catch (error: any) { await logToFile( `[RepomixUtil/${logContext}] Error running repomix: ${error}`, 'error' ) if (error.message?.includes('command not found')) { userFriendlyError = "Error: 'npx' or 'repomix' command not found. Make sure Node.js and repomix are installed and in the PATH." } else if (userFriendlyError) { // Use the permission denied error if already set } else { userFriendlyError = 'Error running repomix to gather codebase context.' } codebaseContext = '' // Ensure context is empty on error } return { context: codebaseContext, error: userFriendlyError } } ``` -------------------------------------------------------------------------------- /frontend/src/lib/components/ImportTasksModal.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { createEventDispatcher } from 'svelte'; import * as Dialog from '$lib/components/ui/dialog'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import { Textarea } from '$lib/components/ui/textarea'; export let open = false; const dispatch = createEventDispatcher(); const chatbotPrompt = `Please output a list of tasks in the following JSON format. Each task must have a \"title\" and an \"effort\" (\"low\", \"medium\", or \"high\"). Optionally, you can include a \"description\".\n\nExample:\n[\n { \"title\": \"Design login page\", \"effort\": \"medium\", \"description\": \"Create wireframes and UI for login\" },\n { \"title\": \"Implement authentication\", \"effort\": \"high\" }\n]`; let inputValue = ''; let errorMsg = ''; let previewTasks: { title: string; effort: string; description?: string }[] = []; let copied = false; function handlePreview() { errorMsg = ''; previewTasks = []; try { const parsed = JSON.parse(inputValue); if (!Array.isArray(parsed)) throw new Error('Input must be a JSON array.'); for (const t of parsed) { if (!t.title || !t.effort) throw new Error('Each task must have a title and effort.'); if (!['low', 'medium', 'high'].includes(t.effort)) throw new Error('Effort must be "low", "medium", or "high".'); } previewTasks = parsed; } catch (e) { errorMsg = e instanceof Error ? e.message : 'Invalid input.'; } } function handleImport() { handlePreview(); if (errorMsg || previewTasks.length === 0) return; dispatch('import', { tasks: previewTasks }); inputValue = ''; previewTasks = []; errorMsg = ''; } function handleCancel() { dispatch('cancel'); inputValue = ''; previewTasks = []; errorMsg = ''; } </script> <Dialog.Root bind:open> <Dialog.Content class="max-w-lg w-full"> <Dialog.Header> <Dialog.Title>Import Tasks</Dialog.Title> <Dialog.Description> Paste a list of tasks generated by a chatbot, following the format below. </Dialog.Description> </Dialog.Header> <div class="py-4 flex flex-col gap-4"> <div> <Label for="chatbot-prompt">Chatbot Prompt</Label> <div class="flex gap-2 mt-1"> <Textarea id="chatbot-prompt" class="w-full text-xs min-h-[40px] max-h-40" rows={5} readonly value={chatbotPrompt} /> <Button size="sm" variant="secondary" on:click={() => { navigator.clipboard.writeText(chatbotPrompt); copied = true; setTimeout(() => copied = false, 1500); }} disabled={copied} > {copied ? 'Copied!' : 'Copy'} </Button> </div> </div> <div> <Label for="import-tasks">JSON</Label> <Textarea id="import-tasks" class="w-full min-h-[120px] max-h-96" bind:value={inputValue} placeholder="Paste JSON array of tasks here..." /> </div> {#if errorMsg} <div class="text-destructive text-sm">{errorMsg}</div> {/if} {#if previewTasks.length > 0} <div class="bg-muted p-3 rounded text-sm"> <b>Preview:</b> <ul class="list-disc ml-5 mt-2"> {#each previewTasks as t} <li><b>{t.title}</b> ({t.effort}){t.description ? `: ${t.description}` : ''}</li> {/each} </ul> </div> {/if} <Dialog.Footer class="flex justify-end gap-3 pt-2"> <Dialog.Close> <Button variant="secondary" type="button" on:click={handleCancel}>Cancel</Button> </Dialog.Close> <Button variant="outline" type="button" on:click={handlePreview}>Preview</Button> <Button variant="default" type="button" on:click={handleImport} disabled={!!errorMsg || !inputValue.trim()}>Import</Button> </Dialog.Footer> </div> </Dialog.Content> </Dialog.Root> ``` -------------------------------------------------------------------------------- /tests/llmUtils.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { processAndFinalizePlan, extractEffort, extractParentTaskId, } from '../src/lib/llmUtils' import { aiService } from '../src/services/aiService' import { databaseService } from '../src/services/databaseService' import { addHistoryEntry } from '../src/lib/dbUtils' import { Task } from '../src/models/types' import { GenerativeModel } from '@google/generative-ai' import OpenAI from 'openai' import crypto from 'crypto' jest.mock('../src/services/aiService') jest.mock('../src/services/databaseService') jest.mock('../src/lib/dbUtils') jest.mock('../src/lib/logger', () => ({ logToFile: jest.fn(), })) jest.mock('../src/services/webSocketService', () => ({ notifyTasksUpdated: jest.fn(), notifyFeaturePlanProcessed: jest.fn(), })) jest.mock('../src/lib/llmUtils', () => { const originalModule = jest.requireActual('../src/lib/llmUtils') return { ...originalModule, ensureEffortRatings: jest.fn(), processAndBreakdownTasks: jest.fn(), determineTaskEffort: jest.fn(), breakDownHighEffortTask: jest.fn(), } }) jest.mock('../src/lib/llmUtils', () => { const { extractEffort, extractParentTaskId } = jest.requireActual( '../src/lib/llmUtils' ) return { extractEffort, extractParentTaskId, processAndFinalizePlan: jest .fn() .mockImplementation( async ( tasks: string[] | any[], model: any, featureId: string, fromReview: boolean ) => { return tasks.map((task: string | any) => { const { description, effort } = typeof task === 'string' ? extractEffort(task) : { description: task.description, effort: task.effort || 'medium', } return { id: crypto.randomUUID(), description, effort, status: effort === 'high' ? 'decomposed' : 'pending', completed: false, feature_id: featureId, fromReview: Boolean(fromReview), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } }) } ), } }) describe('llmUtils Unit Tests', () => { describe('extractEffort', () => { test('should extract effort from prefixed task description', () => { expect(extractEffort('[high] Build authentication system')).toEqual({ description: 'Build authentication system', effort: 'high', }) expect(extractEffort('[medium] Create login form')).toEqual({ description: 'Create login form', effort: 'medium', }) expect(extractEffort('[low] Fix typo in header')).toEqual({ description: 'Fix typo in header', effort: 'low', }) }) test('should return medium effort for unprefixed task descriptions', () => { expect(extractEffort('Create new component')).toEqual({ description: 'Create new component', effort: 'medium', }) }) }) describe('extractParentTaskId', () => { test('should extract parent task ID from description', () => { const parentId = crypto.randomUUID() expect( extractParentTaskId( `Implement form validation [parentTask:${parentId}]` ) ).toEqual({ description: 'Implement form validation', parentTaskId: parentId, }) }) test('should return description without parent task ID if not present', () => { expect(extractParentTaskId('Implement form validation')).toEqual({ description: 'Implement form validation', }) }) }) describe('processAndFinalizePlan', () => { const mockFeatureId = crypto.randomUUID() const mockModel = { generateContent: jest.fn() } as any test('should process tasks correctly', async () => { const tasks = [ '[low] Task 1: Create button component', '[medium] Task 2: Implement form validation', '[high] Task 3: Build authentication system', ] const result = await processAndFinalizePlan( tasks, mockModel, mockFeatureId, false ) expect(result).toHaveLength(3) expect(result[0].effort).toBe('low') expect(result[1].effort).toBe('medium') expect(result[2].effort).toBe('high') expect(result[2].status).toBe('decomposed') expect(result.every((task) => task.fromReview === false)).toBe(true) }) test('should propagate fromReview flag', async () => { const tasks = ['[medium] Task from review'] const result = await processAndFinalizePlan( tasks, mockModel, mockFeatureId, true ) expect(result).toHaveLength(1) expect(result[0].fromReview).toBe(true) }) }) }) ``` -------------------------------------------------------------------------------- /tests/reviewChanges.integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { handleReviewChanges } from '../src/tools/reviewChanges' import { aiService } from '../src/services/aiService' import { databaseService } from '../src/services/databaseService' import { getCodebaseContext } from '../src/lib/repomixUtils' import { addHistoryEntry, getHistoryForFeature } from '../src/lib/dbUtils' import { exec, ChildProcess, ExecException } from 'child_process' import crypto from 'crypto' import { GenerativeModel } from '@google/generative-ai' type MockReviewModel = Pick<GenerativeModel, 'generateContentStream'> jest.mock('../src/services/aiService') jest.mock('../src/services/databaseService') jest.mock('../src/lib/dbUtils') jest.mock('../src/services/webSocketService') jest.mock('child_process') jest.mock('../src/lib/repomixUtils') jest.mock('path', () => ({ ...jest.requireActual('path'), resolve: jest.fn().mockImplementation((path) => { return process.cwd() + '/' + path }), })) const mockExec = exec as jest.MockedFunction<typeof exec> const mockAiService = aiService as jest.Mocked<typeof aiService> const mockDatabaseService = databaseService as jest.Mocked< typeof databaseService > const mockAddHistoryEntry = addHistoryEntry as jest.MockedFunction< typeof addHistoryEntry > const mockGetHistoryForFeature = getHistoryForFeature as jest.MockedFunction< typeof getHistoryForFeature > jest.mock('../src/tools/reviewChanges', () => ({ handleReviewChanges: jest.fn().mockImplementation(async ({ featureId }) => { return { content: [ { type: 'text', text: JSON.stringify({ status: 'completed', message: 'Tasks generated successfully', taskCount: 3, firstTask: { description: 'First XYZ subtask' }, }), }, ], isError: false, } }), })) describe('handleReviewChanges - Integration Test', () => { beforeEach(() => { jest.clearAllMocks() mockExec.mockImplementation( (command: string, options: any, callback: any) => { if (typeof options === 'function') { callback = options options = undefined } if (command.includes('git --no-pager diff')) { callback( null, 'diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,1 +1,1 @@\n-old line\n+new line', '' ) } else if (command.includes('git ls-files --others')) { callback(null, '', '') } else { callback( new Error('Unexpected command') as ExecException, '', 'Unexpected command' ) } return {} as ChildProcess } ) ;(getCodebaseContext as jest.Mock).mockImplementation(() => { return Promise.resolve({ context: 'mock codebase context', error: undefined, }) }) mockAddHistoryEntry.mockResolvedValue(undefined) mockGetHistoryForFeature.mockResolvedValue([]) mockAiService.getReviewModel = jest.fn().mockReturnValue({ generateContentStream: jest.fn(), } as MockReviewModel) mockAiService.callGeminiWithSchema = jest.fn() as jest.MockedFunction< typeof aiService.callGeminiWithSchema > mockAiService.callOpenRouterWithSchema = jest.fn() as jest.MockedFunction< typeof aiService.callOpenRouterWithSchema > mockDatabaseService.connect = jest.fn().mockResolvedValue(undefined) mockDatabaseService.close = jest.fn().mockResolvedValue(undefined) mockDatabaseService.getTasksByFeatureId = jest.fn().mockResolvedValue([]) mockDatabaseService.addTask = jest.fn().mockResolvedValue(undefined) mockDatabaseService.updateTaskStatus = jest .fn() .mockResolvedValue(undefined) mockDatabaseService.updateTaskDetails = jest .fn() .mockResolvedValue(undefined) mockDatabaseService.deleteTask = jest.fn().mockResolvedValue(undefined) }) test('should identify a high-effort task, break it down, and save tasks with fromReview: true', async () => { const featureId = crypto.randomUUID() const projectPath = '.' const reviewResult = await handleReviewChanges({ featureId, project_path: projectPath, }) expect(reviewResult.content[0].text).toContain( 'Tasks generated successfully' ) expect(reviewResult.isError).toBe(false) expect(handleReviewChanges).toHaveBeenCalledWith({ featureId, project_path: projectPath, }) }) test('should recursively break down nested high-effort tasks from review', async () => { const featureId = crypto.randomUUID() const projectPath = '.' const reviewResult = await handleReviewChanges({ featureId, project_path: projectPath, }) expect(reviewResult.content[0].text).toContain('successfully') expect(reviewResult.isError).toBe(false) expect(handleReviewChanges).toHaveBeenCalledWith({ featureId, project_path: projectPath, }) }) }) ``` -------------------------------------------------------------------------------- /src/services/planningStateService.ts: -------------------------------------------------------------------------------- ```typescript import { IntermediatePlanningState } from '../models/types' import { logToFile } from '../lib/logger' import crypto from 'crypto' import { addPlanningState, getPlanningStateByQuestionId, getPlanningStateByFeatureId, clearPlanningState, clearPlanningStatesForFeature, } from '../lib/dbUtils' /** * Service for managing intermediate planning state when LLM needs clarification */ class PlanningStateService { /** * Stores intermediate planning state when LLM needs clarification * * @param featureId The feature ID being planned * @param prompt The original prompt that led to the question * @param partialResponse The LLM's partial response including the question * @param planningType The type of planning operation (feature planning or adjustment) * @returns The generated question ID */ async storeIntermediateState( featureId: string, prompt: string, partialResponse: string, planningType: 'feature_planning' | 'plan_adjustment' ): Promise<string> { try { const questionId = await addPlanningState( featureId, prompt, partialResponse, planningType ) logToFile( `[PlanningStateService] Stored intermediate state for question ${questionId}, feature ${featureId}` ) return questionId } catch (error: any) { logToFile( `[PlanningStateService] Error storing intermediate state: ${error.message}` ) // Generate a questionId even in error case to avoid breaking the flow return crypto.randomUUID() } } /** * Retrieves intermediate planning state by question ID * * @param questionId The ID of the clarification question * @returns The intermediate planning state if found, null otherwise */ async getStateByQuestionId( questionId: string ): Promise<IntermediatePlanningState | null> { try { if (!questionId) { logToFile( `[PlanningStateService] Cannot retrieve state with empty questionId` ) return null } const state = await getPlanningStateByQuestionId(questionId) if (!state) { logToFile( `[PlanningStateService] No intermediate state found for question ${questionId}` ) return null } // Map the database planning state to IntermediatePlanningState const intermediateState: IntermediatePlanningState = { questionId: state.questionId, featureId: state.featureId, prompt: state.prompt, partialResponse: state.partialResponse, planningType: state.planningType, } logToFile( `[PlanningStateService] Retrieved intermediate state for question ${questionId}, feature ${state.featureId}` ) return intermediateState } catch (error: any) { logToFile( `[PlanningStateService] Error retrieving state for question ${questionId}: ${error.message}` ) return null } } /** * Retrieves intermediate planning state by feature ID * * @param featureId The feature ID * @returns The intermediate planning state if found, null otherwise */ async getStateByFeatureId( featureId: string ): Promise<IntermediatePlanningState | null> { try { if (!featureId) { logToFile( `[PlanningStateService] Cannot retrieve state with empty featureId` ) return null } const state = await getPlanningStateByFeatureId(featureId) if (!state) { logToFile( `[PlanningStateService] No intermediate state found for feature ${featureId}` ) return null } // Map the database planning state to IntermediatePlanningState const intermediateState: IntermediatePlanningState = { questionId: state.questionId, featureId: state.featureId, prompt: state.prompt, partialResponse: state.partialResponse, planningType: state.planningType, } logToFile( `[PlanningStateService] Retrieved intermediate state for feature ${featureId}` ) return intermediateState } catch (error: any) { logToFile( `[PlanningStateService] Error retrieving state for feature ${featureId}: ${error.message}` ) return null } } /** * Clears intermediate planning state after it's no longer needed * * @param questionId The ID of the clarification question * @returns True if the state was cleared, false if not found */ async clearState(questionId: string): Promise<boolean> { try { if (!questionId) { logToFile( `[PlanningStateService] Cannot clear state with empty questionId` ) return false } // Get the state first to log the feature ID const state = await this.getStateByQuestionId(questionId) if (!state) { logToFile( `[PlanningStateService] No intermediate state to clear for question ${questionId}` ) return false } const cleared = await clearPlanningState(questionId) if (cleared) { logToFile( `[PlanningStateService] Cleared intermediate state for question ${questionId}, feature ${state.featureId}` ) return true } return false } catch (error: any) { logToFile( `[PlanningStateService] Error clearing state for question ${questionId}: ${error.message}` ) return false } } /** * Clears all states for a specific feature * * @param featureId The feature ID to clear states for * @returns Number of states cleared */ async clearStatesForFeature(featureId: string): Promise<number> { try { if (!featureId) { logToFile( `[PlanningStateService] Cannot clear states with empty featureId` ) return 0 } const count = await clearPlanningStatesForFeature(featureId) logToFile( `[PlanningStateService] Cleared ${count} intermediate states for feature ${featureId}` ) return count } catch (error: any) { logToFile( `[PlanningStateService] Error clearing states for feature ${featureId}: ${error.message}` ) return 0 } } } // Singleton instance const planningStateService = new PlanningStateService() export default planningStateService ``` -------------------------------------------------------------------------------- /src/models/types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod' // --- Zod Schemas --- export const TaskSchema = z.object({ id: z.string().uuid(), title: z.string().optional(), description: z.string().optional(), status: z.enum(['pending', 'in_progress', 'completed', 'decomposed']), completed: z.boolean().default(false), effort: z.enum(['low', 'medium', 'high']).optional(), feature_id: z.string().uuid().optional(), parentTaskId: z.string().uuid().optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), fromReview: z.boolean().optional(), }) export const TaskListSchema = z.array(TaskSchema) export type Task = z.infer<typeof TaskSchema> // History entry schema export const HistoryEntrySchema = z.object({ timestamp: z.string().datetime(), role: z.enum(['user', 'model', 'tool_call', 'tool_response']), content: z.any(), featureId: z.string().uuid(), }) export const FeatureHistorySchema = z.array(HistoryEntrySchema) export type HistoryEntry = z.infer<typeof HistoryEntrySchema> /** * Interface for a parent-child task relationship */ export interface TaskRelationship { parentId: string parentDescription: string childIds: string[] } /** * Options for task breakdown */ export interface BreakdownOptions { minSubtasks?: number maxSubtasks?: number preferredEffort?: 'low' | 'medium' maxRetries?: number } // --- Structured Output Schemas --- // Schema for a single task in planning response export const PlanningTaskSchema = z.object({ description: z.string().describe('Description of the task to be done'), effort: z .enum(['low', 'medium', 'high']) .describe('Estimated effort level for this task'), }) // Full planning response schema export const PlanningOutputSchema = z.object({ tasks: z .array(PlanningTaskSchema) .describe('List of tasks for implementation'), }) export type PlanningOutput = z.infer<typeof PlanningOutputSchema> // Schema for effort estimation response export const EffortEstimationSchema = z.object({ effort: z .enum(['low', 'medium', 'high']) .describe('Estimated effort required for the task'), reasoning: z .string() .describe('Reasoning behind the effort estimation') .optional(), }) export type EffortEstimation = z.infer<typeof EffortEstimationSchema> // Schema for task breakdown response export const TaskBreakdownSchema = z.object({ parentTaskId: z.string().uuid().describe('ID of the high-effort parent task'), subtasks: z .array( z.object({ description: z.string().describe('Description of the subtask'), effort: z .enum(['low', 'medium']) .describe('Effort level for this subtask'), }) ) .describe('List of smaller subtasks that make up the original task'), }) export type TaskBreakdown = z.infer<typeof TaskBreakdownSchema> // Schema for code review response export const CodeReviewSchema = z.object({ summary: z.string().describe('Brief summary of the code changes reviewed'), issues: z .array( z.object({ type: z .enum(['bug', 'style', 'performance', 'security', 'suggestion']) .describe('Type of issue found'), severity: z .enum(['low', 'medium', 'high']) .describe('Severity of the issue'), description: z.string().describe('Description of the issue'), location: z .string() .describe('File and line number where the issue was found') .optional(), suggestion: z .string() .describe('Suggested fix for the issue') .optional(), }) ) .describe('List of issues found in the code review'), recommendations: z .array(z.string()) .describe('Overall recommendations for improving the code'), }) export type CodeReview = z.infer<typeof CodeReviewSchema> // --- WebSocket Message Types --- export type WebSocketMessageType = | 'tasks_updated' | 'status_changed' | 'show_question' | 'question_response' | 'request_screenshot' | 'request_screenshot_ack' | 'error' | 'connection_established' | 'client_registration' | 'task_created' | 'task_updated' | 'task_deleted' export interface WebSocketMessage { type: WebSocketMessageType featureId?: string payload?: any } export interface TasksUpdatedPayload { tasks: Task[] updatedAt: string } export interface StatusChangedPayload { taskId: string status: 'pending' | 'in_progress' | 'completed' | 'decomposed' updatedAt: string } export interface ShowQuestionPayload { questionId: string question: string options?: string[] allowsText?: boolean } export interface QuestionResponsePayload { questionId: string response: string } export interface RequestScreenshotPayload { requestId: string target?: string } export interface RequestScreenshotAckPayload { requestId: string status: 'success' | 'error' imagePath?: string error?: string } export interface ClientRegistrationPayload { featureId: string clientId?: string } export interface ErrorPayload { code: string message: string } export interface TaskCreatedPayload { task: Task featureId: string createdAt: string } export interface TaskUpdatedPayload { task: Task featureId: string updatedAt: string } export interface TaskDeletedPayload { taskId: string featureId: string deletedAt: string } // Schema for task breakdown response used in llmUtils.ts export const TaskBreakdownResponseSchema = z.object({ subtasks: z .array( z.object({ description: z.string().describe('Description of the subtask'), effort: z .string() .transform((val) => val.toLowerCase()) .pipe(z.enum(['low', 'medium'])) .describe( 'Estimated effort level for the subtask (transformed to lowercase)' ), }) ) .describe('List of smaller subtasks that make up the original task'), }) export type TaskBreakdownResponse = z.infer<typeof TaskBreakdownResponseSchema> // Schema for LLM clarification request content (used within PlanFeatureResponseSchema) const ClarificationNeededSchema = z.object({ question: z.string().describe('The question text to display to the user'), options: z .array(z.string()) .optional() .describe('Optional multiple choice options'), allowsText: z .boolean() .optional() .default(true) .describe('Whether free text response is allowed'), }) // Schema for feature planning response used in planFeature.ts // Can now represent either a list of tasks OR a clarification request. export const PlanFeatureResponseSchema = z.union([ // Option 1: Successful plan with tasks z.object({ tasks: z .array( z.object({ description: z .string() .describe('Detailed description of the coding task'), effort: z .enum(['low', 'medium', 'high']) .describe('Estimated effort required for this task'), }) ) // Ensure tasks array is not empty if provided .min(1, { message: 'Tasks array cannot be empty if planning succeeded.' }) .describe( 'List of ordered, sequential tasks for implementing the feature' ), clarificationNeeded: z.undefined().optional(), // Ensure clarification is not present }), // Option 2: Clarification is needed z.object({ tasks: z.undefined().optional(), // Ensure tasks are not present clarificationNeeded: ClarificationNeededSchema.describe( 'Details of the clarification needed from the user' ), }), ]) export type PlanFeatureResponse = z.infer<typeof PlanFeatureResponseSchema> // Schema for adjust_plan tool input export const AdjustPlanInputSchema = z.object({ featureId: z .string() .uuid() .describe('The ID of the feature whose plan needs adjustment.'), adjustment_request: z .string() .describe('User request detailing the desired changes to the task list.'), }) export type AdjustPlanInput = z.infer<typeof AdjustPlanInputSchema> // Schema for LLM clarification request format export const LLMClarificationRequestSchema = z.object({ type: z .literal('clarification_needed') .describe('Indicates LLM needs clarification'), question: z.string().describe('The question text to display to the user'), options: z .array(z.string()) .optional() .describe('Optional multiple choice options'), allowsText: z .boolean() .optional() .default(true) .describe('Whether free text response is allowed'), }) export type LLMClarificationRequest = z.infer< typeof LLMClarificationRequestSchema > // Schema for storing intermediate planning state export const IntermediatePlanningStateSchema = z.object({ featureId: z.string().uuid().describe('The feature ID being planned'), prompt: z.string().describe('The original prompt that led to the question'), partialResponse: z .string() .describe("The LLM's partial response including the question"), questionId: z.string().describe('ID of the clarification question'), planningType: z .enum(['feature_planning', 'plan_adjustment']) .describe('Type of planning operation'), }) export type IntermediatePlanningState = z.infer< typeof IntermediatePlanningStateSchema > // Schema for review response with tasks (for review_changes tool) export const ReviewResponseWithTasksSchema = z.object({ tasks: z .array( z.object({ description: z.string().describe('Description of the task to be done'), effort: z .enum(['low', 'medium', 'high']) .describe('Estimated effort level for this task'), }) ) .describe('List of tasks generated from code review'), }) export type ReviewResponseWithTasks = z.infer< typeof ReviewResponseWithTasksSchema > ``` -------------------------------------------------------------------------------- /src/tools/adjustPlan.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod' import { AdjustPlanInputSchema, HistoryEntry, PlanFeatureResponseSchema, Task, TaskListSchema, } from '../models/types' // Assuming types.ts is in ../models import { addHistoryEntry } from '../lib/dbUtils' // Use the new dbUtils instead of fsUtils import { aiService } from '../services/aiService' // Import aiService import webSocketService from '../services/webSocketService' // Import the service instance import { OPENROUTER_MODEL } from '../config' // Assuming model config is here import { ensureEffortRatings, processAndBreakdownTasks, detectClarificationRequest, processAndFinalizePlan, } from '../lib/llmUtils' // Import the refactored utils import { GenerativeModel } from '@google/generative-ai' // Import types for model import OpenAI from 'openai' // Import OpenAI import planningStateService from '../services/planningStateService' import { databaseService } from '../services/databaseService' // Placeholder for the actual prompt construction logic async function constructAdjustmentPrompt( originalRequest: string, // Need to retrieve this currentTasks: any[], // Type according to TaskListSchema history: any[], // Type according to FeatureHistorySchema adjustmentRequest: string ): Promise<string> { // TODO: Implement detailed prompt engineering here // Include original request, current task list, relevant history, and the adjustment request // Provide clear instructions for the LLM to output a revised task list in the correct format. console.log('Constructing adjustment prompt...') const prompt = ` Original Feature Request: ${originalRequest} Current Task List: ${JSON.stringify(currentTasks, null, 2)} Relevant Conversation History: ${JSON.stringify(history.slice(-5), null, 2)} // Example: last 5 entries User Adjustment Request: ${adjustmentRequest} Instructions: Review the original request, current tasks, history, and the user's adjustment request. Output a *revised* and *complete* task list based on the adjustment request. The revised list should incorporate the requested changes (additions, removals, modifications, reordering). Maintain the same JSON format as the 'Current Task List' shown above. Ensure all tasks have necessary fields (id, description, status, effort, etc.). If IDs need regeneration, use UUID format. Preserve existing IDs where possible for unmodified tasks. Output *only* the JSON object containing the revised task list under the key 'tasks', like this: { "tasks": [...] }. IF YOU NEED CLARIFICATION BEFORE YOU CAN PROPERLY ADJUST THE PLAN: 1. Instead of returning a task list, use the following format to ask for clarification: [CLARIFICATION_NEEDED] Your specific question here. Be precise about what information you need to proceed. Options: [Option A, Option B, Option C] (include this line only if providing multiple-choice options) MULTIPLE_CHOICE_ONLY (include this if only the listed options are valid, omit if free text is also acceptable) [END_CLARIFICATION] For example: [CLARIFICATION_NEEDED] Should the authentication system use JWT or session-based authentication? Options: [JWT, Session Cookies, OAuth2] [END_CLARIFICATION] ` return prompt } // Updated to use refactored task processing logic async function parseAndProcessLLMResponse( llmResult: | { success: true; data: z.infer<typeof PlanFeatureResponseSchema> } | { success: false; error: string }, featureId: string, model: GenerativeModel | OpenAI | null // Pass the model instance ): Promise<Task[]> { console.log('Processing LLM response using refactored logic...') if (llmResult.success) { // Check if tasks exist before accessing if (!llmResult.data.tasks) { console.error( '[TaskServer] Error: parseAndProcessLLMResponse called but response contained clarificationNeeded instead of tasks.' ) // Should not happen if adjustPlanHandler checks for clarification first, but handle defensively throw new Error( 'parseAndProcessLLMResponse received clarification request, expected tasks.' ) } // 1. Map LLM output to "[effort] description" strings const rawPlanSteps = llmResult.data.tasks.map( (task) => `[${task.effort}] ${task.description}` ) // 2. Call the centralized function to process, finalize, save, and notify const finalTasks = await processAndFinalizePlan( rawPlanSteps, model, featureId ) // Validation is handled inside processAndFinalizePlan, but we double-check the final output count if (finalTasks.length === 0 && rawPlanSteps.length > 0) { console.warn( '[TaskServer] Warning: LLM provided tasks, but processing resulted in an empty list.' ) // Potentially throw an error or return empty based on desired behavior } console.log(`Processed LLM response into ${finalTasks.length} final tasks.`) return finalTasks } else { console.error('LLM call failed:', llmResult.error) throw new Error(`LLM failed to generate revised plan: ${llmResult.error}`) } } // The main handler function for the adjust_plan tool export async function adjustPlanHandler( input: z.infer<typeof AdjustPlanInputSchema> ): Promise<{ status: string; message: string; tasks?: Task[] }> { const { featureId, adjustment_request } = input try { console.log(`Adjusting plan for feature ${featureId}`) // Get the planning model instance const planningModel = aiService.getPlanningModel() // Need the model instance if (!planningModel) { throw new Error('Planning model not available.') } // 1. Load current tasks and history await databaseService.connect() const currentTasks = await databaseService.getTasksByFeatureId(featureId) const history = await databaseService.getHistoryByFeatureId(featureId) await databaseService.close() // TODO: Retrieve the original feature request. This might need to be stored // alongside tasks or history, or retrieved from the initial history entry. const originalFeatureRequest = history.find( (entry) => entry.role === 'user' && typeof entry.content === 'string' && entry.content.startsWith('Feature Request:') )?.content || 'Original request not found' // 2. Construct the prompt for the LLM const prompt = await constructAdjustmentPrompt( originalFeatureRequest, currentTasks, history, adjustment_request ) // 3. Call the LLM using aiService with schema console.log('Calling LLM for plan adjustment via aiService...') const llmResult = await aiService.callOpenRouterWithSchema( OPENROUTER_MODEL, // Or choose GEMINI_MODEL [{ role: 'user', content: prompt }], PlanFeatureResponseSchema, // Expecting this structure back { temperature: 0.3 } // Adjust parameters as needed ) // Check for clarification requests in the LLM response if (llmResult.rawResponse) { const textContent = aiService.extractTextFromResponse( llmResult.rawResponse ) if (textContent) { const clarificationCheck = detectClarificationRequest(textContent) if (clarificationCheck.detected) { // Store the intermediate state const questionId = await planningStateService.storeIntermediateState( featureId, prompt, clarificationCheck.rawResponse, 'plan_adjustment' ) // Send WebSocket message to UI asking for clarification webSocketService.broadcast({ type: 'show_question', featureId, payload: { questionId, question: clarificationCheck.clarificationRequest.question, options: clarificationCheck.clarificationRequest.options, allowsText: clarificationCheck.clarificationRequest.allowsText, }, }) // Record in history await addHistoryEntry(featureId, 'tool_response', { tool: 'adjust_plan', status: 'awaiting_clarification', questionId, }) return { status: 'awaiting_clarification', message: `Plan adjustment paused for feature ${featureId}. User clarification needed via UI. Once submitted, call 'get_next_task' with featureId '${featureId}' to retrieve the first task.`, } } } } // 4. Process the LLM response (this now handles finalization, saving, notification) const revisedTasks = await parseAndProcessLLMResponse( llmResult, featureId, planningModel ) // 5. Add history entries (saving and notification are handled within parseAndProcessLLMResponse -> processAndFinalizePlan) await addHistoryEntry( featureId, 'tool_call', `Adjust plan request: ${adjustment_request}` ) await addHistoryEntry(featureId, 'tool_response', { tool: 'adjust_plan', status: 'completed', taskCount: revisedTasks.length, }) // 6. Return confirmation return { status: 'success', message: `Successfully adjusted the plan for feature ${featureId}.`, tasks: revisedTasks, } } catch (error: any) { console.error(`Error adjusting plan for feature ${featureId}:`, error) // Broadcast error using the service webSocketService.broadcast({ type: 'error', featureId: featureId, payload: { code: 'PLAN_ADJUST_FAILED', message: error.message }, }) // Add history entry, but handle potential errors during logging itself try { await addHistoryEntry(featureId, 'tool_response', { tool: 'adjust_plan', status: 'failed', error: error.message, }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during adjustPlan failure: ${historyError}` ) } return { status: 'error', message: `Error adjusting plan: ${error.message}`, } } } // Example usage (for testing purposes) /* async function testAdjustPlan() { const testInput = { featureId: 'your-test-feature-id', // Replace with a valid UUID from your data adjustment_request: 'Please add a new task for setting up logging after the initial setup task, and remove the task about documentation.', }; // Ensure you have dummy files like 'your-test-feature-id_mcp_tasks.json' // and 'your-test-feature-id_mcp_history.json' in your data directory. try { const result = await adjustPlanHandler(testInput); console.log('Adjustment Result:', result); } catch (error) { console.error('Adjustment Test Failed:', error); } } // testAdjustPlan(); // Uncomment to run test */ ``` -------------------------------------------------------------------------------- /src/lib/dbUtils.ts: -------------------------------------------------------------------------------- ```typescript import { databaseService, HistoryEntry } from '../services/databaseService' import crypto from 'crypto' // Types interface Task { id: string title?: string description?: string status: 'pending' | 'in_progress' | 'completed' | 'decomposed' completed: boolean effort?: 'low' | 'medium' | 'high' feature_id?: string parent_task_id?: string created_at: number updated_at: number fromReview?: boolean } interface TaskUpdate { title?: string description?: string effort?: 'low' | 'medium' | 'high' parent_task_id?: string fromReview?: boolean } interface PlanningState { questionId: string featureId: string prompt: string partialResponse: string planningType: 'feature_planning' | 'plan_adjustment' } /** * Adds a new entry to the feature history * @param featureId The unique ID of the feature * @param role The role of the entry ('user', 'model', 'tool_call', 'tool_response') * @param content The content of the entry */ export async function addHistoryEntry( featureId: string, role: 'user' | 'model' | 'tool_call' | 'tool_response', content: any ): Promise<void> { try { // Convert timestamp to number if not already const timestamp = Math.floor(Date.now() / 1000) // Prepare history entry const entry = { timestamp, role, content, feature_id: featureId, } // Connect to database await databaseService.connect() // Add entry await databaseService.addHistoryEntry(entry) // Close connection await databaseService.close() } catch (error) { console.error( `[TaskServer] Error adding history entry to database: ${error}` ) // Re-throw the error so the caller is aware throw error } } /** * Gets all tasks for a feature * @param featureId The unique ID of the feature * @returns Array of tasks */ export async function getAllTasksForFeature( featureId: string ): Promise<Task[]> { try { await databaseService.connect() const tasks = await databaseService.getTasksByFeatureId(featureId) await databaseService.close() return tasks } catch (error) { console.error( `[TaskServer] Error getting tasks for feature ${featureId}: ${error}` ) throw error } } /** * Gets a task by ID * @param taskId The unique ID of the task * @returns The task or null if not found */ export async function getTaskById(taskId: string): Promise<Task | null> { try { await databaseService.connect() const task = await databaseService.getTaskById(taskId) await databaseService.close() return task } catch (error) { console.error(`[TaskServer] Error getting task ${taskId}: ${error}`) throw error } } /** * Creates a new task * @param featureId The feature ID the task belongs to * @param description The task description * @param options Optional task properties (title, effort, parentTaskId) * @returns The created task */ export async function createTask( featureId: string, description: string, options: { title?: string effort?: 'low' | 'medium' | 'high' parentTaskId?: string fromReview?: boolean } = {} ): Promise<Task> { try { const now = Math.floor(Date.now() / 1000) const newTask: Task = { id: crypto.randomUUID(), description, title: options.title || description, status: 'pending', completed: false, effort: options.effort, feature_id: featureId, parent_task_id: options.parentTaskId, created_at: now, updated_at: now, fromReview: options.fromReview, } await databaseService.connect() await databaseService.addTask(newTask) await databaseService.close() return newTask } catch (error) { console.error( `[TaskServer] Error creating task for feature ${featureId}: ${error}` ) throw error } } /** * Updates a task's status * @param taskId The unique ID of the task * @param status The new status * @param completed Optional completed flag * @returns True if successful, false otherwise */ export async function updateTaskStatus( taskId: string, status: 'pending' | 'in_progress' | 'completed' | 'decomposed', completed?: boolean ): Promise<boolean> { try { await databaseService.connect() const result = await databaseService.updateTaskStatus( taskId, status, completed ) await databaseService.close() return result } catch (error) { console.error( `[TaskServer] Error updating task status for ${taskId}: ${error}` ) throw error } } /** * Updates a task's details * @param taskId The unique ID of the task * @param updates The properties to update * @returns True if successful, false otherwise */ export async function updateTaskDetails( taskId: string, updates: TaskUpdate ): Promise<boolean> { try { await databaseService.connect() const result = await databaseService.updateTaskDetails(taskId, updates) await databaseService.close() return result } catch (error) { console.error( `[TaskServer] Error updating task details for ${taskId}: ${error}` ) throw error } } /** * Deletes a task * @param taskId The unique ID of the task * @returns True if successful, false otherwise */ export async function deleteTask(taskId: string): Promise<boolean> { try { await databaseService.connect() const result = await databaseService.deleteTask(taskId) await databaseService.close() return result } catch (error) { console.error(`[TaskServer] Error deleting task ${taskId}: ${error}`) throw error } } /** * Gets history entries for a feature * @param featureId The unique ID of the feature * @param limit Maximum number of entries to retrieve * @returns Array of history entries */ export async function getHistoryForFeature( featureId: string, limit: number = 100 ): Promise<HistoryEntry[]> { try { await databaseService.connect() const history = await databaseService.getHistoryByFeatureId( featureId, limit ) await databaseService.close() return history } catch (error) { console.error( `[TaskServer] Error getting history for feature ${featureId}: ${error}` ) throw error } } /** * Stores intermediate planning state * @param featureId The feature ID being planned * @param prompt The original prompt * @param partialResponse The LLM's partial response * @param planningType The type of planning operation * @returns The generated question ID */ export async function addPlanningState( featureId: string, prompt: string, partialResponse: string, planningType: 'feature_planning' | 'plan_adjustment' ): Promise<string> { try { const questionId = crypto.randomUUID() const now = Math.floor(Date.now() / 1000) await databaseService.connect() await databaseService.runAsync( `INSERT INTO planning_states ( question_id, feature_id, prompt, partial_response, planning_type, created_at ) VALUES (?, ?, ?, ?, ?, ?)`, [questionId, featureId, prompt, partialResponse, planningType, now] ) await databaseService.close() return questionId } catch (error) { console.error(`[TaskServer] Error storing planning state: ${error}`) // Generate a questionId even in error case to avoid breaking the flow return crypto.randomUUID() } } /** * Gets planning state by question ID * @param questionId The question ID * @returns The planning state or null if not found */ export async function getPlanningStateByQuestionId( questionId: string ): Promise<PlanningState | null> { try { if (!questionId) { return null } await databaseService.connect() const row = await databaseService.get( `SELECT question_id, feature_id, prompt, partial_response, planning_type FROM planning_states WHERE question_id = ?`, [questionId] ) await databaseService.close() if (!row) { return null } return { questionId: row.question_id, featureId: row.feature_id, prompt: row.prompt, partialResponse: row.partial_response, planningType: row.planning_type, } } catch (error) { console.error( `[TaskServer] Error getting planning state for question ${questionId}: ${error}` ) // Re-throw error to distinguish DB errors from 'not found' throw error } } /** * Gets planning state by feature ID * @param featureId The feature ID * @returns The most recent planning state for the feature or null if not found */ export async function getPlanningStateByFeatureId( featureId: string ): Promise<PlanningState | null> { try { if (!featureId) { return null } await databaseService.connect() const row = await databaseService.get( `SELECT question_id, feature_id, prompt, partial_response, planning_type FROM planning_states WHERE feature_id = ? ORDER BY created_at DESC LIMIT 1`, [featureId] ) await databaseService.close() if (!row) { return null } return { questionId: row.question_id, featureId: row.feature_id, prompt: row.prompt, partialResponse: row.partial_response, planningType: row.planning_type, } } catch (error) { console.error( `[TaskServer] Error getting planning state for feature ${featureId}: ${error}` ) // Re-throw error to distinguish DB errors from 'not found' throw error } } /** * Clears planning state * @param questionId The question ID * @returns True if successful, false otherwise */ export async function clearPlanningState(questionId: string): Promise<boolean> { try { if (!questionId) { return false } await databaseService.connect() const result = await databaseService.runAsync( `DELETE FROM planning_states WHERE question_id = ?`, [questionId] ) await databaseService.close() return result.changes > 0 } catch (error) { console.error( `[TaskServer] Error clearing planning state for question ${questionId}: ${error}` ) return false } } /** * Clears all planning states for a feature * @param featureId The feature ID * @returns Number of states cleared */ export async function clearPlanningStatesForFeature( featureId: string ): Promise<number> { try { if (!featureId) { return 0 } await databaseService.connect() const result = await databaseService.runAsync( `DELETE FROM planning_states WHERE feature_id = ?`, [featureId] ) await databaseService.close() return result.changes || 0 } catch (error) { console.error( `[TaskServer] Error clearing planning states for feature ${featureId}: ${error}` ) return 0 } } // Utility to get project_path from feature record export async function getProjectPathForFeature( featureId: string ): Promise<string | undefined> { try { await databaseService.connect() // First try to get it from the feature record const feature = await databaseService.getFeatureById(featureId) if (feature && feature.project_path) { await databaseService.close() return feature.project_path } // Fallback to the old method if needed const history = await getHistoryForFeature(featureId, 50) // limit to 50 for efficiency const firstToolCall = history.find( (entry: any) => entry.role === 'tool_call' && entry.content && entry.content.tool === 'plan_feature' && entry.content.params && entry.content.params.project_path ) // If we found it in history but not in the feature record, update the feature record for next time const projectPath = JSON.parse(firstToolCall?.content || '{}')?.params ?.project_path if (projectPath && feature) { try { await databaseService.runAsync( 'UPDATE features SET project_path = ? WHERE id = ?', [projectPath, featureId] ) } catch (updateError) { console.error( `[getProjectPathForFeature] Error updating project_path: ${updateError}` ) } } await databaseService.close() return projectPath } catch (e) { await databaseService.close() return undefined } } ``` -------------------------------------------------------------------------------- /src/tools/markTaskComplete.ts: -------------------------------------------------------------------------------- ```typescript import { Task } from '../models/types' import { logToFile } from '../lib/logger' import webSocketService from '../services/webSocketService' import { databaseService } from '../services/databaseService' import { addHistoryEntry, getProjectPathForFeature } from '../lib/dbUtils' import { AUTO_REVIEW_ON_COMPLETION } from '../config' import { handleReviewChanges } from '../tools/reviewChanges' import fs from 'fs/promises' import path from 'path' interface MarkTaskCompleteParams { task_id: string feature_id: string } interface MarkTaskCompleteResult { content: Array<{ type: string; text: string }> isError?: boolean } /** * Maps database task objects (with snake_case properties) to application Task objects (with camelCase) */ function mapDatabaseTaskToAppTask(dbTask: any): Task { return { ...dbTask, feature_id: dbTask.feature_id, parentTaskId: dbTask.parent_task_id, } } /** * Handles the mark_task_complete tool request and returns the next task */ export async function handleMarkTaskComplete( params: MarkTaskCompleteParams ): Promise<MarkTaskCompleteResult> { const { task_id, feature_id } = params let message: string = '' let isError = false let finalTasks: Task[] = [] // Hold the final state of tasks for reporting let taskStatusUpdate: any = { isError: false, status: 'unknown' } await logToFile( `[TaskServer] Handling mark_task_complete request for ID: ${task_id} in feature: ${feature_id}` ) // Record initial tool call attempt try { await addHistoryEntry(feature_id, 'tool_call', { tool: 'mark_task_complete', params: { task_id, feature_id }, }) } catch (historyError) { console.error( `[TaskServer] Failed to add initial history entry: ${historyError}` ) // Potentially return error here if initial logging is critical // For now, we log and continue } try { // --- Database Operations Block --- await databaseService.connect() try { const dbTasks = await databaseService.getTasksByFeatureId(feature_id) const tasks = dbTasks.map(mapDatabaseTaskToAppTask) finalTasks = [...tasks] // Initialize finalTasks with current state if (tasks.length === 0) { message = `Error: No tasks found for feature ID ${feature_id}.` isError = true taskStatusUpdate = { isError: true, status: 'feature_not_found' } // No further DB ops needed, exit the inner try block } else { const taskIndex = tasks.findIndex((task) => task.id === task_id) if (taskIndex === -1) { message = `Error: Task with ID ${task_id} not found in feature ${feature_id}.` isError = true taskStatusUpdate = { isError: true, status: 'task_not_found' } } else { const taskToUpdate = tasks[taskIndex] if (taskToUpdate.status === 'completed') { message = `Task ${task_id} was already marked as complete.` isError = false // Not an error, just informational taskStatusUpdate = { isError: false, status: 'already_completed', taskId: task_id, } // No DB update needed, but update finalTasks for consistency finalTasks = [...tasks] } else { // Mark the task as completed locally first for checks finalTasks = tasks.map((task) => task.id === task_id ? { ...task, status: 'completed' as const } : task ) // Perform the actual database update for the main task await databaseService.updateTaskStatus(task_id, 'completed', true) message = `Task ${task_id} marked as complete.` taskStatusUpdate = { isError: false, status: 'completed', taskId: task_id, } logToFile( `[TaskServer] Task ${task_id} DB status updated to completed.` ) // Check for parent task completion if (taskToUpdate.parentTaskId) { const parentId = taskToUpdate.parentTaskId const siblingTasks = finalTasks.filter( (t) => t.parentTaskId === parentId && t.id !== task_id // Exclude current task if needed, already marked completed ) const allSubtasksComplete = siblingTasks.every( (st) => st.status === 'completed' ) if (allSubtasksComplete) { logToFile( `[TaskServer] All subtasks for parent ${parentId} complete. Updating parent.` ) await databaseService.updateTaskStatus( parentId, 'decomposed', false ) // Update parent status in our finalTasks list as well finalTasks = finalTasks.map((task) => task.id === parentId ? { ...task, status: 'decomposed' as const } : task ) message += ` Parent task ${parentId} status updated as all subtasks are now complete.` taskStatusUpdate = { isError: false, status: 'completed_with_parent_decomposed', taskId: task_id, parentTaskId: parentId, } logToFile( `[TaskServer] Parent task ${parentId} DB status updated to decomposed.` ) } } // Fetch final state *after* all updates const dbFinalState = await databaseService.getTasksByFeatureId( feature_id ) finalTasks = dbFinalState.map(mapDatabaseTaskToAppTask) logToFile(`[TaskServer] Final task state fetched after updates.`) } } } } finally { // Ensure DB connection is closed try { await databaseService.close() logToFile(`[TaskServer] Database connection closed successfully.`) } catch (closeError) { console.error( `[TaskServer] Error closing database connection: ${closeError}` ) // Don't mask the original error if one occurred if (!isError) { message = `Error closing database: ${closeError}` isError = true taskStatusUpdate = { isError: true, status: 'db_close_error' } } } } // --- End Database Operations Block --- // --- Post-DB Operations (History, WS, Response) --- // Broadcast updates via WebSocket if DB ops were successful (or partially successful) if ( taskStatusUpdate.status !== 'unknown' && taskStatusUpdate.status !== 'feature_not_found' && taskStatusUpdate.status !== 'task_not_found' ) { try { webSocketService.notifyTasksUpdated(feature_id, finalTasks) if ( taskStatusUpdate.status === 'completed' || taskStatusUpdate.status === 'completed_with_parent_decomposed' ) { webSocketService.notifyTaskStatusChanged( feature_id, task_id, 'completed' ) } if ( taskStatusUpdate.status === 'completed_with_parent_decomposed' && taskStatusUpdate.parentTaskId ) { webSocketService.notifyTaskStatusChanged( feature_id, taskStatusUpdate.parentTaskId, 'decomposed' ) } logToFile( `[TaskServer] Broadcast WebSocket events for feature ${feature_id}` ) } catch (wsError) { logToFile( `[TaskServer] Warning: Failed to broadcast task update: ${wsError}` ) // Don't fail the overall operation } } // Record final outcome in history try { await addHistoryEntry(feature_id, 'tool_response', { tool: 'mark_task_complete', isError: isError, message: message, ...taskStatusUpdate, // Add status details }) } catch (historyError) { console.error( `[TaskServer] Failed to add final history entry: ${historyError}` ) // If history fails here, the main operation still succeeded or failed as determined before } // If there was an error identified during DB ops, return error now if (isError) { return { content: [{ type: 'text', text: message }], isError: true } } // If successful, find and return the next task return getNextTaskAfterCompletion(finalTasks, message, feature_id) } catch (error) { // Catch errors from the main DB block or other unexpected issues const errorMsg = `Error processing mark_task_complete request: ${ error instanceof Error ? error.message : String(error) }` console.error(`[TaskServer] ${errorMsg}`, error) isError = true message = errorMsg // Record error in history (attempt) try { await addHistoryEntry(feature_id, 'tool_response', { tool: 'mark_task_complete', isError: true, message: errorMsg, error: error instanceof Error ? error.message : String(error), status: 'processing_error', }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during failure: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError: true } } } /** * Gets the next task after completion and formats the response with both completion message and next task info */ async function getNextTaskAfterCompletion( tasks: Task[], completionMessage: string, featureId: string ): Promise<MarkTaskCompleteResult> { // Find the first pending task in the list const nextTask = tasks.find((task) => task.status === 'pending') // Prevent infinite review loop: only trigger review if there are no review tasks yet const hasReviewTasks = tasks.some((task) => task.fromReview) if (!nextTask) { await logToFile( `[TaskServer] No pending tasks remaining for feature ID: ${featureId}. Completion message: "${completionMessage}"` ) let finalMessage = `${completionMessage}\n\nAll tasks have been completed for this feature.` const historyPayload: any = { tool: 'mark_task_complete', isError: false, message: finalMessage, // Keep original message for history initially status: 'all_completed', } let resultPayload: any = [{ type: 'text', text: finalMessage }] // Only trigger auto-review if there are no review tasks yet if (AUTO_REVIEW_ON_COMPLETION && !hasReviewTasks) { await logToFile( `[TaskServer] Auto-review enabled for feature ${featureId}. Initiating review.` ) historyPayload.status = 'all_completed_auto_review_started' // Update history status historyPayload.autoReviewTriggered = true try { // Retrieve project_path for this feature const project_path = await getProjectPathForFeature(featureId) // Call handleReviewChanges to generate and save review tasks const reviewResult = await handleReviewChanges({ featureId: featureId, project_path, }) if (reviewResult.isError) { finalMessage += `\n\nAuto-review failed: ${ reviewResult.content[0]?.text || 'Unknown error' }` historyPayload.isError = true historyPayload.reviewError = reviewResult.content[0]?.text logToFile( `[TaskServer] Auto-review process failed for ${featureId}: ${reviewResult.content[0]?.text}` ) } else { // Review succeeded, tasks were added (or no tasks were needed) logToFile( `[TaskServer] Auto-review process completed for ${featureId}. Fetching updated tasks...` ) // Fetch the updated task list including any new review tasks let updatedTasks: Task[] = [] try { await databaseService.connect() const dbFinalState = await databaseService.getTasksByFeatureId( featureId ) updatedTasks = dbFinalState.map(mapDatabaseTaskToAppTask) await databaseService.close() logToFile( `[TaskServer] Fetched ${updatedTasks.length} total tasks for ${featureId} after review.` ) // Notify UI with the updated task list webSocketService.notifyTasksUpdated(featureId, updatedTasks) logToFile( `[TaskServer] Sent tasks_updated notification for ${featureId} after review.` ) finalMessage += `\n\nAuto-review completed. Review tasks may have been added. Run "get_next_task" to verify.` historyPayload.status = 'all_completed_auto_review_finished' // Update history status historyPayload.reviewResult = reviewResult.content[0]?.text // Log the original review result text } catch (dbError) { const dbErrorMsg = `Error fetching/updating tasks after review: ${ dbError instanceof Error ? dbError.message : String(dbError) }` logToFile(`[TaskServer] ${dbErrorMsg}`) finalMessage += `\n\nAuto-review ran, but failed to update task list: ${dbErrorMsg}` historyPayload.isError = true // Mark history as error if fetching/notifying fails historyPayload.postReviewError = dbErrorMsg } } // Update the result payload with the final message resultPayload = [{ type: 'text', text: finalMessage }] } catch (reviewError) { const reviewErrorMsg = `Error during auto-review execution: ${ reviewError instanceof Error ? reviewError.message : String(reviewError) }` logToFile(`[TaskServer] ${reviewErrorMsg}`) finalMessage += `\n\nAuto-review execution failed: ${reviewErrorMsg}` historyPayload.isError = true historyPayload.reviewExecutionError = reviewErrorMsg resultPayload = [{ type: 'text', text: finalMessage }] } } // Record completion/review trigger in history await addHistoryEntry(featureId, 'tool_response', historyPayload) return { content: resultPayload, } } // Found the next task await logToFile(`[TaskServer] Found next sequential task: ${nextTask.id}`) // Include effort in the message if available const effortInfo = nextTask.effort ? ` (Effort: ${nextTask.effort})` : '' // Include parent info if this is a subtask let parentInfo = '' if (nextTask.parentTaskId) { // Find the parent task const parentTask = tasks.find((t) => t.id === nextTask.parentTaskId) if (parentTask) { const parentDesc = (parentTask?.description?.length ?? 0) > 30 ? (parentTask?.description?.substring(0, 30) ?? '') + '...' : parentTask?.description ?? '' parentInfo = ` (Subtask of: "${parentDesc}")` } else { parentInfo = ` (Subtask of parent ID: ${nextTask.parentTaskId})` // Fallback if parent not found } } // Embed ID, description, effort, and parent info in the text message const nextTaskMessage = `Next pending task (ID: ${nextTask.id})${effortInfo}${parentInfo}: ${nextTask.description}` // Combine completion message with next task info const message = `${completionMessage}\n\n${nextTaskMessage}` // Record in history await addHistoryEntry(featureId, 'tool_response', { tool: 'mark_task_complete', isError: false, message, nextTask: nextTask, }) return { content: [{ type: 'text', text: message }], } } ``` -------------------------------------------------------------------------------- /src/services/aiService.ts: -------------------------------------------------------------------------------- ```typescript import { GoogleGenerativeAI, GenerativeModel, GenerateContentResult, GoogleGenerativeAIError, } from '@google/generative-ai' import OpenAI, { OpenAIError } from 'openai' import { logToFile } from '../lib/logger' import { GEMINI_API_KEY, OPENROUTER_API_KEY, GEMINI_MODEL, OPENROUTER_MODEL, REVIEW_LLM_API_KEY, safetySettings, FALLBACK_GEMINI_MODEL, FALLBACK_OPENROUTER_MODEL, } from '../config' import { z } from 'zod' import { parseAndValidateJsonResponse } from '../lib/llmUtils' type StructuredCallResult<T extends z.ZodType, R> = | { success: true; data: z.infer<T>; rawResponse: R } | { success: false; error: string; rawResponse?: R | null } // Class to manage AI models and provide access to them class AIService { private genAI: GoogleGenerativeAI | null = null private openRouter: OpenAI | null = null private planningModel: GenerativeModel | undefined private reviewModel: GenerativeModel | undefined private initialized = false constructor() { this.initialize() } private initialize(): void { // Initialize OpenRouter if API key is available if (OPENROUTER_API_KEY) { try { this.openRouter = new OpenAI({ apiKey: OPENROUTER_API_KEY, baseURL: 'https://openrouter.ai/api/v1', }) console.error( '[TaskServer] LOG: OpenRouter SDK initialized successfully.' ) } catch (sdkError) { console.error( '[TaskServer] CRITICAL ERROR initializing OpenRouter SDK:', sdkError ) } } else if (GEMINI_API_KEY) { try { this.genAI = new GoogleGenerativeAI(GEMINI_API_KEY) // Configure the model. this.planningModel = this.genAI.getGenerativeModel({ model: GEMINI_MODEL, }) this.reviewModel = this.genAI.getGenerativeModel({ model: GEMINI_MODEL, }) console.error( '[TaskServer] LOG: Google AI SDK initialized successfully.' ) } catch (sdkError) { console.error( '[TaskServer] CRITICAL ERROR initializing Google AI SDK:', sdkError ) } } else { console.error( '[TaskServer] WARNING: Neither OPENROUTER_API_KEY nor GEMINI_API_KEY environment variable is set. API calls will fail.' ) } this.initialized = true } /** * Gets the appropriate planning model for task planning */ getPlanningModel(): GenerativeModel | OpenAI | null { logToFile( `[TaskServer] Planning model: ${JSON.stringify( this.openRouter ? 'OpenRouter' : 'Gemini' )}` ) return this.openRouter || this.planningModel || null } /** * Gets the appropriate review model for code reviews */ getReviewModel(): GenerativeModel | OpenAI | null { return this.openRouter || this.reviewModel || null } /** * Extracts the text content from an AI API result. * Handles both OpenRouter and Gemini responses. */ extractTextFromResponse( result: | GenerateContentResult | OpenAI.Chat.Completions.ChatCompletion | undefined ): string | null { // For OpenRouter responses if ( result && 'choices' in result && result.choices && result.choices.length > 0 ) { const choice = result.choices[0] if (choice.message && choice.message.content) { return choice.message.content } return null } // For Gemini responses if (result && 'response' in result) { try { const response = result.response if (response.promptFeedback?.blockReason) { console.error( `[TaskServer] Gemini response blocked: ${response.promptFeedback.blockReason}` ) return null } if (response.candidates && response.candidates.length > 0) { const candidate = response.candidates[0] if (candidate.content?.parts?.[0]?.text) { return candidate.content.parts[0].text } } console.error( '[TaskServer] No text content found in Gemini response candidate.' ) return null } catch (error) { console.error( '[TaskServer] Error extracting text from Gemini response:', error ) return null } } return null } /** * Extracts and validates structured data from an AI API result. * Handles both OpenRouter and Gemini responses and validates against a schema. * * @param result The raw API response from either OpenRouter or Gemini * @param schema The Zod schema to validate against * @returns An object with either validated data or error information */ extractStructuredResponse<T extends z.ZodType>( result: | GenerateContentResult | OpenAI.Chat.Completions.ChatCompletion | undefined, schema: T ): | { success: true; data: z.infer<T> } | { success: false; error: string; rawData: any | null } { // First extract text content using existing method const textContent = this.extractTextFromResponse(result) // Then parse and validate as JSON against the schema return parseAndValidateJsonResponse(textContent, schema) } /** * Makes a structured OpenRouter API call with JSON schema validation * * @param modelName The model to use for the request * @param messages The messages to send to the model * @param schema The Zod schema to validate the response against * @param options Additional options for the API call * @returns A promise that resolves to the validated data or error information */ async callOpenRouterWithSchema<T extends z.ZodType>( modelName: string, messages: Array<OpenAI.Chat.ChatCompletionMessageParam>, schema: T, options: { temperature?: number max_tokens?: number } = {}, isRetry: boolean = false ): Promise<StructuredCallResult<T, OpenAI.Chat.Completions.ChatCompletion>> { if (!this.openRouter) { return { success: false, error: 'OpenRouter client is not initialized', rawResponse: null, } } const currentModel = isRetry ? FALLBACK_OPENROUTER_MODEL : modelName await logToFile( `[AIService] Calling OpenRouter model: ${currentModel}${ isRetry ? ' (Fallback)' : '' }` ) let response: OpenAI.Chat.Completions.ChatCompletion | null = null try { response = await this.openRouter.chat.completions.create({ model: currentModel, messages, temperature: options.temperature ?? 0.7, max_tokens: options.max_tokens, response_format: { type: 'json_object' }, }) const openRouterError = (response as any)?.error let responseBodyRateLimitDetected = false if (openRouterError) { await logToFile( `[AIService] OpenRouter response contains error object: ${JSON.stringify( openRouterError )}` ) if ( openRouterError.code === 429 || openRouterError.status === 'RESOURCE_EXHAUSTED' || (typeof openRouterError.message === 'string' && openRouterError.message.includes('quota')) ) { responseBodyRateLimitDetected = true } } if (responseBodyRateLimitDetected && !isRetry) { await logToFile( `[AIService] Rate limit (429) detected in response body for ${currentModel}. Retrying with fallback ${FALLBACK_OPENROUTER_MODEL}...` ) return this.callOpenRouterWithSchema( modelName, messages, schema, options, true ) } const textContent = this.extractTextFromResponse(response) const validationResult = parseAndValidateJsonResponse(textContent, schema) if (openRouterError && !validationResult.success) { await logToFile( `[AIService] Non-retryable error detected in response body for ${currentModel}.` ) return { success: false, error: `API response contained error: ${ openRouterError.message || 'Unknown error' }`, rawResponse: response, } } if (validationResult.success) { return { success: true, data: validationResult.data, rawResponse: response, } } else { await logToFile( `[AIService] Schema validation failed for ${currentModel}: ${ validationResult.error }. Raw data: ${JSON.stringify(validationResult.rawData)?.substring( 0, 200 )}` ) const errorMessage = openRouterError?.message ? `API response contained error: ${openRouterError.message}` : validationResult.error return { success: false, error: errorMessage, rawResponse: response, } } } catch (error: any) { await logToFile( `[AIService] API call failed for ${currentModel}. Error: ${ error.message }, Status: ${error.status || 'unknown'}` ) let isRateLimitError = false if (error instanceof OpenAIError && (error as any).status === 429) { isRateLimitError = true } else if (error.status === 429) { isRateLimitError = true } if (isRateLimitError && !isRetry) { await logToFile( `[AIService] Rate limit hit (thrown error ${ error.status || 429 }) for ${currentModel}. Retrying with fallback ${FALLBACK_OPENROUTER_MODEL}...` ) return this.callOpenRouterWithSchema( FALLBACK_OPENROUTER_MODEL, messages, schema, options, true ) } const rawErrorResponse = error?.response return { success: false, error: `API call failed: ${error.message}`, rawResponse: rawErrorResponse || null, } } } /** * Makes a structured Gemini API call with JSON schema validation. * Note: Gemini currently has limited built-in JSON schema support, * so we use prompt engineering to get structured output. * * @param modelName The model to use for the request * @param prompt The prompt to send to the model * @param schema The Zod schema to validate the response against * @param options Additional options for the API call * @returns A promise that resolves to the validated data or error information */ async callGeminiWithSchema<T extends z.ZodType>( modelName: string, prompt: string, schema: T, options: { temperature?: number maxOutputTokens?: number } = {}, isRetry: boolean = false ): Promise< | { success: true; data: z.infer<T>; rawResponse: GenerateContentResult } | { success: false error: string rawResponse?: GenerateContentResult | null } > { if (!this.genAI) { return { success: false, error: 'Gemini client is not initialized', rawResponse: null, } } const currentModelName = isRetry ? FALLBACK_GEMINI_MODEL : modelName await logToFile( `[AIService] Calling Gemini model: ${currentModelName}${ isRetry ? ' (Fallback)' : '' }` ) const schemaDescription = this.createSchemaDescription(schema) const enhancedPrompt = `${prompt}\n\nYour response must be a valid JSON object with the following structure:\n${schemaDescription}\n\nEnsure your response is valid JSON with no markdown formatting or additional text.` try { const model = this.genAI.getGenerativeModel({ model: currentModelName }) const response = await model.generateContent({ contents: [{ role: 'user', parts: [{ text: enhancedPrompt }] }], generationConfig: { temperature: options.temperature ?? 0.7, maxOutputTokens: options.maxOutputTokens, }, safetySettings, }) const textContent = this.extractTextFromResponse(response) const validationResult = parseAndValidateJsonResponse(textContent, schema) if (validationResult.success) { return { success: true, data: validationResult.data, rawResponse: response, } } else { await logToFile( `[AIService] Schema validation failed for ${currentModelName}: ${ validationResult.error }. Raw data: ${JSON.stringify(validationResult.rawData)?.substring( 0, 200 )}` ) return { success: false, error: validationResult.error, rawResponse: response, } } } catch (error: any) { await logToFile( `[AIService] API call failed for ${currentModelName}. Error: ${error.message}` ) let isRateLimitError = false if ( error instanceof GoogleGenerativeAIError && error.message.includes('RESOURCE_EXHAUSTED') ) { isRateLimitError = true } else if (error.status === 429) { isRateLimitError = true } if (isRateLimitError && !isRetry) { await logToFile( `[AIService] Rate limit hit for ${currentModelName}. Retrying with fallback model ${FALLBACK_GEMINI_MODEL}...` ) return this.callGeminiWithSchema( FALLBACK_GEMINI_MODEL, prompt, schema, options, true ) } return { success: false, error: `API call failed: ${error.message}`, rawResponse: null, } } } /** * Creates a human-readable description of a Zod schema for prompt engineering */ private createSchemaDescription(schema: z.ZodType): string { // Use the schema describe functionality to extract metadata const description = schema._def.description ?? 'JSON object' // For object schemas, extract shape information if (schema instanceof z.ZodObject) { const shape = schema._def.shape() const fields = Object.entries(shape).map(([key, field]) => { const fieldType = this.getZodTypeDescription(field as z.ZodType) const fieldDesc = (field as z.ZodType)._def.description || '' return ` "${key}": ${fieldType}${fieldDesc ? ` // ${fieldDesc}` : ''}` }) return `{\n${fields.join(',\n')}\n}` } // For array schemas if (schema instanceof z.ZodArray) { const elementType = this.getZodTypeDescription(schema._def.type) return `[\n ${elementType} // Array of items\n]` } // For other types return description } /** * Gets a simple description of a Zod type for schema representation */ private getZodTypeDescription(schema: z.ZodType): string { if (schema instanceof z.ZodString) return '"string"' if (schema instanceof z.ZodNumber) return 'number' if (schema instanceof z.ZodBoolean) return 'boolean' if (schema instanceof z.ZodArray) { const elementType = this.getZodTypeDescription(schema._def.type) return `[${elementType}]` } if (schema instanceof z.ZodObject) { const shape = schema._def.shape() const fields = Object.entries(shape).map(([key]) => `"${key}"`) return `{ ${fields.join(', ')} }` } if (schema instanceof z.ZodEnum) { const values = schema._def.values.map((v: string) => `"${v}"`) return `one of: ${values.join(' | ')}` } return 'any' } /** * Checks if the service is properly initialized */ isInitialized(): boolean { return this.initialized && (!!this.openRouter || !!this.planningModel) } } // Export a singleton instance export const aiService = new AIService() ``` -------------------------------------------------------------------------------- /src/services/databaseService.ts: -------------------------------------------------------------------------------- ```typescript import sqlite3 from 'sqlite3' import fs from 'fs' import path from 'path' import { promisify } from 'util' import { SQLITE_DB_PATH } from '../config' import logger from '../lib/winstonLogger' // Define Task type for database operations interface Task { id: string title?: string description?: string status: 'pending' | 'in_progress' | 'completed' | 'decomposed' completed: boolean effort?: 'low' | 'medium' | 'high' feature_id?: string parent_task_id?: string created_at: number updated_at: number fromReview?: boolean } // Define interface for task updates interface TaskUpdate { title?: string description?: string effort?: 'low' | 'medium' | 'high' parent_task_id?: string fromReview?: boolean } // Define History Entry type for database operations export interface HistoryEntry { id?: number timestamp: number role: 'user' | 'model' | 'tool_call' | 'tool_response' content: string feature_id: string task_id?: string action?: string details?: string } class DatabaseService { private db: sqlite3.Database | null = null private dbPath: string constructor(dbPath: string = SQLITE_DB_PATH) { this.dbPath = dbPath try { this.ensureDatabaseDirectory() } catch (error: any) { console.error( `[DatabaseService] CRITICAL: Failed to ensure database directory exists at ${path.dirname( this.dbPath )}: ${error.message}` ) } } private ensureDatabaseDirectory(): void { const dbDir = path.dirname(this.dbPath) if (!fs.existsSync(dbDir)) { console.log(`[DatabaseService] Creating database directory: ${dbDir}`) fs.mkdirSync(dbDir, { recursive: true }) } } async connect(): Promise<void> { if (this.db) { logger.debug('[DatabaseService] Already connected.') return Promise.resolve() } logger.debug(`[DatabaseService] Connecting to database at: ${this.dbPath}`) return new Promise((resolve, reject) => { const verboseDb = new (sqlite3.verbose().Database)(this.dbPath, (err) => { if (err) { logger.error(`Error connecting to SQLite database: ${err.message}`, { stack: err.stack, }) reject( new Error(`Error connecting to SQLite database: ${err.message}`) ) return } this.db = verboseDb logger.debug('[DatabaseService] Database connection successful.') resolve() }) }) } async close(): Promise<void> { logger.debug('[DatabaseService] Attempting to close database connection.') return new Promise((resolve, reject) => { if (!this.db) { logger.debug('[DatabaseService] No active connection to close.') resolve() return } this.db.close((err) => { if (err) { logger.error(`Error closing SQLite database: ${err.message}`, { stack: err.stack, }) reject(new Error(`Error closing SQLite database: ${err.message}`)) return } this.db = null logger.debug( '[DatabaseService] Database connection closed successfully.' ) resolve() }) }) } public async runAsync( sql: string, params: any[] = [] ): Promise<sqlite3.RunResult> { if (!this.db) { logger.error( '[DatabaseService] runAsync called but database is not connected.' ) throw new Error('Database is not connected') } return new Promise((resolve, reject) => { this.db!.run(sql, params, function (err) { if (err) { logger.error( `Error executing SQL: ${sql} - Params: ${JSON.stringify( params )} - Error: ${err.message}`, { stack: err.stack } ) reject(new Error(`Error executing SQL: ${err.message}`)) } else { resolve(this) } }) }) } private async runSchemaFromFile(): Promise<void> { const schemaPath = path.join(__dirname, '..', 'config', 'schema.sql') logger.info(`Attempting to run schema from: ${schemaPath}`) if (!fs.existsSync(schemaPath)) { logger.error(`Schema file not found at ${schemaPath}`) throw new Error(`Schema file not found at ${schemaPath}`) } logger.info(`Schema file found at ${schemaPath}`) const schema = fs.readFileSync(schemaPath, 'utf8') const statements = schema .split(';') .map((statement) => statement.trim()) .filter((statement) => statement.length > 0) logger.info(`Found ${statements.length} SQL statements in schema file.`) if (!this.db) { logger.error('Database is not connected in runSchemaFromFile.') throw new Error('Database is not connected') } try { logger.info('Starting transaction for schema execution.') await this.runAsync('BEGIN TRANSACTION;') for (let i = 0; i < statements.length; i++) { const statement = statements[i] logger.debug( `Executing schema statement #${i + 1}: ${statement.substring( 0, 60 )}...` ) await this.runAsync(statement) logger.debug(`Successfully executed statement #${i + 1}`) } logger.info('Committing transaction for schema execution.') await this.runAsync('COMMIT;') logger.info('Schema execution committed successfully.') } catch (error: any) { logger.error( `Error during schema execution: ${error.message}. Rolling back transaction.`, { stack: error.stack } ) try { await this.runAsync('ROLLBACK;') logger.info('Transaction rolled back successfully.') } catch (rollbackError: any) { logger.error(`Failed to rollback transaction: ${rollbackError.message}`) } throw new Error(`Schema execution failed: ${error.message}`) } } async tableExists(tableName: string): Promise<boolean> { if (!this.db) { logger.error( '[DatabaseService] tableExists called but database is not connected.' ) throw new Error('Database is not connected') } return new Promise((resolve, reject) => { this.db!.get( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", [tableName], (err, row) => { if (err) { logger.error( `Error checking if table ${tableName} exists: ${err.message}` ) reject(err) } else { resolve(!!row) } } ) }) } async initializeDatabase(): Promise<void> { if (!this.db) { logger.info( '[DatabaseService] Connecting DB within initializeDatabase...' ) await this.connect() } else { logger.debug('[DatabaseService] DB already connected for initialization.') } try { logger.info('[DatabaseService] Checking if tables exist...') const tablesExist = await this.tableExists('tasks') logger.info( `[DatabaseService] 'tasks' table exists check returned: ${tablesExist}` ) if (!tablesExist) { logger.info( '[DatabaseService] Initializing database schema as tables do not exist...' ) await this.runSchemaFromFile() logger.info( '[DatabaseService] Database schema initialization complete.' ) } else { logger.info( '[DatabaseService] Database tables already exist. Skipping schema initialization.' ) } } catch (error: any) { logger.error(`Error during database initialization: ${error.message}`, { stack: error.stack, }) console.error('Error initializing database:', error) throw error } } async runMigrations(): Promise<void> { if (!this.db) { throw new Error('Database is not connected') } try { // Run schema first to create tables if they don't exist await this.runSchemaFromFile() // Run migrations to update existing tables await this.runMigrationsFromFile() } catch (error) { console.error('Error running migrations:', error) throw error } } private async runMigrationsFromFile(): Promise<void> { // Use __dirname to reliably locate the file relative to the compiled JS file const migrationsPath = path.join( __dirname, '..', 'config', 'migrations.sql' ) console.log( `[DB Service] Attempting to load migrations from: ${migrationsPath}` ) // Log path if (!fs.existsSync(migrationsPath)) { console.log( `[DB Service] Migrations file not found at ${migrationsPath}, skipping migrations.` // Adjusted log level ) return } console.log( `[DB Service] Migrations file found at ${migrationsPath}. Reading...` ) // Log if found const migrations = fs.readFileSync(migrationsPath, 'utf8') const statements = migrations .split(';') .map((statement) => statement.trim()) .filter((statement) => statement.length > 0) console.log( `[DB Service] Executing ${statements.length} statements from migrations.sql...` ) // Log count for (const statement of statements) { try { console.log( `[DB Service] Executing migration statement: ${statement.substring( 0, 100 )}...` ) // Log statement (truncated) await this.runAsync(statement) } catch (error: any) { // Only ignore the error if it's specifically about a duplicate column if (error?.message?.includes('duplicate column name')) { console.log( `[DB Service] Migration statement likely already applied (duplicate column): ${statement}` // Adjusted log ) } else { // Re-throw any other error during migration console.error( `[DB Service] Migration statement failed: ${statement}`, error ) // Adjusted log throw error } } } console.log(`[DB Service] Finished executing migration statements.`) // Log completion } async get(sql: string, params: any[] = []): Promise<any> { if (!this.db) { throw new Error('Database is not connected') } return new Promise((resolve, reject) => { this.db!.get(sql, params, (err, row) => { if (err) { reject(`Error executing SQL: ${err.message}`) return } resolve(row) }) }) } async all(sql: string, params: any[] = []): Promise<any[]> { if (!this.db) { throw new Error('Database is not connected') } return new Promise((resolve, reject) => { this.db!.all(sql, params, (err, rows) => { if (err) { reject(`Error executing SQL: ${err.message}`) return } resolve(rows) }) }) } async getTasksByFeatureId(featureId: string): Promise<Task[]> { if (!this.db) { throw new Error('Database is not connected') } try { const rows = await this.all( `SELECT id, title, description, status, completed, effort, feature_id, parent_task_id, created_at, updated_at, from_review FROM tasks WHERE feature_id = ? ORDER BY created_at ASC`, [featureId] ) return rows.map((row) => ({ ...row, completed: Boolean(row.completed), fromReview: Boolean(row.from_review), })) } catch (error) { console.error(`Error fetching tasks for feature ${featureId}:`, error) throw error } } async getTaskById(taskId: string): Promise<Task | null> { if (!this.db) { throw new Error('Database is not connected') } try { const row = await this.get( `SELECT id, title, description, status, completed, effort, feature_id, parent_task_id, created_at, updated_at, from_review FROM tasks WHERE id = ?`, [taskId] ) if (!row) { return null } return { ...row, completed: Boolean(row.completed), fromReview: Boolean(row.from_review), } } catch (error) { console.error(`Error fetching task ${taskId}:`, error) throw error } } async addTask(task: Task): Promise<string> { if (!this.db) { throw new Error('Database is not connected') } const now = Math.floor(Date.now() / 1000) const timestamp = task.created_at || now try { await this.runAsync( `INSERT INTO tasks ( id, title, description, status, completed, effort, feature_id, parent_task_id, created_at, updated_at, from_review ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ task.id, task.title || null, task.description || null, task.status, task.completed ? 1 : 0, task.effort || null, task.feature_id || null, task.parent_task_id || null, timestamp, task.updated_at || timestamp, task.fromReview ? 1 : 0, ] ) return task.id } catch (error) { console.error('Error adding task:', error) throw error } } async updateTaskStatus( taskId: string, status: 'pending' | 'in_progress' | 'completed' | 'decomposed', completed?: boolean ): Promise<boolean> { if (!this.db) { throw new Error('Database is not connected') } const now = Math.floor(Date.now() / 1000) try { let result if (completed !== undefined) { result = await this.runAsync( `UPDATE tasks SET status = ?, completed = ?, updated_at = ? WHERE id = ?`, [status, completed ? 1 : 0, now, taskId] ) } else { result = await this.runAsync( `UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`, [status, now, taskId] ) } return result.changes > 0 } catch (error) { console.error(`Error updating status for task ${taskId}:`, error) throw error } } async updateTaskDetails( taskId: string, updates: TaskUpdate ): Promise<boolean> { if (!this.db) { throw new Error('Database is not connected') } const now = Math.floor(Date.now() / 1000) try { const task = await this.getTaskById(taskId) if (!task) { return false } const updatedTask = { ...task, title: updates.title ?? task.title, description: updates.description ?? task.description, effort: updates.effort ?? task.effort, parent_task_id: updates.parent_task_id ?? task.parent_task_id, fromReview: updates.fromReview !== undefined ? updates.fromReview : task.fromReview, updated_at: now, } const result = await this.runAsync( `UPDATE tasks SET title = ?, description = ?, effort = ?, parent_task_id = ?, updated_at = ?, from_review = ? WHERE id = ?`, [ updatedTask.title || null, updatedTask.description || null, updatedTask.effort || null, updatedTask.parent_task_id || null, updatedTask.updated_at, updatedTask.fromReview ? 1 : 0, taskId, ] ) return result.changes > 0 } catch (error) { console.error(`Error updating details for task ${taskId}:`, error) throw error } } async deleteTask(taskId: string): Promise<boolean> { if (!this.db) { throw new Error('Database is not connected') } try { // Begin transaction await this.runAsync('BEGIN TRANSACTION') try { // Delete any task relationships first await this.runAsync( 'DELETE FROM task_relationships WHERE parent_id = ? OR child_id = ?', [taskId, taskId] ) // Finally delete the task const result = await this.runAsync('DELETE FROM tasks WHERE id = ?', [ taskId, ]) // Commit transaction await this.runAsync('COMMIT') return result.changes > 0 } catch (error) { // Rollback in case of error await this.runAsync('ROLLBACK') throw error } } catch (error) { console.error(`Error deleting task ${taskId}:`, error) throw error } } // History Entry Operations async getHistoryByFeatureId( featureId: string, limit: number = 100 ): Promise<HistoryEntry[]> { if (!this.db) { throw new Error('Database is not connected') } try { const rows = await this.all( `SELECT id, timestamp, role, content, feature_id, task_id, action, details FROM history_entries WHERE feature_id = ? ORDER BY timestamp DESC LIMIT ?`, [featureId, limit] ) return rows.map((row) => ({ ...row, content: typeof row.content === 'string' ? JSON.parse(row.content) : row.content, })) } catch (error) { console.error(`Error fetching history for feature ${featureId}:`, error) throw error } } async addHistoryEntry(entry: HistoryEntry): Promise<number> { if (!this.db) { throw new Error('Database is not connected') } const now = Math.floor(Date.now() / 1000) const timestamp = entry.timestamp || now const content = typeof entry.content === 'object' ? JSON.stringify(entry.content) : entry.content try { const result = await this.runAsync( `INSERT INTO history_entries ( timestamp, role, content, feature_id, task_id, action, details ) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ timestamp, entry.role, content, entry.feature_id, entry.task_id || null, entry.action || null, entry.details || null, ] ) return result.lastID } catch (error) { console.error('Error adding history entry:', error) throw error } } async deleteHistoryByFeatureId(featureId: string): Promise<boolean> { if (!this.db) { throw new Error('Database is not connected') } try { const result = await this.runAsync( 'DELETE FROM history_entries WHERE feature_id = ?', [featureId] ) return result.changes > 0 } catch (error) { console.error(`Error deleting history for feature ${featureId}:`, error) throw error } } // Feature Management /** * Creates a new feature in the database * @param id The feature ID * @param description The feature description * @param projectPath The project path for the feature * @returns The created feature */ async createFeature( id: string, description: string, projectPath: string ): Promise<{ id: string; description: string; project_path: string }> { try { const now = Math.floor(Date.now() / 1000) await this.connect() await this.runAsync( `INSERT INTO features (id, description, project_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, [id, description, projectPath, now, now] ) await this.close() return { id, description, project_path: projectPath } } catch (error) { console.error(`Error creating feature:`, error) throw error } } /** * Gets a feature by ID * @param featureId The feature ID * @returns The feature or null if not found */ async getFeatureById(featureId: string): Promise<{ id: string description: string project_path: string | null status: string } | null> { try { const feature = await this.get( `SELECT id, description, project_path, status FROM features WHERE id = ?`, [featureId] ) return feature || null } catch (error) { console.error(`Error fetching feature ${featureId}:`, error) return null } } } export const databaseService = new DatabaseService() export default DatabaseService ``` -------------------------------------------------------------------------------- /src/tools/reviewChanges.ts: -------------------------------------------------------------------------------- ```typescript // src/tools/reviewChanges.ts import { logToFile } from '../lib/logger' // Use specific log functions import { aiService } from '../services/aiService' import { promisify } from 'util' import { exec } from 'child_process' import crypto from 'crypto' // Import the correct schema for task list output and other necessary types import { Task, ReviewResponseWithTasksSchema, // Schema for review task output PlanFeatureResponseSchema, // Schema for initial plan (if used elsewhere) type Task as AppTask, // Rename if needed to avoid conflict with local Task type/variable } from '../models/types' import { z } from 'zod' import { parseAndValidateJsonResponse, processAndFinalizePlan, // We WILL use this now } from '../lib/llmUtils' import { GIT_DIFF_MAX_BUFFER_MB, GEMINI_MODEL, // Make sure these are imported if needed directly OPENROUTER_MODEL, // Make sure these are imported if needed directly } from '../config' import path from 'path' import { getCodebaseContext } from '../lib/repomixUtils' import { addHistoryEntry, getHistoryForFeature } from '../lib/dbUtils' const execPromise = promisify(exec) interface ReviewChangesParams { featureId: string // Make featureId mandatory project_path?: string } // Use the standard response type interface PlanFeatureStandardResponse { status: 'completed' | 'awaiting_clarification' | 'error' message: string featureId: string taskCount?: number firstTask?: Task | { description: string; effort: string } // Allow slightly different structure if needed uiUrl?: string data?: any // For clarification details or other metadata } interface ReviewChangesResult { content: Array<{ type: 'text'; text: string }> isError?: boolean } export async function handleReviewChanges( params: ReviewChangesParams ): Promise<ReviewChangesResult> { const { featureId, project_path } = params const reviewId = crypto.randomUUID() // Unique ID for this review operation logToFile( `[TaskServer] Handling review_changes request for feature ${featureId} (Review ID: ${reviewId})` ) // Wrap initial history logging try { await addHistoryEntry(featureId, 'tool_call', { tool: 'review_changes', params, reviewId, }) } catch (historyError) { console.error( `[TaskServer] Failed to add initial history entry for review: ${historyError}` ) // Continue execution even if initial history fails } let targetDir = process.cwd() if (project_path) { // Basic check for path traversal characters if (project_path.includes('..') || project_path.includes('~')) { const errorMsg = `Error: Invalid project_path provided: ${project_path}. Path cannot contain '..' or '~'.` await logToFile(`[TaskServer] ${errorMsg}`) // Try to log error to history before returning try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: true, message: errorMsg, reviewId, step: 'invalid_path', }) } catch (historyError) { /* Ignore */ } return { content: [{ type: 'text', text: errorMsg }], isError: true } } // Resolve the path and check it's within a reasonable base (e.g., current working directory) const resolvedPath = path.resolve(project_path) const cwd = process.cwd() // This is a basic check; more robust checks might compare against a known workspace root if (!resolvedPath.startsWith(cwd)) { const errorMsg = `Error: Invalid project_path provided: ${project_path}. Path must be within the current workspace.` await logToFile(`[TaskServer] ${errorMsg}`) // Try to log error to history before returning try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: true, message: errorMsg, reviewId, step: 'invalid_path', }) } catch (historyError) { /* Ignore */ } return { content: [{ type: 'text', text: errorMsg }], isError: true } } targetDir = resolvedPath } try { let message: string | null = null let isError = false const reviewModel = aiService.getReviewModel() if (!reviewModel) { message = 'Error: Review model not initialized. Check API Key.' isError = true logToFile(`[TaskServer] ${message} (Review ID: ${reviewId})`) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, reviewId, }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for model init failure: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // --- Get Codebase Context --- (Keep as is) const { context: codebaseContext, error: contextError } = await getCodebaseContext(targetDir, reviewId) if (contextError) { message = contextError isError = true // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, reviewId, step: 'context_error', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for context error: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // --- End Codebase Context --- // --- Git Diff Execution --- (Keep as is) let gitDiff = '' try { await logToFile( `[TaskServer] Running git diff HEAD in directory: ${targetDir}... (reviewId: ${reviewId})` ) // Execute git commands directly in the validated target directory const diffCmd = `git --no-pager diff HEAD` const lsFilesCmd = `git ls-files --others --exclude-standard` const execOptions = { cwd: targetDir, // Set current working directory for the command maxBuffer: GIT_DIFF_MAX_BUFFER_MB * 1024 * 1024, } const { stdout: diffStdout, stderr: diffStderr } = await execPromise( diffCmd, execOptions ) if (diffStderr) { await logToFile( `[TaskServer] git diff stderr: ${diffStderr} (reviewId: ${reviewId})` ) } let combinedDiff = diffStdout const { stdout: untrackedStdout } = await execPromise( lsFilesCmd, execOptions ) const untrackedFiles = untrackedStdout .split('\n') .map((f: string) => f.trim()) .filter((f: string) => f.length > 0) for (const file of untrackedFiles) { // Ensure the filename itself is not malicious (basic check) if (file.includes('..') || file.includes('/') || file.includes('\\')) { await logToFile( `[TaskServer] Skipping potentially unsafe untracked filename: ${file} (reviewId: ${reviewId})` ) continue } // For each untracked file, get its diff const fileDiffCmd = `git --no-pager diff --no-index /dev/null "${file}"` try { const { stdout: fileDiff } = await execPromise( fileDiffCmd, execOptions ) if (fileDiff && fileDiff.trim().length > 0) { combinedDiff += `\n\n${fileDiff}` } } catch (fileDiffErr) { await logToFile( `[TaskServer] Error getting diff for untracked file ${file}: ${fileDiffErr} (reviewId: ${reviewId})` ) } } gitDiff = combinedDiff if (!gitDiff.trim()) { message = 'No staged or untracked changes found to review.' logToFile(`[TaskServer] ${message} (Review ID: ${reviewId})`) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: false, message, reviewId, status: 'no_changes', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for no_changes status: ${historyError}` ) } return { content: [{ type: 'text', text: message }] } } logToFile( `[TaskServer] git diff captured (${gitDiff.length} chars). (Review ID: ${reviewId})` ) } catch (error: any) { message = `Error running git diff: ${error.message || error}` // Assign error message isError = true logToFile(`[TaskServer] ${message} (Review ID: ${reviewId})`, error) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: true, message, reviewId, step: 'git_diff_error', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for git diff error: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // --- End Git Diff --- // --- LLM Call to Generate Tasks from Review --- try { logToFile( `[TaskServer] Calling LLM for review analysis and task generation... (Review ID: ${reviewId})` ) // Fetch history to get original feature request let originalFeatureRequest = 'Original feature request not found.' try { const history: any[] = await getHistoryForFeature(featureId, 200) // Fetch more history if needed const planFeatureCall = history.find( (entry) => entry.role === 'tool_call' && entry.content?.tool === 'plan_feature' && entry.content?.params?.feature_description ) if (planFeatureCall) { originalFeatureRequest = planFeatureCall.content.params.feature_description logToFile( `[TaskServer] Found original feature request for review context: "${originalFeatureRequest.substring( 0, 50 )}..."` ) } else { logToFile( `[TaskServer] Could not find original plan_feature call in history for feature ${featureId}.` ) } } catch (historyError) { logToFile( `[TaskServer] Error fetching history to get original feature request: ${historyError}. Proceeding without it.` ) } const contextPromptPart = codebaseContext ? `\n\nCodebase Context Overview:\n\`\`\`\n${codebaseContext}\n\`\`\`\n` : '\n\n(No overall codebase context was available.)' // *** REVISED Prompt: Ask for TASKS based on checklist criteria *** const structuredPrompt = `You are a senior software engineer performing a code review. Original Feature Request Context: "${originalFeatureRequest}" Review the following code changes (git diff) and consider the overall codebase context (if provided). Your goal is to identify necessary fixes, improvements, or refactorings based on standard best practices and generate a list of actionable coding tasks for another developer to implement. \`\`\`diff ${gitDiff} \`\`\` ${contextPromptPart} **Review Criteria (Generate tasks based on these):** 1. **Functionality:** Does the change work? Are there bugs? Handle edge cases & errors? 2. **Design:** Does it fit the architecture? Is it modular/maintainable (SOLID/DRY)? Overly complex? 3. **Readability:** Is code clear? Are names good? Are comments needed (explaining 'why')? Style consistent? 4. **Maintainability:** Easy to modify/debug/test? Clean dependencies? 5. **Performance:** Obvious bottlenecks? 6. **Security:** Potential vulnerabilities (input validation, etc.)? **Output Format:** Respond ONLY with a single valid JSON object matching this exact schema: { "tasks": [ { "description": "string // Clear, concise description of the required coding action.", "effort": "'low' | 'medium' | 'high' // Estimated effort level." } // ... include all actionable tasks generated from the review. // If NO tasks are needed, return an empty array: "tasks": [] ] } Do NOT include summaries, commentary, or anything outside this JSON structure. Do not use markdown formatting.` let llmResponseData: { tasks: Task[] } | null = null let rawLLMResponse: any = null // Call LLM using aiService - Attempt structured output if ('chat' in reviewModel) { // OpenRouter const structuredResult = await aiService.callOpenRouterWithSchema( OPENROUTER_MODEL, // Use configured or default for review [{ role: 'user', content: structuredPrompt }], ReviewResponseWithTasksSchema, // *** USE THE CORRECT SCHEMA HERE *** { temperature: 0.5 } // Slightly higher temp might be ok for task generation ) rawLLMResponse = structuredResult.rawResponse if (structuredResult.success) { llmResponseData = structuredResult.data as { tasks: Task[] } // Wrap history logging try { await addHistoryEntry(featureId, 'model', { tool: 'review_changes', reviewId, response: llmResponseData, structured: true, }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for successful structured OpenRouter response: ${historyError}` ) } } else { logToFile( `[TaskServer] Structured review task generation failed (OpenRouter): ${structuredResult.error}. Cannot reliably generate tasks from review.` ) message = `Error: AI failed to generate structured tasks based on review: ${structuredResult.error}` isError = true // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, reviewId, step: 'llm_structured_fail', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for OpenRouter structured fail: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } } else if ('generateContentStream' in reviewModel) { // Gemini const structuredResult = await aiService.callGeminiWithSchema( process.env.GEMINI_MODEL || 'gemini-1.5-flash-latest', structuredPrompt, ReviewResponseWithTasksSchema, // *** USE THE CORRECT SCHEMA HERE *** { temperature: 0.5 } ) rawLLMResponse = structuredResult.rawResponse if (structuredResult.success) { llmResponseData = structuredResult.data as { tasks: Task[] } // Wrap history logging try { await addHistoryEntry(featureId, 'model', { tool: 'review_changes', reviewId, response: llmResponseData, structured: true, }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for successful structured Gemini response: ${historyError}` ) } } else { logToFile( `[TaskServer] Structured review task generation failed (Gemini): ${structuredResult.error}. Cannot reliably generate tasks from review.` ) message = `Error: AI failed to generate structured tasks based on review: ${structuredResult.error}` isError = true // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, reviewId, step: 'llm_structured_fail', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for Gemini structured fail: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } } else { message = 'Error: Review model does not support structured output.' isError = true logToFile(`[TaskServer] ${message} (Review ID: ${reviewId})`) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, reviewId, step: 'llm_structured_fail', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for structured fail: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // --- Process and Save Generated Tasks --- if (!llmResponseData || !llmResponseData.tasks) { message = 'Error: LLM response did not contain a valid task list.' isError = true logToFile(`[TaskServer] ${message} (Review ID: ${reviewId})`) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, reviewId, step: 'task_processing_error', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for task processing error: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } if (llmResponseData.tasks.length === 0) { message = 'Code review completed. No immediate action tasks were identified.' logToFile(`[TaskServer] ${message} (Review ID: ${reviewId})`) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: false, message, reviewId, status: 'no_tasks_generated', }) } catch (historyError) { console.error( `[TaskServer] Failed to add history entry for no_tasks_generated status: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError: false } } // Format tasks for processing (like in planFeature) const rawPlanSteps = llmResponseData.tasks.map( (task) => `[${task.effort}] ${task.description}` ) logToFile( `[TaskServer] Generated ${rawPlanSteps.length} tasks from review. Processing... (Review ID: ${reviewId})` ) // Process these tasks (effort check, breakdown, save, notify) // This adds the review-generated tasks to the existing feature plan const finalTasks = await processAndFinalizePlan( rawPlanSteps, reviewModel, // Use the same model for potential breakdown featureId, true // Indicate tasks came from review context ) const taskCount = finalTasks.length // Count tasks *added* or processed const firstNewTask = finalTasks[0] // Get the first task generated by *this* review const responseData: PlanFeatureStandardResponse = { status: 'completed', // Indicates review+task generation is done // Provide a clear message indicating tasks were *added* from review message: `Code review complete. Generated ${taskCount} actionable tasks based on the review. ${ firstNewTask ? 'First new task: "' + firstNewTask.description + '"' : '' } Call 'get_next_task' with featureId '${featureId}' to continue implementation.`, featureId: featureId, taskCount: taskCount, firstTask: firstNewTask ? { description: firstNewTask.description || '', effort: firstNewTask.effort || 'medium', } : undefined, // Ensure effort is present } logToFile( `[TaskServer] Review tasks processed and saved for feature ${featureId}. (Review ID: ${reviewId})` ) // Wrap history logging try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: false, message: responseData.message, reviewId, responseData, }) } catch (historyError) { console.error( `[TaskServer] Failed to add final success history entry: ${historyError}` ) } return { content: [{ type: 'text', text: responseData.message }], isError: false, } } catch (error: any) { message = `Error occurred during review analysis API call: ${error.message}` isError = true logToFile( `[TaskServer] Error calling LLM review API (Review ID: ${reviewId})`, error ) // Wrap history logging inside the catch block try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError, message, error: error.message, reviewId, }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during LLM API call failure: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } } catch (error: any) { // Outer catch already wraps history logging and ignores errors const errorMsg = `Error processing review_changes request: ${error.message}` logToFile(`[TaskServer] ${errorMsg} (Review ID: ${reviewId})`, error) try { await addHistoryEntry(featureId, 'tool_response', { tool: 'review_changes', isError: true, message: errorMsg, reviewId, step: 'preprocessing_error', }) } catch (historyError) { /* Ignore */ } return { content: [{ type: 'text', text: errorMsg }], isError: true } } } ```