Compare commits

..

1 Commits

Author SHA1 Message Date
coderabbitai[bot] a0f538624f 📝 Add docstrings to feature/worktree-ui-config
Docstrings generation was requested by @sbeardsley.

* https://github.com/AndyMik90/Auto-Claude/pull/456#issuecomment-3703000464

The following files were modified:

* `apps/backend/cli/batch_commands.py`
* `apps/backend/cli/main.py`
* `apps/backend/core/config.py`
* `apps/backend/core/workspace/models.py`
* `apps/backend/core/worktree.py`
* `apps/frontend/src/renderer/components/project-settings/GeneralSettings.tsx`
* `apps/frontend/src/renderer/components/project-settings/WorktreeSettings.tsx`
* `apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx`
2025-12-31 23:00:13 +00:00
250 changed files with 3333 additions and 36722 deletions
+8 -60
View File
@@ -71,11 +71,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@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -97,21 +92,13 @@ jobs:
- 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@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -166,11 +153,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@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -189,21 +171,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-3.12.8-
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -257,11 +231,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@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -281,21 +250,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -327,11 +288,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@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -359,21 +315,13 @@ jobs:
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@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
+15 -11
View File
@@ -2,7 +2,7 @@ name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security]
workflows: [CI, Lint, Quality Security, CLA Assistant, Quality Commit Lint]
types: [completed]
permissions:
@@ -31,25 +31,29 @@ jobs:
// ═══════════════════════════════════════════════════════════════════════
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
// ═══════════════════════════════════════════════════════════════════════
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}"
// Format: "{Workflow Name} / {Job Name} (pull_request)"
//
// To find check names: Go to PR → Checks tab → copy exact name
// To update: Edit this list when workflow jobs are added/renamed/removed
//
// Last validated: 2026-01-02
// Last validated: 2025-12-26
// ═══════════════════════════════════════════════════════════════════════
const requiredChecks = [
// CI workflow (ci.yml) - 3 checks
'CI / test-frontend',
'CI / test-python (3.12)',
'CI / test-python (3.13)',
'CI / test-frontend (pull_request)',
'CI / test-python (3.12) (pull_request)',
'CI / test-python (3.13) (pull_request)',
// Lint workflow (lint.yml) - 1 check
'Lint / python',
'Lint / python (pull_request)',
// Quality Security workflow (quality-security.yml) - 4 checks
'Quality Security / CodeQL (javascript-typescript)',
'Quality Security / CodeQL (python)',
'Quality Security / Python Security (Bandit)',
'Quality Security / Security Summary'
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
'Quality Security / CodeQL (python) (pull_request)',
'Quality Security / Python Security (Bandit) (pull_request)',
'Quality Security / Security Summary (pull_request)',
// CLA Assistant workflow (cla.yml) - 1 check
'CLA Assistant / CLA Check',
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
'Quality Commit Lint / Conventional Commits (pull_request)'
];
const statusLabels = {
+6 -124
View File
@@ -1,10 +1,8 @@
name: Prepare Release
# Triggers when code is pushed to main (e.g., merging develop → main)
# If package.json version is newer than the latest tag:
# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing)
# 2. Extracts release notes from CHANGELOG.md
# 3. Creates a new tag which triggers release.yml
# If package.json version is newer than the latest tag, creates a new tag
# which then triggers the release.yml workflow
on:
push:
@@ -69,122 +67,8 @@ jobs:
echo "⏭️ No release needed (package version not newer than latest tag)"
fi
# CRITICAL: Validate CHANGELOG.md has entry for this version BEFORE creating tag
- name: Validate and extract changelog
if: steps.check.outputs.should_release == 'true'
id: changelog
run: |
VERSION="${{ steps.check.outputs.new_version }}"
CHANGELOG_FILE="CHANGELOG.md"
echo "🔍 Validating CHANGELOG.md for version $VERSION..."
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "::error::CHANGELOG.md not found! Please create CHANGELOG.md with release notes."
exit 1
fi
# Extract changelog section for this version
# Looks for "## X.Y.Z" header and captures until next "## " or "---" or end
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
BEGIN { found=0; content="" }
/^## / {
if (found) exit
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
found=1
# Skip the header line itself, we will add our own
next
}
}
/^---$/ { if (found) exit }
found { content = content $0 "\n" }
END {
if (!found) {
print "NOT_FOUND"
exit 1
}
# Trim leading/trailing whitespace
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
print content
}
' "$CHANGELOG_FILE")
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo ""
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error:: CHANGELOG VALIDATION FAILED"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error::"
echo "::error:: Version $VERSION not found in CHANGELOG.md!"
echo "::error::"
echo "::error:: Before releasing, please update CHANGELOG.md with an entry like:"
echo "::error::"
echo "::error:: ## $VERSION - Your Release Title"
echo "::error::"
echo "::error:: ### ✨ New Features"
echo "::error:: - Feature description"
echo "::error::"
echo "::error:: ### 🐛 Bug Fixes"
echo "::error:: - Fix description"
echo "::error::"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo ""
# Also add to job summary for visibility
echo "## ❌ Release Blocked: Missing Changelog" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Version **$VERSION** was not found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### How to fix:" >> $GITHUB_STEP_SUMMARY
echo "1. Update CHANGELOG.md with release notes for version $VERSION" >> $GITHUB_STEP_SUMMARY
echo "2. Commit and push the changes" >> $GITHUB_STEP_SUMMARY
echo "3. The release will automatically retry" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Expected format:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`markdown" >> $GITHUB_STEP_SUMMARY
echo "## $VERSION - Release Title" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✨ New Features" >> $GITHUB_STEP_SUMMARY
echo "- Feature description" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🐛 Bug Fixes" >> $GITHUB_STEP_SUMMARY
echo "- Fix description" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "✅ Found changelog entry for version $VERSION"
echo ""
echo "--- Extracted Release Notes ---"
echo "$CHANGELOG_CONTENT"
echo "--- End Release Notes ---"
# Save changelog to file for artifact upload
echo "$CHANGELOG_CONTENT" > changelog-extract.md
# Also save to output (for short changelogs)
# Using heredoc for multiline output
{
echo "content<<CHANGELOG_EOF"
echo "$CHANGELOG_CONTENT"
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
echo "changelog_valid=true" >> $GITHUB_OUTPUT
# Upload changelog as artifact for release.yml to use
- name: Upload changelog artifact
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
uses: actions/upload-artifact@v4
with:
name: changelog-${{ steps.check.outputs.new_version }}
path: changelog-extract.md
retention-days: 1
- name: Create and push tag
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
if: steps.check.outputs.should_release == 'true'
run: |
VERSION="${{ steps.check.outputs.new_version }}"
TAG="v$VERSION"
@@ -201,19 +85,17 @@ jobs:
- name: Summary
run: |
if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then
if [ "${{ steps.check.outputs.should_release }}" = "true" ]; then
echo "## 🚀 Release Triggered" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** v${{ steps.check.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ Changelog validated and extracted from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The release workflow has been triggered and will:" >> $GITHUB_STEP_SUMMARY
echo "1. Build binaries for all platforms" >> $GITHUB_STEP_SUMMARY
echo "2. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY
echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY
echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then
else
echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY
+23 -133
View File
@@ -46,21 +46,13 @@ jobs:
- 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@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -101,8 +93,6 @@ jobs:
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
@@ -133,21 +123,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-3.12.8-
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -188,8 +170,6 @@ jobs:
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
build-windows:
runs-on: windows-latest
@@ -220,21 +200,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -252,8 +224,6 @@ jobs:
name: windows-builds
path: |
apps/frontend/dist/*.exe
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
build-linux:
runs-on: ubuntu-latest
@@ -291,21 +261,13 @@ jobs:
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@v4
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@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -323,8 +285,6 @@ jobs:
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
apps/frontend/dist/*.flatpak
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
create-release:
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
@@ -344,30 +304,16 @@ jobs:
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec cp {} release-assets/ \;
# Validate that installer files exist (not just manifests)
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$installer_count" -eq 0 ]; then
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
exit 1
fi
echo "Found $installer_count installer(s):"
find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \;
# Validate that electron-updater manifest files are present (required for auto-updates)
yml_count=$(find release-assets -type f -name "*.yml" | wc -l)
if [ "$yml_count" -eq 0 ]; then
echo "::error::No update manifest (.yml) files found! Auto-update architecture detection will not work."
exit 1
fi
echo "Found $yml_count manifest file(s):"
find release-assets -type f -name "*.yml" -exec basename {} \;
echo ""
echo "All release assets:"
echo "Found $artifact_count artifact(s):"
ls -la release-assets/
- name: Generate checksums
@@ -527,78 +473,23 @@ jobs:
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Extract changelog from CHANGELOG.md
if: ${{ github.event_name == 'push' }}
- name: Generate changelog
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
id: changelog
run: |
# Extract version from tag (v2.7.2 -> 2.7.2)
VERSION=${GITHUB_REF_NAME#v}
CHANGELOG_FILE="CHANGELOG.md"
echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..."
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "::warning::CHANGELOG.md not found, using minimal release notes"
echo "body=Release v$VERSION" >> $GITHUB_OUTPUT
exit 0
fi
# Extract changelog section for this version
# Looks for "## X.Y.Z" header and captures until next "## " or "---"
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
BEGIN { found=0; content="" }
/^## / {
if (found) exit
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
found=1
next
}
}
/^---$/ { if (found) exit }
found { content = content $0 "\n" }
END {
if (!found) {
print "NOT_FOUND"
exit 0
}
# Trim leading/trailing whitespace
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
print content
}
' "$CHANGELOG_FILE")
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
CHANGELOG_CONTENT="Release v$VERSION
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi
echo "✅ Extracted changelog content"
# Save to file first (more reliable for multiline)
echo "$CHANGELOG_CONTENT" > changelog-body.md
# Use file-based output for multiline content
{
echo "body<<CHANGELOG_EOF"
cat changelog-body.md
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
uses: softprops/action-gh-release@v2
with:
body: |
${{ steps.changelog.outputs.body }}
---
${{ steps.virustotal.outputs.vt_results }}
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
files: release-assets/*
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
@@ -609,8 +500,7 @@ See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGEL
update-readme:
needs: [create-release]
runs-on: ubuntu-latest
# Only update README on actual releases (tag push), not dry runs
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
permissions:
contents: write
steps:
-1
View File
@@ -163,4 +163,3 @@ _bmad-output/
.claude/
/docs
OPUS_ANALYSIS_AND_IDEAS.md
/.github/agents
+18 -57
View File
@@ -36,44 +36,14 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
echo " Updated apps/backend/__init__.py to $VERSION"
fi
# Sync to README.md - section-aware updates (stable vs beta)
# Sync to README.md
if [ -f "README.md" ]; then
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link (within BETA_VERSION_BADGE section)
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue) - within TOP_VERSION_BADGE section
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
# Update version badge - match both stable (X.Y.Z) and prerelease (X.Y.Z-prerelease.N or X.Y.Z--prerelease.N)
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" README.md
# Update download links - match both stable and prerelease versions
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*\(-[a-z]*\.[0-9]*\)*/Auto-Claude-$VERSION/g" README.md
rm -f README.md.bak
git add README.md
echo " Updated README.md to $VERSION"
@@ -102,25 +72,20 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
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
# Run ruff linting (auto-fix)
echo "Running ruff lint..."
$RUFF check apps/backend/ --fix
if [ $? -ne 0 ]; then
echo "Ruff lint failed. Please fix Python linting errors before committing."
exit 1
fi
# Run ruff format (auto-fix)
echo "Running ruff format..."
$RUFF format apps/backend/
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
else
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
fi
@@ -163,10 +128,6 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run TypeScript type check
echo "Running type check..."
+6 -33
View File
@@ -25,41 +25,14 @@ repos:
# 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)
# Sync to README.md - shields.io version badge (text and URL)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
sed -i.bak -e "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" -e "s|releases/tag/v[0-9.a-z-]*)|releases/tag/v$VERSION)|g" README.md
# Detect if this is a prerelease (contains - after base version)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue)
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue)
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
# Sync to README.md - download links with correct filenames and URLs
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak "s|Auto-Claude-[0-9.a-z-]*-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-${SUFFIX})|Auto-Claude-${VERSION}-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v${VERSION}/Auto-Claude-${VERSION}-${SUFFIX})|g" README.md
done
rm -f README.md.bak
# Stage changes
-280
View File
@@ -1,283 +1,3 @@
## 2.7.2 - Stability & Performance Enhancements
### ✨ New Features
- Added refresh button to Kanban board for manually reloading tasks
- Terminal dropdown with built-in and external options in task review
- Centralized CLI tool path management with customizable settings
- Files tab in task details panel for better file organization
- Enhanced PR review page with filtering capabilities
- GitLab integration support
- Automated PR review with follow-up support and structured outputs
- UI scale feature with 75-200% range for accessibility
- Python 3.12 bundled with packaged Electron app
- OpenRouter support as LLM/embedding provider
- Internationalization (i18n) system for multi-language support
- Flatpak packaging support for Linux
- Path-aware AI merge resolution with device code streaming
### 🛠️ Improvements
- Improved terminal experience with persistent state when switching projects
- Enhanced PR review with structured outputs and fork support
- Better UX for display and scaling changes
- Convert synchronous I/O to async operations in worktree handlers
- Enhanced logs for commit linting stage
- Remove top navigation bars for cleaner UI
- Enhanced PR detail area visual design
- Improved CLI tool detection with more language support
- Added iOS/Swift project detection
- Optimize performance by removing projectTabs from useEffect dependencies
- Improved Python detection and version validation for compatibility
### 🐛 Bug Fixes
- Fixed CI Python setup and PR status gate checks
- Fixed cross-platform CLI path detection and clearing in settings
- Preserve original task description after spec creation
- Fixed learning loop to retrieve patterns and gotchas from memory
- Resolved frontend lag and updated dependencies
- Fixed Content-Security-Policy to allow external HTTPS images
- Fixed PR review isolation by using temporary worktree
- Fixed Homebrew Python detection to prefer versioned Python over system python3
- Added support for Bun 1.2.0+ lock file format detection
- Fixed infinite re-render loop in task selection
- Fixed infinite loop in task detail merge preview loading
- Resolved Windows EINVAL error when opening worktree in VS Code
- Fixed fallback to prevent tasks stuck in ai_review status
- Fixed SDK permissions to include spec_dir
- Added --base-branch argument support to spec_runner
- Allow Windows to run CC PR Reviewer
- Fixed model selection to respect task_metadata.json
- Improved GitHub PR review by passing repo parameter explicitly
- Fixed electron-log imports with .js extension
- Fixed Swift detection order in project analyzer
- Prevent TaskEditDialog from unmounting when opened
- Fixed subprocess handling for Python paths with spaces
- Fixed file system race conditions and unused variables in security scanning
- Resolved Python detection and backend packaging issues
- Fixed version-specific links in README and pre-commit hooks
- Fixed task status persistence reverting on refresh
- Proper semver comparison for pre-release versions
- Use virtual environment Python for all services to fix dotenv errors
- Fixed explicit Windows System32 tar path for builds
- Added augmented PATH environment to all GitHub CLI calls
- Use PowerShell for tar extraction on Windows
- Added --force-local flag to tar on Windows
- Stop tracking spec files in git
- Fixed GitHub API calls with explicit GET method for comment fetches
- Support archiving tasks across all worktree locations
- Validated backend source path before using it
- Resolved spawn Python ENOENT error on Linux
- Fixed CodeQL alerts for uncontrolled command line
- Resolved GitHub follow-up review API issues
- Fixed relative path normalization to POSIX format
- Accepted bug_fix workflow_type alias during planning
- Added global spec numbering lock to prevent collisions
- Fixed ideation status sync
- Stopped running process when task status changes away from in_progress
- Removed legacy path from auto-claude source detection
- Resolved Python environment race condition
---
## What's Changed
- fix(ci): add Python setup to beta-release and fix PR status gate checks (#565) by @Andy in c2148bb9
- fix: detect and clear cross-platform CLI paths in settings (#535) by @Andy in 29e45505
- fix(ui): preserve original task description after spec creation (#536) by @Andy in 7990dcb4
- fix(memory): fix learning loop to retrieve patterns and gotchas (#530) by @Andy in f58c2578
- fix: resolve frontend lag and update dependencies (#526) by @Andy in 30f7951a
- feat(kanban): add refresh button to manually reload tasks (#548) by @Adryan Serage in 252242f9
- fix(csp): allow external HTTPS images in Content-Security-Policy (#549) by @Michael Ludlow in 3db02c5d
- fix(pr-review): use temporary worktree for PR review isolation (#532) by @Andy in 344ec65e
- fix: prefer versioned Homebrew Python over system python3 (#494) by @Navid in 8d58dd6f
- fix(detection): support bun.lock text format for Bun 1.2.0+ (#525) by @Andy in 4da8cd66
- chore: bump version to 2.7.2-beta.12 (#460) by @Andy in 8e5c11ac
- Fix/windows issues (#471) by @Andy in 72106109
- fix(ci): add Rust toolchain for Intel Mac builds (#459) by @Andy in 52a4fcc6
- fix: create spec.md during roadmap-to-task conversion (#446) by @Mulaveesala Pranaveswar in fb6b7fc6
- fix(pr-review): treat LOW-only findings as ready to merge (#455) by @Andy in 0f9c5b84
- Fix/2.7.2 beta12 (#424) by @Andy in 5d8ede23
- feat: remove top bars (#386) by @Vinícius Santos in da31b687
- fix: prevent infinite re-render loop in task selection useEffect (#442) by @Abe Diaz in 2effa535
- fix: accept Python 3.12+ in install-backend.js (#443) by @Abe Diaz in c15bb311
- fix: infinite loop in useTaskDetail merge preview loading (#444) by @Abe Diaz in 203a970a
- fix(windows): resolve EINVAL error when opening worktree in VS Code (#434) by @Vinícius Santos in 3c0708b7
- feat(frontend): Add Files tab to task details panel (#430) by @Mitsu in 666794b5
- refactor: remove deprecated TaskDetailPanel component (#432) by @Mitsu in ac8dfcac
- fix(ui): add fallback to prevent tasks stuck in ai_review status (#397) by @Michael Ludlow in 798ca79d
- feat: Enhance the look of the PR Detail area (#427) by @Alex in bdb01549
- ci: remove conventional commits PR title validation workflow by @AndyMik90 in 515b73b5
- fix(client): add spec_dir to SDK permissions (#429) by @Mitsu in 88c76059
- fix(spec_runner): add --base-branch argument support (#428) by @Mitsu in 62a75515
- feat: enhance pr review page to include PRs filters (#423) by @Alex in 717fba04
- feat: add gitlab integration (#254) by @Mitsu in 0a571d3a
- fix: Allow windows to run CC PR Reviewer (#406) by @Alex in 2f662469
- fix(model): respect task_metadata.json model selection (#415) by @Andy in e7e6b521
- feat(build): add Flatpak packaging support for Linux (#404) by @Mitsu in 230de5fc
- fix(github): pass repo parameter to GHClient for explicit PR resolution (#413) by @Andy in 4bdf7a0c
- chore(ci): remove redundant CLA GitHub Action workflow by @AndyMik90 in a39ea49d
- fix(frontend): add .js extension to electron-log/main imports by @AndyMik90 in 9aef0dd0
- fix: 2.7.2 bug fixes and improvements (#388) by @Andy in 05131217
- fix(analyzer): move Swift detection before Ruby detection (#401) by @Michael Ludlow in 321c9712
- fix(ui): prevent TaskEditDialog from unmounting when opened (#395) by @Michael Ludlow in 98b12ed8
- fix: improve CLI tool detection and add Claude CLI path settings (#393) by @Joe in aaa83131
- feat(analyzer): add iOS/Swift project detection (#389) by @Michael Ludlow in 68548e33
- fix(github): improve PR review with structured outputs and fork support (#363) by @Andy in 7751588e
- fix(ideation): update progress calculation to include just-completed ideation type (#381) by @Illia Filippov in 8b4ce58c
- Fixes failing spec - "gh CLI Check Handler - should return installed: true when gh CLI is found" (#370) by @Ian in bc220645
- fix: Memory Status card respects configured embedding provider (#336) (#373) by @Michael Ludlow in db0cbea3
- fix: fixed version-specific links in readme and pre-commit hook that updates them (#378) by @Ian in 0ca2e3f6
- docs: add security research documentation (#361) by @Brian in 2d3b7fb4
- fix/Improving UX for Display/Scaling Changes (#332) by @Kevin Rajan in 9bbdef09
- fix(perf): remove projectTabs from useEffect deps to fix re-render loop (#362) by @Michael Ludlow in 753dc8bb
- fix(security): invalidate profile cache when file is created/modified (#355) by @Michael Ludlow in 20f20fa3
- fix(subprocess): handle Python paths with spaces (#352) by @Michael Ludlow in eabe7c7d
- fix: Resolve pre-commit hook failures with version sync, pytest path, ruff version, and broken quality-dco workflow (#334) by @Ian in 1fa7a9c7
- fix(terminal): preserve terminal state when switching projects (#358) by @Andy in 7881b2d1
- fix(analyzer): add C#/Java/Swift/Kotlin project files to security hash (#351) by @Michael Ludlow in 4e71361b
- fix: make backend tests pass on Windows (#282) by @Oluwatosin Oyeladun in 4dcc5afa
- fix(ui): close parent modal when Edit dialog opens (#354) by @Michael Ludlow in e9782db0
- chore: bump version to 2.7.2-beta.10 by @AndyMik90 in 40d04d7c
- feat: add terminal dropdown with inbuilt and external options in task review (#347) by @JoshuaRileyDev in fef07c95
- refactor: remove deprecated code across backend and frontend (#348) by @Mitsu in 9d43abed
- feat: centralize CLI tool path management (#341) by @HSSAINI Saad in d51f4562
- refactor(components): remove deprecated TaskDetailPanel re-export (#344) by @Mitsu in 787667e9
- chore: Refactor/kanban realtime status sync (#249) by @souky-byte in 9734b70b
- refactor(settings): remove deprecated ProjectSettings modal and hooks (#343) by @Mitsu in fec6b9f3
- perf: convert synchronous I/O to async operations in worktree handlers (#337) by @JoshuaRileyDev in d3a63b09
- feat: bump version (#329) by @Alex in 50e3111a
- fix(ci): remove version bump to fix branch protection conflict (#325) by @Michael Ludlow in 8a80b1d5
- fix(tasks): sync status to worktree implementation plan to prevent reset (#243) (#323) by @Alex in cb6b2165
- fix(ci): add auto-updater manifest files and version auto-update (#317) by @Michael Ludlow in 661e47c3
- fix(project): fix task status persistence reverting on refresh (#246) (#318) by @Michael Ludlow in e80ef79d
- fix(updater): proper semver comparison for pre-release versions (#313) by @Michael Ludlow in e1b0f743
- fix(python): use venv Python for all services to fix dotenv errors (#311) by @Alex in 92c6f278
- chore(ci): cancel in-progress runs (#302) by @Oluwatosin Oyeladun in 1c142273
- fix(build): use explicit Windows System32 tar path (#308) by @Andy in c0a02a45
- fix(github): add augmented PATH env to all gh CLI calls by @AndyMik90 in 086429cb
- fix(build): use PowerShell for tar extraction on Windows by @AndyMik90 in d9fb8f29
- fix(build): add --force-local flag to tar on Windows (#303) by @Andy in d0b0b3df
- fix: stop tracking spec files in git (#295) by @Andy in 937a60f8
- Fix/2.7.2 fixes (#300) by @Andy in 7a51cbd5
- feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296) by @Andy in 26beefe3
- feat: enhance the logs for the commit linting stage (#293) by @Alex in 8416f307
- fix(github): add explicit GET method to gh api comment fetches (#294) by @Andy in 217249c8
- fix(frontend): support archiving tasks across all worktree locations (#286) by @Andy in 8bb3df91
- Potential fix for code scanning alert no. 224: Uncontrolled command line (#285) by @Andy in 5106c6e9
- fix(frontend): validate backend source path before using it (#287) by @Andy in 3ff61274
- 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
- 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
- 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
- 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
- feat: add i18n internationalization system (#248) by @Mitsu in f8438112
- Revert "Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)" (#251) by @Andy in 5e8c5308
- Feat/Auto Fix Github issues and do extensive AI PR reviews (#250) by @Andy in 348de6df
- fix: resolve Python detection and backend packaging issues (#241) by @HSSAINI Saad in 0f7d6e05
- fix: add future annotations import to discovery.py (#229) by @Joris Slagter in 5ccdb6ab
- Fix/ideation status sync (#212) by @souky-byte in 6ec8549f
- fix(core): add global spec numbering lock to prevent collisions (#209) by @Andy in 53527293
- feat: Add OpenRouter as LLM/embedding provider (#162) by @Fernando Possebon in 02bef954
- fix: Add Python 3.10+ version validation and GitHub Actions Python setup (#180 #167) (#208) by @Fernando Possebon in f168bdc3
- fix(ci): correct welcome workflow PR message (#206) by @Andy in e3eec68a
- Feat/beta release (#193) by @Andy in 407a0bee
- feat/beta-release (#190) by @Andy in 8f766ad1
- fix/PRs from old main setup to apps structure (#185) by @Andy in ced2ad47
- fix: hide status badge when execution phase badge is showing (#154) by @Andy in 05f5d303
- feat: Add UI scale feature with 75-200% range (#125) by @Enes Cingöz in 6951251b
- fix(task): stop running process when task status changes away from in_progress by @AndyMik90 in 30e7536b
- Fix/linear 400 error by @Andy in 220faf0f
- fix: remove legacy path from auto-claude source detection (#148) by @Joris Slagter in f96c6301
- fix: resolve Python environment race condition (#142) by @Joris Slagter in ebd8340d
- Feat: Ollama download progress tracking with new apps structure (#141) by @rayBlock in df779530
- Feature/apps restructure v2.7.2 (#138) by @Andy in 0adaddac
- docs: Add Git Flow branching strategy to CONTRIBUTING.md by @AndyMik90 in 91f7051d
## Thanks to all contributors
@Andy, @Adryan Serage, @Michael Ludlow, @Navid, @Mulaveesala Pranaveswar, @Vinícius Santos, @Abe Diaz, @Mitsu, @Alex, @AndyMik90, @Joe, @Illia Filippov, @Ian, @Brian, @Kevin Rajan, @Oluwatosin Oyeladun, @JoshuaRileyDev, @HSSAINI Saad, @souky-byte, @Todd W. Bucy, @dependabot[bot], @Daniel Frey, @delyethan, @Joris Slagter, @Fernando Possebon, @Enes Cingöz, @rayBlock
## 2.7.1 - Build Pipeline Enhancements
### 🛠️ Improvements
+7 -7
View File
@@ -5,7 +5,7 @@
![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png)
<!-- TOP_VERSION_BADGE -->
[![Version](https://img.shields.io/badge/version-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
[![Version](https://img.shields.io/badge/version-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
<!-- TOP_VERSION_BADGE_END -->
[![License](https://img.shields.io/badge/license-AGPL--3.0-green?style=flat-square)](./agpl-3.0.txt)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/KCXaPBr4Dj)
@@ -18,17 +18,17 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
[![Stable](https://img.shields.io/badge/stable-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) |
<!-- STABLE_DOWNLOADS_END -->
### Beta Release
+23 -90
View File
@@ -69,38 +69,9 @@ This will:
- Update `apps/frontend/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`
### Step 2: Update CHANGELOG.md (REQUIRED)
**IMPORTANT: The release will fail if CHANGELOG.md doesn't have an entry for the new version.**
Add release notes to `CHANGELOG.md` at the top of the file:
```markdown
## 2.8.0 - Your Release Title
### ✨ New Features
- Feature description
### 🛠️ Improvements
- Improvement description
### 🐛 Bug Fixes
- Fix description
---
```
Then amend the version bump commit:
```bash
git add CHANGELOG.md
git commit --amend --no-edit
```
### Step 3: Push and Create PR
### Step 2: Push and Create PR
```bash
# Push your branch
@@ -110,25 +81,24 @@ git push origin your-branch
gh pr create --base main --title "Release v2.8.0"
```
### Step 4: Merge to Main
### Step 3: Merge to Main
Once the PR is approved and merged to `main`, GitHub Actions will automatically:
1. **Detect the version bump** (`prepare-release.yml`)
2. **Validate CHANGELOG.md** has an entry for the new version (FAILS if missing)
3. **Extract release notes** from CHANGELOG.md
4. **Create a git tag** (e.g., `v2.8.0`)
5. **Trigger the release workflow** (`release.yml`)
6. **Build binaries** for all platforms:
2. **Create a git tag** (e.g., `v2.8.0`)
3. **Trigger the release workflow** (`release.yml`)
4. **Build binaries** for all platforms:
- macOS Intel (x64) - code signed & notarized
- macOS Apple Silicon (arm64) - code signed & notarized
- Windows (NSIS installer) - code signed
- Linux (AppImage + .deb)
7. **Scan binaries** with VirusTotal
8. **Create GitHub release** with release notes from CHANGELOG.md
9. **Update README** with new version badge and download links
5. **Generate changelog** from merged PRs (using release-drafter)
6. **Scan binaries** with VirusTotal
7. **Create GitHub release** with all artifacts
8. **Update README** with new version badge and download links
### Step 5: Verify
### Step 4: Verify
After merging, check:
- [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass
@@ -143,49 +113,28 @@ We follow [Semantic Versioning](https://semver.org/):
- **MINOR** (0.X.0): New features, backwards compatible
- **PATCH** (0.0.X): Bug fixes, backwards compatible
## Changelog Management
## Changelog Generation
Release notes are managed in `CHANGELOG.md` and used for GitHub releases.
Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter).
### Changelog Format
### PR Labels for Changelog Categories
Each version entry in `CHANGELOG.md` should follow this format:
| Label | Category |
|-------|----------|
| `feature`, `enhancement` | New Features |
| `bug`, `fix` | Bug Fixes |
| `improvement`, `refactor` | Improvements |
| `documentation` | Documentation |
| (any other) | Other Changes |
```markdown
## X.Y.Z - Release Title
### ✨ New Features
- Feature description with context
### 🛠️ Improvements
- Improvement description
### 🐛 Bug Fixes
- Fix description
---
```
### Changelog Validation
The release workflow **validates** that `CHANGELOG.md` has an entry for the version being released:
- If the entry is **missing**, the release is **blocked** with a clear error message
- If the entry **exists**, its content is used for the GitHub release notes
### Writing Good Release Notes
- **Be specific**: Instead of "Fixed bug", write "Fixed crash when opening large files"
- **Group by impact**: Features first, then improvements, then fixes
- **Credit contributors**: Mention contributors for significant changes
- **Link issues**: Reference GitHub issues where relevant (e.g., "Fixes #123")
**Tip:** Add appropriate labels to your PRs for better changelog organization.
## Workflows
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, creates release |
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
| `update-readme` (in release.yml) | After release | Updates README with new version |
@@ -204,22 +153,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
git diff HEAD~1 --name-only | grep package.json
```
### Release blocked: Missing changelog entry
If you see "CHANGELOG VALIDATION FAILED" in the workflow:
1. The `prepare-release.yml` workflow validated that `CHANGELOG.md` doesn't have an entry for the new version
2. **Fix**: Add an entry to `CHANGELOG.md` with the format `## X.Y.Z - Title`
3. Commit and push the changelog update
4. The workflow will automatically retry when the changes are pushed to `main`
```bash
# Add changelog entry, then:
git add CHANGELOG.md
git commit -m "docs: add changelog for vX.Y.Z"
git push origin main
```
### Build failed after tag was created
- The release won't be published if builds fail
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.2"
__version__ = "2.7.2-beta.10"
__author__ = "Auto Claude Team"
-17
View File
@@ -257,33 +257,16 @@ async def run_autonomous_agent(
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
# Create client (fresh context) with phase-specific model and thinking
# Use appropriate agent_type for correct tool permissions and thinking budget
client = create_client(
project_dir,
spec_dir,
phase_model,
agent_type="planner" if first_run else "coder",
max_thinking_tokens=phase_thinking_budget,
)
# Generate appropriate prompt
if first_run:
prompt = generate_planner_prompt(spec_dir, project_dir)
# Retrieve Graphiti memory context for planning phase
# This gives the planner knowledge of previous patterns, gotchas, and insights
planner_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Planning implementation for new feature",
"id": "planner",
},
)
if planner_context:
prompt += "\n\n" + planner_context
print_status("Graphiti memory context loaded for planner", "success")
first_run = False
current_log_phase = LogPhase.PLANNING
+1 -37
View File
@@ -146,12 +146,6 @@ async def get_graphiti_context(
# 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)
@@ -162,12 +156,10 @@ async def get_graphiti_context(
"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 not context_items and not session_history:
if is_debug_enabled():
debug("memory", "No relevant context found in Graphiti")
return None
@@ -183,34 +175,6 @@ async def get_graphiti_context(
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
+23 -26
View File
@@ -21,7 +21,6 @@ from progress import (
is_build_complete,
)
from recovery import RecoveryManager
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -387,43 +386,41 @@ async def run_agent_session(
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input_display = None
tool_input = 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"]
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
elif "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input = cmd
elif "path" in inp:
tool_input = 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,
tool_input=tool_input,
full_input=str(block.input)[:500]
if hasattr(block, "input")
else None,
)
# Log tool start (handles printing too)
if task_logger:
task_logger.tool_start(
tool_name,
tool_input_display,
phase,
print_to_console=True,
tool_name, tool_input, phase, print_to_console=True
)
else:
print(f"\n[Tool: {tool_name}]", flush=True)
+2 -3
View File
@@ -216,9 +216,8 @@ AGENT_CONFIGS = {
# 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,
# Read-only + Bash (for running tests) - reviewer should NOT edit code
"tools": BASE_READ_TOOLS + ["Bash"] + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
"mcp_servers_optional": ["linear"], # For updating issue status
"auto_claude_tools": [
+2 -6
View File
@@ -11,17 +11,14 @@ import shutil
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-parse", "HEAD"],
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -35,9 +32,8 @@ def get_latest_commit(project_dir: Path) -> str | None:
def get_commit_count(project_dir: Path) -> int:
"""Get the total number of commits."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-list", "--count", "HEAD"],
["git", "rev-list", "--count", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -408,6 +408,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
return "pnpm"
elif self._exists("yarn.lock"):
return "yarn"
elif self._exists("bun.lockb") or self._exists("bun.lock"):
elif self._exists("bun.lockb"):
return "bun"
return "npm"
+3 -8
View File
@@ -18,8 +18,6 @@ import subprocess
from pathlib import Path
from typing import Any
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
# Check for Claude SDK availability
@@ -88,9 +86,8 @@ def get_session_diff(
return "(No changes - same commit)"
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "diff", commit_before, commit_after],
["git", "diff", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
@@ -134,9 +131,8 @@ def get_changed_files(
return []
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "diff", "--name-only", commit_before, commit_after],
["git", "diff", "--name-only", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
@@ -160,9 +156,8 @@ def get_commit_messages(
return "(No commits)"
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "log", "--oneline", f"{commit_before}..{commit_after}"],
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
cwd=project_dir,
capture_output=True,
text=True,
+1 -1
View File
@@ -275,7 +275,7 @@ class TestDiscovery:
return "yarn"
if (project_dir / "package-lock.json").exists():
return "npm"
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
if (project_dir / "bun.lockb").exists():
return "bun"
if (project_dir / "uv.lock").exists():
return "uv"
+17 -10
View File
@@ -6,6 +6,7 @@ Commands for creating and managing multiple tasks from batch files.
"""
import json
import os
from pathlib import Path
from ui import highlight, print_status
@@ -174,17 +175,23 @@ def handle_batch_status_command(project_dir: str) -> bool:
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
Clean up completed spec directories and their associated worktree paths.
Finds spec directories under <project_dir>/.auto-claude/specs that contain a `qa_report.md` (treated as completed),
and, when run in dry-run mode, prints the specs and corresponding worktree paths that would be removed.
Parameters:
project_dir (str): Path to the project root.
dry_run (bool): If True, print what would be removed instead of performing deletions.
Returns:
True if successful
True if the command completed.
"""
from core.config import get_worktree_base_path
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
worktree_base_path = get_worktree_base_path(Path(project_dir))
worktrees_dir = Path(project_dir) / worktree_base_path
if not specs_dir.exists():
print_status("No specs directory found", "info")
@@ -209,8 +216,8 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
print(f" - {spec_name}")
wt_path = worktrees_dir / spec_name
if wt_path.exists():
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
print(f" └─ {worktree_base_path}/{spec_name}/")
print()
print("Run with --no-dry-run to actually delete")
return True
return True
+14 -2
View File
@@ -15,6 +15,8 @@ _PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from dotenv import load_dotenv
from .batch_commands import (
handle_batch_cleanup_command,
@@ -259,7 +261,11 @@ Environment Variables:
def main() -> None:
"""Main CLI entry point."""
"""
Entry point for the CLI: sets up the environment, parses arguments, and dispatches the requested command.
This function initializes runtime environment and debugging, resolves the project directory (and loads a project-specific .auto-claude/.env file if present), determines the model choice from the CLI or the AUTO_BUILD_MODEL environment variable, and routes control to the appropriate handler based on parsed CLI flags (examples include listing specs, worktree management, batch operations, merge/preview/review/discard flows, QA and follow-up commands, or the normal build flow). Exits the process with a non-zero status when required by invalid input or failing command outcomes.
"""
# Set up environment first
setup_environment()
@@ -276,6 +282,12 @@ def main() -> None:
project_dir = get_project_dir(args.project_dir)
debug("run.py", f"Using project directory: {project_dir}")
# Load project-specific .env file (overrides backend .env)
project_env = project_dir / ".auto-claude" / ".env"
if project_env.exists():
load_dotenv(project_env, override=True)
debug("run.py", f"Loaded project .env from: {project_env}")
# 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")
@@ -410,4 +422,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
+3 -3
View File
@@ -28,8 +28,8 @@ from ui import (
muted,
)
# Configuration - uses shorthand that resolves via API Profile if configured
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
# Configuration
DEFAULT_MODEL = "claude-opus-4-5-20251101"
def setup_environment() -> Path:
@@ -82,7 +82,7 @@ def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
return spec_folder
# Check worktree specs (for merge-preview, merge, review, discard operations)
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
worktree_base = project_dir / ".worktrees"
if worktree_base.exists():
# Try exact match in worktree
worktree_spec = (
+12 -19
View File
@@ -9,8 +9,6 @@ import subprocess
import sys
from pathlib import Path
from core.git_bash import get_git_executable_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:
@@ -60,14 +58,12 @@ def _detect_default_branch(project_dir: Path) -> str:
"""
import os
git_path = get_git_executable_path()
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
[git_path, "rev-parse", "--verify", env_branch],
["git", "rev-parse", "--verify", env_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -78,7 +74,7 @@ def _detect_default_branch(project_dir: Path) -> str:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
[git_path, "rev-parse", "--verify", branch],
["git", "rev-parse", "--verify", branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -103,10 +99,9 @@ def _get_changed_files_from_git(
Returns:
List of changed file paths
"""
git_path = get_git_executable_path()
try:
result = subprocess.run(
[git_path, "diff", "--name-only", f"{base_branch}...HEAD"],
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -124,7 +119,7 @@ def _get_changed_files_from_git(
# Fallback: try without the three-dot notation
try:
result = subprocess.run(
[git_path, "diff", "--name-only", base_branch, "HEAD"],
["git", "diff", "--name-only", base_branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -231,9 +226,8 @@ def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None
diff_summary = ""
files_changed = []
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "diff", "--staged", "--stat"],
["git", "diff", "--staged", "--stat"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -243,7 +237,7 @@ def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None
# Get list of changed files
result = subprocess.run(
[git_path, "diff", "--staged", "--name-only"],
["git", "diff", "--staged", "--name-only"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -381,7 +375,6 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
debug(MODULE, "Checking for git-level merge conflicts (non-destructive)...")
git_path = get_git_executable_path()
spec_branch = f"auto-claude/{spec_name}"
result = {
"has_conflicts": False,
@@ -395,7 +388,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
try:
# Get the current branch (base branch)
base_result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -405,7 +398,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get the merge base commit
merge_base_result = subprocess.run(
[git_path, "merge-base", result["base_branch"], spec_branch],
["git", "merge-base", result["base_branch"], spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -418,7 +411,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Count commits main is ahead
ahead_result = subprocess.run(
[git_path, "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -437,7 +430,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
merge_tree_result = subprocess.run(
[
git_path,
"git",
"merge-tree",
"--write-tree",
"--no-messages",
@@ -481,7 +474,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
if not result["conflicting_files"]:
# Files changed in main since merge-base
main_files_result = subprocess.run(
[git_path, "diff", "--name-only", merge_base, result["base_branch"]],
["git", "diff", "--name-only", merge_base, result["base_branch"]],
cwd=project_dir,
capture_output=True,
text=True,
@@ -494,7 +487,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Files changed in spec branch since merge-base
spec_files_result = subprocess.run(
[git_path, "diff", "--name-only", merge_base, spec_branch],
["git", "diff", "--name-only", merge_base, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
-20
View File
@@ -7,15 +7,10 @@ for custom API endpoints.
"""
import json
import logging
import os
import platform
import subprocess
from core.git_bash import get_git_bash_env
logger = logging.getLogger(__name__)
# Priority order for auth token resolution
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
# Auto Claude is designed to use Claude Code OAuth tokens only.
@@ -28,15 +23,8 @@ AUTH_TOKEN_ENV_VARS = [
# Environment variables to pass through to SDK subprocess
# NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing
SDK_ENV_VARS = [
# API endpoint configuration
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
# Model overrides (from API Profile custom model mappings)
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
# SDK behavior configuration
"NO_PROXY",
"DISABLE_TELEMETRY",
"DISABLE_COST_WARNINGS",
@@ -227,8 +215,6 @@ def get_sdk_env_vars() -> dict[str, str]:
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
be passed through to the claude-agent-sdk subprocess.
On Windows, also includes CLAUDE_CODE_GIT_BASH_PATH if git-bash is found.
Returns:
Dict of env var name -> value for non-empty vars
"""
@@ -237,12 +223,6 @@ def get_sdk_env_vars() -> dict[str, str]:
value = os.environ.get(var)
if value:
env[var] = value
# On Windows, add git-bash path for Claude SDK sandbox
git_bash_env = get_git_bash_env()
if git_bash_env:
env.update(git_bash_env)
return env
+2 -103
View File
@@ -12,114 +12,13 @@ The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
single source of truth for phase-aware tool and MCP server configuration.
"""
import copy
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# =============================================================================
# Project Index Cache
# =============================================================================
# Caches project index and capabilities to avoid reloading on every create_client() call.
# This significantly reduces the time to create new agent sessions.
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
def _get_cached_project_data(
project_dir: Path,
) -> tuple[dict[str, Any], dict[str, bool]]:
"""
Get project index and capabilities with caching.
Args:
project_dir: Path to the project directory
Returns:
Tuple of (project_index, project_capabilities)
"""
key = str(project_dir.resolve())
now = time.time()
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Check cache with lock
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = now - cached_time
if cache_age < _CACHE_TTL_SECONDS:
if debug:
print(
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
)
logger.debug(f"Using cached project index for {project_dir}")
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
elif debug:
print(
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
)
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
load_start = time.time()
logger.debug(f"Loading project index for {project_dir}")
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
if debug:
load_duration = (time.time() - load_start) * 1000
print(
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
)
# Store in cache with lock - use double-checked locking pattern
# Re-check if another thread populated the cache while we were loading
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = time.time() - cached_time
if cache_age < _CACHE_TTL_SECONDS:
# Another thread already cached valid data while we were loading
if debug:
print(
"[ClientCache] Cache was populated by another thread, using cached data"
)
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
# Either no cache entry or it's expired - store our fresh data
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
# Return the freshly loaded data (no need to copy since it's not from cache)
return project_index, project_capabilities
def invalidate_project_cache(project_dir: Path | None = None) -> None:
"""
Invalidate the project index cache.
Args:
project_dir: Specific project to invalidate, or None to clear all
"""
with _CACHE_LOCK:
if project_dir is None:
_PROJECT_INDEX_CACHE.clear()
logger.debug("Cleared all project index cache entries")
else:
key = str(project_dir.resolve())
if key in _PROJECT_INDEX_CACHE:
del _PROJECT_INDEX_CACHE[key]
logger.debug(f"Invalidated project index cache for {project_dir}")
from agents.tools_pkg import (
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
@@ -497,8 +396,8 @@ def create_client(
# Load project capabilities for dynamic MCP tool selection
# This enables context-aware tool injection based on project type
# Uses caching to avoid reloading on every create_client() call
project_index, project_capabilities = _get_cached_project_data(project_dir)
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
# Load per-project MCP configuration from .auto-claude/.env
mcp_config = load_project_mcp_config(project_dir)
+78
View File
@@ -0,0 +1,78 @@
"""
Core configuration for Auto Claude.
This module provides centralized configuration management for Auto Claude,
including worktree path resolution and validation. It ensures consistent
configuration access across the entire backend codebase.
Constants:
WORKTREE_BASE_PATH_VAR (str): Environment variable name for custom worktree base path.
DEFAULT_WORKTREE_PATH (str): Default worktree directory name relative to project root.
Example:
>>> from core.config import get_worktree_base_path
>>> from pathlib import Path
>>>
>>> # Get worktree path with validation
>>> project_dir = Path("/path/to/project")
>>> worktree_path = get_worktree_base_path(project_dir)
>>> full_path = project_dir / worktree_path
"""
import os
from pathlib import Path
# Environment variable names
WORKTREE_BASE_PATH_VAR = "WORKTREE_BASE_PATH"
"""str: Environment variable name for configuring custom worktree base path.
Users can set this environment variable in their project's .env file to specify
a custom location for worktree directories, supporting both relative and absolute paths.
"""
# Default values
DEFAULT_WORKTREE_PATH = ".worktrees"
"""str: Default worktree directory name.
This is the fallback value used when WORKTREE_BASE_PATH is not set or when
validation fails (e.g., path points to .auto-claude/ or .git/ directories).
"""
def get_worktree_base_path(project_dir: Path | None = None) -> str:
"""
Determine the validated worktree base path from the WORKTREE_BASE_PATH environment variable or the default.
Parameters:
project_dir (Path | None): Optional project root used to resolve relative paths and perform stricter validation. If omitted, only basic pattern checks are applied.
Returns:
str: The configured worktree base path string, or DEFAULT_WORKTREE_PATH ('.worktrees') if the configured value is invalid or points inside the project's `.auto-claude` or `.git` directories.
"""
worktree_base_path = os.getenv(WORKTREE_BASE_PATH_VAR, DEFAULT_WORKTREE_PATH)
# If no project_dir provided, return as-is (basic validation only)
if not project_dir:
# Check for obviously dangerous patterns
normalized = Path(worktree_base_path).as_posix()
if ".auto-claude" in normalized or ".git" in normalized:
return DEFAULT_WORKTREE_PATH
return worktree_base_path
# Resolve the absolute path
if Path(worktree_base_path).is_absolute():
resolved = Path(worktree_base_path).resolve()
else:
resolved = (project_dir / worktree_base_path).resolve()
# Prevent paths inside .auto-claude/ or .git/
auto_claude_dir = (project_dir / ".auto-claude").resolve()
git_dir = (project_dir / ".git").resolve()
resolved_str = str(resolved)
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
str(git_dir)
):
return DEFAULT_WORKTREE_PATH
return worktree_base_path
-434
View File
@@ -1,434 +0,0 @@
"""
Windows Git Bash Detection
Detects git-bash (bash.exe from Git for Windows) for Claude Agent SDK.
The SDK requires bash.exe on Windows for its sandbox functionality.
Detection Priority:
1. CLAUDE_CODE_GIT_BASH_PATH environment variable (user override)
2. Standard Git installation paths (Program Files)
3. User-specific installations (LocalAppData, Scoop, Chocolatey)
Note: This detects bash.exe, NOT git.exe. They are different executables:
- git.exe: The Git CLI
- bash.exe: Unix shell bundled with Git for Windows (required by Claude SDK)
"""
import logging
import os
import platform
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# Environment variable for user override
ENV_VAR_NAME = "CLAUDE_CODE_GIT_BASH_PATH"
@dataclass
class GitBashDetectionResult:
"""Result of git-bash detection attempt."""
found: bool
path: Optional[str] = None
source: str = "not-found"
message: str = ""
# Module-level cache (persists for process lifetime)
_cache: Optional[GitBashDetectionResult] = None
def _find_git_executable() -> Optional[str]:
"""
Find git.exe using multiple methods.
GUI apps on Windows often have different PATH than terminal sessions.
We try multiple approaches to find git.
Returns:
Path to git.exe if found, None otherwise
"""
import shutil
import subprocess
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Method 1: shutil.which (standard PATH lookup)
git_path = shutil.which("git")
if git_path:
if debug:
print(f"[GitBash] Found git via shutil.which: {git_path}")
return git_path
# Method 2: Windows 'where' command (searches PATH differently)
try:
result = subprocess.run(
["where", "git"],
capture_output=True,
text=True,
timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
if result.returncode == 0 and result.stdout.strip():
git_path = result.stdout.strip().split('\n')[0].strip()
if os.path.isfile(git_path):
if debug:
print(f"[GitBash] Found git via 'where' command: {git_path}")
return git_path
except Exception as e:
if debug:
print(f"[GitBash] 'where git' failed: {e}")
# Method 3: Check Windows Registry for Git install location
try:
import winreg
for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
for key_path in [
r"SOFTWARE\GitForWindows",
r"SOFTWARE\WOW6432Node\GitForWindows",
]:
try:
with winreg.OpenKey(hive, key_path) as key:
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
if install_path:
# Try multiple possible git.exe locations
for subpath in ["cmd\\git.exe", "bin\\git.exe", "mingw64\\bin\\git.exe"]:
git_exe = os.path.join(install_path, subpath)
if os.path.isfile(git_exe):
if debug:
print(f"[GitBash] Found git via registry: {git_exe}")
return git_exe
except (FileNotFoundError, OSError):
continue
except ImportError:
pass # winreg not available (non-Windows)
return None
def _find_bash_from_registry() -> Optional[str]:
"""
Find bash.exe directly from Windows Registry Git InstallPath.
This is more reliable than PATH-based detection for GUI apps.
Returns:
Path to bash.exe if found via registry, None otherwise
"""
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
try:
import winreg
for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
for key_path in [
r"SOFTWARE\GitForWindows",
r"SOFTWARE\WOW6432Node\GitForWindows",
]:
try:
with winreg.OpenKey(hive, key_path) as key:
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
if install_path:
# Check for bash.exe in standard locations
bash_exe = os.path.join(install_path, "bin", "bash.exe")
if os.path.isfile(bash_exe):
if debug:
print(f"[GitBash] Found bash.exe via registry: {bash_exe}")
return bash_exe
# Try usr/bin as fallback
bash_exe = os.path.join(install_path, "usr", "bin", "bash.exe")
if os.path.isfile(bash_exe):
if debug:
print(f"[GitBash] Found bash.exe via registry: {bash_exe}")
return bash_exe
except (FileNotFoundError, OSError):
continue
except ImportError:
pass # winreg not available (non-Windows)
return None
def _derive_bash_from_git(git_path: str) -> Optional[str]:
"""
Derive bash.exe location from git.exe path.
Git for Windows structure:
<install>/cmd/git.exe
<install>/bin/bash.exe
<install>/mingw64/bin/git.exe
Args:
git_path: Path to git.exe
Returns:
Path to bash.exe if found, None otherwise
"""
git_dir = os.path.dirname(git_path)
git_parent = os.path.dirname(git_dir)
# Build list of possible bash locations
possible_paths = []
# If git is in cmd/ folder
if git_dir.endswith("cmd"):
possible_paths.extend([
os.path.join(git_parent, "bin", "bash.exe"),
os.path.join(git_parent, "usr", "bin", "bash.exe"),
])
# If git is in mingw64/bin/ folder
if "mingw64" in git_dir:
git_root = git_parent
while git_root and not git_root.endswith("mingw64"):
git_root = os.path.dirname(git_root)
if git_root:
install_root = os.path.dirname(git_root)
possible_paths.extend([
os.path.join(install_root, "bin", "bash.exe"),
os.path.join(install_root, "usr", "bin", "bash.exe"),
])
# Generic fallbacks
possible_paths.extend([
os.path.join(git_parent, "bin", "bash.exe"),
os.path.join(git_parent, "usr", "bin", "bash.exe"),
])
for bash_path in possible_paths:
if os.path.isfile(bash_path):
return bash_path
return None
def _find_git_from_path() -> Optional[str]:
"""
Find bash.exe by first locating git.exe.
Returns:
Path to bash.exe if found via git location, None otherwise
"""
git_path = _find_git_executable()
if not git_path:
return None
return _derive_bash_from_git(git_path)
def _get_candidate_paths() -> list[str]:
"""
Get list of candidate paths for bash.exe on Windows.
Returns paths in priority order (most common first).
"""
candidates = [
# Standard 64-bit Git installation
r"C:\Program Files\Git\bin\bash.exe",
# Standard 32-bit Git installation
r"C:\Program Files (x86)\Git\bin\bash.exe",
# User-specific installation (Git installer option)
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\bin\bash.exe"),
# Chocolatey installation
r"C:\ProgramData\chocolatey\lib\git\tools\bin\bash.exe",
# Scoop installation
os.path.expandvars(r"%USERPROFILE%\scoop\apps\git\current\bin\bash.exe"),
# Git for Windows SDK
r"C:\Git\bin\bash.exe",
# Alternative usr/bin location (some Git versions)
r"C:\Program Files\Git\usr\bin\bash.exe",
]
return candidates
def detect_git_bash(force_refresh: bool = False) -> GitBashDetectionResult:
"""
Detect git-bash installation on Windows.
Args:
force_refresh: If True, bypass cache and re-detect
Returns:
GitBashDetectionResult with detection outcome
"""
global _cache
# Non-Windows: not applicable
if platform.system() != "Windows":
return GitBashDetectionResult(
found=False,
source="not-applicable",
message="Git Bash detection only applies to Windows"
)
# Return cached result if available
if _cache is not None and not force_refresh:
logger.debug(f"[GitBash] Using cached result: {_cache.path} ({_cache.source})")
return _cache
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
if debug:
print("[GitBash] Starting detection...")
# Priority 1: Environment variable override
env_path = os.environ.get(ENV_VAR_NAME)
if env_path:
if os.path.isfile(env_path):
result = GitBashDetectionResult(
found=True,
path=env_path,
source="env-var",
message=f"Using {ENV_VAR_NAME}: {env_path}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
else:
print(f"[GitBash] WARNING: {ENV_VAR_NAME} set but path does not exist: {env_path}")
# Priority 2: Windows Registry (most reliable for GUI apps)
if debug:
print("[GitBash] Checking Windows Registry...")
registry_bash = _find_bash_from_registry()
if registry_bash:
result = GitBashDetectionResult(
found=True,
path=registry_bash,
source="windows-registry",
message=f"Found git-bash via registry: {registry_bash}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
# Priority 3: Check standard candidate paths
candidates = _get_candidate_paths()
if debug:
print(f"[GitBash] Checking {len(candidates)} standard paths...")
for candidate_path in candidates:
resolved = os.path.expandvars(candidate_path)
if os.path.isfile(resolved):
result = GitBashDetectionResult(
found=True,
path=resolved,
source="standard-path",
message=f"Found git-bash at standard path: {resolved}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
elif debug:
print(f"[GitBash] Not found: {resolved}")
# Priority 4: Derive from git.exe in PATH (handles custom PATH setups)
if debug:
print("[GitBash] Checking PATH-based git installation...")
path_based_bash = _find_git_from_path()
if path_based_bash:
result = GitBashDetectionResult(
found=True,
path=path_based_bash,
source="derived-from-path",
message=f"Found git-bash via PATH: {path_based_bash}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
elif debug:
print("[GitBash] Could not derive bash.exe from git PATH")
# Not found
result = GitBashDetectionResult(
found=False,
source="not-found",
message=(
"Git Bash not found. Install Git for Windows from https://git-scm.com/downloads/win "
f"or set {ENV_VAR_NAME} environment variable."
)
)
print(f"[GitBash] WARNING: {result.message}")
_cache = result
return result
def get_git_bash_path() -> Optional[str]:
"""
Get the path to bash.exe if available.
Convenience function that returns just the path or None.
Returns:
Path to bash.exe or None if not found
"""
result = detect_git_bash()
return result.path if result.found else None
def get_git_bash_env() -> dict[str, str]:
"""
Get environment variables for Claude SDK with git-bash path.
Returns a dict suitable for merging into SDK environment variables.
Only returns the variable if git-bash was found.
Returns:
Dict with CLAUDE_CODE_GIT_BASH_PATH if found, empty dict otherwise
"""
# Skip on non-Windows
if platform.system() != "Windows":
return {}
result = detect_git_bash()
if result.found and result.path:
return {ENV_VAR_NAME: result.path}
return {}
def clear_cache() -> None:
"""Clear the detection cache. Useful for testing."""
global _cache
_cache = None
logger.debug("[GitBash] Cache cleared")
# Module-level cache for git executable path
_git_cache: Optional[str] = None
def get_git_executable_path() -> str:
"""
Get the path to git executable.
On Windows, uses multi-method detection (shutil.which, registry, standard paths).
On other platforms, returns "git" and relies on PATH.
Returns:
Path to git executable, or "git" as fallback
"""
global _git_cache
# Return cached result if available
if _git_cache is not None:
return _git_cache
# On non-Windows, just use "git" from PATH
if platform.system() != "Windows":
_git_cache = "git"
return _git_cache
# On Windows, try to find the full path
git_path = _find_git_executable()
if git_path:
_git_cache = git_path
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
if debug:
print(f"[Git] Using detected git: {git_path}")
return _git_cache
# Fallback to "git" and hope it's in PATH
_git_cache = "git"
return _git_cache
+17 -21
View File
@@ -4,7 +4,7 @@ Workspace Management - Per-Spec Architecture
=============================================
Handles workspace isolation through Git worktrees, where each spec
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
gets its own isolated worktree in .worktrees/{spec-name}/.
This module has been refactored for better maintainability:
- Models and enums: workspace/models.py
@@ -20,7 +20,6 @@ Public API is exported via workspace/__init__.py for backward compatibility.
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
from ui import (
Icons,
bold,
@@ -181,9 +180,8 @@ def merge_existing_build(
# Detect current branch - this is where user wants changes merged
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
# and wants to merge the spec changes into it, then PR to main
git_path = get_git_executable_path()
current_branch_result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -547,7 +545,6 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
"""
import re
git_path = get_git_executable_path()
spec_branch = f"auto-claude/{spec_name}"
result = {
"has_conflicts": False,
@@ -559,7 +556,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
try:
# Get current branch
base_result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -569,7 +566,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get merge base
merge_base_result = subprocess.run(
[git_path, "merge-base", result["base_branch"], spec_branch],
["git", "merge-base", result["base_branch"], spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -582,13 +579,13 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get commit hashes
main_commit_result = subprocess.run(
[git_path, "rev-parse", result["base_branch"]],
["git", "rev-parse", result["base_branch"]],
cwd=project_dir,
capture_output=True,
text=True,
)
spec_commit_result = subprocess.run(
[git_path, "rev-parse", spec_branch],
["git", "rev-parse", spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -605,7 +602,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
merge_tree_result = subprocess.run(
[
git_path,
"git",
"merge-tree",
"--write-tree",
"--no-messages",
@@ -642,7 +639,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
if not result["conflicting_files"]:
main_files_result = subprocess.run(
[git_path, "diff", "--name-only", merge_base, main_commit],
["git", "diff", "--name-only", merge_base, main_commit],
cwd=project_dir,
capture_output=True,
text=True,
@@ -654,7 +651,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
)
spec_files_result = subprocess.run(
[git_path, "diff", "--name-only", merge_base, spec_commit],
["git", "diff", "--name-only", merge_base, spec_commit],
cwd=project_dir,
capture_output=True,
text=True,
@@ -731,9 +728,8 @@ def _resolve_git_conflicts_with_ai(
)
# Get merge-base commit
git_path = get_git_executable_path()
merge_base_result = subprocess.run(
[git_path, "merge-base", base_branch, spec_branch],
["git", "merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -787,7 +783,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
[git_path, "add", target_file_path],
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
@@ -908,7 +904,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(merged_content, encoding="utf-8")
subprocess.run(
[git_path, "add", file_path], cwd=project_dir, capture_output=True
["git", "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path)
print(success(f"{file_path} (new file)"))
@@ -918,7 +914,7 @@ def _resolve_git_conflicts_with_ai(
if target_path.exists():
target_path.unlink()
subprocess.run(
[git_path, "add", file_path],
["git", "add", file_path],
cwd=project_dir,
capture_output=True,
)
@@ -964,7 +960,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
[git_path, "add", result.file_path],
["git", "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1083,7 +1079,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
[git_path, "add", result.file_path],
["git", "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1116,7 +1112,7 @@ def _resolve_git_conflicts_with_ai(
if target_path.exists():
target_path.unlink()
subprocess.run(
[git_path, "add", target_file_path],
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1130,7 +1126,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
[git_path, "add", target_file_path],
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
+1 -1
View File
@@ -4,7 +4,7 @@ Workspace Management Package
=============================
Handles workspace isolation through Git worktrees, where each spec
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
gets its own isolated worktree in .worktrees/{spec-name}/.
This package provides:
- Workspace setup and configuration
+2 -18
View File
@@ -169,15 +169,7 @@ def handle_workspace_choice(
if staging_path:
print(highlight(f" cd {staging_path}"))
else:
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if worktree_path:
print(highlight(f" cd {worktree_path}"))
else:
print(
highlight(
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
)
)
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
# Show likely test/run commands
if staging_path:
@@ -240,15 +232,7 @@ def handle_workspace_choice(
if staging_path:
print(highlight(f" cd {staging_path}"))
else:
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if worktree_path:
print(highlight(f" cd {worktree_path}"))
else:
print(
highlight(
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
)
)
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
print()
print("When you're ready to add it:")
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
+11 -27
View File
@@ -10,8 +10,6 @@ import json
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
# Constants for merge limits
MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this
MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
@@ -24,7 +22,6 @@ LOCK_FILES = {
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"bun.lock",
"Pipfile.lock",
"poetry.lock",
"uv.lock",
@@ -115,10 +112,9 @@ def detect_file_renames(
# -M flag enables rename detection
# --diff-filter=R shows only renames
# --name-status shows status and file names
git_path = get_git_executable_path()
result = subprocess.run(
[
git_path,
"git",
"log",
"--name-status",
"-M",
@@ -179,9 +175,8 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
Merge-base commit hash, or None if not found
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "merge-base", ref1, ref2],
["git", "merge-base", ref1, ref2],
cwd=project_dir,
capture_output=True,
text=True,
@@ -195,9 +190,8 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "status", "--porcelain"],
["git", "status", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -207,9 +201,8 @@ def has_uncommitted_changes(project_dir: Path) -> bool:
def get_current_branch(project_dir: Path) -> str:
"""Get the current branch name."""
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -228,16 +221,10 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
Returns:
Path to the worktree if it exists for this spec, None otherwise
"""
# New path first
new_path = project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name
if new_path.exists():
return new_path
# Legacy fallback
legacy_path = project_dir / ".worktrees" / spec_name
if legacy_path.exists():
return legacy_path
# Per-spec worktree path: .worktrees/{spec-name}/
worktree_path = project_dir / ".worktrees" / spec_name
if worktree_path.exists():
return worktree_path
return None
@@ -245,9 +232,8 @@ def get_file_content_from_ref(
project_dir: Path, ref: str, file_path: str
) -> str | None:
"""Get file content from a git ref (branch, commit, etc.)."""
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "show", f"{ref}:{file_path}"],
["git", "show", f"{ref}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -275,9 +261,8 @@ def get_changed_files_from_branch(
Returns:
List of (file_path, status) tuples
"""
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "diff", "--name-status", f"{base_branch}...{spec_branch}"],
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -499,9 +484,8 @@ def create_conflict_file_with_git(
try:
# git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "merge-file", "-p", main_path, base_path, wt_path],
["git", "merge-file", "-p", main_path, base_path, wt_path],
cwd=project_dir,
capture_output=True,
text=True,
+14 -7
View File
@@ -6,6 +6,7 @@ Workspace Models
Data classes and enums for workspace management.
"""
import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
@@ -227,12 +228,15 @@ class SpecNumberLock:
def get_next_spec_number(self) -> int:
"""
Scan all spec locations and return the next available spec number.
Must be called while lock is held.
Compute the next global spec number by scanning the project's specs and all worktree specs.
Requires the spec-numbering lock to be held; caches the computed maximum for subsequent calls.
Returns:
Next available spec number (global max + 1)
int: The next available spec number (highest existing spec number + 1).
Raises:
SpecNumberLockError: If the lock has not been acquired when called.
"""
if not self.acquired:
raise SpecNumberLockError(
@@ -249,7 +253,10 @@ class SpecNumberLock:
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))
# 2. Scan all worktree specs
worktrees_dir = self.project_dir / ".auto-claude" / "worktrees" / "tasks"
from core.config import get_worktree_base_path
worktree_base_path = get_worktree_base_path(self.project_dir)
worktrees_dir = self.project_dir / worktree_base_path
if worktrees_dir.exists():
for worktree in worktrees_dir.iterdir():
if worktree.is_dir():
@@ -272,4 +279,4 @@ class SpecNumberLock:
except ValueError:
pass
return max_num
return max_num
+1 -3
View File
@@ -12,7 +12,6 @@ import subprocess
import sys
from pathlib import Path
from core.git_bash import get_git_executable_path
from merge import FileTimelineTracker
from ui import (
Icons,
@@ -369,9 +368,8 @@ def initialize_timeline_tracking(
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-parse", "HEAD"],
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
+105 -28
View File
@@ -4,7 +4,7 @@ Git Worktree Manager - Per-Spec Architecture
=============================================
Each spec gets its own worktree:
- Worktree path: .auto-claude/worktrees/tasks/{spec-name}/
- Worktree path: .worktrees/{spec-name}/
- Branch name: auto-claude/{spec-name}
This allows:
@@ -22,8 +22,6 @@ import subprocess
from dataclasses import dataclass
from pathlib import Path
from core.git_bash import get_git_executable_path
class WorktreeError(Exception):
"""Error during worktree operations."""
@@ -50,14 +48,27 @@ class WorktreeManager:
"""
Manages per-spec Git worktrees.
Each spec gets its own worktree in .auto-claude/worktrees/tasks/{spec-name}/ with
Each spec gets its own worktree in .worktrees/{spec-name}/ with
a corresponding branch auto-claude/{spec-name}.
"""
def __init__(self, project_dir: Path, base_branch: str | None = None):
"""
Initialize the WorktreeManager for a repository, determining base branch and worktrees directory.
Parameters:
project_dir (Path): Root path of the repository managed by this instance.
base_branch (str | None): Optional explicit base branch to use; if omitted, the base branch is auto-detected.
"""
from core.config import get_worktree_base_path
self.project_dir = project_dir
self.base_branch = base_branch or self._detect_base_branch()
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
# Use custom worktree path from environment variable with validation
worktree_base_path = get_worktree_base_path(project_dir)
self.worktrees_dir = project_dir / worktree_base_path
self._merge_lock = asyncio.Lock()
def _detect_base_branch(self) -> str:
@@ -72,14 +83,12 @@ class WorktreeManager:
Returns:
The detected base branch name
"""
git_path = get_git_executable_path()
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
[git_path, "rev-parse", "--verify", env_branch],
["git", "rev-parse", "--verify", env_branch],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -96,7 +105,7 @@ class WorktreeManager:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
[git_path, "rev-parse", "--verify", branch],
["git", "rev-parse", "--verify", branch],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -115,9 +124,8 @@ class WorktreeManager:
def _get_current_branch(self) -> str:
"""Get the current git branch."""
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -132,9 +140,8 @@ class WorktreeManager:
self, args: list[str], cwd: Path | None = None
) -> subprocess.CompletedProcess:
"""Run a git command and return the result."""
git_path = get_git_executable_path()
return subprocess.run(
[git_path] + args,
["git"] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
@@ -163,9 +170,8 @@ class WorktreeManager:
# 1. Check which staged files are gitignored
# git check-ignore returns the files that ARE ignored
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "check-ignore", "--stdin"],
["git", "check-ignore", "--stdin"],
cwd=self.project_dir,
input="\n".join(staged_files),
capture_output=True,
@@ -201,7 +207,7 @@ class WorktreeManager:
def setup(self) -> None:
"""Create worktrees directory if needed."""
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
self.worktrees_dir.mkdir(exist_ok=True)
# ==================== Per-Spec Worktree Methods ====================
@@ -485,12 +491,14 @@ class WorktreeManager:
"""List all spec worktrees."""
worktrees = []
if self.worktrees_dir.exists():
for item in self.worktrees_dir.iterdir():
if item.is_dir():
info = self.get_worktree_info(item.name)
if info:
worktrees.append(info)
if not self.worktrees_dir.exists():
return worktrees
for item in self.worktrees_dir.iterdir():
if item.is_dir():
info = self.get_worktree_info(item.name)
if info:
worktrees.append(info)
return worktrees
@@ -592,12 +600,81 @@ class WorktreeManager:
return commands
def has_uncommitted_changes(self, spec_name: str | None = None) -> bool:
# ==================== Backward Compatibility ====================
# These methods provide backward compatibility with the old single-worktree API
def get_staging_path(self) -> Path | None:
"""
Backward compatibility: Get path to any existing spec worktree.
Prefer using get_worktree_path(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return worktrees[0].path
return None
def get_staging_info(self) -> WorktreeInfo | None:
"""
Backward compatibility: Get info about any existing spec worktree.
Prefer using get_worktree_info(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return worktrees[0]
return None
def merge_staging(self, delete_after: bool = True) -> bool:
"""
Backward compatibility: Merge first found worktree.
Prefer using merge_worktree(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return self.merge_worktree(worktrees[0].spec_name, delete_after)
return False
def remove_staging(self, delete_branch: bool = True) -> None:
"""
Backward compatibility: Remove first found worktree.
Prefer using remove_worktree(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
self.remove_worktree(worktrees[0].spec_name, delete_branch)
def get_or_create_staging(self, spec_name: str) -> WorktreeInfo:
"""
Backward compatibility: Alias for get_or_create_worktree.
"""
return self.get_or_create_worktree(spec_name)
def staging_exists(self) -> bool:
"""
Backward compatibility: Check if any spec worktree exists.
Prefer using worktree_exists(spec_name) instead.
"""
return len(self.list_all_worktrees()) > 0
def commit_in_staging(self, message: str) -> bool:
"""
Backward compatibility: Commit in first found worktree.
Prefer using commit_in_worktree(spec_name, message) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return self.commit_in_worktree(worktrees[0].spec_name, message)
return False
def has_uncommitted_changes(self, in_staging: bool = False) -> bool:
"""Check if there are uncommitted changes."""
cwd = None
if spec_name:
worktree_path = self.get_worktree_path(spec_name)
if worktree_path.exists():
cwd = worktree_path
worktrees = self.list_all_worktrees()
if in_staging and worktrees:
cwd = worktrees[0].path
else:
cwd = None
result = self._run_git(["status", "--porcelain"], cwd=cwd)
return bool(result.stdout.strip())
# Keep STAGING_WORKTREE_NAME for backward compatibility in imports
STAGING_WORKTREE_NAME = "auto-claude"
+1 -1
View File
@@ -25,7 +25,7 @@ class IdeationConfigManager:
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
append: bool = False,
+4 -4
View File
@@ -17,7 +17,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from client import create_client
from phase_config import get_thinking_budget, resolve_model_id
from phase_config import get_thinking_budget
from ui import print_status
# Ideation types
@@ -56,7 +56,7 @@ class IdeationGenerator:
self,
project_dir: Path,
output_dir: Path,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
max_ideas_per_type: int = 5,
):
@@ -94,7 +94,7 @@ class IdeationGenerator:
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
self.model,
max_thinking_tokens=self.thinking_budget,
)
@@ -187,7 +187,7 @@ Write the fixed JSON to the file now.
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
self.model,
max_thinking_tokens=self.thinking_budget,
)
+1 -1
View File
@@ -41,7 +41,7 @@ class IdeationOrchestrator:
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
append: bool = False,
+1 -1
View File
@@ -31,6 +31,6 @@ class IdeationConfig:
include_roadmap_context: bool = True
include_kanban_context: bool = True
max_ideas_per_type: int = 5
model: str = "sonnet" # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101"
refresh: bool = False
append: bool = False # If True, preserve existing ideas when merging
@@ -343,34 +343,6 @@ class GraphitiMemory:
return await self._search.get_similar_task_outcomes(task_description, limit)
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Get patterns and gotchas relevant to the query.
This method specifically retrieves PATTERN and GOTCHA episode types
to enable cross-session learning. Unlike get_relevant_context(),
it filters for these specific types rather than doing generic search.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
if not await self._ensure_initialized():
return [], []
return await self._search.get_patterns_and_gotchas(
query, num_results, min_score
)
# Status and utility methods
def get_status_summary(self) -> dict:
@@ -10,8 +10,6 @@ import logging
from pathlib import Path
from .schema import (
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
@@ -57,7 +55,6 @@ class GraphitiSearch:
query: str,
num_results: int = MAX_CONTEXT_RESULTS,
include_project_context: bool = True,
min_score: float = 0.0,
) -> list[dict]:
"""
Search for relevant context based on a query.
@@ -107,12 +104,6 @@ class GraphitiSearch:
}
)
# Filter by minimum score if specified
if min_score > 0:
context_items = [
item for item in context_items if item.get("score", 0) >= min_score
]
logger.info(
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
)
@@ -162,7 +153,7 @@ class GraphitiSearch:
):
continue
sessions.append(data)
except (json.JSONDecodeError, TypeError, AttributeError):
except (json.JSONDecodeError, TypeError):
continue
# Sort by session number and return latest
@@ -214,7 +205,7 @@ class GraphitiSearch:
"score": getattr(result, "score", 0.0),
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
except (json.JSONDecodeError, TypeError):
continue
return outcomes[:limit]
@@ -222,107 +213,3 @@ class GraphitiSearch:
except Exception as e:
logger.warning(f"Failed to get similar task outcomes: {e}")
return []
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Retrieve patterns and gotchas relevant to the current task.
Unlike get_relevant_context(), this specifically filters for
EPISODE_TYPE_PATTERN and EPISODE_TYPE_GOTCHA episodes to enable
cross-session learning.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
patterns = []
gotchas = []
try:
# Search with query focused on patterns
pattern_results = await self.client.graphiti.search(
query=f"pattern: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in pattern_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_PATTERN in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_PATTERN:
patterns.append(
{
"pattern": data.get("pattern", ""),
"applies_to": data.get("applies_to", ""),
"example": data.get("example", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Search with query focused on gotchas
gotcha_results = await self.client.graphiti.search(
query=f"gotcha pitfall avoid: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in gotcha_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_GOTCHA in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_GOTCHA:
gotchas.append(
{
"gotcha": data.get("gotcha", ""),
"trigger": data.get("trigger", ""),
"solution": data.get("solution", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Sort by score and limit
patterns.sort(key=lambda x: x.get("score", 0), reverse=True)
gotchas.sort(key=lambda x: x.get("score", 0), reverse=True)
logger.info(
f"Found {len(patterns)} patterns and {len(gotchas)} gotchas for: {query[:50]}..."
)
return patterns[:num_results], gotchas[:num_results]
except Exception as e:
logger.warning(f"Failed to get patterns/gotchas: {e}")
return [], []
+1 -2
View File
@@ -118,7 +118,6 @@ def _create_linear_client() -> ClaudeSDKClient:
get_sdk_env_vars,
require_auth_token,
)
from phase_config import resolve_model_id
require_auth_token() # Raises ValueError if no token found
ensure_claude_code_oauth_token()
@@ -131,7 +130,7 @@ def _create_linear_client() -> ClaudeSDKClient:
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id("haiku"), # Resolves via API Profile if configured
model="claude-haiku-4-5", # Fast & cheap model for simple API calls
system_prompt="You are a Linear API assistant. Execute the requested Linear operation precisely.",
allowed_tools=LINEAR_TOOLS,
mcp_servers={
@@ -15,8 +15,6 @@ import subprocess
from datetime import datetime
from pathlib import Path
from core.git_bash import get_git_executable_path
from ..types import FileEvolution, TaskSnapshot, compute_content_hash
from .storage import EvolutionStorage
@@ -95,9 +93,8 @@ class BaselineCapture:
List of absolute paths to trackable files
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "ls-files"],
["git", "ls-files"],
cwd=self.storage.project_dir,
capture_output=True,
text=True,
@@ -127,9 +124,8 @@ class BaselineCapture:
Git commit SHA, or "unknown" if not available
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-parse", "HEAD"],
["git", "rev-parse", "HEAD"],
cwd=self.storage.project_dir,
capture_output=True,
text=True,
@@ -15,8 +15,6 @@ import subprocess
from datetime import datetime
from pathlib import Path
from core.git_bash import get_git_executable_path
from ..semantic_analyzer import SemanticAnalyzer
from ..types import FileEvolution, TaskSnapshot, compute_content_hash
from .storage import EvolutionStorage
@@ -159,10 +157,9 @@ class ModificationTracker:
)
try:
git_path = get_git_executable_path()
# Get list of files changed in the worktree vs target branch
result = subprocess.run(
[git_path, "diff", "--name-only", f"{target_branch}...HEAD"],
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -181,7 +178,7 @@ class ModificationTracker:
for file_path in changed_files:
# Get the diff for this file
diff_result = subprocess.run(
[git_path, "diff", f"{target_branch}...HEAD", "--", file_path],
["git", "diff", f"{target_branch}...HEAD", "--", file_path],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -191,7 +188,7 @@ class ModificationTracker:
# Get content before (from target branch) and after (current)
try:
show_result = subprocess.run(
[git_path, "show", f"{target_branch}:{file_path}"],
["git", "show", f"{target_branch}:{file_path}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -262,12 +259,10 @@ class ModificationTracker:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
git_path = get_git_executable_path()
# Try to get the upstream tracking branch
try:
result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -285,7 +280,7 @@ class ModificationTracker:
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
[git_path, "merge-base", branch, "HEAD"],
["git", "merge-base", branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
+2 -4
View File
@@ -45,8 +45,7 @@ def apply_single_task_changes(
# Addition - need to determine where to add
if change.change_type == ChangeType.ADD_IMPORT:
# Add import at top
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
lines = content.splitlines()
lines = content.split("\n")
import_end = find_import_end(lines, file_path)
lines.insert(import_end, change.content_after)
content = "\n".join(lines)
@@ -97,8 +96,7 @@ def combine_non_conflicting_changes(
# Add imports
if imports:
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
lines = content.splitlines()
lines = content.split("\n")
import_end = find_import_end(lines, file_path)
for imp in imports:
if imp.content_after and imp.content_after not in content:
+20 -14
View File
@@ -15,8 +15,6 @@ from __future__ import annotations
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
def find_worktree(project_dir: Path, task_id: str) -> Path | None:
"""
@@ -29,19 +27,28 @@ def find_worktree(project_dir: Path, task_id: str) -> Path | None:
Returns:
Path to the worktree, or None if not found
"""
# Check new path first
new_worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
if new_worktrees_dir.exists():
for entry in new_worktrees_dir.iterdir():
# Check common locations
worktrees_dir = project_dir / ".worktrees"
if worktrees_dir.exists():
# Look for worktree with task_id in name
for entry in worktrees_dir.iterdir():
if entry.is_dir() and task_id in entry.name:
return entry
# Legacy fallback for backwards compatibility
legacy_worktrees_dir = project_dir / ".worktrees"
if legacy_worktrees_dir.exists():
for entry in legacy_worktrees_dir.iterdir():
if entry.is_dir() and task_id in entry.name:
return entry
# Try git worktree list
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
)
for line in result.stdout.split("\n"):
if line.startswith("worktree ") and task_id in line:
return Path(line.split(" ", 1)[1])
except subprocess.CalledProcessError:
pass
return None
@@ -59,9 +66,8 @@ def get_file_from_branch(project_dir: Path, file_path: str, branch: str) -> str
File content as string, or None if file doesn't exist on branch
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "show", f"{branch}:{file_path}"],
["git", "show", f"{branch}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -30,16 +30,11 @@ def analyze_with_regex(
"""
changes: list[SemanticChange] = []
# Normalize line endings to LF for consistent cross-platform behavior
# This handles Windows CRLF, old Mac CR, and Unix LF
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
# Get a unified diff
diff = list(
difflib.unified_diff(
before_normalized.splitlines(keepends=True),
after_normalized.splitlines(keepends=True),
before.splitlines(keepends=True),
after.splitlines(keepends=True),
lineterm="",
)
)
@@ -94,22 +89,8 @@ def analyze_with_regex(
# Detect function changes (simplified)
func_pattern = get_function_pattern(ext)
if func_pattern:
# For JS/TS patterns with alternation, findall() returns tuples
# Extract the non-empty match from each tuple
def extract_func_names(matches):
names = set()
for match in matches:
if isinstance(match, tuple):
# Get the first non-empty group from the tuple
name = next((m for m in match if m), None)
if name:
names.add(name)
elif match:
names.add(match)
return names
funcs_before = extract_func_names(func_pattern.findall(before_normalized))
funcs_after = extract_func_names(func_pattern.findall(after_normalized))
funcs_before = set(func_pattern.findall(before))
funcs_after = set(func_pattern.findall(after))
for func in funcs_after - funcs_before:
changes.append(
+4 -10
View File
@@ -211,18 +211,12 @@ class SemanticAnalyzer:
"""Analyze using tree-sitter AST parsing."""
parser = self._parsers[ext]
# Normalize line endings to LF for consistent cross-platform behavior
# This ensures byte positions and line counts work correctly on all platforms
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
tree_before = parser.parse(bytes(before_normalized, "utf-8"))
tree_after = parser.parse(bytes(after_normalized, "utf-8"))
tree_before = parser.parse(bytes(before, "utf-8"))
tree_after = parser.parse(bytes(after, "utf-8"))
# Extract structural elements from both versions
# Use normalized content to match tree-sitter byte positions
elements_before = self._extract_elements(tree_before, before_normalized, ext)
elements_after = self._extract_elements(tree_after, after_normalized, ext)
elements_before = self._extract_elements(tree_before, before, ext)
elements_after = self._extract_elements(tree_after, after, ext)
# Compare and generate semantic changes
changes = compare_elements(elements_before, elements_after, ext)
+12 -31
View File
@@ -17,8 +17,6 @@ import logging
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
# Import debug utilities
@@ -58,9 +56,8 @@ class TimelineGitHelper:
def get_current_main_commit(self) -> str:
"""Get the current HEAD commit on main branch."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-parse", "HEAD"],
["git", "rev-parse", "HEAD"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -84,9 +81,8 @@ class TimelineGitHelper:
File content as string, or None if file doesn't exist at that commit
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "show", f"{commit_hash}:{file_path}"],
["git", "show", f"{commit_hash}:{file_path}"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -108,10 +104,9 @@ class TimelineGitHelper:
List of file paths changed in the commit
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[
git_path,
"git",
"diff-tree",
"--no-commit-id",
"--name-only",
@@ -139,11 +134,9 @@ class TimelineGitHelper:
"""
info = {}
try:
git_path = get_git_executable_path()
# Get commit message
result = subprocess.run(
[git_path, "log", "-1", "--format=%s", commit_hash],
["git", "log", "-1", "--format=%s", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -153,7 +146,7 @@ class TimelineGitHelper:
# Get author
result = subprocess.run(
[git_path, "log", "-1", "--format=%an", commit_hash],
["git", "log", "-1", "--format=%an", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -163,7 +156,7 @@ class TimelineGitHelper:
# Get diff stat
result = subprocess.run(
[git_path, "diff-tree", "--stat", "--no-commit-id", commit_hash],
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -196,14 +189,7 @@ class TimelineGitHelper:
task_id.replace("task-", "") if task_id.startswith("task-") else task_id
)
worktree_path = (
self.project_path
/ ".auto-claude"
/ "worktrees"
/ "tasks"
/ spec_name
/ file_path
)
worktree_path = self.project_path / ".worktrees" / spec_name / file_path
if worktree_path.exists():
try:
return worktree_path.read_text(encoding="utf-8")
@@ -228,9 +214,8 @@ class TimelineGitHelper:
target_branch = self._detect_target_branch(worktree_path)
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "diff", "--name-only", f"{target_branch}...HEAD"],
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -262,9 +247,8 @@ class TimelineGitHelper:
target_branch = self._detect_target_branch(worktree_path)
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "merge-base", target_branch, "HEAD"],
["git", "merge-base", target_branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -293,12 +277,10 @@ class TimelineGitHelper:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
git_path = get_git_executable_path()
# Try to get the upstream tracking branch
try:
result = subprocess.run(
[git_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -316,7 +298,7 @@ class TimelineGitHelper:
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
[git_path, "merge-base", branch, "HEAD"],
["git", "merge-base", branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -341,9 +323,8 @@ class TimelineGitHelper:
Number of commits between the two points
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "rev-list", "--count", f"{from_commit}..{to_commit}"],
["git", "rev-list", "--count", f"{from_commit}..{to_commit}"],
cwd=self.project_path,
capture_output=True,
text=True,
+4 -22
View File
@@ -7,7 +7,6 @@ Reads configuration from task_metadata.json and provides resolved model IDs.
"""
import json
import os
from pathlib import Path
from typing import Literal, TypedDict
@@ -47,10 +46,10 @@ SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
"complexity_assessment": "medium",
}
# Default phase configuration (fallback, matches 'Balanced' profile)
# Default phase configuration (matches UI defaults)
DEFAULT_PHASE_MODELS: dict[str, str] = {
"spec": "sonnet",
"planning": "sonnet", # Changed from "opus" (fix #433)
"planning": "opus",
"coding": "sonnet",
"qa": "sonnet",
}
@@ -95,34 +94,17 @@ def resolve_model_id(model: str) -> str:
Resolve a model shorthand (haiku, sonnet, opus) to a full model ID.
If the model is already a full ID, return it unchanged.
Priority:
1. Environment variable override (from API Profile)
2. Hardcoded MODEL_ID_MAP
3. Pass through unchanged (assume full model ID)
Args:
model: Model shorthand or full ID
Returns:
Full Claude model ID
"""
# Check for environment variable override (from API Profile custom model mappings)
# Check if it's a shorthand
if model in MODEL_ID_MAP:
env_var_map = {
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
}
env_var = env_var_map.get(model)
if env_var:
env_value = os.environ.get(env_var)
if env_value:
return env_value
# Fall back to hardcoded mapping
return MODEL_ID_MAP[model]
# Already a full model ID or unknown shorthand
# Already a full model ID
return model
@@ -173,16 +173,12 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
"zig",
},
"dart": {
# Core Dart CLI (modern unified tool)
"dart",
"pub",
# Flutter CLI (included in Dart language for SDK detection)
"flutter",
# Legacy commands (deprecated but may exist in older projects)
"dart2js",
"dartanalyzer",
"dartdoc",
"dartfmt",
"pub",
},
}
@@ -33,9 +33,6 @@ PACKAGE_MANAGER_COMMANDS: dict[str, set[str]] = {
"brew": {"brew"},
"apt": {"apt", "apt-get", "dpkg"},
"nix": {"nix", "nix-shell", "nix-build", "nix-env"},
# Dart/Flutter package managers
"pub": {"pub", "dart"},
"melos": {"melos", "dart", "flutter"},
}
@@ -23,8 +23,6 @@ VERSION_MANAGER_COMMANDS: dict[str, set[str]] = {
"rustup": {"rustup"},
"sdkman": {"sdk"},
"jabba": {"jabba"},
# Dart/Flutter version managers
"fvm": {"fvm", "flutter"},
}
+1 -10
View File
@@ -126,7 +126,7 @@ class StackDetector:
self.stack.package_managers.append("yarn")
if self.parser.file_exists("pnpm-lock.yaml"):
self.stack.package_managers.append("pnpm")
if self.parser.file_exists("bun.lockb", "bun.lock"):
if self.parser.file_exists("bun.lockb"):
self.stack.package_managers.append("bun")
if self.parser.file_exists("deno.json", "deno.jsonc"):
self.stack.package_managers.append("deno")
@@ -164,12 +164,6 @@ class StackDetector:
if self.parser.file_exists("build.gradle", "build.gradle.kts"):
self.stack.package_managers.append("gradle")
# Dart/Flutter package managers
if self.parser.file_exists("pubspec.yaml", "pubspec.lock"):
self.stack.package_managers.append("pub")
if self.parser.file_exists("melos.yaml"):
self.stack.package_managers.append("melos")
def detect_databases(self) -> None:
"""Detect databases from config files and dependencies."""
# Check for database config files
@@ -364,6 +358,3 @@ class StackDetector:
self.stack.version_managers.append("rbenv")
if self.parser.file_exists("rust-toolchain.toml", "rust-toolchain"):
self.stack.version_managers.append("rustup")
# Flutter Version Manager
if self.parser.file_exists(".fvm", ".fvmrc", "fvm_config.json"):
self.stack.version_managers.append("fvm")
@@ -6,23 +6,6 @@ You are a focused codebase fit review agent. You have been spawned by the orches
Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Codebase fit issues in changed code** - New code not following project patterns
2. **Missed reuse opportunities** - "Existing `utils.ts` has a helper for this"
3. **Inconsistent with PR's own changes** - "You used `camelCase` here but `snake_case` elsewhere in the PR"
4. **Breaking conventions in touched areas** - "Your change deviates from the pattern in this file"
### What is NOT in scope (do NOT report):
1. **Pre-existing inconsistencies** - Old code that doesn't follow patterns
2. **Unrelated suggestions** - Don't suggest patterns for code the PR didn't touch
**Key distinction:**
- ✅ "Your new component doesn't follow the existing pattern in `components/`" - GOOD
- ✅ "Consider using existing `formatDate()` helper instead of new implementation" - GOOD
- ❌ "The old `legacy/` folder uses different naming conventions" - BAD (pre-existing)
## Codebase Fit Focus Areas
### 1. Naming Conventions
@@ -1,214 +0,0 @@
# Finding Validator Agent
You are a finding re-investigator using EVIDENCE-BASED VALIDATION. For each unresolved finding from a previous PR review, you must actively investigate whether it is a REAL issue or a FALSE POSITIVE.
**Core Principle: Evidence, not confidence scores.** Either you can prove the issue exists with actual code, or you can't. There is no middle ground.
Your job is to prevent false positives from persisting indefinitely by actually reading the code and verifying the issue exists.
## CRITICAL: Check PR Scope First
**Before investigating any finding, verify it's within THIS PR's scope:**
1. **Check if the file is in the PR's changed files list** - If not, likely out-of-scope
2. **Check if the line number exists** - If finding cites line 710 but file has 600 lines, it's hallucinated
3. **Check for PR references in commit messages** - Commits like `fix: something (#584)` are from OTHER PRs
**Dismiss findings as `dismissed_false_positive` if:**
- The finding references a file NOT in the PR's changed files list AND is not about impact on that file
- The line number doesn't exist in the file (hallucinated)
- The finding is about code from a merged branch commit (not this PR's work)
**Keep findings valid if they're about:**
- Issues in code the PR actually changed
- Impact of PR changes on other code (e.g., "this change breaks callers in X")
- Missing updates to related code (e.g., "you updated A but forgot B")
## Your Mission
For each finding you receive:
1. **VERIFY SCOPE** - Is this file/line actually part of this PR?
2. **READ** the actual code at the file/line location using the Read tool
3. **ANALYZE** whether the described issue actually exists in the code
4. **PROVIDE** concrete code evidence - the actual code that proves or disproves the issue
5. **RETURN** validation status with evidence (binary decision based on what the code shows)
## Investigation Process
### Step 1: Fetch the Code
Use the Read tool to get the actual code at `finding.file` around `finding.line`.
Get sufficient context (±20 lines minimum).
```
Read the file: {finding.file}
Focus on lines around: {finding.line}
```
### Step 2: Analyze with Fresh Eyes - NEVER ASSUME
**CRITICAL: Do NOT assume the original finding is correct.** The original reviewer may have:
- Hallucinated line numbers that don't exist
- Misread or misunderstood the code
- Missed validation/sanitization in callers or surrounding code
- Made assumptions without actually reading the implementation
- Confused similar-looking code patterns
**You MUST actively verify by asking:**
- Does the code at this exact line ACTUALLY have this issue?
- Did I READ the actual implementation, not just the function name?
- Is there validation/sanitization BEFORE this code is reached?
- Is there framework protection I'm not accounting for?
- Does this line number even EXIST in the file?
**NEVER:**
- Trust the finding description without reading the code
- Assume a function is vulnerable based on its name
- Skip checking surrounding context (±20 lines minimum)
- Confirm a finding just because "it sounds plausible"
Be HIGHLY skeptical. AI reviews frequently produce false positives. Your job is to catch them.
### Step 3: Document Evidence
You MUST provide concrete evidence:
- **Exact code snippet** you examined (copy-paste from the file) - this is the PROOF
- **Line numbers** where you found (or didn't find) the issue
- **Your analysis** connecting the code to your conclusion
- **Verification flag** - did this code actually exist at the specified location?
## Validation Statuses
### `confirmed_valid`
Use when your code evidence PROVES the issue IS real:
- The problematic code pattern exists exactly as described
- You can point to the specific lines showing the vulnerability/bug
- The code quality issue genuinely impacts the codebase
- **Key question**: Does your code_evidence field contain the actual problematic code?
### `dismissed_false_positive`
Use when your code evidence PROVES the issue does NOT exist:
- The described code pattern is not actually present (code_evidence shows different code)
- There is mitigating code that prevents the issue (code_evidence shows the mitigation)
- The finding was based on incorrect assumptions (code_evidence shows reality)
- The line number doesn't exist or contains different code than claimed
- **Key question**: Does your code_evidence field show code that disproves the original finding?
### `needs_human_review`
Use when you CANNOT find definitive evidence either way:
- The issue requires runtime analysis to verify (static code doesn't prove/disprove)
- The code is too complex to analyze statically
- You found the code but can't determine if it's actually a problem
- **Key question**: Is your code_evidence inconclusive?
## Output Format
Return one result per finding:
```json
{
"finding_id": "SEC-001",
"validation_status": "confirmed_valid",
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
"line_range": [45, 45],
"explanation": "SQL injection vulnerability confirmed. User input 'userId' is directly interpolated into the SQL query at line 45 without any sanitization. The query is executed via db.execute() on line 46.",
"evidence_verified_in_file": true
}
```
```json
{
"finding_id": "QUAL-002",
"validation_status": "dismissed_false_positive",
"code_evidence": "function processInput(data: string): string {\n const sanitized = DOMPurify.sanitize(data);\n return sanitized;\n}",
"line_range": [23, 26],
"explanation": "The original finding claimed XSS vulnerability, but the code uses DOMPurify.sanitize() before output. The input is properly sanitized at line 24 before being returned. The code evidence proves the issue does NOT exist.",
"evidence_verified_in_file": true
}
```
```json
{
"finding_id": "LOGIC-003",
"validation_status": "needs_human_review",
"code_evidence": "async function handleRequest(req) {\n // Complex async logic...\n}",
"line_range": [100, 150],
"explanation": "The original finding claims a race condition, but verifying this requires understanding the runtime behavior and concurrency model. The static code doesn't provide definitive evidence either way.",
"evidence_verified_in_file": true
}
```
```json
{
"finding_id": "HALLUC-004",
"validation_status": "dismissed_false_positive",
"code_evidence": "// Line 710 does not exist - file only has 600 lines",
"line_range": [600, 600],
"explanation": "The original finding claimed an issue at line 710, but the file only has 600 lines. This is a hallucinated finding - the code doesn't exist.",
"evidence_verified_in_file": false
}
```
## Evidence Guidelines
Validation is binary based on what the code evidence shows:
| Scenario | Status | Evidence Required |
|----------|--------|-------------------|
| Code shows the exact problem claimed | `confirmed_valid` | Problematic code snippet |
| Code shows issue doesn't exist or is mitigated | `dismissed_false_positive` | Code proving issue is absent |
| Code couldn't be found (hallucinated line/file) | `dismissed_false_positive` | Note that code doesn't exist |
| Code found but can't prove/disprove statically | `needs_human_review` | The inconclusive code |
**Decision rules:**
- If `code_evidence` contains problematic code → `confirmed_valid`
- If `code_evidence` proves issue doesn't exist → `dismissed_false_positive`
- If `evidence_verified_in_file` is false → `dismissed_false_positive` (hallucinated finding)
- If you can't determine from the code → `needs_human_review`
## Common False Positive Patterns
Watch for these patterns that often indicate false positives:
1. **Non-existent line number**: The line number cited doesn't exist or is beyond EOF - hallucinated finding
2. **Merged branch code**: Finding is about code from a commit like `fix: something (#584)` - another PR
3. **Pre-existing issue, not impact**: Finding flags old bug in untouched code without showing how PR changes relate
4. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
5. **Internal-only code**: Code only handles trusted internal data, not user input
6. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
7. **Dead code**: The flagged code is never executed in the current codebase
8. **Test code**: The issue is in test files where it's acceptable
9. **Misread syntax**: Original reviewer misunderstood the language syntax
**Note**: Findings about files outside the PR's changed list are NOT automatically false positives if they're about:
- Impact of PR changes on that file (e.g., "your change breaks X")
- Missing related updates (e.g., "you forgot to update Y")
## Common Valid Issue Patterns
These patterns often confirm the issue is real:
1. **Direct string concatenation** in SQL/commands with user input
2. **Missing null checks** where null values can flow through
3. **Hardcoded credentials** that are actually used (not examples)
4. **Missing error handling** in critical paths
5. **Race conditions** with clear concurrent access
## Critical Rules
1. **ALWAYS read the actual code** - Never rely on memory or the original finding description
2. **ALWAYS provide code_evidence** - No empty strings. Quote the actual code.
3. **Be skeptical of original findings** - Many AI reviews produce false positives
4. **Evidence is binary** - The code either shows the problem or it doesn't
5. **When evidence is inconclusive, escalate** - Use `needs_human_review` rather than guessing
6. **Look for mitigations** - Check surrounding code for sanitization/validation
7. **Check the full context** - Read ±20 lines, not just the flagged line
8. **Verify code exists** - Set `evidence_verified_in_file` to false if the code/line doesn't exist
## Anti-Patterns to Avoid
- **Trusting the original finding blindly** - Always verify with actual code
- **Dismissing without reading code** - Must provide code_evidence that proves your point
- **Vague explanations** - Be specific about what the code shows and why it proves/disproves the issue
- **Missing line numbers** - Always include line_range
- **Speculative conclusions** - Only conclude what the code evidence actually proves
+5 -7
View File
@@ -71,12 +71,10 @@ Review the diff since the last review for NEW issues:
- Regressions that break previously working code
- Missing error handling in new code paths
**NEVER ASSUME - ALWAYS VERIFY:**
- Actually READ the code before reporting any finding
- Verify the issue exists at the exact line you cite
- Check for validation/mitigation in surrounding code
**Apply the 80% confidence threshold:**
- Only report issues you're confident about
- Don't re-report issues from the previous review
- Focus on genuinely new problems with code EVIDENCE
- Focus on genuinely new problems
### Phase 3: Comment Review
@@ -139,11 +137,11 @@ Return a JSON object with this structure:
"id": "new-finding-1",
"severity": "medium",
"category": "security",
"confidence": 0.85,
"title": "New hardcoded API key in config",
"description": "A new API key was added in config.ts line 45 without using environment variables.",
"file": "src/config.ts",
"line": 45,
"evidence": "const API_KEY = 'sk-prod-abc123xyz789';",
"suggested_fix": "Move to environment variable: process.env.EXTERNAL_API_KEY"
}
],
@@ -177,11 +175,11 @@ Same format as initial review findings:
- **id**: Unique identifier for new finding
- **severity**: `critical` | `high` | `medium` | `low`
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
- **confidence**: Float 0.80-1.0
- **title**: Short summary (max 80 chars)
- **description**: Detailed explanation
- **file**: Relative file path
- **line**: Line number
- **evidence**: **REQUIRED** - Actual code snippet proving the issue exists
- **suggested_fix**: How to resolve
### verdict
@@ -11,23 +11,6 @@ Review the incremental diff for:
4. Potential regressions
5. Incomplete implementations
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
2. **Impact on unchanged code** - "This change breaks callers in `other_file.ts`"
3. **Missing related changes** - "Similar pattern in `utils.ts` wasn't updated"
4. **Incomplete implementations** - "New field added but not handled in serializer"
### What is NOT in scope (do NOT report):
1. **Pre-existing bugs** - Old bugs in code this PR didn't touch
2. **Code from merged branches** - Commits with PR references like `(#584)` are from other PRs
3. **Unrelated improvements** - Don't suggest refactoring untouched code
**Key distinction:**
- ✅ "Your change breaks the caller in `auth.ts`" - GOOD (impact analysis)
- ❌ "The old code in `legacy.ts` has a bug" - BAD (pre-existing, not this PR)
## Focus Areas
Since this is a follow-up review, focus on:
@@ -91,29 +74,15 @@ Since this is a follow-up review, focus on:
- Minor optimizations
- Documentation gaps
## NEVER ASSUME - ALWAYS VERIFY
## Confidence Scoring
**Before reporting ANY new finding:**
Rate confidence (0.0-1.0) based on:
- **>0.9**: Obvious, verifiable issue
- **0.8-0.9**: High confidence with clear evidence
- **0.7-0.8**: Likely issue but some uncertainty
- **<0.7**: Possible issue, needs verification
1. **NEVER assume code is vulnerable** - Read the actual implementation
2. **NEVER assume validation is missing** - Check callers and surrounding code
3. **NEVER assume based on function names** - `unsafeQuery()` might actually be safe
4. **NEVER report without reading the code** - Verify the issue exists at the exact line
**You MUST:**
- Actually READ the code at the file/line you cite
- Verify there's no sanitization/validation before this code
- Check for framework protections you might miss
- Provide the actual code snippet as evidence
## Evidence Requirements
Every finding MUST include an `evidence` field with:
- The actual problematic code copy-pasted from the diff
- The specific line numbers where the issue exists
- Proof that the issue is real, not speculative
**No evidence = No finding**
Only report findings with confidence >0.7.
## Output Format
@@ -130,7 +99,7 @@ Return findings in this structure:
"description": "The new login validation query concatenates user input directly into the SQL string without sanitization.",
"category": "security",
"severity": "critical",
"evidence": "query = f\"SELECT * FROM users WHERE email = '{email}'\"",
"confidence": 0.95,
"suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))",
"fixable": true,
"source_agent": "new-code-reviewer",
@@ -144,7 +113,7 @@ Return findings in this structure:
"description": "The fix for LOGIC-003 removed a null check that was protecting against undefined input. Now input.data can be null.",
"category": "regression",
"severity": "high",
"evidence": "result = input.data.process() # input.data can be null, was previously: if input and input.data:",
"confidence": 0.88,
"suggested_fix": "Restore null check: if (input && input.data) { ... }",
"fixable": true,
"source_agent": "new-code-reviewer",
@@ -9,40 +9,6 @@ Perform a focused, efficient follow-up review by:
2. Delegating to specialized agents based on what needs verification
3. Synthesizing findings into a final merge verdict
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
2. **Impact on unchanged code** - "You changed X but forgot to update Y that depends on it"
3. **Missing related changes** - "This pattern also exists in Z, did you mean to update it too?"
4. **Breaking changes** - "This change breaks callers in other files"
### What is NOT in scope (do NOT report):
1. **Pre-existing issues in unchanged code** - If old code has a bug but this PR didn't touch it, don't flag it
2. **Code from merged branches** - Commits with PR references like `(#584)` are from OTHER already-reviewed PRs
3. **Unrelated improvements** - Don't suggest refactoring code the PR didn't touch
**Key distinction:**
- ✅ "Your change to `validateUser()` breaks the caller in `auth.ts:45`" - GOOD (impact of PR changes)
- ✅ "You updated this validation but similar logic in `utils.ts` wasn't updated" - GOOD (incomplete change)
- ❌ "The existing code in `legacy.ts` has a SQL injection" - BAD (pre-existing issue, not this PR)
- ❌ "This code from commit `fix: something (#584)` has an issue" - BAD (different PR)
**Why this matters:**
When authors merge the base branch into their feature branch, the commit range includes commits from other PRs. The context gathering system filters these out, but if any slip through, recognize them as out-of-scope.
## Merge Conflicts
**Check for merge conflicts in the follow-up context.** If `has_merge_conflicts` is `true`:
1. **Report this prominently** - Merge conflicts block the PR from being merged
2. **Add a CRITICAL finding** with category "merge_conflict" and severity "critical"
3. **Include in verdict reasoning** - The PR cannot be merged until conflicts are resolved
4. **This may be NEW since last review** - Base branch may have changed
Note: GitHub's API tells us IF there are conflicts but not WHICH files. The finding should state:
> "This PR has merge conflicts with the base branch that must be resolved before merging."
## Available Specialist Agents
You have access to these specialist agents via the Task tool:
@@ -69,20 +35,6 @@ You have access to these specialist agents via the Task tool:
- Flags concerns that need addressing
- **Invoke when**: There are comments or reviews since last review
### 4. finding-validator (CRITICAL - Prevent False Positives)
**Use for**: Re-investigating unresolved findings to validate they are real issues
- Reads the ACTUAL CODE at the finding location with fresh eyes
- Actively investigates whether the described issue truly exists
- Can DISMISS findings as false positives if original review was incorrect
- Can CONFIRM findings as valid if issue is genuine
- Requires concrete CODE EVIDENCE for any conclusion
- **ALWAYS invoke after resolution-verifier for ALL unresolved findings**
- **Invoke when**: There are findings still marked as unresolved
**Why this is critical**: Initial reviews may produce false positives (hallucinated issues).
Without validation, these persist indefinitely. This agent prevents that by actually
examining the code and determining if the issue is real.
## Workflow
### Phase 1: Analyze Scope
@@ -98,9 +50,6 @@ Based on your analysis, invoke the appropriate agents:
**Always invoke** `resolution-verifier` if there are previous findings.
**ALWAYS invoke** `finding-validator` for ALL unresolved findings from resolution-verifier.
This is CRITICAL to prevent false positives from persisting.
**Invoke** `new-code-reviewer` if:
- Diff is substantial (>50 lines)
- Changes touch security-sensitive areas
@@ -112,28 +61,17 @@ This is CRITICAL to prevent false positives from persisting.
- There are AI tool reviews to triage
- Questions remain unanswered
### Phase 3: Validate Unresolved Findings
After resolution-verifier returns findings marked as unresolved:
1. Pass ALL unresolved findings to finding-validator
2. finding-validator will read the actual code at each location
3. For each finding, it returns:
- `confirmed_valid`: Issue IS real → keep as unresolved
- `dismissed_false_positive`: Original finding was WRONG → remove from findings
- `needs_human_review`: Cannot determine → flag for human
### Phase 4: Synthesize Results
After all agents complete:
### Phase 3: Synthesize Results
After agents complete:
1. Combine resolution verifications
2. Apply validation results (remove dismissed false positives)
3. Merge new findings (deduplicate if needed)
4. Incorporate comment analysis
5. Generate final verdict based on VALIDATED findings only
2. Merge new findings (deduplicate if needed)
3. Incorporate comment analysis
4. Generate final verdict
## Verdict Guidelines
### READY_TO_MERGE
- All previous findings verified as resolved OR dismissed as false positives
- No CONFIRMED_VALID critical/high issues remaining
- All previous findings verified as resolved
- No new critical/high issues
- No blocking concerns from comments
- Contributor questions addressed
@@ -144,17 +82,15 @@ After all agents complete:
- Optional polish items can be addressed post-merge
### NEEDS_REVISION (Strict Quality Gates)
- HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive)
- HIGH or MEDIUM severity findings unresolved
- New HIGH or MEDIUM severity issues introduced
- Important contributor concerns unaddressed
- **Note: Both HIGH and MEDIUM block merge** (AI fixes quickly, so be strict)
- **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block)
### BLOCKED
- CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive)
- CRITICAL findings remain unresolved
- New CRITICAL issues introduced
- Fundamental problems with the fix approach
- **Note: Only block for findings that passed validation**
## Cross-Validation
@@ -170,28 +106,10 @@ Provide your synthesis as a structured response matching the ParallelFollowupRes
```json
{
"analysis_summary": "Brief summary of what was analyzed",
"agents_invoked": ["resolution-verifier", "finding-validator", "new-code-reviewer"],
"agents_invoked": ["resolution-verifier", "new-code-reviewer"],
"commits_analyzed": 5,
"files_changed": 12,
"resolution_verifications": [...],
"finding_validations": [
{
"finding_id": "SEC-001",
"validation_status": "confirmed_valid",
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
"line_range": [45, 45],
"explanation": "SQL injection is present - user input is concatenated...",
"confidence": 0.92
},
{
"finding_id": "QUAL-002",
"validation_status": "dismissed_false_positive",
"code_evidence": "const sanitized = DOMPurify.sanitize(data);",
"line_range": [23, 26],
"explanation": "Original finding claimed XSS but code uses DOMPurify...",
"confidence": 0.88
}
],
"new_findings": [...],
"comment_analyses": [...],
"comment_findings": [...],
@@ -201,34 +119,15 @@ Provide your synthesis as a structured response matching the ParallelFollowupRes
"resolution_notes": null
},
"verdict": "READY_TO_MERGE",
"verdict_reasoning": "2 findings resolved, 1 dismissed as false positive, 1 confirmed valid but LOW severity..."
"verdict_reasoning": "All 3 previous findings verified as resolved..."
}
```
## CRITICAL: NEVER ASSUME - ALWAYS VERIFY
**This applies to ALL agents you invoke:**
1. **NEVER assume a finding is valid** - The finding-validator MUST read the actual code
2. **NEVER assume a fix is correct** - The resolution-verifier MUST verify the change
3. **NEVER assume line numbers are accurate** - Files may be shorter than cited lines
4. **NEVER assume validation is missing** - Check callers and surrounding code
5. **NEVER trust the original finding's description** - It may have been hallucinated
**Before ANY finding blocks merge:**
- The actual code at that location MUST be read
- The problematic pattern MUST exist as described
- There MUST NOT be mitigation/validation elsewhere
- The evidence MUST be copy-pasted from the actual file
**Why this matters:** AI reviewers sometimes hallucinate findings. Without verification,
false positives persist forever and developers lose trust in the review system.
## Important Notes
1. **Be efficient**: Follow-up reviews should be faster than initial reviews
2. **Focus on changes**: Only review what changed since last review
3. **VERIFY, don't assume**: Don't assume fixes are correct OR that findings are valid
3. **Trust but verify**: Don't assume fixes are correct just because files changed
4. **Acknowledge progress**: Recognize genuine effort to address feedback
5. **Be specific**: Clearly state what blocks merge if verdict is not READY_TO_MERGE
@@ -10,23 +10,6 @@ For each previous finding, determine whether it has been:
- **unresolved**: The issue remains or wasn't addressed
- **cant_verify**: Not enough information to determine status
## CRITICAL: Verify Finding is In-Scope
**Before verifying any finding, check if it's within THIS PR's scope:**
1. **Is the file in the PR's changed files list?** - If not AND the finding isn't about impact, mark as `cant_verify`
2. **Does the line number exist?** - If finding cites line 710 but file has 600 lines, it was hallucinated
3. **Was this from a merged branch?** - Commits with PR references like `(#584)` are from other PRs
**Mark as `cant_verify` if:**
- Finding references a file not in PR AND is not about impact of PR changes on that file
- Line number doesn't exist (hallucinated finding)
- Finding is about code from another PR's commits
**Findings can reference files outside the PR if they're about:**
- Impact of PR changes (e.g., "change to X breaks caller in Y")
- Missing related updates (e.g., "you updated A but forgot B")
## Verification Process
For each previous finding:
@@ -48,26 +31,12 @@ If the file was modified:
- Is the fix approach sound?
- Are there edge cases the fix misses?
### 4. Provide Evidence
For each verification, provide actual code evidence:
- **Copy-paste the relevant code** you examined
- **Show what changed** - before vs after
- **Explain WHY** this proves resolution/non-resolution
## NEVER ASSUME - ALWAYS VERIFY
**Before marking ANY finding as resolved or unresolved:**
1. **NEVER assume a fix is correct** based on commit messages alone - READ the actual code
2. **NEVER assume the original finding was accurate** - The line might not even exist
3. **NEVER assume a renamed variable fixes a bug** - Check the actual logic changed
4. **NEVER assume "file was modified" means "issue was fixed"** - Verify the specific fix
**You MUST:**
- Read the actual code at the cited location
- Verify the problematic pattern no longer exists (for resolved)
- Verify the pattern still exists (for unresolved)
- Check surrounding context for alternative fixes you might miss
### 4. Assign Confidence
Rate your confidence (0.0-1.0):
- **>0.9**: Clear evidence of resolution/non-resolution
- **0.7-0.9**: Strong indicators but some uncertainty
- **0.5-0.7**: Mixed signals, moderate confidence
- **<0.5**: Unclear, consider marking as cant_verify
## Resolution Criteria
@@ -115,20 +84,23 @@ Return verifications in this structure:
{
"finding_id": "SEC-001",
"status": "resolved",
"evidence": "cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))",
"resolution_notes": "Changed from f-string to cursor.execute() with parameters. The code at line 45 now uses parameterized queries."
"confidence": 0.92,
"evidence": "The SQL query at line 45 now uses parameterized queries instead of string concatenation. The fix properly escapes all user inputs.",
"resolution_notes": "Changed from f-string to cursor.execute() with parameters"
},
{
"finding_id": "QUAL-002",
"status": "partially_resolved",
"evidence": "try:\n result = process(data)\nexcept Exception as e:\n log.error(e)\n# But fallback path at line 78 still has: result = fallback(data) # no try-catch",
"confidence": 0.75,
"evidence": "Error handling was added for the main path, but the fallback path at line 78 still lacks try-catch.",
"resolution_notes": "Main function fixed, helper function still needs work"
},
{
"finding_id": "LOGIC-003",
"status": "unresolved",
"evidence": "for i in range(len(items) + 1): # Still uses <= length",
"resolution_notes": "The off-by-one error remains at line 52."
"confidence": 0.88,
"evidence": "The off-by-one error remains. The loop still uses `<= length` instead of `< length`.",
"resolution_notes": null
}
]
```
@@ -6,23 +6,6 @@ You are a focused logic and correctness review agent. You have been spawned by t
Verify that the code logic is correct, handles all edge cases, and doesn't introduce subtle bugs. Focus ONLY on logic and correctness issues - not style, security, or general quality.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Logic issues in changed code** - Bugs in files/lines modified by this PR
2. **Logic impact of changes** - "This change breaks the assumption in `caller.ts:50`"
3. **Incomplete state changes** - "You updated state X but forgot to reset Y"
4. **Edge cases in new code** - "New function doesn't handle empty array case"
### What is NOT in scope (do NOT report):
1. **Pre-existing bugs** - Old logic issues in untouched code
2. **Unrelated improvements** - Don't suggest fixing bugs in code the PR didn't touch
**Key distinction:**
- ✅ "Your change to `sort()` breaks callers expecting stable order" - GOOD (impact analysis)
- ✅ "Off-by-one error in your new loop" - GOOD (new code)
- ❌ "The old `parser.ts` has a race condition" - BAD (pre-existing, not this PR)
## Logic Focus Areas
### 1. Algorithm Correctness
@@ -6,34 +6,6 @@ You are an expert PR reviewer orchestrating a comprehensive, parallel code revie
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
2. **Impact on unchanged code** - "You changed X but forgot to update Y that depends on it"
3. **Missing related changes** - "This pattern also exists in Z, did you mean to update it too?"
4. **Breaking changes** - "This change breaks callers in other files"
### What is NOT in scope (do NOT report):
1. **Pre-existing issues** - Old bugs/issues in code this PR didn't touch
2. **Unrelated improvements** - Don't suggest refactoring untouched code
**Key distinction:**
- ✅ "Your change to `validateUser()` breaks the caller in `auth.ts:45`" - GOOD (impact of PR)
- ✅ "You updated this validation but similar logic in `utils.ts` wasn't updated" - GOOD (incomplete)
- ❌ "The existing code in `legacy.ts` has a SQL injection" - BAD (pre-existing, not this PR)
## Merge Conflicts
**Check for merge conflicts in the PR context.** If `has_merge_conflicts` is `true`:
1. **Report this prominently** - Merge conflicts block the PR from being merged
2. **Add a CRITICAL finding** with category "merge_conflict" and severity "critical"
3. **Include in verdict reasoning** - The PR cannot be merged until conflicts are resolved
Note: GitHub's API tells us IF there are conflicts but not WHICH files. The finding should state:
> "This PR has merge conflicts with the base branch that must be resolved before merging."
## Available Specialist Agents
You have access to these specialized review agents via the Task tool:
@@ -6,23 +6,6 @@ You are a focused code quality review agent. You have been spawned by the orches
Perform a thorough code quality review of the provided code changes. Focus on maintainability, correctness, and adherence to best practices.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Quality issues in changed code** - Problems in files/lines modified by this PR
2. **Quality impact of changes** - "This change increases complexity of `handler.ts`"
3. **Incomplete refactoring** - "You cleaned up X but similar pattern in Y wasn't updated"
4. **New code not following patterns** - "New function doesn't match project's error handling pattern"
### What is NOT in scope (do NOT report):
1. **Pre-existing quality issues** - Old code smells in untouched code
2. **Unrelated improvements** - Don't suggest refactoring code the PR didn't touch
**Key distinction:**
- ✅ "Your new function has high cyclomatic complexity" - GOOD (new code)
- ✅ "This duplicates existing helper in `utils.ts`, consider reusing it" - GOOD (guidance)
- ❌ "The old `legacy.ts` file has 1000 lines" - BAD (pre-existing, not this PR)
## Quality Focus Areas
### 1. Code Complexity
+19 -40
View File
@@ -4,49 +4,24 @@
You are a senior software engineer and security specialist performing a comprehensive code review. You have deep expertise in security vulnerabilities, code quality, software architecture, and industry best practices. Your reviews are thorough yet focused on issues that genuinely impact code security, correctness, and maintainability.
## Review Methodology: Evidence-Based Analysis
## Review Methodology: Chain-of-Thought Analysis
For each potential issue you consider:
1. **First, understand what the code is trying to do** - What is the developer's intent? What problem are they solving?
2. **Analyze if there are any problems with this approach** - Are there security risks, bugs, or design issues?
3. **Assess the severity and real-world impact** - Can this be exploited? Will this cause production issues? How likely is it to occur?
4. **REQUIRE EVIDENCE** - Only report if you can show the actual problematic code snippet
4. **Apply the 80% confidence threshold** - Only report if you have >80% confidence this is a genuine issue with real impact
5. **Provide a specific, actionable fix** - Give the developer exactly what they need to resolve the issue
## Evidence Requirements
## Confidence Requirements
**CRITICAL: No evidence = No finding**
**CRITICAL: Quality over quantity**
- **Every finding MUST include actual code evidence** (the `evidence` field with a copy-pasted code snippet)
- If you can't show the problematic code, **DO NOT report the finding**
- The evidence must be verifiable - it should exist at the file and line you specify
- **5 evidence-backed findings are far better than 15 speculative ones**
- Each finding should pass the test: "Can I prove this with actual code from the file?"
## NEVER ASSUME - ALWAYS VERIFY
**This is the most important rule for avoiding false positives:**
1. **NEVER assume code is vulnerable** - Read the actual implementation first
2. **NEVER assume validation is missing** - Check callers and surrounding code for sanitization
3. **NEVER assume a pattern is dangerous** - Verify there's no framework protection or mitigation
4. **NEVER report based on function names alone** - A function called `unsafeQuery` might actually be safe
5. **NEVER extrapolate from one line** - Read ±20 lines of context minimum
**Before reporting ANY finding, you MUST:**
- Actually read the code at the file/line you're about to cite
- Verify the problematic pattern exists exactly as you describe
- Check if there's validation/sanitization before or after
- Confirm the code path is actually reachable
- Verify the line number exists (file might be shorter than you think)
**Common false positive causes to avoid:**
- Reporting line 500 when the file only has 400 lines (hallucination)
- Claiming "no validation" when validation exists in the caller
- Flagging parameterized queries as SQL injection (framework protection)
- Reporting XSS when output is auto-escaped by the framework
- Citing code that was already fixed in an earlier commit
- Only report findings where you have **>80% confidence** this is a real issue
- If uncertain or it "could be a problem in theory," **DO NOT include it**
- **5 high-quality findings are far better than 15 low-quality ones**
- Each finding should pass the test: "Would I stake my reputation on this being a genuine issue?"
## Anti-Patterns to Avoid
@@ -239,13 +214,14 @@ Return a JSON array with this structure:
"id": "finding-1",
"severity": "critical",
"category": "security",
"confidence": 0.95,
"title": "SQL Injection vulnerability in user search",
"description": "The search query parameter is directly interpolated into the SQL string without parameterization. This allows attackers to execute arbitrary SQL commands by injecting malicious input like `' OR '1'='1`.",
"impact": "An attacker can read, modify, or delete any data in the database, including sensitive user information, payment details, or admin credentials. This could lead to complete data breach.",
"file": "src/api/users.ts",
"line": 42,
"end_line": 45,
"evidence": "const query = `SELECT * FROM users WHERE name LIKE '%${searchTerm}%'`",
"code_snippet": "const query = `SELECT * FROM users WHERE name LIKE '%${searchTerm}%'`",
"suggested_fix": "Use parameterized queries to prevent SQL injection:\n\nconst query = 'SELECT * FROM users WHERE name LIKE ?';\nconst results = await db.query(query, [`%${searchTerm}%`]);",
"fixable": true,
"references": ["https://owasp.org/www-community/attacks/SQL_Injection"]
@@ -254,12 +230,13 @@ Return a JSON array with this structure:
"id": "finding-2",
"severity": "high",
"category": "security",
"confidence": 0.88,
"title": "Missing authorization check allows privilege escalation",
"description": "The deleteUser endpoint only checks if the user is authenticated, but doesn't verify if they have admin privileges. Any logged-in user can delete other user accounts.",
"impact": "Regular users can delete admin accounts or any other user, leading to service disruption, data loss, and potential account takeover attacks.",
"file": "src/api/admin.ts",
"line": 78,
"evidence": "router.delete('/users/:id', authenticate, async (req, res) => {\n await User.delete(req.params.id);\n});",
"code_snippet": "router.delete('/users/:id', authenticate, async (req, res) => {\n await User.delete(req.params.id);\n});",
"suggested_fix": "Add authorization check:\n\nrouter.delete('/users/:id', authenticate, requireAdmin, async (req, res) => {\n await User.delete(req.params.id);\n});\n\n// Or inline:\nif (!req.user.isAdmin) {\n return res.status(403).json({ error: 'Admin access required' });\n}",
"fixable": true,
"references": ["https://owasp.org/Top10/A01_2021-Broken_Access_Control/"]
@@ -268,13 +245,13 @@ Return a JSON array with this structure:
"id": "finding-3",
"severity": "medium",
"category": "quality",
"confidence": 0.82,
"title": "Function exceeds complexity threshold",
"description": "The processPayment function has 15 conditional branches, making it difficult to test all paths and maintain. High cyclomatic complexity increases bug risk.",
"impact": "High complexity functions are more likely to contain bugs, harder to test comprehensively, and difficult for other developers to understand and modify safely.",
"file": "src/payments/processor.ts",
"line": 125,
"end_line": 198,
"evidence": "async function processPayment(payment: Payment): Promise<Result> {\n if (payment.type === 'credit') { ... } else if (payment.type === 'debit') { ... }\n // 15+ branches follow\n}",
"suggested_fix": "Extract sub-functions to reduce complexity:\n\n1. validatePaymentData(payment) - handle all validation\n2. calculateFees(amount, type) - fee calculation logic\n3. processRefund(payment) - refund-specific logic\n4. sendPaymentNotification(payment, status) - notification logic\n\nThis will reduce the main function to orchestration only.",
"fixable": false,
"references": []
@@ -293,18 +270,19 @@ Return a JSON array with this structure:
- **medium** (Recommended): Improve code quality (maintainability concerns) - **Blocks merge: YES** (AI fixes quickly)
- **low** (Suggestion): Suggestions for improvement (minor enhancements) - **Blocks merge: NO**
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
- **confidence**: Float 0.0-1.0 representing your confidence this is a genuine issue (must be ≥0.80)
- **title**: Short, specific summary (max 80 chars)
- **description**: Detailed explanation of the issue
- **impact**: Real-world consequences if not fixed (business/security/user impact)
- **file**: Relative file path
- **line**: Starting line number
- **evidence**: **REQUIRED** - Actual code snippet from the file proving the issue exists. Must be copy-pasted from the actual code.
- **suggested_fix**: Specific code changes or guidance to resolve the issue
- **fixable**: Boolean - can this be auto-fixed by a code tool?
### Optional Fields
- **end_line**: Ending line number for multi-line issues
- **code_snippet**: The problematic code excerpt
- **references**: Array of relevant URLs (OWASP, CVE, documentation)
## Guidelines for High-Quality Reviews
@@ -314,7 +292,7 @@ Return a JSON array with this structure:
3. **Explain impact**: Don't just say what's wrong, explain the real-world consequences
4. **Prioritize ruthlessly**: Focus on issues that genuinely matter
5. **Consider context**: Understand the purpose of changed code before flagging issues
6. **Require evidence**: Always include the actual code snippet in the `evidence` field - no code, no finding
6. **Validate confidence**: If you're not >80% sure, don't report it
7. **Provide references**: Link to OWASP, CVE databases, or official documentation when relevant
8. **Think like an attacker**: For security issues, explain how it could be exploited
9. **Be constructive**: Frame issues as opportunities to improve, not criticisms
@@ -336,12 +314,13 @@ Return a JSON array with this structure:
"id": "finding-auth-1",
"severity": "critical",
"category": "security",
"confidence": 0.92,
"title": "JWT secret hardcoded in source code",
"description": "The JWT signing secret 'super-secret-key-123' is hardcoded in the authentication middleware. Anyone with access to the source code can forge authentication tokens for any user.",
"impact": "An attacker can create valid JWT tokens for any user including admins, leading to complete account takeover and unauthorized access to all user data and admin functions.",
"file": "src/middleware/auth.ts",
"line": 12,
"evidence": "const SECRET = 'super-secret-key-123';\njwt.sign(payload, SECRET);",
"code_snippet": "const SECRET = 'super-secret-key-123';\njwt.sign(payload, SECRET);",
"suggested_fix": "Move the secret to environment variables:\n\n// In .env file:\nJWT_SECRET=<generate-random-256-bit-secret>\n\n// In auth.ts:\nconst SECRET = process.env.JWT_SECRET;\nif (!SECRET) {\n throw new Error('JWT_SECRET not configured');\n}\njwt.sign(payload, SECRET);",
"fixable": true,
"references": [
@@ -353,4 +332,4 @@ Return a JSON array with this structure:
---
Remember: Your goal is to find **genuine, high-impact issues** that will make the codebase more secure, correct, and maintainable. **Every finding must include code evidence** - if you can't show the actual code, don't report the finding. Quality over quantity. Be thorough but focused.
Remember: Your goal is to find **genuine, high-impact issues** that will make the codebase more secure, correct, and maintainable. Quality over quantity. Be thorough but focused.
@@ -6,23 +6,6 @@ You are a focused security review agent. You have been spawned by the orchestrat
Perform a thorough security review of the provided code changes, focusing ONLY on security vulnerabilities. Do not review code quality, style, or other non-security concerns.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Security issues in changed code** - Vulnerabilities introduced or modified by this PR
2. **Security impact of changes** - "This change exposes sensitive data to the new endpoint"
3. **Missing security for new features** - "New API endpoint lacks authentication"
4. **Broken security assumptions** - "Change to auth.ts invalidates security check in handler.ts"
### What is NOT in scope (do NOT report):
1. **Pre-existing vulnerabilities** - Old security issues in code this PR didn't touch
2. **Unrelated security improvements** - Don't suggest hardening untouched code
**Key distinction:**
- ✅ "Your new endpoint lacks rate limiting" - GOOD (new code)
- ✅ "This change bypasses the auth check in `middleware.ts`" - GOOD (impact analysis)
- ❌ "The old `legacy_auth.ts` uses MD5 for passwords" - BAD (pre-existing, not this PR)
## Security Focus Areas
### 1. Injection Vulnerabilities
+16 -75
View File
@@ -3,19 +3,12 @@ QA Fixer Agent Session
=======================
Runs QA fixer sessions to resolve issues identified by the reviewer.
Memory Integration:
- Retrieves past patterns, fixes, and gotchas before fixing
- Saves fix outcomes and learnings after session
"""
from pathlib import Path
# Memory integration for cross-session learning
from agents.memory_manager import get_graphiti_context, save_session_memory
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -51,7 +44,6 @@ async def run_qa_fixer_session(
spec_dir: Path,
fix_session: int,
verbose: bool = False,
project_dir: Path | None = None,
) -> tuple[str, str]:
"""
Run a QA fixer agent session.
@@ -61,18 +53,12 @@ async def run_qa_fixer_session(
spec_dir: Spec directory
fix_session: Fix iteration number
verbose: Whether to show detailed output
project_dir: Project root directory (for memory context)
Returns:
(status, response_text) where status is:
- "fixed" if fixes were applied
- "error" if an error occurred
"""
# Derive project_dir from spec_dir if not provided
# spec_dir is typically: /project/.auto-claude/specs/001-name/
if project_dir is None:
# Walk up from spec_dir to find project root
project_dir = spec_dir.parent.parent.parent
debug_section("qa_fixer", f"QA Fixer Session {fix_session}")
debug(
"qa_fixer",
@@ -102,20 +88,6 @@ async def run_qa_fixer_session(
prompt = load_qa_fixer_prompt()
debug_detailed("qa_fixer", "Loaded QA fixer prompt", prompt_length=len(prompt))
# Retrieve memory context for fixer (past fixes, patterns, gotchas)
fixer_memory_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Fixing QA issues and implementing corrections",
"id": f"qa_fixer_{fix_session}",
},
)
if fixer_memory_context:
prompt += "\n\n" + fixer_memory_context
print("✓ Memory context loaded for QA fixer")
debug_success("qa_fixer", "Graphiti memory context loaded for fixer")
# Add session context - use full path so agent can find files
prompt += f"\n\n---\n\n**Fix Session**: {fix_session}\n"
prompt += f"**Spec Directory**: {spec_dir}\n"
@@ -156,35 +128,34 @@ async def run_qa_fixer_session(
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input_display = None
tool_input = None
tool_count += 1
# Safely extract tool input (handles None, non-dict, etc.)
inp = get_safe_tool_input(block)
if inp:
if "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
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input = cmd
debug(
"qa_fixer",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input_display,
tool_input=tool_input,
)
# Log tool start (handles printing)
if task_logger:
task_logger.tool_start(
tool_name,
tool_input_display,
tool_input,
LogPhase.VALIDATION,
print_to_console=True,
)
@@ -271,42 +242,12 @@ async def run_qa_fixer_session(
if status
else False,
)
# Save fixer session insights to memory
fixer_discoveries = {
"files_understood": {},
"patterns_found": [
f"QA fixer session {fix_session}: Applied fixes from QA_FIX_REQUEST.md"
],
"gotchas_encountered": [],
}
if status and status.get("ready_for_qa_revalidation"):
debug_success("qa_fixer", "Fixes applied, ready for QA revalidation")
# Save successful fix session to memory
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_fixer_{fix_session}",
session_num=fix_session,
success=True,
subtasks_completed=[f"qa_fixer_{fix_session}"],
discoveries=fixer_discoveries,
)
return "fixed", response_text
else:
# Fixer didn't update the status properly, but we'll trust it worked
debug_success("qa_fixer", "Fixes assumed applied (status not updated)")
# Still save to memory as successful (fixes were attempted)
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_fixer_{fix_session}",
session_num=fix_session,
success=True,
subtasks_completed=[f"qa_fixer_{fix_session}"],
discoveries=fixer_discoveries,
)
return "fixed", response_text
except Exception as e:
+13 -72
View File
@@ -4,20 +4,13 @@ QA Reviewer Agent Session
Runs QA validation sessions to review implementation against
acceptance criteria.
Memory Integration:
- Retrieves past patterns, gotchas, and insights before QA session
- Saves QA findings (bugs, patterns, validation outcomes) after session
"""
from pathlib import Path
# Memory integration for cross-session learning
from agents.memory_manager import get_graphiti_context, save_session_memory
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from prompts_pkg import get_qa_reviewer_prompt
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -88,20 +81,6 @@ async def run_qa_agent_session(
project_dir=str(project_dir),
)
# Retrieve memory context for QA (past patterns, gotchas, validation insights)
qa_memory_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "QA validation and acceptance criteria review",
"id": f"qa_reviewer_{qa_session}",
},
)
if qa_memory_context:
prompt += "\n\n" + qa_memory_context
print("✓ Memory context loaded for QA reviewer")
debug_success("qa_reviewer", "Graphiti memory context loaded for QA")
# Add session context
prompt += f"\n\n---\n\n**QA Session**: {qa_session}\n"
prompt += f"**Max Iterations**: {max_iterations}\n"
@@ -216,33 +195,32 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input_display = None
tool_input = None
tool_count += 1
# Safely extract tool input (handles None, non-dict, etc.)
inp = get_safe_tool_input(block)
# Extract tool input for display
if inp:
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input_display = fp
elif "pattern" in inp:
tool_input_display = f"pattern: {inp['pattern']}"
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
debug(
"qa_reviewer",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input_display,
tool_input=tool_input,
)
# Log tool start (handles printing)
if task_logger:
task_logger.tool_start(
tool_name,
tool_input_display,
tool_input,
LogPhase.VALIDATION,
print_to_console=True,
)
@@ -327,48 +305,11 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
response_length=len(response_text),
qa_status=status.get("status") if status else "unknown",
)
# Save QA session insights to memory
qa_discoveries = {
"files_understood": {},
"patterns_found": [],
"gotchas_encountered": [],
}
if status and status.get("status") == "approved":
debug_success("qa_reviewer", "QA APPROVED")
qa_discoveries["patterns_found"].append(
f"QA session {qa_session}: All acceptance criteria validated successfully"
)
# Save successful QA session to memory
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_reviewer_{qa_session}",
session_num=qa_session,
success=True,
subtasks_completed=[f"qa_reviewer_{qa_session}"],
discoveries=qa_discoveries,
)
return "approved", response_text
elif status and status.get("status") == "rejected":
debug_error("qa_reviewer", "QA REJECTED")
# Extract issues found for memory
issues = status.get("issues_found", [])
for issue in issues:
qa_discoveries["gotchas_encountered"].append(
f"QA Issue ({issue.get('type', 'unknown')}): {issue.get('title', 'No title')} at {issue.get('location', 'unknown')}"
)
# Save rejected QA session to memory (learning from failures)
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_reviewer_{qa_session}",
session_num=qa_session,
success=False,
subtasks_completed=[],
discoveries=qa_discoveries,
)
return "rejected", response_text
else:
# Agent didn't update the status properly - provide detailed error
@@ -8,7 +8,6 @@ from typing import Any
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from phase_config import resolve_model_id
CLAUDE_SDK_AVAILABLE = True
except ImportError:
@@ -18,7 +17,7 @@ except ImportError:
class ClaudeAnalysisClient:
"""Wrapper for Claude SDK client with analysis-specific configuration."""
DEFAULT_MODEL = "sonnet" # Shorthand - resolved via API Profile if configured
DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
ALLOWED_TOOLS = ["Read", "Glob", "Grep"]
MAX_TURNS = 50
@@ -111,7 +110,7 @@ class ClaudeAnalysisClient:
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id(self.DEFAULT_MODEL), # Resolve via API Profile
model=self.DEFAULT_MODEL,
system_prompt=system_prompt,
allowed_tools=self.ALLOWED_TOOLS,
max_turns=self.MAX_TURNS,
+9 -25
View File
@@ -1,18 +1,16 @@
"""
DEPRECATED: Review Confidence Scoring
=====================================
Review Confidence Scoring
=========================
This module is DEPRECATED and will be removed in a future version.
Adds confidence scores to review findings to help users prioritize.
The confidence scoring approach has been replaced with EVIDENCE-BASED VALIDATION:
- Instead of assigning confidence scores (0-100), findings now require concrete
code evidence proving the issue exists.
- Simple rule: If you can't show the actual problematic code, don't report it.
- Validation is binary: either the evidence exists in the file or it doesn't.
Features:
- Confidence scoring based on pattern matching, historical accuracy
- Risk assessment (false positive likelihood)
- Evidence tracking for transparency
- Calibration based on outcome tracking
For new code, use evidence-based validation in pydantic_models.py and models.py instead.
Legacy Usage (deprecated):
Usage:
scorer = ConfidenceScorer(learning_tracker=tracker)
# Score a finding
@@ -22,24 +20,10 @@ Legacy Usage (deprecated):
# Get explanation
print(scorer.explain_confidence(scored))
Migration:
- Instead of `confidence: float`, use `evidence: str` with actual code snippets
- Instead of filtering by confidence threshold, verify evidence exists in file
- See pr_finding_validator.md for the new evidence-based approach
"""
from __future__ import annotations
import warnings
warnings.warn(
"The confidence module is deprecated. Use evidence-based validation instead. "
"See models.py 'evidence' field and pr_finding_validator.md for the new approach.",
DeprecationWarning,
stacklevel=2,
)
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
+13 -88
View File
@@ -201,14 +201,6 @@ class PRContext:
ai_bot_comments: list[AIBotComment] = field(default_factory=list)
# Flag indicating if full diff was skipped (PR > 20K lines)
diff_truncated: bool = False
# Commit SHAs for worktree creation (PR review isolation)
head_sha: str = "" # Commit SHA of PR head (headRefOid)
base_sha: str = "" # Commit SHA of PR base (baseRefOid)
# Merge conflict status
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
merge_state_status: str = (
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
)
class PRContextGatherer:
@@ -281,17 +273,6 @@ class PRContextGatherer:
# Check if diff was truncated (empty diff but files were changed)
diff_truncated = len(diff) == 0 and len(changed_files) > 0
# Check merge conflict status
mergeable = pr_data.get("mergeable", "UNKNOWN")
merge_state_status = pr_data.get("mergeStateStatus", "UNKNOWN")
has_merge_conflicts = mergeable == "CONFLICTING"
if has_merge_conflicts:
print(
f"[Context] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
flush=True,
)
return PRContext(
pr_number=self.pr_number,
title=pr_data["title"],
@@ -310,10 +291,6 @@ class PRContextGatherer:
total_deletions=pr_data.get("deletions", 0),
ai_bot_comments=ai_bot_comments,
diff_truncated=diff_truncated,
head_sha=pr_data.get("headRefOid", ""),
base_sha=pr_data.get("baseRefOid", ""),
has_merge_conflicts=has_merge_conflicts,
merge_state_status=merge_state_status,
)
async def _fetch_pr_metadata(self) -> dict:
@@ -335,8 +312,6 @@ class PRContextGatherer:
"deletions",
"changedFiles",
"labels",
"mergeable", # MERGEABLE, CONFLICTING, or UNKNOWN
"mergeStateStatus", # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
],
)
@@ -1056,56 +1031,28 @@ class FollowupContextGatherer:
f"[Followup] Comparing {previous_sha[:8]}...{current_sha[:8]}", flush=True
)
# Get PR-scoped files and commits (excludes merge-introduced changes)
# This solves the problem where merging develop into a feature branch
# would include commits from other PRs in the follow-up review.
# Pass reviewed_file_blobs for rebase-resistant comparison
reviewed_file_blobs = getattr(self.previous_review, "reviewed_file_blobs", {})
# Get commit comparison
try:
pr_files, new_commits = await self.gh_client.get_pr_files_changed_since(
self.pr_number, previous_sha, reviewed_file_blobs=reviewed_file_blobs
)
print(
f"[Followup] PR has {len(pr_files)} files, "
f"{len(new_commits)} commits since last review"
+ (" (blob comparison used)" if reviewed_file_blobs else ""),
flush=True,
)
comparison = await self.gh_client.compare_commits(previous_sha, current_sha)
except Exception as e:
print(f"[Followup] Error getting PR files/commits: {e}", flush=True)
# Fallback to compare_commits if PR endpoints fail
print("[Followup] Falling back to commit comparison...", flush=True)
try:
comparison = await self.gh_client.compare_commits(
previous_sha, current_sha
)
new_commits = comparison.get("commits", [])
pr_files = comparison.get("files", [])
print(
f"[Followup] Fallback: Found {len(new_commits)} commits, "
f"{len(pr_files)} files (may include merge-introduced changes)",
flush=True,
)
except Exception as e2:
print(f"[Followup] Fallback also failed: {e2}", flush=True)
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
previous_commit_sha=previous_sha,
current_commit_sha=current_sha,
error=f"Failed to get PR context: {e}, fallback: {e2}",
)
print(f"[Followup] Error comparing commits: {e}", flush=True)
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
previous_commit_sha=previous_sha,
current_commit_sha=current_sha,
error=f"Failed to compare commits: {e}",
)
# Use PR files as the canonical list (excludes files from merged branches)
commits = new_commits
files = pr_files
# Extract data from comparison
commits = comparison.get("commits", [])
files = comparison.get("files", [])
print(
f"[Followup] Found {len(commits)} new commits, {len(files)} changed files",
flush=True,
)
# Build diff from file patches
# Note: PR files endpoint returns 'filename' key, compare returns 'filename' too
diff_parts = []
files_changed = []
for file_info in files:
@@ -1187,26 +1134,6 @@ class FollowupContextGatherer:
flush=True,
)
# Fetch current merge conflict status
has_merge_conflicts = False
merge_state_status = "UNKNOWN"
try:
pr_status = await self.gh_client.pr_get(
self.pr_number,
json_fields=["mergeable", "mergeStateStatus"],
)
mergeable = pr_status.get("mergeable", "UNKNOWN")
merge_state_status = pr_status.get("mergeStateStatus", "UNKNOWN")
has_merge_conflicts = mergeable == "CONFLICTING"
if has_merge_conflicts:
print(
f"[Followup] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
flush=True,
)
except Exception as e:
print(f"[Followup] Could not fetch merge status: {e}", flush=True)
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
@@ -1219,6 +1146,4 @@ class FollowupContextGatherer:
+ contributor_reviews,
ai_bot_comments_since_review=ai_comments,
pr_reviews_since_review=pr_reviews,
has_merge_conflicts=has_merge_conflicts,
merge_state_status=merge_state_status,
)
-271
View File
@@ -810,274 +810,3 @@ class GHClient:
# Last commit is the HEAD
return commits[-1].get("oid")
return None
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
"""
Get CI check runs status for a PR.
Uses `gh pr checks` to get the status of all check runs.
Args:
pr_number: PR number
Returns:
Dict with:
- checks: List of check runs with name, state
- passing: Number of passing checks
- failing: Number of failing checks
- pending: Number of pending checks
- failed_checks: List of failed check names
"""
try:
# Note: gh pr checks --json only supports: bucket, completedAt, description,
# event, link, name, startedAt, state, workflow
# The 'state' field directly contains the result (SUCCESS, FAILURE, PENDING, etc.)
args = ["pr", "checks", str(pr_number), "--json", "name,state"]
args = self._add_repo_flag(args)
result = await self.run(args, timeout=30.0)
checks = json.loads(result.stdout) if result.stdout.strip() else []
passing = 0
failing = 0
pending = 0
failed_checks = []
for check in checks:
state = check.get("state", "").upper()
name = check.get("name", "Unknown")
# gh pr checks 'state' directly contains: SUCCESS, FAILURE, PENDING, NEUTRAL, etc.
if state in ("SUCCESS", "NEUTRAL", "SKIPPED"):
passing += 1
elif state in ("FAILURE", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE"):
failing += 1
failed_checks.append(name)
else:
# PENDING, QUEUED, IN_PROGRESS, etc.
pending += 1
return {
"checks": checks,
"passing": passing,
"failing": failing,
"pending": pending,
"failed_checks": failed_checks,
}
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
logger.warning(f"Failed to get PR checks for #{pr_number}: {e}")
return {
"checks": [],
"passing": 0,
"failing": 0,
"pending": 0,
"failed_checks": [],
"error": str(e),
}
async def get_pr_files(self, pr_number: int) -> list[dict[str, Any]]:
"""
Get files changed by a PR using the PR files endpoint.
IMPORTANT: This returns only files that are part of the PR's actual changes,
NOT files that came in from merging another branch (e.g., develop).
This is crucial for follow-up reviews to avoid reviewing code from other PRs.
Uses: GET /repos/{owner}/{repo}/pulls/{pr_number}/files
Args:
pr_number: PR number
Returns:
List of file objects with:
- filename: Path to the file
- status: added, removed, modified, renamed, copied, changed
- additions: Number of lines added
- deletions: Number of lines deleted
- changes: Total number of line changes
- patch: The unified diff patch for this file (may be absent for large files)
"""
files = []
page = 1
per_page = 100
while True:
endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/files?page={page}&per_page={per_page}"
args = ["api", "--method", "GET", endpoint]
result = await self.run(args, timeout=60.0)
page_files = json.loads(result.stdout) if result.stdout.strip() else []
if not page_files:
break
files.extend(page_files)
# Check if we got a full page (more pages might exist)
if len(page_files) < per_page:
break
page += 1
# Safety limit to prevent infinite loops
if page > 50:
logger.warning(
f"PR #{pr_number} has more than 5000 files, stopping pagination"
)
break
return files
async def get_pr_commits(self, pr_number: int) -> list[dict[str, Any]]:
"""
Get commits that are part of a PR using the PR commits endpoint.
IMPORTANT: This returns only commits that are part of the PR's branch,
NOT commits that came in from merging another branch (e.g., develop).
This is crucial for follow-up reviews to avoid reviewing commits from other PRs.
Uses: GET /repos/{owner}/{repo}/pulls/{pr_number}/commits
Args:
pr_number: PR number
Returns:
List of commit objects with:
- sha: Commit SHA
- commit: Object with message, author, committer info
- author: GitHub user who authored the commit
- committer: GitHub user who committed
- parents: List of parent commit SHAs
"""
commits = []
page = 1
per_page = 100
while True:
endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/commits?page={page}&per_page={per_page}"
args = ["api", "--method", "GET", endpoint]
result = await self.run(args, timeout=60.0)
page_commits = json.loads(result.stdout) if result.stdout.strip() else []
if not page_commits:
break
commits.extend(page_commits)
# Check if we got a full page (more pages might exist)
if len(page_commits) < per_page:
break
page += 1
# Safety limit
if page > 10:
logger.warning(
f"PR #{pr_number} has more than 1000 commits, stopping pagination"
)
break
return commits
async def get_pr_files_changed_since(
self,
pr_number: int,
base_sha: str,
reviewed_file_blobs: dict[str, str] | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""
Get files and commits that are part of the PR and changed since a specific commit.
This method solves the "merge introduced commits" problem by:
1. Getting the canonical list of PR files (excludes files from merged branches)
2. Getting the canonical list of PR commits (excludes commits from merged branches)
3. Filtering to only include commits after base_sha
When a rebase/force-push is detected (base_sha not found in commits), and
reviewed_file_blobs is provided, uses blob SHA comparison to identify which
files actually changed content. This prevents re-reviewing unchanged files.
Args:
pr_number: PR number
base_sha: The commit SHA to compare from (e.g., last reviewed commit)
reviewed_file_blobs: Optional dict mapping filename -> blob SHA from the
previous review. Used as fallback when base_sha is not found (rebase).
Returns:
Tuple of:
- List of file objects that are part of the PR (filtered if blob comparison used)
- List of commit objects that are part of the PR and after base_sha
"""
# Get PR's canonical files (these are the actual PR changes)
pr_files = await self.get_pr_files(pr_number)
# Get PR's canonical commits
pr_commits = await self.get_pr_commits(pr_number)
# Find the position of base_sha in PR commits
# Use minimum 7-char prefix comparison (git's default short SHA length)
base_index = -1
min_prefix_len = 7
base_prefix = (
base_sha[:min_prefix_len] if len(base_sha) >= min_prefix_len else base_sha
)
for i, commit in enumerate(pr_commits):
commit_prefix = commit["sha"][:min_prefix_len]
if commit_prefix == base_prefix:
base_index = i
break
# Commits after base_sha (these are the new commits to review)
if base_index >= 0:
new_commits = pr_commits[base_index + 1 :]
return pr_files, new_commits
# base_sha not found in PR commits - this happens when:
# 1. The base_sha was from a merge commit (not a direct PR commit)
# 2. The PR was rebased/force-pushed
logger.warning(
f"base_sha {base_sha[:8]} not found in PR #{pr_number} commits. "
"PR was likely rebased or force-pushed."
)
# If we have blob SHAs from the previous review, use them to filter files
# Blob SHAs persist across rebases - same content = same blob SHA
if reviewed_file_blobs: # Only use blob comparison if we have actual blob data
changed_files = []
unchanged_count = 0
for file in pr_files:
filename = file.get("filename", "")
current_blob_sha = file.get("sha", "")
file_status = file.get("status", "")
previous_blob_sha = reviewed_file_blobs.get(filename, "")
# Always include files that were added, removed, or renamed
# These are significant changes regardless of blob SHA
if file_status in ("added", "removed", "renamed"):
changed_files.append(file)
elif not previous_blob_sha:
# File wasn't in previous review - include it
changed_files.append(file)
elif current_blob_sha != previous_blob_sha:
# File content changed - include it
changed_files.append(file)
else:
# Same blob SHA = same content - skip it
unchanged_count += 1
if unchanged_count > 0:
logger.info(
f"Blob comparison: {len(changed_files)} files changed, "
f"{unchanged_count} unchanged (skipped)"
)
# Return filtered files but all commits (can't filter commits after rebase)
return changed_files, pr_commits
# No blob data available - return all files and commits
logger.warning(
"No reviewed_file_blobs available for blob comparison. "
"Returning all PR files."
)
return pr_files, pr_commits
+6 -32
View File
@@ -214,20 +214,13 @@ class PRReviewFinding:
end_line: int | None = None
suggested_fix: str | None = None
fixable: bool = False
# Evidence-based validation: actual code proving the issue exists
evidence: str | None = None # Actual code snippet showing the issue
# NEW: Support for verification and redundancy detection
confidence: float = 0.85 # AI's confidence in this finding (0.0-1.0)
verification_note: str | None = (
None # What evidence is missing or couldn't be verified
)
redundant_with: str | None = None # Reference to duplicate code (file:line)
# Finding validation fields (from finding-validator re-investigation)
validation_status: str | None = (
None # confirmed_valid, dismissed_false_positive, needs_human_review
)
validation_evidence: str | None = None # Code snippet examined during validation
validation_explanation: str | None = None # Why finding was validated/dismissed
def to_dict(self) -> dict:
return {
"id": self.id,
@@ -240,14 +233,10 @@ class PRReviewFinding:
"end_line": self.end_line,
"suggested_fix": self.suggested_fix,
"fixable": self.fixable,
# Evidence-based validation fields
"evidence": self.evidence,
# NEW fields
"confidence": self.confidence,
"verification_note": self.verification_note,
"redundant_with": self.redundant_with,
# Validation fields
"validation_status": self.validation_status,
"validation_evidence": self.validation_evidence,
"validation_explanation": self.validation_explanation,
}
@classmethod
@@ -263,14 +252,10 @@ class PRReviewFinding:
end_line=data.get("end_line"),
suggested_fix=data.get("suggested_fix"),
fixable=data.get("fixable", False),
# Evidence-based validation fields
evidence=data.get("evidence"),
# NEW fields
confidence=data.get("confidence", 0.85),
verification_note=data.get("verification_note"),
redundant_with=data.get("redundant_with"),
# Validation fields
validation_status=data.get("validation_status"),
validation_evidence=data.get("validation_evidence"),
validation_explanation=data.get("validation_explanation"),
)
@@ -380,9 +365,6 @@ class PRReviewResult:
# Follow-up review tracking
reviewed_commit_sha: str | None = None # HEAD SHA at time of review
reviewed_file_blobs: dict[str, str] = field(
default_factory=dict
) # filename → blob SHA at time of review (survives rebases)
is_followup_review: bool = False # True if this is a follow-up review
previous_review_id: int | None = None # Reference to the review this follows up on
resolved_findings: list[str] = field(default_factory=list) # Finding IDs now fixed
@@ -421,7 +403,6 @@ class PRReviewResult:
"quick_scan_summary": self.quick_scan_summary,
# Follow-up review fields
"reviewed_commit_sha": self.reviewed_commit_sha,
"reviewed_file_blobs": self.reviewed_file_blobs,
"is_followup_review": self.is_followup_review,
"previous_review_id": self.previous_review_id,
"resolved_findings": self.resolved_findings,
@@ -466,7 +447,6 @@ class PRReviewResult:
quick_scan_summary=data.get("quick_scan_summary", {}),
# Follow-up review fields
reviewed_commit_sha=data.get("reviewed_commit_sha"),
reviewed_file_blobs=data.get("reviewed_file_blobs", {}),
is_followup_review=data.get("is_followup_review", False),
previous_review_id=data.get("previous_review_id"),
resolved_findings=data.get("resolved_findings", []),
@@ -564,12 +544,6 @@ class FollowupReviewContext:
# These are different from comments - they're full review submissions with body text
pr_reviews_since_review: list[dict] = field(default_factory=list)
# Merge conflict status
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
merge_state_status: str = (
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
)
# Error flag - if set, context gathering failed and data may be incomplete
error: str | None = None
+6 -81
View File
@@ -389,17 +389,9 @@ class GitHubOrchestrator:
pr_number=pr_number,
)
# Check CI status
ci_status = await self.gh_client.get_pr_checks(pr_number)
print(
f"[DEBUG orchestrator] CI status: {ci_status.get('passing', 0)} passing, "
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending",
flush=True,
)
# Generate verdict (now includes CI status)
# Generate verdict
verdict, verdict_reasoning, blockers = self._generate_verdict(
findings, structural_issues, ai_triages, ci_status
findings, structural_issues, ai_triages
)
print(
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
@@ -435,25 +427,6 @@ class GitHubOrchestrator:
# Get HEAD SHA for follow-up review tracking
head_sha = self.bot_detector.get_last_commit_sha(pr_context.commits)
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
pr_files = await self.gh_client.get_pr_files(pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
print(
f"[Review] Captured {len(file_blobs)} file blob SHAs for follow-up tracking",
flush=True,
)
except Exception as e:
print(
f"[Review] Warning: Could not capture file blobs: {e}", flush=True
)
# Create result
result = PRReviewResult(
pr_number=pr_number,
@@ -471,8 +444,6 @@ class GitHubOrchestrator:
quick_scan_summary=quick_scan,
# Track the commit SHA for follow-up reviews
reviewed_commit_sha=head_sha,
# Track file blobs for rebase-resistant follow-up reviews
reviewed_file_blobs=file_blobs,
)
# Post review if configured
@@ -690,37 +661,6 @@ class GitHubOrchestrator:
)
result = await reviewer.review_followup(followup_context)
# Check CI status and override verdict if failing
ci_status = await self.gh_client.get_pr_checks(pr_number)
failed_checks = ci_status.get("failed_checks", [])
if failed_checks:
print(
f"[Followup] CI checks failing: {failed_checks}",
flush=True,
)
# Override verdict if CI is failing
if result.verdict in (
MergeVerdict.READY_TO_MERGE,
MergeVerdict.MERGE_WITH_CHANGES,
):
result.verdict = MergeVerdict.BLOCKED
result.verdict_reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
"Fix CI before merge."
)
result.overall_status = "request_changes"
# Add CI failures to blockers
for check_name in failed_checks:
if f"CI Failed: {check_name}" not in result.blockers:
result.blockers.append(f"CI Failed: {check_name}")
# Update summary to reflect CI status
ci_warning = (
f"\n\n**⚠️ CI Status:** {len(failed_checks)} check(s) failing: "
f"{', '.join(failed_checks)}"
)
if ci_warning not in result.summary:
result.summary += ci_warning
# Save result
await result.save(self.github_dir)
@@ -750,16 +690,13 @@ class GitHubOrchestrator:
findings: list[PRReviewFinding],
structural_issues: list[StructuralIssue],
ai_triages: list[AICommentTriage],
ci_status: dict | None = None,
) -> tuple[MergeVerdict, str, list[str]]:
"""
Generate merge verdict based on all findings and CI status.
Generate merge verdict based on all findings.
NEW: Strengthened to block on verification failures, redundancy issues,
and failing CI checks.
NEW: Strengthened to block on verification failures and redundancy issues.
"""
blockers = []
ci_status = ci_status or {}
# Count by severity
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
@@ -796,11 +733,6 @@ class GitHubOrchestrator:
ai_critical = [t for t in ai_triages if t.verdict == AICommentVerdict.CRITICAL]
# Build blockers list with NEW categories first
# CI failures block merging
failed_checks = ci_status.get("failed_checks", [])
for check_name in failed_checks:
blockers.append(f"CI Failed: {check_name}")
# NEW: Verification failures block merging
for f in verification_failures:
note = f" - {f.verification_note}" if f.verification_note else ""
@@ -833,17 +765,10 @@ class GitHubOrchestrator:
)
blockers.append(f"{t.tool_name}: {summary}")
# Determine verdict with CI, verification and redundancy checks
# Determine verdict with NEW verification and redundancy checks
if blockers:
# CI failures are always blockers
if failed_checks:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
"Fix CI before merge."
)
# NEW: Prioritize verification failures
elif verification_failures:
if verification_failures:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: Cannot verify {len(verification_failures)} claim(s) in PR. "
@@ -26,7 +26,6 @@ if TYPE_CHECKING:
from ..models import FollowupReviewContext, GitHubRunnerConfig
try:
from ..gh_client import GHClient
from ..models import (
MergeVerdict,
PRReviewFinding,
@@ -38,7 +37,6 @@ try:
from .prompt_manager import PromptManager
from .pydantic_models import FollowupReviewResponse
except (ImportError, ValueError, SystemError):
from gh_client import GHClient
from models import (
MergeVerdict,
PRReviewFinding,
@@ -232,27 +230,6 @@ class FollowupReviewer:
"complete", 100, "Follow-up review complete!", context.pr_number
)
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
repo=self.config.repo,
)
pr_files = await gh_client.get_pr_files(context.pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
logger.info(
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
)
except Exception as e:
logger.warning(f"Could not capture file blobs: {e}")
return PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
@@ -266,7 +243,6 @@ class FollowupReviewer:
reviewed_at=datetime.now().isoformat(),
# Follow-up specific fields
reviewed_commit_sha=context.current_commit_sha,
reviewed_file_blobs=file_blobs,
is_followup_review=True,
previous_review_id=context.previous_review.review_id,
resolved_findings=[f.id for f in resolved],
@@ -32,7 +32,6 @@ from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ..gh_client import GHClient
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -45,7 +44,6 @@ try:
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from core.client import create_client
from gh_client import GHClient
from models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -152,7 +150,6 @@ class ParallelFollowupReviewer:
resolution_prompt = self._load_prompt("pr_followup_resolution_agent.md")
newcode_prompt = self._load_prompt("pr_followup_newcode_agent.md")
comment_prompt = self._load_prompt("pr_followup_comment_agent.md")
validator_prompt = self._load_prompt("pr_finding_validator.md")
return {
"resolution-verifier": AgentDefinition(
@@ -189,20 +186,6 @@ class ParallelFollowupReviewer:
tools=["Read", "Grep", "Glob"],
model="inherit",
),
"finding-validator": AgentDefinition(
description=(
"Finding re-investigation specialist. Re-investigates unresolved findings "
"to validate they are actually real issues, not false positives. "
"Actively reads the code at the finding location with fresh eyes. "
"Can confirm findings as valid OR dismiss them as false positives. "
"CRITICAL: Invoke for ALL unresolved findings after resolution-verifier runs. "
"Invoke when: There are findings marked as unresolved that need validation."
),
prompt=validator_prompt
or "You validate whether unresolved findings are real issues.",
tools=["Read", "Grep", "Glob"],
model="inherit",
),
}
def _format_previous_findings(self, context: FollowupReviewContext) -> str:
@@ -461,11 +444,6 @@ The SDK will run invoked agents in parallel automatically.
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved"
)
# Extract validation counts
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
confirmed_count = result_data.get("confirmed_valid_count", 0)
needs_human_count = result_data.get("needs_human_review_count", 0)
# Generate summary
summary = self._generate_summary(
verdict=verdict,
@@ -474,9 +452,6 @@ The SDK will run invoked agents in parallel automatically.
unresolved_count=len(unresolved_ids),
new_count=len(new_finding_ids),
agents_invoked=final_agents,
dismissed_false_positive_count=dismissed_count,
confirmed_valid_count=confirmed_count,
needs_human_review_count=needs_human_count,
)
# Map verdict to overall_status
@@ -500,27 +475,6 @@ The SDK will run invoked agents in parallel automatically.
):
blockers.append(f"{finding.category.value}: {finding.title}")
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
repo=self.config.repo,
)
pr_files = await gh_client.get_pr_files(context.pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
logger.info(
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
)
except Exception as e:
logger.warning(f"Could not capture file blobs: {e}")
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
@@ -532,7 +486,6 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
reviewed_commit_sha=context.current_commit_sha,
reviewed_file_blobs=file_blobs,
is_followup_review=True,
previous_review_id=context.previous_review.review_id
or context.previous_review.pr_number,
@@ -592,34 +545,10 @@ The SDK will run invoked agents in parallel automatically.
new_finding_ids = []
# Process resolution verifications
# First, build a map of finding validations (from finding-validator agent)
validation_map = {}
dismissed_ids = []
for fv in response.finding_validations:
validation_map[fv.finding_id] = fv
if fv.validation_status == "dismissed_false_positive":
dismissed_ids.append(fv.finding_id)
print(
f"[ParallelFollowup] Finding {fv.finding_id} DISMISSED as false positive: {fv.explanation[:100]}",
flush=True,
)
for rv in response.resolution_verifications:
if rv.status == "resolved":
resolved_ids.append(rv.finding_id)
elif rv.status in ("unresolved", "partially_resolved", "cant_verify"):
# Check if finding was validated and dismissed as false positive
if rv.finding_id in dismissed_ids:
# Finding-validator determined this was a false positive - skip it
print(
f"[ParallelFollowup] Skipping {rv.finding_id} - dismissed as false positive by finding-validator",
flush=True,
)
resolved_ids.append(
rv.finding_id
) # Count as resolved (false positive)
continue
# Include "cant_verify" as unresolved - if we can't verify, assume not fixed
unresolved_ids.append(rv.finding_id)
# Add unresolved as a finding
@@ -634,17 +563,6 @@ The SDK will run invoked agents in parallel automatically.
None,
)
if original:
# Check if we have validation evidence
validation = validation_map.get(rv.finding_id)
validation_status = None
validation_evidence = None
validation_explanation = None
if validation:
validation_status = validation.validation_status
validation_evidence = validation.code_evidence
validation_explanation = validation.explanation
findings.append(
PRReviewFinding(
id=rv.finding_id,
@@ -656,9 +574,6 @@ The SDK will run invoked agents in parallel automatically.
line=original.line,
suggested_fix=original.suggested_fix,
fixable=original.fixable,
validation_status=validation_status,
validation_evidence=validation_evidence,
validation_explanation=validation_explanation,
)
)
@@ -711,18 +626,6 @@ The SDK will run invoked agents in parallel automatically.
}
verdict = verdict_map.get(response.verdict, MergeVerdict.NEEDS_REVISION)
# Count validation results
confirmed_valid_count = sum(
1
for fv in response.finding_validations
if fv.validation_status == "confirmed_valid"
)
needs_human_count = sum(
1
for fv in response.finding_validations
if fv.validation_status == "needs_human_review"
)
# Log findings summary for verification
print(
f"[ParallelFollowup] Parsed {len(findings)} findings, "
@@ -730,22 +633,11 @@ The SDK will run invoked agents in parallel automatically.
f"{len(new_finding_ids)} new",
flush=True,
)
if dismissed_ids:
print(
f"[ParallelFollowup] Validation: {len(dismissed_ids)} findings dismissed as false positives, "
f"{confirmed_valid_count} confirmed valid, {needs_human_count} need human review",
flush=True,
)
if findings:
print("[ParallelFollowup] Findings summary:", flush=True)
for i, f in enumerate(findings, 1):
validation_note = ""
if f.validation_status == "confirmed_valid":
validation_note = " [VALIDATED]"
elif f.validation_status == "needs_human_review":
validation_note = " [NEEDS HUMAN REVIEW]"
print(
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line}){validation_note}",
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})",
flush=True,
)
@@ -754,9 +646,6 @@ The SDK will run invoked agents in parallel automatically.
"resolved_ids": resolved_ids,
"unresolved_ids": unresolved_ids,
"new_finding_ids": new_finding_ids,
"dismissed_false_positive_ids": dismissed_ids,
"confirmed_valid_count": confirmed_valid_count,
"needs_human_review_count": needs_human_count,
"verdict": verdict,
"verdict_reasoning": response.verdict_reasoning,
"agents_invoked": agents_from_output,
@@ -830,9 +719,6 @@ The SDK will run invoked agents in parallel automatically.
unresolved_count: int,
new_count: int,
agents_invoked: list[str],
dismissed_false_positive_count: int = 0,
confirmed_valid_count: int = 0,
needs_human_review_count: int = 0,
) -> str:
"""Generate a human-readable summary of the follow-up review."""
status_emoji = {
@@ -847,27 +733,13 @@ The SDK will run invoked agents in parallel automatically.
", ".join(agents_invoked) if agents_invoked else "orchestrator only"
)
# Build validation section if there are validation results
validation_section = ""
if (
dismissed_false_positive_count > 0
or confirmed_valid_count > 0
or needs_human_review_count > 0
):
validation_section = f"""
### Finding Validation
- 🔍 **Dismissed as False Positives**: {dismissed_false_positive_count} findings were re-investigated and found to be incorrect
- **Confirmed Valid**: {confirmed_valid_count} findings verified as genuine issues
- 👤 **Needs Human Review**: {needs_human_review_count} findings require manual verification
"""
summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()}
### Resolution Status
- **Resolved**: {resolved_count} previous findings addressed
- **Unresolved**: {unresolved_count} previous findings remain
- 🆕 **New Issues**: {new_count} new findings in recent changes
{validation_section}
### Verdict
{verdict_reasoning}
@@ -875,6 +747,6 @@ The SDK will run invoked agents in parallel automatically.
Agents invoked: {agents_str}
---
*This is an AI-generated follow-up review using parallel specialist analysis with finding validation.*
*This is an AI-generated follow-up review using parallel specialist analysis.*
"""
return summary
@@ -20,20 +20,15 @@ from __future__ import annotations
import hashlib
import logging
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentDefinition
from core.git_bash import get_git_executable_path
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ..context_gatherer import PRContext, _validate_git_ref
from ..gh_client import GHClient
from ..context_gatherer import PRContext
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -45,9 +40,8 @@ try:
from .pydantic_models import ParallelOrchestratorResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext, _validate_git_ref
from context_gatherer import PRContext
from core.client import create_client
from gh_client import GHClient
from models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -66,9 +60,6 @@ logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
# Directory for PR review worktrees (inside github/pr for consistency)
PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees"
class ParallelOrchestratorReviewer:
"""
@@ -125,204 +116,6 @@ class ParallelOrchestratorReviewer:
logger.warning(f"Prompt file not found: {prompt_file}")
return ""
def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path:
"""Create a temporary worktree at the PR head commit.
Args:
head_sha: The commit SHA of the PR head (validated before use)
pr_number: The PR number for naming
Returns:
Path to the created worktree
Raises:
RuntimeError: If worktree creation fails
ValueError: If head_sha fails validation (command injection prevention)
"""
# SECURITY: Validate git ref before use in subprocess calls
if not _validate_git_ref(head_sha):
raise ValueError(
f"Invalid git ref: '{head_sha}'. "
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
)
worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}"
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if DEBUG_MODE:
print(f"[PRReview] DEBUG: project_dir={self.project_dir}", flush=True)
print(f"[PRReview] DEBUG: worktree_dir={worktree_dir}", flush=True)
print(f"[PRReview] DEBUG: head_sha={head_sha}", flush=True)
worktree_dir.mkdir(parents=True, exist_ok=True)
worktree_path = worktree_dir / worktree_name
if DEBUG_MODE:
print(f"[PRReview] DEBUG: worktree_path={worktree_path}", flush=True)
print(
f"[PRReview] DEBUG: worktree_dir exists={worktree_dir.exists()}",
flush=True,
)
# Fetch the commit if not available locally (handles fork PRs)
git_path = get_git_executable_path()
fetch_result = subprocess.run(
[git_path, "fetch", "origin", head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=60,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: fetch returncode={fetch_result.returncode}",
flush=True,
)
if fetch_result.stderr:
print(
f"[PRReview] DEBUG: fetch stderr={fetch_result.stderr[:200]}",
flush=True,
)
# Create detached worktree at the PR commit
result = subprocess.run(
[git_path, "worktree", "add", "--detach", str(worktree_path), head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=120, # Worktree add can be slow for large repos
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree add returncode={result.returncode}",
flush=True,
)
if result.stderr:
print(
f"[PRReview] DEBUG: worktree add stderr={result.stderr[:200]}",
flush=True,
)
if result.stdout:
print(
f"[PRReview] DEBUG: worktree add stdout={result.stdout[:200]}",
flush=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree created, exists={worktree_path.exists()}",
flush=True,
)
logger.info(f"[PRReview] Created worktree at {worktree_path}")
return worktree_path
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
"""Remove a temporary PR review worktree with fallback chain.
Args:
worktree_path: Path to the worktree to remove
"""
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: _cleanup_pr_worktree called with {worktree_path}",
flush=True,
)
if not worktree_path or not worktree_path.exists():
if DEBUG_MODE:
print(
"[PRReview] DEBUG: worktree path doesn't exist, skipping cleanup",
flush=True,
)
return
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Attempting to remove worktree at {worktree_path}",
flush=True,
)
# Try 1: git worktree remove
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree remove returncode={result.returncode}",
flush=True,
)
if result.returncode == 0:
logger.info(f"[PRReview] Cleaned up worktree: {worktree_path.name}")
return
# Try 2: shutil.rmtree fallback
try:
shutil.rmtree(worktree_path, ignore_errors=True)
subprocess.run(
[git_path, "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
logger.warning(f"[PRReview] Used shutil fallback for: {worktree_path.name}")
except Exception as e:
logger.error(f"[PRReview] Failed to cleanup worktree {worktree_path}: {e}")
def _cleanup_stale_pr_worktrees(self) -> None:
"""Clean up orphaned PR review worktrees on startup."""
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if not worktree_dir.exists():
return
# Get registered worktrees from git
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "worktree", "list", "--porcelain"],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
registered = set()
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
# Safely parse - check bounds to prevent IndexError
parts = line.split(" ", 1)
if len(parts) > 1 and parts[1]:
registered.add(Path(parts[1]))
# Remove unregistered directories
stale_count = 0
for item in worktree_dir.iterdir():
if item.is_dir() and item not in registered:
logger.info(f"[PRReview] Removing stale worktree: {item.name}")
shutil.rmtree(item, ignore_errors=True)
stale_count += 1
if stale_count > 0:
subprocess.run(
[git_path, "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Cleaned up {stale_count} stale worktree(s)",
flush=True,
)
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
"""
Define specialist agents for the SDK.
@@ -590,7 +383,7 @@ The SDK will run invoked agents in parallel automatically.
category=category,
severity=severity,
suggested_fix=finding_data.suggested_fix or "",
evidence=finding_data.evidence,
confidence=self._normalize_confidence(finding_data.confidence),
)
async def review(self, context: PRContext) -> PRReviewResult:
@@ -607,12 +400,6 @@ The SDK will run invoked agents in parallel automatically.
f"[ParallelOrchestrator] Starting review for PR #{context.pr_number}"
)
# Clean up any stale worktrees from previous runs
self._cleanup_stale_pr_worktrees()
# Track worktree for cleanup
worktree_path: Path | None = None
try:
self._report_progress(
"orchestrating",
@@ -624,75 +411,12 @@ The SDK will run invoked agents in parallel automatically.
# Build orchestrator prompt
prompt = self._build_orchestrator_prompt(context)
# Create temporary worktree at PR head commit for isolated review
# This ensures agents read from the correct PR state, not the current checkout
head_sha = context.head_sha or context.head_branch
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: context.head_sha='{context.head_sha}'",
flush=True,
)
print(
f"[PRReview] DEBUG: context.head_branch='{context.head_branch}'",
flush=True,
)
print(f"[PRReview] DEBUG: resolved head_sha='{head_sha}'", flush=True)
# SECURITY: Validate the resolved head_sha (whether SHA or branch name)
# This catches invalid refs early before subprocess calls
if head_sha and not _validate_git_ref(head_sha):
logger.warning(
f"[ParallelOrchestrator] Invalid git ref '{head_sha}', "
"using current checkout for safety"
)
head_sha = None
if not head_sha:
if DEBUG_MODE:
print("[PRReview] DEBUG: No head_sha - using fallback", flush=True)
logger.warning(
"[ParallelOrchestrator] No head_sha available, using current checkout"
)
# Fallback to original behavior if no SHA available
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
else:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Creating worktree for head_sha={head_sha}",
flush=True,
)
try:
worktree_path = self._create_pr_worktree(
head_sha, context.pr_number
)
project_root = worktree_path
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Using worktree as "
f"project_root={project_root}",
flush=True,
)
except (RuntimeError, ValueError) as e:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Worktree creation FAILED: {e}",
flush=True,
)
logger.warning(
f"[ParallelOrchestrator] Worktree creation failed, "
f"using current checkout: {e}"
)
# Fallback to original behavior if worktree creation fails
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
# Get project root
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
# Use model and thinking level from config (user settings)
model = self.config.model or "claude-sonnet-4-5-20250929"
@@ -805,27 +529,6 @@ The SDK will run invoked agents in parallel automatically.
latest_commit = context.commits[-1]
head_sha = latest_commit.get("oid") or latest_commit.get("sha")
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
repo=self.config.repo,
)
pr_files = await gh_client.get_pr_files(context.pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
logger.info(
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
)
except Exception as e:
logger.warning(f"Could not capture file blobs: {e}")
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
@@ -837,7 +540,6 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
reviewed_commit_sha=head_sha,
reviewed_file_blobs=file_blobs,
)
self._report_progress(
@@ -857,10 +559,6 @@ The SDK will run invoked agents in parallel automatically.
success=False,
error=str(e),
)
finally:
# Always cleanup worktree, even on error
if worktree_path:
self._cleanup_pr_worktree(worktree_path)
def _parse_structured_output(
self, structured_output: dict[str, Any]
@@ -973,7 +671,7 @@ The SDK will run invoked agents in parallel automatically.
category=category,
severity=severity,
suggested_fix=f_data.get("suggested_fix", ""),
evidence=f_data.get("evidence"),
confidence=self._normalize_confidence(f_data.get("confidence", 85)),
)
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
@@ -26,7 +26,7 @@ from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# =============================================================================
# Common Finding Types
@@ -46,10 +46,6 @@ class BaseFinding(BaseModel):
line: int = Field(0, description="Line number of the issue")
suggested_fix: str | None = Field(None, description="How to fix this issue")
fixable: bool = Field(False, description="Whether this can be auto-fixed")
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
)
class SecurityFinding(BaseFinding):
@@ -82,6 +78,9 @@ class DeepAnalysisFinding(BaseFinding):
"performance",
"logic",
] = Field(description="Issue category")
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="AI's confidence in this finding (0.0-1.0)"
)
verification_note: str | None = Field(
None, description="What evidence is missing or couldn't be verified"
)
@@ -316,11 +315,21 @@ class OrchestratorFinding(BaseModel):
description="Issue severity level"
)
suggestion: str | None = Field(None, description="How to fix this issue")
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
confidence: float = Field(
0.85,
ge=0.0,
le=1.0,
description="Confidence (0.0-1.0 or 0-100, normalized to 0.0-1.0)",
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range (accepts 0-100 or 0.0-1.0)."""
if v > 1:
return v / 100.0
return float(v)
class OrchestratorReviewResponse(BaseModel):
"""Complete response schema for orchestrator PR review."""
@@ -346,6 +355,9 @@ class LogicFinding(BaseFinding):
category: Literal["logic"] = Field(
default="logic", description="Always 'logic' for logic findings"
)
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
example_input: str | None = Field(
None, description="Concrete input that triggers the bug"
)
@@ -354,6 +366,14 @@ class LogicFinding(BaseFinding):
None, description="What the code should produce"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class CodebaseFitFinding(BaseFinding):
"""A codebase fit finding from the codebase fit review agent."""
@@ -361,6 +381,9 @@ class CodebaseFitFinding(BaseFinding):
category: Literal["codebase_fit"] = Field(
default="codebase_fit", description="Always 'codebase_fit' for fit findings"
)
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
existing_code: str | None = Field(
None, description="Reference to existing code that should be used instead"
)
@@ -368,6 +391,14 @@ class CodebaseFitFinding(BaseFinding):
None, description="Description of the established pattern being violated"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class ParallelOrchestratorFinding(BaseModel):
"""A finding from the parallel orchestrator with source agent tracking."""
@@ -392,9 +423,8 @@ class ParallelOrchestratorFinding(BaseModel):
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
suggested_fix: str | None = Field(None, description="How to fix this issue")
fixable: bool = Field(False, description="Whether this can be auto-fixed")
@@ -406,6 +436,14 @@ class ParallelOrchestratorFinding(BaseModel):
False, description="Whether multiple agents agreed on this finding"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class AgentAgreement(BaseModel):
"""Tracks agreement between agents on findings."""
@@ -458,14 +496,22 @@ class ResolutionVerification(BaseModel):
status: Literal["resolved", "partially_resolved", "unresolved", "cant_verify"] = (
Field(description="Resolution status after AI verification")
)
evidence: str = Field(
min_length=1,
description="Actual code snippet showing the resolution status. Required.",
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in the resolution status"
)
evidence: str = Field(description="What evidence supports this resolution status")
resolution_notes: str | None = Field(
None, description="Detailed notes on how the issue was addressed"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class ParallelFollowupFinding(BaseModel):
"""A finding from parallel follow-up review with source agent tracking."""
@@ -488,9 +534,8 @@ class ParallelFollowupFinding(BaseModel):
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
suggested_fix: str | None = Field(None, description="How to fix this issue")
fixable: bool = Field(False, description="Whether this can be auto-fixed")
@@ -501,6 +546,14 @@ class ParallelFollowupFinding(BaseModel):
None, description="ID of related previous finding if this is a regression"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class CommentAnalysis(BaseModel):
"""Analysis of a contributor or AI comment."""
@@ -538,15 +591,6 @@ class ParallelFollowupResponse(BaseModel):
description="AI-verified resolution status for each previous finding",
)
# Finding validations (from finding-validator agent)
finding_validations: list[FindingValidationResult] = Field(
default_factory=list,
description=(
"Re-investigation results for unresolved findings. "
"Validates whether findings are real issues or false positives."
),
)
# New findings (from new-code-reviewer agent)
new_findings: list[ParallelFollowupFinding] = Field(
default_factory=list,
@@ -574,72 +618,3 @@ class ParallelFollowupResponse(BaseModel):
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
verdict_reasoning: str = Field(description="Explanation for the verdict")
# =============================================================================
# Finding Validation Response (Re-investigation of unresolved findings)
# =============================================================================
class FindingValidationResult(BaseModel):
"""
Result of re-investigating an unresolved finding to validate it's actually real.
The finding-validator agent uses this to report whether a previous finding
is a genuine issue or a false positive that should be dismissed.
EVIDENCE-BASED VALIDATION: No confidence scores - validation is binary.
Either the evidence shows the issue exists, or it doesn't.
"""
finding_id: str = Field(description="ID of the finding being validated")
validation_status: Literal[
"confirmed_valid", "dismissed_false_positive", "needs_human_review"
] = Field(
description=(
"Validation result: "
"confirmed_valid = code evidence proves issue IS real; "
"dismissed_false_positive = code evidence proves issue does NOT exist; "
"needs_human_review = cannot find definitive evidence either way"
)
)
code_evidence: str = Field(
min_length=1,
description=(
"REQUIRED: Exact code snippet examined from the file. "
"Must be actual code copy-pasted from the file, not a description. "
"This is the proof that determines the validation status."
),
)
line_range: tuple[int, int] = Field(
description="Start and end line numbers of the examined code"
)
explanation: str = Field(
min_length=20,
description=(
"Detailed explanation connecting the code_evidence to the validation_status. "
"Must explain: (1) what the original finding claimed, (2) what the actual code shows, "
"(3) why this proves/disproves the issue."
),
)
evidence_verified_in_file: bool = Field(
description=(
"True if the code_evidence was verified to exist at the specified line_range. "
"False if the code couldn't be found (indicates hallucination in original finding)."
)
)
class FindingValidationResponse(BaseModel):
"""Complete response from the finding-validator agent."""
validations: list[FindingValidationResult] = Field(
default_factory=list,
description="Validation results for each finding investigated",
)
summary: str = Field(
description=(
"Brief summary of validation results: how many confirmed, "
"how many dismissed, how many need human review"
)
)
@@ -33,9 +33,8 @@ except (ImportError, ValueError, SystemError):
TriageResult,
)
# Evidence-based validation replaces confidence scoring
# Findings without evidence are filtered out instead of using confidence thresholds
MIN_EVIDENCE_LENGTH = 20 # Minimum chars for evidence to be considered valid
# Confidence threshold for filtering findings (GitHub Copilot standard)
CONFIDENCE_THRESHOLD = 0.80
class ResponseParser:
@@ -66,13 +65,9 @@ class ResponseParser:
@staticmethod
def parse_review_findings(
response_text: str, require_evidence: bool = True
response_text: str, apply_confidence_filter: bool = True
) -> list[PRReviewFinding]:
"""Parse findings from AI response with optional evidence validation.
Evidence-based validation: Instead of confidence scores, findings
require actual code evidence proving the issue exists.
"""
"""Parse findings from AI response with optional confidence filtering."""
findings = []
try:
@@ -82,14 +77,14 @@ class ResponseParser:
if json_match:
findings_data = json.loads(json_match.group(1))
for i, f in enumerate(findings_data):
# Get evidence (code snippet proving the issue)
evidence = f.get("evidence") or f.get("code_snippet") or ""
# Get confidence (default to 0.85 if not provided for backward compat)
confidence = float(f.get("confidence", 0.85))
# Apply evidence-based validation
if require_evidence and len(evidence.strip()) < MIN_EVIDENCE_LENGTH:
# Apply confidence threshold filter
if apply_confidence_filter and confidence < CONFIDENCE_THRESHOLD:
print(
f"[AI] Dropped finding '{f.get('title', 'unknown')}': "
f"insufficient evidence ({len(evidence.strip())} chars < {MIN_EVIDENCE_LENGTH})",
f"confidence {confidence:.2f} < {CONFIDENCE_THRESHOLD}",
flush=True,
)
continue
@@ -110,8 +105,8 @@ class ResponseParser:
end_line=f.get("end_line"),
suggested_fix=f.get("suggested_fix"),
fixable=f.get("fixable", False),
# Evidence-based validation fields
evidence=evidence if evidence.strip() else None,
# NEW: Support verification and redundancy fields
confidence=confidence,
verification_note=f.get("verification_note"),
redundant_with=f.get("redundant_with"),
)
+2 -2
View File
@@ -94,8 +94,8 @@ def main():
parser.add_argument(
"--model",
type=str,
default="sonnet", # Changed from "opus" (fix #433)
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-opus-4-5-20251101",
help="Model to use (default: claude-opus-4-5-20251101)",
)
parser.add_argument(
"--thinking-level",
+4 -5
View File
@@ -39,7 +39,6 @@ from debug import (
debug_section,
debug_success,
)
from phase_config import resolve_model_id
def load_project_context(project_dir: str) -> str:
@@ -133,7 +132,7 @@ async def run_with_sdk(
project_dir: str,
message: str,
history: list,
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
thinking_level: str = "medium",
) -> None:
"""Run the chat using Claude SDK with streaming."""
@@ -181,7 +180,7 @@ Current question: {message}"""
# Create Claude SDK client with appropriate settings for insights
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id(model), # Resolve via API Profile if configured
model=model, # Use configured model
system_prompt=system_prompt,
allowed_tools=[
"Read",
@@ -337,8 +336,8 @@ def main():
)
parser.add_argument(
"--model",
default="sonnet",
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-sonnet-4-5-20250929",
help="Claude model ID (default: claude-sonnet-4-5-20250929)",
)
parser.add_argument(
"--thinking-level",
+1 -1
View File
@@ -23,6 +23,6 @@ class RoadmapConfig:
project_dir: Path
output_dir: Path
model: str = "sonnet" # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101"
refresh: bool = False # Force regeneration even if roadmap exists
enable_competitor_analysis: bool = False # Enable competitor analysis phase
+1 -1
View File
@@ -27,7 +27,7 @@ class RoadmapOrchestrator:
self,
project_dir: Path,
output_dir: Path | None = None,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
enable_competitor_analysis: bool = False,
+2 -2
View File
@@ -55,8 +55,8 @@ def main():
parser.add_argument(
"--model",
type=str,
default="sonnet", # Changed from "opus" (fix #433)
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-opus-4-5-20251101",
help="Model to use (default: claude-opus-4-5-20251101)",
)
parser.add_argument(
"--thinking-level",
+2 -11
View File
@@ -47,8 +47,6 @@ if sys.version_info < (3, 10): # noqa: UP036
import asyncio
import io
import os
import platform
import subprocess
from pathlib import Path
# Configure safe encoding on Windows BEFORE any imports that might print
@@ -342,15 +340,8 @@ Examples:
print(f" {muted('Running:')} {' '.join(run_cmd)}")
print()
# Execute run.py
# On Windows, use subprocess.run() to properly handle paths with spaces
# os.execv() on Windows has issues with argument quoting for paths containing spaces
if platform.system() == "Windows":
result = subprocess.run(run_cmd)
sys.exit(result.returncode)
else:
# On Unix, os.execv() replaces the current process (more efficient)
os.execv(sys.executable, run_cmd)
# Execute run.py - replace current process
os.execv(sys.executable, run_cmd)
sys.exit(0)
-9
View File
@@ -50,12 +50,6 @@ from .profile import (
reset_profile_cache,
)
# Tool input validation
from .tool_input_validator import (
get_safe_tool_input,
validate_tool_input,
)
# Validators (for advanced usage)
from .validator import (
VALIDATORS,
@@ -106,7 +100,4 @@ __all__ = [
"is_command_allowed",
"needs_validation",
"BASE_COMMANDS",
# Tool input validation
"validate_tool_input",
"get_safe_tool_input",
]
+10 -26
View File
@@ -26,11 +26,10 @@ async def bash_security_hook(
Pre-tool-use hook that validates bash commands using dynamic allowlist.
This is the main security enforcement point. It:
1. Validates tool_input structure (must be dict with 'command' key)
2. Extracts command names from the command string
3. Checks each command against the project's security profile
4. Runs additional validation for sensitive commands
5. Blocks disallowed commands with clear error messages
1. Extracts command names from the command string
2. Checks each command against the project's security profile
3. Runs additional validation for sensitive commands
4. Blocks disallowed commands with clear error messages
Args:
input_data: Dict containing tool_name and tool_input
@@ -43,30 +42,15 @@ async def bash_security_hook(
if input_data.get("tool_name") != "Bash":
return {}
# Validate tool_input structure before accessing
tool_input = input_data.get("tool_input")
# Check if tool_input is None (malformed tool call)
if tool_input is None:
return {
"decision": "block",
"reason": "Bash tool_input is None - malformed tool call from SDK",
}
# Check if tool_input is a dict
if not isinstance(tool_input, dict):
return {
"decision": "block",
"reason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
}
# Now safe to access command
command = tool_input.get("command", "")
command = input_data.get("tool_input", {}).get("command", "")
if not command:
return {}
# Get the working directory from input_data (SDK passes it there, not in context)
cwd = input_data.get("cwd") or os.getcwd()
# Get the working directory from context or use current directory
# In the actual client, this would be set by the ClaudeSDKClient
cwd = os.getcwd()
if context and hasattr(context, "cwd"):
cwd = context.cwd
# Get or create security profile
# Note: In actual use, spec_dir would be passed through context
+2 -6
View File
@@ -22,8 +22,6 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from core.git_bash import get_git_executable_path
# =============================================================================
# SECRET PATTERNS
# =============================================================================
@@ -366,9 +364,8 @@ def scan_content(content: str, file_path: str) -> list[SecretMatch]:
def get_staged_files() -> list[str]:
"""Get list of staged files from git (excluding deleted files)."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "diff", "--cached", "--name-only", "--diff-filter=ACM"],
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True,
text=True,
check=True,
@@ -382,9 +379,8 @@ def get_staged_files() -> list[str]:
def get_all_tracked_files() -> list[str]:
"""Get all tracked files in the repository."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "ls-files"],
["git", "ls-files"],
capture_output=True,
text=True,
check=True,
@@ -1,97 +0,0 @@
"""
Tool Input Validator
====================
Validates tool_input structure before tool execution.
Catches malformed inputs (None, wrong type, missing required keys) early.
"""
from typing import Any
# Required keys per tool type
TOOL_REQUIRED_KEYS: dict[str, list[str]] = {
"Bash": ["command"],
"Read": ["file_path"],
"Write": ["file_path", "content"],
"Edit": ["file_path", "old_string", "new_string"],
"Glob": ["pattern"],
"Grep": ["pattern"],
"WebFetch": ["url"],
"WebSearch": ["query"],
}
def validate_tool_input(
tool_name: str,
tool_input: Any,
) -> tuple[bool, str | None]:
"""
Validate tool input structure.
Args:
tool_name: Name of the tool being called
tool_input: The tool_input value from the SDK
Returns:
(is_valid, error_message) where error_message is None if valid
"""
# Must not be None
if tool_input is None:
return False, f"{tool_name}: tool_input is None (malformed tool call)"
# Must be a dict
if not isinstance(tool_input, dict):
return (
False,
f"{tool_name}: tool_input must be dict, got {type(tool_input).__name__}",
)
# Check required keys for known tools
required_keys = TOOL_REQUIRED_KEYS.get(tool_name, [])
missing_keys = [key for key in required_keys if key not in tool_input]
if missing_keys:
return (
False,
f"{tool_name}: missing required keys: {', '.join(missing_keys)}",
)
# Additional validation for specific tools
if tool_name == "Bash":
command = tool_input.get("command")
if not isinstance(command, str):
return (
False,
f"Bash: 'command' must be string, got {type(command).__name__}",
)
if not command.strip():
return False, "Bash: 'command' is empty"
return True, None
def get_safe_tool_input(block: Any, default: dict | None = None) -> dict:
"""
Safely extract tool_input from a ToolUseBlock, defaulting to empty dict.
Args:
block: A ToolUseBlock from Claude SDK
default: Default value if extraction fails (defaults to empty dict)
Returns:
The tool input as a dict (never None)
"""
if default is None:
default = {}
if not hasattr(block, "input"):
return default
tool_input = block.input
if tool_input is None:
return default
if not isinstance(tool_input, dict):
return default
return tool_input
+1 -4
View File
@@ -20,8 +20,6 @@ from datetime import datetime
from enum import Enum
from pathlib import Path
from core.git_bash import get_git_executable_path
class FailureType(Enum):
"""Types of failures that can occur during autonomous builds."""
@@ -427,9 +425,8 @@ class RecoveryManager:
"""
try:
# Use git reset --hard to rollback
git_path = get_git_executable_path()
result = subprocess.run(
[git_path, "reset", "--hard", commit_hash],
["git", "reset", "--hard", commit_hash],
cwd=self.project_dir,
capture_output=True,
text=True,
+1 -1
View File
@@ -16,7 +16,7 @@ from core.simple_client import create_simple_client
async def summarize_phase_output(
phase_name: str,
phase_output: str,
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
target_words: int = 500,
) -> str:
"""
+8 -8
View File
@@ -14,7 +14,6 @@ configure_safe_encoding()
from core.client import create_client
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -161,24 +160,25 @@ class AgentRunner:
block, "name"
):
tool_name = block.name
tool_input = None
tool_count += 1
# Safely extract tool input (handles None, non-dict, etc.)
inp = get_safe_tool_input(block)
tool_input_display = self._extract_tool_input_display(
inp
)
# Extract meaningful tool input for display
if hasattr(block, "input") and block.input:
tool_input = self._extract_tool_input_display(
block.input
)
debug(
"agent_runner",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input_display,
tool_input=tool_input,
)
if self.task_logger:
self.task_logger.tool_start(
tool_name,
tool_input_display,
tool_input,
LogPhase.PLANNING,
print_to_console=True,
)
+2 -3
View File
@@ -57,7 +57,7 @@ class SpecOrchestrator:
spec_name: str | None = None,
spec_dir: Path
| None = None, # Use existing spec directory (for UI integration)
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
thinking_level: str = "medium", # Thinking level for extended thinking
complexity_override: str | None = None, # Force a specific complexity
use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics)
@@ -173,11 +173,10 @@ class SpecOrchestrator:
return
# Summarize the output
# Use sonnet shorthand - will resolve via API Profile if configured
summary = await summarize_phase_output(
phase_name,
phase_output,
model="sonnet",
model="claude-sonnet-4-5-20250929", # Use Sonnet for efficiency
target_words=500,
)
+34 -128
View File
@@ -6,7 +6,6 @@ Build status tracking and status file management for ccstatusline integration.
"""
import json
import threading
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
@@ -105,16 +104,10 @@ class BuildStatus:
class StatusManager:
"""Manages the .auto-claude-status file for ccstatusline integration."""
# Class-level debounce delay (ms) for batched writes
_WRITE_DEBOUNCE_MS = 50
def __init__(self, project_dir: Path):
self.project_dir = Path(project_dir)
self.status_file = self.project_dir / ".auto-claude-status"
self._status = BuildStatus()
self._write_pending = False
self._write_timer: threading.Timer | None = None
self._write_lock = threading.Lock() # Protects _write_pending and _write_timer
def read(self) -> BuildStatus:
"""Read current status from file."""
@@ -129,114 +122,38 @@ class StatusManager:
except (OSError, json.JSONDecodeError):
return BuildStatus()
def _do_write(self) -> None:
"""Perform the actual file write."""
import os
import time
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
write_start = time.time()
with self._write_lock:
self._write_pending = False
self._write_timer = None
# Update timestamp inside lock to prevent race conditions
self._status.last_update = datetime.now().isoformat()
# Capture consistent snapshot while holding lock
status_dict = self._status.to_dict()
def write(self, status: BuildStatus = None) -> None:
"""Write status to file."""
if status:
self._status = status
self._status.last_update = datetime.now().isoformat()
try:
with open(self.status_file, "w") as f:
json.dump(status_dict, f, indent=2)
if debug:
write_duration = (time.time() - write_start) * 1000
print(
f"[StatusManager] Batched write completed in {write_duration:.2f}ms"
)
json.dump(self._status.to_dict(), f, indent=2)
except OSError as e:
print(warning(f"Could not write status file: {e}"))
def _schedule_write(self) -> None:
"""Schedule a debounced write to batch multiple updates."""
import os
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
with self._write_lock:
if self._write_timer is not None:
self._write_timer.cancel()
if debug:
print(
"[StatusManager] Cancelled pending write, batching with new update"
)
self._write_pending = True
self._write_timer = threading.Timer(
self._WRITE_DEBOUNCE_MS / 1000.0, self._do_write
)
self._write_timer.start()
if debug:
print(
f"[StatusManager] Scheduled batched write in {self._WRITE_DEBOUNCE_MS}ms"
)
def write(self, status: BuildStatus | None = None, immediate: bool = False) -> None:
"""Write status to file.
Args:
status: Optional status to set before writing
immediate: If True, write immediately without debouncing
"""
# Protect status assignment with lock to prevent race conditions
with self._write_lock:
if status:
self._status = status
if immediate:
# Cancel any pending debounced write
with self._write_lock:
if self._write_timer is not None:
self._write_timer.cancel()
self._write_timer = None
self._do_write()
else:
self._schedule_write()
def flush(self) -> None:
"""Force any pending writes to complete immediately."""
with self._write_lock:
should_write = self._write_pending
if self._write_timer is not None:
self._write_timer.cancel()
self._write_timer = None
if should_write:
self._do_write()
def update(self, **kwargs) -> None:
"""Update specific status fields."""
with self._write_lock:
for key, value in kwargs.items():
if hasattr(self._status, key):
setattr(self._status, key, value)
for key, value in kwargs.items():
if hasattr(self._status, key):
setattr(self._status, key, value)
self.write()
def set_active(self, spec: str, state: BuildState) -> None:
"""Mark build as active. Writes immediately for visibility."""
with self._write_lock:
self._status.active = True
self._status.spec = spec
self._status.state = state
self._status.session_started = datetime.now().isoformat()
self.write(immediate=True)
"""Mark build as active."""
self._status.active = True
self._status.spec = spec
self._status.state = state
self._status.session_started = datetime.now().isoformat()
self.write()
def set_inactive(self) -> None:
"""Mark build as inactive. Writes immediately for visibility."""
with self._write_lock:
self._status.active = False
self._status.state = BuildState.IDLE
self.write(immediate=True)
"""Mark build as inactive."""
self._status.active = False
self._status.state = BuildState.IDLE
self.write()
def update_subtasks(
self,
@@ -246,48 +163,37 @@ class StatusManager:
failed: int = None,
) -> None:
"""Update subtask progress."""
with self._write_lock:
if completed is not None:
self._status.subtasks_completed = completed
if total is not None:
self._status.subtasks_total = total
if in_progress is not None:
self._status.subtasks_in_progress = in_progress
if failed is not None:
self._status.subtasks_failed = failed
if completed is not None:
self._status.subtasks_completed = completed
if total is not None:
self._status.subtasks_total = total
if in_progress is not None:
self._status.subtasks_in_progress = in_progress
if failed is not None:
self._status.subtasks_failed = failed
self.write()
def update_phase(self, current: str, phase_id: int = 0, total: int = 0) -> None:
"""Update current phase."""
with self._write_lock:
self._status.phase_current = current
self._status.phase_id = phase_id
self._status.phase_total = total
self._status.phase_current = current
self._status.phase_id = phase_id
self._status.phase_total = total
self.write()
def update_workers(self, active: int, max_workers: int = None) -> None:
"""Update worker count."""
with self._write_lock:
self._status.workers_active = active
if max_workers is not None:
self._status.workers_max = max_workers
self._status.workers_active = active
if max_workers is not None:
self._status.workers_max = max_workers
self.write()
def update_session(self, number: int) -> None:
"""Update session number."""
with self._write_lock:
self._status.session_number = number
self._status.session_number = number
self.write()
def clear(self) -> None:
"""Remove status file."""
# Cancel any pending writes
with self._write_lock:
if self._write_timer is not None:
self._write_timer.cancel()
self._write_timer = None
self._write_pending = False
if self.status_file.exists():
try:
self.status_file.unlink()
+844 -317
View File
File diff suppressed because it is too large Load Diff
+5 -8
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.2",
"version": "2.7.2-beta.10",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -48,7 +48,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -82,14 +81,13 @@
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"i18next": "^25.7.3",
"lucide-react": "^0.562.0",
"lucide-react": "^0.560.0",
"motion": "^12.23.26",
"proper-lockfile": "^4.1.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
@@ -104,7 +102,6 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.0.0",
"@types/react": "^19.2.7",
@@ -114,13 +111,13 @@
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.22",
"cross-env": "^10.1.0",
"electron": "39.2.7",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.0.0",
"globals": "^16.5.0",
"husky": "^9.1.7",
"jsdom": "^27.3.0",
"lint-staged": "^16.2.7",
+2 -37
View File
@@ -46,8 +46,6 @@ const STRIP_PATTERNS = {
'.pytest_cache',
'.mypy_cache',
'__pypackages__',
// Windows-specific bloat
'pythonwin', // PyWin32 IDE - not needed (9MB)
],
// File extensions to remove
extensions: [
@@ -70,7 +68,6 @@ const STRIP_PATTERNS = {
'.gitignore',
'.gitattributes',
'.editorconfig',
'.chm', // Windows help files - not needed
],
// Specific files to remove
files: [
@@ -100,12 +97,6 @@ const STRIP_PATTERNS = {
'conftest.py',
'pytest.ini',
],
// Specific paths within packages to remove (relative to package directory)
// Format: 'package_name/subpath' - removes the entire subpath
packagePaths: [
'googleapiclient/discovery_cache/documents', // Cached Google API discovery docs (92MB!)
'claude_agent_sdk/_bundled', // Bundled Claude CLI (224MB!) - users have it installed separately
],
// Packages that should NEVER be bundled (too large, specialized)
// If these appear in dependencies, warn and skip
blockedPackages: [
@@ -464,32 +455,6 @@ function stripSitePackages(sitePackagesDir) {
const sizeBefore = getDirectorySize(sitePackagesDir);
let removedCount = 0;
// First, remove specific package paths (e.g., googleapiclient/discovery_cache/documents)
// Use try/catch instead of existsSync to avoid TOCTOU race conditions
if (STRIP_PATTERNS.packagePaths) {
for (const pkgPath of STRIP_PATTERNS.packagePaths) {
const fullPath = path.join(sitePackagesDir, pkgPath);
try {
// Get size first (may throw ENOENT if path doesn't exist)
let pathSize = 0;
try {
pathSize = getDirectorySize(fullPath);
} catch {
// Path doesn't exist or can't get size - skip
continue;
}
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`[download-python] Removed ${pkgPath} (${formatBytes(pathSize)})`);
removedCount++;
} catch (err) {
// ENOENT means file was already gone - not an error
if (err.code !== 'ENOENT') {
console.warn(`[download-python] Failed to remove ${pkgPath}: ${err.message}`);
}
}
}
}
function shouldRemoveDir(name) {
return STRIP_PATTERNS.dirs.includes(name.toLowerCase());
}
@@ -609,12 +574,12 @@ function installPackages(pythonBin, requirementsPath, targetSitePackages) {
// Install packages directly to target directory
// --no-compile: Don't create .pyc files (saves space, Python will work without them)
// --no-cache-dir: Don't use pip cache
// --target: Install to specific directory
// Note: We intentionally DO use pip's cache to preserve built wheels for packages
// like real_ladybug that must be compiled from source on Intel Mac (no PyPI wheel)
const pipArgs = [
'-m', 'pip', 'install',
'--no-compile',
'--no-cache-dir',
'--target', targetSitePackages,
'-r', requirementsPath,
];
+1 -24
View File
@@ -42,36 +42,13 @@ To install:
================================================================================
`;
/**
* Get electron version from package.json
*/
function getElectronVersion() {
const pkgPath = path.join(__dirname, '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const electronVersion = pkg.devDependencies?.electron || pkg.dependencies?.electron;
if (!electronVersion) {
return null;
}
// Strip leading ^ or ~ from version
return electronVersion.replace(/^[\^~]/, '');
}
/**
* Run electron-rebuild
*/
function runElectronRebuild() {
return new Promise((resolve, reject) => {
const npx = isWindows ? 'npx.cmd' : 'npx';
const electronVersion = getElectronVersion();
const args = ['electron-rebuild'];
// Explicitly pass electron version if detected
if (electronVersion) {
args.push('-v', electronVersion);
console.log(`[postinstall] Using Electron version: ${electronVersion}`);
}
const child = spawn(npx, args, {
const child = spawn(npx, ['electron-rebuild'], {
stdio: 'inherit',
shell: isWindows,
cwd: path.join(__dirname, '..'),
@@ -30,13 +30,9 @@ const mockProcess = Object.assign(new EventEmitter(), {
})
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(() => mockProcess)
};
});
vi.mock('child_process', () => ({
spawn: vi.fn(() => mockProcess)
}));
// Mock claude-profile-manager to bypass auth checks in tests
vi.mock('../../main/claude-profile-manager', () => ({
@@ -111,7 +107,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
@@ -136,7 +132,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
@@ -158,7 +154,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
@@ -182,7 +178,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
parallel: true,
workers: 4
});
@@ -208,7 +204,7 @@ describe('Subprocess Spawn Integration', () => {
const logHandler = vi.fn();
manager.on('log', logHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate stdout data (must include newline for buffered output processing)
mockStdout.emit('data', Buffer.from('Test log output\n'));
@@ -224,7 +220,7 @@ describe('Subprocess Spawn Integration', () => {
const logHandler = vi.fn();
manager.on('log', logHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate stderr data (must include newline for buffered output processing)
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
@@ -240,7 +236,7 @@ describe('Subprocess Spawn Integration', () => {
const exitHandler = vi.fn();
manager.on('exit', exitHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate process exit
mockProcess.emit('exit', 0);
@@ -257,7 +253,7 @@ describe('Subprocess Spawn Integration', () => {
const errorHandler = vi.fn();
manager.on('error', errorHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate process error
mockProcess.emit('error', new Error('Spawn failed'));
@@ -270,7 +266,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(manager.isRunning('task-1')).toBe(true);
@@ -297,10 +293,10 @@ describe('Subprocess Spawn Integration', () => {
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
expect(manager.getRunningTasks()).toHaveLength(0);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
expect(manager.getRunningTasks()).toContain('task-1');
await manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
expect(manager.getRunningTasks()).toHaveLength(2);
});
@@ -311,7 +307,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure('/custom/python3', AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(spawn).toHaveBeenCalledWith(
'/custom/python3',
@@ -325,8 +321,8 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
await manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
await manager.killAll();
@@ -338,10 +334,10 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
// Start another process for same task
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 2');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 2');
// Should have killed the first one
expect(mockProcess.kill).toHaveBeenCalled();
+1 -8
View File
@@ -88,14 +88,7 @@ if (typeof window !== 'undefined') {
success: true,
data: { openProjectIds: [], activeProjectId: null, tabOrder: [] }
}),
saveTabState: vi.fn().mockResolvedValue({ success: true }),
// Profile-related API methods (API Profile feature)
getAPIProfiles: vi.fn(),
saveAPIProfile: vi.fn(),
updateAPIProfile: vi.fn(),
deleteAPIProfile: vi.fn(),
setActiveAPIProfile: vi.fn(),
testConnection: vi.fn()
saveTabState: vi.fn().mockResolvedValue({ success: true })
};
}
@@ -139,8 +139,7 @@ function cleanupTestDirs(): void {
}
}
// Increase timeout for all tests in this file due to dynamic imports and setup overhead
describe('IPC Handlers', { timeout: 15000 }, () => {
describe('IPC Handlers', () => {
let ipcMain: EventEmitter & {
handlers: Map<string, Function>;
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
@@ -87,14 +87,14 @@ export class AgentManager extends EventEmitter {
/**
* Start spec creation process
*/
async startSpecCreation(
startSpecCreation(
taskId: string,
projectPath: string,
taskDescription: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string
): Promise<void> {
): void {
// Pre-flight auth check: Verify active profile has valid authentication
const profileManager = getClaudeProfileManager();
if (!profileManager.hasValidAuth()) {
@@ -156,18 +156,18 @@ export class AgentManager extends EventEmitter {
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
// Note: This is spec-creation but it chains to task-execution via run.py
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
}
/**
* Start task execution (run.py)
*/
async startTaskExecution(
startTaskExecution(
taskId: string,
projectPath: string,
specId: string,
options: TaskExecutionOptions = {}
): Promise<void> {
): void {
// Pre-flight auth check: Verify active profile has valid authentication
const profileManager = getClaudeProfileManager();
if (!profileManager.hasValidAuth()) {
@@ -213,17 +213,17 @@ export class AgentManager extends EventEmitter {
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, specId, options, false);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
}
/**
* Start QA process
*/
async startQAProcess(
startQAProcess(
taskId: string,
projectPath: string,
specId: string
): Promise<void> {
): void {
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
if (!autoBuildSource) {
@@ -243,7 +243,7 @@ export class AgentManager extends EventEmitter {
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
}
/**

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