Compare commits

..

1 Commits

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

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

The following files were modified:

* `apps/backend/cli/batch_commands.py`
* `apps/backend/cli/main.py`
* `apps/backend/core/config.py`
* `apps/backend/core/workspace/models.py`
* `apps/backend/core/worktree.py`
* `apps/frontend/src/renderer/components/project-settings/GeneralSettings.tsx`
* `apps/frontend/src/renderer/components/project-settings/WorktreeSettings.tsx`
* `apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx`
2025-12-31 23:00:13 +00:00
391 changed files with 7076 additions and 48958 deletions
+8 -60
View File
@@ -71,11 +71,6 @@ jobs:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -97,21 +92,13 @@ jobs:
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -166,11 +153,6 @@ jobs:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -189,21 +171,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-arm64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-3.12.8-
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -257,11 +231,6 @@ jobs:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -281,21 +250,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~\AppData\Local\pip\Cache
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -327,11 +288,6 @@ jobs:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -359,21 +315,13 @@ jobs:
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
+30 -693
View File
@@ -1,386 +1,49 @@
# ╔═══════════════════════════════════════════════════════════════════════════════╗
# ║ ║
# ║ AUTO CLAUDE - CI PIPELINE ║
# ║ ║
# ║ A unified, enterprise-grade CI workflow for pull request validation ║
# ║ and scheduled security scanning. ║
# ║ ║
# ║ TRIGGERS: ║
# ║ - Pull requests to main/develop branches ║
# ║ - Weekly scheduled security scans (Monday 00:00 UTC) ║
# ║ - Manual workflow dispatch ║
# ║ ║
# ║ WORKFLOW STAGES: ║
# ║ ┌─────────────────────────────────────────────────────────────────────────┐ ║
# ║ │ Stage 1: PR Setup - Label PR and set "Checking" status │ ║
# ║ │ Stage 2: Change Detection - Determine which tests to run │ ║
# ║ │ Stage 3: Quality Gates - Run tests, linting, and security scans │ ║
# ║ │ Stage 4: Status Update - Update PR with final status │ ║
# ║ └─────────────────────────────────────────────────────────────────────────┘ ║
# ║ ║
# ║ SMART PATH FILTERING (PR mode): ║
# ║ - Backend changes (apps/backend/**, tests/**) → Python tests + lint ║
# ║ - Frontend changes (apps/frontend/**) → Frontend tests + build ║
# ║ - No code changes (docs, CI configs only) → Skip tests, mark ready ║
# ║ ║
# ║ SCHEDULED SECURITY SCANS: ║
# ║ - Weekly CodeQL analysis for Python and JavaScript/TypeScript ║
# ║ - Weekly Bandit security scan for Python backend ║
# ║ - Detects newly discovered CVEs in existing code ║
# ║ ║
# ║ LABELS APPLIED (PR mode only): ║
# ║ - Status: 🔄 Checking → ✅ Ready for Review / ❌ Checks Failed ║
# ║ - Type: feature, bug, docs, refactor, ci, chore (from PR title) ║
# ║ - Area: area/frontend, area/backend, area/fullstack, area/ci ║
# ║ - Size: size/XS, size/S, size/M, size/L, size/XL ║
# ║ ║
# ║ MAINTAINERS: See CONTRIBUTING.md for workflow modification guidelines. ║
# ║ ║
# ╚═══════════════════════════════════════════════════════════════════════════════╝
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
types: [opened, synchronize, reopened]
# Weekly scheduled security scans to detect newly discovered CVEs
# Runs every Monday at midnight UTC
schedule:
- cron: '0 0 * * 1'
# Allow manual triggering for security scans
workflow_dispatch:
# ─────────────────────────────────────────────────────────────────────────────────
# CONCURRENCY: Cancel redundant runs when new commits are pushed to the same PR
# ─────────────────────────────────────────────────────────────────────────────────
concurrency:
group: ci-${{ github.head_ref || github.ref }}
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────────
# PERMISSIONS: Minimal permissions required for this workflow
# ─────────────────────────────────────────────────────────────────────────────────
permissions:
contents: read # Read repository contents
actions: read # Read workflow runs
security-events: write # Upload CodeQL results
pull-requests: write # Update PR labels
checks: read # Read check run status
# ═══════════════════════════════════════════════════════════════════════════════════
# JOBS
# ═══════════════════════════════════════════════════════════════════════════════════
contents: read
actions: read
jobs:
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 1: PR SETUP │
# │ │
# │ Purpose: Initialize PR with labels and "Checking" status │
# │ Runs: First, before any other job │
# │ Labels: Status (Checking), Type, Area, Size │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-1-setup:
name: "Stage 1: PR Setup"
# Python tests
test-python:
runs-on: ubuntu-latest
# Skip for fork PRs (they cannot write labels) and scheduled runs (no PR context)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: "1.1 Apply Labels and Set Checking Status"
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`\n${'═'.repeat(60)}`);
console.log(` PR #${prNumber}: ${title}`);
console.log(`${'═'.repeat(60)}\n`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ─────────────────────────────────────────────────────────────
// STATUS LABEL: Set to "Checking" while CI runs
// ─────────────────────────────────────────────────────────────
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
statusLabels.forEach(l => labelsToRemove.add(l));
labelsToAdd.add('🔄 Checking');
console.log('Status: 🔄 Checking');
// ─────────────────────────────────────────────────────────────
// TYPE LABEL: Extracted from Conventional Commit prefix
// Format: type(scope)!: description
// Examples: feat:, fix(ui):, docs!:, refactor(api):
// ─────────────────────────────────────────────────────────────
const typeMap = {
'feat': 'feature', // New feature
'fix': 'bug', // Bug fix
'docs': 'documentation',// Documentation only
'refactor': 'refactor', // Code refactoring
'test': 'test', // Adding/updating tests
'ci': 'ci', // CI/CD changes
'chore': 'chore', // Maintenance tasks
'perf': 'performance', // Performance improvements
'style': 'style', // Code style changes
'build': 'build' // Build system changes
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(`Type: ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log('⚠️ Breaking change detected');
}
} else {
console.log('Type: (no conventional commit prefix detected)');
}
// ─────────────────────────────────────────────────────────────
// AREA LABEL: Determined by which files were changed
// ─────────────────────────────────────────────────────────────
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner, repo, pull_number: prNumber, per_page: 100
});
files = data;
} catch (e) {
console.log(`Warning: Could not fetch changed files: ${e.message}`);
}
const areas = { frontend: false, backend: false, ci: false };
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/') || path.startsWith('tests/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
}
// Area labels are mutually exclusive
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
let areaLabel = null;
if (areas.frontend && areas.backend) {
areaLabel = 'area/fullstack';
} else if (areas.frontend) {
areaLabel = 'area/frontend';
} else if (areas.backend) {
areaLabel = 'area/backend';
} else if (areas.ci) {
areaLabel = 'area/ci';
}
if (areaLabel) {
labelsToAdd.add(areaLabel);
areaLabels.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
console.log(`Area: ${areaLabel.replace('area/', '')}`);
}
// ─────────────────────────────────────────────────────────────
// SIZE LABEL: Based on total lines changed
// XS: <10, S: <100, M: <500, L: <1000, XL: >=1000
// ─────────────────────────────────────────────────────────────
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
const sizeLabel = totalLines < 10 ? 'size/XS' :
totalLines < 100 ? 'size/S' :
totalLines < 500 ? 'size/M' :
totalLines < 1000 ? 'size/L' : 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(`Size: ${sizeLabel.replace('size/', '')} (+${additions}/-${deletions} = ${totalLines} lines)`);
// ─────────────────────────────────────────────────────────────
// APPLY LABELS
// ─────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(60)}`);
console.log('Applying labels...');
// Remove old labels first
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
for (const label of removeArray) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: addArray
});
console.log(`✓ Labels applied: ${addArray.join(', ')}`);
} catch (e) {
if (e.status === 404) {
core.warning('Some labels do not exist. Please create them in Settings > Labels.');
// Try adding labels one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
}
}
}
console.log(`${'─'.repeat(60)}\n`);
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 2: CHANGE DETECTION │
# │ │
# │ Purpose: Analyze changed files to determine which tests to run │
# │ Runs: After Stage 1 completes │
# │ Outputs: backend, frontend, any_code, skip_tests │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-2-changes:
name: "Stage 2: Detect Changes"
runs-on: ubuntu-latest
needs: stage-1-setup
# Always run even if stage-1 was skipped (fork PRs)
if: always()
timeout-minutes: 5
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
any_code: ${{ steps.filter.outputs.any_code }}
skip_tests: ${{ steps.evaluate.outputs.skip_tests }}
scheduled_scan: ${{ steps.evaluate.outputs.scheduled_scan }}
steps:
- name: "2.1 Checkout Repository"
uses: actions/checkout@v4
- name: "2.2 Analyze Changed Files"
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
backend:
- 'apps/backend/**'
- 'tests/**'
- 'requirements*.txt'
frontend:
- 'apps/frontend/**'
- 'package*.json'
any_code:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- name: "2.3 Evaluate Test Requirements"
id: evaluate
run: |
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " CHANGE DETECTION RESULTS"
echo "═══════════════════════════════════════════════════════════"
echo ""
# For scheduled runs, always run security scans on all code
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo " Event type: ${{ github.event_name }}"
echo " → Scheduled/manual security scan - running all checks"
echo ""
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo "scheduled_scan=true" >> $GITHUB_OUTPUT
else
echo " Backend changes: ${{ steps.filter.outputs.backend }}"
echo " Frontend changes: ${{ steps.filter.outputs.frontend }}"
echo " Any code changes: ${{ steps.filter.outputs.any_code }}"
echo ""
echo "scheduled_scan=false" >> $GITHUB_OUTPUT
if [ "${{ steps.filter.outputs.any_code }}" = "false" ]; then
echo "skip_tests=true" >> $GITHUB_OUTPUT
echo " → No code changes detected"
echo " → Tests will be SKIPPED (docs/config only changes)"
else
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo " → Code changes detected"
echo " → Tests will be EXECUTED"
fi
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 3: QUALITY GATES │
# │ │
# │ Purpose: Run tests, linting, and security scans based on detected changes │
# │ Runs: After Stage 2, jobs run in parallel where possible │
# │ Jobs: Python Tests, Frontend Tests, Python Lint, CodeQL, Bandit │
# └─────────────────────────────────────────────────────────────────────────────┘
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON TESTS: Run pytest across Python version matrix
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-python:
name: "Stage 3: Python Tests (${{ matrix.python-version }})"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- name: "3.1 Checkout Repository"
- name: Checkout
uses: actions/checkout@v4
- name: "3.2 Setup Python ${{ matrix.python-version }}"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: "3.3 Setup UV Package Manager"
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: "3.4 Install Dependencies"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: "3.5 Run Test Suite"
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
@@ -388,20 +51,16 @@ jobs:
source .venv/bin/activate
pytest ../../tests/ -v --tb=short -x
- name: "3.6 Run Tests with Coverage"
- name: Run tests with coverage
if: matrix.python-version == '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v \
--cov=. \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=20
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
- name: "3.7 Upload Coverage to Codecov"
- name: Upload coverage reports
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
@@ -410,366 +69,44 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ─────────────────────────────────────────────────────────────────────────────
# FRONTEND TESTS: Lint, typecheck, test, and build
# Triggered: When frontend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-frontend:
name: "Stage 3: Frontend Tests"
# Frontend lint, typecheck, test, and build
test-frontend:
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.frontend == 'true'
timeout-minutes: 15
steps:
- name: "3.1 Checkout Repository"
- name: Checkout
uses: actions/checkout@v4
- name: "3.2 Setup Node.js"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: "3.3 Cache npm Dependencies"
uses: actions/cache@v4
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ~/.npm
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: "3.4 Install Dependencies"
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: "3.5 Run ESLint"
- name: Lint
working-directory: apps/frontend
run: npm run lint
- name: "3.6 Run TypeScript Type Check"
- name: Type check
working-directory: apps/frontend
run: npm run typecheck
- name: "3.7 Run Unit Tests"
- name: Run tests
working-directory: apps/frontend
run: npm run test
- name: "3.8 Build Application"
- name: Build
working-directory: apps/frontend
run: npm run build
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON LINT: Check code style and formatting with Ruff
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-lint-python:
name: "Stage 3: Python Lint"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 10
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Ruff version pinned to match .pre-commit-config.yaml
- name: "3.3 Install Ruff"
run: pip install ruff==0.14.10
- name: "3.4 Run Ruff Linter"
run: ruff check apps/backend/ --output-format=github
- name: "3.5 Check Code Formatting"
run: ruff format apps/backend/ --check --diff
# ─────────────────────────────────────────────────────────────────────────────
# CODEQL: Static analysis for security vulnerabilities
# Triggered: When any code files change OR on scheduled/manual security scans
# ─────────────────────────────────────────────────────────────────────────────
stage-3-codeql:
name: "Stage 3: CodeQL (${{ matrix.language }})"
runs-on: ubuntu-latest
needs: stage-2-changes
# Run on code changes OR scheduled/manual security scans
if: needs.stage-2-changes.outputs.any_code == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Initialize CodeQL"
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: "3.3 Autobuild"
uses: github/codeql-action/autobuild@v3
- name: "3.4 Run CodeQL Analysis"
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON SECURITY: Bandit security scanner for Python code
# Triggered: When backend files change OR on scheduled/manual security scans
# ─────────────────────────────────────────────────────────────────────────────
stage-3-security-python:
name: "Stage 3: Python Security"
runs-on: ubuntu-latest
needs: stage-2-changes
# Run on backend changes OR scheduled/manual security scans
if: needs.stage-2-changes.outputs.backend == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
timeout-minutes: 10
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: "3.3 Install Bandit"
run: pip install bandit
- name: "3.4 Run Bandit Security Scan"
id: bandit
run: |
# Run Bandit and capture exit code
# Exit 0 = no issues, Exit 1 = issues found, Exit > 1 = real error
set +e
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json
BANDIT_EXIT=$?
set -e
if [ $BANDIT_EXIT -eq 0 ]; then
echo "✓ Bandit scan completed - no issues found"
echo "scan_status=clean" >> $GITHUB_OUTPUT
elif [ $BANDIT_EXIT -eq 1 ]; then
echo "⚠ Bandit scan completed - security issues found"
echo "scan_status=issues_found" >> $GITHUB_OUTPUT
else
echo "✗ Bandit scan failed with exit code $BANDIT_EXIT"
echo " This indicates a configuration error or missing directory"
exit $BANDIT_EXIT
fi
- name: "3.5 Analyze Security Results"
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const scanStatus = '${{ steps.bandit.outputs.scan_status }}';
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan failed before producing output');
return;
}
// If scan was clean, we can skip detailed analysis
if (scanStatus === 'clean') {
console.log('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log('\n✓ No security issues found\n');
console.log('═'.repeat(60) + '\n');
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('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log(`\n HIGH: ${high.length}`);
console.log(` MEDIUM: ${medium.length}`);
console.log(` LOW: ${low.length}\n`);
if (high.length > 0) {
console.log('─'.repeat(60));
console.log(' HIGH SEVERITY ISSUES:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(`\n 📍 ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
}
console.log('\n' + '═'.repeat(60));
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✓ No high severity security issues found');
console.log('═'.repeat(60) + '\n');
}
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 4: STATUS UPDATE │
# │ │
# │ Purpose: Aggregate results from all quality gates and update PR status │
# │ Runs: After ALL Stage 3 jobs complete (success, failure, or skipped) │
# │ Updates: PR label to "Ready for Review" or "Checks Failed" │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-4-status:
name: "Stage 4: Update PR Status"
runs-on: ubuntu-latest
needs:
- stage-2-changes
- stage-3-test-python
- stage-3-test-frontend
- stage-3-lint-python
- stage-3-codeql
- stage-3-security-python
# Always run to update status, even if previous jobs failed or were skipped
# For scheduled runs, skip PR label updates (no PR context)
if: always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
timeout-minutes: 5
steps:
- name: "4.1 Evaluate CI Results and Update PR"
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const eventName = '${{ github.event_name }}';
const isScheduledRun = eventName === 'schedule' || eventName === 'workflow_dispatch';
const prNumber = context.payload.pull_request?.number;
// ─────────────────────────────────────────────────────────────
// COLLECT RESULTS FROM ALL QUALITY GATE JOBS
// ─────────────────────────────────────────────────────────────
const results = {
'Python Tests': '${{ needs.stage-3-test-python.result }}',
'Frontend Tests': '${{ needs.stage-3-test-frontend.result }}',
'Python Lint': '${{ needs.stage-3-lint-python.result }}',
'CodeQL': '${{ needs.stage-3-codeql.result }}',
'Python Security': '${{ needs.stage-3-security-python.result }}'
};
const skipTests = '${{ needs.stage-2-changes.outputs.skip_tests }}' === 'true';
console.log('\n' + '═'.repeat(60));
if (isScheduledRun) {
console.log(' SCHEDULED SECURITY SCAN RESULTS');
} else {
console.log(' CI PIPELINE RESULTS');
}
console.log('═'.repeat(60) + '\n');
if (skipTests && !isScheduledRun) {
console.log(' ️ No code changes detected - tests were skipped\n');
}
console.log(' Job Results:');
console.log(' ' + '─'.repeat(40));
for (const [job, result] of Object.entries(results)) {
const icon = result === 'success' ? '✓' :
result === 'skipped' ? '○' :
result === 'failure' ? '✗' : '?';
console.log(` ${icon} ${job}: ${result}`);
}
console.log(' ' + '─'.repeat(40) + '\n');
// ─────────────────────────────────────────────────────────────
// DETERMINE FINAL STATUS
// Success and skipped are acceptable; failure is not
// ─────────────────────────────────────────────────────────────
const acceptable = ['success', 'skipped'];
const failed = Object.entries(results)
.filter(([_, result]) => !acceptable.includes(result))
.map(([job, _]) => job);
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
// ─────────────────────────────────────────────────────────────
// UPDATE PR LABELS (skip for scheduled runs - no PR context)
// ─────────────────────────────────────────────────────────────
if (!isScheduledRun && prNumber) {
// Remove all status labels first
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add appropriate status label
const newLabel = failed.length > 0 ? statusLabels.failed : statusLabels.passed;
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [newLabel]
});
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in Settings > Labels.`);
}
}
}
// ─────────────────────────────────────────────────────────────
// GENERATE SUMMARY
// ─────────────────────────────────────────────────────────────
if (failed.length > 0) {
const resultType = isScheduledRun ? 'SECURITY SCAN FAILED' : 'CI FAILED';
console.log(` ❌ RESULT: ${resultType}`);
console.log(` Failed jobs: ${failed.join(', ')}`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ❌ ${resultType}\n\n`);
core.summary.addRaw(`The following checks failed:\n`);
for (const job of failed) {
core.summary.addRaw(`- ${job}\n`);
}
core.setFailed(`${resultType}: ${failed.join(', ')}`);
} else if (isScheduledRun) {
console.log(` ✅ RESULT: SECURITY SCAN PASSED`);
console.log(` All security checks passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Weekly Security Scan Passed\n\n`);
core.summary.addRaw(`All scheduled security checks (CodeQL, Bandit) completed successfully.\n`);
} else if (skipTests) {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` No code changes - tests skipped`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`No code changes detected. Documentation or configuration changes only.\n`);
} else {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` All quality gates passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`All CI checks passed successfully.\n`);
}
await core.summary.write();
-121
View File
@@ -1,121 +0,0 @@
name: Community
# Consolidated community automation:
# - Welcome messages for first-time contributors (issues & PRs)
# - Auto-label issues based on form selection
# - Mark and close stale issues
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
workflow_dispatch: # Allow manual trigger for stale check
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# WELCOME - First-time contributor welcome messages
# ═══════════════════════════════════════════════════════════════════════════
welcome:
name: Welcome New Contributors
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target' || github.event_name == 'issues'
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1.4.0
with:
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
pr-message: |
Thanks for your first PR!
A maintainer will review it soon. Please make sure:
- Your branch is synced with `develop`
- 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!
# ═══════════════════════════════════════════════════════════════════════════
# ISSUE LABELS - Auto-label issues based on form area selection
# ═══════════════════════════════════════════════════════════════════════════
issue-labels:
name: Label Issue by Area
runs-on: ubuntu-latest
if: github.event_name == 'issues'
permissions:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || '';
console.log(`Processing issue #${issue.number}: ${issue.title}`);
// Map form selection to label
const areaMap = {
'Frontend': 'area/frontend',
'Backend': 'area/backend',
'Fullstack': 'area/fullstack'
};
const labels = [];
for (const [key, label] of Object.entries(areaMap)) {
if (body.includes(key)) {
console.log(`Found area: ${key}, adding label: ${label}`);
labels.push(label);
break;
}
}
if (labels.length > 0) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Successfully added labels: ${labels.join(', ')}`);
} catch (error) {
core.setFailed(`Failed to add labels: ${error.message}`);
}
} else {
console.log('No matching area found in issue body');
}
# ═══════════════════════════════════════════════════════════════════════════
# STALE - Mark and close inactive issues
# ═══════════════════════════════════════════════════════════════════════════
stale:
name: Mark Stale Issues
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
permissions:
issues: write
steps:
- uses: actions/stale@v9
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.'
stale-issue-label: 'stale'
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
+53
View File
@@ -0,0 +1,53 @@
name: Issue Auto Label
on:
issues:
types: [opened]
jobs:
label-area:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || '';
console.log(`Processing issue #${issue.number}: ${issue.title}`);
// Map form selection to label
const areaMap = {
'Frontend': 'area/frontend',
'Backend': 'area/backend',
'Fullstack': 'area/fullstack'
};
const labels = [];
for (const [key, label] of Object.entries(areaMap)) {
if (body.includes(key)) {
console.log(`Found area: ${key}, adding label: ${label}`);
labels.push(label);
break;
}
}
if (labels.length > 0) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Successfully added labels: ${labels.join(', ')}`);
} catch (error) {
core.setFailed(`Failed to add labels: ${error.message}`);
}
} else {
console.log('No matching area found in issue body');
}
+34
View File
@@ -0,0 +1,34 @@
name: Lint
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Python linting
python:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
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
- name: Run ruff check
run: ruff check apps/backend/ --output-format=github
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
+227
View File
@@ -0,0 +1,227 @@
name: PR Auto Label
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR
concurrency:
group: pr-auto-label-${{ 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
# Don't run on fork PRs (they can't write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Auto-label PR
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`::group::PR #${prNumber} - Auto-labeling`);
console.log(`Title: ${title}`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ═══════════════════════════════════════════════════════════════
// TYPE LABELS (from PR title - Conventional Commits)
// ═══════════════════════════════════════════════════════════════
const typeMap = {
'feat': 'feature',
'fix': 'bug',
'docs': 'documentation',
'refactor': 'refactor',
'test': 'test',
'ci': 'ci',
'chore': 'chore',
'perf': 'performance',
'style': 'style',
'build': 'build'
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log(` ⚠️ Breaking change detected`);
}
} else {
console.log(` ⚠️ No conventional commit prefix found in title`);
}
// ═══════════════════════════════════════════════════════════════
// AREA LABELS (from changed files)
// ═══════════════════════════════════════════════════════════════
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100
});
files = data;
} catch (e) {
console.log(` ⚠️ Could not fetch files: ${e.message}`);
}
const areas = {
frontend: false,
backend: false,
ci: false,
docs: false,
tests: false
};
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
}
// Determine area label (mutually exclusive)
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
if (areas.frontend && areas.backend) {
labelsToAdd.add('area/fullstack');
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: fullstack (${files.length} files)`);
} else if (areas.frontend) {
labelsToAdd.add('area/frontend');
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: frontend (${files.length} files)`);
} else if (areas.backend) {
labelsToAdd.add('area/backend');
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: backend (${files.length} files)`);
} else if (areas.ci) {
labelsToAdd.add('area/ci');
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: ci (${files.length} files)`);
}
// ═══════════════════════════════════════════════════════════════
// SIZE LABELS (from lines changed)
// ═══════════════════════════════════════════════════════════════
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
let sizeLabel;
if (totalLines < 10) sizeLabel = 'size/XS';
else if (totalLines < 100) sizeLabel = 'size/S';
else if (totalLines < 500) sizeLabel = 'size/M';
else if (totalLines < 1000) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
console.log('::endgroup::');
// ═══════════════════════════════════════════════════════════════
// APPLY LABELS
// ═══════════════════════════════════════════════════════════════
console.log(`::group::Applying labels`);
// Remove old labels (in parallel)
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
if (removeArray.length > 0) {
const removePromises = removeArray.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: addArray
});
console.log(` ✓ Added: ${addArray.join(', ')}`);
} catch (e) {
// Some labels might not exist
if (e.status === 404) {
core.warning(`Some labels do not exist. Please create them in repository settings.`);
// Try adding one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
} else {
throw e;
}
}
}
console.log('::endgroup::');
// Summary
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
// Write job summary
core.summary
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
.addTable([
[{data: 'Category', header: true}, {data: 'Label', header: true}],
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
['Size', sizeLabel]
])
.addRaw(`\n**Files changed:** ${files.length}\n`)
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
await core.summary.write();
+72
View File
@@ -0,0 +1,72 @@
name: PR Status Check
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR
concurrency:
group: pr-status-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
pull-requests: write
jobs:
mark-checking:
name: Set Checking Status
runs-on: ubuntu-latest
# Don't run on fork PRs (they can't write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Update PR status label
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 statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
// Remove old status labels (parallel for speed)
const removePromises = statusLabels.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
// Add checking label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: ['🔄 Checking']
});
console.log(` ✓ Added: 🔄 Checking`);
} catch (e) {
// Label might not exist - create helpful error
if (e.status === 404) {
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
console.log(`✅ PR #${prNumber} marked as checking`);
+195
View File
@@ -0,0 +1,195 @@
name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security, CLA Assistant, Quality Commit Lint]
types: [completed]
permissions:
pull-requests: write
checks: read
jobs:
update-status:
name: Update PR Status
runs-on: ubuntu-latest
# Only run if this workflow_run is associated with a PR
if: 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
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
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;
// ═══════════════════════════════════════════════════════════════════════
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
// ═══════════════════════════════════════════════════════════════════════
// Format: "{Workflow Name} / {Job Name} (pull_request)"
//
// To find check names: Go to PR → Checks tab → copy exact name
// To update: Edit this list when workflow jobs are added/renamed/removed
//
// Last validated: 2025-12-26
// ═══════════════════════════════════════════════════════════════════════
const requiredChecks = [
// CI workflow (ci.yml) - 3 checks
'CI / test-frontend (pull_request)',
'CI / test-python (3.12) (pull_request)',
'CI / test-python (3.13) (pull_request)',
// Lint workflow (lint.yml) - 1 check
'Lint / python (pull_request)',
// Quality Security workflow (quality-security.yml) - 4 checks
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
'Quality Security / CodeQL (python) (pull_request)',
'Quality Security / Python Security (Bandit) (pull_request)',
'Quality Security / Security Summary (pull_request)',
// CLA Assistant workflow (cla.yml) - 1 check
'CLA Assistant / CLA Check',
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
'Quality Commit Lint / Conventional Commits (pull_request)'
];
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
console.log(`::group::PR #${prNumber} - Checking required checks`);
console.log(`Triggered by: ${triggerWorkflow}`);
console.log(`Head SHA: ${headSha}`);
console.log(`Required checks: ${requiredChecks.length}`);
console.log('');
// Fetch all check runs for this commit
let allCheckRuns = [];
try {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: headSha,
per_page: 100
});
allCheckRuns = data.check_runs;
console.log(`Found ${allCheckRuns.length} total check runs`);
} catch (error) {
// Add warning annotation so maintainers are alerted
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
console.log(`::error::Failed to fetch check runs: ${error.message}`);
console.log('::endgroup::');
return;
}
let allComplete = true;
let anyFailed = false;
const results = [];
// Check each required check
for (const checkName of requiredChecks) {
const check = allCheckRuns.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') {
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
} else {
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
anyFailed = true;
}
}
// Print results table
console.log('');
console.log('Check Status:');
console.log('─'.repeat(70));
for (const r of results) {
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
console.log(` ${r.status.padEnd(12)} ${shortName}`);
}
console.log('─'.repeat(70));
console.log('::endgroup::');
// Only update label if all required checks are complete
if (!allComplete) {
const pending = results.filter(r => !r.complete).length;
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
return;
}
// Determine final label
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
console.log(`::group::Updating PR #${prNumber} label`);
// Remove old status labels
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
}
// Add final status label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [newLabel]
});
console.log(` ✓ Added: ${newLabel}`);
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
// Summary
const passedCount = results.filter(r => r.status === '✅ Passed').length;
const skippedCount = results.filter(r => r.skipped).length;
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) {
console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`);
core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`);
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
} else {
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
const totalSuccessful = passedCount + skippedCount;
console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`);
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
}
// Add results to summary
core.summary.addTable([
[{data: 'Check', header: true}, {data: 'Status', header: true}],
...results.map(r => [r.name, r.status])
]);
await core.summary.write();
+6 -188
View File
@@ -1,10 +1,8 @@
name: Prepare Release
# Triggers when code is pushed to main (e.g., merging develop → main)
# If package.json version is newer than the latest tag:
# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing)
# 2. Extracts release notes from CHANGELOG.md
# 3. Creates a new tag which triggers release.yml
# If package.json version is newer than the latest tag, creates a new tag
# which then triggers the release.yml workflow
on:
push:
@@ -34,70 +32,6 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
# Validate all version files are in sync before proceeding
- name: Validate version sync
run: |
echo "Validating version synchronization across all files..."
ROOT_VERSION=$(node -p "require('./package.json').version")
FRONTEND_VERSION=$(node -p "require('./apps/frontend/package.json').version")
# Extract Python version - handles both formats: __version__ = "X.Y.Z" or __version__="X.Y.Z"
BACKEND_VERSION=$(grep -oP '__version__\s*=\s*["\x27]\K[^"\x27]+' apps/backend/__init__.py || echo "NOT_FOUND")
echo "=========================================="
echo "Version Sync Validation"
echo "=========================================="
echo "Root package.json: $ROOT_VERSION"
echo "Frontend package.json: $FRONTEND_VERSION"
echo "Backend __init__.py: $BACKEND_VERSION"
echo "=========================================="
ERRORS=0
if [ "$ROOT_VERSION" != "$FRONTEND_VERSION" ]; then
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != frontend package.json ($FRONTEND_VERSION)"
ERRORS=$((ERRORS + 1))
fi
if [ "$BACKEND_VERSION" = "NOT_FOUND" ]; then
echo "::error::Could not extract version from apps/backend/__init__.py"
ERRORS=$((ERRORS + 1))
elif [ "$ROOT_VERSION" != "$BACKEND_VERSION" ]; then
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != backend __init__.py ($BACKEND_VERSION)"
ERRORS=$((ERRORS + 1))
fi
if [ $ERRORS -gt 0 ]; then
echo ""
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error:: VERSION SYNC FAILED"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error::"
echo "::error:: All version files must be in sync before releasing."
echo "::error::"
echo "::error:: To fix this, use the bump-version script:"
echo "::error:: node scripts/bump-version.js <patch|minor|major|X.Y.Z>"
echo "::error::"
echo "::error:: This will update all version files automatically."
echo "::error::═══════════════════════════════════════════════════════════════════════"
# Add to job summary
echo "## Version Sync Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| File | Version |" >> $GITHUB_STEP_SUMMARY
echo "|------|---------|" >> $GITHUB_STEP_SUMMARY
echo "| package.json | $ROOT_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "| apps/frontend/package.json | $FRONTEND_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "| apps/backend/__init__.py | $BACKEND_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Use \`node scripts/bump-version.js <version>\` to sync all files." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo ""
echo "All version files are in sync: $ROOT_VERSION"
- name: Get latest tag version
id: latest_tag
run: |
@@ -133,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"
@@ -265,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
+178
View File
@@ -0,0 +1,178 @@
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();
+23 -133
View File
@@ -46,21 +46,13 @@ jobs:
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -101,8 +93,6 @@ jobs:
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
@@ -133,21 +123,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-arm64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-3.12.8-
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -188,8 +170,6 @@ jobs:
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
build-windows:
runs-on: windows-latest
@@ -220,21 +200,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~\AppData\Local\pip\Cache
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -252,8 +224,6 @@ jobs:
name: windows-builds
path: |
apps/frontend/dist/*.exe
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
build-linux:
runs-on: ubuntu-latest
@@ -291,21 +261,13 @@ jobs:
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-3.12.8-
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
@@ -323,8 +285,6 @@ jobs:
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
apps/frontend/dist/*.flatpak
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
create-release:
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
@@ -344,30 +304,16 @@ jobs:
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec cp {} release-assets/ \;
# Validate that installer files exist (not just manifests)
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$installer_count" -eq 0 ]; then
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
exit 1
fi
echo "Found $installer_count installer(s):"
find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \;
# Validate that electron-updater manifest files are present (required for auto-updates)
yml_count=$(find release-assets -type f -name "*.yml" | wc -l)
if [ "$yml_count" -eq 0 ]; then
echo "::error::No update manifest (.yml) files found! Auto-update architecture detection will not work."
exit 1
fi
echo "Found $yml_count manifest file(s):"
find release-assets -type f -name "*.yml" -exec basename {} \;
echo ""
echo "All release assets:"
echo "Found $artifact_count artifact(s):"
ls -la release-assets/
- name: Generate checksums
@@ -527,78 +473,23 @@ jobs:
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Extract changelog from CHANGELOG.md
if: ${{ github.event_name == 'push' }}
- name: Generate changelog
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
id: changelog
run: |
# Extract version from tag (v2.7.2 -> 2.7.2)
VERSION=${GITHUB_REF_NAME#v}
CHANGELOG_FILE="CHANGELOG.md"
echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..."
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "::warning::CHANGELOG.md not found, using minimal release notes"
echo "body=Release v$VERSION" >> $GITHUB_OUTPUT
exit 0
fi
# Extract changelog section for this version
# Looks for "## X.Y.Z" header and captures until next "## " or "---"
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
BEGIN { found=0; content="" }
/^## / {
if (found) exit
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
found=1
next
}
}
/^---$/ { if (found) exit }
found { content = content $0 "\n" }
END {
if (!found) {
print "NOT_FOUND"
exit 0
}
# Trim leading/trailing whitespace
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
print content
}
' "$CHANGELOG_FILE")
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
CHANGELOG_CONTENT="Release v$VERSION
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi
echo "✅ Extracted changelog content"
# Save to file first (more reliable for multiline)
echo "$CHANGELOG_CONTENT" > changelog-body.md
# Use file-based output for multiline content
{
echo "body<<CHANGELOG_EOF"
cat changelog-body.md
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
uses: softprops/action-gh-release@v2
with:
body: |
${{ steps.changelog.outputs.body }}
---
${{ steps.virustotal.outputs.vt_results }}
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
files: release-assets/*
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
@@ -609,8 +500,7 @@ See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGEL
update-readme:
needs: [create-release]
runs-on: ubuntu-latest
# Only update README on actual releases (tag push), not dry runs
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
permissions:
contents: write
steps:
+25
View File
@@ -0,0 +1,25 @@
name: Stale Issues
on:
schedule:
- cron: '0 0 * * 0' # Every Sunday
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v9
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.'
stale-issue-label: 'stale'
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
+63
View File
@@ -0,0 +1,63 @@
name: Test on Tag
on:
push:
tags:
- 'v*'
jobs:
# Python tests
test-python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --tb=short
# Frontend tests
test-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 tests
working-directory: apps/frontend
run: npm run test
+71
View File
@@ -0,0 +1,71 @@
name: Validate Version
on:
push:
tags:
- 'v*'
jobs:
validate-version:
name: Validate package.json version matches tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: tag_version
run: |
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Extract version from package.json
id: package_version
run: |
# Read version from package.json
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "Package.json version: $PACKAGE_VERSION"
- name: Compare versions
run: |
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
echo "=========================================="
echo "Version Validation"
echo "=========================================="
echo "Git tag version: v$TAG_VERSION"
echo "package.json version: $PACKAGE_VERSION"
echo "=========================================="
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo ""
echo "❌ ERROR: Version mismatch detected!"
echo ""
echo "The version in package.json ($PACKAGE_VERSION) does not match"
echo "the git tag version ($TAG_VERSION)."
echo ""
echo "To fix this:"
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
echo " 2. Update package.json version to $TAG_VERSION"
echo " 3. Commit the change"
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
echo ""
echo "Or use the automated script:"
echo " node scripts/bump-version.js $TAG_VERSION"
echo ""
exit 1
fi
echo ""
echo "✅ SUCCESS: Versions match!"
echo ""
- name: Version validation result
if: success()
run: |
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
+33
View File
@@ -0,0 +1,33 @@
name: Welcome
on:
pull_request_target:
types: [opened]
issues:
types: [opened]
jobs:
welcome:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1
with:
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
pr-message: |
🎉 Thanks for your first PR!
A maintainer will review it soon. Please make sure:
- Your branch is synced with `develop`
- 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!
-1
View File
@@ -163,4 +163,3 @@ _bmad-output/
.claude/
/docs
OPUS_ANALYSIS_AND_IDEAS.md
/.github/agents
+18 -57
View File
@@ -36,44 +36,14 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
echo " Updated apps/backend/__init__.py to $VERSION"
fi
# Sync to README.md - section-aware updates (stable vs beta)
# Sync to README.md
if [ -f "README.md" ]; then
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link (within BETA_VERSION_BADGE section)
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue) - within TOP_VERSION_BADGE section
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
# Update version badge - match both stable (X.Y.Z) and prerelease (X.Y.Z-prerelease.N or X.Y.Z--prerelease.N)
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" README.md
# Update download links - match both stable and prerelease versions
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*\(-[a-z]*\.[0-9]*\)*/Auto-Claude-$VERSION/g" README.md
rm -f README.md.bak
git add README.md
echo " Updated README.md to $VERSION"
@@ -102,25 +72,20 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
fi
if [ -n "$RUFF" ]; then
# Get only staged Python files in apps/backend (process only what's being committed)
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "^apps/backend/.*\.py$" || true)
if [ -n "$STAGED_PY_FILES" ]; then
# Run ruff linting (auto-fix) only on staged files
echo "Running ruff lint on staged files..."
echo "$STAGED_PY_FILES" | xargs $RUFF check --fix
if [ $? -ne 0 ]; then
echo "Ruff lint failed. Please fix Python linting errors before committing."
exit 1
fi
# Run ruff format (auto-fix) only on staged files
echo "Running ruff format on staged files..."
echo "$STAGED_PY_FILES" | xargs $RUFF format
# Re-stage only the files that were originally staged (in case ruff modified them)
echo "$STAGED_PY_FILES" | xargs git add
# Run ruff linting (auto-fix)
echo "Running ruff lint..."
$RUFF check apps/backend/ --fix
if [ $? -ne 0 ]; then
echo "Ruff lint failed. Please fix Python linting errors before committing."
exit 1
fi
# Run ruff format (auto-fix)
echo "Running ruff format..."
$RUFF format apps/backend/
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
else
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
fi
@@ -163,10 +128,6 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run TypeScript type check
echo "Running type check..."
+6 -33
View File
@@ -25,41 +25,14 @@ repos:
# Sync to apps/backend/__init__.py
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
# Sync to README.md - section-aware updates (stable vs beta)
# Sync to README.md - shields.io version badge (text and URL)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
sed -i.bak -e "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" -e "s|releases/tag/v[0-9.a-z-]*)|releases/tag/v$VERSION)|g" README.md
# Detect if this is a prerelease (contains - after base version)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue)
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue)
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
# Sync to README.md - download links with correct filenames and URLs
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak "s|Auto-Claude-[0-9.a-z-]*-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-${SUFFIX})|Auto-Claude-${VERSION}-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v${VERSION}/Auto-Claude-${VERSION}-${SUFFIX})|g" README.md
done
rm -f README.md.bak
# Stage changes
-280
View File
@@ -1,283 +1,3 @@
## 2.7.2 - Stability & Performance Enhancements
### ✨ New Features
- Added refresh button to Kanban board for manually reloading tasks
- Terminal dropdown with built-in and external options in task review
- Centralized CLI tool path management with customizable settings
- Files tab in task details panel for better file organization
- Enhanced PR review page with filtering capabilities
- GitLab integration support
- Automated PR review with follow-up support and structured outputs
- UI scale feature with 75-200% range for accessibility
- Python 3.12 bundled with packaged Electron app
- OpenRouter support as LLM/embedding provider
- Internationalization (i18n) system for multi-language support
- Flatpak packaging support for Linux
- Path-aware AI merge resolution with device code streaming
### 🛠️ Improvements
- Improved terminal experience with persistent state when switching projects
- Enhanced PR review with structured outputs and fork support
- Better UX for display and scaling changes
- Convert synchronous I/O to async operations in worktree handlers
- Enhanced logs for commit linting stage
- Remove top navigation bars for cleaner UI
- Enhanced PR detail area visual design
- Improved CLI tool detection with more language support
- Added iOS/Swift project detection
- Optimize performance by removing projectTabs from useEffect dependencies
- Improved Python detection and version validation for compatibility
### 🐛 Bug Fixes
- Fixed CI Python setup and PR status gate checks
- Fixed cross-platform CLI path detection and clearing in settings
- Preserve original task description after spec creation
- Fixed learning loop to retrieve patterns and gotchas from memory
- Resolved frontend lag and updated dependencies
- Fixed Content-Security-Policy to allow external HTTPS images
- Fixed PR review isolation by using temporary worktree
- Fixed Homebrew Python detection to prefer versioned Python over system python3
- Added support for Bun 1.2.0+ lock file format detection
- Fixed infinite re-render loop in task selection
- Fixed infinite loop in task detail merge preview loading
- Resolved Windows EINVAL error when opening worktree in VS Code
- Fixed fallback to prevent tasks stuck in ai_review status
- Fixed SDK permissions to include spec_dir
- Added --base-branch argument support to spec_runner
- Allow Windows to run CC PR Reviewer
- Fixed model selection to respect task_metadata.json
- Improved GitHub PR review by passing repo parameter explicitly
- Fixed electron-log imports with .js extension
- Fixed Swift detection order in project analyzer
- Prevent TaskEditDialog from unmounting when opened
- Fixed subprocess handling for Python paths with spaces
- Fixed file system race conditions and unused variables in security scanning
- Resolved Python detection and backend packaging issues
- Fixed version-specific links in README and pre-commit hooks
- Fixed task status persistence reverting on refresh
- Proper semver comparison for pre-release versions
- Use virtual environment Python for all services to fix dotenv errors
- Fixed explicit Windows System32 tar path for builds
- Added augmented PATH environment to all GitHub CLI calls
- Use PowerShell for tar extraction on Windows
- Added --force-local flag to tar on Windows
- Stop tracking spec files in git
- Fixed GitHub API calls with explicit GET method for comment fetches
- Support archiving tasks across all worktree locations
- Validated backend source path before using it
- Resolved spawn Python ENOENT error on Linux
- Fixed CodeQL alerts for uncontrolled command line
- Resolved GitHub follow-up review API issues
- Fixed relative path normalization to POSIX format
- Accepted bug_fix workflow_type alias during planning
- Added global spec numbering lock to prevent collisions
- Fixed ideation status sync
- Stopped running process when task status changes away from in_progress
- Removed legacy path from auto-claude source detection
- Resolved Python environment race condition
---
## What's Changed
- fix(ci): add Python setup to beta-release and fix PR status gate checks (#565) by @Andy in c2148bb9
- fix: detect and clear cross-platform CLI paths in settings (#535) by @Andy in 29e45505
- fix(ui): preserve original task description after spec creation (#536) by @Andy in 7990dcb4
- fix(memory): fix learning loop to retrieve patterns and gotchas (#530) by @Andy in f58c2578
- fix: resolve frontend lag and update dependencies (#526) by @Andy in 30f7951a
- feat(kanban): add refresh button to manually reload tasks (#548) by @Adryan Serage in 252242f9
- fix(csp): allow external HTTPS images in Content-Security-Policy (#549) by @Michael Ludlow in 3db02c5d
- fix(pr-review): use temporary worktree for PR review isolation (#532) by @Andy in 344ec65e
- fix: prefer versioned Homebrew Python over system python3 (#494) by @Navid in 8d58dd6f
- fix(detection): support bun.lock text format for Bun 1.2.0+ (#525) by @Andy in 4da8cd66
- chore: bump version to 2.7.2-beta.12 (#460) by @Andy in 8e5c11ac
- Fix/windows issues (#471) by @Andy in 72106109
- fix(ci): add Rust toolchain for Intel Mac builds (#459) by @Andy in 52a4fcc6
- fix: create spec.md during roadmap-to-task conversion (#446) by @Mulaveesala Pranaveswar in fb6b7fc6
- fix(pr-review): treat LOW-only findings as ready to merge (#455) by @Andy in 0f9c5b84
- Fix/2.7.2 beta12 (#424) by @Andy in 5d8ede23
- feat: remove top bars (#386) by @Vinícius Santos in da31b687
- fix: prevent infinite re-render loop in task selection useEffect (#442) by @Abe Diaz in 2effa535
- fix: accept Python 3.12+ in install-backend.js (#443) by @Abe Diaz in c15bb311
- fix: infinite loop in useTaskDetail merge preview loading (#444) by @Abe Diaz in 203a970a
- fix(windows): resolve EINVAL error when opening worktree in VS Code (#434) by @Vinícius Santos in 3c0708b7
- feat(frontend): Add Files tab to task details panel (#430) by @Mitsu in 666794b5
- refactor: remove deprecated TaskDetailPanel component (#432) by @Mitsu in ac8dfcac
- fix(ui): add fallback to prevent tasks stuck in ai_review status (#397) by @Michael Ludlow in 798ca79d
- feat: Enhance the look of the PR Detail area (#427) by @Alex in bdb01549
- ci: remove conventional commits PR title validation workflow by @AndyMik90 in 515b73b5
- fix(client): add spec_dir to SDK permissions (#429) by @Mitsu in 88c76059
- fix(spec_runner): add --base-branch argument support (#428) by @Mitsu in 62a75515
- feat: enhance pr review page to include PRs filters (#423) by @Alex in 717fba04
- feat: add gitlab integration (#254) by @Mitsu in 0a571d3a
- fix: Allow windows to run CC PR Reviewer (#406) by @Alex in 2f662469
- fix(model): respect task_metadata.json model selection (#415) by @Andy in e7e6b521
- feat(build): add Flatpak packaging support for Linux (#404) by @Mitsu in 230de5fc
- fix(github): pass repo parameter to GHClient for explicit PR resolution (#413) by @Andy in 4bdf7a0c
- chore(ci): remove redundant CLA GitHub Action workflow by @AndyMik90 in a39ea49d
- fix(frontend): add .js extension to electron-log/main imports by @AndyMik90 in 9aef0dd0
- fix: 2.7.2 bug fixes and improvements (#388) by @Andy in 05131217
- fix(analyzer): move Swift detection before Ruby detection (#401) by @Michael Ludlow in 321c9712
- fix(ui): prevent TaskEditDialog from unmounting when opened (#395) by @Michael Ludlow in 98b12ed8
- fix: improve CLI tool detection and add Claude CLI path settings (#393) by @Joe in aaa83131
- feat(analyzer): add iOS/Swift project detection (#389) by @Michael Ludlow in 68548e33
- fix(github): improve PR review with structured outputs and fork support (#363) by @Andy in 7751588e
- fix(ideation): update progress calculation to include just-completed ideation type (#381) by @Illia Filippov in 8b4ce58c
- Fixes failing spec - "gh CLI Check Handler - should return installed: true when gh CLI is found" (#370) by @Ian in bc220645
- fix: Memory Status card respects configured embedding provider (#336) (#373) by @Michael Ludlow in db0cbea3
- fix: fixed version-specific links in readme and pre-commit hook that updates them (#378) by @Ian in 0ca2e3f6
- docs: add security research documentation (#361) by @Brian in 2d3b7fb4
- fix/Improving UX for Display/Scaling Changes (#332) by @Kevin Rajan in 9bbdef09
- fix(perf): remove projectTabs from useEffect deps to fix re-render loop (#362) by @Michael Ludlow in 753dc8bb
- fix(security): invalidate profile cache when file is created/modified (#355) by @Michael Ludlow in 20f20fa3
- fix(subprocess): handle Python paths with spaces (#352) by @Michael Ludlow in eabe7c7d
- fix: Resolve pre-commit hook failures with version sync, pytest path, ruff version, and broken quality-dco workflow (#334) by @Ian in 1fa7a9c7
- fix(terminal): preserve terminal state when switching projects (#358) by @Andy in 7881b2d1
- fix(analyzer): add C#/Java/Swift/Kotlin project files to security hash (#351) by @Michael Ludlow in 4e71361b
- fix: make backend tests pass on Windows (#282) by @Oluwatosin Oyeladun in 4dcc5afa
- fix(ui): close parent modal when Edit dialog opens (#354) by @Michael Ludlow in e9782db0
- chore: bump version to 2.7.2-beta.10 by @AndyMik90 in 40d04d7c
- feat: add terminal dropdown with inbuilt and external options in task review (#347) by @JoshuaRileyDev in fef07c95
- refactor: remove deprecated code across backend and frontend (#348) by @Mitsu in 9d43abed
- feat: centralize CLI tool path management (#341) by @HSSAINI Saad in d51f4562
- refactor(components): remove deprecated TaskDetailPanel re-export (#344) by @Mitsu in 787667e9
- chore: Refactor/kanban realtime status sync (#249) by @souky-byte in 9734b70b
- refactor(settings): remove deprecated ProjectSettings modal and hooks (#343) by @Mitsu in fec6b9f3
- perf: convert synchronous I/O to async operations in worktree handlers (#337) by @JoshuaRileyDev in d3a63b09
- feat: bump version (#329) by @Alex in 50e3111a
- fix(ci): remove version bump to fix branch protection conflict (#325) by @Michael Ludlow in 8a80b1d5
- fix(tasks): sync status to worktree implementation plan to prevent reset (#243) (#323) by @Alex in cb6b2165
- fix(ci): add auto-updater manifest files and version auto-update (#317) by @Michael Ludlow in 661e47c3
- fix(project): fix task status persistence reverting on refresh (#246) (#318) by @Michael Ludlow in e80ef79d
- fix(updater): proper semver comparison for pre-release versions (#313) by @Michael Ludlow in e1b0f743
- fix(python): use venv Python for all services to fix dotenv errors (#311) by @Alex in 92c6f278
- chore(ci): cancel in-progress runs (#302) by @Oluwatosin Oyeladun in 1c142273
- fix(build): use explicit Windows System32 tar path (#308) by @Andy in c0a02a45
- fix(github): add augmented PATH env to all gh CLI calls by @AndyMik90 in 086429cb
- fix(build): use PowerShell for tar extraction on Windows by @AndyMik90 in d9fb8f29
- fix(build): add --force-local flag to tar on Windows (#303) by @Andy in d0b0b3df
- fix: stop tracking spec files in git (#295) by @Andy in 937a60f8
- Fix/2.7.2 fixes (#300) by @Andy in 7a51cbd5
- feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296) by @Andy in 26beefe3
- feat: enhance the logs for the commit linting stage (#293) by @Alex in 8416f307
- fix(github): add explicit GET method to gh api comment fetches (#294) by @Andy in 217249c8
- fix(frontend): support archiving tasks across all worktree locations (#286) by @Andy in 8bb3df91
- Potential fix for code scanning alert no. 224: Uncontrolled command line (#285) by @Andy in 5106c6e9
- fix(frontend): validate backend source path before using it (#287) by @Andy in 3ff61274
- feat(python): bundle Python 3.12 with packaged Electron app (#284) by @Andy in 7f19c2e1
- fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281) by @Todd W. Bucy in d98e2830
- fix(ci): add write permissions to beta-release update-version job by @AndyMik90 in 0b874d4b
- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270) by @dependabot[bot] in 50dd1078
- fix(github): resolve follow-up review API issues by @AndyMik90 in f1cc5a09
- fix(security): resolve CodeQL file system race conditions and unused variables (#277) by @Andy in b005fa5c
- fix(ci): use correct electron-builder arch flags (#278) by @Andy in d79f2da4
- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268) by @dependabot[bot] in 5ac566e2
- chore(deps): bump typescript-eslint in /apps/frontend (#269) by @dependabot[bot] in f49d4817
- fix(ci): use develop branch for dry-run builds in beta-release workflow (#276) by @Andy in 1e1d7d9b
- fix: accept bug_fix workflow_type alias during planning (#240) by @Daniel Frey in e74a3dff
- fix(paths): normalize relative paths to posix (#239) by @Daniel Frey in 6ac8250b
- chore(deps): bump @electron/rebuild in /apps/frontend (#271) by @dependabot[bot] in a2cee694
- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272) by @dependabot[bot] in d4cad80a
- feat(github): add automated PR review with follow-up support (#252) by @Andy in 596e9513
- ci: implement enterprise-grade PR quality gates and security scanning (#266) by @Alex in d42041c5
- fix: update path resolution for ollama_model_detector.py in memory handlers (#263) by @delyethan in a3f87540
- feat: add i18n internationalization system (#248) by @Mitsu in f8438112
- Revert "Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)" (#251) by @Andy in 5e8c5308
- Feat/Auto Fix Github issues and do extensive AI PR reviews (#250) by @Andy in 348de6df
- fix: resolve Python detection and backend packaging issues (#241) by @HSSAINI Saad in 0f7d6e05
- fix: add future annotations import to discovery.py (#229) by @Joris Slagter in 5ccdb6ab
- Fix/ideation status sync (#212) by @souky-byte in 6ec8549f
- fix(core): add global spec numbering lock to prevent collisions (#209) by @Andy in 53527293
- feat: Add OpenRouter as LLM/embedding provider (#162) by @Fernando Possebon in 02bef954
- fix: Add Python 3.10+ version validation and GitHub Actions Python setup (#180 #167) (#208) by @Fernando Possebon in f168bdc3
- fix(ci): correct welcome workflow PR message (#206) by @Andy in e3eec68a
- Feat/beta release (#193) by @Andy in 407a0bee
- feat/beta-release (#190) by @Andy in 8f766ad1
- fix/PRs from old main setup to apps structure (#185) by @Andy in ced2ad47
- fix: hide status badge when execution phase badge is showing (#154) by @Andy in 05f5d303
- feat: Add UI scale feature with 75-200% range (#125) by @Enes Cingöz in 6951251b
- fix(task): stop running process when task status changes away from in_progress by @AndyMik90 in 30e7536b
- Fix/linear 400 error by @Andy in 220faf0f
- fix: remove legacy path from auto-claude source detection (#148) by @Joris Slagter in f96c6301
- fix: resolve Python environment race condition (#142) by @Joris Slagter in ebd8340d
- Feat: Ollama download progress tracking with new apps structure (#141) by @rayBlock in df779530
- Feature/apps restructure v2.7.2 (#138) by @Andy in 0adaddac
- docs: Add Git Flow branching strategy to CONTRIBUTING.md by @AndyMik90 in 91f7051d
## Thanks to all contributors
@Andy, @Adryan Serage, @Michael Ludlow, @Navid, @Mulaveesala Pranaveswar, @Vinícius Santos, @Abe Diaz, @Mitsu, @Alex, @AndyMik90, @Joe, @Illia Filippov, @Ian, @Brian, @Kevin Rajan, @Oluwatosin Oyeladun, @JoshuaRileyDev, @HSSAINI Saad, @souky-byte, @Todd W. Bucy, @dependabot[bot], @Daniel Frey, @delyethan, @Joris Slagter, @Fernando Possebon, @Enes Cingöz, @rayBlock
## 2.7.1 - Build Pipeline Enhancements
### 🛠️ Improvements
+116 -19
View File
@@ -4,9 +4,11 @@
![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png)
<!-- TOP_VERSION_BADGE -->
[![Version](https://img.shields.io/badge/version-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
<!-- TOP_VERSION_BADGE_END -->
[![License](https://img.shields.io/badge/license-AGPL--3.0-green?style=flat-square)](./agpl-3.0.txt)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/KCXaPBr4Dj)
[![YouTube](https://img.shields.io/badge/YouTube-Subscribe-FF0000?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/@AndreMikalsen)
[![CI](https://img.shields.io/github/actions/workflow/status/AndyMik90/Auto-Claude/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/AndyMik90/Auto-Claude/actions)
---
@@ -16,17 +18,17 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
[![Stable](https://img.shields.io/badge/stable-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) |
<!-- STABLE_DOWNLOADS_END -->
### Beta Release
@@ -57,6 +59,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
---
@@ -145,11 +148,113 @@ 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 |
| `GITLAB_TOKEN` | No | GitLab Personal Access Token for GitLab integration |
| `GITLAB_INSTANCE_URL` | No | GitLab instance URL (defaults to gitlab.com) |
| `LINEAR_API_KEY` | No | Linear API key for task sync |
---
## 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+
**Installing dependencies by platform:**
<details>
<summary><b>Windows</b></summary>
```bash
winget install Python.Python.3.12
winget install OpenJS.NodeJS.LTS
```
</details>
<details>
<summary><b>macOS</b></summary>
```bash
brew install python@3.12 node@24
```
</details>
<details>
<summary><b>Linux (Ubuntu/Debian)</b></summary>
```bash
sudo apt install python3.12 python3.12-venv
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
```
</details>
<details>
<summary><b>Linux (Fedora)</b></summary>
```bash
sudo dnf install python3.12 nodejs npm
```
</details>
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
### Building Flatpak
To build the Flatpak package, you need additional dependencies:
```bash
# Fedora/RHEL
sudo dnf install flatpak-builder
# Ubuntu/Debian
sudo apt install flatpak-builder
# Install required Flatpak runtimes
flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install flathub org.electronjs.Electron2.BaseApp//25.08
# Build the Flatpak
cd apps/frontend
npm run package:flatpak
```
The Flatpak will be created in `apps/frontend/dist/`.
---
@@ -179,7 +284,7 @@ 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 package:flatpak` | Package as Flatpak |
| `npm run lint` | Run linter |
| `npm test` | Run frontend tests |
| `npm run test:backend` | Run backend tests |
@@ -211,11 +316,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
[![GitHub Repo stars](https://img.shields.io/github/stars/AndyMik90/Auto-Claude?style=social)](https://github.com/AndyMik90/Auto-Claude/stargazers)
[![Star History Chart](https://api.star-history.com/svg?repos=AndyMik90/Auto-Claude&type=Date)](https://star-history.com/#AndyMik90/Auto-Claude&Date)
+23 -90
View File
@@ -69,38 +69,9 @@ This will:
- Update `apps/frontend/package.json`
- Update `package.json` (root)
- Update `apps/backend/__init__.py`
- Check if `CHANGELOG.md` has an entry for the new version (warns if missing)
- Create a commit with message `chore: bump version to X.Y.Z`
### Step 2: Update CHANGELOG.md (REQUIRED)
**IMPORTANT: The release will fail if CHANGELOG.md doesn't have an entry for the new version.**
Add release notes to `CHANGELOG.md` at the top of the file:
```markdown
## 2.8.0 - Your Release Title
### ✨ New Features
- Feature description
### 🛠️ Improvements
- Improvement description
### 🐛 Bug Fixes
- Fix description
---
```
Then amend the version bump commit:
```bash
git add CHANGELOG.md
git commit --amend --no-edit
```
### Step 3: Push and Create PR
### Step 2: Push and Create PR
```bash
# Push your branch
@@ -110,25 +81,24 @@ git push origin your-branch
gh pr create --base main --title "Release v2.8.0"
```
### Step 4: Merge to Main
### Step 3: Merge to Main
Once the PR is approved and merged to `main`, GitHub Actions will automatically:
1. **Detect the version bump** (`prepare-release.yml`)
2. **Validate CHANGELOG.md** has an entry for the new version (FAILS if missing)
3. **Extract release notes** from CHANGELOG.md
4. **Create a git tag** (e.g., `v2.8.0`)
5. **Trigger the release workflow** (`release.yml`)
6. **Build binaries** for all platforms:
2. **Create a git tag** (e.g., `v2.8.0`)
3. **Trigger the release workflow** (`release.yml`)
4. **Build binaries** for all platforms:
- macOS Intel (x64) - code signed & notarized
- macOS Apple Silicon (arm64) - code signed & notarized
- Windows (NSIS installer) - code signed
- Linux (AppImage + .deb)
7. **Scan binaries** with VirusTotal
8. **Create GitHub release** with release notes from CHANGELOG.md
9. **Update README** with new version badge and download links
5. **Generate changelog** from merged PRs (using release-drafter)
6. **Scan binaries** with VirusTotal
7. **Create GitHub release** with all artifacts
8. **Update README** with new version badge and download links
### Step 5: Verify
### Step 4: Verify
After merging, check:
- [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass
@@ -143,49 +113,28 @@ We follow [Semantic Versioning](https://semver.org/):
- **MINOR** (0.X.0): New features, backwards compatible
- **PATCH** (0.0.X): Bug fixes, backwards compatible
## Changelog Management
## Changelog Generation
Release notes are managed in `CHANGELOG.md` and used for GitHub releases.
Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter).
### Changelog Format
### PR Labels for Changelog Categories
Each version entry in `CHANGELOG.md` should follow this format:
| Label | Category |
|-------|----------|
| `feature`, `enhancement` | New Features |
| `bug`, `fix` | Bug Fixes |
| `improvement`, `refactor` | Improvements |
| `documentation` | Documentation |
| (any other) | Other Changes |
```markdown
## X.Y.Z - Release Title
### ✨ New Features
- Feature description with context
### 🛠️ Improvements
- Improvement description
### 🐛 Bug Fixes
- Fix description
---
```
### Changelog Validation
The release workflow **validates** that `CHANGELOG.md` has an entry for the version being released:
- If the entry is **missing**, the release is **blocked** with a clear error message
- If the entry **exists**, its content is used for the GitHub release notes
### Writing Good Release Notes
- **Be specific**: Instead of "Fixed bug", write "Fixed crash when opening large files"
- **Group by impact**: Features first, then improvements, then fixes
- **Credit contributors**: Mention contributors for significant changes
- **Link issues**: Reference GitHub issues where relevant (e.g., "Fixes #123")
**Tip:** Add appropriate labels to your PRs for better changelog organization.
## Workflows
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, creates release |
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
| `update-readme` (in release.yml) | After release | Updates README with new version |
@@ -204,22 +153,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
git diff HEAD~1 --name-only | grep package.json
```
### Release blocked: Missing changelog entry
If you see "CHANGELOG VALIDATION FAILED" in the workflow:
1. The `prepare-release.yml` workflow validated that `CHANGELOG.md` doesn't have an entry for the new version
2. **Fix**: Add an entry to `CHANGELOG.md` with the format `## X.Y.Z - Title`
3. Commit and push the changelog update
4. The workflow will automatically retry when the changes are pushed to `main`
```bash
# Add changelog entry, then:
git add CHANGELOG.md
git commit -m "docs: add changelog for vX.Y.Z"
git push origin main
```
### Build failed after tag was created
- The release won't be published if builds fail
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.2"
__version__ = "2.7.2-beta.10"
__author__ = "Auto Claude Team"
+2 -2
View File
@@ -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,
)
```
+3 -7
View File
@@ -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]
+2 -19
View File
@@ -62,7 +62,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__)
@@ -257,33 +257,16 @@ async def run_autonomous_agent(
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
# Create client (fresh context) with phase-specific model and thinking
# Use appropriate agent_type for correct tool permissions and thinking budget
client = create_client(
project_dir,
spec_dir,
phase_model,
agent_type="planner" if first_run else "coder",
max_thinking_tokens=phase_thinking_budget,
)
# Generate appropriate prompt
if first_run:
prompt = generate_planner_prompt(spec_dir, project_dir)
# Retrieve Graphiti memory context for planning phase
# This gives the planner knowledge of previous patterns, gotchas, and insights
planner_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Planning implementation for new feature",
"id": "planner",
},
)
if planner_context:
prompt += "\n\n" + planner_context
print_status("Graphiti memory context loaded for planner", "success")
first_run = False
current_log_phase = LogPhase.PLANNING
@@ -404,7 +387,7 @@ 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
+1 -37
View File
@@ -146,12 +146,6 @@ async def get_graphiti_context(
# Get relevant context
context_items = await memory.get_relevant_context(query, num_results=5)
# Get patterns and gotchas specifically (THE FIX for learning loop!)
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
patterns, gotchas = await memory.get_patterns_and_gotchas(
query, num_results=3, min_score=0.5
)
# Also get recent session history
session_history = await memory.get_session_history(limit=3)
@@ -162,12 +156,10 @@ async def get_graphiti_context(
"memory",
"Graphiti context retrieval complete",
context_items_found=len(context_items) if context_items else 0,
patterns_found=len(patterns) if patterns else 0,
gotchas_found=len(gotchas) if gotchas else 0,
session_history_found=len(session_history) if session_history else 0,
)
if not context_items and not session_history and not patterns and not gotchas:
if not context_items and not session_history:
if is_debug_enabled():
debug("memory", "No relevant context found in Graphiti")
return None
@@ -183,34 +175,6 @@ async def get_graphiti_context(
item_type = item.get("type", "unknown")
sections.append(f"- **[{item_type}]** {content}\n")
# Add patterns section (cross-session learning)
if patterns:
sections.append("### Learned Patterns\n")
sections.append("_Patterns discovered in previous sessions:_\n")
for p in patterns:
pattern_text = p.get("pattern", "")
applies_to = p.get("applies_to", "")
if applies_to:
sections.append(
f"- **Pattern**: {pattern_text}\n _Applies to:_ {applies_to}\n"
)
else:
sections.append(f"- **Pattern**: {pattern_text}\n")
# Add gotchas section (cross-session learning)
if gotchas:
sections.append("### Known Gotchas\n")
sections.append("_Pitfalls to avoid:_\n")
for g in gotchas:
gotcha_text = g.get("gotcha", "")
solution = g.get("solution", "")
if solution:
sections.append(
f"- **Gotcha**: {gotcha_text}\n _Solution:_ {solution}\n"
)
else:
sections.append(f"- **Gotcha**: {gotcha_text}\n")
if session_history:
sections.append("### Recent Session Insights\n")
for session in session_history[:2]: # Only show last 2
+25 -28
View File
@@ -21,7 +21,6 @@ from progress import (
is_build_complete,
)
from recovery import RecoveryManager
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -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)
+2 -3
View File
@@ -216,9 +216,8 @@ AGENT_CONFIGS = {
# QA PHASES (Read + test + browser + Graphiti memory)
# ═══════════════════════════════════════════════════════════════════════
"qa_reviewer": {
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
# Read-only + Bash (for running tests) - reviewer should NOT edit code
"tools": BASE_READ_TOOLS + ["Bash"] + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
"mcp_servers_optional": ["linear"], # For updating issue status
"auto_claude_tools": [
+4 -113
View File
@@ -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,79 +19,6 @@ except ImportError:
SDK_TOOLS_AVAILABLE = False
tool = None
logger = logging.getLogger(__name__)
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 async operation).
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
async def _async_save():
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()
# Run async operation in event loop
try:
asyncio.get_running_loop()
# If we're already in an async context, schedule the task
# Don't block - just fire and forget for the Graphiti save
# The file-based save is the primary, Graphiti is supplementary
asyncio.ensure_future(_async_save())
return False # Can't confirm async success, file-based is source of truth
except RuntimeError:
# No running loop, create one
return asyncio.run(_async_save())
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 create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
"""
@@ -125,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")
@@ -134,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:
@@ -159,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 = _save_to_graphiti_sync(
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}",
}
]
}
@@ -196,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", "")
@@ -204,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}"
@@ -222,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 = _save_to_graphiti_sync(
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 {
+18 -87
View File
@@ -23,10 +23,9 @@ def get_latest_commit(project_dir: Path) -> str | None:
capture_output=True,
text=True,
check=True,
timeout=10,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
except subprocess.CalledProcessError:
return None
@@ -39,10 +38,9 @@ def get_commit_count(project_dir: Path) -> int:
capture_output=True,
text=True,
check=True,
timeout=10,
)
return int(result.stdout.strip())
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
except (subprocess.CalledProcessError, ValueError):
return 0
@@ -76,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:
@@ -118,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
@@ -408,6 +408,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
return "pnpm"
elif self._exists("yarn.lock"):
return "yarn"
elif self._exists("bun.lockb") or self._exists("bun.lock"):
elif self._exists("bun.lockb"):
return "bun"
return "npm"
+1 -1
View File
@@ -275,7 +275,7 @@ class TestDiscovery:
return "yarn"
if (project_dir / "package-lock.json").exists():
return "npm"
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
if (project_dir / "bun.lockb").exists():
return "bun"
if (project_dir / "uv.lock").exists():
return "uv"
+17 -60
View File
@@ -6,8 +6,7 @@ Commands for creating and managing multiple tasks from batch files.
"""
import json
import shutil
import subprocess
import os
from pathlib import Path
from ui import highlight, print_status
@@ -176,17 +175,23 @@ def handle_batch_status_command(project_dir: str) -> bool:
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
"""
Clean up completed specs and worktrees.
Args:
project_dir: Project directory
dry_run: If True, show what would be deleted
Clean up completed spec directories and their associated worktree paths.
Finds spec directories under <project_dir>/.auto-claude/specs that contain a `qa_report.md` (treated as completed),
and, when run in dry-run mode, prints the specs and corresponding worktree paths that would be removed.
Parameters:
project_dir (str): Path to the project root.
dry_run (bool): If True, print what would be removed instead of performing deletions.
Returns:
True if successful
True if the command completed.
"""
from core.config import get_worktree_base_path
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
worktree_base_path = get_worktree_base_path(Path(project_dir))
worktrees_dir = Path(project_dir) / worktree_base_path
if not specs_dir.exists():
print_status("No specs directory found", "info")
@@ -211,56 +216,8 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
print(f" - {spec_name}")
wt_path = worktrees_dir / spec_name
if wt_path.exists():
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
print(f" └─ {worktree_base_path}/{spec_name}/")
print()
print("Run with --no-dry-run to actually delete")
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
return True
+2 -2
View File
@@ -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"
)
+14 -2
View File
@@ -15,6 +15,8 @@ _PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from dotenv import load_dotenv
from .batch_commands import (
handle_batch_cleanup_command,
@@ -259,7 +261,11 @@ Environment Variables:
def main() -> None:
"""Main CLI entry point."""
"""
Entry point for the CLI: sets up the environment, parses arguments, and dispatches the requested command.
This function initializes runtime environment and debugging, resolves the project directory (and loads a project-specific .auto-claude/.env file if present), determines the model choice from the CLI or the AUTO_BUILD_MODEL environment variable, and routes control to the appropriate handler based on parsed CLI flags (examples include listing specs, worktree management, batch operations, merge/preview/review/discard flows, QA and follow-up commands, or the normal build flow). Exits the process with a non-zero status when required by invalid input or failing command outcomes.
"""
# Set up environment first
setup_environment()
@@ -276,6 +282,12 @@ def main() -> None:
project_dir = get_project_dir(args.project_dir)
debug("run.py", f"Using project directory: {project_dir}")
# Load project-specific .env file (overrides backend .env)
project_env = project_dir / ".auto-claude" / ".env"
if project_env.exists():
load_dotenv(project_env, override=True)
debug("run.py", f"Loaded project .env from: {project_env}")
# Get model from CLI arg or env var (None if not explicitly set)
# This allows get_phase_model() to fall back to task_metadata.json
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
@@ -410,4 +422,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
+3 -3
View File
@@ -28,8 +28,8 @@ from ui import (
muted,
)
# Configuration - uses shorthand that resolves via API Profile if configured
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
# Configuration
DEFAULT_MODEL = "claude-opus-4-5-20251101"
def setup_environment() -> Path:
@@ -82,7 +82,7 @@ def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
return spec_folder
# Check worktree specs (for merge-preview, merge, review, discard operations)
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
worktree_base = project_dir / ".worktrees"
if worktree_base.exists():
# Try exact match in worktree
worktree_spec = (
+6 -29
View File
@@ -67,7 +67,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
@@ -79,7 +78,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
@@ -92,32 +90,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,
@@ -129,10 +113,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"],
@@ -147,7 +131,7 @@ 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 []
@@ -616,13 +600,6 @@ def handle_merge_preview_command(
changed_files=all_changed_files[:10], # Log first 10
)
# NOTE: We intentionally do NOT have a fast path here.
# Even if commits_behind == 0 (main hasn't moved), we still need to:
# 1. Call refresh_from_git() to update evolution data for this task
# 2. Call preview_merge() to detect potential conflicts with OTHER parallel tasks
# that may be tracked in the evolution data but haven't been merged yet.
# Skipping semantic analysis when commits_behind == 0 would miss these conflicts.
debug(MODULE, "Initializing MergeOrchestrator for preview...")
# Initialize the orchestrator
+2 -2
View File
@@ -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",
]
-98
View File
@@ -23,21 +23,12 @@ 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",
]
@@ -217,85 +208,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 +215,6 @@ def get_sdk_env_vars() -> dict[str, str]:
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
be passed through to the claude-agent-sdk subprocess.
On Windows, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
Returns:
Dict of env var name -> value for non-empty vars
"""
@@ -313,14 +223,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
+2 -110
View File
@@ -12,115 +12,13 @@ The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
single source of truth for phase-aware tool and MCP server configuration.
"""
import copy
import json
import logging
import os
import 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,
@@ -489,12 +387,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,8 +396,8 @@ def create_client(
# Load project capabilities for dynamic MCP tool selection
# This enables context-aware tool injection based on project type
# Uses caching to avoid reloading on every create_client() call
project_index, project_capabilities = _get_cached_project_data(project_dir)
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
# Load per-project MCP configuration from .auto-claude/.env
mcp_config = load_project_mcp_config(project_dir)
+78
View File
@@ -0,0 +1,78 @@
"""
Core configuration for Auto Claude.
This module provides centralized configuration management for Auto Claude,
including worktree path resolution and validation. It ensures consistent
configuration access across the entire backend codebase.
Constants:
WORKTREE_BASE_PATH_VAR (str): Environment variable name for custom worktree base path.
DEFAULT_WORKTREE_PATH (str): Default worktree directory name relative to project root.
Example:
>>> from core.config import get_worktree_base_path
>>> from pathlib import Path
>>>
>>> # Get worktree path with validation
>>> project_dir = Path("/path/to/project")
>>> worktree_path = get_worktree_base_path(project_dir)
>>> full_path = project_dir / worktree_path
"""
import os
from pathlib import Path
# Environment variable names
WORKTREE_BASE_PATH_VAR = "WORKTREE_BASE_PATH"
"""str: Environment variable name for configuring custom worktree base path.
Users can set this environment variable in their project's .env file to specify
a custom location for worktree directories, supporting both relative and absolute paths.
"""
# Default values
DEFAULT_WORKTREE_PATH = ".worktrees"
"""str: Default worktree directory name.
This is the fallback value used when WORKTREE_BASE_PATH is not set or when
validation fails (e.g., path points to .auto-claude/ or .git/ directories).
"""
def get_worktree_base_path(project_dir: Path | None = None) -> str:
"""
Determine the validated worktree base path from the WORKTREE_BASE_PATH environment variable or the default.
Parameters:
project_dir (Path | None): Optional project root used to resolve relative paths and perform stricter validation. If omitted, only basic pattern checks are applied.
Returns:
str: The configured worktree base path string, or DEFAULT_WORKTREE_PATH ('.worktrees') if the configured value is invalid or points inside the project's `.auto-claude` or `.git` directories.
"""
worktree_base_path = os.getenv(WORKTREE_BASE_PATH_VAR, DEFAULT_WORKTREE_PATH)
# If no project_dir provided, return as-is (basic validation only)
if not project_dir:
# Check for obviously dangerous patterns
normalized = Path(worktree_base_path).as_posix()
if ".auto-claude" in normalized or ".git" in normalized:
return DEFAULT_WORKTREE_PATH
return worktree_base_path
# Resolve the absolute path
if Path(worktree_base_path).is_absolute():
resolved = Path(worktree_base_path).resolve()
else:
resolved = (project_dir / worktree_base_path).resolve()
# Prevent paths inside .auto-claude/ or .git/
auto_claude_dir = (project_dir / ".auto-claude").resolve()
git_dir = (project_dir / ".git").resolve()
resolved_str = str(resolved)
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
str(git_dir)
):
return DEFAULT_WORKTREE_PATH
return worktree_base_path
+1 -5
View File
@@ -52,8 +52,4 @@ def emit_phase(
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
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
+1 -1
View File
@@ -4,7 +4,7 @@ Workspace Management - Per-Spec Architecture
=============================================
Handles workspace isolation through Git worktrees, where each spec
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
gets its own isolated worktree in .worktrees/{spec-name}/.
This module has been refactored for better maintainability:
- Models and enums: workspace/models.py
+1 -1
View File
@@ -4,7 +4,7 @@ Workspace Management Package
=============================
Handles workspace isolation through Git worktrees, where each spec
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
gets its own isolated worktree in .worktrees/{spec-name}/.
This package provides:
- Workspace setup and configuration
+2 -18
View File
@@ -169,15 +169,7 @@ def handle_workspace_choice(
if staging_path:
print(highlight(f" cd {staging_path}"))
else:
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if worktree_path:
print(highlight(f" cd {worktree_path}"))
else:
print(
highlight(
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
)
)
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
# Show likely test/run commands
if staging_path:
@@ -240,15 +232,7 @@ def handle_workspace_choice(
if staging_path:
print(highlight(f" cd {staging_path}"))
else:
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if worktree_path:
print(highlight(f" cd {worktree_path}"))
else:
print(
highlight(
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
)
)
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
print()
print("When you're ready to add it:")
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
+4 -11
View File
@@ -22,7 +22,6 @@ LOCK_FILES = {
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"bun.lock",
"Pipfile.lock",
"poetry.lock",
"uv.lock",
@@ -222,16 +221,10 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
Returns:
Path to the worktree if it exists for this spec, None otherwise
"""
# New path first
new_path = project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name
if new_path.exists():
return new_path
# Legacy fallback
legacy_path = project_dir / ".worktrees" / spec_name
if legacy_path.exists():
return legacy_path
# Per-spec worktree path: .worktrees/{spec-name}/
worktree_path = project_dir / ".worktrees" / spec_name
if worktree_path.exists():
return worktree_path
return None
+14 -7
View File
@@ -6,6 +6,7 @@ Workspace Models
Data classes and enums for workspace management.
"""
import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
@@ -227,12 +228,15 @@ class SpecNumberLock:
def get_next_spec_number(self) -> int:
"""
Scan all spec locations and return the next available spec number.
Must be called while lock is held.
Compute the next global spec number by scanning the project's specs and all worktree specs.
Requires the spec-numbering lock to be held; caches the computed maximum for subsequent calls.
Returns:
Next available spec number (global max + 1)
int: The next available spec number (highest existing spec number + 1).
Raises:
SpecNumberLockError: If the lock has not been acquired when called.
"""
if not self.acquired:
raise SpecNumberLockError(
@@ -249,7 +253,10 @@ class SpecNumberLock:
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))
# 2. Scan all worktree specs
worktrees_dir = self.project_dir / ".auto-claude" / "worktrees" / "tasks"
from core.config import get_worktree_base_path
worktree_base_path = get_worktree_base_path(self.project_dir)
worktrees_dir = self.project_dir / worktree_base_path
if worktrees_dir.exists():
for worktree in worktrees_dir.iterdir():
if worktree.is_dir():
@@ -272,4 +279,4 @@ class SpecNumberLock:
except ValueError:
pass
return max_num
return max_num
-9
View File
@@ -267,15 +267,6 @@ def setup_workspace(
f"Environment files copied: {', '.join(copied_env_files)}", "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():
+112 -72
View File
@@ -4,7 +4,7 @@ Git Worktree Manager - Per-Spec Architecture
=============================================
Each spec gets its own worktree:
- Worktree path: .auto-claude/worktrees/tasks/{spec-name}/
- Worktree path: .worktrees/{spec-name}/
- Branch name: auto-claude/{spec-name}
This allows:
@@ -48,14 +48,27 @@ class WorktreeManager:
"""
Manages per-spec Git worktrees.
Each spec gets its own worktree in .auto-claude/worktrees/tasks/{spec-name}/ with
Each spec gets its own worktree in .worktrees/{spec-name}/ with
a corresponding branch auto-claude/{spec-name}.
"""
def __init__(self, project_dir: Path, base_branch: str | None = None):
"""
Initialize the WorktreeManager for a repository, determining base branch and worktrees directory.
Parameters:
project_dir (Path): Root path of the repository managed by this instance.
base_branch (str | None): Optional explicit base branch to use; if omitted, the base branch is auto-detected.
"""
from core.config import get_worktree_base_path
self.project_dir = project_dir
self.base_branch = base_branch or self._detect_base_branch()
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
# Use custom worktree path from environment variable with validation
worktree_base_path = get_worktree_base_path(project_dir)
self.worktrees_dir = project_dir / worktree_base_path
self._merge_lock = asyncio.Lock()
def _detect_base_branch(self) -> str:
@@ -124,37 +137,17 @@ class WorktreeManager:
return result.stdout.strip()
def _run_git(
self, args: list[str], cwd: Path | None = None, timeout: int = 60
self, args: list[str], cwd: Path | None = None
) -> subprocess.CompletedProcess:
"""Run a git command and return the result.
Args:
args: Git command arguments (without 'git' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
Returns:
CompletedProcess with command results. On timeout, returns a
CompletedProcess with returncode=-1 and timeout error in stderr.
"""
try:
return subprocess.run(
["git"] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
# Return a failed result on timeout instead of raising
return subprocess.CompletedProcess(
args=["git"] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
"""Run a git command and return the result."""
return subprocess.run(
["git"] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def _unstage_gitignored_files(self) -> None:
"""
@@ -214,7 +207,7 @@ class WorktreeManager:
def setup(self) -> None:
"""Create worktrees directory if needed."""
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
self.worktrees_dir.mkdir(exist_ok=True)
# ==================== Per-Spec Worktree Methods ====================
@@ -347,33 +340,9 @@ class WorktreeManager:
# Delete branch if it exists (from previous attempt)
self._run_git(["branch", "-D", branch_name])
# Fetch latest from remote to ensure we have the most up-to-date code
# GitHub/remote is the source of truth, not the local branch
fetch_result = self._run_git(["fetch", "origin", self.base_branch])
if fetch_result.returncode != 0:
print(
f"Warning: Could not fetch {self.base_branch} from origin: {fetch_result.stderr}"
)
print("Falling back to local branch...")
# Determine the start point for the worktree
# Prefer origin/{base_branch} (remote) over local branch to ensure we have latest code
remote_ref = f"origin/{self.base_branch}"
start_point = self.base_branch # Default to local branch
# Check if remote ref exists and use it as the source of truth
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Create worktree with new branch from the start point (remote preferred)
# Create worktree with new branch from base
result = self._run_git(
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
["worktree", "add", "-b", branch_name, str(worktree_path), self.base_branch]
)
if result.returncode != 0:
@@ -522,12 +491,14 @@ class WorktreeManager:
"""List all spec worktrees."""
worktrees = []
if self.worktrees_dir.exists():
for item in self.worktrees_dir.iterdir():
if item.is_dir():
info = self.get_worktree_info(item.name)
if info:
worktrees.append(info)
if not self.worktrees_dir.exists():
return worktrees
for item in self.worktrees_dir.iterdir():
if item.is_dir():
info = self.get_worktree_info(item.name)
if info:
worktrees.append(info)
return worktrees
@@ -629,12 +600,81 @@ class WorktreeManager:
return commands
def has_uncommitted_changes(self, spec_name: str | None = None) -> bool:
# ==================== Backward Compatibility ====================
# These methods provide backward compatibility with the old single-worktree API
def get_staging_path(self) -> Path | None:
"""
Backward compatibility: Get path to any existing spec worktree.
Prefer using get_worktree_path(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return worktrees[0].path
return None
def get_staging_info(self) -> WorktreeInfo | None:
"""
Backward compatibility: Get info about any existing spec worktree.
Prefer using get_worktree_info(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return worktrees[0]
return None
def merge_staging(self, delete_after: bool = True) -> bool:
"""
Backward compatibility: Merge first found worktree.
Prefer using merge_worktree(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return self.merge_worktree(worktrees[0].spec_name, delete_after)
return False
def remove_staging(self, delete_branch: bool = True) -> None:
"""
Backward compatibility: Remove first found worktree.
Prefer using remove_worktree(spec_name) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
self.remove_worktree(worktrees[0].spec_name, delete_branch)
def get_or_create_staging(self, spec_name: str) -> WorktreeInfo:
"""
Backward compatibility: Alias for get_or_create_worktree.
"""
return self.get_or_create_worktree(spec_name)
def staging_exists(self) -> bool:
"""
Backward compatibility: Check if any spec worktree exists.
Prefer using worktree_exists(spec_name) instead.
"""
return len(self.list_all_worktrees()) > 0
def commit_in_staging(self, message: str) -> bool:
"""
Backward compatibility: Commit in first found worktree.
Prefer using commit_in_worktree(spec_name, message) instead.
"""
worktrees = self.list_all_worktrees()
if worktrees:
return self.commit_in_worktree(worktrees[0].spec_name, message)
return False
def has_uncommitted_changes(self, in_staging: bool = False) -> bool:
"""Check if there are uncommitted changes."""
cwd = None
if spec_name:
worktree_path = self.get_worktree_path(spec_name)
if worktree_path.exists():
cwd = worktree_path
worktrees = self.list_all_worktrees()
if in_staging and worktrees:
cwd = worktrees[0].path
else:
cwd = None
result = self._run_git(["status", "--porcelain"], cwd=cwd)
return bool(result.stdout.strip())
# Keep STAGING_WORKTREE_NAME for backward compatibility in imports
STAGING_WORKTREE_NAME = "auto-claude"
+1 -1
View File
@@ -25,7 +25,7 @@ class IdeationConfigManager:
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
append: bool = False,
+4 -4
View File
@@ -17,7 +17,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from client import create_client
from phase_config import get_thinking_budget, resolve_model_id
from phase_config import get_thinking_budget
from ui import print_status
# Ideation types
@@ -56,7 +56,7 @@ class IdeationGenerator:
self,
project_dir: Path,
output_dir: Path,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
max_ideas_per_type: int = 5,
):
@@ -94,7 +94,7 @@ class IdeationGenerator:
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
self.model,
max_thinking_tokens=self.thinking_budget,
)
@@ -187,7 +187,7 @@ Write the fixed JSON to the file now.
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
self.model,
max_thinking_tokens=self.thinking_budget,
)
+1 -1
View File
@@ -41,7 +41,7 @@ class IdeationOrchestrator:
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
append: bool = False,
+1 -1
View File
@@ -31,6 +31,6 @@ class IdeationConfig:
include_roadmap_context: bool = True
include_kanban_context: bool = True
max_ideas_per_type: int = 5
model: str = "sonnet" # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101"
refresh: bool = False
append: bool = False # If True, preserve existing ideas when merging
@@ -343,34 +343,6 @@ class GraphitiMemory:
return await self._search.get_similar_task_outcomes(task_description, limit)
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Get patterns and gotchas relevant to the query.
This method specifically retrieves PATTERN and GOTCHA episode types
to enable cross-session learning. Unlike get_relevant_context(),
it filters for these specific types rather than doing generic search.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
if not await self._ensure_initialized():
return [], []
return await self._search.get_patterns_and_gotchas(
query, num_results, min_score
)
# Status and utility methods
def get_status_summary(self) -> dict:
@@ -10,8 +10,6 @@ import logging
from pathlib import Path
from .schema import (
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
@@ -57,7 +55,6 @@ class GraphitiSearch:
query: str,
num_results: int = MAX_CONTEXT_RESULTS,
include_project_context: bool = True,
min_score: float = 0.0,
) -> list[dict]:
"""
Search for relevant context based on a query.
@@ -107,12 +104,6 @@ class GraphitiSearch:
}
)
# Filter by minimum score if specified
if min_score > 0:
context_items = [
item for item in context_items if item.get("score", 0) >= min_score
]
logger.info(
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
)
@@ -162,7 +153,7 @@ class GraphitiSearch:
):
continue
sessions.append(data)
except (json.JSONDecodeError, TypeError, AttributeError):
except (json.JSONDecodeError, TypeError):
continue
# Sort by session number and return latest
@@ -214,7 +205,7 @@ class GraphitiSearch:
"score": getattr(result, "score", 0.0),
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
except (json.JSONDecodeError, TypeError):
continue
return outcomes[:limit]
@@ -222,107 +213,3 @@ class GraphitiSearch:
except Exception as e:
logger.warning(f"Failed to get similar task outcomes: {e}")
return []
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Retrieve patterns and gotchas relevant to the current task.
Unlike get_relevant_context(), this specifically filters for
EPISODE_TYPE_PATTERN and EPISODE_TYPE_GOTCHA episodes to enable
cross-session learning.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
patterns = []
gotchas = []
try:
# Search with query focused on patterns
pattern_results = await self.client.graphiti.search(
query=f"pattern: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in pattern_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_PATTERN in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_PATTERN:
patterns.append(
{
"pattern": data.get("pattern", ""),
"applies_to": data.get("applies_to", ""),
"example": data.get("example", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Search with query focused on gotchas
gotcha_results = await self.client.graphiti.search(
query=f"gotcha pitfall avoid: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in gotcha_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_GOTCHA in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_GOTCHA:
gotchas.append(
{
"gotcha": data.get("gotcha", ""),
"trigger": data.get("trigger", ""),
"solution": data.get("solution", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Sort by score and limit
patterns.sort(key=lambda x: x.get("score", 0), reverse=True)
gotchas.sort(key=lambda x: x.get("score", 0), reverse=True)
logger.info(
f"Found {len(patterns)} patterns and {len(gotchas)} gotchas for: {query[:50]}..."
)
return patterns[:num_results], gotchas[:num_results]
except Exception as e:
logger.warning(f"Failed to get patterns/gotchas: {e}")
return [], []
+1 -2
View File
@@ -118,7 +118,6 @@ def _create_linear_client() -> ClaudeSDKClient:
get_sdk_env_vars,
require_auth_token,
)
from phase_config import resolve_model_id
require_auth_token() # Raises ValueError if no token found
ensure_claude_code_oauth_token()
@@ -131,7 +130,7 @@ def _create_linear_client() -> ClaudeSDKClient:
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id("haiku"), # Resolves via API Profile if configured
model="claude-haiku-4-5", # Fast & cheap model for simple API calls
system_prompt="You are a Linear API assistant. Execute the requested Linear operation precisely.",
allowed_tools=LINEAR_TOOLS,
mcp_servers={
@@ -87,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)
@@ -157,21 +157,9 @@ class ModificationTracker:
)
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,
@@ -188,19 +176,19 @@ class ModificationTracker:
)
for file_path in changed_files:
# Get the diff for this file (using merge-base for accurate task-only diff)
# Get the diff for this file
diff_result = subprocess.run(
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
["git", "diff", f"{target_branch}...HEAD", "--", file_path],
cwd=worktree_path,
capture_output=True,
text=True,
check=True,
)
# Get content before (from merge-base - the point where task branched)
# Get content before (from target branch) and after (current)
try:
show_result = subprocess.run(
["git", "show", f"{merge_base}:{file_path}"],
["git", "show", f"{target_branch}:{file_path}"],
cwd=worktree_path,
capture_output=True,
text=True,
+7 -42
View File
@@ -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,
@@ -66,9 +37,6 @@ def apply_single_task_changes(
"""
content = baseline
# Detect line ending style once at the start to use consistently
line_ending = detect_line_ending(content)
for change in snapshot.semantic_changes:
if change.content_before and change.content_after:
# Modification - replace
@@ -77,13 +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}"
content += f"\n\n{change.content_after}"
return content
@@ -106,9 +74,6 @@ def combine_non_conflicting_changes(
"""
content = baseline
# Detect line ending style once at the start to use consistently
line_ending = detect_line_ending(content)
# Group changes by type for proper ordering
imports: list[SemanticChange] = []
functions: list[SemanticChange] = []
@@ -131,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:
@@ -147,12 +112,12 @@ 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)
+19 -10
View File
@@ -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
@@ -30,16 +30,11 @@ def analyze_with_regex(
"""
changes: list[SemanticChange] = []
# Normalize line endings to LF for consistent cross-platform behavior
# This handles Windows CRLF, old Mac CR, and Unix LF
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
# Get a unified diff
diff = list(
difflib.unified_diff(
before_normalized.splitlines(keepends=True),
after_normalized.splitlines(keepends=True),
before.splitlines(keepends=True),
after.splitlines(keepends=True),
lineterm="",
)
)
@@ -94,22 +89,8 @@ def analyze_with_regex(
# Detect function changes (simplified)
func_pattern = get_function_pattern(ext)
if func_pattern:
# For JS/TS patterns with alternation, findall() returns tuples
# Extract the non-empty match from each tuple
def extract_func_names(matches):
names = set()
for match in matches:
if isinstance(match, tuple):
# Get the first non-empty group from the tuple
name = next((m for m in match if m), None)
if name:
names.add(name)
elif match:
names.add(match)
return names
funcs_before = extract_func_names(func_pattern.findall(before_normalized))
funcs_after = extract_func_names(func_pattern.findall(after_normalized))
funcs_before = set(func_pattern.findall(before))
funcs_after = set(func_pattern.findall(after))
for func in funcs_after - funcs_before:
changes.append(
+4 -10
View File
@@ -211,18 +211,12 @@ class SemanticAnalyzer:
"""Analyze using tree-sitter AST parsing."""
parser = self._parsers[ext]
# Normalize line endings to LF for consistent cross-platform behavior
# This ensures byte positions and line counts work correctly on all platforms
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
tree_before = parser.parse(bytes(before_normalized, "utf-8"))
tree_after = parser.parse(bytes(after_normalized, "utf-8"))
tree_before = parser.parse(bytes(before, "utf-8"))
tree_after = parser.parse(bytes(after, "utf-8"))
# Extract structural elements from both versions
# Use normalized content to match tree-sitter byte positions
elements_before = self._extract_elements(tree_before, before_normalized, ext)
elements_after = self._extract_elements(tree_after, after_normalized, ext)
elements_before = self._extract_elements(tree_before, before, ext)
elements_after = self._extract_elements(tree_after, after, ext)
# Compare and generate semantic changes
changes = compare_elements(elements_before, elements_after, ext)
+1 -8
View File
@@ -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")
+4 -22
View File
@@ -7,7 +7,6 @@ Reads configuration from task_metadata.json and provides resolved model IDs.
"""
import json
import os
from pathlib import Path
from typing import Literal, TypedDict
@@ -47,10 +46,10 @@ SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
"complexity_assessment": "medium",
}
# Default phase configuration (fallback, matches 'Balanced' profile)
# Default phase configuration (matches UI defaults)
DEFAULT_PHASE_MODELS: dict[str, str] = {
"spec": "sonnet",
"planning": "sonnet", # Changed from "opus" (fix #433)
"planning": "opus",
"coding": "sonnet",
"qa": "sonnet",
}
@@ -95,34 +94,17 @@ def resolve_model_id(model: str) -> str:
Resolve a model shorthand (haiku, sonnet, opus) to a full model ID.
If the model is already a full ID, return it unchanged.
Priority:
1. Environment variable override (from API Profile)
2. Hardcoded MODEL_ID_MAP
3. Pass through unchanged (assume full model ID)
Args:
model: Model shorthand or full ID
Returns:
Full Claude model ID
"""
# Check for environment variable override (from API Profile custom model mappings)
# Check if it's a shorthand
if model in MODEL_ID_MAP:
env_var_map = {
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
}
env_var = env_var_map.get(model)
if env_var:
env_value = os.environ.get(env_var)
if env_value:
return env_value
# Fall back to hardcoded mapping
return MODEL_ID_MAP[model]
# Already a full model ID or unknown shorthand
# Already a full model ID
return model
@@ -173,16 +173,12 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
"zig",
},
"dart": {
# Core Dart CLI (modern unified tool)
"dart",
"pub",
# Flutter CLI (included in Dart language for SDK detection)
"flutter",
# Legacy commands (deprecated but may exist in older projects)
"dart2js",
"dartanalyzer",
"dartdoc",
"dartfmt",
"pub",
},
}
@@ -33,9 +33,6 @@ PACKAGE_MANAGER_COMMANDS: dict[str, set[str]] = {
"brew": {"brew"},
"apt": {"apt", "apt-get", "dpkg"},
"nix": {"nix", "nix-shell", "nix-build", "nix-env"},
# Dart/Flutter package managers
"pub": {"pub", "dart"},
"melos": {"melos", "dart", "flutter"},
}
@@ -23,8 +23,6 @@ VERSION_MANAGER_COMMANDS: dict[str, set[str]] = {
"rustup": {"rustup"},
"sdkman": {"sdk"},
"jabba": {"jabba"},
# Dart/Flutter version managers
"fvm": {"fvm", "flutter"},
}
+1 -10
View File
@@ -126,7 +126,7 @@ class StackDetector:
self.stack.package_managers.append("yarn")
if self.parser.file_exists("pnpm-lock.yaml"):
self.stack.package_managers.append("pnpm")
if self.parser.file_exists("bun.lockb", "bun.lock"):
if self.parser.file_exists("bun.lockb"):
self.stack.package_managers.append("bun")
if self.parser.file_exists("deno.json", "deno.jsonc"):
self.stack.package_managers.append("deno")
@@ -164,12 +164,6 @@ class StackDetector:
if self.parser.file_exists("build.gradle", "build.gradle.kts"):
self.stack.package_managers.append("gradle")
# Dart/Flutter package managers
if self.parser.file_exists("pubspec.yaml", "pubspec.lock"):
self.stack.package_managers.append("pub")
if self.parser.file_exists("melos.yaml"):
self.stack.package_managers.append("melos")
def detect_databases(self) -> None:
"""Detect databases from config files and dependencies."""
# Check for database config files
@@ -364,6 +358,3 @@ class StackDetector:
self.stack.version_managers.append("rbenv")
if self.parser.file_exists("rust-toolchain.toml", "rust-toolchain"):
self.stack.version_managers.append("rustup")
# Flutter Version Manager
if self.parser.file_exists(".fvm", ".fvmrc", "fvm_config.json"):
self.stack.version_managers.append("fvm")
+2 -17
View File
@@ -634,7 +634,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
api_key = os.environ.get("API_KEY")
```
3. **Update .env.example** - Add placeholder for the new variable
4. **Re-stage and retry** - `git add . ':!.auto-claude' && git commit ...`
4. **Re-stage and retry** - `git add . && git commit ...`
**If it's a false positive:**
- Add the file pattern to `.secretsignore` in the project root
@@ -643,8 +643,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
### Create the Commit
```bash
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
git add . ':!.auto-claude'
git add .
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
- Files modified: [list]
@@ -652,9 +651,6 @@ git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
- Phase progress: [X]/[Y] subtasks complete"
```
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
These are internal tracking files that must stay local.
### DO NOT Push to Remote
**IMPORTANT**: Do NOT run `git push`. All work stays local until the user reviews and approves.
@@ -960,17 +956,6 @@ Prepare → Test (small batch) → Execute (full) → Cleanup
- Clean, working state
- **Secret scan must pass before commit**
### Git Configuration - NEVER MODIFY
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
- `git config user.name`
- `git config user.email`
- `git config --local user.*`
- `git config --global user.*`
The repository inherits the user's configured git identity. Creating "Test User" or
any other fake identity breaks attribution and causes serious issues. If you need
to commit changes, use the existing git identity - do NOT set a new one.
### The Golden Rule
**FIX BUGS NOW.** The next session has no memory.
@@ -6,23 +6,6 @@ You are a focused codebase fit review agent. You have been spawned by the orches
Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Codebase fit issues in changed code** - New code not following project patterns
2. **Missed reuse opportunities** - "Existing `utils.ts` has a helper for this"
3. **Inconsistent with PR's own changes** - "You used `camelCase` here but `snake_case` elsewhere in the PR"
4. **Breaking conventions in touched areas** - "Your change deviates from the pattern in this file"
### What is NOT in scope (do NOT report):
1. **Pre-existing inconsistencies** - Old code that doesn't follow patterns
2. **Unrelated suggestions** - Don't suggest patterns for code the PR didn't touch
**Key distinction:**
- ✅ "Your new component doesn't follow the existing pattern in `components/`" - GOOD
- ✅ "Consider using existing `formatDate()` helper instead of new implementation" - GOOD
- ❌ "The old `legacy/` folder uses different naming conventions" - BAD (pre-existing)
## Codebase Fit Focus Areas
### 1. Naming Conventions
@@ -1,214 +0,0 @@
# Finding Validator Agent
You are a finding re-investigator using EVIDENCE-BASED VALIDATION. For each unresolved finding from a previous PR review, you must actively investigate whether it is a REAL issue or a FALSE POSITIVE.
**Core Principle: Evidence, not confidence scores.** Either you can prove the issue exists with actual code, or you can't. There is no middle ground.
Your job is to prevent false positives from persisting indefinitely by actually reading the code and verifying the issue exists.
## CRITICAL: Check PR Scope First
**Before investigating any finding, verify it's within THIS PR's scope:**
1. **Check if the file is in the PR's changed files list** - If not, likely out-of-scope
2. **Check if the line number exists** - If finding cites line 710 but file has 600 lines, it's hallucinated
3. **Check for PR references in commit messages** - Commits like `fix: something (#584)` are from OTHER PRs
**Dismiss findings as `dismissed_false_positive` if:**
- The finding references a file NOT in the PR's changed files list AND is not about impact on that file
- The line number doesn't exist in the file (hallucinated)
- The finding is about code from a merged branch commit (not this PR's work)
**Keep findings valid if they're about:**
- Issues in code the PR actually changed
- Impact of PR changes on other code (e.g., "this change breaks callers in X")
- Missing updates to related code (e.g., "you updated A but forgot B")
## Your Mission
For each finding you receive:
1. **VERIFY SCOPE** - Is this file/line actually part of this PR?
2. **READ** the actual code at the file/line location using the Read tool
3. **ANALYZE** whether the described issue actually exists in the code
4. **PROVIDE** concrete code evidence - the actual code that proves or disproves the issue
5. **RETURN** validation status with evidence (binary decision based on what the code shows)
## Investigation Process
### Step 1: Fetch the Code
Use the Read tool to get the actual code at `finding.file` around `finding.line`.
Get sufficient context (±20 lines minimum).
```
Read the file: {finding.file}
Focus on lines around: {finding.line}
```
### Step 2: Analyze with Fresh Eyes - NEVER ASSUME
**CRITICAL: Do NOT assume the original finding is correct.** The original reviewer may have:
- Hallucinated line numbers that don't exist
- Misread or misunderstood the code
- Missed validation/sanitization in callers or surrounding code
- Made assumptions without actually reading the implementation
- Confused similar-looking code patterns
**You MUST actively verify by asking:**
- Does the code at this exact line ACTUALLY have this issue?
- Did I READ the actual implementation, not just the function name?
- Is there validation/sanitization BEFORE this code is reached?
- Is there framework protection I'm not accounting for?
- Does this line number even EXIST in the file?
**NEVER:**
- Trust the finding description without reading the code
- Assume a function is vulnerable based on its name
- Skip checking surrounding context (±20 lines minimum)
- Confirm a finding just because "it sounds plausible"
Be HIGHLY skeptical. AI reviews frequently produce false positives. Your job is to catch them.
### Step 3: Document Evidence
You MUST provide concrete evidence:
- **Exact code snippet** you examined (copy-paste from the file) - this is the PROOF
- **Line numbers** where you found (or didn't find) the issue
- **Your analysis** connecting the code to your conclusion
- **Verification flag** - did this code actually exist at the specified location?
## Validation Statuses
### `confirmed_valid`
Use when your code evidence PROVES the issue IS real:
- The problematic code pattern exists exactly as described
- You can point to the specific lines showing the vulnerability/bug
- The code quality issue genuinely impacts the codebase
- **Key question**: Does your code_evidence field contain the actual problematic code?
### `dismissed_false_positive`
Use when your code evidence PROVES the issue does NOT exist:
- The described code pattern is not actually present (code_evidence shows different code)
- There is mitigating code that prevents the issue (code_evidence shows the mitigation)
- The finding was based on incorrect assumptions (code_evidence shows reality)
- The line number doesn't exist or contains different code than claimed
- **Key question**: Does your code_evidence field show code that disproves the original finding?
### `needs_human_review`
Use when you CANNOT find definitive evidence either way:
- The issue requires runtime analysis to verify (static code doesn't prove/disprove)
- The code is too complex to analyze statically
- You found the code but can't determine if it's actually a problem
- **Key question**: Is your code_evidence inconclusive?
## Output Format
Return one result per finding:
```json
{
"finding_id": "SEC-001",
"validation_status": "confirmed_valid",
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
"line_range": [45, 45],
"explanation": "SQL injection vulnerability confirmed. User input 'userId' is directly interpolated into the SQL query at line 45 without any sanitization. The query is executed via db.execute() on line 46.",
"evidence_verified_in_file": true
}
```
```json
{
"finding_id": "QUAL-002",
"validation_status": "dismissed_false_positive",
"code_evidence": "function processInput(data: string): string {\n const sanitized = DOMPurify.sanitize(data);\n return sanitized;\n}",
"line_range": [23, 26],
"explanation": "The original finding claimed XSS vulnerability, but the code uses DOMPurify.sanitize() before output. The input is properly sanitized at line 24 before being returned. The code evidence proves the issue does NOT exist.",
"evidence_verified_in_file": true
}
```
```json
{
"finding_id": "LOGIC-003",
"validation_status": "needs_human_review",
"code_evidence": "async function handleRequest(req) {\n // Complex async logic...\n}",
"line_range": [100, 150],
"explanation": "The original finding claims a race condition, but verifying this requires understanding the runtime behavior and concurrency model. The static code doesn't provide definitive evidence either way.",
"evidence_verified_in_file": true
}
```
```json
{
"finding_id": "HALLUC-004",
"validation_status": "dismissed_false_positive",
"code_evidence": "// Line 710 does not exist - file only has 600 lines",
"line_range": [600, 600],
"explanation": "The original finding claimed an issue at line 710, but the file only has 600 lines. This is a hallucinated finding - the code doesn't exist.",
"evidence_verified_in_file": false
}
```
## Evidence Guidelines
Validation is binary based on what the code evidence shows:
| Scenario | Status | Evidence Required |
|----------|--------|-------------------|
| Code shows the exact problem claimed | `confirmed_valid` | Problematic code snippet |
| Code shows issue doesn't exist or is mitigated | `dismissed_false_positive` | Code proving issue is absent |
| Code couldn't be found (hallucinated line/file) | `dismissed_false_positive` | Note that code doesn't exist |
| Code found but can't prove/disprove statically | `needs_human_review` | The inconclusive code |
**Decision rules:**
- If `code_evidence` contains problematic code → `confirmed_valid`
- If `code_evidence` proves issue doesn't exist → `dismissed_false_positive`
- If `evidence_verified_in_file` is false → `dismissed_false_positive` (hallucinated finding)
- If you can't determine from the code → `needs_human_review`
## Common False Positive Patterns
Watch for these patterns that often indicate false positives:
1. **Non-existent line number**: The line number cited doesn't exist or is beyond EOF - hallucinated finding
2. **Merged branch code**: Finding is about code from a commit like `fix: something (#584)` - another PR
3. **Pre-existing issue, not impact**: Finding flags old bug in untouched code without showing how PR changes relate
4. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
5. **Internal-only code**: Code only handles trusted internal data, not user input
6. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
7. **Dead code**: The flagged code is never executed in the current codebase
8. **Test code**: The issue is in test files where it's acceptable
9. **Misread syntax**: Original reviewer misunderstood the language syntax
**Note**: Findings about files outside the PR's changed list are NOT automatically false positives if they're about:
- Impact of PR changes on that file (e.g., "your change breaks X")
- Missing related updates (e.g., "you forgot to update Y")
## Common Valid Issue Patterns
These patterns often confirm the issue is real:
1. **Direct string concatenation** in SQL/commands with user input
2. **Missing null checks** where null values can flow through
3. **Hardcoded credentials** that are actually used (not examples)
4. **Missing error handling** in critical paths
5. **Race conditions** with clear concurrent access
## Critical Rules
1. **ALWAYS read the actual code** - Never rely on memory or the original finding description
2. **ALWAYS provide code_evidence** - No empty strings. Quote the actual code.
3. **Be skeptical of original findings** - Many AI reviews produce false positives
4. **Evidence is binary** - The code either shows the problem or it doesn't
5. **When evidence is inconclusive, escalate** - Use `needs_human_review` rather than guessing
6. **Look for mitigations** - Check surrounding code for sanitization/validation
7. **Check the full context** - Read ±20 lines, not just the flagged line
8. **Verify code exists** - Set `evidence_verified_in_file` to false if the code/line doesn't exist
## Anti-Patterns to Avoid
- **Trusting the original finding blindly** - Always verify with actual code
- **Dismissing without reading code** - Must provide code_evidence that proves your point
- **Vague explanations** - Be specific about what the code shows and why it proves/disproves the issue
- **Missing line numbers** - Always include line_range
- **Speculative conclusions** - Only conclude what the code evidence actually proves
+5 -7
View File
@@ -71,12 +71,10 @@ Review the diff since the last review for NEW issues:
- Regressions that break previously working code
- Missing error handling in new code paths
**NEVER ASSUME - ALWAYS VERIFY:**
- Actually READ the code before reporting any finding
- Verify the issue exists at the exact line you cite
- Check for validation/mitigation in surrounding code
**Apply the 80% confidence threshold:**
- Only report issues you're confident about
- Don't re-report issues from the previous review
- Focus on genuinely new problems with code EVIDENCE
- Focus on genuinely new problems
### Phase 3: Comment Review
@@ -139,11 +137,11 @@ Return a JSON object with this structure:
"id": "new-finding-1",
"severity": "medium",
"category": "security",
"confidence": 0.85,
"title": "New hardcoded API key in config",
"description": "A new API key was added in config.ts line 45 without using environment variables.",
"file": "src/config.ts",
"line": 45,
"evidence": "const API_KEY = 'sk-prod-abc123xyz789';",
"suggested_fix": "Move to environment variable: process.env.EXTERNAL_API_KEY"
}
],
@@ -177,11 +175,11 @@ Same format as initial review findings:
- **id**: Unique identifier for new finding
- **severity**: `critical` | `high` | `medium` | `low`
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
- **confidence**: Float 0.80-1.0
- **title**: Short summary (max 80 chars)
- **description**: Detailed explanation
- **file**: Relative file path
- **line**: Line number
- **evidence**: **REQUIRED** - Actual code snippet proving the issue exists
- **suggested_fix**: How to resolve
### verdict
@@ -11,23 +11,6 @@ Review the incremental diff for:
4. Potential regressions
5. Incomplete implementations
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
2. **Impact on unchanged code** - "This change breaks callers in `other_file.ts`"
3. **Missing related changes** - "Similar pattern in `utils.ts` wasn't updated"
4. **Incomplete implementations** - "New field added but not handled in serializer"
### What is NOT in scope (do NOT report):
1. **Pre-existing bugs** - Old bugs in code this PR didn't touch
2. **Code from merged branches** - Commits with PR references like `(#584)` are from other PRs
3. **Unrelated improvements** - Don't suggest refactoring untouched code
**Key distinction:**
- ✅ "Your change breaks the caller in `auth.ts`" - GOOD (impact analysis)
- ❌ "The old code in `legacy.ts` has a bug" - BAD (pre-existing, not this PR)
## Focus Areas
Since this is a follow-up review, focus on:
@@ -91,29 +74,15 @@ Since this is a follow-up review, focus on:
- Minor optimizations
- Documentation gaps
## NEVER ASSUME - ALWAYS VERIFY
## Confidence Scoring
**Before reporting ANY new finding:**
Rate confidence (0.0-1.0) based on:
- **>0.9**: Obvious, verifiable issue
- **0.8-0.9**: High confidence with clear evidence
- **0.7-0.8**: Likely issue but some uncertainty
- **<0.7**: Possible issue, needs verification
1. **NEVER assume code is vulnerable** - Read the actual implementation
2. **NEVER assume validation is missing** - Check callers and surrounding code
3. **NEVER assume based on function names** - `unsafeQuery()` might actually be safe
4. **NEVER report without reading the code** - Verify the issue exists at the exact line
**You MUST:**
- Actually READ the code at the file/line you cite
- Verify there's no sanitization/validation before this code
- Check for framework protections you might miss
- Provide the actual code snippet as evidence
## Evidence Requirements
Every finding MUST include an `evidence` field with:
- The actual problematic code copy-pasted from the diff
- The specific line numbers where the issue exists
- Proof that the issue is real, not speculative
**No evidence = No finding**
Only report findings with confidence >0.7.
## Output Format
@@ -130,7 +99,7 @@ Return findings in this structure:
"description": "The new login validation query concatenates user input directly into the SQL string without sanitization.",
"category": "security",
"severity": "critical",
"evidence": "query = f\"SELECT * FROM users WHERE email = '{email}'\"",
"confidence": 0.95,
"suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))",
"fixable": true,
"source_agent": "new-code-reviewer",
@@ -144,7 +113,7 @@ Return findings in this structure:
"description": "The fix for LOGIC-003 removed a null check that was protecting against undefined input. Now input.data can be null.",
"category": "regression",
"severity": "high",
"evidence": "result = input.data.process() # input.data can be null, was previously: if input and input.data:",
"confidence": 0.88,
"suggested_fix": "Restore null check: if (input && input.data) { ... }",
"fixable": true,
"source_agent": "new-code-reviewer",
@@ -9,40 +9,6 @@ Perform a focused, efficient follow-up review by:
2. Delegating to specialized agents based on what needs verification
3. Synthesizing findings into a final merge verdict
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
2. **Impact on unchanged code** - "You changed X but forgot to update Y that depends on it"
3. **Missing related changes** - "This pattern also exists in Z, did you mean to update it too?"
4. **Breaking changes** - "This change breaks callers in other files"
### What is NOT in scope (do NOT report):
1. **Pre-existing issues in unchanged code** - If old code has a bug but this PR didn't touch it, don't flag it
2. **Code from merged branches** - Commits with PR references like `(#584)` are from OTHER already-reviewed PRs
3. **Unrelated improvements** - Don't suggest refactoring code the PR didn't touch
**Key distinction:**
- ✅ "Your change to `validateUser()` breaks the caller in `auth.ts:45`" - GOOD (impact of PR changes)
- ✅ "You updated this validation but similar logic in `utils.ts` wasn't updated" - GOOD (incomplete change)
- ❌ "The existing code in `legacy.ts` has a SQL injection" - BAD (pre-existing issue, not this PR)
- ❌ "This code from commit `fix: something (#584)` has an issue" - BAD (different PR)
**Why this matters:**
When authors merge the base branch into their feature branch, the commit range includes commits from other PRs. The context gathering system filters these out, but if any slip through, recognize them as out-of-scope.
## Merge Conflicts
**Check for merge conflicts in the follow-up context.** If `has_merge_conflicts` is `true`:
1. **Report this prominently** - Merge conflicts block the PR from being merged
2. **Add a CRITICAL finding** with category "merge_conflict" and severity "critical"
3. **Include in verdict reasoning** - The PR cannot be merged until conflicts are resolved
4. **This may be NEW since last review** - Base branch may have changed
Note: GitHub's API tells us IF there are conflicts but not WHICH files. The finding should state:
> "This PR has merge conflicts with the base branch that must be resolved before merging."
## Available Specialist Agents
You have access to these specialist agents via the Task tool:
@@ -69,20 +35,6 @@ You have access to these specialist agents via the Task tool:
- Flags concerns that need addressing
- **Invoke when**: There are comments or reviews since last review
### 4. finding-validator (CRITICAL - Prevent False Positives)
**Use for**: Re-investigating unresolved findings to validate they are real issues
- Reads the ACTUAL CODE at the finding location with fresh eyes
- Actively investigates whether the described issue truly exists
- Can DISMISS findings as false positives if original review was incorrect
- Can CONFIRM findings as valid if issue is genuine
- Requires concrete CODE EVIDENCE for any conclusion
- **ALWAYS invoke after resolution-verifier for ALL unresolved findings**
- **Invoke when**: There are findings still marked as unresolved
**Why this is critical**: Initial reviews may produce false positives (hallucinated issues).
Without validation, these persist indefinitely. This agent prevents that by actually
examining the code and determining if the issue is real.
## Workflow
### Phase 1: Analyze Scope
@@ -98,9 +50,6 @@ Based on your analysis, invoke the appropriate agents:
**Always invoke** `resolution-verifier` if there are previous findings.
**ALWAYS invoke** `finding-validator` for ALL unresolved findings from resolution-verifier.
This is CRITICAL to prevent false positives from persisting.
**Invoke** `new-code-reviewer` if:
- Diff is substantial (>50 lines)
- Changes touch security-sensitive areas
@@ -112,67 +61,36 @@ This is CRITICAL to prevent false positives from persisting.
- There are AI tool reviews to triage
- Questions remain unanswered
### Phase 3: Validate Unresolved Findings
After resolution-verifier returns findings marked as unresolved:
1. Pass ALL unresolved findings to finding-validator
2. finding-validator will read the actual code at each location
3. For each finding, it returns:
- `confirmed_valid`: Issue IS real → keep as unresolved
- `dismissed_false_positive`: Original finding was WRONG → remove from findings
- `needs_human_review`: Cannot determine → flag for human
### Phase 4: Synthesize Results
After all agents complete:
### Phase 3: Synthesize Results
After agents complete:
1. Combine resolution verifications
2. Apply validation results (remove dismissed false positives)
3. Merge new findings (deduplicate if needed)
4. Incorporate comment analysis
5. Generate final verdict based on VALIDATED findings only
2. Merge new findings (deduplicate if needed)
3. Incorporate comment analysis
4. Generate final verdict
## Verdict Guidelines
### CRITICAL: CI Status ALWAYS Factors Into Verdict
**CI status is provided in the context and MUST be considered:**
- ❌ **Failing CI = BLOCKED** - If ANY CI checks are failing, verdict MUST be BLOCKED regardless of code quality
- ⏳ **Pending CI = NEEDS_REVISION** - If CI is still running, verdict cannot be READY_TO_MERGE
- ⏸️ **Awaiting approval = BLOCKED** - Fork PR workflows awaiting maintainer approval block merge
- ✅ **All passing = Continue with code analysis** - Only then do code findings determine verdict
**Always mention CI status in your verdict_reasoning.** For example:
- "BLOCKED: 2 CI checks failing (CodeQL, test-frontend). Fix CI before merge."
- "READY_TO_MERGE: All CI checks passing and all findings resolved."
### READY_TO_MERGE
- **All CI checks passing** (no failing, no pending)
- All previous findings verified as resolved OR dismissed as false positives
- No CONFIRMED_VALID critical/high issues remaining
- All previous findings verified as resolved
- No new critical/high issues
- No blocking concerns from comments
- Contributor questions addressed
### MERGE_WITH_CHANGES
- **All CI checks passing**
- Previous findings resolved
- Only LOW severity new issues (suggestions)
- Optional polish items can be addressed post-merge
### NEEDS_REVISION (Strict Quality Gates)
- **CI checks pending** OR
- HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive)
- HIGH or MEDIUM severity findings unresolved
- New HIGH or MEDIUM severity issues introduced
- Important contributor concerns unaddressed
- **Note: Both HIGH and MEDIUM block merge** (AI fixes quickly, so be strict)
- **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block)
### BLOCKED
- **Any CI checks failing** OR
- **Workflows awaiting maintainer approval** (fork PRs) OR
- CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive)
- CRITICAL findings remain unresolved
- New CRITICAL issues introduced
- Fundamental problems with the fix approach
- **Note: Only block for findings that passed validation**
## Cross-Validation
@@ -188,28 +106,10 @@ Provide your synthesis as a structured response matching the ParallelFollowupRes
```json
{
"analysis_summary": "Brief summary of what was analyzed",
"agents_invoked": ["resolution-verifier", "finding-validator", "new-code-reviewer"],
"agents_invoked": ["resolution-verifier", "new-code-reviewer"],
"commits_analyzed": 5,
"files_changed": 12,
"resolution_verifications": [...],
"finding_validations": [
{
"finding_id": "SEC-001",
"validation_status": "confirmed_valid",
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
"line_range": [45, 45],
"explanation": "SQL injection is present - user input is concatenated...",
"confidence": 0.92
},
{
"finding_id": "QUAL-002",
"validation_status": "dismissed_false_positive",
"code_evidence": "const sanitized = DOMPurify.sanitize(data);",
"line_range": [23, 26],
"explanation": "Original finding claimed XSS but code uses DOMPurify...",
"confidence": 0.88
}
],
"new_findings": [...],
"comment_analyses": [...],
"comment_findings": [...],
@@ -219,40 +119,20 @@ Provide your synthesis as a structured response matching the ParallelFollowupRes
"resolution_notes": null
},
"verdict": "READY_TO_MERGE",
"verdict_reasoning": "2 findings resolved, 1 dismissed as false positive, 1 confirmed valid but LOW severity..."
"verdict_reasoning": "All 3 previous findings verified as resolved..."
}
```
## CRITICAL: NEVER ASSUME - ALWAYS VERIFY
**This applies to ALL agents you invoke:**
1. **NEVER assume a finding is valid** - The finding-validator MUST read the actual code
2. **NEVER assume a fix is correct** - The resolution-verifier MUST verify the change
3. **NEVER assume line numbers are accurate** - Files may be shorter than cited lines
4. **NEVER assume validation is missing** - Check callers and surrounding code
5. **NEVER trust the original finding's description** - It may have been hallucinated
**Before ANY finding blocks merge:**
- The actual code at that location MUST be read
- The problematic pattern MUST exist as described
- There MUST NOT be mitigation/validation elsewhere
- The evidence MUST be copy-pasted from the actual file
**Why this matters:** AI reviewers sometimes hallucinate findings. Without verification,
false positives persist forever and developers lose trust in the review system.
## Important Notes
1. **Be efficient**: Follow-up reviews should be faster than initial reviews
2. **Focus on changes**: Only review what changed since last review
3. **VERIFY, don't assume**: Don't assume fixes are correct OR that findings are valid
3. **Trust but verify**: Don't assume fixes are correct just because files changed
4. **Acknowledge progress**: Recognize genuine effort to address feedback
5. **Be specific**: Clearly state what blocks merge if verdict is not READY_TO_MERGE
## Context You Will Receive
- **CI Status (CRITICAL)** - Passing/failing/pending checks and specific failed check names
- Previous review summary and findings
- New commits since last review (SHAs, messages)
- Diff of changes since last review
@@ -10,23 +10,6 @@ For each previous finding, determine whether it has been:
- **unresolved**: The issue remains or wasn't addressed
- **cant_verify**: Not enough information to determine status
## CRITICAL: Verify Finding is In-Scope
**Before verifying any finding, check if it's within THIS PR's scope:**
1. **Is the file in the PR's changed files list?** - If not AND the finding isn't about impact, mark as `cant_verify`
2. **Does the line number exist?** - If finding cites line 710 but file has 600 lines, it was hallucinated
3. **Was this from a merged branch?** - Commits with PR references like `(#584)` are from other PRs
**Mark as `cant_verify` if:**
- Finding references a file not in PR AND is not about impact of PR changes on that file
- Line number doesn't exist (hallucinated finding)
- Finding is about code from another PR's commits
**Findings can reference files outside the PR if they're about:**
- Impact of PR changes (e.g., "change to X breaks caller in Y")
- Missing related updates (e.g., "you updated A but forgot B")
## Verification Process
For each previous finding:
@@ -48,26 +31,12 @@ If the file was modified:
- Is the fix approach sound?
- Are there edge cases the fix misses?
### 4. Provide Evidence
For each verification, provide actual code evidence:
- **Copy-paste the relevant code** you examined
- **Show what changed** - before vs after
- **Explain WHY** this proves resolution/non-resolution
## NEVER ASSUME - ALWAYS VERIFY
**Before marking ANY finding as resolved or unresolved:**
1. **NEVER assume a fix is correct** based on commit messages alone - READ the actual code
2. **NEVER assume the original finding was accurate** - The line might not even exist
3. **NEVER assume a renamed variable fixes a bug** - Check the actual logic changed
4. **NEVER assume "file was modified" means "issue was fixed"** - Verify the specific fix
**You MUST:**
- Read the actual code at the cited location
- Verify the problematic pattern no longer exists (for resolved)
- Verify the pattern still exists (for unresolved)
- Check surrounding context for alternative fixes you might miss
### 4. Assign Confidence
Rate your confidence (0.0-1.0):
- **>0.9**: Clear evidence of resolution/non-resolution
- **0.7-0.9**: Strong indicators but some uncertainty
- **0.5-0.7**: Mixed signals, moderate confidence
- **<0.5**: Unclear, consider marking as cant_verify
## Resolution Criteria
@@ -115,20 +84,23 @@ Return verifications in this structure:
{
"finding_id": "SEC-001",
"status": "resolved",
"evidence": "cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))",
"resolution_notes": "Changed from f-string to cursor.execute() with parameters. The code at line 45 now uses parameterized queries."
"confidence": 0.92,
"evidence": "The SQL query at line 45 now uses parameterized queries instead of string concatenation. The fix properly escapes all user inputs.",
"resolution_notes": "Changed from f-string to cursor.execute() with parameters"
},
{
"finding_id": "QUAL-002",
"status": "partially_resolved",
"evidence": "try:\n result = process(data)\nexcept Exception as e:\n log.error(e)\n# But fallback path at line 78 still has: result = fallback(data) # no try-catch",
"confidence": 0.75,
"evidence": "Error handling was added for the main path, but the fallback path at line 78 still lacks try-catch.",
"resolution_notes": "Main function fixed, helper function still needs work"
},
{
"finding_id": "LOGIC-003",
"status": "unresolved",
"evidence": "for i in range(len(items) + 1): # Still uses <= length",
"resolution_notes": "The off-by-one error remains at line 52."
"confidence": 0.88,
"evidence": "The off-by-one error remains. The loop still uses `<= length` instead of `< length`.",
"resolution_notes": null
}
]
```
@@ -6,23 +6,6 @@ You are a focused logic and correctness review agent. You have been spawned by t
Verify that the code logic is correct, handles all edge cases, and doesn't introduce subtle bugs. Focus ONLY on logic and correctness issues - not style, security, or general quality.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Logic issues in changed code** - Bugs in files/lines modified by this PR
2. **Logic impact of changes** - "This change breaks the assumption in `caller.ts:50`"
3. **Incomplete state changes** - "You updated state X but forgot to reset Y"
4. **Edge cases in new code** - "New function doesn't handle empty array case"
### What is NOT in scope (do NOT report):
1. **Pre-existing bugs** - Old logic issues in untouched code
2. **Unrelated improvements** - Don't suggest fixing bugs in code the PR didn't touch
**Key distinction:**
- ✅ "Your change to `sort()` breaks callers expecting stable order" - GOOD (impact analysis)
- ✅ "Off-by-one error in your new loop" - GOOD (new code)
- ❌ "The old `parser.ts` has a race condition" - BAD (pre-existing, not this PR)
## Logic Focus Areas
### 1. Algorithm Correctness
@@ -6,34 +6,6 @@ You are an expert PR reviewer orchestrating a comprehensive, parallel code revie
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
2. **Impact on unchanged code** - "You changed X but forgot to update Y that depends on it"
3. **Missing related changes** - "This pattern also exists in Z, did you mean to update it too?"
4. **Breaking changes** - "This change breaks callers in other files"
### What is NOT in scope (do NOT report):
1. **Pre-existing issues** - Old bugs/issues in code this PR didn't touch
2. **Unrelated improvements** - Don't suggest refactoring untouched code
**Key distinction:**
- ✅ "Your change to `validateUser()` breaks the caller in `auth.ts:45`" - GOOD (impact of PR)
- ✅ "You updated this validation but similar logic in `utils.ts` wasn't updated" - GOOD (incomplete)
- ❌ "The existing code in `legacy.ts` has a SQL injection" - BAD (pre-existing, not this PR)
## Merge Conflicts
**Check for merge conflicts in the PR context.** If `has_merge_conflicts` is `true`:
1. **Report this prominently** - Merge conflicts block the PR from being merged
2. **Add a CRITICAL finding** with category "merge_conflict" and severity "critical"
3. **Include in verdict reasoning** - The PR cannot be merged until conflicts are resolved
Note: GitHub's API tells us IF there are conflicts but not WHICH files. The finding should state:
> "This PR has merge conflicts with the base branch that must be resolved before merging."
## Available Specialist Agents
You have access to these specialized review agents via the Task tool:
@@ -6,23 +6,6 @@ You are a focused code quality review agent. You have been spawned by the orches
Perform a thorough code quality review of the provided code changes. Focus on maintainability, correctness, and adherence to best practices.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Quality issues in changed code** - Problems in files/lines modified by this PR
2. **Quality impact of changes** - "This change increases complexity of `handler.ts`"
3. **Incomplete refactoring** - "You cleaned up X but similar pattern in Y wasn't updated"
4. **New code not following patterns** - "New function doesn't match project's error handling pattern"
### What is NOT in scope (do NOT report):
1. **Pre-existing quality issues** - Old code smells in untouched code
2. **Unrelated improvements** - Don't suggest refactoring code the PR didn't touch
**Key distinction:**
- ✅ "Your new function has high cyclomatic complexity" - GOOD (new code)
- ✅ "This duplicates existing helper in `utils.ts`, consider reusing it" - GOOD (guidance)
- ❌ "The old `legacy.ts` file has 1000 lines" - BAD (pre-existing, not this PR)
## Quality Focus Areas
### 1. Code Complexity
+19 -40
View File
@@ -4,49 +4,24 @@
You are a senior software engineer and security specialist performing a comprehensive code review. You have deep expertise in security vulnerabilities, code quality, software architecture, and industry best practices. Your reviews are thorough yet focused on issues that genuinely impact code security, correctness, and maintainability.
## Review Methodology: Evidence-Based Analysis
## Review Methodology: Chain-of-Thought Analysis
For each potential issue you consider:
1. **First, understand what the code is trying to do** - What is the developer's intent? What problem are they solving?
2. **Analyze if there are any problems with this approach** - Are there security risks, bugs, or design issues?
3. **Assess the severity and real-world impact** - Can this be exploited? Will this cause production issues? How likely is it to occur?
4. **REQUIRE EVIDENCE** - Only report if you can show the actual problematic code snippet
4. **Apply the 80% confidence threshold** - Only report if you have >80% confidence this is a genuine issue with real impact
5. **Provide a specific, actionable fix** - Give the developer exactly what they need to resolve the issue
## Evidence Requirements
## Confidence Requirements
**CRITICAL: No evidence = No finding**
**CRITICAL: Quality over quantity**
- **Every finding MUST include actual code evidence** (the `evidence` field with a copy-pasted code snippet)
- If you can't show the problematic code, **DO NOT report the finding**
- The evidence must be verifiable - it should exist at the file and line you specify
- **5 evidence-backed findings are far better than 15 speculative ones**
- Each finding should pass the test: "Can I prove this with actual code from the file?"
## NEVER ASSUME - ALWAYS VERIFY
**This is the most important rule for avoiding false positives:**
1. **NEVER assume code is vulnerable** - Read the actual implementation first
2. **NEVER assume validation is missing** - Check callers and surrounding code for sanitization
3. **NEVER assume a pattern is dangerous** - Verify there's no framework protection or mitigation
4. **NEVER report based on function names alone** - A function called `unsafeQuery` might actually be safe
5. **NEVER extrapolate from one line** - Read ±20 lines of context minimum
**Before reporting ANY finding, you MUST:**
- Actually read the code at the file/line you're about to cite
- Verify the problematic pattern exists exactly as you describe
- Check if there's validation/sanitization before or after
- Confirm the code path is actually reachable
- Verify the line number exists (file might be shorter than you think)
**Common false positive causes to avoid:**
- Reporting line 500 when the file only has 400 lines (hallucination)
- Claiming "no validation" when validation exists in the caller
- Flagging parameterized queries as SQL injection (framework protection)
- Reporting XSS when output is auto-escaped by the framework
- Citing code that was already fixed in an earlier commit
- Only report findings where you have **>80% confidence** this is a real issue
- If uncertain or it "could be a problem in theory," **DO NOT include it**
- **5 high-quality findings are far better than 15 low-quality ones**
- Each finding should pass the test: "Would I stake my reputation on this being a genuine issue?"
## Anti-Patterns to Avoid
@@ -239,13 +214,14 @@ Return a JSON array with this structure:
"id": "finding-1",
"severity": "critical",
"category": "security",
"confidence": 0.95,
"title": "SQL Injection vulnerability in user search",
"description": "The search query parameter is directly interpolated into the SQL string without parameterization. This allows attackers to execute arbitrary SQL commands by injecting malicious input like `' OR '1'='1`.",
"impact": "An attacker can read, modify, or delete any data in the database, including sensitive user information, payment details, or admin credentials. This could lead to complete data breach.",
"file": "src/api/users.ts",
"line": 42,
"end_line": 45,
"evidence": "const query = `SELECT * FROM users WHERE name LIKE '%${searchTerm}%'`",
"code_snippet": "const query = `SELECT * FROM users WHERE name LIKE '%${searchTerm}%'`",
"suggested_fix": "Use parameterized queries to prevent SQL injection:\n\nconst query = 'SELECT * FROM users WHERE name LIKE ?';\nconst results = await db.query(query, [`%${searchTerm}%`]);",
"fixable": true,
"references": ["https://owasp.org/www-community/attacks/SQL_Injection"]
@@ -254,12 +230,13 @@ Return a JSON array with this structure:
"id": "finding-2",
"severity": "high",
"category": "security",
"confidence": 0.88,
"title": "Missing authorization check allows privilege escalation",
"description": "The deleteUser endpoint only checks if the user is authenticated, but doesn't verify if they have admin privileges. Any logged-in user can delete other user accounts.",
"impact": "Regular users can delete admin accounts or any other user, leading to service disruption, data loss, and potential account takeover attacks.",
"file": "src/api/admin.ts",
"line": 78,
"evidence": "router.delete('/users/:id', authenticate, async (req, res) => {\n await User.delete(req.params.id);\n});",
"code_snippet": "router.delete('/users/:id', authenticate, async (req, res) => {\n await User.delete(req.params.id);\n});",
"suggested_fix": "Add authorization check:\n\nrouter.delete('/users/:id', authenticate, requireAdmin, async (req, res) => {\n await User.delete(req.params.id);\n});\n\n// Or inline:\nif (!req.user.isAdmin) {\n return res.status(403).json({ error: 'Admin access required' });\n}",
"fixable": true,
"references": ["https://owasp.org/Top10/A01_2021-Broken_Access_Control/"]
@@ -268,13 +245,13 @@ Return a JSON array with this structure:
"id": "finding-3",
"severity": "medium",
"category": "quality",
"confidence": 0.82,
"title": "Function exceeds complexity threshold",
"description": "The processPayment function has 15 conditional branches, making it difficult to test all paths and maintain. High cyclomatic complexity increases bug risk.",
"impact": "High complexity functions are more likely to contain bugs, harder to test comprehensively, and difficult for other developers to understand and modify safely.",
"file": "src/payments/processor.ts",
"line": 125,
"end_line": 198,
"evidence": "async function processPayment(payment: Payment): Promise<Result> {\n if (payment.type === 'credit') { ... } else if (payment.type === 'debit') { ... }\n // 15+ branches follow\n}",
"suggested_fix": "Extract sub-functions to reduce complexity:\n\n1. validatePaymentData(payment) - handle all validation\n2. calculateFees(amount, type) - fee calculation logic\n3. processRefund(payment) - refund-specific logic\n4. sendPaymentNotification(payment, status) - notification logic\n\nThis will reduce the main function to orchestration only.",
"fixable": false,
"references": []
@@ -293,18 +270,19 @@ Return a JSON array with this structure:
- **medium** (Recommended): Improve code quality (maintainability concerns) - **Blocks merge: YES** (AI fixes quickly)
- **low** (Suggestion): Suggestions for improvement (minor enhancements) - **Blocks merge: NO**
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
- **confidence**: Float 0.0-1.0 representing your confidence this is a genuine issue (must be ≥0.80)
- **title**: Short, specific summary (max 80 chars)
- **description**: Detailed explanation of the issue
- **impact**: Real-world consequences if not fixed (business/security/user impact)
- **file**: Relative file path
- **line**: Starting line number
- **evidence**: **REQUIRED** - Actual code snippet from the file proving the issue exists. Must be copy-pasted from the actual code.
- **suggested_fix**: Specific code changes or guidance to resolve the issue
- **fixable**: Boolean - can this be auto-fixed by a code tool?
### Optional Fields
- **end_line**: Ending line number for multi-line issues
- **code_snippet**: The problematic code excerpt
- **references**: Array of relevant URLs (OWASP, CVE, documentation)
## Guidelines for High-Quality Reviews
@@ -314,7 +292,7 @@ Return a JSON array with this structure:
3. **Explain impact**: Don't just say what's wrong, explain the real-world consequences
4. **Prioritize ruthlessly**: Focus on issues that genuinely matter
5. **Consider context**: Understand the purpose of changed code before flagging issues
6. **Require evidence**: Always include the actual code snippet in the `evidence` field - no code, no finding
6. **Validate confidence**: If you're not >80% sure, don't report it
7. **Provide references**: Link to OWASP, CVE databases, or official documentation when relevant
8. **Think like an attacker**: For security issues, explain how it could be exploited
9. **Be constructive**: Frame issues as opportunities to improve, not criticisms
@@ -336,12 +314,13 @@ Return a JSON array with this structure:
"id": "finding-auth-1",
"severity": "critical",
"category": "security",
"confidence": 0.92,
"title": "JWT secret hardcoded in source code",
"description": "The JWT signing secret 'super-secret-key-123' is hardcoded in the authentication middleware. Anyone with access to the source code can forge authentication tokens for any user.",
"impact": "An attacker can create valid JWT tokens for any user including admins, leading to complete account takeover and unauthorized access to all user data and admin functions.",
"file": "src/middleware/auth.ts",
"line": 12,
"evidence": "const SECRET = 'super-secret-key-123';\njwt.sign(payload, SECRET);",
"code_snippet": "const SECRET = 'super-secret-key-123';\njwt.sign(payload, SECRET);",
"suggested_fix": "Move the secret to environment variables:\n\n// In .env file:\nJWT_SECRET=<generate-random-256-bit-secret>\n\n// In auth.ts:\nconst SECRET = process.env.JWT_SECRET;\nif (!SECRET) {\n throw new Error('JWT_SECRET not configured');\n}\njwt.sign(payload, SECRET);",
"fixable": true,
"references": [
@@ -353,4 +332,4 @@ Return a JSON array with this structure:
---
Remember: Your goal is to find **genuine, high-impact issues** that will make the codebase more secure, correct, and maintainable. **Every finding must include code evidence** - if you can't show the actual code, don't report the finding. Quality over quantity. Be thorough but focused.
Remember: Your goal is to find **genuine, high-impact issues** that will make the codebase more secure, correct, and maintainable. Quality over quantity. Be thorough but focused.
@@ -6,23 +6,6 @@ You are a focused security review agent. You have been spawned by the orchestrat
Perform a thorough security review of the provided code changes, focusing ONLY on security vulnerabilities. Do not review code quality, style, or other non-security concerns.
## CRITICAL: PR Scope and Context
### What IS in scope (report these issues):
1. **Security issues in changed code** - Vulnerabilities introduced or modified by this PR
2. **Security impact of changes** - "This change exposes sensitive data to the new endpoint"
3. **Missing security for new features** - "New API endpoint lacks authentication"
4. **Broken security assumptions** - "Change to auth.ts invalidates security check in handler.ts"
### What is NOT in scope (do NOT report):
1. **Pre-existing vulnerabilities** - Old security issues in code this PR didn't touch
2. **Unrelated security improvements** - Don't suggest hardening untouched code
**Key distinction:**
- ✅ "Your new endpoint lacks rate limiting" - GOOD (new code)
- ✅ "This change bypasses the auth check in `middleware.ts`" - GOOD (impact analysis)
- ❌ "The old `legacy_auth.ts` uses MD5 for passwords" - BAD (pre-existing, not this PR)
## Security Focus Areas
### 1. Injection Vulnerabilities
+1 -11
View File
@@ -167,8 +167,7 @@ If any issue is not fixed, go back to Phase 3.
## PHASE 6: COMMIT FIXES
```bash
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
git add . ':!.auto-claude'
git add .
git commit -m "fix: Address QA issues (qa-requested)
Fixes:
@@ -183,8 +182,6 @@ Verified:
QA Fix Session: [N]"
```
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
**NOTE**: Do NOT push to remote. All work stays local until user reviews and approves.
---
@@ -307,13 +304,6 @@ npx prisma migrate dev --name [name]
- How you verified
- Commit messages
### Git Configuration - NEVER MODIFY
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
- `git config user.name`
- `git config user.email`
The repository inherits the user's configured git identity. Do NOT set test users.
---
## QA LOOP BEHAVIOR
+3 -3
View File
@@ -35,8 +35,8 @@ cat project_index.json
# 4. Check build progress
cat build-progress.txt
# 5. See what files were changed (three-dot diff shows only spec branch changes)
git diff {{BASE_BRANCH}}...HEAD --name-status
# 5. See what files were changed
git diff main --name-only
# 6. Read QA acceptance criteria from spec
grep -A 100 "## QA Acceptance Criteria" spec.md
@@ -514,7 +514,7 @@ All acceptance criteria verified:
The implementation is production-ready.
Sign-off recorded in implementation_plan.json.
Ready for merge to {{BASE_BRANCH}}.
Ready for merge to main.
```
### If Rejected:
-147
View File
@@ -7,9 +7,7 @@ Supports dynamic prompt assembly based on project type for context optimization.
"""
import json
import os
import re
import subprocess
from pathlib import Path
from .project_context import (
@@ -18,133 +16,6 @@ from .project_context import (
load_project_index,
)
def _validate_branch_name(branch: str | None) -> str | None:
"""
Validate a git branch name for safety and correctness.
Args:
branch: The branch name to validate
Returns:
The validated branch name, or None if invalid
"""
if not branch or not isinstance(branch, str):
return None
# Trim whitespace
branch = branch.strip()
# Reject empty or whitespace-only strings
if not branch:
return None
# Enforce maximum length (git refs can be long, but 255 is reasonable)
if len(branch) > 255:
return None
# Require at least one alphanumeric character
if not any(c.isalnum() for c in branch):
return None
# Only allow common git-ref characters: letters, numbers, ., _, -, /
# This prevents prompt injection and other security issues
if not re.match(r"^[A-Za-z0-9._/-]+$", branch):
return None
# Reject suspicious patterns that could be prompt injection attempts
# (newlines, control characters are already blocked by the regex above)
return branch
def _get_base_branch_from_metadata(spec_dir: Path) -> str | None:
"""
Read baseBranch from task_metadata.json if it exists.
Args:
spec_dir: Directory containing the spec files
Returns:
The baseBranch from metadata, or None if not found or invalid
"""
metadata_path = spec_dir / "task_metadata.json"
if metadata_path.exists():
try:
with open(metadata_path, encoding="utf-8") as f:
metadata = json.load(f)
base_branch = metadata.get("baseBranch")
# Validate the branch name before returning
return _validate_branch_name(base_branch)
except (json.JSONDecodeError, OSError):
pass
return None
def _detect_base_branch(spec_dir: Path, project_dir: Path) -> str:
"""
Detect the base branch for a project/task.
Priority order:
1. baseBranch from task_metadata.json (task-level override)
2. DEFAULT_BRANCH environment variable
3. Auto-detect main/master/develop (if they exist in git)
4. Fall back to "main"
Args:
spec_dir: Directory containing the spec files
project_dir: Project root directory
Returns:
The detected base branch name
"""
# 1. Check task_metadata.json for task-specific baseBranch
metadata_branch = _get_base_branch_from_metadata(spec_dir)
if metadata_branch:
return metadata_branch
# 2. Check for DEFAULT_BRANCH env var
env_branch = _validate_branch_name(os.getenv("DEFAULT_BRANCH"))
if env_branch:
# Verify the branch exists (with timeout to prevent hanging)
try:
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
cwd=project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=3,
)
if result.returncode == 0:
return env_branch
except subprocess.TimeoutExpired:
# Treat timeout as branch verification failure
pass
# 3. Auto-detect main/master/develop
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
cwd=project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=3,
)
if result.returncode == 0:
return branch
except subprocess.TimeoutExpired:
# Treat timeout as branch verification failure, try next branch
continue
# 4. Fall back to "main"
return "main"
# Directory containing prompt files
# prompts/ is a sibling directory of prompts_pkg/, so go up one level first
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
@@ -433,7 +304,6 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str:
1. Loads the base QA reviewer prompt
2. Detects project capabilities from project_index.json
3. Injects only relevant MCP tool documentation (Electron, Puppeteer, DB, API)
4. Detects and injects the correct base branch for git comparisons
This saves context window by excluding irrelevant tool docs.
For example, a CLI Python project won't get Electron validation docs.
@@ -445,15 +315,9 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str:
Returns:
The QA reviewer prompt with project-specific tools injected
"""
# Detect the base branch for this task (from task_metadata.json or auto-detect)
base_branch = _detect_base_branch(spec_dir, project_dir)
# Load base QA reviewer prompt
base_prompt = _load_prompt_file("qa_reviewer.md")
# Replace {{BASE_BRANCH}} placeholder with the actual base branch
base_prompt = base_prompt.replace("{{BASE_BRANCH}}", base_branch)
# Load project index and detect capabilities
project_index = load_project_index(project_dir)
capabilities = detect_project_capabilities(project_index)
@@ -483,17 +347,6 @@ Your spec and progress files are located at:
The project root is: `{project_dir}`
## GIT BRANCH CONFIGURATION
**Base branch for comparison:** `{base_branch}`
When checking for unrelated changes, use three-dot diff syntax:
```bash
git diff {base_branch}...HEAD --name-status
```
This shows only changes made in the spec branch since it diverged from `{base_branch}`.
---
## PROJECT CAPABILITIES DETECTED
+16 -75
View File
@@ -3,19 +3,12 @@ QA Fixer Agent Session
=======================
Runs QA fixer sessions to resolve issues identified by the reviewer.
Memory Integration:
- Retrieves past patterns, fixes, and gotchas before fixing
- Saves fix outcomes and learnings after session
"""
from pathlib import Path
# Memory integration for cross-session learning
from agents.memory_manager import get_graphiti_context, save_session_memory
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -51,7 +44,6 @@ async def run_qa_fixer_session(
spec_dir: Path,
fix_session: int,
verbose: bool = False,
project_dir: Path | None = None,
) -> tuple[str, str]:
"""
Run a QA fixer agent session.
@@ -61,18 +53,12 @@ async def run_qa_fixer_session(
spec_dir: Spec directory
fix_session: Fix iteration number
verbose: Whether to show detailed output
project_dir: Project root directory (for memory context)
Returns:
(status, response_text) where status is:
- "fixed" if fixes were applied
- "error" if an error occurred
"""
# Derive project_dir from spec_dir if not provided
# spec_dir is typically: /project/.auto-claude/specs/001-name/
if project_dir is None:
# Walk up from spec_dir to find project root
project_dir = spec_dir.parent.parent.parent
debug_section("qa_fixer", f"QA Fixer Session {fix_session}")
debug(
"qa_fixer",
@@ -102,20 +88,6 @@ async def run_qa_fixer_session(
prompt = load_qa_fixer_prompt()
debug_detailed("qa_fixer", "Loaded QA fixer prompt", prompt_length=len(prompt))
# Retrieve memory context for fixer (past fixes, patterns, gotchas)
fixer_memory_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Fixing QA issues and implementing corrections",
"id": f"qa_fixer_{fix_session}",
},
)
if fixer_memory_context:
prompt += "\n\n" + fixer_memory_context
print("✓ Memory context loaded for QA fixer")
debug_success("qa_fixer", "Graphiti memory context loaded for fixer")
# Add session context - use full path so agent can find files
prompt += f"\n\n---\n\n**Fix Session**: {fix_session}\n"
prompt += f"**Spec Directory**: {spec_dir}\n"
@@ -156,35 +128,34 @@ async def run_qa_fixer_session(
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input_display = None
tool_input = None
tool_count += 1
# Safely extract tool input (handles None, non-dict, etc.)
inp = get_safe_tool_input(block)
if inp:
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input_display = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input_display = cmd
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input = cmd
debug(
"qa_fixer",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input_display,
tool_input=tool_input,
)
# Log tool start (handles printing)
if task_logger:
task_logger.tool_start(
tool_name,
tool_input_display,
tool_input,
LogPhase.VALIDATION,
print_to_console=True,
)
@@ -271,42 +242,12 @@ async def run_qa_fixer_session(
if status
else False,
)
# Save fixer session insights to memory
fixer_discoveries = {
"files_understood": {},
"patterns_found": [
f"QA fixer session {fix_session}: Applied fixes from QA_FIX_REQUEST.md"
],
"gotchas_encountered": [],
}
if status and status.get("ready_for_qa_revalidation"):
debug_success("qa_fixer", "Fixes applied, ready for QA revalidation")
# Save successful fix session to memory
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_fixer_{fix_session}",
session_num=fix_session,
success=True,
subtasks_completed=[f"qa_fixer_{fix_session}"],
discoveries=fixer_discoveries,
)
return "fixed", response_text
else:
# Fixer didn't update the status properly, but we'll trust it worked
debug_success("qa_fixer", "Fixes assumed applied (status not updated)")
# Still save to memory as successful (fixes were attempted)
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_fixer_{fix_session}",
session_num=fix_session,
success=True,
subtasks_completed=[f"qa_fixer_{fix_session}"],
discoveries=fixer_discoveries,
)
return "fixed", response_text
except Exception as e:
+13 -72
View File
@@ -4,20 +4,13 @@ QA Reviewer Agent Session
Runs QA validation sessions to review implementation against
acceptance criteria.
Memory Integration:
- Retrieves past patterns, gotchas, and insights before QA session
- Saves QA findings (bugs, patterns, validation outcomes) after session
"""
from pathlib import Path
# Memory integration for cross-session learning
from agents.memory_manager import get_graphiti_context, save_session_memory
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from prompts_pkg import get_qa_reviewer_prompt
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -88,20 +81,6 @@ async def run_qa_agent_session(
project_dir=str(project_dir),
)
# Retrieve memory context for QA (past patterns, gotchas, validation insights)
qa_memory_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "QA validation and acceptance criteria review",
"id": f"qa_reviewer_{qa_session}",
},
)
if qa_memory_context:
prompt += "\n\n" + qa_memory_context
print("✓ Memory context loaded for QA reviewer")
debug_success("qa_reviewer", "Graphiti memory context loaded for QA")
# Add session context
prompt += f"\n\n---\n\n**QA Session**: {qa_session}\n"
prompt += f"**Max Iterations**: {max_iterations}\n"
@@ -216,33 +195,32 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input_display = None
tool_input = None
tool_count += 1
# Safely extract tool input (handles None, non-dict, etc.)
inp = get_safe_tool_input(block)
# Extract tool input for display
if inp:
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input_display = fp
elif "pattern" in inp:
tool_input_display = f"pattern: {inp['pattern']}"
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
debug(
"qa_reviewer",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input_display,
tool_input=tool_input,
)
# Log tool start (handles printing)
if task_logger:
task_logger.tool_start(
tool_name,
tool_input_display,
tool_input,
LogPhase.VALIDATION,
print_to_console=True,
)
@@ -327,48 +305,11 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
response_length=len(response_text),
qa_status=status.get("status") if status else "unknown",
)
# Save QA session insights to memory
qa_discoveries = {
"files_understood": {},
"patterns_found": [],
"gotchas_encountered": [],
}
if status and status.get("status") == "approved":
debug_success("qa_reviewer", "QA APPROVED")
qa_discoveries["patterns_found"].append(
f"QA session {qa_session}: All acceptance criteria validated successfully"
)
# Save successful QA session to memory
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_reviewer_{qa_session}",
session_num=qa_session,
success=True,
subtasks_completed=[f"qa_reviewer_{qa_session}"],
discoveries=qa_discoveries,
)
return "approved", response_text
elif status and status.get("status") == "rejected":
debug_error("qa_reviewer", "QA REJECTED")
# Extract issues found for memory
issues = status.get("issues_found", [])
for issue in issues:
qa_discoveries["gotchas_encountered"].append(
f"QA Issue ({issue.get('type', 'unknown')}): {issue.get('title', 'No title')} at {issue.get('location', 'unknown')}"
)
# Save rejected QA session to memory (learning from failures)
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_reviewer_{qa_session}",
session_num=qa_session,
success=False,
subtasks_completed=[],
discoveries=qa_discoveries,
)
return "rejected", response_text
else:
# Agent didn't update the status properly - provide detailed error
+32 -187
View File
@@ -185,31 +185,24 @@ def cmd_get_memories(args):
"""
result = conn.execute(query, parameters={"limit": limit})
df = result.get_as_df()
# Process results without pandas (iterate through result set directly)
memories = []
while result.has_next():
row = result.get_next()
# Row order: uuid, name, created_at, content, description, group_id
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
name_val = serialize_value(row[1]) if len(row) > 1 else ""
created_at_val = serialize_value(row[2]) if len(row) > 2 else None
content_val = serialize_value(row[3]) if len(row) > 3 else ""
description_val = serialize_value(row[4]) if len(row) > 4 else ""
group_id_val = serialize_value(row[5]) if len(row) > 5 else ""
for _, row in df.iterrows():
memory = {
"id": uuid_val or name_val or "unknown",
"name": name_val or "",
"type": infer_episode_type(name_val or "", content_val or ""),
"timestamp": created_at_val or datetime.now().isoformat(),
"content": content_val or description_val or name_val or "",
"description": description_val or "",
"group_id": group_id_val or "",
"id": row.get("uuid") or row.get("name", "unknown"),
"name": row.get("name", ""),
"type": infer_episode_type(row.get("name", ""), row.get("content", "")),
"timestamp": row.get("created_at") or datetime.now().isoformat(),
"content": row.get("content")
or row.get("description")
or row.get("name", ""),
"description": row.get("description", ""),
"group_id": row.get("group_id", ""),
}
# Extract session number if present
session_num = extract_session_number(name_val or "")
session_num = extract_session_number(row.get("name", ""))
if session_num:
memory["session_number"] = session_num
@@ -258,31 +251,24 @@ def cmd_search(args):
result = conn.execute(
query, parameters={"search_query": search_query, "limit": limit}
)
df = result.get_as_df()
# Process results without pandas
memories = []
while result.has_next():
row = result.get_next()
# Row order: uuid, name, created_at, content, description, group_id
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
name_val = serialize_value(row[1]) if len(row) > 1 else ""
created_at_val = serialize_value(row[2]) if len(row) > 2 else None
content_val = serialize_value(row[3]) if len(row) > 3 else ""
description_val = serialize_value(row[4]) if len(row) > 4 else ""
group_id_val = serialize_value(row[5]) if len(row) > 5 else ""
for _, row in df.iterrows():
memory = {
"id": uuid_val or name_val or "unknown",
"name": name_val or "",
"type": infer_episode_type(name_val or "", content_val or ""),
"timestamp": created_at_val or datetime.now().isoformat(),
"content": content_val or description_val or name_val or "",
"description": description_val or "",
"group_id": group_id_val or "",
"id": row.get("uuid") or row.get("name", "unknown"),
"name": row.get("name", ""),
"type": infer_episode_type(row.get("name", ""), row.get("content", "")),
"timestamp": row.get("created_at") or datetime.now().isoformat(),
"content": row.get("content")
or row.get("description")
or row.get("name", ""),
"description": row.get("description", ""),
"group_id": row.get("group_id", ""),
"score": 1.0, # Keyword match score
}
session_num = extract_session_number(name_val or "")
session_num = extract_session_number(row.get("name", ""))
if session_num:
memory["session_number"] = session_num
@@ -475,26 +461,19 @@ def cmd_get_entities(args):
"""
result = conn.execute(query, parameters={"limit": limit})
df = result.get_as_df()
# Process results without pandas
entities = []
while result.has_next():
row = result.get_next()
# Row order: uuid, name, summary, created_at
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
name_val = serialize_value(row[1]) if len(row) > 1 else ""
summary_val = serialize_value(row[2]) if len(row) > 2 else ""
created_at_val = serialize_value(row[3]) if len(row) > 3 else None
if not summary_val:
for _, row in df.iterrows():
if not row.get("summary"):
continue
entity = {
"id": uuid_val or name_val or "unknown",
"name": name_val or "",
"type": infer_entity_type(name_val or ""),
"timestamp": created_at_val or datetime.now().isoformat(),
"content": summary_val or "",
"id": row.get("uuid") or row.get("name", "unknown"),
"name": row.get("name", ""),
"type": infer_entity_type(row.get("name", "")),
"timestamp": row.get("created_at") or datetime.now().isoformat(),
"content": row.get("summary", ""),
}
entities.append(entity)
@@ -509,118 +488,6 @@ def cmd_get_entities(args):
output_error(f"Query failed: {e}")
def cmd_add_episode(args):
"""
Add a new episode to the memory database.
This is called from the Electron main process to save PR review insights,
patterns, gotchas, and other memories directly to the LadybugDB database.
Args:
args.db_path: Path to database directory
args.database: Database name
args.name: Episode name/title
args.content: Episode content (JSON string)
args.episode_type: Type of episode (session_insight, pattern, gotcha, task_outcome, pr_review)
args.group_id: Optional group ID for namespacing
"""
if not apply_monkeypatch():
output_error("Neither kuzu nor LadybugDB is installed")
return
try:
import uuid as uuid_module
try:
import kuzu
except ImportError:
import real_ladybug as kuzu
# Parse content from JSON if provided
content = args.content
if content:
try:
# Try to parse as JSON to validate
parsed = json.loads(content)
# Re-serialize to ensure consistent formatting
content = json.dumps(parsed)
except json.JSONDecodeError:
# If not valid JSON, use as-is
pass
# Generate unique ID
episode_uuid = str(uuid_module.uuid4())
created_at = datetime.now().isoformat()
# Get database path - create directory if needed
full_path = Path(args.db_path) / args.database
if not full_path.exists():
# For new databases, create the parent directory
Path(args.db_path).mkdir(parents=True, exist_ok=True)
# Open database (creates it if it doesn't exist)
db = kuzu.Database(str(full_path))
conn = kuzu.Connection(db)
# Always try to create the Episodic table if it doesn't exist
# This handles both new databases and existing databases without the table
try:
conn.execute("""
CREATE NODE TABLE IF NOT EXISTS Episodic (
uuid STRING PRIMARY KEY,
name STRING,
content STRING,
source_description STRING,
group_id STRING,
created_at STRING
)
""")
except Exception as schema_err:
# Table might already exist with different schema - that's ok
# The insert will fail if schema is incompatible
sys.stderr.write(f"Schema creation note: {schema_err}\n")
# Insert the episode
try:
insert_query = """
CREATE (e:Episodic {
uuid: $uuid,
name: $name,
content: $content,
source_description: $description,
group_id: $group_id,
created_at: $created_at
})
"""
conn.execute(
insert_query,
parameters={
"uuid": episode_uuid,
"name": args.name,
"content": content,
"description": f"[{args.episode_type}] {args.name}",
"group_id": args.group_id or "",
"created_at": created_at,
},
)
output_json(
True,
data={
"id": episode_uuid,
"name": args.name,
"type": args.episode_type,
"timestamp": created_at,
},
)
except Exception as e:
output_error(f"Failed to insert episode: {e}")
except Exception as e:
output_error(f"Failed to add episode: {e}")
def infer_episode_type(name: str, content: str = "") -> str:
"""Infer the episode type from its name and content."""
name_lower = (name or "").lower()
@@ -713,27 +580,6 @@ def main():
"--limit", type=int, default=20, help="Maximum results"
)
# add-episode command (for saving memories from Electron app)
add_parser = subparsers.add_parser(
"add-episode",
help="Add an episode to the memory database (called from Electron)",
)
add_parser.add_argument("db_path", help="Path to database directory")
add_parser.add_argument("database", help="Database name")
add_parser.add_argument("--name", required=True, help="Episode name/title")
add_parser.add_argument(
"--content", required=True, help="Episode content (JSON string)"
)
add_parser.add_argument(
"--type",
dest="episode_type",
default="session_insight",
help="Episode type (session_insight, pattern, gotcha, task_outcome, pr_review)",
)
add_parser.add_argument(
"--group-id", dest="group_id", help="Optional group ID for namespacing"
)
args = parser.parse_args()
if not args.command:
@@ -748,7 +594,6 @@ def main():
"search": cmd_search,
"semantic-search": cmd_semantic_search,
"get-entities": cmd_get_entities,
"add-episode": cmd_add_episode,
}
handler = commands.get(args.command)
@@ -8,7 +8,6 @@ from typing import Any
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from phase_config import resolve_model_id
CLAUDE_SDK_AVAILABLE = True
except ImportError:
@@ -18,7 +17,7 @@ except ImportError:
class ClaudeAnalysisClient:
"""Wrapper for Claude SDK client with analysis-specific configuration."""
DEFAULT_MODEL = "sonnet" # Shorthand - resolved via API Profile if configured
DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
ALLOWED_TOOLS = ["Read", "Glob", "Grep"]
MAX_TURNS = 50
@@ -111,7 +110,7 @@ class ClaudeAnalysisClient:
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id(self.DEFAULT_MODEL), # Resolve via API Profile
model=self.DEFAULT_MODEL,
system_prompt=system_prompt,
allowed_tools=self.ALLOWED_TOOLS,
max_turns=self.MAX_TURNS,
+9 -25
View File
@@ -1,18 +1,16 @@
"""
DEPRECATED: Review Confidence Scoring
=====================================
Review Confidence Scoring
=========================
This module is DEPRECATED and will be removed in a future version.
Adds confidence scores to review findings to help users prioritize.
The confidence scoring approach has been replaced with EVIDENCE-BASED VALIDATION:
- Instead of assigning confidence scores (0-100), findings now require concrete
code evidence proving the issue exists.
- Simple rule: If you can't show the actual problematic code, don't report it.
- Validation is binary: either the evidence exists in the file or it doesn't.
Features:
- Confidence scoring based on pattern matching, historical accuracy
- Risk assessment (false positive likelihood)
- Evidence tracking for transparency
- Calibration based on outcome tracking
For new code, use evidence-based validation in pydantic_models.py and models.py instead.
Legacy Usage (deprecated):
Usage:
scorer = ConfidenceScorer(learning_tracker=tracker)
# Score a finding
@@ -22,24 +20,10 @@ Legacy Usage (deprecated):
# Get explanation
print(scorer.explain_confidence(scored))
Migration:
- Instead of `confidence: float`, use `evidence: str` with actual code snippets
- Instead of filtering by confidence threshold, verify evidence exists in file
- See pr_finding_validator.md for the new evidence-based approach
"""
from __future__ import annotations
import warnings
warnings.warn(
"The confidence module is deprecated. Use evidence-based validation instead. "
"See models.py 'evidence' field and pr_finding_validator.md for the new approach.",
DeprecationWarning,
stacklevel=2,
)
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
+13 -88
View File
@@ -201,14 +201,6 @@ class PRContext:
ai_bot_comments: list[AIBotComment] = field(default_factory=list)
# Flag indicating if full diff was skipped (PR > 20K lines)
diff_truncated: bool = False
# Commit SHAs for worktree creation (PR review isolation)
head_sha: str = "" # Commit SHA of PR head (headRefOid)
base_sha: str = "" # Commit SHA of PR base (baseRefOid)
# Merge conflict status
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
merge_state_status: str = (
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
)
class PRContextGatherer:
@@ -281,17 +273,6 @@ class PRContextGatherer:
# Check if diff was truncated (empty diff but files were changed)
diff_truncated = len(diff) == 0 and len(changed_files) > 0
# Check merge conflict status
mergeable = pr_data.get("mergeable", "UNKNOWN")
merge_state_status = pr_data.get("mergeStateStatus", "UNKNOWN")
has_merge_conflicts = mergeable == "CONFLICTING"
if has_merge_conflicts:
print(
f"[Context] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
flush=True,
)
return PRContext(
pr_number=self.pr_number,
title=pr_data["title"],
@@ -310,10 +291,6 @@ class PRContextGatherer:
total_deletions=pr_data.get("deletions", 0),
ai_bot_comments=ai_bot_comments,
diff_truncated=diff_truncated,
head_sha=pr_data.get("headRefOid", ""),
base_sha=pr_data.get("baseRefOid", ""),
has_merge_conflicts=has_merge_conflicts,
merge_state_status=merge_state_status,
)
async def _fetch_pr_metadata(self) -> dict:
@@ -335,8 +312,6 @@ class PRContextGatherer:
"deletions",
"changedFiles",
"labels",
"mergeable", # MERGEABLE, CONFLICTING, or UNKNOWN
"mergeStateStatus", # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
],
)
@@ -1056,56 +1031,28 @@ class FollowupContextGatherer:
f"[Followup] Comparing {previous_sha[:8]}...{current_sha[:8]}", flush=True
)
# Get PR-scoped files and commits (excludes merge-introduced changes)
# This solves the problem where merging develop into a feature branch
# would include commits from other PRs in the follow-up review.
# Pass reviewed_file_blobs for rebase-resistant comparison
reviewed_file_blobs = getattr(self.previous_review, "reviewed_file_blobs", {})
# Get commit comparison
try:
pr_files, new_commits = await self.gh_client.get_pr_files_changed_since(
self.pr_number, previous_sha, reviewed_file_blobs=reviewed_file_blobs
)
print(
f"[Followup] PR has {len(pr_files)} files, "
f"{len(new_commits)} commits since last review"
+ (" (blob comparison used)" if reviewed_file_blobs else ""),
flush=True,
)
comparison = await self.gh_client.compare_commits(previous_sha, current_sha)
except Exception as e:
print(f"[Followup] Error getting PR files/commits: {e}", flush=True)
# Fallback to compare_commits if PR endpoints fail
print("[Followup] Falling back to commit comparison...", flush=True)
try:
comparison = await self.gh_client.compare_commits(
previous_sha, current_sha
)
new_commits = comparison.get("commits", [])
pr_files = comparison.get("files", [])
print(
f"[Followup] Fallback: Found {len(new_commits)} commits, "
f"{len(pr_files)} files (may include merge-introduced changes)",
flush=True,
)
except Exception as e2:
print(f"[Followup] Fallback also failed: {e2}", flush=True)
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
previous_commit_sha=previous_sha,
current_commit_sha=current_sha,
error=f"Failed to get PR context: {e}, fallback: {e2}",
)
print(f"[Followup] Error comparing commits: {e}", flush=True)
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
previous_commit_sha=previous_sha,
current_commit_sha=current_sha,
error=f"Failed to compare commits: {e}",
)
# Use PR files as the canonical list (excludes files from merged branches)
commits = new_commits
files = pr_files
# Extract data from comparison
commits = comparison.get("commits", [])
files = comparison.get("files", [])
print(
f"[Followup] Found {len(commits)} new commits, {len(files)} changed files",
flush=True,
)
# Build diff from file patches
# Note: PR files endpoint returns 'filename' key, compare returns 'filename' too
diff_parts = []
files_changed = []
for file_info in files:
@@ -1187,26 +1134,6 @@ class FollowupContextGatherer:
flush=True,
)
# Fetch current merge conflict status
has_merge_conflicts = False
merge_state_status = "UNKNOWN"
try:
pr_status = await self.gh_client.pr_get(
self.pr_number,
json_fields=["mergeable", "mergeStateStatus"],
)
mergeable = pr_status.get("mergeable", "UNKNOWN")
merge_state_status = pr_status.get("mergeStateStatus", "UNKNOWN")
has_merge_conflicts = mergeable == "CONFLICTING"
if has_merge_conflicts:
print(
f"[Followup] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
flush=True,
)
except Exception as e:
print(f"[Followup] Could not fetch merge status: {e}", flush=True)
return FollowupReviewContext(
pr_number=self.pr_number,
previous_review=self.previous_review,
@@ -1219,6 +1146,4 @@ class FollowupContextGatherer:
+ contributor_reviews,
ai_bot_comments_since_review=ai_comments,
pr_reviews_since_review=pr_reviews,
has_merge_conflicts=has_merge_conflicts,
merge_state_status=merge_state_status,
)
-397
View File
@@ -810,400 +810,3 @@ class GHClient:
# Last commit is the HEAD
return commits[-1].get("oid")
return None
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
"""
Get CI check runs status for a PR.
Uses `gh pr checks` to get the status of all check runs.
Args:
pr_number: PR number
Returns:
Dict with:
- checks: List of check runs with name, state
- passing: Number of passing checks
- failing: Number of failing checks
- pending: Number of pending checks
- failed_checks: List of failed check names
"""
try:
# Note: gh pr checks --json only supports: bucket, completedAt, description,
# event, link, name, startedAt, state, workflow
# The 'state' field directly contains the result (SUCCESS, FAILURE, PENDING, etc.)
args = ["pr", "checks", str(pr_number), "--json", "name,state"]
args = self._add_repo_flag(args)
result = await self.run(args, timeout=30.0)
checks = json.loads(result.stdout) if result.stdout.strip() else []
passing = 0
failing = 0
pending = 0
failed_checks = []
for check in checks:
state = check.get("state", "").upper()
name = check.get("name", "Unknown")
# gh pr checks 'state' directly contains: SUCCESS, FAILURE, PENDING, NEUTRAL, etc.
if state in ("SUCCESS", "NEUTRAL", "SKIPPED"):
passing += 1
elif state in ("FAILURE", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE"):
failing += 1
failed_checks.append(name)
else:
# PENDING, QUEUED, IN_PROGRESS, etc.
pending += 1
return {
"checks": checks,
"passing": passing,
"failing": failing,
"pending": pending,
"failed_checks": failed_checks,
}
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
logger.warning(f"Failed to get PR checks for #{pr_number}: {e}")
return {
"checks": [],
"passing": 0,
"failing": 0,
"pending": 0,
"failed_checks": [],
"error": str(e),
}
async def get_workflows_awaiting_approval(self, pr_number: int) -> dict[str, Any]:
"""
Get workflow runs awaiting approval for a PR from a fork.
Workflows from forked repositories require manual approval before running.
These are NOT included in `gh pr checks` and must be queried separately.
Args:
pr_number: PR number
Returns:
Dict with:
- awaiting_approval: Number of workflows waiting for approval
- workflow_runs: List of workflow runs with id, name, html_url
- can_approve: Whether this token can approve workflows
"""
try:
# First, get the PR's head SHA to filter workflow runs
pr_args = ["pr", "view", str(pr_number), "--json", "headRefOid"]
pr_args = self._add_repo_flag(pr_args)
pr_result = await self.run(pr_args, timeout=30.0)
pr_data = json.loads(pr_result.stdout) if pr_result.stdout.strip() else {}
head_sha = pr_data.get("headRefOid", "")
if not head_sha:
return {
"awaiting_approval": 0,
"workflow_runs": [],
"can_approve": False,
}
# Query workflow runs with action_required status
# Note: We need to use the API endpoint as gh CLI doesn't have direct support
endpoint = (
"repos/{owner}/{repo}/actions/runs?status=action_required&per_page=100"
)
args = ["api", "--method", "GET", endpoint]
result = await self.run(args, timeout=30.0)
data = json.loads(result.stdout) if result.stdout.strip() else {}
all_runs = data.get("workflow_runs", [])
# Filter to only runs for this PR's head SHA
pr_runs = [
{
"id": run.get("id"),
"name": run.get("name"),
"html_url": run.get("html_url"),
"workflow_name": run.get("workflow", {}).get("name", "Unknown"),
}
for run in all_runs
if run.get("head_sha") == head_sha
]
return {
"awaiting_approval": len(pr_runs),
"workflow_runs": pr_runs,
"can_approve": True, # Assume token has permission, will fail if not
}
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
logger.warning(
f"Failed to get workflows awaiting approval for #{pr_number}: {e}"
)
return {
"awaiting_approval": 0,
"workflow_runs": [],
"can_approve": False,
"error": str(e),
}
async def approve_workflow_run(self, run_id: int) -> bool:
"""
Approve a workflow run that's waiting for approval (from a fork).
Args:
run_id: The workflow run ID to approve
Returns:
True if approval succeeded, False otherwise
"""
try:
endpoint = f"repos/{{owner}}/{{repo}}/actions/runs/{run_id}/approve"
args = ["api", "--method", "POST", endpoint]
await self.run(args, timeout=30.0)
logger.info(f"Approved workflow run {run_id}")
return True
except (GHCommandError, GHTimeoutError) as e:
logger.warning(f"Failed to approve workflow run {run_id}: {e}")
return False
async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]:
"""
Get comprehensive CI status including workflows awaiting approval.
This combines:
- Standard check runs from `gh pr checks`
- Workflows awaiting approval (for fork PRs)
Args:
pr_number: PR number
Returns:
Dict with all check information including awaiting_approval count
"""
# Get standard checks
checks = await self.get_pr_checks(pr_number)
# Get workflows awaiting approval
awaiting = await self.get_workflows_awaiting_approval(pr_number)
# Merge the results
checks["awaiting_approval"] = awaiting.get("awaiting_approval", 0)
checks["awaiting_workflow_runs"] = awaiting.get("workflow_runs", [])
# Update pending count to include awaiting approval
checks["pending"] = checks.get("pending", 0) + awaiting.get(
"awaiting_approval", 0
)
return checks
async def get_pr_files(self, pr_number: int) -> list[dict[str, Any]]:
"""
Get files changed by a PR using the PR files endpoint.
IMPORTANT: This returns only files that are part of the PR's actual changes,
NOT files that came in from merging another branch (e.g., develop).
This is crucial for follow-up reviews to avoid reviewing code from other PRs.
Uses: GET /repos/{owner}/{repo}/pulls/{pr_number}/files
Args:
pr_number: PR number
Returns:
List of file objects with:
- filename: Path to the file
- status: added, removed, modified, renamed, copied, changed
- additions: Number of lines added
- deletions: Number of lines deleted
- changes: Total number of line changes
- patch: The unified diff patch for this file (may be absent for large files)
"""
files = []
page = 1
per_page = 100
while True:
endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/files?page={page}&per_page={per_page}"
args = ["api", "--method", "GET", endpoint]
result = await self.run(args, timeout=60.0)
page_files = json.loads(result.stdout) if result.stdout.strip() else []
if not page_files:
break
files.extend(page_files)
# Check if we got a full page (more pages might exist)
if len(page_files) < per_page:
break
page += 1
# Safety limit to prevent infinite loops
if page > 50:
logger.warning(
f"PR #{pr_number} has more than 5000 files, stopping pagination"
)
break
return files
async def get_pr_commits(self, pr_number: int) -> list[dict[str, Any]]:
"""
Get commits that are part of a PR using the PR commits endpoint.
IMPORTANT: This returns only commits that are part of the PR's branch,
NOT commits that came in from merging another branch (e.g., develop).
This is crucial for follow-up reviews to avoid reviewing commits from other PRs.
Uses: GET /repos/{owner}/{repo}/pulls/{pr_number}/commits
Args:
pr_number: PR number
Returns:
List of commit objects with:
- sha: Commit SHA
- commit: Object with message, author, committer info
- author: GitHub user who authored the commit
- committer: GitHub user who committed
- parents: List of parent commit SHAs
"""
commits = []
page = 1
per_page = 100
while True:
endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/commits?page={page}&per_page={per_page}"
args = ["api", "--method", "GET", endpoint]
result = await self.run(args, timeout=60.0)
page_commits = json.loads(result.stdout) if result.stdout.strip() else []
if not page_commits:
break
commits.extend(page_commits)
# Check if we got a full page (more pages might exist)
if len(page_commits) < per_page:
break
page += 1
# Safety limit
if page > 10:
logger.warning(
f"PR #{pr_number} has more than 1000 commits, stopping pagination"
)
break
return commits
async def get_pr_files_changed_since(
self,
pr_number: int,
base_sha: str,
reviewed_file_blobs: dict[str, str] | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""
Get files and commits that are part of the PR and changed since a specific commit.
This method solves the "merge introduced commits" problem by:
1. Getting the canonical list of PR files (excludes files from merged branches)
2. Getting the canonical list of PR commits (excludes commits from merged branches)
3. Filtering to only include commits after base_sha
When a rebase/force-push is detected (base_sha not found in commits), and
reviewed_file_blobs is provided, uses blob SHA comparison to identify which
files actually changed content. This prevents re-reviewing unchanged files.
Args:
pr_number: PR number
base_sha: The commit SHA to compare from (e.g., last reviewed commit)
reviewed_file_blobs: Optional dict mapping filename -> blob SHA from the
previous review. Used as fallback when base_sha is not found (rebase).
Returns:
Tuple of:
- List of file objects that are part of the PR (filtered if blob comparison used)
- List of commit objects that are part of the PR and after base_sha.
NOTE: Returns empty list if rebase/force-push detected, since commit SHAs
are rewritten and we cannot determine which commits are truly "new".
"""
# Get PR's canonical files (these are the actual PR changes)
pr_files = await self.get_pr_files(pr_number)
# Get PR's canonical commits
pr_commits = await self.get_pr_commits(pr_number)
# Find the position of base_sha in PR commits
# Use minimum 7-char prefix comparison (git's default short SHA length)
base_index = -1
min_prefix_len = 7
base_prefix = (
base_sha[:min_prefix_len] if len(base_sha) >= min_prefix_len else base_sha
)
for i, commit in enumerate(pr_commits):
commit_prefix = commit["sha"][:min_prefix_len]
if commit_prefix == base_prefix:
base_index = i
break
# Commits after base_sha (these are the new commits to review)
if base_index >= 0:
new_commits = pr_commits[base_index + 1 :]
return pr_files, new_commits
# base_sha not found in PR commits - this happens when:
# 1. The base_sha was from a merge commit (not a direct PR commit)
# 2. The PR was rebased/force-pushed
logger.warning(
f"base_sha {base_sha[:8]} not found in PR #{pr_number} commits. "
"PR was likely rebased or force-pushed."
)
# If we have blob SHAs from the previous review, use them to filter files
# Blob SHAs persist across rebases - same content = same blob SHA
if reviewed_file_blobs: # Only use blob comparison if we have actual blob data
changed_files = []
unchanged_count = 0
for file in pr_files:
filename = file.get("filename", "")
current_blob_sha = file.get("sha", "")
file_status = file.get("status", "")
previous_blob_sha = reviewed_file_blobs.get(filename, "")
# Always include files that were added, removed, or renamed
# These are significant changes regardless of blob SHA
if file_status in ("added", "removed", "renamed"):
changed_files.append(file)
elif not previous_blob_sha:
# File wasn't in previous review - include it
changed_files.append(file)
elif current_blob_sha != previous_blob_sha:
# File content changed - include it
changed_files.append(file)
else:
# Same blob SHA = same content - skip it
unchanged_count += 1
if unchanged_count > 0:
logger.info(
f"Blob comparison: {len(changed_files)} files changed, "
f"{unchanged_count} unchanged (skipped)"
)
# Return filtered files but empty commits list (can't determine "new" commits after rebase)
# After a rebase, all commit SHAs are rewritten so we can't identify which are truly new.
# The file changes via blob comparison are the reliable source of what changed.
return changed_files, []
# No blob data available - return all files but empty commits (can't determine new commits)
logger.warning(
"No reviewed_file_blobs available for blob comparison after rebase. "
"Returning all PR files with empty commits list."
)
return pr_files, []
+6 -36
View File
@@ -214,20 +214,13 @@ class PRReviewFinding:
end_line: int | None = None
suggested_fix: str | None = None
fixable: bool = False
# Evidence-based validation: actual code proving the issue exists
evidence: str | None = None # Actual code snippet showing the issue
# NEW: Support for verification and redundancy detection
confidence: float = 0.85 # AI's confidence in this finding (0.0-1.0)
verification_note: str | None = (
None # What evidence is missing or couldn't be verified
)
redundant_with: str | None = None # Reference to duplicate code (file:line)
# Finding validation fields (from finding-validator re-investigation)
validation_status: str | None = (
None # confirmed_valid, dismissed_false_positive, needs_human_review
)
validation_evidence: str | None = None # Code snippet examined during validation
validation_explanation: str | None = None # Why finding was validated/dismissed
def to_dict(self) -> dict:
return {
"id": self.id,
@@ -240,14 +233,10 @@ class PRReviewFinding:
"end_line": self.end_line,
"suggested_fix": self.suggested_fix,
"fixable": self.fixable,
# Evidence-based validation fields
"evidence": self.evidence,
# NEW fields
"confidence": self.confidence,
"verification_note": self.verification_note,
"redundant_with": self.redundant_with,
# Validation fields
"validation_status": self.validation_status,
"validation_evidence": self.validation_evidence,
"validation_explanation": self.validation_explanation,
}
@classmethod
@@ -263,14 +252,10 @@ class PRReviewFinding:
end_line=data.get("end_line"),
suggested_fix=data.get("suggested_fix"),
fixable=data.get("fixable", False),
# Evidence-based validation fields
evidence=data.get("evidence"),
# NEW fields
confidence=data.get("confidence", 0.85),
verification_note=data.get("verification_note"),
redundant_with=data.get("redundant_with"),
# Validation fields
validation_status=data.get("validation_status"),
validation_evidence=data.get("validation_evidence"),
validation_explanation=data.get("validation_explanation"),
)
@@ -380,9 +365,6 @@ class PRReviewResult:
# Follow-up review tracking
reviewed_commit_sha: str | None = None # HEAD SHA at time of review
reviewed_file_blobs: dict[str, str] = field(
default_factory=dict
) # filename → blob SHA at time of review (survives rebases)
is_followup_review: bool = False # True if this is a follow-up review
previous_review_id: int | None = None # Reference to the review this follows up on
resolved_findings: list[str] = field(default_factory=list) # Finding IDs now fixed
@@ -421,7 +403,6 @@ class PRReviewResult:
"quick_scan_summary": self.quick_scan_summary,
# Follow-up review fields
"reviewed_commit_sha": self.reviewed_commit_sha,
"reviewed_file_blobs": self.reviewed_file_blobs,
"is_followup_review": self.is_followup_review,
"previous_review_id": self.previous_review_id,
"resolved_findings": self.resolved_findings,
@@ -466,7 +447,6 @@ class PRReviewResult:
quick_scan_summary=data.get("quick_scan_summary", {}),
# Follow-up review fields
reviewed_commit_sha=data.get("reviewed_commit_sha"),
reviewed_file_blobs=data.get("reviewed_file_blobs", {}),
is_followup_review=data.get("is_followup_review", False),
previous_review_id=data.get("previous_review_id"),
resolved_findings=data.get("resolved_findings", []),
@@ -564,16 +544,6 @@ class FollowupReviewContext:
# These are different from comments - they're full review submissions with body text
pr_reviews_since_review: list[dict] = field(default_factory=list)
# Merge conflict status
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
merge_state_status: str = (
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
)
# CI status - passed to AI orchestrator so it can factor into verdict
# Dict with: passing, failing, pending, failed_checks, awaiting_approval
ci_status: dict = field(default_factory=dict)
# Error flag - if set, context gathering failed and data may be incomplete
error: str | None = None
+11 -145
View File
@@ -389,33 +389,9 @@ class GitHubOrchestrator:
pr_number=pr_number,
)
# Check CI status (comprehensive - includes workflows awaiting approval)
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
# Log CI status with awaiting approval info
awaiting = ci_status.get("awaiting_approval", 0)
pending_without_awaiting = ci_status.get("pending", 0) - awaiting
ci_log_parts = [
f"{ci_status.get('passing', 0)} passing",
f"{ci_status.get('failing', 0)} failing",
]
if pending_without_awaiting > 0:
ci_log_parts.append(f"{pending_without_awaiting} pending")
if awaiting > 0:
ci_log_parts.append(f"{awaiting} awaiting approval")
print(
f"[orchestrator] CI status: {', '.join(ci_log_parts)}",
flush=True,
)
if awaiting > 0:
print(
f"[orchestrator] ⚠️ {awaiting} workflow(s) from fork need maintainer approval to run",
flush=True,
)
# Generate verdict (now includes CI status)
# Generate verdict
verdict, verdict_reasoning, blockers = self._generate_verdict(
findings, structural_issues, ai_triages, ci_status
findings, structural_issues, ai_triages
)
print(
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
@@ -451,25 +427,6 @@ class GitHubOrchestrator:
# Get HEAD SHA for follow-up review tracking
head_sha = self.bot_detector.get_last_commit_sha(pr_context.commits)
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
pr_files = await self.gh_client.get_pr_files(pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
print(
f"[Review] Captured {len(file_blobs)} file blob SHAs for follow-up tracking",
flush=True,
)
except Exception as e:
print(
f"[Review] Warning: Could not capture file blobs: {e}", flush=True
)
# Create result
result = PRReviewResult(
pr_number=pr_number,
@@ -487,8 +444,6 @@ class GitHubOrchestrator:
quick_scan_summary=quick_scan,
# Track the commit SHA for follow-up reviews
reviewed_commit_sha=head_sha,
# Track file blobs for rebase-resistant follow-up reviews
reviewed_file_blobs=file_blobs,
)
# Post review if configured
@@ -516,9 +471,6 @@ class GitHubOrchestrator:
# Save result
await result.save(self.github_dir)
# Note: PR review memory is now saved by the Electron app after the review completes
# This ensures memory is saved to the embedded LadybugDB managed by the app
# Mark as reviewed (head_sha already fetched above)
if head_sha:
self.bot_detector.mark_reviewed(pr_number, head_sha)
@@ -634,29 +586,19 @@ class GitHubOrchestrator:
await result.save(self.github_dir)
return result
# Check if there are changes to review (commits OR files via blob comparison)
# After a rebase/force-push, commits_since_review will be empty (commit
# SHAs are rewritten), but files_changed_since_review will contain files
# that actually changed content based on blob SHA comparison.
has_commits = bool(followup_context.commits_since_review)
has_file_changes = bool(followup_context.files_changed_since_review)
if not has_commits and not has_file_changes:
base_sha = previous_review.reviewed_commit_sha[:8]
# Check if there are new commits
if not followup_context.commits_since_review:
print(
f"[Followup] No changes since last review at {base_sha}",
f"[Followup] No new commits since last review at {previous_review.reviewed_commit_sha[:8]}",
flush=True,
)
# Return a result indicating no changes
no_change_summary = (
"No new commits since last review. Previous findings still apply."
)
result = PRReviewResult(
pr_number=pr_number,
repo=self.config.repo,
success=True,
findings=previous_review.findings,
summary=no_change_summary,
summary="No new commits since last review. Previous findings still apply.",
overall_status=previous_review.overall_status,
verdict=previous_review.verdict,
verdict_reasoning="No changes since last review.",
@@ -668,26 +610,13 @@ class GitHubOrchestrator:
await result.save(self.github_dir)
return result
# Build progress message based on what changed
if has_commits:
num_commits = len(followup_context.commits_since_review)
change_desc = f"{num_commits} new commits"
else:
# Rebase detected - files changed but no trackable commits
num_files = len(followup_context.files_changed_since_review)
change_desc = f"{num_files} files (rebase detected)"
self._report_progress(
"analyzing",
30,
f"Analyzing {change_desc}...",
f"Analyzing {len(followup_context.commits_since_review)} new commits...",
pr_number=pr_number,
)
# Fetch CI status BEFORE calling reviewer so AI can factor it into verdict
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
followup_context.ci_status = ci_status
# Use parallel orchestrator for follow-up if enabled
if self.config.use_parallel_orchestrator:
print(
@@ -732,43 +661,9 @@ class GitHubOrchestrator:
)
result = await reviewer.review_followup(followup_context)
# Fallback: ensure CI failures block merge even if AI didn't factor it in
# (CI status was already passed to AI via followup_context.ci_status)
failed_checks = followup_context.ci_status.get("failed_checks", [])
if failed_checks:
print(
f"[Followup] CI checks failing: {failed_checks}",
flush=True,
)
# Override verdict if CI is failing
if result.verdict in (
MergeVerdict.READY_TO_MERGE,
MergeVerdict.MERGE_WITH_CHANGES,
):
result.verdict = MergeVerdict.BLOCKED
result.verdict_reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
"Fix CI before merge."
)
result.overall_status = "request_changes"
# Add CI failures to blockers
for check_name in failed_checks:
if f"CI Failed: {check_name}" not in result.blockers:
result.blockers.append(f"CI Failed: {check_name}")
# Update summary to reflect CI status
ci_warning = (
f"\n\n**⚠️ CI Status:** {len(failed_checks)} check(s) failing: "
f"{', '.join(failed_checks)}"
)
if ci_warning not in result.summary:
result.summary += ci_warning
# Save result
await result.save(self.github_dir)
# Note: PR review memory is now saved by the Electron app after the review completes
# This ensures memory is saved to the embedded LadybugDB managed by the app
# Mark as reviewed with new commit SHA
if result.reviewed_commit_sha:
self.bot_detector.mark_reviewed(pr_number, result.reviewed_commit_sha)
@@ -795,16 +690,13 @@ class GitHubOrchestrator:
findings: list[PRReviewFinding],
structural_issues: list[StructuralIssue],
ai_triages: list[AICommentTriage],
ci_status: dict | None = None,
) -> tuple[MergeVerdict, str, list[str]]:
"""
Generate merge verdict based on all findings and CI status.
Generate merge verdict based on all findings.
NEW: Strengthened to block on verification failures, redundancy issues,
and failing CI checks.
NEW: Strengthened to block on verification failures and redundancy issues.
"""
blockers = []
ci_status = ci_status or {}
# Count by severity
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
@@ -841,18 +733,6 @@ class GitHubOrchestrator:
ai_critical = [t for t in ai_triages if t.verdict == AICommentVerdict.CRITICAL]
# Build blockers list with NEW categories first
# CI failures block merging
failed_checks = ci_status.get("failed_checks", [])
for check_name in failed_checks:
blockers.append(f"CI Failed: {check_name}")
# Workflows awaiting approval block merging (fork PRs)
awaiting_approval = ci_status.get("awaiting_approval", 0)
if awaiting_approval > 0:
blockers.append(
f"Workflows Pending: {awaiting_approval} workflow(s) awaiting maintainer approval"
)
# NEW: Verification failures block merging
for f in verification_failures:
note = f" - {f.verification_note}" if f.verification_note else ""
@@ -885,24 +765,10 @@ class GitHubOrchestrator:
)
blockers.append(f"{t.tool_name}: {summary}")
# Determine verdict with CI, verification and redundancy checks
# Determine verdict with NEW verification and redundancy checks
if blockers:
# CI failures are always blockers
if failed_checks:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
"Fix CI before merge."
)
# Workflows awaiting approval block merging
elif awaiting_approval > 0:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: {awaiting_approval} workflow(s) awaiting approval. "
"Approve workflows on GitHub to run CI checks."
)
# NEW: Prioritize verification failures
elif verification_failures:
if verification_failures:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: Cannot verify {len(verification_failures)} claim(s) in PR. "
@@ -26,7 +26,6 @@ if TYPE_CHECKING:
from ..models import FollowupReviewContext, GitHubRunnerConfig
try:
from ..gh_client import GHClient
from ..models import (
MergeVerdict,
PRReviewFinding,
@@ -38,7 +37,6 @@ try:
from .prompt_manager import PromptManager
from .pydantic_models import FollowupReviewResponse
except (ImportError, ValueError, SystemError):
from gh_client import GHClient
from models import (
MergeVerdict,
PRReviewFinding,
@@ -232,27 +230,6 @@ class FollowupReviewer:
"complete", 100, "Follow-up review complete!", context.pr_number
)
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
repo=self.config.repo,
)
pr_files = await gh_client.get_pr_files(context.pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
logger.info(
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
)
except Exception as e:
logger.warning(f"Could not capture file blobs: {e}")
return PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
@@ -266,7 +243,6 @@ class FollowupReviewer:
reviewed_at=datetime.now().isoformat(),
# Follow-up specific fields
reviewed_commit_sha=context.current_commit_sha,
reviewed_file_blobs=file_blobs,
is_followup_review=True,
previous_review_id=context.previous_review.review_id,
resolved_findings=[f.id for f in resolved],
@@ -21,9 +21,6 @@ from __future__ import annotations
import hashlib
import logging
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import TYPE_CHECKING
@@ -35,8 +32,6 @@ from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ..context_gatherer import _validate_git_ref
from ..gh_client import GHClient
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -48,9 +43,7 @@ try:
from .pydantic_models import ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import _validate_git_ref
from core.client import create_client
from gh_client import GHClient
from models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -69,9 +62,6 @@ logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
# Directory for PR review worktrees (shared with initial reviewer)
PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees"
# Severity mapping for AI responses
_SEVERITY_MAPPING = {
"critical": ReviewSeverity.CRITICAL,
@@ -146,122 +136,6 @@ class ParallelFollowupReviewer:
logger.warning(f"Prompt file not found: {prompt_file}")
return ""
def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path:
"""Create a temporary worktree at the PR head commit.
Args:
head_sha: The commit SHA of the PR head (validated before use)
pr_number: The PR number for naming
Returns:
Path to the created worktree
Raises:
RuntimeError: If worktree creation fails
ValueError: If head_sha fails validation (command injection prevention)
"""
# SECURITY: Validate git ref before use in subprocess calls
if not _validate_git_ref(head_sha):
raise ValueError(
f"Invalid git ref: '{head_sha}'. "
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
)
worktree_name = f"pr-followup-{pr_number}-{uuid.uuid4().hex[:8]}"
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if DEBUG_MODE:
print(f"[Followup] DEBUG: project_dir={self.project_dir}", flush=True)
print(f"[Followup] DEBUG: worktree_dir={worktree_dir}", flush=True)
print(f"[Followup] DEBUG: head_sha={head_sha}", flush=True)
worktree_dir.mkdir(parents=True, exist_ok=True)
worktree_path = worktree_dir / worktree_name
if DEBUG_MODE:
print(f"[Followup] DEBUG: worktree_path={worktree_path}", flush=True)
# Fetch the commit if not available locally (handles fork PRs)
fetch_result = subprocess.run(
["git", "fetch", "origin", head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=60,
)
if DEBUG_MODE:
print(
f"[Followup] DEBUG: fetch returncode={fetch_result.returncode}",
flush=True,
)
# Create detached worktree at the PR commit
result = subprocess.run(
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=120,
)
if DEBUG_MODE:
print(
f"[Followup] DEBUG: worktree add returncode={result.returncode}",
flush=True,
)
if result.stderr:
print(
f"[Followup] DEBUG: worktree add stderr={result.stderr[:200]}",
flush=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
logger.info(f"[Followup] Created worktree at {worktree_path}")
return worktree_path
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
"""Remove a temporary PR review worktree with fallback chain.
Args:
worktree_path: Path to the worktree to remove
"""
if not worktree_path or not worktree_path.exists():
return
if DEBUG_MODE:
print(
f"[Followup] DEBUG: Cleaning up worktree at {worktree_path}",
flush=True,
)
# Try 1: git worktree remove
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
logger.info(f"[Followup] Cleaned up worktree: {worktree_path.name}")
return
# Try 2: shutil.rmtree fallback
try:
shutil.rmtree(worktree_path, ignore_errors=True)
subprocess.run(
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
logger.warning(f"[Followup] Used shutil fallback for: {worktree_path.name}")
except Exception as e:
logger.error(f"[Followup] Failed to cleanup worktree {worktree_path}: {e}")
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
"""
Define specialist agents for follow-up review.
@@ -276,7 +150,6 @@ class ParallelFollowupReviewer:
resolution_prompt = self._load_prompt("pr_followup_resolution_agent.md")
newcode_prompt = self._load_prompt("pr_followup_newcode_agent.md")
comment_prompt = self._load_prompt("pr_followup_comment_agent.md")
validator_prompt = self._load_prompt("pr_finding_validator.md")
return {
"resolution-verifier": AgentDefinition(
@@ -313,20 +186,6 @@ class ParallelFollowupReviewer:
tools=["Read", "Grep", "Glob"],
model="inherit",
),
"finding-validator": AgentDefinition(
description=(
"Finding re-investigation specialist. Re-investigates unresolved findings "
"to validate they are actually real issues, not false positives. "
"Actively reads the code at the finding location with fresh eyes. "
"Can confirm findings as valid OR dismiss them as false positives. "
"CRITICAL: Invoke for ALL unresolved findings after resolution-verifier runs. "
"Invoke when: There are findings marked as unresolved that need validation."
),
prompt=validator_prompt
or "You validate whether unresolved findings are real issues.",
tools=["Read", "Grep", "Glob"],
model="inherit",
),
}
def _format_previous_findings(self, context: FollowupReviewContext) -> str:
@@ -391,44 +250,6 @@ class ParallelFollowupReviewer:
return "\n\n---\n\n".join(ai_content)
def _format_ci_status(self, context: FollowupReviewContext) -> str:
"""Format CI status for the prompt."""
ci_status = context.ci_status
if not ci_status:
return "CI status not available."
passing = ci_status.get("passing", 0)
failing = ci_status.get("failing", 0)
pending = ci_status.get("pending", 0)
failed_checks = ci_status.get("failed_checks", [])
awaiting_approval = ci_status.get("awaiting_approval", 0)
lines = []
# Overall status
if failing > 0:
lines.append(f"⚠️ **{failing} CI check(s) FAILING** - PR cannot be merged")
elif pending > 0:
lines.append(f"⏳ **{pending} CI check(s) pending** - Wait for completion")
elif passing > 0:
lines.append(f"✅ **All {passing} CI check(s) passing**")
else:
lines.append("No CI checks configured")
# List failed checks
if failed_checks:
lines.append("\n**Failed checks:**")
for check in failed_checks:
lines.append(f" - ❌ {check}")
# Awaiting approval (fork PRs)
if awaiting_approval > 0:
lines.append(
f"\n⏸️ **{awaiting_approval} workflow(s) awaiting maintainer approval** (fork PR)"
)
return "\n".join(lines)
def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str:
"""Build full prompt for orchestrator with follow-up context."""
# Load orchestrator prompt
@@ -441,7 +262,6 @@ class ParallelFollowupReviewer:
commits = self._format_commits(context)
contributor_comments = self._format_comments(context)
ai_reviews = self._format_ai_reviews(context)
ci_status = self._format_ci_status(context)
# Truncate diff if too long
MAX_DIFF_CHARS = 100_000
@@ -460,9 +280,6 @@ class ParallelFollowupReviewer:
**New Commits:** {len(context.commits_since_review)}
**Files Changed:** {len(context.files_changed_since_review)}
### CI Status (CRITICAL - Must Factor Into Verdict)
{ci_status}
### Previous Review Summary
{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."}
@@ -491,7 +308,6 @@ class ParallelFollowupReviewer:
Now analyze this follow-up and delegate to the appropriate specialist agents.
Remember: YOU decide which agents to invoke based on YOUR analysis.
The SDK will run invoked agents in parallel automatically.
**CRITICAL: Your verdict MUST account for CI status. Failing CI = BLOCKED verdict.**
"""
return base_prompt + followup_context
@@ -510,9 +326,6 @@ The SDK will run invoked agents in parallel automatically.
f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}"
)
# Track worktree for cleanup
worktree_path: Path | None = None
try:
self._report_progress(
"orchestrating",
@@ -524,48 +337,13 @@ The SDK will run invoked agents in parallel automatically.
# Build orchestrator prompt
prompt = self._build_orchestrator_prompt(context)
# Get project root - default to local checkout
# Get project root
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
# Create temporary worktree at PR head commit for isolated review
# This ensures agents read from the correct PR state, not the current checkout
head_sha = context.current_commit_sha
if head_sha and _validate_git_ref(head_sha):
try:
if DEBUG_MODE:
print(
f"[Followup] DEBUG: Creating worktree for head_sha={head_sha}",
flush=True,
)
worktree_path = self._create_pr_worktree(
head_sha, context.pr_number
)
project_root = worktree_path
print(
f"[Followup] Using worktree at {worktree_path.name} for PR review",
flush=True,
)
except Exception as e:
if DEBUG_MODE:
print(
f"[Followup] DEBUG: Worktree creation FAILED: {e}",
flush=True,
)
logger.warning(
f"[ParallelFollowup] Worktree creation failed, "
f"falling back to local checkout: {e}"
)
# Fallback to original behavior if worktree creation fails
else:
logger.warning(
f"[ParallelFollowup] Invalid or missing head_sha '{head_sha}', "
"using local checkout"
)
# Use model and thinking level from config (user settings)
model = self.config.model or "claude-sonnet-4-5-20250929"
thinking_level = self.config.thinking_level or "medium"
@@ -666,11 +444,6 @@ The SDK will run invoked agents in parallel automatically.
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved"
)
# Extract validation counts
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
confirmed_count = result_data.get("confirmed_valid_count", 0)
needs_human_count = result_data.get("needs_human_review_count", 0)
# Generate summary
summary = self._generate_summary(
verdict=verdict,
@@ -679,9 +452,6 @@ The SDK will run invoked agents in parallel automatically.
unresolved_count=len(unresolved_ids),
new_count=len(new_finding_ids),
agents_invoked=final_agents,
dismissed_false_positive_count=dismissed_count,
confirmed_valid_count=confirmed_count,
needs_human_review_count=needs_human_count,
)
# Map verdict to overall_status
@@ -705,27 +475,6 @@ The SDK will run invoked agents in parallel automatically.
):
blockers.append(f"{finding.category.value}: {finding.title}")
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
repo=self.config.repo,
)
pr_files = await gh_client.get_pr_files(context.pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
logger.info(
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
)
except Exception as e:
logger.warning(f"Could not capture file blobs: {e}")
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
@@ -737,7 +486,6 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
reviewed_commit_sha=context.current_commit_sha,
reviewed_file_blobs=file_blobs,
is_followup_review=True,
previous_review_id=context.previous_review.review_id
or context.previous_review.pr_number,
@@ -772,10 +520,6 @@ The SDK will run invoked agents in parallel automatically.
is_followup_review=True,
reviewed_commit_sha=context.current_commit_sha,
)
finally:
# Always cleanup worktree, even on error
if worktree_path:
self._cleanup_pr_worktree(worktree_path)
def _parse_structured_output(
self, data: dict, context: FollowupReviewContext
@@ -801,34 +545,10 @@ The SDK will run invoked agents in parallel automatically.
new_finding_ids = []
# Process resolution verifications
# First, build a map of finding validations (from finding-validator agent)
validation_map = {}
dismissed_ids = []
for fv in response.finding_validations:
validation_map[fv.finding_id] = fv
if fv.validation_status == "dismissed_false_positive":
dismissed_ids.append(fv.finding_id)
print(
f"[ParallelFollowup] Finding {fv.finding_id} DISMISSED as false positive: {fv.explanation[:100]}",
flush=True,
)
for rv in response.resolution_verifications:
if rv.status == "resolved":
resolved_ids.append(rv.finding_id)
elif rv.status in ("unresolved", "partially_resolved", "cant_verify"):
# Check if finding was validated and dismissed as false positive
if rv.finding_id in dismissed_ids:
# Finding-validator determined this was a false positive - skip it
print(
f"[ParallelFollowup] Skipping {rv.finding_id} - dismissed as false positive by finding-validator",
flush=True,
)
resolved_ids.append(
rv.finding_id
) # Count as resolved (false positive)
continue
# Include "cant_verify" as unresolved - if we can't verify, assume not fixed
unresolved_ids.append(rv.finding_id)
# Add unresolved as a finding
@@ -843,17 +563,6 @@ The SDK will run invoked agents in parallel automatically.
None,
)
if original:
# Check if we have validation evidence
validation = validation_map.get(rv.finding_id)
validation_status = None
validation_evidence = None
validation_explanation = None
if validation:
validation_status = validation.validation_status
validation_evidence = validation.code_evidence
validation_explanation = validation.explanation
findings.append(
PRReviewFinding(
id=rv.finding_id,
@@ -865,9 +574,6 @@ The SDK will run invoked agents in parallel automatically.
line=original.line,
suggested_fix=original.suggested_fix,
fixable=original.fixable,
validation_status=validation_status,
validation_evidence=validation_evidence,
validation_explanation=validation_explanation,
)
)
@@ -920,18 +626,6 @@ The SDK will run invoked agents in parallel automatically.
}
verdict = verdict_map.get(response.verdict, MergeVerdict.NEEDS_REVISION)
# Count validation results
confirmed_valid_count = sum(
1
for fv in response.finding_validations
if fv.validation_status == "confirmed_valid"
)
needs_human_count = sum(
1
for fv in response.finding_validations
if fv.validation_status == "needs_human_review"
)
# Log findings summary for verification
print(
f"[ParallelFollowup] Parsed {len(findings)} findings, "
@@ -939,22 +633,11 @@ The SDK will run invoked agents in parallel automatically.
f"{len(new_finding_ids)} new",
flush=True,
)
if dismissed_ids:
print(
f"[ParallelFollowup] Validation: {len(dismissed_ids)} findings dismissed as false positives, "
f"{confirmed_valid_count} confirmed valid, {needs_human_count} need human review",
flush=True,
)
if findings:
print("[ParallelFollowup] Findings summary:", flush=True)
for i, f in enumerate(findings, 1):
validation_note = ""
if f.validation_status == "confirmed_valid":
validation_note = " [VALIDATED]"
elif f.validation_status == "needs_human_review":
validation_note = " [NEEDS HUMAN REVIEW]"
print(
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line}){validation_note}",
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})",
flush=True,
)
@@ -963,9 +646,6 @@ The SDK will run invoked agents in parallel automatically.
"resolved_ids": resolved_ids,
"unresolved_ids": unresolved_ids,
"new_finding_ids": new_finding_ids,
"dismissed_false_positive_ids": dismissed_ids,
"confirmed_valid_count": confirmed_valid_count,
"needs_human_review_count": needs_human_count,
"verdict": verdict,
"verdict_reasoning": response.verdict_reasoning,
"agents_invoked": agents_from_output,
@@ -1039,9 +719,6 @@ The SDK will run invoked agents in parallel automatically.
unresolved_count: int,
new_count: int,
agents_invoked: list[str],
dismissed_false_positive_count: int = 0,
confirmed_valid_count: int = 0,
needs_human_review_count: int = 0,
) -> str:
"""Generate a human-readable summary of the follow-up review."""
status_emoji = {
@@ -1056,27 +733,13 @@ The SDK will run invoked agents in parallel automatically.
", ".join(agents_invoked) if agents_invoked else "orchestrator only"
)
# Build validation section if there are validation results
validation_section = ""
if (
dismissed_false_positive_count > 0
or confirmed_valid_count > 0
or needs_human_review_count > 0
):
validation_section = f"""
### Finding Validation
- 🔍 **Dismissed as False Positives**: {dismissed_false_positive_count} findings were re-investigated and found to be incorrect
- **Confirmed Valid**: {confirmed_valid_count} findings verified as genuine issues
- 👤 **Needs Human Review**: {needs_human_review_count} findings require manual verification
"""
summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()}
### Resolution Status
- **Resolved**: {resolved_count} previous findings addressed
- **Unresolved**: {unresolved_count} previous findings remain
- 🆕 **New Issues**: {new_count} new findings in recent changes
{validation_section}
### Verdict
{verdict_reasoning}
@@ -1084,6 +747,6 @@ The SDK will run invoked agents in parallel automatically.
Agents invoked: {agents_str}
---
*This is an AI-generated follow-up review using parallel specialist analysis with finding validation.*
*This is an AI-generated follow-up review using parallel specialist analysis.*
"""
return summary
@@ -20,9 +20,6 @@ from __future__ import annotations
import hashlib
import logging
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Any
@@ -31,8 +28,7 @@ from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ..context_gatherer import PRContext, _validate_git_ref
from ..gh_client import GHClient
from ..context_gatherer import PRContext
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -44,9 +40,8 @@ try:
from .pydantic_models import ParallelOrchestratorResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext, _validate_git_ref
from context_gatherer import PRContext
from core.client import create_client
from gh_client import GHClient
from models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -65,9 +60,6 @@ logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
# Directory for PR review worktrees (inside github/pr for consistency)
PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees"
class ParallelOrchestratorReviewer:
"""
@@ -124,201 +116,6 @@ class ParallelOrchestratorReviewer:
logger.warning(f"Prompt file not found: {prompt_file}")
return ""
def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path:
"""Create a temporary worktree at the PR head commit.
Args:
head_sha: The commit SHA of the PR head (validated before use)
pr_number: The PR number for naming
Returns:
Path to the created worktree
Raises:
RuntimeError: If worktree creation fails
ValueError: If head_sha fails validation (command injection prevention)
"""
# SECURITY: Validate git ref before use in subprocess calls
if not _validate_git_ref(head_sha):
raise ValueError(
f"Invalid git ref: '{head_sha}'. "
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
)
worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}"
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if DEBUG_MODE:
print(f"[PRReview] DEBUG: project_dir={self.project_dir}", flush=True)
print(f"[PRReview] DEBUG: worktree_dir={worktree_dir}", flush=True)
print(f"[PRReview] DEBUG: head_sha={head_sha}", flush=True)
worktree_dir.mkdir(parents=True, exist_ok=True)
worktree_path = worktree_dir / worktree_name
if DEBUG_MODE:
print(f"[PRReview] DEBUG: worktree_path={worktree_path}", flush=True)
print(
f"[PRReview] DEBUG: worktree_dir exists={worktree_dir.exists()}",
flush=True,
)
# Fetch the commit if not available locally (handles fork PRs)
fetch_result = subprocess.run(
["git", "fetch", "origin", head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=60,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: fetch returncode={fetch_result.returncode}",
flush=True,
)
if fetch_result.stderr:
print(
f"[PRReview] DEBUG: fetch stderr={fetch_result.stderr[:200]}",
flush=True,
)
# Create detached worktree at the PR commit
result = subprocess.run(
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=120, # Worktree add can be slow for large repos
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree add returncode={result.returncode}",
flush=True,
)
if result.stderr:
print(
f"[PRReview] DEBUG: worktree add stderr={result.stderr[:200]}",
flush=True,
)
if result.stdout:
print(
f"[PRReview] DEBUG: worktree add stdout={result.stdout[:200]}",
flush=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree created, exists={worktree_path.exists()}",
flush=True,
)
logger.info(f"[PRReview] Created worktree at {worktree_path}")
return worktree_path
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
"""Remove a temporary PR review worktree with fallback chain.
Args:
worktree_path: Path to the worktree to remove
"""
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: _cleanup_pr_worktree called with {worktree_path}",
flush=True,
)
if not worktree_path or not worktree_path.exists():
if DEBUG_MODE:
print(
"[PRReview] DEBUG: worktree path doesn't exist, skipping cleanup",
flush=True,
)
return
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Attempting to remove worktree at {worktree_path}",
flush=True,
)
# Try 1: git worktree remove
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree remove returncode={result.returncode}",
flush=True,
)
if result.returncode == 0:
logger.info(f"[PRReview] Cleaned up worktree: {worktree_path.name}")
return
# Try 2: shutil.rmtree fallback
try:
shutil.rmtree(worktree_path, ignore_errors=True)
subprocess.run(
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
logger.warning(f"[PRReview] Used shutil fallback for: {worktree_path.name}")
except Exception as e:
logger.error(f"[PRReview] Failed to cleanup worktree {worktree_path}: {e}")
def _cleanup_stale_pr_worktrees(self) -> None:
"""Clean up orphaned PR review worktrees on startup."""
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if not worktree_dir.exists():
return
# Get registered worktrees from git
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
registered = set()
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
# Safely parse - check bounds to prevent IndexError
parts = line.split(" ", 1)
if len(parts) > 1 and parts[1]:
registered.add(Path(parts[1]))
# Remove unregistered directories
stale_count = 0
for item in worktree_dir.iterdir():
if item.is_dir() and item not in registered:
logger.info(f"[PRReview] Removing stale worktree: {item.name}")
shutil.rmtree(item, ignore_errors=True)
stale_count += 1
if stale_count > 0:
subprocess.run(
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Cleaned up {stale_count} stale worktree(s)",
flush=True,
)
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
"""
Define specialist agents for the SDK.
@@ -586,7 +383,7 @@ The SDK will run invoked agents in parallel automatically.
category=category,
severity=severity,
suggested_fix=finding_data.suggested_fix or "",
evidence=finding_data.evidence,
confidence=self._normalize_confidence(finding_data.confidence),
)
async def review(self, context: PRContext) -> PRReviewResult:
@@ -603,12 +400,6 @@ The SDK will run invoked agents in parallel automatically.
f"[ParallelOrchestrator] Starting review for PR #{context.pr_number}"
)
# Clean up any stale worktrees from previous runs
self._cleanup_stale_pr_worktrees()
# Track worktree for cleanup
worktree_path: Path | None = None
try:
self._report_progress(
"orchestrating",
@@ -620,75 +411,12 @@ The SDK will run invoked agents in parallel automatically.
# Build orchestrator prompt
prompt = self._build_orchestrator_prompt(context)
# Create temporary worktree at PR head commit for isolated review
# This ensures agents read from the correct PR state, not the current checkout
head_sha = context.head_sha or context.head_branch
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: context.head_sha='{context.head_sha}'",
flush=True,
)
print(
f"[PRReview] DEBUG: context.head_branch='{context.head_branch}'",
flush=True,
)
print(f"[PRReview] DEBUG: resolved head_sha='{head_sha}'", flush=True)
# SECURITY: Validate the resolved head_sha (whether SHA or branch name)
# This catches invalid refs early before subprocess calls
if head_sha and not _validate_git_ref(head_sha):
logger.warning(
f"[ParallelOrchestrator] Invalid git ref '{head_sha}', "
"using current checkout for safety"
)
head_sha = None
if not head_sha:
if DEBUG_MODE:
print("[PRReview] DEBUG: No head_sha - using fallback", flush=True)
logger.warning(
"[ParallelOrchestrator] No head_sha available, using current checkout"
)
# Fallback to original behavior if no SHA available
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
else:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Creating worktree for head_sha={head_sha}",
flush=True,
)
try:
worktree_path = self._create_pr_worktree(
head_sha, context.pr_number
)
project_root = worktree_path
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Using worktree as "
f"project_root={project_root}",
flush=True,
)
except (RuntimeError, ValueError) as e:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Worktree creation FAILED: {e}",
flush=True,
)
logger.warning(
f"[ParallelOrchestrator] Worktree creation failed, "
f"using current checkout: {e}"
)
# Fallback to original behavior if worktree creation fails
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
# Get project root
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
# Use model and thinking level from config (user settings)
model = self.config.model or "claude-sonnet-4-5-20250929"
@@ -801,27 +529,6 @@ The SDK will run invoked agents in parallel automatically.
latest_commit = context.commits[-1]
head_sha = latest_commit.get("oid") or latest_commit.get("sha")
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
try:
gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
repo=self.config.repo,
)
pr_files = await gh_client.get_pr_files(context.pr_number)
for file in pr_files:
filename = file.get("filename", "")
blob_sha = file.get("sha", "")
if filename and blob_sha:
file_blobs[filename] = blob_sha
logger.info(
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
)
except Exception as e:
logger.warning(f"Could not capture file blobs: {e}")
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
@@ -833,7 +540,6 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
reviewed_commit_sha=head_sha,
reviewed_file_blobs=file_blobs,
)
self._report_progress(
@@ -853,10 +559,6 @@ The SDK will run invoked agents in parallel automatically.
success=False,
error=str(e),
)
finally:
# Always cleanup worktree, even on error
if worktree_path:
self._cleanup_pr_worktree(worktree_path)
def _parse_structured_output(
self, structured_output: dict[str, Any]
@@ -969,7 +671,7 @@ The SDK will run invoked agents in parallel automatically.
category=category,
severity=severity,
suggested_fix=f_data.get("suggested_fix", ""),
evidence=f_data.get("evidence"),
confidence=self._normalize_confidence(f_data.get("confidence", 85)),
)
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
@@ -26,7 +26,7 @@ from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# =============================================================================
# Common Finding Types
@@ -46,10 +46,6 @@ class BaseFinding(BaseModel):
line: int = Field(0, description="Line number of the issue")
suggested_fix: str | None = Field(None, description="How to fix this issue")
fixable: bool = Field(False, description="Whether this can be auto-fixed")
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
)
class SecurityFinding(BaseFinding):
@@ -82,6 +78,9 @@ class DeepAnalysisFinding(BaseFinding):
"performance",
"logic",
] = Field(description="Issue category")
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="AI's confidence in this finding (0.0-1.0)"
)
verification_note: str | None = Field(
None, description="What evidence is missing or couldn't be verified"
)
@@ -316,11 +315,21 @@ class OrchestratorFinding(BaseModel):
description="Issue severity level"
)
suggestion: str | None = Field(None, description="How to fix this issue")
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
confidence: float = Field(
0.85,
ge=0.0,
le=1.0,
description="Confidence (0.0-1.0 or 0-100, normalized to 0.0-1.0)",
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range (accepts 0-100 or 0.0-1.0)."""
if v > 1:
return v / 100.0
return float(v)
class OrchestratorReviewResponse(BaseModel):
"""Complete response schema for orchestrator PR review."""
@@ -346,6 +355,9 @@ class LogicFinding(BaseFinding):
category: Literal["logic"] = Field(
default="logic", description="Always 'logic' for logic findings"
)
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
example_input: str | None = Field(
None, description="Concrete input that triggers the bug"
)
@@ -354,6 +366,14 @@ class LogicFinding(BaseFinding):
None, description="What the code should produce"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class CodebaseFitFinding(BaseFinding):
"""A codebase fit finding from the codebase fit review agent."""
@@ -361,6 +381,9 @@ class CodebaseFitFinding(BaseFinding):
category: Literal["codebase_fit"] = Field(
default="codebase_fit", description="Always 'codebase_fit' for fit findings"
)
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
existing_code: str | None = Field(
None, description="Reference to existing code that should be used instead"
)
@@ -368,6 +391,14 @@ class CodebaseFitFinding(BaseFinding):
None, description="Description of the established pattern being violated"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class ParallelOrchestratorFinding(BaseModel):
"""A finding from the parallel orchestrator with source agent tracking."""
@@ -392,9 +423,8 @@ class ParallelOrchestratorFinding(BaseModel):
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
suggested_fix: str | None = Field(None, description="How to fix this issue")
fixable: bool = Field(False, description="Whether this can be auto-fixed")
@@ -406,6 +436,14 @@ class ParallelOrchestratorFinding(BaseModel):
False, description="Whether multiple agents agreed on this finding"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class AgentAgreement(BaseModel):
"""Tracks agreement between agents on findings."""
@@ -458,14 +496,22 @@ class ResolutionVerification(BaseModel):
status: Literal["resolved", "partially_resolved", "unresolved", "cant_verify"] = (
Field(description="Resolution status after AI verification")
)
evidence: str = Field(
min_length=1,
description="Actual code snippet showing the resolution status. Required.",
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in the resolution status"
)
evidence: str = Field(description="What evidence supports this resolution status")
resolution_notes: str | None = Field(
None, description="Detailed notes on how the issue was addressed"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class ParallelFollowupFinding(BaseModel):
"""A finding from parallel follow-up review with source agent tracking."""
@@ -488,9 +534,8 @@ class ParallelFollowupFinding(BaseModel):
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
evidence: str | None = Field(
None,
description="Actual code snippet proving the issue exists. Required for validation.",
confidence: float = Field(
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
)
suggested_fix: str | None = Field(None, description="How to fix this issue")
fixable: bool = Field(False, description="Whether this can be auto-fixed")
@@ -501,6 +546,14 @@ class ParallelFollowupFinding(BaseModel):
None, description="ID of related previous finding if this is a regression"
)
@field_validator("confidence", mode="before")
@classmethod
def normalize_confidence(cls, v: int | float) -> float:
"""Normalize confidence to 0.0-1.0 range."""
if v > 1:
return v / 100.0
return float(v)
class CommentAnalysis(BaseModel):
"""Analysis of a contributor or AI comment."""
@@ -538,15 +591,6 @@ class ParallelFollowupResponse(BaseModel):
description="AI-verified resolution status for each previous finding",
)
# Finding validations (from finding-validator agent)
finding_validations: list[FindingValidationResult] = Field(
default_factory=list,
description=(
"Re-investigation results for unresolved findings. "
"Validates whether findings are real issues or false positives."
),
)
# New findings (from new-code-reviewer agent)
new_findings: list[ParallelFollowupFinding] = Field(
default_factory=list,
@@ -574,72 +618,3 @@ class ParallelFollowupResponse(BaseModel):
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
verdict_reasoning: str = Field(description="Explanation for the verdict")
# =============================================================================
# Finding Validation Response (Re-investigation of unresolved findings)
# =============================================================================
class FindingValidationResult(BaseModel):
"""
Result of re-investigating an unresolved finding to validate it's actually real.
The finding-validator agent uses this to report whether a previous finding
is a genuine issue or a false positive that should be dismissed.
EVIDENCE-BASED VALIDATION: No confidence scores - validation is binary.
Either the evidence shows the issue exists, or it doesn't.
"""
finding_id: str = Field(description="ID of the finding being validated")
validation_status: Literal[
"confirmed_valid", "dismissed_false_positive", "needs_human_review"
] = Field(
description=(
"Validation result: "
"confirmed_valid = code evidence proves issue IS real; "
"dismissed_false_positive = code evidence proves issue does NOT exist; "
"needs_human_review = cannot find definitive evidence either way"
)
)
code_evidence: str = Field(
min_length=1,
description=(
"REQUIRED: Exact code snippet examined from the file. "
"Must be actual code copy-pasted from the file, not a description. "
"This is the proof that determines the validation status."
),
)
line_range: tuple[int, int] = Field(
description="Start and end line numbers of the examined code"
)
explanation: str = Field(
min_length=20,
description=(
"Detailed explanation connecting the code_evidence to the validation_status. "
"Must explain: (1) what the original finding claimed, (2) what the actual code shows, "
"(3) why this proves/disproves the issue."
),
)
evidence_verified_in_file: bool = Field(
description=(
"True if the code_evidence was verified to exist at the specified line_range. "
"False if the code couldn't be found (indicates hallucination in original finding)."
)
)
class FindingValidationResponse(BaseModel):
"""Complete response from the finding-validator agent."""
validations: list[FindingValidationResult] = Field(
default_factory=list,
description="Validation results for each finding investigated",
)
summary: str = Field(
description=(
"Brief summary of validation results: how many confirmed, "
"how many dismissed, how many need human review"
)
)
@@ -33,9 +33,8 @@ except (ImportError, ValueError, SystemError):
TriageResult,
)
# Evidence-based validation replaces confidence scoring
# Findings without evidence are filtered out instead of using confidence thresholds
MIN_EVIDENCE_LENGTH = 20 # Minimum chars for evidence to be considered valid
# Confidence threshold for filtering findings (GitHub Copilot standard)
CONFIDENCE_THRESHOLD = 0.80
class ResponseParser:
@@ -66,13 +65,9 @@ class ResponseParser:
@staticmethod
def parse_review_findings(
response_text: str, require_evidence: bool = True
response_text: str, apply_confidence_filter: bool = True
) -> list[PRReviewFinding]:
"""Parse findings from AI response with optional evidence validation.
Evidence-based validation: Instead of confidence scores, findings
require actual code evidence proving the issue exists.
"""
"""Parse findings from AI response with optional confidence filtering."""
findings = []
try:
@@ -82,14 +77,14 @@ class ResponseParser:
if json_match:
findings_data = json.loads(json_match.group(1))
for i, f in enumerate(findings_data):
# Get evidence (code snippet proving the issue)
evidence = f.get("evidence") or f.get("code_snippet") or ""
# Get confidence (default to 0.85 if not provided for backward compat)
confidence = float(f.get("confidence", 0.85))
# Apply evidence-based validation
if require_evidence and len(evidence.strip()) < MIN_EVIDENCE_LENGTH:
# Apply confidence threshold filter
if apply_confidence_filter and confidence < CONFIDENCE_THRESHOLD:
print(
f"[AI] Dropped finding '{f.get('title', 'unknown')}': "
f"insufficient evidence ({len(evidence.strip())} chars < {MIN_EVIDENCE_LENGTH})",
f"confidence {confidence:.2f} < {CONFIDENCE_THRESHOLD}",
flush=True,
)
continue
@@ -110,8 +105,8 @@ class ResponseParser:
end_line=f.get("end_line"),
suggested_fix=f.get("suggested_fix"),
fixable=f.get("fixable", False),
# Evidence-based validation fields
evidence=evidence if evidence.strip() else None,
# NEW: Support verification and redundancy fields
confidence=confidence,
verification_note=f.get("verification_note"),
redundant_with=f.get("redundant_with"),
)
+2 -2
View File
@@ -94,8 +94,8 @@ def main():
parser.add_argument(
"--model",
type=str,
default="sonnet", # Changed from "opus" (fix #433)
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-opus-4-5-20251101",
help="Model to use (default: claude-opus-4-5-20251101)",
)
parser.add_argument(
"--thinking-level",
+4 -5
View File
@@ -39,7 +39,6 @@ from debug import (
debug_section,
debug_success,
)
from phase_config import resolve_model_id
def load_project_context(project_dir: str) -> str:
@@ -133,7 +132,7 @@ async def run_with_sdk(
project_dir: str,
message: str,
history: list,
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
thinking_level: str = "medium",
) -> None:
"""Run the chat using Claude SDK with streaming."""
@@ -181,7 +180,7 @@ Current question: {message}"""
# Create Claude SDK client with appropriate settings for insights
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id(model), # Resolve via API Profile if configured
model=model, # Use configured model
system_prompt=system_prompt,
allowed_tools=[
"Read",
@@ -337,8 +336,8 @@ def main():
)
parser.add_argument(
"--model",
default="sonnet",
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-sonnet-4-5-20250929",
help="Claude model ID (default: claude-sonnet-4-5-20250929)",
)
parser.add_argument(
"--thinking-level",
+1 -1
View File
@@ -23,6 +23,6 @@ class RoadmapConfig:
project_dir: Path
output_dir: Path
model: str = "sonnet" # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101"
refresh: bool = False # Force regeneration even if roadmap exists
enable_competitor_analysis: bool = False # Enable competitor analysis phase
+1 -1
View File
@@ -27,7 +27,7 @@ class RoadmapOrchestrator:
self,
project_dir: Path,
output_dir: Path | None = None,
model: str = "sonnet", # Changed from "opus" (fix #433)
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
enable_competitor_analysis: bool = False,

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