Compare commits

..

1 Commits

Author SHA1 Message Date
Kacper
3559e0104c feat: add token consumption tracking per task
- Add TokenUsage interface to track input/output tokens and cost
- Capture usage from Claude SDK result messages in auto-mode-service
- Accumulate token usage across multiple streams and follow-up sessions
- Store token usage in feature.json for persistence
- Display token count and cost on Kanban cards with tooltip breakdown
- Add token usage summary header in log viewer

Closes #166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 03:05:13 +01:00
788 changed files with 32681 additions and 64307 deletions

View File

@@ -21,4 +21,4 @@
"mcp__puppeteer__puppeteer_evaluate"
]
}
}
}

View File

@@ -1,28 +1,28 @@
name: 'Setup Project'
description: 'Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules'
name: "Setup Project"
description: "Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules"
inputs:
node-version:
description: 'Node.js version to use'
description: "Node.js version to use"
required: false
default: '22'
default: "22"
check-lockfile:
description: 'Run lockfile lint check for SSH URLs'
description: "Run lockfile lint check for SSH URLs"
required: false
default: 'false'
default: "false"
rebuild-node-pty-path:
description: 'Working directory for node-pty rebuild (empty = root)'
description: "Working directory for node-pty rebuild (empty = root)"
required: false
default: ''
default: ""
runs:
using: 'composite'
using: "composite"
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache: "npm"
cache-dependency-path: package-lock.json
- name: Check for SSH URLs in lockfile
@@ -52,11 +52,6 @@ runs:
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Build shared packages
shell: bash
# Build shared packages (types, utils, platform, etc.) before apps can use them
run: npm run build:packages
- name: Rebuild native modules (root)
if: inputs.rebuild-node-pty-path == ''
shell: bash

View File

@@ -1,11 +1,15 @@
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('fs');
const path = require('path');
const https = require('https');
const { pipeline } = require('stream/promises');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
} = require("@aws-sdk/client-s3");
const fs = require("fs");
const path = require("path");
const https = require("https");
const { pipeline } = require("stream/promises");
const s3Client = new S3Client({
region: 'auto',
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
@@ -24,14 +28,14 @@ async function fetchExistingReleases() {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
Key: "releases.json",
})
);
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
console.log('No existing releases.json found, creating new one');
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
console.log("No existing releases.json found, creating new one");
return { latestVersion: null, releases: [] };
}
throw error;
@@ -81,7 +85,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
resolve({
accessible: false,
statusCode,
error: 'Redirect without location header',
error: "Redirect without location header",
});
return;
}
@@ -89,16 +93,18 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
return https
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
const redirectStatus = redirectResponse.statusCode;
const contentType = redirectResponse.headers['content-type'] || '';
const contentType =
redirectResponse.headers["content-type"] || "";
// Check if it's actually a file (zip/tar.gz) and not HTML
const isFile =
contentType.includes('application/zip') ||
contentType.includes('application/gzip') ||
contentType.includes('application/x-gzip') ||
contentType.includes('application/x-tar') ||
redirectUrl.includes('.zip') ||
redirectUrl.includes('.tar.gz');
const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
contentType.includes("application/zip") ||
contentType.includes("application/gzip") ||
contentType.includes("application/x-gzip") ||
contentType.includes("application/x-tar") ||
redirectUrl.includes(".zip") ||
redirectUrl.includes(".tar.gz");
const isGood =
redirectStatus >= 200 && redirectStatus < 300 && isFile;
redirectResponse.destroy();
resolve({
accessible: isGood,
@@ -107,38 +113,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
contentType,
});
})
.on('error', (error) => {
.on("error", (error) => {
resolve({
accessible: false,
statusCode,
error: error.message,
});
})
.on('timeout', function () {
.on("timeout", function () {
this.destroy();
resolve({
accessible: false,
statusCode,
error: 'Timeout following redirect',
error: "Timeout following redirect",
});
});
}
// Check if status is good (200-299 range) and it's actually a file
const contentType = response.headers['content-type'] || '';
const contentType = response.headers["content-type"] || "";
const isFile =
contentType.includes('application/zip') ||
contentType.includes('application/gzip') ||
contentType.includes('application/x-gzip') ||
contentType.includes('application/x-tar') ||
url.includes('.zip') ||
url.includes('.tar.gz');
contentType.includes("application/zip") ||
contentType.includes("application/gzip") ||
contentType.includes("application/x-gzip") ||
contentType.includes("application/x-tar") ||
url.includes(".zip") ||
url.includes(".tar.gz");
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
response.destroy();
resolve({ accessible: isGood, statusCode, contentType });
});
request.on('error', (error) => {
request.on("error", (error) => {
resolve({
accessible: false,
statusCode: null,
@@ -146,12 +152,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
});
});
request.on('timeout', () => {
request.on("timeout", () => {
request.destroy();
resolve({
accessible: false,
statusCode: null,
error: 'Request timeout',
error: "Request timeout",
});
});
});
@@ -162,14 +168,22 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
);
} else {
console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
console.log(
`✓ URL ${url} is accessible (status: ${result.statusCode})`
);
}
return result.finalUrl || url; // Return the final URL (after redirects) if available
} else {
const errorMsg = result.error ? ` - ${result.error}` : '';
const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
const errorMsg = result.error ? ` - ${result.error}` : "";
const statusMsg = result.statusCode
? ` (status: ${result.statusCode})`
: "";
const contentTypeMsg = result.contentType
? ` [content-type: ${result.contentType}]`
: "";
console.log(
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
);
}
} catch (error) {
console.log(`✗ URL ${url} check failed: ${error.message}`);
@@ -177,7 +191,9 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
if (attempt < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, attempt);
console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
console.log(
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
@@ -191,7 +207,12 @@ async function downloadFromGitHub(url, outputPath) {
const statusCode = response.statusCode;
// Follow redirects (all redirect types)
if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
if (
statusCode === 301 ||
statusCode === 302 ||
statusCode === 307 ||
statusCode === 308
) {
const redirectUrl = response.headers.location;
response.destroy();
if (!redirectUrl) {
@@ -199,33 +220,39 @@ async function downloadFromGitHub(url, outputPath) {
return;
}
// Resolve relative redirects
const finalRedirectUrl = redirectUrl.startsWith('http')
const finalRedirectUrl = redirectUrl.startsWith("http")
? redirectUrl
: new URL(redirectUrl, url).href;
console.log(` Following redirect: ${finalRedirectUrl}`);
return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
return downloadFromGitHub(finalRedirectUrl, outputPath)
.then(resolve)
.catch(reject);
}
if (statusCode !== 200) {
response.destroy();
reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
reject(
new Error(
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
)
);
return;
}
const fileStream = fs.createWriteStream(outputPath);
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.on("finish", () => {
fileStream.close();
resolve();
});
fileStream.on('error', (error) => {
fileStream.on("error", (error) => {
response.destroy();
reject(error);
});
});
request.on('error', reject);
request.on('timeout', () => {
request.on("error", reject);
request.on("timeout", () => {
request.destroy();
reject(new Error(`Request timeout for ${url}`));
});
@@ -233,8 +260,8 @@ async function downloadFromGitHub(url, outputPath) {
}
async function main() {
const artifactsDir = 'artifacts';
const tempDir = path.join(artifactsDir, 'temp');
const artifactsDir = "artifacts";
const tempDir = path.join(artifactsDir, "temp");
// Create temp directory for downloaded GitHub archives
if (!fs.existsSync(tempDir)) {
@@ -265,30 +292,40 @@ async function main() {
// Find all artifacts
const artifacts = {
windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
macosArm: findArtifacts(
path.join(artifactsDir, "macos-builds"),
/-arm64\.dmg$/
),
linux: findArtifacts(
path.join(artifactsDir, "linux-builds"),
/\.AppImage$/
),
sourceZip: [sourceZipPath],
sourceTarGz: [sourceTarGzPath],
};
console.log('Found artifacts:');
console.log("Found artifacts:");
for (const [platform, files] of Object.entries(artifacts)) {
console.log(
` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
` ${platform}: ${
files.length > 0
? files.map((f) => path.basename(f)).join(", ")
: "none"
}`
);
}
// Upload each artifact to R2
const assets = {};
const contentTypes = {
windows: 'application/x-msdownload',
macos: 'application/x-apple-diskimage',
macosArm: 'application/x-apple-diskimage',
linux: 'application/x-executable',
sourceZip: 'application/zip',
sourceTarGz: 'application/gzip',
windows: "application/x-msdownload",
macos: "application/x-apple-diskimage",
macosArm: "application/x-apple-diskimage",
linux: "application/x-executable",
sourceZip: "application/zip",
sourceTarGz: "application/gzip",
};
for (const [platform, files] of Object.entries(artifacts)) {
@@ -308,11 +345,11 @@ async function main() {
filename,
size,
arch:
platform === 'macosArm'
? 'arm64'
: platform === 'sourceZip' || platform === 'sourceTarGz'
? 'source'
: 'x64',
platform === "macosArm"
? "arm64"
: platform === "sourceZip" || platform === "sourceTarGz"
? "source"
: "x64",
};
}
@@ -327,7 +364,9 @@ async function main() {
};
// Remove existing entry for this version if re-running
releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
releasesData.releases = releasesData.releases.filter(
(r) => r.version !== VERSION
);
// Prepend new release
releasesData.releases.unshift(newRelease);
@@ -337,19 +376,19 @@ async function main() {
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
Key: "releases.json",
Body: JSON.stringify(releasesData, null, 2),
ContentType: 'application/json',
CacheControl: 'public, max-age=60',
ContentType: "application/json",
CacheControl: "public, max-age=60",
})
);
console.log('Successfully updated releases.json');
console.log("Successfully updated releases.json");
console.log(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`);
}
main().catch((err) => {
console.error('Failed to upload to R2:', err);
console.error("Failed to upload to R2:", err);
process.exit(1);
});

View File

@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -3,7 +3,7 @@ name: E2E Tests
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
@@ -21,8 +21,8 @@ jobs:
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
rebuild-node-pty-path: 'apps/server'
check-lockfile: "true"
rebuild-node-pty-path: "apps/server"
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
@@ -58,7 +58,7 @@ jobs:
env:
CI: true
VITE_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true'
VITE_SKIP_SETUP: "true"
- name: Upload Playwright report
uses: actions/upload-artifact@v4

View File

@@ -1,31 +0,0 @@
name: Format Check
on:
pull_request:
branches:
- '*'
push:
branches:
- main
- master
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm install --ignore-scripts
- name: Check formatting
run: npm run format:check

View File

@@ -3,7 +3,7 @@ name: PR Build Check
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
@@ -20,7 +20,7 @@ jobs:
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
check-lockfile: "true"
- name: Run build:electron (dir only - faster CI)
run: npm run build:electron:dir

View File

@@ -1,111 +0,0 @@
name: Release Build
on:
release:
types: [published]
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from tag
id: version
shell: bash
run: |
# Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3")
VERSION="${{ github.event.release.tag_name }}"
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Extracted version: ${VERSION}"
- name: Update package.json version
shell: bash
run: |
node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
- name: Build Electron app (macOS)
if: matrix.os == 'macos-latest'
shell: bash
run: npm run build:electron:mac --workspace=apps/ui
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- name: Build Electron app (Windows)
if: matrix.os == 'windows-latest'
shell: bash
run: npm run build:electron:win --workspace=apps/ui
- name: Build Electron app (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: npm run build:electron:linux --workspace=apps/ui
- name: Upload macOS artifacts
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: macos-builds
path: apps/ui/release/*.{dmg,zip}
retention-days: 30
- name: Upload Windows artifacts
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: windows-builds
path: apps/ui/release/*.exe
retention-days: 30
- name: Upload Linux artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: apps/ui/release/*.{AppImage,deb}
retention-days: 30
upload:
needs: build
runs-on: ubuntu-latest
if: github.event.release.draft == false
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos-builds
path: artifacts/macos-builds
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows-builds
path: artifacts/windows-builds
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: linux-builds
path: artifacts/linux-builds
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/macos-builds/*
artifacts/windows-builds/*
artifacts/linux-builds/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,30 +0,0 @@
name: Security Audit
on:
pull_request:
branches:
- '*'
push:
branches:
- main
- master
schedule:
# Run weekly on Mondays at 9 AM UTC
- cron: '0 9 * * 1'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
- name: Run npm audit
run: npm audit --audit-level=moderate
continue-on-error: false

View File

@@ -3,7 +3,7 @@ name: Test Suite
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
@@ -20,13 +20,8 @@ jobs:
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
rebuild-node-pty-path: 'apps/server'
- name: Run package tests
run: npm run test:packages
env:
NODE_ENV: test
check-lockfile: "true"
rebuild-node-pty-path: "apps/server"
- name: Run server tests with coverage
run: npm run test:server:coverage

3
.gitignore vendored
View File

@@ -78,6 +78,3 @@ blob-report/
# Misc
*.pem
docker-compose.override.yml
.claude/

View File

@@ -1 +0,0 @@
npx lint-staged

View File

@@ -1,39 +0,0 @@
# Dependencies
node_modules/
# Build outputs
dist/
build/
out/
.next/
.turbo/
release/
# Automaker
.automaker/
# Logs
logs/
*.log
# Lock files
package-lock.json
pnpm-lock.yaml
# Generated files
*.min.js
*.min.css
# Test artifacts
test-results/
coverage/
playwright-report/
blob-report/
# IDE/Editor
.vscode/
.idea/
# Electron
dist-electron/
server-bundle/

View File

@@ -1,10 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@@ -30,26 +30,6 @@ Before running Automaker, we strongly recommend reviewing the source code yourse
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
#### Running in Isolated Docker Container
For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
```bash
# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
# On Windows PowerShell, use instead:
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
# 2. Build and run isolated container
docker-compose up -d
# 3. Access the UI at http://localhost:3007
# API at http://localhost:3008/api/health
```
The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
### 3. Limit Access
If you must run locally:

417
README.md
View File

@@ -8,7 +8,7 @@
>
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
>
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
# Automaker
@@ -19,7 +19,7 @@
- [What Makes Automaker Different?](#what-makes-automaker-different)
- [The Workflow](#the-workflow)
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
- [Powered by Claude Code](#powered-by-claude-code)
- [Why This Matters](#why-this-matters)
- [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support)
@@ -28,36 +28,22 @@
- [Quick Start](#quick-start)
- [How to Run](#how-to-run)
- [Development Mode](#development-mode)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing)
- [Linting](#linting)
- [Environment Configuration](#environment-configuration)
- [Authentication Setup](#authentication-setup)
- [Authentication Options](#authentication-options)
- [Persistent Setup (Optional)](#persistent-setup-optional)
- [Features](#features)
- [Core Workflow](#core-workflow)
- [AI & Planning](#ai--planning)
- [Project Management](#project-management)
- [Collaboration & Review](#collaboration--review)
- [Developer Tools](#developer-tools)
- [Advanced Features](#advanced-features)
- [Tech Stack](#tech-stack)
- [Frontend](#frontend)
- [Backend](#backend)
- [Testing & Quality](#testing--quality)
- [Shared Libraries](#shared-libraries)
- [Available Views](#available-views)
- [Architecture](#architecture)
- [Monorepo Structure](#monorepo-structure)
- [How It Works](#how-it-works)
- [Key Architectural Patterns](#key-architectural-patterns)
- [Security & Isolation](#security--isolation)
- [Data Storage](#data-storage)
- [Learn More](#learn-more)
- [License](#license)
</details>
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution.
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
![Automaker UI](https://i.imgur.com/jdwKydM.png)
@@ -73,9 +59,9 @@ Traditional development tools help you write code. Automaker helps you **orchest
4. **Review & Verify** - Review the changes, run tests, and approve when ready
5. **Ship Faster** - Build entire applications in days, not weeks
### Powered by Claude Agent SDK
### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention.
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters
@@ -93,7 +79,7 @@ The future of software development is **agentic coding**—where developers beco
>
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
>
> **[Read the full disclaimer](./DISCLAIMER.md)**
> **[Read the full disclaimer](../DISCLAIMER.md)**
---
@@ -109,7 +95,8 @@ In the Discord, you can:
- 🚀 Show off projects built with AI agents
- 🤝 Collaborate with other developers and contributors
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
👉 **Join the Discord:**
https://discord.gg/jjem7aEDKU
---
@@ -117,49 +104,25 @@ In the Discord, you can:
### Prerequisites
- **Node.js 18+** (tested with Node.js 22)
- **npm** (comes with Node.js)
- **Authentication** (choose one):
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
- Node.js 18+
- npm
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
### Quick Start
```bash
# 1. Clone the repository
# 1. Clone the repo
git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker
# 2. Install dependencies
npm install
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
npm run build:packages
# 4. Set up authentication (skip if using Claude Code CLI)
# If using Claude Code CLI: credentials are detected automatically
# If using API key directly, choose one method:
# Option A: Environment variable
export ANTHROPIC_API_KEY="sk-ant-..."
# Option B: Create .env file in project root
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
# 5. Start Automaker (interactive launcher)
# 3. Run Automaker (pick your mode)
npm run dev
# Choose between:
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
# Then choose your run mode when prompted, or use specific commands below
```
**Note:** The `npm run dev` command will:
- Check for dependencies and install if needed
- Install Playwright browsers for E2E tests
- Kill any processes on ports 3007/3008
- Present an interactive menu to choose your run mode
## How to Run
### Development Mode
@@ -197,65 +160,31 @@ npm run dev:web
### Building for Production
#### Web Application
```bash
# Build for web deployment (uses Vite)
# Build Next.js app
npm run build
# Run production build
npm run start
```
#### Desktop Application
```bash
# Build for current platform (macOS/Windows/Linux)
# Build Electron app for distribution
npm run build:electron
# Platform-specific builds
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
npm run build:electron:win # Windows (NSIS installer, x64)
npm run build:electron:linux # Linux (AppImage + DEB, x64)
# Output directory: apps/ui/release/
```
#### Docker Deployment
### Running Production Build
```bash
# Build and run with Docker Compose (recommended for security)
docker-compose up -d
# Access at http://localhost:3007
# API at http://localhost:3008
# Start production Next.js server
npm run start
```
### Testing
#### End-to-End Tests (Playwright)
```bash
npm run test # Headless E2E tests
npm run test:headed # Browser visible E2E tests
# Run tests headless
npm run test
# Run tests with browser visible
npm run test:headed
```
#### Unit Tests (Vitest)
```bash
npm run test:server # Server unit tests
npm run test:server:coverage # Server tests with coverage
npm run test:packages # All shared package tests
npm run test:all # Packages + server tests
```
#### Test Configuration
- E2E tests run on ports 3007 (UI) and 3008 (server)
- Automatically starts test servers before running
- Uses Chromium browser via Playwright
- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
### Linting
```bash
@@ -263,283 +192,59 @@ npm run test:all # Packages + server tests
npm run lint
```
### Environment Configuration
### Authentication Options
#### Authentication (if not using Claude Code CLI)
Automaker supports multiple authentication methods (in order of priority):
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
| Method | Environment Variable | Description |
| ---------------- | -------------------- | ------------------------------- |
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
| API Key (stored) | — | Anthropic API key stored in app |
#### Optional - Server
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
#### Optional - Security
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*)
#### Optional - Development
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
### Authentication Setup
#### Option 1: Claude Code CLI (Recommended)
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
#### Option 2: Direct API Key
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
##### 2a. Shell Configuration
### Persistent Setup (Optional)
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
##### 2b. .env File
Create a `.env` file in the project root (gitignored):
```bash
ANTHROPIC_API_KEY=sk-ant-...
PORT=3008
DATA_DIR=./data
```
##### 2c. In-App Storage
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
Then restart your terminal or run `source ~/.bashrc`.
## Features
### Core Workflow
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
### AI & Planning
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
- **Plan Approval** - Review and approve AI-generated plans before implementation begins
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
### Project Management
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis
- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference
- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order
- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization
- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically
### Collaboration & Review
- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing
- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work
- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings
- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history
- 🔍 **Git Diff Viewer** - Review changes made by agents before approving
### Developer Tools
- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions
- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context
-**Concurrent Execution** - Configure how many features can run simultaneously (default: 3)
- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
### Advanced Features
- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access
- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees
- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics
- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings)
- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
- 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
- **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
## Tech Stack
### Frontend
- **React 19** - UI framework
- **Vite 7** - Build tool and development server
- **Electron 39** - Desktop application framework
- **TypeScript 5.9** - Type safety
- **TanStack Router** - File-based routing
- **Zustand 5** - State management with persistence
- **Tailwind CSS 4** - Utility-first styling with 25+ themes
- **Radix UI** - Accessible component primitives
- **dnd-kit** - Drag and drop for Kanban board
- **@xyflow/react** - Graph visualization for dependencies
- **xterm.js** - Integrated terminal emulator
- **CodeMirror 6** - Code editor for XML/syntax highlighting
- **Lucide Icons** - Icon library
### Backend
- **Node.js** - JavaScript runtime with ES modules
- **Express 5** - HTTP server framework
- **TypeScript 5.9** - Type safety
- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk)
- **WebSocket (ws)** - Real-time event streaming
- **node-pty** - PTY terminal sessions
### Testing & Quality
- **Playwright** - End-to-end testing
- **Vitest** - Unit testing framework
- **ESLint 9** - Code linting
- **Prettier 3** - Code formatting
- **Husky** - Git hooks for pre-commit formatting
### Shared Libraries
- **@automaker/types** - Shared TypeScript definitions
- **@automaker/utils** - Logging, error handling, image processing
- **@automaker/prompts** - AI prompt templates
- **@automaker/platform** - Path management and security
- **@automaker/model-resolver** - Claude model alias resolution
- **@automaker/dependency-resolver** - Feature dependency ordering
- **@automaker/git-utils** - Git operations and worktree management
## Available Views
Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts:
| View | Shortcut | Description |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------ |
| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) |
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks |
| **Running Agents** | - | View all active agents across projects with status and progress |
### Keyboard Navigation
All shortcuts are customizable in Settings. Default shortcuts:
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal)
- **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project)
## Architecture
### Monorepo Structure
Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
```text
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend
│ └── server/ # Express + WebSocket backend
└── libs/ # Shared packages
├── types/ # Core TypeScript definitions
├── utils/ # Logging, errors, utilities
├── prompts/ # AI prompt templates
├── platform/ # Path management, security
├── model-resolver/ # Claude model aliasing
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### How It Works
1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
8. **Integration** - After approval, changes can be committed and PRs created from the worktree
### Key Architectural Patterns
- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
- **State Management** - Zustand with persistence for frontend state across restarts
- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
### Security & Isolation
- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
### Data Storage
Automaker uses a file-based storage system (no database required):
#### Per-Project Data
Stored in `{projectPath}/.automaker/`:
```text
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json # Feature metadata
│ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images
├── context/ # Context files for AI agents
├── settings.json # Project-specific settings
├── spec.md # Project specification
├── analysis.json # Project structure analysis
└── feature-suggestions.json # AI-generated suggestions
```
#### Global Data
Stored in `DATA_DIR` (default `./data`):
```text
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys (encrypted)
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
└── {sessionId}.json
```
- [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- [Tailwind CSS](https://tailwindcss.com/) - Styling
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
## Learn More
### Documentation
To learn more about Next.js, take a look at the following resources:
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community
Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## License

View File

@@ -16,11 +16,9 @@ ANTHROPIC_API_KEY=sk-ant-...
# If set, all API requests must include X-API-Key header
AUTOMAKER_API_KEY=
# Root directory for projects and file operations
# If set, users can only create/open projects and files within this directory
# Recommended for sandboxed deployments (Docker, restricted environments)
# Example: ALLOWED_ROOT_DIRECTORY=/projects
ALLOWED_ROOT_DIRECTORY=
# Restrict file operations to these directories (comma-separated)
# Important for security in multi-tenant environments
ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
# CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production
@@ -36,6 +34,13 @@ PORT=3008
# Data directory for sessions and metadata
DATA_DIR=./data
# ============================================
# OPTIONAL - Additional AI Providers
# ============================================
# Google API key (for future Gemini support)
GOOGLE_API_KEY=
# ============================================
# OPTIONAL - Terminal Access
# ============================================

View File

@@ -4,15 +4,11 @@
# Build stage
FROM node:20-alpine AS builder
# Install build dependencies for native modules (node-pty)
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package files and scripts needed for postinstall
# Copy package files
COPY package*.json ./
COPY apps/server/package*.json ./apps/server/
COPY scripts ./scripts
# Install dependencies
RUN npm ci --workspace=apps/server
@@ -26,14 +22,6 @@ RUN npm run build --workspace=apps/server
# Production stage
FROM node:20-alpine
# Install git, curl, and GitHub CLI (pinned version for reproducible builds)
RUN apk add --no-cache git curl && \
GH_VERSION="2.63.2" && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv "gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh && \
rm -rf gh.tar.gz "gh_${GH_VERSION}_linux_amd64"
WORKDIR /app
# Create non-root user

View File

@@ -2,8 +2,6 @@
"name": "@automaker/server",
"version": "0.1.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"private": true,
"type": "module",
"main": "dist/index.js",
@@ -21,13 +19,6 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
"@automaker/dependency-resolver": "^1.0.0",
"@automaker/git-utils": "^1.0.0",
"@automaker/model-resolver": "^1.0.0",
"@automaker/platform": "^1.0.0",
"@automaker/prompts": "^1.0.0",
"@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",

View File

@@ -6,57 +6,49 @@
* In web mode, this server runs on a remote host.
*/
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import dotenv from 'dotenv';
import express from "express";
import cors from "cors";
import morgan from "morgan";
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import dotenv from "dotenv";
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform';
import { authMiddleware, getAuthStatus } from './lib/auth.js';
import { createFsRoutes } from './routes/fs/index.js';
import { createHealthRoutes } from './routes/health/index.js';
import { createAgentRoutes } from './routes/agent/index.js';
import { createSessionsRoutes } from './routes/sessions/index.js';
import { createFeaturesRoutes } from './routes/features/index.js';
import { createAutoModeRoutes } from './routes/auto-mode/index.js';
import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
import { createWorktreeRoutes } from './routes/worktree/index.js';
import { createGitRoutes } from './routes/git/index.js';
import { createSetupRoutes } from './routes/setup/index.js';
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
import { createModelsRoutes } from './routes/models/index.js';
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
import { createWorkspaceRoutes } from './routes/workspace/index.js';
import { createTemplatesRoutes } from './routes/templates/index.js';
import { createEventEmitter, type EventEmitter } from "./lib/events.js";
import { initAllowedPaths } from "./lib/security.js";
import { authMiddleware, getAuthStatus } from "./lib/auth.js";
import { createFsRoutes } from "./routes/fs/index.js";
import { createHealthRoutes } from "./routes/health/index.js";
import { createAgentRoutes } from "./routes/agent/index.js";
import { createSessionsRoutes } from "./routes/sessions/index.js";
import { createFeaturesRoutes } from "./routes/features/index.js";
import { createAutoModeRoutes } from "./routes/auto-mode/index.js";
import { createEnhancePromptRoutes } from "./routes/enhance-prompt/index.js";
import { createWorktreeRoutes } from "./routes/worktree/index.js";
import { createGitRoutes } from "./routes/git/index.js";
import { createSetupRoutes } from "./routes/setup/index.js";
import { createSuggestionsRoutes } from "./routes/suggestions/index.js";
import { createModelsRoutes } from "./routes/models/index.js";
import { createRunningAgentsRoutes } from "./routes/running-agents/index.js";
import { createWorkspaceRoutes } from "./routes/workspace/index.js";
import { createTemplatesRoutes } from "./routes/templates/index.js";
import {
createTerminalRoutes,
validateTerminalToken,
isTerminalEnabled,
isTerminalPasswordRequired,
} from './routes/terminal/index.js';
import { createSettingsRoutes } from './routes/settings/index.js';
import { AgentService } from './services/agent-service.js';
import { FeatureLoader } from './services/feature-loader.js';
import { AutoModeService } from './services/auto-mode-service.js';
import { getTerminalService } from './services/terminal-service.js';
import { SettingsService } from './services/settings-service.js';
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
import { createClaudeRoutes } from './routes/claude/index.js';
import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
} from "./routes/terminal/index.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
import { getTerminalService } from "./services/terminal-service.js";
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
// Load environment variables
dotenv.config();
const PORT = parseInt(process.env.PORT || '3008', 10);
const DATA_DIR = process.env.DATA_DIR || './data';
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
const PORT = parseInt(process.env.PORT || "3008", 10);
const DATA_DIR = process.env.DATA_DIR || "./data";
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
@@ -75,7 +67,7 @@ if (!hasAnthropicKey) {
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else {
console.log('[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)');
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
}
// Initialize security
@@ -87,7 +79,7 @@ const app = express();
// Middleware
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
if (ENABLE_REQUEST_LOGGING) {
morgan.token('status-colored', (req, res) => {
morgan.token("status-colored", (req, res) => {
const status = res.statusCode;
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
@@ -96,72 +88,55 @@ if (ENABLE_REQUEST_LOGGING) {
});
app.use(
morgan(':method :url :status-colored', {
skip: (req) => req.url === '/api/health', // Skip health check logs
morgan(":method :url :status-colored", {
skip: (req) => req.url === "/api/health", // Skip health check logs
})
);
}
app.use(
cors({
origin: process.env.CORS_ORIGIN || '*',
origin: process.env.CORS_ORIGIN || "*",
credentials: true,
})
);
app.use(express.json({ limit: '50mb' }));
app.use(express.json({ limit: "50mb" }));
// Create shared event emitter for streaming
const events: EventEmitter = createEventEmitter();
// Create services
// Note: settingsService is created first so it can be injected into other services
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService);
const claudeUsageService = new ClaudeUsageService();
const autoModeService = new AutoModeService(events);
// Initialize services
(async () => {
await agentService.initialize();
console.log('[Server] Agent service initialized');
console.log("[Server] Agent service initialized");
})();
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
setInterval(() => {
const cleaned = cleanupStaleValidations();
if (cleaned > 0) {
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
}
}, VALIDATION_CLEANUP_INTERVAL_MS);
// Mount API routes - health is unauthenticated for monitoring
app.use('/api/health', createHealthRoutes());
app.use("/api/health", createHealthRoutes());
// Apply authentication to all other routes
app.use('/api', authMiddleware);
app.use("/api", authMiddleware);
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes());
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
app.use('/api/setup', createSetupRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
app.use('/api/workspace', createWorkspaceRoutes());
app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use("/api/fs", createFsRoutes(events));
app.use("/api/agent", createAgentRoutes(agentService, events));
app.use("/api/sessions", createSessionsRoutes(agentService));
app.use("/api/features", createFeaturesRoutes(featureLoader));
app.use("/api/auto-mode", createAutoModeRoutes(autoModeService));
app.use("/api/enhance-prompt", createEnhancePromptRoutes());
app.use("/api/worktree", createWorktreeRoutes());
app.use("/api/git", createGitRoutes());
app.use("/api/setup", createSetupRoutes());
app.use("/api/suggestions", createSuggestionsRoutes(events));
app.use("/api/models", createModelsRoutes());
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
app.use("/api/workspace", createWorkspaceRoutes());
app.use("/api/templates", createTemplatesRoutes());
app.use("/api/terminal", createTerminalRoutes());
// Create HTTP server
const server = createServer(app);
@@ -172,16 +147,19 @@ const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
server.on("upgrade", (request, socket, head) => {
const { pathname } = new URL(
request.url || "",
`http://${request.headers.host}`
);
if (pathname === '/api/events') {
if (pathname === "/api/events") {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
wss.emit("connection", ws, request);
});
} else if (pathname === '/api/terminal/ws') {
} else if (pathname === "/api/terminal/ws") {
terminalWss.handleUpgrade(request, socket, head, (ws) => {
terminalWss.emit('connection', ws, request);
terminalWss.emit("connection", ws, request);
});
} else {
socket.destroy();
@@ -189,42 +167,23 @@ server.on('upgrade', (request, socket, head) => {
});
// Events WebSocket connection handler
wss.on('connection', (ws: WebSocket) => {
console.log('[WebSocket] Client connected, ready state:', ws.readyState);
wss.on("connection", (ws: WebSocket) => {
console.log("[WebSocket] Client connected");
// Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => {
console.log('[WebSocket] Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});
if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload });
console.log('[WebSocket] Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as any)?.sessionId,
});
ws.send(message);
} else {
console.log(
'[WebSocket] WARNING: Cannot send event, WebSocket not open. ReadyState:',
ws.readyState
);
ws.send(JSON.stringify({ type, payload }));
}
});
ws.on('close', () => {
console.log('[WebSocket] Client disconnected');
ws.on("close", () => {
console.log("[WebSocket] Client disconnected");
unsubscribe();
});
ws.on('error', (error) => {
console.error('[WebSocket] ERROR:', error);
ws.on("error", (error) => {
console.error("[WebSocket] Error:", error);
unsubscribe();
});
});
@@ -245,199 +204,184 @@ terminalService.onExit((sessionId) => {
});
// Terminal WebSocket connection handler
terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => {
// Parse URL to get session ID and token
const url = new URL(req.url || '', `http://${req.headers.host}`);
const sessionId = url.searchParams.get('sessionId');
const token = url.searchParams.get('token');
terminalWss.on(
"connection",
(ws: WebSocket, req: import("http").IncomingMessage) => {
// Parse URL to get session ID and token
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const token = url.searchParams.get("token");
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
// Check if terminal is enabled
if (!isTerminalEnabled()) {
console.log('[Terminal WS] Terminal is disabled');
ws.close(4003, 'Terminal access is disabled');
return;
}
// Check if terminal is enabled
if (!isTerminalEnabled()) {
console.log("[Terminal WS] Terminal is disabled");
ws.close(4003, "Terminal access is disabled");
return;
}
// Validate token if password is required
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
console.log('[Terminal WS] Invalid or missing token');
ws.close(4001, 'Authentication required');
return;
}
// Validate token if password is required
if (
isTerminalPasswordRequired() &&
!validateTerminalToken(token || undefined)
) {
console.log("[Terminal WS] Invalid or missing token");
ws.close(4001, "Authentication required");
return;
}
if (!sessionId) {
console.log('[Terminal WS] No session ID provided');
ws.close(4002, 'Session ID required');
return;
}
if (!sessionId) {
console.log("[Terminal WS] No session ID provided");
ws.close(4002, "Session ID required");
return;
}
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
console.log(`[Terminal WS] Session ${sessionId} not found`);
ws.close(4004, 'Session not found');
return;
}
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
console.log(`[Terminal WS] Session ${sessionId} not found`);
ws.close(4004, "Session not found");
return;
}
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
// Track this connection
if (!terminalConnections.has(sessionId)) {
terminalConnections.set(sessionId, new Set());
}
terminalConnections.get(sessionId)!.add(ws);
// Track this connection
if (!terminalConnections.has(sessionId)) {
terminalConnections.set(sessionId, new Set());
}
terminalConnections.get(sessionId)!.add(ws);
// Send initial connection success FIRST
ws.send(
JSON.stringify({
type: 'connected',
sessionId,
shell: session.shell,
cwd: session.cwd,
})
);
// Send scrollback buffer BEFORE subscribing to prevent race condition
// Also clear pending output buffer to prevent duplicates from throttled flush
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
if (scrollback && scrollback.length > 0) {
// Send initial connection success FIRST
ws.send(
JSON.stringify({
type: 'scrollback',
data: scrollback,
type: "connected",
sessionId,
shell: session.shell,
cwd: session.cwd,
})
);
// Send scrollback buffer BEFORE subscribing to prevent race condition
// Also clear pending output buffer to prevent duplicates from throttled flush
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
if (scrollback && scrollback.length > 0) {
ws.send(
JSON.stringify({
type: "scrollback",
data: scrollback,
})
);
}
// NOW subscribe to terminal data (after scrollback is sent)
const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "data", data }));
}
});
// Subscribe to terminal exit
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "exit", exitCode }));
ws.close(1000, "Session ended");
}
});
// Handle incoming messages
ws.on("message", (message) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "input":
// Write user input to terminal
terminalService.write(sessionId, msg.data);
break;
case "resize":
// Resize terminal with deduplication and rate limiting
if (msg.cols && msg.rows) {
const now = Date.now();
const lastTime = lastResizeTime.get(sessionId) || 0;
const lastDimensions = lastResizeDimensions.get(sessionId);
// Skip if resized too recently (prevents resize storm during splits)
if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
break;
}
// Check if dimensions are different from last resize
if (
!lastDimensions ||
lastDimensions.cols !== msg.cols ||
lastDimensions.rows !== msg.rows
) {
// Only suppress output on subsequent resizes, not the first one
// The first resize happens on terminal open and we don't want to drop the initial prompt
const isFirstResize = !lastDimensions;
terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
lastResizeDimensions.set(sessionId, {
cols: msg.cols,
rows: msg.rows,
});
lastResizeTime.set(sessionId, now);
}
}
break;
case "ping":
// Respond to ping
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
}
} catch (error) {
console.error("[Terminal WS] Error processing message:", error);
}
});
ws.on("close", () => {
console.log(
`[Terminal WS] Client disconnected from session ${sessionId}`
);
unsubscribeData();
unsubscribeExit();
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
// DON'T delete lastResizeDimensions/lastResizeTime here!
// The session still exists, and reconnecting clients need to know
// this isn't the "first resize" to prevent duplicate prompts.
// These get cleaned up when the session actually exits.
}
}
});
ws.on("error", (error) => {
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
unsubscribeData();
unsubscribeExit();
});
}
// NOW subscribe to terminal data (after scrollback is sent)
const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'data', data }));
}
});
// Subscribe to terminal exit
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'exit', exitCode }));
ws.close(1000, 'Session ended');
}
});
// Handle incoming messages
ws.on('message', (message) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case 'input':
// Validate input data type and length
if (typeof msg.data !== 'string') {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid input type' }));
break;
}
// Limit input size to 1MB to prevent memory issues
if (msg.data.length > 1024 * 1024) {
ws.send(JSON.stringify({ type: 'error', message: 'Input too large' }));
break;
}
// Write user input to terminal
terminalService.write(sessionId, msg.data);
break;
case 'resize':
// Validate resize dimensions are positive integers within reasonable bounds
if (
typeof msg.cols !== 'number' ||
typeof msg.rows !== 'number' ||
!Number.isInteger(msg.cols) ||
!Number.isInteger(msg.rows) ||
msg.cols < 1 ||
msg.cols > 1000 ||
msg.rows < 1 ||
msg.rows > 500
) {
break; // Silently ignore invalid resize requests
}
// Resize terminal with deduplication and rate limiting
if (msg.cols && msg.rows) {
const now = Date.now();
const lastTime = lastResizeTime.get(sessionId) || 0;
const lastDimensions = lastResizeDimensions.get(sessionId);
// Skip if resized too recently (prevents resize storm during splits)
if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
break;
}
// Check if dimensions are different from last resize
if (
!lastDimensions ||
lastDimensions.cols !== msg.cols ||
lastDimensions.rows !== msg.rows
) {
// Only suppress output on subsequent resizes, not the first one
// The first resize happens on terminal open and we don't want to drop the initial prompt
const isFirstResize = !lastDimensions;
terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
lastResizeDimensions.set(sessionId, {
cols: msg.cols,
rows: msg.rows,
});
lastResizeTime.set(sessionId, now);
}
}
break;
case 'ping':
// Respond to ping
ws.send(JSON.stringify({ type: 'pong' }));
break;
default:
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
}
} catch (error) {
console.error('[Terminal WS] Error processing message:', error);
}
});
ws.on('close', () => {
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
unsubscribeData();
unsubscribeExit();
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
// DON'T delete lastResizeDimensions/lastResizeTime here!
// The session still exists, and reconnecting clients need to know
// this isn't the "first resize" to prevent duplicate prompts.
// These get cleaned up when the session actually exits.
}
}
});
ws.on('error', (error) => {
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
unsubscribeData();
unsubscribeExit();
});
});
);
// Start server with error handling for port conflicts
const startServer = (port: number) => {
server.listen(port, () => {
const terminalStatus = isTerminalEnabled()
? isTerminalPasswordRequired()
? 'enabled (password protected)'
: 'enabled'
: 'disabled';
? "enabled (password protected)"
: "enabled"
: "disabled";
const portStr = port.toString().padEnd(4);
console.log(`
╔═══════════════════════════════════════════════════════╗
@@ -452,8 +396,8 @@ const startServer = (port: number) => {
`);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
server.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") {
console.error(`
╔═══════════════════════════════════════════════════════╗
║ ❌ ERROR: Port ${port} is already in use ║
@@ -474,7 +418,7 @@ const startServer = (port: number) => {
`);
process.exit(1);
} else {
console.error('[Server] Error starting server:', error);
console.error("[Server] Error starting server:", error);
process.exit(1);
}
});
@@ -483,20 +427,20 @@ const startServer = (port: number) => {
startServer(PORT);
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down...');
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down...");
terminalService.cleanup();
server.close(() => {
console.log('Server closed');
console.log("Server closed");
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down...');
process.on("SIGINT", () => {
console.log("SIGINT received, shutting down...");
terminalService.cleanup();
server.close(() => {
console.log('Server closed');
console.log("Server closed");
process.exit(0);
});
});

View File

@@ -5,27 +5,139 @@
* app specifications to ensure consistency across the application.
*/
// Import and re-export spec types from shared package
export type { SpecOutput } from '@automaker/types';
export { specOutputSchema } from '@automaker/types';
/**
* TypeScript interface for structured spec output
*/
export interface SpecOutput {
project_name: string;
overview: string;
technology_stack: string[];
core_capabilities: string[];
implemented_features: Array<{
name: string;
description: string;
file_locations?: string[];
}>;
additional_requirements?: string[];
development_guidelines?: string[];
implementation_roadmap?: Array<{
phase: string;
status: "completed" | "in_progress" | "pending";
description: string;
}>;
}
/**
* JSON Schema for structured spec output
* Used with Claude's structured output feature for reliable parsing
*/
export const specOutputSchema = {
type: "object",
properties: {
project_name: {
type: "string",
description: "The name of the project",
},
overview: {
type: "string",
description:
"A comprehensive description of what the project does, its purpose, and key goals",
},
technology_stack: {
type: "array",
items: { type: "string" },
description:
"List of all technologies, frameworks, libraries, and tools used",
},
core_capabilities: {
type: "array",
items: { type: "string" },
description: "List of main features and capabilities the project provides",
},
implemented_features: {
type: "array",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Name of the implemented feature",
},
description: {
type: "string",
description: "Description of what the feature does",
},
file_locations: {
type: "array",
items: { type: "string" },
description: "File paths where this feature is implemented",
},
},
required: ["name", "description"],
},
description: "Features that have been implemented based on code analysis",
},
additional_requirements: {
type: "array",
items: { type: "string" },
description: "Any additional requirements or constraints",
},
development_guidelines: {
type: "array",
items: { type: "string" },
description: "Development standards and practices",
},
implementation_roadmap: {
type: "array",
items: {
type: "object",
properties: {
phase: {
type: "string",
description: "Name of the implementation phase",
},
status: {
type: "string",
enum: ["completed", "in_progress", "pending"],
description: "Current status of this phase",
},
description: {
type: "string",
description: "Description of what this phase involves",
},
},
required: ["phase", "status", "description"],
},
description: "Phases or roadmap items for implementation",
},
},
required: [
"project_name",
"overview",
"technology_stack",
"core_capabilities",
"implemented_features",
],
additionalProperties: false,
};
/**
* Escape special XML characters
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Convert structured spec output to XML format
*/
export function specToXml(spec: import('@automaker/types').SpecOutput): string {
const indent = ' ';
export function specToXml(spec: SpecOutput): string {
const indent = " ";
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
@@ -36,11 +148,11 @@ ${indent}${indent}${escapeXml(spec.overview)}
${indent}</overview>
${indent}<technology_stack>
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join('\n')}
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join("\n")}
${indent}</technology_stack>
${indent}<core_capabilities>
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join('\n')}
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join("\n")}
${indent}</core_capabilities>
${indent}<implemented_features>
@@ -51,13 +163,13 @@ ${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
f.file_locations && f.file_locations.length > 0
? `\n${indent}${indent}${indent}<file_locations>
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join('\n')}
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join("\n")}
${indent}${indent}${indent}</file_locations>`
: ''
: ""
}
${indent}${indent}</feature>`
)
.join('\n')}
.join("\n")}
${indent}</implemented_features>`;
// Optional sections
@@ -65,7 +177,7 @@ ${indent}</implemented_features>`;
xml += `
${indent}<additional_requirements>
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join('\n')}
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join("\n")}
${indent}</additional_requirements>`;
}
@@ -73,7 +185,7 @@ ${indent}</additional_requirements>`;
xml += `
${indent}<development_guidelines>
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join('\n')}
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join("\n")}
${indent}</development_guidelines>`;
}
@@ -89,7 +201,7 @@ ${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
${indent}${indent}</phase>`
)
.join('\n')}
.join("\n")}
${indent}</implementation_roadmap>`;
}

View File

@@ -4,7 +4,7 @@
* Supports API key authentication via header or environment variable.
*/
import type { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from "express";
// API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY;
@@ -23,12 +23,12 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
}
// Check for API key in header
const providedKey = req.headers['x-api-key'] as string | undefined;
const providedKey = req.headers["x-api-key"] as string | undefined;
if (!providedKey) {
res.status(401).json({
success: false,
error: 'Authentication required. Provide X-API-Key header.',
error: "Authentication required. Provide X-API-Key header.",
});
return;
}
@@ -36,7 +36,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
if (providedKey !== API_KEY) {
res.status(403).json({
success: false,
error: 'Invalid API key.',
error: "Invalid API key.",
});
return;
}
@@ -57,6 +57,6 @@ export function isAuthEnabled(): boolean {
export function getAuthStatus(): { enabled: boolean; method: string } {
return {
enabled: !!API_KEY,
method: API_KEY ? 'api_key' : 'none',
method: API_KEY ? "api_key" : "none",
};
}

View File

@@ -0,0 +1,91 @@
/**
* Automaker Paths - Utilities for managing automaker data storage
*
* Stores project data inside the project directory at {projectPath}/.automaker/
*/
import fs from "fs/promises";
import path from "path";
/**
* Get the automaker data directory for a project
* This is stored inside the project at .automaker/
*/
export function getAutomakerDir(projectPath: string): string {
return path.join(projectPath, ".automaker");
}
/**
* Get the features directory for a project
*/
export function getFeaturesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "features");
}
/**
* Get the directory for a specific feature
*/
export function getFeatureDir(projectPath: string, featureId: string): string {
return path.join(getFeaturesDir(projectPath), featureId);
}
/**
* Get the images directory for a feature
*/
export function getFeatureImagesDir(
projectPath: string,
featureId: string
): string {
return path.join(getFeatureDir(projectPath, featureId), "images");
}
/**
* Get the board directory for a project (board backgrounds, etc.)
*/
export function getBoardDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "board");
}
/**
* Get the images directory for a project (general images)
*/
export function getImagesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "images");
}
/**
* Get the context files directory for a project (user-added context files)
*/
export function getContextDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "context");
}
/**
* Get the worktrees metadata directory for a project
*/
export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "worktrees");
}
/**
* Get the app spec file path for a project
*/
export function getAppSpecPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
}
/**
* Get the branch tracking file path for a project
*/
export function getBranchTrackingPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "active-branches.json");
}
/**
* Ensure the automaker directory structure exists for a project
*/
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
const automakerDir = getAutomakerDir(projectPath);
await fs.mkdir(automakerDir, { recursive: true });
return automakerDir;
}

View File

@@ -8,7 +8,7 @@
* - Convert history to Claude SDK message format
*/
import type { ConversationMessage } from '@automaker/types';
import type { ConversationMessage } from "../providers/types.js";
/**
* Extract plain text from message content (handles both string and array formats)
@@ -19,15 +19,15 @@ import type { ConversationMessage } from '@automaker/types';
export function extractTextFromContent(
content: string | Array<{ type: string; text?: string; source?: object }>
): string {
if (typeof content === 'string') {
if (typeof content === "string") {
return content;
}
// Extract text blocks only
return content
.filter((block) => block.type === 'text')
.map((block) => block.text || '')
.join('\n');
.filter((block) => block.type === "text")
.map((block) => block.text || "")
.join("\n");
}
/**
@@ -42,7 +42,7 @@ export function normalizeContentBlocks(
if (Array.isArray(content)) {
return content;
}
return [{ type: 'text', text: content }];
return [{ type: "text", text: content }];
}
/**
@@ -53,18 +53,18 @@ export function normalizeContentBlocks(
*/
export function formatHistoryAsText(history: ConversationMessage[]): string {
if (history.length === 0) {
return '';
return "";
}
let historyText = 'Previous conversation:\n\n';
let historyText = "Previous conversation:\n\n";
for (const msg of history) {
const contentText = extractTextFromContent(msg.content);
const role = msg.role === 'user' ? 'User' : 'Assistant';
const role = msg.role === "user" ? "User" : "Assistant";
historyText += `${role}: ${contentText}\n\n`;
}
historyText += '---\n\n';
historyText += "---\n\n";
return historyText;
}
@@ -74,18 +74,20 @@ export function formatHistoryAsText(history: ConversationMessage[]): string {
* @param history - Array of conversation messages
* @returns Array of Claude SDK formatted messages
*/
export function convertHistoryToMessages(history: ConversationMessage[]): Array<{
type: 'user' | 'assistant';
export function convertHistoryToMessages(
history: ConversationMessage[]
): Array<{
type: "user" | "assistant";
session_id: string;
message: {
role: 'user' | 'assistant';
role: "user" | "assistant";
content: Array<{ type: string; text?: string; source?: object }>;
};
parent_tool_use_id: null;
}> {
return history.map((historyMsg) => ({
type: historyMsg.role,
session_id: '',
session_id: "",
message: {
role: historyMsg.role,
content: normalizeContentBlocks(historyMsg.content),

View File

@@ -0,0 +1,221 @@
/**
* Dependency Resolution Utility (Server-side)
*
* Provides topological sorting and dependency analysis for features.
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
*/
import type { Feature } from "../services/feature-loader.js";
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
* Resolves feature dependencies using topological sort with priority-aware ordering.
*
* Algorithm:
* 1. Build dependency graph and detect missing/blocked dependencies
* 2. Apply Kahn's algorithm for topological sort
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
* 4. Detect circular dependencies for features that can't be ordered
*
* @param features - Array of features to order
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
const blockedFeatures = new Map<string, string[]>();
// Initialize graph structures
for (const feature of features) {
inDegree.set(feature.id, 0);
adjacencyList.set(feature.id, []);
}
// Build dependency graph and detect missing/blocked dependencies
for (const feature of features) {
const deps = feature.dependencies || [];
for (const depId of deps) {
if (!featureMap.has(depId)) {
// Missing dependency - track it
if (!missingDependencies.has(feature.id)) {
missingDependencies.set(feature.id, []);
}
missingDependencies.get(feature.id)!.push(depId);
} else {
// Valid dependency - add edge to graph
adjacencyList.get(depId)!.push(feature.id);
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
// Check if dependency is incomplete (blocking)
const depFeature = featureMap.get(depId)!;
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
if (!blockedFeatures.has(feature.id)) {
blockedFeatures.set(feature.id, []);
}
blockedFeatures.get(feature.id)!.push(depId);
}
}
}
}
// Kahn's algorithm with priority-aware selection
const queue: Feature[] = [];
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
if (degree === 0) {
queue.push(featureMap.get(id)!);
}
}
// Sort initial queue by priority
queue.sort(sortByPriority);
// Process features in topological order
while (queue.length > 0) {
// Take highest priority feature from queue
const current = queue.shift()!;
orderedFeatures.push(current);
// Process features that depend on this one
for (const dependentId of adjacencyList.get(current.id) || []) {
const currentDegree = inDegree.get(dependentId);
if (currentDegree === undefined) {
throw new Error(`In-degree not initialized for feature ${dependentId}`);
}
const newDegree = currentDegree - 1;
inDegree.set(dependentId, newDegree);
if (newDegree === 0) {
queue.push(featureMap.get(dependentId)!);
// Re-sort queue to maintain priority order
queue.sort(sortByPriority);
}
}
}
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
// Add remaining features at end (part of cycles)
orderedFeatures.push(...remaining);
}
return {
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
};
}
/**
* Detects circular dependencies using depth-first search
*
* @param features - Features that couldn't be topologically sorted (potential cycles)
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const currentPath: string[] = [];
function dfs(featureId: string): boolean {
visited.add(featureId);
recursionStack.add(featureId);
currentPath.push(featureId);
const feature = featureMap.get(featureId);
if (feature) {
for (const depId of feature.dependencies || []) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
// Found cycle - extract it
const cycleStart = currentPath.indexOf(depId);
cycles.push(currentPath.slice(cycleStart));
return true;
}
}
}
currentPath.pop();
recursionStack.delete(featureId);
return false;
}
for (const feature of features) {
if (!visited.has(feature.id)) {
dfs(feature.id);
}
}
return cycles;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
/**
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

View File

@@ -1,25 +1,456 @@
/**
* Enhancement Prompts - Re-exported from @automaker/prompts
* Enhancement Prompts Library - AI-powered text enhancement for task descriptions
*
* This file now re-exports enhancement prompts from the shared @automaker/prompts package
* to maintain backward compatibility with existing imports in the server codebase.
* Provides prompt templates and utilities for enhancing user-written task descriptions:
* - Improve: Transform vague requests into clear, actionable tasks
* - Technical: Add implementation details and technical specifications
* - Simplify: Make verbose descriptions concise and focused
* - Acceptance: Add testable acceptance criteria
*
* Uses chain-of-thought prompting with few-shot examples for consistent results.
*/
export {
IMPROVE_SYSTEM_PROMPT,
TECHNICAL_SYSTEM_PROMPT,
SIMPLIFY_SYSTEM_PROMPT,
ACCEPTANCE_SYSTEM_PROMPT,
IMPROVE_EXAMPLES,
TECHNICAL_EXAMPLES,
SIMPLIFY_EXAMPLES,
ACCEPTANCE_EXAMPLES,
getEnhancementPrompt,
getSystemPrompt,
getExamples,
buildUserPrompt,
isValidEnhancementMode,
getAvailableEnhancementModes,
} from '@automaker/prompts';
/**
* Available enhancement modes for transforming task descriptions
*/
export type EnhancementMode = "improve" | "technical" | "simplify" | "acceptance";
export type { EnhancementMode, EnhancementExample } from '@automaker/prompts';
/**
* Example input/output pair for few-shot learning
*/
export interface EnhancementExample {
input: string;
output: string;
}
/**
* System prompt for the "improve" enhancement mode.
* Transforms vague or unclear requests into clear, actionable task descriptions.
*/
export const IMPROVE_SYSTEM_PROMPT = `You are an expert at transforming vague, unclear, or incomplete task descriptions into clear, actionable specifications.
Your task is to take a user's rough description and improve it by:
1. ANALYZE the input:
- Identify the core intent behind the request
- Note any ambiguities or missing details
- Determine what success would look like
2. CLARIFY the scope:
- Define clear boundaries for the task
- Identify implicit requirements
- Add relevant context that may be assumed
3. STRUCTURE the output:
- Write a clear, actionable title
- Provide a concise description of what needs to be done
- Break down into specific sub-tasks if appropriate
4. ENHANCE with details:
- Add specific, measurable outcomes where possible
- Include edge cases to consider
- Note any dependencies or prerequisites
Output ONLY the improved task description. Do not include explanations, markdown formatting, or meta-commentary about your changes.`;
/**
* System prompt for the "technical" enhancement mode.
* Adds implementation details and technical specifications.
*/
export const TECHNICAL_SYSTEM_PROMPT = `You are a senior software engineer skilled at adding technical depth to feature descriptions.
Your task is to enhance a task description with technical implementation details:
1. ANALYZE the requirement:
- Understand the functional goal
- Identify the technical domain (frontend, backend, database, etc.)
- Consider the likely tech stack based on context
2. ADD technical specifications:
- Suggest specific technologies, libraries, or patterns
- Define API contracts or data structures if relevant
- Note performance considerations
- Identify security implications
3. OUTLINE implementation approach:
- Break down into technical sub-tasks
- Suggest file structure or component organization
- Note integration points with existing systems
4. CONSIDER edge cases:
- Error handling requirements
- Loading and empty states
- Boundary conditions
Output ONLY the enhanced technical description. Keep it concise but comprehensive. Do not include explanations about your reasoning.`;
/**
* System prompt for the "simplify" enhancement mode.
* Makes verbose descriptions concise and focused.
*/
export const SIMPLIFY_SYSTEM_PROMPT = `You are an expert editor who excels at making verbose text concise without losing meaning.
Your task is to simplify a task description while preserving essential information:
1. IDENTIFY the core message:
- Extract the primary goal or requirement
- Note truly essential details
- Separate nice-to-have from must-have information
2. ELIMINATE redundancy:
- Remove repeated information
- Cut unnecessary qualifiers and hedging language
- Remove filler words and phrases
3. CONSOLIDATE related points:
- Merge overlapping requirements
- Group related items together
- Use concise language
4. PRESERVE critical details:
- Keep specific technical requirements
- Retain important constraints
- Maintain actionable specifics
Output ONLY the simplified description. Aim for 30-50% reduction in length while keeping all essential information. Do not explain your changes.`;
/**
* System prompt for the "acceptance" enhancement mode.
* Adds testable acceptance criteria to task descriptions.
*/
export const ACCEPTANCE_SYSTEM_PROMPT = `You are a QA specialist skilled at defining testable acceptance criteria for software features.
Your task is to enhance a task description by adding clear acceptance criteria:
1. UNDERSTAND the feature:
- Identify all user-facing behaviors
- Note system state changes
- Consider different user roles or scenarios
2. DEFINE acceptance criteria using Given-When-Then format:
- Given: The initial context or preconditions
- When: The action or trigger
- Then: The expected outcome
3. COVER key scenarios:
- Happy path (successful completion)
- Edge cases (boundary conditions)
- Error scenarios (what should NOT happen)
- Performance requirements if relevant
4. MAKE criteria testable:
- Use specific, measurable outcomes
- Avoid vague terms like "quickly" or "easily"
- Include specific values where applicable
Output the original description followed by a clear "Acceptance Criteria:" section with numbered, testable criteria. Do not include explanations about your process.`;
/**
* Few-shot examples for the "improve" enhancement mode
*/
export const IMPROVE_EXAMPLES: EnhancementExample[] = [
{
input: "add dark mode",
output: `Add Dark Mode Theme Support
Implement a dark mode option that allows users to switch between light and dark themes throughout the application.
Requirements:
- Add a theme toggle switch in the settings area
- Store user's theme preference persistently
- Apply dark theme colors to all UI components
- Respect system preference as default if user hasn't set a preference
- Ensure smooth transition when switching themes`,
},
{
input: "fix the login bug",
output: `Fix Login Authentication Flow
Investigate and resolve issues preventing users from successfully logging in to the application.
Requirements:
- Identify the specific failure point in the login process
- Ensure proper credential validation
- Handle error states with clear user feedback
- Verify session token is correctly stored after successful login
- Test with various credential combinations (valid, invalid, edge cases)`,
},
{
input: "make it faster",
output: `Improve Application Performance
Optimize application loading and response times to enhance user experience.
Requirements:
- Profile current performance to identify bottlenecks
- Implement code splitting and lazy loading for routes/components
- Optimize database queries and add appropriate indexes
- Add caching for frequently accessed data
- Set performance budget targets (e.g., LCP < 2.5s, FID < 100ms)
- Measure and document improvements`,
},
];
/**
* Few-shot examples for the "technical" enhancement mode
*/
export const TECHNICAL_EXAMPLES: EnhancementExample[] = [
{
input: "Add user profile page",
output: `Add User Profile Page
Create a dedicated profile page for viewing and editing user information.
Technical Implementation:
- Frontend: React component at /profile route with form validation
- API Endpoint: GET/PUT /api/users/:id for fetching and updating profile
- Data Model: Extend User schema with profile fields (avatar, bio, preferences)
- File Upload: Integrate cloud storage for avatar images with client-side resizing
- State Management: Use optimistic updates for better UX
- Validation: Server-side validation matching client-side rules
Components:
- ProfileHeader (avatar, name, edit button)
- ProfileForm (editable fields with validation)
- AvatarUploader (drag-drop with preview)
Security: Ensure users can only edit their own profile (auth middleware)`,
},
{
input: "Add search functionality",
output: `Add Search Functionality
Implement full-text search across application content.
Technical Implementation:
- Search Engine: Use Elasticsearch or PostgreSQL full-text search
- API: GET /api/search?q={query}&type={type}&page={page}
- Indexing: Create search index with relevant fields, update on content changes
- Frontend: Debounced search input (300ms) with typeahead suggestions
- Results: Paginated results with highlighted matching text
Architecture:
- SearchService class handling query building and execution
- SearchIndex worker for background indexing
- SearchResults component with filtering and sorting
Performance:
- Implement search result caching (Redis, 5-minute TTL)
- Limit results per page (20 items)
- Add query complexity limits to prevent abuse`,
},
];
/**
* Few-shot examples for the "simplify" enhancement mode
*/
export const SIMPLIFY_EXAMPLES: EnhancementExample[] = [
{
input: `We need to implement a feature that would allow our users to be able to export their data in various different formats. The formats we are thinking about supporting include CSV which is commonly used for spreadsheet applications, and also JSON which is good for developers, and possibly PDF for people who want a printable version. The user should be able to go somewhere in the UI and select which format they want and then download their data in that format. We should probably also show some kind of progress indicator while the export is happening since it might take a while for large datasets.`,
output: `Add Data Export Feature
Allow users to export their data in CSV, JSON, or PDF format.
Requirements:
- Add export button/menu in the UI
- Support three formats: CSV (spreadsheets), JSON (developers), PDF (printing)
- Show progress indicator during export
- Handle large datasets gracefully`,
},
{
input: `The current notification system is not working very well and users are complaining about it. Sometimes they don't receive notifications at all, and other times they receive too many notifications and feel overwhelmed. We also have had reports that the notifications are not appearing correctly on mobile devices. Additionally, some users have requested the ability to customize which types of notifications they receive. We should look into all of these issues and make the notification system work better overall.`,
output: `Fix and Improve Notification System
Address reliability issues and add user controls for notifications.
Issues to fix:
- Missing notifications (delivery reliability)
- Notification overload (implement batching/digest)
- Mobile display problems
Enhancements:
- Add notification preferences (per-type controls)
- Test across devices and platforms`,
},
];
/**
* Few-shot examples for the "acceptance" enhancement mode
*/
export const ACCEPTANCE_EXAMPLES: EnhancementExample[] = [
{
input: "Add password reset functionality",
output: `Add Password Reset Functionality
Allow users to reset their password via email when they forget it.
Acceptance Criteria:
1. Given a user is on the login page, when they click "Forgot Password", then they should see a password reset form requesting their email.
2. Given a user enters a valid registered email, when they submit the form, then they should receive a password reset email within 2 minutes.
3. Given a user clicks the reset link in their email, when the link is less than 1 hour old, then they should see a form to enter a new password.
4. Given a user enters a new valid password (min 8 chars, 1 number, 1 special char), when they submit, then their password should be updated and they should be redirected to login.
5. Given a user enters an unregistered email, when they submit the reset form, then they should see the same success message (security: don't reveal which emails exist).
6. Given a reset link older than 1 hour, when a user clicks it, then they should see an expiration message with option to request a new link.`,
},
{
input: "Shopping cart checkout",
output: `Shopping Cart Checkout
Implement the checkout flow for purchasing items in the shopping cart.
Acceptance Criteria:
1. Given a user has items in their cart, when they click "Checkout", then they should see an order summary with item details and total price.
2. Given a user is on the checkout page, when they enter valid shipping information, then the form should validate in real-time and show estimated delivery date.
3. Given valid shipping info is entered, when the user proceeds to payment, then they should see available payment methods (credit card, PayPal).
4. Given valid payment details are entered, when the user confirms the order, then the payment should be processed and order confirmation displayed within 5 seconds.
5. Given a successful order, when confirmation is shown, then the user should receive an email receipt and their cart should be emptied.
6. Given a payment failure, when the error occurs, then the user should see a clear error message and their cart should remain intact.
7. Given the user closes the browser during checkout, when they return, then their cart contents should still be available.`,
},
];
/**
* Map of enhancement modes to their system prompts
*/
const SYSTEM_PROMPTS: Record<EnhancementMode, string> = {
improve: IMPROVE_SYSTEM_PROMPT,
technical: TECHNICAL_SYSTEM_PROMPT,
simplify: SIMPLIFY_SYSTEM_PROMPT,
acceptance: ACCEPTANCE_SYSTEM_PROMPT,
};
/**
* Map of enhancement modes to their few-shot examples
*/
const EXAMPLES: Record<EnhancementMode, EnhancementExample[]> = {
improve: IMPROVE_EXAMPLES,
technical: TECHNICAL_EXAMPLES,
simplify: SIMPLIFY_EXAMPLES,
acceptance: ACCEPTANCE_EXAMPLES,
};
/**
* Enhancement prompt configuration returned by getEnhancementPrompt
*/
export interface EnhancementPromptConfig {
/** System prompt for the enhancement mode */
systemPrompt: string;
/** Description of what this mode does */
description: string;
}
/**
* Descriptions for each enhancement mode
*/
const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: "Transform vague requests into clear, actionable task descriptions",
technical: "Add implementation details and technical specifications",
simplify: "Make verbose descriptions concise and focused",
acceptance: "Add testable acceptance criteria to task descriptions",
};
/**
* Get the enhancement prompt configuration for a given mode
*
* @param mode - The enhancement mode (falls back to 'improve' if invalid)
* @returns The enhancement prompt configuration
*/
export function getEnhancementPrompt(mode: string): EnhancementPromptConfig {
const normalizedMode = mode.toLowerCase() as EnhancementMode;
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : "improve";
return {
systemPrompt: SYSTEM_PROMPTS[validMode],
description: MODE_DESCRIPTIONS[validMode],
};
}
/**
* Get the system prompt for a specific enhancement mode
*
* @param mode - The enhancement mode to get the prompt for
* @returns The system prompt string
*/
export function getSystemPrompt(mode: EnhancementMode): string {
return SYSTEM_PROMPTS[mode];
}
/**
* Get the few-shot examples for a specific enhancement mode
*
* @param mode - The enhancement mode to get examples for
* @returns Array of input/output example pairs
*/
export function getExamples(mode: EnhancementMode): EnhancementExample[] {
return EXAMPLES[mode];
}
/**
* Build a user prompt for enhancement with optional few-shot examples
*
* @param mode - The enhancement mode
* @param text - The text to enhance
* @param includeExamples - Whether to include few-shot examples (default: true)
* @returns The formatted user prompt string
*/
export function buildUserPrompt(
mode: EnhancementMode,
text: string,
includeExamples: boolean = true
): string {
const examples = includeExamples ? getExamples(mode) : [];
if (examples.length === 0) {
return `Please enhance the following task description:\n\n${text}`;
}
// Build few-shot examples section
const examplesSection = examples
.map(
(example, index) =>
`Example ${index + 1}:\nInput: ${example.input}\nOutput: ${example.output}`
)
.join("\n\n---\n\n");
return `Here are some examples of how to enhance task descriptions:
${examplesSection}
---
Now, please enhance the following task description:
${text}`;
}
/**
* Check if a mode is a valid enhancement mode
*
* @param mode - The mode to check
* @returns True if the mode is valid
*/
export function isValidEnhancementMode(mode: string): mode is EnhancementMode {
return mode in SYSTEM_PROMPTS;
}
/**
* Get all available enhancement modes
*
* @returns Array of available enhancement mode names
*/
export function getAvailableEnhancementModes(): EnhancementMode[] {
return Object.keys(SYSTEM_PROMPTS) as EnhancementMode[];
}

View File

@@ -8,8 +8,6 @@
* - Generating user-friendly error messages
*/
import type { ErrorType, ErrorInfo } from '@automaker/types';
/**
* Check if an error is an abort/cancellation error
*
@@ -17,7 +15,10 @@ import type { ErrorType, ErrorInfo } from '@automaker/types';
* @returns True if the error is an abort error
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('abort'));
return (
error instanceof Error &&
(error.name === "AbortError" || error.message.includes("abort"))
);
}
/**
@@ -29,10 +30,10 @@ export function isAbortError(error: unknown): boolean {
export function isCancellationError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase();
return (
lowerMessage.includes('cancelled') ||
lowerMessage.includes('canceled') ||
lowerMessage.includes('stopped') ||
lowerMessage.includes('aborted')
lowerMessage.includes("cancelled") ||
lowerMessage.includes("canceled") ||
lowerMessage.includes("stopped") ||
lowerMessage.includes("aborted")
);
}
@@ -44,13 +45,30 @@ export function isCancellationError(errorMessage: string): boolean {
*/
export function isAuthenticationError(errorMessage: string): boolean {
return (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('Invalid API key') ||
errorMessage.includes('authentication_failed') ||
errorMessage.includes('Fix external API key')
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed") ||
errorMessage.includes("Fix external API key")
);
}
/**
* Error type classification
*/
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
/**
* Classified error information
*/
export interface ErrorInfo {
type: ErrorType;
message: string;
isAbort: boolean;
isAuth: boolean;
isCancellation: boolean;
originalError: unknown;
}
/**
* Classify an error into a specific type
*
@@ -58,22 +76,22 @@ export function isAuthenticationError(errorMessage: string): boolean {
* @returns Classified error information
*/
export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const message = error instanceof Error ? error.message : String(error || "Unknown error");
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message);
let type: ErrorType;
if (isAuth) {
type = 'authentication';
type = "authentication";
} else if (isAbort) {
type = 'abort';
type = "abort";
} else if (isCancellation) {
type = 'cancellation';
type = "cancellation";
} else if (error instanceof Error) {
type = 'execution';
type = "execution";
} else {
type = 'unknown';
type = "unknown";
}
return {
@@ -96,36 +114,12 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
const info = classifyError(error);
if (info.isAbort) {
return 'Operation was cancelled';
return "Operation was cancelled";
}
if (info.isAuth) {
return 'Authentication failed. Please check your API key.';
return "Authentication failed. Please check your API key.";
}
return info.message;
}
/**
* Extract error message from an unknown error value
*
* Simple utility for getting a string error message from any error type.
* Returns the error's message property if it's an Error, otherwise
* converts to string. Used throughout the codebase for consistent
* error message extraction.
*
* @param error - The error value (Error object, string, or unknown)
* @returns Error message string
*
* @example
* ```typescript
* try {
* throw new Error("Something went wrong");
* } catch (error) {
* const message = getErrorMessage(error); // "Something went wrong"
* }
* ```
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}

View File

@@ -2,10 +2,31 @@
* Event emitter for streaming events to WebSocket clients
*/
import type { EventType, EventCallback } from '@automaker/types';
export type EventType =
| "agent:stream"
| "auto-mode:event"
| "auto-mode:started"
| "auto-mode:stopped"
| "auto-mode:idle"
| "auto-mode:error"
| "feature:started"
| "feature:completed"
| "feature:stopped"
| "feature:error"
| "feature:progress"
| "feature:tool-use"
| "feature:follow-up-started"
| "feature:follow-up-completed"
| "feature:verified"
| "feature:committed"
| "project:analysis-started"
| "project:analysis-progress"
| "project:analysis-completed"
| "project:analysis-error"
| "suggestions:event"
| "spec-regeneration:event";
// Re-export event types from shared package
export type { EventType, EventCallback };
export type EventCallback = (type: EventType, payload: unknown) => void;
export interface EventEmitter {
emit: (type: EventType, payload: unknown) => void;
@@ -21,7 +42,7 @@ export function createEventEmitter(): EventEmitter {
try {
callback(type, payload);
} catch (error) {
console.error('Error in event subscriber:', error);
console.error("Error in event subscriber:", error);
}
}
},

View File

@@ -2,8 +2,8 @@
* File system utilities that handle symlinks safely
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
import fs from "fs/promises";
import path from "path";
/**
* Create a directory, handling symlinks safely to avoid ELOOP errors.
@@ -14,7 +14,7 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await secureFs.lstat(resolvedPath);
const stats = await fs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
return;
@@ -23,10 +23,10 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
} catch (error: any) {
// ENOENT means path doesn't exist - we should create it
if (error.code !== 'ENOENT') {
if (error.code !== "ENOENT") {
// Some other error (could be ELOOP in parent path)
// If it's ELOOP, the path involves symlinks - don't try to create
if (error.code === 'ELOOP') {
if (error.code === "ELOOP") {
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
return;
}
@@ -36,10 +36,10 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
// Path doesn't exist, create it
try {
await secureFs.mkdir(resolvedPath, { recursive: true });
await fs.mkdir(resolvedPath, { recursive: true });
} catch (error: any) {
// Handle race conditions and symlink issues
if (error.code === 'EEXIST' || error.code === 'ELOOP') {
if (error.code === "EEXIST" || error.code === "ELOOP") {
return;
}
throw error;
@@ -52,14 +52,14 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
*/
export async function existsSafe(filePath: string): Promise<boolean> {
try {
await secureFs.lstat(filePath);
await fs.lstat(filePath);
return true;
} catch (error: any) {
if (error.code === 'ENOENT') {
if (error.code === "ENOENT") {
return false;
}
// ELOOP or other errors - path exists but is problematic
if (error.code === 'ELOOP') {
if (error.code === "ELOOP") {
return true; // Symlink exists, even if looping
}
throw error;

View File

@@ -8,21 +8,42 @@
* - Path resolution (relative/absolute)
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
import type { ImageData, ImageContentBlock } from '@automaker/types';
import fs from "fs/promises";
import path from "path";
/**
* MIME type mapping for image file extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
} as const;
/**
* Image data with base64 encoding and metadata
*/
export interface ImageData {
base64: string;
mimeType: string;
filename: string;
originalPath: string;
}
/**
* Content block for image (Claude SDK format)
*/
export interface ImageContentBlock {
type: "image";
source: {
type: "base64";
media_type: string;
data: string;
};
}
/**
* Get MIME type for an image file based on extension
*
@@ -31,7 +52,7 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
*/
export function getMimeTypeForImage(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || 'image/png';
return IMAGE_MIME_TYPES[ext] || "image/png";
}
/**
@@ -42,8 +63,8 @@ export function getMimeTypeForImage(imagePath: string): string {
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = (await secureFs.readFile(imagePath)) as Buffer;
const base64Data = imageBuffer.toString('base64');
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const mimeType = getMimeTypeForImage(imagePath);
return {
@@ -71,15 +92,16 @@ export async function convertImagesToContentBlocks(
for (const imagePath of imagePaths) {
try {
// Resolve to absolute path if needed
const absolutePath =
workDir && !path.isAbsolute(imagePath) ? path.join(workDir, imagePath) : imagePath;
const absolutePath = workDir && !path.isAbsolute(imagePath)
? path.join(workDir, imagePath)
: imagePath;
const imageData = await readImageAsBase64(absolutePath);
blocks.push({
type: 'image',
type: "image",
source: {
type: 'base64',
type: "base64",
media_type: imageData.mimeType,
data: imageData.base64,
},
@@ -102,10 +124,10 @@ export async function convertImagesToContentBlocks(
*/
export function formatImagePathsForPrompt(imagePaths: string[]): string {
if (imagePaths.length === 0) {
return '';
return "";
}
let text = '\n\nAttached images:\n';
let text = "\n\nAttached images:\n";
for (const imagePath of imagePaths) {
text += `- ${imagePath}\n`;
}

View File

@@ -53,7 +53,7 @@ export function createLogger(context: string) {
debug: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.DEBUG) {
console.log(prefix, '[DEBUG]', ...args);
console.log(prefix, "[DEBUG]", ...args);
}
},
};
@@ -72,3 +72,4 @@ export function getLogLevel(): LogLevel {
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}

View File

@@ -7,7 +7,21 @@
* - Handles multiple model sources with priority
*/
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types';
/**
* Model alias mapping for Claude models
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
} as const;
/**
* Default models per provider
*/
export const DEFAULT_MODELS = {
claude: "claude-opus-4-5-20251101",
} as const;
/**
* Resolve a model key/alias to a full model string
@@ -26,7 +40,7 @@ export function resolveModelString(
}
// Full Claude model string - pass through unchanged
if (modelKey.includes('claude-')) {
if (modelKey.includes("claude-")) {
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
return modelKey;
}
@@ -34,12 +48,16 @@ export function resolveModelString(
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
console.log(
`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`
);
return resolved;
}
// Unknown model key - use default
console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`);
console.warn(
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
);
return defaultModel;
}

View File

@@ -8,18 +8,16 @@
* - Supports both vision and non-vision models
*/
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from './image-handler.js';
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
/**
* Content that can be either simple text or structured blocks
*/
export type PromptContent =
| string
| Array<{
type: string;
text?: string;
source?: object;
}>;
export type PromptContent = string | Array<{
type: string;
text?: string;
source?: object;
}>;
/**
* Result of building a prompt with optional images
@@ -64,7 +62,7 @@ export async function buildPromptWithImages(
// Add text block if we have text
if (textContent.trim()) {
contentBlocks.push({ type: 'text', text: textContent });
contentBlocks.push({ type: "text", text: textContent });
}
// Add image blocks
@@ -73,7 +71,9 @@ export async function buildPromptWithImages(
// Return appropriate format
const content: PromptContent =
contentBlocks.length > 1 || contentBlocks[0]?.type === 'image' ? contentBlocks : textContent;
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
? contentBlocks
: textContent;
return { content, hasImages: true };
}

View File

@@ -9,59 +9,48 @@
* - Chat: Full tool access for interactive coding
*
* Uses model-resolver for consistent model handling across the application.
*
* SECURITY: All factory functions validate the working directory (cwd) against
* ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized
* security check that applies to ALL AI model invocations, regardless of provider.
*/
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
/**
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
* This is the centralized security check for ALL AI model invocations.
*
* @param cwd - The working directory to validate
* @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY
*
* This function is called by all create*Options() factory functions to ensure
* that AI models can only operate within allowed directories. This applies to:
* - All current models (Claude, future models)
* - All invocation types (chat, auto-mode, spec generation, etc.)
*/
export function validateWorkingDirectory(cwd: string): void {
const resolvedCwd = path.resolve(cwd);
if (!isPathAllowed(resolvedCwd)) {
const allowedRoot = getAllowedRootDirectory();
throw new PathNotAllowedError(
`Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` +
(allowedRoot
? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
: 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.')
);
}
}
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import {
resolveModelString,
DEFAULT_MODELS,
CLAUDE_MODEL_MAP,
} from "./model-resolver.js";
/**
* Tool presets for different use cases
*/
export const TOOL_PRESETS = {
/** Read-only tools for analysis */
readOnly: ['Read', 'Glob', 'Grep'] as const,
readOnly: ["Read", "Glob", "Grep"] as const,
/** Tools for spec generation that needs to read the codebase */
specGeneration: ['Read', 'Glob', 'Grep'] as const,
specGeneration: ["Read", "Glob", "Grep"] as const,
/** Full tool access for feature implementation */
fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const,
fullAccess: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const,
/** Tools for chat/interactive mode */
chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const,
chat: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const,
} as const;
/**
@@ -92,7 +81,7 @@ export const MAX_TURNS = {
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
*/
export function getModelForUseCase(
useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default',
useCase: "spec" | "features" | "suggestions" | "chat" | "auto" | "default",
explicitModel?: string
): string {
// Explicit model takes precedence
@@ -116,12 +105,12 @@ export function getModelForUseCase(
}
const defaultModels: Record<string, string> = {
spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs
features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs
suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions
chat: CLAUDE_MODEL_MAP['haiku'], // used for chat
auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards
default: CLAUDE_MODEL_MAP['opus'],
spec: CLAUDE_MODEL_MAP["haiku"], // used to generate app specs
features: CLAUDE_MODEL_MAP["haiku"], // used to generate features from app specs
suggestions: CLAUDE_MODEL_MAP["haiku"], // used for suggestions
chat: CLAUDE_MODEL_MAP["haiku"], // used for chat
auto: CLAUDE_MODEL_MAP["opus"], // used to implement kanban cards
default: CLAUDE_MODEL_MAP["opus"],
};
return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
@@ -132,63 +121,10 @@ export function getModelForUseCase(
*/
function getBaseOptions(): Partial<Options> {
return {
permissionMode: 'acceptEdits',
permissionMode: "acceptEdits",
};
}
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
* - If there's a custom systemPrompt, appends it to the preset
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
*
* @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options
*/
function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} {
if (!config.autoLoadClaudeMd) {
// Standard mode - just pass through the system prompt as-is
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
}
// Auto-load CLAUDE.md mode - use preset with settingSources
const result: {
systemPrompt: SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>;
} = {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
},
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
settingSources: ['user', 'project'],
};
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
result.systemPrompt.append = config.systemPrompt;
}
return result;
}
/**
* System prompt configuration for SDK options
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
*/
export interface SystemPromptConfig {
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
type: 'preset';
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
preset: 'claude_code';
/** Optional additional prompt to append to the preset */
append?: string;
}
/**
* Options configuration for creating SDK options
*/
@@ -210,15 +146,9 @@ export interface CreateSdkOptionsConfig {
/** Optional output format for structured outputs */
outputFormat?: {
type: 'json_schema';
type: "json_schema";
schema: Record<string, unknown>;
};
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash command isolation */
enableSandboxMode?: boolean;
}
/**
@@ -228,26 +158,21 @@ export interface CreateSdkOptionsConfig {
* - Uses read-only tools for codebase analysis
* - Extended turns for thorough exploration
* - Opus model by default (can be overridden)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
export function createSpecGenerationOptions(
config: CreateSdkOptionsConfig
): Options {
return {
...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools
// Using "acceptEdits" can cause Claude to write files to unexpected locations
// See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: 'default',
model: getModelForUseCase('spec', config.model),
permissionMode: "default",
model: getModelForUseCase("spec", config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
@@ -260,24 +185,19 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
* - Uses read-only tools (just needs to read the spec)
* - Quick turns since it's mostly JSON generation
* - Sonnet model by default for speed
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
export function createFeatureGenerationOptions(
config: CreateSdkOptionsConfig
): Options {
return {
...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools
permissionMode: 'default',
model: getModelForUseCase('features', config.model),
permissionMode: "default",
model: getModelForUseCase("features", config.model),
maxTurns: MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -289,22 +209,17 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
* - Uses read-only tools for analysis
* - Standard turns to allow thorough codebase exploration and structured output generation
* - Opus model by default for thorough analysis
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
export function createSuggestionsOptions(
config: CreateSdkOptionsConfig
): Options {
return {
...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model),
model: getModelForUseCase("suggestions", config.model),
maxTurns: MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
@@ -317,32 +232,23 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification
* - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default
* - Sandbox mode controlled by enableSandboxMode setting
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
* - Sandbox enabled for bash safety
*/
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Model priority: explicit model > session model > chat default
const effectiveModel = config.model || config.sessionModel;
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
model: getModelForUseCase("chat", effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
...(config.enableSandboxMode && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -354,29 +260,20 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation
* - Uses default model (can be overridden)
* - Sandbox mode controlled by enableSandboxMode setting
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
* - Sandbox enabled for bash safety
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
model: getModelForUseCase("auto", config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
...(config.enableSandboxMode && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -385,7 +282,6 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
* Create custom SDK options with explicit configuration
*
* Use this when the preset options don't fit your use case.
* When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createCustomOptions(
config: CreateSdkOptionsConfig & {
@@ -394,20 +290,16 @@ export function createCustomOptions(
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
}
): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('default', config.model),
model: getModelForUseCase("default", config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
allowedTools: config.allowedTools
? [...config.allowedTools]
: [...TOOL_PRESETS.readOnly],
...(config.sandbox && { sandbox: config.sandbox }),
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}

View File

@@ -1,28 +0,0 @@
/**
* Re-export secure file system utilities from @automaker/platform
* This file exists for backward compatibility with existing imports
*/
import { secureFs } from '@automaker/platform';
export const {
access,
readFile,
writeFile,
mkdir,
readdir,
stat,
rm,
unlink,
copyFile,
appendFile,
rename,
lstat,
joinPath,
resolvePath,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,
getPendingOperations,
getActiveOperations,
} = secureFs;

View File

@@ -0,0 +1,63 @@
/**
* Security utilities for path validation
* Note: All permission checks have been disabled to allow unrestricted access
*/
import path from "path";
// Allowed project directories - kept for API compatibility
const allowedPaths = new Set<string>();
/**
* Initialize allowed paths from environment variable
* Note: All paths are now allowed regardless of this setting
*/
export function initAllowedPaths(): void {
const dirs = process.env.ALLOWED_PROJECT_DIRS;
if (dirs) {
for (const dir of dirs.split(",")) {
const trimmed = dir.trim();
if (trimmed) {
allowedPaths.add(path.resolve(trimmed));
}
}
}
const dataDir = process.env.DATA_DIR;
if (dataDir) {
allowedPaths.add(path.resolve(dataDir));
}
const workspaceDir = process.env.WORKSPACE_DIR;
if (workspaceDir) {
allowedPaths.add(path.resolve(workspaceDir));
}
}
/**
* Add a path to the allowed list (no-op, all paths allowed)
*/
export function addAllowedPath(filePath: string): void {
allowedPaths.add(path.resolve(filePath));
}
/**
* Check if a path is allowed - always returns true
*/
export function isPathAllowed(_filePath: string): boolean {
return true;
}
/**
* Validate a path - just resolves the path without checking permissions
*/
export function validatePath(filePath: string): string {
return path.resolve(filePath);
}
/**
* Get list of allowed paths (for debugging)
*/
export function getAllowedPaths(): string[] {
return Array.from(allowedPaths);
}

View File

@@ -1,138 +0,0 @@
/**
* Helper utilities for loading settings and context file handling across different parts of the server
*/
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
* Returns false if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[DescribeImage]')
* @returns Promise resolving to the autoLoadClaudeMd setting value
*/
export async function getAutoLoadClaudeMdSetting(
projectPath: string,
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return false;
}
try {
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.autoLoadClaudeMd !== undefined) {
console.log(
`${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
);
return projectSettings.autoLoadClaudeMd;
}
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false;
console.log(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
throw error;
}
}
/**
* Get the enableSandboxMode setting from global settings.
* Returns false if settings service is not available.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the enableSandboxMode setting value
*/
export async function getEnableSandboxModeSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`);
return false;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? true;
console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
throw error;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.
*
* When autoLoadClaudeMd is true, the SDK handles CLAUDE.md loading via settingSources,
* so we need to exclude it from the manual context loading to avoid duplication.
* Other context files (CODE_QUALITY.md, CONVENTIONS.md, etc.) are preserved.
*
* @param contextResult - Result from loadContextFiles
* @param autoLoadClaudeMd - Whether SDK auto-loading is enabled
* @returns Filtered context prompt (empty string if no non-CLAUDE.md files)
*/
export function filterClaudeMdFromContext(
contextResult: ContextFilesResult,
autoLoadClaudeMd: boolean
): string {
// If autoLoadClaudeMd is disabled, return the original prompt unchanged
if (!autoLoadClaudeMd || contextResult.files.length === 0) {
return contextResult.formattedPrompt;
}
// Filter out CLAUDE.md (case-insensitive)
const nonClaudeFiles = contextResult.files.filter((f) => f.name.toLowerCase() !== 'claude.md');
// If all files were CLAUDE.md, return empty string
if (nonClaudeFiles.length === 0) {
return '';
}
// Rebuild prompt without CLAUDE.md using the same format as loadContextFiles
const formattedFiles = nonClaudeFiles.map((file) => formatContextFileEntry(file));
return `# Project Context Files
The following context files provide project-specific rules, conventions, and guidelines.
Each file serves a specific purpose - use the description to understand when to reference it.
If you need more details about a context file, you can read the full file at the path provided.
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
- Reference these rules before running ANY shell commands or making commits
---
${formattedFiles.join('\n\n---\n\n')}
---
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
`;
}
/**
* Format a single context file entry for the prompt
* (Matches the format used in @automaker/utils/context-loader.ts)
*/
function formatContextFileEntry(file: ContextFileInfo): string {
const header = `## ${file.name}`;
const pathInfo = `**Path:** \`${file.path}\``;
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
}

View File

@@ -2,8 +2,8 @@
* Subprocess management utilities for CLI providers
*/
import { spawn, type ChildProcess } from 'child_process';
import readline from 'readline';
import { spawn, type ChildProcess } from "child_process";
import readline from "readline";
export interface SubprocessOptions {
command: string;
@@ -23,7 +23,9 @@ export interface SubprocessResult {
/**
* Spawns a subprocess and streams JSONL output line-by-line
*/
export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown> {
export async function* spawnJSONLProcess(
options: SubprocessOptions
): AsyncGenerator<unknown> {
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
const processEnv = {
@@ -31,22 +33,22 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
...env,
};
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(' ')}`);
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
console.log(`[SubprocessManager] Working directory: ${cwd}`);
const childProcess: ChildProcess = spawn(command, args, {
cwd,
env: processEnv,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
});
let stderrOutput = '';
let stderrOutput = "";
let lastOutputTime = Date.now();
let timeoutHandle: NodeJS.Timeout | null = null;
// Collect stderr for error reporting
if (childProcess.stderr) {
childProcess.stderr.on('data', (data: Buffer) => {
childProcess.stderr.on("data", (data: Buffer) => {
const text = data.toString();
stderrOutput += text;
console.error(`[SubprocessManager] stderr: ${text}`);
@@ -62,8 +64,10 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
timeoutHandle = setTimeout(() => {
const elapsed = Date.now() - lastOutputTime;
if (elapsed >= timeout) {
console.error(`[SubprocessManager] Process timeout: no output for ${timeout}ms`);
childProcess.kill('SIGTERM');
console.error(
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
);
childProcess.kill("SIGTERM");
}
}, timeout);
};
@@ -72,12 +76,12 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
// Setup abort handling
if (abortController) {
abortController.signal.addEventListener('abort', () => {
console.log('[SubprocessManager] Abort signal received, killing process');
abortController.signal.addEventListener("abort", () => {
console.log("[SubprocessManager] Abort signal received, killing process");
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
childProcess.kill('SIGTERM');
childProcess.kill("SIGTERM");
});
}
@@ -98,16 +102,19 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
const parsed = JSON.parse(line);
yield parsed;
} catch (parseError) {
console.error(`[SubprocessManager] Failed to parse JSONL line: ${line}`, parseError);
console.error(
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
parseError
);
// Yield error but continue processing
yield {
type: 'error',
type: "error",
error: `Failed to parse output: ${line}`,
};
}
}
} catch (error) {
console.error('[SubprocessManager] Error reading stdout:', error);
console.error("[SubprocessManager] Error reading stdout:", error);
throw error;
} finally {
if (timeoutHandle) {
@@ -118,13 +125,13 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
// Wait for process to exit
const exitCode = await new Promise<number | null>((resolve) => {
childProcess.on('exit', (code) => {
childProcess.on("exit", (code) => {
console.log(`[SubprocessManager] Process exited with code: ${code}`);
resolve(code);
});
childProcess.on('error', (error) => {
console.error('[SubprocessManager] Process error:', error);
childProcess.on("error", (error) => {
console.error("[SubprocessManager] Process error:", error);
resolve(null);
});
});
@@ -134,21 +141,23 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
yield {
type: 'error',
type: "error",
error: errorMessage,
};
}
// Process completed successfully
if (exitCode === 0 && !stderrOutput) {
console.log('[SubprocessManager] Process completed successfully');
console.log("[SubprocessManager] Process completed successfully");
}
}
/**
* Spawns a subprocess and collects all output
*/
export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> {
export async function spawnProcess(
options: SubprocessOptions
): Promise<SubprocessResult> {
const { command, args, cwd, env, abortController } = options;
const processEnv = {
@@ -160,37 +169,37 @@ export async function spawnProcess(options: SubprocessOptions): Promise<Subproce
const childProcess = spawn(command, args, {
cwd,
env: processEnv,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = '';
let stderr = '';
let stdout = "";
let stderr = "";
if (childProcess.stdout) {
childProcess.stdout.on('data', (data: Buffer) => {
childProcess.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
}
if (childProcess.stderr) {
childProcess.stderr.on('data', (data: Buffer) => {
childProcess.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
}
// Setup abort handling
if (abortController) {
abortController.signal.addEventListener('abort', () => {
childProcess.kill('SIGTERM');
reject(new Error('Process aborted'));
abortController.signal.addEventListener("abort", () => {
childProcess.kill("SIGTERM");
reject(new Error("Process aborted"));
});
}
childProcess.on('exit', (code) => {
childProcess.on("exit", (code) => {
resolve({ stdout, stderr, exitCode: code });
});
childProcess.on('error', (error) => {
childProcess.on("error", (error) => {
reject(error);
});
});

View File

@@ -1,181 +0,0 @@
/**
* Validation Storage - CRUD operations for GitHub issue validation results
*
* Stores validation results in .automaker/validations/{issueNumber}/validation.json
* Results include the validation verdict, metadata, and timestamp for cache invalidation.
*/
import * as secureFs from './secure-fs.js';
import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform';
import type { StoredValidation } from '@automaker/types';
// Re-export StoredValidation for convenience
export type { StoredValidation };
/** Number of hours before a validation is considered stale */
const VALIDATION_CACHE_TTL_HOURS = 24;
/**
* Write validation result to storage
*
* Creates the validation directory if needed and stores the result as JSON.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @param data - Validation data to store
*/
export async function writeValidation(
projectPath: string,
issueNumber: number,
data: StoredValidation
): Promise<void> {
const validationDir = getValidationDir(projectPath, issueNumber);
const validationPath = getValidationPath(projectPath, issueNumber);
// Ensure directory exists
await secureFs.mkdir(validationDir, { recursive: true });
// Write validation result
await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Read validation result from storage
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Stored validation or null if not found
*/
export async function readValidation(
projectPath: string,
issueNumber: number
): Promise<StoredValidation | null> {
try {
const validationPath = getValidationPath(projectPath, issueNumber);
const content = (await secureFs.readFile(validationPath, 'utf-8')) as string;
return JSON.parse(content) as StoredValidation;
} catch {
// File doesn't exist or can't be read
return null;
}
}
/**
* Get all stored validations for a project
*
* @param projectPath - Absolute path to project directory
* @returns Array of stored validations
*/
export async function getAllValidations(projectPath: string): Promise<StoredValidation[]> {
const validationsDir = getValidationsDir(projectPath);
try {
const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
// Read all validation files in parallel for better performance
const promises = dirs
.filter((dir) => dir.isDirectory())
.map((dir) => {
const issueNumber = parseInt(dir.name, 10);
if (!isNaN(issueNumber)) {
return readValidation(projectPath, issueNumber);
}
return Promise.resolve(null);
});
const results = await Promise.all(promises);
const validations = results.filter((v): v is StoredValidation => v !== null);
// Sort by issue number
validations.sort((a, b) => a.issueNumber - b.issueNumber);
return validations;
} catch {
// Directory doesn't exist
return [];
}
}
/**
* Delete a validation from storage
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns true if validation was deleted, false if not found
*/
export async function deleteValidation(projectPath: string, issueNumber: number): Promise<boolean> {
try {
const validationDir = getValidationDir(projectPath, issueNumber);
await secureFs.rm(validationDir, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
/**
* Check if a validation is stale (older than TTL)
*
* @param validation - Stored validation to check
* @returns true if validation is older than 24 hours
*/
export function isValidationStale(validation: StoredValidation): boolean {
const validatedAt = new Date(validation.validatedAt);
const now = new Date();
const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60);
return hoursDiff > VALIDATION_CACHE_TTL_HOURS;
}
/**
* Get validation with freshness info
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Object with validation and isStale flag, or null if not found
*/
export async function getValidationWithFreshness(
projectPath: string,
issueNumber: number
): Promise<{ validation: StoredValidation; isStale: boolean } | null> {
const validation = await readValidation(projectPath, issueNumber);
if (!validation) {
return null;
}
return {
validation,
isStale: isValidationStale(validation),
};
}
/**
* Mark a validation as viewed by the user
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns true if validation was marked as viewed, false if not found
*/
export async function markValidationViewed(
projectPath: string,
issueNumber: number
): Promise<boolean> {
const validation = await readValidation(projectPath, issueNumber);
if (!validation) {
return false;
}
validation.viewedAt = new Date().toISOString();
await writeValidation(projectPath, issueNumber, validation);
return true;
}
/**
* Get count of unviewed, non-stale validations for a project
*
* @param projectPath - Absolute path to project directory
* @returns Number of unviewed validations
*/
export async function getUnviewedValidationsCount(projectPath: string): Promise<number> {
const validations = await getAllValidations(projectPath);
return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length;
}

View File

@@ -1,180 +0,0 @@
/**
* Worktree metadata storage utilities
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
*/
import * as secureFs from './secure-fs.js';
import * as path from 'path';
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
}
/**
* Sanitize branch name for cross-platform filesystem safety
*/
function sanitizeBranchName(branch: string): string {
// Replace characters that are invalid or problematic on various filesystems:
// - Forward and backslashes (path separators)
// - Windows invalid chars: : * ? " < > |
// - Other potentially problematic chars
let safeBranch = branch
.replace(/[/\\:*?"<>|]/g, '-') // Replace invalid chars with dash
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/\.+$/g, '') // Remove trailing dots (Windows issue)
.replace(/-+/g, '-') // Collapse multiple dashes
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
// Truncate to safe length (leave room for path components)
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
safeBranch = `_${safeBranch || 'branch'}`;
}
return safeBranch;
}
/**
* Get the path to the worktree metadata directory
*/
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
const safeBranch = sanitizeBranchName(branch);
return path.join(projectPath, '.automaker', 'worktrees', safeBranch);
}
/**
* Get the path to the worktree metadata file
*/
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json');
}
/**
* Read worktree metadata for a branch
*/
export async function readWorktreeMetadata(
projectPath: string,
branch: string
): Promise<WorktreeMetadata | null> {
try {
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
// File doesn't exist or can't be read
return null;
}
}
/**
* Write worktree metadata for a branch
*/
export async function writeWorktreeMetadata(
projectPath: string,
branch: string,
metadata: WorktreeMetadata
): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
// Ensure directory exists
await secureFs.mkdir(metadataDir, { recursive: true });
// Write metadata
await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
}
/**
* Update PR info in worktree metadata
*/
export async function updateWorktreePRInfo(
projectPath: string,
branch: string,
prInfo: WorktreePRInfo
): Promise<void> {
// Read existing metadata or create new
let metadata = await readWorktreeMetadata(projectPath, branch);
if (!metadata) {
metadata = {
branch,
createdAt: new Date().toISOString(),
};
}
// Update PR info
metadata.pr = prInfo;
// Write back
await writeWorktreeMetadata(projectPath, branch, metadata);
}
/**
* Get PR info for a branch from metadata
*/
export async function getWorktreePRInfo(
projectPath: string,
branch: string
): Promise<WorktreePRInfo | null> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.pr || null;
}
/**
* Read all worktree metadata for a project
*/
export async function readAllWorktreeMetadata(
projectPath: string
): Promise<Map<string, WorktreeMetadata>> {
const result = new Map<string, WorktreeMetadata>();
const worktreesDir = path.join(projectPath, '.automaker', 'worktrees');
try {
const dirs = await secureFs.readdir(worktreesDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const metadataPath = path.join(worktreesDir, dir.name, 'worktree.json');
try {
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
const metadata = JSON.parse(content) as WorktreeMetadata;
result.set(metadata.branch, metadata);
} catch {
// Skip if file doesn't exist or can't be read
}
}
}
} catch {
// Directory doesn't exist
}
return result;
}
/**
* Delete worktree metadata for a branch
*/
export async function deleteWorktreeMetadata(projectPath: string, branch: string): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
try {
await secureFs.rm(metadataDir, { recursive: true, force: true });
} catch {
// Ignore errors if directory doesn't exist
}
}

View File

@@ -1,69 +0,0 @@
/**
* Middleware for validating path parameters against ALLOWED_ROOT_DIRECTORY
* Provides a clean, reusable way to validate paths without repeating the same
* try-catch block in every route handler
*/
import type { Request, Response, NextFunction } from 'express';
import { validatePath, PathNotAllowedError } from '@automaker/platform';
/**
* Creates a middleware that validates specified path parameters in req.body
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
* @example
* router.post('/create', validatePathParams('projectPath'), handler);
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
*
* Special syntax:
* - 'paramName?' - Optional parameter (only validated if present)
* - 'paramName[]' - Array parameter (validates each element)
*/
export function validatePathParams(...paramNames: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
for (const paramName of paramNames) {
// Handle optional parameters (paramName?)
if (paramName.endsWith('?')) {
const actualName = paramName.slice(0, -1);
const value = req.body[actualName];
if (value) {
validatePath(value);
}
continue;
}
// Handle array parameters (paramName[])
if (paramName.endsWith('[]')) {
const actualName = paramName.slice(0, -2);
const values = req.body[actualName];
if (Array.isArray(values) && values.length > 0) {
for (const value of values) {
validatePath(value);
}
}
continue;
}
// Handle regular parameters
const value = req.body[paramName];
if (value) {
validatePath(value);
}
}
next();
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({
success: false,
error: error.message,
});
return;
}
// Re-throw unexpected errors
throw error;
}
};
}

View File

@@ -9,7 +9,7 @@ import type {
InstallationStatus,
ValidationResult,
ModelDefinition,
} from './types.js';
} from "./types.js";
/**
* Base provider class that all provider implementations must extend
@@ -33,7 +33,9 @@ export abstract class BaseProvider {
* @param options Execution options
* @returns AsyncGenerator yielding provider messages
*/
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
abstract executeQuery(
options: ExecuteOptions
): AsyncGenerator<ProviderMessage>;
/**
* Detect if the provider is installed and configured
@@ -57,7 +59,7 @@ export abstract class BaseProvider {
// Base validation (can be overridden)
if (!this.config) {
errors.push('Provider config is missing');
errors.push("Provider config is missing");
}
return {
@@ -74,7 +76,7 @@ export abstract class BaseProvider {
*/
supportsFeature(feature: string): boolean {
// Default implementation - override in subclasses
const commonFeatures = ['tools', 'text'];
const commonFeatures = ["tools", "text"];
return commonFeatures.includes(feature);
}

View File

@@ -5,24 +5,26 @@
* with the provider architecture.
*/
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { BaseProvider } from "./base-provider.js";
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
} from "./types.js";
export class ClaudeProvider extends BaseProvider {
getName(): string {
return 'claude';
return "claude";
}
/**
* Execute a query using Claude Agent SDK
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
async *executeQuery(
options: ExecuteOptions
): AsyncGenerator<ProviderMessage> {
const {
prompt,
model,
@@ -36,7 +38,16 @@ export class ClaudeProvider extends BaseProvider {
} = options;
// Build Claude SDK options
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
const defaultTools = [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
];
const toolsToUse = allowedTools || defaultTools;
const sdkOptions: Options = {
@@ -45,16 +56,16 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
allowedTools: toolsToUse,
permissionMode: 'default',
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController,
// Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
? { resume: sdkSessionId }
: {}),
// Forward settingSources for CLAUDE.md file loading
...(options.settingSources && { settingSources: options.settingSources }),
// Forward sandbox configuration
...(options.sandbox && { sandbox: options.sandbox }),
};
// Build prompt payload
@@ -64,10 +75,10 @@ export class ClaudeProvider extends BaseProvider {
// Multi-part prompt (with images)
promptPayload = (async function* () {
const multiPartPrompt = {
type: 'user' as const,
session_id: '',
type: "user" as const,
session_id: "",
message: {
role: 'user' as const,
role: "user" as const,
content: prompt,
},
parent_tool_use_id: null,
@@ -88,8 +99,10 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage;
}
} catch (error) {
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
console.error(
"[ClaudeProvider] executeQuery() error during execution:",
error
);
throw error;
}
}
@@ -103,7 +116,7 @@ export class ClaudeProvider extends BaseProvider {
const status: InstallationStatus = {
installed: true,
method: 'sdk',
method: "sdk",
hasApiKey,
authenticated: hasApiKey,
};
@@ -117,53 +130,53 @@ export class ClaudeProvider extends BaseProvider {
getAvailableModels(): ModelDefinition[] {
const models = [
{
id: 'claude-opus-4-5-20251101',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
provider: 'anthropic',
description: 'Most capable Claude model',
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
modelString: "claude-opus-4-5-20251101",
provider: "anthropic",
description: "Most capable Claude model",
contextWindow: 200000,
maxOutputTokens: 16000,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
tier: "premium" as const,
default: true,
},
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',
modelString: 'claude-sonnet-4-20250514',
provider: 'anthropic',
description: 'Balanced performance and cost',
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
modelString: "claude-sonnet-4-20250514",
provider: "anthropic",
description: "Balanced performance and cost",
contextWindow: 200000,
maxOutputTokens: 16000,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
tier: "standard" as const,
},
{
id: 'claude-3-5-sonnet-20241022',
name: 'Claude 3.5 Sonnet',
modelString: 'claude-3-5-sonnet-20241022',
provider: 'anthropic',
description: 'Fast and capable',
id: "claude-3-5-sonnet-20241022",
name: "Claude 3.5 Sonnet",
modelString: "claude-3-5-sonnet-20241022",
provider: "anthropic",
description: "Fast and capable",
contextWindow: 200000,
maxOutputTokens: 8000,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
tier: "standard" as const,
},
{
id: 'claude-haiku-4-5-20251001',
name: 'Claude Haiku 4.5',
modelString: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
description: 'Fastest Claude model',
id: "claude-3-5-haiku-20241022",
name: "Claude 3.5 Haiku",
modelString: "claude-3-5-haiku-20241022",
provider: "anthropic",
description: "Fastest Claude model",
contextWindow: 200000,
maxOutputTokens: 8000,
supportsVision: true,
supportsTools: true,
tier: 'basic' as const,
tier: "basic" as const,
},
] satisfies ModelDefinition[];
return models;
@@ -173,7 +186,7 @@ export class ClaudeProvider extends BaseProvider {
* Check if the provider supports a specific feature
*/
supportsFeature(feature: string): boolean {
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
const supportedFeatures = ["tools", "text", "vision", "thinking"];
return supportedFeatures.includes(feature);
}
}

View File

@@ -6,9 +6,9 @@
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
*/
import { BaseProvider } from './base-provider.js';
import { ClaudeProvider } from './claude-provider.js';
import type { InstallationStatus } from './types.js';
import { BaseProvider } from "./base-provider.js";
import { ClaudeProvider } from "./claude-provider.js";
import type { InstallationStatus } from "./types.js";
export class ProviderFactory {
/**
@@ -21,7 +21,10 @@ export class ProviderFactory {
const lowerModel = modelId.toLowerCase();
// Claude models (claude-*, opus, sonnet, haiku)
if (lowerModel.startsWith('claude-') || ['haiku', 'sonnet', 'opus'].includes(lowerModel)) {
if (
lowerModel.startsWith("claude-") ||
["haiku", "sonnet", "opus"].includes(lowerModel)
) {
return new ClaudeProvider();
}
@@ -34,7 +37,9 @@ export class ProviderFactory {
// }
// Default to Claude for unknown models
console.warn(`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`);
console.warn(
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
);
return new ClaudeProvider();
}
@@ -53,7 +58,9 @@ export class ProviderFactory {
*
* @returns Map of provider name to installation status
*/
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
static async checkAllProviders(): Promise<
Record<string, InstallationStatus>
> {
const providers = this.getAllProviders();
const statuses: Record<string, InstallationStatus> = {};
@@ -76,8 +83,8 @@ export class ProviderFactory {
const lowerName = name.toLowerCase();
switch (lowerName) {
case 'claude':
case 'anthropic':
case "claude":
case "anthropic":
return new ClaudeProvider();
// Future providers:

View File

@@ -15,7 +15,7 @@ export interface ProviderConfig {
* Message in conversation history
*/
export interface ConversationMessage {
role: 'user' | 'assistant';
role: "user" | "assistant";
content: string | Array<{ type: string; text?: string; source?: object }>;
}
@@ -26,22 +26,20 @@ export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string;
cwd: string;
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
systemPrompt?: string;
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
}
/**
* Content block in a provider message (matches Claude SDK format)
*/
export interface ContentBlock {
type: 'text' | 'tool_use' | 'thinking' | 'tool_result';
type: "text" | "tool_use" | "thinking" | "tool_result";
text?: string;
thinking?: string;
name?: string;
@@ -50,20 +48,52 @@ export interface ContentBlock {
content?: string;
}
/**
* Token usage statistics from SDK execution
*/
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalTokens: number;
costUSD: number;
}
/**
* Per-model usage breakdown from SDK result
*/
export interface ModelUsageData {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
costUSD: number;
}
/**
* Message returned by a provider (matches Claude SDK streaming format)
*/
export interface ProviderMessage {
type: 'assistant' | 'user' | 'error' | 'result';
subtype?: 'success' | 'error';
type: "assistant" | "user" | "error" | "result";
subtype?: "success" | "error";
session_id?: string;
message?: {
role: 'user' | 'assistant';
role: "user" | "assistant";
content: ContentBlock[];
};
result?: string;
error?: string;
parent_tool_use_id?: string | null;
// Token usage fields (present in result messages)
usage?: {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
total_cost_usd?: number;
modelUsage?: Record<string, ModelUsageData>;
}
/**
@@ -73,7 +103,7 @@ export interface InstallationStatus {
installed: boolean;
path?: string;
version?: string;
method?: 'cli' | 'npm' | 'brew' | 'sdk';
method?: "cli" | "npm" | "brew" | "sdk";
hasApiKey?: boolean;
authenticated?: boolean;
error?: string;
@@ -101,6 +131,6 @@ export interface ModelDefinition {
maxOutputTokens?: number;
supportsVision?: boolean;
supportsTools?: boolean;
tier?: 'basic' | 'standard' | 'premium';
tier?: "basic" | "standard" | "premium";
default?: boolean;
}

View File

@@ -2,10 +2,13 @@
* Common utilities for agent routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { createLogger } from "../../lib/logger.js";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
const logger = createLogger('Agent');
const logger = createLogger("Agent");
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };

View File

@@ -2,44 +2,28 @@
* Agent routes - HTTP API for Claude agent interactions
*/
import { Router } from 'express';
import { AgentService } from '../../services/agent-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStartHandler } from './routes/start.js';
import { createSendHandler } from './routes/send.js';
import { createHistoryHandler } from './routes/history.js';
import { createStopHandler } from './routes/stop.js';
import { createClearHandler } from './routes/clear.js';
import { createModelHandler } from './routes/model.js';
import { createQueueAddHandler } from './routes/queue-add.js';
import { createQueueListHandler } from './routes/queue-list.js';
import { createQueueRemoveHandler } from './routes/queue-remove.js';
import { createQueueClearHandler } from './routes/queue-clear.js';
import { Router } from "express";
import { AgentService } from "../../services/agent-service.js";
import type { EventEmitter } from "../../lib/events.js";
import { createStartHandler } from "./routes/start.js";
import { createSendHandler } from "./routes/send.js";
import { createHistoryHandler } from "./routes/history.js";
import { createStopHandler } from "./routes/stop.js";
import { createClearHandler } from "./routes/clear.js";
import { createModelHandler } from "./routes/model.js";
export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router {
export function createAgentRoutes(
agentService: AgentService,
_events: EventEmitter
): Router {
const router = Router();
router.post('/start', validatePathParams('workingDirectory?'), createStartHandler(agentService));
router.post(
'/send',
validatePathParams('workingDirectory?', 'imagePaths[]'),
createSendHandler(agentService)
);
router.post('/history', createHistoryHandler(agentService));
router.post('/stop', createStopHandler(agentService));
router.post('/clear', createClearHandler(agentService));
router.post('/model', createModelHandler(agentService));
// Queue routes
router.post(
'/queue/add',
validatePathParams('imagePaths[]'),
createQueueAddHandler(agentService)
);
router.post('/queue/list', createQueueListHandler(agentService));
router.post('/queue/remove', createQueueRemoveHandler(agentService));
router.post('/queue/clear', createQueueClearHandler(agentService));
router.post("/start", createStartHandler(agentService));
router.post("/send", createSendHandler(agentService));
router.post("/history", createHistoryHandler(agentService));
router.post("/stop", createStopHandler(agentService));
router.post("/clear", createClearHandler(agentService));
router.post("/model", createModelHandler(agentService));
return router;
}

View File

@@ -2,9 +2,9 @@
* POST /clear endpoint - Clear conversation
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createClearHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -12,14 +12,16 @@ export function createClearHandler(agentService: AgentService) {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({ success: false, error: 'sessionId is required' });
res
.status(400)
.json({ success: false, error: "sessionId is required" });
return;
}
const result = await agentService.clearSession(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Clear session failed');
logError(error, "Clear session failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /history endpoint - Get conversation history
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createHistoryHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -12,14 +12,16 @@ export function createHistoryHandler(agentService: AgentService) {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({ success: false, error: 'sessionId is required' });
res
.status(400)
.json({ success: false, error: "sessionId is required" });
return;
}
const result = agentService.getHistory(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Get history failed');
logError(error, "Get history failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /model endpoint - Set session model
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createModelHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,14 +15,16 @@ export function createModelHandler(agentService: AgentService) {
};
if (!sessionId || !model) {
res.status(400).json({ success: false, error: 'sessionId and model are required' });
res
.status(400)
.json({ success: false, error: "sessionId and model are required" });
return;
}
const result = await agentService.setSessionModel(sessionId, model);
res.json({ success: result });
} catch (error) {
logError(error, 'Set session model failed');
logError(error, "Set session model failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -1,34 +0,0 @@
/**
* POST /queue/add endpoint - Add a prompt to the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueAddHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, message, imagePaths, model } = req.body as {
sessionId: string;
message: string;
imagePaths?: string[];
model?: string;
};
if (!sessionId || !message) {
res.status(400).json({
success: false,
error: 'sessionId and message are required',
});
return;
}
const result = await agentService.addToQueue(sessionId, { message, imagePaths, model });
res.json(result);
} catch (error) {
logError(error, 'Add to queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,29 +0,0 @@
/**
* POST /queue/clear endpoint - Clear all prompts from the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueClearHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required',
});
return;
}
const result = await agentService.clearQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Clear queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,29 +0,0 @@
/**
* POST /queue/list endpoint - List queued prompts
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueListHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required',
});
return;
}
const result = agentService.getQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'List queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,32 +0,0 @@
/**
* POST /queue/remove endpoint - Remove a prompt from the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueRemoveHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, promptId } = req.body as {
sessionId: string;
promptId: string;
};
if (!sessionId || !promptId) {
res.status(400).json({
success: false,
error: 'sessionId and promptId are required',
});
return;
}
const result = await agentService.removeFromQueue(sessionId, promptId);
res.json(result);
} catch (error) {
logError(error, 'Remove from queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -2,42 +2,33 @@
* POST /send endpoint - Send a message
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('Agent');
import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger("Agent");
export function createSendHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
sessionId: string;
message: string;
workingDirectory?: string;
imagePaths?: string[];
model?: string;
};
console.log('[Send Handler] Received request:', {
sessionId,
messageLength: message?.length,
workingDirectory,
imageCount: imagePaths?.length || 0,
model,
});
const { sessionId, message, workingDirectory, imagePaths, model } =
req.body as {
sessionId: string;
message: string;
workingDirectory?: string;
imagePaths?: string[];
model?: string;
};
if (!sessionId || !message) {
console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message');
res.status(400).json({
success: false,
error: 'sessionId and message are required',
error: "sessionId and message are required",
});
return;
}
console.log('[Send Handler] Validation passed, calling agentService.sendMessage()');
// Start the message processing (don't await - it streams via WebSocket)
agentService
.sendMessage({
@@ -48,17 +39,13 @@ export function createSendHandler(agentService: AgentService) {
model,
})
.catch((error) => {
console.error('[Send Handler] ERROR: Background error in sendMessage():', error);
logError(error, 'Send message failed (background)');
logError(error, "Send message failed (background)");
});
console.log('[Send Handler] Returning immediate response to client');
// Return immediately - responses come via WebSocket
res.json({ success: true, message: 'Message sent' });
res.json({ success: true, message: "Message sent" });
} catch (error) {
console.error('[Send Handler] ERROR: Synchronous error:', error);
logError(error, 'Send message failed');
logError(error, "Send message failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,12 @@
* POST /start endpoint - Start a conversation
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('Agent');
import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger("Agent");
export function createStartHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -17,7 +18,9 @@ export function createStartHandler(agentService: AgentService) {
};
if (!sessionId) {
res.status(400).json({ success: false, error: 'sessionId is required' });
res
.status(400)
.json({ success: false, error: "sessionId is required" });
return;
}
@@ -28,7 +31,7 @@ export function createStartHandler(agentService: AgentService) {
res.json(result);
} catch (error) {
logError(error, 'Start conversation failed');
logError(error, "Start conversation failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /stop endpoint - Stop execution
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import { AgentService } from "../../../services/agent-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStopHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -12,14 +12,16 @@ export function createStopHandler(agentService: AgentService) {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({ success: false, error: 'sessionId is required' });
res
.status(400)
.json({ success: false, error: "sessionId is required" });
return;
}
const result = await agentService.stopExecution(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Stop execution failed');
logError(error, "Stop execution failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* Common utilities and state management for spec regeneration
*/
import { createLogger } from '@automaker/utils';
import { createLogger } from "../../lib/logger.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
// Shared state for tracking generation status - private
let isRunning = false;
@@ -23,7 +23,10 @@ export function getSpecRegenerationStatus(): {
/**
* Set the running state and abort controller
*/
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
export function setRunningState(
running: boolean,
controller: AbortController | null = null
): void {
isRunning = running;
currentAbortController = controller;
}
@@ -37,12 +40,14 @@ export function logAuthStatus(context: string): void {
logger.info(`${context} - Auth Status:`);
logger.info(
` ANTHROPIC_API_KEY: ${
hasApiKey ? 'SET (' + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + '...)' : 'NOT SET'
hasApiKey
? "SET (" + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + "...)"
: "NOT SET"
}`
);
if (!hasApiKey) {
logger.warn('⚠️ WARNING: No authentication configured! SDK will fail.');
logger.warn("⚠️ WARNING: No authentication configured! SDK will fail.");
}
}
@@ -51,13 +56,16 @@ export function logAuthStatus(context: string): void {
*/
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`);
logger.error('Error name:', (error as any)?.name);
logger.error('Error message:', (error as Error)?.message);
logger.error('Error stack:', (error as Error)?.stack);
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
logger.error("Error name:", (error as any)?.name);
logger.error("Error message:", (error as Error)?.message);
logger.error("Error stack:", (error as Error)?.stack);
logger.error(
"Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
}
import { getErrorMessage as getErrorMessageShared } from '../common.js';
import { getErrorMessage as getErrorMessageShared } from "../common.js";
// Re-export shared utility
export { getErrorMessageShared as getErrorMessage };

View File

@@ -2,18 +2,16 @@
* Generate features from existing app_spec.txt
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import { query } from "@anthropic-ai/claude-agent-sdk";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js";
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
import { getAppSpecPath } from "../../lib/automaker-paths.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
const DEFAULT_MAX_FEATURES = 50;
@@ -21,30 +19,31 @@ export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController,
maxFeatures?: number,
settingsService?: SettingsService
maxFeatures?: number
): Promise<void> {
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
logger.debug('========== generateFeaturesFromSpec() started ==========');
logger.debug('projectPath:', projectPath);
logger.debug('maxFeatures:', featureCount);
logger.debug("========== generateFeaturesFromSpec() started ==========");
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", featureCount);
// Read existing spec from .automaker directory
const specPath = getAppSpecPath(projectPath);
let spec: string;
logger.debug('Reading spec from:', specPath);
logger.debug("Reading spec from:", specPath);
try {
spec = (await secureFs.readFile(specPath, 'utf-8')) as string;
spec = await fs.readFile(specPath, "utf-8");
logger.info(`Spec loaded successfully (${spec.length} chars)`);
logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`);
logger.info(`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`);
logger.info(
`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`
);
} catch (readError) {
logger.error('❌ Failed to read spec file:', readError);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: 'No project spec found. Generate spec first.',
logger.error("❌ Failed to read spec file:", readError);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: "No project spec found. Generate spec first.",
projectPath: projectPath,
});
return;
@@ -83,91 +82,91 @@ Generate ${featureCount} features that build on each other logically.
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
logger.info('========== PROMPT BEING SENT ==========');
logger.info("========== PROMPT BEING SENT ==========");
logger.info(`Prompt length: ${prompt.length} chars`);
logger.info(`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`);
logger.info('========== END PROMPT PREVIEW ==========');
logger.info(
`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`
);
logger.info("========== END PROMPT PREVIEW ==========");
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Analyzing spec and generating features...\n',
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: "Analyzing spec and generating features...\n",
projectPath: projectPath,
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[FeatureGeneration]'
);
const options = createFeatureGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query() for features...');
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
logger.info("Calling Claude Agent SDK query() for features...");
logAuthStatus('Right before SDK query() for features');
logAuthStatus("Right before SDK query() for features");
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
logger.debug("query() returned stream successfully");
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
logger.error("❌ query() threw an exception:");
logger.error("Error:", queryError);
throw queryError;
}
let responseText = '';
let responseText = "";
let messageCount = 0;
logger.debug('Starting to iterate over feature stream...');
logger.debug("Starting to iterate over feature stream...");
try {
for await (const msg of stream) {
messageCount++;
logger.debug(
`Feature stream message #${messageCount}:`,
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
);
if (msg.type === 'assistant' && msg.message.content) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
if (block.type === "text") {
responseText += block.text;
logger.debug(`Feature text block received (${block.text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
logger.debug(
`Feature text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.debug('Received success result for features');
} else if (msg.type === "result" && (msg as any).subtype === "success") {
logger.debug("Received success result for features");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from feature stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
} else if ((msg as { type: string }).type === "error") {
logger.error("❌ Received error message from feature stream:");
logger.error("Error message:", JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
logger.error('❌ Error while iterating feature stream:');
logger.error('Stream error:', streamError);
logger.error("❌ Error while iterating feature stream:");
logger.error("Stream error:", streamError);
throw streamError;
}
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info("========== FULL RESPONSE TEXT ==========");
logger.info(responseText);
logger.info('========== END RESPONSE TEXT ==========');
logger.info("========== END RESPONSE TEXT ==========");
await parseAndCreateFeatures(projectPath, responseText, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
logger.debug("========== generateFeaturesFromSpec() completed ==========");
}

View File

@@ -2,25 +2,23 @@
* Generate app_spec.txt from project overview
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import {
specOutputSchema,
specToXml,
getStructuredSpecPromptInstruction,
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
} from "../../lib/app-spec-format.js";
import { createLogger } from "../../lib/logger.js";
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
import { ensureAutomakerDir, getAppSpecPath } from "../../lib/automaker-paths.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
export async function generateSpec(
projectPath: string,
@@ -29,20 +27,19 @@ export async function generateSpec(
abortController: AbortController,
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number,
settingsService?: SettingsService
maxFeatures?: number
): Promise<void> {
logger.info('========== generateSpec() started ==========');
logger.info('projectPath:', projectPath);
logger.info('projectOverview length:', `${projectOverview.length} chars`);
logger.info('projectOverview preview:', projectOverview.substring(0, 300));
logger.info('generateFeatures:', generateFeatures);
logger.info('analyzeProject:', analyzeProject);
logger.info('maxFeatures:', maxFeatures);
logger.info("========== generateSpec() started ==========");
logger.info("projectPath:", projectPath);
logger.info("projectOverview length:", `${projectOverview.length} chars`);
logger.info("projectOverview preview:", projectOverview.substring(0, 300));
logger.info("generateFeatures:", generateFeatures);
logger.info("analyzeProject:", analyzeProject);
logger.info("maxFeatures:", maxFeatures);
// Build the prompt based on whether we should analyze the project
let analysisInstructions = '';
let techStackDefaults = '';
let analysisInstructions = "";
let techStackDefaults = "";
if (analyzeProject !== false) {
// Default to true - analyze the project
@@ -76,118 +73,114 @@ ${analysisInstructions}
${getStructuredSpecPromptInstruction()}`;
logger.info('========== PROMPT BEING SENT ==========');
logger.info("========== PROMPT BEING SENT ==========");
logger.info(`Prompt length: ${prompt.length} chars`);
logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`);
logger.info('========== END PROMPT PREVIEW ==========');
logger.info("========== END PROMPT PREVIEW ==========");
events.emit('spec-regeneration:event', {
type: 'spec_progress',
content: 'Starting spec generation...\n',
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: "Starting spec generation...\n",
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecRegeneration]'
);
const options = createSpecGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
type: "json_schema",
schema: specOutputSchema,
},
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query()...');
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
logger.info("Calling Claude Agent SDK query()...");
// Log auth status right before the SDK call
logAuthStatus('Right before SDK query()');
logAuthStatus("Right before SDK query()");
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
logger.debug("query() returned stream successfully");
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
logger.error("❌ query() threw an exception:");
logger.error("Error:", queryError);
throw queryError;
}
let responseText = '';
let responseText = "";
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
logger.info('Starting to iterate over stream...');
logger.info("Starting to iterate over stream...");
try {
for await (const msg of stream) {
messageCount++;
logger.info(
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
`Stream message #${messageCount}: type=${msg.type}, subtype=${
(msg as any).subtype
}`
);
if (msg.type === 'assistant') {
if (msg.type === "assistant") {
const msgAny = msg as any;
if (msgAny.message?.content) {
for (const block of msgAny.message.content) {
if (block.type === 'text') {
if (block.type === "text") {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
} else if (block.type === 'tool_use') {
logger.info('Tool use:', block.name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
} else if (block.type === "tool_use") {
logger.info("Tool use:", block.name);
events.emit("spec-regeneration:event", {
type: "spec_tool",
tool: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.info('Received success result');
} else if (msg.type === "result" && (msg as any).subtype === "success") {
logger.info("Received success result");
// Check for structured output - this is the reliable way to get spec data
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as SpecOutput;
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
logger.info("✅ Received structured output");
logger.debug("Structured output:", JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
logger.warn("⚠️ No structured output in result, will fall back to text parsing");
}
} else if (msg.type === 'result') {
} else if (msg.type === "result") {
// Handle error result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === 'error_max_turns') {
logger.error('❌ Hit max turns limit!');
} else if (subtype === 'error_max_structured_output_retries') {
logger.error('❌ Failed to produce valid structured output after retries');
throw new Error('Could not produce valid spec output');
if (subtype === "error_max_turns") {
logger.error("❌ Hit max turns limit!");
} else if (subtype === "error_max_structured_output_retries") {
logger.error("❌ Failed to produce valid structured output after retries");
throw new Error("Could not produce valid spec output");
}
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
} else if (msg.type === 'user') {
} else if ((msg as { type: string }).type === "error") {
logger.error("❌ Received error message from stream:");
logger.error("Error message:", JSON.stringify(msg, null, 2));
} else if (msg.type === "user") {
// Log user messages (tool results)
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
logger.info(
`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`
);
}
}
} catch (streamError) {
logger.error('❌ Error while iterating stream:');
logger.error('Stream error:', streamError);
logger.error("❌ Error while iterating stream:");
logger.error("Stream error:", streamError);
throw streamError;
}
@@ -199,42 +192,40 @@ ${getStructuredSpecPromptInstruction()}`;
if (structuredOutput) {
// Use structured output - convert JSON to XML
logger.info('✅ Using structured output for XML generation');
logger.info("✅ Using structured output for XML generation");
xmlContent = specToXml(structuredOutput);
logger.info(`Generated XML from structured output: ${xmlContent.length} chars`);
} else {
// Fallback: Extract XML content from response text
// Claude might include conversational text before/after
// See: https://github.com/AutoMaker-Org/automaker/issues/149
logger.warn('⚠️ No structured output, falling back to text parsing');
logger.info('========== FINAL RESPONSE TEXT ==========');
logger.info(responseText || '(empty)');
logger.info('========== END RESPONSE TEXT ==========');
logger.warn("⚠️ No structured output, falling back to text parsing");
logger.info("========== FINAL RESPONSE TEXT ==========");
logger.info(responseText || "(empty)");
logger.info("========== END RESPONSE TEXT ==========");
if (!responseText || responseText.trim().length === 0) {
throw new Error('No response text and no structured output - cannot generate spec');
throw new Error("No response text and no structured output - cannot generate spec");
}
const xmlStart = responseText.indexOf('<project_specification>');
const xmlEnd = responseText.lastIndexOf('</project_specification>');
const xmlStart = responseText.indexOf("<project_specification>");
const xmlEnd = responseText.lastIndexOf("</project_specification>");
if (xmlStart !== -1 && xmlEnd !== -1) {
// Extract just the XML content, discarding any conversational text before/after
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
xmlContent = responseText.substring(xmlStart, xmlEnd + "</project_specification>".length);
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
} else {
// No valid XML structure found in the response text
// This happens when structured output was expected but not received, and the agent
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
// We should NOT save this conversational text as it's not a valid spec
logger.error('❌ Response does not contain valid <project_specification> XML structure');
logger.error(
'This typically happens when structured output failed and the agent produced conversational text instead of XML'
);
logger.error("❌ Response does not contain valid <project_specification> XML structure");
logger.error("This typically happens when structured output failed and the agent produced conversational text instead of XML");
throw new Error(
'Failed to generate spec: No valid XML structure found in response. ' +
'The response contained conversational text but no <project_specification> tags. ' +
'Please try again.'
"Failed to generate spec: No valid XML structure found in response. " +
"The response contained conversational text but no <project_specification> tags. " +
"Please try again."
);
}
}
@@ -243,40 +234,40 @@ ${getStructuredSpecPromptInstruction()}`;
await ensureAutomakerDir(projectPath);
const specPath = getAppSpecPath(projectPath);
logger.info('Saving spec to:', specPath);
logger.info("Saving spec to:", specPath);
logger.info(`Content to save (${xmlContent.length} chars)`);
await secureFs.writeFile(specPath, xmlContent);
await fs.writeFile(specPath, xmlContent);
// Verify the file was written
const savedContent = await secureFs.readFile(specPath, 'utf-8');
const savedContent = await fs.readFile(specPath, "utf-8");
logger.info(`Verified saved file: ${savedContent.length} chars`);
if (savedContent.length === 0) {
logger.error('❌ File was saved but is empty!');
logger.error("❌ File was saved but is empty!");
}
logger.info('Spec saved successfully');
logger.info("Spec saved successfully");
// Emit spec completion event
if (generateFeatures) {
// If features will be generated, emit intermediate completion
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: '[Phase: spec_complete] Spec created! Generating features...\n',
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: "[Phase: spec_complete] Spec created! Generating features...\n",
projectPath: projectPath,
});
} else {
// If no features, emit final completion
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
message: 'Spec regeneration complete!',
events.emit("spec-regeneration:event", {
type: "spec_regeneration_complete",
message: "Spec regeneration complete!",
projectPath: projectPath,
});
}
// If generate features was requested, generate them from the spec
if (generateFeatures) {
logger.info('Starting feature generation from spec...');
logger.info("Starting feature generation from spec...");
// Create a new abort controller for feature generation
const featureAbortController = new AbortController();
try {
@@ -284,20 +275,19 @@ ${getStructuredSpecPromptInstruction()}`;
projectPath,
events,
featureAbortController,
maxFeatures,
settingsService
maxFeatures
);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (featureError) {
logger.error('Feature generation failed:', featureError);
logger.error("Feature generation failed:", featureError);
// Don't throw - spec generation succeeded, feature generation is optional
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: (featureError as Error).message || 'Feature generation failed',
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: (featureError as Error).message || "Feature generation failed",
projectPath: projectPath,
});
}
}
logger.debug('========== generateSpec() completed ==========');
logger.debug("========== generateSpec() completed ==========");
}

View File

@@ -2,26 +2,25 @@
* Spec Regeneration routes - HTTP API for AI-powered spec generation
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { createCreateHandler } from './routes/create.js';
import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from './routes/generate-features.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
import { Router } from "express";
import type { EventEmitter } from "../../lib/events.js";
import { createCreateHandler } from "./routes/create.js";
import { createGenerateHandler } from "./routes/generate.js";
import { createGenerateFeaturesHandler } from "./routes/generate-features.js";
import { createStopHandler } from "./routes/stop.js";
import { createStatusHandler } from "./routes/status.js";
export function createSpecRegenerationRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
router.post("/create", createCreateHandler(events));
router.post("/generate", createGenerateHandler(events));
router.post("/generate-features", createGenerateFeaturesHandler(events));
router.post("/stop", createStopHandler());
router.get("/status", createStatusHandler());
return router;
}

View File

@@ -2,71 +2,71 @@
* Parse agent response and create feature files
*/
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform';
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js";
import { getFeaturesDir } from "../../lib/automaker-paths.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
export async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
): Promise<void> {
logger.info('========== parseAndCreateFeatures() started ==========');
logger.info("========== parseAndCreateFeatures() started ==========");
logger.info(`Content length: ${content.length} chars`);
logger.info('========== CONTENT RECEIVED FOR PARSING ==========');
logger.info("========== CONTENT RECEIVED FOR PARSING ==========");
logger.info(content);
logger.info('========== END CONTENT ==========');
logger.info("========== END CONTENT ==========");
try {
// Extract JSON from response
logger.info('Extracting JSON from response...');
logger.info("Extracting JSON from response...");
logger.info(`Looking for pattern: /{[\\s\\S]*"features"[\\s\\S]*}/`);
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
if (!jsonMatch) {
logger.error('❌ No valid JSON found in response');
logger.error('Full content received:');
logger.error("❌ No valid JSON found in response");
logger.error("Full content received:");
logger.error(content);
throw new Error('No valid JSON found in response');
throw new Error("No valid JSON found in response");
}
logger.info(`JSON match found (${jsonMatch[0].length} chars)`);
logger.info('========== MATCHED JSON ==========');
logger.info("========== MATCHED JSON ==========");
logger.info(jsonMatch[0]);
logger.info('========== END MATCHED JSON ==========');
logger.info("========== END MATCHED JSON ==========");
const parsed = JSON.parse(jsonMatch[0]);
logger.info(`Parsed ${parsed.features?.length || 0} features`);
logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2));
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
const featuresDir = getFeaturesDir(projectPath);
await secureFs.mkdir(featuresDir, { recursive: true });
await fs.mkdir(featuresDir, { recursive: true });
const createdFeatures: Array<{ id: string; title: string }> = [];
for (const feature of parsed.features) {
logger.debug('Creating feature:', feature.id);
logger.debug("Creating feature:", feature.id);
const featureDir = path.join(featuresDir, feature.id);
await secureFs.mkdir(featureDir, { recursive: true });
await fs.mkdir(featureDir, { recursive: true });
const featureData = {
id: feature.id,
category: feature.category || 'Uncategorized',
category: feature.category || "Uncategorized",
title: feature.title,
description: feature.description,
status: 'backlog', // Features go to backlog - user must manually start them
status: "backlog", // Features go to backlog - user must manually start them
priority: feature.priority || 2,
complexity: feature.complexity || 'moderate',
complexity: feature.complexity || "moderate",
dependencies: feature.dependencies || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await secureFs.writeFile(
path.join(featureDir, 'feature.json'),
await fs.writeFile(
path.join(featureDir, "feature.json"),
JSON.stringify(featureData, null, 2)
);
@@ -75,20 +75,20 @@ export async function parseAndCreateFeatures(
logger.info(`✓ Created ${createdFeatures.length} features successfully`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
events.emit("spec-regeneration:event", {
type: "spec_regeneration_complete",
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath,
});
} catch (error) {
logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
logger.error("❌ parseAndCreateFeatures() failed:");
logger.error("Error:", error);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: (error as Error).message,
projectPath: projectPath,
});
}
logger.debug('========== parseAndCreateFeatures() completed ==========');
logger.debug("========== parseAndCreateFeatures() completed ==========");
}

View File

@@ -2,24 +2,24 @@
* POST /create endpoint - Create project spec from overview
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import type { Request, Response } from "express";
import type { EventEmitter } from "../../../lib/events.js";
import { createLogger } from "../../../lib/logger.js";
import {
getSpecRegenerationStatus,
setRunningState,
logAuthStatus,
logError,
getErrorMessage,
} from '../common.js';
import { generateSpec } from '../generate-spec.js';
} from "../common.js";
import { generateSpec } from "../generate-spec.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
export function createCreateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /create endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
logger.info("========== /create endpoint called ==========");
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
try {
const { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures } =
@@ -31,34 +31,37 @@ export function createCreateHandler(events: EventEmitter) {
maxFeatures?: number;
};
logger.debug('Parsed params:');
logger.debug(' projectPath:', projectPath);
logger.debug(' projectOverview length:', `${projectOverview?.length || 0} chars`);
logger.debug(' generateFeatures:', generateFeatures);
logger.debug(' analyzeProject:', analyzeProject);
logger.debug(' maxFeatures:', maxFeatures);
logger.debug("Parsed params:");
logger.debug(" projectPath:", projectPath);
logger.debug(
" projectOverview length:",
`${projectOverview?.length || 0} chars`
);
logger.debug(" generateFeatures:", generateFeatures);
logger.debug(" analyzeProject:", analyzeProject);
logger.debug(" maxFeatures:", maxFeatures);
if (!projectPath || !projectOverview) {
logger.error('Missing required parameters');
logger.error("Missing required parameters");
res.status(400).json({
success: false,
error: 'projectPath and projectOverview required',
error: "projectPath and projectOverview required",
});
return;
}
const { isRunning } = getSpecRegenerationStatus();
if (isRunning) {
logger.warn('Generation already running, rejecting request');
res.json({ success: false, error: 'Spec generation already running' });
logger.warn("Generation already running, rejecting request");
res.json({ success: false, error: "Spec generation already running" });
return;
}
logAuthStatus('Before starting generation');
logAuthStatus("Before starting generation");
const abortController = new AbortController();
setRunningState(true, abortController);
logger.info('Starting background generation task...');
logger.info("Starting background generation task...");
// Start generation in background
generateSpec(
@@ -71,22 +74,24 @@ export function createCreateHandler(events: EventEmitter) {
maxFeatures
)
.catch((error) => {
logError(error, 'Generation failed with error');
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
logError(error, "Generation failed with error");
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: getErrorMessage(error),
projectPath: projectPath,
});
})
.finally(() => {
logger.info('Generation task finished (success or error)');
logger.info("Generation task finished (success or error)");
setRunningState(false, null);
});
logger.info('Returning success response (generation running in background)');
logger.info(
"Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
logError(error, 'Create spec route handler failed');
logError(error, "Create spec route handler failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,28 +2,24 @@
* POST /generate-features endpoint - Generate features from existing spec
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import type { Request, Response } from "express";
import type { EventEmitter } from "../../../lib/events.js";
import { createLogger } from "../../../lib/logger.js";
import {
getSpecRegenerationStatus,
setRunningState,
logAuthStatus,
logError,
getErrorMessage,
} from '../common.js';
import { generateFeaturesFromSpec } from '../generate-features-from-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
} from "../common.js";
import { generateFeaturesFromSpec } from "../generate-features-from-spec.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
export function createGenerateFeaturesHandler(
events: EventEmitter,
settingsService?: SettingsService
) {
export function createGenerateFeaturesHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate-features endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
logger.info("========== /generate-features endpoint called ==========");
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
try {
const { projectPath, maxFeatures } = req.body as {
@@ -31,45 +27,52 @@ export function createGenerateFeaturesHandler(
maxFeatures?: number;
};
logger.debug('projectPath:', projectPath);
logger.debug('maxFeatures:', maxFeatures);
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", maxFeatures);
if (!projectPath) {
logger.error('Missing projectPath parameter');
res.status(400).json({ success: false, error: 'projectPath required' });
logger.error("Missing projectPath parameter");
res.status(400).json({ success: false, error: "projectPath required" });
return;
}
const { isRunning } = getSpecRegenerationStatus();
if (isRunning) {
logger.warn('Generation already running, rejecting request');
res.json({ success: false, error: 'Generation already running' });
logger.warn("Generation already running, rejecting request");
res.json({ success: false, error: "Generation already running" });
return;
}
logAuthStatus('Before starting feature generation');
logAuthStatus("Before starting feature generation");
const abortController = new AbortController();
setRunningState(true, abortController);
logger.info('Starting background feature generation task...');
logger.info("Starting background feature generation task...");
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
generateFeaturesFromSpec(
projectPath,
events,
abortController,
maxFeatures
)
.catch((error) => {
logError(error, 'Feature generation failed with error');
events.emit('spec-regeneration:event', {
type: 'features_error',
logError(error, "Feature generation failed with error");
events.emit("spec-regeneration:event", {
type: "features_error",
error: getErrorMessage(error),
});
})
.finally(() => {
logger.info('Feature generation task finished (success or error)');
logger.info("Feature generation task finished (success or error)");
setRunningState(false, null);
});
logger.info('Returning success response (generation running in background)');
logger.info(
"Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
logError(error, 'Generate features route handler failed');
logError(error, "Generate features route handler failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,64 +2,71 @@
* POST /generate endpoint - Generate spec from project definition
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import type { Request, Response } from "express";
import type { EventEmitter } from "../../../lib/events.js";
import { createLogger } from "../../../lib/logger.js";
import {
getSpecRegenerationStatus,
setRunningState,
logAuthStatus,
logError,
getErrorMessage,
} from '../common.js';
import { generateSpec } from '../generate-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
} from "../common.js";
import { generateSpec } from "../generate-spec.js";
const logger = createLogger('SpecRegeneration');
const logger = createLogger("SpecRegeneration");
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
export function createGenerateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
logger.info("========== /generate endpoint called ==========");
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
try {
const { projectPath, projectDefinition, generateFeatures, analyzeProject, maxFeatures } =
req.body as {
projectPath: string;
projectDefinition: string;
generateFeatures?: boolean;
analyzeProject?: boolean;
maxFeatures?: number;
};
const {
projectPath,
projectDefinition,
generateFeatures,
analyzeProject,
maxFeatures,
} = req.body as {
projectPath: string;
projectDefinition: string;
generateFeatures?: boolean;
analyzeProject?: boolean;
maxFeatures?: number;
};
logger.debug('Parsed params:');
logger.debug(' projectPath:', projectPath);
logger.debug(' projectDefinition length:', `${projectDefinition?.length || 0} chars`);
logger.debug(' generateFeatures:', generateFeatures);
logger.debug(' analyzeProject:', analyzeProject);
logger.debug(' maxFeatures:', maxFeatures);
logger.debug("Parsed params:");
logger.debug(" projectPath:", projectPath);
logger.debug(
" projectDefinition length:",
`${projectDefinition?.length || 0} chars`
);
logger.debug(" generateFeatures:", generateFeatures);
logger.debug(" analyzeProject:", analyzeProject);
logger.debug(" maxFeatures:", maxFeatures);
if (!projectPath || !projectDefinition) {
logger.error('Missing required parameters');
logger.error("Missing required parameters");
res.status(400).json({
success: false,
error: 'projectPath and projectDefinition required',
error: "projectPath and projectDefinition required",
});
return;
}
const { isRunning } = getSpecRegenerationStatus();
if (isRunning) {
logger.warn('Generation already running, rejecting request');
res.json({ success: false, error: 'Spec generation already running' });
logger.warn("Generation already running, rejecting request");
res.json({ success: false, error: "Spec generation already running" });
return;
}
logAuthStatus('Before starting generation');
logAuthStatus("Before starting generation");
const abortController = new AbortController();
setRunningState(true, abortController);
logger.info('Starting background generation task...');
logger.info("Starting background generation task...");
generateSpec(
projectPath,
@@ -68,26 +75,27 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
abortController,
generateFeatures,
analyzeProject,
maxFeatures,
settingsService
maxFeatures
)
.catch((error) => {
logError(error, 'Generation failed with error');
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
logError(error, "Generation failed with error");
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: getErrorMessage(error),
projectPath: projectPath,
});
})
.finally(() => {
logger.info('Generation task finished (success or error)');
logger.info("Generation task finished (success or error)");
setRunningState(false, null);
});
logger.info('Returning success response (generation running in background)');
logger.info(
"Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
logError(error, 'Generate spec route handler failed');
logError(error, "Generate spec route handler failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,8 +2,8 @@
* GET /status endpoint - Get generation status
*/
import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
import type { Request, Response } from "express";
import { getSpecRegenerationStatus, getErrorMessage } from "../common.js";
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {

View File

@@ -2,8 +2,12 @@
* POST /stop endpoint - Stop generation
*/
import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
import type { Request, Response } from "express";
import {
getSpecRegenerationStatus,
setRunningState,
getErrorMessage,
} from "../common.js";
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {

View File

@@ -2,10 +2,13 @@
* Common utilities for auto-mode routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { createLogger } from "../../lib/logger.js";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
const logger = createLogger('AutoMode');
const logger = createLogger("AutoMode");
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };

View File

@@ -4,65 +4,35 @@
* Uses the AutoModeService for real feature execution with Claude Agent SDK
*/
import { Router } from 'express';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
import { createRunFeatureHandler } from './routes/run-feature.js';
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
import { createResumeFeatureHandler } from './routes/resume-feature.js';
import { createContextExistsHandler } from './routes/context-exists.js';
import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
import { createCommitFeatureHandler } from './routes/commit-feature.js';
import { createApprovePlanHandler } from './routes/approve-plan.js';
import { Router } from "express";
import type { AutoModeService } from "../../services/auto-mode-service.js";
import { createStopFeatureHandler } from "./routes/stop-feature.js";
import { createStatusHandler } from "./routes/status.js";
import { createRunFeatureHandler } from "./routes/run-feature.js";
import { createVerifyFeatureHandler } from "./routes/verify-feature.js";
import { createResumeFeatureHandler } from "./routes/resume-feature.js";
import { createContextExistsHandler } from "./routes/context-exists.js";
import { createAnalyzeProjectHandler } from "./routes/analyze-project.js";
import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js";
import { createCommitFeatureHandler } from "./routes/commit-feature.js";
import { createApprovePlanHandler } from "./routes/approve-plan.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService));
router.post("/verify-feature", createVerifyFeatureHandler(autoModeService));
router.post("/resume-feature", createResumeFeatureHandler(autoModeService));
router.post("/context-exists", createContextExistsHandler(autoModeService));
router.post("/analyze-project", createAnalyzeProjectHandler(autoModeService));
router.post(
'/run-feature',
validatePathParams('projectPath'),
createRunFeatureHandler(autoModeService)
);
router.post(
'/verify-feature',
validatePathParams('projectPath'),
createVerifyFeatureHandler(autoModeService)
);
router.post(
'/resume-feature',
validatePathParams('projectPath'),
createResumeFeatureHandler(autoModeService)
);
router.post(
'/context-exists',
validatePathParams('projectPath'),
createContextExistsHandler(autoModeService)
);
router.post(
'/analyze-project',
validatePathParams('projectPath'),
createAnalyzeProjectHandler(autoModeService)
);
router.post(
'/follow-up-feature',
validatePathParams('projectPath', 'imagePaths[]'),
"/follow-up-feature",
createFollowUpFeatureHandler(autoModeService)
);
router.post(
'/commit-feature',
validatePathParams('projectPath', 'worktreePath?'),
createCommitFeatureHandler(autoModeService)
);
router.post(
'/approve-plan',
validatePathParams('projectPath'),
createApprovePlanHandler(autoModeService)
);
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
return router;
}

View File

@@ -2,12 +2,12 @@
* POST /analyze-project endpoint - Analyze project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger('AutoMode');
const logger = createLogger("AutoMode");
export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,7 +15,9 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
res
.status(400)
.json({ success: false, error: "projectPath is required" });
return;
}
@@ -24,9 +26,9 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
logger.error(`[AutoMode] Project analysis error:`, error);
});
res.json({ success: true, message: 'Project analysis started' });
res.json({ success: true, message: "Project analysis started" });
} catch (error) {
logError(error, 'Analyze project failed');
logError(error, "Analyze project failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /approve-plan endpoint - Approve or reject a generated plan/spec
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger('AutoMode');
const logger = createLogger("AutoMode");
export function createApprovePlanHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -23,15 +23,15 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
if (!featureId) {
res.status(400).json({
success: false,
error: 'featureId is required',
error: "featureId is required",
});
return;
}
if (typeof approved !== 'boolean') {
if (typeof approved !== "boolean") {
res.status(400).json({
success: false,
error: 'approved must be a boolean',
error: "approved must be a boolean",
});
return;
}
@@ -41,9 +41,9 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
// This supports cases where the server restarted while waiting for approval
logger.info(
`[AutoMode] Plan ${approved ? 'approved' : 'rejected'} for feature ${featureId}${
editedPlan ? ' (with edits)' : ''
}${feedback ? ` - Feedback: ${feedback}` : ''}`
`[AutoMode] Plan ${approved ? "approved" : "rejected"} for feature ${featureId}${
editedPlan ? " (with edits)" : ""
}${feedback ? ` - Feedback: ${feedback}` : ""}`
);
// Resolve the pending approval (with recovery support)
@@ -67,11 +67,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
success: true,
approved,
message: approved
? 'Plan approved - implementation will continue'
: 'Plan rejected - feature execution stopped',
? "Plan approved - implementation will continue"
: "Plan rejected - feature execution stopped",
});
} catch (error) {
logError(error, 'Approve plan failed');
logError(error, "Approve plan failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /commit-feature endpoint - Commit feature changes
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -16,17 +16,23 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) {
};
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
});
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath);
const commitHash = await autoModeService.commitFeature(
projectPath,
featureId,
worktreePath
);
res.json({ success: true, commitHash });
} catch (error) {
logError(error, 'Commit feature failed');
logError(error, "Commit feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /context-exists endpoint - Check if context exists for a feature
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createContextExistsHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,17 +15,22 @@ export function createContextExistsHandler(autoModeService: AutoModeService) {
};
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
});
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
const exists = await autoModeService.contextExists(projectPath, featureId);
const exists = await autoModeService.contextExists(
projectPath,
featureId
);
res.json({ success: true, exists });
} catch (error) {
logError(error, 'Check context exists failed');
logError(error, "Check context exists failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,28 +2,29 @@
* POST /follow-up-feature endpoint - Follow up on a feature
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger('AutoMode');
const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
useWorktrees?: boolean;
};
const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
useWorktrees?: boolean;
};
if (!projectPath || !featureId || !prompt) {
res.status(400).json({
success: false,
error: 'projectPath, featureId, and prompt are required',
error: "projectPath, featureId, and prompt are required",
});
return;
}
@@ -31,9 +32,18 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
.followUpFeature(
projectPath,
featureId,
prompt,
imagePaths,
useWorktrees ?? true
)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
logger.error(
`[AutoMode] Follow up feature ${featureId} error:`,
error
);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
@@ -42,7 +52,7 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
res.json({ success: true });
} catch (error) {
logError(error, 'Follow up feature failed');
logError(error, "Follow up feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /resume-feature endpoint - Resume a feature
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger('AutoMode');
const logger = createLogger("AutoMode");
export function createResumeFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -21,7 +21,7 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
error: "projectPath and featureId are required",
});
return;
}
@@ -36,7 +36,7 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
res.json({ success: true });
} catch (error) {
logError(error, 'Resume feature failed');
logError(error, "Resume feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /run-feature endpoint - Run a single feature
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger('AutoMode');
const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -21,7 +21,7 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
error: "projectPath and featureId are required",
});
return;
}
@@ -40,7 +40,7 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
res.json({ success: true });
} catch (error) {
logError(error, 'Run feature failed');
logError(error, "Run feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /status endpoint - Get auto mode status
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,7 +15,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
...status,
});
} catch (error) {
logError(error, 'Get status failed');
logError(error, "Get status failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /stop-feature endpoint - Stop a specific feature
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStopFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -12,14 +12,16 @@ export function createStopFeatureHandler(autoModeService: AutoModeService) {
const { featureId } = req.body as { featureId: string };
if (!featureId) {
res.status(400).json({ success: false, error: 'featureId is required' });
res
.status(400)
.json({ success: false, error: "featureId is required" });
return;
}
const stopped = await autoModeService.stopFeature(featureId);
res.json({ success: true, stopped });
} catch (error) {
logError(error, 'Stop feature failed');
logError(error, "Stop feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /verify-feature endpoint - Verify a feature
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,17 +15,22 @@ export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
};
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
});
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
const passes = await autoModeService.verifyFeature(projectPath, featureId);
const passes = await autoModeService.verifyFeature(
projectPath,
featureId
);
res.json({ success: true, passes });
} catch (error) {
logError(error, 'Verify feature failed');
logError(error, "Verify feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -1,39 +0,0 @@
/**
* Common utilities for backlog plan routes
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('BacklogPlan');
// State for tracking running generation
let isRunning = false;
let currentAbortController: AbortController | null = null;
export function getBacklogPlanStatus(): { isRunning: boolean } {
return { isRunning };
}
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
isRunning = running;
if (abortController !== undefined) {
currentAbortController = abortController;
}
}
export function getAbortController(): AbortController | null {
return currentAbortController;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error));
}
export { logger };

View File

@@ -1,217 +0,0 @@
/**
* Generate backlog plan using Claude AI
*/
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logger, setRunningState, getErrorMessage } from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
/**
* Format features for the AI prompt
*/
function formatFeaturesForPrompt(features: Feature[]): string {
if (features.length === 0) {
return 'No features in backlog yet.';
}
return features
.map((f) => {
const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : '';
const priority = f.priority !== undefined ? `Priority: ${f.priority}` : '';
return `- ID: ${f.id}
Title: ${f.title || 'Untitled'}
Description: ${f.description}
Category: ${f.category}
Status: ${f.status || 'backlog'}
${priority}
${deps}`.trim();
})
.join('\n\n');
}
/**
* Parse the AI response into a BacklogPlanResult
*/
function parsePlanResponse(response: string): BacklogPlanResult {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/```json\n?([\s\S]*?)\n?```/);
if (jsonMatch) {
return JSON.parse(jsonMatch[1]);
}
// Try to parse the whole response as JSON
return JSON.parse(response);
} catch {
// If parsing fails, return an empty result
logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
return {
changes: [],
summary: 'Failed to parse AI response',
dependencyUpdates: [],
};
}
}
/**
* Generate a backlog modification plan based on user prompt
*/
export async function generateBacklogPlan(
projectPath: string,
prompt: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
model?: string
): Promise<BacklogPlanResult> {
try {
// Load current features
const features = await featureLoader.getAll(projectPath);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: `Loaded ${features.length} features from backlog`,
});
// Build the system prompt
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
You will be given the current list of features and a user request to modify the backlog.
IMPORTANT CONTEXT (automatically injected):
- Remember to update the dependency graph if deleting existing features
- Remember to define dependencies on new features hooked into relevant existing ones
- Maintain dependency graph integrity (no orphaned dependencies)
- When deleting a feature, identify which other features depend on it
Your task is to analyze the request and produce a structured JSON plan with:
1. Features to ADD (include title, description, category, and dependencies)
2. Features to UPDATE (specify featureId and the updates)
3. Features to DELETE (specify featureId)
4. A summary of the changes
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
Respond with ONLY a JSON object in this exact format:
\`\`\`json
{
"changes": [
{
"type": "add",
"feature": {
"title": "Feature title",
"description": "Feature description",
"category": "Category name",
"dependencies": ["existing-feature-id"],
"priority": 1
},
"reason": "Why this feature should be added"
},
{
"type": "update",
"featureId": "existing-feature-id",
"feature": {
"title": "Updated title"
},
"reason": "Why this feature should be updated"
},
{
"type": "delete",
"featureId": "feature-id-to-delete",
"reason": "Why this feature should be deleted"
}
],
"summary": "Brief overview of all proposed changes",
"dependencyUpdates": [
{
"featureId": "feature-that-depended-on-deleted",
"removedDependencies": ["deleted-feature-id"],
"addedDependencies": []
}
]
}
\`\`\``;
// Build the user prompt
const userPrompt = `Current Features in Backlog:
${formatFeaturesForPrompt(features)}
---
User Request: ${prompt}
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: 'Generating plan with AI...',
});
// Get the model to use
const effectiveModel = model || 'sonnet';
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Get autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// Execute the query
const stream = provider.executeQuery({
prompt: userPrompt,
model: effectiveModel,
cwd: projectPath,
systemPrompt,
maxTurns: 1,
allowedTools: [], // No tools needed for this
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
});
let responseText = '';
for await (const msg of stream) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
}
}
}
}
}
// Parse the response
const result = parsePlanResponse(responseText);
events.emit('backlog-plan:event', {
type: 'backlog_plan_complete',
result,
});
return result;
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error('[BacklogPlan] Generation failed:', errorMessage);
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: errorMessage,
});
throw error;
} finally {
setRunningState(false, null);
}
}

View File

@@ -1,30 +0,0 @@
/**
* Backlog Plan routes - HTTP API for AI-assisted backlog modification
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import { createApplyHandler } from './routes/apply.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createBacklogPlanRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post(
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
return router;
}

View File

@@ -1,147 +0,0 @@
/**
* POST /apply endpoint - Apply a backlog plan
*/
import type { Request, Response } from 'express';
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, plan } = req.body as {
projectPath: string;
plan: BacklogPlanResult;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
if (!plan || !plan.changes) {
res.status(400).json({ success: false, error: 'plan with changes required' });
return;
}
const appliedChanges: string[] = [];
// Load current features for dependency validation
const allFeatures = await featureLoader.getAll(projectPath);
const featureMap = new Map(allFeatures.map((f) => [f.id, f]));
// Process changes in order: deletes first, then adds, then updates
// This ensures we can remove dependencies before they cause issues
// 1. First pass: Handle deletes
const deletions = plan.changes.filter((c) => c.type === 'delete');
for (const change of deletions) {
if (!change.featureId) continue;
try {
// Before deleting, update any features that depend on this one
for (const feature of allFeatures) {
if (feature.dependencies?.includes(change.featureId)) {
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
logger.info(
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
);
}
}
// Now delete the feature
const deleted = await featureLoader.delete(projectPath, change.featureId);
if (deleted) {
appliedChanges.push(`deleted:${change.featureId}`);
featureMap.delete(change.featureId);
logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`);
}
} catch (error) {
logger.error(
`[BacklogPlan] Failed to delete ${change.featureId}:`,
getErrorMessage(error)
);
}
}
// 2. Second pass: Handle adds
const additions = plan.changes.filter((c) => c.type === 'add');
for (const change of additions) {
if (!change.feature) continue;
try {
// Create the new feature
const newFeature = await featureLoader.create(projectPath, {
title: change.feature.title,
description: change.feature.description || '',
category: change.feature.category || 'Uncategorized',
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
});
appliedChanges.push(`added:${newFeature.id}`);
featureMap.set(newFeature.id, newFeature);
logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`);
} catch (error) {
logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error));
}
}
// 3. Third pass: Handle updates
const updates = plan.changes.filter((c) => c.type === 'update');
for (const change of updates) {
if (!change.featureId || !change.feature) continue;
try {
const updated = await featureLoader.update(projectPath, change.featureId, change.feature);
appliedChanges.push(`updated:${change.featureId}`);
featureMap.set(change.featureId, updated);
logger.info(`[BacklogPlan] Updated feature ${change.featureId}`);
} catch (error) {
logger.error(
`[BacklogPlan] Failed to update ${change.featureId}:`,
getErrorMessage(error)
);
}
}
// 4. Apply dependency updates from the plan
if (plan.dependencyUpdates) {
for (const depUpdate of plan.dependencyUpdates) {
try {
const feature = featureMap.get(depUpdate.featureId);
if (feature) {
const currentDeps = feature.dependencies || [];
const newDeps = currentDeps
.filter((d) => !depUpdate.removedDependencies.includes(d))
.concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d)));
await featureLoader.update(projectPath, depUpdate.featureId, {
dependencies: newDeps,
});
logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`);
}
} catch (error) {
logger.error(
`[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`,
getErrorMessage(error)
);
}
}
}
res.json({
success: true,
appliedChanges,
});
} catch (error) {
logError(error, 'Apply backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,62 +0,0 @@
/**
* POST /generate endpoint - Generate a backlog plan
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateBacklogPlan } from '../generate-plan.js';
import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prompt, model } = req.body as {
projectPath: string;
prompt: string;
model?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
if (!prompt) {
res.status(400).json({ success: false, error: 'prompt required' });
return;
}
const { isRunning } = getBacklogPlanStatus();
if (isRunning) {
res.json({
success: false,
error: 'Backlog plan generation is already running',
});
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
logError(error, 'Generate backlog plan failed (background)');
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
});
res.json({ success: true });
} catch (error) {
logError(error, 'Generate backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,18 +0,0 @@
/**
* GET /status endpoint - Get backlog plan generation status
*/
import type { Request, Response } from 'express';
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const status = getBacklogPlanStatus();
res.json({ success: true, ...status });
} catch (error) {
logError(error, 'Get backlog plan status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,22 +0,0 @@
/**
* POST /stop endpoint - Stop the current backlog plan generation
*/
import type { Request, Response } from 'express';
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const abortController = getAbortController();
if (abortController) {
abortController.abort();
setRunningState(false, null);
}
res.json({ success: true });
} catch (error) {
logError(error, 'Stop backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,43 +0,0 @@
import { Router, Request, Response } from 'express';
import { ClaudeUsageService } from '../../services/claude-usage-service.js';
export function createClaudeRoutes(service: ClaudeUsageService): Router {
const router = Router();
// Get current usage (fetches from Claude CLI)
router.get('/usage', async (req: Request, res: Response) => {
try {
// Check if Claude CLI is available first
const isAvailable = await service.isAvailable();
if (!isAvailable) {
res.status(503).json({
error: 'Claude CLI not found',
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
});
return;
}
const usage = await service.fetchUsageData();
res.json(usage);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('Authentication required') || message.includes('token_expired')) {
res.status(401).json({
error: 'Authentication required',
message: "Please run 'claude login' to authenticate",
});
} else if (message.includes('timed out')) {
res.status(504).json({
error: 'Command timed out',
message: 'The Claude CLI took too long to respond',
});
} else {
console.error('Error fetching usage:', error);
res.status(500).json({ error: message });
}
}
});
return router;
}

View File

@@ -1,35 +0,0 @@
/**
* Claude Usage types for CLI-based usage tracking
*/
export type ClaudeUsage = {
sessionTokensUsed: number;
sessionLimit: number;
sessionPercentage: number;
sessionResetTime: string; // ISO date string
sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
weeklyTokensUsed: number;
weeklyLimit: number;
weeklyPercentage: number;
weeklyResetTime: string; // ISO date string
weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
sonnetWeeklyTokensUsed: number;
sonnetWeeklyPercentage: number;
sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
costUsed: number | null;
costLimit: number | null;
costCurrency: string | null;
lastUpdated: string; // ISO date string
userTimezone: string;
};
export type ClaudeStatus = {
indicator: {
color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
};
description: string;
};

View File

@@ -2,29 +2,373 @@
* Common utilities shared across all route modules
*/
import { createLogger } from '@automaker/utils';
// Re-export git utilities from shared package
export {
BINARY_EXTENSIONS,
GIT_STATUS_MAP,
type FileStatus,
isGitRepo,
parseGitStatus,
generateSyntheticDiffForNewFile,
appendUntrackedFileDiffs,
listAllFilesInDirectory,
generateDiffsForNonGitDirectory,
getGitRepositoryDiffs,
} from '@automaker/git-utils';
import { createLogger } from "../lib/logger.js";
import fs from "fs/promises";
import path from "path";
import { exec } from "child_process";
import { promisify } from "util";
type Logger = ReturnType<typeof createLogger>;
const execAsync = promisify(exec);
const logger = createLogger("Common");
// Max file size for generating synthetic diffs (1MB)
const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024;
// Binary file extensions to skip
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".tar", ".gz", ".rar", ".7z",
".exe", ".dll", ".so", ".dylib",
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
".ttf", ".otf", ".woff", ".woff2", ".eot",
".db", ".sqlite", ".sqlite3",
".pyc", ".pyo", ".class", ".o", ".obj",
]);
// Status map for git status codes
// Git porcelain format uses XY where X=staging area, Y=working tree
const GIT_STATUS_MAP: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
"!": "Ignored",
" ": "Unmodified",
};
/**
* Get a readable status text from git status codes
* Handles both single character and XY format status codes
*/
function getStatusText(indexStatus: string, workTreeStatus: string): string {
// Untracked files
if (indexStatus === "?" && workTreeStatus === "?") {
return "Untracked";
}
// Ignored files
if (indexStatus === "!" && workTreeStatus === "!") {
return "Ignored";
}
// Prioritize staging area status, then working tree
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
// Handle combined statuses
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
// Both staging and working tree have changes
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
if (indexText === workText) {
return indexText;
}
return `${indexText} (staged), ${workText} (unstaged)`;
}
return GIT_STATUS_MAP[primaryStatus] || "Changed";
}
/**
* File status interface for git status results
*/
export interface FileStatus {
status: string;
path: string;
statusText: string;
}
/**
* Check if a file is likely binary based on extension
*/
function isBinaryFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return BINARY_EXTENSIONS.has(ext);
}
/**
* Check if a path is a git repository
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Parse the output of `git status --porcelain` into FileStatus array
* Git porcelain format: XY PATH where X=staging area status, Y=working tree status
* For renamed files: XY ORIG_PATH -> NEW_PATH
*/
export function parseGitStatus(statusOutput: string): FileStatus[] {
return statusOutput
.split("\n")
.filter(Boolean)
.map((line) => {
// Git porcelain format uses two status characters: XY
// X = status in staging area (index)
// Y = status in working tree
const indexStatus = line[0] || " ";
const workTreeStatus = line[1] || " ";
// File path starts at position 3 (after "XY ")
let filePath = line.slice(3);
// Handle renamed files (format: "R old_path -> new_path")
if (indexStatus === "R" || workTreeStatus === "R") {
const arrowIndex = filePath.indexOf(" -> ");
if (arrowIndex !== -1) {
filePath = filePath.slice(arrowIndex + 4); // Use new path
}
}
// Determine the primary status character for backwards compatibility
// Prioritize staging area status, then working tree
let primaryStatus: string;
if (indexStatus === "?" && workTreeStatus === "?") {
primaryStatus = "?"; // Untracked
} else if (indexStatus !== " " && indexStatus !== "?") {
primaryStatus = indexStatus; // Staged change
} else {
primaryStatus = workTreeStatus; // Working tree change
}
return {
status: primaryStatus,
path: filePath,
statusText: getStatusText(indexStatus, workTreeStatus),
};
});
}
/**
* Generate a synthetic unified diff for an untracked (new) file
* This is needed because `git diff HEAD` doesn't include untracked files
*/
export async function generateSyntheticDiffForNewFile(
basePath: string,
relativePath: string
): Promise<string> {
const fullPath = path.join(basePath, relativePath);
try {
// Check if it's a binary file
if (isBinaryFile(relativePath)) {
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
Binary file ${relativePath} added
`;
}
// Get file stats to check size
const stats = await fs.stat(fullPath);
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
const sizeKB = Math.round(stats.size / 1024);
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1 @@
+[File too large to display: ${sizeKB}KB]
`;
}
// Read file content
const content = await fs.readFile(fullPath, "utf-8");
const hasTrailingNewline = content.endsWith("\n");
const lines = content.split("\n");
// Remove trailing empty line if the file ends with newline
if (lines.length > 0 && lines.at(-1) === "") {
lines.pop();
}
// Generate diff format
const lineCount = lines.length;
const addedLines = lines.map(line => `+${line}`).join("\n");
let diff = `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1,${lineCount} @@
${addedLines}`;
// Add "No newline at end of file" indicator if needed
if (!hasTrailingNewline && content.length > 0) {
diff += "\n\\ No newline at end of file";
}
return diff + "\n";
} catch (error) {
// Log the error for debugging
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
// Return a placeholder diff
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1 @@
+[Unable to read file content]
`;
}
}
/**
* Generate synthetic diffs for all untracked files and combine with existing diff
*/
export async function appendUntrackedFileDiffs(
basePath: string,
existingDiff: string,
files: Array<{ status: string; path: string }>
): Promise<string> {
// Find untracked files (status "?")
const untrackedFiles = files.filter(f => f.status === "?");
if (untrackedFiles.length === 0) {
return existingDiff;
}
// Generate synthetic diffs for each untracked file
const syntheticDiffs = await Promise.all(
untrackedFiles.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
);
// Combine existing diff with synthetic diffs
const combinedDiff = existingDiff + syntheticDiffs.join("");
return combinedDiff;
}
/**
* List all files in a directory recursively (for non-git repositories)
* Excludes hidden files/folders and common build artifacts
*/
export async function listAllFilesInDirectory(
basePath: string,
relativePath: string = ""
): Promise<string[]> {
const files: string[] = [];
const fullPath = path.join(basePath, relativePath);
// Directories to skip
const skipDirs = new Set([
"node_modules", ".git", ".automaker", "dist", "build",
".next", ".nuxt", "__pycache__", ".cache", "coverage"
]);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
// Skip hidden files/folders (except we want to allow some)
if (entry.name.startsWith(".") && entry.name !== ".env") {
continue;
}
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
const subFiles = await listAllFilesInDirectory(basePath, entryRelPath);
files.push(...subFiles);
}
} else if (entry.isFile()) {
files.push(entryRelPath);
}
}
} catch (error) {
// Log the error to help diagnose file system issues
logger.error(`Error reading directory ${fullPath}:`, error);
}
return files;
}
/**
* Generate diffs for all files in a non-git directory
* Treats all files as "new" files
*/
export async function generateDiffsForNonGitDirectory(
basePath: string
): Promise<{ diff: string; files: FileStatus[] }> {
const allFiles = await listAllFilesInDirectory(basePath);
const files: FileStatus[] = allFiles.map(filePath => ({
status: "?",
path: filePath,
statusText: "New",
}));
// Generate synthetic diffs for all files
const syntheticDiffs = await Promise.all(
files.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
);
return {
diff: syntheticDiffs.join(""),
files,
};
}
/**
* Get git repository diffs for a given path
* Handles both git repos and non-git directories
*/
export async function getGitRepositoryDiffs(
repoPath: string
): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> {
// Check if it's a git repository
const isRepo = await isGitRepo(repoPath);
if (!isRepo) {
// Not a git repo - list all files and treat them as new
const result = await generateDiffsForNonGitDirectory(repoPath);
return {
diff: result.diff,
files: result.files,
hasChanges: result.files.length > 0,
};
}
// Get git diff and status
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: repoPath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: repoPath,
});
const files = parseGitStatus(status);
// Generate synthetic diffs for untracked (new) files
const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files);
return {
diff: combinedDiff,
files,
hasChanges: files.length > 0,
};
}
/**
* Get error message from error object
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
return error instanceof Error ? error.message : "Unknown error";
}
/**

View File

@@ -1,26 +0,0 @@
/**
* Context routes - HTTP API for context file operations
*
* Provides endpoints for managing context files including
* AI-powered image description generation.
*/
import { Router } from 'express';
import { createDescribeImageHandler } from './routes/describe-image.js';
import { createDescribeFileHandler } from './routes/describe-file.js';
import type { SettingsService } from '../../services/settings-service.js';
/**
* Create the context router
*
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express router with context endpoints
*/
export function createContextRoutes(settingsService?: SettingsService): Router {
const router = Router();
router.post('/describe-image', createDescribeImageHandler(settingsService));
router.post('/describe-file', createDescribeFileHandler(settingsService));
return router;
}

View File

@@ -1,233 +0,0 @@
/**
* POST /context/describe-file endpoint - Generate description for a text file
*
* Uses Claude Haiku to analyze a text file and generate a concise description
* suitable for context file metadata.
*
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
* and reads file content directly (not via Claude's Read tool) to prevent
* arbitrary file reads and prompt injection attacks.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
/**
* Request body for the describe-file endpoint
*/
interface DescribeFileRequestBody {
/** Path to the file */
filePath: string;
}
/**
* Success response from the describe-file endpoint
*/
interface DescribeFileSuccessResponse {
success: true;
description: string;
}
/**
* Error response from the describe-file endpoint
*/
interface DescribeFileErrorResponse {
success: false;
error: string;
}
/**
* Extract text content from Claude SDK response messages
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Create the describe-file request handler
*
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express request handler for file description
*/
export function createDescribeFileHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { filePath } = req.body as DescribeFileRequestBody;
// Validate required fields
if (!filePath || typeof filePath !== 'string') {
const response: DescribeFileErrorResponse = {
success: false,
error: 'filePath is required and must be a string',
};
res.status(400).json(response);
return;
}
logger.info(`[DescribeFile] Starting description generation for: ${filePath}`);
// Resolve the path for logging and cwd derivation
const resolvedPath = secureFs.resolvePath(filePath);
// Read file content using secureFs (validates path against ALLOWED_ROOT_DIRECTORY)
// This prevents arbitrary file reads (e.g., /etc/passwd, ~/.ssh/id_rsa)
// and prompt injection attacks where malicious filePath values could inject instructions
let fileContent: string;
try {
const content = await secureFs.readFile(resolvedPath, 'utf-8');
fileContent = typeof content === 'string' ? content : content.toString('utf-8');
} catch (readError) {
// Path not allowed - return 403 Forbidden
if (readError instanceof PathNotAllowedError) {
logger.warn(`[DescribeFile] Path not allowed: ${filePath}`);
const response: DescribeFileErrorResponse = {
success: false,
error: 'File path is not within the allowed directory',
};
res.status(403).json(response);
return;
}
// File not found
if (
readError !== null &&
typeof readError === 'object' &&
'code' in readError &&
readError.code === 'ENOENT'
) {
logger.warn(`[DescribeFile] File not found: ${resolvedPath}`);
const response: DescribeFileErrorResponse = {
success: false,
error: `File not found: ${filePath}`,
};
res.status(404).json(response);
return;
}
const errorMessage = readError instanceof Error ? readError.message : 'Unknown error';
logger.error(`[DescribeFile] Failed to read file: ${errorMessage}`);
const response: DescribeFileErrorResponse = {
success: false,
error: `Failed to read file: ${errorMessage}`,
};
res.status(500).json(response);
return;
}
// Truncate very large files to avoid token limits
const MAX_CONTENT_LENGTH = 50000;
const truncated = fileContent.length > MAX_CONTENT_LENGTH;
const contentToAnalyze = truncated
? fileContent.substring(0, MAX_CONTENT_LENGTH)
: fileContent;
// Get the filename for context
const fileName = path.basename(resolvedPath);
// Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
];
// Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeFile]'
);
// Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
// Extract the description from the response
const description = await extractTextFromStream(stream);
if (!description || description.trim().length === 0) {
logger.warn('Received empty response from Claude');
const response: DescribeFileErrorResponse = {
success: false,
error: 'Failed to generate description - empty response',
};
res.status(500).json(response);
return;
}
logger.info(`Description generated, length: ${description.length} chars`);
const response: DescribeFileSuccessResponse = {
success: true,
description: description.trim(),
};
res.json(response);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
logger.error('File description failed:', errorMessage);
const response: DescribeFileErrorResponse = {
success: false,
error: errorMessage,
};
res.status(500).json(response);
}
};
}

View File

@@ -1,429 +0,0 @@
/**
* POST /context/describe-image endpoint - Generate description for an image
*
* Uses Claude Haiku to analyze an image and generate a concise description
* suitable for context file metadata.
*
* IMPORTANT:
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
* not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach
* so it doesn't depend on Claude's filesystem tool access or working directory restrictions.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as fs from 'fs';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
/**
* Allowlist of safe headers to log
* All other headers are excluded to prevent leaking sensitive values
*/
const SAFE_HEADERS_ALLOWLIST = new Set([
'content-type',
'accept',
'user-agent',
'host',
'referer',
'content-length',
'origin',
'x-request-id',
]);
/**
* Filter request headers to only include safe, non-sensitive values
*/
function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unknown> {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(headers)) {
if (SAFE_HEADERS_ALLOWLIST.has(key.toLowerCase())) {
filtered[key] = value;
}
}
return filtered;
}
/**
* Find the actual file path, handling Unicode character variations.
* macOS screenshots use U+202F (NARROW NO-BREAK SPACE) before AM/PM,
* but this may be transmitted as a regular space through the API.
*/
function findActualFilePath(requestedPath: string): string | null {
// First, try the exact path
if (fs.existsSync(requestedPath)) {
return requestedPath;
}
// Try with Unicode normalization
const normalizedPath = requestedPath.normalize('NFC');
if (fs.existsSync(normalizedPath)) {
return normalizedPath;
}
// If not found, try to find the file in the directory by matching the basename
// This handles cases where the space character differs (U+0020 vs U+202F vs U+00A0)
const dir = path.dirname(requestedPath);
const baseName = path.basename(requestedPath);
if (!fs.existsSync(dir)) {
return null;
}
try {
const files = fs.readdirSync(dir);
// Normalize the requested basename for comparison
// Replace various space-like characters with regular space for comparison
const normalizeSpaces = (s: string): string => s.replace(/[\u00A0\u202F\u2009\u200A]/g, ' ');
const normalizedBaseName = normalizeSpaces(baseName);
for (const file of files) {
if (normalizeSpaces(file) === normalizedBaseName) {
logger.info(`Found matching file with different space encoding: ${file}`);
return path.join(dir, file);
}
}
} catch (err) {
logger.error(`Error reading directory ${dir}: ${err}`);
}
return null;
}
/**
* Request body for the describe-image endpoint
*/
interface DescribeImageRequestBody {
/** Path to the image file */
imagePath: string;
}
/**
* Success response from the describe-image endpoint
*/
interface DescribeImageSuccessResponse {
success: true;
description: string;
}
/**
* Error response from the describe-image endpoint
*/
interface DescribeImageErrorResponse {
success: false;
error: string;
requestId?: string;
}
/**
* Map SDK/CLI errors to a stable status + user-facing message.
*/
function mapDescribeImageError(rawMessage: string | undefined): {
statusCode: number;
userMessage: string;
} {
const baseResponse = {
statusCode: 500,
userMessage: 'Failed to generate an image description. Please try again.',
};
if (!rawMessage) return baseResponse;
if (rawMessage.includes('Claude Code process exited')) {
return {
statusCode: 503,
userMessage:
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
};
}
if (
rawMessage.includes('Failed to spawn Claude Code process') ||
rawMessage.includes('Claude Code executable not found') ||
rawMessage.includes('Claude Code native binary not found')
) {
return {
statusCode: 503,
userMessage:
'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.',
};
}
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
return {
statusCode: 429,
userMessage: 'Rate limited while describing the image. Please wait a moment and try again.',
};
}
if (rawMessage.toLowerCase().includes('payload too large') || rawMessage.includes('413')) {
return {
statusCode: 413,
userMessage:
'The image is too large to send for description. Please resize/compress it and try again.',
};
}
return baseResponse;
}
/**
* Extract text content from Claude SDK response messages and log high-signal stream events.
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>,
requestId: string
): Promise<string> {
let responseText = '';
let messageCount = 0;
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
for await (const msg of stream) {
messageCount++;
const msgType = msg?.type;
const msgSubtype = msg?.subtype;
// Keep this concise but informative. Full error object is logged in catch blocks.
logger.info(
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
);
if (msgType === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
if (msgType === 'result' && msgSubtype === 'success') {
if (typeof msg.result === 'string' && msg.result.length > 0) {
responseText = msg.result;
}
}
}
logger.info(
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
);
return responseText;
}
/**
* Create the describe-image request handler
*
* Uses Claude SDK query with multi-part content blocks to include the image (base64),
* matching the agent runner behavior.
*
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express request handler for image description
*/
export function createDescribeImageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const startedAt = Date.now();
// Request envelope logs (high value when correlating failures)
// Only log safe headers to prevent leaking sensitive values (auth tokens, cookies, etc.)
logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`);
logger.info(`[${requestId}] headers=${JSON.stringify(filterSafeHeaders(req.headers))}`);
logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`);
try {
const { imagePath } = req.body as DescribeImageRequestBody;
// Validate required fields
if (!imagePath || typeof imagePath !== 'string') {
const response: DescribeImageErrorResponse = {
success: false,
error: 'imagePath is required and must be a string',
requestId,
};
res.status(400).json(response);
return;
}
logger.info(`[${requestId}] imagePath="${imagePath}" type=${typeof imagePath}`);
// Find the actual file path (handles Unicode space character variations)
const actualPath = findActualFilePath(imagePath);
if (!actualPath) {
logger.error(`[${requestId}] File not found: ${imagePath}`);
// Log hex representation of the path for debugging
const hexPath = Buffer.from(imagePath).toString('hex');
logger.error(`[${requestId}] imagePath hex: ${hexPath}`);
const response: DescribeImageErrorResponse = {
success: false,
error: `File not found: ${imagePath}`,
requestId,
};
res.status(404).json(response);
return;
}
if (actualPath !== imagePath) {
logger.info(`[${requestId}] Using actual path: ${actualPath}`);
}
// Log path + stats (this is often where issues start: missing file, perms, size)
let stat: fs.Stats | null = null;
try {
stat = fs.statSync(actualPath);
logger.info(
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
);
} catch (statErr) {
logger.warn(
`[${requestId}] Unable to stat image file (continuing to read base64): ${String(statErr)}`
);
}
// Read image and convert to base64 (same as agent runner)
logger.info(`[${requestId}] Reading image into base64...`);
const imageReadStart = Date.now();
const imageData = await readImageAsBase64(actualPath);
const imageReadMs = Date.now() - imageReadStart;
const base64Length = imageData.base64.length;
const estimatedBytes = Math.ceil((base64Length * 3) / 4);
logger.info(`[${requestId}] imageReadMs=${imageReadMs}`);
logger.info(
`[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}`
);
// Build multi-part prompt with image block (no Read tool required)
const instructionText =
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: imageData.mimeType,
data: imageData.base64,
},
},
];
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeImage]'
);
// Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});
logger.info(
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
sdkOptions.allowedTools
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
);
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
logger.info(`[${requestId}] Calling query()...`);
const queryStart = Date.now();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
// Extract the description from the response
const extractStart = Date.now();
const description = await extractTextFromStream(stream, requestId);
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
if (!description || description.trim().length === 0) {
logger.warn(`[${requestId}] Received empty response from Claude`);
const response: DescribeImageErrorResponse = {
success: false,
error: 'Failed to generate description - empty response',
requestId,
};
res.status(500).json(response);
return;
}
const totalMs = Date.now() - startedAt;
logger.info(`[${requestId}] Success descriptionLen=${description.length} totalMs=${totalMs}`);
const response: DescribeImageSuccessResponse = {
success: true,
description: description.trim(),
};
res.json(response);
} catch (error) {
const totalMs = Date.now() - startedAt;
const err = error as unknown;
const errMessage = err instanceof Error ? err.message : String(err);
const errName = err instanceof Error ? err.name : 'UnknownError';
const errStack = err instanceof Error ? err.stack : undefined;
logger.error(`[${requestId}] FAILED totalMs=${totalMs}`);
logger.error(`[${requestId}] errorName=${errName}`);
logger.error(`[${requestId}] errorMessage=${errMessage}`);
if (errStack) logger.error(`[${requestId}] errorStack=${errStack}`);
// Dump all enumerable + non-enumerable props (this is where stderr/stdout/exitCode often live)
try {
const props = err && typeof err === 'object' ? Object.getOwnPropertyNames(err) : [];
logger.error(`[${requestId}] errorProps=${JSON.stringify(props)}`);
if (err && typeof err === 'object') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyErr = err as any;
const details = JSON.stringify(anyErr, props as unknown as string[]);
logger.error(`[${requestId}] errorDetails=${details}`);
}
} catch (stringifyErr) {
logger.error(`[${requestId}] Failed to serialize error object: ${String(stringifyErr)}`);
}
const { statusCode, userMessage } = mapDescribeImageError(errMessage);
const response: DescribeImageErrorResponse = {
success: false,
error: `${userMessage} (requestId: ${requestId})`,
requestId,
};
res.status(statusCode).json(response);
}
};
}

View File

@@ -5,8 +5,8 @@
* with different enhancement modes (improve, expand, simplify, etc.)
*/
import { Router } from 'express';
import { createEnhanceHandler } from './routes/enhance.js';
import { Router } from "express";
import { createEnhanceHandler } from "./routes/enhance.js";
/**
* Create the enhance-prompt router
@@ -16,7 +16,7 @@ import { createEnhanceHandler } from './routes/enhance.js';
export function createEnhancePromptRoutes(): Router {
const router = Router();
router.post('/', createEnhanceHandler());
router.post("/", createEnhanceHandler());
return router;
}

View File

@@ -5,19 +5,18 @@
* Supports modes: improve, technical, simplify, acceptance
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import type { Request, Response } from "express";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { createLogger } from "../../../lib/logger.js";
import {
getSystemPrompt,
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
} from '../../../lib/enhancement-prompts.js';
} from "../../../lib/enhancement-prompts.js";
import { resolveModelString, CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
const logger = createLogger('EnhancePrompt');
const logger = createLogger("EnhancePrompt");
/**
* Request body for the enhance endpoint
@@ -63,16 +62,16 @@ async function extractTextFromStream(
};
}>
): Promise<string> {
let responseText = '';
let responseText = "";
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
if (block.type === "text" && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
@@ -85,25 +84,29 @@ async function extractTextFromStream(
*
* @returns Express request handler for text enhancement
*/
export function createEnhanceHandler(): (req: Request, res: Response) => Promise<void> {
export function createEnhanceHandler(): (
req: Request,
res: Response
) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
const { originalText, enhancementMode, model } =
req.body as EnhanceRequestBody;
// Validate required fields
if (!originalText || typeof originalText !== 'string') {
if (!originalText || typeof originalText !== "string") {
const response: EnhanceErrorResponse = {
success: false,
error: 'originalText is required and must be a string',
error: "originalText is required and must be a string",
};
res.status(400).json(response);
return;
}
if (!enhancementMode || typeof enhancementMode !== 'string') {
if (!enhancementMode || typeof enhancementMode !== "string") {
const response: EnhanceErrorResponse = {
success: false,
error: 'enhancementMode is required and must be a string',
error: "enhancementMode is required and must be a string",
};
res.status(400).json(response);
return;
@@ -114,7 +117,7 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
if (trimmedText.length === 0) {
const response: EnhanceErrorResponse = {
success: false,
error: 'originalText cannot be empty',
error: "originalText cannot be empty",
};
res.status(400).json(response);
return;
@@ -124,9 +127,11 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
const normalizedMode = enhancementMode.toLowerCase();
const validMode: EnhancementMode = isValidEnhancementMode(normalizedMode)
? normalizedMode
: 'improve';
: "improve";
logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
logger.info(
`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`
);
// Get the system prompt for this mode
const systemPrompt = getSystemPrompt(validMode);
@@ -149,7 +154,7 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
permissionMode: "acceptEdits",
},
});
@@ -157,16 +162,18 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
const enhancedText = await extractTextFromStream(stream);
if (!enhancedText || enhancedText.trim().length === 0) {
logger.warn('Received empty response from Claude');
logger.warn("Received empty response from Claude");
const response: EnhanceErrorResponse = {
success: false,
error: 'Failed to generate enhanced text - empty response',
error: "Failed to generate enhanced text - empty response",
};
res.status(500).json(response);
return;
}
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
logger.info(
`Enhancement complete, output length: ${enhancedText.length} chars`
);
const response: EnhanceSuccessResponse = {
success: true,
@@ -174,8 +181,9 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
};
res.json(response);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
logger.error('Enhancement failed:', errorMessage);
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
logger.error("Enhancement failed:", errorMessage);
const response: EnhanceErrorResponse = {
success: false,

View File

@@ -2,10 +2,13 @@
* Common utilities for features routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { createLogger } from "../../lib/logger.js";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
const logger = createLogger('Features');
const logger = createLogger("Features");
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };

View File

@@ -2,27 +2,24 @@
* Features routes - HTTP API for feature management
*/
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
import { createCreateHandler } from './routes/create.js';
import { createUpdateHandler } from './routes/update.js';
import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { Router } from "express";
import { FeatureLoader } from "../../services/feature-loader.js";
import { createListHandler } from "./routes/list.js";
import { createGetHandler } from "./routes/get.js";
import { createCreateHandler } from "./routes/create.js";
import { createUpdateHandler } from "./routes/update.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createAgentOutputHandler } from "./routes/agent-output.js";
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler());
router.post("/list", createListHandler(featureLoader));
router.post("/get", createGetHandler(featureLoader));
router.post("/create", createCreateHandler(featureLoader));
router.post("/update", createUpdateHandler(featureLoader));
router.post("/delete", createDeleteHandler(featureLoader));
router.post("/agent-output", createAgentOutputHandler(featureLoader));
return router;
}

View File

@@ -2,9 +2,9 @@
* POST /agent-output endpoint - Get agent output for a feature
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import { FeatureLoader } from "../../../services/feature-loader.js";
import { getErrorMessage, logError } from "../common.js";
export function createAgentOutputHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,17 +15,22 @@ export function createAgentOutputHandler(featureLoader: FeatureLoader) {
};
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
});
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
const content = await featureLoader.getAgentOutput(projectPath, featureId);
const content = await featureLoader.getAgentOutput(
projectPath,
featureId
);
res.json({ success: true, content });
} catch (error) {
logError(error, 'Get agent output failed');
logError(error, "Get agent output failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,13 @@
* POST /create endpoint - Create a new feature
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import {
FeatureLoader,
type Feature,
} from "../../../services/feature-loader.js";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
export function createCreateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
@@ -16,17 +19,22 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
};
if (!projectPath || !feature) {
res.status(400).json({
success: false,
error: 'projectPath and feature are required',
});
res
.status(400)
.json({
success: false,
error: "projectPath and feature are required",
});
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const created = await featureLoader.create(projectPath, feature);
res.json({ success: true, feature: created });
} catch (error) {
logError(error, 'Create feature failed');
logError(error, "Create feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,9 +2,9 @@
* POST /delete endpoint - Delete a feature
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError } from '../common.js';
import type { Request, Response } from "express";
import { FeatureLoader } from "../../../services/feature-loader.js";
import { getErrorMessage, logError } from "../common.js";
export function createDeleteHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,17 +15,19 @@ export function createDeleteHandler(featureLoader: FeatureLoader) {
};
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath and featureId are required',
});
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
const success = await featureLoader.delete(projectPath, featureId);
res.json({ success });
} catch (error) {
logError(error, 'Delete feature failed');
logError(error, "Delete feature failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -1,133 +0,0 @@
/**
* POST /features/generate-title endpoint - Generate a concise title from description
*
* Uses Claude Haiku to generate a short, descriptive title from feature description.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
const logger = createLogger('GenerateTitle');
interface GenerateTitleRequestBody {
description: string;
}
interface GenerateTitleSuccessResponse {
success: true;
title: string;
}
interface GenerateTitleErrorResponse {
success: false;
error: string;
}
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
Rules:
- Output ONLY the title, nothing else
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {
success: false,
error: 'description is required and must be a string',
};
res.status(400).json(response);
return;
}
const trimmedDescription = description.trim();
if (trimmedDescription.length === 0) {
const response: GenerateTitleErrorResponse = {
success: false,
error: 'description cannot be empty',
};
res.status(400).json(response);
return;
}
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
const stream = query({
prompt: userPrompt,
options: {
model: CLAUDE_MODEL_MAP.haiku,
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
});
const title = await extractTextFromStream(stream);
if (!title || title.trim().length === 0) {
logger.warn('Received empty response from Claude');
const response: GenerateTitleErrorResponse = {
success: false,
error: 'Failed to generate title - empty response',
};
res.status(500).json(response);
return;
}
logger.info(`Generated title: ${title.trim()}`);
const response: GenerateTitleSuccessResponse = {
success: true,
title: title.trim(),
};
res.json(response);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
logger.error('Title generation failed:', errorMessage);
const response: GenerateTitleErrorResponse = {
success: false,
error: errorMessage,
};
res.status(500).json(response);
}
};
}

Some files were not shown because too many files have changed in this diff Show More