Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a490c5af |
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: AndyMik90
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💡 Feature Request
|
||||
url: https://github.com/AndyMik90/Auto-Claude/discussions
|
||||
url: https://github.com/AndyMik90/Auto-Claude/discussions/new?category=ideas
|
||||
about: Suggest new features in GitHub Discussions
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.gg/QhRnz9m5HE
|
||||
url: https://discord.gg/KCXaPBr4Dj
|
||||
about: Questions and discussions - join our Discord!
|
||||
|
||||
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before asking:** Check [Discord](https://discord.gg/QhRnz9m5HE) - your question may already be answered there!
|
||||
**Before asking:** Check [Discord](https://discord.gg/KCXaPBr4Dj) - your question may already be answered there!
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
- package-ecosystem: pip
|
||||
directory: /apps/backend
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- javascript
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Issue Auto Label
|
||||
name: Auto Label
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -32,49 +32,62 @@ jobs:
|
||||
|
||||
echo "Valid beta version: $VERSION"
|
||||
|
||||
create-tag:
|
||||
name: Create beta tag
|
||||
update-version:
|
||||
name: Update package.json version
|
||||
needs: validate-version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ github.event.inputs.version }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push tag
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Update package.json version
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
|
||||
# Update frontend package.json
|
||||
cd apps/frontend
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Updated package.json to version $VERSION"
|
||||
|
||||
- name: Commit version bump
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
# Stage all changed files in frontend directory (handles package-lock.json if it exists)
|
||||
git add -A apps/frontend/
|
||||
git commit -m "chore: bump version to $VERSION for beta release"
|
||||
git push origin develop
|
||||
|
||||
- name: Create and push tag
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
git tag -a "v$VERSION" -m "Beta release v$VERSION"
|
||||
git push origin "v$VERSION"
|
||||
echo "Created tag v$VERSION"
|
||||
|
||||
- name: Create tag only (dry run)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
echo "DRY RUN: Would create tag v$VERSION"
|
||||
|
||||
# Intel build on Intel runner for native compilation
|
||||
build-macos-intel:
|
||||
needs: create-tag
|
||||
needs: update-version
|
||||
runs-on: macos-15-intel
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 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'
|
||||
ref: v${{ needs.update-version.outputs.version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -94,43 +107,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
|
||||
run: cd apps/frontend && npm run package:mac -- --arch=x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
@@ -161,22 +146,15 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
needs: create-tag
|
||||
needs: update-version
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 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'
|
||||
ref: v${{ needs.update-version.outputs.version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -196,40 +174,15 @@ 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
|
||||
run: cd apps/frontend && npm run package:mac -- --arch=arm64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
@@ -260,27 +213,14 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
needs: update-version
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 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'
|
||||
ref: v${{ needs.update-version.outputs.version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -301,152 +241,15 @@ 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
run: cd apps/frontend && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -454,21 +257,14 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-linux:
|
||||
needs: create-tag
|
||||
needs: update-version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 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'
|
||||
ref: v${{ needs.update-version.outputs.version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -488,47 +284,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
run: cd apps/frontend && npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -537,11 +299,9 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
create-release:
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
needs: [update-version, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
permissions:
|
||||
@@ -549,7 +309,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: v${{ needs.create-tag.outputs.version }}
|
||||
ref: v${{ needs.update-version.outputs.version }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
@@ -560,12 +320,12 @@ 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" \) -exec cp {} release-assets/ \;
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# 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)
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -581,10 +341,10 @@ jobs:
|
||||
- name: Create Beta Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.create-tag.outputs.version }}
|
||||
name: v${{ needs.create-tag.outputs.version }} (Beta)
|
||||
tag_name: v${{ needs.update-version.outputs.version }}
|
||||
name: v${{ needs.update-version.outputs.version }} (Beta)
|
||||
body: |
|
||||
## Beta Release v${{ needs.create-tag.outputs.version }}
|
||||
## Beta Release v${{ needs.update-version.outputs.version }}
|
||||
|
||||
This is a **beta release** for testing new features. It may contain bugs or incomplete functionality.
|
||||
|
||||
@@ -598,7 +358,7 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/main...v${{ needs.create-tag.outputs.version }}
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/main...v${{ needs.update-version.outputs.version }}
|
||||
files: release-assets/*
|
||||
draft: false
|
||||
prerelease: true
|
||||
@@ -606,7 +366,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
dry-run-summary:
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
needs: [update-version, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
steps:
|
||||
@@ -619,11 +379,11 @@ jobs:
|
||||
run: |
|
||||
echo "## Beta Release Dry Run Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ needs.create-tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ needs.update-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -6,14 +6,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Python tests
|
||||
test-python:
|
||||
@@ -58,7 +50,7 @@ jobs:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: matrix.python-version == '3.12'
|
||||
|
||||
@@ -6,10 +6,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Python linting
|
||||
python:
|
||||
@@ -23,12 +19,35 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
|
||||
- name: Install ruff
|
||||
run: pip install ruff==0.14.10
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff check
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
# TypeScript/React linting
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/frontend
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run ESLint
|
||||
working-directory: apps/frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run TypeScript check
|
||||
working-directory: apps/frontend
|
||||
run: npm run typecheck
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pr-labeler-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Security: Prevent fork PRs from modifying labels (they don't have write access)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CONFIGURATION - Single source of truth for all settings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const CONFIG = {
|
||||
// Size thresholds (lines changed)
|
||||
SIZE_THRESHOLDS: {
|
||||
XS: 10,
|
||||
S: 100,
|
||||
M: 500,
|
||||
L: 1000
|
||||
},
|
||||
|
||||
// Conventional commit type mappings
|
||||
TYPE_MAP: Object.freeze({
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
}),
|
||||
|
||||
// Area detection paths
|
||||
AREA_PATHS: Object.freeze({
|
||||
frontend: 'apps/frontend/',
|
||||
backend: 'apps/backend/',
|
||||
ci: '.github/'
|
||||
}),
|
||||
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'],
|
||||
STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'],
|
||||
REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
MAX_FILES_PER_PAGE: 100
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HELPER FUNCTIONS - Small, focused, single responsibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Safely parse conventional commit type from PR title
|
||||
* @param {string} title - PR title
|
||||
* @returns {{type: string|null, isBreaking: boolean}}
|
||||
*/
|
||||
function parseConventionalCommit(title) {
|
||||
if (!title || typeof title !== 'string') {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
// Limit input length to prevent ReDoS attacks
|
||||
const safeTitle = title.slice(0, 200);
|
||||
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
|
||||
|
||||
if (!match) {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
isBreaking: match[3] === '!'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine size label based on lines changed
|
||||
* @param {number} totalLines - Total lines changed
|
||||
* @returns {string} Size label
|
||||
*/
|
||||
function determineSizeLabel(totalLines) {
|
||||
const { SIZE_THRESHOLDS } = CONFIG;
|
||||
|
||||
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
|
||||
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
|
||||
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
|
||||
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
|
||||
return 'size/XL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect areas affected by file changes
|
||||
* @param {Array} files - List of changed files
|
||||
* @returns {{frontend: boolean, backend: boolean, ci: boolean}}
|
||||
*/
|
||||
function detectAreas(files) {
|
||||
const areas = { frontend: false, backend: false, ci: false };
|
||||
const { AREA_PATHS } = CONFIG;
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename || '';
|
||||
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
|
||||
if (path.startsWith(AREA_PATHS.backend)) areas.backend = true;
|
||||
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
|
||||
}
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine area label based on detected areas
|
||||
* @param {{frontend: boolean, backend: boolean, ci: boolean}} areas
|
||||
* @returns {string|null} Area label or null
|
||||
*/
|
||||
function determineAreaLabel(areas) {
|
||||
if (areas.frontend && areas.backend) return 'area/fullstack';
|
||||
if (areas.frontend) return 'area/frontend';
|
||||
if (areas.backend) return 'area/backend';
|
||||
if (areas.ci) return 'area/ci';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove labels from PR (with error handling)
|
||||
* @param {Array} labels - Labels to remove
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function removeLabels(labels, prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
await Promise.allSettled(labels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
// 404 means label wasn't present - that's fine
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to PR (with error handling)
|
||||
* @param {Array} labels - Labels to add
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function addLabels(labels, prNumber) {
|
||||
if (labels.length === 0) return;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels
|
||||
});
|
||||
console.log(` ✓ Added: ${labels.join(', ')}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`One or more labels do not exist. Create them in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PR files with full pagination support
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Array} List of all files (paginated)
|
||||
*/
|
||||
async function fetchPRFiles(prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Use paginate to fetch ALL files, not just first 100
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
|
||||
);
|
||||
return files;
|
||||
} catch (e) {
|
||||
console.log(` ⚠ Could not fetch files: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN LOGIC - Orchestrates the labeling process
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title || '';
|
||||
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
|
||||
console.log(`Action: ${context.payload.action}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// 1. Parse conventional commit type
|
||||
const { type, isBreaking } = parseConventionalCommit(title);
|
||||
if (type && CONFIG.TYPE_MAP[type]) {
|
||||
labelsToAdd.add(CONFIG.TYPE_MAP[type]);
|
||||
console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`);
|
||||
} else {
|
||||
console.log(` ℹ️ No conventional commit prefix detected`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
|
||||
// 2. Detect areas from changed files
|
||||
const files = await fetchPRFiles(prNumber);
|
||||
const areas = detectAreas(files);
|
||||
const areaLabel = determineAreaLabel(areas);
|
||||
|
||||
if (areaLabel) {
|
||||
labelsToAdd.add(areaLabel);
|
||||
CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`);
|
||||
}
|
||||
|
||||
// 3. Calculate size label
|
||||
const totalLines = (pr.additions || 0) + (pr.deletions || 0);
|
||||
const sizeLabel = determineSizeLabel(totalLines);
|
||||
labelsToAdd.add(sizeLabel);
|
||||
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
|
||||
|
||||
// 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes)
|
||||
// Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('🔄 Checking');
|
||||
CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 🔄 Status: Checking`);
|
||||
} else {
|
||||
console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`);
|
||||
}
|
||||
|
||||
// 5. Add review label for new PRs only
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('Missing AC Approval');
|
||||
console.log(` ⏳ Review: Missing AC Approval`);
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// 6. Apply label changes
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove labels that should be replaced (exclude ones we're adding)
|
||||
const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
await removeLabels(removeList, prNumber);
|
||||
|
||||
// Add new labels
|
||||
await addLabels([...labelsToAdd], prNumber);
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} labeled successfully`);
|
||||
|
||||
// 7. Write job summary
|
||||
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
|
||||
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
|
||||
|
||||
await core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
|
||||
['Type', summaryType],
|
||||
['Area', summaryArea],
|
||||
['Size', sizeLabel],
|
||||
['Status', isNewPR ? '🔄 Checking' : '(unchanged)'],
|
||||
['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)']
|
||||
])
|
||||
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
|
||||
.write();
|
||||
@@ -1,585 +0,0 @@
|
||||
name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
types: [completed]
|
||||
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
concurrency:
|
||||
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
env:
|
||||
# Shared configuration - single source of truth
|
||||
REQUIRED_CHECKS: |
|
||||
CI / test-frontend
|
||||
CI / test-python (3.12)
|
||||
CI / test-python (3.13)
|
||||
Lint / python
|
||||
Quality Security / CodeQL (javascript-typescript)
|
||||
Quality Security / CodeQL (python)
|
||||
Quality Security / Python Security (Bandit)
|
||||
Quality Security / Security Summary
|
||||
|
||||
jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 1: CI STATUS (triggered by workflow_run)
|
||||
# Updates CI status labels when monitored workflows complete
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-ci-status:
|
||||
name: Update CI Status
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function fetchCheckRuns(sha) {
|
||||
const { owner, repo } = context.repo;
|
||||
// Let the configured retries (retries: 3) handle transient failures
|
||||
// Don't catch errors - allow them to propagate for retry logic
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: sha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
return checkRuns;
|
||||
}
|
||||
|
||||
function analyzeChecks(checkRuns) {
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
return { allComplete, anyFailed, results };
|
||||
}
|
||||
|
||||
async function updateStatusLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
const allLabels = Object.values(STATUS_LABELS);
|
||||
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of allLabels) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
const checkRuns = await fetchCheckRuns(headSha);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
|
||||
|
||||
for (const r of results) {
|
||||
console.log(` ${r.status} ${r.name}`);
|
||||
}
|
||||
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`);
|
||||
// Update to CHECKING status if checks are still running (prevents stale Ready/Failed status)
|
||||
await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
await updateStatusLabels(prNumber, newLabel);
|
||||
|
||||
const passedCount = results.filter(r => r.status === '✅ Passed').length;
|
||||
const failedCount = results.filter(r => r.failed).length;
|
||||
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`);
|
||||
} else {
|
||||
console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 2: /check-status COMMAND
|
||||
# Manual status check - anyone can trigger by commenting /check-status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
check-status-command:
|
||||
name: Check Status Command
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Run status check and post report
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.issue.number;
|
||||
const requestedBy = context.payload.comment.user.login;
|
||||
|
||||
// Get PR details
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber
|
||||
});
|
||||
const headSha = pr.head.sha;
|
||||
|
||||
console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
// Fetch check runs with pagination to handle >100 checks
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: headSha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
|
||||
// Analyze results
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, emoji: '⏳', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, emoji: '🔄', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, emoji: '✅', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = currentLabels.map(l => l.name);
|
||||
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
|
||||
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
|
||||
|
||||
// Update label if all checks complete
|
||||
let newStatusLabel = STATUS_LABELS.CHECKING;
|
||||
let statusChanged = false;
|
||||
|
||||
if (allComplete) {
|
||||
newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
|
||||
if (newStatusLabel !== currentStatusLabel) {
|
||||
statusChanged = true;
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of Object.values(STATUS_LABELS)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] });
|
||||
}
|
||||
}
|
||||
|
||||
// Build status report
|
||||
const passedCount = results.filter(r => r.emoji === '✅').length;
|
||||
let statusEmoji = '🔄';
|
||||
if (allComplete && !anyFailed) statusEmoji = '✅';
|
||||
else if (allComplete && anyFailed) statusEmoji = '❌';
|
||||
|
||||
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
|
||||
|
||||
const lines = [
|
||||
`## ${statusEmoji} PR Status Report`,
|
||||
'',
|
||||
`| Label | Value |`,
|
||||
`|-------|-------|`,
|
||||
`| CI Status | ${newStatusLabel} |`,
|
||||
`| AC Review | ${currentReviewLabel} |`,
|
||||
''
|
||||
];
|
||||
|
||||
if (statusChanged) {
|
||||
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
lines.push('');
|
||||
lines.push('| Status | Check |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(checksTable);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: lines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted status report to PR #${prNumber}`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 3: AUTO-CLAUDE REVIEW
|
||||
# Processes Auto-Claude review comments from trusted sources
|
||||
# Security: Only bots and collaborators can update labels
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-review-status:
|
||||
name: Update Review Status
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
!contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check for Auto-Claude review
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// Security configuration
|
||||
// SECURITY: Only [bot] suffixed accounts are protected by GitHub.
|
||||
// Regular usernames can be registered by anyone and are NOT trusted.
|
||||
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
|
||||
'github-actions[bot]',
|
||||
'auto-claude[bot]'
|
||||
]);
|
||||
|
||||
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
|
||||
'COLLABORATOR',
|
||||
'MEMBER',
|
||||
'OWNER'
|
||||
]);
|
||||
|
||||
const IDENTIFIER_PATTERNS = Object.freeze([
|
||||
'🤖 Auto Claude PR Review',
|
||||
'Auto Claude Review',
|
||||
'Auto-Claude Review'
|
||||
]);
|
||||
|
||||
// SECURITY: Regex patterns are tightened to prevent false matches
|
||||
// Using \s* instead of .* and requiring specific emoji + verdict format
|
||||
const VERDICTS = Object.freeze({
|
||||
APPROVED: {
|
||||
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE
|
||||
regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i,
|
||||
label: 'AC: Approved'
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
patterns: ['NEEDS REVISION', 'Needs Revision'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠
|
||||
regex: /Merge Verdict:\s*🟠/,
|
||||
label: 'AC: Changes Requested'
|
||||
},
|
||||
BLOCKED: {
|
||||
patterns: ['BLOCKED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴
|
||||
regex: /Merge Verdict:\s*🔴/,
|
||||
label: 'AC: Blocked'
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
// SECURITY: Verify both username AND account type to prevent spoofing
|
||||
function isTrustedBot(username, userType) {
|
||||
const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
|
||||
// Only trust if it's a known bot account AND GitHub confirms it's a Bot type
|
||||
return isKnownBot && userType === 'Bot';
|
||||
}
|
||||
|
||||
function isTrustedAssociation(assoc) {
|
||||
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
|
||||
}
|
||||
|
||||
function isAutoClaudeComment(body) {
|
||||
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
|
||||
}
|
||||
|
||||
function parseVerdict(body) {
|
||||
const safeBody = body.slice(0, 5000);
|
||||
for (const [key, config] of Object.entries(VERDICTS)) {
|
||||
const patternMatch = config.patterns.some(p => safeBody.includes(p));
|
||||
const regexMatch = config.regex && config.regex.test(safeBody);
|
||||
if (patternMatch || regexMatch) {
|
||||
return { verdict: key, label: config.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateReviewLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Remove all review labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of REVIEW_LABELS) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
console.log(` Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
console.log(` Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.issue.number;
|
||||
const comment = context.payload.comment;
|
||||
const commenter = comment.user.login;
|
||||
const commenterType = comment.user.type;
|
||||
const authorAssociation = comment.author_association;
|
||||
const body = comment.body || '';
|
||||
|
||||
console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`);
|
||||
|
||||
// Security checks
|
||||
// SECURITY: Bot status requires BOTH username match AND verified Bot type
|
||||
const isBot = isTrustedBot(commenter, commenterType);
|
||||
const isCollaborator = isTrustedAssociation(authorAssociation);
|
||||
const isACComment = isAutoClaudeComment(body);
|
||||
|
||||
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
|
||||
|
||||
if (!isBot && !isCollaborator) {
|
||||
console.log('Skipping: Not a trusted bot or collaborator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isACComment) {
|
||||
console.log('Skipping: Not an Auto-Claude comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdictResult = parseVerdict(body);
|
||||
if (!verdictResult) {
|
||||
console.log('Skipping: Could not parse verdict');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
|
||||
await updateReviewLabels(prNumber, verdictResult.label);
|
||||
console.log(`✅ PR #${prNumber} review status updated`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 4: RE-REVIEW ON PUSH
|
||||
# When new commits pushed after AC approval, require re-review
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
require-re-review:
|
||||
name: Require Re-review on Push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check and reset AC approval if needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pusher = context.payload.sender.login;
|
||||
|
||||
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
|
||||
|
||||
// Get current labels
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = labels.map(l => l.name);
|
||||
|
||||
// Check if PR was approved
|
||||
const wasApproved = labelNames.includes('AC: Approved');
|
||||
|
||||
if (!wasApproved) {
|
||||
console.log('PR was not AC-approved, no action needed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PR was AC-approved, resetting to require re-review');
|
||||
|
||||
// Remove AC: Approved - throw on non-404 errors to prevent conflicting labels
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'AC: Approved'
|
||||
});
|
||||
console.log(' Removed: AC: Approved');
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels)
|
||||
core.error(`Failed to remove 'AC: Approved' label: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Add AC: Needs Re-review
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
|
||||
});
|
||||
console.log(' Added: AC: Needs Re-review');
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning("Label 'AC: Needs Re-review' does not exist");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Post notification comment
|
||||
const commentLines = [
|
||||
'## 🔄 Re-review Required',
|
||||
'',
|
||||
'New commits were pushed after Auto-Claude approval.',
|
||||
'',
|
||||
'| Previous | Current |',
|
||||
'|----------|---------|',
|
||||
'| `AC: Approved` | `AC: Needs Re-review` |',
|
||||
'',
|
||||
'Please run Auto-Claude review again or request a manual review.',
|
||||
'',
|
||||
'---',
|
||||
`<sub>Triggered by push from @${pusher}</sub>`
|
||||
];
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
|
||||
@@ -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
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
name: Quality Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
python-security:
|
||||
name: Python Security (Bandit)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Bandit
|
||||
run: pip install bandit
|
||||
|
||||
- name: Run Bandit security scan
|
||||
id: bandit
|
||||
run: |
|
||||
echo "::group::Running Bandit security scan"
|
||||
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
|
||||
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
|
||||
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
|
||||
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
|
||||
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Analyze Bandit results
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// Check if report exists
|
||||
if (!fs.existsSync('bandit-report.json')) {
|
||||
core.setFailed('Bandit report not found - scan may have failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
|
||||
const results = report.results || [];
|
||||
|
||||
// Categorize by severity
|
||||
const high = results.filter(r => r.issue_severity === 'HIGH');
|
||||
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
|
||||
const low = results.filter(r => r.issue_severity === 'LOW');
|
||||
|
||||
console.log(`::group::Bandit Security Scan Results`);
|
||||
console.log(`Found ${results.length} issues:`);
|
||||
console.log(` 🔴 HIGH: ${high.length}`);
|
||||
console.log(` 🟡 MEDIUM: ${medium.length}`);
|
||||
console.log(` 🟢 LOW: ${low.length}`);
|
||||
console.log('');
|
||||
|
||||
// Print high severity issues
|
||||
if (high.length > 0) {
|
||||
console.log('High Severity Issues:');
|
||||
console.log('─'.repeat(60));
|
||||
for (const issue of high) {
|
||||
console.log(` ${issue.filename}:${issue.line_number}`);
|
||||
console.log(` ${issue.issue_text}`);
|
||||
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Build summary
|
||||
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
|
||||
summary += `| Severity | Count |\n`;
|
||||
summary += `|----------|-------|\n`;
|
||||
summary += `| 🔴 High | ${high.length} |\n`;
|
||||
summary += `| 🟡 Medium | ${medium.length} |\n`;
|
||||
summary += `| 🟢 Low | ${low.length} |\n\n`;
|
||||
|
||||
if (high.length > 0) {
|
||||
summary += `### High Severity Issues\n\n`;
|
||||
for (const issue of high) {
|
||||
summary += `- **${issue.filename}:${issue.line_number}**\n`;
|
||||
summary += ` - ${issue.issue_text}\n`;
|
||||
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw(summary);
|
||||
await core.summary.write();
|
||||
|
||||
// Fail if high severity issues found
|
||||
if (high.length > 0) {
|
||||
core.setFailed(`Found ${high.length} high severity security issue(s)`);
|
||||
} else {
|
||||
console.log('✅ No high severity security issues found');
|
||||
}
|
||||
|
||||
# Summary job that waits for all security checks
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [codeql, python-security]
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security results
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const codeql = '${{ needs.codeql.result }}';
|
||||
const bandit = '${{ needs.python-security.result }}';
|
||||
|
||||
console.log('Security Check Results:');
|
||||
console.log(` CodeQL: ${codeql}`);
|
||||
console.log(` Bandit: ${bandit}`);
|
||||
|
||||
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
|
||||
const acceptable = ['success', 'skipped'];
|
||||
const codeqlOk = acceptable.includes(codeql);
|
||||
const banditOk = acceptable.includes(bandit);
|
||||
const allPassed = codeqlOk && banditOk;
|
||||
|
||||
if (allPassed) {
|
||||
console.log('\n✅ All security checks passed');
|
||||
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
|
||||
} else {
|
||||
console.log('\n❌ Some security checks failed');
|
||||
core.summary.addRaw('## ❌ Security Checks Failed\n\nOne or more security scans found issues.');
|
||||
core.setFailed('Security checks failed');
|
||||
}
|
||||
|
||||
await core.summary.write();
|
||||
+29
-438
@@ -20,11 +20,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -43,41 +38,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: cd apps/frontend && npm run package:mac -- --x64
|
||||
run: cd apps/frontend && npm run package:mac -- --arch=x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
@@ -108,8 +77,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:
|
||||
@@ -117,11 +84,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -140,38 +102,15 @@ 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: cd apps/frontend && npm run package:mac -- --arm64
|
||||
run: cd apps/frontend && npm run package:mac -- --arch=arm64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
@@ -202,25 +141,12 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -240,149 +166,15 @@ 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
run: cd apps/frontend && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -390,19 +182,12 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -421,44 +206,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: cd apps/frontend && npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -467,9 +221,6 @@ jobs:
|
||||
path: |
|
||||
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]
|
||||
@@ -489,30 +240,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" \) -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" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb 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
|
||||
@@ -537,7 +274,7 @@ jobs:
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb}; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
@@ -672,78 +409,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') }}
|
||||
@@ -754,8 +436,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:
|
||||
@@ -764,116 +445,26 @@ See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGEL
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version and detect release type
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Detect if this is a prerelease (contains - after version, e.g., 2.7.2-beta.10)
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected PRERELEASE: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected STABLE release: $VERSION"
|
||||
fi
|
||||
echo "Updating README to version: $VERSION"
|
||||
|
||||
- name: Update README.md
|
||||
run: |
|
||||
python3 << 'EOF'
|
||||
import re
|
||||
import sys
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
version = "${{ steps.version.outputs.version }}"
|
||||
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
|
||||
# Update version badge: version-X.Y.Z-blue
|
||||
sed -i "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-${VERSION}-blue/g" README.md
|
||||
|
||||
# Shields.io escapes hyphens as --
|
||||
version_badge = version.replace("-", "--")
|
||||
# Update download links: Auto-Claude-X.Y.Z
|
||||
sed -i "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-${VERSION}/g" README.md
|
||||
|
||||
# Read README
|
||||
with open("README.md", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
|
||||
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
|
||||
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
# Shields.io escaped pattern (hyphens as --)
|
||||
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
|
||||
def update_section(text, start_marker, end_marker, replacements):
|
||||
"""Update content between markers with given replacements."""
|
||||
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
|
||||
def replace_section(match):
|
||||
section = match.group(2)
|
||||
for old_pattern, new_value in replacements:
|
||||
section = re.sub(old_pattern, new_value, section)
|
||||
return match.group(1) + section + match.group(3)
|
||||
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
|
||||
|
||||
if is_prerelease:
|
||||
print(f"Updating BETA section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update beta badge
|
||||
content = re.sub(
|
||||
rf'beta-{semver_badge}-orange',
|
||||
f'beta-{version_badge}-orange',
|
||||
content
|
||||
)
|
||||
|
||||
# Update beta version badge link
|
||||
content = update_section(content,
|
||||
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update beta downloads
|
||||
content = update_section(content,
|
||||
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
else:
|
||||
print(f"Updating STABLE section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update top version badge
|
||||
content = update_section(content,
|
||||
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
|
||||
[
|
||||
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
|
||||
(rf'tag/v{semver}\)', f'tag/v{version})'),
|
||||
])
|
||||
|
||||
# Update stable badge
|
||||
content = re.sub(
|
||||
rf'stable-{semver_badge}-blue',
|
||||
f'stable-{version_badge}-blue',
|
||||
content
|
||||
)
|
||||
|
||||
# Update stable version badge link
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update stable downloads
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
|
||||
# Write updated README
|
||||
with open("README.md", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"README.md updated for {version} (prerelease={is_prerelease})")
|
||||
EOF
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
|
||||
echo "README.md updated to version $VERSION"
|
||||
grep -E "(version-|Auto-Claude-)" README.md | head -10
|
||||
|
||||
- name: Commit and push README update
|
||||
run: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
with:
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
|
||||
|
||||
|
||||
- If this is still relevant, please comment or update the issue
|
||||
- If you're working on this, add the `in-progress` label
|
||||
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
|
||||
|
||||
@@ -18,16 +18,16 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: |
|
||||
👋 Thanks for opening your first issue!
|
||||
|
||||
|
||||
A maintainer will triage this soon. In the meantime:
|
||||
- Make sure you've provided all the requested info
|
||||
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
|
||||
- Join our [Discord](https://discord.gg/KCXaPBr4Dj) for faster help
|
||||
pr-message: |
|
||||
🎉 Thanks for your first PR!
|
||||
|
||||
|
||||
A maintainer will review it soon. Please make sure:
|
||||
- Your branch is synced with `develop`
|
||||
- Your branch is synced with `main`
|
||||
- CI checks pass
|
||||
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
|
||||
|
||||
Welcome to the Auto Claude community!
|
||||
- You've followed our [contribution guide](CONTRIBUTING.md)
|
||||
|
||||
Welcome to the Auto-Claude community!
|
||||
|
||||
@@ -14,7 +14,6 @@ Desktop.ini
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/config.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -116,7 +115,6 @@ node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
apps/frontend/python-runtime/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
@@ -164,7 +162,3 @@ _bmad-output/
|
||||
.claude/
|
||||
/docs
|
||||
OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/.github/agents
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
|
||||
+4
-12
@@ -1,16 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Commit message validation
|
||||
# Enforces conventional commit format: type(scope)!?: description
|
||||
# Enforces conventional commit format: type(scope): description
|
||||
#
|
||||
# Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots
|
||||
# Optional ! for breaking changes
|
||||
# Examples:
|
||||
# feat(tasks): add drag and drop support
|
||||
# fix(terminal): resolve scroll position issue
|
||||
# feat!: breaking change without scope
|
||||
# feat(api)!: breaking change with scope
|
||||
# docs: update README with setup instructions
|
||||
# chore: update dependencies
|
||||
|
||||
@@ -18,10 +14,8 @@ commit_msg_file=$1
|
||||
commit_msg=$(cat "$commit_msg_file")
|
||||
|
||||
# Regex for conventional commits
|
||||
# Format: type(optional-scope)!?: description
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots (consistent with GitHub workflow)
|
||||
# Optional ! for breaking changes: feat!: or feat(scope)!:
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_/.-]+\))?!?: .{1,100}$"
|
||||
# Format: type(optional-scope): description
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .{1,100}$"
|
||||
|
||||
# Allow merge commits
|
||||
if echo "$commit_msg" | grep -qE "^Merge "; then
|
||||
@@ -42,7 +36,7 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo ""
|
||||
echo "Your message: $first_line"
|
||||
echo ""
|
||||
echo "Expected format: type(scope)!?: description"
|
||||
echo "Expected format: type(scope): description"
|
||||
echo ""
|
||||
echo "Valid types:"
|
||||
echo " feat - A new feature"
|
||||
@@ -60,8 +54,6 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo "Examples:"
|
||||
echo " feat(tasks): add drag and drop support"
|
||||
echo " fix(terminal): resolve scroll position issue"
|
||||
echo " feat!: breaking change without scope"
|
||||
echo " feat(api)!: breaking change with scope"
|
||||
echo " docs: update README"
|
||||
echo " chore: update dependencies"
|
||||
echo ""
|
||||
|
||||
+24
-65
@@ -36,44 +36,12 @@ 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
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md
|
||||
# Update download links
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[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 +70,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
|
||||
@@ -148,7 +111,7 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
exit 1
|
||||
fi
|
||||
cd ../..
|
||||
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
@@ -160,14 +123,10 @@ fi
|
||||
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Frontend changes detected, running frontend checks..."
|
||||
cd apps/frontend
|
||||
|
||||
|
||||
# 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..."
|
||||
npm run typecheck
|
||||
@@ -175,7 +134,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Run linting
|
||||
echo "Running lint..."
|
||||
npm run lint
|
||||
@@ -183,7 +142,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Check for vulnerabilities (only high severity)
|
||||
echo "Checking for vulnerabilities..."
|
||||
npm audit --audit-level=high
|
||||
@@ -191,7 +150,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
cd ../..
|
||||
echo "Frontend checks passed!"
|
||||
fi
|
||||
|
||||
+18
-121
@@ -1,84 +1,29 @@
|
||||
repos:
|
||||
# Version sync - propagate root package.json version to all files
|
||||
# NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: version-sync
|
||||
name: Version Sync
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# Version sync modifies root-level files that may not exist in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping version-sync in worktree (root files not accessible)"
|
||||
exit 0
|
||||
fi
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = require('./apps/frontend/package.json');
|
||||
const v = process.argv[1];
|
||||
if (p.version !== v) {
|
||||
p.version = v;
|
||||
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(p, null, 2) + '\n');
|
||||
}
|
||||
" "$VERSION"
|
||||
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# 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
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true
|
||||
fi
|
||||
entry: bash -c '
|
||||
VERSION=$(node -p "require(\"./package.json\").version");
|
||||
if [ -n "$VERSION" ]; then
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "const fs=require(\"fs\");const p=require(\"./apps/frontend/package.json\");if(p.version!==\"$VERSION\"){p.version=\"$VERSION\";fs.writeFileSync(\"./apps/frontend/package.json\",JSON.stringify(p,null,2)+\"\n\");}";
|
||||
# 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
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md;
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md && rm -f README.md.bak;
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true;
|
||||
fi
|
||||
'
|
||||
language: system
|
||||
files: ^package\.json$
|
||||
pass_filenames: false
|
||||
|
||||
# Python linting (apps/backend/)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
rev: v0.8.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
@@ -88,83 +33,35 @@ repos:
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: Python Tests
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=../../tests/test_graphiti.py \
|
||||
--ignore=../../tests/test_merge_file_tracker.py \
|
||||
--ignore=../../tests/test_service_orchestrator.py \
|
||||
--ignore=../../tests/test_worktree.py \
|
||||
--ignore=../../tests/test_workspace.py
|
||||
entry: bash -c 'cd apps/backend && PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" --ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py'
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/)
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: eslint
|
||||
name: ESLint
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping ESLint in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run lint
|
||||
entry: bash -c 'cd apps/frontend && npm run lint'
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
name: TypeScript Check
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping TypeScript check in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run typecheck
|
||||
entry: bash -c 'cd apps/frontend && npm run typecheck'
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
# General checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
||||
-280
@@ -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
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Auto Claude Individual Contributor License Agreement
|
||||
|
||||
Thank you for your interest in contributing to Auto Claude. This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project.
|
||||
|
||||
By signing this Agreement, you accept and agree to the following terms and conditions for your present and future Contributions submitted to the Project.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**"You" (or "Your")** means the individual who submits a Contribution to the Project.
|
||||
|
||||
**"Contribution"** means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Project.
|
||||
|
||||
**"Project"** means Auto Claude, a multi-agent autonomous coding framework, currently available at https://github.com/AndyMik90/Auto-Claude.
|
||||
|
||||
**"Project Owner"** means Andre Mikalsen and any designated successors or assignees.
|
||||
|
||||
## 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:
|
||||
|
||||
- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works
|
||||
- Sublicense any or all of the foregoing rights to third parties
|
||||
|
||||
## 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Project to which such Contribution(s) was submitted.
|
||||
|
||||
## 4. Future Licensing Flexibility
|
||||
|
||||
You understand and agree that the Project Owner may, in the future, license the Project, including Your Contributions, under additional licenses beyond the current GNU Affero General Public License version 3.0 (AGPL-3.0). Such additional licenses may include commercial or enterprise licenses.
|
||||
|
||||
This provision ensures the Project has proper licensing flexibility should such licensing options be introduced in the future. The open source version of the Project will continue to be available under AGPL-3.0.
|
||||
|
||||
## 5. Representations
|
||||
|
||||
You represent that:
|
||||
|
||||
(a) You are legally entitled to grant the above licenses. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, or that your employer has waived such rights for your Contributions to the Project.
|
||||
|
||||
(b) Each of Your Contributions is Your original creation. You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
(c) Your Contribution does not violate any third-party rights, including but not limited to intellectual property rights, privacy rights, or contractual obligations.
|
||||
|
||||
## 6. Support and Warranty Disclaimer
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all.
|
||||
|
||||
UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, YOU PROVIDE YOUR CONTRIBUTIONS ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 7. No Obligation to Use
|
||||
|
||||
You understand that the decision to include Your Contribution in any project or source repository is entirely at the discretion of the Project Owner, and this Agreement does not guarantee that Your Contributions will be included in any product.
|
||||
|
||||
## 8. Contributor Rights
|
||||
|
||||
You retain full copyright ownership of Your Contributions. Nothing in this Agreement shall be interpreted to prohibit you from licensing Your Contributions under different terms to third parties or from using Your Contributions for any other purpose.
|
||||
|
||||
## 9. Notification
|
||||
|
||||
You agree to notify the Project Owner of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
---
|
||||
|
||||
## How to Sign
|
||||
|
||||
To sign this CLA, comment on your Pull Request with:
|
||||
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
Your signature will be recorded automatically.
|
||||
|
||||
---
|
||||
|
||||
*This CLA is based on the Apache Software Foundation Individual Contributor License Agreement v2.0.*
|
||||
@@ -4,38 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Auto Claude is a multi-agent autonomous coding framework that builds software through coordinated AI agent sessions. It uses the Claude Agent SDK to run agents in isolated workspaces with security controls.
|
||||
|
||||
**CRITICAL: All AI interactions use the Claude Agent SDK (`claude-agent-sdk` package), NOT the Anthropic API directly.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
autonomous-coding/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python backend/CLI - ALL agent logic lives here
|
||||
│ │ ├── core/ # Client, auth, security
|
||||
│ │ ├── agents/ # Agent implementations
|
||||
│ │ ├── spec_agents/ # Spec creation agents
|
||||
│ │ ├── integrations/ # Graphiti, Linear, GitHub
|
||||
│ │ └── prompts/ # Agent system prompts
|
||||
│ └── frontend/ # Electron desktop UI
|
||||
├── guides/ # Documentation
|
||||
├── tests/ # Test suite
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
|
||||
**When working with AI/LLM code:**
|
||||
- Look in `apps/backend/core/client.py` for the Claude SDK client setup
|
||||
- Reference `apps/backend/agents/` for working agent implementations
|
||||
- Check `apps/backend/spec_agents/` for spec creation agent examples
|
||||
- NEVER use `anthropic.Anthropic()` directly - always use `create_client()` from `core.client`
|
||||
|
||||
**Frontend (Electron Desktop App):**
|
||||
- Built with Electron, React, TypeScript
|
||||
- AI agents can perform E2E testing using the Electron MCP server
|
||||
- When bug fixing or implementing features, use the Electron MCP server for automated testing
|
||||
- See "End-to-End Testing" section below for details
|
||||
Auto Claude is a multi-agent autonomous coding framework that builds software through coordinated AI agent sessions. It uses the Claude Code SDK to run agents in isolated workspaces with security controls.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -164,41 +133,19 @@ See [RELEASE.md](RELEASE.md) for detailed release process documentation.
|
||||
**Implementation (run.py → agent.py)** - Multi-session build:
|
||||
1. Planner Agent creates subtask-based implementation plan
|
||||
2. Coder Agent implements subtasks (can spawn subagents for parallel work)
|
||||
3. QA Reviewer validates acceptance criteria (can perform E2E testing via Electron MCP for frontend changes)
|
||||
4. QA Fixer resolves issues in a loop (with E2E testing to verify fixes)
|
||||
3. QA Reviewer validates acceptance criteria
|
||||
4. QA Fixer resolves issues in a loop
|
||||
|
||||
### Key Components (apps/backend/)
|
||||
|
||||
**Core Infrastructure:**
|
||||
- **core/client.py** - Claude Agent SDK client factory with security hooks and tool permissions
|
||||
- **core/security.py** - Dynamic command allowlisting based on detected project stack
|
||||
- **core/auth.py** - OAuth token management for Claude SDK authentication
|
||||
- **agents/** - Agent implementations (planner, coder, qa_reviewer, qa_fixer)
|
||||
- **spec_agents/** - Spec creation agents (gatherer, researcher, writer, critic)
|
||||
|
||||
**Memory & Context:**
|
||||
- **integrations/graphiti/** - Graphiti memory system (mandatory)
|
||||
- `queries_pkg/graphiti.py` - Main GraphitiMemory class
|
||||
- `queries_pkg/client.py` - LadybugDB client wrapper
|
||||
- `queries_pkg/queries.py` - Graph query operations
|
||||
- `queries_pkg/search.py` - Semantic search logic
|
||||
- `queries_pkg/schema.py` - Graph schema definitions
|
||||
- **client.py** - Claude SDK client with security hooks and tool permissions
|
||||
- **security.py** + **project_analyzer.py** - Dynamic command allowlisting based on detected project stack
|
||||
- **worktree.py** - Git worktree isolation for safe feature development
|
||||
- **memory.py** - File-based session memory (primary, always-available storage)
|
||||
- **graphiti_memory.py** - Graph-based cross-session memory with semantic search
|
||||
- **graphiti_providers.py** - Multi-provider factory for Graphiti (OpenAI, Anthropic, Azure, Ollama, Google AI)
|
||||
- **graphiti_config.py** - Configuration and validation for Graphiti integration
|
||||
- **graphiti_providers.py** - Multi-provider factory (OpenAI, Anthropic, Azure, Ollama, Google AI)
|
||||
- **agents/memory_manager.py** - Session memory orchestration
|
||||
|
||||
**Workspace & Security:**
|
||||
- **cli/worktree.py** - Git worktree isolation for safe feature development
|
||||
- **context/project_analyzer.py** - Project stack detection for dynamic tooling
|
||||
- **auto_claude_tools.py** - Custom MCP tools integration
|
||||
|
||||
**Integrations:**
|
||||
- **linear_updater.py** - Optional Linear integration for progress tracking
|
||||
- **runners/github/** - GitHub Issues & PRs automation
|
||||
- **Electron MCP** - E2E testing integration for QA agents (Chrome DevTools Protocol)
|
||||
- Enabled with `ELECTRON_MCP_ENABLED=true` in `.env`
|
||||
- Allows QA agents to interact with running Electron app
|
||||
- See "End-to-End Testing" section for details
|
||||
|
||||
### Agent Prompts (apps/backend/prompts/)
|
||||
|
||||
@@ -248,23 +195,6 @@ main (user's branch)
|
||||
4. User runs `--merge` to add to their project
|
||||
5. User pushes to remote when ready
|
||||
|
||||
### Contributing to Upstream
|
||||
|
||||
**CRITICAL: When submitting PRs to AndyMik90/Auto-Claude, always target the `develop` branch, NOT `main`.**
|
||||
|
||||
**Correct workflow for contributions:**
|
||||
1. Fetch upstream: `git fetch upstream`
|
||||
2. Create feature branch from upstream/develop: `git checkout -b fix/my-fix upstream/develop`
|
||||
3. Make changes and commit with sign-off: `git commit -s -m "fix: description"`
|
||||
4. Push to your fork: `git push origin fix/my-fix`
|
||||
5. Create PR targeting `develop`: `gh pr create --repo AndyMik90/Auto-Claude --base develop`
|
||||
|
||||
**Verify before PR:**
|
||||
```bash
|
||||
# Ensure only your commits are included
|
||||
git log --oneline upstream/develop..HEAD
|
||||
```
|
||||
|
||||
### Security Model
|
||||
|
||||
Three-layer defense:
|
||||
@@ -274,208 +204,35 @@ Three-layer defense:
|
||||
|
||||
Security profile cached in `.auto-claude-security.json`.
|
||||
|
||||
### Claude Agent SDK Integration
|
||||
|
||||
**CRITICAL: Auto Claude uses the Claude Agent SDK for ALL AI interactions. Never use the Anthropic API directly.**
|
||||
|
||||
**Client Location:** `apps/backend/core/client.py`
|
||||
|
||||
The `create_client()` function creates a configured `ClaudeSDKClient` instance with:
|
||||
- Multi-layered security (sandbox, permissions, security hooks)
|
||||
- Agent-specific tool permissions (planner, coder, qa_reviewer, qa_fixer)
|
||||
- Dynamic MCP server integration based on project capabilities
|
||||
- Extended thinking token budget control
|
||||
|
||||
**Example usage in agents:**
|
||||
```python
|
||||
from core.client import create_client
|
||||
|
||||
# Create SDK client (NOT raw Anthropic API client)
|
||||
client = create_client(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
agent_type="coder",
|
||||
max_thinking_tokens=None # or 5000/10000/16000
|
||||
)
|
||||
|
||||
# Run agent session
|
||||
response = client.create_agent_session(
|
||||
name="coder-agent-session",
|
||||
starting_message="Implement the authentication feature"
|
||||
)
|
||||
```
|
||||
|
||||
**Why use the SDK:**
|
||||
- Pre-configured security (sandbox, allowlists, hooks)
|
||||
- Automatic MCP server integration (Context7, Linear, Graphiti, Electron, Puppeteer)
|
||||
- Tool permissions based on agent role
|
||||
- Session management and recovery
|
||||
- Unified API across all agent types
|
||||
|
||||
**Where to find working examples:**
|
||||
- `apps/backend/agents/planner.py` - Planner agent
|
||||
- `apps/backend/agents/coder.py` - Coder agent
|
||||
- `apps/backend/agents/qa_reviewer.py` - QA reviewer
|
||||
- `apps/backend/agents/qa_fixer.py` - QA fixer
|
||||
- `apps/backend/spec_agents/` - Spec creation agents
|
||||
|
||||
### Memory System
|
||||
|
||||
**Graphiti Memory (Mandatory)** - `integrations/graphiti/`
|
||||
Dual-layer memory architecture:
|
||||
|
||||
Auto Claude uses Graphiti as its primary memory system with embedded LadybugDB (no Docker required):
|
||||
**File-Based Memory (Primary)** - `memory.py`
|
||||
- Zero dependencies, always available
|
||||
- Human-readable files in `specs/XXX/memory/`
|
||||
- Session insights, patterns, gotchas, codebase map
|
||||
|
||||
- **Graph database with semantic search** - Knowledge graph for cross-session context
|
||||
- **Session insights** - Patterns, gotchas, discoveries automatically extracted
|
||||
- **Multi-provider support:**
|
||||
**Graphiti Memory** - `graphiti_memory.py`
|
||||
- Graph database with semantic search (LadybugDB - embedded, no Docker)
|
||||
- Cross-session context retrieval
|
||||
- Multi-provider support:
|
||||
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI (Gemini)
|
||||
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI
|
||||
- **Modular architecture:** (`integrations/graphiti/queries_pkg/`)
|
||||
- `graphiti.py` - Main GraphitiMemory class
|
||||
- `client.py` - LadybugDB client wrapper
|
||||
- `queries.py` - Graph query operations
|
||||
- `search.py` - Semantic search logic
|
||||
- `schema.py` - Graph schema definitions
|
||||
- Configure with provider credentials in `.env.example`
|
||||
|
||||
**Configuration:**
|
||||
- Set provider credentials in `apps/backend/.env` (see `.env.example`)
|
||||
- Required env vars: `GRAPHITI_ENABLED=true`, `ANTHROPIC_API_KEY` or other provider keys
|
||||
- Memory data stored in `.auto-claude/specs/XXX/graphiti/`
|
||||
## Project Structure
|
||||
|
||||
**Usage in agents:**
|
||||
```python
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
|
||||
memory = get_graphiti_memory(spec_dir, project_dir)
|
||||
context = memory.get_context_for_session("Implementing feature X")
|
||||
memory.add_session_insight("Pattern: use React hooks for state")
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Frontend Internationalization (i18n)
|
||||
|
||||
**CRITICAL: Always use i18n translation keys for all user-facing text in the frontend.**
|
||||
|
||||
The frontend uses `react-i18next` for internationalization. All labels, buttons, messages, and user-facing text MUST use translation keys.
|
||||
|
||||
**Translation file locations:**
|
||||
- `apps/frontend/src/shared/i18n/locales/en/*.json` - English translations
|
||||
- `apps/frontend/src/shared/i18n/locales/fr/*.json` - French translations
|
||||
|
||||
**Translation namespaces:**
|
||||
- `common.json` - Shared labels, buttons, common terms
|
||||
- `navigation.json` - Sidebar navigation items, sections
|
||||
- `settings.json` - Settings page content
|
||||
- `dialogs.json` - Dialog boxes and modals
|
||||
- `tasks.json` - Task/spec related content
|
||||
- `onboarding.json` - Onboarding wizard content
|
||||
- `welcome.json` - Welcome screen content
|
||||
|
||||
**Usage pattern:**
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// In component
|
||||
const { t } = useTranslation(['navigation', 'common']);
|
||||
|
||||
// Use translation keys, NOT hardcoded strings
|
||||
<span>{t('navigation:items.githubPRs')}</span> // ✅ CORRECT
|
||||
<span>GitHub PRs</span> // ❌ WRONG
|
||||
auto-claude/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python backend/CLI (the framework code)
|
||||
│ └── frontend/ # Electron desktop UI
|
||||
├── guides/ # Documentation
|
||||
├── tests/ # Test suite
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
|
||||
**When adding new UI text:**
|
||||
1. Add the translation key to ALL language files (at minimum: `en/*.json` and `fr/*.json`)
|
||||
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
|
||||
3. Never use hardcoded strings in JSX/TSX files
|
||||
|
||||
### End-to-End Testing (Electron App)
|
||||
|
||||
**IMPORTANT: When bug fixing or implementing new features in the frontend, AI agents can perform automated E2E testing using the Electron MCP server.**
|
||||
|
||||
The Electron MCP server allows QA agents to interact with the running Electron app via Chrome DevTools Protocol:
|
||||
|
||||
**Setup:**
|
||||
1. Start the Electron app with remote debugging enabled:
|
||||
```bash
|
||||
npm run dev # Already configured with --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
2. Enable Electron MCP in `apps/backend/.env`:
|
||||
```bash
|
||||
ELECTRON_MCP_ENABLED=true
|
||||
ELECTRON_DEBUG_PORT=9222 # Default port
|
||||
```
|
||||
|
||||
**Available Testing Capabilities:**
|
||||
|
||||
QA agents (`qa_reviewer` and `qa_fixer`) automatically get access to Electron MCP tools:
|
||||
|
||||
1. **Window Management**
|
||||
- `mcp__electron__get_electron_window_info` - Get info about running windows
|
||||
- `mcp__electron__take_screenshot` - Capture screenshots for visual verification
|
||||
|
||||
2. **UI Interaction**
|
||||
- `mcp__electron__send_command_to_electron` with commands:
|
||||
- `click_by_text` - Click buttons/links by visible text
|
||||
- `click_by_selector` - Click elements by CSS selector
|
||||
- `fill_input` - Fill form fields by placeholder or selector
|
||||
- `select_option` - Select dropdown options
|
||||
- `send_keyboard_shortcut` - Send keyboard shortcuts (Enter, Ctrl+N, etc.)
|
||||
- `navigate_to_hash` - Navigate to hash routes (#settings, #create, etc.)
|
||||
|
||||
3. **Page Inspection**
|
||||
- `get_page_structure` - Get organized overview of page elements
|
||||
- `debug_elements` - Get debugging info about buttons and forms
|
||||
- `verify_form_state` - Check form state and validation
|
||||
- `eval` - Execute custom JavaScript code
|
||||
|
||||
4. **Logging**
|
||||
- `mcp__electron__read_electron_logs` - Read console logs for debugging
|
||||
|
||||
**Example E2E Test Flow:**
|
||||
|
||||
```python
|
||||
# 1. Agent takes screenshot to see current state
|
||||
agent: "Take a screenshot to see the current UI"
|
||||
# Uses: mcp__electron__take_screenshot
|
||||
|
||||
# 2. Agent inspects page structure
|
||||
agent: "Get page structure to find available buttons"
|
||||
# Uses: mcp__electron__send_command_to_electron (command: "get_page_structure")
|
||||
|
||||
# 3. Agent clicks a button to navigate
|
||||
agent: "Click the 'Create New Spec' button"
|
||||
# Uses: mcp__electron__send_command_to_electron (command: "click_by_text", args: {text: "Create New Spec"})
|
||||
|
||||
# 4. Agent fills out a form
|
||||
agent: "Fill the task description field"
|
||||
# Uses: mcp__electron__send_command_to_electron (command: "fill_input", args: {placeholder: "Describe your task", value: "Add login feature"})
|
||||
|
||||
# 5. Agent submits and verifies
|
||||
agent: "Click Submit and verify success"
|
||||
# Uses: click_by_text → take_screenshot → verify result
|
||||
```
|
||||
|
||||
**When to Use E2E Testing:**
|
||||
|
||||
- **Bug Fixes**: Reproduce the bug, apply fix, verify it's resolved
|
||||
- **New Features**: Implement feature, test the UI flow end-to-end
|
||||
- **UI Changes**: Verify visual changes and interactions work correctly
|
||||
- **Form Validation**: Test form submission, validation, error handling
|
||||
|
||||
**Configuration in `core/client.py`:**
|
||||
|
||||
The client automatically enables Electron MCP tools for QA agents when:
|
||||
- Project is detected as Electron (`is_electron` capability)
|
||||
- `ELECTRON_MCP_ENABLED=true` is set
|
||||
- Agent type is `qa_reviewer` or `qa_fixer`
|
||||
|
||||
**Note:** Screenshots are automatically compressed (1280x720, quality 60, JPEG) to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
|
||||
## Running the Application
|
||||
|
||||
**As a standalone CLI tool**:
|
||||
```bash
|
||||
cd apps/backend
|
||||
@@ -485,14 +242,7 @@ python run.py --spec 001
|
||||
**With the Electron frontend**:
|
||||
```bash
|
||||
npm start # Build and run desktop app
|
||||
npm run dev # Run in development mode (includes --remote-debugging-port=9222 for E2E testing)
|
||||
npm run dev # Run in development mode
|
||||
```
|
||||
|
||||
**For E2E Testing with QA Agents:**
|
||||
1. Start the Electron app: `npm run dev`
|
||||
2. Enable Electron MCP in `apps/backend/.env`: `ELECTRON_MCP_ENABLED=true`
|
||||
3. Run QA: `python run.py --spec 001 --qa`
|
||||
4. QA agents will automatically interact with the running app for testing
|
||||
|
||||
**Project data storage:**
|
||||
- `.auto-claude/specs/` - Per-project data (specs, plans, QA reports, memory) - gitignored
|
||||
- `.auto-claude/specs/` - Per-project data (specs, plans, QA reports) - gitignored
|
||||
|
||||
-107
@@ -4,7 +4,6 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Development Setup](#development-setup)
|
||||
@@ -24,30 +23,10 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
- [Pull Request Targets](#pull-request-targets)
|
||||
- [Release Process](#release-process-maintainers)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [PR Hygiene](#pr-hygiene)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
All contributors must sign our Contributor License Agreement (CLA) before contributions can be accepted.
|
||||
|
||||
### Why We Require a CLA
|
||||
|
||||
Auto Claude is currently licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options (such as commercial/enterprise licenses) in the future.
|
||||
|
||||
You retain full copyright ownership of your contributions.
|
||||
|
||||
### How to Sign
|
||||
|
||||
1. Open a Pull Request
|
||||
2. The CLA bot will automatically comment with instructions
|
||||
3. Comment on the PR with: `I have read the CLA Document and I hereby sign the CLA`
|
||||
4. Done - you only need to sign once, and it applies to all future contributions
|
||||
|
||||
Read the full CLA here: [CLA.md](CLA.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
@@ -56,7 +35,6 @@ Before contributing, ensure you have the following installed:
|
||||
- **Node.js 24+** - For the Electron frontend
|
||||
- **npm 10+** - Package manager for the frontend (comes with Node.js)
|
||||
- **uv** (recommended) or **pip** - Python package manager
|
||||
- **CMake** - Required for building native dependencies (e.g., LadybugDB)
|
||||
- **Git** - Version control
|
||||
|
||||
### Installing Python 3.12
|
||||
@@ -76,56 +54,6 @@ brew install python@3.12
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install python3.12
|
||||
```
|
||||
|
||||
### Installing Node.js 24+
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install node@24
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install nodejs npm
|
||||
```
|
||||
|
||||
### Installing CMake
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install Kitware.CMake
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
sudo apt install cmake
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install cmake
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get started:
|
||||
@@ -668,41 +596,6 @@ git commit -m "WIP"
|
||||
- **body**: Detailed explanation if needed (wrap at 72 chars)
|
||||
- **footer**: Reference issues, breaking changes
|
||||
|
||||
### PR Hygiene
|
||||
|
||||
**Rebasing:**
|
||||
- **Rebase onto develop** before opening a PR and before merge to maintain linear history
|
||||
- Use `git fetch origin && git rebase origin/develop` to sync your branch
|
||||
- Use `--force-with-lease` when force-pushing rebased branches (safer than `--force`)
|
||||
- Notify reviewers after force-pushing during active review
|
||||
- **Exception:** Never rebase after PR is approved and others have reviewed specific commits
|
||||
|
||||
**Commit organization:**
|
||||
- **Squash fixup commits** (typos, "oops", review feedback) into their parent commits
|
||||
- **Keep logically distinct changes** as separate commits that could be reverted independently
|
||||
- Each commit should compile and pass tests independently
|
||||
- No "WIP", "fix tests", or "lint" commits in final PR - squash these
|
||||
|
||||
**Before requesting review:**
|
||||
```bash
|
||||
# Ensure up-to-date with develop
|
||||
git fetch origin && git rebase origin/develop
|
||||
|
||||
# Clean up commit history (squash fixups, reword messages)
|
||||
git rebase -i origin/develop
|
||||
|
||||
# Force push with safety check
|
||||
git push --force-with-lease
|
||||
|
||||
# Verify everything works
|
||||
npm run test:backend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
**PR size:**
|
||||
- Keep PRs small (<400 lines changed ideally)
|
||||
- Split large features into stacked PRs if possible
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Fork the repository** and create your branch from `develop` (not main!)
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
# Root Cause Investigation: Task Workflow Halts After Planning Stage
|
||||
|
||||
## Investigation Summary
|
||||
|
||||
After adding comprehensive logging to the task loading and plan update pipeline, I've analyzed the data flow from backend to frontend to identify why subtasks fail to display after spec completion.
|
||||
|
||||
## Data Flow Analysis
|
||||
|
||||
### Current Architecture
|
||||
|
||||
```
|
||||
Backend (Python)
|
||||
↓
|
||||
Creates implementation_plan.json
|
||||
↓
|
||||
Emits IPC event: 'task:progress' with plan data
|
||||
↓
|
||||
Frontend (Electron Renderer)
|
||||
↓
|
||||
useIpc.ts: onTaskProgress handler (batched)
|
||||
↓
|
||||
task-store.ts: updateTaskFromPlan(taskId, plan)
|
||||
↓
|
||||
Creates subtasks from plan.phases.flatMap(phase => phase.subtasks)
|
||||
↓
|
||||
UI: TaskSubtasks.tsx renders subtasks
|
||||
```
|
||||
|
||||
### Critical Code Paths
|
||||
|
||||
**1. Plan Update Handler** (`apps/frontend/src/renderer/hooks/useIpc.ts:131-135`)
|
||||
```typescript
|
||||
window.electronAPI.onTaskProgress(
|
||||
(taskId: string, plan: ImplementationPlan) => {
|
||||
queueUpdate(taskId, { plan });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**2. Subtask Creation** (`apps/frontend/src/renderer/stores/task-store.ts:124-133`)
|
||||
```typescript
|
||||
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
|
||||
phase.subtasks.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.description,
|
||||
description: subtask.description,
|
||||
status: subtask.status,
|
||||
files: [],
|
||||
verification: subtask.verification as Subtask['verification']
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
**3. Initial Task Loading** (`apps/frontend/src/main/project-store.ts:461-470`)
|
||||
```typescript
|
||||
const subtasks = plan?.phases?.flatMap((phase) => {
|
||||
const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || [];
|
||||
return items.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.description,
|
||||
description: subtask.description,
|
||||
status: subtask.status,
|
||||
files: []
|
||||
}));
|
||||
}) || [];
|
||||
```
|
||||
|
||||
## Root Cause Identification
|
||||
|
||||
### Primary Root Cause: Early Plan Update Event with Empty Phases
|
||||
|
||||
**What's Happening:**
|
||||
|
||||
1. **Backend creates `implementation_plan.json` in stages:**
|
||||
- First writes the file with minimal structure: `{ "feature": "...", "phases": [] }`
|
||||
- Then adds phases and subtasks incrementally
|
||||
- Emits IPC event each time the plan is updated
|
||||
|
||||
2. **Frontend receives the FIRST plan update event:**
|
||||
- Plan has `feature` and basic metadata
|
||||
- **But `phases` array is EMPTY: `[]`**
|
||||
- `updateTaskFromPlan` is called with this incomplete plan
|
||||
- Subtasks are created as empty array: `plan.phases.flatMap(...)` → `[]`
|
||||
|
||||
3. **Later plan updates with full subtask data are ignored:**
|
||||
- When backend writes the complete plan with subtasks
|
||||
- Another IPC event is emitted
|
||||
- But due to race conditions or event handling issues, this update doesn't reach the frontend
|
||||
- Or it does reach but the task UI doesn't refresh
|
||||
|
||||
**Evidence from Code:**
|
||||
|
||||
Looking at `updateTaskFromPlan` (task-store.ts:106-190):
|
||||
- Line 108-114: Logs show `phases: plan.phases?.length || 0`
|
||||
- Line 112: If plan has 0 phases, `totalSubtasks` will be 0
|
||||
- Line 124-133: `plan.phases.flatMap(...)` on empty array creates `subtasks = []`
|
||||
- **No validation to check if plan is complete before updating state**
|
||||
|
||||
**Why "!" Indicators Appear:**
|
||||
|
||||
The "!" indicators likely come from the UI attempting to render subtasks when:
|
||||
- Subtask count shows as 18 (from later plan update metadata)
|
||||
- But `task.subtasks` array is actually empty `[]` (from early plan update)
|
||||
- This mismatch causes the UI to show warning indicators
|
||||
|
||||
### Secondary Contributing Factors
|
||||
|
||||
**A. No Plan Validation Before State Update**
|
||||
|
||||
Current code in `updateTaskFromPlan` immediately creates subtasks from whatever plan data it receives:
|
||||
```typescript
|
||||
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
|
||||
phase.subtasks.map((subtask) => ({ ... }))
|
||||
);
|
||||
```
|
||||
|
||||
**Problem:** No check if plan is "ready" or "complete" before updating state.
|
||||
|
||||
**B. Missing Reload Trigger After Spec Completion**
|
||||
|
||||
When spec creation completes and the full plan is written:
|
||||
- The IPC event might not fire again
|
||||
- Or the event fires but the batching mechanism drops it
|
||||
- Frontend state remains stuck with empty subtasks
|
||||
|
||||
**C. Race Condition in Batch Update Queue**
|
||||
|
||||
In `useIpc.ts:92-112`, the batching mechanism queues updates:
|
||||
```typescript
|
||||
function queueUpdate(taskId: string, update: BatchedUpdate): void {
|
||||
const existing = batchQueue.get(taskId) || {};
|
||||
batchQueue.set(taskId, { ...existing, ...update });
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** If two plan updates arrive within 16ms:
|
||||
- First update has empty phases: `{ plan: { phases: [] } }`
|
||||
- Second update has full phases: `{ plan: { phases: [...18 subtasks...] } }`
|
||||
- Second update **overwrites** first in the queue
|
||||
- But if order gets reversed, empty plan overwrites full plan
|
||||
|
||||
## Log Evidence to Look For
|
||||
|
||||
To confirm this root cause, check console logs for:
|
||||
|
||||
### 1. Plan Loading Sequence
|
||||
```
|
||||
[updateTaskFromPlan] called with plan:
|
||||
taskId: "xxx"
|
||||
feature: "..."
|
||||
phases: 0 ← SMOKING GUN: phases array is empty
|
||||
totalSubtasks: 0 ← No subtasks
|
||||
```
|
||||
|
||||
If you see `phases: 0` followed later by no update with `phases: 3` (or more), the early empty plan is stuck in state.
|
||||
|
||||
### 2. Multiple Plan Updates
|
||||
```
|
||||
[updateTaskFromPlan] called with plan:
|
||||
phases: 0
|
||||
totalSubtasks: 0
|
||||
|
||||
[updateTaskFromPlan] called with plan: ← This might never appear
|
||||
phases: 3
|
||||
totalSubtasks: 18
|
||||
```
|
||||
|
||||
If second log never appears, the plan update event isn't firing after spec completion.
|
||||
|
||||
### 3. Project Store Loading
|
||||
```
|
||||
[ProjectStore] Loading implementation_plan.json for spec: xxx
|
||||
[ProjectStore] Loaded plan for xxx:
|
||||
phaseCount: 0 ← Empty plan loaded from disk
|
||||
subtaskCount: 0
|
||||
```
|
||||
|
||||
If plan file on disk has empty phases, the issue is in backend plan writing.
|
||||
|
||||
### 4. Plan File Utils
|
||||
```
|
||||
[plan-file-utils] Reading implementation_plan.json to update status
|
||||
[plan-file-utils] Successfully persisted status ← Plan exists but might be incomplete
|
||||
```
|
||||
|
||||
Check if plan file reads/writes are happening during spec creation.
|
||||
|
||||
## Proposed Fix Approach
|
||||
|
||||
### Fix 1: Add Plan Completeness Validation (Immediate Fix)
|
||||
|
||||
**File:** `apps/frontend/src/renderer/stores/task-store.ts`
|
||||
|
||||
**Change:** Only update subtasks if plan has valid phases and subtasks:
|
||||
|
||||
```typescript
|
||||
updateTaskFromPlan: (taskId, plan) =>
|
||||
set((state) => {
|
||||
console.log('[updateTaskFromPlan] called with plan:', { ... });
|
||||
|
||||
const index = findTaskIndex(state.tasks, taskId);
|
||||
if (index === -1) {
|
||||
console.log('[updateTaskFromPlan] Task not found:', taskId);
|
||||
return state;
|
||||
}
|
||||
|
||||
// VALIDATION: Don't update if plan is incomplete
|
||||
if (!plan.phases || plan.phases.length === 0) {
|
||||
console.warn('[updateTaskFromPlan] Plan has no phases, skipping update:', taskId);
|
||||
return state; // Keep existing state, don't overwrite with empty data
|
||||
}
|
||||
|
||||
const totalSubtasks = plan.phases.reduce((acc, p) => acc + (p.subtasks?.length || 0), 0);
|
||||
if (totalSubtasks === 0) {
|
||||
console.warn('[updateTaskFromPlan] Plan has no subtasks, skipping update:', taskId);
|
||||
return state; // Keep existing state
|
||||
}
|
||||
|
||||
// ... rest of existing code to create subtasks ...
|
||||
})
|
||||
```
|
||||
|
||||
### Fix 2: Trigger Reload After Spec Completion (Comprehensive Fix)
|
||||
|
||||
**File:** `apps/frontend/src/renderer/hooks/useIpc.ts`
|
||||
|
||||
**Change:** Add explicit "spec completed" event handler that reloads the task:
|
||||
|
||||
```typescript
|
||||
// Add new IPC event listener
|
||||
const cleanupSpecComplete = window.electronAPI.onSpecComplete(
|
||||
async (taskId: string) => {
|
||||
console.log('[IPC] Spec completed for task:', taskId);
|
||||
// Force reload the task from disk to get the complete plan
|
||||
const task = useTaskStore.getState().tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
// Reload plan from file
|
||||
const result = await window.electronAPI.getTaskPlan(task.projectId, taskId);
|
||||
if (result.success && result.data) {
|
||||
updateTaskFromPlan(taskId, result.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Fix 3: Prevent Plan Overwrite in Batch Queue (Race Condition Fix)
|
||||
|
||||
**File:** `apps/frontend/src/renderer/hooks/useIpc.ts`
|
||||
|
||||
**Change:** Don't overwrite plan if incoming plan has fewer subtasks than existing:
|
||||
|
||||
```typescript
|
||||
function queueUpdate(taskId: string, update: BatchedUpdate): void {
|
||||
const existing = batchQueue.get(taskId) || {};
|
||||
|
||||
// For plan updates, only accept if it has MORE data than existing
|
||||
let mergedPlan = existing.plan;
|
||||
if (update.plan) {
|
||||
const existingSubtasks = existing.plan?.phases?.flatMap(p => p.subtasks || []).length || 0;
|
||||
const newSubtasks = update.plan.phases?.flatMap(p => p.subtasks || []).length || 0;
|
||||
|
||||
if (newSubtasks >= existingSubtasks) {
|
||||
mergedPlan = update.plan; // Accept new plan
|
||||
} else {
|
||||
console.warn('[IPC Batch] Rejecting plan update with fewer subtasks:',
|
||||
{ taskId, existing: existingSubtasks, new: newSubtasks });
|
||||
// Keep existing plan, don't overwrite with less complete data
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
## Testing the Fix
|
||||
|
||||
### Manual Verification Steps
|
||||
|
||||
1. **Create a new task** and move it to "In Progress"
|
||||
2. **Watch the console logs** for:
|
||||
```
|
||||
[updateTaskFromPlan] called with plan: { phases: 0, totalSubtasks: 0 }
|
||||
```
|
||||
3. **Wait for spec to complete** (planning phase finishes)
|
||||
4. **Check console logs** for:
|
||||
```
|
||||
[updateTaskFromPlan] called with plan: { phases: 3, totalSubtasks: 18 }
|
||||
```
|
||||
5. **Expand subtask list** in task card
|
||||
6. **Verify:** Subtasks display with full details, no "!" indicators
|
||||
|
||||
### Expected Outcome After Fix
|
||||
|
||||
- ✅ Empty/incomplete plan updates are ignored
|
||||
- ✅ Only complete plans with phases and subtasks update the UI
|
||||
- ✅ Subtasks display with id, description, and status
|
||||
- ✅ No "!" warning indicators
|
||||
- ✅ Subtask count shows "0/18 completed" (not "0/0")
|
||||
- ✅ Plan pulsing animation stops when spec completes
|
||||
- ✅ Resume functionality works without infinite loop
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ **This Investigation** - Root cause identified (COMPLETE)
|
||||
2. 🔄 **Subtask 2-1** - Implement Fix 1 (validation in updateTaskFromPlan)
|
||||
3. 🔄 **Subtask 2-2** - Add data validation before subtask state updates
|
||||
4. 🔄 **Subtask 2-3** - Fix pulsing animation condition
|
||||
5. 🔄 **Subtask 2-4** - Fix resume logic to reload plan if subtasks missing
|
||||
6. 🔄 **Phase 3** - Add comprehensive tests to prevent regressions
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Root Cause:** Frontend receives and accepts incomplete plan data (empty `phases` array) during the spec creation process, before subtasks are written. This overwrites any existing subtask data and leaves the UI in a stuck state with no subtasks to display.
|
||||
|
||||
**Fix Priority:** Implement Fix 1 (validation) immediately to prevent incomplete plans from updating state. This is a minimal, low-risk change that will resolve the core issue.
|
||||
|
||||
**Long-term Solution:** Add explicit event handling for spec completion (Fix 2) and improve batch queue logic (Fix 3) to make the system more robust against race conditions and out-of-order updates.
|
||||
@@ -4,49 +4,24 @@
|
||||
|
||||

|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/latest)
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
### Stable Release
|
||||
Get the latest pre-built release for your platform:
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
|
||||
<!-- 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) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Windows** | [Auto-Claude-2.7.1.exe](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Installer (NSIS) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | M1/M2/M3 Macs |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.1-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Intel Macs |
|
||||
| **Linux** | [Auto-Claude-2.7.1.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Universal |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.1.deb](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Ubuntu/Debian |
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -57,6 +32,7 @@
|
||||
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
|
||||
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
- **Git repository** - Your project must be initialized as a git repo
|
||||
- **Python 3.12+** - Required for the backend and Memory Layer
|
||||
|
||||
---
|
||||
|
||||
@@ -80,8 +56,6 @@
|
||||
| **Self-Validating QA** | Built-in quality assurance loop catches issues before you review |
|
||||
| **AI-Powered Merge** | Automatic conflict resolution when integrating back to main |
|
||||
| **Memory Layer** | Agents retain insights across sessions for smarter builds |
|
||||
| **GitHub/GitLab Integration** | Import issues, investigate with AI, create merge requests |
|
||||
| **Linear Integration** | Sync tasks with Linear for team progress tracking |
|
||||
| **Cross-Platform** | Native desktop apps for Windows, macOS, and Linux |
|
||||
| **Auto-Updates** | App updates automatically when new versions are released |
|
||||
|
||||
@@ -145,11 +119,47 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
## Configuration
|
||||
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
Create `apps/backend/.env` from the example:
|
||||
|
||||
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
|
||||
```bash
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
|
||||
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
For contributors and development:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Or build and run
|
||||
npm start
|
||||
```
|
||||
|
||||
**System requirements for building:**
|
||||
- Node.js 24+
|
||||
- Python 3.12+
|
||||
- npm 10+
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
|
||||
|
||||
---
|
||||
|
||||
@@ -179,7 +189,6 @@ All releases are:
|
||||
| `npm run package:mac` | Package for macOS |
|
||||
| `npm run package:win` | Package for Windows |
|
||||
| `npm run package:linux` | Package for Linux |
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
@@ -211,11 +220,3 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/stargazers)
|
||||
|
||||
[](https://star-history.com/#AndyMik90/Auto-Claude&Date)
|
||||
|
||||
+23
-90
@@ -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
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
# Auto Claude uses Claude Code OAuth authentication.
|
||||
# Direct API keys (ANTHROPIC_API_KEY) are NOT supported to prevent silent billing.
|
||||
#
|
||||
# Option 1: Run `claude setup-token` to save token to system keychain (recommended)
|
||||
# (macOS: Keychain, Windows: Credential Manager, Linux: secret-service)
|
||||
# Option 1: Run `claude setup-token` to save token to macOS Keychain (recommended)
|
||||
# Option 2: Set the token explicitly:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
#
|
||||
@@ -76,38 +75,6 @@
|
||||
# Pre-configured Project ID (OPTIONAL - will create project if not set)
|
||||
# LINEAR_PROJECT_ID=
|
||||
|
||||
# =============================================================================
|
||||
# GITLAB INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable GitLab integration for issue tracking and merge requests.
|
||||
# Supports both GitLab.com and self-hosted GitLab instances.
|
||||
#
|
||||
# Authentication Options (choose one):
|
||||
#
|
||||
# Option 1: glab CLI OAuth (Recommended)
|
||||
# Install glab CLI: https://gitlab.com/gitlab-org/cli#installation
|
||||
# Then run: glab auth login
|
||||
# This opens your browser for OAuth authentication. Once complete,
|
||||
# Auto Claude will automatically use your glab credentials (no env vars needed).
|
||||
# For self-hosted: glab auth login --hostname gitlab.example.com
|
||||
#
|
||||
# Option 2: Personal Access Token
|
||||
# Set GITLAB_TOKEN below. Token auth is used if set, otherwise falls back to glab CLI.
|
||||
|
||||
# GitLab Instance URL (OPTIONAL - defaults to gitlab.com)
|
||||
# For self-hosted: GITLAB_INSTANCE_URL=https://gitlab.example.com
|
||||
# GITLAB_INSTANCE_URL=https://gitlab.com
|
||||
|
||||
# GitLab Personal Access Token (OPTIONAL - only needed if not using glab CLI)
|
||||
# Required scope: api (covers issues, merge requests, releases, project info)
|
||||
# Optional scope: write_repository (only if creating new GitLab projects from local repos)
|
||||
# Get from: https://gitlab.com/-/user_settings/personal_access_tokens
|
||||
# GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# GitLab Project (OPTIONAL - format: group/project or numeric ID)
|
||||
# If not set, will auto-detect from git remote
|
||||
# GITLAB_PROJECT=mygroup/myproject
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
@@ -186,10 +153,10 @@ GRAPHITI_ENABLED=true
|
||||
# Choose which providers to use for LLM and embeddings.
|
||||
# Default is "openai" for both.
|
||||
|
||||
# LLM provider: openai | anthropic | azure_openai | ollama | google | openrouter
|
||||
# LLM provider: openai | anthropic | azure_openai | ollama | google
|
||||
# GRAPHITI_LLM_PROVIDER=openai
|
||||
|
||||
# Embedder provider: openai | voyage | azure_openai | ollama | google | openrouter
|
||||
# Embedder provider: openai | voyage | azure_openai | ollama | google
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openai
|
||||
|
||||
# =============================================================================
|
||||
@@ -254,28 +221,6 @@ GRAPHITI_ENABLED=true
|
||||
# Google Embedding Model (default: text-embedding-004)
|
||||
# GOOGLE_EMBEDDING_MODEL=text-embedding-004
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: OpenRouter Provider (Multi-provider aggregator)
|
||||
# =============================================================================
|
||||
# Use OpenRouter to access multiple LLM providers through a single API.
|
||||
# OpenRouter provides access to Anthropic, OpenAI, Google, and many other models.
|
||||
# Get API key from: https://openrouter.ai/keys
|
||||
#
|
||||
# Required: OPENROUTER_API_KEY
|
||||
|
||||
# OpenRouter API Key
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
|
||||
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
|
||||
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Azure OpenAI Provider
|
||||
# =============================================================================
|
||||
@@ -362,11 +307,3 @@ GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=google
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=google
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxx
|
||||
#
|
||||
# --- Example 6: OpenRouter (multi-provider aggregator) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
@@ -26,7 +26,7 @@ auto-claude/agents/
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
- Workspace sync: `sync_plan_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
@@ -73,7 +73,7 @@ from agents import (
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ This module provides:
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
@@ -36,7 +32,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
@@ -81,7 +77,7 @@ def __getattr__(name):
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
@@ -89,7 +85,7 @@ def __getattr__(name):
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Custom MCP Tools for Auto-Claude Agents
|
||||
========================================
|
||||
|
||||
DEPRECATED: This module is now a compatibility shim.
|
||||
Please import from the tools_pkg package instead:
|
||||
|
||||
from agents.tools_pkg import create_auto_claude_mcp_server, get_allowed_tools
|
||||
|
||||
This file remains for backward compatibility with existing imports.
|
||||
All functionality has been moved to the tools_pkg package for better
|
||||
organization and maintainability.
|
||||
"""
|
||||
|
||||
# Import everything from the package to maintain backward compatibility
|
||||
# Use try/except to handle both relative and absolute imports
|
||||
try:
|
||||
from .tools_pkg import (
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
is_electron_mcp_enabled,
|
||||
is_tools_available,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback for direct execution - import from tools_pkg directly
|
||||
from tools_pkg import (
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
is_electron_mcp_enabled,
|
||||
is_tools_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
@@ -7,7 +7,6 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -19,7 +18,6 @@ from linear_updater import (
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
count_subtasks_detailed,
|
||||
@@ -38,7 +36,6 @@ from prompt_generator import (
|
||||
)
|
||||
from prompts import is_first_run
|
||||
from recovery import RecoveryManager
|
||||
from security.constants import PROJECT_DIR_ENV_VAR
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -64,7 +61,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -92,10 +89,6 @@ async def run_autonomous_agent(
|
||||
verbose: Whether to show detailed output
|
||||
source_spec_dir: Original spec directory in main project (for syncing from worktree)
|
||||
"""
|
||||
# Set environment variable for security hooks to find the correct project directory
|
||||
# This is needed because os.getcwd() may return the wrong directory in worktree mode
|
||||
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
|
||||
|
||||
# Initialize recovery manager (handles memory persistence)
|
||||
recovery_manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
@@ -153,7 +146,6 @@ async def run_autonomous_agent(
|
||||
|
||||
# Update status for planning phase
|
||||
status_manager.update(state=BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Creating implementation plan")
|
||||
is_planning_phase = True
|
||||
current_log_phase = LogPhase.PLANNING
|
||||
|
||||
@@ -181,9 +173,6 @@ async def run_autonomous_agent(
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.CODING, "Continuing implementation...")
|
||||
|
||||
# Emit phase event when continuing build
|
||||
emit_phase(ExecutionPhase.CODING, "Continuing implementation")
|
||||
|
||||
# Show human intervention hint
|
||||
content = [
|
||||
bold("INTERACTIVE CONTROLS"),
|
||||
@@ -263,33 +252,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
|
||||
|
||||
@@ -301,7 +273,6 @@ async def run_autonomous_agent(
|
||||
if is_planning_phase:
|
||||
is_planning_phase = False
|
||||
current_log_phase = LogPhase.CODING
|
||||
emit_phase(ExecutionPhase.CODING, "Starting implementation")
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
@@ -410,16 +381,15 @@ async def run_autonomous_agent(
|
||||
print_status("Linear notified of stuck subtask", "info")
|
||||
elif is_planning_phase and source_spec_dir:
|
||||
# After planning phase, sync the newly created implementation plan back to source
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Handle session status
|
||||
if status == "complete":
|
||||
# Don't emit COMPLETE here - subtasks are done but QA hasn't run yet
|
||||
# QA loop will emit COMPLETE after actual approval
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
|
||||
# End coding phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
@@ -427,6 +397,7 @@ async def run_autonomous_agent(
|
||||
message="All subtasks completed successfully",
|
||||
)
|
||||
|
||||
# Notify Linear that build is complete (moving to QA)
|
||||
if linear_task and linear_task.task_id:
|
||||
await linear_build_complete(spec_dir)
|
||||
print_status("Linear notified: build complete, ready for QA", "success")
|
||||
@@ -461,7 +432,6 @@ async def run_autonomous_agent(
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
|
||||
elif status == "error":
|
||||
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
|
||||
print_status("Session encountered an error", "error")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,8 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from phase_config import get_phase_thinking_budget
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -68,7 +67,6 @@ async def run_followup_planner(
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
@@ -91,14 +89,12 @@ async def run_followup_planner(
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
|
||||
task_logger.set_session(1)
|
||||
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
# Create client (fresh context) with planning phase thinking budget
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -40,7 +39,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,7 +81,7 @@ async def post_session_processing(
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
@@ -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)
|
||||
@@ -445,9 +442,8 @@ async def run_agent_session(
|
||||
result_content = getattr(block, "content", "")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
|
||||
# Check if this is an error (not just content containing "blocked")
|
||||
if is_error and "blocked" in str(result_content).lower():
|
||||
# Actual blocked command by security hook
|
||||
# Check if command was blocked by security hook
|
||||
if "blocked" in str(result_content).lower():
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
|
||||
@@ -30,32 +30,16 @@ Usage:
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
# Agent configuration registry
|
||||
AGENT_CONFIGS,
|
||||
# Base tools
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
# MCP tool lists
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
# Auto-Claude tool names
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
WEB_TOOLS,
|
||||
# Config functions
|
||||
get_agent_config,
|
||||
get_default_thinking_level,
|
||||
get_required_mcp_servers,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .permissions import get_all_agent_types, get_allowed_tools
|
||||
from .permissions import get_allowed_tools
|
||||
from .registry import create_auto_claude_mcp_server, is_tools_available
|
||||
|
||||
__all__ = [
|
||||
@@ -63,29 +47,14 @@ __all__ = [
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Agent configuration registry
|
||||
"AGENT_CONFIGS",
|
||||
"get_agent_config",
|
||||
"get_required_mcp_servers",
|
||||
"get_default_thinking_level",
|
||||
"get_all_agent_types",
|
||||
# Base tool lists
|
||||
"BASE_READ_TOOLS",
|
||||
"BASE_WRITE_TOOLS",
|
||||
"WEB_TOOLS",
|
||||
# MCP tool lists
|
||||
"CONTEXT7_TOOLS",
|
||||
"LINEAR_TOOLS",
|
||||
"GRAPHITI_MCP_TOOLS",
|
||||
"ELECTRON_TOOLS",
|
||||
"PUPPETEER_TOOLS",
|
||||
# Auto-Claude tool name constants
|
||||
# Tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Config
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
|
||||
@@ -3,32 +3,12 @@ Tool Models and Constants
|
||||
==========================
|
||||
|
||||
Defines tool name constants and configuration for auto-claude MCP tools.
|
||||
|
||||
This module is the single source of truth for all tool definitions used by
|
||||
the Claude Agent SDK client. Tool lists are organized by category:
|
||||
|
||||
- Base tools: Core file operations (Read, Write, Edit, etc.)
|
||||
- Web tools: Documentation and research (WebFetch, WebSearch)
|
||||
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
|
||||
- Auto-Claude tools: Custom build management tools
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# =============================================================================
|
||||
# Base Tools (Built-in Claude Code tools)
|
||||
# =============================================================================
|
||||
|
||||
# Core file operation tools
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# Web tools for documentation lookup and research
|
||||
# Always available to all agents for accessing external information
|
||||
WEB_TOOLS = ["WebFetch", "WebSearch"]
|
||||
|
||||
# =============================================================================
|
||||
# Auto-Claude MCP Tools (Custom build management)
|
||||
# Tool Name Constants
|
||||
# =============================================================================
|
||||
|
||||
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
|
||||
@@ -39,54 +19,8 @@ TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
|
||||
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
|
||||
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
|
||||
# =============================================================================
|
||||
# External MCP Tools
|
||||
# =============================================================================
|
||||
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Browser Automation MCP Tools (QA agents only)
|
||||
# =============================================================================
|
||||
|
||||
# Puppeteer MCP tools for web browser automation
|
||||
# Used for web frontend validation (non-Electron web apps)
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
@@ -102,7 +36,6 @@ PUPPETEER_TOOLS = [
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
@@ -110,6 +43,10 @@ ELECTRON_TOOLS = [
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Base tools available to all agents
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
@@ -124,387 +61,3 @@ def is_electron_mcp_enabled() -> bool:
|
||||
via Chrome DevTools Protocol on the configured debug port.
|
||||
"""
|
||||
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Configuration Registry
|
||||
# =============================================================================
|
||||
# Single source of truth for phase → tools → MCP servers mapping.
|
||||
# This enables phase-aware tool control and context window optimization.
|
||||
|
||||
AGENT_CONFIGS = {
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SPEC CREATION PHASES (Minimal tools, fast startup)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"spec_gatherer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [], # No MCP needed - just reads project
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_researcher": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # Needs docs lookup
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_writer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [], # Just writes spec.md
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_critic": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "ultrathink",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_context": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_compaction": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# BUILD PHASES (Full tools + Graphiti memory)
|
||||
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"planner": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"], # Only if project setting enabled
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"coder": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"qa_fixer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# UTILITY PHASES (Minimal, no MCP)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"insights": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"commit_message": {
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_orchestrator_parallel": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_parallel": {
|
||||
"tools": BASE_READ_TOOLS
|
||||
+ WEB_TOOLS, # Read-only for parallel followup reviewer
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"batch_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"batch_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ROADMAP & IDEATION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"roadmap_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # WebSearch for competitor research
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"ideation": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Config Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_agent_config(agent_type: str) -> dict:
|
||||
"""
|
||||
Get full configuration for an agent type.
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
|
||||
Returns:
|
||||
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
|
||||
"""
|
||||
if agent_type not in AGENT_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Unknown agent type: '{agent_type}'. "
|
||||
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
|
||||
)
|
||||
return AGENT_CONFIGS[agent_type]
|
||||
|
||||
|
||||
def _map_mcp_server_name(
|
||||
name: str, custom_server_ids: list[str] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Map user-friendly MCP server names to internal identifiers.
|
||||
Also accepts custom server IDs directly.
|
||||
|
||||
Args:
|
||||
name: User-provided MCP server name
|
||||
custom_server_ids: List of custom server IDs to accept as-is
|
||||
|
||||
Returns:
|
||||
Internal server identifier or None if not recognized
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
mappings = {
|
||||
"context7": "context7",
|
||||
"graphiti-memory": "graphiti",
|
||||
"graphiti": "graphiti",
|
||||
"linear": "linear",
|
||||
"electron": "electron",
|
||||
"puppeteer": "puppeteer",
|
||||
"auto-claude": "auto-claude",
|
||||
}
|
||||
# Check if it's a known mapping
|
||||
mapped = mappings.get(name.lower().strip())
|
||||
if mapped:
|
||||
return mapped
|
||||
# Check if it's a custom server ID (accept as-is)
|
||||
if custom_server_ids and name in custom_server_ids:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def get_required_mcp_servers(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get MCP servers required for this agent type.
|
||||
|
||||
Handles dynamic server selection:
|
||||
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
|
||||
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
|
||||
- "graphiti" → only if GRAPHITI_MCP_URL is set
|
||||
- Respects per-project MCP config overrides from .auto-claude/.env
|
||||
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
|
||||
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Returns:
|
||||
List of MCP server names to start
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
servers = list(config.get("mcp_servers", []))
|
||||
|
||||
# Load per-project config (or use defaults)
|
||||
if mcp_config is None:
|
||||
mcp_config = {}
|
||||
|
||||
# Filter context7 if explicitly disabled by project config
|
||||
if "context7" in servers:
|
||||
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
|
||||
if str(context7_enabled).lower() == "false":
|
||||
servers = [s for s in servers if s != "context7"]
|
||||
|
||||
# Handle optional servers (e.g., Linear if project setting enabled)
|
||||
optional = config.get("mcp_servers_optional", [])
|
||||
if "linear" in optional and linear_enabled:
|
||||
# Also check per-project LINEAR_MCP_ENABLED override
|
||||
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
|
||||
if str(linear_mcp_enabled).lower() != "false":
|
||||
servers.append("linear")
|
||||
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type and config
|
||||
if "browser" in servers:
|
||||
servers = [s for s in servers if s != "browser"]
|
||||
if project_capabilities:
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Check per-project overrides (default false for both)
|
||||
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
|
||||
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
|
||||
|
||||
# Electron: enabled by project config OR global env var
|
||||
if is_electron and (
|
||||
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
|
||||
):
|
||||
servers.append("electron")
|
||||
# Puppeteer: enabled by project config (no global env var)
|
||||
elif is_web_frontend and not is_electron:
|
||||
if str(puppeteer_enabled).lower() == "true":
|
||||
servers.append("puppeteer")
|
||||
|
||||
# Filter graphiti if not enabled
|
||||
if "graphiti" in servers:
|
||||
if not os.environ.get("GRAPHITI_MCP_URL"):
|
||||
servers = [s for s in servers if s != "graphiti"]
|
||||
|
||||
# ========== Apply per-agent MCP overrides ==========
|
||||
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
|
||||
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
|
||||
add_key = f"AGENT_MCP_{agent_type}_ADD"
|
||||
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
|
||||
|
||||
# Extract custom server IDs for mapping (allows custom servers to be recognized)
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
|
||||
|
||||
# Process additions
|
||||
if add_key in mcp_config:
|
||||
additions = [
|
||||
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in additions:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped not in servers:
|
||||
servers.append(mapped)
|
||||
|
||||
# Process removals (but never remove auto-claude)
|
||||
if remove_key in mcp_config:
|
||||
removals = [
|
||||
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in removals:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
|
||||
servers = [s for s in servers if s != mapped]
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def get_default_thinking_level(agent_type: str) -> str:
|
||||
"""
|
||||
Get default thinking level string for agent type.
|
||||
|
||||
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
|
||||
To convert to tokens, use phase_config.get_thinking_budget(level).
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -8,30 +8,26 @@ pollution and accidental misuse.
|
||||
Supports dynamic tool filtering based on project capabilities to optimize
|
||||
context window usage. For example, Electron tools are only included for
|
||||
Electron projects, not for Next.js or CLI projects.
|
||||
|
||||
This module now uses AGENT_CONFIGS from models.py as the single source of truth
|
||||
for tool permissions. The get_allowed_tools() function remains the primary API
|
||||
for backwards compatibility.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
AGENT_CONFIGS,
|
||||
CONTEXT7_TOOLS,
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
get_agent_config,
|
||||
get_required_mcp_servers,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .registry import is_tools_available
|
||||
|
||||
|
||||
def get_allowed_tools(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get the list of allowed tools for a specific agent type.
|
||||
@@ -39,82 +35,107 @@ def get_allowed_tools(
|
||||
This ensures each agent only sees tools relevant to their role,
|
||||
preventing context pollution and accidental misuse.
|
||||
|
||||
Uses AGENT_CONFIGS as the single source of truth for tool permissions.
|
||||
Dynamic MCP tools are added based on project capabilities and required servers.
|
||||
When project_capabilities is provided, MCP tools are filtered based on
|
||||
the project type. For example:
|
||||
- Electron projects get Electron MCP tools
|
||||
- Web frontends (non-Electron) get Puppeteer MCP tools
|
||||
- CLI projects get neither
|
||||
|
||||
Args:
|
||||
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
agent_type: One of 'planner', 'coder', 'qa_reviewer', 'qa_fixer'
|
||||
project_capabilities: Optional dict from detect_project_capabilities()
|
||||
containing flags like is_electron, is_web_frontend, etc.
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
|
||||
Returns:
|
||||
List of allowed tool names
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
# Auto-claude tool mappings by agent type
|
||||
tool_mappings = {
|
||||
"planner": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
},
|
||||
"coder": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"qa_reviewer": {
|
||||
"base": BASE_READ_TOOLS + ["Bash"], # Can run tests but not edit
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"qa_fixer": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Start with base tools from config
|
||||
tools = list(config.get("tools", []))
|
||||
if agent_type not in tool_mappings:
|
||||
# Default to coder tools
|
||||
agent_type = "coder"
|
||||
|
||||
# Get required MCP servers for this agent
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
mapping = tool_mappings[agent_type]
|
||||
tools = mapping["base"] + mapping["auto_claude"]
|
||||
|
||||
# Add auto-claude tools ONLY if the MCP server is available
|
||||
# This prevents allowing tools that won't work because the server isn't running
|
||||
if "auto-claude" in required_servers and is_tools_available():
|
||||
tools.extend(config.get("auto_claude_tools", []))
|
||||
|
||||
# Add MCP tool names based on required servers
|
||||
tools.extend(_get_mcp_tools_for_servers(required_servers))
|
||||
# Add MCP tools for QA agents only, based on project capabilities
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
tools.extend(_get_qa_mcp_tools(project_capabilities))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
|
||||
def _get_qa_mcp_tools(project_capabilities: dict | None) -> list[str]:
|
||||
"""
|
||||
Get the list of MCP tools for a list of required servers.
|
||||
Get the list of MCP tools for QA agents based on project capabilities.
|
||||
|
||||
Maps server names to their corresponding tool lists.
|
||||
This function determines which MCP tools to include based on:
|
||||
1. Project type detection (Electron, web frontend, etc.)
|
||||
2. Environment variables (ELECTRON_MCP_ENABLED)
|
||||
|
||||
Args:
|
||||
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
|
||||
Returns:
|
||||
List of MCP tool names for all specified servers
|
||||
List of MCP tool names to include
|
||||
"""
|
||||
tools = []
|
||||
|
||||
for server in servers:
|
||||
if server == "context7":
|
||||
tools.extend(CONTEXT7_TOOLS)
|
||||
elif server == "linear":
|
||||
tools.extend(LINEAR_TOOLS)
|
||||
elif server == "graphiti":
|
||||
tools.extend(GRAPHITI_MCP_TOOLS)
|
||||
elif server == "electron":
|
||||
# If no capabilities provided, fall back to legacy behavior
|
||||
# (check env var only)
|
||||
if project_capabilities is None:
|
||||
if is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
elif server == "puppeteer":
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
# auto-claude tools are already added via config["auto_claude_tools"]
|
||||
return tools
|
||||
|
||||
# Project-capability-based tool selection
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Electron projects get Electron MCP tools (if enabled)
|
||||
if is_electron and is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
|
||||
# Web frontends (non-Electron) get Puppeteer tools
|
||||
# Puppeteer is always available, no env var check needed
|
||||
if is_web_frontend and not is_electron:
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_all_agent_types() -> list[str]:
|
||||
"""
|
||||
Get all registered agent types.
|
||||
|
||||
Returns:
|
||||
Sorted list of all agent type identifiers
|
||||
"""
|
||||
return sorted(AGENT_CONFIGS.keys())
|
||||
|
||||
@@ -4,16 +4,9 @@ Session Memory Tools
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -26,108 +19,6 @@ except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _save_to_graphiti_async(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (async implementation).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if Graphiti is enabled
|
||||
from graphiti_config import is_graphiti_enabled
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
return False
|
||||
|
||||
from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
try:
|
||||
if save_type == "discovery":
|
||||
# Save as codebase discovery
|
||||
# Format: {file_path: description}
|
||||
result = await memory.save_codebase_discoveries(
|
||||
{data["file_path"]: data["description"]}
|
||||
)
|
||||
elif save_type == "gotcha":
|
||||
# Save as gotcha
|
||||
gotcha_text = data["gotcha"]
|
||||
if data.get("context"):
|
||||
gotcha_text += f" (Context: {data['context']})"
|
||||
result = await memory.save_gotcha(gotcha_text)
|
||||
elif save_type == "pattern":
|
||||
# Save as pattern
|
||||
result = await memory.save_pattern(data["pattern"])
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug(f"Graphiti not available for memory tools: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_to_graphiti_sync(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (synchronous wrapper for sync contexts only).
|
||||
|
||||
NOTE: This should only be called from synchronous code. For async callers,
|
||||
use _save_to_graphiti_async() directly to ensure proper resource cleanup.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context - caller should use _save_to_graphiti_async
|
||||
# Log a warning and return False to avoid the resource leak bug
|
||||
logger.warning(
|
||||
"_save_to_graphiti_sync called from async context. "
|
||||
"Use _save_to_graphiti_async instead for proper cleanup."
|
||||
)
|
||||
return False
|
||||
except RuntimeError:
|
||||
# No running loop - safe to create one
|
||||
return asyncio.run(
|
||||
_save_to_graphiti_async(spec_dir, project_dir, save_type, data)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
@@ -154,7 +45,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
"""Record a discovery to the codebase map."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
@@ -163,10 +54,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file) as f:
|
||||
@@ -188,23 +77,11 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
with open(codebase_map_file, "w") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"discovery",
|
||||
{
|
||||
"file_path": file_path,
|
||||
"description": f"[{category}] {description}",
|
||||
},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}{storage_note}",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -225,7 +102,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
"""Record a gotcha to session memory."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
@@ -233,10 +110,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
@@ -251,20 +126,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
)
|
||||
f.write(entry)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"gotcha",
|
||||
{"gotcha": gotcha, "context": context},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"}
|
||||
]
|
||||
}
|
||||
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
|
||||
+38
-103
@@ -8,38 +8,40 @@ Helper functions for git operations, plan management, and file syncing.
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_latest_commit(project_dir: Path) -> str | None:
|
||||
"""Get the hash of the latest git commit."""
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def get_commit_count(project_dir: Path) -> int:
|
||||
"""Get the total number of commits."""
|
||||
result = run_git(
|
||||
["rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
@@ -72,32 +74,16 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
Sync implementation_plan.json from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
When running in isolated mode (worktrees), the agent updates the implementation
|
||||
plan inside the worktree. This function syncs those changes back to the main
|
||||
project's spec directory so the frontend/UI can see the progress.
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
spec_dir: Current spec directory (may be inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
@@ -114,68 +100,17 @@ def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
synced_any = False
|
||||
# Sync the implementation plan
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return False
|
||||
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
source_plan_file = source_spec_dir / "implementation_plan.json"
|
||||
|
||||
try:
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
shutil.copy2(plan_file, source_plan_file)
|
||||
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
logger.warning(f"Failed to sync implementation plan to source: {e}")
|
||||
return False
|
||||
|
||||
@@ -75,15 +75,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
content = self._read_file("Cargo.toml")
|
||||
self._detect_rust_framework(content)
|
||||
|
||||
# Swift/iOS detection (check BEFORE Ruby - iOS projects often have Gemfile for CocoaPods/Fastlane)
|
||||
elif self._exists("Package.swift") or any(self.path.glob("*.xcodeproj")):
|
||||
self.analysis["language"] = "Swift"
|
||||
if self._exists("Package.swift"):
|
||||
self.analysis["package_manager"] = "Swift Package Manager"
|
||||
else:
|
||||
self.analysis["package_manager"] = "Xcode"
|
||||
self._detect_swift_framework()
|
||||
|
||||
# Ruby detection
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["language"] = "Ruby"
|
||||
@@ -299,115 +290,12 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
if "sidekiq" in content.lower():
|
||||
self.analysis["task_queue"] = "Sidekiq"
|
||||
|
||||
def _detect_swift_framework(self) -> None:
|
||||
"""Detect Swift/iOS framework and dependencies."""
|
||||
try:
|
||||
# Scan Swift files for imports, excluding hidden/vendor dirs
|
||||
swift_files = []
|
||||
for swift_file in self.path.rglob("*.swift"):
|
||||
# Skip hidden directories, node_modules, .worktrees, etc.
|
||||
if any(
|
||||
part.startswith(".") or part in ("node_modules", "Pods", "Carthage")
|
||||
for part in swift_file.parts
|
||||
):
|
||||
continue
|
||||
swift_files.append(swift_file)
|
||||
if len(swift_files) >= 50: # Limit for performance
|
||||
break
|
||||
|
||||
imports = set()
|
||||
for swift_file in swift_files:
|
||||
try:
|
||||
content = swift_file.read_text(encoding="utf-8", errors="ignore")
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("import "):
|
||||
module = line.replace("import ", "").split()[0]
|
||||
imports.add(module)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Detect UI framework
|
||||
if "SwiftUI" in imports:
|
||||
self.analysis["framework"] = "SwiftUI"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "UIKit" in imports:
|
||||
self.analysis["framework"] = "UIKit"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "AppKit" in imports:
|
||||
self.analysis["framework"] = "AppKit"
|
||||
self.analysis["type"] = "desktop"
|
||||
|
||||
# Detect iOS/Apple frameworks
|
||||
apple_frameworks = []
|
||||
framework_map = {
|
||||
"Combine": "Combine",
|
||||
"CoreData": "CoreData",
|
||||
"MapKit": "MapKit",
|
||||
"WidgetKit": "WidgetKit",
|
||||
"CoreLocation": "CoreLocation",
|
||||
"StoreKit": "StoreKit",
|
||||
"CloudKit": "CloudKit",
|
||||
"ActivityKit": "ActivityKit",
|
||||
"UserNotifications": "UserNotifications",
|
||||
}
|
||||
for key, name in framework_map.items():
|
||||
if key in imports:
|
||||
apple_frameworks.append(name)
|
||||
|
||||
if apple_frameworks:
|
||||
self.analysis["apple_frameworks"] = apple_frameworks
|
||||
|
||||
# Detect SPM dependencies from Package.swift or xcodeproj
|
||||
dependencies = self._detect_spm_dependencies()
|
||||
if dependencies:
|
||||
self.analysis["spm_dependencies"] = dependencies
|
||||
except Exception:
|
||||
# Silently fail if Swift detection has issues
|
||||
pass
|
||||
|
||||
def _detect_spm_dependencies(self) -> list[str]:
|
||||
"""Detect Swift Package Manager dependencies."""
|
||||
dependencies = []
|
||||
|
||||
# Try Package.swift first
|
||||
if self._exists("Package.swift"):
|
||||
content = self._read_file("Package.swift")
|
||||
# Look for .package(url: "...", patterns
|
||||
import re
|
||||
|
||||
urls = re.findall(r'\.package\s*\([^)]*url:\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
# Extract package name from URL
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name:
|
||||
dependencies.append(name)
|
||||
|
||||
# Also check xcodeproj for XCRemoteSwiftPackageReference
|
||||
for xcodeproj in self.path.glob("*.xcodeproj"):
|
||||
pbxproj = xcodeproj / "project.pbxproj"
|
||||
if pbxproj.exists():
|
||||
try:
|
||||
content = pbxproj.read_text(encoding="utf-8", errors="ignore")
|
||||
import re
|
||||
|
||||
# Match repositoryURL patterns
|
||||
urls = re.findall(r'repositoryURL\s*=\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name and name not in dependencies:
|
||||
dependencies.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return dependencies
|
||||
|
||||
def _detect_node_package_manager(self) -> str:
|
||||
"""Detect Node.js package manager."""
|
||||
if self._exists("pnpm-lock.yaml"):
|
||||
return "pnpm"
|
||||
elif self._exists("yarn.lock"):
|
||||
return "yarn"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
elif self._exists("bun.lockb"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
|
||||
@@ -366,19 +366,19 @@ async def run_insight_extraction(
|
||||
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
|
||||
|
||||
try:
|
||||
# Use simple_client for insight extraction
|
||||
from pathlib import Path
|
||||
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="insights",
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
cwd=Path(cwd) if cwd else None,
|
||||
# Create a minimal SDK client for insight extraction
|
||||
# No tools needed - just text generation
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
allowed_tools=[], # No tools needed for extraction
|
||||
max_turns=1, # Single turn extraction
|
||||
cwd=cwd,
|
||||
)
|
||||
)
|
||||
|
||||
# Use async context manager
|
||||
@@ -387,40 +387,12 @@ async def run_insight_extraction(
|
||||
|
||||
# Collect the response
|
||||
response_text = ""
|
||||
message_count = 0
|
||||
text_blocks_found = 0
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
text_blocks_found += 1
|
||||
if block.text: # Only add non-empty text
|
||||
response_text += block.text
|
||||
else:
|
||||
logger.debug(
|
||||
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
||||
)
|
||||
|
||||
# Log response collection summary
|
||||
logger.debug(
|
||||
f"Insight extraction response: {message_count} messages, "
|
||||
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
||||
)
|
||||
|
||||
# Validate we received content before parsing
|
||||
if not response_text.strip():
|
||||
logger.warning(
|
||||
f"Insight extraction returned empty response. "
|
||||
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
||||
f"This may indicate the AI model did not respond with text content."
|
||||
)
|
||||
return None
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
# Parse JSON from response
|
||||
return parse_insights(response_text)
|
||||
@@ -443,11 +415,6 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Try to extract JSON from the response
|
||||
text = response_text.strip()
|
||||
|
||||
# Early validation - check for empty response
|
||||
if not text:
|
||||
logger.warning("Cannot parse insights: response text is empty")
|
||||
return None
|
||||
|
||||
# Handle markdown code blocks
|
||||
if text.startswith("```"):
|
||||
# Remove code block markers
|
||||
@@ -455,26 +422,17 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Remove first line (```json or ```)
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's ```
|
||||
# Remove last line if it's ``
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
# Check again after removing code blocks
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Cannot parse insights: response contained only markdown code block markers with no content"
|
||||
)
|
||||
return None
|
||||
text = "\n".join(lines)
|
||||
|
||||
try:
|
||||
insights = json.loads(text)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(insights, dict):
|
||||
logger.warning(
|
||||
f"Insights is not a dict, got type: {type(insights).__name__}"
|
||||
)
|
||||
logger.warning("Insights is not a dict")
|
||||
return None
|
||||
|
||||
# Ensure required keys exist with defaults
|
||||
@@ -488,13 +446,7 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse insights JSON: {e}")
|
||||
# Show more context in the error message
|
||||
preview_length = min(500, len(text))
|
||||
logger.warning(
|
||||
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
||||
)
|
||||
if len(text) > preview_length:
|
||||
logger.warning(f"... (total length: {len(text)} chars)")
|
||||
logger.debug(f"Response text was: {text[:500]}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,8 +6,6 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -186,7 +184,7 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir = Path(project_dir) / ".worktrees"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
@@ -211,56 +209,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" └─ .worktrees/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
|
||||
@@ -79,7 +79,7 @@ def handle_build_command(
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from agent import run_autonomous_agent, sync_plan_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
@@ -274,7 +274,7 @@ def handle_build_command(
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
|
||||
+23
-47
@@ -15,6 +15,10 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from ui import (
|
||||
Icons,
|
||||
icon,
|
||||
)
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
@@ -38,7 +42,6 @@ from .utils import (
|
||||
)
|
||||
from .workspace_commands import (
|
||||
handle_cleanup_worktrees_command,
|
||||
handle_create_pr_command,
|
||||
handle_discard_command,
|
||||
handle_list_worktrees_command,
|
||||
handle_merge_command,
|
||||
@@ -154,30 +157,6 @@ Environment Variables:
|
||||
action="store_true",
|
||||
help="Discard an existing build (requires confirmation)",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--create-pr",
|
||||
action="store_true",
|
||||
help="Push branch and create a GitHub Pull Request",
|
||||
)
|
||||
|
||||
# PR options
|
||||
parser.add_argument(
|
||||
"--pr-target",
|
||||
type=str,
|
||||
metavar="BRANCH",
|
||||
help="With --create-pr: target branch for PR (default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-title",
|
||||
type=str,
|
||||
metavar="TITLE",
|
||||
help="With --create-pr: custom PR title (default: generated from spec name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-draft",
|
||||
action="store_true",
|
||||
help="With --create-pr: create as draft PR",
|
||||
)
|
||||
|
||||
# Merge options
|
||||
parser.add_argument(
|
||||
@@ -222,6 +201,13 @@ Environment Variables:
|
||||
help="Show human review/approval status for a spec",
|
||||
)
|
||||
|
||||
# Dev mode (deprecated)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
action="store_true",
|
||||
help="[Deprecated] No longer has any effect - kept for compatibility",
|
||||
)
|
||||
|
||||
# Non-interactive mode (for UI/automation)
|
||||
parser.add_argument(
|
||||
"--auto-continue",
|
||||
@@ -301,14 +287,19 @@ def main() -> None:
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
# Get model (with env var fallback)
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL", DEFAULT_MODEL)
|
||||
|
||||
# Note: --dev flag is deprecated but kept for API compatibility
|
||||
if args.dev:
|
||||
print(
|
||||
f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now use .auto-claude/specs/\n"
|
||||
)
|
||||
|
||||
# Handle --list command
|
||||
if args.list:
|
||||
print_banner()
|
||||
print_specs_list(project_dir)
|
||||
print_specs_list(project_dir, args.dev)
|
||||
return
|
||||
|
||||
# Handle --list-worktrees command
|
||||
@@ -346,14 +337,14 @@ def main() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
# Find the spec
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec)
|
||||
spec_dir = find_spec(project_dir, args.spec)
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec, dev_mode=args.dev)
|
||||
spec_dir = find_spec(project_dir, args.spec, args.dev)
|
||||
if not spec_dir:
|
||||
debug_error("run.py", "Spec not found", spec=args.spec)
|
||||
print_banner()
|
||||
print(f"\nError: Spec '{args.spec}' not found")
|
||||
print("\nAvailable specs:")
|
||||
print_specs_list(project_dir)
|
||||
print_specs_list(project_dir, args.dev)
|
||||
sys.exit(1)
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
@@ -390,21 +381,6 @@ def main() -> None:
|
||||
handle_discard_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
if args.create_pr:
|
||||
# Pass args.pr_target directly - WorktreeManager._detect_base_branch
|
||||
# handles base branch detection internally when target_branch is None
|
||||
result = handle_create_pr_command(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_dir.name,
|
||||
target_branch=args.pr_target,
|
||||
title=args.pr_title,
|
||||
draft=args.pr_draft,
|
||||
)
|
||||
# JSON output is already printed by handle_create_pr_command
|
||||
if not result.get("success"):
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Handle QA commands
|
||||
if args.qa_status:
|
||||
handle_qa_status_command(spec_dir)
|
||||
|
||||
@@ -19,17 +19,18 @@ from workspace import get_existing_build_worktree
|
||||
from .utils import get_specs_dir
|
||||
|
||||
|
||||
def list_specs(project_dir: Path) -> list[dict]:
|
||||
def list_specs(project_dir: Path, dev_mode: bool = False) -> list[dict]:
|
||||
"""
|
||||
List all specs in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
|
||||
Returns:
|
||||
List of spec info dicts with keys: number, name, path, status, progress
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs_dir = get_specs_dir(project_dir, dev_mode)
|
||||
specs = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
@@ -92,16 +93,19 @@ def list_specs(project_dir: Path) -> list[dict]:
|
||||
return specs
|
||||
|
||||
|
||||
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
|
||||
def print_specs_list(
|
||||
project_dir: Path, dev_mode: bool = False, auto_create: bool = True
|
||||
) -> None:
|
||||
"""Print a formatted list of all specs.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
auto_create: If True and no specs exist, automatically launch spec creation
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
specs = list_specs(project_dir)
|
||||
specs = list_specs(project_dir, dev_mode)
|
||||
|
||||
if not specs:
|
||||
print("\nNo specs found.")
|
||||
|
||||
+19
-83
@@ -15,47 +15,7 @@ if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.auth import get_auth_token, get_auth_token_source
|
||||
from core.dependency_validator import validate_platform_dependencies
|
||||
|
||||
|
||||
def import_dotenv():
|
||||
"""
|
||||
Import and return load_dotenv with helpful error message if not installed.
|
||||
|
||||
This centralized function ensures consistent error messaging across all
|
||||
runner scripts when python-dotenv is not available.
|
||||
|
||||
Returns:
|
||||
The load_dotenv function
|
||||
|
||||
Raises:
|
||||
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
|
||||
"""
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
|
||||
return _load_dotenv
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Error: Required Python package 'python-dotenv' is not installed.\n"
|
||||
"\n"
|
||||
"This usually means you're not using the virtual environment.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. From the 'apps/backend/' directory, activate the venv:\n"
|
||||
" source .venv/bin/activate # Linux/macOS\n"
|
||||
" .venv\\Scripts\\activate # Windows\n"
|
||||
"\n"
|
||||
"2. Or install dependencies directly:\n"
|
||||
" pip install python-dotenv\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
from dotenv import load_dotenv
|
||||
from graphiti_config import get_graphiti_status
|
||||
from linear_integration import LinearManager
|
||||
from linear_updater import is_linear_enabled
|
||||
@@ -68,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:
|
||||
@@ -94,56 +54,35 @@ def setup_environment() -> Path:
|
||||
return script_dir
|
||||
|
||||
|
||||
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
def find_spec(
|
||||
project_dir: Path, spec_identifier: str, dev_mode: bool = False
|
||||
) -> Path | None:
|
||||
"""
|
||||
Find a spec by number or full name.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_identifier: Either "001" or "001-feature-name"
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
|
||||
Returns:
|
||||
Path to spec folder, or None if not found
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs_dir = get_specs_dir(project_dir, dev_mode)
|
||||
|
||||
if specs_dir.exists():
|
||||
# Try exact match first
|
||||
exact_path = specs_dir / spec_identifier
|
||||
if exact_path.exists() and (exact_path / "spec.md").exists():
|
||||
return exact_path
|
||||
if not specs_dir.exists():
|
||||
return None
|
||||
|
||||
# Try matching by number prefix
|
||||
for spec_folder in specs_dir.iterdir():
|
||||
if spec_folder.is_dir() and spec_folder.name.startswith(
|
||||
spec_identifier + "-"
|
||||
):
|
||||
if (spec_folder / "spec.md").exists():
|
||||
return spec_folder
|
||||
# Try exact match first
|
||||
exact_path = specs_dir / spec_identifier
|
||||
if exact_path.exists() and (exact_path / "spec.md").exists():
|
||||
return exact_path
|
||||
|
||||
# Check worktree specs (for merge-preview, merge, review, discard operations)
|
||||
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if worktree_base.exists():
|
||||
# Try exact match in worktree
|
||||
worktree_spec = (
|
||||
worktree_base / spec_identifier / ".auto-claude" / "specs" / spec_identifier
|
||||
)
|
||||
if worktree_spec.exists() and (worktree_spec / "spec.md").exists():
|
||||
return worktree_spec
|
||||
|
||||
# Try matching by prefix in worktrees
|
||||
for worktree_dir in worktree_base.iterdir():
|
||||
if worktree_dir.is_dir() and worktree_dir.name.startswith(
|
||||
spec_identifier + "-"
|
||||
):
|
||||
spec_in_worktree = (
|
||||
worktree_dir / ".auto-claude" / "specs" / worktree_dir.name
|
||||
)
|
||||
if (
|
||||
spec_in_worktree.exists()
|
||||
and (spec_in_worktree / "spec.md").exists()
|
||||
):
|
||||
return spec_in_worktree
|
||||
# Try matching by number prefix
|
||||
for spec_folder in specs_dir.iterdir():
|
||||
if spec_folder.is_dir() and spec_folder.name.startswith(spec_identifier + "-"):
|
||||
if (spec_folder / "spec.md").exists():
|
||||
return spec_folder
|
||||
|
||||
return None
|
||||
|
||||
@@ -155,9 +94,6 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
Returns:
|
||||
True if valid, False otherwise (with error messages printed)
|
||||
"""
|
||||
# Validate platform-specific dependencies first (exits if missing)
|
||||
validate_platform_dependencies()
|
||||
|
||||
valid = True
|
||||
|
||||
# Check for OAuth token (API keys are not supported)
|
||||
|
||||
@@ -5,7 +5,6 @@ Workspace Commands
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -15,16 +14,7 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.workspace.git_utils import (
|
||||
_is_auto_claude_file,
|
||||
apply_path_mapping,
|
||||
detect_file_renames,
|
||||
get_file_content_from_ref,
|
||||
get_merge_base,
|
||||
is_lock_file,
|
||||
)
|
||||
from core.worktree import PushAndCreatePRResult as CreatePRResult
|
||||
from core.worktree import WorktreeManager
|
||||
from core.workspace.git_utils import _is_auto_claude_file, is_lock_file
|
||||
from debug import debug_warning
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -33,7 +23,6 @@ from ui import (
|
||||
from workspace import (
|
||||
cleanup_all_worktrees,
|
||||
discard_existing_build,
|
||||
get_existing_build_worktree,
|
||||
list_all_worktrees,
|
||||
merge_existing_build,
|
||||
review_existing_build,
|
||||
@@ -71,7 +60,6 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
@@ -83,7 +71,6 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
@@ -96,32 +83,18 @@ def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of files changed by the task (not files changed on base branch).
|
||||
|
||||
Uses merge-base to accurately identify only the files modified in the worktree,
|
||||
not files that changed on the base branch since the worktree was created.
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths (task changes only)
|
||||
List of changed file paths
|
||||
"""
|
||||
try:
|
||||
# First, get the merge-base (the point where the worktree branched)
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Use two-dot diff from merge-base to get only task's changes
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -133,10 +106,10 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff with merge-base failed: returncode={e.returncode}, "
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try direct two-arg diff (less accurate but works)
|
||||
# Fallback: try without the three-dot notation
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
@@ -151,176 +124,12 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (fallback) failed: returncode={e.returncode}, "
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _detect_worktree_base_branch(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
spec_name: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Detect which branch a worktree was created from.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Check worktree config file (.auto-claude/worktree-config.json)
|
||||
2. Find merge-base with known branches (develop, main, master)
|
||||
3. Return None if unable to detect
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
worktree_path: Path to the worktree
|
||||
spec_name: Name of the spec
|
||||
|
||||
Returns:
|
||||
The detected base branch name, or None if unable to detect
|
||||
"""
|
||||
# Strategy 1: Check for worktree config file
|
||||
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text())
|
||||
if config.get("base_branch"):
|
||||
debug(
|
||||
MODULE,
|
||||
f"Found base branch in worktree config: {config['base_branch']}",
|
||||
)
|
||||
return config["base_branch"]
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Failed to read worktree config: {e}")
|
||||
|
||||
# Strategy 2: Find which branch has the closest merge-base
|
||||
# Check common branches: develop, main, master
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
candidate_branches = ["develop", "main", "master"]
|
||||
|
||||
best_branch = None
|
||||
best_commits_behind = float("inf")
|
||||
|
||||
for branch in candidate_branches:
|
||||
try:
|
||||
# Check if branch exists
|
||||
check = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
continue
|
||||
|
||||
# Get merge base
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
continue
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Count commits between merge-base and branch tip
|
||||
# The branch with fewer commits ahead is likely the one we branched from
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_ahead = int(ahead_result.stdout.strip())
|
||||
debug(
|
||||
MODULE,
|
||||
f"Branch {branch} is {commits_ahead} commits ahead of merge-base",
|
||||
)
|
||||
if commits_ahead < best_commits_behind:
|
||||
best_commits_behind = commits_ahead
|
||||
best_branch = branch
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Error checking branch {branch}: {e}")
|
||||
continue
|
||||
|
||||
if best_branch:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})",
|
||||
)
|
||||
return best_branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_parallel_task_conflicts(
|
||||
project_dir: Path,
|
||||
current_task_id: str,
|
||||
current_task_files: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Detect potential conflicts between this task and other active tasks.
|
||||
|
||||
Uses existing evolution data to check if any of this task's files
|
||||
have been modified by other active tasks. This is a lightweight check
|
||||
that doesn't require re-processing all files.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
current_task_id: ID of the current task
|
||||
current_task_files: Files modified by this task (from git diff)
|
||||
|
||||
Returns:
|
||||
List of conflict dictionaries with 'file' and 'tasks' keys
|
||||
"""
|
||||
try:
|
||||
from merge import MergeOrchestrator
|
||||
|
||||
# Initialize orchestrator just to access evolution data
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
# Get all active tasks from evolution data
|
||||
active_tasks = orchestrator.evolution_tracker.get_active_tasks()
|
||||
|
||||
# Remove current task from active tasks
|
||||
other_active_tasks = active_tasks - {current_task_id}
|
||||
|
||||
if not other_active_tasks:
|
||||
return []
|
||||
|
||||
# Convert current task files to a set for fast lookup
|
||||
current_files_set = set(current_task_files)
|
||||
|
||||
# Get files modified by other active tasks
|
||||
conflicts = []
|
||||
other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks(
|
||||
list(other_active_tasks)
|
||||
)
|
||||
|
||||
# Find intersection - files modified by both this task and other tasks
|
||||
for file_path, tasks in other_task_files.items():
|
||||
if file_path in current_files_set:
|
||||
# This file was modified by both current task and other task(s)
|
||||
all_tasks = [current_task_id] + tasks
|
||||
conflicts.append({"file": file_path, "tasks": all_tasks})
|
||||
|
||||
return conflicts
|
||||
|
||||
except Exception as e:
|
||||
# If anything fails, just return empty - parallel task detection is optional
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"Parallel task conflict detection failed: {e}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
@@ -536,9 +345,7 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
@@ -548,7 +355,6 @@ def _check_git_merge_conflicts(
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
base_branch: Branch the task was created from (default: auto-detect)
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
@@ -567,25 +373,21 @@ def _check_git_merge_conflicts(
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": base_branch or "main",
|
||||
"base_branch": "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Use provided base_branch, or detect from current HEAD
|
||||
if not base_branch:
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
else:
|
||||
result["base_branch"] = base_branch
|
||||
debug(MODULE, f"Using provided base branch: {base_branch}")
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
@@ -744,6 +546,7 @@ def handle_merge_preview_command(
|
||||
spec_name=spec_name,
|
||||
)
|
||||
|
||||
from merge import MergeOrchestrator
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
@@ -770,32 +573,16 @@ def handle_merge_preview_command(
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Determine the task's source branch (where the task was created from)
|
||||
# Priority:
|
||||
# 1. Provided base_branch (from task metadata)
|
||||
# 2. Detect from worktree's git history (find which branch it diverged from)
|
||||
# 3. Fall back to default branch detection (main/master)
|
||||
# Use provided base_branch (from task metadata), or fall back to detected default
|
||||
task_source_branch = base_branch
|
||||
if not task_source_branch:
|
||||
# Try to detect from worktree's git history
|
||||
task_source_branch = _detect_worktree_base_branch(
|
||||
project_dir, worktree_path, spec_name
|
||||
)
|
||||
if not task_source_branch:
|
||||
# Fall back to auto-detecting main/master
|
||||
# Auto-detect the default branch (main/master) that worktrees are typically created from
|
||||
task_source_branch = _detect_default_branch(project_dir)
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Using task source branch: {task_source_branch}",
|
||||
provided=base_branch is not None,
|
||||
)
|
||||
|
||||
# Check for git-level conflicts (diverged branches) using the task's source branch
|
||||
git_conflicts = _check_git_merge_conflicts(
|
||||
project_dir, spec_name, base_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
all_changed_files = _get_changed_files_from_git(
|
||||
worktree_path, task_source_branch
|
||||
@@ -806,39 +593,49 @@ def handle_merge_preview_command(
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
|
||||
# For merge-preview, we only need to detect:
|
||||
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
|
||||
# 2. Parallel task conflicts (this task vs other active tasks)
|
||||
#
|
||||
# For parallel task detection, we just check if this task's files overlap
|
||||
# with files OTHER tasks have already recorded - no need to re-process all files.
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
debug(MODULE, "Checking for parallel task conflicts (lightweight)...")
|
||||
|
||||
# Check for parallel task conflicts by looking at existing evolution data
|
||||
parallel_conflicts = _detect_parallel_task_conflicts(
|
||||
project_dir, spec_name, all_changed_files
|
||||
# Initialize the orchestrator
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False, # Don't use AI for preview
|
||||
dry_run=True, # Don't write anything
|
||||
)
|
||||
|
||||
# Refresh evolution data from the worktree
|
||||
# Compare against the task's source branch (where the task was created from)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
|
||||
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
|
||||
f"Refreshing evolution data from worktree: {worktree_path}",
|
||||
task_source_branch=task_source_branch,
|
||||
)
|
||||
orchestrator.evolution_tracker.refresh_from_git(
|
||||
spec_name, worktree_path, target_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Build conflict list - start with parallel task conflicts
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
conflicts = []
|
||||
for pc in parallel_conflicts:
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(
|
||||
MODULE,
|
||||
"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"),
|
||||
)
|
||||
conflicts.append(
|
||||
{
|
||||
"file": pc["file"],
|
||||
"location": "file-level",
|
||||
"tasks": pc["tasks"],
|
||||
"severity": "medium",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
|
||||
"type": "parallel",
|
||||
"file": c.get("file", ""),
|
||||
"location": c.get("location", ""),
|
||||
"tasks": c.get("tasks", []),
|
||||
"severity": c.get("severity", "unknown"),
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -865,14 +662,13 @@ def handle_merge_preview_command(
|
||||
}
|
||||
)
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
# Count only non-lock-file conflicts
|
||||
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
||||
lock_files_excluded
|
||||
)
|
||||
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
|
||||
parallel_conflict_count = len(parallel_conflicts)
|
||||
total_conflicts = git_conflict_count + parallel_conflict_count
|
||||
conflict_files = git_conflict_count + parallel_conflict_count
|
||||
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
|
||||
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
|
||||
|
||||
# Filter lock files from the git conflicts list for the response
|
||||
non_lock_conflicting_files = [
|
||||
@@ -884,58 +680,6 @@ def handle_merge_preview_command(
|
||||
# but we want to show the user all files that will be merged
|
||||
total_files_from_git = len(all_changed_files)
|
||||
|
||||
# Detect files that need AI merge due to path mappings (file renames)
|
||||
# This happens when the target branch has renamed/moved files that the
|
||||
# worktree modified at their old locations
|
||||
path_mapped_ai_merges: list[dict] = []
|
||||
path_mappings: dict[str, str] = {}
|
||||
|
||||
if git_conflicts["needs_rebase"] and git_conflicts["commits_behind"] > 0:
|
||||
# Get the merge-base between the branches
|
||||
spec_branch = git_conflicts["spec_branch"]
|
||||
base_branch = git_conflicts["base_branch"]
|
||||
merge_base = get_merge_base(project_dir, spec_branch, base_branch)
|
||||
|
||||
if merge_base:
|
||||
# Detect file renames between merge-base and current base branch
|
||||
path_mappings = detect_file_renames(
|
||||
project_dir, merge_base, base_branch
|
||||
)
|
||||
|
||||
if path_mappings:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected {len(path_mappings)} file rename(s) between merge-base and target",
|
||||
sample_mappings={
|
||||
k: v for k, v in list(path_mappings.items())[:3]
|
||||
},
|
||||
)
|
||||
|
||||
# Check which changed files have path mappings and need AI merge
|
||||
for file_path in all_changed_files:
|
||||
mapped_path = apply_path_mapping(file_path, path_mappings)
|
||||
if mapped_path != file_path:
|
||||
# File was renamed - check if both versions exist
|
||||
worktree_content = get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
target_content = get_file_content_from_ref(
|
||||
project_dir, base_branch, mapped_path
|
||||
)
|
||||
|
||||
if worktree_content and target_content:
|
||||
path_mapped_ai_merges.append(
|
||||
{
|
||||
"oldPath": file_path,
|
||||
"newPath": mapped_path,
|
||||
"reason": "File was renamed/moved and modified in both branches",
|
||||
}
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Path-mapped file needs AI merge: {file_path} -> {mapped_path}",
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
# Use git diff files as the authoritative list of files to merge
|
||||
@@ -949,20 +693,15 @@ def handle_merge_preview_command(
|
||||
"commitsBehind": git_conflicts["commits_behind"],
|
||||
"baseBranch": git_conflicts["base_branch"],
|
||||
"specBranch": git_conflicts["spec_branch"],
|
||||
# Path-mapped files that need AI merge due to renames
|
||||
"pathMappedAIMerges": path_mapped_ai_merges,
|
||||
"totalRenames": len(path_mappings),
|
||||
},
|
||||
"summary": {
|
||||
# Use git diff count, not semantic tracker count
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
"pathMappedAIMergeCount": len(path_mapped_ai_merges),
|
||||
},
|
||||
# Include lock files info so UI can optionally show them
|
||||
"lockFilesExcluded": lock_files_excluded,
|
||||
@@ -973,11 +712,10 @@ def handle_merge_preview_command(
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
parallel_conflicts=parallel_conflict_count,
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -998,223 +736,5 @@ def handle_merge_preview_command(
|
||||
"conflictFiles": 0,
|
||||
"totalConflicts": 0,
|
||||
"autoMergeable": 0,
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_create_pr_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> CreatePRResult:
|
||||
"""
|
||||
Handle the --create-pr command: push branch and create a GitHub PR.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
spec_name: Name of the spec (e.g., "001-feature-name")
|
||||
target_branch: Target branch for PR (defaults to base branch)
|
||||
title: Custom PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
|
||||
Returns:
|
||||
CreatePRResult with success status, pr_url, and any errors
|
||||
"""
|
||||
from core.worktree import WorktreeManager
|
||||
|
||||
print_banner()
|
||||
print("\n" + "=" * 70)
|
||||
print(" CREATE PULL REQUEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if worktree exists
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if not worktree_path:
|
||||
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
|
||||
print("\nA completed build worktree is required to create a PR.")
|
||||
print("Run your build first, then use --create-pr.")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": "No build found for this spec",
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Create worktree manager
|
||||
manager = WorktreeManager(project_dir, base_branch=target_branch)
|
||||
|
||||
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
|
||||
print(f" Spec: {spec_name}")
|
||||
print(f" Target: {target_branch or manager.base_branch}")
|
||||
if title:
|
||||
print(f" Title: {title}")
|
||||
if draft:
|
||||
print(" Mode: Draft PR")
|
||||
|
||||
# Push and create PR with exception handling for clean JSON output
|
||||
try:
|
||||
raw_result = manager.push_and_create_pr(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Exception during PR creation: {e}")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Failed to create PR",
|
||||
}
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
|
||||
print(json.dumps(error_result))
|
||||
return error_result
|
||||
|
||||
# Convert PushAndCreatePRResult to CreatePRResult
|
||||
result: CreatePRResult = {
|
||||
"success": raw_result.get("success", False),
|
||||
"pr_url": raw_result.get("pr_url"),
|
||||
"already_exists": raw_result.get("already_exists", False),
|
||||
"error": raw_result.get("error"),
|
||||
"message": raw_result.get("message"),
|
||||
"pushed": raw_result.get("pushed", False),
|
||||
"remote": raw_result.get("remote", ""),
|
||||
"branch": raw_result.get("branch", ""),
|
||||
}
|
||||
|
||||
if result.get("success"):
|
||||
pr_url = result.get("pr_url")
|
||||
already_exists = result.get("already_exists", False)
|
||||
|
||||
if already_exists:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
|
||||
else:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
|
||||
|
||||
if pr_url:
|
||||
print(f"\n{icon(Icons.LINK)} {pr_url}")
|
||||
else:
|
||||
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
|
||||
|
||||
print("\nNext steps:")
|
||||
print(" 1. Review the PR on GitHub")
|
||||
print(" 2. Request reviews from your team")
|
||||
print(" 3. Merge when approved")
|
||||
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
else:
|
||||
error = result.get("error", "Unknown error")
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_old_worktrees_command(
|
||||
project_dir: Path, days: int = 30, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Clean up old worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
days: Number of days threshold (default: 30)
|
||||
dry_run: If True, only show what would be removed (default: False)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
removed, failed = manager.cleanup_old_worktrees(
|
||||
days_threshold=days, dry_run=dry_run
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"days_threshold": days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"removed": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def worktree_summary_command(project_dir: Path) -> dict:
|
||||
"""
|
||||
Get a summary of all worktrees with age information.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Dictionary with worktree summary data
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
# Print to console for CLI usage
|
||||
manager.print_worktree_summary()
|
||||
|
||||
# Also return data for programmatic access
|
||||
worktrees = manager.list_all_worktrees()
|
||||
warning = manager.get_worktree_count_warning()
|
||||
|
||||
# Categorize by age
|
||||
recent = []
|
||||
week_old = []
|
||||
month_old = []
|
||||
very_old = []
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
data = {
|
||||
"spec_name": info.spec_name,
|
||||
"days_since_last_commit": info.days_since_last_commit,
|
||||
"commit_count": info.commit_count,
|
||||
}
|
||||
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(data)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(data)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(data)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(data)
|
||||
else:
|
||||
very_old.append(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_worktrees": len(worktrees),
|
||||
"categories": {
|
||||
"recent": recent,
|
||||
"week_old": week_old,
|
||||
"month_old": month_old,
|
||||
"very_old": very_old,
|
||||
"unknown_age": unknown_age,
|
||||
},
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"total_worktrees": 0,
|
||||
"categories": {},
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
@@ -186,15 +186,9 @@ Fixes #N (if applicable)"""
|
||||
return prompt
|
||||
|
||||
|
||||
async def _call_claude(prompt: str) -> str:
|
||||
"""Call Claude for commit message generation.
|
||||
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
"""
|
||||
async def _call_claude_haiku(prompt: str) -> str:
|
||||
"""Call Claude Haiku with low thinking for fast commit message generation."""
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found")
|
||||
@@ -203,23 +197,19 @@ async def _call_claude(prompt: str) -> str:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available")
|
||||
logger.warning("claude_agent_sdk not installed")
|
||||
return ""
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Commit message using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="commit_message",
|
||||
model=model,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -231,9 +221,7 @@ async def _call_claude(prompt: str) -> str:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
@@ -299,9 +287,11 @@ def generate_commit_message_sync(
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
|
||||
result = pool.submit(
|
||||
lambda: asyncio.run(_call_claude_haiku(prompt))
|
||||
).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude(prompt))
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
@@ -363,7 +353,7 @@ async def generate_commit_message(
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude(prompt)
|
||||
result = await _call_claude_haiku(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@@ -39,7 +39,7 @@ from agents import (
|
||||
run_followup_planner,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
# Ensure all exports are available at module level
|
||||
@@ -57,7 +57,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
+17
-164
@@ -23,50 +23,31 @@ 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",
|
||||
"API_TIMEOUT_MS",
|
||||
# Windows-specific: Git Bash path for Claude Code CLI
|
||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
||||
]
|
||||
|
||||
|
||||
def get_token_from_keychain() -> str | None:
|
||||
"""
|
||||
Get authentication token from system credential store.
|
||||
Get authentication token from macOS Keychain.
|
||||
|
||||
Reads Claude Code credentials from:
|
||||
- macOS: Keychain
|
||||
- Windows: Credential Manager
|
||||
- Linux: Not yet supported (use env var)
|
||||
Reads Claude Code credentials from macOS Keychain and extracts the OAuth token.
|
||||
Only works on macOS (Darwin platform).
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
Token string if found in Keychain, None otherwise
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
return _get_token_from_macos_keychain()
|
||||
elif system == "Windows":
|
||||
return _get_token_from_windows_credential_files()
|
||||
else:
|
||||
# Linux: secret-service not yet implemented
|
||||
# Only attempt on macOS
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_macos_keychain() -> str | None:
|
||||
"""Get token from macOS Keychain."""
|
||||
try:
|
||||
# Query macOS Keychain for Claude Code credentials
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/security",
|
||||
@@ -83,11 +64,14 @@ def _get_token_from_macos_keychain() -> str | None:
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
# Parse JSON response
|
||||
credentials_json = result.stdout.strip()
|
||||
if not credentials_json:
|
||||
return None
|
||||
|
||||
data = json.loads(credentials_json)
|
||||
|
||||
# Extract OAuth token from nested structure
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
|
||||
if not token:
|
||||
@@ -100,45 +84,18 @@ def _get_token_from_macos_keychain() -> str | None:
|
||||
return token
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_windows_credential_files() -> str | None:
|
||||
"""Get token from Windows credential files.
|
||||
|
||||
Claude Code on Windows stores credentials in ~/.claude/.credentials.json
|
||||
"""
|
||||
try:
|
||||
# Claude Code stores credentials in ~/.claude/.credentials.json
|
||||
cred_paths = [
|
||||
os.path.expandvars(r"%USERPROFILE%\.claude\.credentials.json"),
|
||||
os.path.expandvars(r"%USERPROFILE%\.claude\credentials.json"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Claude\credentials.json"),
|
||||
os.path.expandvars(r"%APPDATA%\Claude\credentials.json"),
|
||||
]
|
||||
|
||||
for cred_path in cred_paths:
|
||||
if os.path.exists(cred_path):
|
||||
with open(cred_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
if token and token.startswith("sk-ant-oat01-"):
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError, Exception):
|
||||
# Silently fail - this is a fallback mechanism
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or system credential store.
|
||||
Get authentication token from environment variables or macOS Keychain.
|
||||
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager)
|
||||
3. macOS Keychain (if on Darwin platform)
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
@@ -152,7 +109,7 @@ def get_auth_token() -> str | None:
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Fallback to system credential store
|
||||
# Fallback to macOS Keychain
|
||||
return get_token_from_keychain()
|
||||
|
||||
|
||||
@@ -163,15 +120,9 @@ def get_auth_token_source() -> str | None:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
|
||||
# Check if token came from system credential store
|
||||
# Check if token came from macOS Keychain
|
||||
if get_token_from_keychain():
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macOS Keychain"
|
||||
elif system == "Windows":
|
||||
return "Windows Credential Files"
|
||||
else:
|
||||
return "System Credential Store"
|
||||
return "macOS Keychain"
|
||||
|
||||
return None
|
||||
|
||||
@@ -191,22 +142,13 @@ def require_auth_token() -> str:
|
||||
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
|
||||
)
|
||||
# Provide platform-specific guidance
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
if platform.system() == "Darwin":
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token will be saved to macOS Keychain automatically\n\n"
|
||||
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
)
|
||||
elif system == "Windows":
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token should be saved to Windows Credential Manager\n\n"
|
||||
"If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n"
|
||||
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
|
||||
)
|
||||
else:
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
@@ -217,85 +159,6 @@ def require_auth_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def _find_git_bash_path() -> str | None:
|
||||
"""
|
||||
Find git-bash (bash.exe) path on Windows.
|
||||
|
||||
Uses 'where git' to find git.exe, then derives bash.exe location from it.
|
||||
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
|
||||
or in the parent 'bin' directory when git.exe is in 'cmd'.
|
||||
|
||||
Returns:
|
||||
Full path to bash.exe if found, None otherwise
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
|
||||
# If already set in environment, use that
|
||||
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if existing and os.path.exists(existing):
|
||||
return existing
|
||||
|
||||
git_path = None
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
common_git_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
]
|
||||
for path in common_git_paths:
|
||||
if os.path.exists(path):
|
||||
git_path = path
|
||||
break
|
||||
|
||||
if not git_path:
|
||||
return None
|
||||
|
||||
# Derive bash.exe location from git.exe location
|
||||
# Git for Windows structure:
|
||||
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
git_dir = os.path.dirname(git_path)
|
||||
git_parent = os.path.dirname(git_dir)
|
||||
git_grandparent = os.path.dirname(git_parent)
|
||||
|
||||
# Check common bash.exe locations relative to git installation
|
||||
possible_bash_paths = [
|
||||
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
|
||||
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
|
||||
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
|
||||
]
|
||||
|
||||
for bash_path in possible_bash_paths:
|
||||
if os.path.exists(bash_path):
|
||||
return bash_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
@@ -303,8 +166,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, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
|
||||
|
||||
Returns:
|
||||
Dict of env var name -> value for non-empty vars
|
||||
"""
|
||||
@@ -313,14 +174,6 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
env[var] = value
|
||||
|
||||
# On Windows, auto-detect git-bash path if not already set
|
||||
# Claude Code CLI requires bash.exe to run on Windows
|
||||
if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
||||
bash_path = _find_git_bash_path()
|
||||
if bash_path:
|
||||
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
|
||||
|
||||
return env
|
||||
|
||||
|
||||
|
||||
+185
-638
@@ -3,135 +3,19 @@ Claude SDK Client Configuration
|
||||
===============================
|
||||
|
||||
Functions for creating and configuring the Claude Agent SDK client.
|
||||
|
||||
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
|
||||
and proper tool/MCP configuration. For simple message calls without full agent sessions,
|
||||
use `create_simple_client()` from `core.simple_client`.
|
||||
|
||||
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 platform
|
||||
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,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
from auto_claude_tools import (
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
get_required_mcp_servers,
|
||||
is_tools_available,
|
||||
)
|
||||
from auto_claude_tools import (
|
||||
get_allowed_tools as get_agent_allowed_tools,
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
@@ -140,245 +24,6 @@ from prompts_pkg.project_context import detect_project_capabilities, load_projec
|
||||
from security import bash_security_hook
|
||||
|
||||
|
||||
def _validate_custom_mcp_server(server: dict) -> bool:
|
||||
"""
|
||||
Validate a custom MCP server configuration for security.
|
||||
|
||||
Ensures only expected fields with valid types are present.
|
||||
Rejects configurations that could lead to command injection.
|
||||
|
||||
Args:
|
||||
server: Dict representing a custom MCP server configuration
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not isinstance(server, dict):
|
||||
return False
|
||||
|
||||
# Required fields
|
||||
required_fields = {"id", "name", "type"}
|
||||
if not all(field in server for field in required_fields):
|
||||
logger.warning(
|
||||
f"Custom MCP server missing required fields: {required_fields - server.keys()}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate field types
|
||||
if not isinstance(server.get("id"), str) or not server["id"]:
|
||||
return False
|
||||
if not isinstance(server.get("name"), str) or not server["name"]:
|
||||
return False
|
||||
# FIX: Changed from ('command', 'url') to ('command', 'http') to match actual usage
|
||||
if server.get("type") not in ("command", "http"):
|
||||
logger.warning(f"Invalid MCP server type: {server.get('type')}")
|
||||
return False
|
||||
|
||||
# Allowlist of safe executable commands for MCP servers
|
||||
# Only allow known package managers and interpreters - NO shell commands
|
||||
SAFE_COMMANDS = {
|
||||
"npx",
|
||||
"npm",
|
||||
"node",
|
||||
"python",
|
||||
"python3",
|
||||
"uv",
|
||||
"uvx",
|
||||
}
|
||||
|
||||
# Blocklist of dangerous shell commands that should never be allowed
|
||||
DANGEROUS_COMMANDS = {
|
||||
"bash",
|
||||
"sh",
|
||||
"cmd",
|
||||
"powershell",
|
||||
"pwsh", # PowerShell Core
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/zsh",
|
||||
"/usr/bin/bash",
|
||||
"/usr/bin/sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
}
|
||||
|
||||
# Dangerous interpreter flags that allow arbitrary code execution
|
||||
# Covers Python (-e, -c, -m, -p), Node.js (--eval, --print, loaders), and general
|
||||
DANGEROUS_FLAGS = {
|
||||
"--eval",
|
||||
"-e",
|
||||
"-c",
|
||||
"--exec",
|
||||
"-m", # Python module execution
|
||||
"-p", # Python eval+print
|
||||
"--print", # Node.js print
|
||||
"--input-type=module", # Node.js ES module mode
|
||||
"--experimental-loader", # Node.js custom loaders
|
||||
"--require", # Node.js require injection
|
||||
"-r", # Node.js require shorthand
|
||||
}
|
||||
|
||||
# Type-specific validation
|
||||
if server["type"] == "command":
|
||||
if not isinstance(server.get("command"), str) or not server["command"]:
|
||||
logger.warning("Command-type MCP server missing 'command' field")
|
||||
return False
|
||||
|
||||
# SECURITY FIX: Validate command is in safe list and not in dangerous list
|
||||
command = server.get("command", "")
|
||||
|
||||
# Reject paths - commands must be bare names only (no / or \)
|
||||
# This prevents path traversal like '/custom/malicious' or './evil'
|
||||
if "/" in command or "\\" in command:
|
||||
logger.warning(
|
||||
f"Rejected command with path in MCP server: {command}. "
|
||||
f"Commands must be bare names without path separators."
|
||||
)
|
||||
return False
|
||||
|
||||
if command in DANGEROUS_COMMANDS:
|
||||
logger.warning(
|
||||
f"Rejected dangerous command in MCP server: {command}. "
|
||||
f"Shell commands are not allowed for security reasons."
|
||||
)
|
||||
return False
|
||||
|
||||
if command not in SAFE_COMMANDS:
|
||||
logger.warning(
|
||||
f"Rejected unknown command in MCP server: {command}. "
|
||||
f"Only allowed commands: {', '.join(sorted(SAFE_COMMANDS))}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate args is a list of strings if present
|
||||
if "args" in server:
|
||||
if not isinstance(server["args"], list):
|
||||
return False
|
||||
if not all(isinstance(arg, str) for arg in server["args"]):
|
||||
return False
|
||||
# Check for dangerous interpreter flags that allow code execution
|
||||
for arg in server["args"]:
|
||||
if arg in DANGEROUS_FLAGS:
|
||||
logger.warning(
|
||||
f"Rejected dangerous flag '{arg}' in MCP server args. "
|
||||
f"Interpreter code execution flags are not allowed."
|
||||
)
|
||||
return False
|
||||
elif server["type"] == "http":
|
||||
if not isinstance(server.get("url"), str) or not server["url"]:
|
||||
logger.warning("HTTP-type MCP server missing 'url' field")
|
||||
return False
|
||||
# Validate headers is a dict of strings if present
|
||||
if "headers" in server:
|
||||
if not isinstance(server["headers"], dict):
|
||||
return False
|
||||
if not all(
|
||||
isinstance(k, str) and isinstance(v, str)
|
||||
for k, v in server["headers"].items()
|
||||
):
|
||||
return False
|
||||
|
||||
# Optional description must be string if present
|
||||
if "description" in server and not isinstance(server.get("description"), str):
|
||||
return False
|
||||
|
||||
# Reject any unexpected fields that could be exploited
|
||||
allowed_fields = {
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"command",
|
||||
"args",
|
||||
"url",
|
||||
"headers",
|
||||
"description",
|
||||
}
|
||||
unexpected_fields = set(server.keys()) - allowed_fields
|
||||
if unexpected_fields:
|
||||
logger.warning(f"Custom MCP server has unexpected fields: {unexpected_fields}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def load_project_mcp_config(project_dir: Path) -> dict:
|
||||
"""
|
||||
Load MCP configuration from project's .auto-claude/.env file.
|
||||
|
||||
Returns a dict of MCP-related env vars:
|
||||
- CONTEXT7_ENABLED (default: true)
|
||||
- LINEAR_MCP_ENABLED (default: true)
|
||||
- ELECTRON_MCP_ENABLED (default: false)
|
||||
- PUPPETEER_MCP_ENABLED (default: false)
|
||||
- AGENT_MCP_<agent>_ADD (per-agent MCP additions)
|
||||
- AGENT_MCP_<agent>_REMOVE (per-agent MCP removals)
|
||||
- CUSTOM_MCP_SERVERS (JSON array of custom server configs)
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Dict of MCP configuration values (string values, except CUSTOM_MCP_SERVERS which is parsed JSON)
|
||||
"""
|
||||
env_path = project_dir / ".auto-claude" / ".env"
|
||||
if not env_path.exists():
|
||||
return {}
|
||||
|
||||
config = {}
|
||||
mcp_keys = {
|
||||
"CONTEXT7_ENABLED",
|
||||
"LINEAR_MCP_ENABLED",
|
||||
"ELECTRON_MCP_ENABLED",
|
||||
"PUPPETEER_MCP_ENABLED",
|
||||
}
|
||||
|
||||
try:
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
# Include global MCP toggles
|
||||
if key in mcp_keys:
|
||||
config[key] = value
|
||||
# Include per-agent MCP overrides (AGENT_MCP_<agent>_ADD/REMOVE)
|
||||
elif key.startswith("AGENT_MCP_"):
|
||||
config[key] = value
|
||||
# Include custom MCP servers (parse JSON with schema validation)
|
||||
elif key == "CUSTOM_MCP_SERVERS":
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if not isinstance(parsed, list):
|
||||
logger.warning(
|
||||
"CUSTOM_MCP_SERVERS must be a JSON array"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = []
|
||||
else:
|
||||
# Validate each server and filter out invalid ones
|
||||
valid_servers = []
|
||||
for i, server in enumerate(parsed):
|
||||
if _validate_custom_mcp_server(server):
|
||||
valid_servers.append(server)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Skipping invalid custom MCP server at index {i}"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = valid_servers
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"Failed to parse CUSTOM_MCP_SERVERS JSON: {value}"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = []
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load project MCP config from {env_path}: {e}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def is_graphiti_mcp_enabled() -> bool:
|
||||
"""
|
||||
Check if Graphiti MCP server integration is enabled.
|
||||
@@ -410,28 +55,78 @@ def get_electron_debug_port() -> int:
|
||||
return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222"))
|
||||
|
||||
|
||||
def should_use_claude_md() -> bool:
|
||||
"""Check if CLAUDE.md instructions should be included in system prompt."""
|
||||
return os.environ.get("USE_CLAUDE_MD", "").lower() == "true"
|
||||
# Puppeteer MCP tools for browser automation
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
def load_claude_md(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Load CLAUDE.md content from project root if it exists.
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
Returns:
|
||||
Content of CLAUDE.md if found, None otherwise
|
||||
"""
|
||||
claude_md_path = project_dir / "CLAUDE.md"
|
||||
if claude_md_path.exists():
|
||||
try:
|
||||
return claude_md_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set)
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
# See GitHub issue #74.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
"mcp__electron__send_command_to_electron", # Send commands (click, fill, evaluate JS)
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Built-in tools
|
||||
BUILTIN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
]
|
||||
|
||||
|
||||
def create_client(
|
||||
@@ -440,41 +135,25 @@ def create_client(
|
||||
model: str,
|
||||
agent_type: str = "coder",
|
||||
max_thinking_tokens: int | None = None,
|
||||
output_format: dict | None = None,
|
||||
agents: dict | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
|
||||
Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration.
|
||||
Only starts MCP servers that the agent actually needs, reducing context
|
||||
window bloat and startup latency.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project (working directory)
|
||||
spec_dir: Directory containing the spec (for settings file)
|
||||
model: Claude model to use
|
||||
agent_type: Agent type identifier from AGENT_CONFIGS
|
||||
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
|
||||
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
|
||||
This determines which custom auto-claude tools are available.
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (coding)
|
||||
output_format: Optional structured output format for validated JSON responses.
|
||||
Use {"type": "json_schema", "schema": Model.model_json_schema()}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
agents: Optional dict of subagent definitions for SDK parallel execution.
|
||||
Format: {"agent-name": {"description": "...", "prompt": "...",
|
||||
"tools": [...], "model": "inherit"}}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
|
||||
Security layers (defense in depth):
|
||||
1. Sandbox - OS-level bash command isolation prevents filesystem escape
|
||||
2. Permissions - File operations restricted to project_dir only
|
||||
@@ -489,12 +168,6 @@ def create_client(
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
elif platform.system() == "Windows":
|
||||
logger.warning("Git Bash path not detected on Windows!")
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
|
||||
@@ -504,138 +177,71 @@ 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)
|
||||
# Build the list of allowed tools
|
||||
# Start with agent-specific tools (includes base tools + auto-claude tools)
|
||||
# Pass project capabilities for dynamic MCP tool filtering
|
||||
if auto_claude_tools_enabled:
|
||||
allowed_tools_list = get_agent_allowed_tools(agent_type, project_capabilities)
|
||||
else:
|
||||
allowed_tools_list = [*BUILTIN_TOOLS]
|
||||
|
||||
# Get allowed tools using phase-aware configuration
|
||||
# This respects AGENT_CONFIGS and only includes tools the agent needs
|
||||
# Also respects per-project MCP configuration
|
||||
allowed_tools_list = get_allowed_tools(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
# Check if Graphiti MCP is enabled
|
||||
graphiti_mcp_enabled = is_graphiti_mcp_enabled()
|
||||
|
||||
# Get required MCP servers for this agent type
|
||||
# This is the key optimization - only start servers the agent needs
|
||||
# Now also respects per-project MCP configuration
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
# Check if Electron MCP is enabled (for QA agents testing Electron apps)
|
||||
electron_mcp_enabled = is_electron_mcp_enabled()
|
||||
|
||||
# Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers)
|
||||
graphiti_mcp_enabled = "graphiti" in required_servers
|
||||
# Add external MCP tools based on project capabilities
|
||||
# This saves context window by only including relevant tools
|
||||
allowed_tools_list.extend(CONTEXT7_TOOLS) # Always available
|
||||
if linear_enabled:
|
||||
allowed_tools_list.extend(LINEAR_TOOLS)
|
||||
if graphiti_mcp_enabled:
|
||||
allowed_tools_list.extend(GRAPHITI_MCP_TOOLS)
|
||||
# Note: Browser automation tools (ELECTRON_TOOLS, PUPPETEER_TOOLS) are already
|
||||
# added by get_agent_allowed_tools() via _get_qa_mcp_tools() for QA agents
|
||||
|
||||
# Determine browser tools for permissions (already in allowed_tools_list)
|
||||
# Determine which browser automation tools to allow based on project type
|
||||
# Note: Must check "not is_electron" for Puppeteer to avoid tool mismatch
|
||||
# when Electron MCP is disabled for an Electron project
|
||||
browser_tools_permissions = []
|
||||
if "electron" in required_servers:
|
||||
browser_tools_permissions = ELECTRON_TOOLS
|
||||
elif "puppeteer" in required_servers:
|
||||
browser_tools_permissions = PUPPETEER_TOOLS
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
browser_tools_permissions = ELECTRON_TOOLS
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
# Only add Puppeteer for non-Electron web frontends
|
||||
browser_tools_permissions = PUPPETEER_TOOLS
|
||||
|
||||
# Create comprehensive security settings
|
||||
# Note: Using both relative paths ("./**") and absolute paths to handle
|
||||
# cases where Claude uses absolute paths for file operations
|
||||
project_path_str = str(project_dir.resolve())
|
||||
spec_path_str = str(spec_dir.resolve())
|
||||
|
||||
# Detect if we're running in a worktree and get the original project directory
|
||||
# Worktrees are located in either:
|
||||
# - .auto-claude/worktrees/tasks/{spec-name}/ (new location)
|
||||
# - .worktrees/{spec-name}/ (legacy location)
|
||||
# When running in a worktree, we need to allow access to both the worktree
|
||||
# and the original project's .auto-claude/ directory for spec files
|
||||
original_project_permissions = []
|
||||
resolved_project_path = project_dir.resolve()
|
||||
|
||||
# Check for worktree paths and extract original project directory
|
||||
# This handles spec worktrees, PR review worktrees, and legacy worktrees
|
||||
# Note: Windows paths are normalized to forward slashes before comparison
|
||||
worktree_markers = [
|
||||
"/.auto-claude/worktrees/tasks/", # Spec/task worktrees
|
||||
"/.auto-claude/github/pr/worktrees/", # PR review worktrees
|
||||
"/.worktrees/", # Legacy worktree location
|
||||
]
|
||||
project_path_posix = str(resolved_project_path).replace("\\", "/")
|
||||
|
||||
for marker in worktree_markers:
|
||||
if marker in project_path_posix:
|
||||
# Extract the original project directory (parent of worktree location)
|
||||
# Use rsplit to get the rightmost occurrence (handles nested projects)
|
||||
original_project_str = project_path_posix.rsplit(marker, 1)[0]
|
||||
original_project_dir = Path(original_project_str)
|
||||
|
||||
# Grant permissions for relevant directories in the original project
|
||||
permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"]
|
||||
dirs_to_permit = [
|
||||
original_project_dir / ".auto-claude",
|
||||
original_project_dir / ".worktrees", # Legacy support
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_permit:
|
||||
if dir_path.exists():
|
||||
path_str = str(dir_path.resolve())
|
||||
original_project_permissions.extend(
|
||||
[f"{op}({path_str}/**)" for op in permission_ops]
|
||||
)
|
||||
break
|
||||
|
||||
# Note: Using relative paths ("./**") restricts access to project directory
|
||||
# since cwd is set to project_dir
|
||||
security_settings = {
|
||||
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits", # Auto-approve edits within allowed directories
|
||||
"allow": [
|
||||
# Allow all file operations within the project directory
|
||||
# Include both relative (./**) and absolute paths for compatibility
|
||||
"Read(./**)",
|
||||
"Write(./**)",
|
||||
"Edit(./**)",
|
||||
"Glob(./**)",
|
||||
"Grep(./**)",
|
||||
# Also allow absolute paths (Claude sometimes uses full paths)
|
||||
f"Read({project_path_str}/**)",
|
||||
f"Write({project_path_str}/**)",
|
||||
f"Edit({project_path_str}/**)",
|
||||
f"Glob({project_path_str}/**)",
|
||||
f"Grep({project_path_str}/**)",
|
||||
# Allow spec directory explicitly (needed when spec is in worktree)
|
||||
f"Read({spec_path_str}/**)",
|
||||
f"Write({spec_path_str}/**)",
|
||||
f"Edit({spec_path_str}/**)",
|
||||
# Allow original project's .auto-claude/ and .worktrees/ directories
|
||||
# when running in a worktree (fixes issue #385 - permission errors)
|
||||
*original_project_permissions,
|
||||
# Bash permission granted here, but actual commands are validated
|
||||
# by the bash_security_hook (see security.py for allowed commands)
|
||||
"Bash(*)",
|
||||
# Allow web tools for documentation and research
|
||||
"WebFetch(*)",
|
||||
"WebSearch(*)",
|
||||
# Allow MCP tools based on required servers
|
||||
# Format: tool_name(*) allows all arguments
|
||||
*(
|
||||
[f"{tool}(*)" for tool in CONTEXT7_TOOLS]
|
||||
if "context7" in required_servers
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[f"{tool}(*)" for tool in LINEAR_TOOLS]
|
||||
if "linear" in required_servers
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[f"{tool}(*)" for tool in GRAPHITI_MCP_TOOLS]
|
||||
if graphiti_mcp_enabled
|
||||
else []
|
||||
),
|
||||
*[f"{tool}(*)" for tool in browser_tools_permissions],
|
||||
# Allow Context7 MCP tools for documentation lookup
|
||||
*CONTEXT7_TOOLS,
|
||||
# Allow Linear MCP tools for project management (if enabled)
|
||||
*(LINEAR_TOOLS if linear_enabled else []),
|
||||
# Allow Graphiti MCP tools for knowledge graph memory (if enabled)
|
||||
*(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []),
|
||||
# Allow browser automation tools based on project type
|
||||
*browser_tools_permissions,
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -648,34 +254,30 @@ def create_client(
|
||||
print(f"Security settings: {settings_file}")
|
||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||
if original_project_permissions:
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
# Build list of MCP servers for display based on required_servers
|
||||
mcp_servers_list = []
|
||||
if "context7" in required_servers:
|
||||
mcp_servers_list.append("context7 (documentation)")
|
||||
if "electron" in required_servers:
|
||||
mcp_servers_list.append(
|
||||
f"electron (desktop automation, port {get_electron_debug_port()})"
|
||||
)
|
||||
if "puppeteer" in required_servers:
|
||||
mcp_servers_list.append("puppeteer (browser automation)")
|
||||
if "linear" in required_servers:
|
||||
# Build list of MCP servers for display
|
||||
mcp_servers_list = ["context7 (documentation)"]
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
mcp_servers_list.append(
|
||||
f"electron (desktop automation, port {get_electron_debug_port()})"
|
||||
)
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
mcp_servers_list.append("puppeteer (browser automation)")
|
||||
if linear_enabled:
|
||||
mcp_servers_list.append("linear (project management)")
|
||||
if graphiti_mcp_enabled:
|
||||
mcp_servers_list.append("graphiti-memory (knowledge graph)")
|
||||
if "auto-claude" in required_servers and auto_claude_tools_enabled:
|
||||
if auto_claude_tools_enabled:
|
||||
mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
|
||||
if mcp_servers_list:
|
||||
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
|
||||
else:
|
||||
print(" - MCP servers: none (minimal configuration)")
|
||||
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
|
||||
|
||||
# Show detected project capabilities for QA agents
|
||||
if agent_type in ("qa_reviewer", "qa_fixer") and any(project_capabilities.values()):
|
||||
@@ -687,131 +289,76 @@ def create_client(
|
||||
print(f" - Project capabilities: {', '.join(caps)}")
|
||||
print()
|
||||
|
||||
# Configure MCP servers - ONLY start servers that are required
|
||||
# This is the key optimization to reduce context bloat and startup latency
|
||||
mcp_servers = {}
|
||||
# Configure MCP servers
|
||||
mcp_servers = {
|
||||
"context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp"]},
|
||||
}
|
||||
|
||||
if "context7" in required_servers:
|
||||
mcp_servers["context7"] = {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
}
|
||||
# Add browser automation MCP server based on project type
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
# Electron MCP for desktop apps
|
||||
# Electron app must be started with --remote-debugging-port=<port>
|
||||
mcp_servers["electron"] = {
|
||||
"command": "npm",
|
||||
"args": ["exec", "electron-mcp-server"],
|
||||
}
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
# Puppeteer for web frontends (not Electron)
|
||||
mcp_servers["puppeteer"] = {
|
||||
"command": "npx",
|
||||
"args": ["puppeteer-mcp-server"],
|
||||
}
|
||||
|
||||
if "electron" in required_servers:
|
||||
# Electron MCP for desktop apps
|
||||
# Electron app must be started with --remote-debugging-port=<port>
|
||||
mcp_servers["electron"] = {
|
||||
"command": "npm",
|
||||
"args": ["exec", "electron-mcp-server"],
|
||||
}
|
||||
|
||||
if "puppeteer" in required_servers:
|
||||
# Puppeteer for web frontends (not Electron)
|
||||
mcp_servers["puppeteer"] = {
|
||||
"command": "npx",
|
||||
"args": ["puppeteer-mcp-server"],
|
||||
}
|
||||
|
||||
if "linear" in required_servers:
|
||||
# Add Linear MCP server if enabled
|
||||
if linear_enabled:
|
||||
mcp_servers["linear"] = {
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp",
|
||||
"headers": {"Authorization": f"Bearer {linear_api_key}"},
|
||||
}
|
||||
|
||||
# Graphiti MCP server for knowledge graph memory
|
||||
# Add Graphiti MCP server if enabled
|
||||
# Graphiti MCP server for knowledge graph memory (uses embedded LadybugDB)
|
||||
if graphiti_mcp_enabled:
|
||||
mcp_servers["graphiti-memory"] = {
|
||||
"type": "http",
|
||||
"url": get_graphiti_mcp_url(),
|
||||
}
|
||||
|
||||
# Add custom auto-claude MCP server if required and available
|
||||
if "auto-claude" in required_servers and auto_claude_tools_enabled:
|
||||
# Add custom auto-claude MCP server if available
|
||||
auto_claude_mcp_server = None
|
||||
if auto_claude_tools_enabled:
|
||||
auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
|
||||
if auto_claude_mcp_server:
|
||||
mcp_servers["auto-claude"] = auto_claude_mcp_server
|
||||
|
||||
# Add custom MCP servers from project config
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
for custom in custom_servers:
|
||||
server_id = custom.get("id")
|
||||
if not server_id:
|
||||
continue
|
||||
# Only include if agent has it in their effective server list
|
||||
if server_id not in required_servers:
|
||||
continue
|
||||
server_type = custom.get("type", "command")
|
||||
if server_type == "command":
|
||||
mcp_servers[server_id] = {
|
||||
"command": custom.get("command", "npx"),
|
||||
"args": custom.get("args", []),
|
||||
}
|
||||
elif server_type == "http":
|
||||
server_config = {
|
||||
"type": "http",
|
||||
"url": custom.get("url", ""),
|
||||
}
|
||||
if custom.get("headers"):
|
||||
server_config["headers"] = custom["headers"]
|
||||
mcp_servers[server_id] = server_config
|
||||
|
||||
# Build system prompt
|
||||
base_prompt = (
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
f"Your working directory is: {project_dir.resolve()}\n"
|
||||
f"Your filesystem access is RESTRICTED to this directory only. "
|
||||
f"Use relative paths (starting with ./) for all file operations. "
|
||||
f"Never use absolute paths or try to access files outside your working directory.\n\n"
|
||||
f"You follow existing code patterns, write clean maintainable code, and verify "
|
||||
f"your work through thorough testing. You communicate progress through Git commits "
|
||||
f"and build-progress.txt updates."
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
f"Your working directory is: {project_dir.resolve()}\n"
|
||||
f"Your filesystem access is RESTRICTED to this directory only. "
|
||||
f"Use relative paths (starting with ./) for all file operations. "
|
||||
f"Never use absolute paths or try to access files outside your working directory.\n\n"
|
||||
f"You follow existing code patterns, write clean maintainable code, and verify "
|
||||
f"your work through thorough testing. You communicate progress through Git commits "
|
||||
f"and build-progress.txt updates."
|
||||
),
|
||||
allowed_tools=allowed_tools_list,
|
||||
mcp_servers=mcp_servers,
|
||||
hooks={
|
||||
"PreToolUse": [
|
||||
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
|
||||
],
|
||||
},
|
||||
max_turns=1000,
|
||||
cwd=str(project_dir.resolve()),
|
||||
settings=str(settings_file.resolve()),
|
||||
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
max_thinking_tokens=max_thinking_tokens, # Extended thinking budget
|
||||
)
|
||||
)
|
||||
|
||||
# Include CLAUDE.md if enabled and present
|
||||
if should_use_claude_md():
|
||||
claude_md_content = load_claude_md(project_dir)
|
||||
if claude_md_content:
|
||||
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
|
||||
print(" - CLAUDE.md: included in system prompt")
|
||||
else:
|
||||
print(" - CLAUDE.md: not found in project root")
|
||||
else:
|
||||
print(" - CLAUDE.md: disabled by project settings")
|
||||
print()
|
||||
|
||||
# Build options dict, conditionally including output_format
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": base_prompt,
|
||||
"allowed_tools": allowed_tools_list,
|
||||
"mcp_servers": mcp_servers,
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
|
||||
],
|
||||
},
|
||||
"max_turns": 1000,
|
||||
"cwd": str(project_dir.resolve()),
|
||||
"settings": str(settings_file.resolve()),
|
||||
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
|
||||
"max_buffer_size": 10
|
||||
* 1024
|
||||
* 1024, # 10MB buffer (default: 1MB) - fixes large tool results
|
||||
# Enable file checkpointing to track file read/write state across tool calls
|
||||
# This prevents "File has not been read yet" errors in recovery sessions
|
||||
"enable_file_checkpointing": True,
|
||||
}
|
||||
|
||||
# Add structured output format if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
if output_format:
|
||||
options_kwargs["output_format"] = output_format
|
||||
|
||||
# Add subagent definitions if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
if agents:
|
||||
options_kwargs["agents"] = agents
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
Dependency Validator
|
||||
====================
|
||||
|
||||
Validates platform-specific dependencies are installed before running agents.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_platform_dependencies() -> None:
|
||||
"""
|
||||
Validate that platform-specific dependencies are installed.
|
||||
|
||||
Raises:
|
||||
SystemExit: If required platform-specific dependencies are missing,
|
||||
with helpful installation instructions.
|
||||
"""
|
||||
# Check Windows-specific dependencies
|
||||
if sys.platform == "win32" and sys.version_info >= (3, 12):
|
||||
try:
|
||||
import pywintypes # noqa: F401
|
||||
except ImportError:
|
||||
_exit_with_pywin32_error()
|
||||
|
||||
|
||||
def _exit_with_pywin32_error() -> None:
|
||||
"""Exit with helpful error message for missing pywin32."""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
# This works for venv and poetry environments
|
||||
venv_activate = Path(sys.prefix) / "Scripts" / "activate"
|
||||
|
||||
sys.exit(
|
||||
"Error: Required Windows dependency 'pywin32' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. Activate your virtual environment:\n"
|
||||
f" {venv_activate}\n"
|
||||
"\n"
|
||||
"2. Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Executable Finder
|
||||
======================
|
||||
|
||||
Utility to find the git executable, with Windows-specific fallbacks.
|
||||
Separated into its own module to avoid circular imports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_cached_git_path: str | None = None
|
||||
|
||||
|
||||
def get_git_executable() -> str:
|
||||
"""Find the git executable, with Windows-specific fallbacks.
|
||||
|
||||
Returns the path to git executable. On Windows, checks multiple sources:
|
||||
1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend)
|
||||
2. shutil.which (if git is in PATH)
|
||||
3. Common installation locations
|
||||
4. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find.
|
||||
"""
|
||||
global _cached_git_path
|
||||
|
||||
# Return cached result if available
|
||||
if _cached_git_path is not None:
|
||||
return _cached_git_path
|
||||
|
||||
git_path = _find_git_executable()
|
||||
_cached_git_path = git_path
|
||||
return git_path
|
||||
|
||||
|
||||
def _find_git_executable() -> str:
|
||||
"""Internal function to find git executable."""
|
||||
# 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend)
|
||||
# This env var points to bash.exe, we can derive git.exe from it
|
||||
bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if bash_path:
|
||||
try:
|
||||
bash_path_obj = Path(bash_path)
|
||||
if bash_path_obj.exists():
|
||||
git_dir = bash_path_obj.parent.parent
|
||||
# Try cmd/git.exe first (preferred), then bin/git.exe
|
||||
for git_subpath in ["cmd/git.exe", "bin/git.exe"]:
|
||||
git_path = git_dir / git_subpath
|
||||
if git_path.is_file():
|
||||
return str(git_path)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Try shutil.which (works if git is in PATH)
|
||||
git_path = shutil.which("git")
|
||||
if git_path:
|
||||
return git_path
|
||||
|
||||
# 3. Windows-specific: check common installation locations
|
||||
if os.name == "nt":
|
||||
common_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
r"C:\Program Files\Git\cmd\git.exe",
|
||||
r"C:\Program Files (x86)\Git\cmd\git.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 4. Try 'where' command with shell=True (more reliable on Windows)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where git",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if found_path and os.path.isfile(found_path):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
# Default fallback - let subprocess handle it (may fail)
|
||||
return "git"
|
||||
|
||||
|
||||
def run_git(
|
||||
args: list[str],
|
||||
cwd: Path | str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command with proper executable finding.
|
||||
|
||||
Args:
|
||||
args: Git command arguments (without 'git' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
try:
|
||||
return subprocess.run(
|
||||
[git] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[git] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[git] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Git executable not found. Please ensure git is installed and in PATH.",
|
||||
)
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Model Configuration Utilities
|
||||
==============================
|
||||
|
||||
Shared utilities for reading and parsing model configuration from environment variables.
|
||||
Used by both commit_message.py and merge resolver.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default model for utility operations (commit messages, merge resolution)
|
||||
DEFAULT_UTILITY_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
|
||||
def get_utility_model_config(
|
||||
default_model: str = DEFAULT_UTILITY_MODEL,
|
||||
) -> tuple[str, int | None]:
|
||||
"""
|
||||
Get utility model configuration from environment variables.
|
||||
|
||||
Reads UTILITY_MODEL_ID and UTILITY_THINKING_BUDGET from environment,
|
||||
with sensible defaults and validation.
|
||||
|
||||
Args:
|
||||
default_model: Default model ID to use if UTILITY_MODEL_ID not set
|
||||
|
||||
Returns:
|
||||
Tuple of (model_id, thinking_budget) where thinking_budget is None
|
||||
if extended thinking is disabled, or an int representing token budget
|
||||
"""
|
||||
model = os.environ.get("UTILITY_MODEL_ID", default_model)
|
||||
thinking_budget_str = os.environ.get("UTILITY_THINKING_BUDGET", "")
|
||||
|
||||
# Parse thinking budget: empty string = disabled (None), number = budget tokens
|
||||
# Note: 0 is treated as "disable thinking" (same as None) since 0 tokens is meaningless
|
||||
thinking_budget: int | None
|
||||
if not thinking_budget_str:
|
||||
# Empty string means "none" level - disable extended thinking
|
||||
thinking_budget = None
|
||||
else:
|
||||
try:
|
||||
parsed_budget = int(thinking_budget_str)
|
||||
# Validate positive values - 0 or negative are invalid
|
||||
# 0 would mean "thinking enabled but 0 tokens" which is meaningless
|
||||
if parsed_budget <= 0:
|
||||
if parsed_budget == 0:
|
||||
# Zero means disable thinking (same as empty string)
|
||||
logger.debug(
|
||||
"UTILITY_THINKING_BUDGET=0 interpreted as 'disable thinking'"
|
||||
)
|
||||
thinking_budget = None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Negative UTILITY_THINKING_BUDGET value '{thinking_budget_str}' not allowed, using default 1024"
|
||||
)
|
||||
thinking_budget = 1024
|
||||
else:
|
||||
thinking_budget = parsed_budget
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Invalid UTILITY_THINKING_BUDGET value '{thinking_budget_str}', using default 1024"
|
||||
)
|
||||
thinking_budget = 1024
|
||||
|
||||
return model, thinking_budget
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
Execution phase event protocol for frontend synchronization.
|
||||
|
||||
Protocol: __EXEC_PHASE__:{"phase":"coding","message":"Starting"}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
PHASE_MARKER_PREFIX = "__EXEC_PHASE__:"
|
||||
_DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
class ExecutionPhase(str, Enum):
|
||||
"""Maps to frontend's ExecutionPhase type for task card badges."""
|
||||
|
||||
PLANNING = "planning"
|
||||
CODING = "coding"
|
||||
QA_REVIEW = "qa_review"
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
phase: ExecutionPhase | str,
|
||||
message: str = "",
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"phase": phase_value,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
if not (0 <= progress <= 100):
|
||||
progress = max(0, min(100, progress))
|
||||
payload["progress"] = progress
|
||||
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
try:
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
try:
|
||||
sys.stderr.write(f"[phase_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Truly silent on complete I/O failure
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
Simple Claude SDK Client Factory
|
||||
================================
|
||||
|
||||
Factory for creating minimal Claude SDK clients for single-turn utility operations
|
||||
like commit message generation, merge conflict resolution, and batch analysis.
|
||||
|
||||
These clients don't need full security configurations, MCP servers, or hooks.
|
||||
Use `create_client()` from `core.client` for full agent sessions with security.
|
||||
|
||||
Example usage:
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
# For commit message generation (text-only, no tools)
|
||||
client = create_simple_client(agent_type="commit_message")
|
||||
|
||||
# For merge conflict resolution (text-only, no tools)
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# For insights extraction (read tools only)
|
||||
client = create_simple_client(agent_type="insights", cwd=project_dir)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
|
||||
def create_simple_client(
|
||||
agent_type: str = "merge_resolver",
|
||||
model: str = "claude-haiku-4-5-20251001",
|
||||
system_prompt: str | None = None,
|
||||
cwd: Path | None = None,
|
||||
max_turns: int = 1,
|
||||
max_thinking_tokens: int | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a minimal Claude SDK client for single-turn utility operations.
|
||||
|
||||
This factory creates lightweight clients without MCP servers, security hooks,
|
||||
or full permission configurations. Use for text-only analysis tasks.
|
||||
|
||||
Args:
|
||||
agent_type: Agent type from AGENT_CONFIGS. Determines available tools.
|
||||
Common utility types:
|
||||
- "merge_resolver" - Text-only merge conflict analysis
|
||||
- "commit_message" - Text-only commit message generation
|
||||
- "insights" - Read-only code insight extraction
|
||||
- "batch_analysis" - Read-only batch issue analysis
|
||||
- "batch_validation" - Read-only validation
|
||||
model: Claude model to use (defaults to Haiku for fast/cheap operations)
|
||||
system_prompt: Optional custom system prompt (for specialized tasks)
|
||||
cwd: Working directory for file operations (optional)
|
||||
max_turns: Maximum conversation turns (default: 1 for single-turn)
|
||||
max_thinking_tokens: Override thinking budget (None = use agent default from
|
||||
AGENT_CONFIGS, converted using phase_config.THINKING_BUDGET_MAP)
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient for single-turn operations
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token()
|
||||
import os
|
||||
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Get environment variables for SDK
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
# Get tools from config (no MCP tools for simple clients)
|
||||
allowed_tools = list(config.get("tools", []))
|
||||
|
||||
# Determine thinking budget using the single source of truth (phase_config.py)
|
||||
if max_thinking_tokens is None:
|
||||
thinking_level = get_default_thinking_level(agent_type)
|
||||
max_thinking_tokens = get_thinking_budget(thinking_level)
|
||||
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
allowed_tools=allowed_tools,
|
||||
max_turns=max_turns,
|
||||
cwd=str(cwd.resolve()) if cwd else None,
|
||||
env=sdk_env,
|
||||
max_thinking_tokens=max_thinking_tokens,
|
||||
)
|
||||
)
|
||||
+54
-306
@@ -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
|
||||
@@ -84,30 +84,15 @@ from core.workspace.git_utils import (
|
||||
_is_auto_claude_file,
|
||||
get_existing_build_worktree,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
apply_path_mapping as _apply_path_mapping,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
detect_file_renames as _detect_file_renames,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_binary_file_content_from_ref as _get_binary_file_content_from_ref,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_changed_files_from_branch as _get_changed_files_from_branch,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_file_content_from_ref as _get_file_content_from_ref,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
is_binary_file as _is_binary_file,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
is_lock_file as _is_lock_file,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
validate_merged_syntax as _validate_merged_syntax,
|
||||
)
|
||||
|
||||
# Import from refactored modules in core/workspace/
|
||||
from core.workspace.models import (
|
||||
@@ -245,17 +230,12 @@ def merge_existing_build(
|
||||
if smart_result is not None:
|
||||
# Smart merge handled it (success or identified conflicts)
|
||||
if smart_result.get("success"):
|
||||
# Check if smart merge actually DID work (resolved conflicts via AI)
|
||||
# NOTE: "files_merged" in stats is misleading - it's "files TO merge" not "files WERE merged"
|
||||
# The smart merge preview returns this count but doesn't actually perform the merge
|
||||
# in the no-conflict path. We only skip git merge if AI actually did work.
|
||||
# Check if smart merge resolved git conflicts directly
|
||||
stats = smart_result.get("stats", {})
|
||||
had_conflicts = stats.get("conflicts_resolved", 0) > 0
|
||||
ai_assisted = stats.get("ai_assisted", 0) > 0
|
||||
|
||||
if had_conflicts or ai_assisted:
|
||||
# AI actually resolved conflicts or assisted with merges
|
||||
# Changes are already written and staged - no need for git merge
|
||||
if had_conflicts:
|
||||
# Git conflicts were resolved (via AI or lock file exclusion) - changes are already staged
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
)
|
||||
@@ -266,8 +246,7 @@ def merge_existing_build(
|
||||
|
||||
return True
|
||||
else:
|
||||
# No conflicts needed AI resolution - do standard git merge
|
||||
# This is the common case: no divergence, just need to merge changes
|
||||
# No git conflicts, do standard git merge
|
||||
success_result = manager.merge_worktree(
|
||||
spec_name, delete_after=False, no_commit=no_commit
|
||||
)
|
||||
@@ -752,23 +731,6 @@ def _resolve_git_conflicts_with_ai(
|
||||
merge_base=merge_base[:12] if merge_base else None,
|
||||
)
|
||||
|
||||
# Detect file renames between merge-base and target branch
|
||||
# This handles cases where files were moved/renamed (e.g., directory restructures)
|
||||
path_mappings: dict[str, str] = {}
|
||||
if merge_base:
|
||||
path_mappings = _detect_file_renames(project_dir, merge_base, base_branch)
|
||||
if path_mappings:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected {len(path_mappings)} file renames between merge-base and target",
|
||||
sample_mappings=dict(list(path_mappings.items())[:5]),
|
||||
)
|
||||
print(
|
||||
muted(
|
||||
f" Detected {len(path_mappings)} file rename(s) since branch creation"
|
||||
)
|
||||
)
|
||||
|
||||
# FIX: Copy NEW files FIRST before resolving conflicts
|
||||
# This ensures dependencies exist before files that import them are written
|
||||
changed_files = _get_changed_files_from_branch(
|
||||
@@ -782,44 +744,18 @@ def _resolve_git_conflicts_with_ai(
|
||||
print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)..."))
|
||||
for file_path, status in new_files:
|
||||
try:
|
||||
# Apply path mapping - write to new location if file was renamed
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Handle binary files differently - use bytes instead of text
|
||||
if _is_binary_file(file_path):
|
||||
binary_content = _get_binary_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path = project_dir / file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
debug(MODULE, f"Copied new binary file: {file_path}")
|
||||
else:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
else:
|
||||
debug(MODULE, f"Copied new file: {file_path}")
|
||||
resolved_files.append(file_path)
|
||||
debug(MODULE, f"Copied new file: {file_path}")
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not copy new file {file_path}: {e}")
|
||||
|
||||
@@ -833,26 +769,20 @@ def _resolve_git_conflicts_with_ai(
|
||||
debug(MODULE, "Categorizing conflicting files for parallel processing")
|
||||
|
||||
for file_path in conflicting_files:
|
||||
# Apply path mapping to get the target path in the current branch
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Categorizing conflicting file: {file_path}"
|
||||
+ (f" -> {target_file_path}" if target_file_path != file_path else ""),
|
||||
)
|
||||
debug(MODULE, f"Categorizing conflicting file: {file_path}")
|
||||
|
||||
try:
|
||||
# Get content from main branch using MAPPED path (file may have been renamed)
|
||||
# Get content from main branch
|
||||
main_content = _get_file_content_from_ref(
|
||||
project_dir, base_branch, target_file_path
|
||||
project_dir, base_branch, file_path
|
||||
)
|
||||
|
||||
# Get content from worktree branch using ORIGINAL path
|
||||
# Get content from worktree branch
|
||||
worktree_content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
|
||||
# Get content from merge-base (common ancestor) using ORIGINAL path
|
||||
# Get content from merge-base (common ancestor)
|
||||
base_content = None
|
||||
if merge_base:
|
||||
base_content = _get_file_content_from_ref(
|
||||
@@ -865,49 +795,38 @@ def _resolve_git_conflicts_with_ai(
|
||||
|
||||
if main_content is None:
|
||||
# File only exists in worktree - it's a new file (no AI needed)
|
||||
# Write to target path (mapped if applicable)
|
||||
simple_merges.append((target_file_path, worktree_content))
|
||||
simple_merges.append((file_path, worktree_content))
|
||||
debug(MODULE, f" {file_path}: new file (no AI needed)")
|
||||
elif worktree_content is None:
|
||||
# File only exists in main - was deleted in worktree (no AI needed)
|
||||
simple_merges.append((target_file_path, None)) # None = delete
|
||||
simple_merges.append((file_path, None)) # None = delete
|
||||
debug(MODULE, f" {file_path}: deleted (no AI needed)")
|
||||
else:
|
||||
# File exists in both - check if it's a lock file
|
||||
if _is_lock_file(target_file_path):
|
||||
if _is_lock_file(file_path):
|
||||
# Lock files should be excluded from merge entirely
|
||||
# They must be regenerated after merge by running the package manager
|
||||
# (e.g., npm install, pnpm install, uv sync, cargo update)
|
||||
#
|
||||
# Strategy: Take main branch version and let user regenerate
|
||||
lock_files_excluded.append(target_file_path)
|
||||
simple_merges.append((target_file_path, main_content))
|
||||
lock_files_excluded.append(file_path)
|
||||
simple_merges.append((file_path, main_content))
|
||||
debug(
|
||||
MODULE,
|
||||
f" {target_file_path}: lock file (excluded - will use main version)",
|
||||
f" {file_path}: lock file (excluded - will use main version)",
|
||||
)
|
||||
else:
|
||||
# Regular file - needs AI merge
|
||||
# Store the TARGET path for writing, but track original for content retrieval
|
||||
files_needing_ai_merge.append(
|
||||
ParallelMergeTask(
|
||||
file_path=target_file_path, # Use target path for writing
|
||||
file_path=file_path,
|
||||
main_content=main_content,
|
||||
worktree_content=worktree_content,
|
||||
base_content=base_content,
|
||||
spec_name=spec_name,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f" {file_path}: needs AI merge"
|
||||
+ (
|
||||
f" (will write to {target_file_path})"
|
||||
if target_file_path != file_path
|
||||
else ""
|
||||
),
|
||||
)
|
||||
debug(MODULE, f" {file_path}: needs AI merge")
|
||||
|
||||
except Exception as e:
|
||||
print(error(f" ✗ Failed to categorize {file_path}: {e}"))
|
||||
@@ -1027,160 +946,29 @@ def _resolve_git_conflicts_with_ai(
|
||||
if f not in conflicting_files and s != "A" # Skip new files, already copied
|
||||
]
|
||||
|
||||
# Separate files that need AI merge (path-mapped) from simple copies
|
||||
path_mapped_files: list[ParallelMergeTask] = []
|
||||
simple_copy_files: list[
|
||||
tuple[str, str, str]
|
||||
] = [] # (file_path, target_path, status)
|
||||
|
||||
for file_path, status in non_conflicting:
|
||||
# Apply path mapping for renamed/moved files
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
|
||||
if target_file_path != file_path and status != "D":
|
||||
# File was renamed/moved - needs AI merge to incorporate changes
|
||||
# Get content from worktree (old path) and target branch (new path)
|
||||
worktree_content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
target_content = _get_file_content_from_ref(
|
||||
project_dir, base_branch, target_file_path
|
||||
)
|
||||
base_content = None
|
||||
if merge_base:
|
||||
base_content = _get_file_content_from_ref(
|
||||
project_dir, merge_base, file_path
|
||||
)
|
||||
|
||||
if worktree_content and target_content:
|
||||
# Both exist - need AI merge
|
||||
path_mapped_files.append(
|
||||
ParallelMergeTask(
|
||||
file_path=target_file_path,
|
||||
main_content=target_content,
|
||||
worktree_content=worktree_content,
|
||||
base_content=base_content,
|
||||
spec_name=spec_name,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Path-mapped file needs AI merge: {file_path} -> {target_file_path}",
|
||||
)
|
||||
elif worktree_content:
|
||||
# Only exists in worktree - simple copy to new path
|
||||
simple_copy_files.append((file_path, target_file_path, status))
|
||||
else:
|
||||
# No path mapping or deletion - simple operation
|
||||
simple_copy_files.append((file_path, target_file_path, status))
|
||||
|
||||
# Process path-mapped files with AI merge
|
||||
if path_mapped_files:
|
||||
print()
|
||||
print_status(
|
||||
f"Merging {len(path_mapped_files)} path-mapped file(s) with AI...",
|
||||
"progress",
|
||||
)
|
||||
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run parallel merges for path-mapped files
|
||||
path_mapped_results = asyncio.run(
|
||||
_run_parallel_merges(
|
||||
tasks=path_mapped_files,
|
||||
project_dir=project_dir,
|
||||
max_concurrent=MAX_PARALLEL_AI_MERGES,
|
||||
)
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
for result in path_mapped_results:
|
||||
if result.success:
|
||||
target_path = project_dir / result.file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(result.merged_content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", result.file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(result.file_path)
|
||||
|
||||
if result.was_auto_merged:
|
||||
auto_merged_count += 1
|
||||
print(success(f" ✓ {result.file_path} (auto-merged)"))
|
||||
else:
|
||||
ai_merged_count += 1
|
||||
print(success(f" ✓ {result.file_path} (AI merged)"))
|
||||
else:
|
||||
print(error(f" ✗ {result.file_path}: {result.error}"))
|
||||
remaining_conflicts.append(
|
||||
{
|
||||
"file": result.file_path,
|
||||
"reason": result.error or "AI could not merge path-mapped file",
|
||||
"severity": "high",
|
||||
}
|
||||
)
|
||||
|
||||
print(muted(f" Path-mapped merge completed in {elapsed:.1f}s"))
|
||||
|
||||
# Process simple copy/delete files
|
||||
for file_path, target_file_path, status in simple_copy_files:
|
||||
try:
|
||||
if status == "D":
|
||||
# Deleted in worktree - delete from target path
|
||||
target_path = project_dir / target_file_path
|
||||
# Deleted in worktree
|
||||
target_path = project_dir / file_path
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
)
|
||||
else:
|
||||
# Modified without path change - simple copy
|
||||
# Check if binary file to use correct read/write method
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if _is_binary_file(file_path):
|
||||
binary_content = _get_binary_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
# Added or modified - copy from worktree
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path = project_dir / file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Merged binary with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
else:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Merged with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
resolved_files.append(file_path)
|
||||
except Exception as e:
|
||||
print(muted(f" Warning: Could not process {file_path}: {e}"))
|
||||
|
||||
@@ -1452,20 +1240,23 @@ async def _merge_file_with_ai_async(
|
||||
|
||||
# Call Claude Haiku for fast merge
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="core.simple_client not available",
|
||||
error="claude_agent_sdk not installed",
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
@@ -1476,56 +1267,13 @@ async def _merge_file_with_ai_async(
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
if response_text:
|
||||
# Strip any code fences the model might have added
|
||||
merged_content = _strip_code_fences(response_text.strip())
|
||||
|
||||
# VALIDATION: Check if AI returned natural language instead of code
|
||||
# This catches cases where AI says "I need to see more..." instead of merging
|
||||
natural_language_patterns = [
|
||||
"I need to",
|
||||
"Let me",
|
||||
"I cannot",
|
||||
"I'm unable",
|
||||
"The file appears",
|
||||
"I don't have",
|
||||
"Unfortunately",
|
||||
"I apologize",
|
||||
]
|
||||
first_line = merged_content.split("\n")[0] if merged_content else ""
|
||||
if any(pattern in first_line for pattern in natural_language_patterns):
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"AI returned natural language instead of code for {task.file_path}: {first_line[:100]}",
|
||||
)
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error=f"AI returned explanation instead of code: {first_line[:80]}...",
|
||||
)
|
||||
|
||||
# VALIDATION: Run syntax check on the merged content
|
||||
is_valid, syntax_error = _validate_merged_syntax(
|
||||
task.file_path, merged_content, task.project_dir
|
||||
)
|
||||
if not is_valid:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"AI merge produced invalid syntax for {task.file_path}: {syntax_error}",
|
||||
)
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error=f"AI merge produced invalid syntax: {syntax_error}",
|
||||
)
|
||||
|
||||
debug(MODULE, f"AI merged {task.file_path} successfully")
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +62,6 @@ from .git_utils import (
|
||||
MAX_SYNTAX_FIX_RETRIES,
|
||||
MERGE_LOCK_TIMEOUT,
|
||||
_create_conflict_file_with_git,
|
||||
_get_binary_file_content_from_ref,
|
||||
_get_changed_files_from_branch,
|
||||
_get_file_content_from_ref,
|
||||
_is_binary_file,
|
||||
@@ -71,7 +70,6 @@ from .git_utils import (
|
||||
_is_process_running,
|
||||
_validate_merged_syntax,
|
||||
create_conflict_file_with_git,
|
||||
get_binary_file_content_from_ref,
|
||||
get_changed_files_from_branch,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
@@ -119,7 +117,6 @@ __all__ = [
|
||||
"get_current_branch",
|
||||
"get_existing_build_worktree",
|
||||
"get_file_content_from_ref",
|
||||
"get_binary_file_content_from_ref",
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_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"))
|
||||
|
||||
@@ -10,45 +10,6 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import get_git_executable, run_git
|
||||
|
||||
__all__ = [
|
||||
# Exported helpers
|
||||
"get_git_executable",
|
||||
"run_git",
|
||||
# Constants
|
||||
"MAX_FILE_LINES_FOR_AI",
|
||||
"MAX_PARALLEL_AI_MERGES",
|
||||
"LOCK_FILES",
|
||||
"BINARY_EXTENSIONS",
|
||||
"MERGE_LOCK_TIMEOUT",
|
||||
"MAX_SYNTAX_FIX_RETRIES",
|
||||
# Functions
|
||||
"detect_file_renames",
|
||||
"apply_path_mapping",
|
||||
"get_merge_base",
|
||||
"has_uncommitted_changes",
|
||||
"get_current_branch",
|
||||
"get_existing_build_worktree",
|
||||
"get_file_content_from_ref",
|
||||
"get_binary_file_content_from_ref",
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
"is_lock_file",
|
||||
"validate_merged_syntax",
|
||||
"create_conflict_file_with_git",
|
||||
# Backward compat aliases
|
||||
"_is_process_running",
|
||||
"_is_binary_file",
|
||||
"_is_lock_file",
|
||||
"_validate_merged_syntax",
|
||||
"_get_file_content_from_ref",
|
||||
"_get_binary_file_content_from_ref",
|
||||
"_get_changed_files_from_branch",
|
||||
"_create_conflict_file_with_git",
|
||||
]
|
||||
|
||||
# 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
|
||||
@@ -61,7 +22,6 @@ LOCK_FILES = {
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"Pipfile.lock",
|
||||
"poetry.lock",
|
||||
"uv.lock",
|
||||
@@ -72,7 +32,6 @@ LOCK_FILES = {
|
||||
}
|
||||
|
||||
BINARY_EXTENSIONS = {
|
||||
# Images
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -81,11 +40,6 @@ BINARY_EXTENSIONS = {
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".tiff",
|
||||
".tif",
|
||||
".heic",
|
||||
".heif",
|
||||
# Documents
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
@@ -93,63 +47,32 @@ BINARY_EXTENSIONS = {
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
# Archives
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".rar",
|
||||
".7z",
|
||||
".bz2",
|
||||
".xz",
|
||||
".zst",
|
||||
# Executables and libraries
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".bin",
|
||||
".msi",
|
||||
".app",
|
||||
# WebAssembly
|
||||
".wasm",
|
||||
# Audio
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
# Video
|
||||
".mp4",
|
||||
".wav",
|
||||
".avi",
|
||||
".mov",
|
||||
".mkv",
|
||||
".webm",
|
||||
".wmv",
|
||||
".flv",
|
||||
# Fonts
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".otf",
|
||||
".eot",
|
||||
# Compiled code
|
||||
".pyc",
|
||||
".pyo",
|
||||
".class",
|
||||
".o",
|
||||
".obj",
|
||||
# Data files
|
||||
".dat",
|
||||
".db",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
# Other binary formats
|
||||
".cur",
|
||||
".ani",
|
||||
".pbm",
|
||||
".pgm",
|
||||
".ppm",
|
||||
}
|
||||
|
||||
# Merge lock timeout in seconds
|
||||
@@ -160,109 +83,25 @@ MERGE_LOCK_TIMEOUT = 300 # 5 minutes
|
||||
MAX_SYNTAX_FIX_RETRIES = 2
|
||||
|
||||
|
||||
def detect_file_renames(
|
||||
project_dir: Path,
|
||||
from_ref: str,
|
||||
to_ref: str,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Detect file renames between two git refs using git's rename detection.
|
||||
|
||||
This analyzes the commit history between two refs to find all file
|
||||
renames/moves. Critical for merging changes from older branches that
|
||||
used a different directory structure.
|
||||
|
||||
Uses git's -M flag for rename detection with high similarity threshold.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
from_ref: Starting ref (e.g., merge-base commit or old branch)
|
||||
to_ref: Target ref (e.g., current branch HEAD)
|
||||
|
||||
Returns:
|
||||
Dict mapping old_path -> new_path for all renamed files
|
||||
"""
|
||||
renames: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
# Use git log with rename detection to find all renames between refs
|
||||
# -M flag enables rename detection
|
||||
# --diff-filter=R shows only renames
|
||||
# --name-status shows status and file names
|
||||
result = run_git(
|
||||
[
|
||||
"log",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"--diff-filter=R",
|
||||
"--format=", # No commit info, just file changes
|
||||
f"{from_ref}..{to_ref}",
|
||||
],
|
||||
cwd=project_dir,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("R"):
|
||||
# Format: R100\told_path\tnew_path (tab-separated)
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
old_path = parts[1]
|
||||
new_path = parts[2]
|
||||
renames[old_path] = new_path
|
||||
|
||||
except Exception:
|
||||
pass # Return empty dict on error
|
||||
|
||||
return renames
|
||||
|
||||
|
||||
def apply_path_mapping(file_path: str, mappings: dict[str, str]) -> str:
|
||||
"""
|
||||
Apply file path mappings to get the new path for a file.
|
||||
|
||||
Args:
|
||||
file_path: Original file path (from older branch)
|
||||
mappings: Dict of old_path -> new_path from detect_file_renames
|
||||
|
||||
Returns:
|
||||
Mapped new path if found, otherwise original path
|
||||
"""
|
||||
# Direct match
|
||||
if file_path in mappings:
|
||||
return mappings[file_path]
|
||||
|
||||
# No mapping found
|
||||
return file_path
|
||||
|
||||
|
||||
def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
|
||||
"""
|
||||
Get the merge-base commit between two refs.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
ref1: First ref (branch/commit)
|
||||
ref2: Second ref (branch/commit)
|
||||
|
||||
Returns:
|
||||
Merge-base commit hash, or None if not found
|
||||
"""
|
||||
result = run_git(["merge-base", ref1, ref2], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def has_uncommitted_changes(project_dir: Path) -> bool:
|
||||
"""Check if user has unsaved work."""
|
||||
result = run_git(["status", "--porcelain"], cwd=project_dir)
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_current_branch(project_dir: Path) -> str:
|
||||
"""Get the current branch name."""
|
||||
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -277,16 +116,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
|
||||
|
||||
|
||||
@@ -294,29 +127,11 @@ 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.)."""
|
||||
result = run_git(["show", f"{ref}:{file_path}"], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return None
|
||||
|
||||
|
||||
def get_binary_file_content_from_ref(
|
||||
project_dir: Path, ref: str, file_path: str
|
||||
) -> bytes | None:
|
||||
"""Get binary file content from a git ref (branch, commit, etc.).
|
||||
|
||||
Unlike get_file_content_from_ref, this returns raw bytes without
|
||||
text decoding, suitable for binary files like images, audio, etc.
|
||||
|
||||
Note: Uses subprocess directly with get_git_executable() since
|
||||
run_git() always returns text output.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
result = subprocess.run(
|
||||
[git, "show", f"{ref}:{file_path}"],
|
||||
["git", "show", f"{ref}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=False, # Return bytes, not text
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
@@ -341,9 +156,11 @@ def get_changed_files_from_branch(
|
||||
Returns:
|
||||
List of (file_path, status) tuples
|
||||
"""
|
||||
result = run_git(
|
||||
["diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
files = []
|
||||
@@ -360,23 +177,15 @@ def get_changed_files_from_branch(
|
||||
return files
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Normalize path separators to forward slashes for cross-platform comparison."""
|
||||
return path.replace("\\", "/")
|
||||
|
||||
|
||||
def _is_auto_claude_file(file_path: str) -> bool:
|
||||
"""Check if a file is in the .auto-claude or auto-claude/specs directory.
|
||||
|
||||
Handles both forward slashes (Unix/Git output) and backslashes (Windows).
|
||||
"""
|
||||
normalized = _normalize_path(file_path)
|
||||
"""Check if a file is in the .auto-claude or auto-claude/specs directory."""
|
||||
# These patterns cover the internal spec/build files that shouldn't be merged
|
||||
excluded_patterns = [
|
||||
".auto-claude/",
|
||||
"auto-claude/specs/",
|
||||
]
|
||||
for pattern in excluded_patterns:
|
||||
if normalized.startswith(pattern):
|
||||
if file_path.startswith(pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -570,9 +379,11 @@ def create_conflict_file_with_git(
|
||||
try:
|
||||
# git merge-file <current> <base> <other>
|
||||
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
|
||||
result = run_git(
|
||||
["merge-file", "-p", main_path, base_path, wt_path],
|
||||
result = subprocess.run(
|
||||
["git", "merge-file", "-p", main_path, base_path, wt_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Read the merged content
|
||||
@@ -599,6 +410,5 @@ _is_binary_file = is_binary_file
|
||||
_is_lock_file = is_lock_file
|
||||
_validate_merged_syntax = validate_merged_syntax
|
||||
_get_file_content_from_ref = get_file_content_from_ref
|
||||
_get_binary_file_content_from_ref = get_binary_file_content_from_ref
|
||||
_get_changed_files_from_branch = get_changed_files_from_branch
|
||||
_create_conflict_file_with_git = create_conflict_file_with_git
|
||||
|
||||
@@ -36,7 +36,6 @@ class ParallelMergeTask:
|
||||
worktree_content: str
|
||||
base_content: str | None
|
||||
spec_name: str
|
||||
project_dir: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -135,141 +134,3 @@ class MergeLock:
|
||||
self.lock_file.unlink()
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
|
||||
|
||||
class SpecNumberLockError(Exception):
|
||||
"""Raised when a spec number lock cannot be acquired."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpecNumberLock:
|
||||
"""
|
||||
Context manager for spec number coordination across main project and worktrees.
|
||||
|
||||
Prevents race conditions when creating specs by:
|
||||
1. Acquiring an exclusive file lock
|
||||
2. Scanning ALL spec locations (main + worktrees)
|
||||
3. Finding global maximum spec number
|
||||
4. Allowing atomic spec directory creation
|
||||
5. Releasing lock
|
||||
"""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir
|
||||
self.lock_dir = project_dir / ".auto-claude" / ".locks"
|
||||
self.lock_file = self.lock_dir / "spec-numbering.lock"
|
||||
self.acquired = False
|
||||
self._global_max: int | None = None
|
||||
|
||||
def __enter__(self) -> "SpecNumberLock":
|
||||
"""Acquire the spec numbering lock."""
|
||||
import os
|
||||
import time
|
||||
|
||||
self.lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
max_wait = 30 # seconds
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Try to create lock file exclusively (atomic operation)
|
||||
fd = os.open(
|
||||
str(self.lock_file),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o644,
|
||||
)
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()))
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
except FileExistsError:
|
||||
# Lock file exists - check if process is still running
|
||||
if self.lock_file.exists():
|
||||
try:
|
||||
pid = int(self.lock_file.read_text().strip())
|
||||
import os as _os
|
||||
|
||||
try:
|
||||
_os.kill(pid, 0)
|
||||
is_running = True
|
||||
except (OSError, ProcessLookupError):
|
||||
is_running = False
|
||||
|
||||
if not is_running:
|
||||
# Stale lock - remove it
|
||||
self.lock_file.unlink()
|
||||
continue
|
||||
except (ValueError, ProcessLookupError):
|
||||
# Invalid PID or can't check - remove stale lock
|
||||
self.lock_file.unlink()
|
||||
continue
|
||||
|
||||
# Active lock - wait or timeout
|
||||
if time.time() - start_time >= max_wait:
|
||||
raise SpecNumberLockError(
|
||||
f"Could not acquire spec numbering lock after {max_wait}s"
|
||||
)
|
||||
|
||||
time.sleep(0.1) # Shorter sleep for spec creation
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Release the spec numbering lock."""
|
||||
if self.acquired and self.lock_file.exists():
|
||||
try:
|
||||
self.lock_file.unlink()
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Next available spec number (global max + 1)
|
||||
"""
|
||||
if not self.acquired:
|
||||
raise SpecNumberLockError(
|
||||
"Lock must be acquired before getting next spec number"
|
||||
)
|
||||
|
||||
if self._global_max is not None:
|
||||
return self._global_max + 1
|
||||
|
||||
max_number = 0
|
||||
|
||||
# 1. Scan main project specs
|
||||
main_specs_dir = self.project_dir / ".auto-claude" / "specs"
|
||||
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"
|
||||
if worktrees_dir.exists():
|
||||
for worktree in worktrees_dir.iterdir():
|
||||
if worktree.is_dir():
|
||||
worktree_specs = worktree / ".auto-claude" / "specs"
|
||||
max_number = max(max_number, self._scan_specs_dir(worktree_specs))
|
||||
|
||||
self._global_max = max_number
|
||||
return max_number + 1
|
||||
|
||||
def _scan_specs_dir(self, specs_dir: Path) -> int:
|
||||
"""Scan a specs directory and return the highest spec number found."""
|
||||
if not specs_dir.exists():
|
||||
return 0
|
||||
|
||||
max_num = 0
|
||||
for folder in specs_dir.glob("[0-9][0-9][0-9]-*"):
|
||||
try:
|
||||
num = int(folder.name[:3])
|
||||
max_num = max(max_num, num)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
@@ -8,12 +8,11 @@ Functions for setting up and initializing workspaces.
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
Icons,
|
||||
MenuOption,
|
||||
@@ -144,43 +143,6 @@ def choose_workspace(
|
||||
return WorkspaceMode.ISOLATED
|
||||
|
||||
|
||||
def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[str]:
|
||||
"""
|
||||
Copy .env files from project root to worktree (without overwriting).
|
||||
|
||||
This ensures the worktree has access to environment variables needed
|
||||
to run the project (e.g., API keys, database URLs).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of copied file names
|
||||
"""
|
||||
copied = []
|
||||
# Common .env file patterns - copy if they exist
|
||||
env_patterns = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.development.local",
|
||||
".env.test",
|
||||
".env.test.local",
|
||||
]
|
||||
|
||||
for pattern in env_patterns:
|
||||
env_file = project_dir / pattern
|
||||
if env_file.is_file():
|
||||
target = worktree_path / pattern
|
||||
if not target.exists():
|
||||
shutil.copy2(env_file, target)
|
||||
copied.append(pattern)
|
||||
debug(MODULE, f"Copied {pattern} to worktree")
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def copy_spec_to_worktree(
|
||||
source_spec_dir: Path,
|
||||
worktree_path: Path,
|
||||
@@ -261,50 +223,6 @@ def setup_workspace(
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
worktree_info = manager.get_or_create_worktree(spec_name)
|
||||
|
||||
# Copy .env files to worktree so user can run the project
|
||||
copied_env_files = copy_env_files_to_worktree(project_dir, worktree_info.path)
|
||||
if copied_env_files:
|
||||
print_status(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
# the worktree uses the same security rules as the main project.
|
||||
# This prevents security bypasses through stale worktree configs.
|
||||
security_files = [
|
||||
ALLOWLIST_FILENAME,
|
||||
PROFILE_FILENAME,
|
||||
]
|
||||
security_files_copied = []
|
||||
|
||||
for filename in security_files:
|
||||
source_file = project_dir / filename
|
||||
if source_file.is_file():
|
||||
target_file = worktree_info.path / filename
|
||||
try:
|
||||
shutil.copy2(source_file, target_file)
|
||||
security_files_copied.append(filename)
|
||||
except (OSError, PermissionError) as e:
|
||||
debug_warning(MODULE, f"Failed to copy {filename}: {e}")
|
||||
print_status(
|
||||
f"Warning: Could not copy {filename} to worktree", "warning"
|
||||
)
|
||||
|
||||
if security_files_copied:
|
||||
print_status(
|
||||
f"Security config copied: {', '.join(security_files_copied)}", "success"
|
||||
)
|
||||
|
||||
# Ensure .auto-claude/ is in the worktree's .gitignore
|
||||
# This is critical because the worktree inherits .gitignore from the base branch,
|
||||
# which may not have .auto-claude/ if that change wasn't committed/pushed.
|
||||
# Without this, spec files would be committed to the worktree's branch.
|
||||
from init import ensure_gitignore_entry
|
||||
|
||||
if ensure_gitignore_entry(worktree_info.path, ".auto-claude/"):
|
||||
debug(MODULE, "Added .auto-claude/ to worktree's .gitignore")
|
||||
|
||||
# Copy spec files to worktree if provided
|
||||
localized_spec_dir = None
|
||||
if source_spec_dir and source_spec_dir.exists():
|
||||
@@ -406,9 +324,11 @@ def initialize_timeline_tracking(
|
||||
files_to_modify.extend(subtask.get("files", []))
|
||||
|
||||
# Get the current branch point commit
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
branch_point = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
|
||||
+125
-795
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Implementation Plan Manager
|
||||
============================
|
||||
|
||||
DEPRECATED: This module is now a compatibility shim. The implementation has been
|
||||
refactored into the implementation_plan/ package for better modularity.
|
||||
|
||||
Please import from the package directly:
|
||||
from implementation_plan import ImplementationPlan, Subtask, Phase, etc.
|
||||
|
||||
This file re-exports all public APIs for backwards compatibility.
|
||||
|
||||
Core data structures and utilities for subtask-based implementation plans.
|
||||
Replaces the test-centric feature_list.json with implementation_plan.json.
|
||||
|
||||
The key insight: Tests verify outcomes, but SUBTASKS define implementation steps.
|
||||
For complex multi-service features, implementation order matters.
|
||||
|
||||
Workflow Types:
|
||||
- feature: Standard multi-service feature (phases = services)
|
||||
- refactor: Migration/refactor work (phases = stages: add, migrate, remove)
|
||||
- investigation: Bug hunting (phases = investigate, hypothesize, fix)
|
||||
- migration: Data migration (phases = prepare, test, execute, cleanup)
|
||||
- simple: Single-service enhancement (minimal overhead)
|
||||
"""
|
||||
|
||||
# Re-export everything from the implementation_plan package
|
||||
from implementation_plan import (
|
||||
Chunk,
|
||||
ChunkStatus,
|
||||
ImplementationPlan,
|
||||
Phase,
|
||||
PhaseType,
|
||||
Subtask,
|
||||
SubtaskStatus,
|
||||
Verification,
|
||||
VerificationType,
|
||||
WorkflowType,
|
||||
create_feature_plan,
|
||||
create_investigation_plan,
|
||||
create_refactor_plan,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"WorkflowType",
|
||||
"PhaseType",
|
||||
"SubtaskStatus",
|
||||
"VerificationType",
|
||||
# Models
|
||||
"Verification",
|
||||
"Subtask",
|
||||
"Phase",
|
||||
"ImplementationPlan",
|
||||
# Factories
|
||||
"create_feature_plan",
|
||||
"create_investigation_plan",
|
||||
"create_refactor_plan",
|
||||
# Backwards compatibility
|
||||
"Chunk",
|
||||
"ChunkStatus",
|
||||
]
|
||||
|
||||
|
||||
# CLI for testing
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python implementation_plan.py <plan.json>")
|
||||
print(" python implementation_plan.py --demo")
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == "--demo":
|
||||
# Create a demo plan
|
||||
plan = create_feature_plan(
|
||||
feature="Avatar Upload with Processing",
|
||||
services=["backend", "worker", "frontend"],
|
||||
phases_config=[
|
||||
{
|
||||
"name": "Backend Foundation",
|
||||
"parallel_safe": True,
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "avatar-model",
|
||||
"service": "backend",
|
||||
"description": "Add avatar fields to User model",
|
||||
"files_to_modify": ["app/models/user.py"],
|
||||
"files_to_create": ["migrations/add_avatar.py"],
|
||||
"verification": {
|
||||
"type": "command",
|
||||
"run": "flask db upgrade",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "avatar-endpoint",
|
||||
"service": "backend",
|
||||
"description": "POST /api/users/avatar endpoint",
|
||||
"files_to_modify": ["app/routes/users.py"],
|
||||
"patterns_from": ["app/routes/profile.py"],
|
||||
"verification": {
|
||||
"type": "api",
|
||||
"method": "POST",
|
||||
"url": "/api/users/avatar",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Worker Pipeline",
|
||||
"depends_on": [1],
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "image-task",
|
||||
"service": "worker",
|
||||
"description": "Celery task for image processing",
|
||||
"files_to_create": ["app/tasks/images.py"],
|
||||
"patterns_from": ["app/tasks/reports.py"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Frontend",
|
||||
"depends_on": [1],
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "avatar-component",
|
||||
"service": "frontend",
|
||||
"description": "AvatarUpload React component",
|
||||
"files_to_create": ["src/components/AvatarUpload.tsx"],
|
||||
"patterns_from": ["src/components/FileUpload.tsx"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Integration",
|
||||
"depends_on": [2, 3],
|
||||
"type": "integration",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "e2e-wiring",
|
||||
"all_services": True,
|
||||
"description": "Connect frontend → backend → worker",
|
||||
"verification": {
|
||||
"type": "browser",
|
||||
"scenario": "Upload → Process → Display",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
plan.final_acceptance = [
|
||||
"User can upload avatar from profile page",
|
||||
"Avatar is automatically resized",
|
||||
"Large/invalid files show error",
|
||||
]
|
||||
|
||||
print(json.dumps(plan.to_dict(), indent=2))
|
||||
print("\n---\n")
|
||||
print(plan.get_status_summary())
|
||||
else:
|
||||
# Load and display existing plan
|
||||
plan = ImplementationPlan.load(Path(sys.argv[1]))
|
||||
print(plan.get_status_summary())
|
||||
+15
-114
@@ -6,32 +6,6 @@ Handles first-time setup of .auto-claude directory and ensures proper gitignore
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# All entries that should be added to .gitignore for auto-claude projects
|
||||
AUTO_CLAUDE_GITIGNORE_ENTRIES = [
|
||||
".auto-claude/",
|
||||
".auto-claude-security.json",
|
||||
".auto-claude-status",
|
||||
".claude_settings.json",
|
||||
".worktrees/",
|
||||
".security-key",
|
||||
"logs/security/",
|
||||
]
|
||||
|
||||
|
||||
def _entry_exists_in_gitignore(lines: list[str], entry: str) -> bool:
|
||||
"""Check if an entry already exists in gitignore (handles trailing slash variations)."""
|
||||
entry_normalized = entry.rstrip("/")
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
# Match both "entry" and "entry/"
|
||||
if (
|
||||
line_stripped == entry
|
||||
or line_stripped == entry_normalized
|
||||
or line_stripped == entry_normalized + "/"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> bool:
|
||||
"""
|
||||
@@ -53,8 +27,17 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
content = gitignore_path.read_text()
|
||||
lines = content.splitlines()
|
||||
|
||||
if _entry_exists_in_gitignore(lines, entry):
|
||||
return False # Already exists
|
||||
# Check if entry already exists (exact match or with trailing newline variations)
|
||||
entry_normalized = entry.rstrip("/")
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
# Match both ".auto-claude" and ".auto-claude/"
|
||||
if (
|
||||
line_stripped == entry
|
||||
or line_stripped == entry_normalized
|
||||
or line_stripped == entry_normalized + "/"
|
||||
):
|
||||
return False # Already exists
|
||||
|
||||
# Entry doesn't exist, append it
|
||||
# Ensure file ends with newline before adding our entry
|
||||
@@ -76,58 +59,11 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
return True
|
||||
|
||||
|
||||
def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Ensure all auto-claude related entries exist in the project's .gitignore file.
|
||||
|
||||
Creates .gitignore if it doesn't exist.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
|
||||
Returns:
|
||||
List of entries that were added (empty if all already existed)
|
||||
"""
|
||||
gitignore_path = project_dir / ".gitignore"
|
||||
added_entries: list[str] = []
|
||||
|
||||
# Read existing content or start fresh
|
||||
if gitignore_path.exists():
|
||||
content = gitignore_path.read_text()
|
||||
lines = content.splitlines()
|
||||
else:
|
||||
content = ""
|
||||
lines = []
|
||||
|
||||
# Find entries that need to be added
|
||||
entries_to_add = [
|
||||
entry
|
||||
for entry in AUTO_CLAUDE_GITIGNORE_ENTRIES
|
||||
if not _entry_exists_in_gitignore(lines, entry)
|
||||
]
|
||||
|
||||
if not entries_to_add:
|
||||
return []
|
||||
|
||||
# Build the new content to append
|
||||
# Ensure file ends with newline before adding our entries
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
|
||||
content += "\n# Auto Claude generated files\n"
|
||||
for entry in entries_to_add:
|
||||
content += entry + "\n"
|
||||
added_entries.append(entry)
|
||||
|
||||
gitignore_path.write_text(content)
|
||||
return added_entries
|
||||
|
||||
|
||||
def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
|
||||
"""
|
||||
Initialize the .auto-claude directory for a project.
|
||||
|
||||
Creates the directory if needed and ensures all auto-claude files are in .gitignore.
|
||||
Creates the directory if needed and ensures it's in .gitignore.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
@@ -142,18 +78,16 @@ def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
|
||||
dir_created = not auto_claude_dir.exists()
|
||||
auto_claude_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Ensure all auto-claude entries are in .gitignore (only on first creation)
|
||||
# Ensure .auto-claude is in .gitignore (only on first creation)
|
||||
gitignore_updated = False
|
||||
if dir_created:
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
gitignore_updated = len(added) > 0
|
||||
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
|
||||
else:
|
||||
# Even if dir exists, check gitignore on first run
|
||||
# Use a marker file to track if we've already checked
|
||||
marker = auto_claude_dir / ".gitignore_checked"
|
||||
if not marker.exists():
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
gitignore_updated = len(added) > 0
|
||||
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
|
||||
marker.touch()
|
||||
|
||||
return auto_claude_dir, gitignore_updated
|
||||
@@ -175,36 +109,3 @@ def get_auto_claude_dir(project_dir: Path, ensure_exists: bool = True) -> Path:
|
||||
return auto_claude_dir
|
||||
|
||||
return Path(project_dir) / ".auto-claude"
|
||||
|
||||
|
||||
def repair_gitignore(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Repair an existing project's .gitignore to include all auto-claude entries.
|
||||
|
||||
This is useful for projects created before all entries were being added,
|
||||
or when gitignore entries were manually removed.
|
||||
|
||||
Also resets the .gitignore_checked marker to allow future updates.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
|
||||
Returns:
|
||||
List of entries that were added (empty if all already existed)
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
auto_claude_dir = project_dir / ".auto-claude"
|
||||
|
||||
# Remove the marker file so future checks will also run
|
||||
marker = auto_claude_dir / ".gitignore_checked"
|
||||
if marker.exists():
|
||||
marker.unlink()
|
||||
|
||||
# Add all missing entries
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
|
||||
# Re-create the marker
|
||||
if auto_claude_dir.exists():
|
||||
marker.touch()
|
||||
|
||||
return added
|
||||
|
||||
@@ -8,8 +8,8 @@ Follows the same patterns as linear_config.py for consistency.
|
||||
Uses LadybugDB as the embedded graph database (no Docker required, requires Python 3.12+).
|
||||
|
||||
Multi-Provider Support (V2):
|
||||
- LLM Providers: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI, OpenRouter
|
||||
- Embedder Providers: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI, OpenRouter
|
||||
- LLM Providers: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI
|
||||
- Embedder Providers: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI
|
||||
|
||||
Environment Variables:
|
||||
# Core
|
||||
@@ -89,7 +89,6 @@ class LLMProvider(str, Enum):
|
||||
AZURE_OPENAI = "azure_openai"
|
||||
OLLAMA = "ollama"
|
||||
GOOGLE = "google"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
|
||||
class EmbedderProvider(str, Enum):
|
||||
@@ -100,7 +99,6 @@ class EmbedderProvider(str, Enum):
|
||||
AZURE_OPENAI = "azure_openai"
|
||||
OLLAMA = "ollama"
|
||||
GOOGLE = "google"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -143,12 +141,6 @@ class GraphitiConfig:
|
||||
google_llm_model: str = "gemini-2.0-flash"
|
||||
google_embedding_model: str = "text-embedding-004"
|
||||
|
||||
# OpenRouter settings (multi-provider aggregator)
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
openrouter_llm_model: str = "anthropic/claude-3.5-sonnet"
|
||||
openrouter_embedding_model: str = "openai/text-embedding-3-small"
|
||||
|
||||
# Ollama settings (local)
|
||||
ollama_base_url: str = DEFAULT_OLLAMA_BASE_URL
|
||||
ollama_llm_model: str = ""
|
||||
@@ -204,18 +196,6 @@ class GraphitiConfig:
|
||||
"GOOGLE_EMBEDDING_MODEL", "text-embedding-004"
|
||||
)
|
||||
|
||||
# OpenRouter settings
|
||||
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
||||
openrouter_base_url = os.environ.get(
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
openrouter_llm_model = os.environ.get(
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-3.5-sonnet"
|
||||
)
|
||||
openrouter_embedding_model = os.environ.get(
|
||||
"OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small"
|
||||
)
|
||||
|
||||
# Ollama settings
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL)
|
||||
ollama_llm_model = os.environ.get("OLLAMA_LLM_MODEL", "")
|
||||
@@ -247,10 +227,6 @@ class GraphitiConfig:
|
||||
google_api_key=google_api_key,
|
||||
google_llm_model=google_llm_model,
|
||||
google_embedding_model=google_embedding_model,
|
||||
openrouter_api_key=openrouter_api_key,
|
||||
openrouter_base_url=openrouter_base_url,
|
||||
openrouter_llm_model=openrouter_llm_model,
|
||||
openrouter_embedding_model=openrouter_embedding_model,
|
||||
ollama_base_url=ollama_base_url,
|
||||
ollama_llm_model=ollama_llm_model,
|
||||
ollama_embedding_model=ollama_embedding_model,
|
||||
@@ -291,8 +267,6 @@ class GraphitiConfig:
|
||||
return bool(self.ollama_embedding_model)
|
||||
elif self.embedder_provider == "google":
|
||||
return bool(self.google_api_key)
|
||||
elif self.embedder_provider == "openrouter":
|
||||
return bool(self.openrouter_api_key)
|
||||
return False
|
||||
|
||||
def get_validation_errors(self) -> list[str]:
|
||||
@@ -335,11 +309,6 @@ class GraphitiConfig:
|
||||
elif self.embedder_provider == "google":
|
||||
if not self.google_api_key:
|
||||
errors.append("Google embedder provider requires GOOGLE_API_KEY")
|
||||
elif self.embedder_provider == "openrouter":
|
||||
if not self.openrouter_api_key:
|
||||
errors.append(
|
||||
"OpenRouter embedder provider requires OPENROUTER_API_KEY"
|
||||
)
|
||||
else:
|
||||
errors.append(f"Unknown embedder provider: {self.embedder_provider}")
|
||||
|
||||
@@ -398,18 +367,6 @@ class GraphitiConfig:
|
||||
elif self.embedder_provider == "azure_openai":
|
||||
# Depends on the deployment, default to 1536
|
||||
return 1536
|
||||
elif self.embedder_provider == "openrouter":
|
||||
# OpenRouter uses provider/model format
|
||||
# Extract underlying provider to determine dimension
|
||||
model = self.openrouter_embedding_model.lower()
|
||||
if model.startswith("openai/"):
|
||||
return 1536 # OpenAI text-embedding-3-small
|
||||
elif model.startswith("voyage/"):
|
||||
return 1024 # Voyage-3
|
||||
elif model.startswith("google/"):
|
||||
return 768 # Google text-embedding-004
|
||||
# Add more providers as needed
|
||||
return 1536 # Default for unknown OpenRouter models
|
||||
return 768 # Safe default
|
||||
|
||||
def get_provider_signature(self) -> str:
|
||||
@@ -446,14 +403,7 @@ class GraphitiConfig:
|
||||
base_name = self.database
|
||||
|
||||
# Remove existing provider suffix if present
|
||||
for provider in [
|
||||
"openai",
|
||||
"ollama",
|
||||
"voyage",
|
||||
"google",
|
||||
"azure_openai",
|
||||
"openrouter",
|
||||
]:
|
||||
for provider in ["openai", "ollama", "voyage", "google", "azure_openai"]:
|
||||
if f"_{provider}_" in base_name:
|
||||
base_name = base_name.split(f"_{provider}_")[0]
|
||||
break
|
||||
@@ -622,23 +572,10 @@ def get_graphiti_status() -> dict:
|
||||
status["errors"] = errors
|
||||
# Errors are informational - embedder is optional (keyword search fallback)
|
||||
|
||||
# CRITICAL FIX: Actually verify packages are importable before reporting available
|
||||
# Don't just check config.is_valid() - actually try to import the module
|
||||
if not config.is_valid():
|
||||
# Available if is_valid() returns True (just needs enabled flag)
|
||||
status["available"] = config.is_valid()
|
||||
if not status["available"]:
|
||||
status["reason"] = errors[0] if errors else "Configuration invalid"
|
||||
return status
|
||||
|
||||
# Try importing the required Graphiti packages
|
||||
try:
|
||||
# Attempt to import the main graphiti_memory module
|
||||
import graphiti_core # noqa: F401
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver # noqa: F401
|
||||
|
||||
# If we got here, packages are importable
|
||||
status["available"] = True
|
||||
except ImportError as e:
|
||||
status["available"] = False
|
||||
status["reason"] = f"Graphiti packages not installed: {e}"
|
||||
|
||||
return status
|
||||
|
||||
@@ -680,11 +617,6 @@ def get_available_providers() -> dict:
|
||||
available_llm.append("google")
|
||||
available_embedder.append("google")
|
||||
|
||||
# Check OpenRouter
|
||||
if config.openrouter_api_key:
|
||||
available_llm.append("openrouter")
|
||||
available_embedder.append("openrouter")
|
||||
|
||||
# Check Ollama
|
||||
if config.ollama_llm_model:
|
||||
available_llm.append("ollama")
|
||||
|
||||
@@ -18,7 +18,6 @@ from .ollama_embedder import (
|
||||
get_embedding_dim_for_model,
|
||||
)
|
||||
from .openai_embedder import create_openai_embedder
|
||||
from .openrouter_embedder import create_openrouter_embedder
|
||||
from .voyage_embedder import create_voyage_embedder
|
||||
|
||||
__all__ = [
|
||||
@@ -27,7 +26,6 @@ __all__ = [
|
||||
"create_azure_openai_embedder",
|
||||
"create_ollama_embedder",
|
||||
"create_google_embedder",
|
||||
"create_openrouter_embedder",
|
||||
"KNOWN_OLLAMA_EMBEDDING_MODELS",
|
||||
"get_embedding_dim_for_model",
|
||||
]
|
||||
|
||||
+3
-3
@@ -94,9 +94,6 @@ def create_ollama_embedder(config: "GraphitiConfig") -> Any:
|
||||
ProviderNotInstalled: If graphiti-core is not installed
|
||||
ProviderError: If model is not specified
|
||||
"""
|
||||
if not config.ollama_embedding_model:
|
||||
raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL")
|
||||
|
||||
try:
|
||||
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
|
||||
except ImportError as e:
|
||||
@@ -106,6 +103,9 @@ def create_ollama_embedder(config: "GraphitiConfig") -> Any:
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
if not config.ollama_embedding_model:
|
||||
raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL")
|
||||
|
||||
# Get embedding dimension (auto-detect for known models, or use configured value)
|
||||
embedding_dim = get_embedding_dim_for_model(
|
||||
config.ollama_embedding_model,
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
OpenRouter Embedder Provider
|
||||
=============================
|
||||
|
||||
OpenRouter embedder implementation for Graphiti.
|
||||
Uses OpenAI-compatible embedding API.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import GraphitiConfig
|
||||
|
||||
from ..exceptions import ProviderError, ProviderNotInstalled
|
||||
|
||||
|
||||
def create_openrouter_embedder(config: "GraphitiConfig") -> Any:
|
||||
"""
|
||||
Create OpenRouter embedder client.
|
||||
|
||||
OpenRouter uses OpenAI-compatible API, so we use the OpenAI embedder
|
||||
with custom base URL.
|
||||
|
||||
Args:
|
||||
config: GraphitiConfig with OpenRouter settings
|
||||
|
||||
Returns:
|
||||
OpenAI-compatible embedder instance
|
||||
|
||||
Raises:
|
||||
ProviderNotInstalled: If graphiti-core is not installed
|
||||
ProviderError: If API key is missing
|
||||
|
||||
Example:
|
||||
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
|
||||
>>> config = GraphitiConfig(
|
||||
... openrouter_api_key="sk-or-...",
|
||||
... openrouter_embedding_model="openai/text-embedding-3-small"
|
||||
... )
|
||||
>>> embedder = create_openrouter_embedder(config)
|
||||
"""
|
||||
try:
|
||||
from graphiti_core.embedder import EmbedderConfig, OpenAIEmbedder
|
||||
except ImportError as e:
|
||||
raise ProviderNotInstalled(
|
||||
f"OpenRouter provider requires graphiti-core. "
|
||||
f"Install with: pip install graphiti-core\n"
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
if not config.openrouter_api_key:
|
||||
raise ProviderError("OpenRouter provider requires OPENROUTER_API_KEY")
|
||||
|
||||
embedder_config = EmbedderConfig(
|
||||
api_key=config.openrouter_api_key,
|
||||
model=config.openrouter_embedding_model,
|
||||
base_url=config.openrouter_base_url,
|
||||
)
|
||||
|
||||
return OpenAIEmbedder(config=embedder_config)
|
||||
@@ -16,7 +16,6 @@ from .embedder_providers import (
|
||||
create_google_embedder,
|
||||
create_ollama_embedder,
|
||||
create_openai_embedder,
|
||||
create_openrouter_embedder,
|
||||
create_voyage_embedder,
|
||||
)
|
||||
from .exceptions import ProviderError
|
||||
@@ -26,7 +25,6 @@ from .llm_providers import (
|
||||
create_google_llm_client,
|
||||
create_ollama_llm_client,
|
||||
create_openai_llm_client,
|
||||
create_openrouter_llm_client,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,8 +58,6 @@ def create_llm_client(config: "GraphitiConfig") -> Any:
|
||||
return create_ollama_llm_client(config)
|
||||
elif provider == "google":
|
||||
return create_google_llm_client(config)
|
||||
elif provider == "openrouter":
|
||||
return create_openrouter_llm_client(config)
|
||||
else:
|
||||
raise ProviderError(f"Unknown LLM provider: {provider}")
|
||||
|
||||
@@ -94,7 +90,5 @@ def create_embedder(config: "GraphitiConfig") -> Any:
|
||||
return create_ollama_embedder(config)
|
||||
elif provider == "google":
|
||||
return create_google_embedder(config)
|
||||
elif provider == "openrouter":
|
||||
return create_openrouter_embedder(config)
|
||||
else:
|
||||
raise ProviderError(f"Unknown embedder provider: {provider}")
|
||||
|
||||
@@ -15,7 +15,6 @@ from .azure_openai_llm import create_azure_openai_llm_client
|
||||
from .google_llm import create_google_llm_client
|
||||
from .ollama_llm import create_ollama_llm_client
|
||||
from .openai_llm import create_openai_llm_client
|
||||
from .openrouter_llm import create_openrouter_llm_client
|
||||
|
||||
__all__ = [
|
||||
"create_openai_llm_client",
|
||||
@@ -23,5 +22,4 @@ __all__ = [
|
||||
"create_azure_openai_llm_client",
|
||||
"create_ollama_llm_client",
|
||||
"create_google_llm_client",
|
||||
"create_openrouter_llm_client",
|
||||
]
|
||||
|
||||
@@ -27,9 +27,6 @@ def create_openai_llm_client(config: "GraphitiConfig") -> Any:
|
||||
ProviderNotInstalled: If graphiti-core is not installed
|
||||
ProviderError: If API key is missing
|
||||
"""
|
||||
if not config.openai_api_key:
|
||||
raise ProviderError("OpenAI provider requires OPENAI_API_KEY")
|
||||
|
||||
try:
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
from graphiti_core.llm_client.openai_client import OpenAIClient
|
||||
@@ -40,6 +37,9 @@ def create_openai_llm_client(config: "GraphitiConfig") -> Any:
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
if not config.openai_api_key:
|
||||
raise ProviderError("OpenAI provider requires OPENAI_API_KEY")
|
||||
|
||||
llm_config = LLMConfig(
|
||||
api_key=config.openai_api_key,
|
||||
model=config.openai_model,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
OpenRouter LLM Provider
|
||||
=======================
|
||||
|
||||
OpenRouter LLM client implementation for Graphiti.
|
||||
Uses OpenAI-compatible API.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import GraphitiConfig
|
||||
|
||||
from ..exceptions import ProviderError, ProviderNotInstalled
|
||||
|
||||
|
||||
def create_openrouter_llm_client(config: "GraphitiConfig") -> Any:
|
||||
"""
|
||||
Create OpenRouter LLM client.
|
||||
|
||||
OpenRouter uses OpenAI-compatible API, so we use the OpenAI client
|
||||
with custom base URL.
|
||||
|
||||
Args:
|
||||
config: GraphitiConfig with OpenRouter settings
|
||||
|
||||
Returns:
|
||||
OpenAI-compatible LLM client instance
|
||||
|
||||
Raises:
|
||||
ProviderNotInstalled: If graphiti-core is not installed
|
||||
ProviderError: If API key is missing
|
||||
|
||||
Example:
|
||||
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
|
||||
>>> config = GraphitiConfig(
|
||||
... openrouter_api_key="sk-or-...",
|
||||
... openrouter_llm_model="anthropic/claude-3.5-sonnet"
|
||||
... )
|
||||
>>> client = create_openrouter_llm_client(config)
|
||||
"""
|
||||
try:
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
from graphiti_core.llm_client.openai_client import OpenAIClient
|
||||
except ImportError as e:
|
||||
raise ProviderNotInstalled(
|
||||
f"OpenRouter provider requires graphiti-core. "
|
||||
f"Install with: pip install graphiti-core\n"
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
if not config.openrouter_api_key:
|
||||
raise ProviderError("OpenRouter provider requires OPENROUTER_API_KEY")
|
||||
|
||||
llm_config = LLMConfig(
|
||||
api_key=config.openrouter_api_key,
|
||||
model=config.openrouter_llm_model,
|
||||
base_url=config.openrouter_base_url,
|
||||
)
|
||||
|
||||
# OpenRouter uses OpenAI-compatible API
|
||||
# Disable reasoning/verbosity for compatibility
|
||||
return OpenAIClient(config=llm_config, reasoning=None, verbosity=None)
|
||||
@@ -34,25 +34,8 @@ def _apply_ladybug_monkeypatch() -> bool:
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
logger.info("Applied LadybugDB monkeypatch (kuzu -> real_ladybug)")
|
||||
return True
|
||||
except ImportError as e:
|
||||
logger.debug(f"LadybugDB import failed: {e}")
|
||||
# On Windows with Python 3.12+, provide more specific error details
|
||||
# (pywin32 is only required for Python 3.12+ per requirements.txt)
|
||||
if sys.platform == "win32" and sys.version_info >= (3, 12):
|
||||
# Check if it's the pywin32 error using both name attribute and string match
|
||||
# for robustness across Python versions
|
||||
is_pywin32_error = (
|
||||
(hasattr(e, "name") and e.name in ("pywintypes", "pywin32", "win32api"))
|
||||
or "pywintypes" in str(e)
|
||||
or "pywin32" in str(e)
|
||||
)
|
||||
if is_pywin32_error:
|
||||
logger.error(
|
||||
"LadybugDB requires pywin32 on Windows. "
|
||||
"Install with: pip install pywin32>=306"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Windows-specific import issue: {e}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fall back to native kuzu
|
||||
try:
|
||||
@@ -163,12 +146,12 @@ class GraphitiClient:
|
||||
# The original graphiti-core KuzuDriver has build_indices_and_constraints()
|
||||
# as a no-op, which causes FTS search failures
|
||||
from integrations.graphiti.queries_pkg.kuzu_driver_patched import (
|
||||
create_patched_kuzu_driver,
|
||||
PatchedKuzuDriver as KuzuDriver,
|
||||
)
|
||||
|
||||
db_path = self.config.get_db_path()
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
self._driver = KuzuDriver(db=str(db_path))
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
|
||||
@@ -108,7 +108,7 @@ class GraphitiMemory:
|
||||
if self.group_id_mode == GroupIdMode.PROJECT:
|
||||
project_name = self.project_dir.name
|
||||
path_hash = hashlib.md5(
|
||||
str(self.project_dir.resolve()).encode(), usedforsecurity=False
|
||||
str(self.project_dir.resolve()).encode()
|
||||
).hexdigest()[:8]
|
||||
return f"project_{project_name}_{path_hash}"
|
||||
else:
|
||||
@@ -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:
|
||||
|
||||
@@ -9,7 +9,6 @@ This patched driver fixes both issues for LadybugDB compatibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Import kuzu (might be real_ladybug via monkeypatch)
|
||||
@@ -18,159 +17,157 @@ try:
|
||||
except ImportError:
|
||||
import real_ladybug as kuzu # type: ignore
|
||||
|
||||
from graphiti_core.driver.driver import GraphProvider
|
||||
from graphiti_core.driver.kuzu_driver import KuzuDriver as OriginalKuzuDriver
|
||||
from graphiti_core.graph_queries import get_fulltext_indices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int = 1):
|
||||
from graphiti_core.driver.driver import GraphProvider
|
||||
from graphiti_core.driver.kuzu_driver import KuzuDriver as OriginalKuzuDriver
|
||||
from graphiti_core.graph_queries import get_fulltext_indices
|
||||
class PatchedKuzuDriver(OriginalKuzuDriver):
|
||||
"""
|
||||
KuzuDriver with proper FTS index creation and parameter handling.
|
||||
|
||||
class PatchedKuzuDriver(OriginalKuzuDriver):
|
||||
Fixes two bugs in graphiti-core:
|
||||
1. FTS indexes are never created (build_indices_and_constraints is a no-op)
|
||||
2. None parameters are filtered out, causing "Parameter not found" errors
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: str = ":memory:",
|
||||
max_concurrent_queries: int = 1,
|
||||
):
|
||||
# Store database path before calling parent (which creates the Database)
|
||||
self._database = db # Required by Graphiti for group_id checks
|
||||
super().__init__(db, max_concurrent_queries)
|
||||
|
||||
async def execute_query(
|
||||
self, cypher_query_: str, **kwargs: Any
|
||||
) -> tuple[list[dict[str, Any]] | list[list[dict[str, Any]]], None, None]:
|
||||
"""
|
||||
KuzuDriver with proper FTS index creation and parameter handling.
|
||||
Execute a Cypher query with proper None parameter handling.
|
||||
|
||||
Fixes two bugs in graphiti-core:
|
||||
1. FTS indexes are never created (build_indices_and_constraints is a no-op)
|
||||
2. None parameters are filtered out, causing "Parameter not found" errors
|
||||
The original driver filters out None values, but LadybugDB requires
|
||||
all referenced parameters to exist. This override keeps None values
|
||||
in the parameters dict.
|
||||
"""
|
||||
# Don't filter out None values - LadybugDB needs them
|
||||
params = {k: v for k, v in kwargs.items()}
|
||||
# Still remove these unsupported parameters
|
||||
params.pop("database_", None)
|
||||
params.pop("routing_", None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: str = ":memory:",
|
||||
max_concurrent_queries: int = 1,
|
||||
):
|
||||
# Store database path before calling parent (which creates the Database)
|
||||
self._database = db # Required by Graphiti for group_id checks
|
||||
super().__init__(db, max_concurrent_queries)
|
||||
try:
|
||||
results = await self.client.execute(cypher_query_, parameters=params)
|
||||
except Exception as e:
|
||||
# Truncate long values for logging
|
||||
log_params = {
|
||||
k: (v[:5] if isinstance(v, list) else v) for k, v in params.items()
|
||||
}
|
||||
logger.error(
|
||||
f"Error executing Kuzu query: {e}\n{cypher_query_}\n{log_params}"
|
||||
)
|
||||
raise
|
||||
|
||||
async def execute_query(
|
||||
self, cypher_query_: str, **kwargs: Any
|
||||
) -> tuple[list[dict[str, Any]] | list[list[dict[str, Any]]], None, None]:
|
||||
"""
|
||||
Execute a Cypher query with proper None parameter handling.
|
||||
if not results:
|
||||
return [], None, None
|
||||
|
||||
The original driver filters out None values, but LadybugDB requires
|
||||
all referenced parameters to exist. This override keeps None values
|
||||
in the parameters dict.
|
||||
"""
|
||||
# Don't filter out None values - LadybugDB needs them
|
||||
params = {k: v for k, v in kwargs.items()}
|
||||
# Still remove these unsupported parameters
|
||||
params.pop("database_", None)
|
||||
params.pop("routing_", None)
|
||||
if isinstance(results, list):
|
||||
dict_results = [list(result.rows_as_dict()) for result in results]
|
||||
else:
|
||||
dict_results = list(results.rows_as_dict())
|
||||
return dict_results, None, None # type: ignore
|
||||
|
||||
async def build_indices_and_constraints(self, delete_existing: bool = False):
|
||||
"""
|
||||
Build FTS indexes required for Graphiti's hybrid search.
|
||||
|
||||
The original KuzuDriver has this as a no-op, but we need to actually
|
||||
create the FTS indexes for search to work.
|
||||
|
||||
Args:
|
||||
delete_existing: If True, drop and recreate indexes (default: False)
|
||||
"""
|
||||
logger.info("Building FTS indexes for Kuzu/LadybugDB...")
|
||||
|
||||
# Get the FTS index creation queries from Graphiti
|
||||
fts_queries = get_fulltext_indices(GraphProvider.KUZU)
|
||||
|
||||
# Create a sync connection for index creation
|
||||
conn = kuzu.Connection(self.db)
|
||||
|
||||
try:
|
||||
for query in fts_queries:
|
||||
try:
|
||||
# Check if we need to drop existing index first
|
||||
if delete_existing:
|
||||
# Extract index name from query
|
||||
# Format: CALL CREATE_FTS_INDEX('TableName', 'index_name', [...])
|
||||
parts = query.split("'")
|
||||
if len(parts) >= 4:
|
||||
table_name = parts[1]
|
||||
index_name = parts[3]
|
||||
drop_query = (
|
||||
f"CALL DROP_FTS_INDEX('{table_name}', '{index_name}')"
|
||||
)
|
||||
try:
|
||||
conn.execute(drop_query)
|
||||
logger.debug(
|
||||
f"Dropped existing FTS index: {index_name}"
|
||||
)
|
||||
except Exception:
|
||||
# Index might not exist, that's fine
|
||||
pass
|
||||
|
||||
# Create the FTS index
|
||||
conn.execute(query)
|
||||
logger.debug(f"Created FTS index: {query[:80]}...")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# Handle "index already exists" gracefully
|
||||
if "already exists" in error_msg or "duplicate" in error_msg:
|
||||
logger.debug(
|
||||
f"FTS index already exists (skipping): {query[:60]}..."
|
||||
)
|
||||
else:
|
||||
# Log but don't fail - some indexes might fail in certain Kuzu versions
|
||||
logger.warning(f"Failed to create FTS index: {e}")
|
||||
logger.debug(f"Query was: {query}")
|
||||
|
||||
logger.info("FTS indexes created successfully")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def setup_schema(self):
|
||||
"""
|
||||
Set up the database schema and install/load the FTS extension.
|
||||
|
||||
Extends the parent setup_schema() to properly set up FTS support.
|
||||
"""
|
||||
conn = kuzu.Connection(self.db)
|
||||
|
||||
try:
|
||||
# First, install the FTS extension (required before loading)
|
||||
try:
|
||||
results = await self.client.execute(cypher_query_, parameters=params)
|
||||
conn.execute("INSTALL fts")
|
||||
logger.debug("Installed FTS extension")
|
||||
except Exception as e:
|
||||
# Truncate long values for logging
|
||||
log_params = {
|
||||
k: (v[:5] if isinstance(v, list) else v) for k, v in params.items()
|
||||
}
|
||||
logger.error(
|
||||
f"Error executing Kuzu query: {e}\n{cypher_query_}\n{log_params}"
|
||||
)
|
||||
raise
|
||||
|
||||
if not results:
|
||||
return [], None, None
|
||||
|
||||
if isinstance(results, list):
|
||||
dict_results = [list(result.rows_as_dict()) for result in results]
|
||||
else:
|
||||
dict_results = list(results.rows_as_dict())
|
||||
return dict_results, None, None # type: ignore
|
||||
|
||||
async def build_indices_and_constraints(self, delete_existing: bool = False):
|
||||
"""
|
||||
Build FTS indexes required for Graphiti's hybrid search.
|
||||
|
||||
The original KuzuDriver has this as a no-op, but we need to actually
|
||||
create the FTS indexes for search to work.
|
||||
|
||||
Args:
|
||||
delete_existing: If True, drop and recreate indexes (default: False)
|
||||
"""
|
||||
logger.info("Building FTS indexes for Kuzu/LadybugDB...")
|
||||
|
||||
# Get the FTS index creation queries from Graphiti
|
||||
fts_queries = get_fulltext_indices(GraphProvider.KUZU)
|
||||
|
||||
# Create a sync connection for index creation
|
||||
conn = kuzu.Connection(self.db)
|
||||
error_msg = str(e).lower()
|
||||
if "already" not in error_msg:
|
||||
logger.debug(f"FTS extension install note: {e}")
|
||||
|
||||
# Then load the FTS extension
|
||||
try:
|
||||
for query in fts_queries:
|
||||
try:
|
||||
# Check if we need to drop existing index first
|
||||
if delete_existing:
|
||||
# Extract index name from query
|
||||
# Format: CALL CREATE_FTS_INDEX('TableName', 'index_name', [...])
|
||||
match = re.search(
|
||||
r"CREATE_FTS_INDEX\('([^']+)',\s*'([^']+)'", query
|
||||
)
|
||||
if match:
|
||||
table_name, index_name = match.groups()
|
||||
drop_query = f"CALL DROP_FTS_INDEX('{table_name}', '{index_name}')"
|
||||
try:
|
||||
conn.execute(drop_query)
|
||||
logger.debug(
|
||||
f"Dropped existing FTS index: {index_name}"
|
||||
)
|
||||
except Exception:
|
||||
# Index might not exist, that's fine
|
||||
pass
|
||||
conn.execute("LOAD EXTENSION fts")
|
||||
logger.debug("Loaded FTS extension")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already loaded" not in error_msg:
|
||||
logger.debug(f"FTS extension load note: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Create the FTS index
|
||||
conn.execute(query)
|
||||
logger.debug(f"Created FTS index: {query[:80]}...")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# Handle "index already exists" gracefully
|
||||
if "already exists" in error_msg or "duplicate" in error_msg:
|
||||
logger.debug(
|
||||
f"FTS index already exists (skipping): {query[:60]}..."
|
||||
)
|
||||
else:
|
||||
# Log but don't fail - some indexes might fail in certain Kuzu versions
|
||||
logger.warning(f"Failed to create FTS index: {e}")
|
||||
logger.debug(f"Query was: {query}")
|
||||
|
||||
logger.info("FTS indexes created successfully")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def setup_schema(self):
|
||||
"""
|
||||
Set up the database schema and install/load the FTS extension.
|
||||
|
||||
Extends the parent setup_schema() to properly set up FTS support.
|
||||
"""
|
||||
conn = kuzu.Connection(self.db)
|
||||
|
||||
try:
|
||||
# First, install the FTS extension (required before loading)
|
||||
try:
|
||||
conn.execute("INSTALL fts")
|
||||
logger.debug("Installed FTS extension")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already" not in error_msg:
|
||||
logger.debug(f"FTS extension install note: {e}")
|
||||
|
||||
# Then load the FTS extension
|
||||
try:
|
||||
conn.execute("LOAD EXTENSION fts")
|
||||
logger.debug("Loaded FTS extension")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already loaded" not in error_msg:
|
||||
logger.debug(f"FTS extension load note: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Run the parent schema setup (creates tables)
|
||||
super().setup_schema()
|
||||
|
||||
return PatchedKuzuDriver(db=db, max_concurrent_queries=max_concurrent_queries)
|
||||
# Run the parent schema setup (creates tables)
|
||||
super().setup_schema()
|
||||
|
||||
@@ -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.
|
||||
@@ -78,7 +75,7 @@ class GraphitiSearch:
|
||||
if self.group_id_mode == GroupIdMode.SPEC and include_project_context:
|
||||
project_name = self.project_dir.name
|
||||
path_hash = hashlib.md5(
|
||||
str(self.project_dir.resolve()).encode(), usedforsecurity=False
|
||||
str(self.project_dir.resolve()).encode()
|
||||
).hexdigest()[:8]
|
||||
project_group_id = f"project_{project_name}_{path_hash}"
|
||||
if project_group_id != self.group_id:
|
||||
@@ -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,862 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Script for Ollama Embedding Memory Integration
|
||||
====================================================
|
||||
|
||||
This test validates that the memory system works correctly with local Ollama
|
||||
embedding models (like embeddinggemma, nomic-embed-text) for creating and
|
||||
retrieving memories in the hybrid RAG system.
|
||||
|
||||
The test covers:
|
||||
1. Ollama embedding generation (direct API test)
|
||||
2. Creating memories with Ollama embeddings via GraphitiMemory
|
||||
3. Retrieving memories via semantic search
|
||||
4. Verifying the full create → store → retrieve cycle
|
||||
|
||||
Prerequisites:
|
||||
1. Install Ollama: https://ollama.ai/
|
||||
2. Pull an embedding model:
|
||||
ollama pull embeddinggemma # 768 dimensions (lightweight)
|
||||
ollama pull nomic-embed-text # 768 dimensions (good quality)
|
||||
3. Pull an LLM model (for knowledge graph construction):
|
||||
ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b
|
||||
4. Start Ollama server: ollama serve
|
||||
5. Configure environment:
|
||||
export GRAPHITI_ENABLED=true
|
||||
export GRAPHITI_LLM_PROVIDER=ollama
|
||||
export GRAPHITI_EMBEDDER_PROVIDER=ollama
|
||||
export OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
export OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
export OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
|
||||
For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy
|
||||
The reranker will fail at search time, but embedding creation works.
|
||||
For production, use OpenAI API key for best search quality.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py
|
||||
|
||||
# Run specific tests:
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test create
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
except ImportError:
|
||||
print("Note: python-dotenv not installed, using environment variables only")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def print_result(label: str, value: str, success: bool = True):
|
||||
"""Print a result line."""
|
||||
status = "PASS" if success else "FAIL"
|
||||
print(f" [{status}] {label}: {value}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Print an info line."""
|
||||
print(f" INFO: {message}")
|
||||
|
||||
|
||||
def print_step(step: int, message: str):
|
||||
"""Print a step indicator."""
|
||||
print(f"\n Step {step}: {message}")
|
||||
|
||||
|
||||
def apply_ladybug_monkeypatch():
|
||||
"""Apply LadybugDB monkeypatch for embedded database support."""
|
||||
try:
|
||||
import real_ladybug
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
import kuzu # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 1: Ollama Embedding Generation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_ollama_embeddings() -> bool:
|
||||
"""
|
||||
Test Ollama embedding generation directly via API.
|
||||
|
||||
This validates that Ollama is running and can generate embeddings
|
||||
with the configured model.
|
||||
"""
|
||||
print_header("Test 1: Ollama Embedding Generation")
|
||||
|
||||
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768"))
|
||||
|
||||
print(f" Ollama Model: {ollama_model}")
|
||||
print(f" Base URL: {ollama_base_url}")
|
||||
print(f" Expected Dimension: {expected_dim}")
|
||||
print()
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print_result("requests library", "Not installed - pip install requests", False)
|
||||
return False
|
||||
|
||||
# Step 1: Check Ollama is running
|
||||
print_step(1, "Checking Ollama server status")
|
||||
try:
|
||||
resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
f"Not responding (status {resp.status_code})",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
models = resp.json().get("models", [])
|
||||
model_names = [m.get("name", "") for m in models]
|
||||
print_result("Ollama server", f"Running with {len(models)} models", True)
|
||||
|
||||
# Check if embedding model is available
|
||||
embedding_model_found = any(
|
||||
ollama_model in name or ollama_model.split(":")[0] in name
|
||||
for name in model_names
|
||||
)
|
||||
if not embedding_model_found:
|
||||
print_info(f"Model '{ollama_model}' not found. Available: {model_names}")
|
||||
print_info(f"Pull it with: ollama pull {ollama_model}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
"Not running - start with 'ollama serve'",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
# Step 2: Generate test embedding
|
||||
print_step(2, "Generating test embeddings")
|
||||
|
||||
test_texts = [
|
||||
"This is a test memory about implementing OAuth authentication.",
|
||||
"The user prefers using TypeScript for frontend development.",
|
||||
"A gotcha discovered: always validate JWT tokens on the server side.",
|
||||
]
|
||||
|
||||
embeddings = []
|
||||
for i, text in enumerate(test_texts):
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": text},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Failed: {resp.status_code} - {resp.text[:100]}",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
data = resp.json()
|
||||
embedding = data.get("embedding", [])
|
||||
embeddings.append(embedding)
|
||||
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Generated {len(embedding)} dimensions",
|
||||
True,
|
||||
)
|
||||
|
||||
# Step 3: Validate embedding dimensions
|
||||
print_step(3, "Validating embedding dimensions")
|
||||
|
||||
for i, embedding in enumerate(embeddings):
|
||||
if len(embedding) != expected_dim:
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension",
|
||||
f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
|
||||
False,
|
||||
)
|
||||
print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config")
|
||||
return False
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True
|
||||
)
|
||||
|
||||
# Step 4: Test embedding similarity (basic sanity check)
|
||||
print_step(4, "Testing embedding similarity")
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
dot_product = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = sum(x * x for x in a) ** 0.5
|
||||
norm_b = sum(x * x for x in b) ** 0.5
|
||||
return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0
|
||||
|
||||
# Generate embedding for a similar query
|
||||
query = "OAuth authentication implementation"
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": query},
|
||||
timeout=60,
|
||||
)
|
||||
query_embedding = resp.json().get("embedding", [])
|
||||
|
||||
similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings]
|
||||
|
||||
print(f" Query: '{query}'")
|
||||
print(" Similarities to test texts:")
|
||||
for i, (text, sim) in enumerate(zip(test_texts, similarities)):
|
||||
print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'")
|
||||
|
||||
# First text (about OAuth) should have highest similarity to OAuth query
|
||||
if similarities[0] > similarities[1] and similarities[0] > similarities[2]:
|
||||
print_result("Semantic similarity", "OAuth query matches OAuth text best", True)
|
||||
else:
|
||||
print_info("Similarity ordering may vary - embeddings are still working")
|
||||
|
||||
print()
|
||||
print_result("Ollama Embeddings", "All tests passed", True)
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 2: Memory Creation with Ollama
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]:
|
||||
"""
|
||||
Test creating memories using GraphitiMemory with Ollama embeddings.
|
||||
|
||||
Returns:
|
||||
Tuple of (spec_dir, project_dir, success)
|
||||
"""
|
||||
print_header("Test 2: Memory Creation with Ollama Embeddings")
|
||||
|
||||
# Create test directories
|
||||
spec_dir = test_db_path / "test_spec"
|
||||
project_dir = test_db_path / "test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" Spec dir: {spec_dir}")
|
||||
print(f" Project dir: {project_dir}")
|
||||
print(f" Database path: {test_db_path}")
|
||||
print()
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
# Step 1: Initialize GraphitiMemory
|
||||
print_step(1, "Initializing GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
print(f" Is enabled: {memory.is_enabled}")
|
||||
print(f" Group ID: {memory.group_id}")
|
||||
|
||||
if not memory.is_enabled:
|
||||
print_result(
|
||||
"GraphitiMemory",
|
||||
"Not enabled - check GRAPHITI_ENABLED=true",
|
||||
False,
|
||||
)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
init_result = await memory.initialize()
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to initialize", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
# Step 2: Save session insights
|
||||
print_step(2, "Saving session insights")
|
||||
|
||||
session_insights = {
|
||||
"subtasks_completed": ["implement-oauth-login", "add-jwt-validation"],
|
||||
"discoveries": {
|
||||
"files_understood": {
|
||||
"auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub",
|
||||
"auth/jwt.py": "JWT token generation and validation utilities",
|
||||
},
|
||||
"patterns_found": [
|
||||
"Pattern: Use refresh tokens for long-lived sessions",
|
||||
"Pattern: Store tokens in httpOnly cookies for security",
|
||||
],
|
||||
"gotchas_encountered": [
|
||||
"Gotcha: Always validate JWT signature on server side",
|
||||
"Gotcha: OAuth state parameter prevents CSRF attacks",
|
||||
],
|
||||
},
|
||||
"what_worked": [
|
||||
"Using PyJWT for token handling",
|
||||
"Separating OAuth providers into individual modules",
|
||||
],
|
||||
"what_failed": [],
|
||||
"recommendations_for_next_session": [
|
||||
"Consider adding refresh token rotation",
|
||||
"Add rate limiting to auth endpoints",
|
||||
],
|
||||
}
|
||||
|
||||
save_result = await memory.save_session_insights(
|
||||
session_num=1, insights=session_insights
|
||||
)
|
||||
print_result(
|
||||
"save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
|
||||
)
|
||||
|
||||
# Step 3: Save patterns
|
||||
print_step(3, "Saving code patterns")
|
||||
|
||||
patterns = [
|
||||
"OAuth implementation uses authorization code flow for web apps",
|
||||
"JWT tokens include user ID, roles, and expiration in payload",
|
||||
"Token refresh happens automatically when access token expires",
|
||||
]
|
||||
|
||||
for i, pattern in enumerate(patterns):
|
||||
result = await memory.save_pattern(pattern)
|
||||
print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 4: Save gotchas
|
||||
print_step(4, "Saving gotchas (pitfalls)")
|
||||
|
||||
gotchas = [
|
||||
"Never store config values in frontend code or files checked into git",
|
||||
"API redirect URIs must exactly match the registered URIs",
|
||||
"Cache expiration times should be short for performance (15 min default)",
|
||||
]
|
||||
|
||||
for i, gotcha in enumerate(gotchas):
|
||||
result = await memory.save_gotcha(gotcha)
|
||||
print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 5: Save codebase discoveries
|
||||
print_step(5, "Saving codebase discoveries")
|
||||
|
||||
discoveries = {
|
||||
"api/routes/users.py": "User management API endpoints (list, create, update)",
|
||||
"middleware/logging.py": "Request logging middleware for all routes",
|
||||
"models/user.py": "User model with profile data and role management",
|
||||
"services/notifications.py": "Notification service integrations (email, SMS, push)",
|
||||
}
|
||||
|
||||
discovery_result = await memory.save_codebase_discoveries(discoveries)
|
||||
print_result(
|
||||
"save_codebase_discoveries",
|
||||
"SUCCESS" if discovery_result else "FAILED",
|
||||
discovery_result,
|
||||
)
|
||||
|
||||
# Brief wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 3 seconds for embedding processing...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
print_result("Memory Creation", "All memories saved successfully", True)
|
||||
return spec_dir, project_dir, True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 3: Memory Retrieval with Semantic Search
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool:
|
||||
"""
|
||||
Test retrieving memories using semantic search with Ollama embeddings.
|
||||
|
||||
This validates that saved memories can be found via semantic similarity.
|
||||
"""
|
||||
print_header("Test 3: Memory Retrieval with Semantic Search")
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Initialize memory (reconnect)
|
||||
print_step(1, "Reconnecting to GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to reconnect", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "Reconnected successfully", True)
|
||||
|
||||
# Step 2: Semantic search for API-related content
|
||||
print_step(2, "Searching for API-related memories")
|
||||
|
||||
api_query = "How do the API endpoints work in this project?"
|
||||
results = await memory.get_relevant_context(api_query, num_results=5)
|
||||
|
||||
print(f" Query: '{api_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
api_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "api" in content.lower() or "routes" in content.lower():
|
||||
api_found = True
|
||||
|
||||
if api_found:
|
||||
print_result("API search", "Found API-related content", True)
|
||||
else:
|
||||
print_info("API content may not be in top results - checking other queries")
|
||||
|
||||
# Step 3: Search for middleware-related content
|
||||
print_step(3, "Searching for middleware patterns")
|
||||
|
||||
middleware_query = "middleware and request handling best practices"
|
||||
results = await memory.get_relevant_context(middleware_query, num_results=5)
|
||||
|
||||
print(f" Query: '{middleware_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
middleware_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "middleware" in content.lower() or "routes" in content.lower():
|
||||
middleware_found = True
|
||||
|
||||
print_result(
|
||||
"Middleware search",
|
||||
"Found middleware-related content" if middleware_found else "No direct matches",
|
||||
middleware_found or len(results) > 0,
|
||||
)
|
||||
|
||||
# Step 4: Get session history
|
||||
print_step(4, "Retrieving session history")
|
||||
|
||||
history = await memory.get_session_history(limit=3)
|
||||
print(f" Found {len(history)} session records:")
|
||||
|
||||
for i, session in enumerate(history):
|
||||
session_num = session.get("session_number", "?")
|
||||
subtasks = session.get("subtasks_completed", [])
|
||||
print(f" Session {session_num}: {len(subtasks)} subtasks completed")
|
||||
for subtask in subtasks[:3]:
|
||||
print(f" - {subtask}")
|
||||
|
||||
print_result(
|
||||
"Session history", f"Retrieved {len(history)} sessions", len(history) > 0
|
||||
)
|
||||
|
||||
# Step 5: Get status summary
|
||||
print_step(5, "Memory status summary")
|
||||
|
||||
status = memory.get_status_summary()
|
||||
for key, value in status.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
all_passed = len(results) > 0 and len(history) > 0
|
||||
print_result(
|
||||
"Memory Retrieval",
|
||||
"All retrieval tests passed" if all_passed else "Some tests had issues",
|
||||
all_passed,
|
||||
)
|
||||
return all_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 4: Full Create → Store → Retrieve Cycle
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_full_cycle(test_db_path: Path) -> bool:
|
||||
"""
|
||||
Test the complete memory lifecycle:
|
||||
1. Create unique test data
|
||||
2. Store in graph database with Ollama embeddings
|
||||
3. Search and retrieve via semantic similarity
|
||||
4. Verify retrieved data matches what was stored
|
||||
"""
|
||||
print_header("Test 4: Full Create-Store-Retrieve Cycle")
|
||||
|
||||
# Create fresh test directories
|
||||
spec_dir = test_db_path / "cycle_test_spec"
|
||||
project_dir = test_db_path / "cycle_test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_full_cycle"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Create unique test content
|
||||
print_step(1, "Creating unique test content")
|
||||
|
||||
unique_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_pattern = (
|
||||
f"Unique pattern {unique_id}: Use dependency injection for database connections"
|
||||
)
|
||||
unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks"
|
||||
|
||||
print(f" Unique ID: {unique_id}")
|
||||
print(f" Pattern: {unique_pattern[:60]}...")
|
||||
print(f" Gotcha: {unique_gotcha[:60]}...")
|
||||
|
||||
# Step 2: Store the content
|
||||
print_step(2, "Storing content in memory system")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
pattern_result = await memory.save_pattern(unique_pattern)
|
||||
print_result(
|
||||
"save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
|
||||
)
|
||||
|
||||
gotcha_result = await memory.save_gotcha(unique_gotcha)
|
||||
print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result)
|
||||
|
||||
# Wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 4 seconds for embedding processing and indexing...")
|
||||
await asyncio.sleep(4)
|
||||
|
||||
# Step 3: Search for the unique content
|
||||
print_step(3, "Searching for unique content")
|
||||
|
||||
# Search for the pattern
|
||||
pattern_query = "dependency injection database connections"
|
||||
pattern_results = await memory.get_relevant_context(pattern_query, num_results=5)
|
||||
|
||||
print(f" Query: '{pattern_query}'")
|
||||
print(f" Found {len(pattern_results)} results")
|
||||
|
||||
pattern_found = False
|
||||
for result in pattern_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
pattern_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Pattern retrieval",
|
||||
f"Found unique pattern (ID: {unique_id})"
|
||||
if pattern_found
|
||||
else "Unique pattern not in top results",
|
||||
pattern_found,
|
||||
)
|
||||
|
||||
# Search for the gotcha
|
||||
gotcha_query = "database connection cleanup finally block"
|
||||
gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5)
|
||||
|
||||
print(f" Query: '{gotcha_query}'")
|
||||
print(f" Found {len(gotcha_results)} results")
|
||||
|
||||
gotcha_found = False
|
||||
for result in gotcha_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
gotcha_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Gotcha retrieval",
|
||||
f"Found unique gotcha (ID: {unique_id})"
|
||||
if gotcha_found
|
||||
else "Unique gotcha not in top results",
|
||||
gotcha_found,
|
||||
)
|
||||
|
||||
# Step 4: Verify semantic similarity works
|
||||
print_step(4, "Verifying semantic similarity")
|
||||
|
||||
# Search with semantically similar but different wording
|
||||
alt_query = "closing connections properly in error handling"
|
||||
alt_results = await memory.get_relevant_context(alt_query, num_results=3)
|
||||
|
||||
print(f" Alternative query: '{alt_query}'")
|
||||
print(f" Found {len(alt_results)} semantically similar results:")
|
||||
|
||||
for i, result in enumerate(alt_results):
|
||||
content = result.get("content", "")[:80]
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. (score: {score:.4f}) {content}...")
|
||||
|
||||
semantic_works = len(alt_results) > 0
|
||||
print_result(
|
||||
"Semantic similarity",
|
||||
"Working - found related content" if semantic_works else "No results",
|
||||
semantic_works,
|
||||
)
|
||||
|
||||
await memory.close()
|
||||
|
||||
# Summary
|
||||
print()
|
||||
cycle_passed = (
|
||||
pattern_result
|
||||
and gotcha_result
|
||||
and (pattern_found or gotcha_found or len(alt_results) > 0)
|
||||
)
|
||||
print_result(
|
||||
"Full Cycle Test",
|
||||
"Create-Store-Retrieve cycle verified"
|
||||
if cycle_passed
|
||||
else "Some steps had issues",
|
||||
cycle_passed,
|
||||
)
|
||||
|
||||
return cycle_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run Ollama embedding memory tests."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Ollama Embedding Memory Integration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
choices=["all", "embeddings", "create", "retrieve", "full-cycle"],
|
||||
default="all",
|
||||
help="Which test to run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-db",
|
||||
action="store_true",
|
||||
help="Keep test database after completion (default: cleanup)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" OLLAMA EMBEDDING MEMORY TEST SUITE")
|
||||
print("=" * 70)
|
||||
|
||||
# Configuration check
|
||||
print_header("Configuration Check")
|
||||
|
||||
config_items = {
|
||||
"GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""),
|
||||
"GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""),
|
||||
"GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""),
|
||||
"OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""),
|
||||
"OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"),
|
||||
"OPENAI_API_KEY": "(set)"
|
||||
if os.environ.get("OPENAI_API_KEY")
|
||||
else "(not set - needed for reranker)",
|
||||
}
|
||||
|
||||
all_configured = True
|
||||
required_keys = [
|
||||
"GRAPHITI_ENABLED",
|
||||
"GRAPHITI_LLM_PROVIDER",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
"OLLAMA_LLM_MODEL",
|
||||
"OLLAMA_EMBEDDING_MODEL",
|
||||
]
|
||||
|
||||
for key, value in config_items.items():
|
||||
is_optional = key in [
|
||||
"OLLAMA_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OLLAMA_EMBEDDING_DIM",
|
||||
]
|
||||
is_set = bool(value) if not is_optional else True
|
||||
display_value = value or "(not set)"
|
||||
if key == "OPENAI_API_KEY":
|
||||
display_value = value # Already formatted above
|
||||
is_set = True # Optional for testing
|
||||
print_result(key, display_value, is_set)
|
||||
if key in required_keys and not bool(os.environ.get(key)):
|
||||
all_configured = False
|
||||
|
||||
if not all_configured:
|
||||
print()
|
||||
print(" Missing required configuration. Please set:")
|
||||
print(" export GRAPHITI_ENABLED=true")
|
||||
print(" export GRAPHITI_LLM_PROVIDER=ollama")
|
||||
print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama")
|
||||
print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b")
|
||||
print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma")
|
||||
print(" export OLLAMA_EMBEDDING_DIM=768")
|
||||
print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker")
|
||||
print()
|
||||
return
|
||||
|
||||
# Check LadybugDB
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print()
|
||||
print_result("LadybugDB", "Not installed - pip install real-ladybug", False)
|
||||
return
|
||||
|
||||
print_result("LadybugDB", "Installed", True)
|
||||
|
||||
# Create temp directory for test database
|
||||
test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_"))
|
||||
print()
|
||||
print_info(f"Test database: {test_db_path}")
|
||||
|
||||
# Run tests
|
||||
test = args.test
|
||||
results = {}
|
||||
|
||||
try:
|
||||
if test in ["all", "embeddings"]:
|
||||
results["embeddings"] = await test_ollama_embeddings()
|
||||
|
||||
spec_dir = None
|
||||
project_dir = None
|
||||
|
||||
if test in ["all", "create"]:
|
||||
spec_dir, project_dir, results["create"] = await test_memory_creation(
|
||||
test_db_path
|
||||
)
|
||||
|
||||
if test in ["all", "retrieve"]:
|
||||
if spec_dir and project_dir:
|
||||
results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir)
|
||||
else:
|
||||
print_info(
|
||||
"Skipping retrieve test - no spec/project dir from create test"
|
||||
)
|
||||
|
||||
if test in ["all", "full-cycle"]:
|
||||
results["full-cycle"] = await test_full_cycle(test_db_path)
|
||||
|
||||
finally:
|
||||
# Cleanup unless --keep-db specified
|
||||
if not args.keep_db and test_db_path.exists():
|
||||
print()
|
||||
print_info(f"Cleaning up test database: {test_db_path}")
|
||||
shutil.rmtree(test_db_path, ignore_errors=True)
|
||||
|
||||
# Summary
|
||||
print_header("TEST SUMMARY")
|
||||
|
||||
all_passed = True
|
||||
for test_name, passed in results.items():
|
||||
status = "PASSED" if passed else "FAILED"
|
||||
print(f" {test_name}: {status}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print(" All tests PASSED!")
|
||||
print()
|
||||
print(" The memory system is working correctly with Ollama embeddings.")
|
||||
print(" Memories can be created and retrieved using semantic search.")
|
||||
else:
|
||||
print(" Some tests FAILED. Check the output above for details.")
|
||||
print()
|
||||
print(" Common issues:")
|
||||
print(" - Ollama not running: ollama serve")
|
||||
print(" - Model not pulled: ollama pull embeddinggemma")
|
||||
print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model")
|
||||
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
|
||||
print()
|
||||
print(" # Run specific test:")
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
|
||||
)
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
|
||||
)
|
||||
print()
|
||||
print(" # Keep database for inspection:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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={
|
||||
|
||||
@@ -9,7 +9,7 @@ conflict resolution, enabling multiple AI agents to work in parallel without
|
||||
traditional merge conflicts.
|
||||
|
||||
Components:
|
||||
- SemanticAnalyzer: Regex-based semantic change extraction
|
||||
- SemanticAnalyzer: Tree-sitter based semantic change extraction
|
||||
- ConflictDetector: Rule-based conflict detection and compatibility analysis
|
||||
- AutoMerger: Deterministic merge strategies (no AI needed)
|
||||
- AIResolver: Minimal-context AI resolution for ambiguous conflicts
|
||||
|
||||
@@ -26,16 +26,12 @@ def create_claude_resolver() -> AIResolver:
|
||||
Create an AIResolver configured to use Claude via the Agent SDK.
|
||||
|
||||
Uses the same OAuth token pattern as the rest of the auto-claude framework.
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
|
||||
Returns:
|
||||
Configured AIResolver instance
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
from .resolver import AIResolver
|
||||
|
||||
@@ -47,28 +43,23 @@ def create_claude_resolver() -> AIResolver:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available, AI resolution unavailable")
|
||||
logger.warning("claude_agent_sdk not installed, AI resolution unavailable")
|
||||
return AIResolver()
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Merge resolver using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
def call_claude(system: str, user: str) -> str:
|
||||
"""Call Claude using the Agent SDK for merge resolution."""
|
||||
|
||||
async def _run_merge() -> str:
|
||||
# Create a minimal client for merge resolution
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model=model,
|
||||
system_prompt=system,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="sonnet",
|
||||
system_prompt=system,
|
||||
allowed_tools=[], # No tools needed for merge
|
||||
max_turns=1,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -82,9 +73,7 @@ def create_claude_resolver() -> AIResolver:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"AI merge response: {len(response_text)} chars")
|
||||
|
||||
@@ -68,7 +68,6 @@ class ModificationTracker:
|
||||
new_content: str,
|
||||
evolutions: dict[str, FileEvolution],
|
||||
raw_diff: str | None = None,
|
||||
skip_semantic_analysis: bool = False,
|
||||
) -> TaskSnapshot | None:
|
||||
"""
|
||||
Record a file modification by a task.
|
||||
@@ -80,9 +79,6 @@ class ModificationTracker:
|
||||
new_content: File content after modification
|
||||
evolutions: Current evolution data (will be updated)
|
||||
raw_diff: Optional unified diff for reference
|
||||
skip_semantic_analysis: If True, skip expensive semantic analysis.
|
||||
Use this for lightweight file tracking when only conflict
|
||||
detection is needed (not conflict resolution).
|
||||
|
||||
Returns:
|
||||
Updated TaskSnapshot, or None if file not being tracked
|
||||
@@ -91,8 +87,8 @@ class ModificationTracker:
|
||||
|
||||
# Get or create evolution
|
||||
if rel_path not in evolutions:
|
||||
# Debug level: this is expected for files not in baseline (e.g., from main's changes)
|
||||
logger.debug(f"File {rel_path} not in evolution tracking - skipping")
|
||||
logger.warning(f"File {rel_path} not being tracked")
|
||||
# Note: We could auto-create here, but for now return None
|
||||
return None
|
||||
|
||||
evolution = evolutions.get(rel_path)
|
||||
@@ -109,19 +105,9 @@ class ModificationTracker:
|
||||
content_hash_before=compute_content_hash(old_content),
|
||||
)
|
||||
|
||||
# Analyze semantic changes (or skip for lightweight tracking)
|
||||
if skip_semantic_analysis:
|
||||
# Fast path: just track the file change without analysis
|
||||
# This is used for files that don't have conflicts
|
||||
semantic_changes = []
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping semantic analysis for {rel_path} (lightweight tracking)",
|
||||
)
|
||||
else:
|
||||
# Full analysis (only for conflict files)
|
||||
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
|
||||
semantic_changes = analysis.changes
|
||||
# Analyze semantic changes
|
||||
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
|
||||
semantic_changes = analysis.changes
|
||||
|
||||
# Update snapshot
|
||||
snapshot.completed_at = datetime.now()
|
||||
@@ -135,7 +121,6 @@ class ModificationTracker:
|
||||
logger.info(
|
||||
f"Recorded modification to {rel_path} by {task_id}: "
|
||||
f"{len(semantic_changes)} semantic changes"
|
||||
+ (" (lightweight)" if skip_semantic_analysis else "")
|
||||
)
|
||||
return snapshot
|
||||
|
||||
@@ -145,7 +130,6 @@ class ModificationTracker:
|
||||
worktree_path: Path,
|
||||
evolutions: dict[str, FileEvolution],
|
||||
target_branch: str | None = None,
|
||||
analyze_only_files: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Refresh task snapshots by analyzing git diff from worktree.
|
||||
@@ -158,10 +142,6 @@ class ModificationTracker:
|
||||
worktree_path: Path to the task's worktree
|
||||
evolutions: Current evolution data (will be updated)
|
||||
target_branch: Branch to compare against (default: detect from worktree)
|
||||
analyze_only_files: If provided, only run full semantic analysis on
|
||||
these files. Other files will be tracked with lightweight mode
|
||||
(no semantic analysis). This optimizes performance by only
|
||||
analyzing files that have actual conflicts.
|
||||
"""
|
||||
# Determine the target branch to compare against
|
||||
if not target_branch:
|
||||
@@ -174,27 +154,12 @@ class ModificationTracker:
|
||||
task_id=task_id,
|
||||
worktree_path=str(worktree_path),
|
||||
target_branch=target_branch,
|
||||
analyze_only_files=list(analyze_only_files)[:10]
|
||||
if analyze_only_files
|
||||
else "all",
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the merge-base to accurately identify task-only changes
|
||||
# Using two-dot diff (merge-base..HEAD) returns only files changed by the task,
|
||||
# not files changed on the target branch since divergence
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", target_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Get list of files changed in the worktree since the merge-base
|
||||
# Get list of files changed in the worktree vs target branch
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -210,104 +175,56 @@ class ModificationTracker:
|
||||
else changed_files,
|
||||
)
|
||||
|
||||
processed_count = 0
|
||||
for file_path in changed_files:
|
||||
# Get the diff for this file
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", f"{target_branch}...HEAD", "--", file_path],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get content before (from target branch) and after (current)
|
||||
try:
|
||||
# Get the diff for this file (using merge-base for accurate task-only diff)
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{target_branch}:{file_path}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
old_content = show_result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# File is new
|
||||
old_content = ""
|
||||
|
||||
# Get content before (from merge-base - the point where task branched)
|
||||
current_file = worktree_path / file_path
|
||||
if current_file.exists():
|
||||
try:
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
new_content = current_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
old_content = show_result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# File is new
|
||||
old_content = ""
|
||||
else:
|
||||
# File was deleted
|
||||
new_content = ""
|
||||
|
||||
current_file = worktree_path / file_path
|
||||
if current_file.exists():
|
||||
try:
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
new_content = current_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
else:
|
||||
# File was deleted
|
||||
new_content = ""
|
||||
|
||||
# Auto-create FileEvolution entry if not already tracked
|
||||
# This handles retroactive tracking when capture_baselines wasn't called
|
||||
rel_path = self.storage.get_relative_path(file_path)
|
||||
if rel_path not in evolutions:
|
||||
evolutions[rel_path] = FileEvolution(
|
||||
file_path=rel_path,
|
||||
baseline_commit=merge_base,
|
||||
baseline_captured_at=datetime.now(),
|
||||
baseline_content_hash=compute_content_hash(old_content),
|
||||
baseline_snapshot_path="", # Not storing baseline file
|
||||
task_snapshots=[],
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Auto-created evolution entry for {rel_path}",
|
||||
baseline_commit=merge_base[:8],
|
||||
)
|
||||
|
||||
# Determine if this file needs full semantic analysis
|
||||
# If analyze_only_files is provided, only analyze files in that set
|
||||
# Otherwise, analyze all files (backward compatible)
|
||||
skip_analysis = False
|
||||
if analyze_only_files is not None:
|
||||
skip_analysis = rel_path not in analyze_only_files
|
||||
|
||||
# Record the modification
|
||||
self.record_modification(
|
||||
task_id=task_id,
|
||||
file_path=file_path,
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
evolutions=evolutions,
|
||||
raw_diff=diff_result.stdout,
|
||||
skip_semantic_analysis=skip_analysis,
|
||||
)
|
||||
processed_count += 1
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log error but continue with remaining files
|
||||
logger.warning(
|
||||
f"Failed to process {file_path} in refresh_from_git: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate how many files were fully analyzed vs just tracked
|
||||
if analyze_only_files is not None:
|
||||
analyzed_count = len(
|
||||
[f for f in changed_files if f in analyze_only_files]
|
||||
)
|
||||
tracked_only_count = processed_count - analyzed_count
|
||||
logger.info(
|
||||
f"Refreshed {processed_count}/{len(changed_files)} files from worktree for task {task_id} "
|
||||
f"(analyzed: {analyzed_count}, tracked only: {tracked_only_count})"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Refreshed {processed_count}/{len(changed_files)} files from worktree for task {task_id} "
|
||||
"(full analysis on all files)"
|
||||
# Record the modification
|
||||
self.record_modification(
|
||||
task_id=task_id,
|
||||
file_path=file_path,
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
evolutions=evolutions,
|
||||
raw_diff=diff_result.stdout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Refreshed {len(changed_files)} files from worktree for task {task_id}"
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Failed to refresh from git: {e}")
|
||||
|
||||
@@ -331,23 +248,35 @@ class ModificationTracker:
|
||||
|
||||
def _detect_target_branch(self, worktree_path: Path) -> str:
|
||||
"""
|
||||
Detect the base branch to compare against for a worktree.
|
||||
Detect the target branch to compare against for a worktree.
|
||||
|
||||
This finds the branch that the worktree was created FROM by looking
|
||||
for common branch names (main, master, develop) that have a valid
|
||||
merge-base with the worktree.
|
||||
|
||||
Note: We don't use upstream tracking because that returns the worktree's
|
||||
own branch (e.g., origin/auto-claude/...) rather than the base branch.
|
||||
This finds the branch that the worktree was created from by looking
|
||||
at the merge-base between the worktree and common branch names.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
The detected base branch name, defaults to 'main' if detection fails
|
||||
The detected target branch name, defaults to 'main' if detection fails
|
||||
"""
|
||||
# Try to get the upstream tracking branch
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
upstream = result.stdout.strip()
|
||||
# Extract branch name from origin/branch format
|
||||
if "/" in upstream:
|
||||
return upstream.split("/", 1)[1]
|
||||
return upstream
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Try common branch names and find which one has a valid merge-base
|
||||
# This is the reliable way to find what branch the worktree diverged from
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -357,39 +286,14 @@ class ModificationTracker:
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected base branch: {branch}",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return branch
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
# Before defaulting to 'main', verify it exists
|
||||
# This handles non-standard projects that use trunk, production, etc.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", "main"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Could not find merge-base with standard branches, defaulting to 'main'",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return "main"
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Last resort: use HEAD~10 as a fallback comparison point
|
||||
# This allows modification tracking even on non-standard branch setups
|
||||
# Default to main
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"No standard base branch found, modification tracking may be limited",
|
||||
"Could not detect target branch, defaulting to 'main'",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return "HEAD~10"
|
||||
return "main"
|
||||
|
||||
@@ -180,8 +180,8 @@ class EvolutionStorage:
|
||||
try:
|
||||
# Resolve both paths to handle symlinks (e.g., /var -> /private/var on macOS)
|
||||
resolved_path = path.resolve()
|
||||
return resolved_path.relative_to(self.project_dir).as_posix()
|
||||
return str(resolved_path.relative_to(self.project_dir))
|
||||
except ValueError:
|
||||
# Path is not under project_dir, return as-is
|
||||
return path.as_posix()
|
||||
return path.as_posix()
|
||||
return str(path)
|
||||
return str(path)
|
||||
|
||||
@@ -327,7 +327,6 @@ class FileEvolutionTracker:
|
||||
task_id: str,
|
||||
worktree_path: Path,
|
||||
target_branch: str | None = None,
|
||||
analyze_only_files: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Refresh task snapshots by analyzing git diff from worktree.
|
||||
@@ -339,16 +338,11 @@ class FileEvolutionTracker:
|
||||
task_id: The task identifier
|
||||
worktree_path: Path to the task's worktree
|
||||
target_branch: Branch to compare against (default: auto-detect)
|
||||
analyze_only_files: If provided, only run full semantic analysis on
|
||||
these files. Other files will be tracked with lightweight mode
|
||||
(no semantic analysis). This optimizes performance by only
|
||||
analyzing files that have actual conflicts.
|
||||
"""
|
||||
self.modification_tracker.refresh_from_git(
|
||||
task_id=task_id,
|
||||
worktree_path=worktree_path,
|
||||
evolutions=self._evolutions,
|
||||
target_branch=target_branch,
|
||||
analyze_only_files=analyze_only_files,
|
||||
)
|
||||
self._save_evolutions()
|
||||
|
||||
@@ -19,35 +19,6 @@ from pathlib import Path
|
||||
from .types import ChangeType, SemanticChange, TaskSnapshot
|
||||
|
||||
|
||||
def detect_line_ending(content: str) -> str:
|
||||
"""
|
||||
Detect line ending style in content using priority-based detection.
|
||||
|
||||
Uses a priority order (CRLF > CR > LF) to detect the line ending style.
|
||||
CRLF is checked first because it contains LF, so presence of any CRLF
|
||||
indicates Windows-style endings. This approach is fast and works well
|
||||
for files that consistently use one style.
|
||||
|
||||
Note: This returns the first detected style by priority, not the most
|
||||
frequent style. For files with mixed line endings, consider normalizing
|
||||
to a single style before processing.
|
||||
|
||||
Args:
|
||||
content: File content to analyze
|
||||
|
||||
Returns:
|
||||
The detected line ending string: "\\r\\n", "\\r", or "\\n"
|
||||
"""
|
||||
# Check for CRLF first (Windows) - must check before LF since CRLF contains LF
|
||||
if "\r\n" in content:
|
||||
return "\r\n"
|
||||
# Check for CR (classic Mac, rare but possible)
|
||||
if "\r" in content:
|
||||
return "\r"
|
||||
# Default to LF (Unix/modern Mac)
|
||||
return "\n"
|
||||
|
||||
|
||||
def apply_single_task_changes(
|
||||
baseline: str,
|
||||
snapshot: TaskSnapshot,
|
||||
@@ -64,16 +35,7 @@ def apply_single_task_changes(
|
||||
Returns:
|
||||
Modified content with changes applied
|
||||
"""
|
||||
# Detect line ending style before normalizing
|
||||
original_line_ending = detect_line_ending(baseline)
|
||||
|
||||
# Normalize to LF for consistent matching with regex_analyzer output
|
||||
# The regex_analyzer normalizes content to LF when extracting content_before/after,
|
||||
# so we must also normalize baseline to ensure replace() matches correctly
|
||||
content = baseline.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Use LF for internal processing
|
||||
line_ending = "\n"
|
||||
content = baseline
|
||||
|
||||
for change in snapshot.semantic_changes:
|
||||
if change.content_before and change.content_after:
|
||||
@@ -83,19 +45,13 @@ def apply_single_task_changes(
|
||||
# Addition - need to determine where to add
|
||||
if change.change_type == ChangeType.ADD_IMPORT:
|
||||
# Add import at top
|
||||
lines = content.splitlines()
|
||||
lines = content.split("\n")
|
||||
import_end = find_import_end(lines, file_path)
|
||||
lines.insert(import_end, change.content_after)
|
||||
content = line_ending.join(lines)
|
||||
content = "\n".join(lines)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
# Add function at end (before exports)
|
||||
content += f"{line_ending}{line_ending}{change.content_after}"
|
||||
|
||||
# Restore original line ending style if it was CRLF
|
||||
if original_line_ending == "\r\n":
|
||||
content = content.replace("\n", "\r\n")
|
||||
elif original_line_ending == "\r":
|
||||
content = content.replace("\n", "\r")
|
||||
content += f"\n\n{change.content_after}"
|
||||
|
||||
return content
|
||||
|
||||
@@ -116,16 +72,7 @@ def combine_non_conflicting_changes(
|
||||
Returns:
|
||||
Combined content with all changes applied
|
||||
"""
|
||||
# Detect line ending style before normalizing
|
||||
original_line_ending = detect_line_ending(baseline)
|
||||
|
||||
# Normalize to LF for consistent matching with regex_analyzer output
|
||||
# The regex_analyzer normalizes content to LF when extracting content_before/after,
|
||||
# so we must also normalize baseline to ensure replace() matches correctly
|
||||
content = baseline.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Use LF for internal processing
|
||||
line_ending = "\n"
|
||||
content = baseline
|
||||
|
||||
# Group changes by type for proper ordering
|
||||
imports: list[SemanticChange] = []
|
||||
@@ -149,13 +96,13 @@ def combine_non_conflicting_changes(
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
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:
|
||||
lines.insert(import_end, imp.content_after)
|
||||
import_end += 1
|
||||
content = line_ending.join(lines)
|
||||
content = "\n".join(lines)
|
||||
|
||||
# Apply modifications
|
||||
for mod in modifications:
|
||||
@@ -165,21 +112,15 @@ def combine_non_conflicting_changes(
|
||||
# Add functions
|
||||
for func in functions:
|
||||
if func.content_after:
|
||||
content += f"{line_ending}{line_ending}{func.content_after}"
|
||||
content += f"\n\n{func.content_after}"
|
||||
|
||||
# Apply other changes
|
||||
for change in other:
|
||||
if change.content_after and not change.content_before:
|
||||
content += f"{line_ending}{change.content_after}"
|
||||
content += f"\n{change.content_after}"
|
||||
elif change.content_before and change.content_after:
|
||||
content = content.replace(change.content_before, change.content_after)
|
||||
|
||||
# Restore original line ending style if it was CRLF
|
||||
if original_line_ending == "\r\n":
|
||||
content = content.replace("\n", "\r\n")
|
||||
elif original_line_ending == "\r":
|
||||
content = content.replace("\n", "\r")
|
||||
|
||||
return content
|
||||
|
||||
|
||||
|
||||
@@ -27,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
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Semantic analyzer package for code analysis.
|
||||
Semantic analyzer package for AST-based code analysis.
|
||||
|
||||
This package provides modular semantic analysis capabilities:
|
||||
- models.py: Data structures for extracted elements
|
||||
- python_analyzer.py: Python-specific AST extraction
|
||||
- js_analyzer.py: JavaScript/TypeScript-specific AST extraction
|
||||
- comparison.py: Element comparison and change classification
|
||||
- regex_analyzer.py: Regex-based analysis for code changes
|
||||
- regex_analyzer.py: Fallback regex-based analysis
|
||||
"""
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
JavaScript/TypeScript-specific semantic analysis using tree-sitter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
try:
|
||||
from tree_sitter import Node
|
||||
except ImportError:
|
||||
Node = None
|
||||
|
||||
|
||||
def extract_js_elements(
|
||||
node: Node,
|
||||
elements: dict[str, ExtractedElement],
|
||||
get_text: Callable[[Node], str],
|
||||
get_line: Callable[[int], int],
|
||||
ext: str,
|
||||
parent: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Extract structural elements from JavaScript/TypeScript AST.
|
||||
|
||||
Args:
|
||||
node: The tree-sitter node to extract from
|
||||
elements: Dictionary to populate with extracted elements
|
||||
get_text: Function to extract text from a node
|
||||
get_line: Function to convert byte position to line number
|
||||
ext: File extension (.js, .jsx, .ts, .tsx)
|
||||
parent: Parent element name for nested elements
|
||||
"""
|
||||
for child in node.children:
|
||||
if child.type == "import_statement":
|
||||
text = get_text(child)
|
||||
# Try to extract the source module
|
||||
source_node = child.child_by_field_name("source")
|
||||
if source_node:
|
||||
source = get_text(source_node).strip("'\"")
|
||||
elements[f"import:{source}"] = ExtractedElement(
|
||||
element_type="import",
|
||||
name=source,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=text,
|
||||
)
|
||||
|
||||
elif child.type in {"function_declaration", "function"}:
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
full_name = f"{parent}.{name}" if parent else name
|
||||
elements[f"function:{full_name}"] = ExtractedElement(
|
||||
element_type="function",
|
||||
name=full_name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "arrow_function":
|
||||
# Arrow functions are usually assigned to variables
|
||||
# We'll catch these via variable declarations
|
||||
pass
|
||||
|
||||
elif child.type in {"lexical_declaration", "variable_declaration"}:
|
||||
# const/let/var declarations
|
||||
for declarator in child.children:
|
||||
if declarator.type == "variable_declarator":
|
||||
name_node = declarator.child_by_field_name("name")
|
||||
value_node = declarator.child_by_field_name("value")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
content = get_text(child)
|
||||
|
||||
# Check if it's a function (arrow function or function expression)
|
||||
is_function = False
|
||||
if value_node and value_node.type in {
|
||||
"arrow_function",
|
||||
"function",
|
||||
}:
|
||||
is_function = True
|
||||
elements[f"function:{name}"] = ExtractedElement(
|
||||
element_type="function",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=content,
|
||||
parent=parent,
|
||||
)
|
||||
else:
|
||||
elements[f"variable:{name}"] = ExtractedElement(
|
||||
element_type="variable",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=content,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "class_declaration":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
elements[f"class:{name}"] = ExtractedElement(
|
||||
element_type="class",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
)
|
||||
# Recurse into class body
|
||||
body = child.child_by_field_name("body")
|
||||
if body:
|
||||
extract_js_elements(
|
||||
body, elements, get_text, get_line, ext, parent=name
|
||||
)
|
||||
|
||||
elif child.type == "method_definition":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
full_name = f"{parent}.{name}" if parent else name
|
||||
elements[f"method:{full_name}"] = ExtractedElement(
|
||||
element_type="method",
|
||||
name=full_name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "export_statement":
|
||||
# Recurse into exports to find the actual declaration
|
||||
extract_js_elements(child, elements, get_text, get_line, ext, parent)
|
||||
|
||||
# TypeScript specific
|
||||
elif child.type in {"interface_declaration", "type_alias_declaration"}:
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
elem_type = "interface" if "interface" in child.type else "type"
|
||||
elements[f"{elem_type}:{name}"] = ExtractedElement(
|
||||
element_type=elem_type,
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
)
|
||||
|
||||
# Recurse into statement blocks
|
||||
elif child.type in {"program", "statement_block", "class_body"}:
|
||||
extract_js_elements(child, elements, get_text, get_line, ext, parent)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Python-specific semantic analysis using tree-sitter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
try:
|
||||
from tree_sitter import Node
|
||||
except ImportError:
|
||||
Node = None
|
||||
|
||||
|
||||
def extract_python_elements(
|
||||
node: Node,
|
||||
elements: dict[str, ExtractedElement],
|
||||
get_text: Callable[[Node], str],
|
||||
get_line: Callable[[int], int],
|
||||
parent: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Extract structural elements from Python AST.
|
||||
|
||||
Args:
|
||||
node: The tree-sitter node to extract from
|
||||
elements: Dictionary to populate with extracted elements
|
||||
get_text: Function to extract text from a node
|
||||
get_line: Function to convert byte position to line number
|
||||
parent: Parent element name for nested elements
|
||||
"""
|
||||
for child in node.children:
|
||||
if child.type == "import_statement":
|
||||
# import x, y
|
||||
text = get_text(child)
|
||||
# Extract module names
|
||||
for name_node in child.children:
|
||||
if name_node.type == "dotted_name":
|
||||
name = get_text(name_node)
|
||||
elements[f"import:{name}"] = ExtractedElement(
|
||||
element_type="import",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=text,
|
||||
)
|
||||
|
||||
elif child.type == "import_from_statement":
|
||||
# from x import y, z
|
||||
text = get_text(child)
|
||||
module = None
|
||||
for sub in child.children:
|
||||
if sub.type == "dotted_name":
|
||||
module = get_text(sub)
|
||||
break
|
||||
if module:
|
||||
elements[f"import_from:{module}"] = ExtractedElement(
|
||||
element_type="import_from",
|
||||
name=module,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=text,
|
||||
)
|
||||
|
||||
elif child.type == "function_definition":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
full_name = f"{parent}.{name}" if parent else name
|
||||
elements[f"function:{full_name}"] = ExtractedElement(
|
||||
element_type="function",
|
||||
name=full_name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "class_definition":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
elements[f"class:{name}"] = ExtractedElement(
|
||||
element_type="class",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
)
|
||||
# Recurse into class body for methods
|
||||
body = child.child_by_field_name("body")
|
||||
if body:
|
||||
extract_python_elements(
|
||||
body, elements, get_text, get_line, parent=name
|
||||
)
|
||||
|
||||
elif child.type == "decorated_definition":
|
||||
# Handle decorated functions/classes
|
||||
for sub in child.children:
|
||||
if sub.type in {"function_definition", "class_definition"}:
|
||||
extract_python_elements(child, elements, get_text, get_line, parent)
|
||||
break
|
||||
|
||||
# Recurse for other compound statements
|
||||
elif child.type in {
|
||||
"if_statement",
|
||||
"while_statement",
|
||||
"for_statement",
|
||||
"try_statement",
|
||||
"with_statement",
|
||||
}:
|
||||
extract_python_elements(child, elements, get_text, get_line, parent)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Regex-based semantic analysis for code changes.
|
||||
Regex-based fallback analysis when tree-sitter is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +17,7 @@ def analyze_with_regex(
|
||||
ext: str,
|
||||
) -> FileAnalysis:
|
||||
"""
|
||||
Analyze code changes using regex patterns.
|
||||
Fallback analysis using regex when tree-sitter isn't available.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file being analyzed
|
||||
@@ -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(
|
||||
|
||||
@@ -2,27 +2,32 @@
|
||||
Semantic Analyzer
|
||||
=================
|
||||
|
||||
Analyzes code changes at a semantic level using regex-based heuristics.
|
||||
Analyzes code changes at a semantic level using tree-sitter.
|
||||
|
||||
This module provides analysis of code changes, extracting meaningful
|
||||
semantic changes like "added import", "modified function", "wrapped JSX element"
|
||||
rather than line-level diffs.
|
||||
This module provides AST-based analysis of code changes, extracting
|
||||
meaningful semantic changes like "added import", "modified function",
|
||||
"wrapped JSX element" rather than line-level diffs.
|
||||
|
||||
When tree-sitter is not available, falls back to regex-based heuristics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .types import FileAnalysis
|
||||
from .types import ChangeType, FileAnalysis
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback if debug module not available
|
||||
@@ -38,18 +43,71 @@ except ImportError:
|
||||
def debug_success(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
return False
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MODULE = "merge.semantic_analyzer"
|
||||
|
||||
# Import regex-based analyzer
|
||||
# Try to import tree-sitter - it's optional but recommended
|
||||
TREE_SITTER_AVAILABLE = False
|
||||
try:
|
||||
import tree_sitter # noqa: F401
|
||||
from tree_sitter import Language, Node, Parser, Tree
|
||||
|
||||
TREE_SITTER_AVAILABLE = True
|
||||
logger.info("tree-sitter available, using AST-based analysis")
|
||||
except ImportError:
|
||||
logger.warning("tree-sitter not available, using regex-based fallback")
|
||||
Tree = None
|
||||
Node = None
|
||||
|
||||
# Try to import language bindings
|
||||
LANGUAGES_AVAILABLE: dict[str, Any] = {}
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
try:
|
||||
import tree_sitter_python as tspython
|
||||
|
||||
LANGUAGES_AVAILABLE[".py"] = tspython.language()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import tree_sitter_javascript as tsjs
|
||||
|
||||
LANGUAGES_AVAILABLE[".js"] = tsjs.language()
|
||||
LANGUAGES_AVAILABLE[".jsx"] = tsjs.language()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import tree_sitter_typescript as tsts
|
||||
|
||||
LANGUAGES_AVAILABLE[".ts"] = tsts.language_typescript()
|
||||
LANGUAGES_AVAILABLE[".tsx"] = tsts.language_tsx()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Import our modular components
|
||||
from .semantic_analysis.comparison import compare_elements
|
||||
from .semantic_analysis.models import ExtractedElement
|
||||
from .semantic_analysis.regex_analyzer import analyze_with_regex
|
||||
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
from .semantic_analysis.js_analyzer import extract_js_elements
|
||||
from .semantic_analysis.python_analyzer import extract_python_elements
|
||||
|
||||
|
||||
class SemanticAnalyzer:
|
||||
"""
|
||||
Analyzes code changes at a semantic level using regex-based heuristics.
|
||||
Analyzes code changes at a semantic level.
|
||||
|
||||
Uses tree-sitter for AST-based analysis when available,
|
||||
falling back to regex-based heuristics when not.
|
||||
|
||||
Example:
|
||||
analyzer = SemanticAnalyzer()
|
||||
@@ -59,8 +117,28 @@ class SemanticAnalyzer:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the analyzer."""
|
||||
debug(MODULE, "Initializing SemanticAnalyzer (regex-based)")
|
||||
"""Initialize the analyzer with available parsers."""
|
||||
self._parsers: dict[str, Parser] = {}
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
"Initializing SemanticAnalyzer",
|
||||
tree_sitter_available=TREE_SITTER_AVAILABLE,
|
||||
)
|
||||
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
for ext, lang in LANGUAGES_AVAILABLE.items():
|
||||
parser = Parser()
|
||||
parser.language = Language(lang)
|
||||
self._parsers[ext] = parser
|
||||
debug_detailed(MODULE, f"Initialized parser for {ext}")
|
||||
debug_success(
|
||||
MODULE,
|
||||
"SemanticAnalyzer initialized",
|
||||
parsers=list(self._parsers.keys()),
|
||||
)
|
||||
else:
|
||||
debug(MODULE, "Using regex-based fallback (tree-sitter not available)")
|
||||
|
||||
def analyze_diff(
|
||||
self,
|
||||
@@ -93,8 +171,13 @@ class SemanticAnalyzer:
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# Use regex-based analysis
|
||||
analysis = analyze_with_regex(file_path, before, after, ext)
|
||||
# Use tree-sitter if available for this language
|
||||
if ext in self._parsers:
|
||||
debug_detailed(MODULE, f"Using tree-sitter parser for {ext}")
|
||||
analysis = self._analyze_with_tree_sitter(file_path, before, after, ext)
|
||||
else:
|
||||
debug_detailed(MODULE, f"Using regex fallback for {ext}")
|
||||
analysis = analyze_with_regex(file_path, before, after, ext)
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
@@ -118,6 +201,77 @@ class SemanticAnalyzer:
|
||||
|
||||
return analysis
|
||||
|
||||
def _analyze_with_tree_sitter(
|
||||
self,
|
||||
file_path: str,
|
||||
before: str,
|
||||
after: str,
|
||||
ext: str,
|
||||
) -> FileAnalysis:
|
||||
"""Analyze using tree-sitter AST parsing."""
|
||||
parser = self._parsers[ext]
|
||||
|
||||
tree_before = parser.parse(bytes(before, "utf-8"))
|
||||
tree_after = parser.parse(bytes(after, "utf-8"))
|
||||
|
||||
# Extract structural elements from both versions
|
||||
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)
|
||||
|
||||
# Build the analysis
|
||||
analysis = FileAnalysis(file_path=file_path, changes=changes)
|
||||
|
||||
# Populate summary fields
|
||||
for change in changes:
|
||||
if change.change_type in {
|
||||
ChangeType.MODIFY_FUNCTION,
|
||||
ChangeType.ADD_HOOK_CALL,
|
||||
}:
|
||||
analysis.functions_modified.add(change.target)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
analysis.functions_added.add(change.target)
|
||||
elif change.change_type == ChangeType.ADD_IMPORT:
|
||||
analysis.imports_added.add(change.target)
|
||||
elif change.change_type == ChangeType.REMOVE_IMPORT:
|
||||
analysis.imports_removed.add(change.target)
|
||||
elif change.change_type in {
|
||||
ChangeType.MODIFY_CLASS,
|
||||
ChangeType.ADD_METHOD,
|
||||
}:
|
||||
analysis.classes_modified.add(change.target.split(".")[0])
|
||||
|
||||
analysis.total_lines_changed += change.line_end - change.line_start + 1
|
||||
|
||||
return analysis
|
||||
|
||||
def _extract_elements(
|
||||
self,
|
||||
tree: Tree,
|
||||
source: str,
|
||||
ext: str,
|
||||
) -> dict[str, ExtractedElement]:
|
||||
"""Extract structural elements from a syntax tree."""
|
||||
elements: dict[str, ExtractedElement] = {}
|
||||
source_bytes = bytes(source, "utf-8")
|
||||
|
||||
def get_text(node: Node) -> str:
|
||||
return source_bytes[node.start_byte : node.end_byte].decode("utf-8")
|
||||
|
||||
def get_line(byte_pos: int) -> int:
|
||||
# Convert byte position to line number (1-indexed)
|
||||
return source[:byte_pos].count("\n") + 1
|
||||
|
||||
# Language-specific extraction
|
||||
if ext == ".py":
|
||||
extract_python_elements(tree.root_node, elements, get_text, get_line)
|
||||
elif ext in {".js", ".jsx", ".ts", ".tsx"}:
|
||||
extract_js_elements(tree.root_node, elements, get_text, get_line, ext)
|
||||
|
||||
return elements
|
||||
|
||||
def analyze_file(self, file_path: str, content: str) -> FileAnalysis:
|
||||
"""
|
||||
Analyze a single file's structure (not a diff).
|
||||
@@ -137,7 +291,12 @@ class SemanticAnalyzer:
|
||||
@property
|
||||
def supported_extensions(self) -> set[str]:
|
||||
"""Get the set of supported file extensions."""
|
||||
return {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
# Tree-sitter extensions plus regex fallbacks
|
||||
return set(self._parsers.keys()) | {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
else:
|
||||
# Only regex-supported extensions
|
||||
return {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
|
||||
def is_supported(self, file_path: str) -> bool:
|
||||
"""Check if a file type is supported for semantic analysis."""
|
||||
|
||||
@@ -189,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")
|
||||
|
||||
@@ -16,7 +16,6 @@ Output:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -24,10 +23,6 @@ from typing import Any
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
||||
|
||||
# Minimum Ollama version required for newer embedding models (qwen3-embedding, etc.)
|
||||
# These models were added in Ollama 0.10.0
|
||||
MIN_OLLAMA_VERSION_FOR_NEW_MODELS = "0.10.0"
|
||||
|
||||
# Known embedding models and their dimensions
|
||||
# This list helps identify embedding models from the model name
|
||||
KNOWN_EMBEDDING_MODELS = {
|
||||
@@ -36,26 +31,10 @@ KNOWN_EMBEDDING_MODELS = {
|
||||
"dim": 768,
|
||||
"description": "Google EmbeddingGemma (lightweight)",
|
||||
},
|
||||
"qwen3-embedding": {
|
||||
"dim": 1024,
|
||||
"description": "Qwen3 Embedding (0.6B)",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding:0.6b": {
|
||||
"dim": 1024,
|
||||
"description": "Qwen3 Embedding 0.6B",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding:4b": {
|
||||
"dim": 2560,
|
||||
"description": "Qwen3 Embedding 4B",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding:8b": {
|
||||
"dim": 4096,
|
||||
"description": "Qwen3 Embedding 8B",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding": {"dim": 1024, "description": "Qwen3 Embedding (0.6B)"},
|
||||
"qwen3-embedding:0.6b": {"dim": 1024, "description": "Qwen3 Embedding 0.6B"},
|
||||
"qwen3-embedding:4b": {"dim": 2560, "description": "Qwen3 Embedding 4B"},
|
||||
"qwen3-embedding:8b": {"dim": 4096, "description": "Qwen3 Embedding 8B"},
|
||||
"bge-base-en": {"dim": 768, "description": "BAAI General Embedding - Base"},
|
||||
"bge-large-en": {"dim": 1024, "description": "BAAI General Embedding - Large"},
|
||||
"bge-small-en": {"dim": 384, "description": "BAAI General Embedding - Small"},
|
||||
@@ -78,36 +57,18 @@ KNOWN_EMBEDDING_MODELS = {
|
||||
|
||||
# Recommended embedding models for download (shown in UI)
|
||||
RECOMMENDED_EMBEDDING_MODELS = [
|
||||
{
|
||||
"name": "qwen3-embedding:4b",
|
||||
"description": "Qwen3 4B - Balanced quality and speed",
|
||||
"size_estimate": "3.1 GB",
|
||||
"dim": 2560,
|
||||
"badge": "recommended",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:8b",
|
||||
"description": "Qwen3 8B - Best embedding quality",
|
||||
"size_estimate": "6.0 GB",
|
||||
"dim": 4096,
|
||||
"badge": "quality",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
"description": "Qwen3 0.6B - Smallest and fastest",
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
"badge": "fast",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
"description": "Google's lightweight embedding model (768 dim)",
|
||||
"size_estimate": "621 MB",
|
||||
"dim": 768,
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
"description": "Qwen3 small embedding model (1024 dim)",
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
},
|
||||
{
|
||||
"name": "nomic-embed-text",
|
||||
"description": "Popular general-purpose embeddings (768 dim)",
|
||||
@@ -136,22 +97,6 @@ EMBEDDING_PATTERNS = [
|
||||
]
|
||||
|
||||
|
||||
def parse_version(version_str: str | None) -> tuple[int, ...]:
|
||||
"""Parse a version string like '0.10.0' into a tuple for comparison."""
|
||||
if not version_str or not isinstance(version_str, str):
|
||||
return (0, 0, 0)
|
||||
# Extract just the numeric parts (handles versions like "0.10.0-rc1")
|
||||
match = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str)
|
||||
if match:
|
||||
return tuple(int(x) for x in match.groups())
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def version_gte(version: str | None, min_version: str | None) -> bool:
|
||||
"""Check if version >= min_version."""
|
||||
return parse_version(version) >= parse_version(min_version)
|
||||
|
||||
|
||||
def output_json(success: bool, data: Any = None, error: str | None = None) -> None:
|
||||
"""Output JSON result to stdout and exit."""
|
||||
result = {"success": success}
|
||||
@@ -185,14 +130,6 @@ def fetch_ollama_api(base_url: str, endpoint: str, timeout: int = 5) -> dict | N
|
||||
return None
|
||||
|
||||
|
||||
def get_ollama_version(base_url: str) -> str | None:
|
||||
"""Get the Ollama server version."""
|
||||
result = fetch_ollama_api(base_url, "api/version")
|
||||
if result:
|
||||
return result.get("version")
|
||||
return None
|
||||
|
||||
|
||||
def is_embedding_model(model_name: str) -> bool:
|
||||
"""Check if a model name suggests it's an embedding model."""
|
||||
name_lower = model_name.lower()
|
||||
@@ -240,19 +177,6 @@ def get_embedding_description(model_name: str) -> str:
|
||||
return "Embedding model"
|
||||
|
||||
|
||||
def get_model_min_version(model_name: str) -> str | None:
|
||||
"""Get the minimum Ollama version required for a model."""
|
||||
name_lower = model_name.lower()
|
||||
|
||||
# Sort keys by length descending to match more specific names first
|
||||
# e.g., "qwen3-embedding:8b" before "qwen3-embedding"
|
||||
for known_model in sorted(KNOWN_EMBEDDING_MODELS.keys(), key=len, reverse=True):
|
||||
if known_model in name_lower:
|
||||
return KNOWN_EMBEDDING_MODELS[known_model].get("min_version")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def cmd_check_status(args) -> None:
|
||||
"""Check if Ollama is running and accessible."""
|
||||
base_url = args.base_url or DEFAULT_OLLAMA_URL
|
||||
@@ -261,18 +185,12 @@ def cmd_check_status(args) -> None:
|
||||
result = fetch_ollama_api(base_url, "api/version")
|
||||
|
||||
if result:
|
||||
version = result.get("version", "unknown")
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"running": True,
|
||||
"url": base_url,
|
||||
"version": version,
|
||||
"supports_new_models": version_gte(
|
||||
version, MIN_OLLAMA_VERSION_FOR_NEW_MODELS
|
||||
)
|
||||
if version != "unknown"
|
||||
else None,
|
||||
"version": result.get("version", "unknown"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
@@ -386,9 +304,6 @@ def cmd_get_recommended_models(args) -> None:
|
||||
"""Get recommended embedding models with install status."""
|
||||
base_url = args.base_url or DEFAULT_OLLAMA_URL
|
||||
|
||||
# Get Ollama version for compatibility checking
|
||||
ollama_version = get_ollama_version(base_url)
|
||||
|
||||
# Get currently installed models
|
||||
result = fetch_ollama_api(base_url, "api/tags")
|
||||
installed_names = set()
|
||||
@@ -400,30 +315,17 @@ def cmd_get_recommended_models(args) -> None:
|
||||
installed_names.add(name)
|
||||
installed_names.add(base_name)
|
||||
|
||||
# Build recommended list with install status and compatibility
|
||||
# Build recommended list with install status
|
||||
recommended = []
|
||||
for model in RECOMMENDED_EMBEDDING_MODELS:
|
||||
name = model["name"]
|
||||
base_name = name.split(":")[0] if ":" in name else name
|
||||
is_installed = name in installed_names or base_name in installed_names
|
||||
|
||||
# Check version compatibility
|
||||
min_version = model.get("min_ollama_version")
|
||||
is_compatible = True
|
||||
compatibility_note = None
|
||||
if min_version and ollama_version:
|
||||
is_compatible = version_gte(ollama_version, min_version)
|
||||
if not is_compatible:
|
||||
compatibility_note = f"Requires Ollama {min_version}+"
|
||||
elif min_version and not ollama_version:
|
||||
compatibility_note = "Version compatibility could not be verified"
|
||||
|
||||
recommended.append(
|
||||
{
|
||||
**model,
|
||||
"installed": is_installed,
|
||||
"compatible": is_compatible,
|
||||
"compatibility_note": compatibility_note,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -433,94 +335,55 @@ def cmd_get_recommended_models(args) -> None:
|
||||
"recommended": recommended,
|
||||
"count": len(recommended),
|
||||
"url": base_url,
|
||||
"ollama_version": ollama_version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def cmd_pull_model(args) -> None:
|
||||
"""Pull (download) an Ollama model using the HTTP API for progress tracking."""
|
||||
model_name = args.model
|
||||
base_url = getattr(args, "base_url", None) or DEFAULT_OLLAMA_URL
|
||||
"""Pull (download) an Ollama model."""
|
||||
import subprocess
|
||||
|
||||
model_name = args.model
|
||||
if not model_name:
|
||||
output_error("Model name is required")
|
||||
return
|
||||
|
||||
# Check Ollama version compatibility before attempting pull
|
||||
ollama_version = get_ollama_version(base_url)
|
||||
min_version = get_model_min_version(model_name)
|
||||
|
||||
if min_version and ollama_version:
|
||||
if not version_gte(ollama_version, min_version):
|
||||
output_error(
|
||||
f"Model '{model_name}' requires Ollama {min_version} or newer. "
|
||||
f"Your version is {ollama_version}. "
|
||||
f"Please upgrade Ollama: https://ollama.com/download"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
url = f"{base_url.rstrip('/')}/api/pull"
|
||||
data = json.dumps({"name": model_name}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=600) as response:
|
||||
# Ollama streams NDJSON (newline-delimited JSON) progress
|
||||
for line in response:
|
||||
try:
|
||||
progress = json.loads(line.decode("utf-8"))
|
||||
|
||||
# Check for error in the streaming response
|
||||
# This handles cases like "requires newer version of Ollama"
|
||||
if "error" in progress:
|
||||
error_msg = progress["error"]
|
||||
# Clean up the error message (remove extra whitespace/newlines)
|
||||
error_msg = " ".join(error_msg.split())
|
||||
# Check if it's a version-related error
|
||||
if "newer version" in error_msg.lower():
|
||||
error_msg = (
|
||||
f"Model '{model_name}' requires a newer version of Ollama. "
|
||||
f"Your version: {ollama_version or 'unknown'}. "
|
||||
f"Please upgrade: https://ollama.com/download"
|
||||
)
|
||||
output_error(error_msg)
|
||||
return
|
||||
|
||||
# Emit progress as NDJSON to stderr for main process to parse
|
||||
if "completed" in progress and "total" in progress:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": progress.get("status", "downloading"),
|
||||
"completed": progress.get("completed", 0),
|
||||
"total": progress.get("total", 0),
|
||||
}
|
||||
),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif progress.get("status") == "success":
|
||||
# Download complete
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"model": model_name,
|
||||
"status": "completed",
|
||||
"output": ["Download completed successfully"],
|
||||
},
|
||||
# Run ollama pull command
|
||||
process = subprocess.Popen(
|
||||
["ollama", "pull", model_name],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
output_error(f"Failed to connect to Ollama: {str(e)}")
|
||||
except urllib.error.HTTPError as e:
|
||||
output_error(f"Ollama API error: {e.code} - {e.reason}")
|
||||
output_lines = []
|
||||
for line in iter(process.stdout.readline, ""):
|
||||
line = line.strip()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
# Print progress to stderr for streaming
|
||||
print(line, file=sys.stderr, flush=True)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"model": model_name,
|
||||
"status": "completed",
|
||||
"output": output_lines,
|
||||
},
|
||||
)
|
||||
else:
|
||||
output_json(
|
||||
False, error=f"Failed to pull model: {' '.join(output_lines[-3:])}"
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
output_error("Ollama CLI not found. Please install Ollama first.")
|
||||
except Exception as e:
|
||||
output_error(f"Failed to pull model: {str(e)}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user