Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b7534db6 | |||
| 76fdbade6f | |||
| 979d97757f | |||
| 3f8e16edb2 | |||
| 86dadb3981 | |||
| 53b55468c9 | |||
| d6360e492c | |||
| 1984a62d9b | |||
| dcdd75376d | |||
| 48f8396bdd | |||
| 33107fcd91 | |||
| eb20eed304 | |||
| 8f3ea8f311 | |||
| 7da21d131e | |||
| 7705c563bb | |||
| 13a48e26e1 | |||
| dcc6afad2d | |||
| 7896be5f69 | |||
| be83ad46f6 | |||
| 5eac6d8d0f | |||
| 7a1db877f9 | |||
| 7a39253214 | |||
| 60bdf51aef | |||
| b437dc6f30 | |||
| 2e56176514 | |||
| dfa08dc588 | |||
| a8f7519a53 | |||
| 302233b31c | |||
| 80170562a4 | |||
| 4e2a614112 | |||
| db5eff4b9b | |||
| b188fed7e2 | |||
| 7fd0b4be38 | |||
| de04b81d60 | |||
| f2d2b0b680 | |||
| 8b279bf1ff | |||
| 9492440460 | |||
| 86da9ee6dd | |||
| 228a58a87c | |||
| af4c7fe89e | |||
| cd3fb11239 | |||
| 5d50a87d3c | |||
| d45c2033fc | |||
| 5236d6285b | |||
| 186940964a | |||
| 94621fde2b | |||
| f64d5decd1 | |||
| 0d727a3b91 | |||
| 7af1c57e20 | |||
| be78dc6849 | |||
| fb6c666706 | |||
| 35585053cd | |||
| 74d95d14a9 | |||
| efce7cdd46 | |||
| d5a276ebfa | |||
| 532458546f | |||
| 63c608b38f | |||
| a91163200d | |||
| 571bf51b33 | |||
| e4c517c09f | |||
| 11ed33569c | |||
| f21610a8b2 | |||
| 011a24239e | |||
| 9a11866f12 | |||
| 13c07f0a51 | |||
| b470b367bf | |||
| 42469a75df | |||
| 11299f59cb | |||
| c14e2fbaa2 | |||
| 8e29eb03ec | |||
| 53d24262e1 | |||
| a5670a6912 | |||
| d958fa65cb | |||
| bec3fc88a2 | |||
| 1308ec1433 | |||
| d5c8949173 | |||
| 04e68e37e5 | |||
| da5a708218 | |||
| 75869f7e22 | |||
| 96ea7d367c |
+4
-8
@@ -42,18 +42,14 @@ reviews:
|
||||
|
||||
# Path-specific review instructions
|
||||
path_instructions:
|
||||
- path: "apps/backend/**/*.py"
|
||||
instructions: |
|
||||
Focus on Python best practices, type hints, and async patterns.
|
||||
Check for proper error handling and security considerations.
|
||||
Verify compatibility with Python 3.12+.
|
||||
- path: "apps/frontend/**/*.{ts,tsx}"
|
||||
- path: "apps/desktop/**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review React patterns and TypeScript type safety.
|
||||
Check for proper state management and component composition.
|
||||
- path: "tests/**"
|
||||
Verify Vercel AI SDK v6 usage patterns and tool definitions.
|
||||
- path: "apps/desktop/**/*.test.{ts,tsx}"
|
||||
instructions: |
|
||||
Ensure tests are comprehensive and follow pytest conventions.
|
||||
Ensure tests are comprehensive and follow Vitest conventions.
|
||||
Check for proper mocking and test isolation.
|
||||
|
||||
chat:
|
||||
|
||||
@@ -41,7 +41,7 @@ runs:
|
||||
shell: bash
|
||||
# Run npm ci from root to properly handle workspace dependencies.
|
||||
# With npm workspaces, the lock file is at root and dependencies are hoisted there.
|
||||
# Running npm ci in apps/frontend would fail to populate node_modules correctly.
|
||||
# Running npm ci in apps/desktop would fail to populate node_modules correctly.
|
||||
run: |
|
||||
if [ "${{ inputs.ignore-scripts }}" == "true" ]; then
|
||||
npm ci --ignore-scripts
|
||||
@@ -51,12 +51,12 @@ runs:
|
||||
|
||||
- name: Link node_modules for electron-builder
|
||||
shell: bash
|
||||
# electron-builder expects node_modules in apps/frontend for native module rebuilding.
|
||||
# electron-builder expects node_modules in apps/desktop for native module rebuilding.
|
||||
# With npm workspaces, packages are hoisted to root. Create a link so electron-builder
|
||||
# can find the modules during packaging and code signing.
|
||||
# Uses symlink on Unix, directory junction on Windows (works without admin privileges).
|
||||
#
|
||||
# IMPORTANT: npm workspaces may create a partial node_modules in apps/frontend for
|
||||
# IMPORTANT: npm workspaces may create a partial node_modules in apps/desktop for
|
||||
# packages that couldn't be hoisted. We must remove it and create a proper link to root.
|
||||
run: |
|
||||
# Verify npm ci succeeded
|
||||
@@ -65,42 +65,42 @@ runs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove any existing node_modules in apps/frontend
|
||||
# Remove any existing node_modules in apps/desktop
|
||||
# This handles: partial directories from npm workspaces, AND broken symlinks
|
||||
if [ -e "apps/frontend/node_modules" ] || [ -L "apps/frontend/node_modules" ]; then
|
||||
if [ -e "apps/desktop/node_modules" ] || [ -L "apps/desktop/node_modules" ]; then
|
||||
# Check if it's a valid symlink pointing to root node_modules
|
||||
if [ -L "apps/frontend/node_modules" ]; then
|
||||
target=$(readlink apps/frontend/node_modules 2>/dev/null || echo "")
|
||||
if [ "$target" = "../../node_modules" ] && [ -d "apps/frontend/node_modules" ]; then
|
||||
echo "Correct symlink already exists: apps/frontend/node_modules -> ../../node_modules"
|
||||
if [ -L "apps/desktop/node_modules" ]; then
|
||||
target=$(readlink apps/desktop/node_modules 2>/dev/null || echo "")
|
||||
if [ "$target" = "../../node_modules" ] && [ -d "apps/desktop/node_modules" ]; then
|
||||
echo "Correct symlink already exists: apps/desktop/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "Removing incorrect/broken symlink (was: $target)..."
|
||||
rm -f "apps/frontend/node_modules"
|
||||
rm -f "apps/desktop/node_modules"
|
||||
fi
|
||||
else
|
||||
echo "Removing partial node_modules directory created by npm workspaces..."
|
||||
rm -rf "apps/frontend/node_modules"
|
||||
rm -rf "apps/desktop/node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create link if it doesn't exist or was removed
|
||||
if [ ! -L "apps/frontend/node_modules" ]; then
|
||||
if [ ! -L "apps/desktop/node_modules" ]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
# Use directory junction on Windows (works without admin privileges)
|
||||
# Use PowerShell's New-Item -ItemType Junction for reliable path handling
|
||||
abs_target=$(cygpath -w "$(pwd)/node_modules")
|
||||
link_path=$(cygpath -w "$(pwd)/apps/frontend/node_modules")
|
||||
link_path=$(cygpath -w "$(pwd)/apps/desktop/node_modules")
|
||||
powershell -Command "New-Item -ItemType Junction -Path '$link_path' -Target '$abs_target'" > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Created junction: apps/frontend/node_modules -> $abs_target"
|
||||
echo "Created junction: apps/desktop/node_modules -> $abs_target"
|
||||
else
|
||||
echo "::error::Failed to create directory junction on Windows"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Use symlink on Unix (macOS/Linux)
|
||||
if ln -s ../../node_modules apps/frontend/node_modules; then
|
||||
echo "Created symlink: apps/frontend/node_modules -> ../../node_modules"
|
||||
if ln -s ../../node_modules apps/desktop/node_modules; then
|
||||
echo "Created symlink: apps/desktop/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "::error::Failed to create symlink"
|
||||
exit 1
|
||||
@@ -111,16 +111,16 @@ runs:
|
||||
# Final verification - the link must exist and resolve correctly
|
||||
# Note: On Windows, junctions don't show as symlinks (-L), so we check if the directory exists
|
||||
# and can be listed. On Unix, we also verify it's a symlink.
|
||||
if [ "$RUNNER_OS" != "Windows" ] && [ ! -L "apps/frontend/node_modules" ]; then
|
||||
echo "::error::apps/frontend/node_modules symlink was not created"
|
||||
if [ "$RUNNER_OS" != "Windows" ] && [ ! -L "apps/desktop/node_modules" ]; then
|
||||
echo "::error::apps/desktop/node_modules symlink was not created"
|
||||
exit 1
|
||||
fi
|
||||
# Verify the link resolves to a valid directory with content
|
||||
if ! ls apps/frontend/node_modules/electron >/dev/null 2>&1; then
|
||||
echo "::error::apps/frontend/node_modules does not resolve correctly (electron not found)"
|
||||
ls -la apps/frontend/ || true
|
||||
ls apps/frontend/node_modules 2>&1 | head -5 || true
|
||||
if ! ls apps/desktop/node_modules/electron >/dev/null 2>&1; then
|
||||
echo "::error::apps/desktop/node_modules does not resolve correctly (electron not found)"
|
||||
ls -la apps/desktop/ || true
|
||||
ls apps/desktop/node_modules 2>&1 | head -5 || true
|
||||
exit 1
|
||||
fi
|
||||
count=$(ls apps/frontend/node_modules 2>/dev/null | wc -l)
|
||||
echo "Verified: apps/frontend/node_modules resolves correctly ($count entries)"
|
||||
count=$(ls apps/desktop/node_modules 2>/dev/null | wc -l)
|
||||
echo "Verified: apps/desktop/node_modules resolves correctly ($count entries)"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: 'Setup Python Backend'
|
||||
description: 'Set up Python with uv package manager and cached dependencies for the backend'
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: 'Python version to use'
|
||||
required: false
|
||||
default: '3.12'
|
||||
install-test-deps:
|
||||
description: 'Whether to install test dependencies'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'Whether cache was hit'
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Install uv package manager
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
id: cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/AppData/Local/uv/cache
|
||||
~/Library/Caches/uv
|
||||
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-${{ hashFiles('apps/backend/requirements.txt', 'tests/requirements-test.txt') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
if [ "${{ inputs.install-test-deps }}" == "true" ]; then
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
fi
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
dmg-path:
|
||||
description: 'Path to the dist directory containing the DMG file'
|
||||
required: false
|
||||
default: 'apps/frontend/dist'
|
||||
default: 'apps/desktop/dist'
|
||||
|
||||
outputs:
|
||||
notarization-id:
|
||||
|
||||
+1
-13
@@ -1,20 +1,8 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
- package-ecosystem: pip
|
||||
directory: /apps/backend
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/frontend
|
||||
directory: /apps/desktop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
@@ -74,35 +74,11 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Rust toolchain (for building native Python packages)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
@@ -111,7 +87,7 @@ jobs:
|
||||
- name: Package macOS (Intel)
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
|
||||
cd apps/desktop && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
@@ -133,9 +109,9 @@ jobs:
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
@@ -150,32 +126,11 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
@@ -184,7 +139,7 @@ jobs:
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
|
||||
cd apps/desktop && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
@@ -206,9 +161,9 @@ jobs:
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
@@ -225,32 +180,11 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~\AppData\Local\pip\Cache
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
@@ -260,7 +194,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
cd apps/desktop && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
@@ -284,7 +218,7 @@ jobs:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder: apps/desktop/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
@@ -294,7 +228,7 @@ jobs:
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
cd apps/desktop/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
@@ -318,7 +252,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
cd apps/desktop/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
@@ -385,8 +319,8 @@ jobs:
|
||||
with:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
apps/desktop/dist/*.exe
|
||||
apps/desktop/dist/*.yml
|
||||
|
||||
build-linux:
|
||||
needs: create-tag
|
||||
@@ -397,11 +331,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
@@ -409,29 +338,13 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
|
||||
sudo apt-get install -y flatpak flatpak-builder squashfs-tools
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
@@ -440,7 +353,7 @@ jobs:
|
||||
- name: Package Linux
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
cd apps/desktop && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
@@ -448,17 +361,17 @@ jobs:
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/frontend && npm run verify:linux
|
||||
run: cd apps/desktop && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
apps/desktop/dist/*.AppImage
|
||||
apps/desktop/dist/*.deb
|
||||
apps/desktop/dist/*.flatpak
|
||||
apps/desktop/dist/*.yml
|
||||
|
||||
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
|
||||
finalize-notarization:
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Install node-pty and rebuild for Electron
|
||||
working-directory: apps/frontend
|
||||
working-directory: apps/desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install only node-pty
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
npx @electron/rebuild --version $env:ELECTRON_VERSION --module-dir node_modules/node-pty --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Package prebuilt binaries
|
||||
working-directory: apps/frontend
|
||||
working-directory: apps/desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
Get-ChildItem $prebuildDir
|
||||
|
||||
- name: Create archive
|
||||
working-directory: apps/frontend
|
||||
working-directory: apps/desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
@@ -93,14 +93,14 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-win32-${{ matrix.arch }}
|
||||
path: apps/frontend/node-pty-*.zip
|
||||
path: apps/desktop/node-pty-*.zip
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: apps/frontend/node-pty-*.zip
|
||||
files: apps/desktop/node-pty-*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
+34
-80
@@ -3,8 +3,7 @@
|
||||
# Tests on all target platforms (Linux, Windows, macOS) to catch
|
||||
# platform-specific bugs before they merge. ALL platforms must pass.
|
||||
#
|
||||
# Optimized: Reduced matrix (4 jobs vs 6), merged integration tests,
|
||||
# coverage on Linux only, path filters to skip on docs-only changes.
|
||||
# Optimized: Frontend-only matrix, path filters to skip on docs-only changes.
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -13,10 +12,7 @@ on:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
@@ -25,10 +21,7 @@ on:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
@@ -41,72 +34,9 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# Python Backend Tests - Optimized Matrix (4 jobs instead of 6)
|
||||
# --------------------------------------------------------------------------
|
||||
test-python:
|
||||
name: test-python (${{ matrix.python-version }}, ${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 3.12 on all OS for cross-platform coverage
|
||||
# 3.13 on Linux only for compatibility check (saves 2 jobs)
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: '3.12'
|
||||
- os: ubuntu-latest
|
||||
python-version: '3.13'
|
||||
- os: windows-latest
|
||||
python-version: '3.12'
|
||||
- os: macos-latest
|
||||
python-version: '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python backend
|
||||
uses: ./.github/actions/setup-python-backend
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
install-test-deps: 'true'
|
||||
|
||||
- name: Run all tests (including platform-specific)
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
source .venv/Scripts/activate
|
||||
else
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
pytest ../../tests/ -v --tb=short -x
|
||||
|
||||
- name: Run coverage (Linux + Python 3.12 only)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=10
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./apps/backend/coverage.xml
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Frontend Tests - All Platforms
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -129,15 +59,41 @@ jobs:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Run TypeScript type check
|
||||
working-directory: apps/frontend
|
||||
working-directory: apps/desktop
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
if: matrix.os != 'ubuntu-latest'
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Upload coverage report
|
||||
if: matrix.os == 'ubuntu-latest' && always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: apps/desktop/coverage/
|
||||
retention-days: 14
|
||||
|
||||
- name: Coverage PR comment
|
||||
if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
working-directory: apps/desktop
|
||||
json-summary-path: coverage/coverage-summary.json
|
||||
json-final-path: coverage/coverage-final.json
|
||||
|
||||
- name: Build application
|
||||
working-directory: apps/frontend
|
||||
working-directory: apps/desktop
|
||||
run: npm run build
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -146,18 +102,16 @@ jobs:
|
||||
ci-complete:
|
||||
name: CI Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-python, test-frontend]
|
||||
needs: [test-frontend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all CI jobs passed
|
||||
run: |
|
||||
echo "CI Job Results:"
|
||||
echo " test-python: ${{ needs.test-python.result }}"
|
||||
echo " test-frontend: ${{ needs.test-frontend.result }}"
|
||||
echo ""
|
||||
|
||||
if [[ "${{ needs.test-python.result }}" != "success" ]] || \
|
||||
[[ "${{ needs.test-frontend.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.test-frontend.result }}" != "success" ]]; then
|
||||
echo "❌ One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# E2E Tests
|
||||
#
|
||||
# Runs Playwright E2E tests for the Electron desktop app on Linux.
|
||||
# Ubuntu-only since Electron E2E is platform-agnostic (Chromium renderer).
|
||||
# Non-blocking initially — separate from ci-complete gate while stabilizing.
|
||||
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js frontend
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: apps/desktop
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build application
|
||||
working-directory: apps/desktop
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: apps/desktop
|
||||
continue-on-error: true # Non-blocking while stabilizing — pre-existing __dirname ESM issue
|
||||
run: xvfb-run --auto-servernum npm run test:e2e
|
||||
|
||||
- name: Upload E2E report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-report
|
||||
path: |
|
||||
apps/desktop/e2e/playwright-report/
|
||||
apps/desktop/e2e/test-results/
|
||||
retention-days: 14
|
||||
@@ -4,50 +4,23 @@ on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'apps/desktop/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/frontend/biome.jsonc'
|
||||
- '.pre-commit-config.yaml'
|
||||
- 'apps/desktop/biome.jsonc'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'apps/desktop/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/frontend/biome.jsonc'
|
||||
- '.pre-commit-config.yaml'
|
||||
- 'apps/desktop/biome.jsonc'
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Python linting (Ruff) - already fast, no changes needed
|
||||
python:
|
||||
name: Python (Ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Pin ruff version to match .pre-commit-config.yaml
|
||||
- name: Install ruff
|
||||
run: pip install ruff==0.14.10
|
||||
|
||||
- name: Run ruff check
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
|
||||
typescript:
|
||||
name: TypeScript (Biome)
|
||||
@@ -63,7 +36,7 @@ jobs:
|
||||
version: 2.3.11
|
||||
|
||||
- name: Run Biome
|
||||
working-directory: apps/frontend
|
||||
working-directory: apps/desktop
|
||||
# biome ci fails on errors by default; warnings are reported but don't block
|
||||
# Use --error-on-warnings when ready to enforce all rules
|
||||
run: biome ci .
|
||||
@@ -74,15 +47,13 @@ jobs:
|
||||
lint-complete:
|
||||
name: Lint Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python, typescript]
|
||||
needs: [typescript]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check lint results
|
||||
run: |
|
||||
if [[ "${{ needs.python.result }}" != "success" ]] || \
|
||||
[[ "${{ needs.typescript.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.typescript.result }}" != "success" ]]; then
|
||||
echo "❌ Linting failed"
|
||||
echo " Python: ${{ needs.python.result }}"
|
||||
echo " TypeScript: ${{ needs.typescript.result }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -56,15 +56,14 @@ jobs:
|
||||
|
||||
// Area detection paths
|
||||
AREA_PATHS: Object.freeze({
|
||||
frontend: 'apps/frontend/',
|
||||
backend: 'apps/backend/',
|
||||
frontend: 'apps/desktop/',
|
||||
ci: '.github/'
|
||||
}),
|
||||
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci']
|
||||
AREA: ['area/frontend', 'area/ci']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
@@ -117,16 +116,15 @@ jobs:
|
||||
/**
|
||||
* Detect areas affected by file changes
|
||||
* @param {Array} files - List of changed files
|
||||
* @returns {{frontend: boolean, backend: boolean, ci: boolean}}
|
||||
* @returns {{frontend: boolean, ci: boolean}}
|
||||
*/
|
||||
function detectAreas(files) {
|
||||
const areas = { frontend: false, backend: false, ci: false };
|
||||
const areas = { frontend: false, ci: false };
|
||||
const { AREA_PATHS } = CONFIG;
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename || '';
|
||||
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
|
||||
if (path.startsWith(AREA_PATHS.backend)) areas.backend = true;
|
||||
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
|
||||
}
|
||||
|
||||
@@ -135,13 +133,11 @@ jobs:
|
||||
|
||||
/**
|
||||
* Determine area label based on detected areas
|
||||
* @param {{frontend: boolean, backend: boolean, ci: boolean}} areas
|
||||
* @param {{frontend: boolean, ci: boolean}} areas
|
||||
* @returns {string|null} Area label or null
|
||||
*/
|
||||
function determineAreaLabel(areas) {
|
||||
if (areas.frontend && areas.backend) return 'area/fullstack';
|
||||
if (areas.frontend) return 'area/frontend';
|
||||
if (areas.backend) return 'area/backend';
|
||||
if (areas.ci) return 'area/ci';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/frontend/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
- 'package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Get package version
|
||||
id: package
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/frontend/package.json').version")
|
||||
VERSION=$(node -p "require('./apps/desktop/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package version: $VERSION"
|
||||
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
name: Quality Security
|
||||
|
||||
# CodeQL runs on all PRs, pushes to main, and weekly schedule
|
||||
# Note: CodeQL takes 20-30 min per language (40-60 min total)
|
||||
# Bandit is fast (5-10 min)
|
||||
# Note: CodeQL takes 20-30 min
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'apps/desktop/**'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'apps/desktop/**'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
schedule:
|
||||
@@ -41,7 +36,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
language: [javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -60,91 +55,13 @@ jobs:
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# Bandit runs on all PRs - it's fast (5-10 min)
|
||||
python-security:
|
||||
name: Python Security (Bandit)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Bandit
|
||||
run: pip install bandit
|
||||
|
||||
- name: Run Bandit security scan
|
||||
id: bandit
|
||||
run: |
|
||||
echo "::group::Running Bandit security scan"
|
||||
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
|
||||
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
|
||||
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Analyze Bandit results
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
if (!fs.existsSync('bandit-report.json')) {
|
||||
core.setFailed('Bandit report not found - scan may have failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
|
||||
const results = report.results || [];
|
||||
|
||||
const high = results.filter(r => r.issue_severity === 'HIGH');
|
||||
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
|
||||
const low = results.filter(r => r.issue_severity === 'LOW');
|
||||
|
||||
console.log(`::group::Bandit Security Scan Results`);
|
||||
console.log(`Found ${results.length} issues:`);
|
||||
console.log(` HIGH: ${high.length}`);
|
||||
console.log(` MEDIUM: ${medium.length}`);
|
||||
console.log(` LOW: ${low.length}`);
|
||||
console.log('::endgroup::');
|
||||
|
||||
let summary = `## Python Security Scan (Bandit)\n\n`;
|
||||
summary += `| Severity | Count |\n`;
|
||||
summary += `|----------|-------|\n`;
|
||||
summary += `| High | ${high.length} |\n`;
|
||||
summary += `| Medium | ${medium.length} |\n`;
|
||||
summary += `| Low | ${low.length} |\n\n`;
|
||||
|
||||
if (high.length > 0) {
|
||||
summary += `### High Severity Issues\n\n`;
|
||||
for (const issue of high) {
|
||||
summary += `- **${issue.filename}:${issue.line_number}**\n`;
|
||||
summary += ` - ${issue.issue_text}\n`;
|
||||
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw(summary);
|
||||
await core.summary.write();
|
||||
|
||||
if (high.length > 0) {
|
||||
core.setFailed(`Found ${high.length} high severity security issue(s)`);
|
||||
} else {
|
||||
console.log('No high severity security issues found');
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [codeql, python-security]
|
||||
needs: [codeql]
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -153,19 +70,15 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const codeql = '${{ needs.codeql.result }}';
|
||||
const bandit = '${{ needs.python-security.result }}';
|
||||
|
||||
console.log('Security Check Results:');
|
||||
console.log(` CodeQL: ${codeql}`);
|
||||
console.log(` Bandit: ${bandit}`);
|
||||
|
||||
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters, PR skipping CodeQL)
|
||||
const acceptable = ['success', 'skipped'];
|
||||
const codeqlOk = acceptable.includes(codeql);
|
||||
const banditOk = acceptable.includes(bandit);
|
||||
const allPassed = codeqlOk && banditOk;
|
||||
|
||||
if (allPassed) {
|
||||
if (codeqlOk) {
|
||||
console.log('\n✅ All security checks passed');
|
||||
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
|
||||
} else {
|
||||
|
||||
+31
-118
@@ -29,42 +29,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Rust toolchain (for building native Python packages)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: cd apps/frontend && npm run package:mac -- --x64
|
||||
run: cd apps/desktop && npm run package:mac -- --x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
@@ -86,10 +62,10 @@ jobs:
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
@@ -100,39 +76,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: cd apps/frontend && npm run package:mac -- --arm64
|
||||
run: cd apps/desktop && npm run package:mac -- --arm64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
@@ -154,10 +109,10 @@ jobs:
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
@@ -170,39 +125,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~\AppData\Local\pip\Cache
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
run: cd apps/frontend && npm run package:win
|
||||
run: cd apps/desktop && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
@@ -226,7 +160,7 @@ jobs:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder: apps/desktop/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
@@ -236,7 +170,7 @@ jobs:
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
cd apps/desktop/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
@@ -260,7 +194,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
cd apps/desktop/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
@@ -327,56 +261,35 @@ jobs:
|
||||
with:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
apps/desktop/dist/*.exe
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Setup Flatpak and verification tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
|
||||
sudo apt-get install -y flatpak flatpak-builder squashfs-tools
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: cd apps/frontend && npm run package:linux
|
||||
run: cd apps/desktop && npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
@@ -384,18 +297,18 @@ jobs:
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/frontend && npm run verify:linux
|
||||
run: cd apps/desktop && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
apps/desktop/dist/*.AppImage
|
||||
apps/desktop/dist/*.deb
|
||||
apps/desktop/dist/*.flatpak
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
|
||||
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
|
||||
finalize-notarization:
|
||||
@@ -659,9 +572,9 @@ jobs:
|
||||
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
python3 scripts/update-readme.py "$VERSION" --prerelease
|
||||
node scripts/update-readme.mjs "$VERSION" --prerelease
|
||||
else
|
||||
python3 scripts/update-readme.py "$VERSION"
|
||||
node scripts/update-readme.mjs "$VERSION"
|
||||
fi
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
|
||||
+10
-47
@@ -66,52 +66,10 @@ lerna-debug.log*
|
||||
.update-metadata.json
|
||||
|
||||
# ===========================
|
||||
# Python (apps/backend)
|
||||
# ===========================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
eggs/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.conda/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pytype/
|
||||
.pyre/
|
||||
|
||||
# ===========================
|
||||
# Node.js (apps/frontend)
|
||||
# Node.js (apps/desktop)
|
||||
# ===========================
|
||||
node_modules
|
||||
apps/frontend/node_modules
|
||||
apps/desktop/node_modules
|
||||
.npm
|
||||
.yarn/
|
||||
.pnp.*
|
||||
@@ -120,7 +78,6 @@ apps/frontend/node_modules
|
||||
dist/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
apps/frontend/python-runtime/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
@@ -132,8 +89,8 @@ apps/frontend/python-runtime/
|
||||
# ===========================
|
||||
# Electron
|
||||
# ===========================
|
||||
apps/frontend/dist/
|
||||
apps/frontend/out/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/out/
|
||||
*.asar
|
||||
*.blockmap
|
||||
*.snap
|
||||
@@ -153,6 +110,12 @@ test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# ===========================
|
||||
# Python
|
||||
# ===========================
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ===========================
|
||||
# Misc
|
||||
# ===========================
|
||||
|
||||
+15
-143
@@ -48,26 +48,18 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [ -n "$VERSION" ]; then
|
||||
# Sync to apps/frontend/package.json
|
||||
if [ -f "apps/frontend/package.json" ]; then
|
||||
# Sync to apps/desktop/package.json
|
||||
if [ -f "apps/desktop/package.json" ]; then
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = require('./apps/frontend/package.json');
|
||||
const pkg = require('./apps/desktop/package.json');
|
||||
if (pkg.version !== '$VERSION') {
|
||||
pkg.version = '$VERSION';
|
||||
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(' Updated apps/frontend/package.json to $VERSION');
|
||||
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(' Updated apps/desktop/package.json to $VERSION');
|
||||
}
|
||||
"
|
||||
git add apps/frontend/package.json
|
||||
fi
|
||||
|
||||
# Sync to apps/backend/__init__.py
|
||||
if [ -f "apps/backend/__init__.py" ]; then
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py
|
||||
rm -f apps/backend/__init__.py.bak
|
||||
git add apps/backend/__init__.py
|
||||
echo " Updated apps/backend/__init__.py to $VERSION"
|
||||
git add apps/desktop/package.json
|
||||
fi
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
@@ -119,126 +111,14 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# BACKEND CHECKS (Python) - Run first, before frontend
|
||||
# =============================================================================
|
||||
|
||||
# Check if there are staged Python files in apps/backend
|
||||
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "Python changes detected, running backend checks..."
|
||||
|
||||
# Detect if we're in a worktree
|
||||
IS_WORKTREE=false
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
# Determine ruff command (venv or global)
|
||||
RUFF=""
|
||||
if [ -f "apps/backend/.venv/bin/ruff" ]; then
|
||||
RUFF="apps/backend/.venv/bin/ruff"
|
||||
elif [ -f "apps/backend/.venv/Scripts/ruff.exe" ]; then
|
||||
RUFF="apps/backend/.venv/Scripts/ruff.exe"
|
||||
elif command -v ruff >/dev/null 2>&1; then
|
||||
RUFF="ruff"
|
||||
fi
|
||||
|
||||
if [ -n "$RUFF" ]; then
|
||||
# Get only staged Python files in apps/backend (process only what's being committed)
|
||||
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "^apps/backend/.*\.py$" || true)
|
||||
|
||||
if [ -n "$STAGED_PY_FILES" ]; then
|
||||
# Run ruff linting (auto-fix) only on staged files
|
||||
echo "Running ruff lint on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF check --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix) only on staged files
|
||||
echo "Running ruff format on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF format
|
||||
|
||||
# Re-stage only the files that were originally staged (in case ruff modified them)
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
fi
|
||||
else
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: ruff not available in this worktree."
|
||||
echo " Python linting checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
# Run from repo root (not apps/backend) so tests that use Path.resolve() get correct CWD.
|
||||
# PYTHONPATH includes apps/backend so imports resolve correctly.
|
||||
echo "Running Python tests..."
|
||||
(
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# Also skip tests that require optional dependencies (pydantic structured outputs)
|
||||
# Also skip gitlab_e2e (e2e test sensitive to test-ordering env contamination, validated by CI)
|
||||
IGNORE_TESTS="--ignore=tests/test_graphiti.py --ignore=tests/test_merge_file_tracker.py --ignore=tests/test_service_orchestrator.py --ignore=tests/test_worktree.py --ignore=tests/test_workspace.py --ignore=tests/test_finding_validation.py --ignore=tests/test_sdk_structured_output.py --ignore=tests/test_structured_outputs.py --ignore=tests/test_gitlab_e2e.py"
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
if [ -f "apps/backend/.venv/bin/python" ]; then
|
||||
VENV_PYTHON="apps/backend/.venv/bin/python"
|
||||
elif [ -f "apps/backend/.venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON="apps/backend/.venv/Scripts/python.exe"
|
||||
fi
|
||||
|
||||
# -k "not windows_path": skip tests using fake Windows paths that break
|
||||
# Path.resolve() on macOS/Linux. These are validated by CI on all platforms.
|
||||
if [ -n "$VENV_PYTHON" ]; then
|
||||
# Check if pytest is installed in venv
|
||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||
PYTHONPATH=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: pytest not installed in venv. Installing test dependencies..."
|
||||
$VENV_PYTHON -m pip install -q -r tests/requirements-test.txt
|
||||
PYTHONPATH=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
elif [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Python venv not available in this worktree."
|
||||
echo " Python tests will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
exit 77 # GNU convention for 'test skipped' (avoids pytest exit-code collision)
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
PYTHON_EXIT=$?
|
||||
if [ $PYTHON_EXIT -eq 77 ]; then
|
||||
echo "Backend checks passed! (Python tests skipped — worktree)"
|
||||
elif [ $PYTHON_EXIT -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
else
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# FRONTEND CHECKS (TypeScript/React)
|
||||
# DESKTOP APP CHECKS (TypeScript/React)
|
||||
# =============================================================================
|
||||
|
||||
# Check if there are staged files in apps/frontend
|
||||
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Frontend changes detected, running frontend checks..."
|
||||
# Check if there are staged files in apps/desktop
|
||||
if git diff --cached --name-only | grep -q "^apps/desktop/"; then
|
||||
echo "Desktop app changes detected, running checks..."
|
||||
|
||||
# Detect if we're in a worktree and check if dependencies are available
|
||||
IS_WORKTREE=false
|
||||
@@ -252,11 +132,11 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
|
||||
# Check if node_modules has actual dependencies by looking for a known package
|
||||
# @lydell/node-pty is required for terminal code and is a common source of TypeScript errors
|
||||
# It may be in root node_modules (hoisted) or apps/frontend/node_modules
|
||||
# It may be in root node_modules (hoisted) or apps/desktop/node_modules
|
||||
# Note: -d follows symlinks automatically, so this works for both real dirs and symlinks
|
||||
# We check for the full package path (@lydell/node-pty) rather than just the namespace
|
||||
# for precise detection - ensures the actual dependency is installed, not just any @lydell package
|
||||
if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/frontend/node_modules/@lydell/node-pty" ]; then
|
||||
if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/desktop/node_modules/@lydell/node-pty" ]; then
|
||||
DEPS_AVAILABLE=false
|
||||
fi
|
||||
|
||||
@@ -278,7 +158,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
# Dependencies available - run full frontend checks
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
(
|
||||
cd apps/frontend
|
||||
cd apps/desktop
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
@@ -287,22 +167,14 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run TypeScript type check
|
||||
# Run TypeScript type check (incremental: only rechecks changed files after first run)
|
||||
echo "Running type check..."
|
||||
npm run typecheck
|
||||
NODE_OPTIONS="--max-old-space-size=2048" npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run linting
|
||||
echo "Running lint..."
|
||||
npm run lint
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for vulnerabilities (only critical severity)
|
||||
# Note: Using critical level because electron-builder has a known high-severity
|
||||
# tar vulnerability (CVE-2026-23745) that cannot be fixed until electron-builder
|
||||
|
||||
+11
-67
@@ -18,20 +18,17 @@ repos:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
# Sync to apps/frontend/package.json
|
||||
# Sync to apps/desktop/package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = require('./apps/frontend/package.json');
|
||||
const p = require('./apps/desktop/package.json');
|
||||
const v = process.argv[1];
|
||||
if (p.version !== v) {
|
||||
p.version = v;
|
||||
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(p, null, 2) + '\n');
|
||||
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(p, null, 2) + '\n');
|
||||
}
|
||||
" "$VERSION"
|
||||
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
@@ -70,66 +67,13 @@ repos:
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true
|
||||
git add apps/desktop/package.json README.md 2>/dev/null || true
|
||||
fi
|
||||
language: system
|
||||
files: ^package\.json$
|
||||
pass_filenames: false
|
||||
|
||||
# Python encoding check - prevent regression of UTF-8 encoding fixes (PR #782)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-file-encoding
|
||||
name: Check file encoding parameters
|
||||
entry: python scripts/check_encoding.py
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^apps/backend/
|
||||
description: Ensures all file operations specify encoding="utf-8"
|
||||
|
||||
# Python linting (apps/backend/)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
files: ^apps/backend/
|
||||
- id: ruff-format
|
||||
files: ^apps/backend/
|
||||
|
||||
# Python tests (apps/backend/) - run full test suite from project root
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: Python Tests
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Run pytest directly from project root
|
||||
if [ -f "apps/backend/.venv/bin/pytest" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/bin/pytest"
|
||||
elif [ -f "apps/backend/.venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
$PYTEST_CMD tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=tests/test_graphiti.py \
|
||||
--ignore=tests/test_merge_file_tracker.py \
|
||||
--ignore=tests/test_service_orchestrator.py \
|
||||
--ignore=tests/test_worktree.py \
|
||||
--ignore=tests/test_workspace.py
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/) - Biome is 15-25x faster than ESLint
|
||||
# Frontend linting (apps/desktop/) - Biome is 15-25x faster than ESLint
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
- repo: local
|
||||
hooks:
|
||||
@@ -140,13 +84,13 @@ repos:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
if [ -f ".git" ] && [ ! -d "apps/desktop/node_modules" ]; then
|
||||
echo "Skipping Biome in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npx biome check --write --no-errors-on-unmatched .
|
||||
cd apps/desktop && npx biome check --write --no-errors-on-unmatched .
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx|json)$
|
||||
files: ^apps/desktop/.*\.(ts|tsx|js|jsx|json)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
@@ -156,13 +100,13 @@ repos:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
if [ -f ".git" ] && [ ! -d "apps/desktop/node_modules" ]; then
|
||||
echo "Skipping TypeScript check in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run typecheck
|
||||
cd apps/desktop && npm run typecheck
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx)$
|
||||
files: ^apps/desktop/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
# General checks
|
||||
|
||||
+10
-10
@@ -1246,17 +1246,17 @@
|
||||
- feat(python): bundle Python 3.12 with packaged Electron app (#284) by @Andy in 7f19c2e1
|
||||
- fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281) by @Todd W. Bucy in d98e2830
|
||||
- fix(ci): add write permissions to beta-release update-version job by @AndyMik90 in 0b874d4b
|
||||
- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270) by @dependabot[bot] in 50dd1078
|
||||
- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/desktop (#270) by @dependabot[bot] in 50dd1078
|
||||
- fix(github): resolve follow-up review API issues by @AndyMik90 in f1cc5a09
|
||||
- fix(security): resolve CodeQL file system race conditions and unused variables (#277) by @Andy in b005fa5c
|
||||
- fix(ci): use correct electron-builder arch flags (#278) by @Andy in d79f2da4
|
||||
- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268) by @dependabot[bot] in 5ac566e2
|
||||
- chore(deps): bump typescript-eslint in /apps/frontend (#269) by @dependabot[bot] in f49d4817
|
||||
- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/desktop (#268) by @dependabot[bot] in 5ac566e2
|
||||
- chore(deps): bump typescript-eslint in /apps/desktop (#269) by @dependabot[bot] in f49d4817
|
||||
- fix(ci): use develop branch for dry-run builds in beta-release workflow (#276) by @Andy in 1e1d7d9b
|
||||
- fix: accept bug_fix workflow_type alias during planning (#240) by @Daniel Frey in e74a3dff
|
||||
- fix(paths): normalize relative paths to posix (#239) by @Daniel Frey in 6ac8250b
|
||||
- chore(deps): bump @electron/rebuild in /apps/frontend (#271) by @dependabot[bot] in a2cee694
|
||||
- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272) by @dependabot[bot] in d4cad80a
|
||||
- chore(deps): bump @electron/rebuild in /apps/desktop (#271) by @dependabot[bot] in a2cee694
|
||||
- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/desktop (#272) by @dependabot[bot] in d4cad80a
|
||||
- feat(github): add automated PR review with follow-up support (#252) by @Andy in 596e9513
|
||||
- ci: implement enterprise-grade PR quality gates and security scanning (#266) by @Alex in d42041c5
|
||||
- fix: update path resolution for ollama_model_detector.py in memory handlers (#263) by @delyethan in a3f87540
|
||||
@@ -1526,17 +1526,17 @@
|
||||
- feat(python): bundle Python 3.12 with packaged Electron app (#284) by @Andy in 7f19c2e1
|
||||
- fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281) by @Todd W. Bucy in d98e2830
|
||||
- fix(ci): add write permissions to beta-release update-version job by @AndyMik90 in 0b874d4b
|
||||
- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270) by @dependabot[bot] in 50dd1078
|
||||
- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/desktop (#270) by @dependabot[bot] in 50dd1078
|
||||
- fix(github): resolve follow-up review API issues by @AndyMik90 in f1cc5a09
|
||||
- fix(security): resolve CodeQL file system race conditions and unused variables (#277) by @Andy in b005fa5c
|
||||
- fix(ci): use correct electron-builder arch flags (#278) by @Andy in d79f2da4
|
||||
- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268) by @dependabot[bot] in 5ac566e2
|
||||
- chore(deps): bump typescript-eslint in /apps/frontend (#269) by @dependabot[bot] in f49d4817
|
||||
- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/desktop (#268) by @dependabot[bot] in 5ac566e2
|
||||
- chore(deps): bump typescript-eslint in /apps/desktop (#269) by @dependabot[bot] in f49d4817
|
||||
- fix(ci): use develop branch for dry-run builds in beta-release workflow (#276) by @Andy in 1e1d7d9b
|
||||
- fix: accept bug_fix workflow_type alias during planning (#240) by @Daniel Frey in e74a3dff
|
||||
- fix(paths): normalize relative paths to posix (#239) by @Daniel Frey in 6ac8250b
|
||||
- chore(deps): bump @electron/rebuild in /apps/frontend (#271) by @dependabot[bot] in a2cee694
|
||||
- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272) by @dependabot[bot] in d4cad80a
|
||||
- chore(deps): bump @electron/rebuild in /apps/desktop (#271) by @dependabot[bot] in a2cee694
|
||||
- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/desktop (#272) by @dependabot[bot] in d4cad80a
|
||||
- feat(github): add automated PR review with follow-up support (#252) by @Andy in 596e9513
|
||||
- ci: implement enterprise-grade PR quality gates and security scanning (#266) by @Alex in d42041c5
|
||||
- fix: update path resolution for ollama_model_detector.py in memory handlers (#263) by @delyethan in a3f87540
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
Auto Claude is an autonomous multi-agent coding framework that plans, builds, and validates software for you. It's a monorepo with a Python backend (CLI + agent logic) and an Electron/React frontend (desktop UI).
|
||||
Auto Claude is an autonomous multi-agent coding framework that plans, builds, and validates software for you. It's a TypeScript-first Electron desktop application with a self-contained AI agent layer (Vercel AI SDK v6). A lightweight Python sidecar provides the optional Graphiti memory system.
|
||||
|
||||
> **Deep-dive reference:** [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md) | **Frontend contributing:** [apps/frontend/CONTRIBUTING.md](apps/frontend/CONTRIBUTING.md)
|
||||
> **Deep-dive reference:** [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md) | **Frontend contributing:** [apps/desktop/CONTRIBUTING.md](apps/desktop/CONTRIBUTING.md)
|
||||
|
||||
## Product Overview
|
||||
|
||||
@@ -30,11 +30,11 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**Claude Agent SDK only** — All AI interactions use `claude-agent-sdk` because it handles security hooks, tool permissions, and MCP server integration. Use `create_client()` from `core.client`, not `anthropic.Anthropic()` directly.
|
||||
**Vercel AI SDK only** — All AI interactions use the Vercel AI SDK v6 (`ai` package) via the TypeScript agent layer in `apps/desktop/src/main/ai/`. NEVER use `@anthropic-ai/sdk` or `anthropic.Anthropic()` directly. Use `createProvider()` from `ai/providers/factory.ts` and `streamText()`/`generateText()` from the `ai` package. Provider-specific adapters (e.g., `@ai-sdk/anthropic`, `@ai-sdk/openai`) are managed through the provider registry.
|
||||
|
||||
**i18n required** — All frontend user-facing text uses `react-i18next` translation keys. Hardcoded strings in JSX/TSX break localization for non-English users. Add keys to both `en/*.json` and `fr/*.json`.
|
||||
|
||||
**Platform abstraction** — Use the platform modules in `apps/frontend/src/main/platform/` or `apps/backend/core/platform/` instead of `process.platform` directly. CI tests all three platforms, and raw platform checks cause failures.
|
||||
**Platform abstraction** — Never use `process.platform` directly. Import from `apps/desktop/src/main/platform/`. CI tests all three platforms.
|
||||
|
||||
**No time estimates** — Provide priority-based ordering instead of duration predictions.
|
||||
|
||||
@@ -94,29 +94,28 @@ To fully clear all PR review data so reviews run fresh, delete/reset these three
|
||||
```
|
||||
autonomous-coding/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python backend/CLI — ALL agent logic
|
||||
│ │ ├── core/ # client.py, auth.py, worktree.py, platform/
|
||||
│ │ ├── security/ # Command allowlisting, validators, hooks
|
||||
│ │ ├── agents/ # planner, coder, session management
|
||||
│ │ ├── qa/ # reviewer, fixer, loop, criteria
|
||||
│ │ ├── spec/ # Spec creation pipeline
|
||||
│ │ ├── cli/ # CLI commands (spec, build, workspace, QA)
|
||||
│ │ ├── context/ # Task context building, semantic search
|
||||
│ │ ├── runners/ # Standalone runners (spec, roadmap, insights, github)
|
||||
│ │ ├── services/ # Background services, recovery orchestration
|
||||
│ │ ├── integrations/ # graphiti/, linear, github
|
||||
│ │ ├── project/ # Project analysis, security profiles
|
||||
│ │ ├── merge/ # Intent-aware semantic merge for parallel agents
|
||||
│ │ └── prompts/ # Agent system prompts (.md)
|
||||
│ └── frontend/ # Electron desktop UI
|
||||
│ └── desktop/ # Electron desktop application (sole app)
|
||||
│ ├── prompts/ # Agent system prompts (.md)
|
||||
│ └── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── ai/ # TypeScript AI agent layer (Vercel AI SDK v6)
|
||||
│ │ │ ├── providers/ # Multi-provider registry + factory (9+ providers)
|
||||
│ │ │ ├── tools/ # Builtin tools (Read, Write, Edit, Bash, Glob, Grep, etc.)
|
||||
│ │ │ ├── security/ # Bash validator, command parser, path containment
|
||||
│ │ │ ├── config/ # Agent configs (25+ types), phase config, model resolution
|
||||
│ │ │ ├── session/ # streamText() agent loop, error classification, progress
|
||||
│ │ │ ├── agent/ # Worker thread executor + bridge
|
||||
│ │ │ ├── orchestration/ # Build pipeline (planner → coder → QA)
|
||||
│ │ │ ├── runners/ # Utility runners (insights, roadmap, PR review, etc.)
|
||||
│ │ │ ├── mcp/ # MCP client integration
|
||||
│ │ │ ├── client/ # Client factory convenience constructors
|
||||
│ │ │ └── auth/ # Token resolution (reuses claude-profile/)
|
||||
│ │ ├── agent/ # Agent queue, process, state, events
|
||||
│ │ ├── claude-profile/ # Multi-profile credentials, token refresh, usage
|
||||
│ │ ├── terminal/ # PTY daemon, lifecycle, Claude integration
|
||||
│ │ ├── platform/ # Cross-platform abstraction
|
||||
│ │ ├── ipc-handlers/# 40+ handler modules by domain
|
||||
│ │ ├── services/ # SDK session recovery, profile service
|
||||
│ │ ├── services/ # Session recovery, profile service
|
||||
│ │ └── changelog/ # Changelog generation and formatting
|
||||
│ ├── preload/ # Electron preload scripts (electronAPI bridge)
|
||||
│ ├── renderer/ # React UI
|
||||
@@ -133,7 +132,6 @@ autonomous-coding/
|
||||
│ │ └── utils/ # ANSI sanitizer, shell escape, provider detection
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── guides/ # Documentation
|
||||
├── tests/ # Backend test suite
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
|
||||
@@ -143,18 +141,15 @@ autonomous-coding/
|
||||
```bash
|
||||
npm run install:all # Install all dependencies from root
|
||||
# Or separately:
|
||||
cd apps/backend && uv venv && uv pip install -r requirements.txt
|
||||
cd apps/frontend && npm install
|
||||
cd apps/desktop && npm install
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
| Stack | Command | Tool |
|
||||
|-------|---------|------|
|
||||
| Backend | `apps/backend/.venv/bin/pytest tests/ -v` | pytest |
|
||||
| Frontend unit | `cd apps/frontend && npm test` | Vitest |
|
||||
| Frontend E2E | `cd apps/frontend && npm run test:e2e` | Playwright |
|
||||
| All backend | `npm run test:backend` (from root) | pytest |
|
||||
| Frontend unit | `cd apps/desktop && npm test` | Vitest |
|
||||
| Frontend E2E | `cd apps/desktop && npm run test:e2e` | Playwright |
|
||||
|
||||
### Releases
|
||||
```bash
|
||||
@@ -164,15 +159,53 @@ git push && gh pr create --base main # PR to main triggers release
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for full release process.
|
||||
|
||||
## Backend Development
|
||||
## AI Agent Layer (`apps/desktop/src/main/ai/`)
|
||||
|
||||
### Claude Agent SDK Usage
|
||||
All AI agent logic lives in TypeScript using the Vercel AI SDK v6. This replaces the previous Python `claude-agent-sdk` integration.
|
||||
|
||||
Client: `apps/backend/core/client.py` — `create_client()` returns a configured `ClaudeSDKClient` with security hooks, tool permissions, and MCP server integration.
|
||||
### Architecture Overview
|
||||
|
||||
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values
|
||||
- **Provider Layer** (`providers/`) — Multi-provider support via `createProviderRegistry()`. Supports Anthropic, OpenAI, Google, Bedrock, Azure, Mistral, Groq, xAI, and Ollama. Provider-specific transforms handle thinking token normalization and prompt caching.
|
||||
- **Session Runtime** (`session/`) — `runAgentSession()` uses `streamText()` with `stopWhen: stepCountIs(N)` for agentic tool-use loops. Includes error classification (429/401/400) and progress tracking.
|
||||
- **Worker Threads** (`agent/`) — Agent sessions run in `worker_threads` to avoid blocking the Electron main process. The `WorkerBridge` relays `postMessage()` events to the existing `AgentManagerEvents` interface.
|
||||
- **Build Orchestration** (`orchestration/`) — Full planner → coder → QA pipeline. Parallel subagent execution via `Promise.allSettled()`.
|
||||
- **Tools** (`tools/`) — 8 builtin tools (Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch) defined with Zod schemas via AI SDK `tool()`.
|
||||
- **Security** (`security/`) — Bash validator, command parser, and path containment ported from Python with identical allowlist behavior.
|
||||
- **Config** (`config/`) — `AGENT_CONFIGS` registry (25+ agent types), phase-aware model resolution, thinking budgets.
|
||||
|
||||
### Agent Prompts (`apps/backend/prompts/`)
|
||||
### Key Patterns
|
||||
|
||||
```typescript
|
||||
// Agent session using streamText()
|
||||
import { streamText, stepCountIs } from 'ai';
|
||||
|
||||
const result = streamText({
|
||||
model: provider,
|
||||
system: systemPrompt,
|
||||
messages: conversationHistory,
|
||||
tools: toolRegistry.getToolsForAgent(agentType),
|
||||
stopWhen: stepCountIs(1000),
|
||||
onStepFinish: ({ toolCalls, text, usage }) => {
|
||||
progressTracker.update(toolCalls, text);
|
||||
},
|
||||
});
|
||||
|
||||
// Tool definition with Zod schema
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const readTool = tool({
|
||||
description: 'Read a file from the filesystem',
|
||||
inputSchema: z.object({
|
||||
file_path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
execute: async ({ file_path, offset, limit }) => { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
### Agent Prompts (`apps/desktop/prompts/`)
|
||||
|
||||
| Prompt | Purpose |
|
||||
|--------|---------|
|
||||
@@ -188,13 +221,13 @@ Each spec in `.auto-claude/specs/XXX-name/` contains: `spec.md`, `requirements.j
|
||||
|
||||
### Memory System (Graphiti)
|
||||
|
||||
Graph-based semantic memory in `integrations/graphiti/`. Configured through the Electron app's onboarding/settings UI (CLI users can alternatively set `GRAPHITI_ENABLED=true` in `.env`). See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#memory-system) for details.
|
||||
Graph-based semantic memory accessed via a Python MCP sidecar (lives outside `apps/desktop/`). The AI layer connects to it via `createMCPClient` from `@ai-sdk/mcp`. Configured through the Electron app's onboarding/settings UI. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#memory-system) for details.
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Tech Stack
|
||||
|
||||
React 19, TypeScript (strict), Electron 39, Zustand 5, Tailwind CSS v4, Radix UI, xterm.js 6, Vite 7, Vitest 4, Biome 2, Motion (Framer Motion)
|
||||
React 19, TypeScript (strict), Electron 39, Vercel AI SDK v6, Zustand 5, Tailwind CSS v4, Radix UI, xterm.js 6, Vite 7, Vitest 4, Biome 2, Motion (Framer Motion)
|
||||
|
||||
### Path Aliases (tsconfig.json)
|
||||
|
||||
@@ -240,9 +273,9 @@ Main ↔ Renderer communication via Electron IPC:
|
||||
|
||||
The frontend manages agent lifecycle end-to-end:
|
||||
- **`agent-queue.ts`** — Queue routing, prioritization, spec number locking
|
||||
- **`agent-process.ts`** — Spawns and manages agent subprocess communication
|
||||
- **`agent-process.ts`** — Spawns worker threads via `WorkerBridge` for agent execution
|
||||
- **`agent-state.ts`** — Tracks running agent state and status
|
||||
- **`agent-events.ts`** — Agent lifecycle events and state transitions
|
||||
- **`agent-events.ts`** — Agent lifecycle events and state transitions (structured events from worker threads)
|
||||
|
||||
### Claude Profile System (`src/main/claude-profile/`)
|
||||
|
||||
@@ -268,13 +301,10 @@ Full PTY-based terminal integration:
|
||||
- **Pre-commit:** Husky + lint-staged runs Biome on staged `.ts/.tsx/.js/.jsx/.json`
|
||||
- **Testing:** Vitest + React Testing Library + jsdom
|
||||
|
||||
### Backend
|
||||
- **Linting:** Ruff
|
||||
- **Testing:** pytest (`apps/backend/.venv/bin/pytest tests/ -v`)
|
||||
|
||||
## i18n Guidelines
|
||||
|
||||
All frontend UI text uses `react-i18next`. Translation files: `apps/frontend/src/shared/i18n/locales/{en,fr}/*.json`
|
||||
All frontend UI text uses `react-i18next`. Translation files: `apps/desktop/src/shared/i18n/locales/{en,fr}/*.json`
|
||||
|
||||
**Namespaces:** `common`, `navigation`, `settings`, `dialogs`, `tasks`, `errors`, `onboarding`, `welcome`
|
||||
|
||||
@@ -295,7 +325,7 @@ When adding new UI text: add keys to ALL language files, use `namespace:section.
|
||||
|
||||
Supports Windows, macOS, Linux. CI tests all three.
|
||||
|
||||
**Platform modules:** `apps/frontend/src/main/platform/` and `apps/backend/core/platform/`
|
||||
**Platform modules:** `apps/desktop/src/main/platform/`
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
@@ -311,17 +341,14 @@ Use `findExecutable()` and `joinPaths()` instead of hardcoded paths. See [ARCHIT
|
||||
QA agents can interact with the running Electron app via Chrome DevTools Protocol:
|
||||
|
||||
1. Start app: `npm run dev:debug` (debug mode for AI self-validation via Electron MCP)
|
||||
2. Set `ELECTRON_MCP_ENABLED=true` in `apps/backend/.env`
|
||||
3. Run QA: `python run.py --spec 001 --qa`
|
||||
2. Enable Electron MCP in settings
|
||||
3. QA runs automatically through the TypeScript agent pipeline
|
||||
|
||||
Tools: `take_screenshot`, `click_by_text`, `fill_input`, `get_page_structure`, `send_keyboard_shortcut`, `eval`. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#end-to-end-testing) for full capabilities.
|
||||
|
||||
## Running the Application
|
||||
|
||||
```bash
|
||||
# CLI only
|
||||
cd apps/backend && python run.py --spec 001
|
||||
|
||||
# Desktop app
|
||||
npm start # Production build + run
|
||||
npm run dev # Development mode with HMR
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# Codex Rate Limit Monitoring — Full System Research
|
||||
|
||||
> Temporary research file. Delete after implementation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Codex Usage API](#1-codex-usage-api)
|
||||
2. [Current System Architecture](#2-current-system-architecture)
|
||||
3. [Anthropic-Hardcoded Locations](#3-anthropic-hardcoded-locations)
|
||||
4. [Provider-Agnostic Parts (No Changes Needed)](#4-provider-agnostic-parts)
|
||||
5. [Implementation Plan](#5-implementation-plan)
|
||||
|
||||
---
|
||||
|
||||
## 1. Codex Usage API
|
||||
|
||||
**Sources:** OpenAI Codex source code (`github.com/openai/codex`, Rust codebase), CodexBar macOS app (`github.com/steipete/CodexBar`), Context7 Codex developer docs.
|
||||
|
||||
### 1.1 Active Polling Endpoint
|
||||
|
||||
```
|
||||
GET https://chatgpt.com/backend-api/wham/usage
|
||||
```
|
||||
|
||||
Fallback (when base URL doesn't contain `/backend-api`):
|
||||
```
|
||||
GET {base_url}/api/codex/usage
|
||||
```
|
||||
|
||||
**Required Headers:**
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
ChatGPT-Account-Id: <account_id>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
- `access_token` — The OAuth access token from `auth.openai.com` (same token our `codex-oauth.ts` already obtains)
|
||||
- `account_id` — Account UUID from OAuth token data. Stored in `~/.codex/auth.json` under `tokens.account_id`. Optional per CodexBar ("when available") but may be required.
|
||||
|
||||
### 1.2 Response Schema
|
||||
|
||||
From `codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 96,
|
||||
"limit_window_seconds": 18000,
|
||||
"reset_after_seconds": 673,
|
||||
"reset_at": 1730947200
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 70,
|
||||
"limit_window_seconds": 604800,
|
||||
"reset_after_seconds": 43200,
|
||||
"reset_at": 1730980800
|
||||
}
|
||||
},
|
||||
"credits": {
|
||||
"has_credits": false,
|
||||
"unlimited": true,
|
||||
"balance": null
|
||||
},
|
||||
"additional_rate_limits": [
|
||||
{
|
||||
"limit_name": "codex_other",
|
||||
"metered_feature": "codex_other",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 70,
|
||||
"limit_window_seconds": 3600,
|
||||
"reset_after_seconds": 1800,
|
||||
"reset_at": 1730947200
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `primary_window` = 5h session (18000s). Maps to our `sessionPercent`.
|
||||
- `secondary_window` = Weekly (604800s = 7d). Maps to our `weeklyPercent`.
|
||||
- `reset_at` = Unix timestamp (seconds). Convert to ms for our `sessionResetTimestamp`/`weeklyResetTimestamp`.
|
||||
- `plan_type` values: `guest`, `free`, `go`, `plus`, `pro`, `free_workspace`, `team`, `business`, `education`, `quorum`, `k12`, `enterprise`, `edu`
|
||||
|
||||
### 1.3 Passive Headers (From API Responses)
|
||||
|
||||
Rate limit data is also returned in HTTP response headers on every `/v1/responses` call:
|
||||
|
||||
```
|
||||
x-codex-primary-used-percent → float (e.g., "25.0")
|
||||
x-codex-primary-window-minutes → integer (e.g., "300" for 5h)
|
||||
x-codex-primary-reset-at → unix timestamp seconds
|
||||
x-codex-secondary-used-percent → float (weekly)
|
||||
x-codex-secondary-window-minutes → integer
|
||||
x-codex-secondary-reset-at → unix timestamp seconds
|
||||
x-codex-credits-has-credits → "true" or "false"
|
||||
x-codex-credits-unlimited → "true" or "false"
|
||||
x-codex-credits-balance → decimal string e.g. "9.99"
|
||||
```
|
||||
|
||||
SSE event type `codex.rate_limits` also carries this data inline in streaming responses.
|
||||
|
||||
### 1.4 Token Details
|
||||
|
||||
Our `codex-oauth.ts` already uses the correct flow:
|
||||
- **Client ID:** `app_EMoamEEZ73f0CkXaXp7hrann` (same as Codex CLI)
|
||||
- **Auth endpoint:** `https://auth.openai.com/oauth/authorize`
|
||||
- **Token endpoint:** `https://auth.openai.com/oauth/token`
|
||||
- **Scopes:** `openid profile email offline_access`
|
||||
- **Refresh:** `POST https://auth.openai.com/oauth/token` with `grant_type=refresh_token`
|
||||
|
||||
**Missing:** `account_id` for the `ChatGPT-Account-Id` header. Options:
|
||||
1. Decode from the JWT access token
|
||||
2. Read from `~/.codex/auth.json` (`tokens.account_id`)
|
||||
3. Extract during OAuth token exchange (may be in response)
|
||||
4. Try without it first (optional per CodexBar docs)
|
||||
|
||||
---
|
||||
|
||||
## 2. Current System Architecture
|
||||
|
||||
### 2.1 Two Parallel Account Systems
|
||||
|
||||
The app has TWO account management systems that don't fully integrate:
|
||||
|
||||
**System A: Legacy Claude Profile Manager (Main Process)**
|
||||
- `claude-profile-manager.ts` — Manages OAuth profiles, rate limits, usage, auto-swap
|
||||
- `claude-profiles.json` — Stores profiles with `activeProfileId`, `accountPriorityOrder`
|
||||
- `usage-monitor.ts` — Polls Anthropic's `/api/oauth/usage` endpoint every 30s
|
||||
- `token-refresh.ts` — Refreshes tokens via `console.anthropic.com/v1/oauth/token`
|
||||
- `rate-limit-detector.ts` — Detects rate limits, triggers auto-swap
|
||||
- `profile-scorer.ts` — Scores profiles by availability for auto-swap
|
||||
- **100% Anthropic-specific.** Only knows about Anthropic OAuth tokens, Anthropic endpoints, Anthropic keychain format.
|
||||
|
||||
**System B: Multi-Provider Accounts (Renderer + Settings)**
|
||||
- `ProviderAccount[]` in `settings-store.ts` — All connected accounts (any provider)
|
||||
- `globalPriorityOrder: string[]` in AppSettings — Manual priority queue
|
||||
- `useActiveProvider()` hook — First account in priority order = active
|
||||
- **Provider-agnostic.** Works for all 10 providers. But has NO usage monitoring, NO auto-swap.
|
||||
|
||||
**The gap:** System A handles usage monitoring + auto-swap but only for Anthropic. System B handles multi-provider accounts but has no usage awareness.
|
||||
|
||||
### 2.2 Data Flow: Usage Polling
|
||||
|
||||
```
|
||||
UsageMonitor.start() → 30s interval
|
||||
↓
|
||||
checkUsageAndSwap()
|
||||
├─ determineActiveProfile() ← Hardcoded: defaults to anthropic baseUrl
|
||||
├─ getCredential() ← Hardcoded: reads from Anthropic keychain
|
||||
│ └─ ensureValidToken(configDir) ← Hardcoded: refreshes via Anthropic endpoint
|
||||
├─ fetchUsageViaAPI() ← Hardcoded: only allows anthropic/zai/zhipu domains
|
||||
│ ├─ getUsageEndpoint(provider) ← Only 3 providers configured
|
||||
│ ├─ Add anthropic-specific headers ← if (provider === 'anthropic') add beta headers
|
||||
│ └─ Parse response ← Provider-specific normalization
|
||||
├─ emit('usage-updated') → IPC 'claude:usageUpdated' → renderer
|
||||
├─ emit('all-profiles-usage-updated') → IPC 'claude:allProfilesUsageUpdated' → renderer
|
||||
└─ checkThresholdsExceeded()
|
||||
└─ performProactiveSwap() ← Only swaps Anthropic profiles
|
||||
```
|
||||
|
||||
### 2.3 Data Flow: Account Swapping
|
||||
|
||||
**Manual swap (UI):**
|
||||
```
|
||||
User clicks account in UsageIndicator popover
|
||||
→ handleSwapAccount(accountId)
|
||||
→ setQueueOrder([accountId, ...rest]) ← Reorders globalPriorityOrder
|
||||
→ requestUsageUpdate() ← Refreshes usage display
|
||||
```
|
||||
|
||||
**Automatic swap (rate limit hit):**
|
||||
```
|
||||
SDK operation fails with 429
|
||||
→ detectRateLimit(output) ← Pattern: "Limit reached · resets..."
|
||||
→ recordRateLimitEvent(profileId)
|
||||
→ getBestAvailableProfileEnv()
|
||||
→ profileManager.setActiveProfile() ← Only updates claude-profiles.json
|
||||
→ usageMonitor.getAllProfilesUsage() ← Refreshes UI
|
||||
← Returns new profile env vars
|
||||
```
|
||||
|
||||
**Problem:** Auto-swap updates `claude-profiles.json` but NOT `globalPriorityOrder`. The renderer's priority queue may be out of sync.
|
||||
|
||||
### 2.4 UI Components
|
||||
|
||||
| Component | What it shows | Provider-specific? |
|
||||
|---|---|---|
|
||||
| `AuthStatusIndicator` | Provider badge (OpenAI/Anthropic) + auth type label | Codex = green "Codex", Anthropic = orange "OAuth" |
|
||||
| `UsageIndicator` | Usage bars OR "Subscription" OR "Unlimited" | Anthropic OAuth = bars, Codex OAuth = "Subscription", API = "Unlimited" |
|
||||
| `ProviderAccountCard` | Account card in settings with usage bars | Shows usage bars only when `account.usage` populated (Anthropic only) |
|
||||
| `ProviderAccountsList` | All accounts grouped by provider | Generic, but re-auth routes differ per provider |
|
||||
| `AddAccountDialog` | OAuth flow + account creation | Different flows: Codex → `codexAuthLogin()`, Anthropic → `claudeAuthLoginSubprocess()` |
|
||||
| `ProviderSection` | Provider group with "Add" buttons | Button label: "Add Codex Subscription" vs "Add OAuth" |
|
||||
|
||||
### 2.5 Type Naming
|
||||
|
||||
Types use "Claude" prefix but are structurally generic:
|
||||
```typescript
|
||||
ClaudeUsageSnapshot → { sessionPercent, weeklyPercent, resetTimestamps, profileId, ... }
|
||||
ClaudeUsageData → { sessionUsagePercent, weeklyUsagePercent }
|
||||
ClaudeRateLimitEvent → { type, hitAt, resetAt }
|
||||
ProfileUsageSummary → { sessionPercent, weeklyPercent, availabilityScore, ... }
|
||||
AllProfilesUsage → { activeProfile, allProfiles[], fetchedAt }
|
||||
```
|
||||
|
||||
These types work perfectly for Codex data — same session/weekly model. No structural changes needed, just need to populate them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Anthropic-Hardcoded Locations
|
||||
|
||||
### 3.1 CRITICAL — Must Change
|
||||
|
||||
| File | Line(s) | What's hardcoded | What to do |
|
||||
|---|---|---|---|
|
||||
| `usage-monitor.ts:45-49` | `ALLOWED_USAGE_API_DOMAINS` | Only `api.anthropic.com`, `api.z.ai`, `open.bigmodel.cn` | Add `chatgpt.com` |
|
||||
| `usage-monitor.ts:60-73` | `PROVIDER_USAGE_ENDPOINTS` | Only anthropic/zai/zhipu paths | Add `{ provider: 'openai', usagePath: '/wham/usage' }` |
|
||||
| `usage-monitor.ts:662,1069,1346,1359` | `baseUrl: 'https://api.anthropic.com'` | Hardcoded fallback for all OAuth profiles | Detect provider from account, use `chatgpt.com/backend-api` for Codex |
|
||||
| `usage-monitor.ts:1424` | `if (provider === 'anthropic')` adds beta headers | Anthropic-specific `anthropic-beta` header | Add `else if (provider === 'openai')` to add `ChatGPT-Account-Id` header |
|
||||
| `token-refresh.ts:31` | `ANTHROPIC_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token'` | Only Anthropic refresh endpoint | Route to `auth.openai.com/oauth/token` for Codex |
|
||||
| `token-refresh.ts:37` | `CLAUDE_CODE_CLIENT_ID = '9d1c250a-...'` | Only Anthropic client ID | Use `app_EMoamEEZ73f0CkXaXp7hrann` for Codex |
|
||||
| `UsageIndicator.tsx:118` | `provider === 'anthropic' && authType === 'oauth'` | Only Anthropic gets usage bars | Add `\|\| provider === 'openai'` |
|
||||
|
||||
### 3.2 MODERATE — Should Change
|
||||
|
||||
| File | Line(s) | What's hardcoded | What to do |
|
||||
|---|---|---|---|
|
||||
| `usage-monitor.ts:1040-1072` | `determineActiveProfile()` | Returns `baseUrl: 'https://api.anthropic.com'` for all OAuth | Detect provider, return `chatgpt.com/backend-api` for Codex |
|
||||
| `credential-utils.ts` | Keychain service names | `"Claude Code-credentials"` | Codex tokens stored differently (file-based, not keychain) |
|
||||
| `usage-monitor.ts:1513` | `if (provider === 'zai' \|\| provider === 'zhipu')` | Provider-specific response unwrapping | Add Codex response parsing (different JSON structure) |
|
||||
| `rate-limit-detector.ts:14` | `RATE_LIMIT_PATTERN` | Claude-specific: `"Limit reached · resets..."` | Add Codex-specific patterns |
|
||||
| IPC channel names | `'claude:usageUpdated'`, `'claude:allProfilesUsageUpdated'` | "claude" prefix | Cosmetic — rename to `'usage:updated'` etc. (optional, low priority) |
|
||||
|
||||
### 3.3 LOW PRIORITY — Nice to Have
|
||||
|
||||
| Item | What | Why low priority |
|
||||
|---|---|---|
|
||||
| Type naming | `ClaudeUsageSnapshot` → `UsageSnapshot` | Structural refactor, types work as-is for Codex |
|
||||
| IPC method names | `requestUsageUpdate` returns `ClaudeUsageSnapshot` | Works fine, just naming |
|
||||
| `claudeProfileId` on `ProviderAccount` | Only used for Anthropic OAuth | Codex doesn't need it |
|
||||
|
||||
---
|
||||
|
||||
## 4. Provider-Agnostic Parts
|
||||
|
||||
These components already work for any provider and need NO changes:
|
||||
|
||||
| Component/Module | Why it's already generic |
|
||||
|---|---|
|
||||
| `profile-scorer.ts` | Scores by `billingModel`, usage thresholds, rate limit events — no provider checks |
|
||||
| `rate-limit-manager.ts` | Stores/checks rate limit events — pure data, no provider logic |
|
||||
| `operation-registry.ts` | Tracks running operations — no provider awareness |
|
||||
| `ProviderAccount` type | Has `provider` field, `billingModel`, `usage` — works for any provider |
|
||||
| `globalPriorityOrder` | Array of account IDs — provider-agnostic ordering |
|
||||
| `useActiveProvider()` hook | Returns first account in priority order — generic |
|
||||
| `ProviderAccountCard` | Shows usage bars when `account.usage` is populated — will work for Codex once data flows |
|
||||
| `AddAccountDialog` | Already has separate Codex OAuth flow |
|
||||
| `AuthStatusIndicator` | Already shows Codex-specific green badge |
|
||||
| All i18n keys | Codex-specific labels already exist |
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Phase 1: Codex Usage Fetcher (Core)
|
||||
|
||||
Create `apps/desktop/src/main/claude-profile/codex-usage-fetcher.ts`:
|
||||
|
||||
```typescript
|
||||
// Responsibilities:
|
||||
// 1. Read Codex OAuth token (from our codex-auth.json)
|
||||
// 2. Read account_id (from ~/.codex/auth.json or JWT decode)
|
||||
// 3. Call GET https://chatgpt.com/backend-api/wham/usage
|
||||
// 4. Parse response into ClaudeUsageSnapshot format
|
||||
// 5. Handle 401 → refresh token via codex-oauth.ts
|
||||
// 6. Handle 403 → mark as needsReauthentication
|
||||
```
|
||||
|
||||
**Key function:**
|
||||
```typescript
|
||||
async function fetchCodexUsage(accessToken: string, accountId?: string): Promise<ClaudeUsageSnapshot>
|
||||
```
|
||||
|
||||
### Phase 2: Wire into Usage Monitor
|
||||
|
||||
Modify `usage-monitor.ts`:
|
||||
|
||||
1. Add `chatgpt.com` to `ALLOWED_USAGE_API_DOMAINS`
|
||||
2. Add Codex to `PROVIDER_USAGE_ENDPOINTS`
|
||||
3. Update `determineActiveProfile()` to detect Codex accounts from `globalPriorityOrder`
|
||||
4. Update `getCredential()` to read Codex OAuth token (from `codex-auth.json`)
|
||||
5. Update `fetchUsageViaAPI()` to handle Codex response format
|
||||
6. Add Codex-specific headers (`ChatGPT-Account-Id`)
|
||||
7. Add Codex response parsing (different JSON structure than Anthropic)
|
||||
|
||||
### Phase 3: Token Refresh Routing
|
||||
|
||||
Modify `token-refresh.ts` or create parallel Codex path:
|
||||
|
||||
- When refreshing a Codex token, use `auth.openai.com/oauth/token` with Codex client ID
|
||||
- When refreshing an Anthropic token, use `console.anthropic.com/v1/oauth/token` with Claude client ID
|
||||
- Provider detection: check the account's `provider` field, or detect from token prefix
|
||||
|
||||
### Phase 4: UI Updates
|
||||
|
||||
1. `UsageIndicator.tsx:118` — Add `|| provider === 'openai'` to `hasUsageMonitoring`
|
||||
2. That's it — the rest of the UI already handles usage bars, reset times, multi-profile display generically
|
||||
|
||||
### Phase 5: Auto-Swap for Codex
|
||||
|
||||
1. Add Codex-specific rate limit patterns to `rate-limit-detector.ts`
|
||||
2. Codex returns `"codexErrorInfo": "UsageLimitExceeded"` on limit hit
|
||||
3. Auto-swap logic in `profile-scorer.ts` already works — it just needs usage data populated
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Comparison Table
|
||||
|
||||
| Aspect | Anthropic (Claude Code) | OpenAI (Codex) |
|
||||
|---|---|---|
|
||||
| **Usage endpoint** | `api.anthropic.com/api/oauth/usage` | `chatgpt.com/backend-api/wham/usage` |
|
||||
| **Auth header** | `Bearer <oauth_token>` | `Bearer <access_token>` + `ChatGPT-Account-Id` |
|
||||
| **Session window** | ~5h | Configurable (`limit_window_seconds`) |
|
||||
| **Weekly window** | 7 days | Configurable (`limit_window_seconds`) |
|
||||
| **Token source** | Keychain (`Claude Code-credentials`) | File (`codex-auth.json`) |
|
||||
| **Token refresh** | `console.anthropic.com/v1/oauth/token` | `auth.openai.com/oauth/token` |
|
||||
| **Client ID** | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` | `app_EMoamEEZ73f0CkXaXp7hrann` |
|
||||
| **Passive tracking** | Not available | `x-codex-*` response headers |
|
||||
| **Rate limit error** | `"Limit reached · resets Dec 17..."` | `"codexErrorInfo": "UsageLimitExceeded"` |
|
||||
| **Profile isolation** | `~/.claude-profiles/{name}/` dirs | Single `codex-auth.json` file |
|
||||
| **Multi-account** | Multiple config dirs in keychain | Single file (no multi-account yet) |
|
||||
|
||||
## Appendix: Caveats
|
||||
|
||||
1. **Undocumented API** — `chatgpt.com/backend-api/wham/usage` is internal. The Codex CLI depends on it, so it's unlikely to break silently.
|
||||
2. **Account ID** — May be required. Test without it first. If needed, decode from JWT or read `~/.codex/auth.json`.
|
||||
3. **CORS** — Not an issue (Electron main process = Node.js).
|
||||
4. **Polling rate** — Unknown if OpenAI rate-limits `wham/usage`. Start conservatively (every 30-60s).
|
||||
5. **Multi-account Codex** — Codex CLI doesn't support multiple accounts. We store one token file. If user has multiple Codex accounts, they'd need to re-auth each time (unlike Anthropic which supports multiple config dirs).
|
||||
+41
-223
@@ -73,35 +73,11 @@ Read the full CLA here: [CLA.md](CLA.md)
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
|
||||
- **Python 3.12+** - For the backend framework
|
||||
- **Node.js 24+** - For the Electron frontend
|
||||
- **npm 10+** - Package manager for the frontend (comes with Node.js)
|
||||
- **uv** (recommended) or **pip** - Python package manager
|
||||
- **CMake** - Required for building native dependencies (e.g., LadybugDB)
|
||||
- **Node.js 24+** - For the Electron desktop app
|
||||
- **npm 10+** - Package manager (comes with Node.js)
|
||||
- **CMake** - Required for building native dependencies (e.g., node-pty)
|
||||
- **Git** - Version control
|
||||
|
||||
### Installing Python 3.12
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install Python.Python.3.12
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install python@3.12
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install python3.12
|
||||
```
|
||||
|
||||
### Installing Node.js 24+
|
||||
|
||||
**Windows:**
|
||||
@@ -168,43 +144,27 @@ npm start
|
||||
|
||||
## Development Setup
|
||||
|
||||
The project consists of two main components:
|
||||
The project is a single Electron desktop application in `apps/desktop/`. All AI agent logic lives in TypeScript using the Vercel AI SDK v6.
|
||||
|
||||
1. **Python Backend** (`apps/backend/`) - The core autonomous coding framework
|
||||
2. **Electron Frontend** (`apps/frontend/`) - Desktop UI
|
||||
|
||||
From the repository root, two commands handle everything:
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# Install all dependencies (Python backend + Electron frontend)
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Start development mode (hot reload)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm run install:all` automatically:
|
||||
- Detects Python 3.12+ on your system
|
||||
- Creates a virtual environment (`apps/backend/.venv`)
|
||||
- Installs backend runtime and test dependencies
|
||||
- Copies `.env.example` to `.env` (if not already present)
|
||||
- Installs frontend npm dependencies
|
||||
|
||||
After install, configure your credentials in `apps/backend/.env`:
|
||||
```bash
|
||||
# Get your Claude Code OAuth token
|
||||
claude setup-token
|
||||
|
||||
# Then edit apps/backend/.env with your token and any other provider keys
|
||||
```
|
||||
`npm run install:all` installs the npm dependencies for `apps/desktop/`.
|
||||
|
||||
### Other Useful Commands
|
||||
|
||||
```bash
|
||||
npm start # Build and run production
|
||||
npm run build # Build frontend for production
|
||||
npm run build # Build for production
|
||||
npm run package # Package for distribution
|
||||
npm run test:backend # Run Python tests
|
||||
npm test # Run frontend tests
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -223,28 +183,20 @@ Auto Claude automatically downloads prebuilt binaries for Windows. If prebuilts
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
We use [pre-commit](https://pre-commit.com/) to run linting and formatting checks before each commit. This ensures code quality and consistency across the project.
|
||||
We use Husky + lint-staged to run Biome linting and formatting checks before each commit.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Install the git hooks (run once after cloning)
|
||||
pre-commit install
|
||||
```
|
||||
Husky is installed automatically when you run `npm install` inside `apps/desktop/`.
|
||||
|
||||
### What Runs on Commit
|
||||
|
||||
When you commit, the following checks run automatically:
|
||||
When you commit, the following checks run automatically on staged files:
|
||||
|
||||
| Check | Scope | Description |
|
||||
|-------|-------|-------------|
|
||||
| **ruff** | `apps/backend/` | Python linter with auto-fix |
|
||||
| **ruff-format** | `apps/backend/` | Python code formatter |
|
||||
| **eslint** | `apps/frontend/` | TypeScript/React linter |
|
||||
| **typecheck** | `apps/frontend/` | TypeScript type checking |
|
||||
| **Biome** | `apps/desktop/` | TypeScript/React linter + formatter |
|
||||
| **typecheck** | `apps/desktop/` | TypeScript type checking |
|
||||
| **trailing-whitespace** | All files | Removes trailing whitespace |
|
||||
| **end-of-file-fixer** | All files | Ensures files end with newline |
|
||||
| **check-yaml** | All files | Validates YAML syntax |
|
||||
@@ -253,55 +205,29 @@ When you commit, the following checks run automatically:
|
||||
### Running Manually
|
||||
|
||||
```bash
|
||||
# Run all checks on all files
|
||||
pre-commit run --all-files
|
||||
cd apps/desktop
|
||||
|
||||
# Run a specific hook
|
||||
pre-commit run ruff --all-files
|
||||
# Run linter (Biome)
|
||||
npm run lint
|
||||
|
||||
# Skip hooks temporarily (not recommended)
|
||||
git commit --no-verify -m "message"
|
||||
# Auto-fix lint issues
|
||||
npm run lint:fix
|
||||
|
||||
# Run type checking
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### If a Check Fails
|
||||
|
||||
1. **Ruff auto-fixes**: Some issues are fixed automatically. Stage the changes and commit again.
|
||||
2. **ESLint errors**: Fix the reported issues in your code.
|
||||
3. **Type errors**: Resolve TypeScript type issues before committing.
|
||||
1. **Biome auto-fixes**: Run `npm run lint:fix` in `apps/desktop/`. Stage the changes and commit again.
|
||||
2. **Type errors**: Resolve TypeScript type issues before committing.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python
|
||||
|
||||
- Follow PEP 8 style guidelines
|
||||
- Use type hints for function signatures
|
||||
- Use docstrings for public functions and classes
|
||||
- Keep functions focused and under 50 lines when possible
|
||||
- Use meaningful variable and function names
|
||||
|
||||
```python
|
||||
# Good
|
||||
def get_next_chunk(spec_dir: Path) -> dict | None:
|
||||
"""
|
||||
Find the next pending chunk in the implementation plan.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
The next chunk dict or None if all chunks are complete
|
||||
"""
|
||||
...
|
||||
|
||||
# Avoid
|
||||
def gnc(sd):
|
||||
...
|
||||
```
|
||||
|
||||
### TypeScript/React
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Follow the existing component patterns in `apps/frontend/src/`
|
||||
- Follow the existing component patterns in `apps/desktop/src/`
|
||||
- Use functional components with hooks
|
||||
- Prefer named exports over default exports
|
||||
- Use the UI components from `src/renderer/components/ui/`
|
||||
@@ -326,96 +252,12 @@ export default function(props) {
|
||||
- End files with a newline
|
||||
- Keep line length under 100 characters when practical
|
||||
|
||||
### File Encoding (Python)
|
||||
|
||||
**Always specify `encoding="utf-8"` for text file operations** to ensure Windows compatibility.
|
||||
|
||||
Windows Python defaults to `cp1252` encoding instead of UTF-8, causing errors with:
|
||||
- Emoji (🚀, ✅, ❌)
|
||||
- International characters (ñ, é, 中文, العربية)
|
||||
- Special symbols (™, ©, ®)
|
||||
|
||||
**DO:**
|
||||
|
||||
```python
|
||||
# Reading files
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Writing files
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Path methods
|
||||
from pathlib import Path
|
||||
content = Path(file).read_text(encoding="utf-8")
|
||||
Path(file).write_text(content, encoding="utf-8")
|
||||
|
||||
# JSON files - reading
|
||||
import json
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# JSON files - writing
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
```
|
||||
|
||||
**DON'T:**
|
||||
|
||||
```python
|
||||
# Wrong - platform-dependent encoding
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Wrong - Path methods without encoding
|
||||
content = Path(file).read_text()
|
||||
|
||||
# Wrong - encoding on json.dump (not open!)
|
||||
json.dump(data, f, encoding="utf-8") # ERROR
|
||||
```
|
||||
|
||||
**Binary files - NO encoding:**
|
||||
|
||||
```python
|
||||
with open(path, "rb") as f: # Correct
|
||||
data = f.read()
|
||||
```
|
||||
|
||||
Our pre-commit hooks automatically check for missing encoding parameters. See [PR #782](https://github.com/AndyMik90/Auto-Claude/pull/782) for the comprehensive encoding fix and [guides/windows-development.md](guides/windows-development.md) for Windows-specific development guidance.
|
||||
|
||||
## Testing
|
||||
|
||||
### Python Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (from repository root)
|
||||
npm run test:backend
|
||||
|
||||
# Or manually with pytest
|
||||
cd apps/backend
|
||||
.venv/Scripts/pytest.exe ../tests -v # Windows
|
||||
.venv/bin/pytest ../tests -v # macOS/Linux
|
||||
|
||||
# Run a specific test file
|
||||
npm run test:backend -- tests/test_security.py -v
|
||||
|
||||
# Run a specific test
|
||||
npm run test:backend -- tests/test_security.py::test_bash_command_validation -v
|
||||
|
||||
# Skip slow tests
|
||||
npm run test:backend -- -m "not slow"
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/ --cov=apps/backend --cov-report=html
|
||||
```
|
||||
|
||||
Test configuration is in `tests/pytest.ini`.
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
cd apps/desktop
|
||||
|
||||
# Run unit tests
|
||||
npm test
|
||||
@@ -454,29 +296,22 @@ All pull requests and pushes to `main` trigger automated CI checks via GitHub Ac
|
||||
|
||||
| Workflow | Trigger | What it checks |
|
||||
|----------|---------|----------------|
|
||||
| **CI** | Push to `main`, PRs | Python tests (3.11 & 3.12), Frontend tests |
|
||||
| **Lint** | Push to `main`, PRs | Ruff (Python), ESLint + TypeScript (Frontend) |
|
||||
| **CI** | Push to `main`, PRs | Frontend tests (all 3 platforms), TypeScript type check, build |
|
||||
| **Lint** | Push to `main`, PRs | Biome (TypeScript/React) |
|
||||
|
||||
### PR Requirements
|
||||
|
||||
Before a PR can be merged:
|
||||
|
||||
1. All CI checks must pass (green checkmarks)
|
||||
2. Python tests pass on both Python 3.11 and 3.12
|
||||
3. Frontend tests pass
|
||||
4. Linting passes (no ruff or eslint errors)
|
||||
5. TypeScript type checking passes
|
||||
2. Frontend tests pass on all three platforms (Ubuntu, Windows, macOS)
|
||||
3. Linting passes (no Biome errors)
|
||||
4. TypeScript type checking passes
|
||||
|
||||
### Running CI Checks Locally
|
||||
|
||||
```bash
|
||||
# Python tests
|
||||
cd apps/backend
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v
|
||||
|
||||
# Frontend tests
|
||||
cd apps/frontend
|
||||
cd apps/desktop
|
||||
npm test
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
@@ -787,8 +622,7 @@ git rebase -i origin/develop
|
||||
git push --force-with-lease
|
||||
|
||||
# Verify everything works
|
||||
npm run test:backend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
cd apps/desktop && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
**PR size:**
|
||||
@@ -809,11 +643,7 @@ cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
|
||||
3. **Test thoroughly**:
|
||||
```bash
|
||||
# Python (from repository root)
|
||||
npm run test:backend
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
cd apps/desktop && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
4. **Update documentation** if your changes affect:
|
||||
@@ -851,8 +681,7 @@ When reporting a bug, include:
|
||||
1. **Clear title** describing the issue
|
||||
2. **Environment details**:
|
||||
- OS and version
|
||||
- Python version
|
||||
- Node.js version (for UI issues)
|
||||
- Node.js version
|
||||
- Auto Claude version
|
||||
3. **Steps to reproduce** the issue
|
||||
4. **Expected behavior** vs **actual behavior**
|
||||
@@ -870,25 +699,14 @@ When requesting a feature:
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Auto Claude consists of two main parts:
|
||||
Auto Claude is a single Electron desktop application in `apps/desktop/`.
|
||||
|
||||
### Python Backend (`apps/backend/`)
|
||||
### Electron Desktop (`apps/desktop/`)
|
||||
|
||||
The core autonomous coding framework:
|
||||
|
||||
- **Entry Points**: `run.py` (build runner), `spec_runner.py` (spec creator)
|
||||
- **Agent System**: `agent.py`, `client.py`, `prompts/`
|
||||
- **Execution**: `coordinator.py` (parallel), `worktree.py` (isolation)
|
||||
- **Memory**: `memory.py` (file-based), `graphiti_memory.py` (graph-based)
|
||||
- **QA**: `qa_loop.py`, `prompts/qa_*.md`
|
||||
|
||||
### Electron Frontend (`apps/frontend/`)
|
||||
|
||||
Desktop interface:
|
||||
|
||||
- **Main Process**: `src/main/` - Electron main process, IPC handlers
|
||||
- **Renderer**: `src/renderer/` - React UI components
|
||||
- **Shared**: `src/shared/` - Types and utilities
|
||||
- **AI Agent Layer** (`src/main/ai/`) - Vercel AI SDK v6 agent runtime, providers, tools, security, orchestration
|
||||
- **Main Process** (`src/main/`) - IPC handlers, agent queue, terminal management, claude-profile
|
||||
- **Renderer** (`src/renderer/`) - React UI components and Zustand stores
|
||||
- **Shared** (`src/shared/`) - Types, i18n locales, constants, utilities
|
||||
|
||||
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Auto Claude
|
||||
# Aperant (formerly Auto Claude)
|
||||
|
||||
**Autonomous multi-agent coding framework that plans, builds, and validates software for you.**
|
||||
|
||||

|
||||

|
||||
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
@@ -36,18 +36,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.6)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.5)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.6-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.6-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.6-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.6-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.6-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.6-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
@@ -114,39 +114,15 @@ AI-assisted feature planning with competitor analysis and audience targeting.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Auto-Claude/
|
||||
Aperant/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python agents, specs, QA pipeline
|
||||
│ └── frontend/ # Electron desktop application
|
||||
│ └── desktop/ # Electron desktop application (TypeScript AI agent layer + UI)
|
||||
├── guides/ # Additional documentation
|
||||
├── tests/ # Test suite
|
||||
└── scripts/ # Build utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
For headless operation, CI/CD integration, or terminal-only workflows:
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Create a spec interactively
|
||||
python spec_runner.py --interactive
|
||||
|
||||
# Run autonomous build
|
||||
python run.py --spec 001
|
||||
|
||||
# Review and merge
|
||||
python run.py --spec 001 --review
|
||||
python run.py --spec 001 --merge
|
||||
```
|
||||
|
||||
See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
@@ -157,7 +133,7 @@ For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linu
|
||||
|
||||
## Security
|
||||
|
||||
Auto Claude uses a three-layer security model:
|
||||
Aperant uses a three-layer security model:
|
||||
|
||||
1. **OS Sandbox** - Bash commands run in isolation
|
||||
2. **Filesystem Restrictions** - Operations limited to project directory
|
||||
@@ -174,7 +150,7 @@ All releases are:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run install:all` | Install backend and frontend dependencies |
|
||||
| `npm run install:all` | Install all dependencies |
|
||||
| `npm start` | Build and run the desktop app |
|
||||
| `npm run dev` | Run in development mode with hot reload |
|
||||
| `npm run package` | Package for current platform |
|
||||
@@ -184,7 +160,6 @@ All releases are:
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +185,7 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
|
||||
**AGPL-3.0** - GNU Affero General Public License v3.0
|
||||
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
Aperant is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
|
||||
+2
-3
@@ -66,9 +66,8 @@ node scripts/bump-version.js 2.8.0 # Set specific version
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `apps/frontend/package.json`
|
||||
- Update `apps/desktop/package.json`
|
||||
- Update `package.json` (root)
|
||||
- Update `apps/backend/__init__.py`
|
||||
- Check if `CHANGELOG.md` has an entry for the new version (warns if missing)
|
||||
- Create a commit with message `chore: bump version to X.Y.Z`
|
||||
|
||||
@@ -195,7 +194,7 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
1. Check if version in `package.json` is greater than latest tag:
|
||||
```bash
|
||||
git tag -l 'v*' --sort=-version:refname | head -1
|
||||
cat apps/frontend/package.json | grep version
|
||||
cat apps/desktop/package.json | grep version
|
||||
```
|
||||
|
||||
2. Ensure the merge commit touched `package.json`:
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
# Auto Claude Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION (REQUIRED)
|
||||
# =============================================================================
|
||||
# Auto Claude uses Claude Code OAuth authentication.
|
||||
# Direct API keys (ANTHROPIC_API_KEY) are NOT supported to prevent silent billing.
|
||||
#
|
||||
# Option 1: Run `claude setup-token` to save token to system keychain (recommended)
|
||||
# (macOS: Keychain, Windows: Credential Manager, Linux: secret-service)
|
||||
# Option 2: Set the token explicitly:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
#
|
||||
# For enterprise/proxy setups (CCR):
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-zcf-x-ccr
|
||||
|
||||
# =============================================================================
|
||||
# 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-6
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-6
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Configure how Auto Claude handles git worktrees for isolated builds.
|
||||
|
||||
# Default base branch for worktree creation (OPTIONAL)
|
||||
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch.
|
||||
# Common values: main, master, develop
|
||||
# DEFAULT_BRANCH=main
|
||||
|
||||
# =============================================================================
|
||||
# DEBUG MODE (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable debug logging for development and troubleshooting.
|
||||
# Shows detailed information about runner execution, agent calls, file operations.
|
||||
|
||||
# Enable debug mode (default: false)
|
||||
# DEBUG=true
|
||||
|
||||
# Debug log level: 1=basic, 2=detailed, 3=verbose (default: 1)
|
||||
# DEBUG_LEVEL=1
|
||||
|
||||
# Log to file instead of stdout (OPTIONAL)
|
||||
# DEBUG_LOG_FILE=auto-claude/debug.log
|
||||
|
||||
# =============================================================================
|
||||
# LINEAR INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable Linear integration for real-time progress tracking in Linear.
|
||||
# Get your API key from: https://linear.app/YOUR-TEAM/settings/api
|
||||
|
||||
# Linear API Key (OPTIONAL - enables Linear integration)
|
||||
# LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Pre-configured Team ID (OPTIONAL - will auto-detect if not set)
|
||||
# LINEAR_TEAM_ID=
|
||||
|
||||
# Pre-configured Project ID (OPTIONAL - will create project if not set)
|
||||
# LINEAR_PROJECT_ID=
|
||||
|
||||
# =============================================================================
|
||||
# GITLAB INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable GitLab integration for issue tracking and merge requests.
|
||||
# Supports both GitLab.com and self-hosted GitLab instances.
|
||||
#
|
||||
# Authentication Options (choose one):
|
||||
#
|
||||
# Option 1: glab CLI OAuth (Recommended)
|
||||
# Install glab CLI: https://gitlab.com/gitlab-org/cli#installation
|
||||
# Then run: glab auth login
|
||||
# This opens your browser for OAuth authentication. Once complete,
|
||||
# Auto Claude will automatically use your glab credentials (no env vars needed).
|
||||
# For self-hosted: glab auth login --hostname gitlab.example.com
|
||||
#
|
||||
# Option 2: Personal Access Token
|
||||
# Set GITLAB_TOKEN below. Token auth is used if set, otherwise falls back to glab CLI.
|
||||
|
||||
# GitLab Instance URL (OPTIONAL - defaults to gitlab.com)
|
||||
# For self-hosted: GITLAB_INSTANCE_URL=https://gitlab.example.com
|
||||
# GITLAB_INSTANCE_URL=https://gitlab.com
|
||||
|
||||
# GitLab Personal Access Token (OPTIONAL - only needed if not using glab CLI)
|
||||
# Required scope: api (covers issues, merge requests, releases, project info)
|
||||
# Optional scope: write_repository (only if creating new GitLab projects from local repos)
|
||||
# Get from: https://gitlab.com/-/user_settings/personal_access_tokens
|
||||
# GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# GitLab Project (OPTIONAL - format: group/project or numeric ID)
|
||||
# If not set, will auto-detect from git remote
|
||||
# GITLAB_PROJECT=mygroup/myproject
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable fancy terminal UI with icons, colors, and interactive menus.
|
||||
# Set to "false" to use plain text output (useful for CI/CD or log files).
|
||||
|
||||
# Enable fancy UI (default: true)
|
||||
# ENABLE_FANCY_UI=true
|
||||
|
||||
# =============================================================================
|
||||
# ELECTRON MCP SERVER (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable Electron MCP server for AI agents to interact with and validate
|
||||
# Electron desktop applications. This allows QA agents to capture screenshots,
|
||||
# inspect windows, and validate Electron apps during the review process.
|
||||
#
|
||||
# The electron-mcp-server connects via Chrome DevTools Protocol to an Electron
|
||||
# app running with remote debugging enabled.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Start your Electron app with remote debugging:
|
||||
# ./YourElectronApp --remote-debugging-port=9222
|
||||
#
|
||||
# 2. For auto-claude-ui specifically (use the MCP-enabled scripts):
|
||||
# cd auto-claude-ui
|
||||
# pnpm run dev:mcp # Development mode with MCP debugging
|
||||
# # OR for production build:
|
||||
# pnpm run start:mcp # Production mode with MCP debugging
|
||||
#
|
||||
# Note: Only QA agents (qa_reviewer, qa_fixer) receive Electron MCP tools.
|
||||
# Coder and Planner agents do NOT have access to these tools to minimize
|
||||
# context token usage and keep agents focused on their roles.
|
||||
#
|
||||
# See: https://github.com/anthropics/anthropic-quickstarts/tree/main/mcp-electron-demo
|
||||
|
||||
# Enable Electron MCP integration (default: false)
|
||||
# ELECTRON_MCP_ENABLED=true
|
||||
|
||||
# Chrome DevTools debugging port for Electron connection (default: 9222)
|
||||
# ELECTRON_DEBUG_PORT=9222
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI MEMORY INTEGRATION (REQUIRED)
|
||||
# =============================================================================
|
||||
# Graphiti-based persistent memory layer for cross-session context
|
||||
# retention. Uses LadybugDB as the embedded graph database.
|
||||
#
|
||||
# REQUIREMENTS:
|
||||
# - Python 3.12 or higher
|
||||
# - Install: pip install real_ladybug graphiti-core
|
||||
#
|
||||
# Supports multiple LLM and embedder providers:
|
||||
# - OpenAI (default)
|
||||
# - Anthropic (LLM only, use with Voyage for embeddings)
|
||||
# - Azure OpenAI
|
||||
# - Ollama (local, fully offline)
|
||||
# - Google AI (Gemini)
|
||||
|
||||
# Graphiti is enabled by default. Set to false to disable memory features.
|
||||
GRAPHITI_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Database Settings
|
||||
# =============================================================================
|
||||
# LadybugDB stores data in a local directory (no Docker required).
|
||||
|
||||
# Database name (default: auto_claude_memory)
|
||||
# GRAPHITI_DATABASE=auto_claude_memory
|
||||
|
||||
# Database storage path (default: ~/.auto-claude/memories)
|
||||
# GRAPHITI_DB_PATH=~/.auto-claude/memories
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Provider Selection
|
||||
# =============================================================================
|
||||
# Choose which providers to use for LLM and embeddings.
|
||||
# Default is "openai" for both.
|
||||
|
||||
# LLM provider: openai | anthropic | azure_openai | ollama | google | openrouter
|
||||
# GRAPHITI_LLM_PROVIDER=openai
|
||||
|
||||
# Embedder provider: openai | voyage | azure_openai | ollama | google | openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openai
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: OpenAI Provider (Default)
|
||||
# =============================================================================
|
||||
# Use OpenAI for both LLM and embeddings. This is the simplest setup.
|
||||
# Required: OPENAI_API_KEY
|
||||
|
||||
# OpenAI API Key
|
||||
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# OpenAI Model for LLM (default: gpt-4o-mini)
|
||||
# OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# OpenAI Model for embeddings (default: text-embedding-3-small)
|
||||
# Available: text-embedding-3-small (1536 dim), text-embedding-3-large (3072 dim)
|
||||
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Anthropic Provider (LLM only)
|
||||
# =============================================================================
|
||||
# Use Anthropic for LLM. Requires separate embedder (use Voyage or OpenAI).
|
||||
# Example: GRAPHITI_LLM_PROVIDER=anthropic, GRAPHITI_EMBEDDER_PROVIDER=voyage
|
||||
#
|
||||
# Required: ANTHROPIC_API_KEY
|
||||
|
||||
# Anthropic API Key
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Anthropic Model (default: claude-sonnet-4-5-latest)
|
||||
# GRAPHITI_ANTHROPIC_MODEL=claude-sonnet-4-5-latest
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Voyage AI Provider (Embeddings only)
|
||||
# =============================================================================
|
||||
# Use Voyage AI for embeddings. Commonly paired with Anthropic LLM.
|
||||
# Get API key from: https://www.voyageai.com/
|
||||
#
|
||||
# Required: VOYAGE_API_KEY
|
||||
|
||||
# Voyage AI API Key
|
||||
# VOYAGE_API_KEY=pa-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Voyage Embedding Model (default: voyage-3)
|
||||
# Available: voyage-3 (1024 dim), voyage-3-lite (512 dim)
|
||||
# VOYAGE_EMBEDDING_MODEL=voyage-3
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Google AI Provider
|
||||
# =============================================================================
|
||||
# Use Google AI (Gemini) for both LLM and embeddings.
|
||||
# Get API key from: https://aistudio.google.com/apikey
|
||||
#
|
||||
# Required: GOOGLE_API_KEY
|
||||
|
||||
# Google AI API Key
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Google LLM Model (default: gemini-2.0-flash)
|
||||
# GOOGLE_LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Google Embedding Model (default: text-embedding-004)
|
||||
# GOOGLE_EMBEDDING_MODEL=text-embedding-004
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: OpenRouter Provider (Multi-provider aggregator)
|
||||
# =============================================================================
|
||||
# Use OpenRouter to access multiple LLM providers through a single API.
|
||||
# OpenRouter provides access to Anthropic, OpenAI, Google, and many other models.
|
||||
# Get API key from: https://openrouter.ai/keys
|
||||
#
|
||||
# Required: OPENROUTER_API_KEY
|
||||
|
||||
# OpenRouter API Key
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
|
||||
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Azure OpenAI Provider
|
||||
# =============================================================================
|
||||
# Use Azure OpenAI for both LLM and embeddings.
|
||||
# Requires Azure OpenAI deployment with appropriate models.
|
||||
#
|
||||
# Required: AZURE_OPENAI_API_KEY, AZURE_OPENAI_BASE_URL
|
||||
|
||||
# Azure OpenAI API Key
|
||||
# AZURE_OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Azure OpenAI Base URL (your Azure endpoint)
|
||||
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment
|
||||
|
||||
# Azure OpenAI Deployment Names
|
||||
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4
|
||||
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Ollama Provider (Local/Offline)
|
||||
# =============================================================================
|
||||
# Use Ollama for fully offline operation. No API keys required.
|
||||
# Requires Ollama running locally with appropriate models pulled.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Install Ollama: https://ollama.ai/
|
||||
# 2. Pull models: ollama pull deepseek-r1:7b && ollama pull nomic-embed-text
|
||||
# 3. Start Ollama server (usually auto-starts)
|
||||
#
|
||||
# Required: OLLAMA_LLM_MODEL, OLLAMA_EMBEDDING_MODEL, OLLAMA_EMBEDDING_DIM
|
||||
|
||||
# Ollama Server URL (default: http://localhost:11434)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Ollama LLM Model
|
||||
# Popular choices: deepseek-r1:7b, llama3.2:3b, mistral:7b, phi3:medium
|
||||
# OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
|
||||
# Ollama Embedding Model
|
||||
# Popular choices: nomic-embed-text (768 dim), mxbai-embed-large (1024 dim)
|
||||
# OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# Ollama Embedding Dimension (REQUIRED for Ollama embeddings)
|
||||
# Must match your embedding model's output dimension
|
||||
# Common values: nomic-embed-text=768, mxbai-embed-large=1024, all-minilm=384
|
||||
# OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Example Configurations
|
||||
# =============================================================================
|
||||
#
|
||||
# --- Example 1: OpenAI (simplest) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openai
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openai
|
||||
# OPENAI_API_KEY=sk-xxxxxxxx
|
||||
#
|
||||
# --- Example 2: Anthropic + Voyage (high quality) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=anthropic
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=voyage
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxx
|
||||
# VOYAGE_API_KEY=pa-xxxxxxxx
|
||||
#
|
||||
# --- Example 3: Ollama (fully offline) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=ollama
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=ollama
|
||||
# OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
# OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# OLLAMA_EMBEDDING_DIM=768
|
||||
#
|
||||
# --- Example 4: Azure OpenAI (enterprise) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=azure_openai
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=azure_openai
|
||||
# AZURE_OPENAI_API_KEY=xxxxxxxx
|
||||
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/...
|
||||
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4
|
||||
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
||||
#
|
||||
# --- Example 5: Google AI (Gemini) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=google
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=google
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxx
|
||||
#
|
||||
# --- Example 6: OpenRouter (multi-provider aggregator) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
@@ -1,75 +0,0 @@
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
.venv*/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Puppeteer / Browser automation
|
||||
puppeteer_logs/
|
||||
puppeteer-*.log
|
||||
*.screenshot.png
|
||||
screenshots/
|
||||
.puppeteerrc.*
|
||||
chrome-profile/
|
||||
chromium-profile/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git worktrees (used by parallel mode)
|
||||
.worktrees/
|
||||
|
||||
# Claude Code settings (project-specific)
|
||||
.claude_settings.json
|
||||
.auto-build-security.json
|
||||
|
||||
# Tests (development only)
|
||||
tests/
|
||||
|
||||
# Exception: Allow colocated tests within integrations/graphiti
|
||||
!integrations/graphiti/tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
|
||||
# Auto Claude generated files
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.security-key
|
||||
logs/security/
|
||||
@@ -1,122 +0,0 @@
|
||||
# Auto Claude Backend
|
||||
|
||||
Autonomous coding framework powered by Claude AI. Builds software features through coordinated multi-agent sessions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Authenticate with Claude Code (token auto-saved to Keychain):
|
||||
```bash
|
||||
claude
|
||||
# Type: /login
|
||||
# Press Enter to open browser
|
||||
```
|
||||
|
||||
Token is auto-detected from macOS Keychain / Windows Credential Manager.
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
# List available specs
|
||||
python run.py --list
|
||||
|
||||
# Run a spec
|
||||
python run.py --spec 001
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Claude API token
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `--list` | List all specs |
|
||||
| `--spec 001` | Run spec 001 |
|
||||
| `--spec 001 --isolated` | Run in isolated workspace |
|
||||
| `--spec 001 --direct` | Run directly in repo |
|
||||
| `--spec 001 --merge` | Merge completed build |
|
||||
| `--spec 001 --review` | Review build changes |
|
||||
| `--spec 001 --discard` | Discard build |
|
||||
| `--spec 001 --qa` | Run QA validation |
|
||||
| `--list-worktrees` | List all worktrees |
|
||||
| `--help` | Show all options |
|
||||
|
||||
## Configuration
|
||||
|
||||
Optional `.env` settings:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTO_BUILD_MODEL` | Override Claude model |
|
||||
| `DEBUG=true` | Enable debug logging |
|
||||
| `LINEAR_API_KEY` | Enable Linear integration |
|
||||
| `GRAPHITI_ENABLED=true` | Enable memory system |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"tree-sitter not available"** - Safe to ignore, uses regex fallback.
|
||||
|
||||
**Missing module errors** - Run `python -m pip install -r requirements.txt`
|
||||
|
||||
**Debug mode** - Set `DEBUG=true DEBUG_LEVEL=2` before running.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── agents/ # AI agent execution
|
||||
├── analysis/ # Code analysis
|
||||
├── cli/ # Command-line interface
|
||||
├── core/ # Core utilities
|
||||
├── integrations/ # External services (Linear, Graphiti)
|
||||
├── merge/ # Git merge handling
|
||||
├── project/ # Project detection
|
||||
├── prompts/ # Prompt templates
|
||||
├── qa/ # QA validation
|
||||
├── spec/ # Spec management
|
||||
└── ui/ # Terminal UI
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **SOLID** - Single responsibility, clean interfaces
|
||||
- **DRY** - Shared utilities in `core/`
|
||||
- **KISS** - Simple flat imports via facade modules
|
||||
|
||||
### Import Convention
|
||||
|
||||
```python
|
||||
# Use facade modules for clean imports
|
||||
from debug import debug, debug_error
|
||||
from progress import count_subtasks
|
||||
from workspace import setup_workspace
|
||||
```
|
||||
|
||||
### Adding Features
|
||||
|
||||
1. Create module in appropriate folder
|
||||
2. Export API in `__init__.py`
|
||||
3. Add facade module at root if commonly imported
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Auto Claude Backend - Autonomous Coding Framework
|
||||
==================================================
|
||||
|
||||
Multi-agent autonomous coding framework that builds software through
|
||||
coordinated AI agent sessions.
|
||||
|
||||
This package provides:
|
||||
- Autonomous agent execution for building features from specs
|
||||
- Workspace isolation via git worktrees
|
||||
- QA validation loops
|
||||
- Memory management (Graphiti + file-based)
|
||||
- Linear integration for project management
|
||||
|
||||
Quick Start:
|
||||
python run.py --spec 001 # Run a spec
|
||||
python run.py --list # List all specs
|
||||
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6"
|
||||
__author__ = "Auto Claude Team"
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Backward compatibility shim - import from core.agent instead."""
|
||||
|
||||
from core.agent import * # noqa: F403
|
||||
@@ -1,152 +0,0 @@
|
||||
# Agents Module
|
||||
|
||||
Modular agent system for autonomous coding. This module refactors the original monolithic `agent.py` (1,446 lines) into focused, maintainable modules.
|
||||
|
||||
## Architecture
|
||||
|
||||
The agent system is now organized by concern:
|
||||
|
||||
```
|
||||
auto-claude/agents/
|
||||
├── __init__.py # Public API exports
|
||||
├── base.py # Shared constants and imports
|
||||
├── utils.py # Git operations and plan management
|
||||
├── memory.py # Memory management (Graphiti + file-based)
|
||||
├── session.py # Agent session execution
|
||||
├── planner.py # Follow-up planner logic
|
||||
└── coder.py # Main autonomous agent loop
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
### `base.py` (352 bytes)
|
||||
- Shared constants (`AUTO_CONTINUE_DELAY_SECONDS`, `HUMAN_INTERVENTION_FILE`)
|
||||
- Common imports and logging setup
|
||||
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
- `debug_memory_system_status()` - Memory system diagnostics
|
||||
- `get_graphiti_context()` - Retrieve relevant context for subtasks
|
||||
- `save_session_memory()` - Save session insights to memory
|
||||
- `save_session_to_graphiti()` - Backwards compatibility wrapper
|
||||
|
||||
### `session.py` (17 KB)
|
||||
- `run_agent_session()` - Execute a single agent session
|
||||
- `post_session_processing()` - Process results and update memory
|
||||
- Session logging and tool tracking
|
||||
- Recovery manager integration
|
||||
|
||||
### `planner.py` (5.4 KB)
|
||||
- `run_followup_planner()` - Add new subtasks to completed specs
|
||||
- Follow-up planning workflow
|
||||
- Plan validation and status updates
|
||||
|
||||
### `coder.py` (16 KB)
|
||||
- `run_autonomous_agent()` - Main autonomous agent loop
|
||||
- Planning and coding phase management
|
||||
- Linear integration
|
||||
- Recovery and stuck subtask handling
|
||||
|
||||
## Public API
|
||||
|
||||
The `agents` module exports a clean public API:
|
||||
|
||||
```python
|
||||
from agents import (
|
||||
# Main functions
|
||||
run_autonomous_agent,
|
||||
run_followup_planner,
|
||||
|
||||
# Memory functions
|
||||
save_session_memory,
|
||||
get_graphiti_context,
|
||||
|
||||
# Session management
|
||||
run_agent_session,
|
||||
post_session_processing,
|
||||
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
The original `agent.py` is now a facade that re-exports everything from the `agents` module:
|
||||
|
||||
```python
|
||||
# Old code still works
|
||||
from agent import run_autonomous_agent, save_session_memory
|
||||
|
||||
# New code can use modular imports
|
||||
from agents.coder import run_autonomous_agent
|
||||
from agents.memory import save_session_memory
|
||||
```
|
||||
|
||||
All existing imports continue to work without changes.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Separation of Concerns**: Each module has a clear, focused responsibility
|
||||
2. **Maintainability**: Easier to understand and modify individual components
|
||||
3. **Testability**: Modules can be tested in isolation
|
||||
4. **Backwards Compatible**: No breaking changes to existing code
|
||||
5. **Scalability**: Easy to add new agent types or features
|
||||
|
||||
## Module Dependencies
|
||||
|
||||
```
|
||||
coder.py
|
||||
├── session.py (run_agent_session, post_session_processing)
|
||||
├── memory.py (get_graphiti_context, debug_memory_system_status)
|
||||
└── utils.py (git operations, plan management)
|
||||
|
||||
session.py
|
||||
├── memory.py (save_session_memory)
|
||||
└── utils.py (git operations, plan management)
|
||||
|
||||
planner.py
|
||||
└── session.py (run_agent_session)
|
||||
|
||||
memory.py
|
||||
└── base.py (constants, logging)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the verification script to test the refactoring:
|
||||
|
||||
```bash
|
||||
python3 auto-claude/agents/test_refactoring.py
|
||||
```
|
||||
|
||||
This verifies:
|
||||
- Module structure is correct
|
||||
- All imports work
|
||||
- Public API is accessible
|
||||
- Backwards compatibility is maintained
|
||||
|
||||
## Migration Guide
|
||||
|
||||
No migration needed! The refactoring maintains 100% backwards compatibility.
|
||||
|
||||
### For new code:
|
||||
```python
|
||||
# Use focused imports for clarity
|
||||
from agents.coder import run_autonomous_agent
|
||||
from agents.memory import save_session_memory, get_graphiti_context
|
||||
from agents.session import run_agent_session
|
||||
```
|
||||
|
||||
### For existing code:
|
||||
```python
|
||||
# Old imports continue to work
|
||||
from agent import run_autonomous_agent, save_session_memory
|
||||
```
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Agents Module
|
||||
=============
|
||||
|
||||
Modular agent system for autonomous coding.
|
||||
|
||||
This module provides:
|
||||
- run_autonomous_agent: Main coder agent loop
|
||||
- run_followup_planner: Follow-up planner for completed specs
|
||||
- Memory management (Graphiti + file-based fallback)
|
||||
- Session management and post-processing
|
||||
- Utility functions for git and plan management
|
||||
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
# Memory
|
||||
"debug_memory_system_status",
|
||||
"get_graphiti_context",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
# Session
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
# Utils
|
||||
"get_latest_commit",
|
||||
"get_commit_count",
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies."""
|
||||
if name in ("AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE"):
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
|
||||
return locals()[name]
|
||||
elif name == "run_autonomous_agent":
|
||||
from .coder import run_autonomous_agent
|
||||
|
||||
return run_autonomous_agent
|
||||
elif name in (
|
||||
"debug_memory_system_status",
|
||||
"get_graphiti_context",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
):
|
||||
from .memory_manager import (
|
||||
debug_memory_system_status,
|
||||
get_graphiti_context,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
elif name == "run_followup_planner":
|
||||
from .planner import run_followup_planner
|
||||
|
||||
return run_followup_planner
|
||||
elif name in ("post_session_processing", "run_agent_session"):
|
||||
from .session import post_session_processing, run_agent_session
|
||||
|
||||
return locals()[name]
|
||||
elif name in (
|
||||
"find_phase_for_subtask",
|
||||
"find_subtask_in_plan",
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module 'agents' has no attribute '{name}'")
|
||||
@@ -1,99 +0,0 @@
|
||||
"""
|
||||
Base Module for Agent System
|
||||
=============================
|
||||
|
||||
Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration constants
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Retry configuration for subtask execution
|
||||
MAX_SUBTASK_RETRIES = 5 # Maximum attempts before marking subtask as stuck
|
||||
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
INITIAL_RETRY_DELAY_SECONDS = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
# Pause file constants for intelligent error recovery
|
||||
# These files signal pause/resume between frontend and backend
|
||||
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
|
||||
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
|
||||
RESUME_FILE = "RESUME" # Created by frontend to signal resume
|
||||
|
||||
# Maximum time to wait for rate limit reset (2 hours)
|
||||
# If reset time is beyond this, task should fail rather than wait indefinitely
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
|
||||
|
||||
# Wait intervals for pause/resume checking
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
|
||||
30 # Check for RESUME file every 30 seconds during rate limit wait
|
||||
)
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
|
||||
|
||||
|
||||
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
|
||||
"""
|
||||
Sanitize error messages to remove potentially sensitive information.
|
||||
|
||||
Redacts:
|
||||
- API keys (sk-..., key-...)
|
||||
- Bearer tokens
|
||||
- Token/secret values
|
||||
|
||||
Args:
|
||||
error_message: The raw error message to sanitize
|
||||
max_length: Maximum length to truncate to (default 500)
|
||||
|
||||
Returns:
|
||||
Sanitized and truncated error message
|
||||
"""
|
||||
if not error_message:
|
||||
return ""
|
||||
|
||||
# Redact patterns that look like API keys or tokens
|
||||
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
|
||||
sanitized = re.sub(
|
||||
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
|
||||
)
|
||||
|
||||
# Pattern: key-... (generic API keys)
|
||||
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
|
||||
|
||||
# Pattern: Bearer ... (bearer tokens)
|
||||
sanitized = re.sub(
|
||||
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
|
||||
)
|
||||
|
||||
# Pattern: token= or token: followed by long strings
|
||||
sanitized = re.sub(
|
||||
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_TOKEN]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern: secret= or secret: followed by strings
|
||||
sanitized = re.sub(
|
||||
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_SECRET]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
return sanitized
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,494 +0,0 @@
|
||||
"""
|
||||
Memory Management for Agent System
|
||||
===================================
|
||||
|
||||
Handles session memory storage using dual-layer approach:
|
||||
- PRIMARY: Graphiti (when enabled) - semantic search, cross-session context
|
||||
- FALLBACK: File-based memory - zero dependencies, always available
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.sentry import capture_exception
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_section,
|
||||
debug_success,
|
||||
debug_warning,
|
||||
is_debug_enabled,
|
||||
)
|
||||
from graphiti_config import get_graphiti_status, is_graphiti_enabled
|
||||
|
||||
# Import from parent memory package
|
||||
# Now safe since this module is named memory_manager (not memory)
|
||||
from memory import save_session_insights as save_file_based_memory
|
||||
from memory.graphiti_helpers import get_graphiti_memory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_memory_system_status() -> None:
|
||||
"""
|
||||
Print memory system status for debugging.
|
||||
|
||||
Called at startup when DEBUG=true to show memory configuration.
|
||||
"""
|
||||
if not is_debug_enabled():
|
||||
return
|
||||
|
||||
debug_section("memory", "Memory System Status")
|
||||
|
||||
# Get Graphiti status
|
||||
graphiti_status = get_graphiti_status()
|
||||
|
||||
debug(
|
||||
"memory",
|
||||
"Memory system configuration",
|
||||
primary_system="Graphiti"
|
||||
if graphiti_status.get("available")
|
||||
else "File-based (fallback)",
|
||||
graphiti_enabled=graphiti_status.get("enabled"),
|
||||
graphiti_available=graphiti_status.get("available"),
|
||||
)
|
||||
|
||||
if graphiti_status.get("enabled"):
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"Graphiti configuration",
|
||||
host=graphiti_status.get("host"),
|
||||
port=graphiti_status.get("port"),
|
||||
database=graphiti_status.get("database"),
|
||||
llm_provider=graphiti_status.get("llm_provider"),
|
||||
embedder_provider=graphiti_status.get("embedder_provider"),
|
||||
)
|
||||
|
||||
if not graphiti_status.get("available"):
|
||||
debug_warning(
|
||||
"memory",
|
||||
"Graphiti not available",
|
||||
reason=graphiti_status.get("reason"),
|
||||
errors=graphiti_status.get("errors"),
|
||||
)
|
||||
debug("memory", "Will use file-based memory as fallback")
|
||||
else:
|
||||
debug_success("memory", "Graphiti ready as PRIMARY memory system")
|
||||
else:
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti disabled, using file-based memory only",
|
||||
note="Set GRAPHITI_ENABLED=true to enable Graphiti",
|
||||
)
|
||||
|
||||
|
||||
async def get_graphiti_context(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask: dict,
|
||||
) -> str | None:
|
||||
"""
|
||||
Retrieve relevant context from Graphiti for the current subtask.
|
||||
|
||||
This searches the knowledge graph for context relevant to the subtask's
|
||||
task description, returning past insights, patterns, and gotchas.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root directory
|
||||
subtask: The current subtask being worked on
|
||||
|
||||
Returns:
|
||||
Formatted context string or None if unavailable
|
||||
"""
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Retrieving Graphiti context for subtask",
|
||||
subtask_id=subtask.get("id", "unknown"),
|
||||
subtask_desc=subtask.get("description", "")[:100],
|
||||
)
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Graphiti not enabled, skipping context retrieval")
|
||||
return None
|
||||
|
||||
memory = None
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation (async)
|
||||
memory = await get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory not available for context retrieval"
|
||||
)
|
||||
return None
|
||||
|
||||
# Build search query from subtask description
|
||||
subtask_desc = subtask.get("description", "")
|
||||
subtask_id = subtask.get("id", "")
|
||||
query = f"{subtask_desc} {subtask_id}".strip()
|
||||
|
||||
if not query:
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "Empty query, skipping context retrieval")
|
||||
return None
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"Searching Graphiti knowledge graph",
|
||||
query=query[:200],
|
||||
num_results=5,
|
||||
)
|
||||
|
||||
# Get relevant context
|
||||
context_items = await memory.get_relevant_context(query, num_results=5)
|
||||
|
||||
# Get patterns and gotchas specifically (THE FIX for learning loop!)
|
||||
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
|
||||
patterns, gotchas = await memory.get_patterns_and_gotchas(
|
||||
query, num_results=3, min_score=0.5
|
||||
)
|
||||
|
||||
# Also get recent session history
|
||||
session_history = await memory.get_session_history(limit=3)
|
||||
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti context retrieval complete",
|
||||
context_items_found=len(context_items) if context_items else 0,
|
||||
patterns_found=len(patterns) if patterns else 0,
|
||||
gotchas_found=len(gotchas) if gotchas else 0,
|
||||
session_history_found=len(session_history) if session_history else 0,
|
||||
)
|
||||
|
||||
if not context_items and not session_history and not patterns and not gotchas:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "No relevant context found in Graphiti")
|
||||
return None
|
||||
|
||||
# Format the context
|
||||
sections = ["## Graphiti Memory Context\n"]
|
||||
sections.append("_Retrieved from knowledge graph for this subtask:_\n")
|
||||
|
||||
if context_items:
|
||||
sections.append("### Relevant Knowledge\n")
|
||||
for item in context_items:
|
||||
content = item.get("content", "")[:500] # Truncate
|
||||
item_type = item.get("type", "unknown")
|
||||
sections.append(f"- **[{item_type}]** {content}\n")
|
||||
|
||||
# Add patterns section (cross-session learning)
|
||||
if patterns:
|
||||
sections.append("### Learned Patterns\n")
|
||||
sections.append("_Patterns discovered in previous sessions:_\n")
|
||||
for p in patterns:
|
||||
pattern_text = p.get("pattern", "")
|
||||
applies_to = p.get("applies_to", "")
|
||||
if applies_to:
|
||||
sections.append(
|
||||
f"- **Pattern**: {pattern_text}\n _Applies to:_ {applies_to}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Pattern**: {pattern_text}\n")
|
||||
|
||||
# Add gotchas section (cross-session learning)
|
||||
if gotchas:
|
||||
sections.append("### Known Gotchas\n")
|
||||
sections.append("_Pitfalls to avoid:_\n")
|
||||
for g in gotchas:
|
||||
gotcha_text = g.get("gotcha", "")
|
||||
solution = g.get("solution", "")
|
||||
if solution:
|
||||
sections.append(
|
||||
f"- **Gotcha**: {gotcha_text}\n _Solution:_ {solution}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Gotcha**: {gotcha_text}\n")
|
||||
|
||||
if session_history:
|
||||
sections.append("### Recent Session Insights\n")
|
||||
for session in session_history[:2]: # Only show last 2
|
||||
session_num = session.get("session_number", "?")
|
||||
recommendations = session.get("recommendations_for_next_session", [])
|
||||
if recommendations:
|
||||
sections.append(f"**Session {session_num} recommendations:**")
|
||||
for rec in recommendations[:3]: # Limit to 3
|
||||
sections.append(f"- {rec}")
|
||||
sections.append("")
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_success(
|
||||
"memory", "Graphiti context formatted", total_sections=len(sections)
|
||||
)
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Graphiti context: {e}")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "Graphiti context retrieval failed", error=str(e))
|
||||
# Capture exception to Sentry with full context
|
||||
capture_exception(
|
||||
e,
|
||||
operation="get_graphiti_context",
|
||||
subtask_id=subtask.get("id", "unknown"),
|
||||
subtask_desc=subtask.get("description", "")[:200],
|
||||
spec_dir=str(spec_dir),
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def save_session_memory(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
success: bool,
|
||||
subtasks_completed: list[str],
|
||||
discoveries: dict | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Save session insights to memory.
|
||||
|
||||
Memory Strategy:
|
||||
- PRIMARY: Graphiti (when enabled) - provides semantic search, cross-session context
|
||||
- FALLBACK: File-based (when Graphiti is disabled) - zero dependencies, always works
|
||||
|
||||
This is called after each session to persist learnings.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root directory
|
||||
subtask_id: The subtask that was worked on
|
||||
session_num: Current session number
|
||||
success: Whether the subtask was completed successfully
|
||||
subtasks_completed: List of subtask IDs completed this session
|
||||
discoveries: Optional dict with file discoveries, patterns, gotchas
|
||||
|
||||
Returns:
|
||||
Tuple of (success, storage_type) where storage_type is "graphiti" or "file"
|
||||
"""
|
||||
# Debug: Log memory save start
|
||||
if is_debug_enabled():
|
||||
debug_section("memory", f"Saving Session {session_num} Memory")
|
||||
debug(
|
||||
"memory",
|
||||
"Memory save initiated",
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
spec_dir=str(spec_dir),
|
||||
)
|
||||
|
||||
# Build insights structure (same format for both storage systems)
|
||||
insights = {
|
||||
"subtasks_completed": subtasks_completed,
|
||||
"discoveries": discoveries
|
||||
or {
|
||||
"files_understood": {},
|
||||
"patterns_found": [],
|
||||
"gotchas_encountered": [],
|
||||
},
|
||||
"what_worked": [f"Implemented subtask: {subtask_id}"] if success else [],
|
||||
"what_failed": [] if success else [f"Failed to complete subtask: {subtask_id}"],
|
||||
"recommendations_for_next_session": [],
|
||||
}
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_detailed("memory", "Insights structure built", insights=insights)
|
||||
|
||||
# Check Graphiti status for debugging
|
||||
graphiti_enabled = is_graphiti_enabled()
|
||||
if is_debug_enabled():
|
||||
graphiti_status = get_graphiti_status()
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti status check",
|
||||
enabled=graphiti_status.get("enabled"),
|
||||
available=graphiti_status.get("available"),
|
||||
host=graphiti_status.get("host"),
|
||||
port=graphiti_status.get("port"),
|
||||
database=graphiti_status.get("database"),
|
||||
llm_provider=graphiti_status.get("llm_provider"),
|
||||
embedder_provider=graphiti_status.get("embedder_provider"),
|
||||
reason=graphiti_status.get("reason") or "OK",
|
||||
)
|
||||
|
||||
# PRIMARY: Try Graphiti if enabled
|
||||
if graphiti_enabled:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Attempting PRIMARY storage: Graphiti")
|
||||
|
||||
memory = None
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation (async)
|
||||
memory = await get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory not available")
|
||||
debug(
|
||||
"memory",
|
||||
"get_graphiti_memory() returned None - this usually means Graphiti is disabled or provider config is invalid",
|
||||
)
|
||||
# Continue to file-based fallback
|
||||
if memory is not None and memory.is_enabled:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Saving to Graphiti...")
|
||||
|
||||
# Use structured insights if we have rich extracted data
|
||||
if discoveries and discoveries.get("file_insights"):
|
||||
# Rich insights from insight_extractor
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Using save_structured_insights (rich data available)",
|
||||
)
|
||||
result = await memory.save_structured_insights(discoveries)
|
||||
else:
|
||||
# Fallback to basic session insights
|
||||
result = await memory.save_session_insights(session_num, insights)
|
||||
|
||||
if result:
|
||||
logger.info(
|
||||
f"Session {session_num} insights saved to Graphiti (primary)"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_success(
|
||||
"memory",
|
||||
f"Session {session_num} saved to Graphiti (PRIMARY)",
|
||||
storage_type="graphiti",
|
||||
subtasks_saved=len(subtasks_completed),
|
||||
)
|
||||
return True, "graphiti"
|
||||
else:
|
||||
logger.warning(
|
||||
"Graphiti save returned False, falling back to file-based"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "Graphiti save returned False, using FALLBACK"
|
||||
)
|
||||
elif memory is None:
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory not available, using FALLBACK"
|
||||
)
|
||||
else:
|
||||
# memory is not None but memory.is_enabled is False
|
||||
logger.warning(
|
||||
"GraphitiMemory.is_enabled=False, falling back to file-based"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory disabled, using FALLBACK")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Graphiti save failed: {e}, falling back to file-based")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "Graphiti save failed", error=str(e))
|
||||
# Capture exception to Sentry with full context
|
||||
capture_exception(
|
||||
e,
|
||||
operation="save_session_memory_graphiti",
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
spec_dir=str(spec_dir),
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
finally:
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=e
|
||||
)
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Graphiti not enabled, skipping to FALLBACK")
|
||||
|
||||
# FALLBACK: File-based memory (when Graphiti is disabled or fails)
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Attempting FALLBACK storage: File-based")
|
||||
|
||||
try:
|
||||
memory_dir = spec_dir / "memory" / "session_insights"
|
||||
if is_debug_enabled():
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"File-based memory path",
|
||||
memory_dir=str(memory_dir),
|
||||
session_file=f"session_{session_num:03d}.json",
|
||||
)
|
||||
|
||||
save_file_based_memory(spec_dir, session_num, insights)
|
||||
logger.info(
|
||||
f"Session {session_num} insights saved to file-based memory (fallback)"
|
||||
)
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_success(
|
||||
"memory",
|
||||
f"Session {session_num} saved to file-based (FALLBACK)",
|
||||
storage_type="file",
|
||||
file_path=str(memory_dir / f"session_{session_num:03d}.json"),
|
||||
subtasks_saved=len(subtasks_completed),
|
||||
)
|
||||
return True, "file"
|
||||
except Exception as e:
|
||||
logger.error(f"File-based memory save also failed: {e}")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "File-based memory save FAILED", error=str(e))
|
||||
# Capture exception to Sentry with full context
|
||||
capture_exception(
|
||||
e,
|
||||
operation="save_session_memory_file",
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
spec_dir=str(spec_dir),
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
return False, "none"
|
||||
|
||||
|
||||
# Keep the old function name as an alias for backwards compatibility
|
||||
async def save_session_to_graphiti(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
success: bool,
|
||||
subtasks_completed: list[str],
|
||||
discoveries: dict | None = None,
|
||||
) -> bool:
|
||||
"""Backwards compatibility wrapper for save_session_memory."""
|
||||
result, _ = await save_session_memory(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
subtask_id,
|
||||
session_num,
|
||||
success,
|
||||
subtasks_completed,
|
||||
discoveries,
|
||||
)
|
||||
return result
|
||||
@@ -1,198 +0,0 @@
|
||||
"""
|
||||
Planner Agent Module
|
||||
====================
|
||||
|
||||
Handles follow-up planner sessions for adding new subtasks to completed specs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_followup_planner(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Run the follow-up planner to add new subtasks to a completed spec.
|
||||
|
||||
This is a simplified version of run_autonomous_agent that:
|
||||
1. Creates a client
|
||||
2. Loads the followup planner prompt
|
||||
3. Runs a single planning session
|
||||
4. Returns after the plan is updated (doesn't enter coding loop)
|
||||
|
||||
The planner agent will:
|
||||
- Read FOLLOWUP_REQUEST.md for the new task
|
||||
- Read the existing implementation_plan.json
|
||||
- Add new phase(s) with pending subtasks
|
||||
- Update the plan status back to in_progress
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project
|
||||
spec_dir: Directory containing the completed spec
|
||||
model: Claude model to use
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
bool: True if planning completed successfully
|
||||
"""
|
||||
from implementation_plan import ImplementationPlan
|
||||
from prompts import get_followup_planner_prompt
|
||||
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
|
||||
# Show header
|
||||
content = [
|
||||
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
|
||||
"",
|
||||
f"Spec: {highlight(spec_dir.name)}",
|
||||
muted("Adding follow-up work to completed spec."),
|
||||
"",
|
||||
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
|
||||
# Start planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
|
||||
task_logger.set_session(1)
|
||||
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
planning_betas = get_phase_model_betas(spec_dir, "planning", model)
|
||||
thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, "planning", planning_model
|
||||
)
|
||||
fast_mode = get_fast_mode(spec_dir)
|
||||
logger.info(
|
||||
f"[Planner] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for follow-up planning"
|
||||
)
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
agent_type="planner",
|
||||
betas=planning_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
prompt = get_followup_planner_prompt(spec_dir)
|
||||
|
||||
print_status("Running follow-up planner...", "progress")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Run single planning session
|
||||
async with client:
|
||||
status, response, error_info = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
|
||||
)
|
||||
|
||||
# End planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
success=(status != "error"),
|
||||
message="Follow-up planning session completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
print()
|
||||
print_status("Follow-up planning failed", "error")
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
# Verify the plan was updated (should have pending subtasks now)
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
plan = ImplementationPlan.load(plan_file)
|
||||
|
||||
# Check if there are any pending subtasks
|
||||
all_subtasks = [c for p in plan.phases for c in p.subtasks]
|
||||
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
|
||||
|
||||
if pending_subtasks:
|
||||
# Reset the plan status to in_progress (in case planner didn't)
|
||||
plan.reset_for_followup()
|
||||
await plan.async_save(plan_file)
|
||||
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
|
||||
"",
|
||||
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
|
||||
f"Total subtasks: {len(all_subtasks)}",
|
||||
"",
|
||||
muted("Next steps:"),
|
||||
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return True
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Warning: No pending subtasks found after planning", "warning"
|
||||
)
|
||||
print(muted("The planner may not have added new subtasks."))
|
||||
print(muted("Check implementation_plan.json manually."))
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return False
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Error: implementation_plan.json not found after planning", "error"
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print()
|
||||
print_status(f"Follow-up planning error: {e}", "error")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
@@ -1,347 +0,0 @@
|
||||
"""
|
||||
PR Template Filler Agent Module
|
||||
================================
|
||||
|
||||
Detects GitHub PR templates in a project and uses Claude to intelligently
|
||||
fill them based on code changes, spec context, commit history, and branch info.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from task_logger import LogPhase, get_task_logger
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum diff size (in characters) before truncating to file-level summaries
|
||||
MAX_DIFF_CHARS = 30_000
|
||||
|
||||
|
||||
def detect_pr_template(project_dir: Path | str) -> str | None:
|
||||
"""
|
||||
Detect a GitHub PR template in the project.
|
||||
|
||||
Searches for:
|
||||
1. .github/PULL_REQUEST_TEMPLATE.md (single template)
|
||||
2. .github/PULL_REQUEST_TEMPLATE/ directory (picks the first .md file)
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
The template content as a string, or None if no template is found.
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
# Check for single template file
|
||||
single_template = project_dir / ".github" / "PULL_REQUEST_TEMPLATE.md"
|
||||
if single_template.is_file():
|
||||
try:
|
||||
content = single_template.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
logger.info(f"Found PR template: {single_template}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read PR template {single_template}: {e}")
|
||||
|
||||
# Check for template directory (pick first .md file alphabetically)
|
||||
template_dir = project_dir / ".github" / "PULL_REQUEST_TEMPLATE"
|
||||
if template_dir.is_dir():
|
||||
try:
|
||||
md_files = sorted(template_dir.glob("*.md"))
|
||||
if md_files:
|
||||
content = md_files[0].read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
logger.info(f"Found PR template: {md_files[0]}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read PR template from {template_dir}: {e}")
|
||||
|
||||
logger.info("No GitHub PR template found in project")
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_diff(diff_summary: str) -> str:
|
||||
"""
|
||||
Truncate a large diff to file-level summaries to stay within token limits.
|
||||
|
||||
If the diff is within MAX_DIFF_CHARS, return it unchanged.
|
||||
Otherwise, extract only file-level change summaries (e.g. file names
|
||||
with insertions/deletions counts) and discard line-level detail.
|
||||
|
||||
Args:
|
||||
diff_summary: The full diff summary text
|
||||
|
||||
Returns:
|
||||
The original or truncated diff summary.
|
||||
"""
|
||||
if len(diff_summary) <= MAX_DIFF_CHARS:
|
||||
return diff_summary
|
||||
|
||||
lines = diff_summary.splitlines()
|
||||
summary_lines: list[str] = []
|
||||
summary_lines.append("(Diff truncated to file-level summaries due to size)")
|
||||
summary_lines.append("")
|
||||
|
||||
for line in lines:
|
||||
# Keep file-level summary lines (stat lines, file headers, etc.)
|
||||
stripped = line.strip()
|
||||
if (
|
||||
stripped.startswith("diff --git")
|
||||
or stripped.startswith("---")
|
||||
or stripped.startswith("+++")
|
||||
or "file changed" in stripped.lower()
|
||||
or "files changed" in stripped.lower()
|
||||
or "insertion" in stripped.lower()
|
||||
or "deletion" in stripped.lower()
|
||||
or stripped.startswith("rename")
|
||||
or stripped.startswith("new file")
|
||||
or stripped.startswith("deleted file")
|
||||
or stripped.startswith("Binary files")
|
||||
):
|
||||
summary_lines.append(line)
|
||||
|
||||
# If we couldn't extract meaningful summaries, take the first chunk
|
||||
if len(summary_lines) <= 2:
|
||||
truncated = diff_summary[:MAX_DIFF_CHARS]
|
||||
return truncated + "\n\n(... diff truncated due to size)"
|
||||
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
|
||||
def _strip_markdown_fences(content: str) -> str:
|
||||
"""
|
||||
Strip markdown code fences from the response if present.
|
||||
|
||||
The AI sometimes wraps the output in ```markdown ... ``` even when instructed
|
||||
not to. This ensures the PR body renders correctly on GitHub.
|
||||
|
||||
Args:
|
||||
content: The response content to clean
|
||||
|
||||
Returns:
|
||||
The content with markdown fences stripped.
|
||||
"""
|
||||
result = content
|
||||
|
||||
# Strip opening fence (```markdown or just ```)
|
||||
if result.startswith("```markdown"):
|
||||
result = result[len("```markdown") :].lstrip("\n")
|
||||
elif result.startswith("```md"):
|
||||
result = result[len("```md") :].lstrip("\n")
|
||||
elif result.startswith("```"):
|
||||
result = result[3:].lstrip("\n")
|
||||
|
||||
# Strip closing fence
|
||||
if result.endswith("```"):
|
||||
result = result[:-3].rstrip("\n")
|
||||
|
||||
return result.strip()
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
template_content: str,
|
||||
diff_summary: str,
|
||||
spec_overview: str,
|
||||
commit_log: str,
|
||||
branch_name: str,
|
||||
target_branch: str,
|
||||
) -> str:
|
||||
"""
|
||||
Build the prompt for the PR template filler agent.
|
||||
|
||||
Combines the system prompt context variables into a single message
|
||||
that includes the template and all change context.
|
||||
|
||||
Args:
|
||||
template_content: The PR template markdown
|
||||
diff_summary: Git diff summary (possibly truncated)
|
||||
spec_overview: Spec.md content or summary
|
||||
commit_log: Git log of commits in the PR
|
||||
branch_name: Source branch name
|
||||
target_branch: Target branch name
|
||||
|
||||
Returns:
|
||||
The assembled prompt string.
|
||||
"""
|
||||
return f"""Fill out the following GitHub PR template using the provided context.
|
||||
Return ONLY the filled template markdown — no preamble, no explanation, no code fences.
|
||||
|
||||
## Checkbox Guidelines
|
||||
|
||||
IMPORTANT: Be accurate and honest about what has and hasn't been verified.
|
||||
|
||||
**Check these based on context (you can infer from the diff/spec):**
|
||||
- Base Branch targeting — check based on target_branch value
|
||||
- Type of Change (bug fix, feature, docs, refactor, test) — infer from diff and spec
|
||||
- Area (Frontend, Backend, Fullstack) — infer from changed file paths
|
||||
- Feature Toggle "N/A" — if the feature appears complete and not behind a flag
|
||||
- Breaking Changes "No" — if changes appear backward compatible
|
||||
|
||||
**Leave UNCHECKED (these require human verification you cannot perform):**
|
||||
- "I've tested my changes locally" — you have not tested anything
|
||||
- "All CI checks pass" — CI has not run yet
|
||||
- "Windows/macOS/Linux tested" — requires manual testing on each platform
|
||||
- "All existing tests pass" — CI has not run yet
|
||||
- "New features include test coverage" — unless test files are clearly visible in the diff
|
||||
- "Bug fixes include regression tests" — unless test files are clearly visible in the diff
|
||||
|
||||
**For platform/code quality checkboxes:**
|
||||
- "Used centralized platform/ module" — leave unchecked unless you can verify from the diff
|
||||
- "No hardcoded paths" — leave unchecked unless you can verify from the diff
|
||||
- "PR is small and focused (< 400 lines)" — check only if diff stats show < 400 lines changed
|
||||
|
||||
**For the "I've synced with develop branch" checkbox:**
|
||||
- Leave unchecked — you cannot verify the sync status
|
||||
|
||||
## PR Template
|
||||
|
||||
{template_content}
|
||||
|
||||
## Change Context
|
||||
|
||||
### Branch Information
|
||||
- **Source branch:** {branch_name}
|
||||
- **Target branch:** {target_branch}
|
||||
|
||||
### Git Diff Summary
|
||||
```
|
||||
{diff_summary}
|
||||
```
|
||||
|
||||
### Spec Overview
|
||||
{spec_overview}
|
||||
|
||||
### Commit History
|
||||
```
|
||||
{commit_log}
|
||||
```
|
||||
|
||||
Fill every section of the PR template. Follow the checkbox guidelines above carefully.
|
||||
Output ONLY the completed template — no code fences, no preamble."""
|
||||
|
||||
|
||||
def _load_spec_overview(spec_dir: Path) -> str:
|
||||
"""
|
||||
Load the spec.md content for context. Falls back to a brief note if unavailable.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
The spec content or a fallback message.
|
||||
"""
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.is_file():
|
||||
try:
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
# Truncate very long specs to keep prompt manageable
|
||||
if len(content) > 8000:
|
||||
return content[:8000] + "\n\n(... spec truncated for brevity)"
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read spec.md: {e}")
|
||||
return "(No spec overview available)"
|
||||
|
||||
|
||||
async def run_pr_template_filler(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None = None,
|
||||
branch_name: str = "",
|
||||
target_branch: str = "develop",
|
||||
diff_summary: str = "",
|
||||
commit_log: str = "",
|
||||
verbose: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Run the PR template filler agent to generate a filled PR body.
|
||||
|
||||
Detects the project's PR template, gathers change context, and invokes
|
||||
Claude to intelligently fill out the template sections.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
spec_dir: Directory containing the spec files
|
||||
model: Claude model to use
|
||||
thinking_budget: Max thinking tokens (None to disable extended thinking)
|
||||
branch_name: Source branch name for the PR
|
||||
target_branch: Target branch name for the PR
|
||||
diff_summary: Git diff summary of changes
|
||||
commit_log: Git log of commits included in the PR
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
The filled template markdown string, or None if template detection fails
|
||||
or the agent encounters an error.
|
||||
"""
|
||||
# Detect PR template
|
||||
template_content = detect_pr_template(project_dir)
|
||||
if template_content is None:
|
||||
logger.info("No PR template detected — skipping template filler")
|
||||
return None
|
||||
|
||||
# Load spec overview
|
||||
spec_overview = _load_spec_overview(spec_dir)
|
||||
|
||||
# Truncate diff if too large
|
||||
truncated_diff = _truncate_diff(diff_summary)
|
||||
|
||||
# Build the prompt
|
||||
prompt = _build_prompt(
|
||||
template_content=template_content,
|
||||
diff_summary=truncated_diff,
|
||||
spec_overview=spec_overview,
|
||||
commit_log=commit_log,
|
||||
branch_name=branch_name,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
# Initialize task logger
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.CODING, "PR template filling")
|
||||
|
||||
# Create client following the pattern from planner.py
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
model,
|
||||
agent_type="pr_template_filler",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
status, response, _ = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.CODING
|
||||
)
|
||||
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
success=(status != "error"),
|
||||
message="PR template filling completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
logger.error("PR template filler agent returned an error")
|
||||
return None
|
||||
|
||||
# The agent should return only the filled template markdown
|
||||
if response and response.strip():
|
||||
result = _strip_markdown_fences(response.strip())
|
||||
logger.info("PR template filled successfully")
|
||||
return result
|
||||
|
||||
logger.warning("PR template filler returned empty response")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PR template filler error: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"PR template filler error: {e}", LogPhase.CODING)
|
||||
return None
|
||||
@@ -1,727 +0,0 @@
|
||||
"""
|
||||
Agent Session Management
|
||||
========================
|
||||
|
||||
Handles running agent sessions and post-session processing including
|
||||
memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from core.error_utils import (
|
||||
is_authentication_error,
|
||||
is_rate_limit_error,
|
||||
is_tool_concurrency_error,
|
||||
safe_receive_messages,
|
||||
)
|
||||
from core.file_utils import write_json_atomic
|
||||
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,
|
||||
linear_subtask_failed,
|
||||
)
|
||||
from progress import (
|
||||
count_subtasks_detailed,
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager, check_and_recover, reset_subtask
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
StatusManager,
|
||||
muted,
|
||||
print_key_value,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import sanitize_error_message
|
||||
from .memory_manager import save_session_memory
|
||||
from .utils import (
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _execute_recovery_action(
|
||||
recovery_action,
|
||||
recovery_manager: RecoveryManager,
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
) -> None:
|
||||
"""Execute a recovery action (rollback/retry/skip/escalate)."""
|
||||
if not recovery_action:
|
||||
return
|
||||
|
||||
print_status(f"Recovery action: {recovery_action.action}", "info")
|
||||
print_status(f"Reason: {recovery_action.reason}", "info")
|
||||
|
||||
if recovery_action.action == "rollback":
|
||||
print_status(f"Rolling back to {recovery_action.target[:8]}", "warning")
|
||||
if recovery_manager.rollback_to_commit(recovery_action.target):
|
||||
print_status("Rollback successful", "success")
|
||||
else:
|
||||
print_status("Rollback failed", "error")
|
||||
|
||||
elif recovery_action.action == "retry":
|
||||
print_status(f"Resetting subtask {subtask_id} for retry", "info")
|
||||
reset_subtask(spec_dir, project_dir, subtask_id)
|
||||
print_status("Subtask reset - will retry with different approach", "success")
|
||||
|
||||
elif recovery_action.action in ("skip", "escalate"):
|
||||
print_status(f"Marking subtask {subtask_id} as stuck", "warning")
|
||||
recovery_manager.mark_subtask_stuck(subtask_id, recovery_action.reason)
|
||||
print_status("Subtask marked for human intervention", "warning")
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
commit_before: str | None,
|
||||
commit_count_before: int,
|
||||
recovery_manager: RecoveryManager,
|
||||
linear_enabled: bool = False,
|
||||
status_manager: StatusManager | None = None,
|
||||
source_spec_dir: Path | None = None,
|
||||
error_info: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Process session results and update memory automatically.
|
||||
|
||||
This runs in Python (100% reliable) instead of relying on agent compliance.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory containing memory/
|
||||
project_dir: Project root for git operations
|
||||
subtask_id: The subtask that was being worked on
|
||||
session_num: Current session number
|
||||
commit_before: Git commit hash before session
|
||||
commit_count_before: Number of commits before session
|
||||
recovery_manager: Recovery manager instance
|
||||
linear_enabled: Whether Linear integration is enabled
|
||||
status_manager: Optional status manager for ccstatusline
|
||||
source_spec_dir: Original spec directory (for syncing back from worktree)
|
||||
error_info: Error information from run_agent_session (for rate limit detection)
|
||||
|
||||
Returns:
|
||||
True if subtask was completed successfully
|
||||
"""
|
||||
print()
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
if not plan:
|
||||
print(" Warning: Could not load implementation plan")
|
||||
return False
|
||||
|
||||
subtask = find_subtask_in_plan(plan, subtask_id)
|
||||
if not subtask:
|
||||
print(f" Warning: Subtask {subtask_id} not found in plan")
|
||||
return False
|
||||
|
||||
subtask_status = subtask.get("status", "pending")
|
||||
|
||||
# Check for new commits
|
||||
commit_after = get_latest_commit(project_dir)
|
||||
commit_count_after = get_commit_count(project_dir)
|
||||
new_commits = commit_count_after - commit_count_before
|
||||
|
||||
print_key_value("Subtask status", subtask_status)
|
||||
print_key_value("New commits", str(new_commits))
|
||||
|
||||
if subtask_status == "completed":
|
||||
# Success! Record the attempt and good commit
|
||||
print_status(f"Subtask {subtask_id} completed successfully", "success")
|
||||
|
||||
# Update status file
|
||||
if status_manager:
|
||||
subtasks = count_subtasks_detailed(spec_dir)
|
||||
status_manager.update_subtasks(
|
||||
completed=subtasks["completed"],
|
||||
total=subtasks["total"],
|
||||
in_progress=0,
|
||||
)
|
||||
|
||||
# Record successful attempt
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=session_num,
|
||||
success=True,
|
||||
approach=f"Implemented: {subtask.get('description', 'subtask')[:100]}",
|
||||
)
|
||||
|
||||
# Record good commit for rollback safety
|
||||
if commit_after and commit_after != commit_before:
|
||||
recovery_manager.record_good_commit(commit_after, subtask_id)
|
||||
print_status(f"Recorded good commit: {commit_after[:8]}", "success")
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
# Get progress counts for the comment
|
||||
subtasks_detail = count_subtasks_detailed(spec_dir)
|
||||
await linear_subtask_completed(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
completed_count=subtasks_detail["completed"],
|
||||
total_count=subtasks_detail["total"],
|
||||
)
|
||||
print_status("Linear progress recorded", "success")
|
||||
|
||||
# Extract rich insights from session (LLM-powered analysis)
|
||||
try:
|
||||
extracted_insights = await extract_session_insights(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=True,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
insight_count = len(extracted_insights.get("file_insights", []))
|
||||
pattern_count = len(extracted_insights.get("patterns_discovered", []))
|
||||
if insight_count > 0 or pattern_count > 0:
|
||||
print_status(
|
||||
f"Extracted {insight_count} file insights, {pattern_count} patterns",
|
||||
"success",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Insight extraction failed: {e}")
|
||||
extracted_insights = None
|
||||
|
||||
# Save session memory (Graphiti=primary, file-based=fallback)
|
||||
try:
|
||||
save_success, storage_type = await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=True,
|
||||
subtasks_completed=[subtask_id],
|
||||
discoveries=extracted_insights,
|
||||
)
|
||||
if save_success:
|
||||
if storage_type == "graphiti":
|
||||
print_status("Session saved to Graphiti memory", "success")
|
||||
else:
|
||||
print_status(
|
||||
"Session saved to file-based memory (fallback)", "info"
|
||||
)
|
||||
else:
|
||||
print_status("Failed to save session memory", "warning")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error saving session memory: {e}")
|
||||
print_status("Memory save failed", "warning")
|
||||
|
||||
return True
|
||||
|
||||
elif subtask_status == "in_progress":
|
||||
# Session ended without completion
|
||||
print_status(f"Subtask {subtask_id} still in progress", "warning")
|
||||
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=session_num,
|
||||
success=False,
|
||||
approach="Session ended with subtask in_progress",
|
||||
error="Subtask not marked as completed",
|
||||
)
|
||||
|
||||
# Check if this was a concurrency error - if so, reset subtask to pending for retry
|
||||
is_concurrency_error = (
|
||||
error_info and error_info.get("type") == "tool_concurrency"
|
||||
)
|
||||
|
||||
if is_concurrency_error:
|
||||
print_status(
|
||||
f"Rate limit detected - resetting subtask {subtask_id} to pending for retry",
|
||||
"info",
|
||||
)
|
||||
|
||||
# Use recovery system's reset_subtask for consistency
|
||||
reset_subtask(spec_dir, project_dir, subtask_id)
|
||||
|
||||
# Also reset in implementation plan
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
if plan:
|
||||
# Find and reset the subtask
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
# Reset subtask to pending state
|
||||
subtask["status"] = "pending"
|
||||
subtask["started_at"] = None
|
||||
subtask["completed_at"] = None
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
|
||||
if subtask_found:
|
||||
# Save plan atomically to prevent corruption
|
||||
try:
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
write_json_atomic(plan_path, plan, indent=2)
|
||||
print_status(
|
||||
f"Subtask {subtask_id} reset to pending status", "success"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to save implementation plan after reset: {e}"
|
||||
)
|
||||
print_status("Failed to save plan after reset", "error")
|
||||
else:
|
||||
print_status(
|
||||
f"Warning: Could not find subtask {subtask_id} in plan",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
print_status(
|
||||
"Warning: Could not load implementation plan for reset", "warning"
|
||||
)
|
||||
else:
|
||||
# Non-rate-limit error - use automatic recovery flow
|
||||
error_message = (
|
||||
error_info.get("message", "Subtask not marked as completed")
|
||||
if error_info
|
||||
else "Subtask not marked as completed"
|
||||
)
|
||||
|
||||
recovery_action = check_and_recover(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
error=error_message,
|
||||
)
|
||||
_execute_recovery_action(
|
||||
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
|
||||
)
|
||||
|
||||
# Still record commit if one was made (partial progress)
|
||||
if commit_after and commit_after != commit_before:
|
||||
recovery_manager.record_good_commit(commit_after, subtask_id)
|
||||
print_status(
|
||||
f"Recorded partial progress commit: {commit_after[:8]}", "info"
|
||||
)
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
await linear_subtask_failed(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
attempt=attempt_count,
|
||||
error_summary="Session ended without completion",
|
||||
)
|
||||
|
||||
# Extract insights even from failed sessions (valuable for future attempts)
|
||||
try:
|
||||
extracted_insights = await extract_session_insights(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=False,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Insight extraction failed for incomplete session: {e}")
|
||||
extracted_insights = None
|
||||
|
||||
# Save failed session memory (to track what didn't work)
|
||||
try:
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=False,
|
||||
subtasks_completed=[],
|
||||
discoveries=extracted_insights,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to save incomplete session memory: {e}")
|
||||
|
||||
return False
|
||||
|
||||
else:
|
||||
# Subtask still pending or failed
|
||||
print_status(
|
||||
f"Subtask {subtask_id} not completed (status: {subtask_status})", "error"
|
||||
)
|
||||
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=session_num,
|
||||
success=False,
|
||||
approach="Session ended without progress",
|
||||
error=f"Subtask status is {subtask_status}",
|
||||
)
|
||||
|
||||
# Automatic recovery flow - determine and execute recovery action
|
||||
error_message = f"Subtask status is {subtask_status}"
|
||||
if error_info:
|
||||
error_message = error_info.get("message", error_message)
|
||||
|
||||
recovery_action = check_and_recover(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
error=error_message,
|
||||
)
|
||||
_execute_recovery_action(
|
||||
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
|
||||
)
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
await linear_subtask_failed(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
attempt=attempt_count,
|
||||
error_summary=f"Subtask status: {subtask_status}",
|
||||
)
|
||||
|
||||
# Extract insights even from completely failed sessions
|
||||
try:
|
||||
extracted_insights = await extract_session_insights(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=False,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Insight extraction failed for failed session: {e}")
|
||||
extracted_insights = None
|
||||
|
||||
# Save failed session memory (to track what didn't work)
|
||||
try:
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=False,
|
||||
subtasks_completed=[],
|
||||
discoveries=extracted_insights,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to save failed session memory: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def run_agent_session(
|
||||
client: ClaudeSDKClient,
|
||||
message: str,
|
||||
spec_dir: Path,
|
||||
verbose: bool = False,
|
||||
phase: LogPhase = LogPhase.CODING,
|
||||
) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Run a single agent session using Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
client: Claude SDK client
|
||||
message: The prompt to send
|
||||
spec_dir: Spec directory path
|
||||
verbose: Whether to show detailed output
|
||||
phase: Current execution phase for logging
|
||||
|
||||
Returns:
|
||||
(status, response_text, error_info) where:
|
||||
- status: "continue", "complete", or "error"
|
||||
- response_text: Agent's response text
|
||||
- error_info: Dict with error details (empty if no error):
|
||||
- "type": "tool_concurrency" or "other"
|
||||
- "message": Error message string
|
||||
- "exception_type": Exception class name string
|
||||
"""
|
||||
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 safe_receive_messages(client, caller="session"):
|
||||
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"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
print(block.text, end="", flush=True)
|
||||
# Log text to task logger (persist without double-printing)
|
||||
if task_logger and block.text.strip():
|
||||
task_logger.log(
|
||||
block.text,
|
||||
LogEntryType.TEXT,
|
||||
phase,
|
||||
print_to_console=False,
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = None
|
||||
tool_count += 1
|
||||
|
||||
# Safely extract tool input (handles None, non-dict, etc.)
|
||||
inp = get_safe_tool_input(block)
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if inp:
|
||||
if "pattern" in inp:
|
||||
tool_input_display = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input_display = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input_display = cmd
|
||||
elif "path" in inp:
|
||||
tool_input_display = inp["path"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
full_input=str(inp)[:500] if inp else None,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing too)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
tool_name,
|
||||
tool_input_display,
|
||||
phase,
|
||||
print_to_console=True,
|
||||
)
|
||||
else:
|
||||
print(f"\n[Tool: {tool_name}]", flush=True)
|
||||
|
||||
if verbose and hasattr(block, "input"):
|
||||
input_str = str(block.input)
|
||||
if len(input_str) > 300:
|
||||
print(f" Input: {input_str[:300]}...", flush=True)
|
||||
else:
|
||||
print(f" Input: {input_str}", flush=True)
|
||||
current_tool = tool_name
|
||||
|
||||
# Handle UserMessage (tool results)
|
||||
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
|
||||
if block_type == "ToolResultBlock":
|
||||
result_content = getattr(block, "content", "")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
|
||||
# Check if this is an error (not just content containing "blocked")
|
||||
if is_error and "blocked" in str(result_content).lower():
|
||||
# Actual blocked command by security hook
|
||||
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(
|
||||
current_tool,
|
||||
success=False,
|
||||
result="BLOCKED",
|
||||
detail=str(result_content),
|
||||
phase=phase,
|
||||
)
|
||||
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
|
||||
task_logger.tool_end(
|
||||
current_tool,
|
||||
success=False,
|
||||
result=error_str[:100],
|
||||
detail=str(result_content),
|
||||
phase=phase,
|
||||
)
|
||||
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)
|
||||
else:
|
||||
print(" [Done]", flush=True)
|
||||
if task_logger and current_tool:
|
||||
# Store full result in detail for expandable view (only for certain tools)
|
||||
# Skip storing for very large outputs like Glob results
|
||||
detail_content = None
|
||||
if current_tool in (
|
||||
"Read",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Write",
|
||||
):
|
||||
result_str = str(result_content)
|
||||
# Only store if not too large (detail truncation happens in logger)
|
||||
if (
|
||||
len(result_str) < 50000
|
||||
): # 50KB max before truncation
|
||||
detail_content = result_str
|
||||
task_logger.tool_end(
|
||||
current_tool,
|
||||
success=True,
|
||||
detail=detail_content,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
current_tool = None
|
||||
|
||||
print("\n" + "-" * 70 + "\n")
|
||||
|
||||
# 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:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
is_rate_limit = is_rate_limit_error(e)
|
||||
is_auth = is_authentication_error(e)
|
||||
|
||||
# Classify error type for appropriate handling
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limit:
|
||||
error_type = "rate_limit"
|
||||
elif is_auth:
|
||||
error_type = "authentication"
|
||||
else:
|
||||
error_type = "other"
|
||||
|
||||
debug_error(
|
||||
"session",
|
||||
f"Session error: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
error_category=error_type,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
# Must happen BEFORE printing to stdout, since stdout is captured by the frontend
|
||||
sanitized_error = sanitize_error_message(str(e))
|
||||
|
||||
# Log errors prominently based on type
|
||||
if is_concurrency:
|
||||
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
||||
print(" Claude API limits concurrent tool use in a single request")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_rate_limit:
|
||||
print("\n⚠️ Rate limit reached")
|
||||
print(" API usage quota exceeded - waiting for reset")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_auth:
|
||||
print("\n⚠️ Authentication error")
|
||||
print(" OAuth token may be invalid or expired")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
else:
|
||||
print(f"Error during agent session: {sanitized_error}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {sanitized_error}", phase)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": sanitized_error,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", sanitized_error, error_info
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
Custom MCP Tools for Auto-Claude Agents
|
||||
========================================
|
||||
|
||||
This module provides custom MCP tools that agents can use for reliable
|
||||
operations on auto-claude data structures. These tools replace prompt-based
|
||||
JSON manipulation with guaranteed-correct operations.
|
||||
|
||||
Benefits:
|
||||
- 100% reliable JSON operations (no malformed output)
|
||||
- Reduced context usage (tool definitions << prompt instructions)
|
||||
- Type-safe with proper error handling
|
||||
- Each agent only sees tools relevant to their role via allowed_tools
|
||||
|
||||
Usage:
|
||||
from auto_claude_tools import create_auto_claude_mcp_server, get_allowed_tools
|
||||
|
||||
# Create the MCP server
|
||||
mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
|
||||
|
||||
# Get allowed tools for a specific agent type
|
||||
allowed_tools = get_allowed_tools("coder")
|
||||
|
||||
# Use in ClaudeAgentOptions
|
||||
options = ClaudeAgentOptions(
|
||||
mcp_servers={"auto-claude": mcp_server},
|
||||
allowed_tools=allowed_tools,
|
||||
...
|
||||
)
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
# Agent configuration registry
|
||||
AGENT_CONFIGS,
|
||||
# Base tools
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
# MCP tool lists
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
# Auto-Claude tool names
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
WEB_TOOLS,
|
||||
# Config functions
|
||||
get_agent_config,
|
||||
get_default_thinking_level,
|
||||
get_required_mcp_servers,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .permissions import get_all_agent_types, get_allowed_tools
|
||||
from .registry import create_auto_claude_mcp_server, is_tools_available
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Agent configuration registry
|
||||
"AGENT_CONFIGS",
|
||||
"get_agent_config",
|
||||
"get_required_mcp_servers",
|
||||
"get_default_thinking_level",
|
||||
"get_all_agent_types",
|
||||
# Base tool lists
|
||||
"BASE_READ_TOOLS",
|
||||
"BASE_WRITE_TOOLS",
|
||||
"WEB_TOOLS",
|
||||
# MCP tool lists
|
||||
"CONTEXT7_TOOLS",
|
||||
"LINEAR_TOOLS",
|
||||
"GRAPHITI_MCP_TOOLS",
|
||||
"ELECTRON_TOOLS",
|
||||
"PUPPETEER_TOOLS",
|
||||
# Auto-Claude tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Config
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
@@ -1,538 +0,0 @@
|
||||
"""
|
||||
Tool Models and Constants
|
||||
==========================
|
||||
|
||||
Defines tool name constants and configuration for auto-claude MCP tools.
|
||||
|
||||
This module is the single source of truth for all tool definitions used by
|
||||
the Claude Agent SDK client. Tool lists are organized by category:
|
||||
|
||||
- Base tools: Core file operations (Read, Write, Edit, etc.)
|
||||
- Web tools: Documentation and research (WebFetch, WebSearch)
|
||||
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
|
||||
- Auto-Claude tools: Custom build management tools
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# =============================================================================
|
||||
# Base Tools (Built-in Claude Code tools)
|
||||
# =============================================================================
|
||||
|
||||
# Core file operation tools
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# Web tools for documentation lookup and research
|
||||
# Always available to all agents for accessing external information
|
||||
WEB_TOOLS = ["WebFetch", "WebSearch"]
|
||||
|
||||
# =============================================================================
|
||||
# Auto-Claude MCP Tools (Custom build management)
|
||||
# =============================================================================
|
||||
|
||||
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
|
||||
TOOL_UPDATE_SUBTASK_STATUS = "mcp__auto-claude__update_subtask_status"
|
||||
TOOL_GET_BUILD_PROGRESS = "mcp__auto-claude__get_build_progress"
|
||||
TOOL_RECORD_DISCOVERY = "mcp__auto-claude__record_discovery"
|
||||
TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
|
||||
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
|
||||
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
|
||||
# =============================================================================
|
||||
# External MCP Tools
|
||||
# =============================================================================
|
||||
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__query-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Browser Automation MCP Tools (QA agents only)
|
||||
# =============================================================================
|
||||
|
||||
# Puppeteer MCP tools for web browser automation
|
||||
# Used for web frontend validation (non-Electron web apps)
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate",
|
||||
]
|
||||
|
||||
# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set)
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
"mcp__electron__send_command_to_electron", # Send commands (click, fill, evaluate JS)
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def is_electron_mcp_enabled() -> bool:
|
||||
"""
|
||||
Check if Electron MCP server integration is enabled.
|
||||
|
||||
Requires ELECTRON_MCP_ENABLED to be set to 'true'.
|
||||
When enabled, QA agents can use Electron MCP tools to connect to Electron apps
|
||||
via Chrome DevTools Protocol on the configured debug port.
|
||||
"""
|
||||
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Configuration Registry
|
||||
# =============================================================================
|
||||
# Single source of truth for phase → tools → MCP servers mapping.
|
||||
# This enables phase-aware tool control and context window optimization.
|
||||
|
||||
AGENT_CONFIGS = {
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SPEC CREATION PHASES (Minimal tools, fast startup)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"spec_gatherer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [], # No MCP needed - just reads project
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_researcher": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # Needs docs lookup
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_writer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [], # Just writes spec.md
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_critic": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_context": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_compaction": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# BUILD PHASES (Full tools + Graphiti memory)
|
||||
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"planner": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"], # Only if project setting enabled
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"coder": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "low", # Coding uses minimal thinking (effort: low for Opus, 1024 tokens for Sonnet/Haiku)
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"qa_fixer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# UTILITY PHASES (Minimal, no MCP)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"insights": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
# Note: Default to "low" for minimal thinking overhead
|
||||
# Haiku doesn't support thinking; create_simple_client() handles this
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"commit_message": {
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_template_filler": {
|
||||
"tools": BASE_READ_TOOLS, # Read-only — reads diff, template, spec
|
||||
"mcp_servers": [], # No MCP needed, context passed via prompt
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low", # Fast utility task for structured fill-in
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_orchestrator_parallel": {
|
||||
# Read-only for parallel PR orchestrator
|
||||
# NOTE: Do NOT add "Task" here - the SDK auto-allows Task when agents are defined
|
||||
# via the --agents flag. Explicitly adding it interferes with agent registration.
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_parallel": {
|
||||
# Read-only for parallel followup reviewer
|
||||
# NOTE: Do NOT add "Task" here - same reason as pr_orchestrator_parallel
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_extraction": {
|
||||
# Lightweight extraction call for recovering data when structured output fails
|
||||
# Pure structured output extraction, no tools needed
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"batch_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"batch_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ROADMAP & IDEATION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"roadmap_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # WebSearch for competitor research
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"ideation": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Config Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_agent_config(agent_type: str) -> dict:
|
||||
"""
|
||||
Get full configuration for an agent type.
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
|
||||
Returns:
|
||||
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
|
||||
"""
|
||||
if agent_type not in AGENT_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Unknown agent type: '{agent_type}'. "
|
||||
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
|
||||
)
|
||||
return AGENT_CONFIGS[agent_type]
|
||||
|
||||
|
||||
def _map_mcp_server_name(
|
||||
name: str, custom_server_ids: list[str] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Map user-friendly MCP server names to internal identifiers.
|
||||
Also accepts custom server IDs directly.
|
||||
|
||||
Args:
|
||||
name: User-provided MCP server name
|
||||
custom_server_ids: List of custom server IDs to accept as-is
|
||||
|
||||
Returns:
|
||||
Internal server identifier or None if not recognized
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
mappings = {
|
||||
"context7": "context7",
|
||||
"graphiti-memory": "graphiti",
|
||||
"graphiti": "graphiti",
|
||||
"linear": "linear",
|
||||
"electron": "electron",
|
||||
"puppeteer": "puppeteer",
|
||||
"auto-claude": "auto-claude",
|
||||
}
|
||||
# Check if it's a known mapping
|
||||
mapped = mappings.get(name.lower().strip())
|
||||
if mapped:
|
||||
return mapped
|
||||
# Check if it's a custom server ID (accept as-is)
|
||||
if custom_server_ids and name in custom_server_ids:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def get_required_mcp_servers(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get MCP servers required for this agent type.
|
||||
|
||||
Handles dynamic server selection:
|
||||
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
|
||||
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
|
||||
- "graphiti" → only if GRAPHITI_MCP_URL is set
|
||||
- Respects per-project MCP config overrides from .auto-claude/.env
|
||||
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
|
||||
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Returns:
|
||||
List of MCP server names to start
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
servers = list(config.get("mcp_servers", []))
|
||||
|
||||
# Load per-project config (or use defaults)
|
||||
if mcp_config is None:
|
||||
mcp_config = {}
|
||||
|
||||
# Filter context7 if explicitly disabled by project config
|
||||
if "context7" in servers:
|
||||
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
|
||||
if str(context7_enabled).lower() == "false":
|
||||
servers = [s for s in servers if s != "context7"]
|
||||
|
||||
# Handle optional servers (e.g., Linear if project setting enabled)
|
||||
optional = config.get("mcp_servers_optional", [])
|
||||
if "linear" in optional and linear_enabled:
|
||||
# Also check per-project LINEAR_MCP_ENABLED override
|
||||
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
|
||||
if str(linear_mcp_enabled).lower() != "false":
|
||||
servers.append("linear")
|
||||
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type and config
|
||||
if "browser" in servers:
|
||||
servers = [s for s in servers if s != "browser"]
|
||||
if project_capabilities:
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Check per-project overrides (default false for both)
|
||||
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
|
||||
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
|
||||
|
||||
# Electron: enabled by project config OR global env var
|
||||
if is_electron and (
|
||||
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
|
||||
):
|
||||
servers.append("electron")
|
||||
# Puppeteer: enabled by project config (no global env var)
|
||||
elif is_web_frontend and not is_electron:
|
||||
if str(puppeteer_enabled).lower() == "true":
|
||||
servers.append("puppeteer")
|
||||
|
||||
# Filter graphiti if not enabled
|
||||
if "graphiti" in servers:
|
||||
if not os.environ.get("GRAPHITI_MCP_URL"):
|
||||
servers = [s for s in servers if s != "graphiti"]
|
||||
|
||||
# ========== Apply per-agent MCP overrides ==========
|
||||
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
|
||||
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
|
||||
add_key = f"AGENT_MCP_{agent_type}_ADD"
|
||||
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
|
||||
|
||||
# Extract custom server IDs for mapping (allows custom servers to be recognized)
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
|
||||
|
||||
# Process additions
|
||||
if add_key in mcp_config:
|
||||
additions = [
|
||||
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in additions:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped not in servers:
|
||||
servers.append(mapped)
|
||||
|
||||
# Process removals (but never remove auto-claude)
|
||||
if remove_key in mcp_config:
|
||||
removals = [
|
||||
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in removals:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
|
||||
servers = [s for s in servers if s != mapped]
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def get_default_thinking_level(agent_type: str) -> str:
|
||||
"""
|
||||
Get default thinking level string for agent type.
|
||||
|
||||
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
|
||||
To convert to tokens, use phase_config.get_thinking_budget(level).
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (low, medium, high)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
Agent Tool Permissions
|
||||
======================
|
||||
|
||||
Manages which tools are allowed for each agent type to prevent context
|
||||
pollution and accidental misuse.
|
||||
|
||||
Supports dynamic tool filtering based on project capabilities to optimize
|
||||
context window usage. For example, Electron tools are only included for
|
||||
Electron projects, not for Next.js or CLI projects.
|
||||
|
||||
This module now uses AGENT_CONFIGS from models.py as the single source of truth
|
||||
for tool permissions. The get_allowed_tools() function remains the primary API
|
||||
for backwards compatibility.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
AGENT_CONFIGS,
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
get_agent_config,
|
||||
get_required_mcp_servers,
|
||||
)
|
||||
from .registry import is_tools_available
|
||||
|
||||
|
||||
def get_allowed_tools(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get the list of allowed tools for a specific agent type.
|
||||
|
||||
This ensures each agent only sees tools relevant to their role,
|
||||
preventing context pollution and accidental misuse.
|
||||
|
||||
Uses AGENT_CONFIGS as the single source of truth for tool permissions.
|
||||
Dynamic MCP tools are added based on project capabilities and required servers.
|
||||
|
||||
Args:
|
||||
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
project_capabilities: Optional dict from detect_project_capabilities()
|
||||
containing flags like is_electron, is_web_frontend, etc.
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
|
||||
Returns:
|
||||
List of allowed tool names
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
# Start with base tools from config
|
||||
tools = list(config.get("tools", []))
|
||||
|
||||
# Get required MCP servers for this agent
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
|
||||
# Add auto-claude tools ONLY if the MCP server is available
|
||||
# This prevents allowing tools that won't work because the server isn't running
|
||||
if "auto-claude" in required_servers and is_tools_available():
|
||||
tools.extend(config.get("auto_claude_tools", []))
|
||||
|
||||
# Add MCP tool names based on required servers
|
||||
tools.extend(_get_mcp_tools_for_servers(required_servers))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
|
||||
"""
|
||||
Get the list of MCP tools for a list of required servers.
|
||||
|
||||
Maps server names to their corresponding tool lists.
|
||||
|
||||
Args:
|
||||
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
|
||||
|
||||
Returns:
|
||||
List of MCP tool names for all specified servers
|
||||
"""
|
||||
tools = []
|
||||
|
||||
for server in servers:
|
||||
if server == "context7":
|
||||
tools.extend(CONTEXT7_TOOLS)
|
||||
elif server == "linear":
|
||||
tools.extend(LINEAR_TOOLS)
|
||||
elif server == "graphiti":
|
||||
tools.extend(GRAPHITI_MCP_TOOLS)
|
||||
elif server == "electron":
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
elif server == "puppeteer":
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
# auto-claude tools are already added via config["auto_claude_tools"]
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_all_agent_types() -> list[str]:
|
||||
"""
|
||||
Get all registered agent types.
|
||||
|
||||
Returns:
|
||||
Sorted list of all agent type identifiers
|
||||
"""
|
||||
return sorted(AGENT_CONFIGS.keys())
|
||||
@@ -1,72 +0,0 @@
|
||||
"""
|
||||
Tool Registry
|
||||
=============
|
||||
|
||||
Central registry for creating and managing auto-claude MCP tools.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import create_sdk_mcp_server
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
create_sdk_mcp_server = None
|
||||
|
||||
from .tools import (
|
||||
create_memory_tools,
|
||||
create_progress_tools,
|
||||
create_qa_tools,
|
||||
create_subtask_tools,
|
||||
)
|
||||
|
||||
|
||||
def create_all_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create all custom tools with the given spec and project directories.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of all tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
all_tools = []
|
||||
|
||||
# Create tools by category
|
||||
all_tools.extend(create_subtask_tools(spec_dir, project_dir))
|
||||
all_tools.extend(create_progress_tools(spec_dir, project_dir))
|
||||
all_tools.extend(create_memory_tools(spec_dir, project_dir))
|
||||
all_tools.extend(create_qa_tools(spec_dir, project_dir))
|
||||
|
||||
return all_tools
|
||||
|
||||
|
||||
def create_auto_claude_mcp_server(spec_dir: Path, project_dir: Path):
|
||||
"""
|
||||
Create an MCP server with auto-claude custom tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
MCP server instance, or None if SDK tools not available
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return None
|
||||
|
||||
tools = create_all_tools(spec_dir, project_dir)
|
||||
|
||||
return create_sdk_mcp_server(name="auto-claude", version="1.0.0", tools=tools)
|
||||
|
||||
|
||||
def is_tools_available() -> bool:
|
||||
"""Check if SDK tools functionality is available."""
|
||||
return SDK_TOOLS_AVAILABLE
|
||||
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Auto-Claude MCP Tools
|
||||
=====================
|
||||
|
||||
Individual tool implementations organized by functionality.
|
||||
"""
|
||||
|
||||
from .memory import create_memory_tools
|
||||
from .progress import create_progress_tools
|
||||
from .qa import create_qa_tools
|
||||
from .subtask import create_subtask_tools
|
||||
|
||||
__all__ = [
|
||||
"create_subtask_tools",
|
||||
"create_progress_tools",
|
||||
"create_memory_tools",
|
||||
"create_qa_tools",
|
||||
]
|
||||
@@ -1,356 +0,0 @@
|
||||
"""
|
||||
Session Memory Tools
|
||||
====================
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _save_to_graphiti_async(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (async implementation).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation
|
||||
# The helper handles enablement checks internally
|
||||
from memory.graphiti_helpers import get_graphiti_memory
|
||||
|
||||
memory = await get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if save_type == "discovery":
|
||||
# Save as codebase discovery
|
||||
# Format: {file_path: description}
|
||||
result = await memory.save_codebase_discoveries(
|
||||
{data["file_path"]: data["description"]}
|
||||
)
|
||||
elif save_type == "gotcha":
|
||||
# Save as gotcha
|
||||
gotcha_text = data["gotcha"]
|
||||
if data.get("context"):
|
||||
gotcha_text += f" (Context: {data['context']})"
|
||||
result = await memory.save_gotcha(gotcha_text)
|
||||
elif save_type == "pattern":
|
||||
# Save as pattern
|
||||
result = await memory.save_pattern(data["pattern"])
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
finally:
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_to_graphiti_sync(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (synchronous wrapper for sync contexts only).
|
||||
|
||||
NOTE: This should only be called from synchronous code. For async callers,
|
||||
use _save_to_graphiti_async() directly to ensure proper resource cleanup.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context - caller should use _save_to_graphiti_async
|
||||
# Log a warning and return False to avoid the resource leak bug
|
||||
logger.warning(
|
||||
"_save_to_graphiti_sync called from async context. "
|
||||
"Use _save_to_graphiti_async instead for proper cleanup."
|
||||
)
|
||||
return False
|
||||
except RuntimeError:
|
||||
# No running loop - safe to create one
|
||||
return asyncio.run(
|
||||
_save_to_graphiti_async(spec_dir, project_dir, save_type, data)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create session memory tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of memory tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: record_discovery
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"record_discovery",
|
||||
"Record a codebase discovery to session memory. Use this when you learn something important about the codebase.",
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file, encoding="utf-8") as f:
|
||||
codebase_map = json.load(f)
|
||||
else:
|
||||
codebase_map = {
|
||||
"discovered_files": {},
|
||||
"last_updated": None,
|
||||
}
|
||||
|
||||
# Add or update the discovery
|
||||
codebase_map["discovered_files"][file_path] = {
|
||||
"description": description,
|
||||
"category": category,
|
||||
"discovered_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
codebase_map["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(codebase_map_file, "w", encoding="utf-8") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"discovery",
|
||||
{
|
||||
"file_path": file_path,
|
||||
"description": f"[{category}] {description}",
|
||||
},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}{storage_note}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error recording discovery: {e}"}]
|
||||
}
|
||||
|
||||
tools.append(record_discovery)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: record_gotcha
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"record_gotcha",
|
||||
"Record a gotcha or pitfall to avoid. Use this when you encounter something that future sessions should know.",
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
if context:
|
||||
entry += f"\n\n_Context: {context}_"
|
||||
entry += "\n"
|
||||
|
||||
with open(gotchas_file, "a", encoding="utf-8") as f:
|
||||
if not gotchas_file.exists() or gotchas_file.stat().st_size == 0:
|
||||
f.write(
|
||||
"# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n"
|
||||
)
|
||||
f.write(entry)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"gotcha",
|
||||
{"gotcha": gotcha, "context": context},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error recording gotcha: {e}"}]
|
||||
}
|
||||
|
||||
tools.append(record_gotcha)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: get_session_context
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"get_session_context",
|
||||
"Get context from previous sessions including discoveries, gotchas, and patterns.",
|
||||
{},
|
||||
)
|
||||
async def get_session_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get accumulated session context."""
|
||||
memory_dir = spec_dir / "memory"
|
||||
|
||||
if not memory_dir.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "No session memory found. This appears to be the first session.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result_parts = []
|
||||
|
||||
# Load codebase map
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
if codebase_map_file.exists():
|
||||
try:
|
||||
with open(codebase_map_file, encoding="utf-8") as f:
|
||||
codebase_map = json.load(f)
|
||||
|
||||
discoveries = codebase_map.get("discovered_files", {})
|
||||
if discoveries:
|
||||
result_parts.append("## Codebase Discoveries")
|
||||
for path, info in list(discoveries.items())[:20]: # Limit to 20
|
||||
desc = info.get("description", "No description")
|
||||
result_parts.append(f"- `{path}`: {desc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load gotchas
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
if gotchas_file.exists():
|
||||
try:
|
||||
content = gotchas_file.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
result_parts.append("\n## Gotchas")
|
||||
# Take last 1000 chars to avoid too much context
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load patterns
|
||||
patterns_file = memory_dir / "patterns.md"
|
||||
if patterns_file.exists():
|
||||
try:
|
||||
content = patterns_file.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
result_parts.append("\n## Patterns")
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not result_parts:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": "No session context available yet."}
|
||||
]
|
||||
}
|
||||
|
||||
return {"content": [{"type": "text", "text": "\n".join(result_parts)}]}
|
||||
|
||||
tools.append(get_session_context)
|
||||
|
||||
return tools
|
||||
@@ -1,142 +0,0 @@
|
||||
"""
|
||||
Build Progress Tools
|
||||
====================
|
||||
|
||||
Tools for tracking and reporting build progress.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
|
||||
def create_progress_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create build progress tracking tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of progress tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: get_build_progress
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"get_build_progress",
|
||||
"Get the current build progress including completed subtasks, pending subtasks, and next subtask to work on.",
|
||||
{},
|
||||
)
|
||||
async def get_build_progress(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get current build progress."""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "No implementation plan found. Run the planner first.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
stats = {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"in_progress": 0,
|
||||
"pending": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
phases_summary = []
|
||||
next_subtask = None
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
phase_id = phase.get("id") or phase.get("phase")
|
||||
phase_name = phase.get("name", phase_id)
|
||||
phase_subtasks = phase.get("subtasks", [])
|
||||
|
||||
phase_stats = {"completed": 0, "total": len(phase_subtasks)}
|
||||
|
||||
for subtask in phase_subtasks:
|
||||
stats["total"] += 1
|
||||
status = subtask.get("status", "pending")
|
||||
|
||||
if status == "completed":
|
||||
stats["completed"] += 1
|
||||
phase_stats["completed"] += 1
|
||||
elif status == "in_progress":
|
||||
stats["in_progress"] += 1
|
||||
elif status == "failed":
|
||||
stats["failed"] += 1
|
||||
else:
|
||||
stats["pending"] += 1
|
||||
# Track next subtask to work on
|
||||
if next_subtask is None:
|
||||
next_subtask = {
|
||||
"id": subtask.get("id"),
|
||||
"description": subtask.get("description"),
|
||||
"phase": phase_name,
|
||||
}
|
||||
|
||||
phases_summary.append(
|
||||
f" {phase_name}: {phase_stats['completed']}/{phase_stats['total']}"
|
||||
)
|
||||
|
||||
progress_pct = (
|
||||
(stats["completed"] / stats["total"] * 100) if stats["total"] > 0 else 0
|
||||
)
|
||||
|
||||
result = f"""Build Progress: {stats["completed"]}/{stats["total"]} subtasks ({progress_pct:.0f}%)
|
||||
|
||||
Status breakdown:
|
||||
Completed: {stats["completed"]}
|
||||
In Progress: {stats["in_progress"]}
|
||||
Pending: {stats["pending"]}
|
||||
Failed: {stats["failed"]}
|
||||
|
||||
Phases:
|
||||
{chr(10).join(phases_summary)}"""
|
||||
|
||||
if next_subtask:
|
||||
result += f"""
|
||||
|
||||
Next subtask to work on:
|
||||
ID: {next_subtask["id"]}
|
||||
Phase: {next_subtask["phase"]}
|
||||
Description: {next_subtask["description"]}"""
|
||||
elif stats["completed"] == stats["total"]:
|
||||
result += "\n\nAll subtasks completed! Build is ready for QA."
|
||||
|
||||
return {"content": [{"type": "text", "text": result}]}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Error reading build progress: {e}"}
|
||||
]
|
||||
}
|
||||
|
||||
tools.append(get_build_progress)
|
||||
|
||||
return tools
|
||||
@@ -1,204 +0,0 @@
|
||||
"""
|
||||
QA Management Tools
|
||||
===================
|
||||
|
||||
Tools for managing QA status and sign-off in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from spec.validate_pkg.auto_fix import auto_fix_plan
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
|
||||
def _apply_qa_update(
|
||||
plan: dict[str, Any],
|
||||
status: str,
|
||||
issues: list[Any],
|
||||
tests_passed: dict[str, Any],
|
||||
) -> int:
|
||||
"""
|
||||
Apply QA update to the plan and return the new QA session number.
|
||||
|
||||
Args:
|
||||
plan: The implementation plan dict
|
||||
status: QA status (pending, in_review, approved, rejected, fixes_applied)
|
||||
issues: List of issues found
|
||||
tests_passed: Dict of test results
|
||||
|
||||
Returns:
|
||||
The new QA session number
|
||||
"""
|
||||
# Get current QA session number
|
||||
current_qa = plan.get("qa_signoff", {})
|
||||
qa_session = current_qa.get("qa_session", 0)
|
||||
if status in ["in_review", "rejected"]:
|
||||
qa_session += 1
|
||||
|
||||
plan["qa_signoff"] = {
|
||||
"status": status,
|
||||
"qa_session": qa_session,
|
||||
"issues_found": issues,
|
||||
"tests_passed": tests_passed,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
|
||||
# The frontend XState task state machine owns status transitions.
|
||||
# Writing status here races with XState's persistPlanStatusAndReasonSync()
|
||||
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return qa_session
|
||||
|
||||
|
||||
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create QA management tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of QA tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: update_qa_status
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"update_qa_status",
|
||||
"Update the QA sign-off status in implementation_plan.json. Use after QA review.",
|
||||
{"status": str, "issues": str, "tests_passed": str},
|
||||
)
|
||||
async def update_qa_status(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update QA status in the implementation plan."""
|
||||
status = args["status"]
|
||||
issues_str = args.get("issues", "[]")
|
||||
tests_str = args.get("tests_passed", "{}")
|
||||
|
||||
valid_statuses = [
|
||||
"pending",
|
||||
"in_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
"fixes_applied",
|
||||
]
|
||||
if status not in valid_statuses:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid QA status '{status}'. Must be one of: {valid_statuses}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Error: implementation_plan.json not found",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
# Parse issues and tests
|
||||
try:
|
||||
issues = json.loads(issues_str) if issues_str else []
|
||||
except json.JSONDecodeError:
|
||||
issues = [{"description": issues_str}] if issues_str else []
|
||||
|
||||
try:
|
||||
tests_passed = json.loads(tests_str) if tests_str else {}
|
||||
except json.JSONDecodeError:
|
||||
tests_passed = {}
|
||||
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
|
||||
|
||||
# Use atomic write to prevent file corruption
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Updated QA status to '{status}' (session {qa_session})",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt to auto-fix the plan and retry
|
||||
if auto_fix_plan(spec_dir):
|
||||
# Retry after fix
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Updated QA status to '{status}' (session {qa_session}) (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logging.warning(
|
||||
f"QA update retry failed after auto-fix: {retry_err} (original error: {e})"
|
||||
)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: QA update failed after auto-fix: {retry_err} (original JSON error: {e})",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
|
||||
}
|
||||
|
||||
tools.append(update_qa_status)
|
||||
|
||||
return tools
|
||||
@@ -1,204 +0,0 @@
|
||||
"""
|
||||
Subtask Management Tools
|
||||
========================
|
||||
|
||||
Tools for managing subtask status in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from spec.validate_pkg.auto_fix import auto_fix_plan
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
|
||||
def _update_subtask_in_plan(
|
||||
plan: dict[str, Any],
|
||||
subtask_id: str,
|
||||
status: str,
|
||||
notes: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Update a subtask in the plan.
|
||||
|
||||
Args:
|
||||
plan: The implementation plan dict
|
||||
subtask_id: ID of the subtask to update
|
||||
status: New status (pending, in_progress, completed, failed)
|
||||
notes: Optional notes to add
|
||||
|
||||
Returns:
|
||||
True if subtask was found and updated, False otherwise
|
||||
"""
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
subtask["status"] = status
|
||||
if notes:
|
||||
subtask["notes"] = notes
|
||||
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
|
||||
if subtask_found:
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return subtask_found
|
||||
|
||||
|
||||
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create subtask management tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of subtask tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: update_subtask_status
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"update_subtask_status",
|
||||
"Update the status of a subtask in implementation_plan.json. Use this when completing or starting a subtask.",
|
||||
{"subtask_id": str, "status": str, "notes": str},
|
||||
)
|
||||
async def update_subtask_status(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update subtask status in the implementation plan."""
|
||||
subtask_id = args["subtask_id"]
|
||||
status = args["status"]
|
||||
notes = args.get("notes", "")
|
||||
|
||||
valid_statuses = ["pending", "in_progress", "completed", "failed"]
|
||||
if status not in valid_statuses:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid status '{status}'. Must be one of: {valid_statuses}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Error: implementation_plan.json not found",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
subtask_found = _update_subtask_in_plan(plan, subtask_id, status, notes)
|
||||
|
||||
if not subtask_found:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Use atomic write to prevent file corruption
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}'",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt to auto-fix the plan and retry
|
||||
if auto_fix_plan(spec_dir):
|
||||
# Retry after fix
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
subtask_found = _update_subtask_in_plan(
|
||||
plan, subtask_id, status, notes
|
||||
)
|
||||
|
||||
if subtask_found:
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}' (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logging.warning(
|
||||
f"Subtask update retry failed after auto-fix: {retry_err}"
|
||||
)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask update failed after auto-fix: {retry_err}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Error updating subtask status: {e}"}
|
||||
]
|
||||
}
|
||||
|
||||
tools.append(update_subtask_status)
|
||||
|
||||
return tools
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Utility Functions for Agent System
|
||||
===================================
|
||||
|
||||
Helper functions for git operations, plan management, and file syncing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_latest_commit(project_dir: Path) -> str | None:
|
||||
"""Get the hash of the latest git commit."""
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_commit_count(project_dir: Path) -> int:
|
||||
"""Get the total number of commits."""
|
||||
result = run_git(
|
||||
["rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
"""Load the implementation plan JSON."""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def find_subtask_in_plan(plan: dict, subtask_id: str) -> dict | None:
|
||||
"""Find a subtask by ID in the plan."""
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
return subtask
|
||||
return None
|
||||
|
||||
|
||||
def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
"""Find the phase containing a subtask."""
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
return phase
|
||||
return None
|
||||
|
||||
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
True if sync was performed, False if not needed or failed
|
||||
"""
|
||||
# Skip if no source specified or same path (not in worktree mode)
|
||||
if not source_spec_dir:
|
||||
return False
|
||||
|
||||
# Resolve paths and check if they're different
|
||||
spec_dir_resolved = spec_dir.resolve()
|
||||
source_spec_dir_resolved = source_spec_dir.resolve()
|
||||
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
synced_any = False
|
||||
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Analysis Module
|
||||
===============
|
||||
|
||||
Code analysis and project scanning tools.
|
||||
"""
|
||||
|
||||
# Import from analyzers subpackage (these are the modular analyzers)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .analyzers import (
|
||||
ProjectAnalyzer as ModularProjectAnalyzer,
|
||||
)
|
||||
from .analyzers import (
|
||||
ServiceAnalyzer,
|
||||
analyze_project,
|
||||
analyze_service,
|
||||
)
|
||||
from .ci_discovery import CIDiscovery
|
||||
|
||||
# Import from analysis module root (these are other analysis tools)
|
||||
from .project_analyzer import ProjectAnalyzer
|
||||
from .risk_classifier import RiskClassifier
|
||||
from .security_scanner import SecurityScanner
|
||||
|
||||
# TestDiscovery was removed - tests are now co-located in their respective modules
|
||||
|
||||
# insight_extractor is a module with functions, not a class, so don't import it here
|
||||
# Import it directly when needed: from analysis import insight_extractor
|
||||
|
||||
__all__ = [
|
||||
"ProjectAnalyzer",
|
||||
"ModularProjectAnalyzer",
|
||||
"ServiceAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
"RiskClassifier",
|
||||
"SecurityScanner",
|
||||
"CIDiscovery",
|
||||
# "TestDiscovery", # Removed - tests now co-located in their modules
|
||||
]
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Codebase Analyzer
|
||||
=================
|
||||
|
||||
Automatically detects project structure, frameworks, and services.
|
||||
Supports monorepos with multiple services.
|
||||
|
||||
Usage:
|
||||
# Index entire project (creates project_index.json)
|
||||
python auto-claude/analyzer.py --index
|
||||
|
||||
# Analyze specific service
|
||||
python auto-claude/analyzer.py --service backend
|
||||
|
||||
# Output to specific file
|
||||
python auto-claude/analyzer.py --index --output path/to/output.json
|
||||
|
||||
The analyzer will:
|
||||
1. Detect if this is a monorepo or single project
|
||||
2. Find all services/packages and analyze each separately
|
||||
3. Map interdependencies between services
|
||||
4. Identify infrastructure (Docker, CI/CD)
|
||||
5. Document conventions (linting, testing)
|
||||
|
||||
This module now serves as a facade to the modular analyzer system in the analyzers/ package.
|
||||
All actual implementation is in focused submodules for better maintainability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Import from the new modular structure
|
||||
from .analyzers import (
|
||||
ProjectAnalyzer,
|
||||
ServiceAnalyzer,
|
||||
analyze_project,
|
||||
analyze_service,
|
||||
)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
"ServiceAnalyzer",
|
||||
"ProjectAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze project structure, frameworks, and services"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Project directory to analyze (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
action="store_true",
|
||||
help="Create full project index (default behavior)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--service",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Analyze a specific service only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output file for JSON results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Only output JSON, no status messages",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what to analyze
|
||||
if args.service:
|
||||
results = analyze_service(args.project_dir, args.service, args.output)
|
||||
else:
|
||||
results = analyze_project(args.project_dir, args.output)
|
||||
|
||||
# Print results
|
||||
if not args.quiet or not args.output:
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Analyzers Package
|
||||
=================
|
||||
|
||||
Modular analyzer system for detecting project structure, frameworks, and services.
|
||||
|
||||
Main exports:
|
||||
- ServiceAnalyzer: Analyzes a single service/package
|
||||
- ProjectAnalyzer: Analyzes entire projects (single or monorepo)
|
||||
- analyze_project: Convenience function for project analysis
|
||||
- analyze_service: Convenience function for service analysis
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .project_analyzer_module import ProjectAnalyzer
|
||||
from .service_analyzer import ServiceAnalyzer
|
||||
|
||||
# Re-export main classes
|
||||
__all__ = [
|
||||
"ServiceAnalyzer",
|
||||
"ProjectAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
]
|
||||
|
||||
|
||||
def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
|
||||
"""
|
||||
Analyze a project and optionally save results.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
output_file: Optional path to save JSON output
|
||||
|
||||
Returns:
|
||||
Project index as a dictionary
|
||||
"""
|
||||
import json
|
||||
|
||||
analyzer = ProjectAnalyzer(project_dir)
|
||||
results = analyzer.analyze()
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Project index saved to: {output_file}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def analyze_service(
|
||||
project_dir: Path, service_name: str, output_file: Path | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze a specific service within a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
service_name: Name of the service to analyze
|
||||
output_file: Optional path to save JSON output
|
||||
|
||||
Returns:
|
||||
Service analysis as a dictionary
|
||||
"""
|
||||
import json
|
||||
|
||||
# Find the service
|
||||
service_path = project_dir / service_name
|
||||
if not service_path.exists():
|
||||
# Check common locations
|
||||
for parent in ["packages", "apps", "services"]:
|
||||
candidate = project_dir / parent / service_name
|
||||
if candidate.exists():
|
||||
service_path = candidate
|
||||
break
|
||||
|
||||
if not service_path.exists():
|
||||
raise ValueError(f"Service '{service_name}' not found in {project_dir}")
|
||||
|
||||
analyzer = ServiceAnalyzer(service_path, service_name)
|
||||
results = analyzer.analyze()
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Service analysis saved to: {output_file}")
|
||||
|
||||
return results
|
||||
@@ -1,151 +0,0 @@
|
||||
"""
|
||||
Base Analyzer Module
|
||||
====================
|
||||
|
||||
Provides common constants, utilities, and base functionality shared across all analyzers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Directories to skip during analysis
|
||||
SKIP_DIRS = {
|
||||
"node_modules",
|
||||
".git",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".env",
|
||||
"env",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".nuxt",
|
||||
"target",
|
||||
"vendor",
|
||||
".idea",
|
||||
".vscode",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
"coverage",
|
||||
".coverage",
|
||||
"htmlcov",
|
||||
"eggs",
|
||||
"*.egg-info",
|
||||
".turbo",
|
||||
".cache",
|
||||
".worktrees", # Skip git worktrees directory
|
||||
".auto-claude", # Skip auto-claude metadata directory
|
||||
}
|
||||
|
||||
# Common service directory names
|
||||
SERVICE_INDICATORS = {
|
||||
"backend",
|
||||
"frontend",
|
||||
"api",
|
||||
"web",
|
||||
"app",
|
||||
"server",
|
||||
"client",
|
||||
"worker",
|
||||
"workers",
|
||||
"services",
|
||||
"packages",
|
||||
"apps",
|
||||
"libs",
|
||||
"scraper",
|
||||
"crawler",
|
||||
"proxy",
|
||||
"gateway",
|
||||
"admin",
|
||||
"dashboard",
|
||||
"mobile",
|
||||
"desktop",
|
||||
"cli",
|
||||
"sdk",
|
||||
"core",
|
||||
"shared",
|
||||
"common",
|
||||
}
|
||||
|
||||
# Files that indicate a service root
|
||||
SERVICE_ROOT_FILES = {
|
||||
"package.json",
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
"Gemfile",
|
||||
"composer.json",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"Makefile",
|
||||
"Dockerfile",
|
||||
}
|
||||
|
||||
|
||||
class BaseAnalyzer:
|
||||
"""Base class with common utilities for all analyzers."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path.resolve()
|
||||
|
||||
def _exists(self, path: str) -> bool:
|
||||
"""Check if a file exists relative to the analyzer's path."""
|
||||
return (self.path / path).exists()
|
||||
|
||||
def _read_file(self, path: str) -> str:
|
||||
"""Read a file relative to the analyzer's path."""
|
||||
try:
|
||||
return (self.path / path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
def _read_json(self, path: str) -> dict | None:
|
||||
"""Read and parse a JSON file relative to the analyzer's path."""
|
||||
content = self._read_file(path)
|
||||
if content:
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _infer_env_var_type(self, value: str) -> str:
|
||||
"""Infer the type of an environment variable from its value."""
|
||||
if not value:
|
||||
return "string"
|
||||
|
||||
# Boolean
|
||||
if value.lower() in ["true", "false", "1", "0", "yes", "no"]:
|
||||
return "boolean"
|
||||
|
||||
# Number
|
||||
if value.isdigit():
|
||||
return "number"
|
||||
|
||||
# URL
|
||||
if value.startswith(
|
||||
(
|
||||
"http://",
|
||||
"https://",
|
||||
"postgres://",
|
||||
"postgresql://",
|
||||
"mysql://",
|
||||
"mongodb://",
|
||||
"redis://",
|
||||
)
|
||||
):
|
||||
return "url"
|
||||
|
||||
# Email
|
||||
if "@" in value and "." in value:
|
||||
return "email"
|
||||
|
||||
# Path
|
||||
if "/" in value or "\\" in value:
|
||||
return "path"
|
||||
|
||||
return "string"
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
Context Analyzer Package
|
||||
=========================
|
||||
|
||||
Contains specialized detectors for comprehensive project context analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .api_docs_detector import ApiDocsDetector
|
||||
from .auth_detector import AuthDetector
|
||||
from .env_detector import EnvironmentDetector
|
||||
from .jobs_detector import JobsDetector
|
||||
from .migrations_detector import MigrationsDetector
|
||||
from .monitoring_detector import MonitoringDetector
|
||||
from .services_detector import ServicesDetector
|
||||
|
||||
__all__ = [
|
||||
"ApiDocsDetector",
|
||||
"AuthDetector",
|
||||
"EnvironmentDetector",
|
||||
"JobsDetector",
|
||||
"MigrationsDetector",
|
||||
"MonitoringDetector",
|
||||
"ServicesDetector",
|
||||
]
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
API Documentation Detector Module
|
||||
==================================
|
||||
|
||||
Detects API documentation tools and configurations:
|
||||
- OpenAPI/Swagger (FastAPI auto-generated, swagger-ui-express)
|
||||
- GraphQL playground
|
||||
- API documentation endpoints
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class ApiDocsDetector(BaseAnalyzer):
|
||||
"""Detects API documentation setup."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect API documentation setup.
|
||||
|
||||
Detects: OpenAPI/Swagger, GraphQL playground, API docs endpoints.
|
||||
"""
|
||||
docs_info = {}
|
||||
|
||||
# Detect OpenAPI/Swagger
|
||||
openapi_info = self._detect_fastapi() or self._detect_swagger_nodejs()
|
||||
if openapi_info:
|
||||
docs_info.update(openapi_info)
|
||||
|
||||
# Detect GraphQL
|
||||
graphql_info = self._detect_graphql()
|
||||
if graphql_info:
|
||||
docs_info["graphql"] = graphql_info
|
||||
|
||||
if docs_info:
|
||||
self.analysis["api_documentation"] = docs_info
|
||||
|
||||
def _detect_fastapi(self) -> dict[str, Any] | None:
|
||||
"""Detect FastAPI auto-generated OpenAPI docs."""
|
||||
if self.analysis.get("framework") != "FastAPI":
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "openapi",
|
||||
"auto_generated": True,
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
def _detect_swagger_nodejs(self) -> dict[str, Any] | None:
|
||||
"""Detect Swagger for Node.js projects."""
|
||||
if not self._exists("package.json"):
|
||||
return None
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "swagger-ui-express" in deps or "swagger-jsdoc" in deps:
|
||||
return {
|
||||
"type": "openapi",
|
||||
"library": "swagger-ui-express",
|
||||
"docs_url": "/api-docs",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _detect_graphql(self) -> dict[str, str] | None:
|
||||
"""Detect GraphQL API and playground."""
|
||||
if not self._exists("package.json"):
|
||||
return None
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "graphql" in deps or "apollo-server" in deps or "@apollo/server" in deps:
|
||||
return {
|
||||
"playground_url": "/graphql",
|
||||
"library": "apollo-server" if "apollo-server" in deps else "graphql",
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Authentication Patterns Detector Module
|
||||
========================================
|
||||
|
||||
Detects authentication and authorization patterns:
|
||||
- JWT authentication
|
||||
- OAuth providers
|
||||
- Session-based authentication
|
||||
- API key authentication
|
||||
- User models
|
||||
- Auth middleware and decorators
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class AuthDetector(BaseAnalyzer):
|
||||
"""Detects authentication and authorization patterns."""
|
||||
|
||||
JWT_LIBS = ["python-jose", "pyjwt", "jsonwebtoken", "jose"]
|
||||
OAUTH_LIBS = ["authlib", "passport", "next-auth", "@auth/core", "oauth2"]
|
||||
SESSION_LIBS = ["flask-login", "express-session", "django.contrib.auth"]
|
||||
|
||||
USER_MODEL_FILES = [
|
||||
"models/user.py",
|
||||
"models/User.py",
|
||||
"app/models/user.py",
|
||||
"models/user.ts",
|
||||
"models/User.ts",
|
||||
"src/models/user.ts",
|
||||
]
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect authentication and authorization patterns.
|
||||
|
||||
Detects: JWT, OAuth, session-based, API keys, user models, protected routes.
|
||||
"""
|
||||
auth_info = {
|
||||
"strategies": [],
|
||||
"libraries": [],
|
||||
"user_model": None,
|
||||
"middleware": [],
|
||||
}
|
||||
|
||||
# Get all dependencies
|
||||
all_deps = self._get_all_dependencies()
|
||||
|
||||
# Detect auth strategies and libraries
|
||||
self._detect_jwt(all_deps, auth_info)
|
||||
self._detect_oauth(all_deps, auth_info)
|
||||
self._detect_session(all_deps, auth_info)
|
||||
|
||||
# Find user model
|
||||
auth_info["user_model"] = self._find_user_model()
|
||||
|
||||
# Detect auth middleware/decorators
|
||||
auth_info["middleware"] = self._find_auth_middleware()
|
||||
|
||||
# Remove duplicates from strategies
|
||||
auth_info["strategies"] = list(set(auth_info["strategies"]))
|
||||
|
||||
if auth_info["strategies"] or auth_info["libraries"]:
|
||||
self.analysis["auth"] = auth_info
|
||||
|
||||
def _get_all_dependencies(self) -> set[str]:
|
||||
"""Extract all dependencies from Python and Node.js projects."""
|
||||
all_deps = set()
|
||||
|
||||
if self._exists("requirements.txt"):
|
||||
content = self._read_file("requirements.txt")
|
||||
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
all_deps.update(pkg.get("dependencies", {}).keys())
|
||||
|
||||
return all_deps
|
||||
|
||||
def _detect_jwt(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
|
||||
"""Detect JWT authentication libraries."""
|
||||
for lib in self.JWT_LIBS:
|
||||
if lib in all_deps:
|
||||
auth_info["strategies"].append("jwt")
|
||||
auth_info["libraries"].append(lib)
|
||||
break
|
||||
|
||||
def _detect_oauth(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
|
||||
"""Detect OAuth authentication libraries."""
|
||||
for lib in self.OAUTH_LIBS:
|
||||
if lib in all_deps:
|
||||
auth_info["strategies"].append("oauth")
|
||||
auth_info["libraries"].append(lib)
|
||||
break
|
||||
|
||||
def _detect_session(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
|
||||
"""Detect session-based authentication libraries."""
|
||||
for lib in self.SESSION_LIBS:
|
||||
if lib in all_deps:
|
||||
auth_info["strategies"].append("session")
|
||||
auth_info["libraries"].append(lib)
|
||||
break
|
||||
|
||||
def _find_user_model(self) -> str | None:
|
||||
"""Find the user model file."""
|
||||
for model_file in self.USER_MODEL_FILES:
|
||||
if self._exists(model_file):
|
||||
return model_file
|
||||
return None
|
||||
|
||||
def _find_auth_middleware(self) -> list[str]:
|
||||
"""Detect auth middleware and decorators from Python files."""
|
||||
# Limit to first 20 files for performance
|
||||
all_py_files = list(self.path.glob("**/*.py"))[:20]
|
||||
auth_decorators = set()
|
||||
|
||||
for py_file in all_py_files:
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
# Find custom decorators
|
||||
if (
|
||||
"@require" in content
|
||||
or "@login_required" in content
|
||||
or "@authenticate" in content
|
||||
):
|
||||
decorators = re.findall(r"@(\w*(?:require|auth|login)\w*)", content)
|
||||
auth_decorators.update(decorators)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return list(auth_decorators) if auth_decorators else []
|
||||
@@ -1,223 +0,0 @@
|
||||
"""
|
||||
Environment Variable Detector Module
|
||||
=====================================
|
||||
|
||||
Detects and analyzes environment variables from multiple sources:
|
||||
- .env files and variants
|
||||
- .env.example files
|
||||
- docker-compose.yml
|
||||
- Source code (os.getenv, process.env)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class EnvironmentDetector(BaseAnalyzer):
|
||||
"""Detects environment variables and their configurations."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Discover all environment variables from multiple sources.
|
||||
|
||||
Extracts from: .env files, docker-compose, example files.
|
||||
Categorizes as required/optional and detects sensitive data.
|
||||
"""
|
||||
env_vars = {}
|
||||
required_vars = set()
|
||||
optional_vars = set()
|
||||
|
||||
# Parse various sources
|
||||
self._parse_env_files(env_vars)
|
||||
self._parse_env_example(env_vars, required_vars)
|
||||
self._parse_docker_compose(env_vars)
|
||||
self._parse_code_references(env_vars, optional_vars)
|
||||
|
||||
# Mark required vs optional
|
||||
for key in env_vars:
|
||||
if "required" not in env_vars[key]:
|
||||
env_vars[key]["required"] = key in required_vars
|
||||
|
||||
if env_vars:
|
||||
self.analysis["environment"] = {
|
||||
"variables": env_vars,
|
||||
"required_count": len(required_vars),
|
||||
"optional_count": len(optional_vars),
|
||||
"detected_count": len(env_vars),
|
||||
}
|
||||
|
||||
def _parse_env_files(self, env_vars: dict[str, Any]) -> None:
|
||||
"""Parse .env files and variants."""
|
||||
env_files = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.production",
|
||||
".env.dev",
|
||||
".env.prod",
|
||||
".env.test",
|
||||
".env.staging",
|
||||
"config/.env",
|
||||
"../.env",
|
||||
]
|
||||
|
||||
for env_file in env_files:
|
||||
content = self._read_file(env_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Parse KEY=value or KEY="value" or KEY='value'
|
||||
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$", line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
value = match.group(2).strip().strip('"').strip("'")
|
||||
|
||||
# Detect if sensitive
|
||||
is_sensitive = self._is_sensitive_key(key)
|
||||
|
||||
# Detect type
|
||||
var_type = self._infer_env_var_type(value)
|
||||
|
||||
env_vars[key] = {
|
||||
"value": "<REDACTED>" if is_sensitive else value,
|
||||
"source": env_file,
|
||||
"type": var_type,
|
||||
"sensitive": is_sensitive,
|
||||
}
|
||||
|
||||
def _parse_env_example(
|
||||
self, env_vars: dict[str, Any], required_vars: set[str]
|
||||
) -> None:
|
||||
"""Parse .env.example to find required variables."""
|
||||
example_content = self._read_file(".env.example") or self._read_file(
|
||||
".env.sample"
|
||||
)
|
||||
if not example_content:
|
||||
return
|
||||
|
||||
for line in example_content.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=", line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
required_vars.add(key)
|
||||
|
||||
if key not in env_vars:
|
||||
env_vars[key] = {
|
||||
"value": None,
|
||||
"source": ".env.example",
|
||||
"type": "string",
|
||||
"sensitive": self._is_sensitive_key(key),
|
||||
"required": True,
|
||||
}
|
||||
|
||||
def _parse_docker_compose(self, env_vars: dict[str, Any]) -> None:
|
||||
"""Parse docker-compose.yml environment section."""
|
||||
for compose_file in ["docker-compose.yml", "../docker-compose.yml"]:
|
||||
content = self._read_file(compose_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Look for environment variables in docker-compose
|
||||
in_env_section = False
|
||||
for line in content.split("\n"):
|
||||
if "environment:" in line:
|
||||
in_env_section = True
|
||||
continue
|
||||
|
||||
if in_env_section:
|
||||
# Check if we left the environment section
|
||||
if line and not line.startswith((" ", "\t", "-")):
|
||||
in_env_section = False
|
||||
continue
|
||||
|
||||
# Parse - KEY=value or - KEY
|
||||
match = re.match(r"^\s*-\s*([A-Z_][A-Z0-9_]*)", line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
if key not in env_vars:
|
||||
env_vars[key] = {
|
||||
"value": None,
|
||||
"source": compose_file,
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
}
|
||||
|
||||
def _parse_code_references(
|
||||
self, env_vars: dict[str, Any], optional_vars: set[str]
|
||||
) -> None:
|
||||
"""Scan code for os.getenv() / process.env usage to find optional vars."""
|
||||
entry_files = [
|
||||
"app.py",
|
||||
"main.py",
|
||||
"config.py",
|
||||
"settings.py",
|
||||
"src/config.py",
|
||||
"src/settings.py",
|
||||
"index.js",
|
||||
"index.ts",
|
||||
"config.js",
|
||||
"config.ts",
|
||||
]
|
||||
|
||||
for entry_file in entry_files:
|
||||
content = self._read_file(entry_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Python: os.getenv("VAR") or os.environ.get("VAR")
|
||||
python_patterns = [
|
||||
r'os\.getenv\(["\']([A-Z_][A-Z0-9_]*)["\']',
|
||||
r'os\.environ\.get\(["\']([A-Z_][A-Z0-9_]*)["\']',
|
||||
r'os\.environ\[["\']([A-Z_][A-Z0-9_]*)["\']',
|
||||
]
|
||||
|
||||
# JavaScript: process.env.VAR
|
||||
js_patterns = [
|
||||
r"process\.env\.([A-Z_][A-Z0-9_]*)",
|
||||
]
|
||||
|
||||
for pattern in python_patterns + js_patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
for var_name in matches:
|
||||
if var_name not in env_vars:
|
||||
optional_vars.add(var_name)
|
||||
env_vars[var_name] = {
|
||||
"value": None,
|
||||
"source": f"code:{entry_file}",
|
||||
"type": "string",
|
||||
"sensitive": self._is_sensitive_key(var_name),
|
||||
"required": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
"""Determine if an environment variable key contains sensitive data."""
|
||||
sensitive_keywords = [
|
||||
"secret",
|
||||
"key",
|
||||
"password",
|
||||
"token",
|
||||
"api_key",
|
||||
"private",
|
||||
"credential",
|
||||
"auth",
|
||||
]
|
||||
return any(keyword in key.lower() for keyword in sensitive_keywords)
|
||||
@@ -1,118 +0,0 @@
|
||||
"""
|
||||
Background Jobs Detector Module
|
||||
================================
|
||||
|
||||
Detects background job and task queue systems:
|
||||
- Celery (Python)
|
||||
- BullMQ/Bull (Node.js)
|
||||
- Sidekiq (Ruby)
|
||||
- Scheduled tasks and cron jobs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class JobsDetector(BaseAnalyzer):
|
||||
"""Detects background job and task queue systems."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect background job/task queue systems.
|
||||
|
||||
Detects: Celery, BullMQ, Sidekiq, cron jobs, scheduled tasks.
|
||||
"""
|
||||
jobs_info = None
|
||||
|
||||
# Try each job system in order
|
||||
jobs_info = (
|
||||
self._detect_celery() or self._detect_bullmq() or self._detect_sidekiq()
|
||||
)
|
||||
|
||||
if jobs_info:
|
||||
self.analysis["background_jobs"] = jobs_info
|
||||
|
||||
def _detect_celery(self) -> dict[str, Any] | None:
|
||||
"""Detect Celery (Python) task queue."""
|
||||
celery_files = list(self.path.glob("**/celery.py")) + list(
|
||||
self.path.glob("**/tasks.py")
|
||||
)
|
||||
if not celery_files:
|
||||
return None
|
||||
|
||||
tasks = []
|
||||
for task_file in celery_files:
|
||||
try:
|
||||
content = task_file.read_text(encoding="utf-8")
|
||||
# Find @celery.task or @shared_task decorators
|
||||
task_pattern = r"@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)"
|
||||
task_matches = re.findall(task_pattern, content)
|
||||
|
||||
for task_name in task_matches:
|
||||
tasks.append(
|
||||
{
|
||||
"name": task_name,
|
||||
"file": str(task_file.relative_to(self.path)),
|
||||
}
|
||||
)
|
||||
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
if not tasks:
|
||||
return None
|
||||
|
||||
return {
|
||||
"system": "celery",
|
||||
"tasks": tasks,
|
||||
"total_tasks": len(tasks),
|
||||
"worker_command": "celery -A app worker",
|
||||
}
|
||||
|
||||
def _detect_bullmq(self) -> dict[str, Any] | None:
|
||||
"""Detect BullMQ/Bull (Node.js) task queue."""
|
||||
if not self._exists("package.json"):
|
||||
return None
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
if "bullmq" in deps:
|
||||
return {
|
||||
"system": "bullmq",
|
||||
"tasks": [],
|
||||
"worker_command": "node worker.js",
|
||||
}
|
||||
elif "bull" in deps:
|
||||
return {
|
||||
"system": "bull",
|
||||
"tasks": [],
|
||||
"worker_command": "node worker.js",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _detect_sidekiq(self) -> dict[str, Any] | None:
|
||||
"""Detect Sidekiq (Ruby) background jobs."""
|
||||
if not self._exists("Gemfile"):
|
||||
return None
|
||||
|
||||
gemfile = self._read_file("Gemfile")
|
||||
if "sidekiq" not in gemfile.lower():
|
||||
return None
|
||||
|
||||
return {
|
||||
"system": "sidekiq",
|
||||
"worker_command": "bundle exec sidekiq",
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"""
|
||||
Database Migrations Detector Module
|
||||
====================================
|
||||
|
||||
Detects database migration tools and configurations:
|
||||
- Alembic (Python)
|
||||
- Django migrations
|
||||
- Knex (Node.js)
|
||||
- TypeORM
|
||||
- Prisma
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class MigrationsDetector(BaseAnalyzer):
|
||||
"""Detects database migration setup and tools."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect database migration setup.
|
||||
|
||||
Detects: Alembic, Django migrations, Knex, TypeORM, Prisma migrations.
|
||||
"""
|
||||
migration_info = None
|
||||
|
||||
# Try each migration tool in order
|
||||
migration_info = (
|
||||
self._detect_alembic()
|
||||
or self._detect_django()
|
||||
or self._detect_knex()
|
||||
or self._detect_typeorm()
|
||||
or self._detect_prisma()
|
||||
)
|
||||
|
||||
if migration_info:
|
||||
self.analysis["migrations"] = migration_info
|
||||
|
||||
def _detect_alembic(self) -> dict[str, Any] | None:
|
||||
"""Detect Alembic (Python) migrations."""
|
||||
if not (self._exists("alembic.ini") or self._exists("alembic")):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "alembic",
|
||||
"directory": "alembic/versions"
|
||||
if self._exists("alembic/versions")
|
||||
else "alembic",
|
||||
"config_file": "alembic.ini",
|
||||
"commands": {
|
||||
"upgrade": "alembic upgrade head",
|
||||
"downgrade": "alembic downgrade -1",
|
||||
"create": "alembic revision --autogenerate -m 'message'",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_django(self) -> dict[str, Any] | None:
|
||||
"""Detect Django migrations."""
|
||||
if not self._exists("manage.py"):
|
||||
return None
|
||||
|
||||
migration_dirs = list(self.path.glob("**/migrations"))
|
||||
if not migration_dirs:
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "django",
|
||||
"directories": [str(d.relative_to(self.path)) for d in migration_dirs],
|
||||
"commands": {
|
||||
"migrate": "python manage.py migrate",
|
||||
"makemigrations": "python manage.py makemigrations",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_knex(self) -> dict[str, Any] | None:
|
||||
"""Detect Knex (Node.js) migrations."""
|
||||
if not (self._exists("knexfile.js") or self._exists("knexfile.ts")):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "knex",
|
||||
"directory": "migrations",
|
||||
"config_file": "knexfile.js",
|
||||
"commands": {
|
||||
"migrate": "knex migrate:latest",
|
||||
"rollback": "knex migrate:rollback",
|
||||
"create": "knex migrate:make migration_name",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_typeorm(self) -> dict[str, Any] | None:
|
||||
"""Detect TypeORM migrations."""
|
||||
if not (self._exists("ormconfig.json") or self._exists("data-source.ts")):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "typeorm",
|
||||
"directory": "migrations",
|
||||
"commands": {
|
||||
"run": "typeorm migration:run",
|
||||
"revert": "typeorm migration:revert",
|
||||
"create": "typeorm migration:create",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_prisma(self) -> dict[str, Any] | None:
|
||||
"""Detect Prisma migrations."""
|
||||
if not self._exists("prisma/schema.prisma"):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "prisma",
|
||||
"directory": "prisma/migrations",
|
||||
"config_file": "prisma/schema.prisma",
|
||||
"commands": {
|
||||
"migrate": "prisma migrate deploy",
|
||||
"dev": "prisma migrate dev",
|
||||
"create": "prisma migrate dev --name migration_name",
|
||||
},
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
Monitoring Detector Module
|
||||
===========================
|
||||
|
||||
Detects monitoring and observability setup:
|
||||
- Health check endpoints
|
||||
- Prometheus metrics endpoints
|
||||
- APM tools (Sentry, Datadog, New Relic)
|
||||
- Logging infrastructure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class MonitoringDetector(BaseAnalyzer):
|
||||
"""Detects monitoring and observability setup."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect monitoring and observability setup.
|
||||
|
||||
Detects: Health checks, metrics endpoints, APM tools, logging.
|
||||
"""
|
||||
monitoring_info = {}
|
||||
|
||||
# Detect health check endpoints from existing API analysis
|
||||
health_checks = self._detect_health_checks()
|
||||
if health_checks:
|
||||
monitoring_info["health_checks"] = health_checks
|
||||
|
||||
# Detect Prometheus metrics
|
||||
metrics_info = self._detect_prometheus()
|
||||
if metrics_info:
|
||||
monitoring_info.update(metrics_info)
|
||||
|
||||
# Reference APM tools from services analysis
|
||||
apm_tools = self._get_apm_tools()
|
||||
if apm_tools:
|
||||
monitoring_info["apm_tools"] = apm_tools
|
||||
|
||||
if monitoring_info:
|
||||
self.analysis["monitoring"] = monitoring_info
|
||||
|
||||
def _detect_health_checks(self) -> list[str] | None:
|
||||
"""Detect health check endpoints from API routes."""
|
||||
if "api" not in self.analysis:
|
||||
return None
|
||||
|
||||
routes = self.analysis["api"].get("routes", [])
|
||||
health_routes = [
|
||||
r["path"]
|
||||
for r in routes
|
||||
if "health" in r["path"].lower() or "ping" in r["path"].lower()
|
||||
]
|
||||
|
||||
return health_routes if health_routes else None
|
||||
|
||||
def _detect_prometheus(self) -> dict[str, str] | None:
|
||||
"""Detect Prometheus metrics endpoint."""
|
||||
# Look for actual Prometheus imports/usage, not just keywords
|
||||
all_files = (
|
||||
list(self.path.glob("**/*.py"))[:30] + list(self.path.glob("**/*.js"))[:30]
|
||||
)
|
||||
|
||||
for file_path in all_files:
|
||||
# Skip analyzer files to avoid self-detection
|
||||
if "analyzers" in str(file_path) or "analyzer.py" in str(file_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
# Look for actual Prometheus imports or usage patterns
|
||||
prometheus_patterns = [
|
||||
"from prometheus_client import",
|
||||
"import prometheus_client",
|
||||
"prometheus_client.",
|
||||
"@app.route('/metrics')", # Flask
|
||||
"app.get('/metrics'", # Express/Fastify
|
||||
"router.get('/metrics'", # Express Router
|
||||
]
|
||||
|
||||
if any(pattern in content for pattern in prometheus_patterns):
|
||||
return {
|
||||
"metrics_endpoint": "/metrics",
|
||||
"metrics_type": "prometheus",
|
||||
}
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _get_apm_tools(self) -> list[str] | None:
|
||||
"""Get APM tools from existing services analysis."""
|
||||
if (
|
||||
"services" not in self.analysis
|
||||
or "monitoring" not in self.analysis["services"]
|
||||
):
|
||||
return None
|
||||
|
||||
return [s["type"] for s in self.analysis["services"]["monitoring"]]
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
External Services Detector Module
|
||||
==================================
|
||||
|
||||
Detects external service integrations based on dependencies:
|
||||
- Databases (PostgreSQL, MySQL, MongoDB, Redis, SQLite)
|
||||
- Cache services (Redis, Memcached)
|
||||
- Message queues (Celery, BullMQ, Kafka, RabbitMQ)
|
||||
- Email services (SendGrid, Mailgun, Postmark)
|
||||
- Payment processors (Stripe, PayPal, Square)
|
||||
- Storage services (AWS S3, Google Cloud Storage, Azure)
|
||||
- Auth providers (OAuth, JWT)
|
||||
- Monitoring tools (Sentry, Datadog, New Relic)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class ServicesDetector(BaseAnalyzer):
|
||||
"""Detects external service integrations."""
|
||||
|
||||
# Service indicator mappings
|
||||
DATABASE_INDICATORS = {
|
||||
"psycopg2": "postgresql",
|
||||
"psycopg2-binary": "postgresql",
|
||||
"pg": "postgresql",
|
||||
"mysql": "mysql",
|
||||
"mysql2": "mysql",
|
||||
"pymongo": "mongodb",
|
||||
"mongodb": "mongodb",
|
||||
"mongoose": "mongodb",
|
||||
"redis": "redis",
|
||||
"redis-py": "redis",
|
||||
"ioredis": "redis",
|
||||
"sqlite3": "sqlite",
|
||||
"better-sqlite3": "sqlite",
|
||||
}
|
||||
|
||||
CACHE_INDICATORS = ["redis", "memcached", "node-cache"]
|
||||
|
||||
QUEUE_INDICATORS = {
|
||||
"celery": "celery",
|
||||
"bullmq": "bullmq",
|
||||
"bull": "bull",
|
||||
"kafka-python": "kafka",
|
||||
"kafkajs": "kafka",
|
||||
"amqplib": "rabbitmq",
|
||||
"amqp": "rabbitmq",
|
||||
}
|
||||
|
||||
EMAIL_INDICATORS = {
|
||||
"sendgrid": "sendgrid",
|
||||
"@sendgrid/mail": "sendgrid",
|
||||
"nodemailer": "smtp",
|
||||
"mailgun": "mailgun",
|
||||
"postmark": "postmark",
|
||||
}
|
||||
|
||||
PAYMENT_INDICATORS = {
|
||||
"stripe": "stripe",
|
||||
"paypal": "paypal",
|
||||
"square": "square",
|
||||
"braintree": "braintree",
|
||||
}
|
||||
|
||||
STORAGE_INDICATORS = {
|
||||
"boto3": "aws_s3",
|
||||
"@aws-sdk/client-s3": "aws_s3",
|
||||
"aws-sdk": "aws_s3",
|
||||
"@google-cloud/storage": "google_cloud_storage",
|
||||
"azure-storage-blob": "azure_blob_storage",
|
||||
}
|
||||
|
||||
AUTH_INDICATORS = {
|
||||
"authlib": "oauth",
|
||||
"python-jose": "jwt",
|
||||
"pyjwt": "jwt",
|
||||
"jsonwebtoken": "jwt",
|
||||
"passport": "oauth",
|
||||
"next-auth": "oauth",
|
||||
"@auth/core": "oauth",
|
||||
}
|
||||
|
||||
MONITORING_INDICATORS = {
|
||||
"sentry-sdk": "sentry",
|
||||
"@sentry/node": "sentry",
|
||||
"datadog": "datadog",
|
||||
"newrelic": "new_relic",
|
||||
"loguru": "logging",
|
||||
"winston": "logging",
|
||||
"pino": "logging",
|
||||
}
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect external service integrations.
|
||||
|
||||
Detects: databases, cache, email, payments, storage, monitoring, etc.
|
||||
"""
|
||||
services = {
|
||||
"databases": [],
|
||||
"cache": [],
|
||||
"message_queues": [],
|
||||
"email": [],
|
||||
"payments": [],
|
||||
"storage": [],
|
||||
"auth_providers": [],
|
||||
"monitoring": [],
|
||||
}
|
||||
|
||||
# Get all dependencies
|
||||
all_deps = self._get_all_dependencies()
|
||||
|
||||
# Detect each service category
|
||||
self._detect_databases(all_deps, services["databases"])
|
||||
self._detect_cache(all_deps, services["cache"])
|
||||
self._detect_message_queues(all_deps, services["message_queues"])
|
||||
self._detect_email(all_deps, services["email"])
|
||||
self._detect_payments(all_deps, services["payments"])
|
||||
self._detect_storage(all_deps, services["storage"])
|
||||
self._detect_auth_providers(all_deps, services["auth_providers"])
|
||||
self._detect_monitoring(all_deps, services["monitoring"])
|
||||
|
||||
# Remove empty categories
|
||||
services = {k: v for k, v in services.items() if v}
|
||||
|
||||
if services:
|
||||
self.analysis["services"] = services
|
||||
|
||||
def _get_all_dependencies(self) -> set[str]:
|
||||
"""Extract all dependencies from Python and Node.js projects."""
|
||||
all_deps = set()
|
||||
|
||||
# Python dependencies
|
||||
if self._exists("requirements.txt"):
|
||||
content = self._read_file("requirements.txt")
|
||||
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
|
||||
|
||||
# Node.js dependencies
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
all_deps.update(pkg.get("dependencies", {}).keys())
|
||||
all_deps.update(pkg.get("devDependencies", {}).keys())
|
||||
|
||||
return all_deps
|
||||
|
||||
def _detect_databases(
|
||||
self, all_deps: set[str], databases: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect database clients."""
|
||||
for dep, db_type in self.DATABASE_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
databases.append({"type": db_type, "client": dep})
|
||||
|
||||
def _detect_cache(self, all_deps: set[str], cache: list[dict[str, str]]) -> None:
|
||||
"""Detect cache services."""
|
||||
for indicator in self.CACHE_INDICATORS:
|
||||
if indicator in all_deps:
|
||||
cache.append({"type": indicator})
|
||||
|
||||
def _detect_message_queues(
|
||||
self, all_deps: set[str], queues: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect message queue systems."""
|
||||
for dep, queue_type in self.QUEUE_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
queues.append({"type": queue_type, "client": dep})
|
||||
|
||||
def _detect_email(self, all_deps: set[str], email: list[dict[str, str]]) -> None:
|
||||
"""Detect email service providers."""
|
||||
for dep, email_type in self.EMAIL_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
email.append({"provider": email_type, "client": dep})
|
||||
|
||||
def _detect_payments(
|
||||
self, all_deps: set[str], payments: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect payment processors."""
|
||||
for dep, payment_type in self.PAYMENT_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
payments.append({"provider": payment_type, "client": dep})
|
||||
|
||||
def _detect_storage(
|
||||
self, all_deps: set[str], storage: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect storage services."""
|
||||
for dep, storage_type in self.STORAGE_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
storage.append({"provider": storage_type, "client": dep})
|
||||
|
||||
def _detect_auth_providers(
|
||||
self, all_deps: set[str], auth: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect authentication providers."""
|
||||
for dep, auth_type in self.AUTH_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
auth.append({"type": auth_type, "client": dep})
|
||||
|
||||
def _detect_monitoring(
|
||||
self, all_deps: set[str], monitoring: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect monitoring and observability tools."""
|
||||
for dep, monitoring_type in self.MONITORING_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
monitoring.append({"type": monitoring_type, "client": dep})
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Context Analyzer Module
|
||||
=======================
|
||||
|
||||
Orchestrates comprehensive project context analysis including:
|
||||
- Environment variables and configuration
|
||||
- External service integrations
|
||||
- Authentication patterns
|
||||
- Database migrations
|
||||
- Background jobs/task queues
|
||||
- API documentation
|
||||
- Monitoring and observability
|
||||
|
||||
This module delegates to specialized detectors for clean separation of concerns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
from .context import (
|
||||
ApiDocsDetector,
|
||||
AuthDetector,
|
||||
EnvironmentDetector,
|
||||
JobsDetector,
|
||||
MigrationsDetector,
|
||||
MonitoringDetector,
|
||||
ServicesDetector,
|
||||
)
|
||||
|
||||
|
||||
class ContextAnalyzer(BaseAnalyzer):
|
||||
"""Orchestrates project context and configuration analysis."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect_environment_variables(self) -> None:
|
||||
"""
|
||||
Discover all environment variables from multiple sources.
|
||||
|
||||
Delegates to EnvironmentDetector for actual detection logic.
|
||||
"""
|
||||
detector = EnvironmentDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_external_services(self) -> None:
|
||||
"""
|
||||
Detect external service integrations.
|
||||
|
||||
Delegates to ServicesDetector for actual detection logic.
|
||||
"""
|
||||
detector = ServicesDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_auth_patterns(self) -> None:
|
||||
"""
|
||||
Detect authentication and authorization patterns.
|
||||
|
||||
Delegates to AuthDetector for actual detection logic.
|
||||
"""
|
||||
detector = AuthDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_migrations(self) -> None:
|
||||
"""
|
||||
Detect database migration setup.
|
||||
|
||||
Delegates to MigrationsDetector for actual detection logic.
|
||||
"""
|
||||
detector = MigrationsDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_background_jobs(self) -> None:
|
||||
"""
|
||||
Detect background job/task queue systems.
|
||||
|
||||
Delegates to JobsDetector for actual detection logic.
|
||||
"""
|
||||
detector = JobsDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_api_documentation(self) -> None:
|
||||
"""
|
||||
Detect API documentation setup.
|
||||
|
||||
Delegates to ApiDocsDetector for actual detection logic.
|
||||
"""
|
||||
detector = ApiDocsDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_monitoring(self) -> None:
|
||||
"""
|
||||
Detect monitoring and observability setup.
|
||||
|
||||
Delegates to MonitoringDetector for actual detection logic.
|
||||
"""
|
||||
detector = MonitoringDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
@@ -1,316 +0,0 @@
|
||||
"""
|
||||
Database Detector Module
|
||||
========================
|
||||
|
||||
Detects database models and schemas across different ORMs:
|
||||
- Python: SQLAlchemy, Django ORM
|
||||
- JavaScript/TypeScript: Prisma, TypeORM, Drizzle, Mongoose
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class DatabaseDetector(BaseAnalyzer):
|
||||
"""Detects database models across multiple ORMs."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
super().__init__(path)
|
||||
|
||||
def detect_all_models(self) -> dict:
|
||||
"""Detect all database models across different ORMs."""
|
||||
models = {}
|
||||
|
||||
# Python SQLAlchemy
|
||||
models.update(self._detect_sqlalchemy_models())
|
||||
|
||||
# Python Django
|
||||
models.update(self._detect_django_models())
|
||||
|
||||
# Prisma schema
|
||||
models.update(self._detect_prisma_models())
|
||||
|
||||
# TypeORM entities
|
||||
models.update(self._detect_typeorm_models())
|
||||
|
||||
# Drizzle schema
|
||||
models.update(self._detect_drizzle_models())
|
||||
|
||||
# Mongoose models
|
||||
models.update(self._detect_mongoose_models())
|
||||
|
||||
return models
|
||||
|
||||
def _detect_sqlalchemy_models(self) -> dict:
|
||||
"""Detect SQLAlchemy models."""
|
||||
models = {}
|
||||
py_files = list(self.path.glob("**/*.py"))
|
||||
|
||||
for file_path in py_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find class definitions that inherit from Base or db.Model
|
||||
class_pattern = (
|
||||
r"class\s+(\w+)\([^)]*(?:Base|db\.Model|DeclarativeBase)[^)]*\):"
|
||||
)
|
||||
matches = re.finditer(class_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
|
||||
# Extract table name if defined
|
||||
table_match = re.search(r'__tablename__\s*=\s*["\'](\w+)["\']', content)
|
||||
table_name = (
|
||||
table_match.group(1) if table_match else model_name.lower() + "s"
|
||||
)
|
||||
|
||||
# Extract columns
|
||||
fields = {}
|
||||
column_pattern = r"(\w+)\s*=\s*Column\((.*?)\)"
|
||||
column_matches = re.finditer(
|
||||
column_pattern, content[match.end() : match.end() + 2000]
|
||||
)
|
||||
|
||||
for col_match in column_matches:
|
||||
field_name = col_match.group(1)
|
||||
field_def = col_match.group(2)
|
||||
|
||||
# Detect field properties
|
||||
is_primary = "primary_key=True" in field_def
|
||||
is_unique = "unique=True" in field_def
|
||||
is_nullable = "nullable=False" not in field_def
|
||||
|
||||
# Extract type
|
||||
type_match = re.search(
|
||||
r"(Integer|String|Text|Boolean|DateTime|Float|JSON)", field_def
|
||||
)
|
||||
field_type = type_match.group(1) if type_match else "Unknown"
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"primary_key": is_primary,
|
||||
"unique": is_unique,
|
||||
"nullable": is_nullable,
|
||||
}
|
||||
|
||||
if fields: # Only add if we found fields
|
||||
models[model_name] = {
|
||||
"table": table_name,
|
||||
"fields": fields,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "SQLAlchemy",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_django_models(self) -> dict:
|
||||
"""Detect Django models."""
|
||||
models = {}
|
||||
model_files = list(self.path.glob("**/models.py")) + list(
|
||||
self.path.glob("**/models/*.py")
|
||||
)
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find class definitions that inherit from models.Model
|
||||
class_pattern = r"class\s+(\w+)\(models\.Model\):"
|
||||
matches = re.finditer(class_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
table_name = model_name.lower()
|
||||
|
||||
# Extract fields
|
||||
fields = {}
|
||||
field_pattern = r"(\w+)\s*=\s*models\.(\w+Field)\((.*?)\)"
|
||||
field_matches = re.finditer(
|
||||
field_pattern, content[match.end() : match.end() + 2000]
|
||||
)
|
||||
|
||||
for field_match in field_matches:
|
||||
field_name = field_match.group(1)
|
||||
field_type = field_match.group(2)
|
||||
field_args = field_match.group(3)
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"unique": "unique=True" in field_args,
|
||||
"nullable": "null=True" in field_args,
|
||||
}
|
||||
|
||||
if fields:
|
||||
models[model_name] = {
|
||||
"table": table_name,
|
||||
"fields": fields,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "Django",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_prisma_models(self) -> dict:
|
||||
"""Detect Prisma models from schema.prisma."""
|
||||
models = {}
|
||||
schema_file = self.path / "prisma" / "schema.prisma"
|
||||
|
||||
if not schema_file.exists():
|
||||
return models
|
||||
|
||||
try:
|
||||
content = schema_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return models
|
||||
|
||||
# Find model definitions
|
||||
model_pattern = r"model\s+(\w+)\s*\{([^}]+)\}"
|
||||
matches = re.finditer(model_pattern, content, re.MULTILINE)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
model_body = match.group(2)
|
||||
|
||||
fields = {}
|
||||
# Parse fields: id Int @id @default(autoincrement())
|
||||
field_pattern = r"(\w+)\s+(\w+)([^/\n]*)"
|
||||
field_matches = re.finditer(field_pattern, model_body)
|
||||
|
||||
for field_match in field_matches:
|
||||
field_name = field_match.group(1)
|
||||
field_type = field_match.group(2)
|
||||
field_attrs = field_match.group(3)
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"primary_key": "@id" in field_attrs,
|
||||
"unique": "@unique" in field_attrs,
|
||||
"nullable": "?" in field_type,
|
||||
}
|
||||
|
||||
if fields:
|
||||
models[model_name] = {
|
||||
"table": model_name.lower(),
|
||||
"fields": fields,
|
||||
"file": "prisma/schema.prisma",
|
||||
"orm": "Prisma",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_typeorm_models(self) -> dict:
|
||||
"""Detect TypeORM entities."""
|
||||
models = {}
|
||||
ts_files = list(self.path.glob("**/*.entity.ts")) + list(
|
||||
self.path.glob("**/entities/*.ts")
|
||||
)
|
||||
|
||||
for file_path in ts_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find @Entity() class declarations
|
||||
entity_pattern = r"@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)"
|
||||
matches = re.finditer(entity_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
|
||||
# Extract columns
|
||||
fields = {}
|
||||
column_pattern = (
|
||||
r"@(PrimaryGeneratedColumn|Column)\(([^)]*)\)\s+(\w+):\s*(\w+)"
|
||||
)
|
||||
column_matches = re.finditer(column_pattern, content)
|
||||
|
||||
for col_match in column_matches:
|
||||
decorator = col_match.group(1)
|
||||
options = col_match.group(2)
|
||||
field_name = col_match.group(3)
|
||||
field_type = col_match.group(4)
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"primary_key": decorator == "PrimaryGeneratedColumn",
|
||||
"unique": "unique: true" in options,
|
||||
}
|
||||
|
||||
if fields:
|
||||
models[model_name] = {
|
||||
"table": model_name.lower(),
|
||||
"fields": fields,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "TypeORM",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_drizzle_models(self) -> dict:
|
||||
"""Detect Drizzle ORM schemas."""
|
||||
models = {}
|
||||
schema_files = list(self.path.glob("**/schema.ts")) + list(
|
||||
self.path.glob("**/db/schema.ts")
|
||||
)
|
||||
|
||||
for file_path in schema_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find table definitions: export const users = pgTable('users', {...})
|
||||
table_pattern = r'export\s+const\s+(\w+)\s*=\s*(?:pg|mysql|sqlite)Table\(["\'](\w+)["\']'
|
||||
matches = re.finditer(table_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
const_name = match.group(1)
|
||||
table_name = match.group(2)
|
||||
|
||||
models[const_name] = {
|
||||
"table": table_name,
|
||||
"fields": {}, # Would need more parsing for fields
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "Drizzle",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_mongoose_models(self) -> dict:
|
||||
"""Detect Mongoose models."""
|
||||
models = {}
|
||||
model_files = list(self.path.glob("**/models/*.js")) + list(
|
||||
self.path.glob("**/models/*.ts")
|
||||
)
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find mongoose.model() or new Schema()
|
||||
model_pattern = r'mongoose\.model\(["\'](\w+)["\']'
|
||||
matches = re.finditer(model_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
|
||||
models[model_name] = {
|
||||
"table": model_name.lower(),
|
||||
"fields": {},
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "Mongoose",
|
||||
}
|
||||
|
||||
return models
|
||||
@@ -1,418 +0,0 @@
|
||||
"""
|
||||
Framework Analyzer Module
|
||||
=========================
|
||||
|
||||
Detects programming languages, frameworks, and related technologies across different ecosystems.
|
||||
Supports Python, Node.js/TypeScript, Go, Rust, and Ruby frameworks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class FrameworkAnalyzer(BaseAnalyzer):
|
||||
"""Analyzes and detects programming languages and frameworks."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect_language_and_framework(self) -> None:
|
||||
"""Detect primary language and framework."""
|
||||
# Python detection
|
||||
if self._exists("requirements.txt"):
|
||||
self.analysis["language"] = "Python"
|
||||
self.analysis["package_manager"] = "pip"
|
||||
deps = self._read_file("requirements.txt")
|
||||
self._detect_python_framework(deps)
|
||||
|
||||
elif self._exists("pyproject.toml"):
|
||||
self.analysis["language"] = "Python"
|
||||
content = self._read_file("pyproject.toml")
|
||||
if "[tool.poetry]" in content:
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
elif "[tool.uv]" in content:
|
||||
self.analysis["package_manager"] = "uv"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
self._detect_python_framework(content)
|
||||
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["language"] = "Python"
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
content = self._read_file("Pipfile")
|
||||
self._detect_python_framework(content)
|
||||
|
||||
# Node.js/TypeScript detection
|
||||
elif self._exists("package.json"):
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
# Check if TypeScript
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "typescript" in deps:
|
||||
self.analysis["language"] = "TypeScript"
|
||||
else:
|
||||
self.analysis["language"] = "JavaScript"
|
||||
|
||||
self.analysis["package_manager"] = self._detect_node_package_manager()
|
||||
self._detect_node_framework(pkg)
|
||||
|
||||
# Go detection
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["language"] = "Go"
|
||||
self.analysis["package_manager"] = "go mod"
|
||||
content = self._read_file("go.mod")
|
||||
self._detect_go_framework(content)
|
||||
|
||||
# Rust detection
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["language"] = "Rust"
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
content = self._read_file("Cargo.toml")
|
||||
self._detect_rust_framework(content)
|
||||
|
||||
# Swift/iOS detection (check BEFORE Ruby - iOS projects often have Gemfile for CocoaPods/Fastlane)
|
||||
elif self._exists("Package.swift") or any(self.path.glob("*.xcodeproj")):
|
||||
self.analysis["language"] = "Swift"
|
||||
if self._exists("Package.swift"):
|
||||
self.analysis["package_manager"] = "Swift Package Manager"
|
||||
else:
|
||||
self.analysis["package_manager"] = "Xcode"
|
||||
self._detect_swift_framework()
|
||||
|
||||
# Ruby detection
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["language"] = "Ruby"
|
||||
self.analysis["package_manager"] = "bundler"
|
||||
content = self._read_file("Gemfile")
|
||||
self._detect_ruby_framework(content)
|
||||
|
||||
def _detect_python_framework(self, content: str) -> None:
|
||||
"""Detect Python framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Web frameworks (with conventional defaults)
|
||||
frameworks = {
|
||||
"fastapi": {"name": "FastAPI", "type": "backend", "port": 8000},
|
||||
"flask": {"name": "Flask", "type": "backend", "port": 5000},
|
||||
"django": {"name": "Django", "type": "backend", "port": 8000},
|
||||
"starlette": {"name": "Starlette", "type": "backend", "port": 8000},
|
||||
"litestar": {"name": "Litestar", "type": "backend", "port": 8000},
|
||||
}
|
||||
|
||||
for key, info in frameworks.items():
|
||||
if key in content_lower:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = info["type"]
|
||||
# Try to detect actual port, fall back to default
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
# Task queues
|
||||
if "celery" in content_lower:
|
||||
self.analysis["task_queue"] = "Celery"
|
||||
if not self.analysis.get("type"):
|
||||
self.analysis["type"] = "worker"
|
||||
elif "dramatiq" in content_lower:
|
||||
self.analysis["task_queue"] = "Dramatiq"
|
||||
elif "huey" in content_lower:
|
||||
self.analysis["task_queue"] = "Huey"
|
||||
|
||||
# ORM
|
||||
if "sqlalchemy" in content_lower:
|
||||
self.analysis["orm"] = "SQLAlchemy"
|
||||
elif "tortoise" in content_lower:
|
||||
self.analysis["orm"] = "Tortoise ORM"
|
||||
elif "prisma" in content_lower:
|
||||
self.analysis["orm"] = "Prisma"
|
||||
|
||||
def _detect_node_framework(self, pkg: dict) -> None:
|
||||
"""Detect Node.js/TypeScript framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
deps_lower = {k.lower(): k for k in deps.keys()}
|
||||
|
||||
# Frontend frameworks
|
||||
frontend_frameworks = {
|
||||
"next": {"name": "Next.js", "type": "frontend", "port": 3000},
|
||||
"nuxt": {"name": "Nuxt", "type": "frontend", "port": 3000},
|
||||
"react": {"name": "React", "type": "frontend", "port": 3000},
|
||||
"vue": {"name": "Vue", "type": "frontend", "port": 5173},
|
||||
"svelte": {"name": "Svelte", "type": "frontend", "port": 5173},
|
||||
"@sveltejs/kit": {"name": "SvelteKit", "type": "frontend", "port": 5173},
|
||||
"angular": {"name": "Angular", "type": "frontend", "port": 4200},
|
||||
"@angular/core": {"name": "Angular", "type": "frontend", "port": 4200},
|
||||
"solid-js": {"name": "SolidJS", "type": "frontend", "port": 3000},
|
||||
"astro": {"name": "Astro", "type": "frontend", "port": 4321},
|
||||
}
|
||||
|
||||
# Backend frameworks
|
||||
backend_frameworks = {
|
||||
"express": {"name": "Express", "type": "backend", "port": 3000},
|
||||
"fastify": {"name": "Fastify", "type": "backend", "port": 3000},
|
||||
"koa": {"name": "Koa", "type": "backend", "port": 3000},
|
||||
"hono": {"name": "Hono", "type": "backend", "port": 3000},
|
||||
"elysia": {"name": "Elysia", "type": "backend", "port": 3000},
|
||||
"@nestjs/core": {"name": "NestJS", "type": "backend", "port": 3000},
|
||||
}
|
||||
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
|
||||
# Check frontend first (Next.js includes React, etc.)
|
||||
for key, info in frontend_frameworks.items():
|
||||
if key in deps_lower:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = info["type"]
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
# If no frontend, check backend
|
||||
if not self.analysis.get("framework"):
|
||||
for key, info in backend_frameworks.items():
|
||||
if key in deps_lower:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = info["type"]
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
# Build tool
|
||||
if "vite" in deps_lower:
|
||||
self.analysis["build_tool"] = "Vite"
|
||||
if not self.analysis.get("default_port"):
|
||||
detected_port = port_detector.detect_port_from_sources(5173)
|
||||
self.analysis["default_port"] = detected_port
|
||||
elif "webpack" in deps_lower:
|
||||
self.analysis["build_tool"] = "Webpack"
|
||||
elif "esbuild" in deps_lower:
|
||||
self.analysis["build_tool"] = "esbuild"
|
||||
elif "turbopack" in deps_lower:
|
||||
self.analysis["build_tool"] = "Turbopack"
|
||||
|
||||
# Styling
|
||||
if "tailwindcss" in deps_lower:
|
||||
self.analysis["styling"] = "Tailwind CSS"
|
||||
elif "styled-components" in deps_lower:
|
||||
self.analysis["styling"] = "styled-components"
|
||||
elif "@emotion/react" in deps_lower:
|
||||
self.analysis["styling"] = "Emotion"
|
||||
|
||||
# State management
|
||||
if "zustand" in deps_lower:
|
||||
self.analysis["state_management"] = "Zustand"
|
||||
elif "@reduxjs/toolkit" in deps_lower or "redux" in deps_lower:
|
||||
self.analysis["state_management"] = "Redux"
|
||||
elif "jotai" in deps_lower:
|
||||
self.analysis["state_management"] = "Jotai"
|
||||
elif "pinia" in deps_lower:
|
||||
self.analysis["state_management"] = "Pinia"
|
||||
|
||||
# Task queues
|
||||
if "bullmq" in deps_lower or "bull" in deps_lower:
|
||||
self.analysis["task_queue"] = "BullMQ"
|
||||
if not self.analysis.get("type"):
|
||||
self.analysis["type"] = "worker"
|
||||
|
||||
# ORM
|
||||
if "@prisma/client" in deps_lower or "prisma" in deps_lower:
|
||||
self.analysis["orm"] = "Prisma"
|
||||
elif "typeorm" in deps_lower:
|
||||
self.analysis["orm"] = "TypeORM"
|
||||
elif "drizzle-orm" in deps_lower:
|
||||
self.analysis["orm"] = "Drizzle"
|
||||
elif "mongoose" in deps_lower:
|
||||
self.analysis["orm"] = "Mongoose"
|
||||
|
||||
# Scripts
|
||||
scripts = pkg.get("scripts", {})
|
||||
pkg_mgr = self.analysis.get("package_manager", "npm")
|
||||
if "dev" in scripts:
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run dev"
|
||||
elif "start" in scripts:
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run start"
|
||||
|
||||
# Capture available scripts for downstream consumers (QA agents, init.sh)
|
||||
if scripts:
|
||||
self.analysis["scripts"] = dict(scripts)
|
||||
|
||||
def _detect_go_framework(self, content: str) -> None:
|
||||
"""Detect Go framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
frameworks = {
|
||||
"gin-gonic/gin": {"name": "Gin", "port": 8080},
|
||||
"labstack/echo": {"name": "Echo", "port": 8080},
|
||||
"gofiber/fiber": {"name": "Fiber", "port": 3000},
|
||||
"go-chi/chi": {"name": "Chi", "port": 8080},
|
||||
}
|
||||
|
||||
for key, info in frameworks.items():
|
||||
if key in content:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = "backend"
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
def _detect_rust_framework(self, content: str) -> None:
|
||||
"""Detect Rust framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
frameworks = {
|
||||
"actix-web": {"name": "Actix Web", "port": 8080},
|
||||
"axum": {"name": "Axum", "port": 3000},
|
||||
"rocket": {"name": "Rocket", "port": 8000},
|
||||
}
|
||||
|
||||
for key, info in frameworks.items():
|
||||
if key in content:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = "backend"
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
def _detect_ruby_framework(self, content: str) -> None:
|
||||
"""Detect Ruby framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
|
||||
if "rails" in content.lower():
|
||||
self.analysis["framework"] = "Ruby on Rails"
|
||||
self.analysis["type"] = "backend"
|
||||
detected_port = port_detector.detect_port_from_sources(3000)
|
||||
self.analysis["default_port"] = detected_port
|
||||
elif "sinatra" in content.lower():
|
||||
self.analysis["framework"] = "Sinatra"
|
||||
self.analysis["type"] = "backend"
|
||||
detected_port = port_detector.detect_port_from_sources(4567)
|
||||
self.analysis["default_port"] = detected_port
|
||||
|
||||
if "sidekiq" in content.lower():
|
||||
self.analysis["task_queue"] = "Sidekiq"
|
||||
|
||||
def _detect_swift_framework(self) -> None:
|
||||
"""Detect Swift/iOS framework and dependencies."""
|
||||
try:
|
||||
# Scan Swift files for imports, excluding hidden/vendor dirs
|
||||
swift_files = []
|
||||
for swift_file in self.path.rglob("*.swift"):
|
||||
# Skip hidden directories, node_modules, .worktrees, etc.
|
||||
if any(
|
||||
part.startswith(".") or part in ("node_modules", "Pods", "Carthage")
|
||||
for part in swift_file.parts
|
||||
):
|
||||
continue
|
||||
swift_files.append(swift_file)
|
||||
if len(swift_files) >= 50: # Limit for performance
|
||||
break
|
||||
|
||||
imports = set()
|
||||
for swift_file in swift_files:
|
||||
try:
|
||||
content = swift_file.read_text(encoding="utf-8", errors="ignore")
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("import "):
|
||||
module = line.replace("import ", "").split()[0]
|
||||
imports.add(module)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Detect UI framework
|
||||
if "SwiftUI" in imports:
|
||||
self.analysis["framework"] = "SwiftUI"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "UIKit" in imports:
|
||||
self.analysis["framework"] = "UIKit"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "AppKit" in imports:
|
||||
self.analysis["framework"] = "AppKit"
|
||||
self.analysis["type"] = "desktop"
|
||||
|
||||
# Detect iOS/Apple frameworks
|
||||
apple_frameworks = []
|
||||
framework_map = {
|
||||
"Combine": "Combine",
|
||||
"CoreData": "CoreData",
|
||||
"MapKit": "MapKit",
|
||||
"WidgetKit": "WidgetKit",
|
||||
"CoreLocation": "CoreLocation",
|
||||
"StoreKit": "StoreKit",
|
||||
"CloudKit": "CloudKit",
|
||||
"ActivityKit": "ActivityKit",
|
||||
"UserNotifications": "UserNotifications",
|
||||
}
|
||||
for key, name in framework_map.items():
|
||||
if key in imports:
|
||||
apple_frameworks.append(name)
|
||||
|
||||
if apple_frameworks:
|
||||
self.analysis["apple_frameworks"] = apple_frameworks
|
||||
|
||||
# Detect SPM dependencies from Package.swift or xcodeproj
|
||||
dependencies = self._detect_spm_dependencies()
|
||||
if dependencies:
|
||||
self.analysis["spm_dependencies"] = dependencies
|
||||
except Exception:
|
||||
# Silently fail if Swift detection has issues
|
||||
pass
|
||||
|
||||
def _detect_spm_dependencies(self) -> list[str]:
|
||||
"""Detect Swift Package Manager dependencies."""
|
||||
dependencies = []
|
||||
|
||||
# Try Package.swift first
|
||||
if self._exists("Package.swift"):
|
||||
content = self._read_file("Package.swift")
|
||||
# Look for .package(url: "...", patterns
|
||||
import re
|
||||
|
||||
urls = re.findall(r'\.package\s*\([^)]*url:\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
# Extract package name from URL
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name:
|
||||
dependencies.append(name)
|
||||
|
||||
# Also check xcodeproj for XCRemoteSwiftPackageReference
|
||||
for xcodeproj in self.path.glob("*.xcodeproj"):
|
||||
pbxproj = xcodeproj / "project.pbxproj"
|
||||
if pbxproj.exists():
|
||||
try:
|
||||
content = pbxproj.read_text(encoding="utf-8", errors="ignore")
|
||||
import re
|
||||
|
||||
# Match repositoryURL patterns
|
||||
urls = re.findall(r'repositoryURL\s*=\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name and name not in dependencies:
|
||||
dependencies.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return dependencies
|
||||
|
||||
def _detect_node_package_manager(self) -> str:
|
||||
"""Detect Node.js package manager."""
|
||||
if self._exists("pnpm-lock.yaml"):
|
||||
return "pnpm"
|
||||
elif self._exists("yarn.lock"):
|
||||
return "yarn"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
@@ -1,337 +0,0 @@
|
||||
"""
|
||||
Port Detector Module
|
||||
====================
|
||||
|
||||
Detects application ports from multiple sources including entry points,
|
||||
environment files, Docker Compose, configuration files, and scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class PortDetector(BaseAnalyzer):
|
||||
"""Detects application ports from various configuration sources."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect_port_from_sources(self, default_port: int) -> int:
|
||||
"""
|
||||
Robustly detect the actual port by checking multiple sources.
|
||||
|
||||
Checks in order of priority:
|
||||
1. Entry point files (app.py, main.py, etc.) for uvicorn.run(), app.run(), etc.
|
||||
2. Environment files (.env, .env.local, .env.development)
|
||||
3. Docker Compose port mappings
|
||||
4. Configuration files (config.py, settings.py, etc.)
|
||||
5. Package.json scripts (for Node.js)
|
||||
6. Makefile/shell scripts
|
||||
7. Falls back to default_port if nothing found
|
||||
|
||||
Args:
|
||||
default_port: The framework's conventional default port
|
||||
|
||||
Returns:
|
||||
Detected port or default_port if not found
|
||||
"""
|
||||
# 1. Check entry point files for explicit port definitions
|
||||
port = self._detect_port_in_entry_points()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 2. Check environment files
|
||||
port = self._detect_port_in_env_files()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 3. Check Docker Compose
|
||||
port = self._detect_port_in_docker_compose()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 4. Check configuration files
|
||||
port = self._detect_port_in_config_files()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 5. Check package.json scripts (for Node.js)
|
||||
if self.analysis.get("language") in ["JavaScript", "TypeScript"]:
|
||||
port = self._detect_port_in_package_scripts()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 6. Check Makefile/shell scripts
|
||||
port = self._detect_port_in_scripts()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# Fall back to default
|
||||
return default_port
|
||||
|
||||
def _detect_port_in_entry_points(self) -> int | None:
|
||||
"""Detect port in entry point files."""
|
||||
entry_files = [
|
||||
"app.py",
|
||||
"main.py",
|
||||
"server.py",
|
||||
"__main__.py",
|
||||
"asgi.py",
|
||||
"wsgi.py",
|
||||
"src/app.py",
|
||||
"src/main.py",
|
||||
"src/server.py",
|
||||
"index.js",
|
||||
"index.ts",
|
||||
"server.js",
|
||||
"server.ts",
|
||||
"main.js",
|
||||
"main.ts",
|
||||
"src/index.js",
|
||||
"src/index.ts",
|
||||
"src/server.js",
|
||||
"src/server.ts",
|
||||
"main.go",
|
||||
"cmd/main.go",
|
||||
"src/main.rs",
|
||||
]
|
||||
|
||||
# Patterns to search for ports
|
||||
patterns = [
|
||||
# Python: uvicorn.run(app, host="0.0.0.0", port=8050)
|
||||
r"uvicorn\.run\([^)]*port\s*=\s*(\d+)",
|
||||
# Python: app.run(port=8050, host="0.0.0.0")
|
||||
r"\.run\([^)]*port\s*=\s*(\d+)",
|
||||
# Python: port = 8050 or PORT = 8050
|
||||
r"^\s*[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
|
||||
# Python: os.getenv("PORT", 8050) or os.environ.get("PORT", 8050)
|
||||
r'getenv\(\s*["\']PORT["\']\s*,\s*(\d+)',
|
||||
r'environ\.get\(\s*["\']PORT["\']\s*,\s*(\d+)',
|
||||
# JavaScript/TypeScript: app.listen(8050)
|
||||
r"\.listen\(\s*(\d+)",
|
||||
# JavaScript/TypeScript: const PORT = 8050 or let port = 8050
|
||||
r"(?:const|let|var)\s+[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
|
||||
# JavaScript/TypeScript: process.env.PORT || 8050
|
||||
r"process\.env\.PORT\s*\|\|\s*(\d+)",
|
||||
# JavaScript/TypeScript: Number(process.env.PORT) || 8050
|
||||
r"Number\(process\.env\.PORT\)\s*\|\|\s*(\d+)",
|
||||
# Go: :8050 or ":8050"
|
||||
r':\s*(\d+)(?:["\s]|$)',
|
||||
# Rust: .bind("127.0.0.1:8050")
|
||||
r'\.bind\(["\'][\d.]+:(\d+)',
|
||||
]
|
||||
|
||||
for entry_file in entry_files:
|
||||
content = self._read_file(entry_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
# Return the first valid port found
|
||||
for match in matches:
|
||||
try:
|
||||
port = int(match)
|
||||
if 1000 <= port <= 65535: # Valid port range
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_env_files(self) -> int | None:
|
||||
"""Detect port in environment files."""
|
||||
env_files = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.dev",
|
||||
"config/.env",
|
||||
"config/.env.local",
|
||||
"../.env",
|
||||
]
|
||||
|
||||
patterns = [
|
||||
r"^\s*PORT\s*=\s*(\d+)",
|
||||
r"^\s*API_PORT\s*=\s*(\d+)",
|
||||
r"^\s*SERVER_PORT\s*=\s*(\d+)",
|
||||
r"^\s*APP_PORT\s*=\s*(\d+)",
|
||||
]
|
||||
|
||||
for env_file in env_files:
|
||||
content = self._read_file(env_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_docker_compose(self) -> int | None:
|
||||
"""Detect port from docker-compose.yml mappings."""
|
||||
compose_files = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"../docker-compose.yml",
|
||||
"../docker-compose.yaml",
|
||||
]
|
||||
|
||||
service_name = self.path.name.lower()
|
||||
|
||||
for compose_file in compose_files:
|
||||
content = self._read_file(compose_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Look for port mappings like "8050:8000" or "8050:8050"
|
||||
# Match the service name if possible
|
||||
pattern = r'^\s*-\s*["\']?(\d+):\d+["\']?'
|
||||
|
||||
in_service = False
|
||||
in_ports = False
|
||||
|
||||
for line in content.split("\n"):
|
||||
# Check if we're in the right service block
|
||||
if re.match(rf"^\s*{re.escape(service_name)}\s*:", line):
|
||||
in_service = True
|
||||
continue
|
||||
|
||||
# Check if we hit another service
|
||||
if (
|
||||
in_service
|
||||
and re.match(r"^\s*\w+\s*:", line)
|
||||
and "ports:" not in line
|
||||
):
|
||||
in_service = False
|
||||
in_ports = False
|
||||
continue
|
||||
|
||||
# Check if we're in the ports section
|
||||
if in_service and "ports:" in line:
|
||||
in_ports = True
|
||||
continue
|
||||
|
||||
# Extract port mapping
|
||||
if in_ports:
|
||||
match = re.match(pattern, line)
|
||||
if match:
|
||||
try:
|
||||
port = int(match.group(1))
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_config_files(self) -> int | None:
|
||||
"""Detect port in configuration files."""
|
||||
config_files = [
|
||||
"config.py",
|
||||
"settings.py",
|
||||
"config/settings.py",
|
||||
"src/config.py",
|
||||
"config.json",
|
||||
"settings.json",
|
||||
"config/config.json",
|
||||
"config.toml",
|
||||
"settings.toml",
|
||||
]
|
||||
|
||||
for config_file in config_files:
|
||||
content = self._read_file(config_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Python config patterns
|
||||
patterns = [
|
||||
r"[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
|
||||
r'["\']port["\']\s*:\s*(\d+)',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_package_scripts(self) -> int | None:
|
||||
"""Detect port in package.json scripts."""
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
scripts = pkg.get("scripts", {})
|
||||
|
||||
# Look for port specifications in scripts
|
||||
# e.g., "dev": "next dev -p 3001"
|
||||
# e.g., "start": "node server.js --port 8050"
|
||||
patterns = [
|
||||
r"-p\s+(\d+)",
|
||||
r"--port\s+(\d+)",
|
||||
r"PORT=(\d+)",
|
||||
]
|
||||
|
||||
for script in scripts.values():
|
||||
if not isinstance(script, str):
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, script)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_scripts(self) -> int | None:
|
||||
"""Detect port in Makefile or shell scripts."""
|
||||
script_files = ["Makefile", "start.sh", "run.sh", "dev.sh"]
|
||||
|
||||
patterns = [
|
||||
r"PORT=(\d+)",
|
||||
r"--port\s+(\d+)",
|
||||
r"-p\s+(\d+)",
|
||||
]
|
||||
|
||||
for script_file in script_files:
|
||||
content = self._read_file(script_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
@@ -1,350 +0,0 @@
|
||||
"""
|
||||
Project Analyzer Module
|
||||
=======================
|
||||
|
||||
Analyzes entire projects, detecting monorepo structures, services, infrastructure, and conventions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import SERVICE_INDICATORS, SERVICE_ROOT_FILES, SKIP_DIRS
|
||||
from .service_analyzer import ServiceAnalyzer
|
||||
|
||||
|
||||
class ProjectAnalyzer:
|
||||
"""Analyzes an entire project, detecting monorepo structure and all services."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir.resolve()
|
||||
self.index = {
|
||||
"project_root": str(self.project_dir),
|
||||
"project_type": "single", # or "monorepo"
|
||||
"services": {},
|
||||
"infrastructure": {},
|
||||
"conventions": {},
|
||||
}
|
||||
|
||||
def analyze(self) -> dict[str, Any]:
|
||||
"""Run full project analysis."""
|
||||
self._detect_project_type()
|
||||
self._find_and_analyze_services()
|
||||
self._aggregate_dependency_locations()
|
||||
self._analyze_infrastructure()
|
||||
self._detect_conventions()
|
||||
self._map_dependencies()
|
||||
return self.index
|
||||
|
||||
def _detect_project_type(self) -> None:
|
||||
"""Detect if this is a monorepo or single project."""
|
||||
monorepo_indicators = [
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"nx.json",
|
||||
"turbo.json",
|
||||
"rush.json",
|
||||
]
|
||||
|
||||
for indicator in monorepo_indicators:
|
||||
if (self.project_dir / indicator).exists():
|
||||
self.index["project_type"] = "monorepo"
|
||||
self.index["monorepo_tool"] = indicator.replace(".json", "").replace(
|
||||
".yaml", ""
|
||||
)
|
||||
return
|
||||
|
||||
# Check for packages/apps directories
|
||||
if (self.project_dir / "packages").exists() or (
|
||||
self.project_dir / "apps"
|
||||
).exists():
|
||||
self.index["project_type"] = "monorepo"
|
||||
return
|
||||
|
||||
# Check for multiple service directories
|
||||
service_dirs_found = 0
|
||||
for item in self.project_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if item.name in SKIP_DIRS or item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check if this directory has service root files
|
||||
if any((item / f).exists() for f in SERVICE_ROOT_FILES):
|
||||
service_dirs_found += 1
|
||||
|
||||
# If we have 2+ directories with service root files, it's likely a monorepo
|
||||
if service_dirs_found >= 2:
|
||||
self.index["project_type"] = "monorepo"
|
||||
|
||||
def _find_and_analyze_services(self) -> None:
|
||||
"""Find all services and analyze each."""
|
||||
services = {}
|
||||
|
||||
if self.index["project_type"] == "monorepo":
|
||||
# Look for services in common locations
|
||||
service_locations = [
|
||||
self.project_dir,
|
||||
self.project_dir / "packages",
|
||||
self.project_dir / "apps",
|
||||
self.project_dir / "services",
|
||||
]
|
||||
|
||||
for location in service_locations:
|
||||
if not location.exists():
|
||||
continue
|
||||
|
||||
for item in location.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if item.name in SKIP_DIRS:
|
||||
continue
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check if this looks like a service
|
||||
has_root_file = any((item / f).exists() for f in SERVICE_ROOT_FILES)
|
||||
is_service_name = item.name.lower() in SERVICE_INDICATORS
|
||||
|
||||
if has_root_file or (
|
||||
location == self.project_dir and is_service_name
|
||||
):
|
||||
analyzer = ServiceAnalyzer(item, item.name)
|
||||
service_info = analyzer.analyze()
|
||||
if service_info.get(
|
||||
"language"
|
||||
): # Only include if we detected something
|
||||
services[item.name] = service_info
|
||||
else:
|
||||
# Single project - analyze root
|
||||
analyzer = ServiceAnalyzer(self.project_dir, "main")
|
||||
service_info = analyzer.analyze()
|
||||
if service_info.get("language"):
|
||||
services["main"] = service_info
|
||||
|
||||
self.index["services"] = services
|
||||
|
||||
def _aggregate_dependency_locations(self) -> None:
|
||||
"""Aggregate dependency location metadata from all services.
|
||||
|
||||
Collects dependency_locations from each service and stores them as
|
||||
paths relative to the project root (e.g., 'apps/backend/.venv'
|
||||
instead of just '.venv').
|
||||
"""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
|
||||
for service_name, service_info in self.index.get("services", {}).items():
|
||||
service_deps = service_info.get("dependency_locations", [])
|
||||
service_path = service_info.get("path", "")
|
||||
|
||||
# Compute service-relative prefix once per service
|
||||
service_rel: Path | None = None
|
||||
if service_path:
|
||||
try:
|
||||
service_rel = Path(service_path).relative_to(self.project_dir)
|
||||
except ValueError:
|
||||
# Service path is outside the project root — skip its deps
|
||||
# to avoid producing absolute paths that bypass containment
|
||||
continue
|
||||
|
||||
for dep in service_deps:
|
||||
dep_path = dep.get("path")
|
||||
if not dep_path:
|
||||
continue
|
||||
|
||||
# Build project-relative path from service path + dep path
|
||||
if service_rel is not None:
|
||||
project_relative = str(service_rel / dep_path)
|
||||
else:
|
||||
project_relative = dep_path
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": dep.get("type", "unknown"),
|
||||
"path": project_relative,
|
||||
"exists": dep.get("exists", False),
|
||||
"service": service_name,
|
||||
}
|
||||
if dep.get("requirements_file"):
|
||||
# Convert to project-relative path like we do for "path"
|
||||
if service_rel is not None:
|
||||
entry["requirements_file"] = str(
|
||||
service_rel / dep["requirements_file"]
|
||||
)
|
||||
else:
|
||||
entry["requirements_file"] = dep["requirements_file"]
|
||||
pkg_mgr = dep.get("package_manager") or service_info.get(
|
||||
"package_manager"
|
||||
)
|
||||
if pkg_mgr:
|
||||
entry["package_manager"] = pkg_mgr
|
||||
aggregated.append(entry)
|
||||
|
||||
self.index["dependency_locations"] = aggregated
|
||||
|
||||
def _analyze_infrastructure(self) -> None:
|
||||
"""Analyze infrastructure configuration."""
|
||||
infra = {}
|
||||
|
||||
# Docker
|
||||
if (self.project_dir / "docker-compose.yml").exists():
|
||||
infra["docker_compose"] = "docker-compose.yml"
|
||||
compose_content = self._read_file("docker-compose.yml")
|
||||
infra["docker_services"] = self._parse_compose_services(compose_content)
|
||||
elif (self.project_dir / "docker-compose.yaml").exists():
|
||||
infra["docker_compose"] = "docker-compose.yaml"
|
||||
compose_content = self._read_file("docker-compose.yaml")
|
||||
infra["docker_services"] = self._parse_compose_services(compose_content)
|
||||
|
||||
if (self.project_dir / "Dockerfile").exists():
|
||||
infra["dockerfile"] = "Dockerfile"
|
||||
|
||||
# Docker directory
|
||||
docker_dir = self.project_dir / "docker"
|
||||
if docker_dir.exists():
|
||||
dockerfiles = list(docker_dir.glob("Dockerfile*")) + list(
|
||||
docker_dir.glob("*.Dockerfile")
|
||||
)
|
||||
if dockerfiles:
|
||||
infra["docker_directory"] = "docker/"
|
||||
infra["dockerfiles"] = [
|
||||
str(f.relative_to(self.project_dir)) for f in dockerfiles
|
||||
]
|
||||
|
||||
# CI/CD
|
||||
if (self.project_dir / ".github" / "workflows").exists():
|
||||
infra["ci"] = "GitHub Actions"
|
||||
workflows = list((self.project_dir / ".github" / "workflows").glob("*.yml"))
|
||||
infra["ci_workflows"] = [f.name for f in workflows]
|
||||
elif (self.project_dir / ".gitlab-ci.yml").exists():
|
||||
infra["ci"] = "GitLab CI"
|
||||
elif (self.project_dir / ".circleci").exists():
|
||||
infra["ci"] = "CircleCI"
|
||||
|
||||
# Deployment
|
||||
deployment_files = {
|
||||
"vercel.json": "Vercel",
|
||||
"netlify.toml": "Netlify",
|
||||
"fly.toml": "Fly.io",
|
||||
"render.yaml": "Render",
|
||||
"railway.json": "Railway",
|
||||
"Procfile": "Heroku",
|
||||
"app.yaml": "Google App Engine",
|
||||
"serverless.yml": "Serverless Framework",
|
||||
}
|
||||
|
||||
for file, platform in deployment_files.items():
|
||||
if (self.project_dir / file).exists():
|
||||
infra["deployment"] = platform
|
||||
break
|
||||
|
||||
self.index["infrastructure"] = infra
|
||||
|
||||
def _parse_compose_services(self, content: str) -> list[str]:
|
||||
"""Extract service names from docker-compose content."""
|
||||
services = []
|
||||
in_services = False
|
||||
for line in content.split("\n"):
|
||||
if line.strip() == "services:":
|
||||
in_services = True
|
||||
continue
|
||||
if in_services:
|
||||
# Service names are at 2-space indent
|
||||
if (
|
||||
line.startswith(" ")
|
||||
and not line.startswith(" ")
|
||||
and line.strip().endswith(":")
|
||||
):
|
||||
service_name = line.strip().rstrip(":")
|
||||
services.append(service_name)
|
||||
elif line and not line.startswith(" "):
|
||||
break # End of services section
|
||||
return services
|
||||
|
||||
def _detect_conventions(self) -> None:
|
||||
"""Detect project-wide conventions."""
|
||||
conventions = {}
|
||||
|
||||
# Python linting
|
||||
if (self.project_dir / "ruff.toml").exists() or self._has_in_pyproject("ruff"):
|
||||
conventions["python_linting"] = "Ruff"
|
||||
elif (self.project_dir / ".flake8").exists():
|
||||
conventions["python_linting"] = "Flake8"
|
||||
elif (self.project_dir / "pylintrc").exists():
|
||||
conventions["python_linting"] = "Pylint"
|
||||
|
||||
# Python formatting
|
||||
if (self.project_dir / "pyproject.toml").exists():
|
||||
content = self._read_file("pyproject.toml")
|
||||
if "[tool.black]" in content:
|
||||
conventions["python_formatting"] = "Black"
|
||||
|
||||
# JavaScript/TypeScript linting
|
||||
eslint_files = [
|
||||
".eslintrc",
|
||||
".eslintrc.js",
|
||||
".eslintrc.json",
|
||||
".eslintrc.yml",
|
||||
"eslint.config.js",
|
||||
]
|
||||
if any((self.project_dir / f).exists() for f in eslint_files):
|
||||
conventions["js_linting"] = "ESLint"
|
||||
|
||||
# Prettier
|
||||
prettier_files = [
|
||||
".prettierrc",
|
||||
".prettierrc.js",
|
||||
".prettierrc.json",
|
||||
"prettier.config.js",
|
||||
]
|
||||
if any((self.project_dir / f).exists() for f in prettier_files):
|
||||
conventions["formatting"] = "Prettier"
|
||||
|
||||
# TypeScript
|
||||
if (self.project_dir / "tsconfig.json").exists():
|
||||
conventions["typescript"] = True
|
||||
|
||||
# Git hooks
|
||||
if (self.project_dir / ".husky").exists():
|
||||
conventions["git_hooks"] = "Husky"
|
||||
elif (self.project_dir / ".pre-commit-config.yaml").exists():
|
||||
conventions["git_hooks"] = "pre-commit"
|
||||
|
||||
self.index["conventions"] = conventions
|
||||
|
||||
def _map_dependencies(self) -> None:
|
||||
"""Map dependencies between services."""
|
||||
services = self.index.get("services", {})
|
||||
|
||||
for service_name, service_info in services.items():
|
||||
consumes = []
|
||||
|
||||
# Check for API client patterns
|
||||
if service_info.get("type") == "frontend":
|
||||
# Frontend typically consumes backend
|
||||
for other_name, other_info in services.items():
|
||||
if other_info.get("type") == "backend":
|
||||
consumes.append(f"{other_name}.api")
|
||||
|
||||
# Check for shared libraries
|
||||
if service_info.get("dependencies"):
|
||||
deps = service_info["dependencies"]
|
||||
for other_name in services.keys():
|
||||
if other_name in deps or f"@{other_name}" in str(deps):
|
||||
consumes.append(other_name)
|
||||
|
||||
if consumes:
|
||||
service_info["consumes"] = consumes
|
||||
|
||||
def _has_in_pyproject(self, tool: str) -> bool:
|
||||
"""Check if a tool is configured in pyproject.toml."""
|
||||
if (self.project_dir / "pyproject.toml").exists():
|
||||
content = self._read_file("pyproject.toml")
|
||||
return f"[tool.{tool}]" in content
|
||||
return False
|
||||
|
||||
def _read_file(self, path: str) -> str:
|
||||
try:
|
||||
return (self.project_dir / path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
@@ -1,418 +0,0 @@
|
||||
"""
|
||||
Route Detector Module
|
||||
=====================
|
||||
|
||||
Detects API routes and endpoints across different frameworks:
|
||||
- Python: FastAPI, Flask, Django
|
||||
- Node.js: Express, Next.js
|
||||
- Go: Gin, Echo, Chi, Fiber
|
||||
- Rust: Axum, Actix
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class RouteDetector(BaseAnalyzer):
|
||||
"""Detects API routes across multiple web frameworks."""
|
||||
|
||||
# Directories to exclude from route detection
|
||||
EXCLUDED_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".git"}
|
||||
|
||||
def __init__(self, path: Path):
|
||||
super().__init__(path)
|
||||
|
||||
def _should_include_file(self, file_path: Path) -> bool:
|
||||
"""Check if file should be included (not in excluded directories)."""
|
||||
return not any(part in self.EXCLUDED_DIRS for part in file_path.parts)
|
||||
|
||||
def detect_all_routes(self) -> list[dict]:
|
||||
"""Detect all API routes across different frameworks."""
|
||||
routes = []
|
||||
|
||||
# Python FastAPI
|
||||
routes.extend(self._detect_fastapi_routes())
|
||||
|
||||
# Python Flask
|
||||
routes.extend(self._detect_flask_routes())
|
||||
|
||||
# Python Django
|
||||
routes.extend(self._detect_django_routes())
|
||||
|
||||
# Node.js Express/Fastify/Koa
|
||||
routes.extend(self._detect_express_routes())
|
||||
|
||||
# Next.js (file-based routing)
|
||||
routes.extend(self._detect_nextjs_routes())
|
||||
|
||||
# Go Gin/Echo/Chi
|
||||
routes.extend(self._detect_go_routes())
|
||||
|
||||
# Rust Axum/Actix
|
||||
routes.extend(self._detect_rust_routes())
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_fastapi_routes(self) -> list[dict]:
|
||||
"""Detect FastAPI routes."""
|
||||
routes = []
|
||||
files_to_check = [
|
||||
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: @app.get("/path") or @router.post("/path", dependencies=[...])
|
||||
patterns = [
|
||||
(
|
||||
r'@(?:app|router)\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']',
|
||||
"decorator",
|
||||
),
|
||||
(
|
||||
r'@(?:app|router)\.api_route\(["\']([^"\']+)["\'][^)]*methods\s*=\s*\[([^\]]+)\]',
|
||||
"api_route",
|
||||
),
|
||||
]
|
||||
|
||||
for pattern, pattern_type in patterns:
|
||||
matches = re.finditer(pattern, content, re.MULTILINE)
|
||||
for match in matches:
|
||||
if pattern_type == "decorator":
|
||||
method = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
methods = [method]
|
||||
else:
|
||||
path = match.group(1)
|
||||
methods_str = match.group(2)
|
||||
methods = [
|
||||
m.strip().strip('"').strip("'").upper()
|
||||
for m in methods_str.split(",")
|
||||
]
|
||||
|
||||
# Check if route requires auth (has Depends in the decorator)
|
||||
line_start = content.rfind("\n", 0, match.start()) + 1
|
||||
line_end = content.find("\n", match.end())
|
||||
route_definition = content[
|
||||
line_start : line_end if line_end != -1 else len(content)
|
||||
]
|
||||
|
||||
requires_auth = (
|
||||
"Depends" in route_definition
|
||||
or "require" in route_definition.lower()
|
||||
)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": methods,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "FastAPI",
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_flask_routes(self) -> list[dict]:
|
||||
"""Detect Flask routes."""
|
||||
routes = []
|
||||
files_to_check = [
|
||||
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: @app.route("/path", methods=["GET", "POST"])
|
||||
pattern = r'@(?:app|bp|blueprint)\.route\(["\']([^"\']+)["\'](?:[^)]*methods\s*=\s*\[([^\]]+)\])?'
|
||||
matches = re.finditer(pattern, content, re.MULTILINE)
|
||||
|
||||
for match in matches:
|
||||
path = match.group(1)
|
||||
methods_str = match.group(2)
|
||||
|
||||
if methods_str:
|
||||
methods = [
|
||||
m.strip().strip('"').strip("'").upper()
|
||||
for m in methods_str.split(",")
|
||||
]
|
||||
else:
|
||||
methods = ["GET"] # Flask default
|
||||
|
||||
# Check for @login_required decorator
|
||||
decorator_start = content.rfind("@", 0, match.start())
|
||||
decorator_section = content[decorator_start : match.end()]
|
||||
requires_auth = (
|
||||
"login_required" in decorator_section
|
||||
or "require" in decorator_section.lower()
|
||||
)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": methods,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Flask",
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_django_routes(self) -> list[dict]:
|
||||
"""Detect Django routes from urls.py files."""
|
||||
routes = []
|
||||
url_files = [
|
||||
f for f in self.path.glob("**/urls.py") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in url_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: path('users/<int:id>/', views.user_detail)
|
||||
patterns = [
|
||||
r'path\(["\']([^"\']+)["\']',
|
||||
r're_path\([r]?["\']([^"\']+)["\']',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.finditer(pattern, content)
|
||||
for match in matches:
|
||||
path = match.group(1)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": f"/{path}" if not path.startswith("/") else path,
|
||||
"methods": ["GET", "POST"], # Django allows both by default
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Django",
|
||||
"requires_auth": False, # Can't easily detect without middleware analysis
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_express_routes(self) -> list[dict]:
|
||||
"""Detect Express/Fastify/Koa routes."""
|
||||
routes = []
|
||||
js_files = [
|
||||
f for f in self.path.glob("**/*.js") if self._should_include_file(f)
|
||||
]
|
||||
ts_files = [
|
||||
f for f in self.path.glob("**/*.ts") if self._should_include_file(f)
|
||||
]
|
||||
files_to_check = js_files + ts_files
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: app.get('/path', handler) or router.post('/path', middleware, handler)
|
||||
pattern = (
|
||||
r'(?:app|router)\.(get|post|put|delete|patch|use)\(["\']([^"\']+)["\']'
|
||||
)
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
method = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
|
||||
if method == "USE":
|
||||
# .use() is middleware, might be a route prefix
|
||||
continue
|
||||
|
||||
# Check for auth middleware in the route definition
|
||||
line_start = content.rfind("\n", 0, match.start()) + 1
|
||||
line_end = content.find("\n", match.end())
|
||||
route_line = content[
|
||||
line_start : line_end if line_end != -1 else len(content)
|
||||
]
|
||||
|
||||
requires_auth = any(
|
||||
keyword in route_line.lower()
|
||||
for keyword in ["auth", "authenticate", "protect", "require"]
|
||||
)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": [method],
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Express",
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_nextjs_routes(self) -> list[dict]:
|
||||
"""Detect Next.js file-based routes."""
|
||||
routes = []
|
||||
|
||||
# Next.js App Router (app directory)
|
||||
app_dir = self.path / "app"
|
||||
if app_dir.exists():
|
||||
# Find all route.ts/js files
|
||||
route_files = [
|
||||
f
|
||||
for f in app_dir.glob("**/route.{ts,js,tsx,jsx}")
|
||||
if self._should_include_file(f)
|
||||
]
|
||||
for route_file in route_files:
|
||||
# Convert file path to route path
|
||||
# app/api/users/[id]/route.ts -> /api/users/:id
|
||||
relative_path = route_file.parent.relative_to(app_dir)
|
||||
route_path = "/" + str(relative_path).replace("\\", "/")
|
||||
|
||||
# Convert [id] to :id
|
||||
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
|
||||
|
||||
try:
|
||||
content = route_file.read_text(encoding="utf-8")
|
||||
# Detect exported methods: export async function GET(request)
|
||||
methods = re.findall(
|
||||
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)",
|
||||
content,
|
||||
)
|
||||
|
||||
if methods:
|
||||
routes.append(
|
||||
{
|
||||
"path": route_path,
|
||||
"methods": methods,
|
||||
"file": str(route_file.relative_to(self.path)),
|
||||
"framework": "Next.js",
|
||||
"requires_auth": "auth" in content.lower(),
|
||||
}
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Next.js Pages Router (pages/api directory)
|
||||
pages_api = self.path / "pages" / "api"
|
||||
if pages_api.exists():
|
||||
api_files = [
|
||||
f
|
||||
for f in pages_api.glob("**/*.{ts,js,tsx,jsx}")
|
||||
if self._should_include_file(f)
|
||||
]
|
||||
for api_file in api_files:
|
||||
if api_file.name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Convert file path to route
|
||||
relative_path = api_file.relative_to(pages_api)
|
||||
route_path = "/api/" + str(relative_path.with_suffix("")).replace(
|
||||
"\\", "/"
|
||||
)
|
||||
|
||||
# Convert [id] to :id
|
||||
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": route_path,
|
||||
"methods": [
|
||||
"GET",
|
||||
"POST",
|
||||
], # Next.js API routes handle all methods
|
||||
"file": str(api_file.relative_to(self.path)),
|
||||
"framework": "Next.js",
|
||||
"requires_auth": False,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_go_routes(self) -> list[dict]:
|
||||
"""Detect Go framework routes (Gin, Echo, Chi, Fiber)."""
|
||||
routes = []
|
||||
go_files = [
|
||||
f for f in self.path.glob("**/*.go") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in go_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Gin: r.GET("/path", handler)
|
||||
# Echo: e.POST("/path", handler)
|
||||
# Chi: r.Get("/path", handler)
|
||||
# Fiber: app.Get("/path", handler)
|
||||
pattern = r'(?:r|e|app|router)\.(GET|POST|PUT|DELETE|PATCH|Get|Post|Put|Delete|Patch)\(["\']([^"\']+)["\']'
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
method = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": [method],
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Go",
|
||||
"requires_auth": False,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_rust_routes(self) -> list[dict]:
|
||||
"""Detect Rust framework routes (Axum, Actix)."""
|
||||
routes = []
|
||||
rust_files = [
|
||||
f for f in self.path.glob("**/*.rs") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in rust_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Axum: .route("/path", get(handler))
|
||||
# Actix: web::get().to(handler)
|
||||
patterns = [
|
||||
r'\.route\(["\']([^"\']+)["\'],\s*(get|post|put|delete|patch)',
|
||||
r"web::(get|post|put|delete|patch)\(\)",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.finditer(pattern, content)
|
||||
for match in matches:
|
||||
if len(match.groups()) == 2:
|
||||
path = match.group(1)
|
||||
method = match.group(2).upper()
|
||||
else:
|
||||
path = "/" # Can't determine path from web:: syntax
|
||||
method = match.group(1).upper()
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": [method],
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Rust",
|
||||
"requires_auth": False,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
@@ -1,430 +0,0 @@
|
||||
"""
|
||||
Service Analyzer Module
|
||||
=======================
|
||||
|
||||
Main ServiceAnalyzer class that coordinates all analysis for a single service/package.
|
||||
Integrates framework detection, route analysis, database models, and context extraction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
from .context_analyzer import ContextAnalyzer
|
||||
from .database_detector import DatabaseDetector
|
||||
from .framework_analyzer import FrameworkAnalyzer
|
||||
from .route_detector import RouteDetector
|
||||
|
||||
|
||||
class ServiceAnalyzer(BaseAnalyzer):
|
||||
"""Analyzes a single service/package within a project."""
|
||||
|
||||
def __init__(self, service_path: Path, service_name: str):
|
||||
super().__init__(service_path)
|
||||
self.name = service_name
|
||||
self.analysis = {
|
||||
"name": service_name,
|
||||
"path": str(service_path),
|
||||
"language": None,
|
||||
"framework": None,
|
||||
"type": None, # backend, frontend, worker, library, etc.
|
||||
}
|
||||
|
||||
def analyze(self) -> dict[str, Any]:
|
||||
"""Run full analysis on this service."""
|
||||
self._detect_language_and_framework()
|
||||
self._detect_service_type()
|
||||
self._find_key_directories()
|
||||
self._find_entry_points()
|
||||
self._detect_dependencies()
|
||||
self._detect_dependency_locations()
|
||||
self._detect_package_manager()
|
||||
self._detect_testing()
|
||||
self._find_dockerfile()
|
||||
|
||||
# Comprehensive context extraction
|
||||
self._detect_environment_variables()
|
||||
self._detect_api_routes()
|
||||
self._detect_database_models()
|
||||
self._detect_external_services()
|
||||
self._detect_auth_patterns()
|
||||
self._detect_migrations()
|
||||
self._detect_background_jobs()
|
||||
self._detect_api_documentation()
|
||||
self._detect_monitoring()
|
||||
|
||||
return self.analysis
|
||||
|
||||
def _detect_language_and_framework(self) -> None:
|
||||
"""Detect primary language and framework."""
|
||||
framework_analyzer = FrameworkAnalyzer(self.path, self.analysis)
|
||||
framework_analyzer.detect_language_and_framework()
|
||||
|
||||
def _detect_service_type(self) -> None:
|
||||
"""Infer service type from name and content if not already set."""
|
||||
if self.analysis.get("type"):
|
||||
return
|
||||
|
||||
name_lower = self.name.lower()
|
||||
|
||||
# Infer from name
|
||||
if any(kw in name_lower for kw in ["frontend", "client", "web", "ui", "app"]):
|
||||
self.analysis["type"] = "frontend"
|
||||
elif any(kw in name_lower for kw in ["backend", "api", "server", "service"]):
|
||||
self.analysis["type"] = "backend"
|
||||
elif any(
|
||||
kw in name_lower for kw in ["worker", "job", "queue", "task", "celery"]
|
||||
):
|
||||
self.analysis["type"] = "worker"
|
||||
elif any(kw in name_lower for kw in ["scraper", "crawler", "spider"]):
|
||||
self.analysis["type"] = "scraper"
|
||||
elif any(kw in name_lower for kw in ["proxy", "gateway", "router"]):
|
||||
self.analysis["type"] = "proxy"
|
||||
elif any(
|
||||
kw in name_lower for kw in ["lib", "shared", "common", "core", "utils"]
|
||||
):
|
||||
self.analysis["type"] = "library"
|
||||
else:
|
||||
# Try to infer from language and content if name doesn't match
|
||||
language = self.analysis.get("language")
|
||||
|
||||
if language == "Python":
|
||||
# Check if it's a CLI tool, framework, or backend service
|
||||
has_run_py = (self.path / "run.py").exists()
|
||||
has_main_py = (self.path / "main.py").exists()
|
||||
has_main_module = (self.path / "__main__.py").exists()
|
||||
|
||||
# Check for agent/automation framework patterns
|
||||
has_agent_files = any(
|
||||
(self.path / f).exists()
|
||||
for f in ["agent.py", "agents", "runner.py", "runners"]
|
||||
)
|
||||
|
||||
if has_run_py or has_main_py or has_main_module or has_agent_files:
|
||||
# It's a backend tool/framework/CLI
|
||||
self.analysis["type"] = "backend"
|
||||
return
|
||||
|
||||
# Default to unknown if no clear indicators
|
||||
self.analysis["type"] = "unknown"
|
||||
|
||||
def _find_key_directories(self) -> None:
|
||||
"""Find important directories within this service."""
|
||||
key_dirs = {}
|
||||
|
||||
# Common directory patterns
|
||||
patterns = {
|
||||
"src": "Source code",
|
||||
"lib": "Library code",
|
||||
"app": "Application code",
|
||||
"api": "API endpoints",
|
||||
"routes": "Route handlers",
|
||||
"controllers": "Controllers",
|
||||
"models": "Data models",
|
||||
"schemas": "Schemas/DTOs",
|
||||
"services": "Business logic",
|
||||
"components": "UI components",
|
||||
"pages": "Page components",
|
||||
"views": "Views/templates",
|
||||
"hooks": "Custom hooks",
|
||||
"utils": "Utilities",
|
||||
"helpers": "Helper functions",
|
||||
"middleware": "Middleware",
|
||||
"tests": "Tests",
|
||||
"test": "Tests",
|
||||
"__tests__": "Tests",
|
||||
"config": "Configuration",
|
||||
"tasks": "Background tasks",
|
||||
"jobs": "Background jobs",
|
||||
"workers": "Worker processes",
|
||||
}
|
||||
|
||||
for dir_name, purpose in patterns.items():
|
||||
dir_path = self.path / dir_name
|
||||
if dir_path.exists() and dir_path.is_dir():
|
||||
key_dirs[dir_name] = {
|
||||
"path": str(dir_path.relative_to(self.path)),
|
||||
"purpose": purpose,
|
||||
}
|
||||
|
||||
if key_dirs:
|
||||
self.analysis["key_directories"] = key_dirs
|
||||
|
||||
def _find_entry_points(self) -> None:
|
||||
"""Find main entry point files."""
|
||||
entry_patterns = [
|
||||
"main.py",
|
||||
"app.py",
|
||||
"__main__.py",
|
||||
"server.py",
|
||||
"wsgi.py",
|
||||
"asgi.py",
|
||||
"index.ts",
|
||||
"index.js",
|
||||
"main.ts",
|
||||
"main.js",
|
||||
"server.ts",
|
||||
"server.js",
|
||||
"app.ts",
|
||||
"app.js",
|
||||
"src/index.ts",
|
||||
"src/index.js",
|
||||
"src/main.ts",
|
||||
"src/app.ts",
|
||||
"src/server.ts",
|
||||
"src/App.tsx",
|
||||
"src/App.jsx",
|
||||
"pages/_app.tsx",
|
||||
"pages/_app.js", # Next.js
|
||||
"main.go",
|
||||
"cmd/main.go",
|
||||
"src/main.rs",
|
||||
"src/lib.rs",
|
||||
]
|
||||
|
||||
for pattern in entry_patterns:
|
||||
if self._exists(pattern):
|
||||
self.analysis["entry_point"] = pattern
|
||||
break
|
||||
|
||||
def _detect_dependencies(self) -> None:
|
||||
"""Extract key dependencies."""
|
||||
if self._exists("package.json"):
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
deps = pkg.get("dependencies", {})
|
||||
dev_deps = pkg.get("devDependencies", {})
|
||||
self.analysis["dependencies"] = list(deps.keys())[:20] # Top 20
|
||||
self.analysis["dev_dependencies"] = list(dev_deps.keys())[:10]
|
||||
|
||||
elif self._exists("requirements.txt"):
|
||||
content = self._read_file("requirements.txt")
|
||||
deps = []
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and not line.startswith("-"):
|
||||
match = re.match(r"^([a-zA-Z0-9_-]+)", line)
|
||||
if match:
|
||||
deps.append(match.group(1))
|
||||
self.analysis["dependencies"] = deps[:20]
|
||||
|
||||
def _detect_dependency_locations(self) -> None:
|
||||
"""Detect where dependencies live on disk for this service."""
|
||||
locations: list[dict[str, Any]] = []
|
||||
|
||||
# Node.js: node_modules (only if package.json exists)
|
||||
if self._exists("package.json"):
|
||||
node_modules = self.path / "node_modules"
|
||||
locations.append(
|
||||
{
|
||||
"type": "node_modules",
|
||||
"path": "node_modules",
|
||||
"exists": node_modules.exists() and node_modules.is_dir(),
|
||||
}
|
||||
)
|
||||
|
||||
# Python: .venv or venv
|
||||
for venv_dir in [".venv", "venv"]:
|
||||
venv_path = self.path / venv_dir
|
||||
if venv_path.exists() and venv_path.is_dir():
|
||||
entry: dict[str, Any] = {
|
||||
"type": "venv",
|
||||
"path": venv_dir,
|
||||
"exists": True,
|
||||
}
|
||||
# Find requirements file
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
entry["requirements_file"] = req_file
|
||||
break
|
||||
locations.append(entry)
|
||||
break
|
||||
else:
|
||||
# No venv found, still record requirements file if present
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
locations.append(
|
||||
{
|
||||
"type": "venv",
|
||||
"path": ".venv",
|
||||
"exists": False,
|
||||
"requirements_file": req_file,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# PHP: vendor
|
||||
vendor_path = self.path / "vendor"
|
||||
if vendor_path.exists() and vendor_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_php",
|
||||
"path": "vendor",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Rust: target
|
||||
target_path = self.path / "target"
|
||||
if target_path.exists() and target_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "cargo_target",
|
||||
"path": "target",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Ruby: vendor/bundle
|
||||
bundle_path = self.path / "vendor" / "bundle"
|
||||
if bundle_path.exists() and bundle_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_bundle",
|
||||
"path": "vendor/bundle",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.analysis["dependency_locations"] = locations
|
||||
|
||||
def _detect_package_manager(self) -> None:
|
||||
"""Detect the package manager used by this service."""
|
||||
# Node.js package managers
|
||||
if self._exists("package-lock.json"):
|
||||
self.analysis["package_manager"] = "npm"
|
||||
elif self._exists("yarn.lock"):
|
||||
self.analysis["package_manager"] = "yarn"
|
||||
elif self._exists("pnpm-lock.yaml"):
|
||||
self.analysis["package_manager"] = "pnpm"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
self.analysis["package_manager"] = "bun"
|
||||
# Python package managers
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
elif self._exists("pyproject.toml"):
|
||||
if self._exists("uv.lock"):
|
||||
self.analysis["package_manager"] = "uv"
|
||||
elif self._exists("poetry.lock"):
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
elif self._exists("requirements.txt"):
|
||||
self.analysis["package_manager"] = "pip"
|
||||
# Other
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["package_manager"] = "go_mod"
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["package_manager"] = "gem"
|
||||
elif self._exists("composer.json"):
|
||||
self.analysis["package_manager"] = "composer"
|
||||
else:
|
||||
self.analysis["package_manager"] = None
|
||||
|
||||
def _detect_testing(self) -> None:
|
||||
"""Detect testing framework and configuration."""
|
||||
if self._exists("package.json"):
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "vitest" in deps:
|
||||
self.analysis["testing"] = "Vitest"
|
||||
elif "jest" in deps:
|
||||
self.analysis["testing"] = "Jest"
|
||||
if "@playwright/test" in deps:
|
||||
self.analysis["e2e_testing"] = "Playwright"
|
||||
elif "cypress" in deps:
|
||||
self.analysis["e2e_testing"] = "Cypress"
|
||||
|
||||
elif self._exists("pytest.ini") or self._exists("pyproject.toml"):
|
||||
self.analysis["testing"] = "pytest"
|
||||
|
||||
# Find test directory
|
||||
for test_dir in ["tests", "test", "__tests__", "spec"]:
|
||||
if self._exists(test_dir):
|
||||
self.analysis["test_directory"] = test_dir
|
||||
break
|
||||
|
||||
def _find_dockerfile(self) -> None:
|
||||
"""Find Dockerfile for this service."""
|
||||
dockerfile_patterns = [
|
||||
"Dockerfile",
|
||||
f"Dockerfile.{self.name}",
|
||||
f"docker/{self.name}.Dockerfile",
|
||||
f"docker/Dockerfile.{self.name}",
|
||||
"../docker/Dockerfile." + self.name,
|
||||
]
|
||||
|
||||
for pattern in dockerfile_patterns:
|
||||
if self._exists(pattern):
|
||||
self.analysis["dockerfile"] = pattern
|
||||
break
|
||||
|
||||
def _detect_environment_variables(self) -> None:
|
||||
"""Detect environment variables."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_environment_variables()
|
||||
|
||||
def _detect_api_routes(self) -> None:
|
||||
"""Detect API routes."""
|
||||
route_detector = RouteDetector(self.path)
|
||||
routes = route_detector.detect_all_routes()
|
||||
|
||||
if routes:
|
||||
self.analysis["api"] = {
|
||||
"routes": routes,
|
||||
"total_routes": len(routes),
|
||||
"methods": list(
|
||||
set(method for r in routes for method in r.get("methods", []))
|
||||
),
|
||||
"protected_routes": [
|
||||
r["path"] for r in routes if r.get("requires_auth")
|
||||
],
|
||||
}
|
||||
|
||||
def _detect_database_models(self) -> None:
|
||||
"""Detect database models."""
|
||||
db_detector = DatabaseDetector(self.path)
|
||||
models = db_detector.detect_all_models()
|
||||
|
||||
if models:
|
||||
self.analysis["database"] = {
|
||||
"models": models,
|
||||
"total_models": len(models),
|
||||
"model_names": list(models.keys()),
|
||||
}
|
||||
|
||||
def _detect_external_services(self) -> None:
|
||||
"""Detect external services."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_external_services()
|
||||
|
||||
def _detect_auth_patterns(self) -> None:
|
||||
"""Detect authentication patterns."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_auth_patterns()
|
||||
|
||||
def _detect_migrations(self) -> None:
|
||||
"""Detect database migrations."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_migrations()
|
||||
|
||||
def _detect_background_jobs(self) -> None:
|
||||
"""Detect background jobs."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_background_jobs()
|
||||
|
||||
def _detect_api_documentation(self) -> None:
|
||||
"""Detect API documentation."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_api_documentation()
|
||||
|
||||
def _detect_monitoring(self) -> None:
|
||||
"""Detect monitoring setup."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_monitoring()
|
||||
@@ -1,589 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Discovery Module
|
||||
===================
|
||||
|
||||
Parses CI/CD configuration files to extract test commands and workflows.
|
||||
Supports GitHub Actions, GitLab CI, CircleCI, and Jenkins.
|
||||
|
||||
The CI discovery results are used by:
|
||||
- QA Agent: To understand existing CI test patterns
|
||||
- Validation Strategy: To match CI commands
|
||||
- Planner: To align verification with CI
|
||||
|
||||
Usage:
|
||||
from ci_discovery import CIDiscovery
|
||||
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
|
||||
if result:
|
||||
print(f"CI System: {result.ci_system}")
|
||||
print(f"Test Commands: {result.test_commands}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Try to import yaml, fall back gracefully
|
||||
try:
|
||||
import yaml
|
||||
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class CIWorkflow:
|
||||
"""
|
||||
Represents a CI workflow or job.
|
||||
|
||||
Attributes:
|
||||
name: Name of the workflow/job
|
||||
trigger: What triggers this workflow (push, pull_request, etc.)
|
||||
steps: List of step names or commands
|
||||
test_related: Whether this appears to be test-related
|
||||
"""
|
||||
|
||||
name: str
|
||||
trigger: list[str] = field(default_factory=list)
|
||||
steps: list[str] = field(default_factory=list)
|
||||
test_related: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CIConfig:
|
||||
"""
|
||||
Result of CI configuration discovery.
|
||||
|
||||
Attributes:
|
||||
ci_system: Name of CI system (github_actions, gitlab, circleci, jenkins)
|
||||
config_files: List of CI config files found
|
||||
test_commands: Extracted test commands by type
|
||||
coverage_command: Coverage command if found
|
||||
workflows: List of discovered workflows
|
||||
environment_variables: Environment variables used
|
||||
"""
|
||||
|
||||
ci_system: str
|
||||
config_files: list[str] = field(default_factory=list)
|
||||
test_commands: dict[str, str] = field(default_factory=dict)
|
||||
coverage_command: str | None = None
|
||||
workflows: list[CIWorkflow] = field(default_factory=list)
|
||||
environment_variables: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CI PARSERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CIDiscovery:
|
||||
"""
|
||||
Discovers CI/CD configurations in a project.
|
||||
|
||||
Analyzes:
|
||||
- GitHub Actions (.github/workflows/*.yml)
|
||||
- GitLab CI (.gitlab-ci.yml)
|
||||
- CircleCI (.circleci/config.yml)
|
||||
- Jenkins (Jenkinsfile)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize CI discovery."""
|
||||
self._cache: dict[str, CIConfig | None] = {}
|
||||
|
||||
def discover(self, project_dir: Path) -> CIConfig | None:
|
||||
"""
|
||||
Discover CI configuration in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
CIConfig if CI found, None otherwise
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Try each CI system
|
||||
result = None
|
||||
|
||||
# GitHub Actions
|
||||
github_workflows = project_dir / ".github" / "workflows"
|
||||
if github_workflows.exists():
|
||||
result = self._parse_github_actions(github_workflows)
|
||||
|
||||
# GitLab CI
|
||||
if not result:
|
||||
gitlab_ci = project_dir / ".gitlab-ci.yml"
|
||||
if gitlab_ci.exists():
|
||||
result = self._parse_gitlab_ci(gitlab_ci)
|
||||
|
||||
# CircleCI
|
||||
if not result:
|
||||
circleci = project_dir / ".circleci" / "config.yml"
|
||||
if circleci.exists():
|
||||
result = self._parse_circleci(circleci)
|
||||
|
||||
# Jenkins
|
||||
if not result:
|
||||
jenkinsfile = project_dir / "Jenkinsfile"
|
||||
if jenkinsfile.exists():
|
||||
result = self._parse_jenkinsfile(jenkinsfile)
|
||||
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _parse_github_actions(self, workflows_dir: Path) -> CIConfig:
|
||||
"""Parse GitHub Actions workflow files."""
|
||||
result = CIConfig(ci_system="github_actions")
|
||||
|
||||
workflow_files = list(workflows_dir.glob("*.yml")) + list(
|
||||
workflows_dir.glob("*.yaml")
|
||||
)
|
||||
|
||||
for wf_file in workflow_files:
|
||||
result.config_files.append(
|
||||
str(wf_file.relative_to(workflows_dir.parent.parent))
|
||||
)
|
||||
|
||||
try:
|
||||
content = wf_file.read_text(encoding="utf-8")
|
||||
workflow_data = self._parse_yaml(content)
|
||||
|
||||
if not workflow_data:
|
||||
continue
|
||||
|
||||
# Get workflow name
|
||||
wf_name = workflow_data.get("name", wf_file.stem)
|
||||
|
||||
# Get triggers
|
||||
triggers = []
|
||||
on_trigger = workflow_data.get("on", {})
|
||||
if isinstance(on_trigger, str):
|
||||
triggers = [on_trigger]
|
||||
elif isinstance(on_trigger, list):
|
||||
triggers = on_trigger
|
||||
elif isinstance(on_trigger, dict):
|
||||
triggers = list(on_trigger.keys())
|
||||
|
||||
# Parse jobs
|
||||
jobs = workflow_data.get("jobs", {})
|
||||
for job_name, job_config in jobs.items():
|
||||
if not isinstance(job_config, dict):
|
||||
continue
|
||||
|
||||
steps = job_config.get("steps", [])
|
||||
step_commands = []
|
||||
test_related = False
|
||||
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
|
||||
# Get step name or command
|
||||
step_name = step.get("name", "")
|
||||
run_cmd = step.get("run", "")
|
||||
uses = step.get("uses", "")
|
||||
|
||||
if step_name:
|
||||
step_commands.append(step_name)
|
||||
if run_cmd:
|
||||
step_commands.append(run_cmd)
|
||||
# Extract test commands
|
||||
self._extract_test_commands(run_cmd, result)
|
||||
if uses:
|
||||
step_commands.append(f"uses: {uses}")
|
||||
|
||||
# Check if test-related
|
||||
test_keywords = ["test", "pytest", "jest", "vitest", "coverage"]
|
||||
if any(kw in str(step).lower() for kw in test_keywords):
|
||||
test_related = True
|
||||
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=f"{wf_name}/{job_name}",
|
||||
trigger=triggers,
|
||||
steps=step_commands,
|
||||
test_related=test_related,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract environment variables
|
||||
env = workflow_data.get("env", {})
|
||||
if isinstance(env, dict):
|
||||
result.environment_variables.extend(env.keys())
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
def _parse_gitlab_ci(self, config_file: Path) -> CIConfig:
|
||||
"""Parse GitLab CI configuration."""
|
||||
result = CIConfig(
|
||||
ci_system="gitlab",
|
||||
config_files=[".gitlab-ci.yml"],
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
return result
|
||||
|
||||
# Parse jobs (top-level keys that aren't special keywords)
|
||||
special_keys = {
|
||||
"stages",
|
||||
"variables",
|
||||
"image",
|
||||
"services",
|
||||
"before_script",
|
||||
"after_script",
|
||||
"cache",
|
||||
"include",
|
||||
"default",
|
||||
"workflow",
|
||||
}
|
||||
|
||||
for key, value in data.items():
|
||||
if key.startswith(".") or key in special_keys:
|
||||
continue
|
||||
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
job_config = value
|
||||
script = job_config.get("script", [])
|
||||
if isinstance(script, str):
|
||||
script = [script]
|
||||
|
||||
test_related = any(
|
||||
kw in str(script).lower()
|
||||
for kw in ["test", "pytest", "jest", "vitest", "coverage"]
|
||||
)
|
||||
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=key,
|
||||
trigger=job_config.get("only", [])
|
||||
or job_config.get("rules", []),
|
||||
steps=script,
|
||||
test_related=test_related,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract test commands
|
||||
for cmd in script:
|
||||
if isinstance(cmd, str):
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
# Extract variables
|
||||
variables = data.get("variables", {})
|
||||
if isinstance(variables, dict):
|
||||
result.environment_variables.extend(variables.keys())
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _parse_circleci(self, config_file: Path) -> CIConfig:
|
||||
"""Parse CircleCI configuration."""
|
||||
result = CIConfig(
|
||||
ci_system="circleci",
|
||||
config_files=[".circleci/config.yml"],
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
return result
|
||||
|
||||
# Parse jobs
|
||||
jobs = data.get("jobs", {})
|
||||
for job_name, job_config in jobs.items():
|
||||
if not isinstance(job_config, dict):
|
||||
continue
|
||||
|
||||
steps = job_config.get("steps", [])
|
||||
step_commands = []
|
||||
test_related = False
|
||||
|
||||
for step in steps:
|
||||
if isinstance(step, str):
|
||||
step_commands.append(step)
|
||||
elif isinstance(step, dict):
|
||||
if "run" in step:
|
||||
run = step["run"]
|
||||
if isinstance(run, str):
|
||||
step_commands.append(run)
|
||||
self._extract_test_commands(run, result)
|
||||
elif isinstance(run, dict):
|
||||
cmd = run.get("command", "")
|
||||
step_commands.append(cmd)
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
if any(
|
||||
kw in str(step).lower()
|
||||
for kw in ["test", "pytest", "jest", "coverage"]
|
||||
):
|
||||
test_related = True
|
||||
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=job_name,
|
||||
trigger=[],
|
||||
steps=step_commands,
|
||||
test_related=test_related,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _parse_jenkinsfile(self, jenkinsfile: Path) -> CIConfig:
|
||||
"""Parse Jenkinsfile (basic extraction)."""
|
||||
result = CIConfig(
|
||||
ci_system="jenkins",
|
||||
config_files=["Jenkinsfile"],
|
||||
)
|
||||
|
||||
try:
|
||||
content = jenkinsfile.read_text(encoding="utf-8")
|
||||
|
||||
# Extract sh commands using regex
|
||||
sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]')
|
||||
matches = sh_pattern.findall(content)
|
||||
|
||||
steps = []
|
||||
test_related = False
|
||||
|
||||
for cmd in matches:
|
||||
steps.append(cmd)
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
if any(
|
||||
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
|
||||
):
|
||||
test_related = True
|
||||
|
||||
# Extract stage names
|
||||
stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)')
|
||||
stages = stage_pattern.findall(content)
|
||||
|
||||
for stage in stages:
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=stage,
|
||||
trigger=[],
|
||||
steps=steps if "test" in stage.lower() else [],
|
||||
test_related="test" in stage.lower(),
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _parse_yaml(self, content: str) -> dict | None:
|
||||
"""Parse YAML content, with fallback to basic parsing if yaml not available."""
|
||||
if HAS_YAML:
|
||||
try:
|
||||
return yaml.safe_load(content)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Basic fallback for simple YAML (very limited)
|
||||
# This won't work for complex structures
|
||||
return None
|
||||
|
||||
def _extract_test_commands(self, cmd: str, result: CIConfig) -> None:
|
||||
"""Extract test commands from a command string."""
|
||||
cmd_lower = cmd.lower()
|
||||
|
||||
# Python pytest
|
||||
if "pytest" in cmd_lower:
|
||||
if "pytest" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
if "--cov" in cmd_lower:
|
||||
result.coverage_command = cmd.strip()
|
||||
|
||||
# Node.js test commands
|
||||
if (
|
||||
"npm test" in cmd_lower
|
||||
or "yarn test" in cmd_lower
|
||||
or "pnpm test" in cmd_lower
|
||||
):
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
|
||||
# Jest/Vitest
|
||||
if "jest" in cmd_lower or "vitest" in cmd_lower:
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
if "--coverage" in cmd_lower:
|
||||
result.coverage_command = cmd.strip()
|
||||
|
||||
# E2E testing
|
||||
if "playwright" in cmd_lower:
|
||||
result.test_commands["e2e"] = cmd.strip()
|
||||
if "cypress" in cmd_lower:
|
||||
result.test_commands["e2e"] = cmd.strip()
|
||||
|
||||
# Integration tests
|
||||
if "integration" in cmd_lower:
|
||||
result.test_commands["integration"] = cmd.strip()
|
||||
|
||||
# Go tests
|
||||
if "go test" in cmd_lower:
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
|
||||
# Rust tests
|
||||
if "cargo test" in cmd_lower:
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
|
||||
def to_dict(self, result: CIConfig) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"ci_system": result.ci_system,
|
||||
"config_files": result.config_files,
|
||||
"test_commands": result.test_commands,
|
||||
"coverage_command": result.coverage_command,
|
||||
"workflows": [
|
||||
{
|
||||
"name": w.name,
|
||||
"trigger": w.trigger,
|
||||
"steps": w.steps,
|
||||
"test_related": w.test_related,
|
||||
}
|
||||
for w in result.workflows
|
||||
],
|
||||
"environment_variables": result.environment_variables,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def discover_ci(project_dir: Path) -> CIConfig | None:
|
||||
"""
|
||||
Convenience function to discover CI configuration.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
CIConfig if found, None otherwise
|
||||
"""
|
||||
discovery = CIDiscovery()
|
||||
return discovery.discover(project_dir)
|
||||
|
||||
|
||||
def get_ci_test_commands(project_dir: Path) -> dict[str, str]:
|
||||
"""
|
||||
Get test commands from CI configuration.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
Dictionary of test type to command
|
||||
"""
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
if result:
|
||||
return result.test_commands
|
||||
return {}
|
||||
|
||||
|
||||
def get_ci_system(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Get the CI system name if configured.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
CI system name or None
|
||||
"""
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
if result:
|
||||
return result.ci_system
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Discover CI configuration")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(args.project_dir)
|
||||
|
||||
if not result:
|
||||
print("No CI configuration found")
|
||||
return
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(discovery.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"CI System: {result.ci_system}")
|
||||
print(f"Config Files: {', '.join(result.config_files)}")
|
||||
print("\nTest Commands:")
|
||||
for test_type, cmd in result.test_commands.items():
|
||||
print(f" {test_type}: {cmd}")
|
||||
if result.coverage_command:
|
||||
print(f"\nCoverage Command: {result.coverage_command}")
|
||||
print(f"\nWorkflows ({len(result.workflows)}):")
|
||||
for w in result.workflows:
|
||||
marker = "[TEST]" if w.test_related else ""
|
||||
print(f" - {w.name} {marker}")
|
||||
if w.trigger:
|
||||
print(f" Triggers: {', '.join(str(t) for t in w.trigger)}")
|
||||
if result.environment_variables:
|
||||
print(f"\nEnvironment Variables: {', '.join(result.environment_variables)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,643 +0,0 @@
|
||||
"""
|
||||
Insight Extractor
|
||||
=================
|
||||
|
||||
Automatically extracts structured insights from completed coding sessions.
|
||||
Runs after each session to capture rich, actionable knowledge for Graphiti memory.
|
||||
|
||||
Uses the Claude Agent SDK (same as the rest of the system) for extraction.
|
||||
Falls back to generic insights if extraction fails (never blocks the build).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for Claude SDK availability
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_AVAILABLE = False
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
# Default model for insight extraction (fast and cheap)
|
||||
# Note: Using Haiku 4.5 for fast, cheap extraction. Haiku does not support
|
||||
# extended thinking, so thinking_default is set to "none" in models.py
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
# Maximum diff size to send to the LLM (avoid context limits)
|
||||
MAX_DIFF_CHARS = 15000
|
||||
|
||||
# Maximum attempt history entries to include
|
||||
MAX_ATTEMPTS_TO_INCLUDE = 3
|
||||
|
||||
|
||||
def is_extraction_enabled() -> bool:
|
||||
"""Check if insight extraction is enabled."""
|
||||
# Extraction requires Claude SDK and authentication token
|
||||
if not SDK_AVAILABLE:
|
||||
return False
|
||||
if not get_auth_token():
|
||||
return False
|
||||
enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower()
|
||||
return enabled_str in ("true", "1", "yes")
|
||||
|
||||
|
||||
def get_extraction_model() -> str:
|
||||
"""Get the model to use for insight extraction."""
|
||||
return os.environ.get("INSIGHT_EXTRACTOR_MODEL", DEFAULT_EXTRACTION_MODEL)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Git Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_session_diff(
|
||||
project_dir: Path,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the git diff between two commits.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
commit_before: Commit hash before session (or None)
|
||||
commit_after: Commit hash after session (or None)
|
||||
|
||||
Returns:
|
||||
Diff text (truncated if too large)
|
||||
"""
|
||||
if not commit_before or not commit_after:
|
||||
return "(No commits to diff)"
|
||||
|
||||
if commit_before == commit_after:
|
||||
return "(No changes - same commit)"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", commit_before, commit_after],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
diff = result.stdout
|
||||
|
||||
if len(diff) > MAX_DIFF_CHARS:
|
||||
# Truncate and add note
|
||||
diff = (
|
||||
diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)"
|
||||
)
|
||||
|
||||
return diff if diff else "(Empty diff)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Git diff timed out")
|
||||
return "(Git diff timed out)"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get git diff: {e}")
|
||||
return f"(Failed to get diff: {e})"
|
||||
|
||||
|
||||
def get_changed_files(
|
||||
project_dir: Path,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of files changed between two commits.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
commit_before: Commit hash before session
|
||||
commit_after: Commit hash after session
|
||||
|
||||
Returns:
|
||||
List of changed file paths
|
||||
"""
|
||||
if not commit_before or not commit_after or commit_before == commit_after:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", commit_before, commit_after],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get changed files: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_commit_messages(
|
||||
project_dir: Path,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
) -> str:
|
||||
"""Get commit messages between two commits."""
|
||||
if not commit_before or not commit_after or commit_before == commit_after:
|
||||
return "(No commits)"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.stdout.strip() if result.stdout.strip() else "(No commits)"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get commit messages: {e}")
|
||||
return f"(Failed: {e})"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input Gathering
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def gather_extraction_inputs(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
success: bool,
|
||||
recovery_manager: Any,
|
||||
) -> dict:
|
||||
"""
|
||||
Gather all inputs needed for insight extraction.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root
|
||||
subtask_id: The subtask that was worked on
|
||||
session_num: Session number
|
||||
commit_before: Commit before session
|
||||
commit_after: Commit after session
|
||||
success: Whether session succeeded
|
||||
recovery_manager: Recovery manager with attempt history
|
||||
|
||||
Returns:
|
||||
Dict with all inputs for the extractor
|
||||
"""
|
||||
# Get subtask description from implementation plan
|
||||
subtask_description = _get_subtask_description(spec_dir, subtask_id)
|
||||
|
||||
# Get git diff
|
||||
diff = get_session_diff(project_dir, commit_before, commit_after)
|
||||
|
||||
# Get changed files
|
||||
changed_files = get_changed_files(project_dir, commit_before, commit_after)
|
||||
|
||||
# Get commit messages
|
||||
commit_messages = get_commit_messages(project_dir, commit_before, commit_after)
|
||||
|
||||
# Get attempt history
|
||||
attempt_history = _get_attempt_history(recovery_manager, subtask_id)
|
||||
|
||||
return {
|
||||
"subtask_id": subtask_id,
|
||||
"subtask_description": subtask_description,
|
||||
"session_num": session_num,
|
||||
"success": success,
|
||||
"diff": diff,
|
||||
"changed_files": changed_files,
|
||||
"commit_messages": commit_messages,
|
||||
"attempt_history": attempt_history,
|
||||
}
|
||||
|
||||
|
||||
def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
|
||||
"""Get subtask description from implementation plan."""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Search through phases for the subtask
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
return subtask.get("description", f"Subtask: {subtask_id}")
|
||||
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load subtask description: {e}")
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
|
||||
def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]:
|
||||
"""Get previous attempt history for this subtask."""
|
||||
if not recovery_manager:
|
||||
return []
|
||||
|
||||
try:
|
||||
history = recovery_manager.get_subtask_history(subtask_id)
|
||||
attempts = history.get("attempts", [])
|
||||
|
||||
# Limit to recent attempts
|
||||
return attempts[-MAX_ATTEMPTS_TO_INCLUDE:]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get attempt history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LLM Extraction
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_extraction_prompt(inputs: dict) -> str:
|
||||
"""Build the prompt for insight extraction."""
|
||||
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
|
||||
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
# Fallback if prompt file missing
|
||||
base_prompt = """Extract structured insights from this coding session.
|
||||
Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discovered, approach_outcome, recommendations"""
|
||||
|
||||
# Build session context
|
||||
session_context = f"""
|
||||
---
|
||||
|
||||
## SESSION DATA
|
||||
|
||||
### Subtask
|
||||
- **ID**: {inputs["subtask_id"]}
|
||||
- **Description**: {inputs["subtask_description"]}
|
||||
- **Session Number**: {inputs["session_num"]}
|
||||
- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"}
|
||||
|
||||
### Files Changed
|
||||
{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"}
|
||||
|
||||
### Commit Messages
|
||||
{inputs["commit_messages"]}
|
||||
|
||||
### Git Diff
|
||||
```diff
|
||||
{inputs["diff"]}
|
||||
```
|
||||
|
||||
### Previous Attempts
|
||||
{_format_attempt_history(inputs["attempt_history"])}
|
||||
|
||||
---
|
||||
|
||||
Now analyze this session and output ONLY the JSON object.
|
||||
"""
|
||||
|
||||
return base_prompt + session_context
|
||||
|
||||
|
||||
def _format_attempt_history(attempts: list[dict]) -> str:
|
||||
"""Format attempt history for the prompt."""
|
||||
if not attempts:
|
||||
return "(First attempt - no previous history)"
|
||||
|
||||
lines = []
|
||||
for i, attempt in enumerate(attempts, 1):
|
||||
success = "SUCCESS" if attempt.get("success") else "FAILED"
|
||||
approach = attempt.get("approach", "Unknown approach")
|
||||
error = attempt.get("error", "")
|
||||
lines.append(f"**Attempt {i}** ({success}): {approach}")
|
||||
if error:
|
||||
lines.append(f" Error: {error}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def run_insight_extraction(
|
||||
inputs: dict, project_dir: Path | None = None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Run the insight extraction using Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
inputs: Gathered session inputs
|
||||
project_dir: Project directory for SDK context (optional)
|
||||
|
||||
Returns:
|
||||
Extracted insights dict or None if failed
|
||||
"""
|
||||
if not SDK_AVAILABLE:
|
||||
logger.warning("Claude SDK not available, skipping insight extraction")
|
||||
return None
|
||||
|
||||
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)
|
||||
|
||||
# Use current directory if project_dir not specified
|
||||
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
|
||||
|
||||
try:
|
||||
# Use simple_client for insight extraction
|
||||
from pathlib import Path
|
||||
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="insights",
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
cwd=Path(cwd) if cwd else None,
|
||||
)
|
||||
|
||||
# Use async context manager
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
# Collect the response
|
||||
response_text = ""
|
||||
message_count = 0
|
||||
text_blocks_found = 0
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
text_blocks_found += 1
|
||||
if block.text: # Only add non-empty text
|
||||
response_text += block.text
|
||||
else:
|
||||
logger.debug(
|
||||
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
||||
)
|
||||
|
||||
# Log response collection summary
|
||||
logger.debug(
|
||||
f"Insight extraction response: {message_count} messages, "
|
||||
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
||||
)
|
||||
|
||||
# Validate we received content before parsing
|
||||
if not response_text.strip():
|
||||
logger.warning(
|
||||
f"Insight extraction returned empty response. "
|
||||
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
||||
f"This may indicate the AI model did not respond with text content."
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse JSON from response
|
||||
return parse_insights(response_text)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Insight extraction failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_insights(response_text: str) -> dict | None:
|
||||
"""
|
||||
Parse the LLM response into structured insights.
|
||||
|
||||
Args:
|
||||
response_text: Raw LLM response
|
||||
|
||||
Returns:
|
||||
Parsed insights dict or None if parsing failed
|
||||
"""
|
||||
# Try to extract JSON from the response
|
||||
text = response_text.strip()
|
||||
|
||||
# Early validation - check for empty response
|
||||
if not text:
|
||||
logger.warning("Cannot parse insights: response text is empty")
|
||||
return None
|
||||
|
||||
# Handle markdown code blocks
|
||||
if text.startswith("```"):
|
||||
# Remove code block markers
|
||||
lines = text.split("\n")
|
||||
# Remove first line (```json or ```)
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's ```
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
# Check again after removing code blocks
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Cannot parse insights: response contained only markdown code block markers with no content"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
insights = json.loads(text)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(insights, dict):
|
||||
logger.warning(
|
||||
f"Insights is not a dict, got type: {type(insights).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Ensure required keys exist with defaults
|
||||
insights.setdefault("file_insights", [])
|
||||
insights.setdefault("patterns_discovered", [])
|
||||
insights.setdefault("gotchas_discovered", [])
|
||||
insights.setdefault("approach_outcome", {})
|
||||
insights.setdefault("recommendations", [])
|
||||
|
||||
return insights
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse insights JSON: {e}")
|
||||
# Show more context in the error message
|
||||
preview_length = min(500, len(text))
|
||||
logger.warning(
|
||||
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
||||
)
|
||||
if len(text) > preview_length:
|
||||
logger.warning(f"... (total length: {len(text)} chars)")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry Point
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def extract_session_insights(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
success: bool,
|
||||
recovery_manager: Any,
|
||||
) -> dict:
|
||||
"""
|
||||
Extract insights from a completed coding session.
|
||||
|
||||
This is the main entry point called from post_session_processing().
|
||||
Falls back to generic insights if extraction fails.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root
|
||||
subtask_id: Subtask that was worked on
|
||||
session_num: Session number
|
||||
commit_before: Commit before session
|
||||
commit_after: Commit after session
|
||||
success: Whether session succeeded
|
||||
recovery_manager: Recovery manager with attempt history
|
||||
|
||||
Returns:
|
||||
Insights dict (rich if extraction succeeded, generic if failed)
|
||||
"""
|
||||
# Check if extraction is enabled
|
||||
if not is_extraction_enabled():
|
||||
logger.info("Insight extraction disabled")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
# Check for no changes
|
||||
if commit_before == commit_after:
|
||||
logger.info("No changes to extract insights from")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
try:
|
||||
# Gather inputs
|
||||
inputs = gather_extraction_inputs(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=success,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
|
||||
# Run extraction
|
||||
extracted = await run_insight_extraction(inputs, project_dir=project_dir)
|
||||
|
||||
if extracted:
|
||||
# Add metadata
|
||||
extracted["subtask_id"] = subtask_id
|
||||
extracted["session_num"] = session_num
|
||||
extracted["success"] = success
|
||||
extracted["changed_files"] = inputs["changed_files"]
|
||||
|
||||
logger.info(
|
||||
f"Extracted insights: {len(extracted.get('file_insights', []))} file insights, "
|
||||
f"{len(extracted.get('patterns_discovered', []))} patterns, "
|
||||
f"{len(extracted.get('gotchas_discovered', []))} gotchas"
|
||||
)
|
||||
return extracted
|
||||
else:
|
||||
logger.warning("Extraction returned no results, using generic insights")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Insight extraction failed: {e}, using generic insights")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
|
||||
def _get_generic_insights(subtask_id: str, success: bool) -> dict:
|
||||
"""Return generic insights when extraction fails or is disabled."""
|
||||
return {
|
||||
"file_insights": [],
|
||||
"patterns_discovered": [],
|
||||
"gotchas_discovered": [],
|
||||
"approach_outcome": {
|
||||
"success": success,
|
||||
"approach_used": f"Implemented subtask: {subtask_id}",
|
||||
"why_it_worked": None,
|
||||
"why_it_failed": None,
|
||||
"alternatives_tried": [],
|
||||
},
|
||||
"recommendations": [],
|
||||
"subtask_id": subtask_id,
|
||||
"success": success,
|
||||
"changed_files": [],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI for Testing
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
parser = argparse.ArgumentParser(description="Test insight extraction")
|
||||
parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory")
|
||||
parser.add_argument(
|
||||
"--project-dir", type=Path, required=True, help="Project directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit-before", type=str, required=True, help="Commit before session"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit-after", type=str, required=True, help="Commit after session"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--subtask-id", type=str, default="test-subtask", help="Subtask ID"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
async def main():
|
||||
insights = await extract_session_insights(
|
||||
spec_dir=args.spec_dir,
|
||||
project_dir=args.project_dir,
|
||||
subtask_id=args.subtask_id,
|
||||
session_num=1,
|
||||
commit_before=args.commit_before,
|
||||
commit_after=args.commit_after,
|
||||
success=True,
|
||||
recovery_manager=None,
|
||||
)
|
||||
print(json.dumps(insights, indent=2))
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
Smart Project Analyzer for Dynamic Security Profiles
|
||||
=====================================================
|
||||
|
||||
FACADE MODULE: This module re-exports all functionality from the
|
||||
auto-claude/project/ package for backward compatibility.
|
||||
|
||||
The implementation has been refactored into focused modules:
|
||||
- project/command_registry.py - Command registries
|
||||
- project/models.py - Data structures
|
||||
- project/config_parser.py - Config file parsing
|
||||
- project/stack_detector.py - Stack detection
|
||||
- project/framework_detector.py - Framework detection
|
||||
- project/structure_analyzer.py - Project structure analysis
|
||||
- project/analyzer.py - Main orchestration
|
||||
|
||||
This file maintains the original API so existing imports continue to work.
|
||||
|
||||
This system:
|
||||
1. Detects languages, frameworks, databases, and infrastructure
|
||||
2. Parses package.json scripts, Makefile targets, pyproject.toml scripts
|
||||
3. Builds a tailored security profile for the specific project
|
||||
4. Caches the profile for subsequent runs
|
||||
5. Can re-analyze when project structure changes
|
||||
|
||||
The goal: Allow an AI developer to run any command that's legitimately
|
||||
needed for the detected tech stack, while blocking dangerous operations.
|
||||
"""
|
||||
|
||||
# Re-export all public API from the project module
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from project import (
|
||||
# Command registries
|
||||
BASE_COMMANDS,
|
||||
VALIDATED_COMMANDS,
|
||||
CustomScripts,
|
||||
# Main classes
|
||||
ProjectAnalyzer,
|
||||
SecurityProfile,
|
||||
TechnologyStack,
|
||||
# Utility functions
|
||||
get_or_create_profile,
|
||||
is_command_allowed,
|
||||
needs_validation,
|
||||
)
|
||||
|
||||
# Also re-export command registries for backward compatibility
|
||||
from project.command_registry import (
|
||||
CLOUD_COMMANDS,
|
||||
CODE_QUALITY_COMMANDS,
|
||||
DATABASE_COMMANDS,
|
||||
FRAMEWORK_COMMANDS,
|
||||
INFRASTRUCTURE_COMMANDS,
|
||||
LANGUAGE_COMMANDS,
|
||||
PACKAGE_MANAGER_COMMANDS,
|
||||
VERSION_MANAGER_COMMANDS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main classes
|
||||
"ProjectAnalyzer",
|
||||
"SecurityProfile",
|
||||
"TechnologyStack",
|
||||
"CustomScripts",
|
||||
# Utility functions
|
||||
"get_or_create_profile",
|
||||
"is_command_allowed",
|
||||
"needs_validation",
|
||||
# Base command sets
|
||||
"BASE_COMMANDS",
|
||||
"VALIDATED_COMMANDS",
|
||||
# Technology-specific command sets
|
||||
"LANGUAGE_COMMANDS",
|
||||
"PACKAGE_MANAGER_COMMANDS",
|
||||
"FRAMEWORK_COMMANDS",
|
||||
"DATABASE_COMMANDS",
|
||||
"INFRASTRUCTURE_COMMANDS",
|
||||
"CLOUD_COMMANDS",
|
||||
"CODE_QUALITY_COMMANDS",
|
||||
"VERSION_MANAGER_COMMANDS",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI for testing
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python project_analyzer.py <project_dir> [--force]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = Path(sys.argv[1])
|
||||
force = "--force" in sys.argv
|
||||
|
||||
if not project_dir.exists():
|
||||
print(f"Error: {project_dir} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
profile = get_or_create_profile(project_dir, force_reanalyze=force)
|
||||
|
||||
print("\nAllowed commands:")
|
||||
for cmd in sorted(profile.get_all_allowed_commands()):
|
||||
print(f" {cmd}")
|
||||
@@ -1,591 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Risk Classifier Module
|
||||
======================
|
||||
|
||||
Reads the AI-generated complexity_assessment.json and provides programmatic
|
||||
access to risk classification and validation recommendations.
|
||||
|
||||
This module serves as the bridge between the AI complexity assessor prompt
|
||||
and the rest of the validation system.
|
||||
|
||||
Usage:
|
||||
from risk_classifier import RiskClassifier
|
||||
|
||||
classifier = RiskClassifier()
|
||||
assessment = classifier.load_assessment(spec_dir)
|
||||
|
||||
if classifier.should_skip_validation(spec_dir):
|
||||
print("Validation can be skipped for this task")
|
||||
|
||||
test_types = classifier.get_required_test_types(spec_dir)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopeAnalysis:
|
||||
"""Analysis of task scope."""
|
||||
|
||||
estimated_files: int = 0
|
||||
estimated_services: int = 0
|
||||
is_cross_cutting: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegrationAnalysis:
|
||||
"""Analysis of external integrations."""
|
||||
|
||||
external_services: list[str] = field(default_factory=list)
|
||||
new_dependencies: list[str] = field(default_factory=list)
|
||||
research_needed: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InfrastructureAnalysis:
|
||||
"""Analysis of infrastructure requirements."""
|
||||
|
||||
docker_changes: bool = False
|
||||
database_changes: bool = False
|
||||
config_changes: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeAnalysis:
|
||||
"""Analysis of knowledge requirements."""
|
||||
|
||||
patterns_exist: bool = True
|
||||
research_required: bool = False
|
||||
unfamiliar_tech: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskAnalysis:
|
||||
"""Analysis of task risk."""
|
||||
|
||||
level: str = "low" # low, medium, high
|
||||
concerns: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComplexityAnalysis:
|
||||
"""Full complexity analysis from the AI assessor."""
|
||||
|
||||
scope: ScopeAnalysis = field(default_factory=ScopeAnalysis)
|
||||
integrations: IntegrationAnalysis = field(default_factory=IntegrationAnalysis)
|
||||
infrastructure: InfrastructureAnalysis = field(
|
||||
default_factory=InfrastructureAnalysis
|
||||
)
|
||||
knowledge: KnowledgeAnalysis = field(default_factory=KnowledgeAnalysis)
|
||||
risk: RiskAnalysis = field(default_factory=RiskAnalysis)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationRecommendations:
|
||||
"""Validation recommendations from the AI assessor."""
|
||||
|
||||
risk_level: str = "medium" # trivial, low, medium, high, critical
|
||||
skip_validation: bool = False
|
||||
minimal_mode: bool = False
|
||||
test_types_required: list[str] = field(default_factory=lambda: ["unit"])
|
||||
security_scan_required: bool = False
|
||||
staging_deployment_required: bool = False
|
||||
reasoning: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssessmentFlags:
|
||||
"""Flags indicating special requirements."""
|
||||
|
||||
needs_research: bool = False
|
||||
needs_self_critique: bool = False
|
||||
needs_infrastructure_setup: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskAssessment:
|
||||
"""Complete risk assessment from complexity_assessment.json."""
|
||||
|
||||
complexity: str # simple, standard, complex
|
||||
workflow_type: str # feature, refactor, investigation, migration, simple
|
||||
confidence: float
|
||||
reasoning: str
|
||||
analysis: ComplexityAnalysis
|
||||
recommended_phases: list[str]
|
||||
flags: AssessmentFlags
|
||||
validation: ValidationRecommendations
|
||||
created_at: str | None = None
|
||||
|
||||
@property
|
||||
def risk_level(self) -> str:
|
||||
"""Get the risk level from validation recommendations."""
|
||||
return self.validation.risk_level
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RISK CLASSIFIER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RiskClassifier:
|
||||
"""
|
||||
Reads AI-generated complexity_assessment.json and provides risk classification.
|
||||
|
||||
The complexity_assessment.json is generated by the AI complexity assessor
|
||||
agent using the complexity_assessor.md prompt. This module parses that output
|
||||
and provides programmatic access to the risk classification.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the risk classifier."""
|
||||
self._cache: dict[str, RiskAssessment] = {}
|
||||
|
||||
def load_assessment(self, spec_dir: Path) -> RiskAssessment | None:
|
||||
"""
|
||||
Load complexity_assessment.json from spec directory.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory containing complexity_assessment.json
|
||||
|
||||
Returns:
|
||||
RiskAssessment object if file exists and is valid, None otherwise
|
||||
"""
|
||||
spec_dir = Path(spec_dir)
|
||||
cache_key = str(spec_dir.resolve())
|
||||
|
||||
# Return cached result if available
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
assessment_file = spec_dir / "complexity_assessment.json"
|
||||
if not assessment_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(assessment_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assessment = self._parse_assessment(data)
|
||||
self._cache[cache_key] = assessment
|
||||
return assessment
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
# Log error but don't crash - return None to allow fallback behavior
|
||||
print(f"Warning: Failed to parse complexity_assessment.json: {e}")
|
||||
return None
|
||||
|
||||
def _parse_assessment(self, data: dict[str, Any]) -> RiskAssessment:
|
||||
"""Parse raw JSON data into a RiskAssessment object."""
|
||||
# Parse analysis sections
|
||||
analysis_data = data.get("analysis", {})
|
||||
analysis = ComplexityAnalysis(
|
||||
scope=self._parse_scope(analysis_data.get("scope", {})),
|
||||
integrations=self._parse_integrations(
|
||||
analysis_data.get("integrations", {})
|
||||
),
|
||||
infrastructure=self._parse_infrastructure(
|
||||
analysis_data.get("infrastructure", {})
|
||||
),
|
||||
knowledge=self._parse_knowledge(analysis_data.get("knowledge", {})),
|
||||
risk=self._parse_risk(analysis_data.get("risk", {})),
|
||||
)
|
||||
|
||||
# Parse flags
|
||||
flags_data = data.get("flags", {})
|
||||
flags = AssessmentFlags(
|
||||
needs_research=flags_data.get("needs_research", False),
|
||||
needs_self_critique=flags_data.get("needs_self_critique", False),
|
||||
needs_infrastructure_setup=flags_data.get(
|
||||
"needs_infrastructure_setup", False
|
||||
),
|
||||
)
|
||||
|
||||
# Parse validation recommendations
|
||||
validation_data = data.get("validation_recommendations", {})
|
||||
validation = self._parse_validation_recommendations(validation_data, analysis)
|
||||
|
||||
return RiskAssessment(
|
||||
complexity=data.get("complexity", "standard"),
|
||||
workflow_type=data.get("workflow_type", "feature"),
|
||||
confidence=float(data.get("confidence", 0.5)),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
analysis=analysis,
|
||||
recommended_phases=data.get("recommended_phases", []),
|
||||
flags=flags,
|
||||
validation=validation,
|
||||
created_at=data.get("created_at"),
|
||||
)
|
||||
|
||||
def _parse_scope(self, data: dict[str, Any]) -> ScopeAnalysis:
|
||||
"""Parse scope analysis section."""
|
||||
return ScopeAnalysis(
|
||||
estimated_files=int(data.get("estimated_files", 0)),
|
||||
estimated_services=int(data.get("estimated_services", 0)),
|
||||
is_cross_cutting=bool(data.get("is_cross_cutting", False)),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_integrations(self, data: dict[str, Any]) -> IntegrationAnalysis:
|
||||
"""Parse integrations analysis section."""
|
||||
return IntegrationAnalysis(
|
||||
external_services=list(data.get("external_services", [])),
|
||||
new_dependencies=list(data.get("new_dependencies", [])),
|
||||
research_needed=bool(data.get("research_needed", False)),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_infrastructure(self, data: dict[str, Any]) -> InfrastructureAnalysis:
|
||||
"""Parse infrastructure analysis section."""
|
||||
return InfrastructureAnalysis(
|
||||
docker_changes=bool(data.get("docker_changes", False)),
|
||||
database_changes=bool(data.get("database_changes", False)),
|
||||
config_changes=bool(data.get("config_changes", False)),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_knowledge(self, data: dict[str, Any]) -> KnowledgeAnalysis:
|
||||
"""Parse knowledge analysis section."""
|
||||
return KnowledgeAnalysis(
|
||||
patterns_exist=bool(data.get("patterns_exist", True)),
|
||||
research_required=bool(data.get("research_required", False)),
|
||||
unfamiliar_tech=list(data.get("unfamiliar_tech", [])),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_risk(self, data: dict[str, Any]) -> RiskAnalysis:
|
||||
"""Parse risk analysis section."""
|
||||
return RiskAnalysis(
|
||||
level=str(data.get("level", "low")),
|
||||
concerns=list(data.get("concerns", [])),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_validation_recommendations(
|
||||
self, data: dict[str, Any], analysis: ComplexityAnalysis
|
||||
) -> ValidationRecommendations:
|
||||
"""
|
||||
Parse validation recommendations section.
|
||||
|
||||
If validation_recommendations is not present in the JSON (older assessments),
|
||||
infer appropriate values from the analysis.
|
||||
"""
|
||||
if data:
|
||||
# New format with explicit validation recommendations
|
||||
return ValidationRecommendations(
|
||||
risk_level=str(data.get("risk_level", "medium")),
|
||||
skip_validation=bool(data.get("skip_validation", False)),
|
||||
minimal_mode=bool(data.get("minimal_mode", False)),
|
||||
test_types_required=list(data.get("test_types_required", ["unit"])),
|
||||
security_scan_required=bool(data.get("security_scan_required", False)),
|
||||
staging_deployment_required=bool(
|
||||
data.get("staging_deployment_required", False)
|
||||
),
|
||||
reasoning=str(data.get("reasoning", "")),
|
||||
)
|
||||
else:
|
||||
# Infer from analysis (backward compatibility)
|
||||
return self._infer_validation_recommendations(analysis)
|
||||
|
||||
def _infer_validation_recommendations(
|
||||
self, analysis: ComplexityAnalysis
|
||||
) -> ValidationRecommendations:
|
||||
"""
|
||||
Infer validation recommendations from analysis when not explicitly provided.
|
||||
|
||||
This provides backward compatibility with older complexity assessments
|
||||
that don't have the validation_recommendations section.
|
||||
"""
|
||||
risk_level = analysis.risk.level
|
||||
|
||||
# Map old risk levels to new ones
|
||||
risk_mapping = {
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"high": "high",
|
||||
}
|
||||
normalized_risk = risk_mapping.get(risk_level, "medium")
|
||||
|
||||
# Infer test types based on risk
|
||||
test_types_map = {
|
||||
"low": ["unit"],
|
||||
"medium": ["unit", "integration"],
|
||||
"high": ["unit", "integration", "e2e"],
|
||||
}
|
||||
test_types = test_types_map.get(normalized_risk, ["unit", "integration"])
|
||||
|
||||
# Security scan for high risk or security-related concerns
|
||||
security_keywords = [
|
||||
"security",
|
||||
"auth",
|
||||
"password",
|
||||
"credential",
|
||||
"token",
|
||||
"api key",
|
||||
]
|
||||
has_security_concerns = any(
|
||||
kw in str(analysis.risk.concerns).lower() for kw in security_keywords
|
||||
)
|
||||
security_scan_required = normalized_risk == "high" or has_security_concerns
|
||||
|
||||
# Staging for database or infrastructure changes
|
||||
staging_required = (
|
||||
analysis.infrastructure.database_changes
|
||||
and normalized_risk in ["medium", "high"]
|
||||
)
|
||||
|
||||
# Minimal mode for simple changes
|
||||
minimal_mode = (
|
||||
analysis.scope.estimated_files <= 2
|
||||
and analysis.scope.estimated_services <= 1
|
||||
and not analysis.integrations.external_services
|
||||
)
|
||||
|
||||
return ValidationRecommendations(
|
||||
risk_level=normalized_risk,
|
||||
skip_validation=False, # Never skip by inference
|
||||
minimal_mode=minimal_mode,
|
||||
test_types_required=test_types,
|
||||
security_scan_required=security_scan_required,
|
||||
staging_deployment_required=staging_required,
|
||||
reasoning="Inferred from complexity analysis (no explicit recommendations found)",
|
||||
)
|
||||
|
||||
def should_skip_validation(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Quick check if validation can be skipped entirely.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if validation can be skipped (trivial changes), False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False # When in doubt, don't skip
|
||||
|
||||
return assessment.validation.skip_validation
|
||||
|
||||
def should_use_minimal_mode(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if minimal validation mode should be used.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if minimal mode is recommended, False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False
|
||||
|
||||
return assessment.validation.minimal_mode
|
||||
|
||||
def get_required_test_types(self, spec_dir: Path) -> list[str]:
|
||||
"""
|
||||
Get list of required test types based on risk.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
List of test types (e.g., ["unit", "integration", "e2e"])
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return ["unit"] # Default to unit tests
|
||||
|
||||
return assessment.validation.test_types_required
|
||||
|
||||
def requires_security_scan(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if security scanning is required.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if security scan is required, False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False
|
||||
|
||||
return assessment.validation.security_scan_required
|
||||
|
||||
def requires_staging_deployment(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if staging deployment is required.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if staging deployment is required, False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False
|
||||
|
||||
return assessment.validation.staging_deployment_required
|
||||
|
||||
def get_risk_level(self, spec_dir: Path) -> str:
|
||||
"""
|
||||
Get the risk level for the task.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Risk level string (trivial, low, medium, high, critical)
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return "medium" # Default to medium when unknown
|
||||
|
||||
return assessment.validation.risk_level
|
||||
|
||||
def get_complexity(self, spec_dir: Path) -> str:
|
||||
"""
|
||||
Get the complexity level for the task.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Complexity level string (simple, standard, complex)
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return "standard" # Default to standard when unknown
|
||||
|
||||
return assessment.complexity
|
||||
|
||||
def get_validation_summary(self, spec_dir: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of validation requirements.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Dictionary with validation summary
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return {
|
||||
"risk_level": "unknown",
|
||||
"complexity": "unknown",
|
||||
"skip_validation": False,
|
||||
"minimal_mode": False,
|
||||
"test_types": ["unit"],
|
||||
"security_scan": False,
|
||||
"staging_deployment": False,
|
||||
"confidence": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"risk_level": assessment.validation.risk_level,
|
||||
"complexity": assessment.complexity,
|
||||
"skip_validation": assessment.validation.skip_validation,
|
||||
"minimal_mode": assessment.validation.minimal_mode,
|
||||
"test_types": assessment.validation.test_types_required,
|
||||
"security_scan": assessment.validation.security_scan_required,
|
||||
"staging_deployment": assessment.validation.staging_deployment_required,
|
||||
"confidence": assessment.confidence,
|
||||
"reasoning": assessment.validation.reasoning,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache of loaded assessments."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_risk_assessment(spec_dir: Path) -> RiskAssessment | None:
|
||||
"""
|
||||
Convenience function to load a risk assessment.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
RiskAssessment object or None
|
||||
"""
|
||||
classifier = RiskClassifier()
|
||||
return classifier.load_assessment(spec_dir)
|
||||
|
||||
|
||||
def get_validation_requirements(spec_dir: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Convenience function to get validation requirements.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Dictionary with validation requirements
|
||||
"""
|
||||
classifier = RiskClassifier()
|
||||
return classifier.get_validation_summary(spec_dir)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Load and display risk assessment")
|
||||
parser.add_argument(
|
||||
"spec_dir",
|
||||
type=Path,
|
||||
help="Path to spec directory with complexity_assessment.json",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
classifier = RiskClassifier()
|
||||
summary = classifier.get_validation_summary(args.spec_dir)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2))
|
||||
else:
|
||||
print(f"Risk Level: {summary['risk_level']}")
|
||||
print(f"Complexity: {summary['complexity']}")
|
||||
print(f"Skip Validation: {summary['skip_validation']}")
|
||||
print(f"Minimal Mode: {summary['minimal_mode']}")
|
||||
print(f"Test Types: {', '.join(summary['test_types'])}")
|
||||
print(f"Security Scan: {summary['security_scan']}")
|
||||
print(f"Staging Deployment: {summary['staging_deployment']}")
|
||||
print(f"Confidence: {summary['confidence']:.2f}")
|
||||
if summary.get("reasoning"):
|
||||
print(f"Reasoning: {summary['reasoning']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,599 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Security Scanner Module
|
||||
=======================
|
||||
|
||||
Consolidates security scanning including secrets detection and SAST tools.
|
||||
This module integrates the existing scan_secrets.py and provides a unified
|
||||
interface for all security scanning.
|
||||
|
||||
The security scanner is used by:
|
||||
- QA Agent: To verify no secrets are committed
|
||||
- Validation Strategy: To run security scans for high-risk changes
|
||||
|
||||
Usage:
|
||||
from analysis.security_scanner import SecurityScanner
|
||||
|
||||
scanner = SecurityScanner()
|
||||
results = scanner.scan(project_dir, spec_dir)
|
||||
|
||||
if results.has_critical_issues:
|
||||
print("Security issues found - blocking QA approval")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Import the existing secrets scanner
|
||||
try:
|
||||
from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files
|
||||
|
||||
HAS_SECRETS_SCANNER = True
|
||||
except ImportError:
|
||||
HAS_SECRETS_SCANNER = False
|
||||
SecretMatch = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityVulnerability:
|
||||
"""
|
||||
Represents a security vulnerability found during scanning.
|
||||
|
||||
Attributes:
|
||||
severity: Severity level (critical, high, medium, low, info)
|
||||
source: Which scanner found this (secrets, bandit, npm_audit, etc.)
|
||||
title: Short title of the vulnerability
|
||||
description: Detailed description
|
||||
file: File where vulnerability was found (if applicable)
|
||||
line: Line number (if applicable)
|
||||
cwe: CWE identifier if available
|
||||
"""
|
||||
|
||||
severity: str # critical, high, medium, low, info
|
||||
source: str # secrets, bandit, npm_audit, semgrep, etc.
|
||||
title: str
|
||||
description: str
|
||||
file: str | None = None
|
||||
line: int | None = None
|
||||
cwe: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityScanResult:
|
||||
"""
|
||||
Result of a security scan.
|
||||
|
||||
Attributes:
|
||||
secrets: List of detected secrets
|
||||
vulnerabilities: List of security vulnerabilities
|
||||
scan_errors: List of errors during scanning
|
||||
has_critical_issues: Whether any critical issues were found
|
||||
should_block_qa: Whether these results should block QA approval
|
||||
"""
|
||||
|
||||
secrets: list[dict[str, Any]] = field(default_factory=list)
|
||||
vulnerabilities: list[SecurityVulnerability] = field(default_factory=list)
|
||||
scan_errors: list[str] = field(default_factory=list)
|
||||
has_critical_issues: bool = False
|
||||
should_block_qa: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY SCANNER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SecurityScanner:
|
||||
"""
|
||||
Consolidates all security scanning operations.
|
||||
|
||||
Integrates:
|
||||
- scan_secrets.py for secrets detection
|
||||
- Bandit for Python SAST (if available)
|
||||
- npm audit for JavaScript vulnerabilities (if applicable)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the security scanner."""
|
||||
self._bandit_available: bool | None = None
|
||||
self._npm_available: bool | None = None
|
||||
|
||||
def scan(
|
||||
self,
|
||||
project_dir: Path,
|
||||
spec_dir: Path | None = None,
|
||||
changed_files: list[str] | None = None,
|
||||
run_secrets: bool = True,
|
||||
run_sast: bool = True,
|
||||
run_dependency_audit: bool = True,
|
||||
) -> SecurityScanResult:
|
||||
"""
|
||||
Run all applicable security scans.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
spec_dir: Path to the spec directory (for storing results)
|
||||
changed_files: Optional list of files to scan (if None, scans all)
|
||||
run_secrets: Whether to run secrets scanning
|
||||
run_sast: Whether to run SAST tools
|
||||
run_dependency_audit: Whether to run dependency audits
|
||||
|
||||
Returns:
|
||||
SecurityScanResult with all findings
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
result = SecurityScanResult()
|
||||
|
||||
# Run secrets scan
|
||||
if run_secrets:
|
||||
self._run_secrets_scan(project_dir, changed_files, result)
|
||||
|
||||
# Run SAST based on project type
|
||||
if run_sast:
|
||||
self._run_sast_scans(project_dir, result)
|
||||
|
||||
# Run dependency audits
|
||||
if run_dependency_audit:
|
||||
self._run_dependency_audits(project_dir, result)
|
||||
|
||||
# Determine if should block QA
|
||||
result.has_critical_issues = (
|
||||
any(v.severity in ["critical", "high"] for v in result.vulnerabilities)
|
||||
or len(result.secrets) > 0
|
||||
)
|
||||
|
||||
# Any secrets always block, critical vulnerabilities block
|
||||
result.should_block_qa = len(result.secrets) > 0 or any(
|
||||
v.severity == "critical" for v in result.vulnerabilities
|
||||
)
|
||||
|
||||
# Save results if spec_dir provided
|
||||
if spec_dir:
|
||||
self._save_results(spec_dir, result)
|
||||
|
||||
return result
|
||||
|
||||
def _run_secrets_scan(
|
||||
self,
|
||||
project_dir: Path,
|
||||
changed_files: list[str] | None,
|
||||
result: SecurityScanResult,
|
||||
) -> None:
|
||||
"""Run secrets scanning using scan_secrets.py."""
|
||||
if not HAS_SECRETS_SCANNER:
|
||||
result.scan_errors.append("scan_secrets module not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Get files to scan
|
||||
if changed_files:
|
||||
files_to_scan = changed_files
|
||||
else:
|
||||
files_to_scan = get_all_tracked_files()
|
||||
|
||||
# Run scan
|
||||
matches = scan_files(files_to_scan, project_dir)
|
||||
|
||||
# Convert matches to result format
|
||||
for match in matches:
|
||||
result.secrets.append(
|
||||
{
|
||||
"file": match.file_path,
|
||||
"line": match.line_number,
|
||||
"pattern": match.pattern_name,
|
||||
"matched_text": self._redact_secret(match.matched_text),
|
||||
}
|
||||
)
|
||||
|
||||
# Also add as vulnerability
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity="critical",
|
||||
source="secrets",
|
||||
title=f"Potential secret: {match.pattern_name}",
|
||||
description=f"Found potential {match.pattern_name} in file",
|
||||
file=match.file_path,
|
||||
line=match.line_number,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
result.scan_errors.append(f"Secrets scan error: {str(e)}")
|
||||
|
||||
def _run_sast_scans(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run SAST tools based on project type."""
|
||||
# Python SAST with Bandit
|
||||
if self._is_python_project(project_dir):
|
||||
self._run_bandit(project_dir, result)
|
||||
|
||||
# JavaScript/Node.js - npm audit
|
||||
# (handled in dependency audits for Node projects)
|
||||
|
||||
def _run_bandit(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run Bandit security scanner for Python projects."""
|
||||
if not self._check_bandit_available():
|
||||
return
|
||||
|
||||
try:
|
||||
# Find Python source directories
|
||||
src_dirs = []
|
||||
for candidate in ["src", "app", project_dir.name, "."]:
|
||||
candidate_path = project_dir / candidate
|
||||
if (
|
||||
candidate_path.exists()
|
||||
and (candidate_path / "__init__.py").exists()
|
||||
):
|
||||
src_dirs.append(str(candidate_path))
|
||||
|
||||
if not src_dirs:
|
||||
# Try to find any Python files
|
||||
py_files = list(project_dir.glob("**/*.py"))
|
||||
if not py_files:
|
||||
return
|
||||
src_dirs = ["."]
|
||||
|
||||
# Run bandit
|
||||
cmd = [
|
||||
"bandit",
|
||||
"-r",
|
||||
*src_dirs,
|
||||
"-f",
|
||||
"json",
|
||||
"--exit-zero", # Don't fail on findings
|
||||
]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if proc.stdout:
|
||||
try:
|
||||
bandit_output = json.loads(proc.stdout)
|
||||
for finding in bandit_output.get("results", []):
|
||||
severity = finding.get("issue_severity", "MEDIUM").lower()
|
||||
if severity == "high":
|
||||
severity = "high"
|
||||
elif severity == "medium":
|
||||
severity = "medium"
|
||||
else:
|
||||
severity = "low"
|
||||
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity=severity,
|
||||
source="bandit",
|
||||
title=finding.get("issue_text", "Unknown issue"),
|
||||
description=finding.get("issue_text", ""),
|
||||
file=finding.get("filename"),
|
||||
line=finding.get("line_number"),
|
||||
cwe=finding.get("issue_cwe", {}).get("id"),
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
result.scan_errors.append("Failed to parse Bandit output")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
result.scan_errors.append("Bandit scan timed out")
|
||||
except FileNotFoundError:
|
||||
result.scan_errors.append("Bandit not found")
|
||||
except Exception as e:
|
||||
result.scan_errors.append(f"Bandit error: {str(e)}")
|
||||
|
||||
def _run_dependency_audits(
|
||||
self, project_dir: Path, result: SecurityScanResult
|
||||
) -> None:
|
||||
"""Run dependency vulnerability audits."""
|
||||
# npm audit for JavaScript projects
|
||||
if (project_dir / "package.json").exists():
|
||||
self._run_npm_audit(project_dir, result)
|
||||
|
||||
# pip-audit for Python projects (if available)
|
||||
if self._is_python_project(project_dir):
|
||||
self._run_pip_audit(project_dir, result)
|
||||
|
||||
def _run_npm_audit(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run npm audit for JavaScript projects."""
|
||||
try:
|
||||
cmd = ["npm", "audit", "--json"]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if proc.stdout:
|
||||
try:
|
||||
audit_output = json.loads(proc.stdout)
|
||||
|
||||
# npm audit v2+ format
|
||||
vulnerabilities = audit_output.get("vulnerabilities", {})
|
||||
for pkg_name, vuln_info in vulnerabilities.items():
|
||||
severity = vuln_info.get("severity", "moderate")
|
||||
if severity == "critical":
|
||||
severity = "critical"
|
||||
elif severity == "high":
|
||||
severity = "high"
|
||||
elif severity == "moderate":
|
||||
severity = "medium"
|
||||
else:
|
||||
severity = "low"
|
||||
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity=severity,
|
||||
source="npm_audit",
|
||||
title=f"Vulnerable dependency: {pkg_name}",
|
||||
description=vuln_info.get("via", [{}])[0].get(
|
||||
"title", ""
|
||||
)
|
||||
if isinstance(vuln_info.get("via"), list)
|
||||
and vuln_info.get("via")
|
||||
else str(vuln_info.get("via", "")),
|
||||
file="package.json",
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass # npm audit may return invalid JSON on no findings
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
result.scan_errors.append("npm audit timed out")
|
||||
except FileNotFoundError:
|
||||
pass # npm not available
|
||||
except Exception as e:
|
||||
result.scan_errors.append(f"npm audit error: {str(e)}")
|
||||
|
||||
def _run_pip_audit(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run pip-audit for Python projects (if available)."""
|
||||
try:
|
||||
cmd = ["pip-audit", "--format", "json"]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if proc.stdout:
|
||||
try:
|
||||
audit_output = json.loads(proc.stdout)
|
||||
for vuln in audit_output:
|
||||
severity = "high" if vuln.get("fix_versions") else "medium"
|
||||
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity=severity,
|
||||
source="pip_audit",
|
||||
title=f"Vulnerable package: {vuln.get('name')}",
|
||||
description=vuln.get("description", ""),
|
||||
cwe=vuln.get("aliases", [""])[0]
|
||||
if vuln.get("aliases")
|
||||
else None,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except FileNotFoundError:
|
||||
pass # pip-audit not available
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_python_project(self, project_dir: Path) -> bool:
|
||||
"""Check if this is a Python project."""
|
||||
indicators = [
|
||||
project_dir / "pyproject.toml",
|
||||
project_dir / "requirements.txt",
|
||||
project_dir / "setup.py",
|
||||
project_dir / "setup.cfg",
|
||||
]
|
||||
return any(p.exists() for p in indicators)
|
||||
|
||||
def _check_bandit_available(self) -> bool:
|
||||
"""Check if Bandit is available."""
|
||||
if self._bandit_available is None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["bandit", "--version"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
self._bandit_available = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
self._bandit_available = False
|
||||
return self._bandit_available
|
||||
|
||||
def _redact_secret(self, text: str) -> str:
|
||||
"""Redact a secret for safe logging."""
|
||||
if len(text) <= 8:
|
||||
return "*" * len(text)
|
||||
return text[:4] + "*" * (len(text) - 8) + text[-4:]
|
||||
|
||||
def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Save scan results to spec directory."""
|
||||
spec_dir = Path(spec_dir)
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_file = spec_dir / "security_scan_results.json"
|
||||
output_data = self.to_dict(result)
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(output_data, f, indent=2)
|
||||
|
||||
def to_dict(self, result: SecurityScanResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"secrets": result.secrets,
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"severity": v.severity,
|
||||
"source": v.source,
|
||||
"title": v.title,
|
||||
"description": v.description,
|
||||
"file": v.file,
|
||||
"line": v.line,
|
||||
"cwe": v.cwe,
|
||||
}
|
||||
for v in result.vulnerabilities
|
||||
],
|
||||
"scan_errors": result.scan_errors,
|
||||
"has_critical_issues": result.has_critical_issues,
|
||||
"should_block_qa": result.should_block_qa,
|
||||
"summary": {
|
||||
"total_secrets": len(result.secrets),
|
||||
"total_vulnerabilities": len(result.vulnerabilities),
|
||||
"critical_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "critical"
|
||||
),
|
||||
"high_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "high"
|
||||
),
|
||||
"medium_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "medium"
|
||||
),
|
||||
"low_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "low"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def scan_for_security_issues(
|
||||
project_dir: Path,
|
||||
spec_dir: Path | None = None,
|
||||
changed_files: list[str] | None = None,
|
||||
) -> SecurityScanResult:
|
||||
"""
|
||||
Convenience function to run security scan.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
spec_dir: Optional spec directory to save results
|
||||
changed_files: Optional list of files to scan
|
||||
|
||||
Returns:
|
||||
SecurityScanResult with all findings
|
||||
"""
|
||||
scanner = SecurityScanner()
|
||||
return scanner.scan(project_dir, spec_dir, changed_files)
|
||||
|
||||
|
||||
def has_security_issues(project_dir: Path) -> bool:
|
||||
"""
|
||||
Quick check if project has security issues.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
True if any critical/high issues found
|
||||
"""
|
||||
scanner = SecurityScanner()
|
||||
result = scanner.scan(project_dir, run_sast=False, run_dependency_audit=False)
|
||||
return result.has_critical_issues
|
||||
|
||||
|
||||
def scan_secrets_only(
|
||||
project_dir: Path,
|
||||
changed_files: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Scan only for secrets (quick scan).
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
changed_files: Optional list of files to scan
|
||||
|
||||
Returns:
|
||||
List of detected secrets
|
||||
"""
|
||||
scanner = SecurityScanner()
|
||||
result = scanner.scan(
|
||||
project_dir,
|
||||
changed_files=changed_files,
|
||||
run_sast=False,
|
||||
run_dependency_audit=False,
|
||||
)
|
||||
return result.secrets
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run security scans")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--spec-dir", type=Path, help="Path to spec directory")
|
||||
parser.add_argument(
|
||||
"--secrets-only", action="store_true", help="Only scan for secrets"
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
scanner = SecurityScanner()
|
||||
result = scanner.scan(
|
||||
args.project_dir,
|
||||
spec_dir=args.spec_dir,
|
||||
run_sast=not args.secrets_only,
|
||||
run_dependency_audit=not args.secrets_only,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(scanner.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Secrets Found: {len(result.secrets)}")
|
||||
print(f"Vulnerabilities: {len(result.vulnerabilities)}")
|
||||
print(f"Has Critical Issues: {result.has_critical_issues}")
|
||||
print(f"Should Block QA: {result.should_block_qa}")
|
||||
|
||||
if result.secrets:
|
||||
print("\nSecrets Detected:")
|
||||
for secret in result.secrets:
|
||||
print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}")
|
||||
|
||||
if result.vulnerabilities:
|
||||
print(f"\nVulnerabilities ({len(result.vulnerabilities)}):")
|
||||
for v in result.vulnerabilities:
|
||||
print(f" [{v.severity.upper()}] {v.title}")
|
||||
if v.file:
|
||||
print(f" File: {v.file}:{v.line or ''}")
|
||||
|
||||
if result.scan_errors:
|
||||
print(f"\nScan Errors ({len(result.scan_errors)}):")
|
||||
for error in result.scan_errors:
|
||||
print(f" - {error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyzer facade module.
|
||||
|
||||
Provides backward compatibility for scripts that import from analyzer.py at the root.
|
||||
Actual implementation is in analysis/analyzer.py.
|
||||
"""
|
||||
|
||||
from analysis.analyzer import (
|
||||
ProjectAnalyzer,
|
||||
ServiceAnalyzer,
|
||||
analyze_project,
|
||||
analyze_service,
|
||||
main,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ServiceAnalyzer",
|
||||
"ProjectAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
"main",
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,36 +0,0 @@
|
||||
"""
|
||||
Auto Claude tools module facade.
|
||||
|
||||
Provides MCP tools for agent operations.
|
||||
Re-exports from agents.tools_pkg for clean imports.
|
||||
"""
|
||||
|
||||
from agents.tools_pkg.models import ( # noqa: F401
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from agents.tools_pkg.permissions import get_allowed_tools # noqa: F401
|
||||
from agents.tools_pkg.registry import ( # noqa: F401
|
||||
create_auto_claude_mcp_server,
|
||||
is_tools_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Backward compatibility shim - import from analysis.ci_discovery instead."""
|
||||
|
||||
from analysis.ci_discovery import (
|
||||
HAS_YAML,
|
||||
CIConfig,
|
||||
CIDiscovery,
|
||||
CIWorkflow,
|
||||
discover_ci,
|
||||
get_ci_system,
|
||||
get_ci_test_commands,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CIConfig",
|
||||
"CIWorkflow",
|
||||
"CIDiscovery",
|
||||
"discover_ci",
|
||||
"get_ci_test_commands",
|
||||
"get_ci_system",
|
||||
"HAS_YAML",
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Auto Claude CLI Package
|
||||
=======================
|
||||
|
||||
Command-line interface for the Auto Claude autonomous coding framework.
|
||||
|
||||
This package provides a modular CLI structure:
|
||||
- main.py: Argument parsing and command routing
|
||||
- spec_commands.py: Spec listing and management
|
||||
- build_commands.py: Build execution and follow-up tasks
|
||||
- workspace_commands.py: Workspace management (merge, review, discard)
|
||||
- qa_commands.py: QA validation commands
|
||||
- utils.py: Shared utilities and configuration
|
||||
"""
|
||||
|
||||
from .main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -1,279 +0,0 @@
|
||||
"""
|
||||
Batch Task Management Commands
|
||||
==============================
|
||||
|
||||
Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from qa.criteria import is_fixes_applied, is_qa_approved, is_qa_rejected
|
||||
from ui import highlight, print_status
|
||||
|
||||
|
||||
def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
|
||||
"""
|
||||
Create multiple tasks from a batch JSON file.
|
||||
|
||||
Args:
|
||||
batch_file: Path to JSON file with task definitions
|
||||
project_dir: Project directory
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
batch_path = Path(batch_file)
|
||||
|
||||
if not batch_path.exists():
|
||||
print_status(f"Batch file not found: {batch_file}", "error")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(batch_path, encoding="utf-8") as f:
|
||||
batch_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print_status(f"Invalid JSON in batch file: {e}", "error")
|
||||
return False
|
||||
|
||||
tasks = batch_data.get("tasks", [])
|
||||
if not tasks:
|
||||
print_status("No tasks found in batch file", "warning")
|
||||
return False
|
||||
|
||||
print_status(f"Creating {len(tasks)} tasks from batch file", "info")
|
||||
print()
|
||||
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find next spec ID
|
||||
existing_specs = [d.name for d in specs_dir.iterdir() if d.is_dir()]
|
||||
next_id = (
|
||||
max([int(s.split("-")[0]) for s in existing_specs if s[0].isdigit()] or [0]) + 1
|
||||
)
|
||||
|
||||
created_specs = []
|
||||
|
||||
for idx, task in enumerate(tasks, 1):
|
||||
spec_id = f"{next_id:03d}"
|
||||
task_title = task.get("title", f"Task {idx}")
|
||||
task_slug = task_title.lower().replace(" ", "-")[:50]
|
||||
spec_name = f"{spec_id}-{task_slug}"
|
||||
spec_dir = specs_dir / spec_name
|
||||
spec_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create requirements.json
|
||||
requirements = {
|
||||
"task_description": task.get("description", task_title),
|
||||
"description": task.get("description", task_title),
|
||||
"workflow_type": task.get("workflow_type", "feature"),
|
||||
"services_involved": task.get("services", ["frontend"]),
|
||||
"priority": task.get("priority", 5),
|
||||
"complexity_inferred": task.get("complexity", "standard"),
|
||||
"inferred_from": {},
|
||||
"created_at": Path(spec_dir).stat().st_mtime,
|
||||
"estimate": {
|
||||
"estimated_hours": task.get("estimated_hours", 4.0),
|
||||
"estimated_days": task.get("estimated_days", 0.5),
|
||||
},
|
||||
}
|
||||
|
||||
req_file = spec_dir / "requirements.json"
|
||||
with open(req_file, "w", encoding="utf-8") as f:
|
||||
json.dump(requirements, f, indent=2, default=str)
|
||||
|
||||
created_specs.append(
|
||||
{
|
||||
"id": spec_id,
|
||||
"name": spec_name,
|
||||
"title": task_title,
|
||||
"status": "pending_spec_creation",
|
||||
}
|
||||
)
|
||||
|
||||
print_status(
|
||||
f"[{idx}/{len(tasks)}] Created {spec_id} - {task_title}", "success"
|
||||
)
|
||||
next_id += 1
|
||||
|
||||
print()
|
||||
print_status(f"Created {len(created_specs)} spec(s) successfully", "success")
|
||||
print()
|
||||
|
||||
# Show summary
|
||||
print(highlight("Next steps:"))
|
||||
print(" 1. Generate specs: spec_runner.py --continue <spec_id>")
|
||||
print(" 2. Approve specs and build them")
|
||||
print(" 3. Run: python run.py --spec <id> to execute")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_batch_status_command(project_dir: str) -> bool:
|
||||
"""
|
||||
Show status of all specs in project.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs found in project", "warning")
|
||||
return True
|
||||
|
||||
specs = sorted([d for d in specs_dir.iterdir() if d.is_dir()])
|
||||
|
||||
if not specs:
|
||||
print_status("No specs found", "warning")
|
||||
return True
|
||||
|
||||
print_status(f"Found {len(specs)} spec(s)", "info")
|
||||
print()
|
||||
|
||||
for spec_dir in specs:
|
||||
spec_name = spec_dir.name
|
||||
req_file = spec_dir / "requirements.json"
|
||||
|
||||
status = "unknown"
|
||||
title = spec_name
|
||||
|
||||
if req_file.exists():
|
||||
try:
|
||||
with open(req_file, encoding="utf-8") as f:
|
||||
req = json.load(f)
|
||||
title = req.get("task_description", title)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Determine status (highest priority first)
|
||||
# Use authoritative QA status check, not just file existence
|
||||
if is_qa_approved(spec_dir):
|
||||
status = "qa_approved"
|
||||
elif is_qa_rejected(spec_dir):
|
||||
status = "qa_rejected"
|
||||
elif is_fixes_applied(spec_dir):
|
||||
status = "fixes_applied"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
# Check if there's a qa_report.md but no approval yet (QA in progress)
|
||||
if (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_in_progress"
|
||||
else:
|
||||
status = "building"
|
||||
elif (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
else:
|
||||
status = "pending_spec"
|
||||
|
||||
status_icon = {
|
||||
"pending_spec": "⏳",
|
||||
"spec_created": "📋",
|
||||
"building": "⚙️",
|
||||
"qa_in_progress": "🔍",
|
||||
"qa_approved": "✅",
|
||||
"qa_rejected": "❌",
|
||||
"fixes_applied": "🔧",
|
||||
"unknown": "❓",
|
||||
}.get(status, "❓")
|
||||
|
||||
print(f"{status_icon} {spec_name:<40} {title}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
|
||||
"""
|
||||
Clean up completed specs and worktrees.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
dry_run: If True, show what would be deleted
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
return True
|
||||
|
||||
# Find completed specs (only QA-approved, matching status display logic)
|
||||
completed = []
|
||||
for spec_dir in specs_dir.iterdir():
|
||||
if spec_dir.is_dir() and is_qa_approved(spec_dir):
|
||||
completed.append(spec_dir.name)
|
||||
|
||||
if not completed:
|
||||
print_status("No completed specs to clean up", "info")
|
||||
return True
|
||||
|
||||
print_status(f"Found {len(completed)} completed spec(s)", "info")
|
||||
|
||||
if dry_run:
|
||||
print()
|
||||
print("Would remove:")
|
||||
for spec_name in completed:
|
||||
print(f" - {spec_name}")
|
||||
wt_path = worktrees_dir / spec_name
|
||||
if wt_path.exists():
|
||||
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
@@ -1,487 +0,0 @@
|
||||
"""
|
||||
Build Commands
|
||||
==============
|
||||
|
||||
CLI commands for building specs and handling the main build flow.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
# Import only what we need at module level
|
||||
# Heavy imports are lazy-loaded in functions to avoid import errors
|
||||
from progress import print_paused_banner
|
||||
from review import ReviewState
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
MenuOption,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
select_menu,
|
||||
success,
|
||||
warning,
|
||||
)
|
||||
from workspace import (
|
||||
WorkspaceMode,
|
||||
check_existing_build,
|
||||
choose_workspace,
|
||||
finalize_workspace,
|
||||
get_existing_build_worktree,
|
||||
handle_workspace_choice,
|
||||
setup_workspace,
|
||||
)
|
||||
|
||||
from .input_handlers import (
|
||||
read_from_file,
|
||||
read_multiline_input,
|
||||
)
|
||||
|
||||
|
||||
def handle_build_command(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
max_iterations: int | None,
|
||||
verbose: bool,
|
||||
force_isolated: bool,
|
||||
force_direct: bool,
|
||||
auto_continue: bool,
|
||||
skip_qa: bool,
|
||||
force_bypass_approval: bool,
|
||||
base_branch: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the main build command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
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
|
||||
force_direct: Force direct workspace mode
|
||||
auto_continue: Auto-continue mode (non-interactive)
|
||||
skip_qa: Skip automatic QA validation
|
||||
force_bypass_approval: Force bypass approval check
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
debug_section,
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from prompts_pkg.prompts import (
|
||||
get_base_branch_from_metadata,
|
||||
get_use_local_branch_from_metadata,
|
||||
)
|
||||
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}")
|
||||
# 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}")
|
||||
else:
|
||||
print("Max iterations: Unlimited (runs until all subtasks complete)")
|
||||
|
||||
print()
|
||||
|
||||
# Validate environment
|
||||
if not validate_environment(spec_dir):
|
||||
sys.exit(1)
|
||||
|
||||
# Check human review approval
|
||||
review_state = ReviewState.load(spec_dir)
|
||||
if not review_state.is_approval_valid(spec_dir):
|
||||
if force_bypass_approval:
|
||||
# User explicitly bypassed approval check
|
||||
print()
|
||||
print(
|
||||
warning(
|
||||
f"{icon(Icons.WARNING)} WARNING: Bypassing approval check with --force"
|
||||
)
|
||||
)
|
||||
print(muted("This spec has not been approved for building."))
|
||||
print()
|
||||
else:
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.WARNING)} BUILD BLOCKED - REVIEW REQUIRED"),
|
||||
"",
|
||||
"This spec requires human approval before building.",
|
||||
]
|
||||
|
||||
if review_state.approved and not review_state.is_approval_valid(spec_dir):
|
||||
# Spec changed after approval
|
||||
content.append("")
|
||||
content.append(warning("The spec has been modified since approval."))
|
||||
content.append("Please re-review and re-approve.")
|
||||
|
||||
content.extend(
|
||||
[
|
||||
"",
|
||||
highlight("To review and approve:"),
|
||||
f" python auto-claude/review.py --spec-dir {spec_dir}",
|
||||
"",
|
||||
muted("Or use --force to bypass this check (not recommended)."),
|
||||
]
|
||||
)
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
sys.exit(1)
|
||||
else:
|
||||
debug_success(
|
||||
"run.py", "Review approval validated", approved_by=review_state.approved_by
|
||||
)
|
||||
|
||||
# Check for existing build
|
||||
if get_existing_build_worktree(project_dir, spec_dir.name):
|
||||
if auto_continue:
|
||||
# Non-interactive mode: auto-continue with existing build
|
||||
debug("run.py", "Auto-continue mode: continuing with existing build")
|
||||
print("Auto-continue: Resuming existing build...")
|
||||
else:
|
||||
continue_existing = check_existing_build(project_dir, spec_dir.name)
|
||||
if continue_existing:
|
||||
# Continue with existing worktree
|
||||
pass
|
||||
else:
|
||||
# User chose to start fresh or merged existing
|
||||
pass
|
||||
|
||||
# Choose workspace (skip for parallel mode - it always uses worktrees)
|
||||
working_dir = project_dir
|
||||
worktree_manager = None
|
||||
source_spec_dir = None # Track original spec dir for syncing back from worktree
|
||||
|
||||
# Let user choose workspace mode (or auto-select if --auto-continue)
|
||||
workspace_mode = choose_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
force_isolated=force_isolated,
|
||||
force_direct=force_direct,
|
||||
auto_continue=auto_continue,
|
||||
)
|
||||
|
||||
# If base_branch not provided via CLI, try to read from task_metadata.json
|
||||
# This ensures the backend uses the branch configured in the frontend
|
||||
if base_branch is None:
|
||||
metadata_branch = get_base_branch_from_metadata(spec_dir)
|
||||
if metadata_branch:
|
||||
base_branch = metadata_branch
|
||||
debug("run.py", f"Using base branch from task metadata: {base_branch}")
|
||||
|
||||
# Check if user requested local branch (preserves gitignored files like .env)
|
||||
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
|
||||
working_dir, worktree_manager, localized_spec_dir = setup_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
workspace_mode,
|
||||
source_spec_dir=spec_dir,
|
||||
base_branch=base_branch,
|
||||
use_local_branch=use_local_branch,
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
spec_dir = localized_spec_dir
|
||||
|
||||
# Run the autonomous agent
|
||||
debug_section("run.py", "Starting Build Execution")
|
||||
debug(
|
||||
"run.py",
|
||||
"Build configuration",
|
||||
model=model,
|
||||
workspace_mode=str(workspace_mode),
|
||||
working_dir=str(working_dir),
|
||||
spec_dir=str(spec_dir),
|
||||
)
|
||||
|
||||
try:
|
||||
debug("run.py", "Starting agent execution")
|
||||
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir, # Use worktree if isolated
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
max_iterations=max_iterations,
|
||||
verbose=verbose,
|
||||
source_spec_dir=source_spec_dir, # For syncing progress back to main project
|
||||
)
|
||||
)
|
||||
debug_success("run.py", "Agent execution completed")
|
||||
|
||||
# Run QA validation BEFORE finalization (while worktree still exists)
|
||||
# QA must sign off before the build is considered complete
|
||||
qa_approved = True # Default to approved if QA is skipped
|
||||
if not skip_qa and should_run_qa(spec_dir):
|
||||
print("\n" + "=" * 70)
|
||||
print(" SUBTASKS COMPLETE - STARTING QA VALIDATION")
|
||||
print("=" * 70)
|
||||
print("\nAll subtasks completed. Now running QA validation loop...")
|
||||
print("This ensures production-quality output before sign-off.\n")
|
||||
|
||||
try:
|
||||
qa_approved = asyncio.run(
|
||||
run_qa_validation_loop(
|
||||
project_dir=working_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
if qa_approved:
|
||||
print("\n" + "=" * 70)
|
||||
print(" ✅ QA VALIDATION PASSED")
|
||||
print("=" * 70)
|
||||
print("\nAll acceptance criteria verified.")
|
||||
print("The implementation is production-ready.\n")
|
||||
else:
|
||||
print("\n" + "=" * 70)
|
||||
print(" ⚠️ QA VALIDATION INCOMPLETE")
|
||||
print("=" * 70)
|
||||
print("\nSome issues require manual attention.")
|
||||
print(f"See: {spec_dir / 'qa_report.md'}")
|
||||
print(f"Or: {spec_dir / 'QA_FIX_REQUEST.md'}")
|
||||
print(
|
||||
f"\nResume QA: python auto-claude/run.py --spec {spec_dir.name} --qa\n"
|
||||
)
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nQA validation paused.")
|
||||
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
|
||||
qa_approved = False
|
||||
|
||||
# Post-build finalization (only for isolated sequential mode)
|
||||
# This happens AFTER QA validation so the worktree still exists
|
||||
if worktree_manager:
|
||||
choice = finalize_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
worktree_manager,
|
||||
auto_continue=auto_continue,
|
||||
)
|
||||
handle_workspace_choice(
|
||||
choice, project_dir, spec_dir.name, worktree_manager
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
_handle_build_interrupt(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
worktree_manager=worktree_manager,
|
||||
working_dir=working_dir,
|
||||
model=model,
|
||||
max_iterations=max_iterations,
|
||||
verbose=verbose,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"\nFatal error: {e}")
|
||||
if verbose:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _handle_build_interrupt(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
worktree_manager,
|
||||
working_dir: Path,
|
||||
model: str,
|
||||
max_iterations: int | None,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Handle keyboard interrupt during build.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
project_dir: Project root directory
|
||||
worktree_manager: Worktree manager instance (if using isolated mode)
|
||||
working_dir: Current working directory
|
||||
model: Model being used
|
||||
max_iterations: Maximum iterations
|
||||
verbose: Verbose mode flag
|
||||
"""
|
||||
from agent import run_autonomous_agent
|
||||
|
||||
# Print paused banner
|
||||
print_paused_banner(spec_dir, spec_dir.name, has_worktree=bool(worktree_manager))
|
||||
|
||||
# Update status file
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Offer to add human input with enhanced menu
|
||||
try:
|
||||
options = [
|
||||
MenuOption(
|
||||
key="type",
|
||||
label="Type instructions",
|
||||
icon=Icons.EDIT,
|
||||
description="Enter guidance for the agent's next session",
|
||||
),
|
||||
MenuOption(
|
||||
key="paste",
|
||||
label="Paste from clipboard",
|
||||
icon=Icons.CLIPBOARD,
|
||||
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
|
||||
),
|
||||
MenuOption(
|
||||
key="file",
|
||||
label="Read from file",
|
||||
icon=Icons.DOCUMENT,
|
||||
description="Load instructions from a text file",
|
||||
),
|
||||
MenuOption(
|
||||
key="skip",
|
||||
label="Continue without instructions",
|
||||
icon=Icons.SKIP,
|
||||
description="Resume the build as-is",
|
||||
),
|
||||
MenuOption(
|
||||
key="quit",
|
||||
label="Quit",
|
||||
icon=Icons.DOOR,
|
||||
description="Exit without resuming",
|
||||
),
|
||||
]
|
||||
|
||||
choice = select_menu(
|
||||
title="What would you like to do?",
|
||||
options=options,
|
||||
subtitle="Progress saved. You can add instructions for the agent.",
|
||||
allow_quit=False, # We have explicit quit option
|
||||
)
|
||||
|
||||
if choice == "quit" or choice is None:
|
||||
print()
|
||||
print_status("Exiting...", "info")
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
|
||||
human_input = ""
|
||||
|
||||
if choice == "file":
|
||||
# Read from file
|
||||
human_input = read_from_file()
|
||||
if human_input is None:
|
||||
human_input = ""
|
||||
|
||||
elif choice in ["type", "paste"]:
|
||||
human_input = read_multiline_input("Enter/paste your instructions below.")
|
||||
if human_input is None:
|
||||
print()
|
||||
print_status("Exiting without saving instructions...", "warning")
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
|
||||
if human_input:
|
||||
# Save to HUMAN_INPUT.md
|
||||
input_file = spec_dir / "HUMAN_INPUT.md"
|
||||
input_file.write_text(human_input, encoding="utf-8")
|
||||
|
||||
content = [
|
||||
success(f"{icon(Icons.SUCCESS)} INSTRUCTIONS SAVED"),
|
||||
"",
|
||||
f"Saved to: {highlight(str(input_file.name))}",
|
||||
"",
|
||||
muted(
|
||||
"The agent will read and follow these instructions when you resume."
|
||||
),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
elif choice != "skip":
|
||||
print()
|
||||
print_status("No instructions provided.", "info")
|
||||
|
||||
# If 'skip' was selected, actually resume the build
|
||||
if choice == "skip":
|
||||
print()
|
||||
print_status("Resuming build...", "info")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
max_iterations=max_iterations,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
# Build completed or was interrupted again - exit
|
||||
sys.exit(0)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# User pressed Ctrl+C again during input prompt - exit immediately
|
||||
print()
|
||||
print_status("Exiting...", "warning")
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
except EOFError:
|
||||
# stdin closed
|
||||
pass
|
||||
|
||||
# Resume instructions (shown when user provided instructions or chose file/type/paste)
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.PLAY)} TO RESUME"),
|
||||
"",
|
||||
f"Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
if worktree_manager:
|
||||
content.append("")
|
||||
content.append(muted("Your build is in a separate workspace and is safe."))
|
||||
print(box(content, width=70, style="light"))
|
||||
print()
|
||||
@@ -1,375 +0,0 @@
|
||||
"""
|
||||
Followup Commands
|
||||
=================
|
||||
|
||||
CLI commands for adding follow-up tasks to completed specs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from progress import count_subtasks, is_build_complete
|
||||
from ui import (
|
||||
Icons,
|
||||
MenuOption,
|
||||
bold,
|
||||
box,
|
||||
error,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
select_menu,
|
||||
success,
|
||||
warning,
|
||||
)
|
||||
|
||||
|
||||
def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
|
||||
"""
|
||||
Collect a follow-up task description from the user.
|
||||
|
||||
Provides multiple input methods (type, paste, file) similar to the
|
||||
HUMAN_INPUT.md pattern used during build interrupts. Includes retry
|
||||
logic for empty input.
|
||||
|
||||
Args:
|
||||
spec_dir: The spec directory where FOLLOWUP_REQUEST.md will be saved
|
||||
max_retries: Maximum number of times to prompt on empty input (default: 3)
|
||||
|
||||
Returns:
|
||||
The collected task description, or None if cancelled
|
||||
"""
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
# Present options menu
|
||||
options = [
|
||||
MenuOption(
|
||||
key="type",
|
||||
label="Type follow-up task",
|
||||
icon=Icons.EDIT,
|
||||
description="Enter a description of additional work needed",
|
||||
),
|
||||
MenuOption(
|
||||
key="paste",
|
||||
label="Paste from clipboard",
|
||||
icon=Icons.CLIPBOARD,
|
||||
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
|
||||
),
|
||||
MenuOption(
|
||||
key="file",
|
||||
label="Read from file",
|
||||
icon=Icons.DOCUMENT,
|
||||
description="Load task description from a text file",
|
||||
),
|
||||
MenuOption(
|
||||
key="quit",
|
||||
label="Cancel",
|
||||
icon=Icons.DOOR,
|
||||
description="Exit without adding follow-up",
|
||||
),
|
||||
]
|
||||
|
||||
# Show retry message if this is a retry
|
||||
subtitle = "Describe the additional work you want to add to this spec."
|
||||
if retry_count > 0:
|
||||
subtitle = warning(
|
||||
f"Empty input received. Please try again. ({max_retries - retry_count} attempts remaining)"
|
||||
)
|
||||
|
||||
choice = select_menu(
|
||||
title="How would you like to provide your follow-up task?",
|
||||
options=options,
|
||||
subtitle=subtitle,
|
||||
allow_quit=False, # We have explicit quit option
|
||||
)
|
||||
|
||||
if choice == "quit" or choice is None:
|
||||
return None
|
||||
|
||||
followup_task = ""
|
||||
|
||||
if choice == "file":
|
||||
# Read from file
|
||||
print()
|
||||
print(
|
||||
f"{icon(Icons.DOCUMENT)} Enter the path to your task description file:"
|
||||
)
|
||||
try:
|
||||
file_path_str = input(f" {icon(Icons.POINTER)} ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
|
||||
# Handle empty file path
|
||||
if not file_path_str:
|
||||
print()
|
||||
print_status("No file path provided.", "warning")
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_str).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
followup_task = file_path.read_text(encoding="utf-8").strip()
|
||||
if followup_task:
|
||||
print_status(
|
||||
f"Loaded {len(followup_task)} characters from file",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"File is empty. Please provide a file with task description.",
|
||||
"error",
|
||||
)
|
||||
retry_count += 1
|
||||
continue
|
||||
else:
|
||||
print_status(f"File not found: {file_path}", "error")
|
||||
print(
|
||||
muted(" Check that the path is correct and the file exists.")
|
||||
)
|
||||
retry_count += 1
|
||||
continue
|
||||
except PermissionError:
|
||||
print_status(f"Permission denied: cannot read {file_path_str}", "error")
|
||||
print(muted(" Check file permissions and try again."))
|
||||
retry_count += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
print_status(f"Error reading file: {e}", "error")
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
elif choice in ["type", "paste"]:
|
||||
print()
|
||||
content = [
|
||||
"Enter/paste your follow-up task description below.",
|
||||
"",
|
||||
muted("Describe what additional work you want to add."),
|
||||
muted("The planner will create new subtasks based on this."),
|
||||
"",
|
||||
muted("Press Enter on an empty line when done."),
|
||||
]
|
||||
print(box(content, width=60, style="light"))
|
||||
print()
|
||||
|
||||
lines = []
|
||||
empty_count = 0
|
||||
while True:
|
||||
try:
|
||||
line = input()
|
||||
if line == "":
|
||||
empty_count += 1
|
||||
if empty_count >= 1: # Stop on first empty line
|
||||
break
|
||||
else:
|
||||
empty_count = 0
|
||||
lines.append(line)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
followup_task = "\n".join(lines).strip()
|
||||
|
||||
# Validate that we have content
|
||||
if not followup_task:
|
||||
print()
|
||||
print_status("No task description provided.", "warning")
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
# Save to FOLLOWUP_REQUEST.md
|
||||
request_file = spec_dir / "FOLLOWUP_REQUEST.md"
|
||||
request_file.write_text(followup_task, encoding="utf-8")
|
||||
|
||||
# Show confirmation
|
||||
content = [
|
||||
success(f"{icon(Icons.SUCCESS)} FOLLOW-UP TASK SAVED"),
|
||||
"",
|
||||
f"Saved to: {highlight(str(request_file.name))}",
|
||||
"",
|
||||
muted("The planner will create new subtasks based on this task."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
|
||||
return followup_task
|
||||
|
||||
# Max retries exceeded
|
||||
print()
|
||||
print_status("Maximum retry attempts reached. Follow-up cancelled.", "error")
|
||||
return None
|
||||
|
||||
|
||||
def handle_followup_command(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the --followup command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use
|
||||
verbose: Enable verbose output
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_followup_planner
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
print_banner()
|
||||
print(f"\nFollow-up request for: {spec_dir.name}")
|
||||
|
||||
# Check if implementation_plan.json exists
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
print()
|
||||
print(error(f"{icon(Icons.ERROR)} No implementation plan found."))
|
||||
print()
|
||||
content = [
|
||||
"This spec has not been built yet.",
|
||||
"",
|
||||
"Follow-up tasks can only be added to specs that have been",
|
||||
"built at least once. Run a regular build first:",
|
||||
"",
|
||||
highlight(f" python auto-claude/run.py --spec {spec_dir.name}"),
|
||||
"",
|
||||
muted("After the build completes, you can add follow-up tasks."),
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
sys.exit(1)
|
||||
|
||||
# Check if build is complete
|
||||
if not is_build_complete(spec_dir):
|
||||
completed, total = count_subtasks(spec_dir)
|
||||
pending = total - completed
|
||||
print()
|
||||
print(
|
||||
error(
|
||||
f"{icon(Icons.ERROR)} Build not complete ({completed}/{total} subtasks)."
|
||||
)
|
||||
)
|
||||
print()
|
||||
content = [
|
||||
f"There are still {pending} pending subtask(s) to complete.",
|
||||
"",
|
||||
"Follow-up tasks can only be added after all current subtasks",
|
||||
"are finished. Complete the current build first:",
|
||||
"",
|
||||
highlight(f" python auto-claude/run.py --spec {spec_dir.name}"),
|
||||
"",
|
||||
muted("The build will continue from where it left off."),
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
sys.exit(1)
|
||||
|
||||
# Check for prior follow-ups (for sequential follow-up context)
|
||||
prior_followup_count = 0
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan_data = json.load(f)
|
||||
phases = plan_data.get("phases", [])
|
||||
# Count phases that look like follow-up phases (name contains "Follow" or high phase number)
|
||||
for phase in phases:
|
||||
phase_name = phase.get("name", "")
|
||||
if "follow" in phase_name.lower() or "followup" in phase_name.lower():
|
||||
prior_followup_count += 1
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass # If plan parsing fails, just continue without prior count
|
||||
|
||||
# Build is complete - proceed to follow-up workflow
|
||||
print()
|
||||
if prior_followup_count > 0:
|
||||
print(
|
||||
success(
|
||||
f"{icon(Icons.SUCCESS)} Build is complete ({prior_followup_count} prior follow-up(s)). Ready for more follow-up tasks."
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
success(
|
||||
f"{icon(Icons.SUCCESS)} Build is complete. Ready for follow-up tasks."
|
||||
)
|
||||
)
|
||||
|
||||
# Collect follow-up task from user
|
||||
followup_task = collect_followup_task(spec_dir)
|
||||
|
||||
if followup_task is None:
|
||||
# User cancelled
|
||||
print()
|
||||
print_status("Follow-up cancelled.", "info")
|
||||
return
|
||||
|
||||
# Successfully collected follow-up task
|
||||
# The collect_followup_task() function already saved to FOLLOWUP_REQUEST.md
|
||||
# Now run the follow-up planner to add new subtasks
|
||||
print()
|
||||
|
||||
if not validate_environment(spec_dir):
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
success_result = asyncio.run(
|
||||
run_followup_planner(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
if success_result:
|
||||
# Show next steps after successful planning
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
|
||||
"",
|
||||
"New subtasks have been added to your implementation plan.",
|
||||
"",
|
||||
highlight("To continue building:"),
|
||||
f" python auto-claude/run.py --spec {spec_dir.name}",
|
||||
]
|
||||
print(box(content, width=70, style="heavy"))
|
||||
else:
|
||||
# Planning didn't fully succeed
|
||||
content = [
|
||||
bold(f"{icon(Icons.WARNING)} FOLLOW-UP PLANNING INCOMPLETE"),
|
||||
"",
|
||||
"Check the implementation plan manually.",
|
||||
"",
|
||||
muted("You may need to run the follow-up again."),
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
sys.exit(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nFollow-up planning paused.")
|
||||
print(f"To retry: python auto-claude/run.py --spec {spec_dir.name} --followup")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print()
|
||||
print(error(f"{icon(Icons.ERROR)} Follow-up planning error: {e}"))
|
||||
if verbose:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""
|
||||
Input Handlers
|
||||
==============
|
||||
|
||||
Reusable user input collection utilities for CLI commands.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from ui import (
|
||||
Icons,
|
||||
MenuOption,
|
||||
box,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
select_menu,
|
||||
)
|
||||
|
||||
|
||||
def collect_user_input_interactive(
|
||||
title: str,
|
||||
subtitle: str,
|
||||
prompt_text: str,
|
||||
allow_file: bool = True,
|
||||
allow_paste: bool = True,
|
||||
) -> str | None:
|
||||
"""
|
||||
Collect user input through an interactive menu.
|
||||
|
||||
Provides multiple input methods:
|
||||
- Type directly
|
||||
- Paste from clipboard
|
||||
- Read from file (optional)
|
||||
|
||||
Args:
|
||||
title: Menu title
|
||||
subtitle: Menu subtitle
|
||||
prompt_text: Text to display in the input box
|
||||
allow_file: Whether to allow file input (default: True)
|
||||
allow_paste: Whether to allow paste option (default: True)
|
||||
|
||||
Returns:
|
||||
The collected input string, or None if cancelled
|
||||
"""
|
||||
# Build options list
|
||||
options = [
|
||||
MenuOption(
|
||||
key="type",
|
||||
label="Type instructions",
|
||||
icon=Icons.EDIT,
|
||||
description="Enter text directly",
|
||||
),
|
||||
]
|
||||
|
||||
if allow_paste:
|
||||
options.append(
|
||||
MenuOption(
|
||||
key="paste",
|
||||
label="Paste from clipboard",
|
||||
icon=Icons.CLIPBOARD,
|
||||
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
|
||||
)
|
||||
)
|
||||
|
||||
if allow_file:
|
||||
options.append(
|
||||
MenuOption(
|
||||
key="file",
|
||||
label="Read from file",
|
||||
icon=Icons.DOCUMENT,
|
||||
description="Load text from a file",
|
||||
)
|
||||
)
|
||||
|
||||
options.extend(
|
||||
[
|
||||
MenuOption(
|
||||
key="skip",
|
||||
label="Continue without input",
|
||||
icon=Icons.SKIP,
|
||||
description="Skip this step",
|
||||
),
|
||||
MenuOption(
|
||||
key="quit",
|
||||
label="Quit",
|
||||
icon=Icons.DOOR,
|
||||
description="Exit",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
choice = select_menu(
|
||||
title=title,
|
||||
options=options,
|
||||
subtitle=subtitle,
|
||||
allow_quit=False, # We have explicit quit option
|
||||
)
|
||||
|
||||
if choice == "quit" or choice is None:
|
||||
return None
|
||||
|
||||
if choice == "skip":
|
||||
return ""
|
||||
|
||||
user_input = ""
|
||||
|
||||
if choice == "file":
|
||||
# Read from file
|
||||
user_input = read_from_file()
|
||||
if user_input is None:
|
||||
return None
|
||||
|
||||
elif choice in ["type", "paste"]:
|
||||
user_input = read_multiline_input(prompt_text)
|
||||
if user_input is None:
|
||||
return None
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
def read_from_file() -> str | None:
|
||||
"""
|
||||
Read text content from a file path provided by the user.
|
||||
|
||||
Returns:
|
||||
File contents as string, or None if cancelled/error
|
||||
"""
|
||||
print()
|
||||
print(f"{icon(Icons.DOCUMENT)} Enter the path to your file:")
|
||||
try:
|
||||
file_path_input = input(f" {icon(Icons.POINTER)} ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
|
||||
if not file_path_input:
|
||||
print_status("No file path provided.", "warning")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_input).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
content = file_path.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
print_status(
|
||||
f"Loaded {len(content)} characters from file",
|
||||
"success",
|
||||
)
|
||||
return content
|
||||
else:
|
||||
print_status("File is empty.", "error")
|
||||
return None
|
||||
else:
|
||||
print_status(f"File not found: {file_path}", "error")
|
||||
return None
|
||||
except PermissionError:
|
||||
print_status(f"Permission denied: cannot read {file_path_input}", "error")
|
||||
return None
|
||||
except Exception as e:
|
||||
print_status(f"Error reading file: {e}", "error")
|
||||
return None
|
||||
|
||||
|
||||
def read_multiline_input(prompt_text: str) -> str | None:
|
||||
"""
|
||||
Read multi-line input from the user.
|
||||
|
||||
Args:
|
||||
prompt_text: Text to display in the prompt box
|
||||
|
||||
Returns:
|
||||
User input as string, or None if cancelled
|
||||
"""
|
||||
print()
|
||||
content = [
|
||||
prompt_text,
|
||||
muted("Press Enter on an empty line when done."),
|
||||
]
|
||||
print(box(content, width=60, style="light"))
|
||||
print()
|
||||
|
||||
lines = []
|
||||
empty_count = 0
|
||||
while True:
|
||||
try:
|
||||
line = input()
|
||||
if line == "":
|
||||
empty_count += 1
|
||||
if empty_count >= 1: # Stop on first empty line
|
||||
break
|
||||
else:
|
||||
empty_count = 0
|
||||
lines.append(line)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
@@ -1,484 +0,0 @@
|
||||
"""
|
||||
Auto Claude CLI - Main Entry Point
|
||||
===================================
|
||||
|
||||
Command-line interface for the Auto Claude autonomous coding framework.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
handle_batch_create_command,
|
||||
handle_batch_status_command,
|
||||
)
|
||||
from .build_commands import handle_build_command
|
||||
from .followup_commands import handle_followup_command
|
||||
from .qa_commands import (
|
||||
handle_qa_command,
|
||||
handle_qa_status_command,
|
||||
handle_review_status_command,
|
||||
)
|
||||
from .spec_commands import print_specs_list
|
||||
from .utils import (
|
||||
DEFAULT_MODEL,
|
||||
find_spec,
|
||||
get_project_dir,
|
||||
print_banner,
|
||||
setup_environment,
|
||||
)
|
||||
from .workspace_commands import (
|
||||
handle_cleanup_worktrees_command,
|
||||
handle_create_pr_command,
|
||||
handle_discard_command,
|
||||
handle_list_worktrees_command,
|
||||
handle_merge_command,
|
||||
handle_review_command,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Auto Claude Framework - Autonomous multi-session coding agent",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# List all specs
|
||||
python auto-claude/run.py --list
|
||||
|
||||
# Run a specific spec (by number or full name)
|
||||
python auto-claude/run.py --spec 001
|
||||
python auto-claude/run.py --spec 001-initial-app
|
||||
|
||||
# Workspace management (after build completes)
|
||||
python auto-claude/run.py --spec 001 --merge # Add build to your project
|
||||
python auto-claude/run.py --spec 001 --review # See what was built
|
||||
python auto-claude/run.py --spec 001 --discard # Delete build (with confirmation)
|
||||
|
||||
# Advanced options
|
||||
python auto-claude/run.py --spec 001 --direct # Skip workspace isolation
|
||||
python auto-claude/run.py --spec 001 --isolated # Force workspace isolation
|
||||
|
||||
# Status checks
|
||||
python auto-claude/run.py --spec 001 --review-status # Check human review status
|
||||
python auto-claude/run.py --spec 001 --qa-status # Check QA validation status
|
||||
|
||||
Prerequisites:
|
||||
1. Authenticate: Run 'claude' and type '/login'
|
||||
2. Create a spec first: claude /spec
|
||||
|
||||
Environment Variables:
|
||||
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (auto-detected from Keychain)
|
||||
Or authenticate via: claude → /login
|
||||
AUTO_BUILD_MODEL Override default model (optional)
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List all available specs and their status",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--spec",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Spec to run (e.g., '001' or '001-feature-name')",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Project directory (default: current working directory)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-iterations",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of agent sessions (default: unlimited)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default=None,
|
||||
help=f"Claude model to use (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Enable verbose output",
|
||||
)
|
||||
|
||||
# Workspace options
|
||||
workspace_group = parser.add_mutually_exclusive_group()
|
||||
workspace_group.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help="Force building in isolated workspace (safer)",
|
||||
)
|
||||
workspace_group.add_argument(
|
||||
"--direct",
|
||||
action="store_true",
|
||||
help="Build directly in your project (no isolation)",
|
||||
)
|
||||
|
||||
# Build management commands
|
||||
build_group = parser.add_mutually_exclusive_group()
|
||||
build_group.add_argument(
|
||||
"--merge",
|
||||
action="store_true",
|
||||
help="Merge an existing build into your project",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--review",
|
||||
action="store_true",
|
||||
help="Review what an existing build contains",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--discard",
|
||||
action="store_true",
|
||||
help="Discard an existing build (requires confirmation)",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--create-pr",
|
||||
action="store_true",
|
||||
help="Push branch and create a GitHub Pull Request",
|
||||
)
|
||||
|
||||
# PR options
|
||||
parser.add_argument(
|
||||
"--pr-target",
|
||||
type=str,
|
||||
metavar="BRANCH",
|
||||
help="With --create-pr: target branch for PR (default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-title",
|
||||
type=str,
|
||||
metavar="TITLE",
|
||||
help="With --create-pr: custom PR title (default: generated from spec name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-draft",
|
||||
action="store_true",
|
||||
help="With --create-pr: create as draft PR",
|
||||
)
|
||||
|
||||
# Merge options
|
||||
parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
help="With --merge: stage changes but don't commit (review in IDE first)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--merge-preview",
|
||||
action="store_true",
|
||||
help="Preview merge conflicts without actually merging (returns JSON)",
|
||||
)
|
||||
|
||||
# QA options
|
||||
parser.add_argument(
|
||||
"--qa",
|
||||
action="store_true",
|
||||
help="Run QA validation loop on a completed build",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--qa-status",
|
||||
action="store_true",
|
||||
help="Show QA validation status for a spec",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-qa",
|
||||
action="store_true",
|
||||
help="Skip automatic QA validation after build completes",
|
||||
)
|
||||
|
||||
# Follow-up options
|
||||
parser.add_argument(
|
||||
"--followup",
|
||||
action="store_true",
|
||||
help="Add follow-up tasks to a completed spec (extends existing implementation plan)",
|
||||
)
|
||||
|
||||
# Review options
|
||||
parser.add_argument(
|
||||
"--review-status",
|
||||
action="store_true",
|
||||
help="Show human review/approval status for a spec",
|
||||
)
|
||||
|
||||
# Non-interactive mode (for UI/automation)
|
||||
parser.add_argument(
|
||||
"--auto-continue",
|
||||
action="store_true",
|
||||
help="Non-interactive mode: auto-continue existing builds, skip prompts (for UI integration)",
|
||||
)
|
||||
|
||||
# Worktree management
|
||||
parser.add_argument(
|
||||
"--list-worktrees",
|
||||
action="store_true",
|
||||
help="List all spec worktrees and their status",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cleanup-worktrees",
|
||||
action="store_true",
|
||||
help="Remove all spec worktrees and their branches (with confirmation)",
|
||||
)
|
||||
|
||||
# Force bypass
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Skip approval check and start build anyway (for debugging)",
|
||||
)
|
||||
|
||||
# Base branch for worktree creation
|
||||
parser.add_argument(
|
||||
"--base-branch",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Base branch for creating worktrees (default: auto-detect or current branch)",
|
||||
)
|
||||
|
||||
# Batch task management
|
||||
parser.add_argument(
|
||||
"--batch-create",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Create multiple tasks from a batch JSON file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-status",
|
||||
action="store_true",
|
||||
help="Show status of all specs in the project",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-cleanup",
|
||||
action="store_true",
|
||||
help="Clean up completed specs (dry-run by default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dry-run",
|
||||
action="store_true",
|
||||
help="Actually delete files in cleanup (not just preview)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
# Set up environment first
|
||||
setup_environment()
|
||||
|
||||
# Initialize Sentry early to capture any startup errors
|
||||
from core.sentry import capture_exception, init_sentry
|
||||
|
||||
init_sentry(component="cli")
|
||||
|
||||
try:
|
||||
_run_cli()
|
||||
except KeyboardInterrupt:
|
||||
# Clean exit on Ctrl+C
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
# Capture unexpected errors to Sentry
|
||||
capture_exception(e)
|
||||
print(f"\nUnexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _run_cli() -> None:
|
||||
"""Run the CLI logic (extracted for error handling)."""
|
||||
# Import here to avoid import errors during startup
|
||||
from core.sentry import set_context
|
||||
|
||||
# Parse arguments
|
||||
args = parse_args()
|
||||
|
||||
# Import debug functions after environment setup
|
||||
from debug import debug, debug_error, debug_section, debug_success
|
||||
|
||||
debug_section("run.py", "Starting Auto-Build Framework")
|
||||
debug("run.py", "Arguments parsed", args=vars(args))
|
||||
|
||||
# Determine project directory
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
|
||||
# Handle --list command
|
||||
if args.list:
|
||||
print_banner()
|
||||
print_specs_list(project_dir)
|
||||
return
|
||||
|
||||
# Handle --list-worktrees command
|
||||
if args.list_worktrees:
|
||||
handle_list_worktrees_command(project_dir)
|
||||
return
|
||||
|
||||
# Handle --cleanup-worktrees command
|
||||
if args.cleanup_worktrees:
|
||||
handle_cleanup_worktrees_command(project_dir)
|
||||
return
|
||||
|
||||
# Handle batch commands
|
||||
if args.batch_create:
|
||||
handle_batch_create_command(args.batch_create, str(project_dir))
|
||||
return
|
||||
|
||||
if args.batch_status:
|
||||
handle_batch_status_command(str(project_dir))
|
||||
return
|
||||
|
||||
if args.batch_cleanup:
|
||||
handle_batch_cleanup_command(str(project_dir), dry_run=not args.no_dry_run)
|
||||
return
|
||||
|
||||
# Require --spec if not listing
|
||||
if not args.spec:
|
||||
print_banner()
|
||||
print("\nError: --spec is required")
|
||||
print("\nUsage:")
|
||||
print(" python auto-claude/run.py --list # See all specs")
|
||||
print(" python auto-claude/run.py --spec 001 # Run a spec")
|
||||
print("\nCreate a new spec with:")
|
||||
print(" claude /spec")
|
||||
sys.exit(1)
|
||||
|
||||
# Find the spec
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec)
|
||||
spec_dir = find_spec(project_dir, args.spec)
|
||||
if not spec_dir:
|
||||
debug_error("run.py", "Spec not found", spec=args.spec)
|
||||
print_banner()
|
||||
print(f"\nError: Spec '{args.spec}' not found")
|
||||
print("\nAvailable specs:")
|
||||
print_specs_list(project_dir)
|
||||
sys.exit(1)
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
|
||||
# Set Sentry context for error tracking
|
||||
set_context(
|
||||
"spec",
|
||||
{
|
||||
"name": spec_dir.name,
|
||||
"project": str(project_dir),
|
||||
},
|
||||
)
|
||||
|
||||
# Handle build management commands
|
||||
if args.merge_preview:
|
||||
from cli.workspace_commands import handle_merge_preview_command
|
||||
|
||||
result = handle_merge_preview_command(
|
||||
project_dir, spec_dir.name, base_branch=args.base_branch
|
||||
)
|
||||
# Output as JSON for the UI to parse
|
||||
import json
|
||||
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
if args.merge:
|
||||
success = handle_merge_command(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
no_commit=args.no_commit,
|
||||
base_branch=args.base_branch,
|
||||
)
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
if args.review:
|
||||
handle_review_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
if args.discard:
|
||||
handle_discard_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
if args.create_pr:
|
||||
# Pass args.pr_target directly - WorktreeManager._detect_base_branch
|
||||
# handles base branch detection internally when target_branch is None
|
||||
result = handle_create_pr_command(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_dir.name,
|
||||
target_branch=args.pr_target,
|
||||
title=args.pr_title,
|
||||
draft=args.pr_draft,
|
||||
)
|
||||
# JSON output is already printed by handle_create_pr_command
|
||||
if not result.get("success"):
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Handle QA commands
|
||||
if args.qa_status:
|
||||
handle_qa_status_command(spec_dir)
|
||||
return
|
||||
|
||||
if args.review_status:
|
||||
handle_review_status_command(spec_dir)
|
||||
return
|
||||
|
||||
if args.qa:
|
||||
handle_qa_command(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
return
|
||||
|
||||
# Handle --followup command
|
||||
if args.followup:
|
||||
handle_followup_command(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
return
|
||||
|
||||
# Normal build flow
|
||||
handle_build_command(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
max_iterations=args.max_iterations,
|
||||
verbose=args.verbose,
|
||||
force_isolated=args.isolated,
|
||||
force_direct=args.direct,
|
||||
auto_continue=args.auto_continue,
|
||||
skip_qa=args.skip_qa,
|
||||
force_bypass_approval=args.force,
|
||||
base_branch=args.base_branch,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
QA Commands
|
||||
===========
|
||||
|
||||
CLI commands for QA validation (run QA, check status)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from progress import count_subtasks
|
||||
from qa_loop import (
|
||||
is_qa_approved,
|
||||
print_qa_status,
|
||||
run_qa_validation_loop,
|
||||
should_run_qa,
|
||||
)
|
||||
from review import ReviewState, display_review_status
|
||||
from ui import (
|
||||
Icons,
|
||||
icon,
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
)
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
|
||||
def handle_qa_status_command(spec_dir: Path) -> None:
|
||||
"""
|
||||
Handle the --qa-status command.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
"""
|
||||
print_banner()
|
||||
print(f"\nSpec: {spec_dir.name}\n")
|
||||
print_qa_status(spec_dir)
|
||||
|
||||
|
||||
def handle_review_status_command(spec_dir: Path) -> None:
|
||||
"""
|
||||
Handle the --review-status command.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
"""
|
||||
print_banner()
|
||||
print(f"\nSpec: {spec_dir.name}\n")
|
||||
display_review_status(spec_dir)
|
||||
# Also show if approval is valid for build
|
||||
review_state = ReviewState.load(spec_dir)
|
||||
print()
|
||||
if review_state.is_approval_valid(spec_dir):
|
||||
print(success(f"{icon(Icons.SUCCESS)} Ready to build - approval is valid."))
|
||||
elif review_state.approved:
|
||||
print(
|
||||
warning(
|
||||
f"{icon(Icons.WARNING)} Spec changed since approval - re-review required."
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(info(f"{icon(Icons.INFO)} Review required before building."))
|
||||
print()
|
||||
|
||||
|
||||
def handle_qa_command(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the --qa command (run QA validation loop).
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use for QA
|
||||
verbose: Enable verbose output
|
||||
"""
|
||||
print_banner()
|
||||
print(f"\nRunning QA validation for: {spec_dir.name}")
|
||||
if not validate_environment(spec_dir):
|
||||
sys.exit(1)
|
||||
|
||||
# Check if there's pending human feedback that needs to be processed
|
||||
# Human feedback takes priority over "already approved" status
|
||||
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
|
||||
has_human_feedback = fix_request_file.exists()
|
||||
|
||||
if not should_run_qa(spec_dir) and not has_human_feedback:
|
||||
if is_qa_approved(spec_dir):
|
||||
print("\n✅ Build already approved by QA.")
|
||||
else:
|
||||
completed, total = count_subtasks(spec_dir)
|
||||
print(
|
||||
f"\n❌ Build not ready for QA ({completed}/{total} subtasks completed)."
|
||||
)
|
||||
print(
|
||||
"All subtasks must reach a terminal state (completed, failed, or stuck) before running QA."
|
||||
)
|
||||
return
|
||||
|
||||
if has_human_feedback:
|
||||
print("\n📝 Human feedback detected - processing fix request...")
|
||||
|
||||
try:
|
||||
approved = asyncio.run(
|
||||
run_qa_validation_loop(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
if approved:
|
||||
print("\n✅ QA validation passed. Ready for merge.")
|
||||
else:
|
||||
print("\n❌ QA validation incomplete. See reports for details.")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nQA validation paused.")
|
||||
print(f"Resume with: python auto-claude/run.py --spec {spec_dir.name} --qa")
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON Recovery Utility
|
||||
=====================
|
||||
|
||||
Detects and repairs corrupted JSON files in specs directories.
|
||||
|
||||
Usage:
|
||||
python -m cli.recovery --project-dir /path/to/project --detect
|
||||
python -m cli.recovery --project-dir /path/to/project --spec-id 004-feature --delete
|
||||
python -m cli.recovery --project-dir /path/to/project --all --delete
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from cli.utils import find_specs_dir
|
||||
|
||||
|
||||
def check_json_file(filepath: Path) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if a JSON file is valid.
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
json.load(f)
|
||||
return True, None
|
||||
except json.JSONDecodeError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def detect_corrupted_files(specs_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
Scan specs directory recursively for corrupted JSON files.
|
||||
|
||||
Returns:
|
||||
List of (filepath, error_message) tuples
|
||||
"""
|
||||
corrupted = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
return corrupted
|
||||
|
||||
# Recursively scan for JSON files (includes nested files like memory/*.json)
|
||||
for json_file in specs_dir.rglob("*.json"):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
if not is_valid:
|
||||
# Type narrowing: error is str when is_valid is False
|
||||
assert error is not None
|
||||
corrupted.append((json_file, error))
|
||||
|
||||
return corrupted
|
||||
|
||||
|
||||
def backup_corrupted_file(filepath: Path) -> bool:
|
||||
"""
|
||||
Backup a corrupted file by renaming it with a .corrupted suffix.
|
||||
|
||||
Args:
|
||||
filepath: Path to the corrupted file
|
||||
|
||||
Returns:
|
||||
True if backed up successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create backup before deleting
|
||||
base_backup_path = filepath.with_suffix(f"{filepath.suffix}.corrupted")
|
||||
backup_path = base_backup_path
|
||||
|
||||
# Handle existing backup files by generating unique name with UUID
|
||||
if backup_path.exists():
|
||||
# Use UUID for unique naming to avoid races
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
backup_path = filepath.with_suffix(
|
||||
f"{filepath.suffix}.corrupted.{unique_suffix}"
|
||||
)
|
||||
|
||||
filepath.rename(backup_path)
|
||||
print(f" [BACKUP] Moved corrupted file to: {backup_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Failed to backup file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect and repair corrupted JSON files in specs directories"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Project directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--specs-dir",
|
||||
type=Path,
|
||||
help="Specs directory path (overrides auto-detection)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detect",
|
||||
action="store_true",
|
||||
help="Detect corrupted JSON files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spec-id",
|
||||
type=str,
|
||||
help="Specific spec ID to fix (e.g., 004-feature)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete",
|
||||
action="store_true",
|
||||
help="Delete corrupted files (creates .corrupted backup)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Fix all corrupted files (requires --delete)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate --all requires --delete
|
||||
if args.all and not args.delete:
|
||||
parser.error("--all requires --delete")
|
||||
|
||||
# Find specs directory
|
||||
if args.specs_dir:
|
||||
specs_dir = args.specs_dir
|
||||
else:
|
||||
specs_dir = find_specs_dir(args.project_dir)
|
||||
|
||||
print(f"[INFO] Scanning specs directory: {specs_dir}")
|
||||
|
||||
# Default to detect mode if no flags provided
|
||||
if not args.detect and not args.delete:
|
||||
args.detect = True
|
||||
|
||||
# Detect corrupted files (dry-run when detect-only, otherwise for deletion)
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
# Detect-only mode: show results and exit
|
||||
if args.detect and not args.delete:
|
||||
if not corrupted:
|
||||
print("[OK] No corrupted JSON files found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n[FOUND] {len(corrupted)} corrupted file(s):\n")
|
||||
for filepath, error in corrupted:
|
||||
print(f" - {filepath.relative_to(specs_dir.parent)}")
|
||||
print(f" Error: {error}")
|
||||
print()
|
||||
# Exit with error code when corrupted files are found
|
||||
sys.exit(1)
|
||||
|
||||
# Delete corrupted files
|
||||
if args.delete:
|
||||
if args.spec_id:
|
||||
# Delete specific spec
|
||||
spec_dir = (specs_dir / args.spec_id).resolve()
|
||||
specs_dir_resolved = specs_dir.resolve()
|
||||
# Validate path doesn't escape specs directory
|
||||
if not spec_dir.is_relative_to(specs_dir_resolved):
|
||||
print("[ERROR] Invalid spec ID: path traversal detected")
|
||||
sys.exit(1)
|
||||
|
||||
if not spec_dir.exists():
|
||||
print(f"[ERROR] Spec directory not found: {spec_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] Processing spec: {args.spec_id}")
|
||||
has_failures = False
|
||||
for json_file in spec_dir.rglob("*.json"):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
if not is_valid:
|
||||
print(f" [CORRUPTED] {json_file.name}")
|
||||
if not backup_corrupted_file(json_file):
|
||||
has_failures = True
|
||||
|
||||
if has_failures:
|
||||
sys.exit(1)
|
||||
|
||||
elif args.all:
|
||||
# Delete all corrupted files
|
||||
# Use the already-detected corrupted list, or re-scan if needed
|
||||
if not corrupted:
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
if not corrupted:
|
||||
print("[OK] No corrupted files to delete")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n[INFO] Backing up {len(corrupted)} corrupted file(s):\n")
|
||||
has_failures = False
|
||||
for filepath, _ in corrupted:
|
||||
# backup_corrupted_file prints its own [BACKUP] message
|
||||
if not backup_corrupted_file(filepath):
|
||||
has_failures = True
|
||||
|
||||
if has_failures:
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print("[ERROR] Must specify --spec-id or --all with --delete")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,191 +0,0 @@
|
||||
"""
|
||||
Spec Commands
|
||||
=============
|
||||
|
||||
CLI commands for managing specs (listing, finding, etc.)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from progress import count_subtasks
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
from .utils import get_specs_dir
|
||||
|
||||
|
||||
def list_specs(project_dir: Path) -> list[dict]:
|
||||
"""
|
||||
List all specs in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
List of spec info dicts with keys: number, name, path, status, progress
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
return specs
|
||||
|
||||
for spec_folder in sorted(specs_dir.iterdir()):
|
||||
if not spec_folder.is_dir():
|
||||
continue
|
||||
|
||||
# Parse folder name (e.g., "001-initial-app")
|
||||
folder_name = spec_folder.name
|
||||
parts = folder_name.split("-", 1)
|
||||
if len(parts) != 2 or not parts[0].isdigit():
|
||||
continue
|
||||
|
||||
number = parts[0]
|
||||
name = parts[1]
|
||||
|
||||
# Check for spec.md
|
||||
spec_file = spec_folder / "spec.md"
|
||||
if not spec_file.exists():
|
||||
continue
|
||||
|
||||
# Check for existing build in worktree
|
||||
has_build = get_existing_build_worktree(project_dir, folder_name) is not None
|
||||
|
||||
# Check progress via implementation_plan.json
|
||||
plan_file = spec_folder / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
completed, total = count_subtasks(spec_folder)
|
||||
if total > 0:
|
||||
if completed == total:
|
||||
status = "complete"
|
||||
else:
|
||||
status = "in_progress"
|
||||
progress = f"{completed}/{total}"
|
||||
else:
|
||||
status = "initialized"
|
||||
progress = "0/0"
|
||||
else:
|
||||
status = "pending"
|
||||
progress = "-"
|
||||
|
||||
# Add build indicator
|
||||
if has_build:
|
||||
status = f"{status} (has build)"
|
||||
|
||||
specs.append(
|
||||
{
|
||||
"number": number,
|
||||
"name": name,
|
||||
"folder": folder_name,
|
||||
"path": spec_folder,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"has_build": has_build,
|
||||
}
|
||||
)
|
||||
|
||||
return specs
|
||||
|
||||
|
||||
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
|
||||
"""Print a formatted list of all specs.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
auto_create: If True and no specs exist, automatically launch spec creation
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
specs = list_specs(project_dir)
|
||||
|
||||
if not specs:
|
||||
print("\nNo specs found.")
|
||||
|
||||
if auto_create:
|
||||
# Get the backend directory and find spec_runner.py
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
spec_runner = backend_dir / "runners" / "spec_runner.py"
|
||||
|
||||
# Find Python executable - use current interpreter
|
||||
python_path = sys.executable
|
||||
|
||||
if spec_runner.exists() and python_path:
|
||||
# Quick prompt for task description
|
||||
print("\n" + "=" * 60)
|
||||
print(" QUICK START")
|
||||
print("=" * 60)
|
||||
print("\nWhat do you want to build?")
|
||||
print(
|
||||
"(Enter a brief description, or press Enter for interactive mode)\n"
|
||||
)
|
||||
|
||||
try:
|
||||
task = input("> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
|
||||
if task:
|
||||
# Direct mode: create spec and start building
|
||||
print(f"\nStarting build for: {task}\n")
|
||||
subprocess.run(
|
||||
[
|
||||
python_path,
|
||||
str(spec_runner),
|
||||
"--task",
|
||||
task,
|
||||
"--complexity",
|
||||
"simple",
|
||||
"--auto-approve",
|
||||
],
|
||||
cwd=project_dir,
|
||||
)
|
||||
else:
|
||||
# Interactive mode
|
||||
print("\nLaunching interactive mode...\n")
|
||||
subprocess.run(
|
||||
[python_path, str(spec_runner), "--interactive"],
|
||||
cwd=project_dir,
|
||||
)
|
||||
return
|
||||
else:
|
||||
print("\nCreate your first spec:")
|
||||
print(" python runners/spec_runner.py --interactive")
|
||||
else:
|
||||
print("\nCreate your first spec:")
|
||||
print(" python runners/spec_runner.py --interactive")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" AVAILABLE SPECS")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Status symbols
|
||||
status_symbols = {
|
||||
"complete": "[OK]",
|
||||
"in_progress": "[..]",
|
||||
"initialized": "[--]",
|
||||
"pending": "[ ]",
|
||||
}
|
||||
|
||||
for spec in specs:
|
||||
# Get base status for symbol
|
||||
base_status = spec["status"].split(" ")[0]
|
||||
symbol = status_symbols.get(base_status, "[??]")
|
||||
|
||||
print(f" {symbol} {spec['folder']}")
|
||||
status_line = f" Status: {spec['status']} | Subtasks: {spec['progress']}"
|
||||
print(status_line)
|
||||
print()
|
||||
|
||||
print("-" * 70)
|
||||
print("\nTo run a spec:")
|
||||
print(" python auto-claude/run.py --spec 001")
|
||||
print(" python auto-claude/run.py --spec 001-feature-name")
|
||||
print()
|
||||
@@ -1,278 +0,0 @@
|
||||
"""
|
||||
CLI Utilities
|
||||
==============
|
||||
|
||||
Shared utility functions for the Auto Claude CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_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 get_auth_token, get_auth_token_source
|
||||
from core.dependency_validator import validate_platform_dependencies
|
||||
|
||||
|
||||
def import_dotenv():
|
||||
"""
|
||||
Import and return load_dotenv with helpful error message if not installed.
|
||||
|
||||
This centralized function ensures consistent error messaging across all
|
||||
runner scripts when python-dotenv is not available.
|
||||
|
||||
Returns:
|
||||
The load_dotenv function
|
||||
|
||||
Raises:
|
||||
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
|
||||
"""
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
|
||||
return _load_dotenv
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Error: Required Python package 'python-dotenv' is not installed.\n"
|
||||
"\n"
|
||||
"This usually means you're not using the virtual environment.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. From the 'apps/backend/' directory, activate the venv:\n"
|
||||
" source .venv/bin/activate # Linux/macOS\n"
|
||||
" .venv\\Scripts\\activate # Windows\n"
|
||||
"\n"
|
||||
"2. Or install dependencies directly:\n"
|
||||
" pip install python-dotenv\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
# NOTE: graphiti_config is imported lazily in validate_environment() to avoid
|
||||
# triggering graphiti_core -> real_ladybug -> pywintypes import chain before
|
||||
# platform dependency validation can run. See ACS-253.
|
||||
from linear_integration import LinearManager
|
||||
from linear_updater import is_linear_enabled
|
||||
from spec.pipeline import get_specs_dir
|
||||
from ui import (
|
||||
Icons,
|
||||
bold,
|
||||
box,
|
||||
icon,
|
||||
muted,
|
||||
)
|
||||
|
||||
# Configuration - uses shorthand that resolves via API Profile if configured
|
||||
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
|
||||
|
||||
|
||||
def setup_environment() -> Path:
|
||||
"""
|
||||
Set up the environment and return the script directory.
|
||||
|
||||
Returns:
|
||||
Path to the auto-claude directory
|
||||
"""
|
||||
# Add auto-claude directory to path for imports
|
||||
script_dir = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
# Load .env file - check both auto-claude/ and dev/auto-claude/ locations
|
||||
env_file = script_dir / ".env"
|
||||
dev_env_file = script_dir.parent / "dev" / "auto-claude" / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
elif dev_env_file.exists():
|
||||
load_dotenv(dev_env_file)
|
||||
|
||||
return script_dir
|
||||
|
||||
|
||||
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
"""
|
||||
Find a spec by number or full name.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_identifier: Either "001" or "001-feature-name"
|
||||
|
||||
Returns:
|
||||
Path to spec folder, or None if not found
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
|
||||
if specs_dir.exists():
|
||||
# Try exact match first
|
||||
exact_path = specs_dir / spec_identifier
|
||||
if exact_path.exists() and (exact_path / "spec.md").exists():
|
||||
return exact_path
|
||||
|
||||
# Try matching by number prefix
|
||||
for spec_folder in specs_dir.iterdir():
|
||||
if spec_folder.is_dir() and spec_folder.name.startswith(
|
||||
spec_identifier + "-"
|
||||
):
|
||||
if (spec_folder / "spec.md").exists():
|
||||
return spec_folder
|
||||
|
||||
# Check worktree specs (for merge-preview, merge, review, discard operations)
|
||||
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if worktree_base.exists():
|
||||
# Try exact match in worktree
|
||||
worktree_spec = (
|
||||
worktree_base / spec_identifier / ".auto-claude" / "specs" / spec_identifier
|
||||
)
|
||||
if worktree_spec.exists() and (worktree_spec / "spec.md").exists():
|
||||
return worktree_spec
|
||||
|
||||
# Try matching by prefix in worktrees
|
||||
for worktree_dir in worktree_base.iterdir():
|
||||
if worktree_dir.is_dir() and worktree_dir.name.startswith(
|
||||
spec_identifier + "-"
|
||||
):
|
||||
spec_in_worktree = (
|
||||
worktree_dir / ".auto-claude" / "specs" / worktree_dir.name
|
||||
)
|
||||
if (
|
||||
spec_in_worktree.exists()
|
||||
and (spec_in_worktree / "spec.md").exists()
|
||||
):
|
||||
return spec_in_worktree
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_environment(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Validate that the environment is set up correctly.
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise (with error messages printed)
|
||||
"""
|
||||
# Validate platform-specific dependencies first (exits if missing)
|
||||
validate_platform_dependencies()
|
||||
|
||||
valid = True
|
||||
|
||||
# Check for OAuth token (API keys are not supported)
|
||||
if not get_auth_token():
|
||||
print("Error: No OAuth token found")
|
||||
print("\nAuto Claude requires Claude Code OAuth authentication.")
|
||||
print("Direct API keys (ANTHROPIC_API_KEY) are not supported.")
|
||||
print("\nTo authenticate, run:")
|
||||
print(" claude setup-token")
|
||||
valid = False
|
||||
else:
|
||||
# Show which auth source is being used
|
||||
source = get_auth_token_source()
|
||||
if source:
|
||||
print(f"Auth: {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"
|
||||
if not spec_file.exists():
|
||||
print(f"\nError: spec.md not found in {spec_dir}")
|
||||
valid = False
|
||||
|
||||
# Check Linear integration (optional but show status)
|
||||
if is_linear_enabled():
|
||||
print("Linear integration: ENABLED")
|
||||
# Show Linear project status if initialized
|
||||
project_dir = (
|
||||
spec_dir.parent.parent
|
||||
) # auto-claude/specs/001-name -> project root
|
||||
linear_manager = LinearManager(spec_dir, project_dir)
|
||||
if linear_manager.is_initialized:
|
||||
summary = linear_manager.get_progress_summary()
|
||||
print(f" Project: {summary.get('project_name', 'Unknown')}")
|
||||
print(
|
||||
f" Issues: {summary.get('mapped_subtasks', 0)}/{summary.get('total_subtasks', 0)} mapped"
|
||||
)
|
||||
else:
|
||||
print(" Status: Will be initialized during planner session")
|
||||
else:
|
||||
print("Linear integration: DISABLED (set LINEAR_API_KEY to enable)")
|
||||
|
||||
# Check Graphiti integration (optional but show status)
|
||||
# Lazy import to avoid triggering pywintypes import before validation (ACS-253)
|
||||
from graphiti_config import get_graphiti_status
|
||||
|
||||
graphiti_status = get_graphiti_status()
|
||||
if graphiti_status["available"]:
|
||||
print("Graphiti memory: ENABLED")
|
||||
print(f" Database: {graphiti_status['database']}")
|
||||
if graphiti_status.get("db_path"):
|
||||
print(f" Path: {graphiti_status['db_path']}")
|
||||
elif graphiti_status["enabled"]:
|
||||
print(
|
||||
f"Graphiti memory: CONFIGURED but unavailable ({graphiti_status['reason']})"
|
||||
)
|
||||
else:
|
||||
print("Graphiti memory: DISABLED (set GRAPHITI_ENABLED=true to enable)")
|
||||
|
||||
print()
|
||||
return valid
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
"""Print the Auto-Build banner."""
|
||||
content = [
|
||||
bold(f"{icon(Icons.LIGHTNING)} AUTO-BUILD FRAMEWORK"),
|
||||
"",
|
||||
"Autonomous Multi-Session Coding Agent",
|
||||
muted("Subtask-Based Implementation with Phase Dependencies"),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
|
||||
|
||||
def get_project_dir(provided_dir: Path | None) -> Path:
|
||||
"""
|
||||
Determine the project directory.
|
||||
|
||||
Args:
|
||||
provided_dir: User-provided project directory (or None)
|
||||
|
||||
Returns:
|
||||
Resolved project directory path
|
||||
"""
|
||||
if provided_dir:
|
||||
return provided_dir.resolve()
|
||||
|
||||
project_dir = Path.cwd()
|
||||
|
||||
# Auto-detect if running from within apps/backend directory (the source code)
|
||||
if project_dir.name == "backend" and (project_dir / "run.py").exists():
|
||||
# Running from within apps/backend/ source directory, go up 2 levels
|
||||
project_dir = project_dir.parent.parent
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
def find_specs_dir(project_dir: Path) -> Path:
|
||||
"""
|
||||
Find the specs directory for a project.
|
||||
|
||||
Returns the '.auto-claude/specs' directory path.
|
||||
The directory is guaranteed to exist (get_specs_dir calls init_auto_claude_dir).
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to specs directory (always returns a valid Path)
|
||||
"""
|
||||
return get_specs_dir(project_dir)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Claude client module facade.
|
||||
|
||||
Provides Claude API client utilities.
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid circular imports with auto_claude_tools."""
|
||||
from core import client as _client
|
||||
|
||||
return getattr(_client, name)
|
||||
|
||||
|
||||
def create_client(*args, **kwargs):
|
||||
"""Create a Claude client instance."""
|
||||
from core.client import create_client as _create_client
|
||||
|
||||
return _create_client(*args, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_client",
|
||||
]
|
||||
@@ -1,383 +0,0 @@
|
||||
"""
|
||||
Commit Message Generator
|
||||
========================
|
||||
|
||||
Generates high-quality commit messages using Claude Haiku.
|
||||
|
||||
Features:
|
||||
- Conventional commits format (feat/fix/refactor/etc)
|
||||
- GitHub issue references (Fixes #123)
|
||||
- Context-aware descriptions from spec metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map task categories to conventional commit types
|
||||
CATEGORY_TO_COMMIT_TYPE = {
|
||||
"feature": "feat",
|
||||
"bug_fix": "fix",
|
||||
"bug": "fix",
|
||||
"refactoring": "refactor",
|
||||
"refactor": "refactor",
|
||||
"documentation": "docs",
|
||||
"docs": "docs",
|
||||
"testing": "test",
|
||||
"test": "test",
|
||||
"performance": "perf",
|
||||
"perf": "perf",
|
||||
"security": "security",
|
||||
"chore": "chore",
|
||||
"style": "style",
|
||||
"ci": "ci",
|
||||
"build": "build",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """You are a Git expert who writes clear, concise commit messages following conventional commits format.
|
||||
|
||||
Rules:
|
||||
1. First line: type(scope): description (max 72 chars total)
|
||||
2. Leave blank line after first line
|
||||
3. Body: 1-3 sentences explaining WHAT changed and WHY
|
||||
4. If GitHub issue number provided, end with "Fixes #N" on its own line
|
||||
5. Be specific about the changes, not generic
|
||||
6. Use imperative mood ("Add feature" not "Added feature")
|
||||
|
||||
Types: feat, fix, refactor, docs, test, perf, chore, style, ci, build
|
||||
|
||||
Example output:
|
||||
feat(auth): add OAuth2 login flow
|
||||
|
||||
Implement OAuth2 authentication with Google and GitHub providers.
|
||||
Add token refresh logic and secure storage.
|
||||
|
||||
Fixes #42"""
|
||||
|
||||
|
||||
def _get_spec_context(spec_dir: Path) -> dict:
|
||||
"""
|
||||
Extract context from spec files for commit message generation.
|
||||
|
||||
Returns dict with:
|
||||
- title: Feature/task title
|
||||
- category: Task category (feature, bug_fix, etc)
|
||||
- description: Brief description
|
||||
- github_issue: GitHub issue number if linked
|
||||
"""
|
||||
context = {
|
||||
"title": "",
|
||||
"category": "chore",
|
||||
"description": "",
|
||||
"github_issue": None,
|
||||
}
|
||||
|
||||
# Try to read spec.md for title
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.exists():
|
||||
try:
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
# Extract title from first H1 or H2
|
||||
title_match = re.search(r"^#+ (.+)$", content, re.MULTILINE)
|
||||
if title_match:
|
||||
context["title"] = title_match.group(1).strip()
|
||||
|
||||
# Look for overview/description section
|
||||
overview_match = re.search(
|
||||
r"## Overview\s*\n(.+?)(?=\n##|\Z)", content, re.DOTALL
|
||||
)
|
||||
if overview_match:
|
||||
context["description"] = overview_match.group(1).strip()[:200]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read spec.md: {e}")
|
||||
|
||||
# Try to read requirements.json for metadata
|
||||
req_file = spec_dir / "requirements.json"
|
||||
if req_file.exists():
|
||||
try:
|
||||
req_data = json.loads(req_file.read_text(encoding="utf-8"))
|
||||
if not context["title"] and req_data.get("feature"):
|
||||
context["title"] = req_data["feature"]
|
||||
if req_data.get("workflow_type"):
|
||||
context["category"] = req_data["workflow_type"]
|
||||
if req_data.get("task_description") and not context["description"]:
|
||||
context["description"] = req_data["task_description"][:200]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read requirements.json: {e}")
|
||||
|
||||
# Try to read implementation_plan.json for GitHub issue
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
try:
|
||||
plan_data = json.loads(plan_file.read_text(encoding="utf-8"))
|
||||
# Check for GitHub metadata
|
||||
metadata = plan_data.get("metadata", {})
|
||||
if metadata.get("githubIssueNumber"):
|
||||
context["github_issue"] = metadata["githubIssueNumber"]
|
||||
# Fallback title
|
||||
if not context["title"]:
|
||||
context["title"] = plan_data.get("feature") or plan_data.get(
|
||||
"title", ""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read implementation_plan.json: {e}")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
spec_context: dict,
|
||||
diff_summary: str,
|
||||
files_changed: list[str],
|
||||
) -> str:
|
||||
"""Build the prompt for Claude."""
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
|
||||
github_ref = ""
|
||||
if spec_context.get("github_issue"):
|
||||
github_ref = f"\nGitHub Issue: #{spec_context['github_issue']} (include 'Fixes #{spec_context['github_issue']}' at the end)"
|
||||
|
||||
# Truncate file list if too long
|
||||
if len(files_changed) > 20:
|
||||
files_display = (
|
||||
"\n".join(files_changed[:20])
|
||||
+ f"\n... and {len(files_changed) - 20} more files"
|
||||
)
|
||||
else:
|
||||
files_display = (
|
||||
"\n".join(files_changed) if files_changed else "(no files listed)"
|
||||
)
|
||||
|
||||
prompt = f"""Generate a commit message for this change.
|
||||
|
||||
Task: {spec_context.get("title", "Unknown task")}
|
||||
Type: {commit_type}
|
||||
Files changed: {len(files_changed)}
|
||||
{github_ref}
|
||||
|
||||
Description: {spec_context.get("description", "No description available")}
|
||||
|
||||
Changed files:
|
||||
{files_display}
|
||||
|
||||
Diff summary:
|
||||
{diff_summary[:2000] if diff_summary else "(no diff available)"}
|
||||
|
||||
Generate ONLY the commit message, nothing else. Follow the format exactly:
|
||||
type(scope): short description
|
||||
|
||||
Body explaining changes.
|
||||
|
||||
Fixes #N (if applicable)"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
async def _call_claude(prompt: str) -> str:
|
||||
"""Call Claude for commit message generation.
|
||||
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
"""
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found")
|
||||
return ""
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available")
|
||||
return ""
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Commit message using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="commit_message",
|
||||
model=model,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
return response_text.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude SDK call failed: {e}")
|
||||
print(f" [WARN] Commit message generation failed: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def generate_commit_message_sync(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
diff_summary: str = "",
|
||||
files_changed: list[str] | None = None,
|
||||
github_issue: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message synchronously.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Spec identifier (e.g., "001-add-feature")
|
||||
diff_summary: Git diff stat or summary
|
||||
files_changed: List of changed file paths
|
||||
github_issue: GitHub issue number if linked (overrides spec metadata)
|
||||
|
||||
Returns:
|
||||
Generated commit message or fallback message
|
||||
"""
|
||||
# Find spec directory
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
# Try alternative location
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
# Get context from spec files
|
||||
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
|
||||
|
||||
# Override with provided github_issue
|
||||
if github_issue:
|
||||
spec_context["github_issue"] = github_issue
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_prompt(
|
||||
spec_context,
|
||||
diff_summary,
|
||||
files_changed or [],
|
||||
)
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# Already in an async context - run in a new thread
|
||||
# Use lambda to ensure coroutine is created inside the worker thread
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
|
||||
# Fallback message
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
title = spec_context.get("title", spec_name)
|
||||
fallback = f"{commit_type}: {title}"
|
||||
|
||||
if github_issue or spec_context.get("github_issue"):
|
||||
issue_num = github_issue or spec_context.get("github_issue")
|
||||
fallback += f"\n\nFixes #{issue_num}"
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
async def generate_commit_message(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
diff_summary: str = "",
|
||||
files_changed: list[str] | None = None,
|
||||
github_issue: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message asynchronously.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Spec identifier (e.g., "001-add-feature")
|
||||
diff_summary: Git diff stat or summary
|
||||
files_changed: List of changed file paths
|
||||
github_issue: GitHub issue number if linked (overrides spec metadata)
|
||||
|
||||
Returns:
|
||||
Generated commit message or fallback message
|
||||
"""
|
||||
# Find spec directory
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
# Get context from spec files
|
||||
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
|
||||
|
||||
# Override with provided github_issue
|
||||
if github_issue:
|
||||
spec_context["github_issue"] = github_issue
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_prompt(
|
||||
spec_context,
|
||||
diff_summary,
|
||||
files_changed or [],
|
||||
)
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
|
||||
# Fallback message
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
title = spec_context.get("title", spec_name)
|
||||
fallback = f"{commit_type}: {title}"
|
||||
|
||||
if github_issue or spec_context.get("github_issue"):
|
||||
issue_num = github_issue or spec_context.get("github_issue")
|
||||
fallback += f"\n\nFixes #{issue_num}"
|
||||
|
||||
return fallback
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Context Package
|
||||
===============
|
||||
|
||||
Task context building for autonomous coding.
|
||||
"""
|
||||
|
||||
from .builder import ContextBuilder
|
||||
from .categorizer import FileCategorizer
|
||||
from .graphiti_integration import fetch_graph_hints, is_graphiti_enabled
|
||||
from .keyword_extractor import KeywordExtractor
|
||||
from .models import FileMatch, TaskContext
|
||||
from .pattern_discovery import PatternDiscoverer
|
||||
from .search import CodeSearcher
|
||||
from .serialization import load_context, save_context, serialize_context
|
||||
from .service_matcher import ServiceMatcher
|
||||
|
||||
__all__ = [
|
||||
# Main builder
|
||||
"ContextBuilder",
|
||||
# Models
|
||||
"FileMatch",
|
||||
"TaskContext",
|
||||
# Components
|
||||
"CodeSearcher",
|
||||
"ServiceMatcher",
|
||||
"KeywordExtractor",
|
||||
"FileCategorizer",
|
||||
"PatternDiscoverer",
|
||||
# Graphiti integration
|
||||
"fetch_graph_hints",
|
||||
"is_graphiti_enabled",
|
||||
# Serialization
|
||||
"serialize_context",
|
||||
"save_context",
|
||||
"load_context",
|
||||
]
|
||||
@@ -1,250 +0,0 @@
|
||||
"""
|
||||
Context Builder
|
||||
===============
|
||||
|
||||
Main builder class that orchestrates context building for tasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .categorizer import FileCategorizer
|
||||
from .graphiti_integration import fetch_graph_hints, is_graphiti_enabled
|
||||
from .keyword_extractor import KeywordExtractor
|
||||
from .models import FileMatch, TaskContext
|
||||
from .pattern_discovery import PatternDiscoverer
|
||||
from .search import CodeSearcher
|
||||
from .service_matcher import ServiceMatcher
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds task-specific context by searching the codebase."""
|
||||
|
||||
def __init__(self, project_dir: Path, project_index: dict | None = None):
|
||||
self.project_dir = project_dir.resolve()
|
||||
self.project_index = project_index or self._load_project_index()
|
||||
|
||||
# Initialize components
|
||||
self.searcher = CodeSearcher(self.project_dir)
|
||||
self.service_matcher = ServiceMatcher(self.project_index)
|
||||
self.keyword_extractor = KeywordExtractor()
|
||||
self.categorizer = FileCategorizer()
|
||||
self.pattern_discoverer = PatternDiscoverer(self.project_dir)
|
||||
|
||||
def _load_project_index(self) -> dict:
|
||||
"""Load project index from file or create new one (.auto-claude is the installed instance)."""
|
||||
index_file = self.project_dir / ".auto-claude" / "project_index.json"
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Corrupted or legacy-encoded file, regenerate
|
||||
pass
|
||||
|
||||
# Try to create one
|
||||
from analyzer import analyze_project
|
||||
|
||||
return analyze_project(self.project_dir)
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
task: str,
|
||||
services: list[str] | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
include_graph_hints: bool = True,
|
||||
) -> TaskContext:
|
||||
"""
|
||||
Build context for a specific task.
|
||||
|
||||
Args:
|
||||
task: Description of the task
|
||||
services: List of service names to search (None = auto-detect)
|
||||
keywords: Additional keywords to search for
|
||||
include_graph_hints: Whether to include historical hints from Graphiti
|
||||
|
||||
Returns:
|
||||
TaskContext with relevant files and patterns
|
||||
"""
|
||||
# Auto-detect services if not specified
|
||||
if not services:
|
||||
services = self.service_matcher.suggest_services(task)
|
||||
|
||||
# Extract keywords from task if not provided
|
||||
if not keywords:
|
||||
keywords = self.keyword_extractor.extract_keywords(task)
|
||||
|
||||
# Search each service
|
||||
all_matches: list[FileMatch] = []
|
||||
service_contexts = {}
|
||||
|
||||
for service_name in services:
|
||||
service_info = self.project_index.get("services", {}).get(service_name)
|
||||
if not service_info:
|
||||
continue
|
||||
|
||||
service_path = Path(service_info.get("path", service_name))
|
||||
if not service_path.is_absolute():
|
||||
service_path = self.project_dir / service_path
|
||||
|
||||
# Search this service
|
||||
matches = self.searcher.search_service(service_path, service_name, keywords)
|
||||
all_matches.extend(matches)
|
||||
|
||||
# Load or generate service context
|
||||
service_contexts[service_name] = self._get_service_context(
|
||||
service_path, service_name, service_info
|
||||
)
|
||||
|
||||
# Categorize matches
|
||||
files_to_modify, files_to_reference = self.categorizer.categorize_matches(
|
||||
all_matches, task
|
||||
)
|
||||
|
||||
# Discover patterns from reference files
|
||||
patterns = self.pattern_discoverer.discover_patterns(
|
||||
files_to_reference, keywords
|
||||
)
|
||||
|
||||
# Get graph hints (synchronously wrap async call)
|
||||
graph_hints = []
|
||||
if include_graph_hints and is_graphiti_enabled():
|
||||
try:
|
||||
# Run the async function in a new event loop if necessary
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're already in an async context - this shouldn't happen in CLI
|
||||
# but handle it gracefully
|
||||
graph_hints = []
|
||||
except RuntimeError:
|
||||
# No event loop running - create one
|
||||
graph_hints = asyncio.run(
|
||||
fetch_graph_hints(task, str(self.project_dir))
|
||||
)
|
||||
except Exception:
|
||||
# Graphiti is optional - fail gracefully
|
||||
graph_hints = []
|
||||
|
||||
return TaskContext(
|
||||
task_description=task,
|
||||
scoped_services=services,
|
||||
files_to_modify=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify
|
||||
],
|
||||
files_to_reference=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference
|
||||
],
|
||||
patterns_discovered=patterns,
|
||||
service_contexts=service_contexts,
|
||||
graph_hints=graph_hints,
|
||||
)
|
||||
|
||||
async def build_context_async(
|
||||
self,
|
||||
task: str,
|
||||
services: list[str] | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
include_graph_hints: bool = True,
|
||||
) -> TaskContext:
|
||||
"""
|
||||
Build context for a specific task (async version).
|
||||
|
||||
This version is preferred when called from async code as it can
|
||||
properly await the graph hints retrieval.
|
||||
|
||||
Args:
|
||||
task: Description of the task
|
||||
services: List of service names to search (None = auto-detect)
|
||||
keywords: Additional keywords to search for
|
||||
include_graph_hints: Whether to include historical hints from Graphiti
|
||||
|
||||
Returns:
|
||||
TaskContext with relevant files and patterns
|
||||
"""
|
||||
# Auto-detect services if not specified
|
||||
if not services:
|
||||
services = self.service_matcher.suggest_services(task)
|
||||
|
||||
# Extract keywords from task if not provided
|
||||
if not keywords:
|
||||
keywords = self.keyword_extractor.extract_keywords(task)
|
||||
|
||||
# Search each service
|
||||
all_matches: list[FileMatch] = []
|
||||
service_contexts = {}
|
||||
|
||||
for service_name in services:
|
||||
service_info = self.project_index.get("services", {}).get(service_name)
|
||||
if not service_info:
|
||||
continue
|
||||
|
||||
service_path = Path(service_info.get("path", service_name))
|
||||
if not service_path.is_absolute():
|
||||
service_path = self.project_dir / service_path
|
||||
|
||||
# Search this service
|
||||
matches = self.searcher.search_service(service_path, service_name, keywords)
|
||||
all_matches.extend(matches)
|
||||
|
||||
# Load or generate service context
|
||||
service_contexts[service_name] = self._get_service_context(
|
||||
service_path, service_name, service_info
|
||||
)
|
||||
|
||||
# Categorize matches
|
||||
files_to_modify, files_to_reference = self.categorizer.categorize_matches(
|
||||
all_matches, task
|
||||
)
|
||||
|
||||
# Discover patterns from reference files
|
||||
patterns = self.pattern_discoverer.discover_patterns(
|
||||
files_to_reference, keywords
|
||||
)
|
||||
|
||||
# Get graph hints asynchronously
|
||||
graph_hints = []
|
||||
if include_graph_hints:
|
||||
graph_hints = await fetch_graph_hints(task, str(self.project_dir))
|
||||
|
||||
return TaskContext(
|
||||
task_description=task,
|
||||
scoped_services=services,
|
||||
files_to_modify=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify
|
||||
],
|
||||
files_to_reference=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference
|
||||
],
|
||||
patterns_discovered=patterns,
|
||||
service_contexts=service_contexts,
|
||||
graph_hints=graph_hints,
|
||||
)
|
||||
|
||||
def _get_service_context(
|
||||
self,
|
||||
service_path: Path,
|
||||
service_name: str,
|
||||
service_info: dict,
|
||||
) -> dict:
|
||||
"""Get or generate context for a service."""
|
||||
# Check for SERVICE_CONTEXT.md
|
||||
context_file = service_path / "SERVICE_CONTEXT.md"
|
||||
if context_file.exists():
|
||||
return {
|
||||
"source": "SERVICE_CONTEXT.md",
|
||||
"content": context_file.read_text(encoding="utf-8")[
|
||||
:2000
|
||||
], # First 2000 chars
|
||||
}
|
||||
|
||||
# Generate basic context from service info
|
||||
return {
|
||||
"source": "generated",
|
||||
"language": service_info.get("language"),
|
||||
"framework": service_info.get("framework"),
|
||||
"type": service_info.get("type"),
|
||||
"entry_point": service_info.get("entry_point"),
|
||||
"key_directories": service_info.get("key_directories", {}),
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
File Categorization
|
||||
===================
|
||||
|
||||
Categorizes files into those to modify vs those to reference.
|
||||
"""
|
||||
|
||||
from .models import FileMatch
|
||||
|
||||
|
||||
class FileCategorizer:
|
||||
"""Categorizes matched files based on task context."""
|
||||
|
||||
# Keywords that suggest modification
|
||||
MODIFY_KEYWORDS = [
|
||||
"add",
|
||||
"create",
|
||||
"implement",
|
||||
"fix",
|
||||
"update",
|
||||
"change",
|
||||
"modify",
|
||||
"new",
|
||||
]
|
||||
|
||||
def categorize_matches(
|
||||
self,
|
||||
matches: list[FileMatch],
|
||||
task: str,
|
||||
max_modify: int = 10,
|
||||
max_reference: int = 15,
|
||||
) -> tuple[list[FileMatch], list[FileMatch]]:
|
||||
"""
|
||||
Categorize matches into files to modify vs reference.
|
||||
|
||||
Args:
|
||||
matches: List of FileMatch objects to categorize
|
||||
task: Task description string
|
||||
max_modify: Maximum files to modify
|
||||
max_reference: Maximum reference files
|
||||
|
||||
Returns:
|
||||
Tuple of (files_to_modify, files_to_reference)
|
||||
"""
|
||||
to_modify = []
|
||||
to_reference = []
|
||||
|
||||
task_lower = task.lower()
|
||||
is_modification = any(kw in task_lower for kw in self.MODIFY_KEYWORDS)
|
||||
|
||||
for match in matches:
|
||||
# High relevance files in the "right" location are likely to be modified
|
||||
path_lower = match.path.lower()
|
||||
|
||||
is_test = "test" in path_lower or "spec" in path_lower
|
||||
is_example = "example" in path_lower or "sample" in path_lower
|
||||
is_config = "config" in path_lower and match.relevance_score < 5
|
||||
|
||||
if is_test or is_example or is_config:
|
||||
# Tests/examples are references
|
||||
match.reason = f"Reference pattern: {match.reason}"
|
||||
to_reference.append(match)
|
||||
elif match.relevance_score >= 5 and is_modification:
|
||||
# High relevance + modification task = likely to modify
|
||||
match.reason = f"Likely to modify: {match.reason}"
|
||||
to_modify.append(match)
|
||||
else:
|
||||
# Everything else is a reference
|
||||
match.reason = f"Related: {match.reason}"
|
||||
to_reference.append(match)
|
||||
|
||||
# Limit results
|
||||
return to_modify[:max_modify], to_reference[:max_reference]
|
||||
@@ -1,44 +0,0 @@
|
||||
"""
|
||||
Constants for Context Building
|
||||
================================
|
||||
|
||||
Configuration constants for directory skipping and file filtering.
|
||||
"""
|
||||
|
||||
# Directories to skip during code search
|
||||
SKIP_DIRS = {
|
||||
"node_modules",
|
||||
".git",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".nuxt",
|
||||
"target",
|
||||
"vendor",
|
||||
".idea",
|
||||
".vscode",
|
||||
"auto-claude",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
"coverage",
|
||||
".turbo",
|
||||
".cache",
|
||||
}
|
||||
|
||||
# File extensions to search for code files
|
||||
CODE_EXTENSIONS = {
|
||||
".py",
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".vue",
|
||||
".svelte",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".php",
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Graphiti Knowledge Graph Integration
|
||||
======================================
|
||||
|
||||
Integration with Graphiti for historical hints and cross-session context.
|
||||
"""
|
||||
|
||||
# Import graphiti providers for optional historical hints
|
||||
try:
|
||||
from graphiti_providers import get_graph_hints, is_graphiti_enabled
|
||||
|
||||
GRAPHITI_AVAILABLE = True
|
||||
except ImportError:
|
||||
GRAPHITI_AVAILABLE = False
|
||||
|
||||
def is_graphiti_enabled() -> bool:
|
||||
return False
|
||||
|
||||
async def get_graph_hints(
|
||||
query: str, project_id: str, max_results: int = 10
|
||||
) -> list:
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_graph_hints(
|
||||
query: str, project_id: str, max_results: int = 5
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get historical hints from Graphiti knowledge graph.
|
||||
|
||||
This provides context from past sessions and similar tasks.
|
||||
|
||||
Args:
|
||||
query: The task description or query to search for
|
||||
project_id: The project identifier (typically project path)
|
||||
max_results: Maximum number of hints to return
|
||||
|
||||
Returns:
|
||||
List of graph hints as dictionaries
|
||||
"""
|
||||
if not is_graphiti_enabled():
|
||||
return []
|
||||
|
||||
try:
|
||||
hints = await get_graph_hints(
|
||||
query=query,
|
||||
project_id=project_id,
|
||||
max_results=max_results,
|
||||
)
|
||||
return hints
|
||||
except Exception:
|
||||
# Graphiti is optional - fail gracefully
|
||||
return []
|
||||
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
Keyword Extraction
|
||||
==================
|
||||
|
||||
Extracts meaningful keywords from task descriptions for search.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class KeywordExtractor:
|
||||
"""Extracts and filters keywords from task descriptions."""
|
||||
|
||||
# Common words to filter out
|
||||
STOPWORDS = {
|
||||
"a",
|
||||
"an",
|
||||
"the",
|
||||
"to",
|
||||
"for",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"by",
|
||||
"with",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"must",
|
||||
"can",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"i",
|
||||
"you",
|
||||
"we",
|
||||
"they",
|
||||
"it",
|
||||
"add",
|
||||
"create",
|
||||
"make",
|
||||
"implement",
|
||||
"build",
|
||||
"fix",
|
||||
"update",
|
||||
"change",
|
||||
"modify",
|
||||
"when",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"new",
|
||||
"existing",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def extract_keywords(cls, task: str, max_keywords: int = 10) -> list[str]:
|
||||
"""
|
||||
Extract search keywords from task description.
|
||||
|
||||
Args:
|
||||
task: Task description string
|
||||
max_keywords: Maximum number of keywords to return
|
||||
|
||||
Returns:
|
||||
List of extracted keywords
|
||||
"""
|
||||
# Tokenize and filter
|
||||
words = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", task.lower())
|
||||
keywords = [w for w in words if w not in cls.STOPWORDS and len(w) > 2]
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
unique_keywords = []
|
||||
for kw in keywords:
|
||||
if kw not in seen:
|
||||
seen.add(kw)
|
||||
unique_keywords.append(kw)
|
||||
|
||||
return unique_keywords[:max_keywords]
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task Context Builder
|
||||
====================
|
||||
|
||||
Builds focused context for a specific task by searching relevant services.
|
||||
This is the "RAG-like" component that finds what files matter for THIS task.
|
||||
|
||||
Usage:
|
||||
# Find context for a task across specific services
|
||||
python auto-claude/context.py \
|
||||
--services backend,scraper \
|
||||
--keywords "retry,error,proxy" \
|
||||
--task "Add retry logic when proxies fail" \
|
||||
--output auto-claude/specs/001-retry/context.json
|
||||
|
||||
# Use project index to auto-suggest services
|
||||
python auto-claude/context.py \
|
||||
--task "Add retry logic when proxies fail" \
|
||||
--output context.json
|
||||
|
||||
The context builder will:
|
||||
1. Load project index (from analyzer)
|
||||
2. Search specified services for relevant files
|
||||
3. Find similar implementations to reference
|
||||
4. Output focused context for AI agents
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from context import (
|
||||
ContextBuilder,
|
||||
FileMatch,
|
||||
TaskContext,
|
||||
)
|
||||
from context.serialization import serialize_context
|
||||
|
||||
# Backward compatibility exports
|
||||
__all__ = [
|
||||
"ContextBuilder",
|
||||
"FileMatch",
|
||||
"TaskContext",
|
||||
"build_task_context",
|
||||
]
|
||||
|
||||
|
||||
def build_task_context(
|
||||
project_dir: Path,
|
||||
task: str,
|
||||
services: list[str] | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
output_file: Path | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Build context for a task and optionally save to file.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
task: Task description
|
||||
services: Services to search (None = auto-detect)
|
||||
keywords: Keywords to search for (None = extract from task)
|
||||
output_file: Optional path to save JSON output
|
||||
|
||||
Returns:
|
||||
Context as a dictionary
|
||||
"""
|
||||
builder = ContextBuilder(project_dir)
|
||||
context = builder.build_context(task, services, keywords)
|
||||
|
||||
result = serialize_context(context)
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
print(f"Task context saved to: {output_file}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build task-specific context by searching the codebase"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Project directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Description of the task",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--services",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Comma-separated list of services to search",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keywords",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Comma-separated list of keywords to search for",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output file for JSON results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Only output JSON, no status messages",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse comma-separated args
|
||||
services = args.services.split(",") if args.services else None
|
||||
keywords = args.keywords.split(",") if args.keywords else None
|
||||
|
||||
result = build_task_context(
|
||||
args.project_dir,
|
||||
args.task,
|
||||
services,
|
||||
keywords,
|
||||
args.output,
|
||||
)
|
||||
|
||||
if not args.quiet or not args.output:
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Data Models for Task Context
|
||||
=============================
|
||||
|
||||
Core data structures for representing file matches and task context.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMatch:
|
||||
"""A file that matched the search criteria."""
|
||||
|
||||
path: str
|
||||
service: str
|
||||
reason: str
|
||||
relevance_score: float = 0.0
|
||||
matching_lines: list[tuple[int, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskContext:
|
||||
"""Complete context for a task."""
|
||||
|
||||
task_description: str
|
||||
scoped_services: list[str]
|
||||
files_to_modify: list[dict]
|
||||
files_to_reference: list[dict]
|
||||
patterns_discovered: dict[str, str]
|
||||
service_contexts: dict[str, dict]
|
||||
graph_hints: list[dict] = field(
|
||||
default_factory=list
|
||||
) # Historical hints from Graphiti
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Pattern Discovery
|
||||
=================
|
||||
|
||||
Discovers code patterns from reference files to guide implementation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .models import FileMatch
|
||||
|
||||
|
||||
class PatternDiscoverer:
|
||||
"""Discovers code patterns from reference files."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir.resolve()
|
||||
|
||||
def discover_patterns(
|
||||
self,
|
||||
reference_files: list[FileMatch],
|
||||
keywords: list[str],
|
||||
max_files: int = 5,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Discover code patterns from reference files.
|
||||
|
||||
Args:
|
||||
reference_files: List of FileMatch objects to analyze
|
||||
keywords: Keywords to look for in the code
|
||||
max_files: Maximum number of files to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary mapping pattern keys to code snippets
|
||||
"""
|
||||
patterns = {}
|
||||
|
||||
for match in reference_files[:max_files]:
|
||||
try:
|
||||
file_path = self.project_dir / match.path
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
# Look for common patterns
|
||||
for keyword in keywords:
|
||||
if keyword in content.lower():
|
||||
# Extract a snippet around the keyword
|
||||
lines = content.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if keyword in line.lower():
|
||||
# Get context (3 lines before and after)
|
||||
start = max(0, i - 3)
|
||||
end = min(len(lines), i + 4)
|
||||
snippet = "\n".join(lines[start:end])
|
||||
|
||||
pattern_key = f"{keyword}_pattern"
|
||||
if pattern_key not in patterns:
|
||||
patterns[pattern_key] = (
|
||||
f"From {match.path}:\n{snippet[:300]}"
|
||||
)
|
||||
break
|
||||
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return patterns
|
||||
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
Code Search Functionality
|
||||
==========================
|
||||
|
||||
Search codebase for relevant files based on keywords.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .constants import CODE_EXTENSIONS, SKIP_DIRS
|
||||
from .models import FileMatch
|
||||
|
||||
|
||||
class CodeSearcher:
|
||||
"""Searches code files for relevant matches."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir.resolve()
|
||||
|
||||
def search_service(
|
||||
self,
|
||||
service_path: Path,
|
||||
service_name: str,
|
||||
keywords: list[str],
|
||||
) -> list[FileMatch]:
|
||||
"""
|
||||
Search a service for files matching keywords.
|
||||
|
||||
Args:
|
||||
service_path: Path to the service directory
|
||||
service_name: Name of the service
|
||||
keywords: List of keywords to search for
|
||||
|
||||
Returns:
|
||||
List of FileMatch objects sorted by relevance
|
||||
"""
|
||||
matches = []
|
||||
|
||||
if not service_path.exists():
|
||||
return matches
|
||||
|
||||
for file_path in self._iter_code_files(service_path):
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
content_lower = content.lower()
|
||||
|
||||
# Score this file
|
||||
score = 0
|
||||
matching_keywords = []
|
||||
matching_lines = []
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword in content_lower:
|
||||
# Count occurrences
|
||||
count = content_lower.count(keyword)
|
||||
score += min(count, 10) # Cap at 10 per keyword
|
||||
matching_keywords.append(keyword)
|
||||
|
||||
# Find matching lines (first 3 per keyword)
|
||||
lines = content.split("\n")
|
||||
found = 0
|
||||
for i, line in enumerate(lines, 1):
|
||||
if keyword in line.lower() and found < 3:
|
||||
matching_lines.append((i, line.strip()[:100]))
|
||||
found += 1
|
||||
|
||||
if score > 0:
|
||||
rel_path = str(file_path.relative_to(self.project_dir))
|
||||
matches.append(
|
||||
FileMatch(
|
||||
path=rel_path,
|
||||
service=service_name,
|
||||
reason=f"Contains: {', '.join(matching_keywords)}",
|
||||
relevance_score=score,
|
||||
matching_lines=matching_lines[:5], # Top 5 lines
|
||||
)
|
||||
)
|
||||
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Sort by relevance
|
||||
matches.sort(key=lambda m: m.relevance_score, reverse=True)
|
||||
return matches[:20] # Top 20 per service
|
||||
|
||||
def _iter_code_files(self, directory: Path):
|
||||
"""
|
||||
Iterate over code files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Root directory to search
|
||||
|
||||
Yields:
|
||||
Path objects for code files
|
||||
"""
|
||||
for item in directory.rglob("*"):
|
||||
if item.is_file() and item.suffix in CODE_EXTENSIONS:
|
||||
# Check if in skip directory
|
||||
parts = item.relative_to(directory).parts
|
||||
if not any(part in SKIP_DIRS for part in parts):
|
||||
yield item
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
Context Serialization
|
||||
=====================
|
||||
|
||||
Handles serialization and deserialization of task context.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .models import TaskContext
|
||||
|
||||
|
||||
def serialize_context(context: TaskContext) -> dict:
|
||||
"""
|
||||
Convert TaskContext to dictionary for JSON serialization.
|
||||
|
||||
Args:
|
||||
context: TaskContext object to serialize
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
"task_description": context.task_description,
|
||||
"scoped_services": context.scoped_services,
|
||||
"files_to_modify": context.files_to_modify,
|
||||
"files_to_reference": context.files_to_reference,
|
||||
"patterns": context.patterns_discovered,
|
||||
"service_contexts": context.service_contexts,
|
||||
"graph_hints": context.graph_hints,
|
||||
}
|
||||
|
||||
|
||||
def save_context(context: TaskContext, output_file: Path) -> None:
|
||||
"""
|
||||
Save task context to JSON file.
|
||||
|
||||
Args:
|
||||
context: TaskContext to save
|
||||
output_file: Path to output JSON file
|
||||
"""
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(serialize_context(context), f, indent=2)
|
||||
|
||||
|
||||
def load_context(input_file: Path) -> dict:
|
||||
"""
|
||||
Load task context from JSON file.
|
||||
|
||||
Args:
|
||||
input_file: Path to JSON file
|
||||
|
||||
Returns:
|
||||
Context dictionary
|
||||
"""
|
||||
with open(input_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""
|
||||
Service Matching and Suggestion
|
||||
=================================
|
||||
|
||||
Suggests relevant services based on task description.
|
||||
"""
|
||||
|
||||
|
||||
class ServiceMatcher:
|
||||
"""Matches services to tasks based on keywords and metadata."""
|
||||
|
||||
def __init__(self, project_index: dict):
|
||||
self.project_index = project_index
|
||||
|
||||
def suggest_services(self, task: str) -> list[str]:
|
||||
"""
|
||||
Suggest which services are relevant for a task.
|
||||
|
||||
Args:
|
||||
task: Task description string
|
||||
|
||||
Returns:
|
||||
List of service names most relevant to the task
|
||||
"""
|
||||
task_lower = task.lower()
|
||||
services = self.project_index.get("services", {})
|
||||
suggested = []
|
||||
|
||||
for service_name, service_info in services.items():
|
||||
score = 0
|
||||
name_lower = service_name.lower()
|
||||
|
||||
# Check if service name is mentioned
|
||||
if name_lower in task_lower:
|
||||
score += 10
|
||||
|
||||
# Check service type relevance
|
||||
service_type = service_info.get("type", "")
|
||||
if service_type == "backend" and any(
|
||||
kw in task_lower
|
||||
for kw in ["api", "endpoint", "route", "database", "model"]
|
||||
):
|
||||
score += 5
|
||||
if service_type == "frontend" and any(
|
||||
kw in task_lower for kw in ["ui", "component", "page", "button", "form"]
|
||||
):
|
||||
score += 5
|
||||
if service_type == "worker" and any(
|
||||
kw in task_lower
|
||||
for kw in ["job", "task", "queue", "background", "async"]
|
||||
):
|
||||
score += 5
|
||||
if service_type == "scraper" and any(
|
||||
kw in task_lower for kw in ["scrape", "crawl", "fetch", "parse"]
|
||||
):
|
||||
score += 5
|
||||
|
||||
# Check framework relevance
|
||||
framework = service_info.get("framework", "").lower()
|
||||
if framework and framework in task_lower:
|
||||
score += 3
|
||||
|
||||
if score > 0:
|
||||
suggested.append((service_name, score))
|
||||
|
||||
# Sort by score and return top services
|
||||
suggested.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
if suggested:
|
||||
return [s[0] for s in suggested[:3]] # Top 3
|
||||
|
||||
# Default: return first backend and first frontend
|
||||
default = []
|
||||
for name, info in services.items():
|
||||
if info.get("type") == "backend" and "backend" not in [s for s in default]:
|
||||
default.append(name)
|
||||
elif info.get("type") == "frontend" and "frontend" not in [
|
||||
s for s in default
|
||||
]:
|
||||
default.append(name)
|
||||
return default[:2] if default else list(services.keys())[:2]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Core Framework Module
|
||||
=====================
|
||||
|
||||
Core components for the Auto Claude autonomous coding framework.
|
||||
"""
|
||||
|
||||
# Note: We use lazy imports here because the full agent module has many dependencies
|
||||
# that may not be needed for basic operations like workspace management.
|
||||
|
||||
__all__ = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"WorkspaceManager",
|
||||
"WorktreeManager",
|
||||
"ProgressTracker",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies and heavy imports."""
|
||||
if name in ("run_autonomous_agent", "run_followup_planner"):
|
||||
from .agent import run_autonomous_agent, run_followup_planner
|
||||
|
||||
return locals()[name]
|
||||
elif name == "WorkspaceManager":
|
||||
from .workspace import WorkspaceManager
|
||||
|
||||
return WorkspaceManager
|
||||
elif name == "WorktreeManager":
|
||||
from .worktree import WorktreeManager
|
||||
|
||||
return WorktreeManager
|
||||
elif name == "ProgressTracker":
|
||||
from .progress import ProgressTracker
|
||||
|
||||
return ProgressTracker
|
||||
elif name in ("create_claude_client", "ClaudeClient"):
|
||||
from . import client as _client
|
||||
|
||||
return getattr(_client, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user