Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0acdba6f01 | |||
| de2eccd209 | |||
| 948db57763 | |||
| f98a13eaa0 | |||
| 24ff491d3c | |||
| a8f2d0b110 | |||
| 46d2536600 | |||
| 71535581c2 | |||
| 26725286d5 | |||
| 569e921759 | |||
| 03ccce5cc1 | |||
| 64d5170c94 | |||
| 0710c13964 | |||
| 56cedec2ae | |||
| c0c8067bc5 | |||
| db3a034d75 | |||
| 059315d6ab | |||
| 8df7ba4f16 | |||
| 2bffea842b | |||
| 99cf21e61b | |||
| da5e26b923 | |||
| c957eaa3a1 | |||
| 73d01c0103 | |||
| 41a507fe8b | |||
| e02aa597f2 | |||
| 909305c82b | |||
| 121b2b294f | |||
| c2fe3322a7 | |||
| aac6b106aa | |||
| 7f6beba3ad | |||
| 4b354e7b9f | |||
| eed5297e9d | |||
| 9a5ca8c78f | |||
| 63a1d3c138 | |||
| 3caf9cf18e | |||
| 7d351e3422 | |||
| 9dea155505 | |||
| d3cdd3a1c7 | |||
| a9d1ddb84f |
@@ -0,0 +1,132 @@
|
||||
name: Build Native Module Prebuilds
|
||||
|
||||
on:
|
||||
# Build on releases
|
||||
release:
|
||||
types: [published]
|
||||
# Manual trigger for testing
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
electron_version:
|
||||
description: 'Electron version to build for'
|
||||
required: false
|
||||
default: '39.2.6'
|
||||
|
||||
env:
|
||||
# Default Electron version - update when upgrading Electron in package.json
|
||||
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '39.2.6' }}
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64]
|
||||
# Add arm64 when GitHub Actions supports Windows ARM runners
|
||||
# arch: [x64, arm64]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Visual Studio Build Tools
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Install node-pty and rebuild for Electron
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install only node-pty
|
||||
pnpm add node-pty@1.1.0-beta42
|
||||
|
||||
# Get Electron ABI version
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
Write-Host "Building for Electron $env:ELECTRON_VERSION (ABI: $electronAbi)"
|
||||
|
||||
# Rebuild node-pty for Electron
|
||||
npx @electron/rebuild --version $env:ELECTRON_VERSION --module-dir node_modules/node-pty --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Package prebuilt binaries
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
$prebuildDir = "prebuilds/win32-${{ matrix.arch }}-electron-$electronAbi"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $prebuildDir
|
||||
|
||||
# Copy all built native files
|
||||
$buildDir = "node_modules/node-pty/build/Release"
|
||||
if (Test-Path $buildDir) {
|
||||
Copy-Item "$buildDir/*.node" $prebuildDir/ -Force
|
||||
Copy-Item "$buildDir/*.dll" $prebuildDir/ -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item "$buildDir/*.exe" $prebuildDir/ -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Also copy conpty files if they exist in subdirectory
|
||||
if (Test-Path "$buildDir/conpty") {
|
||||
Copy-Item "$buildDir/conpty/*" $prebuildDir/ -Force
|
||||
}
|
||||
}
|
||||
|
||||
# List what we packaged
|
||||
Write-Host "Packaged prebuilds:"
|
||||
Get-ChildItem $prebuildDir
|
||||
|
||||
- name: Create archive
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
$archiveName = "node-pty-win32-${{ matrix.arch }}-electron-$electronAbi.zip"
|
||||
|
||||
Compress-Archive -Path "prebuilds/*" -DestinationPath $archiveName
|
||||
|
||||
Write-Host "Created archive: $archiveName"
|
||||
Get-ChildItem $archiveName
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-win32-${{ matrix.arch }}
|
||||
path: auto-claude-ui/node-pty-*.zip
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: auto-claude-ui/node-pty-*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Create a combined prebuilds package
|
||||
package-prebuilds:
|
||||
needs: build-windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: List artifacts
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find artifacts -type f -name "*.zip"
|
||||
|
||||
- name: Upload combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-prebuilds-all
|
||||
path: artifacts/**/*.zip
|
||||
retention-days: 90
|
||||
@@ -1,3 +1,99 @@
|
||||
## 2.5.0 - Roadmap Intelligence & Workflow Refinements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Interactive competitor analysis viewer for roadmap planning with real-time data visualization
|
||||
|
||||
- GitHub issue label mapping to task categories for improved organization and tracking
|
||||
|
||||
- GitHub issue comment selection in task creation workflow for better context integration
|
||||
|
||||
- TaskCreationWizard enhanced with drag-and-drop support for file references and inline @mentions
|
||||
|
||||
- Roadmap generation now includes stop functionality and comprehensive debug logging
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Refined visual drop zone feedback in file reference system for more subtle user guidance
|
||||
|
||||
- Remove auto-expand behavior for referenced files on draft restore to improve UX
|
||||
|
||||
- Always-visible referenced files section in TaskCreationWizard for better discoverability
|
||||
|
||||
- Drop zone wrapper added around main modal content area for improved drag-and-drop ergonomics
|
||||
|
||||
- Stuck task detection now enabled for ai_review status to better track blocked work
|
||||
|
||||
- Enhanced React component stability with proper key usage in RoadmapHeader and PhaseProgressIndicator
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Corrected CompetitorAnalysisViewer type definitions for proper TypeScript compliance
|
||||
|
||||
- Fixed multiple CodeRabbit review feedback items for improved code quality
|
||||
|
||||
- Resolved React key warnings in PhaseProgressIndicator component
|
||||
|
||||
- Fixed git status parsing in merge preview for accurate worktree state detection
|
||||
|
||||
- Corrected path resolution in runners for proper module imports and .env loading
|
||||
|
||||
- Resolved CI lint and TypeScript errors across codebase
|
||||
|
||||
- Fixed HTTP error handling and path resolution issues in core modules
|
||||
|
||||
- Corrected worktree test to match intended branch detection behavior
|
||||
|
||||
- Refined TaskReview component conditional rendering for proper staged task display
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat: add interactive competitor analysis viewer for roadmap by @AndyMik90 in 7ff326d
|
||||
|
||||
- fix: correct CompetitorAnalysisViewer to match type definitions by @AndyMik90 in 4f1766b
|
||||
|
||||
- fix: address multiple CodeRabbit review feedback items by @AndyMik90 in 48f7c3c
|
||||
|
||||
- fix: use stable React keys instead of array indices in RoadmapHeader by @AndyMik90 in 892e01d
|
||||
|
||||
- fix: additional fixes for http error handling and path resolution by @AndyMik90 in 54501cb
|
||||
|
||||
- fix: update worktree test to match intended branch detection behavior by @AndyMik90 in f1d578f
|
||||
|
||||
- fix: resolve CI lint and TypeScript errors by @AndyMik90 in 2e3a5d9
|
||||
|
||||
- feat: enhance roadmap generation with stop functionality and debug logging by @AndyMik90 in a6dad42
|
||||
|
||||
- fix: correct path resolution in runners for module imports and .env loading by @AndyMik90 in 3d24f8f
|
||||
|
||||
- fix: resolve React key warning in PhaseProgressIndicator by @AndyMik90 in 9106038
|
||||
|
||||
- fix: enable stuck task detection for ai_review status by @AndyMik90 in 895ed9f
|
||||
|
||||
- feat: map GitHub issue labels to task categories by @AndyMik90 in cbe14fd
|
||||
|
||||
- feat: add GitHub issue comment selection and fix auto-start bug by @AndyMik90 in 4c1dd89
|
||||
|
||||
- feat: enhance TaskCreationWizard with drag-and-drop support for file references and inline @mentions by @AndyMik90 in d93eefe
|
||||
|
||||
- cleanup docs by @AndyMik90 in 8e891df
|
||||
|
||||
- fix: correct git status parsing in merge preview by @AndyMik90 in c721dc2
|
||||
|
||||
- Update TaskReview component to refine conditional rendering for staged tasks, ensuring proper display when staging is unsuccessful by @AndyMik90 in 1a2b7a1
|
||||
|
||||
- auto-claude: subtask-2-3 - Refine visual drop zone feedback to be more subtle by @AndyMik90 in 6cff442
|
||||
|
||||
- auto-claude: subtask-2-1 - Remove showFiles auto-expand on draft restore by @AndyMik90 in 12bf69d
|
||||
|
||||
- auto-claude: subtask-1-3 - Create an always-visible referenced files section by @AndyMik90 in 3818b46
|
||||
|
||||
- auto-claude: subtask-1-2 - Add drop zone wrapper around main modal content area by @AndyMik90 in 219b66d
|
||||
|
||||
- auto-claude: subtask-1-1 - Remove Reference Files toggle button by @AndyMik90 in 4e63e85
|
||||
|
||||
## 2.4.0 - Enhanced Cross-Platform Experience with OAuth & Auto-Updates
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -4,7 +4,7 @@ Your AI coding companion. Build features, fix bugs, and ship faster — with aut
|
||||
|
||||

|
||||
|
||||
[](https://discord.gg/maj9EWmY)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
|
||||
## What It Does ✨
|
||||
|
||||
@@ -35,7 +35,7 @@ The Desktop UI is the recommended way to use Auto Claude. It provides visual tas
|
||||
### Prerequisites
|
||||
|
||||
1. **Node.js 18+** - [Download Node.js](https://nodejs.org/)
|
||||
2. **Python 3.9+** - [Download Python](https://www.python.org/downloads/)
|
||||
2. **Python 3.10+** - [Download Python](https://www.python.org/downloads/)
|
||||
3. **Docker Desktop** - Required for the Memory Layer
|
||||
4. **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
5. **Claude Subscription** - Requires [Claude Pro or Max](https://claude.ai/upgrade) for Claude Code access
|
||||
@@ -114,6 +114,18 @@ pnpm run build && pnpm run start
|
||||
# or: npm run build && npm run start
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Windows users:</b> If installation fails with node-gyp errors, click here</summary>
|
||||
|
||||
Auto Claude automatically downloads prebuilt binaries for Windows. If prebuilts aren't available for your Electron version yet, you'll need Visual Studio Build Tools:
|
||||
|
||||
1. Download [Visual Studio Build Tools 2022](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||
2. Select "Desktop development with C++" workload
|
||||
3. In "Individual Components", add "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs"
|
||||
4. Restart terminal and run `npm install` again
|
||||
|
||||
</details>
|
||||
|
||||
### Step 4: Start Building
|
||||
|
||||
1. Add your project in the UI
|
||||
@@ -309,7 +321,7 @@ See `auto-claude/.env.example` for complete configuration options.
|
||||
|
||||
Join our Discord to get help, share what you're building, and connect with other Auto Claude users:
|
||||
|
||||
[](https://discord.gg/maj9EWmY)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"author": "Auto Claude Team",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"start": "electron .",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Download prebuilt native modules for Windows
|
||||
*
|
||||
* This script downloads pre-compiled node-pty binaries from GitHub releases,
|
||||
* eliminating the need for Visual Studio Build Tools on Windows.
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const GITHUB_REPO = 'AndyMik90/Auto-Claude';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
|
||||
/**
|
||||
* Get the Electron ABI version for the installed Electron
|
||||
*/
|
||||
function getElectronAbi() {
|
||||
try {
|
||||
// Try to get from electron-abi package
|
||||
const result = execSync('npx electron-abi', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
// Fallback: read from electron package
|
||||
try {
|
||||
const electronPkg = require('electron/package.json');
|
||||
const version = electronPkg.version;
|
||||
// Electron 39.x = ABI 140
|
||||
const majorVersion = parseInt(version.split('.')[0], 10);
|
||||
// This is a rough mapping, electron-abi is more accurate
|
||||
const abiMap = {
|
||||
39: 140,
|
||||
38: 139,
|
||||
37: 136,
|
||||
36: 135,
|
||||
35: 134,
|
||||
34: 132,
|
||||
33: 131,
|
||||
32: 130,
|
||||
31: 129,
|
||||
30: 128,
|
||||
};
|
||||
return abiMap[majorVersion] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest release from GitHub
|
||||
*/
|
||||
function getLatestRelease() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'api.github.com',
|
||||
path: `/repos/${GITHUB_REPO}/releases/latest`,
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-Installer',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
};
|
||||
|
||||
https
|
||||
.get(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(JSON.parse(data));
|
||||
} else if (res.statusCode === 404) {
|
||||
resolve(null); // No releases yet
|
||||
} else {
|
||||
reject(new Error(`GitHub API returned ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find prebuild asset in release
|
||||
*/
|
||||
function findPrebuildAsset(release, arch, electronAbi) {
|
||||
if (!release || !release.assets) return null;
|
||||
|
||||
const assetName = `node-pty-win32-${arch}-electron-${electronAbi}.zip`;
|
||||
return release.assets.find((asset) => asset.name === assetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from URL
|
||||
*/
|
||||
function downloadFile(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = (url) => {
|
||||
https
|
||||
.get(url, { headers: { 'User-Agent': 'Auto-Claude-Installer' } }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
// Follow redirect
|
||||
request(res.headers.location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Download failed with status ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {}); // Delete partial file
|
||||
reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
request(url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract zip file (using built-in tools)
|
||||
*/
|
||||
function extractZip(zipPath, destDir) {
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Use PowerShell on Windows
|
||||
execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to download and install prebuilds
|
||||
*/
|
||||
async function downloadPrebuilds() {
|
||||
const arch = process.arch; // x64 or arm64
|
||||
const electronAbi = getElectronAbi();
|
||||
|
||||
if (!electronAbi) {
|
||||
console.log('[prebuilds] Could not determine Electron ABI version');
|
||||
return { success: false, reason: 'unknown-abi' };
|
||||
}
|
||||
|
||||
console.log(`[prebuilds] Looking for prebuilds: win32-${arch}, Electron ABI ${electronAbi}`);
|
||||
|
||||
// Check for prebuilds in GitHub releases
|
||||
let release;
|
||||
try {
|
||||
release = await getLatestRelease();
|
||||
} catch (err) {
|
||||
console.log(`[prebuilds] Could not fetch releases: ${err.message}`);
|
||||
return { success: false, reason: 'fetch-failed' };
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
console.log('[prebuilds] No releases found');
|
||||
return { success: false, reason: 'no-releases' };
|
||||
}
|
||||
|
||||
const asset = findPrebuildAsset(release, arch, electronAbi);
|
||||
if (!asset) {
|
||||
console.log(`[prebuilds] No prebuild found for win32-${arch}-electron-${electronAbi}`);
|
||||
console.log('[prebuilds] Available assets:', release.assets?.map((a) => a.name).join(', ') || 'none');
|
||||
return { success: false, reason: 'no-matching-prebuild' };
|
||||
}
|
||||
|
||||
console.log(`[prebuilds] Found prebuild: ${asset.name}`);
|
||||
|
||||
// Download the prebuild
|
||||
const tempDir = path.join(__dirname, '..', '.prebuild-temp');
|
||||
const zipPath = path.join(tempDir, asset.name);
|
||||
const nodePtyDir = path.join(__dirname, '..', 'node_modules', 'node-pty');
|
||||
const buildDir = path.join(nodePtyDir, 'build', 'Release');
|
||||
|
||||
try {
|
||||
// Create temp directory
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
console.log(`[prebuilds] Downloading ${asset.name}...`);
|
||||
await downloadFile(asset.browser_download_url, zipPath);
|
||||
|
||||
console.log('[prebuilds] Extracting...');
|
||||
extractZip(zipPath, tempDir);
|
||||
|
||||
// Find the extracted prebuild directory
|
||||
const extractedDir = path.join(tempDir, 'prebuilds', `win32-${arch}-electron-${electronAbi}`);
|
||||
|
||||
if (!fs.existsSync(extractedDir)) {
|
||||
throw new Error(`Extracted directory not found: ${extractedDir}`);
|
||||
}
|
||||
|
||||
// Ensure build/Release directory exists
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// Copy files to node_modules/node-pty/build/Release
|
||||
const files = fs.readdirSync(extractedDir);
|
||||
for (const file of files) {
|
||||
const src = path.join(extractedDir, file);
|
||||
const dest = path.join(buildDir, file);
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`[prebuilds] Installed: ${file}`);
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log('[prebuilds] Successfully installed prebuilt binaries!');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`[prebuilds] Download/extract failed: ${err.message}`);
|
||||
return { success: false, reason: 'install-failed', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use by postinstall
|
||||
module.exports = { downloadPrebuilds, getElectronAbi };
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
downloadPrebuilds()
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[prebuilds] Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-install script for Auto Claude UI
|
||||
*
|
||||
* On Windows:
|
||||
* 1. Try to download prebuilt node-pty binaries from GitHub releases
|
||||
* 2. Fall back to electron-rebuild if prebuilds aren't available
|
||||
* 3. Show helpful error message if compilation fails
|
||||
*
|
||||
* On macOS/Linux:
|
||||
* 1. Run electron-rebuild (compilers are typically available)
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
const WINDOWS_BUILD_TOOLS_HELP = `
|
||||
================================================================================
|
||||
VISUAL STUDIO BUILD TOOLS REQUIRED
|
||||
================================================================================
|
||||
|
||||
Prebuilt binaries weren't available for your Electron version, and compilation
|
||||
requires Visual Studio Build Tools.
|
||||
|
||||
To install:
|
||||
|
||||
1. Download Visual Studio Build Tools 2022:
|
||||
https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
|
||||
2. Run installer and select:
|
||||
- "Desktop development with C++" workload
|
||||
|
||||
3. In "Individual Components", also select:
|
||||
- "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs"
|
||||
|
||||
4. Restart your terminal and run: npm install
|
||||
|
||||
================================================================================
|
||||
`;
|
||||
|
||||
/**
|
||||
* Run electron-rebuild
|
||||
*/
|
||||
function runElectronRebuild() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const npx = isWindows ? 'npx.cmd' : 'npx';
|
||||
const child = spawn(npx, ['electron-rebuild'], {
|
||||
stdio: 'inherit',
|
||||
shell: isWindows,
|
||||
cwd: path.join(__dirname, '..'),
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
reject(new Error(`electron-rebuild exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node-pty is already built
|
||||
*/
|
||||
function isNodePtyBuilt() {
|
||||
const buildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
|
||||
if (!fs.existsSync(buildDir)) return false;
|
||||
|
||||
// Check for the main .node file
|
||||
const files = fs.readdirSync(buildDir);
|
||||
return files.some((f) => f.endsWith('.node'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main postinstall logic
|
||||
*/
|
||||
async function main() {
|
||||
console.log('[postinstall] Setting up native modules for Electron...\n');
|
||||
|
||||
// If node-pty is already built (e.g., from a previous successful install), skip
|
||||
if (isNodePtyBuilt()) {
|
||||
console.log('[postinstall] Native modules already built, skipping rebuild.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, try prebuilds first
|
||||
console.log('[postinstall] Windows detected - checking for prebuilt binaries...\n');
|
||||
|
||||
try {
|
||||
// Dynamic import to handle case where the script doesn't exist yet
|
||||
const { downloadPrebuilds } = require('./download-prebuilds.js');
|
||||
const result = await downloadPrebuilds();
|
||||
|
||||
if (result.success) {
|
||||
console.log('\n[postinstall] Successfully installed prebuilt binaries!');
|
||||
console.log('[postinstall] No Visual Studio Build Tools required.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[postinstall] Prebuilds not available (${result.reason})`);
|
||||
console.log('[postinstall] Falling back to electron-rebuild...\n');
|
||||
} catch (err) {
|
||||
console.log('[postinstall] Could not check for prebuilds:', err.message);
|
||||
console.log('[postinstall] Falling back to electron-rebuild...\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Run electron-rebuild
|
||||
try {
|
||||
console.log('[postinstall] Running electron-rebuild...\n');
|
||||
await runElectronRebuild();
|
||||
console.log('\n[postinstall] Native modules built successfully!');
|
||||
} catch (error) {
|
||||
console.error('\n[postinstall] Failed to build native modules.\n');
|
||||
|
||||
if (isWindows) {
|
||||
console.error(WINDOWS_BUILD_TOOLS_HELP);
|
||||
} else {
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nYou may need to install build tools for your platform:');
|
||||
console.error(' macOS: xcode-select --install');
|
||||
console.error(' Linux: sudo apt-get install build-essential\n');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[postinstall] Unexpected error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -29,6 +29,14 @@ vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(() => mockProcess)
|
||||
}));
|
||||
|
||||
// Mock claude-profile-manager to bypass auth checks in tests
|
||||
vi.mock('../../main/claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: () => ({
|
||||
hasValidAuth: () => true,
|
||||
getActiveProfile: () => ({ profileId: 'default', profileName: 'Default' })
|
||||
})
|
||||
}));
|
||||
|
||||
// Auto-claude source path (for getAutoBuildSourcePath to find)
|
||||
const AUTO_CLAUDE_SOURCE = path.join(TEST_DIR, 'auto-claude-source');
|
||||
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Unit tests for rate limit and auth failure detection
|
||||
* Tests detection patterns for rate limiting and authentication failures
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock the claude-profile-manager before importing
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(() => ({
|
||||
getActiveProfile: vi.fn(() => ({
|
||||
id: 'test-profile-id',
|
||||
name: 'Test Profile',
|
||||
isDefault: true
|
||||
})),
|
||||
getProfile: vi.fn((id: string) => ({
|
||||
id,
|
||||
name: 'Test Profile',
|
||||
isDefault: true
|
||||
})),
|
||||
getBestAvailableProfile: vi.fn(() => null),
|
||||
recordRateLimitEvent: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Rate Limit Detector', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectRateLimit', () => {
|
||||
it('should detect rate limit with reset time', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am (Europe/Oslo)';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
expect(result.resetTime).toBe('Dec 17 at 6am (Europe/Oslo)');
|
||||
expect(result.limitType).toBe('weekly');
|
||||
});
|
||||
|
||||
it('should detect rate limit with bullet character', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached • resets 11:59pm';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
expect(result.resetTime).toBe('11:59pm');
|
||||
expect(result.limitType).toBe('session');
|
||||
});
|
||||
|
||||
it('should detect secondary rate limit indicators', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'rate limit exceeded',
|
||||
'usage limit reached',
|
||||
'You have exceeded your limit',
|
||||
'too many requests'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectRateLimit(output);
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectRateLimit('');
|
||||
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it('should return true for rate limit errors', async () => {
|
||||
const { isRateLimitError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isRateLimitError('Limit reached · resets Dec 17 at 6am')).toBe(true);
|
||||
expect(isRateLimitError('rate limit exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit errors', async () => {
|
||||
const { isRateLimitError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isRateLimitError('authentication required')).toBe(false);
|
||||
expect(isRateLimitError('Task completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractResetTime', () => {
|
||||
it('should extract reset time from rate limit message', async () => {
|
||||
const { extractResetTime } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am (Europe/Oslo)';
|
||||
const resetTime = extractResetTime(output);
|
||||
|
||||
expect(resetTime).toBe('Dec 17 at 6am (Europe/Oslo)');
|
||||
});
|
||||
|
||||
it('should return null for non-rate-limit output', async () => {
|
||||
const { extractResetTime } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const resetTime = extractResetTime(output);
|
||||
|
||||
expect(resetTime).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth Failure Detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectAuthFailure', () => {
|
||||
it('should detect "authentication required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: authentication required';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
expect(result.message).toContain('authentication required');
|
||||
});
|
||||
|
||||
it('should detect "authentication is required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Authentication is required to proceed';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "not authenticated" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: not authenticated';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "not yet authenticated" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'You are not yet authenticated';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "login required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Login required';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "oauth token invalid" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token is invalid';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "oauth token expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should detect "oauth token missing" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token missing';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "unauthorized" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: Unauthorized';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "please log in" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Please log in to continue';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
// "please log in" doesn't contain 'required' keyword, so classified as 'unknown'
|
||||
expect(result.failureType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "please authenticate" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Please authenticate before proceeding';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
// "please authenticate" doesn't contain 'required' keyword, so classified as 'unknown'
|
||||
expect(result.failureType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "invalid credentials" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Invalid credentials provided';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "invalid token" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Invalid token';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "auth failed" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Auth failed';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "authentication error" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Authentication error occurred';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "session expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Your session expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should detect "access denied" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Access denied';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "permission denied" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Permission denied';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "401 unauthorized" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'HTTP 401 Unauthorized';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "credentials missing" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Credentials are missing';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "credentials expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Credentials expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should return false for rate limit errors (not auth failure)', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for normal output', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('');
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should include profile ID in result', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required', 'custom-profile');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.profileId).toBe('custom-profile');
|
||||
});
|
||||
|
||||
it('should use active profile ID when not specified', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.profileId).toBe('test-profile-id');
|
||||
});
|
||||
|
||||
it('should include original error in result', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: authentication required for this action';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.originalError).toBe(output);
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for missing auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('Settings');
|
||||
expect(result.message).toContain('Claude Profiles');
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for expired auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('session expired');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('expired');
|
||||
expect(result.message).toContain('re-authenticate');
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for invalid auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('unauthorized');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthFailureError', () => {
|
||||
it('should return true for auth failure errors', async () => {
|
||||
const { isAuthFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isAuthFailureError('authentication required')).toBe(true);
|
||||
expect(isAuthFailureError('not authenticated')).toBe(true);
|
||||
expect(isAuthFailureError('unauthorized')).toBe(true);
|
||||
expect(isAuthFailureError('invalid token')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-auth-failure errors', async () => {
|
||||
const { isAuthFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isAuthFailureError('Limit reached · resets Dec 17')).toBe(false);
|
||||
expect(isAuthFailureError('Task completed')).toBe(false);
|
||||
expect(isAuthFailureError('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth failure does not match rate limit patterns', () => {
|
||||
it('should not detect auth failure as rate limit', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const authErrors = [
|
||||
'authentication required',
|
||||
'not authenticated',
|
||||
'unauthorized',
|
||||
'invalid token',
|
||||
'session expired',
|
||||
'please log in'
|
||||
];
|
||||
|
||||
for (const error of authErrors) {
|
||||
const result = detectRateLimit(error);
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not detect rate limit as auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const rateLimitErrors = [
|
||||
'Limit reached · resets Dec 17 at 6am',
|
||||
'rate limit exceeded',
|
||||
'too many requests',
|
||||
'usage limit reached'
|
||||
];
|
||||
|
||||
for (const error of rateLimitErrors) {
|
||||
const result = detectAuthFailure(error);
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline output with auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Starting task...
|
||||
Processing...
|
||||
Error: authentication required
|
||||
Please authenticate and try again.`;
|
||||
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'AUTHENTICATION REQUIRED',
|
||||
'Authentication Required',
|
||||
'UNAUTHORIZED',
|
||||
'Unauthorized',
|
||||
'NOT AUTHENTICATED',
|
||||
'Not Authenticated'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectAuthFailure(output);
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle partial matches correctly', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
// Should NOT match - word is part of a different context
|
||||
const falsePositives = [
|
||||
'The authenticated user can proceed', // has 'authenticated' but not an error
|
||||
'Authorization header set correctly' // different word
|
||||
];
|
||||
|
||||
// Note: Some false positives may still match due to pattern design
|
||||
// The patterns are intentionally broad to catch errors
|
||||
for (const output of falsePositives) {
|
||||
const result = detectAuthFailure(output);
|
||||
// Just verify it runs without error - actual match depends on pattern design
|
||||
expect(typeof result.isAuthFailure).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle JSON error responses', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = '{"error": "unauthorized", "message": "Please authenticate"}';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle error stack traces with auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Error: authentication required
|
||||
at validateToken (/app/auth.js:42)
|
||||
at processRequest (/app/handler.js:15)
|
||||
at main (/app/index.js:8)`;
|
||||
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
@@ -89,6 +90,13 @@ export class AgentManager extends EventEmitter {
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
@@ -120,6 +128,20 @@ export class AgentManager extends EventEmitter {
|
||||
args.push('--auto-approve');
|
||||
}
|
||||
|
||||
// Pass model and thinking level configuration
|
||||
// For auto profile, use phase-specific config; otherwise use single model/thinking
|
||||
if (metadata?.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
|
||||
// Pass the spec phase model and thinking level to spec_runner
|
||||
args.push('--model', metadata.phaseModels.spec);
|
||||
args.push('--thinking-level', metadata.phaseThinking.spec);
|
||||
} else if (metadata?.model) {
|
||||
// Non-auto profile: use single model and thinking level
|
||||
args.push('--model', metadata.model);
|
||||
if (metadata.thinkingLevel) {
|
||||
args.push('--thinking-level', metadata.thinkingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata);
|
||||
|
||||
@@ -136,6 +158,13 @@ export class AgentManager extends EventEmitter {
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
@@ -168,6 +197,8 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
|
||||
// The options.parallel and options.workers are kept for future use or logging purposes
|
||||
// Note: Model configuration is read from task_metadata.json by the Python scripts,
|
||||
// which allows per-phase configuration for planner, coder, and QA phases
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { ProcessType, ExecutionProgressData } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { projectStore } from '../project-store';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
|
||||
@@ -300,6 +300,17 @@ export class AgentProcessManager {
|
||||
taskId
|
||||
});
|
||||
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
||||
} else {
|
||||
// Not rate limited - check for authentication failure
|
||||
const authFailureDetection = detectAuthFailure(allOutput);
|
||||
if (authFailureDetection.isAuthFailure) {
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,23 @@ export interface TaskExecutionOptions {
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
requireReviewBeforeCoding?: boolean;
|
||||
// Auto profile - phase-based model and thinking configuration
|
||||
isAutoProfile?: boolean;
|
||||
phaseModels?: {
|
||||
spec: 'haiku' | 'sonnet' | 'opus';
|
||||
planning: 'haiku' | 'sonnet' | 'opus';
|
||||
coding: 'haiku' | 'sonnet' | 'opus';
|
||||
qa: 'haiku' | 'sonnet' | 'opus';
|
||||
};
|
||||
phaseThinking?: {
|
||||
spec: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
planning: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
coding: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
qa: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
};
|
||||
// Non-auto profile - single model and thinking level
|
||||
model?: 'haiku' | 'sonnet' | 'opus';
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
|
||||
@@ -451,6 +451,34 @@ export class ClaudeProfileManager {
|
||||
return isProfileAuthenticatedImpl(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile has valid authentication for starting tasks.
|
||||
* A profile is considered authenticated if:
|
||||
* 1) It has a valid OAuth token (not expired), OR
|
||||
* 2) It has an authenticated configDir (credential files exist)
|
||||
*
|
||||
* @param profileId - Optional profile ID to check. If not provided, checks active profile.
|
||||
* @returns true if the profile can authenticate, false otherwise
|
||||
*/
|
||||
hasValidAuth(profileId?: string): boolean {
|
||||
const profile = profileId ? this.getProfile(profileId) : this.getActiveProfile();
|
||||
if (!profile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check 1: Profile has a valid OAuth token
|
||||
if (hasValidToken(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2 & 3: Profile has authenticated configDir (works for both default and non-default)
|
||||
if (this.isProfileAuthenticated(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for invoking Claude with a specific profile
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,8 @@ import { EventEmitter } from 'events';
|
||||
import type {
|
||||
InsightsSession,
|
||||
InsightsSessionSummary,
|
||||
InsightsChatMessage
|
||||
InsightsChatMessage,
|
||||
InsightsModelConfig
|
||||
} from '../shared/types';
|
||||
import { InsightsConfig } from './insights/config';
|
||||
import { InsightsPaths } from './insights/paths';
|
||||
@@ -111,7 +112,12 @@ export class InsightsService extends EventEmitter {
|
||||
/**
|
||||
* Send a message and get AI response
|
||||
*/
|
||||
async sendMessage(projectId: string, projectPath: string, message: string): Promise<void> {
|
||||
async sendMessage(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
message: string,
|
||||
modelConfig?: InsightsModelConfig
|
||||
): Promise<void> {
|
||||
// Cancel any existing session
|
||||
this.executor.cancelSession(projectId);
|
||||
|
||||
@@ -150,13 +156,17 @@ export class InsightsService extends EventEmitter {
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
// Use provided modelConfig or fall back to session's config
|
||||
const configToUse = modelConfig || session.modelConfig;
|
||||
|
||||
try {
|
||||
// Execute insights query
|
||||
const result = await this.executor.execute(
|
||||
projectId,
|
||||
projectPath,
|
||||
message,
|
||||
conversationHistory
|
||||
conversationHistory,
|
||||
configToUse
|
||||
);
|
||||
|
||||
// Add assistant message to session
|
||||
@@ -177,6 +187,13 @@ export class InsightsService extends EventEmitter {
|
||||
console.error('[InsightsService] Error executing insights:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model configuration for a session
|
||||
*/
|
||||
updateSessionModelConfig(projectPath: string, sessionId: string, modelConfig: InsightsModelConfig): boolean {
|
||||
return this.sessionManager.updateSessionModelConfig(projectPath, sessionId, modelConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
InsightsChatMessage,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsToolUsage
|
||||
InsightsToolUsage,
|
||||
InsightsModelConfig
|
||||
} from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { InsightsConfig } from './config';
|
||||
import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector';
|
||||
|
||||
@@ -59,7 +61,8 @@ export class InsightsExecutor extends EventEmitter {
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
message: string,
|
||||
conversationHistory: Array<{ role: string; content: string }>
|
||||
conversationHistory: Array<{ role: string; content: string }>,
|
||||
modelConfig?: InsightsModelConfig
|
||||
): Promise<ProcessorResult> {
|
||||
// Cancel any existing session
|
||||
this.cancelSession(projectId);
|
||||
@@ -69,7 +72,7 @@ export class InsightsExecutor extends EventEmitter {
|
||||
throw new Error('Auto Claude source not found');
|
||||
}
|
||||
|
||||
const runnerPath = path.join(autoBuildSource, 'insights_runner.py');
|
||||
const runnerPath = path.join(autoBuildSource, 'runners', 'insights_runner.py');
|
||||
if (!existsSync(runnerPath)) {
|
||||
throw new Error('insights_runner.py not found in auto-claude directory');
|
||||
}
|
||||
@@ -83,13 +86,23 @@ export class InsightsExecutor extends EventEmitter {
|
||||
// Get process environment
|
||||
const processEnv = this.config.getProcessEnv();
|
||||
|
||||
// Spawn Python process
|
||||
const proc = spawn(this.config.getPythonPath(), [
|
||||
// Build command arguments
|
||||
const args = [
|
||||
runnerPath,
|
||||
'--project-dir', projectPath,
|
||||
'--message', message,
|
||||
'--history', JSON.stringify(conversationHistory)
|
||||
], {
|
||||
];
|
||||
|
||||
// Add model config if provided
|
||||
if (modelConfig) {
|
||||
const modelId = MODEL_ID_MAP[modelConfig.model] || MODEL_ID_MAP['sonnet'];
|
||||
args.push('--model', modelId);
|
||||
args.push('--thinking-level', modelConfig.thinkingLevel);
|
||||
}
|
||||
|
||||
// Spawn Python process
|
||||
const proc = spawn(this.config.getPythonPath(), args, {
|
||||
cwd: autoBuildSource,
|
||||
env: processEnv
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InsightsSession, InsightsSessionSummary } from '../../shared/types';
|
||||
import type { InsightsSession, InsightsSessionSummary, InsightsModelConfig } from '../../shared/types';
|
||||
import { SessionStorage } from './session-storage';
|
||||
import { InsightsPaths } from './paths';
|
||||
|
||||
@@ -119,6 +119,30 @@ export class SessionManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model configuration for a session
|
||||
*/
|
||||
updateSessionModelConfig(projectPath: string, sessionId: string, modelConfig: InsightsModelConfig): boolean {
|
||||
const session = this.storage.loadSessionById(projectPath, sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
session.modelConfig = modelConfig;
|
||||
session.updatedAt = new Date();
|
||||
this.storage.saveSession(projectPath, session);
|
||||
|
||||
// Update cache if this session is cached
|
||||
for (const [projectId, cachedSession] of this.sessions) {
|
||||
if (cachedSession.id === sessionId) {
|
||||
cachedSession.modelConfig = modelConfig;
|
||||
cachedSession.updatedAt = new Date();
|
||||
this.sessions.set(projectId, cachedSession);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to disk and update cache
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
|
||||
@@ -21,6 +21,18 @@ function debugLog(message: string, data?: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Regex pattern to validate GitHub repository format (owner/repo)
|
||||
// Allows alphanumeric characters, hyphens, underscores, and periods
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Validate that a repository string matches the expected owner/repo format
|
||||
* Prevents command injection by rejecting strings with shell metacharacters
|
||||
*/
|
||||
function isValidGitHubRepo(repo: string): boolean {
|
||||
return GITHUB_REPO_PATTERN.test(repo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is installed
|
||||
*/
|
||||
@@ -296,6 +308,106 @@ export function registerListUserRepos(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GitHub repository from git remote origin
|
||||
*/
|
||||
export function registerDetectGitHubRepo(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_DETECT_REPO,
|
||||
async (_event: Electron.IpcMainInvokeEvent, projectPath: string): Promise<IPCResult<string>> => {
|
||||
debugLog('detectGitHubRepo handler called', { projectPath });
|
||||
try {
|
||||
// Get the remote URL
|
||||
debugLog('Running: git remote get-url origin');
|
||||
const remoteUrl = execSync('git remote get-url origin', {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectPath,
|
||||
stdio: 'pipe'
|
||||
}).trim();
|
||||
|
||||
debugLog('Remote URL:', remoteUrl);
|
||||
|
||||
// Parse GitHub repo from URL
|
||||
// Formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
// - git@github.com:owner/repo.git
|
||||
// - https://github.com/owner/repo
|
||||
const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const repo = match[1];
|
||||
debugLog('Detected repo:', repo);
|
||||
return {
|
||||
success: true,
|
||||
data: repo
|
||||
};
|
||||
}
|
||||
|
||||
debugLog('Could not parse GitHub repo from URL');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Remote URL is not a GitHub repository'
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to detect repo:', error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to detect GitHub repository'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branches from GitHub repository
|
||||
*/
|
||||
export function registerGetGitHubBranches(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_BRANCHES,
|
||||
async (_event: Electron.IpcMainInvokeEvent, repo: string, _token: string): Promise<IPCResult<string[]>> => {
|
||||
debugLog('getGitHubBranches handler called', { repo });
|
||||
|
||||
// Validate repo format to prevent command injection
|
||||
if (!isValidGitHubRepo(repo)) {
|
||||
debugLog('Invalid repo format rejected:', repo);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Expected: owner/repo'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Use gh CLI to list branches (uses authenticated session)
|
||||
// Use execFileSync with separate arguments to avoid shell injection
|
||||
const apiEndpoint = `repos/${repo}/branches`;
|
||||
debugLog(`Running: gh api ${apiEndpoint} --paginate --jq '.[].name'`);
|
||||
const output = execFileSync(
|
||||
'gh',
|
||||
['api', apiEndpoint, '--paginate', '--jq', '.[].name'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
}
|
||||
);
|
||||
|
||||
const branches = output.trim().split('\n').filter(b => b.length > 0);
|
||||
debugLog('Found branches:', branches.length);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: branches
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to get branches:', error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get branches'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all GitHub OAuth handlers
|
||||
*/
|
||||
@@ -307,5 +419,7 @@ export function registerGithubOAuthHandlers(): void {
|
||||
registerGetGhToken();
|
||||
registerGetGhUser();
|
||||
registerListUserRepos();
|
||||
registerDetectGitHubRepo();
|
||||
registerGetGitHubBranches();
|
||||
debugLog('GitHub OAuth handlers registered');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import type { IPCResult, InsightsSession, InsightsSessionSummary, Task, TaskMetadata } from '../../shared/types';
|
||||
import type { IPCResult, InsightsSession, InsightsSessionSummary, InsightsModelConfig, Task, TaskMetadata } from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import { insightsService } from '../insights-service';
|
||||
|
||||
@@ -32,7 +32,7 @@ export function registerInsightsHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.INSIGHTS_SEND_MESSAGE,
|
||||
async (_, projectId: string, message: string) => {
|
||||
async (_, projectId: string, message: string, modelConfig?: InsightsModelConfig) => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
const mainWindow = getMainWindow();
|
||||
@@ -44,7 +44,7 @@ export function registerInsightsHandlers(
|
||||
|
||||
// Note: Python environment initialization should be handled by insightsService
|
||||
// or added here with proper dependency injection if needed
|
||||
insightsService.sendMessage(projectId, project.path, message);
|
||||
insightsService.sendMessage(projectId, project.path, message, modelConfig);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -241,4 +241,57 @@ export function registerInsightsHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Update model configuration for a session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG,
|
||||
async (_, projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const success = insightsService.updateSessionModelConfig(project.path, sessionId, modelConfig);
|
||||
if (success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to update model configuration' };
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Insights Event Forwarding (Service -> Renderer)
|
||||
// ============================================
|
||||
|
||||
// Forward streaming chunks to renderer
|
||||
insightsService.on('stream-chunk', (projectId: string, chunk: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, projectId, chunk);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward status updates to renderer
|
||||
insightsService.on('status', (projectId: string, status: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STATUS, projectId, status);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward errors to renderer
|
||||
insightsService.on('error', (projectId: string, error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward SDK rate limit events to renderer
|
||||
insightsService.on('sdk-rate-limit', (rateLimitInfo: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function registerLinearHandlers(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': apiKey
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
@@ -160,6 +160,16 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get roadmap generation status - allows frontend to query if generation is running
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_GET_STATUS,
|
||||
async (_, projectId: string): Promise<IPCResult<{ isRunning: boolean }>> => {
|
||||
const isRunning = agentManager.isRoadmapRunning(projectId);
|
||||
debugLog('[Roadmap Handler] Get status:', { projectId, isRunning });
|
||||
return { success: true, data: { isRunning } };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentManager } from '../../agent';
|
||||
import { fileWatcher } from '../../file-watcher';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { checkGitStatus } from '../../project-initializer';
|
||||
import { getClaudeProfileManager } from '../../claude-profile-manager';
|
||||
|
||||
/**
|
||||
* Register task execution handlers (start, stop, review, status management, recovery)
|
||||
@@ -62,6 +63,18 @@ export function registerTaskExecutionHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication - Claude requires valid auth to run tasks
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[TASK_START] No valid authentication for active profile');
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_ERROR,
|
||||
taskId,
|
||||
'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account, or set an OAuth token.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length);
|
||||
|
||||
// Start file watcher for this task
|
||||
@@ -265,6 +278,37 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition - 'human_review' requires actual work to have been done
|
||||
// This prevents tasks from being incorrectly marked as ready for review when execution failed
|
||||
if (status === 'human_review') {
|
||||
const specsBaseDirForValidation = getSpecsDir(project.autoBuildPath);
|
||||
const specDirForValidation = path.join(
|
||||
project.path,
|
||||
specsBaseDirForValidation,
|
||||
task.specId
|
||||
);
|
||||
const specFilePath = path.join(specDirForValidation, AUTO_BUILD_PATHS.SPEC_FILE);
|
||||
|
||||
// Check if spec.md exists and has meaningful content (at least 100 chars)
|
||||
const MIN_SPEC_CONTENT_LENGTH = 100;
|
||||
let specContent = '';
|
||||
try {
|
||||
if (existsSync(specFilePath)) {
|
||||
specContent = readFileSync(specFilePath, 'utf-8');
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors - treat as empty spec
|
||||
}
|
||||
|
||||
if (!specContent || specContent.length < MIN_SPEC_CONTENT_LENGTH) {
|
||||
console.warn(`[TASK_UPDATE_STATUS] Blocked attempt to set status 'human_review' for task ${taskId}. No spec has been created yet.`);
|
||||
return {
|
||||
success: false,
|
||||
error: "Cannot move to human review - no spec has been created yet. The task must complete processing before review."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get the spec directory
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specDir = path.join(
|
||||
@@ -334,6 +378,20 @@ export function registerTaskExecutionHandlers(
|
||||
return { success: false, error: gitStatusCheck.error || 'Git repository required' };
|
||||
}
|
||||
|
||||
// Check authentication before auto-starting
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[TASK_UPDATE_STATUS] No valid authentication for active profile');
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_ERROR,
|
||||
taskId,
|
||||
'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account, or set an OAuth token.'
|
||||
);
|
||||
}
|
||||
return { success: false, error: 'Claude authentication required' };
|
||||
}
|
||||
|
||||
console.warn('[TASK_UPDATE_STATUS] Auto-starting task:', taskId);
|
||||
|
||||
// Start file watcher for this task
|
||||
@@ -562,6 +620,23 @@ export function registerTaskExecutionHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
// Check authentication before auto-restarting
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[Recovery] Auth check failed, cannot auto-restart task');
|
||||
// Recovery succeeded but we can't restart without auth
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
recovered: true,
|
||||
newStatus,
|
||||
message: 'Task recovered but cannot restart: Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account.',
|
||||
autoRestarted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Set status to in_progress for the restart
|
||||
newStatus = 'in_progress';
|
||||
|
||||
@@ -402,12 +402,75 @@ export function registerWorktreeHandlers(
|
||||
if (code === 0) {
|
||||
const isStageOnly = options?.noCommit === true;
|
||||
|
||||
// For stage-only: keep in human_review so user commits manually
|
||||
// For full merge: mark as done
|
||||
const newStatus = isStageOnly ? 'human_review' : 'done';
|
||||
const planStatus = isStageOnly ? 'review' : 'completed';
|
||||
// Verify changes were actually staged when stage-only mode is requested
|
||||
// This prevents false positives when merge was already committed previously
|
||||
let hasActualStagedChanges = false;
|
||||
let mergeAlreadyCommitted = false;
|
||||
|
||||
debug('Merge successful. isStageOnly:', isStageOnly, 'newStatus:', newStatus);
|
||||
if (isStageOnly) {
|
||||
try {
|
||||
const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' });
|
||||
hasActualStagedChanges = gitDiffStaged.trim().length > 0;
|
||||
debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges);
|
||||
|
||||
if (!hasActualStagedChanges) {
|
||||
// Check if worktree branch was already merged (merge commit exists)
|
||||
const specBranch = `auto-claude/${task.specId}`;
|
||||
try {
|
||||
// Check if current branch contains all commits from spec branch
|
||||
const mergeBaseResult = execSync(
|
||||
`git merge-base --is-ancestor ${specBranch} HEAD 2>/dev/null && echo "merged" || echo "not-merged"`,
|
||||
{ cwd: project.path, encoding: 'utf-8' }
|
||||
).trim();
|
||||
mergeAlreadyCommitted = mergeBaseResult === 'merged';
|
||||
debug('Merge already committed check:', mergeAlreadyCommitted);
|
||||
} catch {
|
||||
// Branch may not exist or other error - assume not merged
|
||||
debug('Could not check merge status, assuming not merged');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to verify staged changes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine actual status based on verification
|
||||
let newStatus: string;
|
||||
let planStatus: string;
|
||||
let message: string;
|
||||
let staged: boolean;
|
||||
|
||||
if (isStageOnly && !hasActualStagedChanges && mergeAlreadyCommitted) {
|
||||
// Stage-only was requested but merge was already committed previously
|
||||
// Mark as done since changes are already in the branch
|
||||
newStatus = 'done';
|
||||
planStatus = 'completed';
|
||||
message = 'Changes were already merged and committed. Task marked as done.';
|
||||
staged = false;
|
||||
debug('Stage-only requested but merge already committed. Marking as done.');
|
||||
} else if (isStageOnly && !hasActualStagedChanges) {
|
||||
// Stage-only was requested but no changes to stage (and not committed)
|
||||
// This could mean nothing to merge or an error - keep in human_review for investigation
|
||||
newStatus = 'human_review';
|
||||
planStatus = 'review';
|
||||
message = 'No changes to stage. The worktree may have no differences from the current branch.';
|
||||
staged = false;
|
||||
debug('Stage-only requested but no changes to stage.');
|
||||
} else if (isStageOnly) {
|
||||
// Stage-only with actual staged changes - expected success case
|
||||
newStatus = 'human_review';
|
||||
planStatus = 'review';
|
||||
message = 'Changes staged in main project. Review with git status and commit when ready.';
|
||||
staged = true;
|
||||
} else {
|
||||
// Full merge (not stage-only)
|
||||
newStatus = 'done';
|
||||
planStatus = 'completed';
|
||||
message = 'Changes merged successfully';
|
||||
staged = false;
|
||||
}
|
||||
|
||||
debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged);
|
||||
|
||||
// Persist the status change to implementation_plan.json
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
@@ -419,7 +482,7 @@ export function registerWorktreeHandlers(
|
||||
plan.status = newStatus;
|
||||
plan.planStatus = planStatus;
|
||||
plan.updated_at = new Date().toISOString();
|
||||
if (isStageOnly) {
|
||||
if (staged) {
|
||||
plan.stagedAt = new Date().toISOString();
|
||||
plan.stagedInMainProject = true;
|
||||
}
|
||||
@@ -434,17 +497,13 @@ export function registerWorktreeHandlers(
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, newStatus);
|
||||
}
|
||||
|
||||
const message = isStageOnly
|
||||
? 'Changes staged in main project. Review with git status and commit when ready.'
|
||||
: 'Changes merged successfully';
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message,
|
||||
staged: isStageOnly,
|
||||
projectPath: isStageOnly ? project.path : undefined
|
||||
staged,
|
||||
projectPath: staged ? project.path : undefined
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -22,6 +22,26 @@ const RATE_LIMIT_INDICATORS = [
|
||||
/too\s*many\s*requests/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate authentication failures
|
||||
* These patterns detect when Claude CLI/SDK fails due to missing or invalid auth
|
||||
*/
|
||||
const AUTH_FAILURE_PATTERNS = [
|
||||
/authentication\s*(is\s*)?required/i,
|
||||
/not\s*(yet\s*)?authenticated/i,
|
||||
/login\s*(is\s*)?required/i,
|
||||
/oauth\s*token\s*(is\s*)?(invalid|expired|missing)/i,
|
||||
/unauthorized/i,
|
||||
/please\s*(log\s*in|login|authenticate)/i,
|
||||
/invalid\s*(credentials|token|api\s*key)/i,
|
||||
/auth(entication)?\s*(failed|error|failure)/i,
|
||||
/session\s*(expired|invalid)/i,
|
||||
/access\s*denied/i,
|
||||
/permission\s*denied/i,
|
||||
/401\s*unauthorized/i,
|
||||
/credentials\s*(are\s*)?(missing|invalid|expired)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -43,6 +63,22 @@ export interface RateLimitDetectionResult {
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of authentication failure detection
|
||||
*/
|
||||
export interface AuthFailureDetectionResult {
|
||||
/** Whether an authentication failure was detected */
|
||||
isAuthFailure: boolean;
|
||||
/** The profile ID that failed to authenticate (if known) */
|
||||
profileId?: string;
|
||||
/** The type of auth failure detected */
|
||||
failureType?: 'missing' | 'invalid' | 'expired' | 'unknown';
|
||||
/** User-friendly message describing the failure */
|
||||
message?: string;
|
||||
/** Original error message from the process output */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify rate limit type based on reset time string
|
||||
*/
|
||||
@@ -132,6 +168,80 @@ export function extractResetTime(output: string): string | null {
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the type of authentication failure based on the error message
|
||||
*/
|
||||
function classifyAuthFailureType(output: string): 'missing' | 'invalid' | 'expired' | 'unknown' {
|
||||
const lowerOutput = output.toLowerCase();
|
||||
|
||||
if (/missing|not\s*(yet\s*)?authenticated|required/.test(lowerOutput)) {
|
||||
return 'missing';
|
||||
}
|
||||
if (/expired|session\s*expired/.test(lowerOutput)) {
|
||||
return 'expired';
|
||||
}
|
||||
if (/invalid|unauthorized|denied/.test(lowerOutput)) {
|
||||
return 'invalid';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for the authentication failure
|
||||
*/
|
||||
function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' | 'unknown'): string {
|
||||
switch (failureType) {
|
||||
case 'missing':
|
||||
return 'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account.';
|
||||
case 'expired':
|
||||
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||
case 'invalid':
|
||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect authentication failure from output (stdout + stderr combined)
|
||||
*/
|
||||
export function detectAuthFailure(
|
||||
output: string,
|
||||
profileId?: string
|
||||
): AuthFailureDetectionResult {
|
||||
// First, make sure this isn't a rate limit error (those should be handled separately)
|
||||
if (detectRateLimit(output).isRateLimited) {
|
||||
return { isAuthFailure: false };
|
||||
}
|
||||
|
||||
// Check for authentication failure patterns
|
||||
for (const pattern of AUTH_FAILURE_PATTERNS) {
|
||||
if (pattern.test(output)) {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const effectiveProfileId = profileId || profileManager.getActiveProfile().id;
|
||||
const failureType = classifyAuthFailureType(output);
|
||||
|
||||
return {
|
||||
isAuthFailure: true,
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getAuthFailureMessage(failureType),
|
||||
originalError: output
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isAuthFailure: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains authentication failure error
|
||||
*/
|
||||
export function isAuthFailureError(output: string): boolean {
|
||||
return detectAuthFailure(output).isAuthFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for a specific Claude profile.
|
||||
* Uses OAuth token (CLAUDE_CODE_OAUTH_TOKEN) if available, otherwise falls back to CLAUDE_CONFIG_DIR.
|
||||
|
||||
@@ -32,6 +32,7 @@ export class TaskLogService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Load task logs from a single spec directory
|
||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
||||
*/
|
||||
loadLogsFromPath(specDir: string): TaskLogs | null {
|
||||
const logFile = path.join(specDir, 'task_logs.json');
|
||||
@@ -45,6 +46,13 @@ export class TaskLogService extends EventEmitter {
|
||||
const logs = JSON.parse(content) as TaskLogs;
|
||||
return logs;
|
||||
} catch (error) {
|
||||
// JSON parse error - file may be mid-write, return cached version if available
|
||||
const cached = this.logCache.get(specDir);
|
||||
if (cached) {
|
||||
// Silently return cached version - this is expected during concurrent access
|
||||
return cached;
|
||||
}
|
||||
// Only log if we have no cached fallback
|
||||
console.error(`[TaskLogService] Failed to load logs from ${logFile}:`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SettingsAPI, createSettingsAPI } from './settings-api';
|
||||
import { FileAPI, createFileAPI } from './file-api';
|
||||
import { AgentAPI, createAgentAPI } from './agent-api';
|
||||
import { IdeationAPI, createIdeationAPI } from './modules/ideation-api';
|
||||
import { InsightsAPI, createInsightsAPI } from './modules/insights-api';
|
||||
import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
@@ -15,6 +16,7 @@ export interface ElectronAPI extends
|
||||
FileAPI,
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI {}
|
||||
|
||||
export const createElectronAPI = (): ElectronAPI => ({
|
||||
@@ -25,6 +27,7 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createFileAPI(),
|
||||
...createAgentAPI(),
|
||||
...createIdeationAPI(),
|
||||
...createInsightsAPI(),
|
||||
...createAppUpdateAPI()
|
||||
});
|
||||
|
||||
@@ -37,6 +40,7 @@ export {
|
||||
createFileAPI,
|
||||
createAgentAPI,
|
||||
createIdeationAPI,
|
||||
createInsightsAPI,
|
||||
createAppUpdateAPI
|
||||
};
|
||||
|
||||
@@ -48,5 +52,6 @@ export type {
|
||||
FileAPI,
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI
|
||||
};
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface GitHubAPI {
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
|
||||
// Repository detection
|
||||
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
|
||||
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
|
||||
|
||||
// Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
@@ -109,6 +113,13 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
listGitHubUserRepos: (): Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS),
|
||||
|
||||
// Repository detection
|
||||
detectGitHubRepo: (projectPath: string): Promise<IPCResult<string>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_DETECT_REPO, projectPath),
|
||||
|
||||
getGitHubBranches: (repo: string, token: string): Promise<IPCResult<string[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_BRANCHES, repo, token),
|
||||
|
||||
// Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InsightsSessionSummary,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsModelConfig,
|
||||
Task,
|
||||
TaskMetadata,
|
||||
IPCResult
|
||||
@@ -16,7 +17,7 @@ import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc
|
||||
export interface InsightsAPI {
|
||||
// Operations
|
||||
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
sendInsightsMessage: (projectId: string, message: string) => void;
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig) => void;
|
||||
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
@@ -29,6 +30,7 @@ export interface InsightsAPI {
|
||||
switchInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
deleteInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult>;
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise<IPCResult>;
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise<IPCResult>;
|
||||
|
||||
// Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
@@ -50,8 +52,8 @@ export const createInsightsAPI = (): InsightsAPI => ({
|
||||
getInsightsSession: (projectId: string): Promise<IPCResult<InsightsSession | null>> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId),
|
||||
|
||||
sendInsightsMessage: (projectId: string, message: string): void =>
|
||||
sendIpc(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message),
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig): void =>
|
||||
sendIpc(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message, modelConfig),
|
||||
|
||||
clearInsightsSession: (projectId: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId),
|
||||
@@ -79,6 +81,9 @@ export const createInsightsAPI = (): InsightsAPI => ({
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle),
|
||||
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG, projectId, sessionId, modelConfig),
|
||||
|
||||
// Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
callback: (projectId: string, chunk: InsightsStreamChunk) => void
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc
|
||||
export interface RoadmapAPI {
|
||||
// Operations
|
||||
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
|
||||
getRoadmapStatus: (projectId: string) => Promise<IPCResult<{ isRunning: boolean }>>;
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
@@ -51,6 +52,9 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
getRoadmap: (projectId: string): Promise<IPCResult<Roadmap | null>> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_GET, projectId),
|
||||
|
||||
getRoadmapStatus: (projectId: string): Promise<IPCResult<{ isRunning: boolean }>> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_GET_STATUS, projectId),
|
||||
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_SAVE, projectId, roadmap),
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { OnboardingWizard } from './components/onboarding';
|
||||
import { AppUpdateNotification } from './components/AppUpdateNotification';
|
||||
import { UsageIndicator } from './components/UsageIndicator';
|
||||
import { ProactiveSwapListener } from './components/ProactiveSwapListener';
|
||||
import { GitHubSetupModal } from './components/GitHubSetupModal';
|
||||
import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store';
|
||||
import { useTaskStore, loadTasks } from './stores/task-store';
|
||||
import { useSettingsStore, loadSettings } from './stores/settings-store';
|
||||
@@ -70,6 +71,10 @@ export function App() {
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [skippedInitProjectId, setSkippedInitProjectId] = useState<string | null>(null);
|
||||
|
||||
// GitHub setup state (shown after Auto Claude init)
|
||||
const [showGitHubSetup, setShowGitHubSetup] = useState(false);
|
||||
const [gitHubSetupProject, setGitHubSetupProject] = useState<Project | null>(null);
|
||||
|
||||
// Get selected project
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
@@ -130,12 +135,15 @@ export function App() {
|
||||
|
||||
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
||||
useEffect(() => {
|
||||
// Don't show dialog while initialization is in progress
|
||||
if (isInitializing) return;
|
||||
|
||||
if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) {
|
||||
// Project exists but isn't initialized - show init dialog
|
||||
setPendingProject(selectedProject);
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}, [selectedProject, skippedInitProjectId]);
|
||||
}, [selectedProject, skippedInitProjectId, isInitializing]);
|
||||
|
||||
// Load tasks when project changes
|
||||
useEffect(() => {
|
||||
@@ -235,18 +243,64 @@ export function App() {
|
||||
const handleInitialize = async () => {
|
||||
if (!pendingProject) return;
|
||||
|
||||
const projectId = pendingProject.id;
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
const result = await initializeProject(pendingProject.id);
|
||||
const result = await initializeProject(projectId);
|
||||
if (result?.success) {
|
||||
setShowInitDialog(false);
|
||||
// Get the updated project from store
|
||||
const updatedProject = useProjectStore.getState().projects.find(p => p.id === projectId);
|
||||
|
||||
// Clear init dialog state
|
||||
setPendingProject(null);
|
||||
setShowInitDialog(false);
|
||||
|
||||
// Show GitHub setup modal
|
||||
if (updatedProject) {
|
||||
setGitHubSetupProject(updatedProject);
|
||||
setShowGitHubSetup(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSetupComplete = async (settings: {
|
||||
githubToken: string;
|
||||
githubRepo: string;
|
||||
mainBranch: string;
|
||||
}) => {
|
||||
if (!gitHubSetupProject) return;
|
||||
|
||||
try {
|
||||
// Update project env config with GitHub settings
|
||||
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
|
||||
githubEnabled: true,
|
||||
githubToken: settings.githubToken,
|
||||
githubRepo: settings.githubRepo
|
||||
});
|
||||
|
||||
// Update project settings with mainBranch
|
||||
await window.electronAPI.updateProjectSettings(gitHubSetupProject.id, {
|
||||
mainBranch: settings.mainBranch
|
||||
});
|
||||
|
||||
// Refresh projects to get updated data
|
||||
await loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to save GitHub settings:', error);
|
||||
}
|
||||
|
||||
setShowGitHubSetup(false);
|
||||
setGitHubSetupProject(null);
|
||||
};
|
||||
|
||||
const handleGitHubSetupSkip = () => {
|
||||
setShowGitHubSetup(false);
|
||||
setGitHubSetupProject(null);
|
||||
};
|
||||
|
||||
const handleSkipInit = () => {
|
||||
if (pendingProject) {
|
||||
setSkippedInitProjectId(pendingProject.id);
|
||||
@@ -258,8 +312,8 @@ export function App() {
|
||||
const handleGoToTask = (taskId: string) => {
|
||||
// Switch to kanban view
|
||||
setActiveView('kanban');
|
||||
// Find and select the task
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
// Find and select the task (match by id or specId)
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
if (task) {
|
||||
setSelectedTask(task);
|
||||
}
|
||||
@@ -340,10 +394,13 @@ export function App() {
|
||||
<Insights projectId={selectedProjectId} />
|
||||
)}
|
||||
{activeView === 'github-issues' && selectedProjectId && (
|
||||
<GitHubIssues onOpenSettings={() => {
|
||||
setSettingsInitialProjectSection('github');
|
||||
setIsSettingsDialogOpen(true);
|
||||
}} />
|
||||
<GitHubIssues
|
||||
onOpenSettings={() => {
|
||||
setSettingsInitialProjectSection('github');
|
||||
setIsSettingsDialogOpen(true);
|
||||
}}
|
||||
onNavigateToTask={handleGoToTask}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'changelog' && selectedProjectId && (
|
||||
<Changelog />
|
||||
@@ -414,7 +471,9 @@ export function App() {
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Only trigger skip if user manually closed the dialog
|
||||
// Don't trigger if pendingProject is null (successful init) or if initializing
|
||||
if (!open && pendingProject && !isInitializing) {
|
||||
handleSkipInit();
|
||||
}
|
||||
}}>
|
||||
@@ -475,6 +534,17 @@ export function App() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* GitHub Setup Modal - shows after Auto Claude init to configure GitHub */}
|
||||
{gitHubSetupProject && (
|
||||
<GitHubSetupModal
|
||||
open={showGitHubSetup}
|
||||
onOpenChange={setShowGitHubSetup}
|
||||
project={gitHubSetupProject}
|
||||
onComplete={handleGitHubSetupComplete}
|
||||
onSkip={handleGitHubSetupSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rate Limit Modal - shows when Claude Code hits usage limits (terminal) */}
|
||||
<RateLimitModal />
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* AgentProfileSelector - Reusable component for selecting agent profile in forms
|
||||
*
|
||||
* Provides a dropdown for quick profile selection (Auto, Complex, Balanced, Quick)
|
||||
* with an inline "Custom" option that reveals model and thinking level selects.
|
||||
* The "Auto" profile shows per-phase model configuration.
|
||||
*
|
||||
* Used in TaskCreationWizard and TaskEditDialog.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import {
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import type { ModelType, ThinkingLevel } from '../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface AgentProfileSelectorProps {
|
||||
/** Currently selected profile ID ('auto', 'complex', 'balanced', 'quick', or 'custom') */
|
||||
profileId: string;
|
||||
/** Current model value (fallback for non-auto profiles) */
|
||||
model: ModelType | '';
|
||||
/** Current thinking level value (fallback for non-auto profiles) */
|
||||
thinkingLevel: ThinkingLevel | '';
|
||||
/** Phase model configuration (for auto profile) */
|
||||
phaseModels?: PhaseModelConfig;
|
||||
/** Phase thinking configuration (for auto profile) */
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
/** Called when profile selection changes */
|
||||
onProfileChange: (profileId: string, model: ModelType, thinkingLevel: ThinkingLevel) => void;
|
||||
/** Called when model changes (in custom mode) */
|
||||
onModelChange: (model: ModelType) => void;
|
||||
/** Called when thinking level changes (in custom mode) */
|
||||
onThinkingLevelChange: (level: ThinkingLevel) => void;
|
||||
/** Called when phase models change (in auto mode) */
|
||||
onPhaseModelsChange?: (phaseModels: PhaseModelConfig) => void;
|
||||
/** Called when phase thinking changes (in auto mode) */
|
||||
onPhaseThinkingChange?: (phaseThinking: PhaseThinkingConfig) => void;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap,
|
||||
Sparkles
|
||||
};
|
||||
|
||||
const PHASE_LABELS: Record<keyof PhaseModelConfig, { label: string; description: string }> = {
|
||||
spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' },
|
||||
planning: { label: 'Planning', description: 'Implementation planning and architecture' },
|
||||
coding: { label: 'Coding', description: 'Actual code implementation' },
|
||||
qa: { label: 'QA Review', description: 'Quality assurance and validation' }
|
||||
};
|
||||
|
||||
export function AgentProfileSelector({
|
||||
profileId,
|
||||
model,
|
||||
thinkingLevel,
|
||||
phaseModels,
|
||||
phaseThinking,
|
||||
onProfileChange,
|
||||
onModelChange,
|
||||
onThinkingLevelChange,
|
||||
onPhaseModelsChange,
|
||||
onPhaseThinkingChange,
|
||||
disabled
|
||||
}: AgentProfileSelectorProps) {
|
||||
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
|
||||
|
||||
const isCustom = profileId === 'custom';
|
||||
const isAuto = profileId === 'auto';
|
||||
|
||||
// Use provided phase configs or defaults
|
||||
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
|
||||
const currentPhaseThinking = phaseThinking || DEFAULT_PHASE_THINKING;
|
||||
|
||||
const handleProfileSelect = (selectedId: string) => {
|
||||
if (selectedId === 'custom') {
|
||||
// Keep current model/thinking level, just mark as custom
|
||||
onProfileChange('custom', model as ModelType || 'sonnet', thinkingLevel as ThinkingLevel || 'medium');
|
||||
} else if (selectedId === 'auto') {
|
||||
// Auto profile - set defaults
|
||||
const autoProfile = DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto');
|
||||
if (autoProfile) {
|
||||
onProfileChange('auto', autoProfile.model, autoProfile.thinkingLevel);
|
||||
// Initialize phase configs with defaults if callback provided
|
||||
if (onPhaseModelsChange && autoProfile.phaseModels) {
|
||||
onPhaseModelsChange(autoProfile.phaseModels);
|
||||
}
|
||||
if (onPhaseThinkingChange && autoProfile.phaseThinking) {
|
||||
onPhaseThinkingChange(autoProfile.phaseThinking);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedId);
|
||||
if (profile) {
|
||||
onProfileChange(profile.id, profile.model, profile.thinkingLevel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseModelChange = (phase: keyof PhaseModelConfig, value: ModelType) => {
|
||||
if (onPhaseModelsChange) {
|
||||
onPhaseModelsChange({
|
||||
...currentPhaseModels,
|
||||
[phase]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseThinkingChange = (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => {
|
||||
if (onPhaseThinkingChange) {
|
||||
onPhaseThinkingChange({
|
||||
...currentPhaseThinking,
|
||||
[phase]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get profile display info
|
||||
const getProfileDisplay = () => {
|
||||
if (isCustom) {
|
||||
return {
|
||||
icon: Sliders,
|
||||
label: 'Custom Configuration',
|
||||
description: 'Choose model & thinking level'
|
||||
};
|
||||
}
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
|
||||
if (profile) {
|
||||
return {
|
||||
icon: iconMap[profile.icon || 'Scale'] || Scale,
|
||||
label: profile.name,
|
||||
description: profile.description
|
||||
};
|
||||
}
|
||||
// Default to balanced
|
||||
return {
|
||||
icon: Scale,
|
||||
label: 'Balanced',
|
||||
description: 'Good balance of speed and quality'
|
||||
};
|
||||
};
|
||||
|
||||
const display = getProfileDisplay();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Agent Profile Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-profile" className="text-sm font-medium text-foreground">
|
||||
Agent Profile
|
||||
</Label>
|
||||
<Select
|
||||
value={profileId}
|
||||
onValueChange={handleProfileSelect}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="agent-profile" className="h-10">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<display.icon className="h-4 w-4" />
|
||||
<span>{display.label}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_AGENT_PROFILES.map((profile) => {
|
||||
const ProfileIcon = iconMap[profile.icon || 'Scale'] || Scale;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === profile.model)?.label;
|
||||
return (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileIcon className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{profile.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{profile.isAutoProfile
|
||||
? '(per-phase optimization)'
|
||||
: `(${modelLabel} + ${profile.thinkingLevel})`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">Custom</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Choose model & thinking level)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{display.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Profile - Phase Configuration */}
|
||||
{isAuto && (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
{/* Phase Summary */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between text-sm',
|
||||
'text-muted-foreground hover:text-foreground transition-colors'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="font-medium text-foreground">Phase Configuration</span>
|
||||
{showPhaseDetails ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Compact summary when collapsed */}
|
||||
{!showPhaseDetails && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => {
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase];
|
||||
return (
|
||||
<div key={phase} className="flex items-center justify-between rounded bg-background/50 px-2 py-1">
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[phase].label}:</span>
|
||||
<span className="font-medium">{modelLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detailed Phase Configuration */}
|
||||
{showPhaseDetails && (
|
||||
<div className="space-y-4 pt-2">
|
||||
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
|
||||
<div key={phase} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{PHASE_LABELS[phase].label}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{PHASE_LABELS[phase].description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={currentPhaseModels[phase]}
|
||||
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={currentPhaseThinking[phase]}
|
||||
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Configuration (shown only when custom is selected) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/30 p-4">
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-model" className="text-xs font-medium text-muted-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => onModelChange(value as ModelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="custom-model" className="h-9">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-thinking" className="text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</Label>
|
||||
<Select
|
||||
value={thinkingLevel}
|
||||
onValueChange={(value) => onThinkingLevelChange(value as ThinkingLevel)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="custom-thinking" className="h-9">
|
||||
<SelectValue placeholder="Select thinking level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{level.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
- {level.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { AVAILABLE_MODELS, THINKING_LEVELS } from '../../shared/constants';
|
||||
import type { InsightsModelConfig } from '../../shared/types';
|
||||
import type { ModelType, ThinkingLevel } from '../../shared/types';
|
||||
|
||||
interface CustomModelModalProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onSave: (config: InsightsModelConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CustomModelModal({ currentConfig, onSave, onClose }: CustomModelModalProps) {
|
||||
const [model, setModel] = useState<ModelType>(
|
||||
currentConfig?.model || 'sonnet'
|
||||
);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(
|
||||
currentConfig?.thinkingLevel || 'medium'
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
profileId: 'custom',
|
||||
model,
|
||||
thinkingLevel
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom Model Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure the model and thinking level for this chat session.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-select">Model</Label>
|
||||
<Select value={model} onValueChange={(v) => setModel(v as ModelType)}>
|
||||
<SelectTrigger id="model-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thinking-select">Thinking Level</Label>
|
||||
<Select value={thinkingLevel} onValueChange={(v) => setThinkingLevel(v as ThinkingLevel)}>
|
||||
<SelectTrigger id="thinking-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{level.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{level.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import { useTaskStore } from '../stores/task-store';
|
||||
import { useGitHubIssues, useGitHubInvestigation, useIssueFiltering } from './github-issues/hooks';
|
||||
import {
|
||||
NotConnectedState,
|
||||
@@ -12,10 +13,11 @@ import {
|
||||
import type { GitHubIssue } from '../../shared/types';
|
||||
import type { GitHubIssuesProps } from './github-issues/types';
|
||||
|
||||
export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesProps) {
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
const tasks = useTaskStore((state) => state.tasks);
|
||||
|
||||
const {
|
||||
issues,
|
||||
@@ -43,6 +45,17 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
const [showInvestigateDialog, setShowInvestigateDialog] = useState(false);
|
||||
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = useState<GitHubIssue | null>(null);
|
||||
|
||||
// Build a map of GitHub issue numbers to task IDs for quick lookup
|
||||
const issueToTaskMap = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const task of tasks) {
|
||||
if (task.metadata?.githubIssueNumber) {
|
||||
map.set(task.metadata.githubIssueNumber, task.specId || task.id);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const handleInvestigate = useCallback((issue: GitHubIssue) => {
|
||||
setSelectedIssueForInvestigation(issue);
|
||||
setShowInvestigateDialog(true);
|
||||
@@ -110,6 +123,8 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
? lastInvestigationResult
|
||||
: null
|
||||
}
|
||||
linkedTaskId={issueToTaskMap.get(selectedIssue.number)}
|
||||
onViewTask={onNavigateToTask}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="Select an issue to view details" />
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Github,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { GitHubOAuthFlow } from './project-settings/GitHubOAuthFlow';
|
||||
import type { Project, ProjectSettings } from '../../shared/types';
|
||||
|
||||
interface GitHubSetupModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: Project;
|
||||
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string }) => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'auth' | 'repo' | 'branch' | 'complete';
|
||||
|
||||
/**
|
||||
* GitHub Setup Modal - Required setup flow after Auto Claude initialization
|
||||
*
|
||||
* Flow:
|
||||
* 1. Authenticate with GitHub (via gh CLI OAuth)
|
||||
* 2. Detect/confirm repository
|
||||
* 3. Select base branch for tasks (with recommended default)
|
||||
*/
|
||||
export function GitHubSetupModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
onComplete,
|
||||
onSkip
|
||||
}: GitHubSetupModalProps) {
|
||||
const [step, setStep] = useState<SetupStep>('auth');
|
||||
const [githubToken, setGithubToken] = useState<string | null>(null);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [detectedRepo, setDetectedRepo] = useState<string | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
const [recommendedBranch, setRecommendedBranch] = useState<string | null>(null);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('auth');
|
||||
setGithubToken(null);
|
||||
setGithubRepo(null);
|
||||
setDetectedRepo(null);
|
||||
setBranches([]);
|
||||
setSelectedBranch(null);
|
||||
setRecommendedBranch(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Detect repository from git remote when auth succeeds
|
||||
const detectRepository = async () => {
|
||||
setIsLoadingRepo(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to detect repo from git remote
|
||||
const result = await window.electronAPI.detectGitHubRepo(project.path);
|
||||
if (result.success && result.data) {
|
||||
setDetectedRepo(result.data);
|
||||
setGithubRepo(result.data);
|
||||
setStep('branch');
|
||||
// Immediately load branches
|
||||
await loadBranches(result.data);
|
||||
} else {
|
||||
// No remote detected, show repo input step
|
||||
setStep('repo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect repository');
|
||||
setStep('repo');
|
||||
} finally {
|
||||
setIsLoadingRepo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load branches from GitHub
|
||||
const loadBranches = async (repo: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get branches from GitHub API
|
||||
const result = await window.electronAPI.getGitHubBranches(repo, githubToken!);
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
|
||||
// Detect recommended branch (main > master > develop > first)
|
||||
const recommended = detectRecommendedBranch(result.data);
|
||||
setRecommendedBranch(recommended);
|
||||
setSelectedBranch(recommended);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load branches');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load branches');
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect recommended branch from list
|
||||
const detectRecommendedBranch = (branchList: string[]): string | null => {
|
||||
const priorities = ['main', 'master', 'develop', 'dev'];
|
||||
for (const priority of priorities) {
|
||||
if (branchList.includes(priority)) {
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
return branchList[0] || null;
|
||||
};
|
||||
|
||||
// Handle OAuth success
|
||||
const handleAuthSuccess = async (token: string) => {
|
||||
setGithubToken(token);
|
||||
// Move to repo detection
|
||||
await detectRepository();
|
||||
};
|
||||
|
||||
// Handle branch selection complete
|
||||
const handleComplete = () => {
|
||||
if (githubToken && githubRepo && selectedBranch) {
|
||||
onComplete({
|
||||
githubToken,
|
||||
githubRepo,
|
||||
mainBranch: selectedBranch
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Connect to GitHub
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auto Claude requires GitHub to manage your code branches and keep tasks up to date.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<GitHubOAuthFlow
|
||||
onSuccess={handleAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'repo':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Repository Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
We couldn't detect a GitHub repository for this project. Please ensure your project has a GitHub remote configured.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">No GitHub remote found</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
To use Auto Claude, your project needs to be connected to a GitHub repository.
|
||||
</p>
|
||||
<div className="text-xs font-mono bg-muted p-2 rounded mt-2">
|
||||
git remote add origin https://github.com/owner/repo.git
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onSkip && (
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={detectRepository} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
'Retry Detection'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'branch':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
Select Base Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which branch Auto Claude should use as the base for creating task branches.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Show detected repo */}
|
||||
{detectedRepo && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Repository:</span>
|
||||
<code className="px-2 py-0.5 bg-muted rounded font-mono text-xs">
|
||||
{detectedRepo}
|
||||
</code>
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Base Branch</Label>
|
||||
<Select
|
||||
value={selectedBranch || ''}
|
||||
onValueChange={setSelectedBranch}
|
||||
disabled={isLoadingBranches || branches.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{isLoadingBranches ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Loading branches...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{branch}</span>
|
||||
{branch === recommendedBranch && (
|
||||
<span className="flex items-center gap-1 text-xs text-success">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All tasks will be created from branches like{' '}
|
||||
<code className="px-1 bg-muted rounded">auto-claude/task-name</code>
|
||||
{selectedBranch && (
|
||||
<> based on <code className="px-1 bg-muted rounded">{selectedBranch}</code></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info about branch selection */}
|
||||
<div className="rounded-lg border border-info/30 bg-info/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-info mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Why select a branch?</p>
|
||||
<p className="mt-1">
|
||||
Auto Claude creates isolated workspaces for each task. Selecting the right base branch ensures
|
||||
your tasks start with the latest code from your main development line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onSkip && (
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!selectedBranch || isLoadingBranches}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Complete Setup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
Setup Complete
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-8 flex flex-col items-center justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Auto Claude is ready to use! You can now create tasks that will be
|
||||
automatically based on <code className="px-1 bg-muted rounded">{selectedBranch}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Progress indicator
|
||||
const renderProgress = () => {
|
||||
const steps: { key: SetupStep; label: string }[] = [
|
||||
{ key: 'auth', label: 'Connect' },
|
||||
{ key: 'branch', label: 'Configure' },
|
||||
];
|
||||
|
||||
// Don't show progress on complete step
|
||||
if (step === 'complete') return null;
|
||||
|
||||
const currentIndex = step === 'auth' ? 0 : step === 'repo' ? 0 : 1;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{steps.map((s, index) => (
|
||||
<div key={s.key} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
index < currentIndex
|
||||
? 'bg-success text-success-foreground'
|
||||
: index === currentIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span className={`ml-2 text-xs ${
|
||||
index === currentIndex ? 'text-foreground font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
{index < steps.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{renderProgress()}
|
||||
{renderStepContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,14 @@ import {
|
||||
switchSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
updateModelConfig,
|
||||
createTaskFromSuggestion,
|
||||
setupInsightsListeners
|
||||
} from '../stores/insights-store';
|
||||
import { loadTasks } from '../stores/task-store';
|
||||
import { ChatHistorySidebar } from './ChatHistorySidebar';
|
||||
import type { InsightsChatMessage } from '../../shared/types';
|
||||
import { InsightsModelSelector } from './InsightsModelSelector';
|
||||
import type { InsightsChatMessage, InsightsModelConfig } from '../../shared/types';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_CATEGORY_COLORS,
|
||||
@@ -141,6 +143,13 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelConfigChange = async (config: InsightsModelConfig) => {
|
||||
// If we have a session, persist the config
|
||||
if (session?.id) {
|
||||
await updateModelConfig(projectId, session.id, config);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = status.phase === 'thinking' || status.phase === 'streaming';
|
||||
const messages = session?.messages || [];
|
||||
|
||||
@@ -187,14 +196,21 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<InsightsModelSelector
|
||||
currentConfig={session?.modelConfig}
|
||||
onConfigChange={handleModelConfigChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Check } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel
|
||||
} from './ui/dropdown-menu';
|
||||
import { DEFAULT_AGENT_PROFILES, AVAILABLE_MODELS } from '../../shared/constants';
|
||||
import type { InsightsModelConfig } from '../../shared/types';
|
||||
import { CustomModelModal } from './CustomModelModal';
|
||||
|
||||
interface InsightsModelSelectorProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onConfigChange: (config: InsightsModelConfig) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap
|
||||
};
|
||||
|
||||
export function InsightsModelSelector({
|
||||
currentConfig,
|
||||
onConfigChange,
|
||||
disabled
|
||||
}: InsightsModelSelectorProps) {
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
|
||||
// Default to 'balanced' if no config
|
||||
const selectedProfileId = currentConfig?.profileId || 'balanced';
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedProfileId);
|
||||
|
||||
// Get the appropriate icon
|
||||
const Icon = selectedProfileId === 'custom'
|
||||
? Sliders
|
||||
: (profile?.icon ? iconMap[profile.icon] : Scale);
|
||||
|
||||
const handleSelectProfile = (profileId: string) => {
|
||||
if (profileId === 'custom') {
|
||||
setShowCustomModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
|
||||
if (selected) {
|
||||
onConfigChange({
|
||||
profileId: selected.id,
|
||||
model: selected.model,
|
||||
thinkingLevel: selected.thinkingLevel
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomSave = (config: InsightsModelConfig) => {
|
||||
onConfigChange(config);
|
||||
setShowCustomModal(false);
|
||||
};
|
||||
|
||||
// Build display text for current selection
|
||||
const getDisplayText = () => {
|
||||
if (selectedProfileId === 'custom' && currentConfig) {
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentConfig.model)?.label || currentConfig.model;
|
||||
return `${modelLabel} + ${currentConfig.thinkingLevel}`;
|
||||
}
|
||||
return profile?.name || 'Balanced';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-2 px-2"
|
||||
disabled={disabled}
|
||||
title={`Model: ${getDisplayText()}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>Agent Profile</DropdownMenuLabel>
|
||||
{DEFAULT_AGENT_PROFILES.map((p) => {
|
||||
const ProfileIcon = iconMap[p.icon || 'Brain'];
|
||||
const isSelected = selectedProfileId === p.id;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === p.model)?.label;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => handleSelectProfile(p.id)}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<ProfileIcon className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{modelLabel} + {p.thinkingLevel}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectProfile('custom')}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Sliders className="h-4 w-4 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Custom...</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Choose model & thinking level
|
||||
</div>
|
||||
</div>
|
||||
{selectedProfileId === 'custom' && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showCustomModal && (
|
||||
<CustomModelModal
|
||||
currentConfig={currentConfig}
|
||||
onSave={handleCustomSave}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -199,12 +199,15 @@ export function Sidebar({
|
||||
const handleInitialize = async () => {
|
||||
if (!pendingProject) return;
|
||||
|
||||
const projectId = pendingProject.id;
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
const result = await initializeProject(pendingProject.id);
|
||||
const result = await initializeProject(projectId);
|
||||
if (result?.success) {
|
||||
setShowInitDialog(false);
|
||||
// Clear pendingProject FIRST before closing dialog
|
||||
// This prevents onOpenChange from triggering skip logic
|
||||
setPendingProject(null);
|
||||
setShowInitDialog(false);
|
||||
}
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
@@ -474,7 +477,12 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
// Only allow closing if user manually closes (not during initialization)
|
||||
if (!open && !isInitializing) {
|
||||
handleSkipInit();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -40,10 +40,12 @@ import {
|
||||
} from './ImageUpload';
|
||||
import { ReferencedFilesSection } from './ReferencedFilesSection';
|
||||
import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer';
|
||||
import { AgentProfileSelector } from './AgentProfileSelector';
|
||||
import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_PRIORITY_LABELS,
|
||||
@@ -53,8 +55,8 @@ import {
|
||||
MAX_REFERENCED_FILES,
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY,
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
|
||||
@@ -73,7 +75,7 @@ export function TaskCreationWizard({
|
||||
const { settings } = useSettingsStore();
|
||||
const selectedProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.id === settings.selectedAgentProfile
|
||||
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'balanced')!;
|
||||
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -97,8 +99,16 @@ export function TaskCreationWizard({
|
||||
const [impact, setImpact] = useState<TaskImpact | ''>('');
|
||||
|
||||
// Model configuration (initialized from selected agent profile)
|
||||
const [profileId, setProfileId] = useState<string>(settings.selectedAgentProfile || 'auto');
|
||||
const [model, setModel] = useState<ModelType | ''>(selectedProfile.model);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel);
|
||||
// Auto profile - per-phase configuration
|
||||
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
|
||||
selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
|
||||
);
|
||||
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
|
||||
selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
|
||||
);
|
||||
|
||||
// Image attachments
|
||||
const [images, setImages] = useState<ImageAttachment[]>([]);
|
||||
@@ -161,9 +171,12 @@ export function TaskCreationWizard({
|
||||
setPriority(draft.priority);
|
||||
setComplexity(draft.complexity);
|
||||
setImpact(draft.impact);
|
||||
// Load model/thinkingLevel from draft if present, otherwise use profile defaults
|
||||
// Load model/thinkingLevel/profileId from draft if present, otherwise use profile defaults
|
||||
setProfileId(draft.profileId || settings.selectedAgentProfile || 'auto');
|
||||
setModel(draft.model || selectedProfile.model);
|
||||
setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel);
|
||||
setPhaseModels(draft.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(draft.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setImages(draft.images);
|
||||
setReferencedFiles(draft.referencedFiles ?? []);
|
||||
setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false);
|
||||
@@ -178,12 +191,15 @@ export function TaskCreationWizard({
|
||||
}
|
||||
// Note: Referenced Files section is always visible, no need to expand
|
||||
} else {
|
||||
// No draft - initialize model/thinkingLevel from selected profile
|
||||
// No draft - initialize from selected profile
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
}
|
||||
}
|
||||
}, [open, projectId, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
}, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
|
||||
/**
|
||||
* Get current form state as a draft
|
||||
@@ -196,13 +212,16 @@ export function TaskCreationWizard({
|
||||
priority,
|
||||
complexity,
|
||||
impact,
|
||||
profileId,
|
||||
model,
|
||||
thinkingLevel,
|
||||
phaseModels,
|
||||
phaseThinking,
|
||||
images,
|
||||
referencedFiles,
|
||||
requireReviewBeforeCoding,
|
||||
savedAt: new Date()
|
||||
}), [projectId, title, description, category, priority, complexity, impact, model, thinkingLevel, images, referencedFiles, requireReviewBeforeCoding]);
|
||||
}), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding]);
|
||||
/**
|
||||
* Handle paste event for screenshot support
|
||||
*/
|
||||
@@ -532,6 +551,12 @@ export function TaskCreationWizard({
|
||||
if (impact) metadata.impact = impact;
|
||||
if (model) metadata.model = model;
|
||||
if (thinkingLevel) metadata.thinkingLevel = thinkingLevel;
|
||||
// Auto profile - per-phase configuration
|
||||
if (profileId === 'auto') {
|
||||
metadata.isAutoProfile = true;
|
||||
if (phaseModels) metadata.phaseModels = phaseModels;
|
||||
if (phaseThinking) metadata.phaseThinking = phaseThinking;
|
||||
}
|
||||
if (images.length > 0) metadata.attachedImages = images;
|
||||
if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles;
|
||||
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
|
||||
@@ -561,9 +586,12 @@ export function TaskCreationWizard({
|
||||
setPriority('');
|
||||
setComplexity('');
|
||||
setImpact('');
|
||||
// Reset model/thinkingLevel to selected profile defaults
|
||||
// Reset to selected profile defaults
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setImages([]);
|
||||
setReferencedFiles([]);
|
||||
setRequireReviewBeforeCoding(false);
|
||||
@@ -765,57 +793,24 @@ export function TaskCreationWizard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model" className="text-sm font-medium text-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => setModel(value as ModelType)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectTrigger id="model" className="h-9">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Claude model to use for this task. Defaults to your selected agent profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thinking-level" className="text-sm font-medium text-foreground">
|
||||
Thinking Level
|
||||
</Label>
|
||||
<Select
|
||||
value={thinkingLevel}
|
||||
onValueChange={(value) => setThinkingLevel(value as ThinkingLevel)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectTrigger id="thinking-level" className="h-9">
|
||||
<SelectValue placeholder="Select thinking level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Extended thinking depth for complex reasoning. Higher levels use more tokens but provide deeper analysis.
|
||||
</p>
|
||||
</div>
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
|
||||
setProfileId(newProfileId);
|
||||
setModel(newModel);
|
||||
setThinkingLevel(newThinkingLevel);
|
||||
}}
|
||||
onModelChange={setModel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
onPhaseModelsChange={setPhaseModels}
|
||||
onPhaseThinkingChange={setPhaseThinking}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
|
||||
@@ -54,17 +54,23 @@ import {
|
||||
isValidImageMimeType,
|
||||
resolveFilename
|
||||
} from './ImageUpload';
|
||||
import { AgentProfileSelector } from './AgentProfileSelector';
|
||||
import { persistUpdateTask } from '../stores/task-store';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types';
|
||||
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_PRIORITY_LABELS,
|
||||
TASK_COMPLEXITY_LABELS,
|
||||
TASK_IMPACT_LABELS,
|
||||
MAX_IMAGES_PER_TASK,
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY,
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
|
||||
/**
|
||||
* Props for the TaskEditDialog component
|
||||
@@ -81,6 +87,12 @@ interface TaskEditDialogProps {
|
||||
}
|
||||
|
||||
export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) {
|
||||
// Get selected agent profile from settings for defaults
|
||||
const { settings } = useSettingsStore();
|
||||
const selectedProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.id === settings.selectedAgentProfile
|
||||
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
|
||||
|
||||
const [title, setTitle] = useState(task.title);
|
||||
const [description, setDescription] = useState(task.description);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -95,6 +107,36 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || '');
|
||||
const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || '');
|
||||
|
||||
// Agent profile / model configuration
|
||||
const [profileId, setProfileId] = useState<string>(() => {
|
||||
// Check if task uses Auto profile
|
||||
if (task.metadata?.isAutoProfile) {
|
||||
return 'auto';
|
||||
}
|
||||
// Determine profile ID from task metadata or default to 'auto'
|
||||
const taskModel = task.metadata?.model;
|
||||
const taskThinking = task.metadata?.thinkingLevel;
|
||||
if (taskModel && taskThinking) {
|
||||
// Check if it matches a known profile
|
||||
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
|
||||
);
|
||||
return matchingProfile?.id || 'custom';
|
||||
}
|
||||
return settings.selectedAgentProfile || 'auto';
|
||||
});
|
||||
const [model, setModel] = useState<ModelType | ''>(task.metadata?.model || selectedProfile.model);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(
|
||||
task.metadata?.thinkingLevel || selectedProfile.thinkingLevel
|
||||
);
|
||||
// Auto profile - per-phase configuration
|
||||
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
|
||||
task.metadata?.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
|
||||
);
|
||||
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
|
||||
task.metadata?.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
|
||||
);
|
||||
|
||||
// Image attachments
|
||||
const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []);
|
||||
|
||||
@@ -118,6 +160,35 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
setPriority(task.metadata?.priority || '');
|
||||
setComplexity(task.metadata?.complexity || '');
|
||||
setImpact(task.metadata?.impact || '');
|
||||
|
||||
// Reset model configuration
|
||||
const taskModel = task.metadata?.model;
|
||||
const taskThinking = task.metadata?.thinkingLevel;
|
||||
const isAutoProfile = task.metadata?.isAutoProfile;
|
||||
|
||||
if (isAutoProfile) {
|
||||
setProfileId('auto');
|
||||
setModel(taskModel || selectedProfile.model);
|
||||
setThinkingLevel(taskThinking || selectedProfile.thinkingLevel);
|
||||
setPhaseModels(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
} else if (taskModel && taskThinking) {
|
||||
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
|
||||
);
|
||||
setProfileId(matchingProfile?.id || 'custom');
|
||||
setModel(taskModel);
|
||||
setThinkingLevel(taskThinking);
|
||||
setPhaseModels(DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(DEFAULT_PHASE_THINKING);
|
||||
} else {
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
}
|
||||
|
||||
setImages(task.metadata?.attachedImages || []);
|
||||
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
|
||||
setError(null);
|
||||
@@ -130,7 +201,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
setShowImages((task.metadata?.attachedImages || []).length > 0);
|
||||
setPasteSuccess(false);
|
||||
}
|
||||
}, [open, task]);
|
||||
}, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
|
||||
/**
|
||||
* Handle paste event for screenshot support
|
||||
@@ -328,6 +399,8 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
priority !== (task.metadata?.priority || '') ||
|
||||
complexity !== (task.metadata?.complexity || '') ||
|
||||
impact !== (task.metadata?.impact || '') ||
|
||||
model !== (task.metadata?.model || '') ||
|
||||
thinkingLevel !== (task.metadata?.thinkingLevel || '') ||
|
||||
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
|
||||
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []);
|
||||
|
||||
@@ -346,6 +419,17 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
if (priority) metadataUpdates.priority = priority;
|
||||
if (complexity) metadataUpdates.complexity = complexity;
|
||||
if (impact) metadataUpdates.impact = impact;
|
||||
if (model) metadataUpdates.model = model as ModelType;
|
||||
if (thinkingLevel) metadataUpdates.thinkingLevel = thinkingLevel as ThinkingLevel;
|
||||
// Auto profile - per-phase configuration
|
||||
if (profileId === 'auto') {
|
||||
metadataUpdates.isAutoProfile = true;
|
||||
if (phaseModels) metadataUpdates.phaseModels = phaseModels;
|
||||
if (phaseThinking) metadataUpdates.phaseThinking = phaseThinking;
|
||||
} else {
|
||||
// Clear auto profile fields if switching away from auto
|
||||
metadataUpdates.isAutoProfile = false;
|
||||
}
|
||||
if (images.length > 0) metadataUpdates.attachedImages = images;
|
||||
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
|
||||
|
||||
@@ -429,6 +513,25 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
|
||||
setProfileId(newProfileId);
|
||||
setModel(newModel);
|
||||
setThinkingLevel(newThinkingLevel);
|
||||
}}
|
||||
onModelChange={setModel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
onPhaseModelsChange={setPhaseModels}
|
||||
onPhaseThinkingChange={setPhaseThinking}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, User, Clock, MessageCircle, Sparkles, CheckCircle2 } from 'lucide-react';
|
||||
import { ExternalLink, User, Clock, MessageCircle, Sparkles, CheckCircle2, Eye } from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
@@ -11,7 +11,17 @@ import {
|
||||
import { formatDate } from '../utils';
|
||||
import type { IssueDetailProps } from '../types';
|
||||
|
||||
export function IssueDetail({ issue, onInvestigate, investigationResult }: IssueDetailProps) {
|
||||
export function IssueDetail({ issue, onInvestigate, investigationResult, linkedTaskId, onViewTask }: IssueDetailProps) {
|
||||
// Determine which task ID to use - either already linked or just created
|
||||
const taskId = linkedTaskId || (investigationResult?.success ? investigationResult.taskId : undefined);
|
||||
const hasLinkedTask = !!taskId;
|
||||
|
||||
const handleViewTask = () => {
|
||||
if (taskId && onViewTask) {
|
||||
onViewTask(taskId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
@@ -77,31 +87,48 @@ export function IssueDetail({ issue, onInvestigate, investigationResult }: Issue
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onInvestigate} className="flex-1">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
{hasLinkedTask ? (
|
||||
<Button onClick={handleViewTask} className="flex-1" variant="secondary">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Task
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onInvestigate} className="flex-1">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Created Result */}
|
||||
{investigationResult?.success && (
|
||||
{/* Task Linked Info */}
|
||||
{hasLinkedTask && (
|
||||
<Card className="bg-success/5 border-success/30">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Task Created
|
||||
Task Linked
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-2">
|
||||
<p className="text-foreground">{investigationResult.analysis.summary}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={GITHUB_COMPLEXITY_COLORS[investigationResult.analysis.estimatedComplexity]}>
|
||||
{investigationResult.analysis.estimatedComplexity}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Task ID: {investigationResult.taskId}
|
||||
</span>
|
||||
</div>
|
||||
{investigationResult?.success ? (
|
||||
<>
|
||||
<p className="text-foreground">{investigationResult.analysis.summary}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={GITHUB_COMPLEXITY_COLORS[investigationResult.analysis.estimatedComplexity]}>
|
||||
{investigationResult.analysis.estimatedComplexity}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Task ID: {taskId}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Task ID: {taskId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ export type FilterState = 'open' | 'closed' | 'all';
|
||||
|
||||
export interface GitHubIssuesProps {
|
||||
onOpenSettings?: () => void;
|
||||
/** Navigate to view a task in the kanban board */
|
||||
onNavigateToTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export interface IssueListItemProps {
|
||||
@@ -17,6 +19,10 @@ export interface IssueDetailProps {
|
||||
issue: GitHubIssue;
|
||||
onInvestigate: () => void;
|
||||
investigationResult: GitHubInvestigationResult | null;
|
||||
/** ID of existing task linked to this issue (from metadata.githubIssueNumber) */
|
||||
linkedTaskId?: string;
|
||||
/** Handler to navigate to view the linked task */
|
||||
onViewTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export interface InvestigationDialogProps {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRoadmapStore, loadRoadmap, generateRoadmap, refreshRoadmap, stopRoadmap } from '../../stores/roadmap-store';
|
||||
import { useTaskStore } from '../../stores/task-store';
|
||||
import type { RoadmapFeature } from '../../../shared/types';
|
||||
|
||||
/**
|
||||
* Hook to manage roadmap data and loading
|
||||
*
|
||||
* When the projectId changes, this hook:
|
||||
* 1. Loads the new project's roadmap data
|
||||
* 2. Queries the backend to check if generation is running for this project
|
||||
* 3. Restores the generation status UI state accordingly
|
||||
*
|
||||
* NOTE: Generation continues in the background when switching projects.
|
||||
* The loadRoadmap function queries the backend to restore the correct UI state.
|
||||
*/
|
||||
export function useRoadmapData(projectId: string) {
|
||||
const roadmap = useRoadmapStore((state) => state.roadmap);
|
||||
@@ -11,6 +20,9 @@ export function useRoadmapData(projectId: string) {
|
||||
const generationStatus = useRoadmapStore((state) => state.generationStatus);
|
||||
|
||||
useEffect(() => {
|
||||
// Load roadmap data and query generation status for this project
|
||||
// The loadRoadmap function handles checking if generation is running
|
||||
// and restores the UI state accordingly
|
||||
loadRoadmap(projectId);
|
||||
}, [projectId]);
|
||||
|
||||
@@ -26,6 +38,7 @@ export function useRoadmapData(projectId: string) {
|
||||
*/
|
||||
export function useFeatureActions() {
|
||||
const updateFeatureLinkedSpec = useRoadmapStore((state) => state.updateFeatureLinkedSpec);
|
||||
const addTask = useTaskStore((state) => state.addTask);
|
||||
|
||||
const convertFeatureToSpec = async (
|
||||
projectId: string,
|
||||
@@ -35,6 +48,10 @@ export function useFeatureActions() {
|
||||
) => {
|
||||
const result = await window.electronAPI.convertFeatureToSpec(projectId, feature.id);
|
||||
if (result.success && result.data) {
|
||||
// Add the created task to the task store so it appears in the kanban immediately
|
||||
addTask(result.data);
|
||||
|
||||
// Update the roadmap feature with the linked spec
|
||||
updateFeatureLinkedSpec(feature.id, result.data.specId);
|
||||
if (selectedFeature?.id === feature.id) {
|
||||
setSelectedFeature({
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
Search,
|
||||
FolderSearch,
|
||||
Wrench,
|
||||
Info
|
||||
Info,
|
||||
Brain,
|
||||
Cpu
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry } from '../../../shared/types';
|
||||
import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry, TaskMetadata } from '../../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig, ThinkingLevel, ModelTypeShort } from '../../../shared/types/settings';
|
||||
|
||||
interface TaskLogsProps {
|
||||
task: Task;
|
||||
@@ -51,6 +54,60 @@ const PHASE_COLORS: Record<TaskLogPhase, string> = {
|
||||
validation: 'text-purple-500 bg-purple-500/10 border-purple-500/30'
|
||||
};
|
||||
|
||||
// Map log phases to config phase keys
|
||||
// Note: 'planning' log phase covers both spec creation and implementation planning
|
||||
const LOG_PHASE_TO_CONFIG_PHASE: Record<TaskLogPhase, keyof PhaseModelConfig> = {
|
||||
planning: 'spec', // Planning log phase primarily shows spec creation
|
||||
coding: 'coding',
|
||||
validation: 'qa'
|
||||
};
|
||||
|
||||
// Short labels for models
|
||||
const MODEL_SHORT_LABELS: Record<ModelTypeShort, string> = {
|
||||
opus: 'Opus',
|
||||
sonnet: 'Sonnet',
|
||||
haiku: 'Haiku'
|
||||
};
|
||||
|
||||
// Short labels for thinking levels
|
||||
const THINKING_SHORT_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: 'None',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra'
|
||||
};
|
||||
|
||||
// Helper to get model and thinking info for a log phase
|
||||
function getPhaseConfig(
|
||||
metadata: TaskMetadata | undefined,
|
||||
logPhase: TaskLogPhase
|
||||
): { model: string; thinking: string } | null {
|
||||
if (!metadata) return null;
|
||||
|
||||
const configPhase = LOG_PHASE_TO_CONFIG_PHASE[logPhase];
|
||||
|
||||
// Auto profile with per-phase config
|
||||
if (metadata.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
|
||||
const model = metadata.phaseModels[configPhase];
|
||||
const thinking = metadata.phaseThinking[configPhase];
|
||||
return {
|
||||
model: MODEL_SHORT_LABELS[model] || model,
|
||||
thinking: THINKING_SHORT_LABELS[thinking] || thinking
|
||||
};
|
||||
}
|
||||
|
||||
// Non-auto profile with single model/thinking
|
||||
if (metadata.model && metadata.thinkingLevel) {
|
||||
return {
|
||||
model: MODEL_SHORT_LABELS[metadata.model] || metadata.model,
|
||||
thinking: THINKING_SHORT_LABELS[metadata.thinkingLevel] || metadata.thinkingLevel
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TaskLogs({
|
||||
task,
|
||||
phaseLogs,
|
||||
@@ -84,6 +141,7 @@ export function TaskLogs({
|
||||
isExpanded={expandedPhases.has(phase)}
|
||||
onToggle={() => onTogglePhase(phase)}
|
||||
isTaskStuck={isStuck}
|
||||
phaseConfig={getPhaseConfig(task.metadata, phase)}
|
||||
/>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
@@ -113,9 +171,10 @@ interface PhaseLogSectionProps {
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
isTaskStuck?: boolean;
|
||||
phaseConfig?: { model: string; thinking: string } | null;
|
||||
}
|
||||
|
||||
function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }: PhaseLogSectionProps) {
|
||||
function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, phaseConfig }: PhaseLogSectionProps) {
|
||||
const Icon = PHASE_ICONS[phase];
|
||||
const status = phaseLog?.status || 'pending';
|
||||
const hasEntries = (phaseLog?.entries.length || 0) > 0;
|
||||
@@ -190,7 +249,23 @@ function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }:
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model and thinking level indicator */}
|
||||
{phaseConfig && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-0.5" title={`Model: ${phaseConfig.model}`}>
|
||||
<Cpu className="h-3 w-3" />
|
||||
<span>{phaseConfig.model}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<div className="flex items-center gap-0.5" title={`Thinking: ${phaseConfig.thinking}`}>
|
||||
<Brain className="h-3 w-3" />
|
||||
<span>{phaseConfig.thinking}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
||||
@@ -48,69 +48,93 @@ export function useIpcListeners(): void {
|
||||
);
|
||||
|
||||
// Roadmap event listeners
|
||||
const setGenerationStatus = useRoadmapStore.getState().setGenerationStatus;
|
||||
const setRoadmap = useRoadmapStore.getState().setRoadmap;
|
||||
// Helper to check if event is for the currently viewed project
|
||||
const isCurrentProject = (eventProjectId: string): boolean => {
|
||||
const currentProjectId = useRoadmapStore.getState().currentProjectId;
|
||||
return currentProjectId === eventProjectId;
|
||||
};
|
||||
|
||||
const cleanupRoadmapProgress = window.electronAPI.onRoadmapProgress(
|
||||
(_projectId: string, status: RoadmapGenerationStatus) => {
|
||||
(projectId: string, status: RoadmapGenerationStatus) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Progress update:', {
|
||||
projectId: _projectId,
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId,
|
||||
phase: status.phase,
|
||||
progress: status.progress,
|
||||
message: status.message
|
||||
});
|
||||
}
|
||||
setGenerationStatus(status);
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setGenerationStatus(status);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupRoadmapComplete = window.electronAPI.onRoadmapComplete(
|
||||
(_projectId: string, roadmap: Roadmap) => {
|
||||
(projectId: string, roadmap: Roadmap) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Generation complete:', {
|
||||
projectId: _projectId,
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId,
|
||||
featuresCount: roadmap.features?.length || 0,
|
||||
phasesCount: roadmap.phases?.length || 0
|
||||
});
|
||||
}
|
||||
setRoadmap(roadmap);
|
||||
setGenerationStatus({
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
message: 'Roadmap ready'
|
||||
});
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setRoadmap(roadmap);
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
message: 'Roadmap ready'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupRoadmapError = window.electronAPI.onRoadmapError(
|
||||
(_projectId: string, error: string) => {
|
||||
(projectId: string, error: string) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.error('[Roadmap] Error received:', { projectId: _projectId, error });
|
||||
console.error('[Roadmap] Error received:', {
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId,
|
||||
error
|
||||
});
|
||||
}
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
message: 'Generation failed',
|
||||
error
|
||||
});
|
||||
}
|
||||
setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
message: 'Generation failed',
|
||||
error
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupRoadmapStopped = window.electronAPI.onRoadmapStopped(
|
||||
(_projectId: string) => {
|
||||
(projectId: string) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Generation stopped:', { projectId: _projectId });
|
||||
console.log('[Roadmap] Generation stopped:', {
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId
|
||||
});
|
||||
}
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: 'Generation stopped'
|
||||
});
|
||||
}
|
||||
setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: 'Generation stopped'
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ const browserMockAPI: ElectronAPI = {
|
||||
data: null
|
||||
}),
|
||||
|
||||
getRoadmapStatus: async () => ({
|
||||
success: true,
|
||||
data: { isRunning: false }
|
||||
}),
|
||||
|
||||
saveRoadmap: async () => ({
|
||||
success: true
|
||||
}),
|
||||
|
||||
@@ -78,6 +78,11 @@ export const insightsMock = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateInsightsModelConfig: async (_projectId: string, _sessionId: string, _modelConfig: unknown) => {
|
||||
console.warn('[Browser Mock] updateInsightsModelConfig called');
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
sendInsightsMessage: () => {
|
||||
console.warn('[Browser Mock] sendInsightsMessage called');
|
||||
},
|
||||
|
||||
@@ -178,5 +178,15 @@ export const integrationMock = {
|
||||
{ fullName: 'user/private-repo', description: 'A private repository', isPrivate: true }
|
||||
]
|
||||
}
|
||||
}),
|
||||
|
||||
detectGitHubRepo: async () => ({
|
||||
success: true,
|
||||
data: 'user/example-repo'
|
||||
}),
|
||||
|
||||
getGitHubBranches: async () => ({
|
||||
success: true,
|
||||
data: ['main', 'develop', 'feature/example']
|
||||
})
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsToolUsage,
|
||||
InsightsModelConfig,
|
||||
TaskMetadata,
|
||||
Task
|
||||
} from '../../shared/types';
|
||||
@@ -221,8 +222,9 @@ export async function loadInsightsSession(projectId: string): Promise<void> {
|
||||
await loadInsightsSessions(projectId);
|
||||
}
|
||||
|
||||
export function sendMessage(projectId: string, message: string): void {
|
||||
export function sendMessage(projectId: string, message: string, modelConfig?: InsightsModelConfig): void {
|
||||
const store = useInsightsStore.getState();
|
||||
const session = store.session;
|
||||
|
||||
// Add user message to session
|
||||
const userMessage: InsightsChatMessage = {
|
||||
@@ -242,8 +244,11 @@ export function sendMessage(projectId: string, message: string): void {
|
||||
message: 'Processing your message...'
|
||||
});
|
||||
|
||||
// Use provided modelConfig, or fall back to session's config
|
||||
const configToUse = modelConfig || session?.modelConfig;
|
||||
|
||||
// Send to main process
|
||||
window.electronAPI.sendInsightsMessage(projectId, message);
|
||||
window.electronAPI.sendInsightsMessage(projectId, message, configToUse);
|
||||
}
|
||||
|
||||
export async function clearSession(projectId: string): Promise<void> {
|
||||
@@ -296,6 +301,25 @@ export async function renameSession(projectId: string, sessionId: string, newTit
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updateModelConfig(projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<boolean> {
|
||||
const result = await window.electronAPI.updateInsightsModelConfig(projectId, sessionId, modelConfig);
|
||||
if (result.success) {
|
||||
// Update local session state
|
||||
const store = useInsightsStore.getState();
|
||||
if (store.session?.id === sessionId) {
|
||||
store.setSession({
|
||||
...store.session,
|
||||
modelConfig,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
// Reload sessions list to reflect the change
|
||||
await loadInsightsSessions(projectId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createTaskFromSuggestion(
|
||||
projectId: string,
|
||||
title: string,
|
||||
|
||||
@@ -12,11 +12,13 @@ interface RoadmapState {
|
||||
roadmap: Roadmap | null;
|
||||
competitorAnalysis: CompetitorAnalysis | null;
|
||||
generationStatus: RoadmapGenerationStatus;
|
||||
currentProjectId: string | null; // Track which project we're viewing/generating for
|
||||
|
||||
// Actions
|
||||
setRoadmap: (roadmap: Roadmap | null) => void;
|
||||
setCompetitorAnalysis: (analysis: CompetitorAnalysis | null) => void;
|
||||
setGenerationStatus: (status: RoadmapGenerationStatus) => void;
|
||||
setCurrentProjectId: (projectId: string | null) => void;
|
||||
updateFeatureStatus: (featureId: string, status: RoadmapFeatureStatus) => void;
|
||||
updateFeatureLinkedSpec: (featureId: string, specId: string) => void;
|
||||
clearRoadmap: () => void;
|
||||
@@ -37,6 +39,7 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
roadmap: null,
|
||||
competitorAnalysis: null,
|
||||
generationStatus: initialGenerationStatus,
|
||||
currentProjectId: null,
|
||||
|
||||
// Actions
|
||||
setRoadmap: (roadmap) => set({ roadmap }),
|
||||
@@ -45,6 +48,8 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
|
||||
setGenerationStatus: (status) => set({ generationStatus: status }),
|
||||
|
||||
setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }),
|
||||
|
||||
updateFeatureStatus: (featureId, status) =>
|
||||
set((state) => {
|
||||
if (!state.roadmap) return state;
|
||||
@@ -85,7 +90,8 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
set({
|
||||
roadmap: null,
|
||||
competitorAnalysis: null,
|
||||
generationStatus: initialGenerationStatus
|
||||
generationStatus: initialGenerationStatus,
|
||||
currentProjectId: null
|
||||
}),
|
||||
|
||||
// Reorder features within a phase
|
||||
@@ -159,9 +165,34 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
|
||||
// Helper functions for loading roadmap
|
||||
export async function loadRoadmap(projectId: string): Promise<void> {
|
||||
const store = useRoadmapStore.getState();
|
||||
|
||||
// Always set current project ID first - this ensures event handlers
|
||||
// only process events for the currently viewed project
|
||||
store.setCurrentProjectId(projectId);
|
||||
|
||||
// Query if roadmap generation is currently running for this project
|
||||
// This restores the generation status when switching back to a project
|
||||
const statusResult = await window.electronAPI.getRoadmapStatus(projectId);
|
||||
if (statusResult.success && statusResult.data?.isRunning) {
|
||||
// Generation is running - restore the UI state to show progress
|
||||
// The actual progress will be updated by incoming events
|
||||
store.setGenerationStatus({
|
||||
phase: 'analyzing',
|
||||
progress: 0,
|
||||
message: 'Roadmap generation in progress...'
|
||||
});
|
||||
} else {
|
||||
// Generation is not running - reset to idle
|
||||
store.setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.getRoadmap(projectId);
|
||||
if (result.success && result.data) {
|
||||
const store = useRoadmapStore.getState();
|
||||
store.setRoadmap(result.data);
|
||||
// Extract and set competitor analysis separately if present
|
||||
if (result.data.competitorAnalysis) {
|
||||
@@ -170,7 +201,6 @@ export async function loadRoadmap(projectId: string): Promise<void> {
|
||||
store.setCompetitorAnalysis(null);
|
||||
}
|
||||
} else {
|
||||
const store = useRoadmapStore.getState();
|
||||
store.setRoadmap(null);
|
||||
store.setCompetitorAnalysis(null);
|
||||
}
|
||||
|
||||
@@ -514,6 +514,19 @@ export function isDraftEmpty(draft: TaskDraft | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GitHub Issue Linking Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find a task by GitHub issue number
|
||||
* Used to check if a task already exists for a GitHub issue
|
||||
*/
|
||||
export function getTaskByGitHubIssue(issueNumber: number): Task | undefined {
|
||||
const store = useTaskStore.getState();
|
||||
return store.tasks.find(t => t.metadata?.githubIssueNumber === issueNumber);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Task State Detection Helpers
|
||||
// ============================================
|
||||
|
||||
@@ -25,8 +25,8 @@ export const DEFAULT_APP_SETTINGS = {
|
||||
// Global API keys (used as defaults for all projects)
|
||||
globalClaudeOAuthToken: undefined as string | undefined,
|
||||
globalOpenAIApiKey: undefined as string | undefined,
|
||||
// Selected agent profile - defaults to 'balanced' for good speed/quality balance
|
||||
selectedAgentProfile: 'balanced',
|
||||
// Selected agent profile - defaults to 'auto' for per-phase optimized model selection
|
||||
selectedAgentProfile: 'auto',
|
||||
// Changelog preferences (persisted between sessions)
|
||||
changelogFormat: 'keep-a-changelog' as const,
|
||||
changelogAudience: 'user-facing' as const,
|
||||
|
||||
@@ -116,6 +116,7 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// Roadmap operations
|
||||
ROADMAP_GET: 'roadmap:get',
|
||||
ROADMAP_GET_STATUS: 'roadmap:getStatus',
|
||||
ROADMAP_SAVE: 'roadmap:save',
|
||||
ROADMAP_GENERATE: 'roadmap:generate',
|
||||
ROADMAP_GENERATE_WITH_COMPETITOR: 'roadmap:generateWithCompetitor',
|
||||
@@ -189,6 +190,8 @@ export const IPC_CHANNELS = {
|
||||
GITHUB_GET_TOKEN: 'github:getToken',
|
||||
GITHUB_GET_USER: 'github:getUser',
|
||||
GITHUB_LIST_USER_REPOS: 'github:listUserRepos',
|
||||
GITHUB_DETECT_REPO: 'github:detectRepo',
|
||||
GITHUB_GET_BRANCHES: 'github:getBranches',
|
||||
|
||||
// GitHub events (main -> renderer)
|
||||
GITHUB_INVESTIGATION_PROGRESS: 'github:investigationProgress',
|
||||
@@ -248,6 +251,7 @@ export const IPC_CHANNELS = {
|
||||
INSIGHTS_SWITCH_SESSION: 'insights:switchSession',
|
||||
INSIGHTS_DELETE_SESSION: 'insights:deleteSession',
|
||||
INSIGHTS_RENAME_SESSION: 'insights:renameSession',
|
||||
INSIGHTS_UPDATE_MODEL_CONFIG: 'insights:updateModelConfig',
|
||||
|
||||
// Insights events (main -> renderer)
|
||||
INSIGHTS_STREAM_CHUNK: 'insights:streamChunk',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Claude models, thinking levels, memory backends, and agent profiles
|
||||
*/
|
||||
|
||||
import type { AgentProfile } from '../types/settings';
|
||||
import type { AgentProfile, PhaseModelConfig } from '../types/settings';
|
||||
|
||||
// ============================================
|
||||
// Available Models
|
||||
@@ -15,6 +15,22 @@ export const AVAILABLE_MODELS = [
|
||||
{ value: 'haiku', label: 'Claude Haiku 4.5' }
|
||||
] as const;
|
||||
|
||||
// Maps model shorthand to actual Claude model IDs
|
||||
export const MODEL_ID_MAP: Record<string, string> = {
|
||||
opus: 'claude-opus-4-5-20251101',
|
||||
sonnet: 'claude-sonnet-4-5-20250929',
|
||||
haiku: 'claude-haiku-4-5-20251001'
|
||||
} as const;
|
||||
|
||||
// Maps thinking levels to budget tokens (null = no extended thinking)
|
||||
export const THINKING_BUDGET_MAP: Record<string, number | null> = {
|
||||
none: null,
|
||||
low: 1024,
|
||||
medium: 4096,
|
||||
high: 16384,
|
||||
ultrathink: 65536
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Thinking Levels
|
||||
// ============================================
|
||||
@@ -32,8 +48,36 @@ export const THINKING_LEVELS = [
|
||||
// Agent Profiles
|
||||
// ============================================
|
||||
|
||||
// Default phase model configuration for Auto profile
|
||||
// Optimized for each phase: fast discovery, quality planning, balanced coding, thorough QA
|
||||
export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
|
||||
spec: 'sonnet', // Good quality specs without being too slow
|
||||
planning: 'opus', // Complex architecture decisions benefit from Opus
|
||||
coding: 'sonnet', // Good balance of speed and quality for implementation
|
||||
qa: 'sonnet' // Thorough but not overly slow QA
|
||||
};
|
||||
|
||||
// Default phase thinking configuration for Auto profile
|
||||
export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = {
|
||||
spec: 'medium', // Moderate thinking for spec creation
|
||||
planning: 'high', // Deep thinking for planning complex features
|
||||
coding: 'medium', // Standard thinking for coding
|
||||
qa: 'high' // Thorough analysis for QA review
|
||||
};
|
||||
|
||||
// Default agent profiles for preset model/thinking configurations
|
||||
export const DEFAULT_AGENT_PROFILES: AgentProfile[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Auto (Optimized)',
|
||||
description: 'Uses different models per phase for optimal speed & quality',
|
||||
model: 'sonnet', // Fallback/default model
|
||||
thinkingLevel: 'medium',
|
||||
icon: 'Sparkles',
|
||||
isAutoProfile: true,
|
||||
phaseModels: DEFAULT_PHASE_MODELS,
|
||||
phaseThinking: DEFAULT_PHASE_THINKING
|
||||
},
|
||||
{
|
||||
id: 'complex',
|
||||
name: 'Complex Tasks',
|
||||
|
||||
@@ -154,6 +154,16 @@ export interface IdeationSummary {
|
||||
// Insights Chat Types
|
||||
// ============================================
|
||||
|
||||
import type { ThinkingLevel } from './settings';
|
||||
import type { ModelType } from './task';
|
||||
|
||||
// Model configuration for insights sessions
|
||||
export interface InsightsModelConfig {
|
||||
profileId: string; // 'complex' | 'balanced' | 'quick' | 'custom'
|
||||
model: ModelType; // 'haiku' | 'sonnet' | 'opus'
|
||||
thinkingLevel: ThinkingLevel;
|
||||
}
|
||||
|
||||
export type InsightsChatRole = 'user' | 'assistant';
|
||||
|
||||
// Tool usage record for showing what tools the AI used
|
||||
@@ -183,6 +193,7 @@ export interface InsightsSession {
|
||||
projectId: string;
|
||||
title?: string; // Auto-generated from first message or user-set
|
||||
messages: InsightsChatMessage[];
|
||||
modelConfig?: InsightsModelConfig; // Per-session model configuration
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -193,6 +204,7 @@ export interface InsightsSessionSummary {
|
||||
projectId: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
modelConfig?: InsightsModelConfig; // For displaying model indicator in sidebar
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ import type {
|
||||
InsightsSession,
|
||||
InsightsSessionSummary,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk
|
||||
InsightsStreamChunk,
|
||||
InsightsModelConfig
|
||||
} from './insights';
|
||||
import type {
|
||||
Roadmap,
|
||||
@@ -236,6 +237,7 @@ export interface ElectronAPI {
|
||||
|
||||
// Roadmap operations
|
||||
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
|
||||
getRoadmapStatus: (projectId: string) => Promise<IPCResult<{ isRunning: boolean }>>;
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
@@ -321,6 +323,8 @@ export interface ElectronAPI {
|
||||
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
|
||||
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
|
||||
|
||||
// GitHub event listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
@@ -461,7 +465,7 @@ export interface ElectronAPI {
|
||||
|
||||
// Insights operations
|
||||
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
sendInsightsMessage: (projectId: string, message: string) => void;
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig) => void;
|
||||
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
@@ -474,6 +478,7 @@ export interface ElectronAPI {
|
||||
switchInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
deleteInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult>;
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise<IPCResult>;
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise<IPCResult>;
|
||||
|
||||
// Insights event listeners
|
||||
onInsightsStreamChunk: (
|
||||
|
||||
@@ -8,14 +8,38 @@ import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './
|
||||
// Thinking level for Claude model (budget token allocation)
|
||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
|
||||
// Model type shorthand
|
||||
export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus';
|
||||
|
||||
// Phase-based model configuration for Auto profile
|
||||
// Each phase can use a different model optimized for that task type
|
||||
export interface PhaseModelConfig {
|
||||
spec: ModelTypeShort; // Spec creation (discovery, requirements, context)
|
||||
planning: ModelTypeShort; // Implementation planning
|
||||
coding: ModelTypeShort; // Actual coding implementation
|
||||
qa: ModelTypeShort; // QA review and fixing
|
||||
}
|
||||
|
||||
// Thinking level configuration per phase
|
||||
export interface PhaseThinkingConfig {
|
||||
spec: ThinkingLevel;
|
||||
planning: ThinkingLevel;
|
||||
coding: ThinkingLevel;
|
||||
qa: ThinkingLevel;
|
||||
}
|
||||
|
||||
// Agent profile for preset model/thinking configurations
|
||||
export interface AgentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
model: 'haiku' | 'sonnet' | 'opus';
|
||||
model: ModelTypeShort;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
icon?: string; // Lucide icon name
|
||||
// Auto profile specific - per-phase configuration
|
||||
isAutoProfile?: boolean;
|
||||
phaseModels?: PhaseModelConfig;
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Task-related types
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from './settings';
|
||||
import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings';
|
||||
|
||||
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'done';
|
||||
|
||||
@@ -136,8 +136,12 @@ export interface TaskDraft {
|
||||
priority: TaskPriority | '';
|
||||
complexity: TaskComplexity | '';
|
||||
impact: TaskImpact | '';
|
||||
profileId?: string; // Agent profile ID ('auto', 'complex', 'balanced', 'quick', 'custom')
|
||||
model: ModelType | '';
|
||||
thinkingLevel: ThinkingLevel | '';
|
||||
// Auto profile - per-phase configuration
|
||||
phaseModels?: PhaseModelConfig;
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
images: ImageAttachment[];
|
||||
referencedFiles: ReferencedFile[];
|
||||
requireReviewBeforeCoding?: boolean;
|
||||
@@ -209,8 +213,12 @@ export interface TaskMetadata {
|
||||
requireReviewBeforeCoding?: boolean; // Require human review of spec/plan before coding starts
|
||||
|
||||
// Agent configuration (from agent profile or manual selection)
|
||||
model?: ModelType; // Claude model to use (haiku, sonnet, opus)
|
||||
model?: ModelType; // Claude model to use (haiku, sonnet, opus) - used when not auto profile
|
||||
thinkingLevel?: ThinkingLevel; // Thinking budget level (none, low, medium, high, ultrathink)
|
||||
// Auto profile - per-phase model configuration
|
||||
isAutoProfile?: boolean; // True when using Auto (Optimized) profile
|
||||
phaseModels?: PhaseModelConfig; // Per-phase model configuration
|
||||
phaseThinking?: PhaseThinkingConfig; // Per-phase thinking configuration
|
||||
|
||||
// Archive status
|
||||
archivedAt?: string; // ISO date when task was archived
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
# Auto Claude Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Claude Code OAuth Token (REQUIRED)
|
||||
# Get this by running: claude setup-token
|
||||
CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
# =============================================================================
|
||||
# AUTHENTICATION (REQUIRED - set ONE of the following)
|
||||
# =============================================================================
|
||||
# The framework checks these in order of priority:
|
||||
# 1. CLAUDE_CODE_OAUTH_TOKEN - Original (from `claude setup-token`)
|
||||
# 2. ANTHROPIC_AUTH_TOKEN - For proxies like CCR
|
||||
# 3. ANTHROPIC_API_KEY - Direct Anthropic API key
|
||||
#
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-zcf-x-ccr
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# =============================================================================
|
||||
# CUSTOM API ENDPOINT (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Override the default Anthropic API endpoint. Useful for:
|
||||
# - Local proxies (ccr, litellm)
|
||||
# - API gateways
|
||||
# - Self-hosted Claude instances
|
||||
#
|
||||
# ANTHROPIC_BASE_URL=http://127.0.0.1:3456
|
||||
#
|
||||
# Related settings (usually set together with ANTHROPIC_BASE_URL):
|
||||
# NO_PROXY=127.0.0.1
|
||||
# DISABLE_TELEMETRY=true
|
||||
# DISABLE_COST_WARNINGS=true
|
||||
# API_TIMEOUT_MS=600000
|
||||
|
||||
# Model override (OPTIONAL)
|
||||
# Default: claude-opus-4-5-20251101
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from client import create_client
|
||||
from core.client import create_client
|
||||
from linear_updater import (
|
||||
LinearTaskState,
|
||||
is_linear_enabled,
|
||||
@@ -17,6 +17,7 @@ from linear_updater import (
|
||||
linear_task_started,
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
count_subtasks_detailed,
|
||||
@@ -244,8 +245,19 @@ async def run_autonomous_agent(
|
||||
commit_before = get_latest_commit(project_dir)
|
||||
commit_count_before = get_commit_count(project_dir)
|
||||
|
||||
# Create client (fresh context)
|
||||
client = create_client(project_dir, spec_dir, model)
|
||||
# Get the phase-specific model (respects task_metadata.json configuration)
|
||||
# first_run means we're in planning phase, otherwise coding phase
|
||||
current_phase = "planning" if first_run else "coding"
|
||||
phase_model = get_phase_model(spec_dir, current_phase, model)
|
||||
|
||||
# Create client (fresh context) with phase-specific model
|
||||
# Coding phase uses no extended thinking for fast iteration
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
max_thinking_tokens=None, # No extended thinking for coding
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
if first_run:
|
||||
|
||||
@@ -8,7 +8,7 @@ Handles follow-up planner sessions for adding new subtasks to completed specs.
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from client import create_client
|
||||
from core.client import create_client
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from insight_extractor import extract_session_insights
|
||||
from linear_updater import (
|
||||
linear_subtask_completed,
|
||||
@@ -332,20 +333,40 @@ async def run_agent_session(
|
||||
- "complete" if all subtasks complete
|
||||
- "error" if an error occurred
|
||||
"""
|
||||
debug_section("session", f"Agent Session - {phase.value}")
|
||||
debug(
|
||||
"session",
|
||||
"Starting agent session",
|
||||
spec_dir=str(spec_dir),
|
||||
phase=phase.value,
|
||||
prompt_length=len(message),
|
||||
prompt_preview=message[:200] + "..." if len(message) > 200 else message,
|
||||
)
|
||||
print("Sending prompt to Claude Agent SDK...\n")
|
||||
|
||||
# Get task logger for this spec
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
current_tool = None
|
||||
message_count = 0
|
||||
tool_count = 0
|
||||
|
||||
try:
|
||||
# Send the query
|
||||
debug("session", "Sending query to Claude SDK...")
|
||||
await client.query(message)
|
||||
debug_success("session", "Query sent successfully")
|
||||
|
||||
# Collect response text and show tool use
|
||||
response_text = ""
|
||||
debug("session", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
"session",
|
||||
f"Received message #{message_count}",
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
# Handle AssistantMessage (text and tool use)
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
@@ -366,6 +387,7 @@ async def run_agent_session(
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if hasattr(block, "input") and block.input:
|
||||
@@ -386,6 +408,15 @@ async def run_agent_session(
|
||||
elif "path" in inp:
|
||||
tool_input = inp["path"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input,
|
||||
full_input=str(block.input)[:500]
|
||||
if hasattr(block, "input")
|
||||
else None,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing too)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
@@ -413,6 +444,11 @@ async def run_agent_session(
|
||||
|
||||
# Check if command was blocked by security hook
|
||||
if "blocked" in str(result_content).lower():
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
result=str(result_content)[:300],
|
||||
)
|
||||
print(f" [BLOCKED] {result_content}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
task_logger.tool_end(
|
||||
@@ -425,6 +461,11 @@ async def run_agent_session(
|
||||
elif is_error:
|
||||
# Show errors (truncated)
|
||||
error_str = str(result_content)[:500]
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool error: {current_tool}",
|
||||
error=error_str[:200],
|
||||
)
|
||||
print(f" [Error] {error_str}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
# Store full error in detail for expandable view
|
||||
@@ -437,6 +478,11 @@ async def run_agent_session(
|
||||
)
|
||||
else:
|
||||
# Tool succeeded
|
||||
debug_detailed(
|
||||
"session",
|
||||
f"Tool success: {current_tool}",
|
||||
result_length=len(str(result_content)),
|
||||
)
|
||||
if verbose:
|
||||
result_str = str(result_content)[:200]
|
||||
print(f" [Done] {result_str}", flush=True)
|
||||
@@ -472,11 +518,32 @@ async def run_agent_session(
|
||||
|
||||
# Check if build is complete
|
||||
if is_build_complete(spec_dir):
|
||||
debug_success(
|
||||
"session",
|
||||
"Session completed - build is complete",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "complete", response_text
|
||||
|
||||
debug_success(
|
||||
"session",
|
||||
"Session completed - continuing",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "continue", response_text
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
"session",
|
||||
f"Session error: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
print(f"Error during agent session: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
|
||||
@@ -28,6 +28,8 @@ except ImportError:
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
# Default model for insight extraction (fast and cheap)
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
|
||||
|
||||
@@ -40,10 +42,10 @@ MAX_ATTEMPTS_TO_INCLUDE = 3
|
||||
|
||||
def is_extraction_enabled() -> bool:
|
||||
"""Check if insight extraction is enabled."""
|
||||
# Extraction requires Claude SDK and OAuth token
|
||||
# Extraction requires Claude SDK and authentication token
|
||||
if not SDK_AVAILABLE:
|
||||
return False
|
||||
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
if not get_auth_token():
|
||||
return False
|
||||
enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower()
|
||||
return enabled_str in ("true", "1", "yes")
|
||||
@@ -348,11 +350,13 @@ async def run_insight_extraction(
|
||||
logger.warning("Claude SDK not available, skipping insight extraction")
|
||||
return None
|
||||
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
logger.warning("CLAUDE_CODE_OAUTH_TOKEN not set, skipping insight extraction")
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found, skipping insight extraction")
|
||||
return None
|
||||
|
||||
# Ensure SDK can find the token
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
model = get_extraction_model()
|
||||
prompt = _build_extraction_prompt(inputs)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def handle_build_command(
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use
|
||||
model: Model to use (used as default; may be overridden by task_metadata.json)
|
||||
max_iterations: Maximum number of iterations (None for unlimited)
|
||||
verbose: Enable verbose output
|
||||
force_isolated: Force isolated workspace mode
|
||||
@@ -86,14 +86,29 @@ def handle_build_command(
|
||||
debug_section,
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
# Get the resolved model for the planning phase (first phase of build)
|
||||
# This respects task_metadata.json phase configuration from the UI
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
coding_model = get_phase_model(spec_dir, "coding", model)
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
|
||||
print_banner()
|
||||
print(f"\nProject directory: {project_dir}")
|
||||
print(f"Spec: {spec_dir.name}")
|
||||
print(f"Model: {model}")
|
||||
# Show phase-specific models if they differ
|
||||
if planning_model != coding_model or coding_model != qa_model:
|
||||
print(
|
||||
f"Models: Planning={planning_model.split('-')[1] if '-' in planning_model else planning_model}, "
|
||||
f"Coding={coding_model.split('-')[1] if '-' in coding_model else coding_model}, "
|
||||
f"QA={qa_model.split('-')[1] if '-' in qa_model else qa_model}"
|
||||
)
|
||||
else:
|
||||
print(f"Model: {planning_model}")
|
||||
|
||||
if max_iterations:
|
||||
print(f"Max iterations: {max_iterations}")
|
||||
|
||||
@@ -14,6 +14,7 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.auth import AUTH_TOKEN_ENV_VARS, get_auth_token, get_auth_token_source
|
||||
from dotenv import load_dotenv
|
||||
from graphiti_config import get_graphiti_status
|
||||
from linear_integration import LinearManager
|
||||
@@ -95,14 +96,25 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
"""
|
||||
valid = True
|
||||
|
||||
# Check for Claude Code OAuth token
|
||||
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
print("Error: CLAUDE_CODE_OAUTH_TOKEN environment variable not set")
|
||||
print("\nGet your OAuth token by running:")
|
||||
# Check for authentication token (supports multiple env vars)
|
||||
if not get_auth_token():
|
||||
print("Error: No authentication token found")
|
||||
print(f"\nSet one of: {', '.join(AUTH_TOKEN_ENV_VARS)}")
|
||||
print("\nFor Claude Code CLI, get your OAuth token by running:")
|
||||
print(" claude setup-token")
|
||||
print("\nThen set it:")
|
||||
print(" export CLAUDE_CODE_OAUTH_TOKEN='your-token-here'")
|
||||
valid = False
|
||||
else:
|
||||
# Show which auth source is being used
|
||||
source = get_auth_token_source()
|
||||
if source and source != "CLAUDE_CODE_OAUTH_TOKEN":
|
||||
print(f"Auth: Using token from {source}")
|
||||
|
||||
# Show custom base URL if set
|
||||
base_url = os.environ.get("ANTHROPIC_BASE_URL")
|
||||
if base_url:
|
||||
print(f"API Endpoint: {base_url}")
|
||||
|
||||
# Check for spec.md in spec directory
|
||||
spec_file = spec_dir / "spec.md"
|
||||
|
||||
@@ -5,6 +5,7 @@ Workspace Commands
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +30,106 @@ from workspace import (
|
||||
|
||||
from .utils import print_banner
|
||||
|
||||
|
||||
def _detect_default_branch(project_dir: Path) -> str:
|
||||
"""
|
||||
Detect the default branch for the repository.
|
||||
|
||||
This matches the logic in WorktreeManager._detect_base_branch() to ensure
|
||||
we compare against the same branch that worktrees are created from.
|
||||
|
||||
Priority order:
|
||||
1. DEFAULT_BRANCH environment variable
|
||||
2. Auto-detect main/master (if they exist)
|
||||
3. Fall back to "main" as final default
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
The detected default branch name
|
||||
"""
|
||||
import os
|
||||
|
||||
# 1. Check for DEFAULT_BRANCH env var
|
||||
env_branch = os.getenv("DEFAULT_BRANCH")
|
||||
if env_branch:
|
||||
# Verify the branch exists
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", env_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
|
||||
# 2. Auto-detect main/master
|
||||
for branch in ["main", "master"]:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
|
||||
# 3. Fall back to "main" as final default
|
||||
return "main"
|
||||
|
||||
|
||||
def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try without the three-dot notation
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
@@ -381,6 +482,18 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
# Detect the default branch (main/master) that worktrees are created from
|
||||
# Note: git_conflicts["base_branch"] is the current project branch, not the
|
||||
# worktree base, so we detect the default branch separately
|
||||
default_branch = _detect_default_branch(project_dir)
|
||||
all_changed_files = _get_changed_files_from_git(worktree_path, default_branch)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Git diff against '{default_branch}' shows {len(all_changed_files)} changed files",
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
# Initialize the orchestrator
|
||||
@@ -456,9 +569,15 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
||||
]
|
||||
|
||||
# Use git diff file count as the authoritative totalFiles count
|
||||
# The semantic tracker may not track all files (e.g., test files, config files)
|
||||
# but we want to show the user all files that will be merged
|
||||
total_files_from_git = len(all_changed_files)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"files": preview.get("files_to_merge", []),
|
||||
# Use git diff files as the authoritative list of files to merge
|
||||
"files": all_changed_files,
|
||||
"conflicts": conflicts,
|
||||
"gitConflicts": {
|
||||
"hasConflicts": git_conflicts["has_conflicts"]
|
||||
@@ -470,7 +589,8 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
"specBranch": git_conflicts["spec_branch"],
|
||||
},
|
||||
"summary": {
|
||||
"totalFiles": summary.get("total_files", 0),
|
||||
# Use git diff count, not semantic tracker count
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
@@ -485,6 +605,8 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
MODULE,
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Authentication helpers for Auto Claude.
|
||||
|
||||
Provides centralized authentication token resolution with fallback support
|
||||
for multiple environment variables, and SDK environment variable passthrough
|
||||
for custom API endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Priority order for auth token resolution
|
||||
AUTH_TOKEN_ENV_VARS = [
|
||||
"CLAUDE_CODE_OAUTH_TOKEN", # Original (highest priority)
|
||||
"ANTHROPIC_AUTH_TOKEN", # CCR/proxy token
|
||||
"ANTHROPIC_API_KEY", # Direct API key (lowest priority)
|
||||
]
|
||||
|
||||
# Environment variables to pass through to SDK subprocess
|
||||
SDK_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"NO_PROXY",
|
||||
"DISABLE_TELEMETRY",
|
||||
"DISABLE_COST_WARNINGS",
|
||||
"API_TIMEOUT_MS",
|
||||
]
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables.
|
||||
|
||||
Checks multiple env vars in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (original)
|
||||
2. ANTHROPIC_AUTH_TOKEN (ccr/proxy)
|
||||
3. ANTHROPIC_API_KEY (direct API key)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token_source() -> str | None:
|
||||
"""Get the name of the env var that provided the auth token."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
return None
|
||||
|
||||
|
||||
def require_auth_token() -> str:
|
||||
"""
|
||||
Get authentication token or raise ValueError.
|
||||
|
||||
Raises:
|
||||
ValueError: If no auth token is found in any supported env var
|
||||
"""
|
||||
token = get_auth_token()
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"No authentication token found.\n"
|
||||
f"Set one of: {', '.join(AUTH_TOKEN_ENV_VARS)}\n"
|
||||
"For Claude Code CLI: run 'claude setup-token'"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
|
||||
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
|
||||
be passed through to the claude-agent-sdk subprocess.
|
||||
|
||||
Returns:
|
||||
Dict of env var name -> value for non-empty vars
|
||||
"""
|
||||
env = {}
|
||||
for var in SDK_ENV_VARS:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
env[var] = value
|
||||
return env
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility).
|
||||
|
||||
If not set but other auth tokens are available, copies the value
|
||||
to CLAUDE_CODE_OAUTH_TOKEN so the underlying SDK can use it.
|
||||
"""
|
||||
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
return
|
||||
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
|
||||
@@ -18,6 +18,7 @@ from auto_claude_tools import (
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from linear_updater import is_linear_enabled
|
||||
from security import bash_security_hook
|
||||
|
||||
@@ -131,6 +132,7 @@ def create_client(
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
agent_type: str = "coder",
|
||||
max_thinking_tokens: int | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
@@ -141,6 +143,11 @@ def create_client(
|
||||
model: Claude model to use
|
||||
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
|
||||
This determines which custom auto-claude tools are available.
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (coding)
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
@@ -152,12 +159,12 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
raise ValueError(
|
||||
"CLAUDE_CODE_OAUTH_TOKEN environment variable not set.\n"
|
||||
"Get your token by running: claude setup-token"
|
||||
)
|
||||
oauth_token = require_auth_token()
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
@@ -236,6 +243,10 @@ def create_client(
|
||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"]
|
||||
if linear_enabled:
|
||||
@@ -318,5 +329,7 @@ def create_client(
|
||||
max_turns=1000,
|
||||
cwd=str(project_dir.resolve()),
|
||||
settings=str(settings_file.resolve()),
|
||||
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
max_thinking_tokens=max_thinking_tokens, # Extended thinking budget
|
||||
)
|
||||
)
|
||||
|
||||
@@ -164,6 +164,35 @@ def merge_existing_build(
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name}"))
|
||||
return False
|
||||
|
||||
# Detect current branch - this is where user wants changes merged
|
||||
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
|
||||
# and wants to merge the spec changes into it, then PR to main
|
||||
current_branch_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
current_branch = (
|
||||
current_branch_result.stdout.strip()
|
||||
if current_branch_result.returncode == 0
|
||||
else None
|
||||
)
|
||||
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
|
||||
# Don't merge a branch into itself
|
||||
if current_branch == spec_branch:
|
||||
print()
|
||||
print_status(
|
||||
"You're on the spec branch. Switch to your target branch first.", "warning"
|
||||
)
|
||||
print()
|
||||
print("Example:")
|
||||
print(highlight(" git checkout main # or your feature branch"))
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
|
||||
return False
|
||||
|
||||
if no_commit:
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} STAGING BUILD FOR REVIEW"),
|
||||
@@ -178,7 +207,8 @@ def merge_existing_build(
|
||||
print()
|
||||
print(box(content, width=60, style="heavy"))
|
||||
|
||||
manager = WorktreeManager(project_dir)
|
||||
# Use current branch as merge target (not auto-detected main/master)
|
||||
manager = WorktreeManager(project_dir, base_branch=current_branch)
|
||||
show_build_summary(manager, spec_name)
|
||||
print()
|
||||
|
||||
|
||||
@@ -235,6 +235,23 @@ class WorktreeManager:
|
||||
**stats,
|
||||
)
|
||||
|
||||
def _check_branch_namespace_conflict(self) -> str | None:
|
||||
"""
|
||||
Check if a branch named 'auto-claude' exists, which would block creating
|
||||
branches in the 'auto-claude/*' namespace.
|
||||
|
||||
Git stores branch refs as files under .git/refs/heads/, so a branch named
|
||||
'auto-claude' creates a file that prevents creating the 'auto-claude/'
|
||||
directory needed for 'auto-claude/{spec-name}' branches.
|
||||
|
||||
Returns:
|
||||
The conflicting branch name if found, None otherwise.
|
||||
"""
|
||||
result = self._run_git(["rev-parse", "--verify", "auto-claude"])
|
||||
if result.returncode == 0:
|
||||
return "auto-claude"
|
||||
return None
|
||||
|
||||
def _get_worktree_stats(self, spec_name: str) -> dict:
|
||||
"""Get diff statistics for a worktree."""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
@@ -283,10 +300,26 @@ class WorktreeManager:
|
||||
|
||||
Returns:
|
||||
WorktreeInfo for the created worktree
|
||||
|
||||
Raises:
|
||||
WorktreeError: If a branch namespace conflict exists or worktree creation fails
|
||||
"""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
branch_name = self.get_branch_name(spec_name)
|
||||
|
||||
# Check for branch namespace conflict (e.g., 'auto-claude' blocking 'auto-claude/*')
|
||||
conflicting_branch = self._check_branch_namespace_conflict()
|
||||
if conflicting_branch:
|
||||
raise WorktreeError(
|
||||
f"Branch '{conflicting_branch}' exists and blocks creating '{branch_name}'.\n"
|
||||
f"\n"
|
||||
f"Git branch names work like file paths - a branch named 'auto-claude' prevents\n"
|
||||
f"creating branches under 'auto-claude/' (like 'auto-claude/{spec_name}').\n"
|
||||
f"\n"
|
||||
f"Fix: Rename the conflicting branch:\n"
|
||||
f" git branch -m {conflicting_branch} {conflicting_branch}-backup"
|
||||
)
|
||||
|
||||
# Remove existing if present (from crashed previous run)
|
||||
if worktree_path.exists():
|
||||
self._run_git(["worktree", "remove", "--force", str(worktree_path)])
|
||||
|
||||
@@ -113,14 +113,21 @@ def _create_linear_client() -> ClaudeSDKClient:
|
||||
Create a minimal Claude client with only Linear MCP tools.
|
||||
Used for focused mini-agent calls.
|
||||
"""
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set")
|
||||
from core.auth import (
|
||||
ensure_claude_code_oauth_token,
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
)
|
||||
|
||||
require_auth_token() # Raises ValueError if no token found
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
linear_api_key = get_linear_api_key()
|
||||
if not linear_api_key:
|
||||
raise ValueError("LINEAR_API_KEY not set")
|
||||
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5", # Fast & cheap model for simple API calls
|
||||
@@ -134,6 +141,7 @@ def _create_linear_client() -> ClaudeSDKClient:
|
||||
}
|
||||
},
|
||||
max_turns=10, # Should complete in 1-3 turns
|
||||
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ def get_memory_dir(spec_dir: Path) -> Path:
|
||||
Get the memory directory for a spec, creating it if needed.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to spec directory (e.g., auto-claude/specs/001-feature/)
|
||||
spec_dir: Path to spec directory (e.g., .auto-claude/specs/001-feature/)
|
||||
|
||||
Returns:
|
||||
Path to memory directory
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -32,13 +31,17 @@ def create_claude_resolver() -> AIResolver:
|
||||
Configured AIResolver instance
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
from .resolver import AIResolver
|
||||
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
logger.warning("CLAUDE_CODE_OAUTH_TOKEN not set, AI resolution unavailable")
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found, AI resolution unavailable")
|
||||
return AIResolver()
|
||||
|
||||
# Ensure SDK can find the token
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Phase Configuration Module
|
||||
===========================
|
||||
|
||||
Handles model and thinking level configuration for different execution phases.
|
||||
Reads configuration from task_metadata.json and provides resolved model IDs.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
# Model shorthand to full model ID mapping
|
||||
MODEL_ID_MAP: dict[str, str] = {
|
||||
"opus": "claude-opus-4-5-20251101",
|
||||
"sonnet": "claude-sonnet-4-5-20250929",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# Thinking level to budget tokens mapping (None = no extended thinking)
|
||||
# Values must match auto-claude-ui/src/shared/constants/models.ts THINKING_BUDGET_MAP
|
||||
THINKING_BUDGET_MAP: dict[str, int | None] = {
|
||||
"none": None,
|
||||
"low": 1024,
|
||||
"medium": 4096, # Moderate analysis
|
||||
"high": 16384, # Deep thinking for QA review
|
||||
"ultrathink": 65536, # Maximum reasoning depth
|
||||
}
|
||||
|
||||
# Spec runner phase-specific thinking levels
|
||||
# Heavy phases use ultrathink for deep analysis
|
||||
# Light phases use medium after compaction
|
||||
SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
|
||||
# Heavy phases - ultrathink (discovery, spec creation, self-critique)
|
||||
"discovery": "ultrathink",
|
||||
"spec_writing": "ultrathink",
|
||||
"self_critique": "ultrathink",
|
||||
# Light phases - medium (after first invocation with compaction)
|
||||
"requirements": "medium",
|
||||
"research": "medium",
|
||||
"context": "medium",
|
||||
"planning": "medium",
|
||||
"validation": "medium",
|
||||
"quick_spec": "medium",
|
||||
"historical_context": "medium",
|
||||
"complexity_assessment": "medium",
|
||||
}
|
||||
|
||||
# Default phase configuration (matches UI defaults)
|
||||
DEFAULT_PHASE_MODELS: dict[str, str] = {
|
||||
"spec": "sonnet",
|
||||
"planning": "opus",
|
||||
"coding": "sonnet",
|
||||
"qa": "sonnet",
|
||||
}
|
||||
|
||||
DEFAULT_PHASE_THINKING: dict[str, str] = {
|
||||
"spec": "medium",
|
||||
"planning": "high",
|
||||
"coding": "medium",
|
||||
"qa": "high",
|
||||
}
|
||||
|
||||
|
||||
class PhaseModelConfig(TypedDict, total=False):
|
||||
spec: str
|
||||
planning: str
|
||||
coding: str
|
||||
qa: str
|
||||
|
||||
|
||||
class PhaseThinkingConfig(TypedDict, total=False):
|
||||
spec: str
|
||||
planning: str
|
||||
coding: str
|
||||
qa: str
|
||||
|
||||
|
||||
class TaskMetadataConfig(TypedDict, total=False):
|
||||
"""Structure of model-related fields in task_metadata.json"""
|
||||
|
||||
isAutoProfile: bool
|
||||
phaseModels: PhaseModelConfig
|
||||
phaseThinking: PhaseThinkingConfig
|
||||
model: str
|
||||
thinkingLevel: str
|
||||
|
||||
|
||||
Phase = Literal["spec", "planning", "coding", "qa"]
|
||||
|
||||
|
||||
def resolve_model_id(model: str) -> str:
|
||||
"""
|
||||
Resolve a model shorthand (haiku, sonnet, opus) to a full model ID.
|
||||
If the model is already a full ID, return it unchanged.
|
||||
|
||||
Args:
|
||||
model: Model shorthand or full ID
|
||||
|
||||
Returns:
|
||||
Full Claude model ID
|
||||
"""
|
||||
# Check if it's a shorthand
|
||||
if model in MODEL_ID_MAP:
|
||||
return MODEL_ID_MAP[model]
|
||||
|
||||
# Already a full model ID
|
||||
return model
|
||||
|
||||
|
||||
def get_thinking_budget(thinking_level: str) -> int | None:
|
||||
"""
|
||||
Get the thinking budget for a thinking level.
|
||||
|
||||
Args:
|
||||
thinking_level: Thinking level (none, low, medium, high, ultrathink)
|
||||
|
||||
Returns:
|
||||
Token budget or None for no extended thinking
|
||||
"""
|
||||
return THINKING_BUDGET_MAP.get(thinking_level, THINKING_BUDGET_MAP["medium"])
|
||||
|
||||
|
||||
def load_task_metadata(spec_dir: Path) -> TaskMetadataConfig | None:
|
||||
"""
|
||||
Load task_metadata.json from the spec directory.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Parsed task metadata or None if not found
|
||||
"""
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def get_phase_model(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the resolved model ID for a specific execution phase.
|
||||
|
||||
Priority:
|
||||
1. CLI argument (if provided)
|
||||
2. Phase-specific config from task_metadata.json (if auto profile)
|
||||
3. Single model from task_metadata.json (if not auto profile)
|
||||
4. Default phase configuration
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
cli_model: Model from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Resolved full model ID
|
||||
"""
|
||||
# CLI argument takes precedence
|
||||
if cli_model:
|
||||
return resolve_model_id(cli_model)
|
||||
|
||||
# Load task metadata
|
||||
metadata = load_task_metadata(spec_dir)
|
||||
|
||||
if metadata:
|
||||
# Check for auto profile with phase-specific config
|
||||
if metadata.get("isAutoProfile") and metadata.get("phaseModels"):
|
||||
phase_models = metadata["phaseModels"]
|
||||
model = phase_models.get(phase, DEFAULT_PHASE_MODELS[phase])
|
||||
return resolve_model_id(model)
|
||||
|
||||
# Non-auto profile: use single model
|
||||
if metadata.get("model"):
|
||||
return resolve_model_id(metadata["model"])
|
||||
|
||||
# Fall back to default phase configuration
|
||||
return resolve_model_id(DEFAULT_PHASE_MODELS[phase])
|
||||
|
||||
|
||||
def get_phase_thinking(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_thinking: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the thinking level for a specific execution phase.
|
||||
|
||||
Priority:
|
||||
1. CLI argument (if provided)
|
||||
2. Phase-specific config from task_metadata.json (if auto profile)
|
||||
3. Single thinking level from task_metadata.json (if not auto profile)
|
||||
4. Default phase configuration
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Thinking level string
|
||||
"""
|
||||
# CLI argument takes precedence
|
||||
if cli_thinking:
|
||||
return cli_thinking
|
||||
|
||||
# Load task metadata
|
||||
metadata = load_task_metadata(spec_dir)
|
||||
|
||||
if metadata:
|
||||
# Check for auto profile with phase-specific config
|
||||
if metadata.get("isAutoProfile") and metadata.get("phaseThinking"):
|
||||
phase_thinking = metadata["phaseThinking"]
|
||||
return phase_thinking.get(phase, DEFAULT_PHASE_THINKING[phase])
|
||||
|
||||
# Non-auto profile: use single thinking level
|
||||
if metadata.get("thinkingLevel"):
|
||||
return metadata["thinkingLevel"]
|
||||
|
||||
# Fall back to default phase configuration
|
||||
return DEFAULT_PHASE_THINKING[phase]
|
||||
|
||||
|
||||
def get_phase_thinking_budget(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_thinking: str | None = None,
|
||||
) -> int | None:
|
||||
"""
|
||||
Get the thinking budget tokens for a specific execution phase.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Token budget or None for no extended thinking
|
||||
"""
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
return get_thinking_budget(thinking_level)
|
||||
|
||||
|
||||
def get_phase_config(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
cli_thinking: str | None = None,
|
||||
) -> tuple[str, str, int | None]:
|
||||
"""
|
||||
Get the full configuration for a specific execution phase.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
cli_model: Model from CLI argument (optional)
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (model_id, thinking_level, thinking_budget)
|
||||
"""
|
||||
model_id = get_phase_model(spec_dir, phase, cli_model)
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
return model_id, thinking_level, thinking_budget
|
||||
|
||||
|
||||
def get_spec_phase_thinking_budget(phase_name: str) -> int | None:
|
||||
"""
|
||||
Get the thinking budget for a specific spec runner phase.
|
||||
|
||||
This maps granular spec phases (discovery, spec_writing, etc.) to their
|
||||
appropriate thinking budgets based on SPEC_PHASE_THINKING_LEVELS.
|
||||
|
||||
Args:
|
||||
phase_name: Name of the spec phase (e.g., 'discovery', 'spec_writing')
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking, or None for no extended thinking
|
||||
"""
|
||||
thinking_level = SPEC_PHASE_THINKING_LEVELS.get(phase_name, "medium")
|
||||
return get_thinking_budget(thinking_level)
|
||||
@@ -8,6 +8,7 @@ Runs QA fixer sessions to resolve issues identified by the reviewer.
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -58,6 +59,14 @@ async def run_qa_fixer_session(
|
||||
- "fixed" if fixes were applied
|
||||
- "error" if an error occurred
|
||||
"""
|
||||
debug_section("qa_fixer", f"QA Fixer Session {fix_session}")
|
||||
debug(
|
||||
"qa_fixer",
|
||||
"Starting QA fixer session",
|
||||
spec_dir=str(spec_dir),
|
||||
fix_session=fix_session,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" QA FIXER SESSION {fix_session}")
|
||||
print(" Applying fixes from QA_FIX_REQUEST.md...")
|
||||
@@ -66,14 +75,18 @@ async def run_qa_fixer_session(
|
||||
# Get task logger for streaming markers
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
current_tool = None
|
||||
message_count = 0
|
||||
tool_count = 0
|
||||
|
||||
# Check that fix request file exists
|
||||
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
|
||||
if not fix_request_file.exists():
|
||||
debug_error("qa_fixer", "QA_FIX_REQUEST.md not found")
|
||||
return "error", "QA_FIX_REQUEST.md not found"
|
||||
|
||||
# Load fixer prompt
|
||||
prompt = load_qa_fixer_prompt()
|
||||
debug_detailed("qa_fixer", "Loaded QA fixer prompt", prompt_length=len(prompt))
|
||||
|
||||
# Add session context - use full path so agent can find files
|
||||
prompt += f"\n\n---\n\n**Fix Session**: {fix_session}\n"
|
||||
@@ -83,11 +96,20 @@ async def run_qa_fixer_session(
|
||||
prompt += f"The fix request file is at: `{spec_dir}/QA_FIX_REQUEST.md`\n"
|
||||
|
||||
try:
|
||||
debug("qa_fixer", "Sending query to Claude SDK...")
|
||||
await client.query(prompt)
|
||||
debug_success("qa_fixer", "Query sent successfully")
|
||||
|
||||
response_text = ""
|
||||
debug("qa_fixer", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
"qa_fixer",
|
||||
f"Received message #{message_count}",
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
@@ -107,6 +129,7 @@ async def run_qa_fixer_session(
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
if hasattr(block, "input") and block.input:
|
||||
inp = block.input
|
||||
@@ -122,6 +145,12 @@ async def run_qa_fixer_session(
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input = cmd
|
||||
|
||||
debug(
|
||||
"qa_fixer",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
@@ -150,6 +179,11 @@ async def run_qa_fixer_session(
|
||||
result_content = getattr(block, "content", "")
|
||||
|
||||
if is_error:
|
||||
debug_error(
|
||||
"qa_fixer",
|
||||
f"Tool error: {current_tool}",
|
||||
error=str(result_content)[:200],
|
||||
)
|
||||
error_str = str(result_content)[:500]
|
||||
print(f" [Error] {error_str}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
@@ -162,6 +196,11 @@ async def run_qa_fixer_session(
|
||||
phase=LogPhase.VALIDATION,
|
||||
)
|
||||
else:
|
||||
debug_detailed(
|
||||
"qa_fixer",
|
||||
f"Tool success: {current_tool}",
|
||||
result_length=len(str(result_content)),
|
||||
)
|
||||
if verbose:
|
||||
result_str = str(result_content)[:200]
|
||||
print(f" [Done] {result_str}", flush=True)
|
||||
@@ -193,13 +232,30 @@ async def run_qa_fixer_session(
|
||||
|
||||
# Check if fixes were applied
|
||||
status = get_qa_signoff_status(spec_dir)
|
||||
debug(
|
||||
"qa_fixer",
|
||||
"Fixer session completed",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
ready_for_revalidation=status.get("ready_for_qa_revalidation")
|
||||
if status
|
||||
else False,
|
||||
)
|
||||
if status and status.get("ready_for_qa_revalidation"):
|
||||
debug_success("qa_fixer", "Fixes applied, ready for QA revalidation")
|
||||
return "fixed", response_text
|
||||
else:
|
||||
# Fixer didn't update the status properly, but we'll trust it worked
|
||||
debug_success("qa_fixer", "Fixes assumed applied (status not updated)")
|
||||
return "fixed", response_text
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
"qa_fixer",
|
||||
f"Fixer session exception: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
)
|
||||
print(f"Error during fixer session: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"QA fixer error: {e}", LogPhase.VALIDATION)
|
||||
|
||||
+114
-5
@@ -9,7 +9,8 @@ approval or max iterations.
|
||||
import time as time_module
|
||||
from pathlib import Path
|
||||
|
||||
from client import create_client
|
||||
from core.client import create_client
|
||||
from debug import debug, debug_error, debug_section, debug_success, debug_warning
|
||||
from linear_updater import (
|
||||
LinearTaskState,
|
||||
is_linear_enabled,
|
||||
@@ -18,6 +19,7 @@ from linear_updater import (
|
||||
linear_qa_rejected,
|
||||
linear_qa_started,
|
||||
)
|
||||
from phase_config import get_phase_model, get_thinking_budget
|
||||
from progress import count_subtasks, is_build_complete
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
@@ -79,6 +81,16 @@ async def run_qa_validation_loop(
|
||||
Returns:
|
||||
True if QA approved, False otherwise
|
||||
"""
|
||||
debug_section("qa_loop", "QA Validation Loop")
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Starting QA validation loop",
|
||||
project_dir=str(project_dir),
|
||||
spec_dir=str(spec_dir),
|
||||
model=model,
|
||||
max_iterations=MAX_QA_ITERATIONS,
|
||||
)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" QA VALIDATION LOOP")
|
||||
print(" Self-validating quality assurance")
|
||||
@@ -89,13 +101,16 @@ async def run_qa_validation_loop(
|
||||
|
||||
# Verify build is complete
|
||||
if not is_build_complete(spec_dir):
|
||||
debug_warning("qa_loop", "Build is not complete, cannot run QA")
|
||||
print("\n❌ Build is not complete. Cannot run QA validation.")
|
||||
completed, total = count_subtasks(spec_dir)
|
||||
debug("qa_loop", "Build progress", completed=completed, total=total)
|
||||
print(f" Progress: {completed}/{total} subtasks completed")
|
||||
return False
|
||||
|
||||
# Check if already approved
|
||||
if is_qa_approved(spec_dir):
|
||||
debug_success("qa_loop", "Build already approved by QA")
|
||||
print("\n✅ Build already approved by QA.")
|
||||
return True
|
||||
|
||||
@@ -127,20 +142,58 @@ async def run_qa_validation_loop(
|
||||
qa_iteration += 1
|
||||
iteration_start = time_module.time()
|
||||
|
||||
debug_section("qa_loop", f"QA Iteration {qa_iteration}")
|
||||
debug(
|
||||
"qa_loop",
|
||||
f"Starting iteration {qa_iteration}/{MAX_QA_ITERATIONS}",
|
||||
iteration=qa_iteration,
|
||||
max_iterations=MAX_QA_ITERATIONS,
|
||||
)
|
||||
|
||||
print(f"\n--- QA Iteration {qa_iteration}/{MAX_QA_ITERATIONS} ---")
|
||||
|
||||
# Run QA reviewer
|
||||
client = create_client(project_dir, spec_dir, model)
|
||||
# Run QA reviewer with phase-specific model and high thinking budget
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
qa_thinking_budget = get_thinking_budget(
|
||||
"high"
|
||||
) # 10,000 tokens for thorough review
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Creating client for QA reviewer session...",
|
||||
model=qa_model,
|
||||
thinking_budget=qa_thinking_budget,
|
||||
)
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
qa_model,
|
||||
agent_type="qa_reviewer",
|
||||
max_thinking_tokens=qa_thinking_budget,
|
||||
)
|
||||
|
||||
async with client:
|
||||
debug("qa_loop", "Running QA reviewer agent session...")
|
||||
status, response = await run_qa_agent_session(
|
||||
client, spec_dir, qa_iteration, MAX_QA_ITERATIONS, verbose
|
||||
)
|
||||
|
||||
iteration_duration = time_module.time() - iteration_start
|
||||
debug(
|
||||
"qa_loop",
|
||||
"QA reviewer session completed",
|
||||
status=status,
|
||||
duration_seconds=f"{iteration_duration:.1f}",
|
||||
response_length=len(response),
|
||||
)
|
||||
|
||||
if status == "approved":
|
||||
# Record successful iteration
|
||||
debug_success(
|
||||
"qa_loop",
|
||||
"QA APPROVED",
|
||||
iteration=qa_iteration,
|
||||
duration=f"{iteration_duration:.1f}s",
|
||||
)
|
||||
record_iteration(spec_dir, qa_iteration, "approved", [], iteration_duration)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
@@ -168,11 +221,23 @@ async def run_qa_validation_loop(
|
||||
return True
|
||||
|
||||
elif status == "rejected":
|
||||
debug_warning(
|
||||
"qa_loop",
|
||||
"QA REJECTED",
|
||||
iteration=qa_iteration,
|
||||
duration=f"{iteration_duration:.1f}s",
|
||||
)
|
||||
print(f"\n❌ QA found issues. Iteration {qa_iteration}/{MAX_QA_ITERATIONS}")
|
||||
|
||||
# Get issues from QA report
|
||||
qa_status = get_qa_signoff_status(spec_dir)
|
||||
current_issues = qa_status.get("issues_found", []) if qa_status else []
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Issues found by QA",
|
||||
issue_count=len(current_issues),
|
||||
issues=current_issues[:3] if current_issues else [], # Show first 3
|
||||
)
|
||||
|
||||
# Record rejected iteration
|
||||
record_iteration(
|
||||
@@ -188,6 +253,12 @@ async def run_qa_validation_loop(
|
||||
if has_recurring:
|
||||
from .report import RECURRING_ISSUE_THRESHOLD
|
||||
|
||||
debug_error(
|
||||
"qa_loop",
|
||||
"Recurring issues detected - escalating to human",
|
||||
recurring_count=len(recurring_issues),
|
||||
threshold=RECURRING_ISSUE_THRESHOLD,
|
||||
)
|
||||
print(
|
||||
f"\n⚠️ Recurring issues detected ({len(recurring_issues)} issue(s) appeared {RECURRING_ISSUE_THRESHOLD}+ times)"
|
||||
)
|
||||
@@ -223,17 +294,40 @@ async def run_qa_validation_loop(
|
||||
print("Escalating to human review.")
|
||||
break
|
||||
|
||||
# Run fixer
|
||||
# Run fixer with medium thinking budget
|
||||
fixer_thinking_budget = get_thinking_budget(
|
||||
"medium"
|
||||
) # 5,000 tokens for focused fixes
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Starting QA fixer session...",
|
||||
model=qa_model,
|
||||
thinking_budget=fixer_thinking_budget,
|
||||
)
|
||||
print("\nRunning QA Fixer Agent...")
|
||||
|
||||
fix_client = create_client(project_dir, spec_dir, model)
|
||||
fix_client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
qa_model,
|
||||
agent_type="qa_fixer",
|
||||
max_thinking_tokens=fixer_thinking_budget,
|
||||
)
|
||||
|
||||
async with fix_client:
|
||||
fix_status, fix_response = await run_qa_fixer_session(
|
||||
fix_client, spec_dir, qa_iteration, verbose
|
||||
)
|
||||
|
||||
debug(
|
||||
"qa_loop",
|
||||
"QA fixer session completed",
|
||||
fix_status=fix_status,
|
||||
response_length=len(fix_response),
|
||||
)
|
||||
|
||||
if fix_status == "error":
|
||||
debug_error("qa_loop", f"Fixer error: {fix_response[:200]}")
|
||||
print(f"\n❌ Fixer encountered error: {fix_response}")
|
||||
record_iteration(
|
||||
spec_dir,
|
||||
@@ -243,9 +337,11 @@ async def run_qa_validation_loop(
|
||||
)
|
||||
break
|
||||
|
||||
debug_success("qa_loop", "Fixes applied, re-running QA validation")
|
||||
print("\n✅ Fixes applied. Re-running QA validation...")
|
||||
|
||||
elif status == "error":
|
||||
debug_error("qa_loop", f"QA session error: {response[:200]}")
|
||||
print(f"\n❌ QA error: {response}")
|
||||
record_iteration(
|
||||
spec_dir,
|
||||
@@ -256,6 +352,12 @@ async def run_qa_validation_loop(
|
||||
print("Retrying...")
|
||||
|
||||
# Max iterations reached without approval
|
||||
debug_error(
|
||||
"qa_loop",
|
||||
"QA VALIDATION INCOMPLETE - max iterations reached",
|
||||
iterations=qa_iteration,
|
||||
max_iterations=MAX_QA_ITERATIONS,
|
||||
)
|
||||
print("\n" + "=" * 70)
|
||||
print(" ⚠️ QA VALIDATION INCOMPLETE")
|
||||
print("=" * 70)
|
||||
@@ -265,6 +367,13 @@ async def run_qa_validation_loop(
|
||||
# Show iteration summary
|
||||
history = get_iteration_history(spec_dir)
|
||||
summary = get_recurring_issue_summary(history)
|
||||
debug(
|
||||
"qa_loop",
|
||||
"QA loop final summary",
|
||||
total_iterations=len(history),
|
||||
total_issues=summary.get("total_issues", 0),
|
||||
unique_issues=summary.get("unique_issues", 0),
|
||||
)
|
||||
if summary["total_issues"] > 0:
|
||||
print("\n📊 Iteration Summary:")
|
||||
print(f" Total iterations: {len(history)}")
|
||||
|
||||
@@ -9,6 +9,7 @@ acceptance criteria.
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -62,6 +63,15 @@ async def run_qa_agent_session(
|
||||
- "rejected" if QA finds issues
|
||||
- "error" if an error occurred
|
||||
"""
|
||||
debug_section("qa_reviewer", f"QA Reviewer Session {qa_session}")
|
||||
debug(
|
||||
"qa_reviewer",
|
||||
"Starting QA reviewer session",
|
||||
spec_dir=str(spec_dir),
|
||||
qa_session=qa_session,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" QA REVIEWER SESSION {qa_session}")
|
||||
print(" Validating all acceptance criteria...")
|
||||
@@ -70,9 +80,14 @@ async def run_qa_agent_session(
|
||||
# Get task logger for streaming markers
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
current_tool = None
|
||||
message_count = 0
|
||||
tool_count = 0
|
||||
|
||||
# Load QA prompt
|
||||
prompt = load_qa_reviewer_prompt()
|
||||
debug_detailed(
|
||||
"qa_reviewer", "Loaded QA reviewer prompt", prompt_length=len(prompt)
|
||||
)
|
||||
|
||||
# Add session context - use full path so agent can find files
|
||||
prompt += f"\n\n---\n\n**QA Session**: {qa_session}\n"
|
||||
@@ -83,11 +98,20 @@ async def run_qa_agent_session(
|
||||
prompt += f"Use the full path when reading files, e.g.: `cat {spec_dir}/spec.md`\n"
|
||||
|
||||
try:
|
||||
debug("qa_reviewer", "Sending query to Claude SDK...")
|
||||
await client.query(prompt)
|
||||
debug_success("qa_reviewer", "Query sent successfully")
|
||||
|
||||
response_text = ""
|
||||
debug("qa_reviewer", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
"qa_reviewer",
|
||||
f"Received message #{message_count}",
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
@@ -107,6 +131,7 @@ async def run_qa_agent_session(
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Extract tool input for display
|
||||
if hasattr(block, "input") and block.input:
|
||||
@@ -120,6 +145,12 @@ async def run_qa_agent_session(
|
||||
elif "pattern" in inp:
|
||||
tool_input = f"pattern: {inp['pattern']}"
|
||||
|
||||
debug(
|
||||
"qa_reviewer",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
@@ -148,6 +179,11 @@ async def run_qa_agent_session(
|
||||
result_content = getattr(block, "content", "")
|
||||
|
||||
if is_error:
|
||||
debug_error(
|
||||
"qa_reviewer",
|
||||
f"Tool error: {current_tool}",
|
||||
error=str(result_content)[:200],
|
||||
)
|
||||
error_str = str(result_content)[:500]
|
||||
print(f" [Error] {error_str}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
@@ -160,6 +196,11 @@ async def run_qa_agent_session(
|
||||
phase=LogPhase.VALIDATION,
|
||||
)
|
||||
else:
|
||||
debug_detailed(
|
||||
"qa_reviewer",
|
||||
f"Tool success: {current_tool}",
|
||||
result_length=len(str(result_content)),
|
||||
)
|
||||
if verbose:
|
||||
result_str = str(result_content)[:200]
|
||||
print(f" [Done] {result_str}", flush=True)
|
||||
@@ -191,15 +232,33 @@ async def run_qa_agent_session(
|
||||
|
||||
# Check the QA result from implementation_plan.json
|
||||
status = get_qa_signoff_status(spec_dir)
|
||||
debug(
|
||||
"qa_reviewer",
|
||||
"QA session completed",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
qa_status=status.get("status") if status else "unknown",
|
||||
)
|
||||
if status and status.get("status") == "approved":
|
||||
debug_success("qa_reviewer", "QA APPROVED")
|
||||
return "approved", response_text
|
||||
elif status and status.get("status") == "rejected":
|
||||
debug_error("qa_reviewer", "QA REJECTED")
|
||||
return "rejected", response_text
|
||||
else:
|
||||
# Agent didn't update the status properly
|
||||
debug_error(
|
||||
"qa_reviewer", "QA agent did not update implementation_plan.json"
|
||||
)
|
||||
return "error", "QA agent did not update implementation_plan.json"
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
"qa_reviewer",
|
||||
f"QA session exception: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
)
|
||||
print(f"Error during QA session: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"QA session error: {e}", LogPhase.VALIDATION)
|
||||
|
||||
+11
-1
@@ -28,9 +28,19 @@ Prerequisites:
|
||||
- Claude Code CLI installed
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
# Python version check - must be before any imports using 3.10+ syntax
|
||||
if sys.version_info < (3, 10): # noqa: UP036
|
||||
sys.exit(
|
||||
f"Error: Auto Claude requires Python 3.10 or higher.\n"
|
||||
f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n"
|
||||
f"\n"
|
||||
f"Please upgrade Python: https://www.python.org/downloads/"
|
||||
)
|
||||
|
||||
import io
|
||||
|
||||
# Configure safe encoding on Windows BEFORE any imports that might print
|
||||
# This handles both TTY and piped output (e.g., from Electron)
|
||||
if sys.platform == "win32":
|
||||
|
||||
@@ -3,7 +3,6 @@ Claude SDK client wrapper for AI analysis.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -38,9 +37,10 @@ class ClaudeAnalysisClient:
|
||||
self._validate_oauth_token()
|
||||
|
||||
def _validate_oauth_token(self) -> None:
|
||||
"""Validate that CLAUDE_CODE_OAUTH_TOKEN is set."""
|
||||
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token")
|
||||
"""Validate that an authentication token is available."""
|
||||
from core.auth import require_auth_token
|
||||
|
||||
require_auth_token() # Raises ValueError if no token found
|
||||
|
||||
async def run_analysis_query(self, prompt: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,6 @@ about a codebase. It can also suggest tasks based on the conversation.
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -32,6 +31,7 @@ except ImportError:
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
@@ -128,22 +128,30 @@ Be conversational and helpful. Focus on providing actionable insights and clear
|
||||
Keep responses concise but informative."""
|
||||
|
||||
|
||||
async def run_with_sdk(project_dir: str, message: str, history: list) -> None:
|
||||
async def run_with_sdk(
|
||||
project_dir: str,
|
||||
message: str,
|
||||
history: list,
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
thinking_level: str = "medium",
|
||||
) -> None:
|
||||
"""Run the chat using Claude SDK with streaming."""
|
||||
if not SDK_AVAILABLE:
|
||||
print("Claude SDK not available, falling back to simple mode", file=sys.stderr)
|
||||
run_simple(project_dir, message, history)
|
||||
return
|
||||
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
if not get_auth_token():
|
||||
print(
|
||||
"CLAUDE_CODE_OAUTH_TOKEN not set, falling back to simple mode",
|
||||
"No authentication token found, falling back to simple mode",
|
||||
file=sys.stderr,
|
||||
)
|
||||
run_simple(project_dir, message, history)
|
||||
return
|
||||
|
||||
# Ensure SDK can find the token
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
system_prompt = build_system_prompt(project_dir)
|
||||
project_path = Path(project_dir).resolve()
|
||||
|
||||
@@ -161,11 +169,18 @@ async def run_with_sdk(project_dir: str, message: str, history: list) -> None:
|
||||
|
||||
Current question: {message}"""
|
||||
|
||||
debug(
|
||||
"insights_runner",
|
||||
"Using model configuration",
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
|
||||
try:
|
||||
# Create Claude SDK client with appropriate settings for insights
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-opus-4-5-20251101", # Opus 4.5 for quality analysis
|
||||
model=model, # Use configured model
|
||||
system_prompt=system_prompt,
|
||||
allowed_tools=[
|
||||
"Read",
|
||||
@@ -316,18 +331,33 @@ def main():
|
||||
parser.add_argument("--project-dir", required=True, help="Project directory path")
|
||||
parser.add_argument("--message", required=True, help="User message")
|
||||
parser.add_argument("--history", default="[]", help="JSON conversation history")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="claude-sonnet-4-5-20250929",
|
||||
help="Claude model ID (default: claude-sonnet-4-5-20250929)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--thinking-level",
|
||||
default="medium",
|
||||
choices=["none", "low", "medium", "high", "ultrathink"],
|
||||
help="Thinking level for extended reasoning (default: medium)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
debug_section("insights_runner", "Starting Insights Chat")
|
||||
|
||||
project_dir = args.project_dir
|
||||
user_message = args.message
|
||||
model = args.model
|
||||
thinking_level = args.thinking_level
|
||||
|
||||
debug(
|
||||
"insights_runner",
|
||||
"Arguments",
|
||||
project_dir=project_dir,
|
||||
message_length=len(user_message),
|
||||
model=model,
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -339,7 +369,7 @@ def main():
|
||||
|
||||
# Run the async SDK function
|
||||
debug("insights_runner", "Running SDK query")
|
||||
asyncio.run(run_with_sdk(project_dir, user_message, history))
|
||||
asyncio.run(run_with_sdk(project_dir, user_message, history, model, thinking_level))
|
||||
debug_success("insights_runner", "Query completed")
|
||||
|
||||
|
||||
|
||||
@@ -33,10 +33,20 @@ Usage:
|
||||
python auto-claude/spec_runner.py --task "Simple fix" --no-ai-assessment
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# Python version check - must be before any imports using 3.10+ syntax
|
||||
if sys.version_info < (3, 10): # noqa: UP036
|
||||
sys.exit(
|
||||
f"Error: Auto Claude requires Python 3.10 or higher.\n"
|
||||
f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n"
|
||||
f"\n"
|
||||
f"Please upgrade Python: https://www.python.org/downloads/"
|
||||
)
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows BEFORE any imports that might print
|
||||
@@ -81,6 +91,8 @@ if env_file.exists():
|
||||
elif dev_env_file.exists():
|
||||
load_dotenv(dev_env_file)
|
||||
|
||||
from debug import debug, debug_error, debug_section, debug_success
|
||||
from phase_config import resolve_model_id
|
||||
from review import ReviewState
|
||||
from spec import SpecOrchestrator
|
||||
from ui import Icons, highlight, icon, muted, print_section, print_status
|
||||
@@ -88,6 +100,7 @@ from ui import Icons, highlight, icon, muted, print_section, print_status
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
debug_section("spec_runner", "Spec Runner CLI")
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -149,8 +162,15 @@ Examples:
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default="claude-opus-4-5-20251101",
|
||||
help="Model to use for agent phases",
|
||||
default="sonnet",
|
||||
help="Model to use for agent phases (haiku, sonnet, opus, or full model ID)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--thinking-level",
|
||||
type=str,
|
||||
default="medium",
|
||||
choices=["none", "low", "medium", "high", "ultrathink"],
|
||||
help="Thinking level for extended thinking (none, low, medium, high, ultrathink)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-ai-assessment",
|
||||
@@ -222,18 +242,36 @@ Examples:
|
||||
f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now go to .auto-claude/specs/\n"
|
||||
)
|
||||
|
||||
# Resolve model shorthand to full model ID
|
||||
resolved_model = resolve_model_id(args.model)
|
||||
|
||||
debug(
|
||||
"spec_runner",
|
||||
"Creating spec orchestrator",
|
||||
project_dir=str(project_dir),
|
||||
task_description=task_description[:200] if task_description else None,
|
||||
model=resolved_model,
|
||||
thinking_level=args.thinking_level,
|
||||
complexity_override=args.complexity,
|
||||
use_ai_assessment=not args.no_ai_assessment,
|
||||
interactive=args.interactive or not task_description,
|
||||
auto_approve=args.auto_approve,
|
||||
)
|
||||
|
||||
orchestrator = SpecOrchestrator(
|
||||
project_dir=project_dir,
|
||||
task_description=task_description,
|
||||
spec_name=args.continue_spec,
|
||||
spec_dir=args.spec_dir,
|
||||
model=args.model,
|
||||
model=resolved_model,
|
||||
thinking_level=args.thinking_level,
|
||||
complexity_override=args.complexity,
|
||||
use_ai_assessment=not args.no_ai_assessment,
|
||||
dev_mode=args.dev,
|
||||
)
|
||||
|
||||
try:
|
||||
debug("spec_runner", "Starting spec orchestrator run...")
|
||||
success = asyncio.run(
|
||||
orchestrator.run(
|
||||
interactive=args.interactive or not task_description,
|
||||
@@ -242,13 +280,22 @@ Examples:
|
||||
)
|
||||
|
||||
if not success:
|
||||
debug_error("spec_runner", "Spec creation failed")
|
||||
sys.exit(1)
|
||||
|
||||
debug_success(
|
||||
"spec_runner",
|
||||
"Spec creation succeeded",
|
||||
spec_dir=str(orchestrator.spec_dir),
|
||||
)
|
||||
|
||||
# Auto-start build unless --no-build is specified
|
||||
if not args.no_build:
|
||||
debug("spec_runner", "Checking if spec is approved for build...")
|
||||
# Verify spec is approved before starting build (defensive check)
|
||||
review_state = ReviewState.load(orchestrator.spec_dir)
|
||||
if not review_state.is_approved():
|
||||
debug_error("spec_runner", "Spec not approved - cannot start build")
|
||||
print()
|
||||
print_status("Build cannot start: spec not approved.", "error")
|
||||
print()
|
||||
@@ -266,6 +313,7 @@ Examples:
|
||||
print(f" {highlight(example_cmd)}")
|
||||
sys.exit(1)
|
||||
|
||||
debug_success("spec_runner", "Spec approved - starting build")
|
||||
print()
|
||||
print_section("STARTING BUILD", Icons.LIGHTNING)
|
||||
print()
|
||||
@@ -286,10 +334,15 @@ Examples:
|
||||
if args.dev:
|
||||
run_cmd.append("--dev")
|
||||
|
||||
# Pass through model if not default
|
||||
if args.model != "claude-opus-4-5-20251101":
|
||||
run_cmd.extend(["--model", args.model])
|
||||
# Note: Model configuration for subsequent phases (planning, coding, qa)
|
||||
# is read from task_metadata.json by run.py, so we don't pass it here.
|
||||
# This allows per-phase configuration when using Auto profile.
|
||||
|
||||
debug(
|
||||
"spec_runner",
|
||||
"Executing run.py for build",
|
||||
command=" ".join(run_cmd),
|
||||
)
|
||||
print(f" {muted('Running:')} {' '.join(run_cmd)}")
|
||||
print()
|
||||
|
||||
@@ -299,6 +352,7 @@ Examples:
|
||||
sys.exit(0)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
debug_error("spec_runner", "Spec creation interrupted by user")
|
||||
print("\n\nSpec creation interrupted.")
|
||||
print(
|
||||
f"To continue: python auto-claude/spec_runner.py --continue {orchestrator.spec_dir.name}"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Conversation Compaction Module
|
||||
==============================
|
||||
|
||||
Summarizes phase outputs to maintain continuity between phases while
|
||||
reducing token usage. After each phase completes, key findings are
|
||||
summarized and passed as context to subsequent phases.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
|
||||
|
||||
async def summarize_phase_output(
|
||||
phase_name: str,
|
||||
phase_output: str,
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
target_words: int = 500,
|
||||
) -> str:
|
||||
"""
|
||||
Summarize phase output to a concise summary for subsequent phases.
|
||||
|
||||
Uses Sonnet for cost efficiency since this is a simple summarization task.
|
||||
|
||||
Args:
|
||||
phase_name: Name of the completed phase (e.g., 'discovery', 'requirements')
|
||||
phase_output: Full output content from the phase (file contents, decisions)
|
||||
model: Model to use for summarization (defaults to Sonnet for efficiency)
|
||||
target_words: Target summary length in words (~500-1000 recommended)
|
||||
|
||||
Returns:
|
||||
Concise summary of key findings, decisions, and insights from the phase
|
||||
"""
|
||||
# Validate auth token
|
||||
require_auth_token()
|
||||
|
||||
# Limit input size to avoid token overflow
|
||||
max_input_chars = 15000
|
||||
truncated_output = phase_output[:max_input_chars]
|
||||
if len(phase_output) > max_input_chars:
|
||||
truncated_output += "\n\n[... output truncated for summarization ...]"
|
||||
|
||||
prompt = f"""Summarize the key findings from the "{phase_name}" phase in {target_words} words or less.
|
||||
|
||||
Focus on extracting ONLY the most critical information that subsequent phases need:
|
||||
- Key decisions made and their rationale
|
||||
- Critical files, components, or patterns identified
|
||||
- Important constraints or requirements discovered
|
||||
- Actionable insights for implementation
|
||||
|
||||
Be concise and use bullet points. Skip boilerplate and meta-commentary.
|
||||
|
||||
## Phase Output:
|
||||
{truncated_output}
|
||||
|
||||
## Summary:
|
||||
"""
|
||||
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are a concise technical summarizer. Extract only the most "
|
||||
"critical information from phase outputs. Use bullet points. "
|
||||
"Focus on decisions, discoveries, and actionable insights."
|
||||
),
|
||||
allowed_tools=[], # No tools needed for summarization
|
||||
max_turns=1,
|
||||
env=get_sdk_env_vars(),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
if hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
return response_text.strip()
|
||||
except Exception as e:
|
||||
# Fallback: return truncated raw output on error
|
||||
# This ensures we don't block the pipeline if summarization fails
|
||||
fallback = phase_output[:2000]
|
||||
if len(phase_output) > 2000:
|
||||
fallback += "\n\n[... truncated ...]"
|
||||
return f"[Summarization failed: {e}]\n\n{fallback}"
|
||||
|
||||
|
||||
def format_phase_summaries(summaries: dict[str, str]) -> str:
|
||||
"""
|
||||
Format accumulated phase summaries for injection into agent context.
|
||||
|
||||
Args:
|
||||
summaries: Dict mapping phase names to their summaries
|
||||
|
||||
Returns:
|
||||
Formatted string suitable for agent context injection
|
||||
"""
|
||||
if not summaries:
|
||||
return ""
|
||||
|
||||
formatted_parts = ["## Context from Previous Phases\n"]
|
||||
for phase_name, summary in summaries.items():
|
||||
formatted_parts.append(
|
||||
f"### {phase_name.replace('_', ' ').title()}\n{summary}\n"
|
||||
)
|
||||
|
||||
return "\n".join(formatted_parts)
|
||||
|
||||
|
||||
def gather_phase_outputs(spec_dir: Path, phase_name: str) -> str:
|
||||
"""
|
||||
Gather output files from a completed phase for summarization.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase_name: Name of the completed phase
|
||||
|
||||
Returns:
|
||||
Concatenated content of phase output files
|
||||
"""
|
||||
outputs = []
|
||||
|
||||
# Map phases to their expected output files
|
||||
phase_outputs: dict[str, list[str]] = {
|
||||
"discovery": ["context.json"],
|
||||
"requirements": ["requirements.json"],
|
||||
"research": ["research.json"],
|
||||
"context": ["context.json"],
|
||||
"quick_spec": ["spec.md"],
|
||||
"spec_writing": ["spec.md"],
|
||||
"self_critique": ["spec.md", "critique_notes.md"],
|
||||
"planning": ["implementation_plan.json"],
|
||||
"validation": [], # No output files to summarize
|
||||
}
|
||||
|
||||
output_files = phase_outputs.get(phase_name, [])
|
||||
|
||||
for filename in output_files:
|
||||
file_path = spec_dir / filename
|
||||
if file_path.exists():
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
# Limit individual file size
|
||||
if len(content) > 10000:
|
||||
content = content[:10000] + "\n\n[... file truncated ...]"
|
||||
outputs.append(f"**{filename}**:\n```\n{content}\n```")
|
||||
except Exception:
|
||||
pass # Skip files that can't be read
|
||||
|
||||
return "\n\n".join(outputs) if outputs else ""
|
||||
@@ -73,7 +73,10 @@ class PlanningPhaseMixin:
|
||||
f"Running planner agent (attempt {attempt + 1})...", "progress"
|
||||
)
|
||||
|
||||
success, output = await self.run_agent_fn("planner.md")
|
||||
success, output = await self.run_agent_fn(
|
||||
"planner.md",
|
||||
phase_name="planning",
|
||||
)
|
||||
|
||||
if success and plan_file.exists():
|
||||
result = self.spec_validator.validate_implementation_plan()
|
||||
@@ -161,6 +164,7 @@ Read the failed files, understand the errors, and fix them.
|
||||
success, output = await self.run_agent_fn(
|
||||
"validation_fixer.md",
|
||||
additional_context=context_str,
|
||||
phase_name="validation",
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
@@ -221,6 +221,7 @@ Output your findings to research.json.
|
||||
success, output = await self.run_agent_fn(
|
||||
"spec_researcher.md",
|
||||
additional_context=context_str,
|
||||
phase_name="research",
|
||||
)
|
||||
|
||||
if success and research_file.exists():
|
||||
|
||||
@@ -50,6 +50,7 @@ Create:
|
||||
success, output = await self.run_agent_fn(
|
||||
"spec_quick.md",
|
||||
additional_context=context_str,
|
||||
phase_name="quick_spec",
|
||||
)
|
||||
|
||||
if success and spec_file.exists():
|
||||
@@ -85,7 +86,10 @@ Create:
|
||||
f"Running spec writer (attempt {attempt + 1})...", "progress"
|
||||
)
|
||||
|
||||
success, output = await self.run_agent_fn("spec_writer.md")
|
||||
success, output = await self.run_agent_fn(
|
||||
"spec_writer.md",
|
||||
phase_name="spec_writing",
|
||||
)
|
||||
|
||||
if success and spec_file.exists():
|
||||
result = self.spec_validator.validate_spec_document()
|
||||
@@ -162,6 +166,7 @@ Output critique_report.json with:
|
||||
success, output = await self.run_agent_fn(
|
||||
"spec_critic.md",
|
||||
additional_context=context_str,
|
||||
phase_name="self_critique",
|
||||
)
|
||||
|
||||
if success:
|
||||
|
||||
@@ -12,7 +12,8 @@ from ui.capabilities import configure_safe_encoding
|
||||
|
||||
configure_safe_encoding()
|
||||
|
||||
from client import create_client
|
||||
from core.client import create_client
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -48,6 +49,8 @@ class AgentRunner:
|
||||
prompt_file: str,
|
||||
additional_context: str = "",
|
||||
interactive: bool = False,
|
||||
thinking_budget: int | None = None,
|
||||
prior_phase_summaries: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Run an agent with the given prompt.
|
||||
|
||||
@@ -55,37 +58,90 @@ class AgentRunner:
|
||||
prompt_file: The prompt file to use (relative to prompts directory)
|
||||
additional_context: Additional context to add to the prompt
|
||||
interactive: Whether to run in interactive mode
|
||||
thinking_budget: Token budget for extended thinking (None = disabled)
|
||||
prior_phase_summaries: Summaries from previous phases for context
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_text)
|
||||
"""
|
||||
debug_section("agent_runner", f"Spec Agent - {prompt_file}")
|
||||
debug(
|
||||
"agent_runner",
|
||||
"Running spec creation agent",
|
||||
prompt_file=prompt_file,
|
||||
spec_dir=str(self.spec_dir),
|
||||
model=self.model,
|
||||
interactive=interactive,
|
||||
)
|
||||
|
||||
prompt_path = Path(__file__).parent.parent.parent / "prompts" / prompt_file
|
||||
|
||||
if not prompt_path.exists():
|
||||
debug_error("agent_runner", f"Prompt file not found: {prompt_path}")
|
||||
return False, f"Prompt not found: {prompt_path}"
|
||||
|
||||
# Load prompt
|
||||
prompt = prompt_path.read_text()
|
||||
debug_detailed(
|
||||
"agent_runner",
|
||||
"Loaded prompt file",
|
||||
prompt_length=len(prompt),
|
||||
)
|
||||
|
||||
# Add context
|
||||
prompt += f"\n\n---\n\n**Spec Directory**: {self.spec_dir}\n"
|
||||
prompt += f"**Project Directory**: {self.project_dir}\n"
|
||||
|
||||
# Add summaries from previous phases (compaction)
|
||||
if prior_phase_summaries:
|
||||
prompt += f"\n{prior_phase_summaries}\n"
|
||||
debug_detailed(
|
||||
"agent_runner",
|
||||
"Added prior phase summaries",
|
||||
summaries_length=len(prior_phase_summaries),
|
||||
)
|
||||
|
||||
if additional_context:
|
||||
prompt += f"\n{additional_context}\n"
|
||||
debug_detailed(
|
||||
"agent_runner",
|
||||
"Added additional context",
|
||||
context_length=len(additional_context),
|
||||
)
|
||||
|
||||
# Create client
|
||||
client = create_client(self.project_dir, self.spec_dir, self.model)
|
||||
# Create client with thinking budget
|
||||
debug(
|
||||
"agent_runner",
|
||||
"Creating Claude SDK client...",
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.spec_dir,
|
||||
self.model,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
current_tool = None
|
||||
message_count = 0
|
||||
tool_count = 0
|
||||
|
||||
try:
|
||||
async with client:
|
||||
debug("agent_runner", "Sending query to Claude SDK...")
|
||||
await client.query(prompt)
|
||||
debug_success("agent_runner", "Query sent successfully")
|
||||
|
||||
response_text = ""
|
||||
debug("agent_runner", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
"agent_runner",
|
||||
f"Received message #{message_count}",
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
@@ -105,6 +161,7 @@ class AgentRunner:
|
||||
):
|
||||
tool_name = block.name
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if hasattr(block, "input") and block.input:
|
||||
@@ -112,6 +169,12 @@ class AgentRunner:
|
||||
block.input
|
||||
)
|
||||
|
||||
debug(
|
||||
"agent_runner",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input,
|
||||
)
|
||||
|
||||
if self.task_logger:
|
||||
self.task_logger.tool_start(
|
||||
tool_name,
|
||||
@@ -129,6 +192,18 @@ class AgentRunner:
|
||||
if block_type == "ToolResultBlock":
|
||||
is_error = getattr(block, "is_error", False)
|
||||
result_content = getattr(block, "content", "")
|
||||
if is_error:
|
||||
debug_error(
|
||||
"agent_runner",
|
||||
f"Tool error: {current_tool}",
|
||||
error=str(result_content)[:200],
|
||||
)
|
||||
else:
|
||||
debug_detailed(
|
||||
"agent_runner",
|
||||
f"Tool success: {current_tool}",
|
||||
result_length=len(str(result_content)),
|
||||
)
|
||||
if self.task_logger and current_tool:
|
||||
detail_content = self._get_tool_detail_content(
|
||||
current_tool, result_content
|
||||
@@ -142,9 +217,21 @@ class AgentRunner:
|
||||
current_tool = None
|
||||
|
||||
print()
|
||||
debug_success(
|
||||
"agent_runner",
|
||||
"Agent session completed successfully",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return True, response_text
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
"agent_runner",
|
||||
f"Agent session error: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
)
|
||||
if self.task_logger:
|
||||
self.task_logger.log_error(f"Agent error: {e}", LogPhase.PLANNING)
|
||||
return False, str(e)
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from phase_config import get_spec_phase_thinking_budget
|
||||
from review import run_review_checkpoint
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
@@ -27,6 +28,11 @@ from ui import (
|
||||
)
|
||||
|
||||
from .. import complexity, phases, requirements
|
||||
from ..compaction import (
|
||||
format_phase_summaries,
|
||||
gather_phase_outputs,
|
||||
summarize_phase_output,
|
||||
)
|
||||
from ..validate_pkg.spec_validator import SpecValidator
|
||||
from .agent_runner import AgentRunner
|
||||
from .models import (
|
||||
@@ -48,7 +54,8 @@ class SpecOrchestrator:
|
||||
spec_name: str | None = None,
|
||||
spec_dir: Path
|
||||
| None = None, # Use existing spec directory (for UI integration)
|
||||
model: str = "claude-opus-4-5-20251101",
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
thinking_level: str = "medium", # Thinking level for extended thinking
|
||||
complexity_override: str | None = None, # Force a specific complexity
|
||||
use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics)
|
||||
dev_mode: bool = False, # Dev mode: specs in gitignored folder, code changes to auto-claude/
|
||||
@@ -61,6 +68,7 @@ class SpecOrchestrator:
|
||||
spec_name: Optional spec name (for existing specs)
|
||||
spec_dir: Optional existing spec directory (for UI integration)
|
||||
model: The model to use for agent execution
|
||||
thinking_level: Thinking level (none, low, medium, high, ultrathink)
|
||||
complexity_override: Force a specific complexity level
|
||||
use_ai_assessment: Whether to use AI for complexity assessment
|
||||
dev_mode: Deprecated, kept for API compatibility
|
||||
@@ -68,6 +76,7 @@ class SpecOrchestrator:
|
||||
self.project_dir = Path(project_dir)
|
||||
self.task_description = task_description
|
||||
self.model = model
|
||||
self.thinking_level = thinking_level
|
||||
self.complexity_override = complexity_override
|
||||
self.use_ai_assessment = use_ai_assessment
|
||||
self.dev_mode = dev_mode
|
||||
@@ -96,6 +105,10 @@ class SpecOrchestrator:
|
||||
# Agent runner (initialized when needed)
|
||||
self._agent_runner: AgentRunner | None = None
|
||||
|
||||
# Phase summaries for conversation compaction
|
||||
# Stores summaries from completed phases to provide context to subsequent phases
|
||||
self._phase_summaries: dict[str, str] = {}
|
||||
|
||||
def _get_agent_runner(self) -> AgentRunner:
|
||||
"""Get or create the agent runner.
|
||||
|
||||
@@ -114,6 +127,7 @@ class SpecOrchestrator:
|
||||
prompt_file: str,
|
||||
additional_context: str = "",
|
||||
interactive: bool = False,
|
||||
phase_name: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Run an agent with the given prompt.
|
||||
|
||||
@@ -121,12 +135,55 @@ class SpecOrchestrator:
|
||||
prompt_file: The prompt file to use
|
||||
additional_context: Additional context to add
|
||||
interactive: Whether to run in interactive mode
|
||||
phase_name: Name of the phase (for thinking budget lookup)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_text)
|
||||
"""
|
||||
runner = self._get_agent_runner()
|
||||
return await runner.run_agent(prompt_file, additional_context, interactive)
|
||||
|
||||
# Get thinking budget for this phase
|
||||
thinking_budget = None
|
||||
if phase_name:
|
||||
thinking_budget = get_spec_phase_thinking_budget(phase_name)
|
||||
|
||||
# Format prior phase summaries for context
|
||||
prior_summaries = format_phase_summaries(self._phase_summaries)
|
||||
|
||||
return await runner.run_agent(
|
||||
prompt_file,
|
||||
additional_context,
|
||||
interactive,
|
||||
thinking_budget=thinking_budget,
|
||||
prior_phase_summaries=prior_summaries if prior_summaries else None,
|
||||
)
|
||||
|
||||
async def _store_phase_summary(self, phase_name: str) -> None:
|
||||
"""Summarize and store phase output for subsequent phases.
|
||||
|
||||
Args:
|
||||
phase_name: Name of the completed phase
|
||||
"""
|
||||
try:
|
||||
# Gather outputs from this phase
|
||||
phase_output = gather_phase_outputs(self.spec_dir, phase_name)
|
||||
if not phase_output:
|
||||
return
|
||||
|
||||
# Summarize the output
|
||||
summary = await summarize_phase_output(
|
||||
phase_name,
|
||||
phase_output,
|
||||
model="claude-sonnet-4-5-20250929", # Use Sonnet for efficiency
|
||||
target_words=500,
|
||||
)
|
||||
|
||||
if summary:
|
||||
self._phase_summaries[phase_name] = summary
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail the pipeline if summarization fails
|
||||
print_status(f"Phase summarization skipped: {e}", "warning")
|
||||
|
||||
async def run(self, interactive: bool = True, auto_approve: bool = False) -> bool:
|
||||
"""Run the spec creation process with dynamic phase selection.
|
||||
@@ -199,6 +256,8 @@ class SpecOrchestrator:
|
||||
LogPhase.PLANNING, success=False, message="Discovery failed"
|
||||
)
|
||||
return False
|
||||
# Store summary for subsequent phases (compaction)
|
||||
await self._store_phase_summary("discovery")
|
||||
|
||||
# === PHASE 2: REQUIREMENTS GATHERING ===
|
||||
result = await run_phase(
|
||||
@@ -213,6 +272,8 @@ class SpecOrchestrator:
|
||||
message="Requirements gathering failed",
|
||||
)
|
||||
return False
|
||||
# Store summary for subsequent phases (compaction)
|
||||
await self._store_phase_summary("requirements")
|
||||
|
||||
# Rename spec folder with better name from requirements
|
||||
rename_spec_dir_from_requirements(self.spec_dir)
|
||||
@@ -275,6 +336,10 @@ class SpecOrchestrator:
|
||||
results.append(result)
|
||||
phases_executed.append(phase_name)
|
||||
|
||||
# Store summary for subsequent phases (compaction)
|
||||
if result.success:
|
||||
await self._store_phase_summary(phase_name)
|
||||
|
||||
if not result.success:
|
||||
print()
|
||||
print_status(
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# This directory contains all spec folders
|
||||
# Each spec is stored in a numbered folder like:
|
||||
# 001-initial-app/
|
||||
# 002-user-auth/
|
||||
# 003-payment-integration/
|
||||
#
|
||||
# Create a new spec with: claude /spec
|
||||
@@ -5,6 +5,8 @@ Main TaskLogger class for logging task execution.
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from core.debug import debug, debug_error, debug_info, debug_success, is_debug_enabled
|
||||
|
||||
from .models import LogEntry, LogEntryType, LogPhase
|
||||
from .storage import LogStorage
|
||||
from .streaming import emit_marker
|
||||
@@ -62,6 +64,53 @@ class TaskLogger:
|
||||
"""Add an entry to the current phase."""
|
||||
self.storage.add_entry(entry)
|
||||
|
||||
def _debug_log(
|
||||
self,
|
||||
content: str,
|
||||
entry_type: LogEntryType = LogEntryType.TEXT,
|
||||
phase: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Output a log entry to the terminal via the debug logging system.
|
||||
|
||||
Only outputs when DEBUG=true is set in the environment.
|
||||
|
||||
Args:
|
||||
content: The message content
|
||||
entry_type: Type of entry for formatting
|
||||
phase: Current phase name
|
||||
tool_name: Tool name if this is a tool log
|
||||
**kwargs: Additional key-value pairs for debug output
|
||||
"""
|
||||
if not is_debug_enabled():
|
||||
return
|
||||
|
||||
module = "task_logger"
|
||||
prefix = f"[{phase or 'unknown'}]" if phase else ""
|
||||
|
||||
if tool_name:
|
||||
prefix = f"{prefix}[{tool_name}]"
|
||||
|
||||
message = f"{prefix} {content}" if prefix else content
|
||||
|
||||
# Route to appropriate debug function based on entry type
|
||||
if entry_type == LogEntryType.ERROR:
|
||||
debug_error(module, message, **kwargs)
|
||||
elif entry_type == LogEntryType.SUCCESS:
|
||||
debug_success(module, message, **kwargs)
|
||||
elif entry_type in (
|
||||
LogEntryType.INFO,
|
||||
LogEntryType.PHASE_START,
|
||||
LogEntryType.PHASE_END,
|
||||
):
|
||||
debug_info(module, message, **kwargs)
|
||||
elif entry_type in (LogEntryType.TOOL_START, LogEntryType.TOOL_END):
|
||||
debug(module, message, level=2, **kwargs)
|
||||
else:
|
||||
debug(module, message, **kwargs)
|
||||
|
||||
def set_session(self, session: int) -> None:
|
||||
"""Set the current session number."""
|
||||
self.current_session = session
|
||||
@@ -110,15 +159,19 @@ class TaskLogger:
|
||||
self._emit("PHASE_START", {"phase": phase_key, "timestamp": self._timestamp()})
|
||||
|
||||
# Add phase start entry
|
||||
phase_message = message or f"Starting {phase_key} phase"
|
||||
entry = LogEntry(
|
||||
timestamp=self._timestamp(),
|
||||
type=LogEntryType.PHASE_START.value,
|
||||
content=message or f"Starting {phase_key} phase",
|
||||
content=phase_message,
|
||||
phase=phase_key,
|
||||
session=self.current_session,
|
||||
)
|
||||
self._add_entry(entry)
|
||||
|
||||
# Debug log (when DEBUG=true)
|
||||
self._debug_log(phase_message, LogEntryType.PHASE_START, phase_key)
|
||||
|
||||
# Also print the message
|
||||
if message:
|
||||
print(message, flush=True)
|
||||
@@ -147,16 +200,22 @@ class TaskLogger:
|
||||
)
|
||||
|
||||
# Add phase end entry
|
||||
phase_message = (
|
||||
message or f"{'Completed' if success else 'Failed'} {phase_key} phase"
|
||||
)
|
||||
entry = LogEntry(
|
||||
timestamp=self._timestamp(),
|
||||
type=LogEntryType.PHASE_END.value,
|
||||
content=message
|
||||
or f"{'Completed' if success else 'Failed'} {phase_key} phase",
|
||||
content=phase_message,
|
||||
phase=phase_key,
|
||||
session=self.current_session,
|
||||
)
|
||||
self._add_entry(entry)
|
||||
|
||||
# Debug log (when DEBUG=true)
|
||||
entry_type = LogEntryType.SUCCESS if success else LogEntryType.ERROR
|
||||
self._debug_log(phase_message, entry_type, phase_key)
|
||||
|
||||
if message:
|
||||
print(message, flush=True)
|
||||
|
||||
@@ -205,6 +264,9 @@ class TaskLogger:
|
||||
},
|
||||
)
|
||||
|
||||
# Debug log (when DEBUG=true)
|
||||
self._debug_log(content, entry_type, phase_key, subtask=self.current_subtask)
|
||||
|
||||
# Also print to console (unless caller handles printing)
|
||||
if print_to_console:
|
||||
print(content, flush=True)
|
||||
@@ -272,6 +334,16 @@ class TaskLogger:
|
||||
},
|
||||
)
|
||||
|
||||
# Debug log (when DEBUG=true) - include detail for verbose mode
|
||||
self._debug_log(
|
||||
content,
|
||||
entry_type,
|
||||
phase_key,
|
||||
subtask=self.current_subtask,
|
||||
subphase=subphase,
|
||||
detail=detail[:500] + "..." if len(detail) > 500 else detail,
|
||||
)
|
||||
|
||||
if print_to_console:
|
||||
print(content, flush=True)
|
||||
|
||||
@@ -308,6 +380,11 @@ class TaskLogger:
|
||||
{"subphase": subphase, "phase": phase_key, "timestamp": self._timestamp()},
|
||||
)
|
||||
|
||||
# Debug log (when DEBUG=true)
|
||||
self._debug_log(
|
||||
f"Starting {subphase}", LogEntryType.INFO, phase_key, subphase=subphase
|
||||
)
|
||||
|
||||
if print_to_console:
|
||||
print(f"\n--- {subphase} ---", flush=True)
|
||||
|
||||
@@ -352,6 +429,14 @@ class TaskLogger:
|
||||
{"name": tool_name, "input": display_input, "phase": phase_key},
|
||||
)
|
||||
|
||||
# Debug log (when DEBUG=true)
|
||||
self._debug_log(
|
||||
display_input or "started",
|
||||
LogEntryType.TOOL_START,
|
||||
phase_key,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
|
||||
if print_to_console:
|
||||
print(f"\n[Tool: {tool_name}]", flush=True)
|
||||
|
||||
@@ -419,6 +504,18 @@ class TaskLogger:
|
||||
},
|
||||
)
|
||||
|
||||
# Debug log (when DEBUG=true)
|
||||
debug_kwargs = {"status": status}
|
||||
if display_result:
|
||||
debug_kwargs["result"] = display_result
|
||||
self._debug_log(
|
||||
content,
|
||||
LogEntryType.SUCCESS if success else LogEntryType.ERROR,
|
||||
phase_key,
|
||||
tool_name=tool_name,
|
||||
**debug_kwargs,
|
||||
)
|
||||
|
||||
if print_to_console:
|
||||
if result:
|
||||
print(f" [{status}] {display_result}", flush=True)
|
||||
|
||||
@@ -3,7 +3,9 @@ Storage functionality for task logs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -65,12 +67,25 @@ class LogStorage:
|
||||
}
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save logs to file."""
|
||||
"""Save logs to file atomically to prevent corruption from concurrent reads."""
|
||||
self._data["updated_at"] = self._timestamp()
|
||||
try:
|
||||
self.spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.log_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2, ensure_ascii=False)
|
||||
# Write to temp file first, then atomic rename to prevent corruption
|
||||
# when the UI reads mid-write
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=self.spec_dir, prefix=".task_logs_", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2, ensure_ascii=False)
|
||||
# Atomic rename (on POSIX systems, rename is atomic)
|
||||
os.replace(tmp_path, self.log_file)
|
||||
except Exception:
|
||||
# Clean up temp file on failure
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
except OSError as e:
|
||||
print(f"Warning: Failed to save task logs: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -602,6 +602,7 @@ def mock_run_agent_fn():
|
||||
async def _mock_agent(
|
||||
prompt_file: str,
|
||||
additional_context: str = None,
|
||||
phase_name: str = None,
|
||||
) -> tuple[bool, str]:
|
||||
nonlocal call_count
|
||||
if side_effect is not None:
|
||||
|
||||
@@ -203,7 +203,7 @@ class TestSpecOrchestratorInit:
|
||||
|
||||
orchestrator = SpecOrchestrator(project_dir=temp_dir)
|
||||
|
||||
assert orchestrator.model == "claude-opus-4-5-20251101"
|
||||
assert orchestrator.model == "claude-sonnet-4-5-20250929"
|
||||
|
||||
def test_init_custom_model(self, temp_dir: Path):
|
||||
"""Uses custom model."""
|
||||
|
||||
Reference in New Issue
Block a user