Compare commits

..

50 Commits

Author SHA1 Message Date
AndyMik90 086429cb49 fix(github): add augmented PATH env to all gh CLI calls
When running from a packaged macOS app (.dmg), the PATH environment
variable doesn't include common locations like /opt/homebrew/bin where
gh is typically installed via Homebrew.

The getAugmentedEnv() function was already being used in some places
but was missing from:
- spawn() call in registerStartGhAuth
- execSync calls for gh auth token, gh api user, gh repo list
- execFileSync calls for gh api, gh repo create
- execFileSync calls in pr-handlers.ts and triage-handlers.ts

This caused "gh: command not found" errors when connecting projects
to GitHub in the packaged app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:19:54 +01:00
AndyMik90 d9fb8f29d5 fix(build): use PowerShell for tar extraction on Windows
The previous fix using --force-local and path conversion still failed
due to shell escaping issues. PowerShell handles Windows paths natively
and has built-in tar support on Windows 10+, avoiding all path escaping
problems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:15:15 +01:00
Andy d0b0b3df0d fix(build): add --force-local flag to tar on Windows (#303)
On Windows, paths like D:\path are misinterpreted by tar as remote
host:path syntax (Unix tar convention). Adding --force-local tells
tar to treat colons as part of the filename, fixing the extraction
failure in GitHub Actions Windows builds.

Error was: "tar (child): Cannot connect to D: resolve failed"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:47 +01:00
Andy 937a60f8bd fix: stop tracking spec files in git (#295)
* fix: stop tracking spec files in git

- Remove git commit instructions from planner.md for spec files
- Spec files (implementation_plan.json, init.sh, build-progress.txt) should be gitignored
- Untrack existing spec files that were accidentally committed
- AI agents should only commit code changes, not spec metadata

The .auto-claude/specs/ directory is gitignored by design - spec files are
local project metadata that shouldn't be version controlled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(prompts): remove git commit instructions for gitignored spec files

The spec files (build-progress.txt, qa_report.md, implementation_plan.json,
QA_FIX_REQUEST.md) are all stored in .auto-claude/specs/ which is gitignored.

Removed instructions telling agents to commit these files, replaced with
notes explaining they're tracked automatically by the framework.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:51:24 +01:00
Andy 7a51cbd5e5 Fix/2.7.2 fixes (#300)
* fix(frontend): prevent false stuck detection for ai_review tasks

Tasks in ai_review status were incorrectly showing "Task Appears Stuck"
in the detail modal. This happened because the isRunning check included
ai_review status, triggering stuck detection when no process was found.

However, ai_review means "all subtasks completed, awaiting QA" - no build
process is expected to be running. This aligns the detail modal logic with
TaskCard which correctly only checks for in_progress status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(beta-release): use tag-based versioning instead of modifying package.json

Previously the beta-release workflow committed version changes to package.json
on the develop branch, which caused two issues:
1. Permission errors (github-actions[bot] denied push access)
2. Beta versions polluted develop, making merges to main unclean

Now the workflow creates only a git tag and injects the version at build time
using electron-builder's --config.extraMetadata.version flag. This keeps
package.json at the next stable version and avoids any commits to develop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ollama): add packaged app path resolution for Ollama detector script

The Ollama detection was failing in packaged builds because the
Python script path resolution only checked development paths.
In packaged apps, __dirname points to the app bundle, and the
relative path "../../../backend" doesn't resolve correctly.

Added process.resourcesPath for packaged builds (checked first via
app.isPackaged) which correctly locates the backend scripts in
the Resources folder. Also added DEBUG-only logging to help
troubleshoot script location issues.

Closes #129

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(paths): remove legacy auto-claude path fallbacks

Replace all legacy 'auto-claude/' source path detection with 'apps/backend'.
Services now validate paths using runners/spec_runner.py as the marker
instead of requirements.txt, ensuring only valid backend directories match.

- Remove legacy fallback paths from all getAutoBuildSourcePath() implementations
- Add startup validation in index.ts to skip invalid saved paths
- Update project-initializer to detect apps/backend for local dev projects
- Standardize path detection across all services

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): address PR review feedback from Auto Claude and bots

Fixes from PR #300 reviews:

CRITICAL:
- path-resolver.ts: Update marker from requirements.txt to runners/spec_runner.py
  for consistent backend detection across all files

HIGH:
- useTaskDetail.ts: Restore stuck task detection for ai_review status
  (CHANGELOG documents this feature)
- TerminalGrid.tsx: Include legacy terminals without projectPath
  (prevents hiding terminals after upgrade)
- memory-handlers.ts: Add packaged app path in OLLAMA_PULL_MODEL handler
  (fixes production builds)

MEDIUM:
- OAuthStep.tsx: Stricter profile slug sanitization
  (only allow alphanumeric and dashes)
- project-store.ts: Fix regex to not truncate at # in code blocks
  (uses \n#{1,6}\s to match valid markdown headings only)
- memory-service.ts: Add backend structure validation with spec_runner.py marker
- subprocess-spawn.test.ts: Update test to use new marker pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): validate empty profile slug after sanitization

Add validation to prevent empty config directory path when profile name
contains only special characters (e.g., "!!!"). Shows user-friendly error
message requiring at least one letter or number.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:47:19 +01:00
Andy 26beefe39d feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296)
* improve/merge-confclit-layer

* improve AI resolution

* fix caching on merge conflicts

* imrpove merge layer with rebase

* fix(github): add OAuth authentication to follow-up PR review

The follow-up PR review AI analysis was failing with "Could not resolve
authentication method" because AsyncAnthropic() was instantiated without
credentials. The codebase uses OAuth tokens (not ANTHROPIC_API_KEY), so
the client needs the auth_token parameter.

Uses get_auth_token() from core.auth to retrieve the OAuth token from
environment variables or macOS Keychain, matching how initial reviews
authenticate via create_client().

* fix(merge): add validation to prevent AI writing natural language to files

When AI merge receives truncated file contents (due to character limits),
it sometimes responds with explanations like "I need to see the complete
file contents..." instead of actual merged code. This garbage was being
written directly to source files.

Adds two validation layers after AI merge:
1. Natural language detection - catches patterns like "I need to", "Let me"
2. Syntax validation - uses esbuild to verify TypeScript/JavaScript syntax

If either validation fails, the merge returns an error instead of writing
invalid content to the file.

Also adds project_dir field to ParallelMergeTask to enable syntax validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): skip git merge when AI already resolved path-mapped files

When AI successfully merges path-mapped files (due to file renames
between branches), the check only looked at `conflicts_resolved` which
was 0 for path-mapped cases. This caused the code to fall through to
`git merge` which then failed with conflicts.

Now also checks `files_merged` and `ai_assisted` stats to determine
if AI has already handled the merge. When files are AI-merged, they're
already written and staged - no need for git merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix device code issue with github

* fix(frontend): remember GitHub auth method (OAuth vs PAT) in settings

Previously, after authenticating via GitHub OAuth, the settings page
would show "Personal Access Token" input even though OAuth was used.
This was confusing for users who expected to see their OAuth status.

Added githubAuthMethod field to track how authentication was performed.
Settings UI now shows "Authenticated via GitHub OAuth" when OAuth was
used, with option to switch to manual token if needed. The auth method
persists across settings reopening.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(backend): centralize OAuth client creation in core/client.py

- Add create_message_client() for simple message API calls
- Refactor followup_reviewer.py to use centralized client factory
- Remove direct anthropic.AsyncAnthropic import from followup_reviewer
- Add proper ValueError handling for missing OAuth token
- Update docstrings to document both client factories

This ensures all AI interactions use the centralized OAuth authentication
in core/, avoiding direct ANTHROPIC_API_KEY usage per CLAUDE.md guidelines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): remove unused statusColor variable in WorkspaceStatus

Dead code cleanup - the statusColor variable was computed but never
used in the component.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): add BrowserWindow mock to oauth-handlers tests

The sendDeviceCodeToRenderer function uses BrowserWindow.getAllWindows()
which wasn't mocked, causing unhandled rejection errors in tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): use ClaudeSDKClient instead of raw anthropic SDK

Remove direct anthropic SDK import from core/client.py and update
followup_reviewer.py to use ClaudeSDKClient directly as per project
conventions. All AI interactions should use claude-agent-sdk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): add debug logging for AI response in followup_reviewer

Add logging to diagnose why AI review returns no JSON - helps identify
if response is in thinking blocks vs text blocks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:42:45 +01:00
Alex 8416f3076a feat: enhance the logs for the commit linting stage (#293)
* ci: implement enterprise-grade PR quality gates and security scanning

* ci: implement enterprise-grade PR quality gates and security scanning

* fix:pr comments and improve code

* fix: improve commit linting and code quality

* Removed the dependency-review job (i added it)

* fix: address CodeRabbit review comments

- Expand scope pattern to allow uppercase, underscores, slashes, dots
- Add concurrency control to cancel duplicate security scan runs
- Add explanatory comment for Bandit CLI flags
- Remove dependency-review job (requires repo settings)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update commit lint examples with expanded scope patterns

Show slashes and dots in scope examples to demonstrate
the newly allowed characters (api/users, package.json)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove feature request issue template

Feature requests are directed to GitHub Discussions
via the issue template config.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address security vulnerabilities in service orchestrator

- Fix port parsing crash on malformed docker-compose entries
- Fix shell injection risk by using shlex.split() with shell=False

Prevents crashes when docker-compose.yml contains environment
variables in port mappings (e.g., '${PORT}:8080') and eliminates
shell injection vulnerabilities in subprocess execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ci): improve PR title validation error messages with examples

Add helpful console output when PR title validation fails:
- Show expected format and valid types
- Provide examples of valid PR titles
- Display the user's current title
- Suggest fixes based on keywords in the title
- Handle verb variations (fixed, adding, updated, etc.)
- Show placeholder when description is empty after cleanup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* lower coverage

* feat: improve status gate to label correctly based on required checks

* fix(ci): address PR review findings for security and efficiency

- Add explicit permissions block to ci.yml (least privilege principle)
- Skip duplicate test run for Python 3.12 (tests with coverage only)
- Sanitize PR title in markdown output to prevent injection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix typo

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 10:31:27 +01:00
Andy 217249c8a3 fix(github): add explicit GET method to gh api comment fetches (#294)
The gh api command defaults to POST for comment endpoints, causing
GitHub to reject the 'since' query parameter as an invalid POST body
field. Adding --method GET explicitly forces a GET request, allowing
the since parameter to work correctly for fetching comments.

This completes the fix started in f1cc5a09 which only changed from
-f flag to query string syntax but didn't address the HTTP method.
2025-12-26 09:24:01 +01:00
Andy 8bb3df917e fix(frontend): support archiving tasks across all worktree locations (#286)
* archive across all worktress and if not in folder

* fix(frontend): address PR security and race condition issues

- Add taskId validation to prevent path traversal attacks
- Fix TOCTOU race conditions in archiveTasks by removing existsSync
- Fix TOCTOU race conditions in unarchiveTasks by removing existsSync

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 09:01:31 +01:00
Andy 5106c6e9b2 Potential fix for code scanning alert no. 224: Uncontrolled command line (#285)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-26 08:53:14 +01:00
Andy 3ff612742f fix(frontend): validate backend source path before using it (#287)
* fix(frontend): validate backend source path before using it

The path resolver was returning invalid autoBuildPath settings without
validating they contained the required backend files. When settings
pointed to a legacy /auto-claude/ directory (missing requirements.txt
and analyzer.py), the project indexer would fail with "can't open file"
errors.

Now validates that all source paths contain requirements.txt before
returning them, falling back to bundled source path detection when
the configured path is invalid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: Initialize subtask-based implementation plan

- Workflow type: feature
- Phases: 4
- Subtasks: 9
- Ready for autonomous implementation

Parallel execution enabled: phases 1 and 2 can run simultaneously

* auto-claude: Initialize subtask-based implementation plan

- Workflow type: investigation
- Phases: 5
- Subtasks: 13
- Ready for autonomous implementation

* fix merge conflict check loop

* fix(frontend): add warning when fallback path is also invalid

Address CodeRabbit review feedback - the fallback path in
getBundledSourcePath() was returning an unvalidated path which could
still cause the same analyzer.py error. Now logs a warning when the
fallback path also lacks requirements.txt.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:51:06 +01:00
Andy 7f19c2e1f3 feat(python): bundle Python 3.12 with packaged Electron app (#284)
* feat(python): bundle Python 3.12 with packaged Electron app

Resolves issue #258 where users with Python aliases couldn't run the app
because shell aliases aren't visible to Electron's subprocess calls.

Changes:
- Add download-python.cjs script to fetch python-build-standalone
- Bundle Python 3.12.8 in extraResources for packaged apps
- Update python-detector.ts to prioritize bundled Python
- Add Python caching to CI workflows for faster builds

Packaged apps now include Python (~35MB), eliminating the need for users
to have Python installed. Dev mode still falls back to system Python.

Closes #258

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for Python bundling

Security improvements:
- Add SHA256 checksum verification for downloaded Python binaries
- Replace execSync with spawnSync to prevent command injection
- Add input validation to prevent log injection from CLI args
- Add download timeout (5 minutes) and redirect limit (10)
- Proper file/connection cleanup on errors

Bug fixes:
- Fix platform naming mismatch: use "mac"/"win" (electron-builder)
  instead of "darwin"/"win32" (Node.js) for output directories
- Handle empty path edge case in parsePythonCommand

Improvements:
- Add restore-keys to CI cache steps for better cache hit rates
- Improve error messages and logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix mac node.js naming

* security: add SHA256 checksums for all Python platforms

Fetched actual checksums from python-build-standalone release:
- darwin-arm64: abe1de24...
- darwin-x64: 867c1af1...
- win32-x64: 1a702b34...
- linux-x64: 698e53b2...
- linux-arm64: fb983ec8...

All platforms now have cryptographic verification for downloaded
Python binaries, eliminating the supply chain risk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add python-runtime to root .gitignore

Ensures bundled Python runtime is ignored from both root and
frontend .gitignore files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:38:05 +01:00
Todd W. Bucy d98e28305d fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281)
- Use getAugmentedEnv() in project-context-handlers.ts to ensure Python is in PATH
- Add /usr/bin and /usr/sbin to Linux paths in env-utils.ts for system Python
- Fixes GUI-launched apps not inheriting shell environment on Ubuntu 24.04

Fixes #215

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 22:26:04 +01:00
AndyMik90 0b874d4b33 fix(ci): add write permissions to beta-release update-version job
The update-version job needs contents: write permission to push the
version bump commit and tag to the repository. Without this, the
workflow fails with a 403 error when trying to git push.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:34:38 +01:00
dependabot[bot] 50dd10788a chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270)
* chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend

Bumps [@xterm/xterm](https://github.com/xtermjs/xterm.js) from 5.5.0 to 6.0.0.
- [Release notes](https://github.com/xtermjs/xterm.js/releases)
- [Commits](https://github.com/xtermjs/xterm.js/compare/5.5.0...6.0.0)

---
updated-dependencies:
- dependency-name: "@xterm/xterm"
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): update xterm addons for 6.0.0 compatibility and use public APIs

CRITICAL: Updated all xterm addons to versions compatible with xterm 6.0.0:
- @xterm/addon-fit: ^0.10.0 → ^0.11.0
- @xterm/addon-serialize: ^0.13.0 → ^0.14.0
- @xterm/addon-web-links: ^0.11.0 → ^0.12.0
- @xterm/addon-webgl: ^0.18.0 → ^0.19.0

HIGH: Refactored scroll-controller.ts to use public xterm APIs:
- Replaced internal _core access with public buffer/scroll APIs
- Uses onScroll and onWriteParsed events for scroll tracking
- Uses scrollLines() for scroll position restoration
- Proper IDisposable cleanup for event listeners
- Falls back gracefully if onWriteParsed is not available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: AndyMik90 <andre@mikalsenutvikling.no>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:05:35 +01:00
AndyMik90 f1cc5a09f2 fix(github): resolve follow-up review API issues
- Fix gh_client.py: use query string syntax for `since` parameter instead
  of `-f` flag which sends POST body fields, causing GitHub API errors
- Fix followup_reviewer.py: use raw Anthropic client for message API calls
  instead of ClaudeSDKClient which is for agent sessions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:53:03 +01:00
Andy b005fa5c86 fix(security): resolve CodeQL file system race conditions and unused variables (#277)
* fix(security): resolve CodeQL file system race conditions and unused variables

Fix high severity CodeQL alerts:
- Remove TOCTOU (time-of-check-time-of-use) race conditions by eliminating
  existsSync checks followed by file operations. Use try-catch instead.
- Files affected: pr-handlers.ts, spec-utils.ts

Fix unused variable warnings:
- Remove unused imports (FeatureModelConfig, FeatureThinkingConfig,
  withProjectSyncOrNull, getBackendPath, validateRunner, githubFetch)
- Prefix intentionally unused destructured variables with underscore
- Remove unused local variables (existing, actualEvent)
- Files affected: pr-handlers.ts, autofix-handlers.ts, triage-handlers.ts,
  PRDetail.tsx, pr-review-store.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): resolve remaining CodeQL alerts for TOCTOU, network data validation, and unused variables

Address CodeRabbit and CodeQL security alerts from PR #277 review:

- HIGH: Fix 12+ file system race conditions (TOCTOU) by replacing
  existsSync() checks with try/catch blocks in pr-handlers.ts,
  autofix-handlers.ts, triage-handlers.ts, and spec-utils.ts
- MEDIUM: Add sanitizeNetworkData() function to validate/sanitize
  GitHub API data before writing to disk, preventing injection attacks
- Clean up 20+ unused variables, imports, and useless assignments
  across frontend components and handlers
- Fix Python Protocol typing in testing.py (add return type annotations)

All changes verified with TypeScript compilation and ESLint (no errors).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:52:22 +01:00
Andy d79f2da411 fix(ci): use correct electron-builder arch flags (#278)
The project switched from pnpm to npm, which handles script argument
passing differently. pnpm adds a -- separator that caused electron-builder
to ignore the --arch argument, but npm passes it directly.

Since --arch is a deprecated electron-builder argument, use the
recommended flags instead:
- --arch=x64 → --x64
- --arch=arm64 → --arm64

This fixes Mac Intel and ARM64 builds failing with "Unknown argument: arch"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:31:57 +01:00
dependabot[bot] 5ac566e2a2 chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268)
Bumps [jsdom](https://github.com/jsdom/jsdom) from 26.1.0 to 27.3.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/26.1.0...27.3.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 27.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 20:05:29 +01:00
dependabot[bot] f49d4817b1 chore(deps): bump typescript-eslint in /apps/frontend (#269)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.49.0 to 8.50.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.50.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 20:03:05 +01:00
Andy 1e1d7d9b68 fix(ci): use develop branch for dry-run builds in beta-release workflow (#276)
When dry_run=true, the workflow skipped creating the version tag but
build jobs still tried to checkout that non-existent tag, causing all
4 platform builds to fail with "git failed with exit code 1".

Now build jobs checkout develop branch for dry runs while still using
the version tag for real releases.

Closes: GitHub Actions run #20464082726
2025-12-25 19:55:08 +01:00
Daniel Frey e74a3dffd2 fix: accept bug_fix workflow_type alias during planning (#240)
* fix(planning): accept bug_fix workflow_type alias

* style(planning): ruff format

* fix: refatored common logic

* fix: remove ruff errors

* fix: remove duplicate _normalize_workflow_type method

Remove the incorrectly placed duplicate method inside ContextLoader class.
The module-level function is the correct implementation being used.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: danielfrey63 <daniel.frey@sbb.ch>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
Co-authored-by: AndyMik90 <andre@mikalsenutvikling.no>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:53:55 +01:00
Daniel Frey 6ac8250b5b fix(paths): normalize relative paths to posix (#239)
Co-authored-by: danielfrey63 <daniel.frey@sbb.ch>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 19:53:33 +01:00
dependabot[bot] a2cee6941f chore(deps): bump @electron/rebuild in /apps/frontend (#271)
Bumps [@electron/rebuild](https://github.com/electron/rebuild) from 3.7.2 to 4.0.2.
- [Release notes](https://github.com/electron/rebuild/releases)
- [Commits](https://github.com/electron/rebuild/compare/v3.7.2...v4.0.2)

---
updated-dependencies:
- dependency-name: "@electron/rebuild"
  dependency-version: 4.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 19:46:00 +01:00
dependabot[bot] d4cad80a73 chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.0.15 to 4.0.16.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-25 19:42:21 +01:00
Andy 596e95137b feat(github): add automated PR review with follow-up support (#252)
* feat(github): add GitHub automation system for issues and PRs

Implements comprehensive GitHub automation with three major components:

1. Issue Auto-Fix: Automatically creates specs from labeled issues
   - AutoFixButton component with progress tracking
   - useAutoFix hook for config and queue management
   - Backend handlers for spec creation from issues

2. GitHub PRs Tool: AI-powered PR review sidebar
   - New sidebar tab (Cmd+Shift+P) alongside GitHub Issues
   - PRList/PRDetail components for viewing PRs
   - Review system with findings by severity
   - Post review comments to GitHub

3. Issue Triage: Duplicate/spam/feature-creep detection
   - Triage handlers with label application
   - Configurable detection thresholds

Also adds:
- Debug logging (DEBUG=true) for all GitHub handlers
- Backend runners/github module with orchestrator
- AI prompts for PR review, triage, duplicate/spam detection
- dev:debug npm script for development with logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-runner): resolve import errors for direct script execution

Changes runner.py and orchestrator.py to handle both:
- Package import: `from runners.github import ...`
- Direct script: `python runners/github/runner.py`

Uses try/except pattern for relative vs direct imports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github): correct argparse argument order for runner.py

Move --project global argument before subcommand so argparse can
correctly parse it. Fixes "unrecognized arguments: --project" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* logs when debug mode is on

* refactor(github): extract service layer and fix linting errors

Major refactoring to improve maintainability and code quality:

Backend (Python):
- Extracted orchestrator.py (2,600 → 835 lines, 68% reduction) into 7 service modules:
  - prompt_manager.py: Prompt template management
  - response_parsers.py: AI response parsing
  - pr_review_engine.py: PR review orchestration
  - triage_engine.py: Issue triage logic
  - autofix_processor.py: Auto-fix workflow
  - batch_processor.py: Batch issue handling
- Fixed 18 ruff linting errors (F401, C405, C414, E741):
  - Removed unused imports (BatchValidationResult, AuditAction, locked_json_write)
  - Optimized collection literals (set([n]) → {n})
  - Removed unnecessary list() calls
  - Renamed ambiguous variable 'l' to 'label' throughout

Frontend (TypeScript):
- Refactored IPC handlers (19% overall reduction) with shared utilities:
  - autofix-handlers.ts: 1,042 → 818 lines
  - pr-handlers.ts: 648 → 543 lines
  - triage-handlers.ts: 437 lines (no duplication)
- Created utils layer: logger, ipc-communicator, project-middleware, subprocess-runner
- Split github-store.ts into focused stores: issues, pr-review, investigation, sync-status
- Split ReviewFindings.tsx into focused components

All imports verified, type checks passing, linting clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fixes during testing of PR

* feat(github): implement PR merge, assign, and comment features

- Add auto-assignment when clicking "Run AI Review"
- Implement PR merge functionality with squash method
- Add ability to post comments on PRs
- Display assignees in PR UI
- Add Approve and Merge buttons when review passes
- Update backend gh_client with pr_merge, pr_comment, pr_assign methods
- Create IPC handlers for new PR operations
- Update TypeScript interfaces and browser mocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Improve PR review AI

* fix(github): use temp files for PR review posting to avoid shell escaping issues

When posting PR reviews with findings containing special characters (backticks,
parentheses, quotes), the shell command was interpreting them as commands instead
of literal text, causing syntax errors.

Changed both postPRReview and postPRComment handlers to write the body content
to temporary files and use gh CLI's --body-file flag instead of --body with
inline content. This safely handles ALL special characters without escaping issues.

Fixes shell errors when posting reviews with suggested fixes containing code snippets.

* fix(i18n): add missing GitHub PRs translation and document i18n requirements

Fixed missing translation key for GitHub PRs feature that was causing
"items.githubPRs" to display instead of the proper translated text.

Added comprehensive i18n guidelines to CLAUDE.md to ensure all future
frontend development follows the translation key pattern instead of
using hardcoded strings.

Also fixed missing deletePRReview mock function in browser-mock.ts
to resolve TypeScript compilation errors.

Changes:
- Added githubPRs translation to en/navigation.json
- Added githubPRs translation to fr/navigation.json
- Added Development Guidelines section to CLAUDE.md with i18n requirements
- Documented translation file locations and namespace usage patterns
- Added deletePRReview mock function to browser-mock.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix ui loading

* Github PR fixes

* improve claude.md

* lints/tests

* fix(github): handle PRs exceeding GitHub's 20K line diff limit

- Add PRTooLargeError exception for large PR detection
- Update pr_diff() to catch and raise PRTooLargeError for HTTP 406 errors
- Gracefully handle large PRs by skipping full diff and using individual file patches
- Add diff_truncated flag to PRContext to track when diff was skipped
- Large PRs will now review successfully using per-file diffs instead of failing

Fixes issue with PR #252 which has 100+ files exceeding the 20,000 line limit.

* fix: implement individual file patch fetching for large PRs

The PR review was getting stuck for large PRs (>20K lines) because when we
skipped the full diff due to GitHub API limits, we had no code to analyze.
The individual file patches were also empty, leaving the AI with just
file names and metadata.

Changes:
- Implemented _get_file_patch() to fetch individual patches via git diff
- Updated PR review engine to build composite diff from file patches when
  diff_truncated is True
- Added missing 'state' field to PRContext dataclass
- Limits composite diff to first 50 files for very large PRs
- Shows appropriate warnings when using reconstructed diffs

This allows AI review to proceed with actual code analysis even when the
full PR diff exceeds GitHub's limits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* 1min reduction

* docs: add GitHub Sponsors funding configuration

Enable the Sponsor button on the repository by adding FUNDING.yml
with the AndyMik90 GitHub Sponsors profile.

* feat(github-pr): add orchestrating agent for thorough PR reviews

Implement a new Opus 4.5 orchestrating agent that performs comprehensive
PR reviews regardless of size. Key changes:

- Add orchestrator_reviewer.py with strategic review workflow
- Add review_tools.py with subagent spawning capabilities
- Add pr_orchestrator.md prompt emphasizing thorough analysis
- Add pr_security_agent.md and pr_quality_agent.md subagent prompts
- Integrate orchestrator into pr_review_engine.py with config flag
- Fix critical bug where findings were extracted but not processed
  (indentation issue in _parse_orchestrator_output)

The orchestrator now correctly identifies issues in PRs that were
previously approved as "trivial". Testing showed 7 findings detected
vs 0 before the fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* i18n

* fix(github-pr): restrict pr_reviewer to read-only permissions

The PR review agent was using qa_reviewer agent type which has Bash
access, allowing it to checkout branches and make changes during
review. Created new pr_reviewer agent type with BASE_READ_TOOLS only
(no Bash, no writes, no auto-claude tools).

This prevents the PR review from accidentally modifying code or
switching branches during analysis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-pr): robust category mapping and JSON parsing for PR review

The orchestrator PR review was failing to extract findings because:

1. AI generates category names like 'correctness', 'consistency', 'testing'
   that aren't in our ReviewCategory enum - added flexible mapping

2. JSON sometimes embedded in markdown code blocks (```json) which broke
   parsing - added code block extraction as first parsing attempt

Changes:
- Add _CATEGORY_MAPPING dict to map AI categories to valid enum values
- Add _map_category() helper function with fallback to QUALITY
- Add severity parsing with fallback to MEDIUM
- Add markdown code block detection (```json) before raw JSON parsing
- Add _extract_findings_from_data() helper to reduce code duplication
- Apply same fixes to review_tools.py for subagent parsing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): improve post findings UX with batch support and feedback

- Fix post findings failing on own PRs by falling back from REQUEST_CHANGES
  to COMMENT when GitHub returns 422 error
- Change status badge to show "Reviewed" instead of "Commented" until
  findings are actually posted to GitHub
- Add success notification when findings are posted (auto-dismisses after 3s)
- Add batch posting support: track posted findings, show "Posted" badge,
  allow posting remaining findings in additional batches
- Show loading state on button while posting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github): resolve stale timestamp and null author bugs

- Fix stale timestamp in batch_issues.py: Move updated_at assignment
  BEFORE to_dict() serialization so the saved JSON contains the correct
  timestamp instead of the old value

- Fix AttributeError in context_gatherer.py: Handle null author/user
  fields when GitHub API returns null for deleted/suspended users
  instead of an empty object

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address all high and medium severity PR review findings

HIGH severity fixes:
- Command Injection in autofix-handlers.ts: Use execFileSync with args array
- Command Injection in pr-handlers.ts (3 locations): Use execFileSync + validation
- Command Injection in triage-handlers.ts: Use execFileSync + label validation
- Token Exposure in bot_detection.py: Pass token via GH_TOKEN env var

MEDIUM severity fixes:
- Environment variable leakage in subprocess-runner.ts: Filter to safe vars only
- Debug logging in subprocess-runner.ts: Only log in development mode
- Delimiter escape bypass in sanitize.py: Use regex pattern for variations
- Insecure file permissions in trust.py: Use os.open with 0o600 mode
- No file locking in learning.py: Use FileLock + atomic_write utilities
- Bare except in confidence.py: Log error with specific exception info
- Fragile module import in pr_review_engine.py: Import at module level
- State transition validation in models.py: Enforce can_transition_to()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* PR followup

* fix(security): add usedforsecurity=False to MD5 hash calls

MD5 is used for generating unique IDs/cache keys, not for security purposes.
Adding usedforsecurity=False resolves Bandit B324 warnings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address all high-priority PR review findings

Fixes 5 high-priority issues from Auto Claude PR Review:

1. orchestrator_reviewer.py: Token budget tracking now increments
   total_tokens from API response usage data

2. pr_review_engine.py: Async exceptions now re-raise RuntimeError
   instead of silently returning empty results

3. batch_issues.py: IssueBatch.save() now uses locked_json_write
   for atomic file operations with file locking

4. project-middleware.ts: Added validateProjectPath() to prevent
   path traversal attacks (checks absolute, no .., exists, is dir)

5. orchestrator.py: Exception handling now logs full traceback and
   preserves exception type/context in error messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address all high-priority PR review findings

Fixes 5 high-priority issues from Auto Claude PR Review:

1. orchestrator_reviewer.py: Token budget tracking now increments
   total_tokens from API response usage data

2. pr_review_engine.py: Async exceptions now re-raise RuntimeError
   instead of silently returning empty results

3. batch_issues.py: IssueBatch.save() now uses locked_json_write
   for atomic file operations with file locking

4. project-middleware.ts: Added validateProjectPath() to prevent
   path traversal attacks (checks absolute, no .., exists, is dir)

5. orchestrator.py: Exception handling now logs full traceback and
   preserves exception type/context in error messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ui): add PR status labels to list view

Add secondary status badges to the PR list showing review state at a glance:
- "Changes Requested" (warning) - PRs with blocking issues (critical/high)
- "Ready to Merge" (green) - PRs with only non-blocking suggestions
- "Ready for Follow-up" (blue) - PRs with new commits since last review

The "Ready for Follow-up" badge uses a cached new commits check from the
store, only shown after the detail view confirms new commits via SHA
comparison. This prevents false positives from PR updatedAt timestamp
changes (which can happen from comments, labels, etc).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* PR labels

* auto-claude: Initialize subtask-based implementation plan

- Workflow type: feature
- Phases: 3
- Subtasks: 6
- Ready for autonomous implementation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:29:04 +01:00
Alex d42041c5b5 ci: implement enterprise-grade PR quality gates and security scanning (#266)
* ci: implement enterprise-grade PR quality gates and security scanning

* ci: implement enterprise-grade PR quality gates and security scanning

* fix:pr comments and improve code

* fix: improve commit linting and code quality

* Removed the dependency-review job (i added it)

* fix: address CodeRabbit review comments

- Expand scope pattern to allow uppercase, underscores, slashes, dots
- Add concurrency control to cancel duplicate security scan runs
- Add explanatory comment for Bandit CLI flags
- Remove dependency-review job (requires repo settings)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update commit lint examples with expanded scope patterns

Show slashes and dots in scope examples to demonstrate
the newly allowed characters (api/users, package.json)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove feature request issue template

Feature requests are directed to GitHub Discussions
via the issue template config.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address security vulnerabilities in service orchestrator

- Fix port parsing crash on malformed docker-compose entries
- Fix shell injection risk by using shlex.split() with shell=False

Prevents crashes when docker-compose.yml contains environment
variables in port mappings (e.g., '${PORT}:8080') and eliminates
shell injection vulnerabilities in subprocess execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:51:55 +01:00
delyethan a3f87540c6 fix: update path resolution for ollama_model_detector.py in memory handlers (#263) 2025-12-25 09:54:31 +01:00
Mitsu f843811292 feat: add i18n internationalization system (#248)
* Add multilingual support and i18n integration

- Implemented i18n framework using `react-i18next` for translation management.
- Added support for English and French languages with translation files.
- Integrated language selector into settings.
- Updated all text strings in UI components to use translation keys.
- Ensured smooth language switching with live updates.

* Migrate remaining hard-coded strings to i18n system

- TaskCard: status labels, review reasons, badges, action buttons
- PhaseProgressIndicator: execution phases, progress labels
- KanbanBoard: drop zone, show archived, tooltips
- CustomModelModal: dialog title, description, labels
- ProactiveSwapListener: account switch notifications
- AgentProfileSelector: phase labels, custom configuration
- GeneralSettings: agent framework option

Added translation keys for en/fr locales in tasks.json, common.json,
and settings.json for complete i18n coverage.

* Add i18n support to dialogs and settings components

- AddFeatureDialog: form labels, validation messages, buttons
- AddProjectModal: dialog steps, form fields, actions
- RateLimitIndicator: rate limit notifications
- RateLimitModal: account switching, upgrade prompts
- AdvancedSettings: updates and notifications sections
- ThemeSettings: theme selection labels
- Updated dialogs.json locales (en/fr)

* Fix truncated 'ready' message in dialogs locales

* Fix backlog terminology in i18n locales

Change "Planning"/"Planification" to standard PM term "Backlog"

* Migrate settings navigation and integration labels to i18n

- AppSettings: nav items, section titles, buttons
- IntegrationSettings: Claude accounts, auto-switch, API keys labels
- Added settings nav/projectSections/integrations translation keys
- Added buttons.saving to common translations

* Migrate AgentProfileSettings and Sidebar init dialog to i18n

- AgentProfileSettings: migrate phase config labels, section title,
  description, and all hardcoded strings to settings namespace
- Sidebar: migrate init dialog strings to dialogs namespace with
  common buttons from common namespace
- Add new translation keys for agent profile settings and update dialog

* Migrate AppSettings navigation labels to i18n

- Add useTranslation hook to AppSettings.tsx
- Replace hardcoded section labels with dynamic translations
- Add projectSections translations for project settings nav
- Add rerunWizardDescription translation key

* Add explicit typing to notificationItems array

Import NotificationSettings type and use keyof to properly type
the notification item keys, removing manual type assertion.
2025-12-24 17:37:31 +01:00
Andy 5e8c53080f Revert "Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)" (#251)
This reverts commit 348de6dfe7.
2025-12-24 17:02:47 +01:00
Andy 348de6dfe7 Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)
* feat(github): add GitHub automation system for issues and PRs

Implements comprehensive GitHub automation with three major components:

1. Issue Auto-Fix: Automatically creates specs from labeled issues
   - AutoFixButton component with progress tracking
   - useAutoFix hook for config and queue management
   - Backend handlers for spec creation from issues

2. GitHub PRs Tool: AI-powered PR review sidebar
   - New sidebar tab (Cmd+Shift+P) alongside GitHub Issues
   - PRList/PRDetail components for viewing PRs
   - Review system with findings by severity
   - Post review comments to GitHub

3. Issue Triage: Duplicate/spam/feature-creep detection
   - Triage handlers with label application
   - Configurable detection thresholds

Also adds:
- Debug logging (DEBUG=true) for all GitHub handlers
- Backend runners/github module with orchestrator
- AI prompts for PR review, triage, duplicate/spam detection
- dev:debug npm script for development with logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-runner): resolve import errors for direct script execution

Changes runner.py and orchestrator.py to handle both:
- Package import: `from runners.github import ...`
- Direct script: `python runners/github/runner.py`

Uses try/except pattern for relative vs direct imports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github): correct argparse argument order for runner.py

Move --project global argument before subcommand so argparse can
correctly parse it. Fixes "unrecognized arguments: --project" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* logs when debug mode is on

* refactor(github): extract service layer and fix linting errors

Major refactoring to improve maintainability and code quality:

Backend (Python):
- Extracted orchestrator.py (2,600 → 835 lines, 68% reduction) into 7 service modules:
  - prompt_manager.py: Prompt template management
  - response_parsers.py: AI response parsing
  - pr_review_engine.py: PR review orchestration
  - triage_engine.py: Issue triage logic
  - autofix_processor.py: Auto-fix workflow
  - batch_processor.py: Batch issue handling
- Fixed 18 ruff linting errors (F401, C405, C414, E741):
  - Removed unused imports (BatchValidationResult, AuditAction, locked_json_write)
  - Optimized collection literals (set([n]) → {n})
  - Removed unnecessary list() calls
  - Renamed ambiguous variable 'l' to 'label' throughout

Frontend (TypeScript):
- Refactored IPC handlers (19% overall reduction) with shared utilities:
  - autofix-handlers.ts: 1,042 → 818 lines
  - pr-handlers.ts: 648 → 543 lines
  - triage-handlers.ts: 437 lines (no duplication)
- Created utils layer: logger, ipc-communicator, project-middleware, subprocess-runner
- Split github-store.ts into focused stores: issues, pr-review, investigation, sync-status
- Split ReviewFindings.tsx into focused components

All imports verified, type checks passing, linting clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:43:20 +01:00
HSSAINI Saad 0f7d6e0530 fix: resolve Python detection and backend packaging issues (#241)
* fix: resolve Python detection and backend packaging issues

- Fix backend packaging path (auto-claude -> backend) to match path-resolver.ts expectations
- Add future annotations import to config_parser.py for Python 3.9+ compatibility
- Use findPythonCommand() in project-context-handlers to prioritize Homebrew Python
- Improve Python detection to prefer Homebrew paths over system Python on macOS

This resolves the following issues:
- 'analyzer.py not found' error due to incorrect packaging destination
- TypeError with 'dict | None' syntax on Python < 3.10
- Wrong Python interpreter being used (system Python instead of Homebrew Python 3.10+)

Tested on macOS with packaged app - project index now loads successfully.

* refactor: address PR review feedback

- Extract findHomebrewPython() helper to eliminate code duplication between
  findPythonCommand() and getDefaultPythonCommand()
- Remove hardcoded version-specific paths (python3.12) and rely only on
  generic Homebrew symlinks for better maintainability
- Remove unnecessary 'from __future__ import annotations' from config_parser.py
  since backend requires Python 3.12+ where union types are native

These changes make the code more maintainable, less fragile to Python version
changes, and properly reflect the project's Python 3.12+ requirement.
2025-12-24 14:03:49 +01:00
Joris Slagter 5ccdb6abc5 fix: add future annotations import to discovery.py (#229)
Adds 'from __future__ import annotations' to spec/discovery.py for
Python 3.9+ compatibility with type hints.

This completes the Python compatibility fixes that were partially
applied in previous commits. All 26 analysis and spec Python files
now have the future annotations import.

Related: #128

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
2025-12-24 07:12:10 +01:00
souky-byte 6ec8549f63 Fix/ideation status sync (#212)
* fix(ideation): add missing event forwarders for status sync

- Add event forwarders in ideation-handlers.ts for progress, log,
  type-complete, type-failed, complete, error, and stopped events
- Fix ideation-type-complete to load actual ideas array from JSON files
  instead of emitting only the count

Resolves UI getting stuck at 0/3 complete during ideation generation.

* fix(ideation): fix UI not updating after actions

- Fix getIdeationSummary to count only active ideas (exclude dismissed/archived)
  This ensures header stats match the visible ideas count
- Add transformSessionFromSnakeCase to properly transform session data
  from backend snake_case to frontend camelCase on ideation-complete event
- Transform raw session before emitting ideation-complete event

Resolves header showing stale counts after dismissing/deleting ideas.

* fix(ideation): improve type safety and async handling in ideation type completion

- Replace synchronous readFileSync with async fsPromises.readFile in ideation-type-complete handler
- Wrap async file read in IIFE with proper error handling to prevent unhandled promise rejections
- Add type validation for IdeationType with VALID_IDEATION_TYPES set and isValidIdeationType guard
- Add validateEnabledTypes function to filter out invalid type values and log dropped entries
- Handle ENOENT separately

* fix(ideation): improve generation state management and error handling

- Add explicit isGenerating flag to prevent race conditions during async operations
- Implement 5-minute timeout for generation with automatic cleanup and error state
- Add ideation-stopped event emission when process is intentionally killed
- Replace console.warn/error with proper ideation-error events in agent-queue
- Add resetGeneratingTypes helper to transition all generating types to a target state
- Filter out dismissed/

* refactor(ideation): improve event listener cleanup and timeout management

- Extract event handler functions in ideation-handlers.ts to enable proper cleanup
- Return cleanup function from registerIdeationHandlers to remove all listeners
- Replace single generationTimeoutId with Map to support multiple concurrent projects
- Add clearGenerationTimeout helper to centralize timeout cleanup logic
- Extract loadIdeationType IIFE to named function for better error context
- Enhance error logging with projectId,

* refactor: use async file read for ideation and roadmap session loading

- Replace synchronous readFileSync with async fsPromises.readFile
- Prevents blocking the event loop during file operations
- Consistent with async pattern used elsewhere in the codebase
- Improved error handling with proper event emission

* fix(agent-queue): improve roadmap completion handling and error reporting

- Add transformRoadmapFromSnakeCase to convert backend snake_case to frontend camelCase
- Transform raw roadmap data before emitting roadmap-complete event
- Add roadmap-error emission for unexpected errors during completion
- Add roadmap-error emission when project path is unavailable
- Remove duplicate ideation-type-complete emission from error handler (event already emitted in loadIdeationType)
- Update error log message
2025-12-23 18:02:45 +01:00
Andy 53527293cd fix(core): add global spec numbering lock to prevent collisions (#209)
Implements distributed file-based locking for spec number coordination
across main project and all worktrees. Previously, parallel spec creation
could assign the same number to different specs (e.g., 042-bmad-task and
042-gitlab-integration both using number 042).

The fix adds SpecNumberLock class that:
- Acquires exclusive lock before calculating spec numbers
- Scans ALL locations (main project + worktrees) for global maximum
- Creates spec directories atomically within the lock
- Handles stale locks via PID-based detection with 30s timeout

Applied to both Python backend (spec_runner.py flow) and TypeScript
frontend (ideation conversion, GitHub/GitLab issue import).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 18:00:55 +01:00
Fernando Possebon 02bef954f3 feat: Add OpenRouter as LLM/embedding provider (#162)
* feat: Add OpenRouter as LLM/embedding provider

Add OpenRouter provider support for Graphiti memory integration,
enabling access to multiple LLM providers through a single API.

Changes:
Backend:
- Created openrouter_llm.py: OpenRouter LLM provider using OpenAI-compatible API
- Created openrouter_embedder.py: OpenRouter embedder provider
- Updated config.py: Added OpenRouter to provider enums and configuration
  - New fields: openrouter_api_key, openrouter_base_url, openrouter_llm_model, openrouter_embedding_model
  - Validation methods updated for OpenRouter
- Updated factory.py: Added OpenRouter to LLM and embedder factories
- Updated provider __init__.py files: Exported new OpenRouter functions

Frontend:
- Updated project.ts types: Added 'openrouter' to provider type unions
  - GraphitiProviderConfig extended with OpenRouter fields
- Updated GraphitiStep.tsx: Added OpenRouter to provider arrays
  - LLM_PROVIDERS: 'Multi-provider aggregator'
  - EMBEDDING_PROVIDERS: 'OpenAI-compatible embeddings'
  - Added OpenRouter API key input field with show/hide toggle
  - Link to https://openrouter.ai/keys
- Updated env-handlers.ts: OpenRouter .env generation and parsing
  - Template generation for OPENROUTER_* variables
  - Parsing from .env files with proper type casting

Documentation:
- Updated .env.example with OpenRouter section
  - Configuration examples
  - Popular model recommendations
  - Example configuration (#6)

Fixes #92

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: address CodeRabbit review comments for OpenRouter

- Add globalOpenRouterApiKey to settings types and store updates
- Initialize openrouterApiKey from global settings
- Update documentation to include OpenRouter in provider lists
- Add OpenRouter handling to get_embedding_dimension() method
- Add openrouter to provider cleanup list
- Add OpenRouter to get_available_providers() function
- Clarify Legacy comment for openrouterLlmModel

These changes complete the OpenRouter integration by ensuring proper
settings persistence and provider detection across the application.

* fix: apply ruff formatting to OpenRouter code

- Break long error message across multiple lines
- Format provider list with one item per line
- Fixes lint CI failure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 17:58:55 +01:00
Fernando Possebon f168bdc3ac fix: Add Python 3.10+ version validation and GitHub Actions Python setup (#180 #167) (#208)
This fixes critical bug where macOS users with default Python 3.9.6 couldn't use Auto-Claude because claude-agent-sdk requires Python 3.10+.

Root Cause:
- Auto-Claude doesn't bundle Python, relies on system Python
- python-detector.ts accepted any Python 3.x without checking minimum version
- macOS ships with Python 3.9.6 by default (incompatible)
- GitHub Actions runners didn't explicitly set Python version

Changes:
1. python-detector.ts:
   - Added getPythonVersion() to extract version from command
   - Added validatePythonVersion() to check if >= 3.10.0
   - Updated findPythonCommand() to skip Python < 3.10 with clear error messages

2. python-env-manager.ts:
   - Import and use findPythonCommand() (already has version validation)
   - Simplified findSystemPython() to use shared validation logic
   - Updated error message from "Python 3.9+" to "Python 3.10+" with download link

3. .github/workflows/release.yml:
   - Added Python 3.11 setup to all 4 build jobs (macOS Intel, macOS ARM64, Windows, Linux)
   - Ensures consistent Python version across all platforms during build

Impact:
- macOS users with Python 3.9 now see clear error with download link
- macOS users with Python 3.10+ work normally
- CI/CD builds use consistent Python 3.11
- Prevents "ModuleNotFoundError: dotenv" and dependency install failures

Fixes #180, #167

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 16:34:02 +01:00
Andy e3eec68aab fix(ci): correct welcome workflow PR message (#206)
- Change branch reference from main to develop
- Fix contribution guide link to use full URL
- Remove hyphen from "Auto Claude" in welcome message
2025-12-23 15:58:59 +01:00
Andy 407a0bee5e Feat/beta release (#193)
* chore: update README version to 2.7.1

Updated the version badge and download links in the README to reflect the new release version 2.7.1, ensuring users have the correct information for downloading the latest builds.

* feat(releases): add beta release system with user opt-in

Implements a complete beta release workflow that allows users to opt-in
to receiving pre-release versions. This enables testing new features
before they're included in stable releases.

Changes:
- Add beta-release.yml workflow for creating beta releases from develop
- Add betaUpdates setting with UI toggle in Settings > Updates
- Add update channel support to electron-updater (beta vs latest)
- Extract shared settings-utils.ts to reduce code duplication
- Add prepare-release.yml workflow for automated release preparation
- Document beta release process in CONTRIBUTING.md and RELEASE.md

Users can enable beta updates in Settings > Updates, and maintainers
can trigger beta releases via the GitHub Actions workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* workflow update

* ci(github): update Discord link and redirect feature requests to discussions

Update Discord invite link to correct URL (QhRnz9m5HE) across all GitHub
templates and workflows. Redirect feature requests from issue template
to GitHub Discussions for better community engagement.

Changes:
- config.yml: Add feature request link to Discussions, fix Discord URL
- question.yml: Update Discord link in pre-question guidance
- welcome.yml: Update Discord link in first-time contributor message

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 15:20:09 +01:00
Andy 8f766ad16e feat/beta-release (#190)
* chore: update README version to 2.7.1

Updated the version badge and download links in the README to reflect the new release version 2.7.1, ensuring users have the correct information for downloading the latest builds.

* feat(releases): add beta release system with user opt-in

Implements a complete beta release workflow that allows users to opt-in
to receiving pre-release versions. This enables testing new features
before they're included in stable releases.

Changes:
- Add beta-release.yml workflow for creating beta releases from develop
- Add betaUpdates setting with UI toggle in Settings > Updates
- Add update channel support to electron-updater (beta vs latest)
- Extract shared settings-utils.ts to reduce code duplication
- Add prepare-release.yml workflow for automated release preparation
- Document beta release process in CONTRIBUTING.md and RELEASE.md

Users can enable beta updates in Settings > Updates, and maintainers
can trigger beta releases via the GitHub Actions workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* workflow update

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:28:09 +01:00
Andy ced2ad479f fix/PRs from old main setup to apps structure (#185)
* fix(core): add task persistence, terminal handling, and HTTP 300 fixes

Consolidated bug fixes from PRs #168, #170, #171:

- Task persistence (#168): Scan worktrees for tasks on app restart
  to prevent loss of in-progress work and wasted API credits. Tasks
  in .worktrees/*/specs are now loaded and deduplicated with main.

- Terminal buttons (#170): Fix "Open Terminal" buttons silently
  failing on macOS by properly awaiting createTerminal() Promise.
  Added useTerminalHandler hook with loading states and error display.

- HTTP 300 errors (#171): Handle branch/tag name collisions that
  cause update failures. Added validation script to prevent conflicts
  before releases and user-friendly error messages with manual
  download links.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(platform): add path resolution, spaces handling, and XDG support

This commit consolidates multiple bug fixes from community PRs:

- PR #187: Path resolution fix - Update path detection to find apps/backend
  instead of legacy auto-claude directory after v2.7.2 restructure

- PR #182/#155: Python path spaces fix - Improve parsePythonCommand() to
  handle quoted paths and paths containing spaces without splitting

- PR #161: Ollama detection fix - Add new apps structure paths for
  ollama_model_detector.py script discovery

- PR #160: AppImage support - Add XDG Base Directory compliant paths for
  Linux sandboxed environments (AppImage, Flatpak, Snap). New files:
  - config-paths.ts: XDG path utilities
  - fs-utils.ts: Filesystem utilities with fallback support

- PR #159: gh CLI PATH fix - Add getAugmentedEnv() utility to include
  common binary locations (Homebrew, snap, local) in PATH for child
  processes. Fixes gh CLI not found when app launched from Finder/Dock.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit/Cursor review comments on PR #185

Fixes from code review:
- http-client.ts: Use GITHUB_CONFIG instead of hardcoded owner in HTTP 300 error message
- validate-release.js: Fix substring matching bug in branch detection that could cause false positives (e.g., v2.7 matching v2.7.2)
- bump-version.js: Remove unnecessary try-catch wrapper (exec() already exits on failure)
- execution-handlers.ts: Capture original subtask status before mutation for accurate logging
- fs-utils.ts: Add error handling to safeWriteFile with proper logging

Dismissed as trivial/not applicable:
- config-paths.ts: Exhaustive switch check (over-engineering)
- env-utils.ts: PATH priority documentation (existing comments sufficient)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address additional CodeRabbit review comments (round 2)

Fixes from second round of code review:
- fs-utils.ts: Wrap test file cleanup in try-catch for Windows file locking
- fs-utils.ts: Add error handling to safeReadFile for consistency with safeWriteFile
- http-client.ts: Use GITHUB_CONFIG in fetchJson (missed in first round)
- validate-release.js: Exclude symbolic refs (origin/HEAD -> origin/main) from branch check
- python-detector.ts: Return cleanPath instead of pythonPath for empty input edge case

Dismissed as trivial/not applicable:
- execution-handlers.ts: Redundant checkSubtasksCompletion call (micro-optimization)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:33:11 +01:00
Andy 05f5d3038b fix: hide status badge when execution phase badge is showing (#154)
* fix: analyzer Python compatibility and settings integration

Fixes project index analyzer failing with TypeError on Python type hints.

Changes:
- Added 'from __future__ import annotations' to all analysis modules
- Fixed project discovery to support new analyzer JSON format
- Read Python path directly from settings.json instead of pythonEnvManager
- Added stderr/stdout logging for analyzer debugging

Resolves 'Discovered 0 files' and 'TypeError: unsupported operand type' issues.

* auto-claude: subtask-1-1 - Hide status badge when execution phase badge is showing

When a task has an active execution (planning, coding, etc.), the
execution phase badge already displays the correct state with a spinner.
The status badge was also rendering, causing duplicate/confusing badges
(e.g., both "Planning" and "Pending" showing at the same time).

This fix wraps the status badge in a conditional that only renders when
there's no active execution, eliminating the redundant badge display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ipc): remove unused pythonEnvManager parameter and fix ES6 import

Address CodeRabbit review feedback:
- Remove unused pythonEnvManager parameter from registerProjectContextHandlers
  and registerContextHandlers (the code reads Python path directly from
  settings.json instead)
- Replace require('electron').app with proper ES6 import for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(lint): fix import sorting in analysis module

Run ruff --fix to resolve I001 lint errors after merging develop.
All 23 files in apps/backend/analysis/ now have properly sorted imports.

---------

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:31:44 +01:00
Enes Cingöz 6951251b33 feat: Add UI scale feature with 75-200% range (#125)
* feat: add UI scale feature

* refactor: extract UI scale bounds to shared constants

* fix: duplicated import
2025-12-23 12:30:32 +01:00
AndyMik90 30e7536b63 fix(task): stop running process when task status changes away from in_progress
When a user drags a running task back to Planning (or any other column),
the process was not being stopped, leaving a "ghost" process that
prevented deletion with "Cannot delete a running task" error.

Now the task process is automatically killed when status changes away
from in_progress, ensuring the process state stays in sync with the UI.
2025-12-23 00:11:48 +01:00
Andy 220faf0fb4 Fix/linear 400 error
* fix: Linear API authentication and GraphQL types

- Remove Bearer prefix from Authorization header (Linear API keys are sent directly)
- Change GraphQL variable types from String! to ID! for teamId and issue IDs
- Improve error handling to show detailed Linear API error messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Radix Select empty value error in Linear import modal

Use '__all__' sentinel value instead of empty string for "All projects"
option, as Radix Select does not allow empty string values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add CodeRabbit configuration file

Introduce a new .coderabbit.yaml file to configure CodeRabbit settings, including review profiles, automatic review options, path filters, and specific instructions for different file types. This enhances the code review process by providing tailored guidelines for Python, TypeScript, and test files.

* fix: correct GraphQL types for Linear team queries

Linear API uses different types for different queries:
- team(id:) expects String!
- issues(filter: { team: { id: { eq: } } }) expects ID!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: refresh task list after Linear import

Call loadTasks() after successful Linear import to update the kanban
board without requiring a page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* cleanup

* cleanup

* fix: address CodeRabbit review comments for Linear integration

- Fix unsafe JSON parsing: check response.ok before parsing JSON to handle
  non-JSON error responses (e.g., 503 from proxy) gracefully
- Use ID! type instead of String! for teamId in LINEAR_GET_PROJECTS query
  for GraphQL type consistency
- Remove debug console.log (ESLint config only allows warn/error)
- Refresh task list on partial import success (imported > 0) instead of
  requiring full success
- Fix pre-existing TypeScript and lint issues blocking commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* version sync logic

* lints for develop branch

* chore: update CI workflow to include develop branch

- Modified the CI configuration to trigger on pushes and pull requests to both main and develop branches, enhancing the workflow for development and integration processes.

* fix: update project directory auto-detection for apps/backend structure

The project directory auto-detection was checking for the old `auto-claude/`
directory name but needed to check for `apps/backend/`. When running from
`apps/backend/`, the directory name is `backend` not `auto-claude`, so the
check would fail and `project_dir` would incorrectly remain as `apps/backend/`
instead of resolving to the project root (2 levels up).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use GraphQL variables instead of string interpolation in LINEAR_GET_ISSUES

Replace direct string interpolation of teamId and linearProjectId with
proper GraphQL variables. This prevents potential query syntax errors if
IDs contain special characters like double quotes, and aligns with the
variable-based approach used elsewhere in the file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): correct logging level and await loadTasks on import complete

- Change console.warn to console.log for import success messages
  (warn is incorrect severity for normal completion)
- Make onImportComplete callback async and await loadTasks()
  to prevent potential unhandled promise rejections

Applies CodeRabbit review feedback across 3 LinearTaskImportModal usages.

* fix(hooks): use POSIX-compliant find instead of bash glob

The pre-commit hook uses #!/bin/sh but had bash-specific ** glob
pattern for staging ruff-formatted files. The ** pattern only works
in bash with globstar enabled - in POSIX sh it expands literally
and won't match subdirectories, causing formatted files in nested
directories to not be staged.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 00:01:19 +01:00
Joris Slagter f96c6301f4 fix: remove legacy path from auto-claude source detection (#148)
Removes the legacy 'auto-claude' path from the possiblePaths array
in agent-process.ts. This path was from before the monorepo
restructure (v2.7.2) and is no longer needed.

The legacy path was causing spec_runner.py to be looked up at the
wrong location:
- OLD (wrong): /path/to/auto-claude/auto-claude/runners/spec_runner.py
- NEW (correct): /path/to/apps/backend/runners/spec_runner.py

This aligns with the new monorepo structure where all backend code
lives in apps/backend/.

Fixes #147

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
2025-12-22 22:59:48 +01:00
Joris Slagter ebd8340d82 fix: resolve Python environment race condition (#142)
Implemented promise queue pattern in PythonEnvManager to handle
concurrent initialization requests. Previously, multiple simultaneous
requests (e.g., startup + merge) would fail with "Already
initializing" error.

Also fixed parsePythonCommand() to handle file paths with spaces by
checking file existence before splitting on whitespace.

Changes:
- Added initializationPromise field to queue concurrent requests
- Split initialize() into public and private _doInitialize()
- Enhanced parsePythonCommand() with existsSync() check

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
2025-12-22 22:50:56 +01:00
rayBlock df779530e7 Feat: Ollama download progress tracking with new apps structure (#141)
* feat(ollama): add real-time download progress tracking for model downloads

Implement comprehensive download progress tracking with:
- NDJSON parsing for streaming progress data from Ollama API
- Real-time speed calculation (MB/s, KB/s, B/s) with useRef for delta tracking
- Time remaining estimation based on download speed
- Animated progress bars in OllamaModelSelector component
- IPC event streaming from main process to renderer
- Proper listener management with cleanup functions

Changes:
- memory-handlers.ts: Parse NDJSON from Ollama stderr, emit progress events
- OllamaModelSelector.tsx: Display progress bars with speed and time remaining
- project-api.ts: Implement onDownloadProgress listener with cleanup
- ipc.ts types: Define onDownloadProgress listener interface
- infrastructure-mock.ts: Add mock implementation for browser testing

This allows users to see real-time feedback when downloading Ollama models,
including percentage complete, current download speed, and estimated time remaining.

* test: add focused test coverage for Ollama download progress feature

Add unit tests for the critical paths of the real-time download progress tracking:

- Progress calculation tests (52 tests): Speed/time/percentage calculations with comprehensive edge case coverage (zero speeds, NaN, Infinity, large numbers)
- NDJSON parser tests (33 tests): Streaming JSON parsing from Ollama, buffer management for incomplete lines, error handling

All 562 unit tests passing with clean dependencies. Tests focus on critical mathematical logic and data processing - the most important paths that need verification.

Test coverage:
 Speed calculation and formatting (B/s, KB/s, MB/s)
 Time remaining calculations (seconds, minutes, hours)
 Percentage clamping (0-100%)
 NDJSON streaming with partial line buffering
 Invalid JSON handling
 Real Ollama API responses
 Multi-chunk streaming scenarios

* docs: add comprehensive JSDoc docstrings for Ollama download progress feature

- Enhanced OllamaModelSelector component with detailed JSDoc
  * Documented component props, behavior, and usage examples
  * Added docstrings to internal functions (checkInstalledModels, handleDownload, handleSelect)
  * Explained progress tracking algorithm and useRef usage

- Improved memory-handlers.ts documentation
  * Added docstring to main registerMemoryHandlers function
  * Documented all Ollama-related IPC handlers (check-status, list-embedding-models, pull-model)
  * Added JSDoc to executeOllamaDetector helper function
  * Documented interface types (OllamaStatus, OllamaModel, OllamaEmbeddingModel, OllamaPullResult)
  * Explained NDJSON parsing and progress event structure

- Enhanced test file documentation
  * Added docstrings to NDJSON parser test utilities with algorithm explanation
  * Documented all calculation functions (speed, time, percentage)
  * Added detailed comments on formatting and bounds-checking logic

- Improved overall code maintainability
  * Docstring coverage now meets 80%+ threshold for code review
  * Clear explanation of progress tracking implementation details
  * Better context for future maintainers working with download streaming

* feat: add batch task creation and management CLI commands

- Handle batch task creation from JSON files
- Show status of all specs in project
- Cleanup tool for completed specs
- Full integration with new apps/backend structure
- Compatible with implementation_plan.json workflow

* test: add batch task test file and testing checklist

- batch_test.json: Sample tasks for testing batch creation
- TESTING_CHECKLIST.md: Comprehensive testing guide for Ollama and batch tasks
- Includes UI testing steps, CLI testing steps, and edge cases
- Ready for manual and automated testing

* chore: update package-lock.json to match v2.7.2

* test: update checklist with verification results and architecture validation

* docs: add comprehensive implementation summary for Ollama + Batch features

* docs: add comprehensive Phase 2 testing guide with checklists and procedures

* docs: add NEXT_STEPS guide for Phase 2 testing

* fix: resolve merge conflict in project-api.ts from Ollama feature cherry-pick

* fix: remove duplicate Ollama check status handler registration

* test: update checklist with Phase 2 bug findings and fixes

---------

Co-authored-by: ray <ray@rays-MacBook-Pro.local>
2025-12-22 22:40:20 +01:00
Andy 0adaddacaa Feature/apps restructure v2.7.2 (#138)
* refactor: restructure project to Apps/frontend and Apps/backend

- Move auto-claude-ui to Apps/frontend with feature-based architecture
- Move auto-claude to Apps/backend
- Switch from pnpm to npm for frontend
- Update Node.js requirement to v24.12.0 LTS
- Add pre-commit hooks for lint, typecheck, and security audit
- Add commit-msg hook for conventional commits
- Fix CommonJS compatibility issues (postcss.config, postinstall scripts)
- Update README with comprehensive setup and contribution guidelines
- Configure ESLint to ignore .cjs files
- 0 npm vulnerabilities

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat(refactor): clean code and move to npm

* feat(refactor): clean code and move to npm

* chore: update to v2.7.0, remove Docker deps (LadybugDB is embedded)

* feat: v2.8.0 - update workflows and configs for Apps/ structure, npm

* fix: resolve Python lint errors (F401, I001)

* fix: update test paths for Apps/backend structure

* fix: add missing facade files and update paths for Apps/backend structure

- Fix ruff lint error I001 in auto_claude_tools.py
- Create missing facade files to match upstream (agent, ci_discovery, critique, etc.)
- Update test paths from auto-claude/ to Apps/backend/
- Update .pre-commit-config.yaml paths for Apps/ structure
- Add pytest to pre-commit hooks (skip slow/integration/Windows-incompatible tests)
- Fix Unicode encoding in test_agent_architecture.py for Windows

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat: improve readme

* fix: new path

* fix: correct release workflow and docs for Apps/ restructure

- Fix ARM64 macOS build: pnpm → npm, auto-claude-ui → Apps/frontend
- Fix artifact upload paths in release.yml
- Update Node.js version to 24 for consistency
- Update CLI-USAGE.md with Apps/backend paths
- Update RELEASE.md with Apps/frontend/package.json paths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: rename Apps/ to apps/ and fix backend path resolution

- Rename Apps/ folder to apps/ for consistency with JS/Node conventions
- Update all path references across CI/CD workflows, docs, and config files
- Fix frontend Python path resolver to look for 'backend' instead of 'auto-claude'
- Update path-resolver.ts to correctly find apps/backend in development mode

This completes the Apps restructure from PR #122 and prepares for v2.8.0 release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(electron): correct preload script path from .js to .mjs

electron-vite builds the preload script as ESM (index.mjs) but the main
process was looking for CommonJS (index.js). This caused the preload to
fail silently, making the app fall back to browser mock mode with fake
data and non-functional IPC handlers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* - Introduced `dev:debug` script to enable debugging during development.
- Added `dev:mcp` script for running the frontend in MCP mode.

These enhancements streamline the development process for frontend developers.

* refactor(memory): make Graphiti memory mandatory and remove Docker dependency

Memory is now a core component of Auto Claude rather than optional:
- Python 3.12+ is required for the backend (not just memory layer)
- Graphiti is enabled by default in .env.example
- Removed all FalkorDB/Docker references (migrated to embedded LadybugDB)
- Deleted guides/DOCKER-SETUP.md and docker-handlers.ts
- Updated onboarding UI to remove "optional" language
- Updated all documentation to reflect LadybugDB architecture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add cross-platform Windows support for npm scripts

- Add scripts/install-backend.js for cross-platform Python venv setup
  - Auto-detects Python 3.12 (py -3.12 on Windows, python3.12 on Unix)
  - Handles platform-specific venv paths
- Add scripts/test-backend.js for cross-platform pytest execution
- Update package.json to use Node.js scripts instead of shell commands
- Update CONTRIBUTING.md with correct paths and instructions:
  - apps/backend/ and apps/frontend/ paths
  - Python 3.12 requirement (memory system now required)
  - Platform-specific install commands (winget, brew, apt)
  - npm instead of pnpm
  - Quick Start section with npm run install:all

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* remove doc

* fix(frontend): correct Ollama detector script path after apps restructure

The Ollama status check was failing because memory-handlers.ts
was looking for ollama_model_detector.py at auto-claude/ but the
script is now at apps/backend/ after the directory restructure.

This caused "Ollama not running" to display even when Ollama was
actually running and accessible.

* chore: bump version to 2.7.2

Downgrade version from 2.8.0 to 2.7.2 as the Apps/ restructure
is better suited as a patch release rather than a minor release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: update package-lock.json for Windows compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs(contributing): add hotfix workflow and update paths for apps/ structure

Add Git Flow hotfix workflow documentation with step-by-step guide
and ASCII diagram showing the branching strategy.

Update all paths from auto-claude/auto-claude-ui to apps/backend/apps/frontend
and migrate package manager references from pnpm to npm to match the
new project structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): remove duplicate ARM64 build from Intel runner

The Intel runner was building both x64 and arm64 architectures,
while a separate ARM64 runner also builds arm64 natively. This
caused duplicate ARM64 builds, wasting CI resources.

Now each runner builds only its native architecture:
- Intel runner: x64 only
- ARM64 runner: arm64 only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Alex Madera <e.a_madera@hotmail.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 21:34:51 +01:00
AndyMik90 91f7051dda docs: Add Git Flow branching strategy to CONTRIBUTING.md
- Add comprehensive branching strategy documentation
- Explain main, develop, feature, fix, release, and hotfix branches
- Clarify that all PRs should target develop (not main)
- Add release process documentation for maintainers
- Update PR process to branch from develop
- Expand table of contents with new sections
2025-12-22 20:20:59 +01:00
422 changed files with 7999 additions and 55915 deletions
+12 -87
View File
@@ -55,12 +55,6 @@ jobs:
git push origin "v$VERSION"
echo "Created tag v$VERSION"
- name: Create tag only (dry run)
if: ${{ github.event.inputs.dry_run == 'true' }}
run: |
VERSION="${{ github.event.inputs.version }}"
echo "DRY RUN: Would create tag v$VERSION"
# Intel build on Intel runner for native compilation
build-macos-intel:
needs: create-tag
@@ -71,11 +65,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:
@@ -94,24 +83,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
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
@@ -154,7 +132,6 @@ jobs:
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
@@ -166,11 +143,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 +161,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
@@ -246,7 +210,6 @@ jobs:
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
build-windows:
needs: create-tag
@@ -257,11 +220,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 +239,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
@@ -316,7 +266,6 @@ jobs:
name: windows-builds
path: |
apps/frontend/dist/*.exe
apps/frontend/dist/*.yml
build-linux:
needs: create-tag
@@ -327,11 +276,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:
@@ -350,30 +294,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Flatpak
run: |
set -e
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
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
@@ -392,8 +319,6 @@ jobs:
path: |
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
apps/frontend/dist/*.flatpak
apps/frontend/dist/*.yml
create-release:
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
@@ -415,12 +340,12 @@ jobs:
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" \) -exec cp {} release-assets/ \;
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) -exec cp {} release-assets/ \;
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
exit 1
fi
@@ -478,7 +403,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
+1 -4
View File
@@ -6,10 +6,6 @@ on:
pull_request:
branches: [main, develop]
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
@@ -44,6 +40,7 @@ jobs:
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
if: matrix.python-version != '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
+2 -6
View File
@@ -6,10 +6,6 @@ on:
pull_request:
branches: [main, develop]
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Python linting
python:
@@ -23,12 +19,12 @@ jobs:
with:
python-version: '3.12'
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
- name: Install ruff
run: pip install ruff==0.14.10
run: pip install ruff
- name: Run ruff check
run: ruff check apps/backend/ --output-format=github
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
+15 -11
View File
@@ -2,7 +2,7 @@ name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security]
workflows: [CI, Lint, Quality Security, Quality DCO, Quality Commit Lint]
types: [completed]
permissions:
@@ -31,25 +31,29 @@ jobs:
// ═══════════════════════════════════════════════════════════════════════
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
// ═══════════════════════════════════════════════════════════════════════
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}"
// Format: "{Workflow Name} / {Job Name} (pull_request)"
//
// To find check names: Go to PR → Checks tab → copy exact name
// To update: Edit this list when workflow jobs are added/renamed/removed
//
// Last validated: 2026-01-02
// Last validated: 2025-12-26
// ═══════════════════════════════════════════════════════════════════════
const requiredChecks = [
// CI workflow (ci.yml) - 3 checks
'CI / test-frontend',
'CI / test-python (3.12)',
'CI / test-python (3.13)',
'CI / test-frontend (pull_request)',
'CI / test-python (3.12) (pull_request)',
'CI / test-python (3.13) (pull_request)',
// Lint workflow (lint.yml) - 1 check
'Lint / python',
'Lint / python (pull_request)',
// Quality Security workflow (quality-security.yml) - 4 checks
'Quality Security / CodeQL (javascript-typescript)',
'Quality Security / CodeQL (python)',
'Quality Security / Python Security (Bandit)',
'Quality Security / Security Summary'
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
'Quality Security / CodeQL (python) (pull_request)',
'Quality Security / Python Security (Bandit) (pull_request)',
'Quality Security / Security Summary (pull_request)',
// Quality DCO workflow (quality-dco.yml) - 1 check
'Quality DCO / DCO Check (pull_request)',
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
'Quality Commit Lint / Conventional Commits (pull_request)'
];
const statusLabels = {
+6 -124
View File
@@ -1,10 +1,8 @@
name: Prepare Release
# Triggers when code is pushed to main (e.g., merging develop → main)
# If package.json version is newer than the latest tag:
# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing)
# 2. Extracts release notes from CHANGELOG.md
# 3. Creates a new tag which triggers release.yml
# If package.json version is newer than the latest tag, creates a new tag
# which then triggers the release.yml workflow
on:
push:
@@ -69,122 +67,8 @@ jobs:
echo "⏭️ No release needed (package version not newer than latest tag)"
fi
# CRITICAL: Validate CHANGELOG.md has entry for this version BEFORE creating tag
- name: Validate and extract changelog
if: steps.check.outputs.should_release == 'true'
id: changelog
run: |
VERSION="${{ steps.check.outputs.new_version }}"
CHANGELOG_FILE="CHANGELOG.md"
echo "🔍 Validating CHANGELOG.md for version $VERSION..."
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "::error::CHANGELOG.md not found! Please create CHANGELOG.md with release notes."
exit 1
fi
# Extract changelog section for this version
# Looks for "## X.Y.Z" header and captures until next "## " or "---" or end
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
BEGIN { found=0; content="" }
/^## / {
if (found) exit
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
found=1
# Skip the header line itself, we will add our own
next
}
}
/^---$/ { if (found) exit }
found { content = content $0 "\n" }
END {
if (!found) {
print "NOT_FOUND"
exit 1
}
# Trim leading/trailing whitespace
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
print content
}
' "$CHANGELOG_FILE")
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo ""
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error:: CHANGELOG VALIDATION FAILED"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error::"
echo "::error:: Version $VERSION not found in CHANGELOG.md!"
echo "::error::"
echo "::error:: Before releasing, please update CHANGELOG.md with an entry like:"
echo "::error::"
echo "::error:: ## $VERSION - Your Release Title"
echo "::error::"
echo "::error:: ### ✨ New Features"
echo "::error:: - Feature description"
echo "::error::"
echo "::error:: ### 🐛 Bug Fixes"
echo "::error:: - Fix description"
echo "::error::"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo ""
# Also add to job summary for visibility
echo "## ❌ Release Blocked: Missing Changelog" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Version **$VERSION** was not found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### How to fix:" >> $GITHUB_STEP_SUMMARY
echo "1. Update CHANGELOG.md with release notes for version $VERSION" >> $GITHUB_STEP_SUMMARY
echo "2. Commit and push the changes" >> $GITHUB_STEP_SUMMARY
echo "3. The release will automatically retry" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Expected format:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`markdown" >> $GITHUB_STEP_SUMMARY
echo "## $VERSION - Release Title" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✨ New Features" >> $GITHUB_STEP_SUMMARY
echo "- Feature description" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🐛 Bug Fixes" >> $GITHUB_STEP_SUMMARY
echo "- Fix description" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "✅ Found changelog entry for version $VERSION"
echo ""
echo "--- Extracted Release Notes ---"
echo "$CHANGELOG_CONTENT"
echo "--- End Release Notes ---"
# Save changelog to file for artifact upload
echo "$CHANGELOG_CONTENT" > changelog-extract.md
# Also save to output (for short changelogs)
# Using heredoc for multiline output
{
echo "content<<CHANGELOG_EOF"
echo "$CHANGELOG_CONTENT"
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
echo "changelog_valid=true" >> $GITHUB_OUTPUT
# Upload changelog as artifact for release.yml to use
- name: Upload changelog artifact
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
uses: actions/upload-artifact@v4
with:
name: changelog-${{ steps.check.outputs.new_version }}
path: changelog-extract.md
retention-days: 1
- name: Create and push tag
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
if: steps.check.outputs.should_release == 'true'
run: |
VERSION="${{ steps.check.outputs.new_version }}"
TAG="v$VERSION"
@@ -201,19 +85,17 @@ jobs:
- name: Summary
run: |
if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then
if [ "${{ steps.check.outputs.should_release }}" = "true" ]; then
echo "## 🚀 Release Triggered" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** v${{ steps.check.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ Changelog validated and extracted from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The release workflow has been triggered and will:" >> $GITHUB_STEP_SUMMARY
echo "1. Build binaries for all platforms" >> $GITHUB_STEP_SUMMARY
echo "2. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY
echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY
echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then
else
echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY
+115
View File
@@ -0,0 +1,115 @@
name: Quality Commit Lint
on:
pull_request:
branches: [main, develop]
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
check:
name: Conventional Commits
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate PR title
uses: actions/github-script@v7
with:
retries: 3
script: |
const pr = context.payload.pull_request;
const title = pr.title;
// Sanitize title for safe markdown interpolation (prevent injection)
const sanitizedTitle = title.replace(/`/g, "'").replace(/\[/g, '\\[').replace(/\]/g, '\\]');
console.log(`::group::PR #${pr.number} - Validating PR title`);
console.log(`Title: ${title}`);
// Conventional Commits pattern for PR title
// type(scope)?: description (max 100 chars)
// Optional ! for breaking changes: feat!: or feat(scope)!:
// Scope allows: letters, numbers, hyphens, underscores, slashes, dots
const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_\-\/\.]+\))?!?: .{1,100}$/;
const isValid = pattern.test(title);
console.log(`Valid: ${isValid}`);
console.log('::endgroup::');
if (!isValid) {
// Log helpful error message to console (visible in workflow logs)
console.log('');
console.log('❌ PR title does not follow Conventional Commits format');
console.log('');
console.log('Expected format: type(scope): description');
console.log('');
console.log('Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert');
console.log('');
console.log('Examples of valid PR titles:');
console.log(' ✓ feat(auth): add OAuth2 login support');
console.log(' ✓ fix(api): handle null response correctly');
console.log(' ✓ docs: update README installation steps');
console.log(' ✓ chore: update dependencies');
console.log('');
console.log(`Your title: "${title}"`);
console.log('');
console.log('Suggested fix for your title:');
// Try to suggest a fix based on the title
const lowerTitle = title.toLowerCase();
const placeholder = '[add description here]';
if (lowerTitle.includes('fix') || lowerTitle.includes('bug')) {
const cleaned = title.replace(/^(fix(ed|es|ing)?|bug)[:\s]*/i, '').trim();
console.log(` → fix: ${cleaned || placeholder}`);
} else if (lowerTitle.includes('add') || lowerTitle.includes('new') || lowerTitle.includes('feature')) {
const cleaned = title.replace(/^(add(ed|s|ing)?|new|features?)[:\s]*/i, '').trim();
console.log(` → feat: ${cleaned || placeholder}`);
} else if (lowerTitle.includes('update') || lowerTitle.includes('change')) {
const cleaned = title.replace(/^(update[ds]?|chang(ed|es|ing)?)[:\s]*/i, '').trim();
console.log(` → chore: ${cleaned || placeholder}`);
} else if (lowerTitle.includes('doc') || lowerTitle.includes('readme')) {
const cleaned = title.replace(/^(docs?|readme)[:\s]*/i, '').trim();
console.log(` → docs: ${cleaned || placeholder}`);
} else {
console.log(` → feat: ${title}`);
console.log(` → fix: ${title}`);
console.log(` → chore: ${title}`);
}
console.log('');
let errorMsg = '## ❌ PR Title Validation Failed\n\n';
errorMsg += `Your PR title does not follow [Conventional Commits](https://www.conventionalcommits.org/) format:\n\n`;
errorMsg += `> \`${sanitizedTitle}\`\n\n`;
errorMsg += '### Expected Format\n\n';
errorMsg += '```\ntype(scope): description\n```\n\n';
errorMsg += '| Type | Description |\n';
errorMsg += '|------|-------------|\n';
errorMsg += '| `feat` | New feature |\n';
errorMsg += '| `fix` | Bug fix |\n';
errorMsg += '| `docs` | Documentation only |\n';
errorMsg += '| `style` | Code style (formatting, etc.) |\n';
errorMsg += '| `refactor` | Code refactoring |\n';
errorMsg += '| `perf` | Performance improvement |\n';
errorMsg += '| `test` | Adding/updating tests |\n';
errorMsg += '| `build` | Build system changes |\n';
errorMsg += '| `ci` | CI/CD changes |\n';
errorMsg += '| `chore` | Maintenance tasks |\n';
errorMsg += '| `revert` | Reverting changes |\n\n';
errorMsg += '### Examples\n\n';
errorMsg += '```\nfeat(auth): add OAuth2 login support\nfix(api/users): handle null response correctly\nfix(package.json): update dependencies\ndocs: update README installation steps\nci: add automated release workflow\n```\n\n';
errorMsg += '### How to Fix\n\n';
errorMsg += 'Edit your PR title to follow the format above.\n';
core.summary.addRaw(errorMsg);
await core.summary.write();
core.setFailed('PR title does not follow Conventional Commits format');
} else {
console.log(`✅ PR title follows Conventional Commits format`);
core.summary
.addHeading('✅ PR Title Valid', 3)
.addRaw(`PR title follows Conventional Commits format: \`${sanitizedTitle}\``);
await core.summary.write();
}
+70
View File
@@ -0,0 +1,70 @@
name: Quality DCO
on:
pull_request:
branches: [main, develop]
permissions:
contents: read
pull-requests: read
jobs:
check:
name: DCO Check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check DCO Sign-off
uses: dcoapp/dco-check@v1
with:
require-signoff: true
- name: DCO Help on Failure
if: failure()
uses: actions/github-script@v7
with:
script: |
const helpMsg = `## ❌ DCO Sign-off Required
This project requires all commits to be signed off with the Developer Certificate of Origin (DCO).
### How to Fix
**Option 1: Sign off your last commit**
\`\`\`bash
git commit --amend --signoff
git push --force-with-lease
\`\`\`
**Option 2: Sign off all commits in this PR**
\`\`\`bash
git rebase HEAD~N --signoff # Replace N with number of commits
git push --force-with-lease
\`\`\`
**Option 3: Configure git to always sign off**
\`\`\`bash
git config --global format.signoff true
\`\`\`
### What is DCO?
The [Developer Certificate of Origin](https://developercertificate.org/) is a lightweight way for contributors to certify that they wrote or have the right to submit the code they are contributing.
By signing off, you agree to the DCO terms:
- The contribution was created by you
- You have the right to submit it under the project's license
- You understand the contribution is public and recorded
### Sign-off Format
Your commit message should end with:
\`\`\`
Signed-off-by: Your Name <your.email@example.com>
\`\`\`
This line is automatically added when you use \`git commit -s\` or \`git commit --signoff\`.
`;
core.summary.addRaw(helpMsg);
await core.summary.write();
+30 -220
View File
@@ -1,5 +1,4 @@
name: Release
# Triggers on version tags (v*) to build and publish releases
on:
push:
@@ -44,24 +43,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
key: python-bundle-${{ runner.os }}-x64-3.12.8
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
@@ -132,21 +120,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
@@ -217,21 +197,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
@@ -278,29 +250,13 @@ jobs:
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Flatpak
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Cache pip wheel cache
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
restore-keys: |
pip-wheel-${{ runner.os }}-x64-
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
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
@@ -317,7 +273,6 @@ jobs:
path: |
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
apps/frontend/dist/*.flatpak
create-release:
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
@@ -337,12 +292,12 @@ jobs:
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec cp {} release-assets/ \;
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) -exec cp {} release-assets/ \;
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
exit 1
fi
@@ -371,7 +326,7 @@ jobs:
echo "## VirusTotal Scan Results" > vt_results.md
echo "" >> vt_results.md
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
for file in release-assets/*.{exe,dmg,AppImage,deb}; do
[ -f "$file" ] || continue
filename=$(basename "$file")
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
@@ -506,77 +461,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"
REPO="${{ github.repository }}"
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/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') }}
@@ -587,8 +488,7 @@ jobs:
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:
@@ -597,116 +497,26 @@ jobs:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version and detect release type
- name: Extract version from tag
id: version
run: |
# Extract version from tag (v2.7.2 -> 2.7.2)
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Detect if this is a prerelease (contains - after version, e.g., 2.7.2-beta.10)
if [[ "$VERSION" == *-* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Detected PRERELEASE: $VERSION"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Detected STABLE release: $VERSION"
fi
echo "Updating README to version: $VERSION"
- name: Update README.md
run: |
python3 << 'EOF'
import re
import sys
VERSION="${{ steps.version.outputs.version }}"
version = "${{ steps.version.outputs.version }}"
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
# Update version badge: version-X.Y.Z-blue
sed -i "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-${VERSION}-blue/g" README.md
# Shields.io escapes hyphens as --
version_badge = version.replace("-", "--")
# Update download links: Auto-Claude-X.Y.Z
sed -i "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-${VERSION}/g" README.md
# Read README
with open("README.md", "r") as f:
content = f.read()
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
# Shields.io escaped pattern (hyphens as --)
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
def update_section(text, start_marker, end_marker, replacements):
"""Update content between markers with given replacements."""
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
def replace_section(match):
section = match.group(2)
for old_pattern, new_value in replacements:
section = re.sub(old_pattern, new_value, section)
return match.group(1) + section + match.group(3)
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
if is_prerelease:
print(f"Updating BETA section to {version} (badge: {version_badge})")
# Update beta badge
content = re.sub(
rf'beta-{semver_badge}-orange',
f'beta-{version_badge}-orange',
content
)
# Update beta version badge link
content = update_section(content,
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
[(rf'tag/v{semver}\)', f'tag/v{version})')])
# Update beta downloads
content = update_section(content,
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
[
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
(rf'download/v{semver}/', f'download/v{version}/'),
])
else:
print(f"Updating STABLE section to {version} (badge: {version_badge})")
# Update top version badge
content = update_section(content,
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
[
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
(rf'tag/v{semver}\)', f'tag/v{version})'),
])
# Update stable badge
content = re.sub(
rf'stable-{semver_badge}-blue',
f'stable-{version_badge}-blue',
content
)
# Update stable version badge link
content = update_section(content,
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
[(rf'tag/v{semver}\)', f'tag/v{version})')])
# Update stable downloads
content = update_section(content,
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
[
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
(rf'download/v{semver}/', f'download/v{version}/'),
])
# Write updated README
with open("README.md", "w") as f:
f.write(content)
print(f"README.md updated for {version} (prerelease={is_prerelease})")
EOF
echo "--- Verifying update ---"
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
echo "README.md updated to version $VERSION"
grep -E "(version-|Auto-Claude-)" README.md | head -10
- name: Commit and push README update
run: |
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
with:
stale-issue-message: |
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
- If this is still relevant, please comment or update the issue
- If you're working on this, add the `in-progress` label
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
+4 -12
View File
@@ -1,16 +1,12 @@
#!/bin/sh
# Commit message validation
# Enforces conventional commit format: type(scope)!?: description
# Enforces conventional commit format: type(scope): description
#
# Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots
# Optional ! for breaking changes
# Examples:
# feat(tasks): add drag and drop support
# fix(terminal): resolve scroll position issue
# feat!: breaking change without scope
# feat(api)!: breaking change with scope
# docs: update README with setup instructions
# chore: update dependencies
@@ -18,10 +14,8 @@ commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
# Regex for conventional commits
# Format: type(optional-scope)!?: description
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots (consistent with GitHub workflow)
# Optional ! for breaking changes: feat!: or feat(scope)!:
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_/.-]+\))?!?: .{1,100}$"
# Format: type(optional-scope): description
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .{1,100}$"
# Allow merge commits
if echo "$commit_msg" | grep -qE "^Merge "; then
@@ -42,7 +36,7 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
echo ""
echo "Your message: $first_line"
echo ""
echo "Expected format: type(scope)!?: description"
echo "Expected format: type(scope): description"
echo ""
echo "Valid types:"
echo " feat - A new feature"
@@ -60,8 +54,6 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
echo "Examples:"
echo " feat(tasks): add drag and drop support"
echo " fix(terminal): resolve scroll position issue"
echo " feat!: breaking change without scope"
echo " feat(api)!: breaking change with scope"
echo " docs: update README"
echo " chore: update dependencies"
echo ""
+24 -65
View File
@@ -36,44 +36,12 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
echo " Updated apps/backend/__init__.py to $VERSION"
fi
# Sync to README.md - section-aware updates (stable vs beta)
# Sync to README.md
if [ -f "README.md" ]; then
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link (within BETA_VERSION_BADGE section)
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue) - within TOP_VERSION_BADGE section
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
# Update version badge
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md
# Update download links
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md
rm -f README.md.bak
git add README.md
echo " Updated README.md to $VERSION"
@@ -102,25 +70,20 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
fi
if [ -n "$RUFF" ]; then
# Get only staged Python files in apps/backend (process only what's being committed)
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "^apps/backend/.*\.py$" || true)
if [ -n "$STAGED_PY_FILES" ]; then
# Run ruff linting (auto-fix) only on staged files
echo "Running ruff lint on staged files..."
echo "$STAGED_PY_FILES" | xargs $RUFF check --fix
if [ $? -ne 0 ]; then
echo "Ruff lint failed. Please fix Python linting errors before committing."
exit 1
fi
# Run ruff format (auto-fix) only on staged files
echo "Running ruff format on staged files..."
echo "$STAGED_PY_FILES" | xargs $RUFF format
# Re-stage only the files that were originally staged (in case ruff modified them)
echo "$STAGED_PY_FILES" | xargs git add
# Run ruff linting (auto-fix)
echo "Running ruff lint..."
$RUFF check apps/backend/ --fix
if [ $? -ne 0 ]; then
echo "Ruff lint failed. Please fix Python linting errors before committing."
exit 1
fi
# Run ruff format (auto-fix)
echo "Running ruff format..."
$RUFF format apps/backend/
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
else
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
fi
@@ -148,7 +111,7 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
exit 1
fi
cd ../..
echo "Backend checks passed!"
fi
@@ -160,14 +123,10 @@ fi
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "Frontend changes detected, running frontend checks..."
cd apps/frontend
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run TypeScript type check
echo "Running type check..."
npm run typecheck
@@ -175,7 +134,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "Type check failed. Please fix TypeScript errors before committing."
exit 1
fi
# Run linting
echo "Running lint..."
npm run lint
@@ -183,7 +142,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
exit 1
fi
# Check for vulnerabilities (only high severity)
echo "Checking for vulnerabilities..."
npm audit --audit-level=high
@@ -191,7 +150,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
cd ../..
echo "Frontend checks passed!"
fi
+16 -86
View File
@@ -4,74 +4,26 @@ repos:
hooks:
- id: version-sync
name: Version Sync
entry: bash
args:
- -c
- |
VERSION=$(node -p "require('./package.json').version")
if [ -n "$VERSION" ]; then
# Sync to apps/frontend/package.json
node -e "
const fs = require('fs');
const p = require('./apps/frontend/package.json');
const v = process.argv[1];
if (p.version !== v) {
p.version = v;
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(p, null, 2) + '\n');
}
" "$VERSION"
# Sync to apps/backend/__init__.py
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
# Sync to README.md - section-aware updates (stable vs beta)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
# Detect if this is a prerelease (contains - after base version)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue)
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue)
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
rm -f README.md.bak
# Stage changes
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true
fi
entry: bash -c '
VERSION=$(node -p "require(\"./package.json\").version");
if [ -n "$VERSION" ]; then
# Sync to apps/frontend/package.json
node -e "const fs=require(\"fs\");const p=require(\"./apps/frontend/package.json\");if(p.version!==\"$VERSION\"){p.version=\"$VERSION\";fs.writeFileSync(\"./apps/frontend/package.json\",JSON.stringify(p,null,2)+\"\n\");}";
# Sync to apps/backend/__init__.py
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak;
# Sync to README.md
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md;
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md && rm -f README.md.bak;
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true;
fi
'
language: system
files: ^package\.json$
pass_filenames: false
# Python linting (apps/backend/)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
rev: v0.8.3
hooks:
- id: ruff
args: [--fix]
@@ -85,29 +37,7 @@ repos:
hooks:
- id: pytest
name: Python Tests
entry: bash
args:
- -c
- |
cd apps/backend
if [ -f ".venv/bin/pytest" ]; then
PYTEST_CMD=".venv/bin/pytest"
elif [ -f ".venv/Scripts/pytest.exe" ]; then
PYTEST_CMD=".venv/Scripts/pytest.exe"
else
PYTEST_CMD="python -m pytest"
fi
PYTHONPATH=. $PYTEST_CMD \
../../tests/ \
-v \
--tb=short \
-x \
-m "not slow and not integration" \
--ignore=../../tests/test_graphiti.py \
--ignore=../../tests/test_merge_file_tracker.py \
--ignore=../../tests/test_service_orchestrator.py \
--ignore=../../tests/test_worktree.py \
--ignore=../../tests/test_workspace.py
entry: bash -c 'cd apps/backend && PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" --ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py'
language: system
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
pass_filenames: false
@@ -131,7 +61,7 @@ repos:
# General checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
-280
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
-76
View File
@@ -1,76 +0,0 @@
# Auto Claude Individual Contributor License Agreement
Thank you for your interest in contributing to Auto Claude. This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project.
By signing this Agreement, you accept and agree to the following terms and conditions for your present and future Contributions submitted to the Project.
## 1. Definitions
**"You" (or "Your")** means the individual who submits a Contribution to the Project.
**"Contribution"** means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Project.
**"Project"** means Auto Claude, a multi-agent autonomous coding framework, currently available at https://github.com/AndyMik90/Auto-Claude.
**"Project Owner"** means Andre Mikalsen and any designated successors or assignees.
## 2. Grant of Copyright License
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:
- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works
- Sublicense any or all of the foregoing rights to third parties
## 3. Grant of Patent License
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Project to which such Contribution(s) was submitted.
## 4. Future Licensing Flexibility
You understand and agree that the Project Owner may, in the future, license the Project, including Your Contributions, under additional licenses beyond the current GNU Affero General Public License version 3.0 (AGPL-3.0). Such additional licenses may include commercial or enterprise licenses.
This provision ensures the Project has proper licensing flexibility should such licensing options be introduced in the future. The open source version of the Project will continue to be available under AGPL-3.0.
## 5. Representations
You represent that:
(a) You are legally entitled to grant the above licenses. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, or that your employer has waived such rights for your Contributions to the Project.
(b) Each of Your Contributions is Your original creation. You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
(c) Your Contribution does not violate any third-party rights, including but not limited to intellectual property rights, privacy rights, or contractual obligations.
## 6. Support and Warranty Disclaimer
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all.
UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, YOU PROVIDE YOUR CONTRIBUTIONS ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
## 7. No Obligation to Use
You understand that the decision to include Your Contribution in any project or source repository is entirely at the discretion of the Project Owner, and this Agreement does not guarantee that Your Contributions will be included in any product.
## 8. Contributor Rights
You retain full copyright ownership of Your Contributions. Nothing in this Agreement shall be interpreted to prohibit you from licensing Your Contributions under different terms to third parties or from using Your Contributions for any other purpose.
## 9. Notification
You agree to notify the Project Owner of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
---
## How to Sign
To sign this CLA, comment on your Pull Request with:
```
I have read the CLA Document and I hereby sign the CLA
```
Your signature will be recorded automatically.
---
*This CLA is based on the Apache Software Foundation Individual Contributor License Agreement v2.0.*
-17
View File
@@ -248,23 +248,6 @@ main (user's branch)
4. User runs `--merge` to add to their project
5. User pushes to remote when ready
### Contributing to Upstream
**CRITICAL: When submitting PRs to AndyMik90/Auto-Claude, always target the `develop` branch, NOT `main`.**
**Correct workflow for contributions:**
1. Fetch upstream: `git fetch upstream`
2. Create feature branch from upstream/develop: `git checkout -b fix/my-fix upstream/develop`
3. Make changes and commit with sign-off: `git commit -s -m "fix: description"`
4. Push to your fork: `git push origin fix/my-fix`
5. Create PR targeting `develop`: `gh pr create --repo AndyMik90/Auto-Claude --base develop`
**Verify before PR:**
```bash
# Ensure only your commits are included
git log --oneline upstream/develop..HEAD
```
### Security Model
Three-layer defense:
-107
View File
@@ -4,7 +4,6 @@ Thank you for your interest in contributing to Auto Claude! This document provid
## Table of Contents
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Development Setup](#development-setup)
@@ -24,30 +23,10 @@ Thank you for your interest in contributing to Auto Claude! This document provid
- [Pull Request Targets](#pull-request-targets)
- [Release Process](#release-process-maintainers)
- [Commit Messages](#commit-messages)
- [PR Hygiene](#pr-hygiene)
- [Pull Request Process](#pull-request-process)
- [Issue Reporting](#issue-reporting)
- [Architecture Overview](#architecture-overview)
## Contributor License Agreement (CLA)
All contributors must sign our Contributor License Agreement (CLA) before contributions can be accepted.
### Why We Require a CLA
Auto Claude is currently licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options (such as commercial/enterprise licenses) in the future.
You retain full copyright ownership of your contributions.
### How to Sign
1. Open a Pull Request
2. The CLA bot will automatically comment with instructions
3. Comment on the PR with: `I have read the CLA Document and I hereby sign the CLA`
4. Done - you only need to sign once, and it applies to all future contributions
Read the full CLA here: [CLA.md](CLA.md)
## Prerequisites
Before contributing, ensure you have the following installed:
@@ -56,7 +35,6 @@ Before contributing, ensure you have the following installed:
- **Node.js 24+** - For the Electron frontend
- **npm 10+** - Package manager for the frontend (comes with Node.js)
- **uv** (recommended) or **pip** - Python package manager
- **CMake** - Required for building native dependencies (e.g., LadybugDB)
- **Git** - Version control
### Installing Python 3.12
@@ -76,56 +54,6 @@ brew install python@3.12
sudo apt install python3.12 python3.12-venv
```
**Linux (Fedora):**
```bash
sudo dnf install python3.12
```
### Installing Node.js 24+
**Windows:**
```bash
winget install OpenJS.NodeJS.LTS
```
**macOS:**
```bash
brew install node@24
```
**Linux (Ubuntu/Debian):**
```bash
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
```
**Linux (Fedora):**
```bash
sudo dnf install nodejs npm
```
### Installing CMake
**Windows:**
```bash
winget install Kitware.CMake
```
**macOS:**
```bash
brew install cmake
```
**Linux (Ubuntu/Debian):**
```bash
sudo apt install cmake
```
**Linux (Fedora):**
```bash
sudo dnf install cmake
```
## Quick Start
The fastest way to get started:
@@ -668,41 +596,6 @@ git commit -m "WIP"
- **body**: Detailed explanation if needed (wrap at 72 chars)
- **footer**: Reference issues, breaking changes
### PR Hygiene
**Rebasing:**
- **Rebase onto develop** before opening a PR and before merge to maintain linear history
- Use `git fetch origin && git rebase origin/develop` to sync your branch
- Use `--force-with-lease` when force-pushing rebased branches (safer than `--force`)
- Notify reviewers after force-pushing during active review
- **Exception:** Never rebase after PR is approved and others have reviewed specific commits
**Commit organization:**
- **Squash fixup commits** (typos, "oops", review feedback) into their parent commits
- **Keep logically distinct changes** as separate commits that could be reverted independently
- Each commit should compile and pass tests independently
- No "WIP", "fix tests", or "lint" commits in final PR - squash these
**Before requesting review:**
```bash
# Ensure up-to-date with develop
git fetch origin && git rebase origin/develop
# Clean up commit history (squash fixups, reword messages)
git rebase -i origin/develop
# Force push with safety check
git push --force-with-lease
# Verify everything works
npm run test:backend
cd apps/frontend && npm test && npm run lint && npm run typecheck
```
**PR size:**
- Keep PRs small (<400 lines changed ideally)
- Split large features into stacked PRs if possible
## Pull Request Process
1. **Fork the repository** and create your branch from `develop` (not main!)
+9 -105
View File
@@ -4,9 +4,7 @@
![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png)
<!-- TOP_VERSION_BADGE -->
[![Version](https://img.shields.io/badge/version-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
<!-- TOP_VERSION_BADGE_END -->
[![Version](https://img.shields.io/badge/version-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/latest)
[![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)
[![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)
@@ -15,40 +13,15 @@
## Download
### Stable Release
Get the latest pre-built release for your platform:
<!-- 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_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **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
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![Beta](https://img.shields.io/badge/beta-2.7.2--beta.10-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
| Platform | Download | Notes |
|----------|----------|-------|
| **Windows** | [Auto-Claude-2.7.2.exe](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Installer (NSIS) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | M1/M2/M3 Macs |
| **macOS (Intel)** | [Auto-Claude-2.7.2-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Intel Macs |
| **Linux** | [Auto-Claude-2.7.2.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Universal |
| **Linux (Debian)** | [Auto-Claude-2.7.2.deb](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Ubuntu/Debian |
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
@@ -83,8 +56,6 @@
| **Self-Validating QA** | Built-in quality assurance loop catches issues before you review |
| **AI-Powered Merge** | Automatic conflict resolution when integrating back to main |
| **Memory Layer** | Agents retain insights across sessions for smarter builds |
| **GitHub/GitLab Integration** | Import issues, investigate with AI, create merge requests |
| **Linear Integration** | Sync tasks with Linear for team progress tracking |
| **Cross-Platform** | Native desktop apps for Windows, macOS, and Linux |
| **Auto-Updates** | App updates automatically when new versions are released |
@@ -161,9 +132,6 @@ cp apps/backend/.env.example apps/backend/.env
| `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 |
---
@@ -191,71 +159,8 @@ npm start
- 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/`.
---
## Security
@@ -284,7 +189,6 @@ All releases are:
| `npm run package:mac` | Package for macOS |
| `npm run package:win` | Package for Windows |
| `npm run package:linux` | Package for Linux |
| `npm run package:flatpak` | Package as Flatpak |
| `npm run lint` | Run linter |
| `npm test` | Run frontend tests |
| `npm run test:backend` | Run backend tests |
+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
View File
+1 -34
View File
@@ -7,8 +7,7 @@
# Auto Claude uses Claude Code OAuth authentication.
# Direct API keys (ANTHROPIC_API_KEY) are NOT supported to prevent silent billing.
#
# Option 1: Run `claude setup-token` to save token to system keychain (recommended)
# (macOS: Keychain, Windows: Credential Manager, Linux: secret-service)
# Option 1: Run `claude setup-token` to save token to macOS Keychain (recommended)
# Option 2: Set the token explicitly:
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
#
@@ -76,38 +75,6 @@
# Pre-configured Project ID (OPTIONAL - will create project if not set)
# LINEAR_PROJECT_ID=
# =============================================================================
# GITLAB INTEGRATION (OPTIONAL)
# =============================================================================
# Enable GitLab integration for issue tracking and merge requests.
# Supports both GitLab.com and self-hosted GitLab instances.
#
# Authentication Options (choose one):
#
# Option 1: glab CLI OAuth (Recommended)
# Install glab CLI: https://gitlab.com/gitlab-org/cli#installation
# Then run: glab auth login
# This opens your browser for OAuth authentication. Once complete,
# Auto Claude will automatically use your glab credentials (no env vars needed).
# For self-hosted: glab auth login --hostname gitlab.example.com
#
# Option 2: Personal Access Token
# Set GITLAB_TOKEN below. Token auth is used if set, otherwise falls back to glab CLI.
# GitLab Instance URL (OPTIONAL - defaults to gitlab.com)
# For self-hosted: GITLAB_INSTANCE_URL=https://gitlab.example.com
# GITLAB_INSTANCE_URL=https://gitlab.com
# GitLab Personal Access Token (OPTIONAL - only needed if not using glab CLI)
# Required scope: api (covers issues, merge requests, releases, project info)
# Optional scope: write_repository (only if creating new GitLab projects from local repos)
# Get from: https://gitlab.com/-/user_settings/personal_access_tokens
# GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
# GitLab Project (OPTIONAL - format: group/project or numeric ID)
# If not set, will auto-detect from git remote
# GITLAB_PROJECT=mygroup/myproject
# =============================================================================
# UI SETTINGS (OPTIONAL)
# =============================================================================
+62
View File
@@ -0,0 +1,62 @@
"""
Custom MCP Tools for Auto-Claude Agents
========================================
DEPRECATED: This module is now a compatibility shim.
Please import from the tools_pkg package instead:
from agents.tools_pkg import create_auto_claude_mcp_server, get_allowed_tools
This file remains for backward compatibility with existing imports.
All functionality has been moved to the tools_pkg package for better
organization and maintainability.
"""
# Import everything from the package to maintain backward compatibility
# Use try/except to handle both relative and absolute imports
try:
from .tools_pkg import (
ELECTRON_TOOLS,
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_UPDATE_QA_STATUS,
TOOL_UPDATE_SUBTASK_STATUS,
create_auto_claude_mcp_server,
get_allowed_tools,
is_electron_mcp_enabled,
is_tools_available,
)
except ImportError:
# Fallback for direct execution - import from tools_pkg directly
from tools_pkg import (
ELECTRON_TOOLS,
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_UPDATE_QA_STATUS,
TOOL_UPDATE_SUBTASK_STATUS,
create_auto_claude_mcp_server,
get_allowed_tools,
is_electron_mcp_enabled,
is_tools_available,
)
__all__ = [
# Main API
"create_auto_claude_mcp_server",
"get_allowed_tools",
"is_tools_available",
# Tool name constants
"TOOL_UPDATE_SUBTASK_STATUS",
"TOOL_GET_BUILD_PROGRESS",
"TOOL_RECORD_DISCOVERY",
"TOOL_RECORD_GOTCHA",
"TOOL_GET_SESSION_CONTEXT",
"TOOL_UPDATE_QA_STATUS",
# Electron MCP
"ELECTRON_TOOLS",
"is_electron_mcp_enabled",
]
+2 -26
View File
@@ -18,7 +18,6 @@ from linear_updater import (
linear_task_stuck,
)
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from progress import (
count_subtasks,
count_subtasks_detailed,
@@ -147,7 +146,6 @@ async def run_autonomous_agent(
# Update status for planning phase
status_manager.update(state=BuildState.PLANNING)
emit_phase(ExecutionPhase.PLANNING, "Creating implementation plan")
is_planning_phase = True
current_log_phase = LogPhase.PLANNING
@@ -175,9 +173,6 @@ async def run_autonomous_agent(
if task_logger:
task_logger.start_phase(LogPhase.CODING, "Continuing implementation...")
# Emit phase event when continuing build
emit_phase(ExecutionPhase.CODING, "Continuing implementation")
# Show human intervention hint
content = [
bold("INTERACTIVE CONTROLS"),
@@ -257,33 +252,16 @@ async def run_autonomous_agent(
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
# Create client (fresh context) with phase-specific model and thinking
# Use appropriate agent_type for correct tool permissions and thinking budget
client = create_client(
project_dir,
spec_dir,
phase_model,
agent_type="planner" if first_run else "coder",
max_thinking_tokens=phase_thinking_budget,
)
# Generate appropriate prompt
if first_run:
prompt = generate_planner_prompt(spec_dir, project_dir)
# Retrieve Graphiti memory context for planning phase
# This gives the planner knowledge of previous patterns, gotchas, and insights
planner_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Planning implementation for new feature",
"id": "planner",
},
)
if planner_context:
prompt += "\n\n" + planner_context
print_status("Graphiti memory context loaded for planner", "success")
first_run = False
current_log_phase = LogPhase.PLANNING
@@ -295,7 +273,6 @@ async def run_autonomous_agent(
if is_planning_phase:
is_planning_phase = False
current_log_phase = LogPhase.CODING
emit_phase(ExecutionPhase.CODING, "Starting implementation")
if task_logger:
task_logger.end_phase(
LogPhase.PLANNING,
@@ -409,11 +386,10 @@ async def run_autonomous_agent(
# Handle session status
if status == "complete":
# Don't emit COMPLETE here - subtasks are done but QA hasn't run yet
# QA loop will emit COMPLETE after actual approval
print_build_complete_banner(spec_dir)
status_manager.update(state=BuildState.COMPLETE)
# End coding phase in task logger
if task_logger:
task_logger.end_phase(
LogPhase.CODING,
@@ -421,6 +397,7 @@ async def run_autonomous_agent(
message="All subtasks completed successfully",
)
# Notify Linear that build is complete (moving to QA)
if linear_task and linear_task.task_id:
await linear_build_complete(spec_dir)
print_status("Linear notified: build complete, ready for QA", "success")
@@ -455,7 +432,6 @@ async def run_autonomous_agent(
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
elif status == "error":
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
print_status("Session encountered an error", "error")
print(muted("Will retry with a fresh session..."))
status_manager.update(state=BuildState.ERROR)
+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
+3 -7
View File
@@ -9,8 +9,7 @@ import logging
from pathlib import Path
from core.client import create_client
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from phase_config import get_phase_thinking_budget
from task_logger import (
LogPhase,
get_task_logger,
@@ -68,7 +67,6 @@ async def run_followup_planner(
# Initialize status manager for ccstatusline
status_manager = StatusManager(project_dir)
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
# Initialize task logger for persistent logging
task_logger = get_task_logger(spec_dir)
@@ -91,14 +89,12 @@ async def run_followup_planner(
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
task_logger.set_session(1)
# Create client with phase-specific model and thinking budget
# Respects task_metadata.json configuration when no CLI override
planning_model = get_phase_model(spec_dir, "planning", model)
# Create client (fresh context) with planning phase thinking budget
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
client = create_client(
project_dir,
spec_dir,
planning_model,
model,
max_thinking_tokens=planning_thinking_budget,
)
+23 -26
View File
@@ -21,7 +21,6 @@ from progress import (
is_build_complete,
)
from recovery import RecoveryManager
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
LogPhase,
@@ -387,43 +386,41 @@ async def run_agent_session(
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input_display = None
tool_input = None
tool_count += 1
# Safely extract tool input (handles None, non-dict, etc.)
inp = get_safe_tool_input(block)
# Extract meaningful tool input for display
if inp:
if "pattern" in inp:
tool_input_display = f"pattern: {inp['pattern']}"
elif "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input_display = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input_display = cmd
elif "path" in inp:
tool_input_display = inp["path"]
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
elif "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input = cmd
elif "path" in inp:
tool_input = inp["path"]
debug(
"session",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input_display,
full_input=str(inp)[:500] if inp else None,
tool_input=tool_input,
full_input=str(block.input)[:500]
if hasattr(block, "input")
else None,
)
# Log tool start (handles printing too)
if task_logger:
task_logger.tool_start(
tool_name,
tool_input_display,
phase,
print_to_console=True,
tool_name, tool_input, phase, print_to_console=True
)
else:
print(f"\n[Tool: {tool_name}]", flush=True)
+4 -35
View File
@@ -30,32 +30,16 @@ Usage:
"""
from .models import (
# Agent configuration registry
AGENT_CONFIGS,
# Base tools
BASE_READ_TOOLS,
BASE_WRITE_TOOLS,
# MCP tool lists
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
GRAPHITI_MCP_TOOLS,
LINEAR_TOOLS,
PUPPETEER_TOOLS,
# Auto-Claude tool names
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_UPDATE_QA_STATUS,
TOOL_UPDATE_SUBTASK_STATUS,
WEB_TOOLS,
# Config functions
get_agent_config,
get_default_thinking_level,
get_required_mcp_servers,
is_electron_mcp_enabled,
)
from .permissions import get_all_agent_types, get_allowed_tools
from .permissions import get_allowed_tools
from .registry import create_auto_claude_mcp_server, is_tools_available
__all__ = [
@@ -63,29 +47,14 @@ __all__ = [
"create_auto_claude_mcp_server",
"get_allowed_tools",
"is_tools_available",
# Agent configuration registry
"AGENT_CONFIGS",
"get_agent_config",
"get_required_mcp_servers",
"get_default_thinking_level",
"get_all_agent_types",
# Base tool lists
"BASE_READ_TOOLS",
"BASE_WRITE_TOOLS",
"WEB_TOOLS",
# MCP tool lists
"CONTEXT7_TOOLS",
"LINEAR_TOOLS",
"GRAPHITI_MCP_TOOLS",
"ELECTRON_TOOLS",
"PUPPETEER_TOOLS",
# Auto-Claude tool name constants
# Tool name constants
"TOOL_UPDATE_SUBTASK_STATUS",
"TOOL_GET_BUILD_PROGRESS",
"TOOL_RECORD_DISCOVERY",
"TOOL_RECORD_GOTCHA",
"TOOL_GET_SESSION_CONTEXT",
"TOOL_UPDATE_QA_STATUS",
# Config
# Electron MCP
"ELECTRON_TOOLS",
"is_electron_mcp_enabled",
]
+5 -452
View File
@@ -3,32 +3,12 @@ Tool Models and Constants
==========================
Defines tool name constants and configuration for auto-claude MCP tools.
This module is the single source of truth for all tool definitions used by
the Claude Agent SDK client. Tool lists are organized by category:
- Base tools: Core file operations (Read, Write, Edit, etc.)
- Web tools: Documentation and research (WebFetch, WebSearch)
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
- Auto-Claude tools: Custom build management tools
"""
import os
# =============================================================================
# Base Tools (Built-in Claude Code tools)
# =============================================================================
# Core file operation tools
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
# Web tools for documentation lookup and research
# Always available to all agents for accessing external information
WEB_TOOLS = ["WebFetch", "WebSearch"]
# =============================================================================
# Auto-Claude MCP Tools (Custom build management)
# Tool Name Constants
# =============================================================================
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
@@ -39,54 +19,8 @@ TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
# =============================================================================
# External MCP Tools
# =============================================================================
# Context7 MCP tools for documentation lookup (always enabled)
CONTEXT7_TOOLS = [
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
]
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
LINEAR_TOOLS = [
"mcp__linear-server__list_teams",
"mcp__linear-server__get_team",
"mcp__linear-server__list_projects",
"mcp__linear-server__get_project",
"mcp__linear-server__create_project",
"mcp__linear-server__update_project",
"mcp__linear-server__list_issues",
"mcp__linear-server__get_issue",
"mcp__linear-server__create_issue",
"mcp__linear-server__update_issue",
"mcp__linear-server__list_comments",
"mcp__linear-server__create_comment",
"mcp__linear-server__list_issue_statuses",
"mcp__linear-server__list_issue_labels",
"mcp__linear-server__list_users",
"mcp__linear-server__get_user",
]
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
# See: https://github.com/getzep/graphiti
GRAPHITI_MCP_TOOLS = [
"mcp__graphiti-memory__search_nodes", # Search entity summaries
"mcp__graphiti-memory__search_facts", # Search relationships between entities
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
]
# =============================================================================
# Browser Automation MCP Tools (QA agents only)
# =============================================================================
# Puppeteer MCP tools for web browser automation
# Used for web frontend validation (non-Electron web apps)
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
PUPPETEER_TOOLS = [
"mcp__puppeteer__puppeteer_connect_active_tab",
"mcp__puppeteer__puppeteer_navigate",
@@ -102,7 +36,6 @@ PUPPETEER_TOOLS = [
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
ELECTRON_TOOLS = [
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
@@ -110,6 +43,10 @@ ELECTRON_TOOLS = [
"mcp__electron__read_electron_logs", # Read console logs from Electron app
]
# Base tools available to all agents
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
# =============================================================================
# Configuration
# =============================================================================
@@ -124,387 +61,3 @@ def is_electron_mcp_enabled() -> bool:
via Chrome DevTools Protocol on the configured debug port.
"""
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
# =============================================================================
# Agent Configuration Registry
# =============================================================================
# Single source of truth for phase → tools → MCP servers mapping.
# This enables phase-aware tool control and context window optimization.
AGENT_CONFIGS = {
# ═══════════════════════════════════════════════════════════════════════
# SPEC CREATION PHASES (Minimal tools, fast startup)
# ═══════════════════════════════════════════════════════════════════════
"spec_gatherer": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [], # No MCP needed - just reads project
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_researcher": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"], # Needs docs lookup
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_writer": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"mcp_servers": [], # Just writes spec.md
"auto_claude_tools": [],
"thinking_default": "high",
},
"spec_critic": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [], # Self-critique, no external tools
"auto_claude_tools": [],
"thinking_default": "ultrathink",
},
"spec_discovery": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_context": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_validation": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "high",
},
"spec_compaction": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
# ═══════════════════════════════════════════════════════════════════════
# BUILD PHASES (Full tools + Graphiti memory)
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
# ═══════════════════════════════════════════════════════════════════════
"planner": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude"],
"mcp_servers_optional": ["linear"], # Only if project setting enabled
"auto_claude_tools": [
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
],
"thinking_default": "high",
},
"coder": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude"],
"mcp_servers_optional": ["linear"],
"auto_claude_tools": [
TOOL_UPDATE_SUBTASK_STATUS,
TOOL_GET_BUILD_PROGRESS,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_GET_SESSION_CONTEXT,
],
"thinking_default": "none", # Coding doesn't use extended thinking
},
# ═══════════════════════════════════════════════════════════════════════
# QA PHASES (Read + test + browser + Graphiti memory)
# ═══════════════════════════════════════════════════════════════════════
"qa_reviewer": {
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
"mcp_servers_optional": ["linear"], # For updating issue status
"auto_claude_tools": [
TOOL_GET_BUILD_PROGRESS,
TOOL_UPDATE_QA_STATUS,
TOOL_GET_SESSION_CONTEXT,
],
"thinking_default": "high",
},
"qa_fixer": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
"mcp_servers_optional": ["linear"],
"auto_claude_tools": [
TOOL_UPDATE_SUBTASK_STATUS,
TOOL_GET_BUILD_PROGRESS,
TOOL_UPDATE_QA_STATUS,
TOOL_RECORD_GOTCHA,
],
"thinking_default": "medium",
},
# ═══════════════════════════════════════════════════════════════════════
# UTILITY PHASES (Minimal, no MCP)
# ═══════════════════════════════════════════════════════════════════════
"insights": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"merge_resolver": {
"tools": [], # Text-only analysis
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"commit_message": {
"tools": [],
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"pr_reviewer": {
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"pr_orchestrator_parallel": {
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"pr_followup_parallel": {
"tools": BASE_READ_TOOLS
+ WEB_TOOLS, # Read-only for parallel followup reviewer
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
# ═══════════════════════════════════════════════════════════════════════
# ANALYSIS PHASES
# ═══════════════════════════════════════════════════════════════════════
"analysis": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"batch_analysis": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"batch_validation": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
# ═══════════════════════════════════════════════════════════════════════
# ROADMAP & IDEATION
# ═══════════════════════════════════════════════════════════════════════
"roadmap_discovery": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"competitor_analysis": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"], # WebSearch for competitor research
"auto_claude_tools": [],
"thinking_default": "high",
},
"ideation": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "high",
},
}
# =============================================================================
# Agent Config Helper Functions
# =============================================================================
def get_agent_config(agent_type: str) -> dict:
"""
Get full configuration for an agent type.
Args:
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
Returns:
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
"""
if agent_type not in AGENT_CONFIGS:
raise ValueError(
f"Unknown agent type: '{agent_type}'. "
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
)
return AGENT_CONFIGS[agent_type]
def _map_mcp_server_name(
name: str, custom_server_ids: list[str] | None = None
) -> str | None:
"""
Map user-friendly MCP server names to internal identifiers.
Also accepts custom server IDs directly.
Args:
name: User-provided MCP server name
custom_server_ids: List of custom server IDs to accept as-is
Returns:
Internal server identifier or None if not recognized
"""
if not name:
return None
mappings = {
"context7": "context7",
"graphiti-memory": "graphiti",
"graphiti": "graphiti",
"linear": "linear",
"electron": "electron",
"puppeteer": "puppeteer",
"auto-claude": "auto-claude",
}
# Check if it's a known mapping
mapped = mappings.get(name.lower().strip())
if mapped:
return mapped
# Check if it's a custom server ID (accept as-is)
if custom_server_ids and name in custom_server_ids:
return name
return None
def get_required_mcp_servers(
agent_type: str,
project_capabilities: dict | None = None,
linear_enabled: bool = False,
mcp_config: dict | None = None,
) -> list[str]:
"""
Get MCP servers required for this agent type.
Handles dynamic server selection:
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
- "graphiti" → only if GRAPHITI_MCP_URL is set
- Respects per-project MCP config overrides from .auto-claude/.env
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
Args:
agent_type: The agent type identifier
project_capabilities: Dict from detect_project_capabilities() or None
linear_enabled: Whether Linear integration is enabled for this project
mcp_config: Per-project MCP server toggles from .auto-claude/.env
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
Returns:
List of MCP server names to start
"""
config = get_agent_config(agent_type)
servers = list(config.get("mcp_servers", []))
# Load per-project config (or use defaults)
if mcp_config is None:
mcp_config = {}
# Filter context7 if explicitly disabled by project config
if "context7" in servers:
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
if str(context7_enabled).lower() == "false":
servers = [s for s in servers if s != "context7"]
# Handle optional servers (e.g., Linear if project setting enabled)
optional = config.get("mcp_servers_optional", [])
if "linear" in optional and linear_enabled:
# Also check per-project LINEAR_MCP_ENABLED override
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
if str(linear_mcp_enabled).lower() != "false":
servers.append("linear")
# Handle dynamic "browser" → electron/puppeteer based on project type and config
if "browser" in servers:
servers = [s for s in servers if s != "browser"]
if project_capabilities:
is_electron = project_capabilities.get("is_electron", False)
is_web_frontend = project_capabilities.get("is_web_frontend", False)
# Check per-project overrides (default false for both)
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
# Electron: enabled by project config OR global env var
if is_electron and (
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
):
servers.append("electron")
# Puppeteer: enabled by project config (no global env var)
elif is_web_frontend and not is_electron:
if str(puppeteer_enabled).lower() == "true":
servers.append("puppeteer")
# Filter graphiti if not enabled
if "graphiti" in servers:
if not os.environ.get("GRAPHITI_MCP_URL"):
servers = [s for s in servers if s != "graphiti"]
# ========== Apply per-agent MCP overrides ==========
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
add_key = f"AGENT_MCP_{agent_type}_ADD"
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
# Extract custom server IDs for mapping (allows custom servers to be recognized)
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
# Process additions
if add_key in mcp_config:
additions = [
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
]
for server in additions:
mapped = _map_mcp_server_name(server, custom_server_ids)
if mapped and mapped not in servers:
servers.append(mapped)
# Process removals (but never remove auto-claude)
if remove_key in mcp_config:
removals = [
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
]
for server in removals:
mapped = _map_mcp_server_name(server, custom_server_ids)
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
servers = [s for s in servers if s != mapped]
return servers
def get_default_thinking_level(agent_type: str) -> str:
"""
Get default thinking level string for agent type.
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
To convert to tokens, use phase_config.get_thinking_budget(level).
Args:
agent_type: The agent type identifier
Returns:
Thinking level string (none, low, medium, high, ultrathink)
"""
config = get_agent_config(agent_type)
return config.get("thinking_default", "medium")
+92 -65
View File
@@ -8,30 +8,26 @@ pollution and accidental misuse.
Supports dynamic tool filtering based on project capabilities to optimize
context window usage. For example, Electron tools are only included for
Electron projects, not for Next.js or CLI projects.
This module now uses AGENT_CONFIGS from models.py as the single source of truth
for tool permissions. The get_allowed_tools() function remains the primary API
for backwards compatibility.
"""
from .models import (
AGENT_CONFIGS,
CONTEXT7_TOOLS,
BASE_READ_TOOLS,
BASE_WRITE_TOOLS,
ELECTRON_TOOLS,
GRAPHITI_MCP_TOOLS,
LINEAR_TOOLS,
PUPPETEER_TOOLS,
get_agent_config,
get_required_mcp_servers,
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_UPDATE_QA_STATUS,
TOOL_UPDATE_SUBTASK_STATUS,
is_electron_mcp_enabled,
)
from .registry import is_tools_available
def get_allowed_tools(
agent_type: str,
project_capabilities: dict | None = None,
linear_enabled: bool = False,
mcp_config: dict | None = None,
) -> list[str]:
"""
Get the list of allowed tools for a specific agent type.
@@ -39,82 +35,113 @@ def get_allowed_tools(
This ensures each agent only sees tools relevant to their role,
preventing context pollution and accidental misuse.
Uses AGENT_CONFIGS as the single source of truth for tool permissions.
Dynamic MCP tools are added based on project capabilities and required servers.
When project_capabilities is provided, MCP tools are filtered based on
the project type. For example:
- Electron projects get Electron MCP tools
- Web frontends (non-Electron) get Puppeteer MCP tools
- CLI projects get neither
Args:
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
agent_type: One of 'planner', 'coder', 'qa_reviewer', 'qa_fixer'
project_capabilities: Optional dict from detect_project_capabilities()
containing flags like is_electron, is_web_frontend, etc.
linear_enabled: Whether Linear integration is enabled for this project
mcp_config: Per-project MCP server toggles from .auto-claude/.env
Returns:
List of allowed tool names
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS
"""
# Get agent configuration (raises ValueError if unknown type)
config = get_agent_config(agent_type)
# Auto-claude tool mappings by agent type
tool_mappings = {
"planner": {
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"auto_claude": [
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
],
},
"coder": {
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"auto_claude": [
TOOL_UPDATE_SUBTASK_STATUS,
TOOL_GET_BUILD_PROGRESS,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_GET_SESSION_CONTEXT,
],
},
"qa_reviewer": {
"base": BASE_READ_TOOLS + ["Bash"], # Can run tests but not edit
"auto_claude": [
TOOL_GET_BUILD_PROGRESS,
TOOL_UPDATE_QA_STATUS,
TOOL_GET_SESSION_CONTEXT,
],
},
"pr_reviewer": {
# PR reviewers can ONLY read - no bash, no edits, no writes
# This prevents the agent from switching branches or making changes
"base": BASE_READ_TOOLS,
"auto_claude": [], # No auto-claude tools needed for PR review
},
"qa_fixer": {
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"auto_claude": [
TOOL_UPDATE_SUBTASK_STATUS,
TOOL_GET_BUILD_PROGRESS,
TOOL_UPDATE_QA_STATUS,
TOOL_RECORD_GOTCHA,
],
},
}
# Start with base tools from config
tools = list(config.get("tools", []))
if agent_type not in tool_mappings:
# Default to coder tools
agent_type = "coder"
# Get required MCP servers for this agent
required_servers = get_required_mcp_servers(
agent_type,
project_capabilities,
linear_enabled,
mcp_config,
)
mapping = tool_mappings[agent_type]
tools = mapping["base"] + mapping["auto_claude"]
# Add auto-claude tools ONLY if the MCP server is available
# This prevents allowing tools that won't work because the server isn't running
if "auto-claude" in required_servers and is_tools_available():
tools.extend(config.get("auto_claude_tools", []))
# Add MCP tool names based on required servers
tools.extend(_get_mcp_tools_for_servers(required_servers))
# Add MCP tools for QA agents only, based on project capabilities
if agent_type in ("qa_reviewer", "qa_fixer"):
tools.extend(_get_qa_mcp_tools(project_capabilities))
return tools
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
def _get_qa_mcp_tools(project_capabilities: dict | None) -> list[str]:
"""
Get the list of MCP tools for a list of required servers.
Get the list of MCP tools for QA agents based on project capabilities.
Maps server names to their corresponding tool lists.
This function determines which MCP tools to include based on:
1. Project type detection (Electron, web frontend, etc.)
2. Environment variables (ELECTRON_MCP_ENABLED)
Args:
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
project_capabilities: Dict from detect_project_capabilities() or None
Returns:
List of MCP tool names for all specified servers
List of MCP tool names to include
"""
tools = []
for server in servers:
if server == "context7":
tools.extend(CONTEXT7_TOOLS)
elif server == "linear":
tools.extend(LINEAR_TOOLS)
elif server == "graphiti":
tools.extend(GRAPHITI_MCP_TOOLS)
elif server == "electron":
# If no capabilities provided, fall back to legacy behavior
# (check env var only)
if project_capabilities is None:
if is_electron_mcp_enabled():
tools.extend(ELECTRON_TOOLS)
elif server == "puppeteer":
tools.extend(PUPPETEER_TOOLS)
# auto-claude tools are already added via config["auto_claude_tools"]
return tools
# Project-capability-based tool selection
is_electron = project_capabilities.get("is_electron", False)
is_web_frontend = project_capabilities.get("is_web_frontend", False)
# Electron projects get Electron MCP tools (if enabled)
if is_electron and is_electron_mcp_enabled():
tools.extend(ELECTRON_TOOLS)
# Web frontends (non-Electron) get Puppeteer tools
# Puppeteer is always available, no env var check needed
if is_web_frontend and not is_electron:
tools.extend(PUPPETEER_TOOLS)
return tools
def get_all_agent_types() -> list[str]:
"""
Get all registered agent types.
Returns:
Sorted list of all agent type identifiers
"""
return sorted(AGENT_CONFIGS.keys())
@@ -75,15 +75,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
content = self._read_file("Cargo.toml")
self._detect_rust_framework(content)
# Swift/iOS detection (check BEFORE Ruby - iOS projects often have Gemfile for CocoaPods/Fastlane)
elif self._exists("Package.swift") or any(self.path.glob("*.xcodeproj")):
self.analysis["language"] = "Swift"
if self._exists("Package.swift"):
self.analysis["package_manager"] = "Swift Package Manager"
else:
self.analysis["package_manager"] = "Xcode"
self._detect_swift_framework()
# Ruby detection
elif self._exists("Gemfile"):
self.analysis["language"] = "Ruby"
@@ -299,115 +290,12 @@ class FrameworkAnalyzer(BaseAnalyzer):
if "sidekiq" in content.lower():
self.analysis["task_queue"] = "Sidekiq"
def _detect_swift_framework(self) -> None:
"""Detect Swift/iOS framework and dependencies."""
try:
# Scan Swift files for imports, excluding hidden/vendor dirs
swift_files = []
for swift_file in self.path.rglob("*.swift"):
# Skip hidden directories, node_modules, .worktrees, etc.
if any(
part.startswith(".") or part in ("node_modules", "Pods", "Carthage")
for part in swift_file.parts
):
continue
swift_files.append(swift_file)
if len(swift_files) >= 50: # Limit for performance
break
imports = set()
for swift_file in swift_files:
try:
content = swift_file.read_text(encoding="utf-8", errors="ignore")
for line in content.split("\n"):
line = line.strip()
if line.startswith("import "):
module = line.replace("import ", "").split()[0]
imports.add(module)
except Exception:
continue
# Detect UI framework
if "SwiftUI" in imports:
self.analysis["framework"] = "SwiftUI"
self.analysis["type"] = "mobile"
elif "UIKit" in imports:
self.analysis["framework"] = "UIKit"
self.analysis["type"] = "mobile"
elif "AppKit" in imports:
self.analysis["framework"] = "AppKit"
self.analysis["type"] = "desktop"
# Detect iOS/Apple frameworks
apple_frameworks = []
framework_map = {
"Combine": "Combine",
"CoreData": "CoreData",
"MapKit": "MapKit",
"WidgetKit": "WidgetKit",
"CoreLocation": "CoreLocation",
"StoreKit": "StoreKit",
"CloudKit": "CloudKit",
"ActivityKit": "ActivityKit",
"UserNotifications": "UserNotifications",
}
for key, name in framework_map.items():
if key in imports:
apple_frameworks.append(name)
if apple_frameworks:
self.analysis["apple_frameworks"] = apple_frameworks
# Detect SPM dependencies from Package.swift or xcodeproj
dependencies = self._detect_spm_dependencies()
if dependencies:
self.analysis["spm_dependencies"] = dependencies
except Exception:
# Silently fail if Swift detection has issues
pass
def _detect_spm_dependencies(self) -> list[str]:
"""Detect Swift Package Manager dependencies."""
dependencies = []
# Try Package.swift first
if self._exists("Package.swift"):
content = self._read_file("Package.swift")
# Look for .package(url: "...", patterns
import re
urls = re.findall(r'\.package\s*\([^)]*url:\s*"([^"]+)"', content)
for url in urls:
# Extract package name from URL
name = url.rstrip("/").split("/")[-1].replace(".git", "")
if name:
dependencies.append(name)
# Also check xcodeproj for XCRemoteSwiftPackageReference
for xcodeproj in self.path.glob("*.xcodeproj"):
pbxproj = xcodeproj / "project.pbxproj"
if pbxproj.exists():
try:
content = pbxproj.read_text(encoding="utf-8", errors="ignore")
import re
# Match repositoryURL patterns
urls = re.findall(r'repositoryURL\s*=\s*"([^"]+)"', content)
for url in urls:
name = url.rstrip("/").split("/")[-1].replace(".git", "")
if name and name not in dependencies:
dependencies.append(name)
except Exception:
continue
return dependencies
def _detect_node_package_manager(self) -> str:
"""Detect Node.js package manager."""
if self._exists("pnpm-lock.yaml"):
return "pnpm"
elif self._exists("yarn.lock"):
return "yarn"
elif self._exists("bun.lockb") or self._exists("bun.lock"):
elif self._exists("bun.lockb"):
return "bun"
return "npm"
+13 -13
View File
@@ -366,19 +366,19 @@ async def run_insight_extraction(
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
try:
# Use simple_client for insight extraction
from pathlib import Path
from core.simple_client import create_simple_client
client = create_simple_client(
agent_type="insights",
model=model,
system_prompt=(
"You are an expert code analyst. You extract structured insights from coding sessions. "
"Always respond with valid JSON only, no markdown formatting or explanations."
),
cwd=Path(cwd) if cwd else None,
# Create a minimal SDK client for insight extraction
# No tools needed - just text generation
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt=(
"You are an expert code analyst. You extract structured insights from coding sessions. "
"Always respond with valid JSON only, no markdown formatting or explanations."
),
allowed_tools=[], # No tools needed for extraction
max_turns=1, # Single turn extraction
cwd=cwd,
)
)
# Use async context manager
+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"
+23 -7
View File
@@ -15,6 +15,10 @@ _PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from ui import (
Icons,
icon,
)
from .batch_commands import (
handle_batch_cleanup_command,
@@ -197,6 +201,13 @@ Environment Variables:
help="Show human review/approval status for a spec",
)
# Dev mode (deprecated)
parser.add_argument(
"--dev",
action="store_true",
help="[Deprecated] No longer has any effect - kept for compatibility",
)
# Non-interactive mode (for UI/automation)
parser.add_argument(
"--auto-continue",
@@ -276,14 +287,19 @@ def main() -> None:
project_dir = get_project_dir(args.project_dir)
debug("run.py", f"Using project directory: {project_dir}")
# Get model from CLI arg or env var (None if not explicitly set)
# This allows get_phase_model() to fall back to task_metadata.json
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
# Get model (with env var fallback)
model = args.model or os.environ.get("AUTO_BUILD_MODEL", DEFAULT_MODEL)
# Note: --dev flag is deprecated but kept for API compatibility
if args.dev:
print(
f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now use .auto-claude/specs/\n"
)
# Handle --list command
if args.list:
print_banner()
print_specs_list(project_dir)
print_specs_list(project_dir, args.dev)
return
# Handle --list-worktrees command
@@ -321,14 +337,14 @@ def main() -> None:
sys.exit(1)
# Find the spec
debug("run.py", "Finding spec", spec_identifier=args.spec)
spec_dir = find_spec(project_dir, args.spec)
debug("run.py", "Finding spec", spec_identifier=args.spec, dev_mode=args.dev)
spec_dir = find_spec(project_dir, args.spec, args.dev)
if not spec_dir:
debug_error("run.py", "Spec not found", spec=args.spec)
print_banner()
print(f"\nError: Spec '{args.spec}' not found")
print("\nAvailable specs:")
print_specs_list(project_dir)
print_specs_list(project_dir, args.dev)
sys.exit(1)
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
+8 -4
View File
@@ -19,17 +19,18 @@ from workspace import get_existing_build_worktree
from .utils import get_specs_dir
def list_specs(project_dir: Path) -> list[dict]:
def list_specs(project_dir: Path, dev_mode: bool = False) -> list[dict]:
"""
List all specs in the project.
Args:
project_dir: Project root directory
dev_mode: If True, use dev/auto-claude/specs/
Returns:
List of spec info dicts with keys: number, name, path, status, progress
"""
specs_dir = get_specs_dir(project_dir)
specs_dir = get_specs_dir(project_dir, dev_mode)
specs = []
if not specs_dir.exists():
@@ -92,16 +93,19 @@ def list_specs(project_dir: Path) -> list[dict]:
return specs
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
def print_specs_list(
project_dir: Path, dev_mode: bool = False, auto_create: bool = True
) -> None:
"""Print a formatted list of all specs.
Args:
project_dir: Project root directory
dev_mode: If True, use dev/auto-claude/specs/
auto_create: If True and no specs exist, automatically launch spec creation
"""
import subprocess
specs = list_specs(project_dir)
specs = list_specs(project_dir, dev_mode)
if not specs:
print("\nNo specs found.")
+5 -2
View File
@@ -54,18 +54,21 @@ def setup_environment() -> Path:
return script_dir
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
def find_spec(
project_dir: Path, spec_identifier: str, dev_mode: bool = False
) -> Path | None:
"""
Find a spec by number or full name.
Args:
project_dir: Project root directory
spec_identifier: Either "001" or "001-feature-name"
dev_mode: If True, use dev/auto-claude/specs/
Returns:
Path to spec folder, or None if not found
"""
specs_dir = get_specs_dir(project_dir)
specs_dir = get_specs_dir(project_dir, dev_mode)
if specs_dir.exists():
# Try exact match first
+17 -25
View File
@@ -186,15 +186,9 @@ Fixes #N (if applicable)"""
return prompt
async def _call_claude(prompt: str) -> str:
"""Call Claude for commit message generation.
Reads model/thinking settings from environment variables:
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
"""
async def _call_claude_haiku(prompt: str) -> str:
"""Call Claude Haiku with low thinking for fast commit message generation."""
from core.auth import ensure_claude_code_oauth_token, get_auth_token
from core.model_config import get_utility_model_config
if not get_auth_token():
logger.warning("No authentication token found")
@@ -203,23 +197,19 @@ async def _call_claude(prompt: str) -> str:
ensure_claude_code_oauth_token()
try:
from core.simple_client import create_simple_client
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ImportError:
logger.warning("core.simple_client not available")
logger.warning("claude_agent_sdk not installed")
return ""
# Get model settings from environment (passed from frontend)
model, thinking_budget = get_utility_model_config()
logger.info(
f"Commit message using model={model}, thinking_budget={thinking_budget}"
)
client = create_simple_client(
agent_type="commit_message",
model=model,
system_prompt=SYSTEM_PROMPT,
max_thinking_tokens=thinking_budget,
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-haiku-4-5-20251001",
system_prompt=SYSTEM_PROMPT,
allowed_tools=[],
max_turns=1,
max_thinking_tokens=1024, # Low thinking for speed
)
)
try:
@@ -297,9 +287,11 @@ def generate_commit_message_sync(
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
result = pool.submit(
lambda: asyncio.run(_call_claude_haiku(prompt))
).result()
else:
result = asyncio.run(_call_claude(prompt))
result = asyncio.run(_call_claude_haiku(prompt))
if result:
return result
@@ -361,7 +353,7 @@ async def generate_commit_message(
# Call Claude
try:
result = await _call_claude(prompt)
result = await _call_claude_haiku(prompt)
if result:
return result
except Exception as e:
+17 -66
View File
@@ -34,30 +34,20 @@ SDK_ENV_VARS = [
def get_token_from_keychain() -> str | None:
"""
Get authentication token from system credential store.
Get authentication token from macOS Keychain.
Reads Claude Code credentials from:
- macOS: Keychain
- Windows: Credential Manager
- Linux: Not yet supported (use env var)
Reads Claude Code credentials from macOS Keychain and extracts the OAuth token.
Only works on macOS (Darwin platform).
Returns:
Token string if found, None otherwise
Token string if found in Keychain, None otherwise
"""
system = platform.system()
if system == "Darwin":
return _get_token_from_macos_keychain()
elif system == "Windows":
return _get_token_from_windows_credential_files()
else:
# Linux: secret-service not yet implemented
# Only attempt on macOS
if platform.system() != "Darwin":
return None
def _get_token_from_macos_keychain() -> str | None:
"""Get token from macOS Keychain."""
try:
# Query macOS Keychain for Claude Code credentials
result = subprocess.run(
[
"/usr/bin/security",
@@ -74,11 +64,14 @@ def _get_token_from_macos_keychain() -> str | None:
if result.returncode != 0:
return None
# Parse JSON response
credentials_json = result.stdout.strip()
if not credentials_json:
return None
data = json.loads(credentials_json)
# Extract OAuth token from nested structure
token = data.get("claudeAiOauth", {}).get("accessToken")
if not token:
@@ -91,45 +84,18 @@ def _get_token_from_macos_keychain() -> str | None:
return token
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
return None
def _get_token_from_windows_credential_files() -> str | None:
"""Get token from Windows credential files.
Claude Code on Windows stores credentials in ~/.claude/.credentials.json
"""
try:
# Claude Code stores credentials in ~/.claude/.credentials.json
cred_paths = [
os.path.expandvars(r"%USERPROFILE%\.claude\.credentials.json"),
os.path.expandvars(r"%USERPROFILE%\.claude\credentials.json"),
os.path.expandvars(r"%LOCALAPPDATA%\Claude\credentials.json"),
os.path.expandvars(r"%APPDATA%\Claude\credentials.json"),
]
for cred_path in cred_paths:
if os.path.exists(cred_path):
with open(cred_path, encoding="utf-8") as f:
data = json.load(f)
token = data.get("claudeAiOauth", {}).get("accessToken")
if token and token.startswith("sk-ant-oat01-"):
return token
return None
except (json.JSONDecodeError, KeyError, FileNotFoundError, Exception):
# Silently fail - this is a fallback mechanism
return None
def get_auth_token() -> str | None:
"""
Get authentication token from environment variables or system credential store.
Get authentication token from environment variables or macOS Keychain.
Checks multiple sources in priority order:
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
3. System credential store (macOS Keychain, Windows Credential Manager)
3. macOS Keychain (if on Darwin platform)
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
silent billing to user's API credits when OAuth is misconfigured.
@@ -143,7 +109,7 @@ def get_auth_token() -> str | None:
if token:
return token
# Fallback to system credential store
# Fallback to macOS Keychain
return get_token_from_keychain()
@@ -154,15 +120,9 @@ def get_auth_token_source() -> str | None:
if os.environ.get(var):
return var
# Check if token came from system credential store
# Check if token came from macOS Keychain
if get_token_from_keychain():
system = platform.system()
if system == "Darwin":
return "macOS Keychain"
elif system == "Windows":
return "Windows Credential Files"
else:
return "System Credential Store"
return "macOS Keychain"
return None
@@ -182,22 +142,13 @@ def require_auth_token() -> str:
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
)
# Provide platform-specific guidance
system = platform.system()
if system == "Darwin":
if platform.system() == "Darwin":
error_msg += (
"To authenticate:\n"
" 1. Run: claude setup-token\n"
" 2. The token will be saved to macOS Keychain automatically\n\n"
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
)
elif system == "Windows":
error_msg += (
"To authenticate:\n"
" 1. Run: claude setup-token\n"
" 2. The token should be saved to Windows Credential Manager\n\n"
"If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n"
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
)
else:
error_msg += (
"To authenticate:\n"
+186 -575
View File
@@ -6,131 +6,20 @@ Functions for creating and configuring the Claude Agent SDK client.
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
and proper tool/MCP configuration. For simple message calls without full agent sessions,
use `create_simple_client()` from `core.simple_client`.
The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
single source of truth for phase-aware tool and MCP server configuration.
use `ClaudeSDKClient` directly with `allowed_tools=[]` and `max_turns=1`.
"""
import copy
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# =============================================================================
# Project Index Cache
# =============================================================================
# Caches project index and capabilities to avoid reloading on every create_client() call.
# This significantly reduces the time to create new agent sessions.
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
def _get_cached_project_data(
project_dir: Path,
) -> tuple[dict[str, Any], dict[str, bool]]:
"""
Get project index and capabilities with caching.
Args:
project_dir: Path to the project directory
Returns:
Tuple of (project_index, project_capabilities)
"""
key = str(project_dir.resolve())
now = time.time()
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Check cache with lock
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = now - cached_time
if cache_age < _CACHE_TTL_SECONDS:
if debug:
print(
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
)
logger.debug(f"Using cached project index for {project_dir}")
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
elif debug:
print(
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
)
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
load_start = time.time()
logger.debug(f"Loading project index for {project_dir}")
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
if debug:
load_duration = (time.time() - load_start) * 1000
print(
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
)
# Store in cache with lock - use double-checked locking pattern
# Re-check if another thread populated the cache while we were loading
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = time.time() - cached_time
if cache_age < _CACHE_TTL_SECONDS:
# Another thread already cached valid data while we were loading
if debug:
print(
"[ClientCache] Cache was populated by another thread, using cached data"
)
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
# Either no cache entry or it's expired - store our fresh data
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
# Return the freshly loaded data (no need to copy since it's not from cache)
return project_index, project_capabilities
def invalidate_project_cache(project_dir: Path | None = None) -> None:
"""
Invalidate the project index cache.
Args:
project_dir: Specific project to invalidate, or None to clear all
"""
with _CACHE_LOCK:
if project_dir is None:
_PROJECT_INDEX_CACHE.clear()
logger.debug("Cleared all project index cache entries")
else:
key = str(project_dir.resolve())
if key in _PROJECT_INDEX_CACHE:
del _PROJECT_INDEX_CACHE[key]
logger.debug(f"Invalidated project index cache for {project_dir}")
from agents.tools_pkg import (
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
GRAPHITI_MCP_TOOLS,
LINEAR_TOOLS,
PUPPETEER_TOOLS,
from auto_claude_tools import (
create_auto_claude_mcp_server,
get_allowed_tools,
get_required_mcp_servers,
is_tools_available,
)
from auto_claude_tools import (
get_allowed_tools as get_agent_allowed_tools,
)
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher
from core.auth import get_sdk_env_vars, require_auth_token
@@ -139,245 +28,6 @@ from prompts_pkg.project_context import detect_project_capabilities, load_projec
from security import bash_security_hook
def _validate_custom_mcp_server(server: dict) -> bool:
"""
Validate a custom MCP server configuration for security.
Ensures only expected fields with valid types are present.
Rejects configurations that could lead to command injection.
Args:
server: Dict representing a custom MCP server configuration
Returns:
True if valid, False otherwise
"""
if not isinstance(server, dict):
return False
# Required fields
required_fields = {"id", "name", "type"}
if not all(field in server for field in required_fields):
logger.warning(
f"Custom MCP server missing required fields: {required_fields - server.keys()}"
)
return False
# Validate field types
if not isinstance(server.get("id"), str) or not server["id"]:
return False
if not isinstance(server.get("name"), str) or not server["name"]:
return False
# FIX: Changed from ('command', 'url') to ('command', 'http') to match actual usage
if server.get("type") not in ("command", "http"):
logger.warning(f"Invalid MCP server type: {server.get('type')}")
return False
# Allowlist of safe executable commands for MCP servers
# Only allow known package managers and interpreters - NO shell commands
SAFE_COMMANDS = {
"npx",
"npm",
"node",
"python",
"python3",
"uv",
"uvx",
}
# Blocklist of dangerous shell commands that should never be allowed
DANGEROUS_COMMANDS = {
"bash",
"sh",
"cmd",
"powershell",
"pwsh", # PowerShell Core
"/bin/bash",
"/bin/sh",
"/bin/zsh",
"/usr/bin/bash",
"/usr/bin/sh",
"zsh",
"fish",
}
# Dangerous interpreter flags that allow arbitrary code execution
# Covers Python (-e, -c, -m, -p), Node.js (--eval, --print, loaders), and general
DANGEROUS_FLAGS = {
"--eval",
"-e",
"-c",
"--exec",
"-m", # Python module execution
"-p", # Python eval+print
"--print", # Node.js print
"--input-type=module", # Node.js ES module mode
"--experimental-loader", # Node.js custom loaders
"--require", # Node.js require injection
"-r", # Node.js require shorthand
}
# Type-specific validation
if server["type"] == "command":
if not isinstance(server.get("command"), str) or not server["command"]:
logger.warning("Command-type MCP server missing 'command' field")
return False
# SECURITY FIX: Validate command is in safe list and not in dangerous list
command = server.get("command", "")
# Reject paths - commands must be bare names only (no / or \)
# This prevents path traversal like '/custom/malicious' or './evil'
if "/" in command or "\\" in command:
logger.warning(
f"Rejected command with path in MCP server: {command}. "
f"Commands must be bare names without path separators."
)
return False
if command in DANGEROUS_COMMANDS:
logger.warning(
f"Rejected dangerous command in MCP server: {command}. "
f"Shell commands are not allowed for security reasons."
)
return False
if command not in SAFE_COMMANDS:
logger.warning(
f"Rejected unknown command in MCP server: {command}. "
f"Only allowed commands: {', '.join(sorted(SAFE_COMMANDS))}"
)
return False
# Validate args is a list of strings if present
if "args" in server:
if not isinstance(server["args"], list):
return False
if not all(isinstance(arg, str) for arg in server["args"]):
return False
# Check for dangerous interpreter flags that allow code execution
for arg in server["args"]:
if arg in DANGEROUS_FLAGS:
logger.warning(
f"Rejected dangerous flag '{arg}' in MCP server args. "
f"Interpreter code execution flags are not allowed."
)
return False
elif server["type"] == "http":
if not isinstance(server.get("url"), str) or not server["url"]:
logger.warning("HTTP-type MCP server missing 'url' field")
return False
# Validate headers is a dict of strings if present
if "headers" in server:
if not isinstance(server["headers"], dict):
return False
if not all(
isinstance(k, str) and isinstance(v, str)
for k, v in server["headers"].items()
):
return False
# Optional description must be string if present
if "description" in server and not isinstance(server.get("description"), str):
return False
# Reject any unexpected fields that could be exploited
allowed_fields = {
"id",
"name",
"type",
"command",
"args",
"url",
"headers",
"description",
}
unexpected_fields = set(server.keys()) - allowed_fields
if unexpected_fields:
logger.warning(f"Custom MCP server has unexpected fields: {unexpected_fields}")
return False
return True
def load_project_mcp_config(project_dir: Path) -> dict:
"""
Load MCP configuration from project's .auto-claude/.env file.
Returns a dict of MCP-related env vars:
- CONTEXT7_ENABLED (default: true)
- LINEAR_MCP_ENABLED (default: true)
- ELECTRON_MCP_ENABLED (default: false)
- PUPPETEER_MCP_ENABLED (default: false)
- AGENT_MCP_<agent>_ADD (per-agent MCP additions)
- AGENT_MCP_<agent>_REMOVE (per-agent MCP removals)
- CUSTOM_MCP_SERVERS (JSON array of custom server configs)
Args:
project_dir: Path to the project directory
Returns:
Dict of MCP configuration values (string values, except CUSTOM_MCP_SERVERS which is parsed JSON)
"""
env_path = project_dir / ".auto-claude" / ".env"
if not env_path.exists():
return {}
config = {}
mcp_keys = {
"CONTEXT7_ENABLED",
"LINEAR_MCP_ENABLED",
"ELECTRON_MCP_ENABLED",
"PUPPETEER_MCP_ENABLED",
}
try:
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("\"'")
# Include global MCP toggles
if key in mcp_keys:
config[key] = value
# Include per-agent MCP overrides (AGENT_MCP_<agent>_ADD/REMOVE)
elif key.startswith("AGENT_MCP_"):
config[key] = value
# Include custom MCP servers (parse JSON with schema validation)
elif key == "CUSTOM_MCP_SERVERS":
try:
parsed = json.loads(value)
if not isinstance(parsed, list):
logger.warning(
"CUSTOM_MCP_SERVERS must be a JSON array"
)
config["CUSTOM_MCP_SERVERS"] = []
else:
# Validate each server and filter out invalid ones
valid_servers = []
for i, server in enumerate(parsed):
if _validate_custom_mcp_server(server):
valid_servers.append(server)
else:
logger.warning(
f"Skipping invalid custom MCP server at index {i}"
)
config["CUSTOM_MCP_SERVERS"] = valid_servers
except json.JSONDecodeError:
logger.warning(
f"Failed to parse CUSTOM_MCP_SERVERS JSON: {value}"
)
config["CUSTOM_MCP_SERVERS"] = []
except Exception as e:
logger.debug(f"Failed to load project MCP config from {env_path}: {e}")
return config
def is_graphiti_mcp_enabled() -> bool:
"""
Check if Graphiti MCP server integration is enabled.
@@ -409,28 +59,78 @@ def get_electron_debug_port() -> int:
return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222"))
def should_use_claude_md() -> bool:
"""Check if CLAUDE.md instructions should be included in system prompt."""
return os.environ.get("USE_CLAUDE_MD", "").lower() == "true"
# Puppeteer MCP tools for browser automation
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
PUPPETEER_TOOLS = [
"mcp__puppeteer__puppeteer_connect_active_tab",
"mcp__puppeteer__puppeteer_navigate",
"mcp__puppeteer__puppeteer_screenshot",
"mcp__puppeteer__puppeteer_click",
"mcp__puppeteer__puppeteer_fill",
"mcp__puppeteer__puppeteer_select",
"mcp__puppeteer__puppeteer_hover",
"mcp__puppeteer__puppeteer_evaluate",
]
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
LINEAR_TOOLS = [
"mcp__linear-server__list_teams",
"mcp__linear-server__get_team",
"mcp__linear-server__list_projects",
"mcp__linear-server__get_project",
"mcp__linear-server__create_project",
"mcp__linear-server__update_project",
"mcp__linear-server__list_issues",
"mcp__linear-server__get_issue",
"mcp__linear-server__create_issue",
"mcp__linear-server__update_issue",
"mcp__linear-server__list_comments",
"mcp__linear-server__create_comment",
"mcp__linear-server__list_issue_statuses",
"mcp__linear-server__list_issue_labels",
"mcp__linear-server__list_users",
"mcp__linear-server__get_user",
]
def load_claude_md(project_dir: Path) -> str | None:
"""
Load CLAUDE.md content from project root if it exists.
# Context7 MCP tools for documentation lookup (always enabled)
CONTEXT7_TOOLS = [
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
]
Args:
project_dir: Root directory of the project
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set)
# See: https://github.com/getzep/graphiti
GRAPHITI_MCP_TOOLS = [
"mcp__graphiti-memory__search_nodes", # Search entity summaries
"mcp__graphiti-memory__search_facts", # Search relationships between entities
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
]
Returns:
Content of CLAUDE.md if found, None otherwise
"""
claude_md_path = project_dir / "CLAUDE.md"
if claude_md_path.exists():
try:
return claude_md_path.read_text(encoding="utf-8")
except Exception:
return None
return None
# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set)
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
# See GitHub issue #74.
ELECTRON_TOOLS = [
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
"mcp__electron__send_command_to_electron", # Send commands (click, fill, evaluate JS)
"mcp__electron__read_electron_logs", # Read console logs from Electron app
]
# Built-in tools
BUILTIN_TOOLS = [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
]
def create_client(
@@ -439,41 +139,25 @@ def create_client(
model: str,
agent_type: str = "coder",
max_thinking_tokens: int | None = None,
output_format: dict | None = None,
agents: dict | None = None,
) -> ClaudeSDKClient:
"""
Create a Claude Agent SDK client with multi-layered security.
Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration.
Only starts MCP servers that the agent actually needs, reducing context
window bloat and startup latency.
Args:
project_dir: Root directory for the project (working directory)
spec_dir: Directory containing the spec (for settings file)
model: Claude model to use
agent_type: Agent type identifier from AGENT_CONFIGS
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
This determines which custom auto-claude tools are available.
max_thinking_tokens: Token budget for extended thinking (None = disabled)
- ultrathink: 16000 (spec creation)
- high: 10000 (QA review)
- medium: 5000 (planning, validation)
- None: disabled (coding)
output_format: Optional structured output format for validated JSON responses.
Use {"type": "json_schema", "schema": Model.model_json_schema()}
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
agents: Optional dict of subagent definitions for SDK parallel execution.
Format: {"agent-name": {"description": "...", "prompt": "...",
"tools": [...], "model": "inherit"}}
See: https://platform.claude.com/docs/en/agent-sdk/subagents
Returns:
Configured ClaudeSDKClient
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS
Security layers (defense in depth):
1. Sandbox - OS-level bash command isolation prevents filesystem escape
2. Permissions - File operations restricted to project_dir only
@@ -497,93 +181,71 @@ def create_client(
# Load project capabilities for dynamic MCP tool selection
# This enables context-aware tool injection based on project type
# Uses caching to avoid reloading on every create_client() call
project_index, project_capabilities = _get_cached_project_data(project_dir)
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
# Load per-project MCP configuration from .auto-claude/.env
mcp_config = load_project_mcp_config(project_dir)
# Build the list of allowed tools
# Start with agent-specific tools (includes base tools + auto-claude tools)
# Pass project capabilities for dynamic MCP tool filtering
if auto_claude_tools_enabled:
allowed_tools_list = get_agent_allowed_tools(agent_type, project_capabilities)
else:
allowed_tools_list = [*BUILTIN_TOOLS]
# Get allowed tools using phase-aware configuration
# This respects AGENT_CONFIGS and only includes tools the agent needs
# Also respects per-project MCP configuration
allowed_tools_list = get_allowed_tools(
agent_type,
project_capabilities,
linear_enabled,
mcp_config,
)
# Check if Graphiti MCP is enabled
graphiti_mcp_enabled = is_graphiti_mcp_enabled()
# Get required MCP servers for this agent type
# This is the key optimization - only start servers the agent needs
# Now also respects per-project MCP configuration
required_servers = get_required_mcp_servers(
agent_type,
project_capabilities,
linear_enabled,
mcp_config,
)
# Check if Electron MCP is enabled (for QA agents testing Electron apps)
electron_mcp_enabled = is_electron_mcp_enabled()
# Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers)
graphiti_mcp_enabled = "graphiti" in required_servers
# Add external MCP tools based on project capabilities
# This saves context window by only including relevant tools
allowed_tools_list.extend(CONTEXT7_TOOLS) # Always available
if linear_enabled:
allowed_tools_list.extend(LINEAR_TOOLS)
if graphiti_mcp_enabled:
allowed_tools_list.extend(GRAPHITI_MCP_TOOLS)
# Note: Browser automation tools (ELECTRON_TOOLS, PUPPETEER_TOOLS) are already
# added by get_agent_allowed_tools() via _get_qa_mcp_tools() for QA agents
# Determine browser tools for permissions (already in allowed_tools_list)
# Determine which browser automation tools to allow based on project type
# Note: Must check "not is_electron" for Puppeteer to avoid tool mismatch
# when Electron MCP is disabled for an Electron project
browser_tools_permissions = []
if "electron" in required_servers:
browser_tools_permissions = ELECTRON_TOOLS
elif "puppeteer" in required_servers:
browser_tools_permissions = PUPPETEER_TOOLS
if agent_type in ("qa_reviewer", "qa_fixer"):
if project_capabilities.get("is_electron") and electron_mcp_enabled:
browser_tools_permissions = ELECTRON_TOOLS
elif project_capabilities.get(
"is_web_frontend"
) and not project_capabilities.get("is_electron"):
# Only add Puppeteer for non-Electron web frontends
browser_tools_permissions = PUPPETEER_TOOLS
# Create comprehensive security settings
# Note: Using both relative paths ("./**") and absolute paths to handle
# cases where Claude uses absolute paths for file operations
project_path_str = str(project_dir.resolve())
spec_path_str = str(spec_dir.resolve())
# Note: Using relative paths ("./**") restricts access to project directory
# since cwd is set to project_dir
security_settings = {
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
"permissions": {
"defaultMode": "acceptEdits", # Auto-approve edits within allowed directories
"allow": [
# Allow all file operations within the project directory
# Include both relative (./**) and absolute paths for compatibility
"Read(./**)",
"Write(./**)",
"Edit(./**)",
"Glob(./**)",
"Grep(./**)",
# Also allow absolute paths (Claude sometimes uses full paths)
f"Read({project_path_str}/**)",
f"Write({project_path_str}/**)",
f"Edit({project_path_str}/**)",
f"Glob({project_path_str}/**)",
f"Grep({project_path_str}/**)",
# Allow spec directory explicitly (needed when spec is in worktree)
f"Read({spec_path_str}/**)",
f"Write({spec_path_str}/**)",
f"Edit({spec_path_str}/**)",
# Bash permission granted here, but actual commands are validated
# by the bash_security_hook (see security.py for allowed commands)
"Bash(*)",
# Allow web tools for documentation and research
"WebFetch(*)",
"WebSearch(*)",
# Allow MCP tools based on required servers
# Format: tool_name(*) allows all arguments
*(
[f"{tool}(*)" for tool in CONTEXT7_TOOLS]
if "context7" in required_servers
else []
),
*(
[f"{tool}(*)" for tool in LINEAR_TOOLS]
if "linear" in required_servers
else []
),
*(
[f"{tool}(*)" for tool in GRAPHITI_MCP_TOOLS]
if graphiti_mcp_enabled
else []
),
*[f"{tool}(*)" for tool in browser_tools_permissions],
# Allow Context7 MCP tools for documentation lookup
*CONTEXT7_TOOLS,
# Allow Linear MCP tools for project management (if enabled)
*(LINEAR_TOOLS if linear_enabled else []),
# Allow Graphiti MCP tools for knowledge graph memory (if enabled)
*(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []),
# Allow browser automation tools based on project type
*browser_tools_permissions,
],
},
}
@@ -602,26 +264,24 @@ def create_client(
else:
print(" - Extended thinking: disabled")
# Build list of MCP servers for display based on required_servers
mcp_servers_list = []
if "context7" in required_servers:
mcp_servers_list.append("context7 (documentation)")
if "electron" in required_servers:
mcp_servers_list.append(
f"electron (desktop automation, port {get_electron_debug_port()})"
)
if "puppeteer" in required_servers:
mcp_servers_list.append("puppeteer (browser automation)")
if "linear" in required_servers:
# Build list of MCP servers for display
mcp_servers_list = ["context7 (documentation)"]
if agent_type in ("qa_reviewer", "qa_fixer"):
if project_capabilities.get("is_electron") and electron_mcp_enabled:
mcp_servers_list.append(
f"electron (desktop automation, port {get_electron_debug_port()})"
)
elif project_capabilities.get(
"is_web_frontend"
) and not project_capabilities.get("is_electron"):
mcp_servers_list.append("puppeteer (browser automation)")
if linear_enabled:
mcp_servers_list.append("linear (project management)")
if graphiti_mcp_enabled:
mcp_servers_list.append("graphiti-memory (knowledge graph)")
if "auto-claude" in required_servers and auto_claude_tools_enabled:
if auto_claude_tools_enabled:
mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
if mcp_servers_list:
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
else:
print(" - MCP servers: none (minimal configuration)")
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
# Show detected project capabilities for QA agents
if agent_type in ("qa_reviewer", "qa_fixer") and any(project_capabilities.values()):
@@ -633,125 +293,76 @@ def create_client(
print(f" - Project capabilities: {', '.join(caps)}")
print()
# Configure MCP servers - ONLY start servers that are required
# This is the key optimization to reduce context bloat and startup latency
mcp_servers = {}
# Configure MCP servers
mcp_servers = {
"context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp"]},
}
if "context7" in required_servers:
mcp_servers["context7"] = {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
}
# Add browser automation MCP server based on project type
if agent_type in ("qa_reviewer", "qa_fixer"):
if project_capabilities.get("is_electron") and electron_mcp_enabled:
# Electron MCP for desktop apps
# Electron app must be started with --remote-debugging-port=<port>
mcp_servers["electron"] = {
"command": "npm",
"args": ["exec", "electron-mcp-server"],
}
elif project_capabilities.get(
"is_web_frontend"
) and not project_capabilities.get("is_electron"):
# Puppeteer for web frontends (not Electron)
mcp_servers["puppeteer"] = {
"command": "npx",
"args": ["puppeteer-mcp-server"],
}
if "electron" in required_servers:
# Electron MCP for desktop apps
# Electron app must be started with --remote-debugging-port=<port>
mcp_servers["electron"] = {
"command": "npm",
"args": ["exec", "electron-mcp-server"],
}
if "puppeteer" in required_servers:
# Puppeteer for web frontends (not Electron)
mcp_servers["puppeteer"] = {
"command": "npx",
"args": ["puppeteer-mcp-server"],
}
if "linear" in required_servers:
# Add Linear MCP server if enabled
if linear_enabled:
mcp_servers["linear"] = {
"type": "http",
"url": "https://mcp.linear.app/mcp",
"headers": {"Authorization": f"Bearer {linear_api_key}"},
}
# Graphiti MCP server for knowledge graph memory
# Add Graphiti MCP server if enabled
# Graphiti MCP server for knowledge graph memory (uses embedded LadybugDB)
if graphiti_mcp_enabled:
mcp_servers["graphiti-memory"] = {
"type": "http",
"url": get_graphiti_mcp_url(),
}
# Add custom auto-claude MCP server if required and available
if "auto-claude" in required_servers and auto_claude_tools_enabled:
# Add custom auto-claude MCP server if available
auto_claude_mcp_server = None
if auto_claude_tools_enabled:
auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
if auto_claude_mcp_server:
mcp_servers["auto-claude"] = auto_claude_mcp_server
# Add custom MCP servers from project config
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
for custom in custom_servers:
server_id = custom.get("id")
if not server_id:
continue
# Only include if agent has it in their effective server list
if server_id not in required_servers:
continue
server_type = custom.get("type", "command")
if server_type == "command":
mcp_servers[server_id] = {
"command": custom.get("command", "npx"),
"args": custom.get("args", []),
}
elif server_type == "http":
server_config = {
"type": "http",
"url": custom.get("url", ""),
}
if custom.get("headers"):
server_config["headers"] = custom["headers"]
mcp_servers[server_id] = server_config
# Build system prompt
base_prompt = (
f"You are an expert full-stack developer building production-quality software. "
f"Your working directory is: {project_dir.resolve()}\n"
f"Your filesystem access is RESTRICTED to this directory only. "
f"Use relative paths (starting with ./) for all file operations. "
f"Never use absolute paths or try to access files outside your working directory.\n\n"
f"You follow existing code patterns, write clean maintainable code, and verify "
f"your work through thorough testing. You communicate progress through Git commits "
f"and build-progress.txt updates."
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt=(
f"You are an expert full-stack developer building production-quality software. "
f"Your working directory is: {project_dir.resolve()}\n"
f"Your filesystem access is RESTRICTED to this directory only. "
f"Use relative paths (starting with ./) for all file operations. "
f"Never use absolute paths or try to access files outside your working directory.\n\n"
f"You follow existing code patterns, write clean maintainable code, and verify "
f"your work through thorough testing. You communicate progress through Git commits "
f"and build-progress.txt updates."
),
allowed_tools=allowed_tools_list,
mcp_servers=mcp_servers,
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
],
},
max_turns=1000,
cwd=str(project_dir.resolve()),
settings=str(settings_file.resolve()),
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
max_thinking_tokens=max_thinking_tokens, # Extended thinking budget
)
)
# Include CLAUDE.md if enabled and present
if should_use_claude_md():
claude_md_content = load_claude_md(project_dir)
if claude_md_content:
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
print(" - CLAUDE.md: included in system prompt")
else:
print(" - CLAUDE.md: not found in project root")
else:
print(" - CLAUDE.md: disabled by project settings")
print()
# Build options dict, conditionally including output_format
options_kwargs = {
"model": model,
"system_prompt": base_prompt,
"allowed_tools": allowed_tools_list,
"mcp_servers": mcp_servers,
"hooks": {
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
],
},
"max_turns": 1000,
"cwd": str(project_dir.resolve()),
"settings": str(settings_file.resolve()),
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
}
# Add structured output format if specified
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
if output_format:
options_kwargs["output_format"] = output_format
# Add subagent definitions if specified
# See: https://platform.claude.com/docs/en/agent-sdk/subagents
if agents:
options_kwargs["agents"] = agents
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
-68
View File
@@ -1,68 +0,0 @@
"""
Model Configuration Utilities
==============================
Shared utilities for reading and parsing model configuration from environment variables.
Used by both commit_message.py and merge resolver.
"""
import logging
import os
logger = logging.getLogger(__name__)
# Default model for utility operations (commit messages, merge resolution)
DEFAULT_UTILITY_MODEL = "claude-haiku-4-5-20251001"
def get_utility_model_config(
default_model: str = DEFAULT_UTILITY_MODEL,
) -> tuple[str, int | None]:
"""
Get utility model configuration from environment variables.
Reads UTILITY_MODEL_ID and UTILITY_THINKING_BUDGET from environment,
with sensible defaults and validation.
Args:
default_model: Default model ID to use if UTILITY_MODEL_ID not set
Returns:
Tuple of (model_id, thinking_budget) where thinking_budget is None
if extended thinking is disabled, or an int representing token budget
"""
model = os.environ.get("UTILITY_MODEL_ID", default_model)
thinking_budget_str = os.environ.get("UTILITY_THINKING_BUDGET", "")
# Parse thinking budget: empty string = disabled (None), number = budget tokens
# Note: 0 is treated as "disable thinking" (same as None) since 0 tokens is meaningless
thinking_budget: int | None
if not thinking_budget_str:
# Empty string means "none" level - disable extended thinking
thinking_budget = None
else:
try:
parsed_budget = int(thinking_budget_str)
# Validate positive values - 0 or negative are invalid
# 0 would mean "thinking enabled but 0 tokens" which is meaningless
if parsed_budget <= 0:
if parsed_budget == 0:
# Zero means disable thinking (same as empty string)
logger.debug(
"UTILITY_THINKING_BUDGET=0 interpreted as 'disable thinking'"
)
thinking_budget = None
else:
logger.warning(
f"Negative UTILITY_THINKING_BUDGET value '{thinking_budget_str}' not allowed, using default 1024"
)
thinking_budget = 1024
else:
thinking_budget = parsed_budget
except ValueError:
logger.warning(
f"Invalid UTILITY_THINKING_BUDGET value '{thinking_budget_str}', using default 1024"
)
thinking_budget = 1024
return model, thinking_budget
-55
View File
@@ -1,55 +0,0 @@
"""
Execution phase event protocol for frontend synchronization.
Protocol: __EXEC_PHASE__:{"phase":"coding","message":"Starting"}
"""
import json
import os
import sys
from enum import Enum
from typing import Any
PHASE_MARKER_PREFIX = "__EXEC_PHASE__:"
_DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
class ExecutionPhase(str, Enum):
"""Maps to frontend's ExecutionPhase type for task card badges."""
PLANNING = "planning"
CODING = "coding"
QA_REVIEW = "qa_review"
QA_FIXING = "qa_fixing"
COMPLETE = "complete"
FAILED = "failed"
def emit_phase(
phase: ExecutionPhase | str,
message: str = "",
*,
progress: int | None = None,
subtask: str | None = None,
) -> None:
"""Emit structured phase event to stdout for frontend parsing."""
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
payload: dict[str, Any] = {
"phase": phase_value,
"message": message,
}
if progress is not None:
if not (0 <= progress <= 100):
progress = max(0, min(100, progress))
payload["progress"] = progress
if subtask is not None:
payload["subtask"] = subtask
try:
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
except (OSError, UnicodeEncodeError) as e:
if _DEBUG:
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
-97
View File
@@ -1,97 +0,0 @@
"""
Simple Claude SDK Client Factory
================================
Factory for creating minimal Claude SDK clients for single-turn utility operations
like commit message generation, merge conflict resolution, and batch analysis.
These clients don't need full security configurations, MCP servers, or hooks.
Use `create_client()` from `core.client` for full agent sessions with security.
Example usage:
from core.simple_client import create_simple_client
# For commit message generation (text-only, no tools)
client = create_simple_client(agent_type="commit_message")
# For merge conflict resolution (text-only, no tools)
client = create_simple_client(agent_type="merge_resolver")
# For insights extraction (read tools only)
client = create_simple_client(agent_type="insights", cwd=project_dir)
"""
from pathlib import Path
from agents.tools_pkg import get_agent_config, get_default_thinking_level
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from core.auth import get_sdk_env_vars, require_auth_token
from phase_config import get_thinking_budget
def create_simple_client(
agent_type: str = "merge_resolver",
model: str = "claude-haiku-4-5-20251001",
system_prompt: str | None = None,
cwd: Path | None = None,
max_turns: int = 1,
max_thinking_tokens: int | None = None,
) -> ClaudeSDKClient:
"""
Create a minimal Claude SDK client for single-turn utility operations.
This factory creates lightweight clients without MCP servers, security hooks,
or full permission configurations. Use for text-only analysis tasks.
Args:
agent_type: Agent type from AGENT_CONFIGS. Determines available tools.
Common utility types:
- "merge_resolver" - Text-only merge conflict analysis
- "commit_message" - Text-only commit message generation
- "insights" - Read-only code insight extraction
- "batch_analysis" - Read-only batch issue analysis
- "batch_validation" - Read-only validation
model: Claude model to use (defaults to Haiku for fast/cheap operations)
system_prompt: Optional custom system prompt (for specialized tasks)
cwd: Working directory for file operations (optional)
max_turns: Maximum conversation turns (default: 1 for single-turn)
max_thinking_tokens: Override thinking budget (None = use agent default from
AGENT_CONFIGS, converted using phase_config.THINKING_BUDGET_MAP)
Returns:
Configured ClaudeSDKClient for single-turn operations
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS
"""
# Get authentication
oauth_token = require_auth_token()
import os
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
# Get environment variables for SDK
sdk_env = get_sdk_env_vars()
# Get agent configuration (raises ValueError if unknown type)
config = get_agent_config(agent_type)
# Get tools from config (no MCP tools for simple clients)
allowed_tools = list(config.get("tools", []))
# Determine thinking budget using the single source of truth (phase_config.py)
if max_thinking_tokens is None:
thinking_level = get_default_thinking_level(agent_type)
max_thinking_tokens = get_thinking_budget(thinking_level)
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt=system_prompt,
allowed_tools=allowed_tools,
max_turns=max_turns,
cwd=str(cwd.resolve()) if cwd else None,
env=sdk_env,
max_thinking_tokens=max_thinking_tokens,
)
)
+10 -7
View File
@@ -1407,20 +1407,23 @@ async def _merge_file_with_ai_async(
# Call Claude Haiku for fast merge
try:
from core.simple_client import create_simple_client
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ImportError:
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error="core.simple_client not available",
error="claude_agent_sdk not installed",
)
client = create_simple_client(
agent_type="merge_resolver",
model="claude-haiku-4-5-20251001",
system_prompt=AI_MERGE_SYSTEM_PROMPT,
max_thinking_tokens=1024, # Low thinking for speed
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-haiku-4-5-20251001",
system_prompt=AI_MERGE_SYSTEM_PROMPT,
allowed_tools=[],
max_turns=1,
max_thinking_tokens=1024, # Low thinking for speed
)
)
response_text = ""
-1
View File
@@ -22,7 +22,6 @@ LOCK_FILES = {
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"bun.lock",
"Pipfile.lock",
"poetry.lock",
"uv.lock",
-44
View File
@@ -143,43 +143,6 @@ def choose_workspace(
return WorkspaceMode.ISOLATED
def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[str]:
"""
Copy .env files from project root to worktree (without overwriting).
This ensures the worktree has access to environment variables needed
to run the project (e.g., API keys, database URLs).
Args:
project_dir: The main project directory
worktree_path: Path to the worktree
Returns:
List of copied file names
"""
copied = []
# Common .env file patterns - copy if they exist
env_patterns = [
".env",
".env.local",
".env.development",
".env.development.local",
".env.test",
".env.test.local",
]
for pattern in env_patterns:
env_file = project_dir / pattern
if env_file.is_file():
target = worktree_path / pattern
if not target.exists():
shutil.copy2(env_file, target)
copied.append(pattern)
debug(MODULE, f"Copied {pattern} to worktree")
return copied
def copy_spec_to_worktree(
source_spec_dir: Path,
worktree_path: Path,
@@ -260,13 +223,6 @@ def setup_workspace(
# Get or create worktree for THIS SPECIFIC SPEC
worktree_info = manager.get_or_create_worktree(spec_name)
# Copy .env files to worktree so user can run the project
copied_env_files = copy_env_files_to_worktree(project_dir, worktree_info.path)
if copied_env_files:
print_status(
f"Environment files copied: {', '.join(copied_env_files)}", "success"
)
# Copy spec files to worktree if provided
localized_spec_dir = None
if source_spec_dir and source_spec_dir.exists():
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Implementation Plan Manager
============================
DEPRECATED: This module is now a compatibility shim. The implementation has been
refactored into the implementation_plan/ package for better modularity.
Please import from the package directly:
from implementation_plan import ImplementationPlan, Subtask, Phase, etc.
This file re-exports all public APIs for backwards compatibility.
Core data structures and utilities for subtask-based implementation plans.
Replaces the test-centric feature_list.json with implementation_plan.json.
The key insight: Tests verify outcomes, but SUBTASKS define implementation steps.
For complex multi-service features, implementation order matters.
Workflow Types:
- feature: Standard multi-service feature (phases = services)
- refactor: Migration/refactor work (phases = stages: add, migrate, remove)
- investigation: Bug hunting (phases = investigate, hypothesize, fix)
- migration: Data migration (phases = prepare, test, execute, cleanup)
- simple: Single-service enhancement (minimal overhead)
"""
# Re-export everything from the implementation_plan package
from implementation_plan import (
Chunk,
ChunkStatus,
ImplementationPlan,
Phase,
PhaseType,
Subtask,
SubtaskStatus,
Verification,
VerificationType,
WorkflowType,
create_feature_plan,
create_investigation_plan,
create_refactor_plan,
)
__all__ = [
# Enums
"WorkflowType",
"PhaseType",
"SubtaskStatus",
"VerificationType",
# Models
"Verification",
"Subtask",
"Phase",
"ImplementationPlan",
# Factories
"create_feature_plan",
"create_investigation_plan",
"create_refactor_plan",
# Backwards compatibility
"Chunk",
"ChunkStatus",
]
# CLI for testing
if __name__ == "__main__":
import json
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("Usage: python implementation_plan.py <plan.json>")
print(" python implementation_plan.py --demo")
sys.exit(1)
if sys.argv[1] == "--demo":
# Create a demo plan
plan = create_feature_plan(
feature="Avatar Upload with Processing",
services=["backend", "worker", "frontend"],
phases_config=[
{
"name": "Backend Foundation",
"parallel_safe": True,
"subtasks": [
{
"id": "avatar-model",
"service": "backend",
"description": "Add avatar fields to User model",
"files_to_modify": ["app/models/user.py"],
"files_to_create": ["migrations/add_avatar.py"],
"verification": {
"type": "command",
"run": "flask db upgrade",
},
},
{
"id": "avatar-endpoint",
"service": "backend",
"description": "POST /api/users/avatar endpoint",
"files_to_modify": ["app/routes/users.py"],
"patterns_from": ["app/routes/profile.py"],
"verification": {
"type": "api",
"method": "POST",
"url": "/api/users/avatar",
},
},
],
},
{
"name": "Worker Pipeline",
"depends_on": [1],
"subtasks": [
{
"id": "image-task",
"service": "worker",
"description": "Celery task for image processing",
"files_to_create": ["app/tasks/images.py"],
"patterns_from": ["app/tasks/reports.py"],
},
],
},
{
"name": "Frontend",
"depends_on": [1],
"subtasks": [
{
"id": "avatar-component",
"service": "frontend",
"description": "AvatarUpload React component",
"files_to_create": ["src/components/AvatarUpload.tsx"],
"patterns_from": ["src/components/FileUpload.tsx"],
},
],
},
{
"name": "Integration",
"depends_on": [2, 3],
"type": "integration",
"subtasks": [
{
"id": "e2e-wiring",
"all_services": True,
"description": "Connect frontend → backend → worker",
"verification": {
"type": "browser",
"scenario": "Upload → Process → Display",
},
},
],
},
],
)
plan.final_acceptance = [
"User can upload avatar from profile page",
"Avatar is automatically resized",
"Large/invalid files show error",
]
print(json.dumps(plan.to_dict(), indent=2))
print("\n---\n")
print(plan.get_status_summary())
else:
# Load and display existing plan
plan = ImplementationPlan.load(Path(sys.argv[1]))
print(plan.get_status_summary())
@@ -94,9 +94,6 @@ def create_ollama_embedder(config: "GraphitiConfig") -> Any:
ProviderNotInstalled: If graphiti-core is not installed
ProviderError: If model is not specified
"""
if not config.ollama_embedding_model:
raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL")
try:
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
except ImportError as e:
@@ -106,6 +103,9 @@ def create_ollama_embedder(config: "GraphitiConfig") -> Any:
f"Error: {e}"
)
if not config.ollama_embedding_model:
raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL")
# Get embedding dimension (auto-detect for known models, or use configured value)
embedding_dim = get_embedding_dim_for_model(
config.ollama_embedding_model,
@@ -27,9 +27,6 @@ def create_openai_llm_client(config: "GraphitiConfig") -> Any:
ProviderNotInstalled: If graphiti-core is not installed
ProviderError: If API key is missing
"""
if not config.openai_api_key:
raise ProviderError("OpenAI provider requires OPENAI_API_KEY")
try:
from graphiti_core.llm_client.config import LLMConfig
from graphiti_core.llm_client.openai_client import OpenAIClient
@@ -40,6 +37,9 @@ def create_openai_llm_client(config: "GraphitiConfig") -> Any:
f"Error: {e}"
)
if not config.openai_api_key:
raise ProviderError("OpenAI provider requires OPENAI_API_KEY")
llm_config = LLMConfig(
api_key=config.openai_api_key,
model=config.openai_model,
@@ -146,12 +146,12 @@ class GraphitiClient:
# The original graphiti-core KuzuDriver has build_indices_and_constraints()
# as a no-op, which causes FTS search failures
from integrations.graphiti.queries_pkg.kuzu_driver_patched import (
create_patched_kuzu_driver,
PatchedKuzuDriver as KuzuDriver,
)
db_path = self.config.get_db_path()
try:
self._driver = create_patched_kuzu_driver(db=str(db_path))
self._driver = KuzuDriver(db=str(db_path))
except (OSError, PermissionError) as e:
logger.warning(
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
@@ -343,34 +343,6 @@ class GraphitiMemory:
return await self._search.get_similar_task_outcomes(task_description, limit)
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Get patterns and gotchas relevant to the query.
This method specifically retrieves PATTERN and GOTCHA episode types
to enable cross-session learning. Unlike get_relevant_context(),
it filters for these specific types rather than doing generic search.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
if not await self._ensure_initialized():
return [], []
return await self._search.get_patterns_and_gotchas(
query, num_results, min_score
)
# Status and utility methods
def get_status_summary(self) -> dict:
@@ -9,7 +9,6 @@ This patched driver fixes both issues for LadybugDB compatibility.
"""
import logging
import re
from typing import Any
# Import kuzu (might be real_ladybug via monkeypatch)
@@ -18,159 +17,157 @@ try:
except ImportError:
import real_ladybug as kuzu # type: ignore
from graphiti_core.driver.driver import GraphProvider
from graphiti_core.driver.kuzu_driver import KuzuDriver as OriginalKuzuDriver
from graphiti_core.graph_queries import get_fulltext_indices
logger = logging.getLogger(__name__)
def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int = 1):
from graphiti_core.driver.driver import GraphProvider
from graphiti_core.driver.kuzu_driver import KuzuDriver as OriginalKuzuDriver
from graphiti_core.graph_queries import get_fulltext_indices
class PatchedKuzuDriver(OriginalKuzuDriver):
"""
KuzuDriver with proper FTS index creation and parameter handling.
class PatchedKuzuDriver(OriginalKuzuDriver):
Fixes two bugs in graphiti-core:
1. FTS indexes are never created (build_indices_and_constraints is a no-op)
2. None parameters are filtered out, causing "Parameter not found" errors
"""
def __init__(
self,
db: str = ":memory:",
max_concurrent_queries: int = 1,
):
# Store database path before calling parent (which creates the Database)
self._database = db # Required by Graphiti for group_id checks
super().__init__(db, max_concurrent_queries)
async def execute_query(
self, cypher_query_: str, **kwargs: Any
) -> tuple[list[dict[str, Any]] | list[list[dict[str, Any]]], None, None]:
"""
KuzuDriver with proper FTS index creation and parameter handling.
Execute a Cypher query with proper None parameter handling.
Fixes two bugs in graphiti-core:
1. FTS indexes are never created (build_indices_and_constraints is a no-op)
2. None parameters are filtered out, causing "Parameter not found" errors
The original driver filters out None values, but LadybugDB requires
all referenced parameters to exist. This override keeps None values
in the parameters dict.
"""
# Don't filter out None values - LadybugDB needs them
params = {k: v for k, v in kwargs.items()}
# Still remove these unsupported parameters
params.pop("database_", None)
params.pop("routing_", None)
def __init__(
self,
db: str = ":memory:",
max_concurrent_queries: int = 1,
):
# Store database path before calling parent (which creates the Database)
self._database = db # Required by Graphiti for group_id checks
super().__init__(db, max_concurrent_queries)
try:
results = await self.client.execute(cypher_query_, parameters=params)
except Exception as e:
# Truncate long values for logging
log_params = {
k: (v[:5] if isinstance(v, list) else v) for k, v in params.items()
}
logger.error(
f"Error executing Kuzu query: {e}\n{cypher_query_}\n{log_params}"
)
raise
async def execute_query(
self, cypher_query_: str, **kwargs: Any
) -> tuple[list[dict[str, Any]] | list[list[dict[str, Any]]], None, None]:
"""
Execute a Cypher query with proper None parameter handling.
if not results:
return [], None, None
The original driver filters out None values, but LadybugDB requires
all referenced parameters to exist. This override keeps None values
in the parameters dict.
"""
# Don't filter out None values - LadybugDB needs them
params = {k: v for k, v in kwargs.items()}
# Still remove these unsupported parameters
params.pop("database_", None)
params.pop("routing_", None)
if isinstance(results, list):
dict_results = [list(result.rows_as_dict()) for result in results]
else:
dict_results = list(results.rows_as_dict())
return dict_results, None, None # type: ignore
async def build_indices_and_constraints(self, delete_existing: bool = False):
"""
Build FTS indexes required for Graphiti's hybrid search.
The original KuzuDriver has this as a no-op, but we need to actually
create the FTS indexes for search to work.
Args:
delete_existing: If True, drop and recreate indexes (default: False)
"""
logger.info("Building FTS indexes for Kuzu/LadybugDB...")
# Get the FTS index creation queries from Graphiti
fts_queries = get_fulltext_indices(GraphProvider.KUZU)
# Create a sync connection for index creation
conn = kuzu.Connection(self.db)
try:
for query in fts_queries:
try:
# Check if we need to drop existing index first
if delete_existing:
# Extract index name from query
# Format: CALL CREATE_FTS_INDEX('TableName', 'index_name', [...])
parts = query.split("'")
if len(parts) >= 4:
table_name = parts[1]
index_name = parts[3]
drop_query = (
f"CALL DROP_FTS_INDEX('{table_name}', '{index_name}')"
)
try:
conn.execute(drop_query)
logger.debug(
f"Dropped existing FTS index: {index_name}"
)
except Exception:
# Index might not exist, that's fine
pass
# Create the FTS index
conn.execute(query)
logger.debug(f"Created FTS index: {query[:80]}...")
except Exception as e:
error_msg = str(e).lower()
# Handle "index already exists" gracefully
if "already exists" in error_msg or "duplicate" in error_msg:
logger.debug(
f"FTS index already exists (skipping): {query[:60]}..."
)
else:
# Log but don't fail - some indexes might fail in certain Kuzu versions
logger.warning(f"Failed to create FTS index: {e}")
logger.debug(f"Query was: {query}")
logger.info("FTS indexes created successfully")
finally:
conn.close()
def setup_schema(self):
"""
Set up the database schema and install/load the FTS extension.
Extends the parent setup_schema() to properly set up FTS support.
"""
conn = kuzu.Connection(self.db)
try:
# First, install the FTS extension (required before loading)
try:
results = await self.client.execute(cypher_query_, parameters=params)
conn.execute("INSTALL fts")
logger.debug("Installed FTS extension")
except Exception as e:
# Truncate long values for logging
log_params = {
k: (v[:5] if isinstance(v, list) else v) for k, v in params.items()
}
logger.error(
f"Error executing Kuzu query: {e}\n{cypher_query_}\n{log_params}"
)
raise
if not results:
return [], None, None
if isinstance(results, list):
dict_results = [list(result.rows_as_dict()) for result in results]
else:
dict_results = list(results.rows_as_dict())
return dict_results, None, None # type: ignore
async def build_indices_and_constraints(self, delete_existing: bool = False):
"""
Build FTS indexes required for Graphiti's hybrid search.
The original KuzuDriver has this as a no-op, but we need to actually
create the FTS indexes for search to work.
Args:
delete_existing: If True, drop and recreate indexes (default: False)
"""
logger.info("Building FTS indexes for Kuzu/LadybugDB...")
# Get the FTS index creation queries from Graphiti
fts_queries = get_fulltext_indices(GraphProvider.KUZU)
# Create a sync connection for index creation
conn = kuzu.Connection(self.db)
error_msg = str(e).lower()
if "already" not in error_msg:
logger.debug(f"FTS extension install note: {e}")
# Then load the FTS extension
try:
for query in fts_queries:
try:
# Check if we need to drop existing index first
if delete_existing:
# Extract index name from query
# Format: CALL CREATE_FTS_INDEX('TableName', 'index_name', [...])
match = re.search(
r"CREATE_FTS_INDEX\('([^']+)',\s*'([^']+)'", query
)
if match:
table_name, index_name = match.groups()
drop_query = f"CALL DROP_FTS_INDEX('{table_name}', '{index_name}')"
try:
conn.execute(drop_query)
logger.debug(
f"Dropped existing FTS index: {index_name}"
)
except Exception:
# Index might not exist, that's fine
pass
conn.execute("LOAD EXTENSION fts")
logger.debug("Loaded FTS extension")
except Exception as e:
error_msg = str(e).lower()
if "already loaded" not in error_msg:
logger.debug(f"FTS extension load note: {e}")
finally:
conn.close()
# Create the FTS index
conn.execute(query)
logger.debug(f"Created FTS index: {query[:80]}...")
except Exception as e:
error_msg = str(e).lower()
# Handle "index already exists" gracefully
if "already exists" in error_msg or "duplicate" in error_msg:
logger.debug(
f"FTS index already exists (skipping): {query[:60]}..."
)
else:
# Log but don't fail - some indexes might fail in certain Kuzu versions
logger.warning(f"Failed to create FTS index: {e}")
logger.debug(f"Query was: {query}")
logger.info("FTS indexes created successfully")
finally:
conn.close()
def setup_schema(self):
"""
Set up the database schema and install/load the FTS extension.
Extends the parent setup_schema() to properly set up FTS support.
"""
conn = kuzu.Connection(self.db)
try:
# First, install the FTS extension (required before loading)
try:
conn.execute("INSTALL fts")
logger.debug("Installed FTS extension")
except Exception as e:
error_msg = str(e).lower()
if "already" not in error_msg:
logger.debug(f"FTS extension install note: {e}")
# Then load the FTS extension
try:
conn.execute("LOAD EXTENSION fts")
logger.debug("Loaded FTS extension")
except Exception as e:
error_msg = str(e).lower()
if "already loaded" not in error_msg:
logger.debug(f"FTS extension load note: {e}")
finally:
conn.close()
# Run the parent schema setup (creates tables)
super().setup_schema()
return PatchedKuzuDriver(db=db, max_concurrent_queries=max_concurrent_queries)
# Run the parent schema setup (creates tables)
super().setup_schema()
@@ -10,8 +10,6 @@ import logging
from pathlib import Path
from .schema import (
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
@@ -57,7 +55,6 @@ class GraphitiSearch:
query: str,
num_results: int = MAX_CONTEXT_RESULTS,
include_project_context: bool = True,
min_score: float = 0.0,
) -> list[dict]:
"""
Search for relevant context based on a query.
@@ -107,12 +104,6 @@ class GraphitiSearch:
}
)
# Filter by minimum score if specified
if min_score > 0:
context_items = [
item for item in context_items if item.get("score", 0) >= min_score
]
logger.info(
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
)
@@ -162,7 +153,7 @@ class GraphitiSearch:
):
continue
sessions.append(data)
except (json.JSONDecodeError, TypeError, AttributeError):
except (json.JSONDecodeError, TypeError):
continue
# Sort by session number and return latest
@@ -214,7 +205,7 @@ class GraphitiSearch:
"score": getattr(result, "score", 0.0),
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
except (json.JSONDecodeError, TypeError):
continue
return outcomes[:limit]
@@ -222,107 +213,3 @@ class GraphitiSearch:
except Exception as e:
logger.warning(f"Failed to get similar task outcomes: {e}")
return []
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Retrieve patterns and gotchas relevant to the current task.
Unlike get_relevant_context(), this specifically filters for
EPISODE_TYPE_PATTERN and EPISODE_TYPE_GOTCHA episodes to enable
cross-session learning.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
patterns = []
gotchas = []
try:
# Search with query focused on patterns
pattern_results = await self.client.graphiti.search(
query=f"pattern: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in pattern_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_PATTERN in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_PATTERN:
patterns.append(
{
"pattern": data.get("pattern", ""),
"applies_to": data.get("applies_to", ""),
"example": data.get("example", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Search with query focused on gotchas
gotcha_results = await self.client.graphiti.search(
query=f"gotcha pitfall avoid: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in gotcha_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_GOTCHA in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_GOTCHA:
gotchas.append(
{
"gotcha": data.get("gotcha", ""),
"trigger": data.get("trigger", ""),
"solution": data.get("solution", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Sort by score and limit
patterns.sort(key=lambda x: x.get("score", 0), reverse=True)
gotchas.sort(key=lambda x: x.get("score", 0), reverse=True)
logger.info(
f"Found {len(patterns)} patterns and {len(gotchas)} gotchas for: {query[:50]}..."
)
return patterns[:num_results], gotchas[:num_results]
except Exception as e:
logger.warning(f"Failed to get patterns/gotchas: {e}")
return [], []
@@ -1,862 +0,0 @@
#!/usr/bin/env python3
"""
Test Script for Ollama Embedding Memory Integration
====================================================
This test validates that the memory system works correctly with local Ollama
embedding models (like embeddinggemma, nomic-embed-text) for creating and
retrieving memories in the hybrid RAG system.
The test covers:
1. Ollama embedding generation (direct API test)
2. Creating memories with Ollama embeddings via GraphitiMemory
3. Retrieving memories via semantic search
4. Verifying the full create store retrieve cycle
Prerequisites:
1. Install Ollama: https://ollama.ai/
2. Pull an embedding model:
ollama pull embeddinggemma # 768 dimensions (lightweight)
ollama pull nomic-embed-text # 768 dimensions (good quality)
3. Pull an LLM model (for knowledge graph construction):
ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b
4. Start Ollama server: ollama serve
5. Configure environment:
export GRAPHITI_ENABLED=true
export GRAPHITI_LLM_PROVIDER=ollama
export GRAPHITI_EMBEDDER_PROVIDER=ollama
export OLLAMA_LLM_MODEL=deepseek-r1:7b
export OLLAMA_EMBEDDING_MODEL=embeddinggemma
export OLLAMA_EMBEDDING_DIM=768
NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy
The reranker will fail at search time, but embedding creation works.
For production, use OpenAI API key for best search quality.
Usage:
cd apps/backend
python integrations/graphiti/test_ollama_embedding_memory.py
# Run specific tests:
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
python integrations/graphiti/test_ollama_embedding_memory.py --test create
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
"""
import argparse
import asyncio
import os
import shutil
import sys
import tempfile
from datetime import datetime
from pathlib import Path
# Add auto-claude to path
auto_claude_dir = Path(__file__).parent.parent.parent
sys.path.insert(0, str(auto_claude_dir))
# Load .env file
try:
from dotenv import load_dotenv
env_file = auto_claude_dir / ".env"
if env_file.exists():
load_dotenv(env_file)
print(f"Loaded .env from {env_file}")
except ImportError:
print("Note: python-dotenv not installed, using environment variables only")
# ============================================================================
# Helper Functions
# ============================================================================
def print_header(title: str):
"""Print a section header."""
print("\n" + "=" * 70)
print(f" {title}")
print("=" * 70 + "\n")
def print_result(label: str, value: str, success: bool = True):
"""Print a result line."""
status = "PASS" if success else "FAIL"
print(f" [{status}] {label}: {value}")
def print_info(message: str):
"""Print an info line."""
print(f" INFO: {message}")
def print_step(step: int, message: str):
"""Print a step indicator."""
print(f"\n Step {step}: {message}")
def apply_ladybug_monkeypatch():
"""Apply LadybugDB monkeypatch for embedded database support."""
try:
import real_ladybug
sys.modules["kuzu"] = real_ladybug
return True
except ImportError:
pass
# Try native kuzu as fallback
try:
import kuzu # noqa: F401
return True
except ImportError:
return False
# ============================================================================
# Test 1: Ollama Embedding Generation
# ============================================================================
async def test_ollama_embeddings() -> bool:
"""
Test Ollama embedding generation directly via API.
This validates that Ollama is running and can generate embeddings
with the configured model.
"""
print_header("Test 1: Ollama Embedding Generation")
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768"))
print(f" Ollama Model: {ollama_model}")
print(f" Base URL: {ollama_base_url}")
print(f" Expected Dimension: {expected_dim}")
print()
try:
import requests
except ImportError:
print_result("requests library", "Not installed - pip install requests", False)
return False
# Step 1: Check Ollama is running
print_step(1, "Checking Ollama server status")
try:
resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10)
if resp.status_code != 200:
print_result(
"Ollama server",
f"Not responding (status {resp.status_code})",
False,
)
return False
models = resp.json().get("models", [])
model_names = [m.get("name", "") for m in models]
print_result("Ollama server", f"Running with {len(models)} models", True)
# Check if embedding model is available
embedding_model_found = any(
ollama_model in name or ollama_model.split(":")[0] in name
for name in model_names
)
if not embedding_model_found:
print_info(f"Model '{ollama_model}' not found. Available: {model_names}")
print_info(f"Pull it with: ollama pull {ollama_model}")
except requests.exceptions.ConnectionError:
print_result(
"Ollama server",
"Not running - start with 'ollama serve'",
False,
)
return False
# Step 2: Generate test embedding
print_step(2, "Generating test embeddings")
test_texts = [
"This is a test memory about implementing OAuth authentication.",
"The user prefers using TypeScript for frontend development.",
"A gotcha discovered: always validate JWT tokens on the server side.",
]
embeddings = []
for i, text in enumerate(test_texts):
resp = requests.post(
f"{ollama_base_url}/api/embeddings",
json={"model": ollama_model, "prompt": text},
timeout=60,
)
if resp.status_code != 200:
print_result(
f"Embedding {i + 1}",
f"Failed: {resp.status_code} - {resp.text[:100]}",
False,
)
return False
data = resp.json()
embedding = data.get("embedding", [])
embeddings.append(embedding)
print_result(
f"Embedding {i + 1}",
f"Generated {len(embedding)} dimensions",
True,
)
# Step 3: Validate embedding dimensions
print_step(3, "Validating embedding dimensions")
for i, embedding in enumerate(embeddings):
if len(embedding) != expected_dim:
print_result(
f"Embedding {i + 1} dimension",
f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
False,
)
print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config")
return False
print_result(
f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True
)
# Step 4: Test embedding similarity (basic sanity check)
print_step(4, "Testing embedding similarity")
def cosine_similarity(a, b):
"""Calculate cosine similarity between two vectors."""
dot_product = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0
# Generate embedding for a similar query
query = "OAuth authentication implementation"
resp = requests.post(
f"{ollama_base_url}/api/embeddings",
json={"model": ollama_model, "prompt": query},
timeout=60,
)
query_embedding = resp.json().get("embedding", [])
similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings]
print(f" Query: '{query}'")
print(" Similarities to test texts:")
for i, (text, sim) in enumerate(zip(test_texts, similarities)):
print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'")
# First text (about OAuth) should have highest similarity to OAuth query
if similarities[0] > similarities[1] and similarities[0] > similarities[2]:
print_result("Semantic similarity", "OAuth query matches OAuth text best", True)
else:
print_info("Similarity ordering may vary - embeddings are still working")
print()
print_result("Ollama Embeddings", "All tests passed", True)
return True
# ============================================================================
# Test 2: Memory Creation with Ollama
# ============================================================================
async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]:
"""
Test creating memories using GraphitiMemory with Ollama embeddings.
Returns:
Tuple of (spec_dir, project_dir, success)
"""
print_header("Test 2: Memory Creation with Ollama Embeddings")
# Create test directories
spec_dir = test_db_path / "test_spec"
project_dir = test_db_path / "test_project"
spec_dir.mkdir(parents=True, exist_ok=True)
project_dir.mkdir(parents=True, exist_ok=True)
print(f" Spec dir: {spec_dir}")
print(f" Project dir: {project_dir}")
print(f" Database path: {test_db_path}")
print()
# Override database path for testing
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory"
try:
from integrations.graphiti.memory import GraphitiMemory
except ImportError as e:
print_result("Import GraphitiMemory", f"Failed: {e}", False)
return spec_dir, project_dir, False
# Step 1: Initialize GraphitiMemory
print_step(1, "Initializing GraphitiMemory")
memory = GraphitiMemory(spec_dir, project_dir)
print(f" Is enabled: {memory.is_enabled}")
print(f" Group ID: {memory.group_id}")
if not memory.is_enabled:
print_result(
"GraphitiMemory",
"Not enabled - check GRAPHITI_ENABLED=true",
False,
)
return spec_dir, project_dir, False
init_result = await memory.initialize()
if not init_result:
print_result("Initialize", "Failed to initialize", False)
return spec_dir, project_dir, False
print_result("Initialize", "SUCCESS", True)
# Step 2: Save session insights
print_step(2, "Saving session insights")
session_insights = {
"subtasks_completed": ["implement-oauth-login", "add-jwt-validation"],
"discoveries": {
"files_understood": {
"auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub",
"auth/jwt.py": "JWT token generation and validation utilities",
},
"patterns_found": [
"Pattern: Use refresh tokens for long-lived sessions",
"Pattern: Store tokens in httpOnly cookies for security",
],
"gotchas_encountered": [
"Gotcha: Always validate JWT signature on server side",
"Gotcha: OAuth state parameter prevents CSRF attacks",
],
},
"what_worked": [
"Using PyJWT for token handling",
"Separating OAuth providers into individual modules",
],
"what_failed": [],
"recommendations_for_next_session": [
"Consider adding refresh token rotation",
"Add rate limiting to auth endpoints",
],
}
save_result = await memory.save_session_insights(
session_num=1, insights=session_insights
)
print_result(
"save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
)
# Step 3: Save patterns
print_step(3, "Saving code patterns")
patterns = [
"OAuth implementation uses authorization code flow for web apps",
"JWT tokens include user ID, roles, and expiration in payload",
"Token refresh happens automatically when access token expires",
]
for i, pattern in enumerate(patterns):
result = await memory.save_pattern(pattern)
print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result)
# Step 4: Save gotchas
print_step(4, "Saving gotchas (pitfalls)")
gotchas = [
"Never store config values in frontend code or files checked into git",
"API redirect URIs must exactly match the registered URIs",
"Cache expiration times should be short for performance (15 min default)",
]
for i, gotcha in enumerate(gotchas):
result = await memory.save_gotcha(gotcha)
print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result)
# Step 5: Save codebase discoveries
print_step(5, "Saving codebase discoveries")
discoveries = {
"api/routes/users.py": "User management API endpoints (list, create, update)",
"middleware/logging.py": "Request logging middleware for all routes",
"models/user.py": "User model with profile data and role management",
"services/notifications.py": "Notification service integrations (email, SMS, push)",
}
discovery_result = await memory.save_codebase_discoveries(discoveries)
print_result(
"save_codebase_discoveries",
"SUCCESS" if discovery_result else "FAILED",
discovery_result,
)
# Brief wait for embedding processing
print()
print_info("Waiting 3 seconds for embedding processing...")
await asyncio.sleep(3)
await memory.close()
print()
print_result("Memory Creation", "All memories saved successfully", True)
return spec_dir, project_dir, True
# ============================================================================
# Test 3: Memory Retrieval with Semantic Search
# ============================================================================
async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool:
"""
Test retrieving memories using semantic search with Ollama embeddings.
This validates that saved memories can be found via semantic similarity.
"""
print_header("Test 3: Memory Retrieval with Semantic Search")
try:
from integrations.graphiti.memory import GraphitiMemory
except ImportError as e:
print_result("Import GraphitiMemory", f"Failed: {e}", False)
return False
# Step 1: Initialize memory (reconnect)
print_step(1, "Reconnecting to GraphitiMemory")
memory = GraphitiMemory(spec_dir, project_dir)
init_result = await memory.initialize()
if not init_result:
print_result("Initialize", "Failed to reconnect", False)
return False
print_result("Initialize", "Reconnected successfully", True)
# Step 2: Semantic search for API-related content
print_step(2, "Searching for API-related memories")
api_query = "How do the API endpoints work in this project?"
results = await memory.get_relevant_context(api_query, num_results=5)
print(f" Query: '{api_query}'")
print(f" Found {len(results)} results:")
api_found = False
for i, result in enumerate(results):
content = result.get("content", "")[:100]
result_type = result.get("type", "unknown")
score = result.get("score", 0)
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
if "api" in content.lower() or "routes" in content.lower():
api_found = True
if api_found:
print_result("API search", "Found API-related content", True)
else:
print_info("API content may not be in top results - checking other queries")
# Step 3: Search for middleware-related content
print_step(3, "Searching for middleware patterns")
middleware_query = "middleware and request handling best practices"
results = await memory.get_relevant_context(middleware_query, num_results=5)
print(f" Query: '{middleware_query}'")
print(f" Found {len(results)} results:")
middleware_found = False
for i, result in enumerate(results):
content = result.get("content", "")[:100]
result_type = result.get("type", "unknown")
score = result.get("score", 0)
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
if "middleware" in content.lower() or "routes" in content.lower():
middleware_found = True
print_result(
"Middleware search",
"Found middleware-related content" if middleware_found else "No direct matches",
middleware_found or len(results) > 0,
)
# Step 4: Get session history
print_step(4, "Retrieving session history")
history = await memory.get_session_history(limit=3)
print(f" Found {len(history)} session records:")
for i, session in enumerate(history):
session_num = session.get("session_number", "?")
subtasks = session.get("subtasks_completed", [])
print(f" Session {session_num}: {len(subtasks)} subtasks completed")
for subtask in subtasks[:3]:
print(f" - {subtask}")
print_result(
"Session history", f"Retrieved {len(history)} sessions", len(history) > 0
)
# Step 5: Get status summary
print_step(5, "Memory status summary")
status = memory.get_status_summary()
for key, value in status.items():
print(f" {key}: {value}")
await memory.close()
print()
all_passed = len(results) > 0 and len(history) > 0
print_result(
"Memory Retrieval",
"All retrieval tests passed" if all_passed else "Some tests had issues",
all_passed,
)
return all_passed
# ============================================================================
# Test 4: Full Create → Store → Retrieve Cycle
# ============================================================================
async def test_full_cycle(test_db_path: Path) -> bool:
"""
Test the complete memory lifecycle:
1. Create unique test data
2. Store in graph database with Ollama embeddings
3. Search and retrieve via semantic similarity
4. Verify retrieved data matches what was stored
"""
print_header("Test 4: Full Create-Store-Retrieve Cycle")
# Create fresh test directories
spec_dir = test_db_path / "cycle_test_spec"
project_dir = test_db_path / "cycle_test_project"
spec_dir.mkdir(parents=True, exist_ok=True)
project_dir.mkdir(parents=True, exist_ok=True)
# Override database path for testing
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
os.environ["GRAPHITI_DATABASE"] = "test_full_cycle"
try:
from integrations.graphiti.memory import GraphitiMemory
except ImportError as e:
print_result("Import", f"Failed: {e}", False)
return False
# Step 1: Create unique test content
print_step(1, "Creating unique test content")
unique_id = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_pattern = (
f"Unique pattern {unique_id}: Use dependency injection for database connections"
)
unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks"
print(f" Unique ID: {unique_id}")
print(f" Pattern: {unique_pattern[:60]}...")
print(f" Gotcha: {unique_gotcha[:60]}...")
# Step 2: Store the content
print_step(2, "Storing content in memory system")
memory = GraphitiMemory(spec_dir, project_dir)
init_result = await memory.initialize()
if not init_result:
print_result("Initialize", "Failed", False)
return False
print_result("Initialize", "SUCCESS", True)
pattern_result = await memory.save_pattern(unique_pattern)
print_result(
"save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
)
gotcha_result = await memory.save_gotcha(unique_gotcha)
print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result)
# Wait for embedding processing
print()
print_info("Waiting 4 seconds for embedding processing and indexing...")
await asyncio.sleep(4)
# Step 3: Search for the unique content
print_step(3, "Searching for unique content")
# Search for the pattern
pattern_query = "dependency injection database connections"
pattern_results = await memory.get_relevant_context(pattern_query, num_results=5)
print(f" Query: '{pattern_query}'")
print(f" Found {len(pattern_results)} results")
pattern_found = False
for result in pattern_results:
content = result.get("content", "")
if unique_id in content:
pattern_found = True
print(f" MATCH: {content[:80]}...")
print_result(
"Pattern retrieval",
f"Found unique pattern (ID: {unique_id})"
if pattern_found
else "Unique pattern not in top results",
pattern_found,
)
# Search for the gotcha
gotcha_query = "database connection cleanup finally block"
gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5)
print(f" Query: '{gotcha_query}'")
print(f" Found {len(gotcha_results)} results")
gotcha_found = False
for result in gotcha_results:
content = result.get("content", "")
if unique_id in content:
gotcha_found = True
print(f" MATCH: {content[:80]}...")
print_result(
"Gotcha retrieval",
f"Found unique gotcha (ID: {unique_id})"
if gotcha_found
else "Unique gotcha not in top results",
gotcha_found,
)
# Step 4: Verify semantic similarity works
print_step(4, "Verifying semantic similarity")
# Search with semantically similar but different wording
alt_query = "closing connections properly in error handling"
alt_results = await memory.get_relevant_context(alt_query, num_results=3)
print(f" Alternative query: '{alt_query}'")
print(f" Found {len(alt_results)} semantically similar results:")
for i, result in enumerate(alt_results):
content = result.get("content", "")[:80]
score = result.get("score", 0)
print(f" {i + 1}. (score: {score:.4f}) {content}...")
semantic_works = len(alt_results) > 0
print_result(
"Semantic similarity",
"Working - found related content" if semantic_works else "No results",
semantic_works,
)
await memory.close()
# Summary
print()
cycle_passed = (
pattern_result
and gotcha_result
and (pattern_found or gotcha_found or len(alt_results) > 0)
)
print_result(
"Full Cycle Test",
"Create-Store-Retrieve cycle verified"
if cycle_passed
else "Some steps had issues",
cycle_passed,
)
return cycle_passed
# ============================================================================
# Main Entry Point
# ============================================================================
async def main():
"""Run Ollama embedding memory tests."""
parser = argparse.ArgumentParser(
description="Test Ollama Embedding Memory Integration"
)
parser.add_argument(
"--test",
choices=["all", "embeddings", "create", "retrieve", "full-cycle"],
default="all",
help="Which test to run",
)
parser.add_argument(
"--keep-db",
action="store_true",
help="Keep test database after completion (default: cleanup)",
)
args = parser.parse_args()
print("\n" + "=" * 70)
print(" OLLAMA EMBEDDING MEMORY TEST SUITE")
print("=" * 70)
# Configuration check
print_header("Configuration Check")
config_items = {
"GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""),
"GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""),
"GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""),
"OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""),
"OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""),
"OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""),
"OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"),
"OPENAI_API_KEY": "(set)"
if os.environ.get("OPENAI_API_KEY")
else "(not set - needed for reranker)",
}
all_configured = True
required_keys = [
"GRAPHITI_ENABLED",
"GRAPHITI_LLM_PROVIDER",
"GRAPHITI_EMBEDDER_PROVIDER",
"OLLAMA_LLM_MODEL",
"OLLAMA_EMBEDDING_MODEL",
]
for key, value in config_items.items():
is_optional = key in [
"OLLAMA_BASE_URL",
"OPENAI_API_KEY",
"OLLAMA_EMBEDDING_DIM",
]
is_set = bool(value) if not is_optional else True
display_value = value or "(not set)"
if key == "OPENAI_API_KEY":
display_value = value # Already formatted above
is_set = True # Optional for testing
print_result(key, display_value, is_set)
if key in required_keys and not bool(os.environ.get(key)):
all_configured = False
if not all_configured:
print()
print(" Missing required configuration. Please set:")
print(" export GRAPHITI_ENABLED=true")
print(" export GRAPHITI_LLM_PROVIDER=ollama")
print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama")
print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b")
print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma")
print(" export OLLAMA_EMBEDDING_DIM=768")
print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker")
print()
return
# Check LadybugDB
if not apply_ladybug_monkeypatch():
print()
print_result("LadybugDB", "Not installed - pip install real-ladybug", False)
return
print_result("LadybugDB", "Installed", True)
# Create temp directory for test database
test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_"))
print()
print_info(f"Test database: {test_db_path}")
# Run tests
test = args.test
results = {}
try:
if test in ["all", "embeddings"]:
results["embeddings"] = await test_ollama_embeddings()
spec_dir = None
project_dir = None
if test in ["all", "create"]:
spec_dir, project_dir, results["create"] = await test_memory_creation(
test_db_path
)
if test in ["all", "retrieve"]:
if spec_dir and project_dir:
results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir)
else:
print_info(
"Skipping retrieve test - no spec/project dir from create test"
)
if test in ["all", "full-cycle"]:
results["full-cycle"] = await test_full_cycle(test_db_path)
finally:
# Cleanup unless --keep-db specified
if not args.keep_db and test_db_path.exists():
print()
print_info(f"Cleaning up test database: {test_db_path}")
shutil.rmtree(test_db_path, ignore_errors=True)
# Summary
print_header("TEST SUMMARY")
all_passed = True
for test_name, passed in results.items():
status = "PASSED" if passed else "FAILED"
print(f" {test_name}: {status}")
if not passed:
all_passed = False
print()
if all_passed:
print(" All tests PASSED!")
print()
print(" The memory system is working correctly with Ollama embeddings.")
print(" Memories can be created and retrieved using semantic search.")
else:
print(" Some tests FAILED. Check the output above for details.")
print()
print(" Common issues:")
print(" - Ollama not running: ollama serve")
print(" - Model not pulled: ollama pull embeddinggemma")
print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model")
print()
print(" Commands:")
print(" # Run all tests:")
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
print()
print(" # Run specific test:")
print(
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
)
print(
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
)
print()
print(" # Keep database for inspection:")
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -26,16 +26,12 @@ def create_claude_resolver() -> AIResolver:
Create an AIResolver configured to use Claude via the Agent SDK.
Uses the same OAuth token pattern as the rest of the auto-claude framework.
Reads model/thinking settings from environment variables:
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
Returns:
Configured AIResolver instance
"""
# Import here to avoid circular dependency
from core.auth import ensure_claude_code_oauth_token, get_auth_token
from core.model_config import get_utility_model_config
from .resolver import AIResolver
@@ -47,28 +43,23 @@ def create_claude_resolver() -> AIResolver:
ensure_claude_code_oauth_token()
try:
from core.simple_client import create_simple_client
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ImportError:
logger.warning("core.simple_client not available, AI resolution unavailable")
logger.warning("claude_agent_sdk not installed, AI resolution unavailable")
return AIResolver()
# Get model settings from environment (passed from frontend)
model, thinking_budget = get_utility_model_config()
logger.info(
f"Merge resolver using model={model}, thinking_budget={thinking_budget}"
)
def call_claude(system: str, user: str) -> str:
"""Call Claude using the Agent SDK for merge resolution."""
async def _run_merge() -> str:
# Create a minimal client for merge resolution
client = create_simple_client(
agent_type="merge_resolver",
model=model,
system_prompt=system,
max_thinking_tokens=thinking_budget,
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="sonnet",
system_prompt=system,
allowed_tools=[], # No tools needed for merge
max_turns=1,
)
)
try:
+2 -4
View File
@@ -45,8 +45,7 @@ def apply_single_task_changes(
# Addition - need to determine where to add
if change.change_type == ChangeType.ADD_IMPORT:
# Add import at top
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
lines = content.splitlines()
lines = content.split("\n")
import_end = find_import_end(lines, file_path)
lines.insert(import_end, change.content_after)
content = "\n".join(lines)
@@ -97,8 +96,7 @@ def combine_non_conflicting_changes(
# Add imports
if imports:
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
lines = content.splitlines()
lines = content.split("\n")
import_end = find_import_end(lines, file_path)
for imp in imports:
if imp.content_after and imp.content_after not in content:
@@ -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)
+42 -66
View File
@@ -57,33 +57,18 @@ KNOWN_EMBEDDING_MODELS = {
# Recommended embedding models for download (shown in UI)
RECOMMENDED_EMBEDDING_MODELS = [
{
"name": "qwen3-embedding:4b",
"description": "Qwen3 4B - Balanced quality and speed",
"size_estimate": "3.1 GB",
"dim": 2560,
"badge": "recommended",
},
{
"name": "qwen3-embedding:8b",
"description": "Qwen3 8B - Best embedding quality",
"size_estimate": "6.0 GB",
"dim": 4096,
"badge": "quality",
},
{
"name": "qwen3-embedding:0.6b",
"description": "Qwen3 0.6B - Smallest and fastest",
"size_estimate": "494 MB",
"dim": 1024,
"badge": "fast",
},
{
"name": "embeddinggemma",
"description": "Google's lightweight embedding model (768 dim)",
"size_estimate": "621 MB",
"dim": 768,
},
{
"name": "qwen3-embedding:0.6b",
"description": "Qwen3 small embedding model (1024 dim)",
"size_estimate": "494 MB",
"dim": 1024,
},
{
"name": "nomic-embed-text",
"description": "Popular general-purpose embeddings (768 dim)",
@@ -355,59 +340,50 @@ def cmd_get_recommended_models(args) -> None:
def cmd_pull_model(args) -> None:
"""Pull (download) an Ollama model using the HTTP API for progress tracking."""
model_name = args.model
base_url = getattr(args, "base_url", None) or DEFAULT_OLLAMA_URL
"""Pull (download) an Ollama model."""
import subprocess
model_name = args.model
if not model_name:
output_error("Model name is required")
return
try:
url = f"{base_url.rstrip('/')}/api/pull"
data = json.dumps({"name": model_name}).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=600) as response:
# Ollama streams NDJSON (newline-delimited JSON) progress
for line in response:
try:
progress = json.loads(line.decode("utf-8"))
# Emit progress as NDJSON to stderr for main process to parse
if "completed" in progress and "total" in progress:
print(
json.dumps(
{
"status": progress.get("status", "downloading"),
"completed": progress.get("completed", 0),
"total": progress.get("total", 0),
}
),
file=sys.stderr,
flush=True,
)
elif progress.get("status") == "success":
# Download complete
pass
except json.JSONDecodeError:
continue
output_json(
True,
data={
"model": model_name,
"status": "completed",
"output": ["Download completed successfully"],
},
# Run ollama pull command
process = subprocess.Popen(
["ollama", "pull", model_name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
except urllib.error.URLError as e:
output_error(f"Failed to connect to Ollama: {str(e)}")
except urllib.error.HTTPError as e:
output_error(f"Ollama API error: {e.code} - {e.reason}")
output_lines = []
for line in iter(process.stdout.readline, ""):
line = line.strip()
if line:
output_lines.append(line)
# Print progress to stderr for streaming
print(line, file=sys.stderr, flush=True)
process.wait()
if process.returncode == 0:
output_json(
True,
data={
"model": model_name,
"status": "completed",
"output": output_lines,
},
)
else:
output_json(
False, error=f"Failed to pull model: {' '.join(output_lines[-3:])}"
)
except FileNotFoundError:
output_error("Ollama CLI not found. Please install Ollama first.")
except Exception as e:
output_error(f"Failed to pull model: {str(e)}")
-16
View File
@@ -1,16 +0,0 @@
"""
Phase event facade for frontend synchronization.
Re-exports from core.phase_event for clean imports.
"""
from core.phase_event import (
PHASE_MARKER_PREFIX,
ExecutionPhase,
emit_phase,
)
__all__ = [
"PHASE_MARKER_PREFIX",
"ExecutionPhase",
"emit_phase",
]
+3 -53
View File
@@ -90,55 +90,28 @@ class ProjectAnalyzer:
This allows us to know when to re-analyze.
"""
hash_files = [
# JavaScript/TypeScript
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
# Python
"pyproject.toml",
"requirements.txt",
"Pipfile",
"poetry.lock",
# Rust
"Cargo.toml",
"Cargo.lock",
# Go
"go.mod",
"go.sum",
# Ruby
"Gemfile",
"Gemfile.lock",
# PHP
"composer.json",
"composer.lock",
# Dart/Flutter
"pubspec.yaml",
"pubspec.lock",
# Java/Kotlin/Scala
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"build.sbt",
# Swift
"Package.swift",
# Infrastructure
"Makefile",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
]
# Glob patterns for project files that can be anywhere in the tree
glob_patterns = [
"*.csproj", # C# projects
"*.sln", # Visual Studio solutions
"*.fsproj", # F# projects
"*.vbproj", # VB.NET projects
]
hasher = hashlib.md5(usedforsecurity=False)
files_found = 0
@@ -150,36 +123,13 @@ class ProjectAnalyzer:
hasher.update(f"{filename}:{stat.st_mtime}:{stat.st_size}".encode())
files_found += 1
except OSError:
continue
# Check glob patterns for project files that can be anywhere
for pattern in glob_patterns:
for filepath in self.project_dir.glob(f"**/{pattern}"):
try:
stat = filepath.stat()
rel_path = filepath.relative_to(self.project_dir)
hasher.update(f"{rel_path}:{stat.st_mtime}:{stat.st_size}".encode())
files_found += 1
except OSError:
continue
pass
# If no config files found, hash the project directory structure
# to at least detect when files are added/removed
if files_found == 0:
# Count source files as a proxy for project structure
source_exts = [
"*.py",
"*.js",
"*.ts",
"*.go",
"*.rs",
"*.dart",
"*.cs",
"*.swift",
"*.kt",
"*.java",
]
for ext in source_exts:
# Count Python, JS, and other source files as a proxy for project structure
for ext in ["*.py", "*.js", "*.ts", "*.go", "*.rs"]:
count = len(list(self.project_dir.glob(f"**/{ext}")))
hasher.update(f"{ext}:{count}".encode())
# Also include the project directory name for uniqueness
@@ -148,21 +148,6 @@ FRAMEWORK_COMMANDS: dict[str, set[str]] = {
# Elixir/Erlang
"phoenix": {"mix", "iex"},
"ecto": {"mix"},
# Dart/Flutter
"flutter": {
"flutter",
"dart",
"pub",
"fvm", # Flutter Version Manager
},
"dart_frog": {"dart_frog", "dart"}, # Dart backend framework
"serverpod": {"serverpod", "dart"}, # Dart backend framework
"shelf": {"dart", "pub"}, # Dart HTTP server middleware
"aqueduct": {
"aqueduct",
"dart",
"pub",
}, # Dart HTTP framework (deprecated but still used)
}
@@ -35,40 +35,12 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
"tsx",
},
"rust": {
# Core toolchain
"cargo",
"rustc",
"rustup",
"rustfmt",
"clippy",
"rust-analyzer",
# Cargo subcommand binaries
"cargo-clippy",
"cargo-fmt",
"cargo-miri",
# Common dev tools
"cargo-watch",
"cargo-nextest",
"cargo-llvm-cov",
"cargo-tarpaulin",
# Dependency management
"cargo-audit",
"cargo-deny",
"cargo-outdated",
"cargo-edit",
"cargo-update",
# Build & release
"cargo-release",
"cargo-dist",
"cargo-make",
"cargo-xtask",
# Cross-compilation & WASM
"cross",
"wasm-pack",
"wasm-bindgen",
"trunk",
# Documentation & publishing
"cargo-doc",
"mdbook",
},
"go": {
"go",
@@ -172,14 +144,6 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
"zig": {
"zig",
},
"dart": {
"dart",
"dart2js",
"dartanalyzer",
"dartdoc",
"dartfmt",
"pub",
},
}
@@ -37,7 +37,6 @@ class FrameworkDetector:
self.detect_python_frameworks()
self.detect_ruby_frameworks()
self.detect_php_frameworks()
self.detect_dart_frameworks()
return self.frameworks
def detect_nodejs_frameworks(self) -> None:
@@ -240,26 +239,3 @@ class FrameworkDetector:
self.frameworks.append("symfony")
if "phpunit/phpunit" in deps:
self.frameworks.append("phpunit")
def detect_dart_frameworks(self) -> None:
"""Detect Dart/Flutter frameworks from pubspec.yaml."""
# Read pubspec.yaml as text since we don't have a YAML parser
content = self.parser.read_text("pubspec.yaml")
if not content:
return
content_lower = content.lower()
# Detect Flutter
if "flutter:" in content_lower or "sdk: flutter" in content_lower:
self.frameworks.append("flutter")
# Detect Dart backend frameworks
if "dart_frog" in content_lower:
self.frameworks.append("dart_frog")
if "serverpod" in content_lower:
self.frameworks.append("serverpod")
if "shelf" in content_lower:
self.frameworks.append("shelf")
if "aqueduct" in content_lower:
self.frameworks.append("aqueduct")
+1 -5
View File
@@ -113,10 +113,6 @@ class StackDetector:
if self.parser.file_exists("Package.swift", "*.swift", "**/*.swift"):
self.stack.languages.append("swift")
# Dart/Flutter
if self.parser.file_exists("pubspec.yaml", "*.dart", "**/*.dart"):
self.stack.languages.append("dart")
def detect_package_managers(self) -> None:
"""Detect package managers used."""
# Node.js package managers
@@ -126,7 +122,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")
@@ -1,203 +0,0 @@
# Codebase Fit Review Agent
You are a focused codebase fit review agent. You have been spawned by the orchestrating agent to verify that new code fits well within the existing codebase, follows established patterns, and doesn't reinvent existing functionality.
## Your Mission
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.
## Codebase Fit Focus Areas
### 1. Naming Conventions
- **Inconsistent Naming**: Using `camelCase` when project uses `snake_case`
- **Different Terminology**: Using `user` when codebase uses `account`
- **Abbreviation Mismatch**: Using `usr` when codebase spells out `user`
- **File Naming**: `MyComponent.tsx` vs `my-component.tsx` vs `myComponent.tsx`
- **Directory Structure**: Placing files in wrong directories
### 2. Pattern Adherence
- **Framework Patterns**: Not following React hooks pattern, Django views pattern, etc.
- **Project Patterns**: Not following established error handling, logging, or API patterns
- **Architectural Patterns**: Violating layer separation (e.g., business logic in controllers)
- **State Management**: Using different state management approach than established
- **Configuration Patterns**: Different config file format or location
### 3. Ecosystem Fit
- **Reinventing Utilities**: Writing new helper when similar one exists
- **Duplicate Functionality**: Adding code that duplicates existing implementation
- **Ignoring Shared Code**: Not using established shared components/utilities
- **Wrong Abstraction Level**: Creating too specific or too generic solutions
- **Missing Integration**: Not integrating with existing systems (logging, metrics, etc.)
### 4. Architectural Consistency
- **Layer Violations**: Calling database directly from UI components
- **Dependency Direction**: Wrong dependency direction between modules
- **Module Boundaries**: Crossing module boundaries inappropriately
- **API Contracts**: Breaking established API patterns
- **Data Flow**: Different data flow pattern than established
### 5. Monolithic File Detection
- **Large Files**: Files exceeding 500 lines (should be split)
- **God Objects**: Classes/modules doing too many unrelated things
- **Mixed Concerns**: UI, business logic, and data access in same file
- **Excessive Exports**: Files exporting too many unrelated items
### 6. Import/Dependency Patterns
- **Import Style**: Relative vs absolute imports, import grouping
- **Circular Dependencies**: Creating import cycles
- **Unused Imports**: Adding imports that aren't used
- **Dependency Injection**: Not following DI patterns when established
## Review Guidelines
### High Confidence Only
- Only report findings with **>80% confidence**
- Verify pattern exists in codebase before flagging deviation
- Consider if "inconsistency" might be intentional improvement
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Architectural violation that will cause maintenance problems
- Example: Tight coupling that makes testing impossible
- **Blocks merge: YES**
- **HIGH** (Required): Significant deviation from established patterns
- Example: Reimplementing existing utility, wrong directory structure
- **Blocks merge: YES**
- **MEDIUM** (Recommended): Inconsistency that affects maintainability
- Example: Different naming convention, unused existing helper
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
- **LOW** (Suggestion): Minor convention deviation
- Example: Different import ordering, minor naming variation
- **Blocks merge: NO** (optional polish)
### Check Before Reporting
Before flagging a "should use existing utility" issue:
1. Verify the existing utility actually does what the new code needs
2. Check if existing utility has the right signature/behavior
3. Consider if the new implementation is intentionally different
## Code Patterns to Flag
### Reinventing Existing Utilities
```javascript
// If codebase has: src/utils/format.ts with formatDate()
// Flag this:
function formatDateString(date) {
return `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
}
// Should use: import { formatDate } from '@/utils/format';
```
### Naming Convention Violations
```python
# If codebase uses snake_case:
def getUserById(user_id): # Should be: get_user_by_id
...
# If codebase uses specific terminology:
class Customer: # Should be: User (if that's the codebase term)
...
```
### Architectural Violations
```typescript
// If codebase separates concerns:
// In UI component:
const users = await db.query('SELECT * FROM users'); // BAD
// Should use: const users = await userService.getAll();
// If codebase has established API patterns:
app.get('/user', ...) // BAD: singular
app.get('/users', ...) // GOOD: matches codebase plural pattern
```
### Monolithic Files
```typescript
// File with 800 lines doing:
// - API handlers
// - Business logic
// - Database queries
// - Utility functions
// Should be split into separate files per concern
```
### Import Pattern Violations
```javascript
// If codebase uses absolute imports:
import { User } from '../../../models/user'; // BAD
import { User } from '@/models/user'; // GOOD
// If codebase groups imports:
// 1. External packages
// 2. Internal modules
// 3. Relative imports
```
## Output Format
Provide findings in JSON format:
```json
[
{
"file": "src/components/UserCard.tsx",
"line": 15,
"title": "Reinventing existing date formatting utility",
"description": "This file implements custom date formatting, but the codebase already has `formatDate()` in `src/utils/date.ts` that does the same thing.",
"category": "codebase_fit",
"severity": "high",
"existing_code": "src/utils/date.ts:formatDate()",
"suggested_fix": "Replace custom implementation with: import { formatDate } from '@/utils/date';",
"confidence": 92
},
{
"file": "src/api/customers.ts",
"line": 1,
"title": "File uses 'customer' but codebase uses 'user'",
"description": "This file uses 'customer' terminology but the rest of the codebase consistently uses 'user'. This creates confusion and makes search/navigation harder.",
"category": "codebase_fit",
"severity": "medium",
"codebase_pattern": "src/models/user.ts, src/api/users.ts, src/services/userService.ts",
"suggested_fix": "Rename to use 'user' terminology to match codebase conventions",
"confidence": 88
},
{
"file": "src/services/orderProcessor.ts",
"line": 1,
"title": "Monolithic file exceeds 500 lines",
"description": "This file is 847 lines and contains order validation, payment processing, inventory management, and notification sending. Each should be separate.",
"category": "codebase_fit",
"severity": "high",
"current_lines": 847,
"suggested_fix": "Split into: orderValidator.ts, paymentProcessor.ts, inventoryManager.ts, notificationService.ts",
"confidence": 95
}
]
```
## Important Notes
1. **Verify Existing Code**: Before flagging "use existing", verify the existing code actually fits
2. **Check Codebase Patterns**: Look at multiple files to confirm a pattern exists
3. **Consider Evolution**: Sometimes new code is intentionally better than existing patterns
4. **Respect Domain Boundaries**: Different domains might have different conventions
5. **Focus on Changed Files**: Don't audit the entire codebase, focus on new/modified code
## What NOT to Report
- Security issues (handled by security agent)
- Logic correctness (handled by logic agent)
- Code quality metrics (handled by quality agent)
- Personal preferences about patterns
- Style issues covered by linters
- Test files that intentionally have different structure
## Codebase Analysis Tips
When analyzing codebase fit, look at:
1. **Similar Files**: How are other similar files structured?
2. **Shared Utilities**: What's in `utils/`, `helpers/`, `shared/`?
3. **Naming Patterns**: What naming style do existing files use?
4. **Directory Structure**: Where do similar files live?
5. **Import Patterns**: How do other files import dependencies?
Focus on **codebase consistency** - new code fitting seamlessly with existing code.
@@ -1,158 +0,0 @@
# Finding Validator Agent
You are a finding re-investigator. For each unresolved finding from a previous PR review, you must actively investigate whether it is a REAL issue or a FALSE POSITIVE.
Your job is to prevent false positives from persisting indefinitely by actually reading the code and verifying the issue exists.
## Your Mission
For each finding you receive:
1. **READ** the actual code at the file/line location using the Read tool
2. **ANALYZE** whether the described issue actually exists in the code
3. **PROVIDE** concrete code evidence for your conclusion
4. **RETURN** validation status with evidence
## 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
**Do NOT assume the original finding is correct.** Ask yourself:
- Does the code ACTUALLY have this issue?
- Is the described vulnerability/bug/problem present?
- Could the original reviewer have misunderstood the code?
- Is there context that makes this NOT an issue (e.g., sanitization elsewhere)?
Be skeptical. The original review may have hallucinated this finding.
### Step 3: Document Evidence
You MUST provide concrete evidence:
- **Exact code snippet** you examined (copy-paste from the file)
- **Line numbers** where you found (or didn't find) the issue
- **Your analysis** of whether the issue exists
- **Confidence level** (0.0-1.0) in your conclusion
## Validation Statuses
### `confirmed_valid`
Use when you verify the issue IS real:
- The problematic code pattern exists exactly as described
- The vulnerability/bug is present and exploitable
- The code quality issue genuinely impacts the codebase
### `dismissed_false_positive`
Use when you verify the issue does NOT exist:
- The described code pattern is not actually present
- The original finding misunderstood the code
- There is mitigating code that prevents the issue (e.g., input validation elsewhere)
- The finding was based on incorrect assumptions
### `needs_human_review`
Use when you cannot determine with confidence:
- The issue requires runtime analysis to verify
- The code is too complex to analyze statically
- You have conflicting evidence
- Your confidence is below 0.70
## 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.",
"confidence": 0.95
}
```
```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.",
"confidence": 0.88
}
```
```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. Cannot determine statically.",
"confidence": 0.45
}
```
## Confidence Guidelines
Rate your confidence based on how certain you are:
| Confidence | Meaning |
|------------|---------|
| 0.90-1.00 | Definitive evidence - code clearly shows the issue exists/doesn't exist |
| 0.80-0.89 | Strong evidence - high confidence with minor uncertainty |
| 0.70-0.79 | Moderate evidence - likely correct but some ambiguity |
| 0.50-0.69 | Uncertain - use `needs_human_review` |
| Below 0.50 | Insufficient evidence - must use `needs_human_review` |
**Minimum thresholds:**
- To confirm as `confirmed_valid`: confidence >= 0.70
- To dismiss as `dismissed_false_positive`: confidence >= 0.80 (higher bar for dismissal)
- If below thresholds: must use `needs_human_review`
## Common False Positive Patterns
Watch for these patterns that often indicate false positives:
1. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
2. **Internal-only code**: Code only handles trusted internal data, not user input
3. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
4. **Dead code**: The flagged code is never executed in the current codebase
5. **Test code**: The issue is in test files where it's acceptable
6. **Misread syntax**: Original reviewer misunderstood the language syntax
## 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. **Higher bar for dismissal** - Need 0.80 confidence to dismiss (vs 0.70 to confirm)
5. **When uncertain, 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
## Anti-Patterns to Avoid
- **Trusting the original finding blindly** - Always verify
- **Dismissing without reading code** - Must provide code_evidence
- **Low confidence dismissals** - Needs 0.80+ confidence to dismiss
- **Vague explanations** - Be specific about what you found
- **Missing line numbers** - Always include line_range
+5 -7
View File
@@ -103,16 +103,14 @@ For important unaddressed comments, create a finding:
### Phase 4: Merge Readiness Assessment
Determine the verdict based on (Strict Quality Gates - MEDIUM also blocks):
Determine the verdict based on:
| Verdict | Criteria |
|---------|----------|
| **READY_TO_MERGE** | All previous findings resolved, no new issues, tests pass |
| **MERGE_WITH_CHANGES** | Previous findings resolved, only new LOW severity suggestions remain |
| **NEEDS_REVISION** | HIGH or MEDIUM severity issues unresolved, or new HIGH/MEDIUM issues found |
| **BLOCKED** | CRITICAL issues unresolved or new CRITICAL issues introduced |
Note: Both HIGH and MEDIUM block merge - AI fixes quickly, so be strict about quality.
| **READY_TO_MERGE** | All previous findings resolved, no new critical/high issues, tests pass |
| **MERGE_WITH_CHANGES** | Previous findings resolved, only new medium/low issues remain |
| **NEEDS_REVISION** | Some high-severity issues unresolved or new high issues found |
| **BLOCKED** | Critical issues unresolved or new critical issues introduced |
## Output Format
@@ -1,183 +0,0 @@
# Comment Analysis Agent (Follow-up)
You are a specialized agent for analyzing comments and reviews posted since the last PR review. You have been spawned by the orchestrating agent to process feedback from contributors and AI tools.
## Your Mission
1. Analyze contributor comments for questions and concerns
2. Triage AI tool reviews (CodeRabbit, Cursor, Gemini, etc.)
3. Identify issues that need addressing before merge
4. Flag unanswered questions
## Comment Sources
### Contributor Comments
- Direct questions about implementation
- Concerns about approach
- Suggestions for improvement
- Approval or rejection signals
### AI Tool Reviews
Common AI reviewers you'll encounter:
- **CodeRabbit**: Comprehensive code analysis
- **Cursor**: AI-assisted review comments
- **Gemini Code Assist**: Google's code reviewer
- **GitHub Copilot**: Inline suggestions
- **Greptile**: Codebase-aware analysis
- **SonarCloud**: Static analysis findings
- **Snyk**: Security scanning results
## Analysis Framework
### For Each Comment
1. **Identify the author**
- Is this a human contributor or AI bot?
- What's their role (maintainer, contributor, reviewer)?
2. **Classify sentiment**
- question: Asking for clarification
- concern: Expressing worry about approach
- suggestion: Proposing alternative
- praise: Positive feedback
- neutral: Informational only
3. **Assess urgency**
- Does this block merge?
- Is a response required?
- What action is needed?
4. **Extract actionable items**
- What specific change is requested?
- Is the concern valid?
- How should it be addressed?
## Triage AI Tool Comments
### Critical (Must Address)
- Security vulnerabilities flagged
- Data loss risks
- Authentication bypasses
- Injection vulnerabilities
### Important (Should Address)
- Logic errors in core paths
- Missing error handling
- Race conditions
- Resource leaks
### Nice-to-Have (Consider)
- Code style suggestions
- Performance optimizations
- Documentation improvements
### False Positive (Dismiss)
- Incorrect analysis
- Not applicable to this context
- Already addressed
- Stylistic preferences
## Output Format
### Comment Analyses
```json
[
{
"comment_id": "IC-12345",
"author": "maintainer-jane",
"is_ai_bot": false,
"requires_response": true,
"sentiment": "question",
"summary": "Asks why async/await was chosen over callbacks",
"action_needed": "Respond explaining the async choice for better error handling"
},
{
"comment_id": "RC-67890",
"author": "coderabbitai[bot]",
"is_ai_bot": true,
"requires_response": false,
"sentiment": "suggestion",
"summary": "Suggests using optional chaining for null safety",
"action_needed": null
}
]
```
### Comment Findings (Issues from Comments)
When AI tools or contributors identify real issues:
```json
[
{
"id": "CMT-001",
"file": "src/api/handler.py",
"line": 89,
"title": "Unhandled exception in error path (from CodeRabbit)",
"description": "CodeRabbit correctly identified that the except block at line 89 catches Exception but doesn't log or handle it properly.",
"category": "quality",
"severity": "medium",
"confidence": 0.85,
"suggested_fix": "Add proper logging and re-raise or handle the exception appropriately",
"fixable": true,
"source_agent": "comment-analyzer",
"related_to_previous": null
}
]
```
## Prioritization Rules
1. **Maintainer comments** > Contributor comments > AI bot comments
2. **Questions from humans** always require response
3. **Security issues from AI** should be verified and escalated
4. **Repeated concerns** (same issue from multiple sources) are higher priority
## What to Flag
### Must Flag
- Unanswered questions from maintainers
- Unaddressed security findings from AI tools
- Explicit change requests not yet implemented
- Blocking concerns from reviewers
### Should Flag
- Valid suggestions not yet addressed
- Questions about implementation approach
- Concerns about test coverage
### Can Skip
- Resolved discussions
- Acknowledged but deferred items
- Style-only suggestions
- Clearly false positive AI findings
## Identifying AI Bots
Common bot patterns:
- `*[bot]` suffix (e.g., `coderabbitai[bot]`)
- `*-bot` suffix
- Known bot names: dependabot, renovate, snyk-bot, sonarcloud
- Automated review format (structured markdown)
## Important Notes
1. **Humans first**: Prioritize human feedback over AI suggestions
2. **Context matters**: Consider the discussion thread, not just individual comments
3. **Don't duplicate**: If an issue is already in previous findings, reference it
4. **Be constructive**: Extract actionable items, not just concerns
5. **Verify AI findings**: AI tools can be wrong - assess validity
## Sample Workflow
1. Collect all comments since last review timestamp
2. Separate by source (contributor vs AI bot)
3. For each contributor comment:
- Classify sentiment and urgency
- Check if response/action is needed
4. For each AI review:
- Triage by severity
- Verify if finding is valid
- Check if already addressed in new code
5. Generate comment_analyses and comment_findings lists
@@ -1,162 +0,0 @@
# New Code Review Agent (Follow-up)
You are a specialized agent for reviewing new code added since the last PR review. You have been spawned by the orchestrating agent to identify issues in recently added changes.
## Your Mission
Review the incremental diff for:
1. Security vulnerabilities
2. Logic errors and edge cases
3. Code quality issues
4. Potential regressions
5. Incomplete implementations
## Focus Areas
Since this is a follow-up review, focus on:
- **New code only**: Don't re-review unchanged code
- **Fix quality**: Are the fixes implemented correctly?
- **Regressions**: Did fixes break other things?
- **Incomplete work**: Are there TODOs or unfinished sections?
## Review Categories
### Security (category: "security")
- New injection vulnerabilities (SQL, XSS, command)
- Hardcoded secrets or credentials
- Authentication/authorization gaps
- Insecure data handling
### Logic (category: "logic")
- Off-by-one errors
- Null/undefined handling
- Race conditions
- Incorrect boundary checks
- State management issues
### Quality (category: "quality")
- Error handling gaps
- Resource leaks
- Performance anti-patterns
- Code duplication
### Regression (category: "regression")
- Fixes that break existing behavior
- Removed functionality without replacement
- Changed APIs without updating callers
- Tests that no longer pass
### Incomplete Fix (category: "incomplete_fix")
- Partial implementations
- TODO comments left in code
- Error paths not handled
- Missing test coverage for fix
## Severity Guidelines
### CRITICAL
- Security vulnerabilities exploitable in production
- Data corruption or loss risks
- Complete feature breakage
### HIGH
- Security issues requiring specific conditions
- Logic errors affecting core functionality
- Regressions in important features
### MEDIUM
- Code quality issues affecting maintainability
- Minor logic issues in edge cases
- Missing error handling
### LOW
- Style inconsistencies
- Minor optimizations
- Documentation gaps
## Confidence Scoring
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
Only report findings with confidence >0.7.
## Output Format
Return findings in this structure:
```json
[
{
"id": "NEW-001",
"file": "src/auth/login.py",
"line": 45,
"end_line": 48,
"title": "SQL injection in new login query",
"description": "The new login validation query concatenates user input directly into the SQL string without sanitization.",
"category": "security",
"severity": "critical",
"confidence": 0.95,
"suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))",
"fixable": true,
"source_agent": "new-code-reviewer",
"related_to_previous": null
},
{
"id": "NEW-002",
"file": "src/utils/parser.py",
"line": 112,
"title": "Fix introduced null pointer regression",
"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",
"confidence": 0.88,
"suggested_fix": "Restore null check: if (input && input.data) { ... }",
"fixable": true,
"source_agent": "new-code-reviewer",
"related_to_previous": "LOGIC-003"
}
]
```
## What NOT to Report
- Issues in unchanged code (that's for initial review)
- Style preferences without functional impact
- Theoretical issues with <70% confidence
- Duplicate findings (check if similar issue exists)
- Issues already flagged by previous review
## Review Strategy
1. **Scan for red flags first**
- eval(), exec(), dangerouslySetInnerHTML
- Hardcoded passwords, API keys
- SQL string concatenation
- Shell command construction
2. **Check fix correctness**
- Does the fix actually address the reported issue?
- Are all code paths covered?
- Are error cases handled?
3. **Look for collateral damage**
- What else changed in the same files?
- Could the fix affect other functionality?
- Are there dependent changes needed?
4. **Verify completeness**
- Are there TODOs left behind?
- Is there test coverage for the changes?
- Is documentation updated if needed?
## Important Notes
1. **Be focused**: Only review new changes, not the entire PR
2. **Consider context**: Understand what the fix was trying to achieve
3. **Be constructive**: Suggest fixes, not just problems
4. **Avoid nitpicking**: Focus on functional issues
5. **Link regressions**: If a fix caused a new issue, reference the original finding
@@ -1,189 +0,0 @@
# Parallel Follow-up Review Orchestrator
You are the orchestrating agent for follow-up PR reviews. Your job is to analyze incremental changes since the last review and coordinate specialized agents to verify resolution of previous findings and identify new issues.
## Your Mission
Perform a focused, efficient follow-up review by:
1. Analyzing the scope of changes since the last review
2. Delegating to specialized agents based on what needs verification
3. Synthesizing findings into a final merge verdict
## Available Specialist Agents
You have access to these specialist agents via the Task tool:
### 1. resolution-verifier
**Use for**: Verifying whether previous findings have been addressed
- Analyzes diffs to determine if issues are truly fixed
- Checks for incomplete or incorrect fixes
- Provides confidence scores for each resolution
- **Invoke when**: There are previous findings to verify
### 2. new-code-reviewer
**Use for**: Reviewing new code added since last review
- Security issues in new code
- Logic errors and edge cases
- Code quality problems
- Regressions that may have been introduced
- **Invoke when**: There are substantial code changes (>50 lines diff)
### 3. comment-analyzer
**Use for**: Processing contributor and AI tool feedback
- Identifies unanswered questions from contributors
- Triages AI tool comments (CodeRabbit, Cursor, Gemini, etc.)
- 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
Evaluate the follow-up context:
- How many new commits?
- How many files changed?
- What's the diff size?
- Are there previous findings to verify?
- Are there new comments to process?
### Phase 2: Delegate to Agents
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
- New files were added
- Complex logic was modified
**Invoke** `comment-analyzer` if:
- There are contributor comments since last review
- 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:
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
## Verdict Guidelines
### READY_TO_MERGE
- All previous findings verified as resolved OR dismissed as false positives
- No CONFIRMED_VALID critical/high issues remaining
- No new critical/high issues
- No blocking concerns from comments
- Contributor questions addressed
### MERGE_WITH_CHANGES
- Previous findings resolved
- Only LOW severity new issues (suggestions)
- Optional polish items can be addressed post-merge
### NEEDS_REVISION (Strict Quality Gates)
- HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive)
- New HIGH or MEDIUM severity issues introduced
- Important contributor concerns unaddressed
- **Note: Both HIGH and MEDIUM block merge** (AI fixes quickly, so be strict)
- **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block)
### BLOCKED
- CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive)
- New CRITICAL issues introduced
- Fundamental problems with the fix approach
- **Note: Only block for findings that passed validation**
## Cross-Validation
When multiple agents report on the same area:
- **Agreement boosts confidence**: If resolution-verifier and new-code-reviewer both flag an issue, increase severity
- **Conflicts need resolution**: If agents disagree, investigate and document your reasoning
- **Track consensus**: Note which findings have cross-agent validation
## Output Format
Provide your synthesis as a structured response matching the ParallelFollowupResponse schema:
```json
{
"analysis_summary": "Brief summary of what was analyzed",
"agents_invoked": ["resolution-verifier", "finding-validator", "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": [...],
"agent_agreement": {
"agreed_findings": [],
"conflicting_findings": [],
"resolution_notes": null
},
"verdict": "READY_TO_MERGE",
"verdict_reasoning": "2 findings resolved, 1 dismissed as false positive, 1 confirmed valid but LOW severity..."
}
```
## 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. **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
- Previous review summary and findings
- New commits since last review (SHAs, messages)
- Diff of changes since last review
- Files modified since last review
- Contributor comments since last review
- AI bot comments and reviews since last review
@@ -1,128 +0,0 @@
# Resolution Verification Agent
You are a specialized agent for verifying whether previous PR review findings have been addressed. You have been spawned by the orchestrating agent to analyze diffs and determine resolution status.
## Your Mission
For each previous finding, determine whether it has been:
- **resolved**: The issue is fully fixed
- **partially_resolved**: Some aspects fixed, but not complete
- **unresolved**: The issue remains or wasn't addressed
- **cant_verify**: Not enough information to determine status
## Verification Process
For each previous finding:
### 1. Locate the Issue
- Find the file mentioned in the finding
- Check if that file was modified in the new changes
- If file wasn't modified, the finding is likely **unresolved**
### 2. Analyze the Fix
If the file was modified:
- Look at the specific lines mentioned
- Check if the problematic code pattern is gone
- Verify the fix actually addresses the root cause
- Watch for "cosmetic" fixes that don't solve the problem
### 3. Check for Regressions
- Did the fix introduce new problems?
- Is the fix approach sound?
- Are there edge cases the fix misses?
### 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
### RESOLVED
The finding is resolved when:
- The problematic code is removed or fixed
- The fix addresses the root cause (not just symptoms)
- No new issues were introduced by the fix
- Edge cases are handled appropriately
### PARTIALLY_RESOLVED
Mark as partially resolved when:
- Main issue is fixed but related problems remain
- Fix works for common cases but misses edge cases
- Some aspects addressed but not all
- Workaround applied instead of proper fix
### UNRESOLVED
Mark as unresolved when:
- File wasn't modified at all
- Code pattern still present
- Fix attempt doesn't address the actual issue
- Problem was misunderstood
### CANT_VERIFY
Use when:
- Diff doesn't include enough context
- Issue requires runtime verification
- Finding references external dependencies
- Not enough information to determine
## Evidence Requirements
For each verification, provide:
1. **What you looked for**: The code pattern or issue from the finding
2. **What you found**: The current state in the diff
3. **Why you concluded**: Your reasoning for the status
## Output Format
Return verifications in this structure:
```json
[
{
"finding_id": "SEC-001",
"status": "resolved",
"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",
"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",
"confidence": 0.88,
"evidence": "The off-by-one error remains. The loop still uses `<= length` instead of `< length`.",
"resolution_notes": null
}
]
```
## Common Pitfalls
### False Positives (Marking resolved when not)
- Code moved but same bug exists elsewhere
- Variable renamed but logic unchanged
- Comments added but no actual fix
- Different code path has same issue
### False Negatives (Marking unresolved when fixed)
- Fix uses different approach than expected
- Issue fixed via configuration change
- Problem resolved by removing feature entirely
- Upstream dependency update fixed it
## Important Notes
1. **Be thorough**: Check both the specific line AND surrounding context
2. **Consider intent**: What was the fix trying to achieve?
3. **Look for patterns**: If one instance was fixed, were all instances fixed?
4. **Document clearly**: Your evidence should be verifiable by others
5. **When uncertain**: Use lower confidence, don't guess at status
@@ -1,203 +0,0 @@
# Logic and Correctness Review Agent
You are a focused logic and correctness review agent. You have been spawned by the orchestrating agent to perform deep analysis of algorithmic correctness, edge cases, and state management.
## Your Mission
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.
## Logic Focus Areas
### 1. Algorithm Correctness
- **Wrong Algorithm**: Using inefficient or incorrect algorithm for the problem
- **Incorrect Implementation**: Algorithm logic doesn't match the intended behavior
- **Missing Steps**: Algorithm is incomplete or skips necessary operations
- **Wrong Data Structure**: Using inappropriate data structure for the operation
### 2. Edge Cases
- **Empty Inputs**: Empty arrays, empty strings, null/undefined values
- **Boundary Conditions**: First/last elements, zero, negative numbers, max values
- **Single Element**: Arrays with one item, strings with one character
- **Large Inputs**: Integer overflow, array size limits, string length limits
- **Invalid Inputs**: Wrong types, malformed data, unexpected formats
### 3. Off-By-One Errors
- **Loop Bounds**: `<=` vs `<`, starting at 0 vs 1
- **Array Access**: Index out of bounds, fence post errors
- **String Operations**: Substring boundaries, character positions
- **Range Calculations**: Inclusive vs exclusive ranges
### 4. State Management
- **Race Conditions**: Concurrent access to shared state
- **Stale State**: Using outdated values after async operations
- **State Mutation**: Unintended side effects from mutations
- **Initialization**: Using uninitialized or partially initialized state
- **Cleanup**: State not reset when it should be
### 5. Conditional Logic
- **Inverted Conditions**: `!condition` when `condition` was intended
- **Missing Conditions**: Incomplete if/else chains
- **Wrong Operators**: `&&` vs `||`, `==` vs `===`
- **Short-Circuit Issues**: Relying on evaluation order incorrectly
- **Truthiness Bugs**: `0`, `""`, `[]` being falsy when they're valid values
### 6. Async/Concurrent Issues
- **Missing Await**: Async function called without await
- **Promise Handling**: Unhandled rejections, missing error handling
- **Deadlocks**: Circular dependencies in async operations
- **Race Conditions**: Multiple async operations accessing same resource
- **Order Dependencies**: Operations that must run in sequence but don't
### 7. Type Coercion & Comparisons
- **Implicit Coercion**: `"5" + 3 = "53"` vs `"5" - 3 = 2`
- **Equality Bugs**: `==` performing unexpected coercion
- **Sorting Issues**: Default string sort on numbers `[1, 10, 2]`
- **Falsy Confusion**: `0`, `""`, `null`, `undefined`, `NaN`, `false`
## Review Guidelines
### High Confidence Only
- Only report findings with **>80% confidence**
- Logic bugs must be demonstrable with a concrete example
- If the edge case is theoretical without practical impact, don't report it
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Bug that will cause wrong results or crashes in production
- Example: Off-by-one causing data corruption, race condition causing lost updates
- **Blocks merge: YES**
- **HIGH** (Required): Logic error that will affect some users/cases
- Example: Missing null check, incorrect boundary condition
- **Blocks merge: YES**
- **MEDIUM** (Recommended): Edge case not handled that could cause issues
- Example: Empty array not handled, large input overflow
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
- **LOW** (Suggestion): Minor logic improvement
- Example: Unnecessary re-computation, suboptimal algorithm
- **Blocks merge: NO** (optional polish)
### Provide Concrete Examples
For each finding, provide:
1. A concrete input that triggers the bug
2. What the current code produces
3. What it should produce
## Code Patterns to Flag
### Off-By-One Errors
```javascript
// BUG: Skips last element
for (let i = 0; i < arr.length - 1; i++) { }
// BUG: Accesses beyond array
for (let i = 0; i <= arr.length; i++) { }
// BUG: Wrong substring bounds
str.substring(0, str.length - 1) // Missing last char
```
### Edge Case Failures
```javascript
// BUG: Crashes on empty array
const first = arr[0].value; // TypeError if empty
// BUG: NaN on empty array
const avg = sum / arr.length; // Division by zero
// BUG: Wrong result for single element
const max = Math.max(...arr.slice(1)); // Wrong if arr.length === 1
```
### State & Async Bugs
```javascript
// BUG: Race condition
let count = 0;
await Promise.all(items.map(async () => {
count++; // Not atomic!
}));
// BUG: Stale closure
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100); // All print 5
}
// BUG: Missing await
async function process() {
getData(); // Returns immediately, doesn't wait
useData(); // Data not ready!
}
```
### Conditional Logic Bugs
```javascript
// BUG: Inverted condition
if (!user.isAdmin) {
grantAccess(); // Should be if (user.isAdmin)
}
// BUG: Wrong operator precedence
if (a || b && c) { // Evaluates as: a || (b && c)
// Probably meant: (a || b) && c
}
// BUG: Falsy check fails for 0
if (!value) { // Fails when value is 0
value = defaultValue;
}
```
## Output Format
Provide findings in JSON format:
```json
[
{
"file": "src/utils/array.ts",
"line": 23,
"title": "Off-by-one error in array iteration",
"description": "Loop uses `i < arr.length - 1` which skips the last element. For array [1, 2, 3], only processes [1, 2].",
"category": "logic",
"severity": "high",
"example": {
"input": "[1, 2, 3]",
"actual_output": "Processes [1, 2]",
"expected_output": "Processes [1, 2, 3]"
},
"suggested_fix": "Change loop to `i < arr.length` to include last element",
"confidence": 95
},
{
"file": "src/services/counter.ts",
"line": 45,
"title": "Race condition in concurrent counter increment",
"description": "Multiple async operations increment `count` without synchronization. With 10 concurrent increments, final count could be less than 10.",
"category": "logic",
"severity": "critical",
"example": {
"input": "10 concurrent increments",
"actual_output": "count might be 7, 8, or 9",
"expected_output": "count should be 10"
},
"suggested_fix": "Use atomic operations or a mutex: await mutex.runExclusive(() => count++)",
"confidence": 90
}
]
```
## Important Notes
1. **Provide Examples**: Every logic bug should have a concrete triggering input
2. **Show Impact**: Explain what goes wrong, not just that something is wrong
3. **Be Specific**: Point to exact line and explain the logical flaw
4. **Consider Context**: Some "bugs" are intentional (e.g., skipping last element on purpose)
5. **Focus on Changed Code**: Prioritize reviewing additions over existing code
## What NOT to Report
- Style issues (naming, formatting)
- Security issues (handled by security agent)
- Performance issues (unless it's algorithmic complexity bug)
- Code quality (duplication, complexity - handled by quality agent)
- Test files with intentionally buggy code for testing
Focus on **logic correctness** - the code doing what it's supposed to do, handling all cases correctly.
@@ -183,14 +183,12 @@ if (!exists) {
**Deduplicate** - Remove duplicates by (file, line, title)
**Generate Verdict (Strict Quality Gates):**
**Generate Verdict:**
- **BLOCKED** - If any CRITICAL issues or tests failing
- **NEEDS_REVISION** - If HIGH or MEDIUM severity issues (both block merge)
- **MERGE_WITH_CHANGES** - If only LOW severity suggestions
- **NEEDS_REVISION** - If HIGH severity issues
- **MERGE_WITH_CHANGES** - If only MEDIUM issues
- **READY_TO_MERGE** - If no blocking issues + tests pass + good coverage
Note: MEDIUM severity blocks merge because AI fixes quickly - be strict about quality.
---
## Available Tools
@@ -1,146 +0,0 @@
# Parallel PR Review Orchestrator
You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict.
## Core Principle
**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.
## Available Specialist Agents
You have access to these specialized review agents via the Task tool:
### security-reviewer
**Description**: Security specialist for OWASP Top 10, authentication, injection, cryptographic issues, and sensitive data exposure.
**When to use**: PRs touching auth, API endpoints, user input handling, database queries, file operations, or any security-sensitive code.
### quality-reviewer
**Description**: Code quality expert for complexity, duplication, error handling, maintainability, and pattern adherence.
**When to use**: PRs with complex logic, large functions, new patterns, or significant business logic changes.
### logic-reviewer
**Description**: Logic and correctness specialist for algorithm verification, edge cases, state management, and race conditions.
**When to use**: PRs with algorithmic changes, data transformations, state management, concurrent operations, or bug fixes.
### codebase-fit-reviewer
**Description**: Codebase consistency expert for naming conventions, ecosystem fit, architectural alignment, and avoiding reinvention.
**When to use**: PRs introducing new patterns, large additions, or code that might duplicate existing functionality.
### ai-triage-reviewer
**Description**: AI comment validator for triaging comments from CodeRabbit, Gemini Code Assist, Cursor, Greptile, and other AI reviewers.
**When to use**: PRs that have existing AI review comments that need validation.
## Your Workflow
### Phase 1: Analysis
Analyze the PR thoroughly:
1. **Understand the Goal**: What does this PR claim to do? Bug fix? Feature? Refactor?
2. **Assess Scope**: How many files? What types? What areas of the codebase?
3. **Identify Risk Areas**: Security-sensitive? Complex logic? New patterns?
4. **Check for AI Comments**: Are there existing AI reviewer comments to triage?
### Phase 2: Delegation
Based on your analysis, invoke the appropriate specialist agents. You can invoke multiple agents in parallel by calling the Task tool multiple times in the same response.
**Delegation Guidelines** (YOU decide, these are suggestions):
- **Small PRs (1-5 files)**: At minimum, invoke one agent for deep analysis. Choose based on content.
- **Medium PRs (5-20 files)**: Invoke 2-3 agents covering different aspects (e.g., security + quality).
- **Large PRs (20+ files)**: Invoke 3-4 agents with focused file assignments.
- **Security-sensitive changes**: Always invoke security-reviewer.
- **Complex logic changes**: Always invoke logic-reviewer.
- **New patterns/large additions**: Always invoke codebase-fit-reviewer.
- **Existing AI comments**: Always invoke ai-triage-reviewer.
**Example delegation**:
```
For a PR adding a new authentication endpoint:
- Invoke security-reviewer for auth logic
- Invoke quality-reviewer for code structure
- Invoke logic-reviewer for edge cases in auth flow
```
### Phase 3: Synthesis
After receiving agent results, synthesize findings:
1. **Aggregate**: Collect all findings from all agents
2. **Cross-validate**:
- If multiple agents report the same issue → boost confidence
- If agents conflict → use your judgment to resolve
3. **Deduplicate**: Remove overlapping findings (same file + line + issue type)
4. **Filter**: Only include findings with confidence ≥80%
5. **Generate Verdict**: Based on severity of remaining findings
## Output Format
After synthesis, output your final review in this JSON format:
```json
{
"analysis_summary": "Brief description of what you analyzed and why you chose those agents",
"agents_invoked": ["security-reviewer", "quality-reviewer"],
"findings": [
{
"id": "finding-1",
"file": "src/auth/login.ts",
"line": 45,
"end_line": 52,
"title": "SQL injection vulnerability in user lookup",
"description": "User input directly interpolated into SQL query",
"category": "security",
"severity": "critical",
"confidence": 0.95,
"suggested_fix": "Use parameterized queries",
"fixable": true,
"source_agents": ["security-reviewer"],
"cross_validated": false
}
],
"agent_agreement": {
"agreed_findings": ["finding-1", "finding-3"],
"conflicting_findings": [],
"resolution_notes": ""
},
"verdict": "NEEDS_REVISION",
"verdict_reasoning": "Critical SQL injection vulnerability must be fixed before merge"
}
```
## Verdict Types (Strict Quality Gates)
We use strict quality gates because AI can fix issues quickly. Only LOW severity findings are optional.
- **READY_TO_MERGE**: No blocking issues found - can merge
- **MERGE_WITH_CHANGES**: Only LOW (Suggestion) severity findings - can merge but consider addressing
- **NEEDS_REVISION**: HIGH or MEDIUM severity findings that must be fixed before merge
- **BLOCKED**: CRITICAL severity issues or failing tests - must be fixed before merge
**Severity → Verdict Mapping:**
- CRITICAL → BLOCKED (must fix)
- HIGH → NEEDS_REVISION (required fix)
- MEDIUM → NEEDS_REVISION (recommended, improves quality - also blocks merge)
- LOW → MERGE_WITH_CHANGES (optional suggestions)
## Key Principles
1. **YOU Decide**: No hardcoded rules - you analyze and choose agents based on content
2. **Parallel Execution**: Invoke multiple agents in the same turn for speed
3. **Thoroughness**: Every PR deserves analysis - never skip because it "looks simple"
4. **Cross-Validation**: Multiple agents agreeing increases confidence
5. **High Confidence**: Only report findings with ≥80% confidence
6. **Actionable**: Every finding must have a specific, actionable fix
7. **Project Agnostic**: Works for any project type - backend, frontend, fullstack, any language
## Remember
You are the orchestrator. The specialist agents provide deep expertise, but YOU make the final decisions about:
- Which agents to invoke
- How to resolve conflicts
- What findings to include
- What verdict to give
Quality over speed. A missed bug in production is far worse than spending extra time on review.
@@ -62,19 +62,15 @@ Perform a thorough code quality review of the provided code changes. Focus on ma
- If it's subjective or debatable, don't report it
- Focus on objective quality issues
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Bug that will cause failures in production
### Severity Classification
- **CRITICAL**: Bug that will cause failures in production
- Example: Unhandled promise rejection, memory leak
- **Blocks merge: YES**
- **HIGH** (Required): Significant quality issue affecting maintainability
- **HIGH**: Significant quality issue affecting maintainability
- Example: 200-line function, duplicated business logic across 5 files
- **Blocks merge: YES**
- **MEDIUM** (Recommended): Quality concern that improves code quality
- **MEDIUM**: Quality concern worth addressing
- Example: Missing error handling, magic numbers
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
- **LOW** (Suggestion): Minor improvement suggestion
- **LOW**: Minor improvement suggestion
- Example: Variable naming, minor refactoring opportunity
- **Blocks merge: NO** (optional polish)
### Contextual Analysis
- Consider project conventions (don't enforce personal preferences)
+5 -5
View File
@@ -264,11 +264,11 @@ Return a JSON array with this structure:
### Required Fields
- **id**: Unique identifier (e.g., "finding-1", "finding-2")
- **severity**: `critical` | `high` | `medium` | `low` (Strict Quality Gates - all block merge except LOW)
- **critical** (Blocker): Must fix before merge (security vulnerabilities, data loss risks) - **Blocks merge: YES**
- **high** (Required): Should fix before merge (significant bugs, major quality issues) - **Blocks merge: YES**
- **medium** (Recommended): Improve code quality (maintainability concerns) - **Blocks merge: YES** (AI fixes quickly)
- **low** (Suggestion): Suggestions for improvement (minor enhancements) - **Blocks merge: NO**
- **severity**: `critical` | `high` | `medium` | `low`
- **critical**: Must fix before merge (security vulnerabilities, data loss risks)
- **high**: Should fix before merge (significant bugs, major quality issues)
- **medium**: Recommended to fix (code quality, maintainability concerns)
- **low**: Suggestions for improvement (minor enhancements)
- **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)
@@ -57,19 +57,15 @@ Perform a thorough security review of the provided code changes, focusing ONLY o
- If you're unsure, don't report it
- Prefer false negatives over false positives
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Exploitable vulnerability leading to data breach, RCE, or system compromise
### Severity Classification
- **CRITICAL**: Exploitable vulnerability leading to data breach, RCE, or system compromise
- Example: SQL injection, hardcoded admin password
- **Blocks merge: YES**
- **HIGH** (Required): Serious security flaw that could be exploited
- **HIGH**: Serious security flaw that could be exploited
- Example: Missing authentication check, XSS vulnerability
- **Blocks merge: YES**
- **MEDIUM** (Recommended): Security weakness that increases risk
- **MEDIUM**: Security weakness that increases risk
- Example: Weak password requirements, missing security headers
- **Blocks merge: YES** (AI fixes quickly, so be strict about security)
- **LOW** (Suggestion): Best practice violation, minimal risk
- **LOW**: Best practice violation, minimal risk
- Example: Using MD5 for non-security checksums
- **Blocks merge: NO** (optional polish)
### Contextual Analysis
- Consider the application type (public API vs internal tool)
+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:
-11
View File
@@ -20,7 +20,6 @@ from linear_updater import (
linear_qa_started,
)
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from progress import count_subtasks, is_build_complete
from task_logger import (
LogPhase,
@@ -110,9 +109,6 @@ async def run_qa_validation_loop(
print(f" Progress: {completed}/{total} subtasks completed")
return False
# Emit phase event at start of QA validation (before any early returns)
emit_phase(ExecutionPhase.QA_REVIEW, "Starting QA validation")
# Check if there's pending human feedback that needs to be processed
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
has_human_feedback = fix_request_file.exists()
@@ -130,7 +126,6 @@ async def run_qa_validation_loop(
"Human feedback detected - will run fixer first",
fix_request_file=str(fix_request_file),
)
emit_phase(ExecutionPhase.QA_FIXING, "Processing human feedback")
print("\n📝 Human feedback detected. Running QA Fixer first...")
# Get model and thinking budget for fixer (uses QA phase config)
@@ -207,9 +202,6 @@ async def run_qa_validation_loop(
)
print(f"\n--- QA Iteration {qa_iteration}/{MAX_QA_ITERATIONS} ---")
emit_phase(
ExecutionPhase.QA_REVIEW, f"Running QA review iteration {qa_iteration}"
)
# Run QA reviewer with phase-specific model and thinking budget
qa_model = get_phase_model(spec_dir, "qa", model)
@@ -250,7 +242,6 @@ async def run_qa_validation_loop(
)
if status == "approved":
emit_phase(ExecutionPhase.COMPLETE, "QA validation passed")
# Reset error tracking on success
consecutive_errors = 0
last_error_context = None
@@ -374,7 +365,6 @@ async def run_qa_validation_loop(
model=qa_model,
thinking_budget=fixer_thinking_budget,
)
emit_phase(ExecutionPhase.QA_FIXING, "Fixing QA issues")
print("\nRunning QA Fixer Agent...")
fix_client = create_client(
@@ -465,7 +455,6 @@ async def run_qa_validation_loop(
print("Retrying with error feedback...")
# Max iterations reached without approval
emit_phase(ExecutionPhase.FAILED, "QA validation incomplete")
debug_error(
"qa_loop",
"QA VALIDATION INCOMPLETE - max iterations reached",
+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
-3
View File
@@ -12,6 +12,3 @@ graphiti-core>=0.5.0; python_version >= "3.12"
# Google AI (optional - for Gemini LLM and embeddings)
google-generativeai>=0.8.0
# Pydantic for structured output schemas
pydantic>=2.0.0
+37 -9
View File
@@ -85,7 +85,7 @@ class ClaudeBatchAnalyzer:
try:
import sys
import claude_agent_sdk # noqa: F401 - check availability
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
backend_path = Path(__file__).parent.parent.parent
sys.path.insert(0, str(backend_path))
@@ -150,13 +150,14 @@ Respond with JSON only:
)
# Using Sonnet for better analysis (still just 1 call)
from core.simple_client import create_simple_client
client = create_simple_client(
agent_type="batch_analysis",
model="claude-sonnet-4-20250514",
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
cwd=self.project_dir,
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-sonnet-4-20250514",
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
allowed_tools=[],
max_turns=1,
cwd=str(self.project_dir.resolve()),
)
)
async with client:
@@ -239,6 +240,30 @@ Respond with JSON only:
return response_text
# Keep old class for backwards compatibility but mark as deprecated
class ClaudeSimilarityDetector(ClaudeBatchAnalyzer):
"""DEPRECATED: Use ClaudeBatchAnalyzer instead."""
async def compare_issues(
self,
repo: str,
issue_a: dict[str, Any],
issue_b: dict[str, Any],
) -> dict[str, Any]:
"""DEPRECATED: Pairwise comparison. Use analyze_and_batch_issues instead."""
logger.warning("ClaudeSimilarityDetector.compare_issues is deprecated")
# Simple fallback for any code still using this
return {
"is_similar": False,
"overall_score": 0.0,
"reasoning": "DEPRECATED: Use ClaudeBatchAnalyzer.analyze_and_batch_issues",
}
async def precompute_embeddings(self, repo: str, issues: list[dict]) -> int:
"""No-op for compatibility."""
return 0
class BatchStatus(str, Enum):
"""Status of an issue batch."""
@@ -421,9 +446,12 @@ class IssueBatcher:
self.max_batch_size = max_batch_size
self.validate_batches_enabled = validate_batches
# Initialize Claude batch analyzer
# Initialize Claude batch analyzer (replaces pairwise similarity detector)
self.analyzer = ClaudeBatchAnalyzer(project_dir=self.project_dir)
# Keep detector for backwards compatibility (deprecated)
self.detector = self.analyzer
# Initialize batch validator (uses Claude SDK with OAuth token)
self.validator = (
BatchValidator(
+17 -11
View File
@@ -8,7 +8,6 @@ Reviews whether semantically grouped issues actually belong together.
from __future__ import annotations
import importlib.util
import json
import logging
from dataclasses import dataclass
@@ -17,8 +16,13 @@ from typing import Any
logger = logging.getLogger(__name__)
# Check for Claude SDK availability without importing (avoids unused import warning)
CLAUDE_SDK_AVAILABLE = importlib.util.find_spec("claude_agent_sdk") is not None
# Check for Claude SDK availability
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
CLAUDE_SDK_AVAILABLE = True
except (ImportError, ValueError, SystemError):
CLAUDE_SDK_AVAILABLE = False
# Default model and thinking configuration
DEFAULT_MODEL = "claude-sonnet-4-20250514"
@@ -203,14 +207,16 @@ class BatchValidator:
try:
# Create Claude SDK client with extended thinking
from core.simple_client import create_simple_client
client = create_simple_client(
agent_type="batch_validation",
model=self.model,
system_prompt="You are an expert at analyzing GitHub issues and determining if they should be grouped together for a combined fix.",
cwd=self.project_dir,
max_thinking_tokens=self.thinking_budget, # Extended thinking
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=self.model,
system_prompt="You are an expert at analyzing GitHub issues and determining if they should be grouped together for a combined fix.",
allowed_tools=[], # No tools needed for this analysis
max_turns=1,
cwd=str(self.project_dir.resolve()),
settings=str(settings_file.resolve()),
max_thinking_tokens=self.thinking_budget, # Extended thinking
)
)
async with client:
+4 -53
View File
@@ -34,11 +34,6 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
try:
from .file_lock import FileLock, atomic_write
except (ImportError, ValueError, SystemError):
from file_lock import FileLock, atomic_write
@dataclass
class BotDetectionState:
@@ -66,14 +61,12 @@ class BotDetectionState:
)
def save(self, state_dir: Path) -> None:
"""Save state to disk with file locking for concurrent safety."""
"""Save state to disk."""
state_dir.mkdir(parents=True, exist_ok=True)
state_file = state_dir / "bot_detection_state.json"
# Use file locking to prevent concurrent write corruption
with FileLock(state_file, timeout=5.0, exclusive=True):
with atomic_write(state_file) as f:
json.dump(self.to_dict(), f, indent=2)
with open(state_file, "w") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
def load(cls, state_dir: Path) -> BotDetectionState:
@@ -318,9 +311,8 @@ class BotDetector:
return True, reason
# Check 2: Is the latest commit by the bot?
# Note: GitHub API returns commits oldest-first, so commits[-1] is the latest
if commits and not self.review_own_prs:
latest_commit = commits[-1] if commits else None
latest_commit = commits[0] if commits else None
if latest_commit and self.is_bot_commit(latest_commit):
reason = "Latest commit authored by bot (likely an auto-fix)"
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
@@ -411,44 +403,3 @@ class BotDetector:
"total_reviews_performed": total_reviews,
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
}
def cleanup_stale_prs(self, max_age_days: int = 30) -> int:
"""
Remove tracking state for PRs that haven't been reviewed recently.
This prevents unbounded growth of the state file by cleaning up
entries for PRs that are likely closed/merged.
Args:
max_age_days: Remove PRs not reviewed in this many days (default: 30)
Returns:
Number of PRs cleaned up
"""
cutoff = datetime.now() - timedelta(days=max_age_days)
prs_to_remove: list[str] = []
for pr_key, last_review_str in self.state.last_review_times.items():
try:
last_review = datetime.fromisoformat(last_review_str)
if last_review < cutoff:
prs_to_remove.append(pr_key)
except (ValueError, TypeError):
# Invalid timestamp - mark for removal
prs_to_remove.append(pr_key)
# Remove stale PRs
for pr_key in prs_to_remove:
if pr_key in self.state.reviewed_commits:
del self.state.reviewed_commits[pr_key]
if pr_key in self.state.last_review_times:
del self.state.last_review_times[pr_key]
if prs_to_remove:
self.state.save(self.state_dir)
print(
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
f"(older than {max_age_days} days)"
)
return len(prs_to_remove)
+14 -271
View File
@@ -28,45 +28,6 @@ try:
except (ImportError, ValueError, SystemError):
from gh_client import GHClient, PRTooLargeError
# Validation patterns for git refs and paths (defense-in-depth)
# These patterns allow common valid characters while rejecting potentially dangerous ones
SAFE_REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/\-]+$")
SAFE_PATH_PATTERN = re.compile(r"^[a-zA-Z0-9._/\-@]+$")
def _validate_git_ref(ref: str) -> bool:
"""
Validate git ref (branch name or commit SHA) for safe use in commands.
Args:
ref: Git ref to validate
Returns:
True if ref is safe, False otherwise
"""
if not ref or len(ref) > 256:
return False
return bool(SAFE_REF_PATTERN.match(ref))
def _validate_file_path(path: str) -> bool:
"""
Validate file path for safe use in git commands.
Args:
path: File path to validate
Returns:
True if path is safe, False otherwise
"""
if not path or len(path) > 1024:
return False
# Reject path traversal attempts
if ".." in path or path.startswith("/"):
return False
return bool(SAFE_PATH_PATTERN.match(path))
if TYPE_CHECKING:
try:
from .models import FollowupReviewContext, PRReviewResult
@@ -101,80 +62,27 @@ class AIBotComment:
# Known AI code review bots and their display names
# Organized by category for maintainability
AI_BOT_PATTERNS: dict[str, str] = {
# === AI Code Review Tools ===
"coderabbitai": "CodeRabbit",
"coderabbit-ai": "CodeRabbit",
"coderabbit[bot]": "CodeRabbit",
"greptile": "Greptile",
"greptile[bot]": "Greptile",
"greptile-ai": "Greptile",
"greptile-apps": "Greptile",
"cursor": "Cursor",
"cursor-ai": "Cursor",
"cursor[bot]": "Cursor",
"sourcery-ai": "Sourcery",
"sourcery-ai[bot]": "Sourcery",
"sourcery-ai-bot": "Sourcery",
"codiumai": "Qodo",
"codium-ai[bot]": "Qodo",
"codiumai-agent": "Qodo",
"qodo-merge-bot": "Qodo",
# === Google AI ===
"gemini-code-assist": "Gemini Code Assist",
"gemini-code-assist[bot]": "Gemini Code Assist",
"google-code-assist": "Gemini Code Assist",
"google-code-assist[bot]": "Gemini Code Assist",
# === AI Coding Assistants ===
"copilot": "GitHub Copilot",
"copilot[bot]": "GitHub Copilot",
"copilot-swe-agent[bot]": "GitHub Copilot",
"sweep-ai[bot]": "Sweep AI",
"sweep-nightly[bot]": "Sweep AI",
"sweep-canary[bot]": "Sweep AI",
"bitoagent": "Bito AI",
"codeium-ai-superpowers": "Codeium",
"devin-ai-integration": "Devin AI",
# === GitHub Native Bots ===
"github-actions": "GitHub Actions",
"github-actions[bot]": "GitHub Actions",
"github-advanced-security": "GitHub Advanced Security",
"github-advanced-security[bot]": "GitHub Advanced Security",
"dependabot": "Dependabot",
"dependabot[bot]": "Dependabot",
"github-merge-queue[bot]": "GitHub Merge Queue",
# === Code Quality & Static Analysis ===
"sonarcloud": "SonarCloud",
"sonarcloud[bot]": "SonarCloud",
"deepsource-autofix": "DeepSource",
"deepsource-autofix[bot]": "DeepSource",
"deepsourcebot": "DeepSource",
"codeclimate[bot]": "CodeClimate",
"codefactor-io[bot]": "CodeFactor",
"codacy[bot]": "Codacy",
# === Security Scanning ===
"snyk-bot": "Snyk",
"snyk[bot]": "Snyk",
"snyk-security-bot": "Snyk",
"gitguardian[bot]": "GitGuardian",
"semgrep-app[bot]": "Semgrep",
"semgrep-bot": "Semgrep",
# === Code Coverage ===
"codecov[bot]": "Codecov",
"codecov-commenter": "Codecov",
"coveralls": "Coveralls",
"coveralls[bot]": "Coveralls",
# === Dependency Management ===
"renovate[bot]": "Renovate",
"renovate-bot": "Renovate",
"self-hosted-renovate[bot]": "Renovate",
# === PR Automation ===
"mergify[bot]": "Mergify",
"imgbotapp": "Imgbot",
"imgbot[bot]": "Imgbot",
"allstar[bot]": "Allstar",
"percy[bot]": "Percy",
"sonarcloud": "SonarCloud",
"sonarcloud[bot]": "SonarCloud",
}
@@ -201,23 +109,18 @@ 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)
class PRContextGatherer:
"""Gathers all context needed for PR review BEFORE the AI starts."""
def __init__(self, project_dir: Path, pr_number: int, repo: str | None = None):
def __init__(self, project_dir: Path, pr_number: int):
self.project_dir = Path(project_dir)
self.pr_number = pr_number
self.repo = repo
self.gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
max_retries=3,
repo=repo,
)
async def gather(self) -> PRContext:
@@ -236,19 +139,6 @@ class PRContextGatherer:
flush=True,
)
# Ensure PR refs are available locally (fetches commits for fork PRs)
head_sha = pr_data.get("headRefOid", "")
base_sha = pr_data.get("baseRefOid", "")
refs_available = False
if head_sha and base_sha:
refs_available = await self._ensure_pr_refs_available(head_sha, base_sha)
if not refs_available:
print(
"[Context] Warning: Could not fetch PR refs locally. "
"Will use GitHub API patches as fallback.",
flush=True,
)
# Fetch changed files with content
changed_files = await self._fetch_changed_files(pr_data)
print(f"[Context] Fetched {len(changed_files)} changed files", flush=True)
@@ -294,8 +184,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", ""),
)
async def _fetch_pr_metadata(self) -> dict:
@@ -309,8 +197,6 @@ class PRContextGatherer:
"state",
"headRefName",
"baseRefName",
"headRefOid", # Commit SHA for head - works even when branch is unavailable locally
"baseRefOid", # Commit SHA for base - works even when branch is unavailable locally
"author",
"files",
"additions",
@@ -320,83 +206,6 @@ class PRContextGatherer:
],
)
async def _ensure_pr_refs_available(self, head_sha: str, base_sha: str) -> bool:
"""
Ensure PR refs are available locally by fetching the commit SHAs.
This solves the "fatal: bad revision" error when PR branches aren't
available locally (e.g., PRs from forks or unfetched branches).
Args:
head_sha: The head commit SHA (from headRefOid)
base_sha: The base commit SHA (from baseRefOid)
Returns:
True if refs are available, False otherwise
"""
# Validate SHAs before using in git commands
if not _validate_git_ref(head_sha):
print(
f"[Context] Invalid head SHA rejected: {head_sha[:50]}...", flush=True
)
return False
if not _validate_git_ref(base_sha):
print(
f"[Context] Invalid base SHA rejected: {base_sha[:50]}...", flush=True
)
return False
try:
# Fetch the specific commits - this works even for fork PRs
proc = await asyncio.create_subprocess_exec(
"git",
"fetch",
"origin",
head_sha,
base_sha,
cwd=self.project_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
if proc.returncode == 0:
print(
f"[Context] Fetched PR refs: base={base_sha[:8]} → head={head_sha[:8]}",
flush=True,
)
return True
else:
# If direct SHA fetch fails, try fetching the PR ref
print("[Context] Direct SHA fetch failed, trying PR ref...", flush=True)
proc2 = await asyncio.create_subprocess_exec(
"git",
"fetch",
"origin",
f"pull/{self.pr_number}/head:refs/pr/{self.pr_number}",
cwd=self.project_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc2.communicate(), timeout=30.0)
if proc2.returncode == 0:
print(
f"[Context] Fetched PR ref: refs/pr/{self.pr_number}",
flush=True,
)
return True
print(
f"[Context] Failed to fetch PR refs: {stderr.decode('utf-8')}",
flush=True,
)
return False
except asyncio.TimeoutError:
print("[Context] Timeout fetching PR refs", flush=True)
return False
except Exception as e:
print(f"[Context] Error fetching PR refs: {e}", flush=True)
return False
async def _fetch_changed_files(self, pr_data: dict) -> list[ChangedFile]:
"""
Fetch all changed files with their full content.
@@ -417,18 +226,16 @@ class PRContextGatherer:
print(f"[Context] Processing {path} ({status})...", flush=True)
# Use commit SHAs if available (works for fork PRs), fallback to branch names
head_ref = pr_data.get("headRefOid") or pr_data["headRefName"]
base_ref = pr_data.get("baseRefOid") or pr_data["baseRefName"]
# Get current content (from PR head branch)
content = await self._read_file_content(path, pr_data["headRefName"])
# Get current content (from PR head commit)
content = await self._read_file_content(path, head_ref)
# Get base content (from base commit)
base_content = await self._read_file_content(path, base_ref)
# Get base content (from base branch)
base_content = await self._read_file_content(path, pr_data["baseRefName"])
# Get the patch for this specific file
patch = await self._get_file_patch(path, base_ref, head_ref)
patch = await self._get_file_patch(
path, pr_data["baseRefName"], pr_data["headRefName"]
)
changed_files.append(
ChangedFile(
@@ -469,14 +276,6 @@ class PRContextGatherer:
Returns:
File content as string, or empty string if file doesn't exist
"""
# Validate inputs to prevent command injection
if not _validate_file_path(path):
print(f"[Context] Invalid file path rejected: {path[:50]}...", flush=True)
return ""
if not _validate_git_ref(ref):
print(f"[Context] Invalid git ref rejected: {ref[:50]}...", flush=True)
return ""
try:
proc = await asyncio.create_subprocess_exec(
"git",
@@ -513,21 +312,6 @@ class PRContextGatherer:
Returns:
Unified diff patch for this file
"""
# Validate inputs to prevent command injection
if not _validate_file_path(path):
print(f"[Context] Invalid file path rejected: {path[:50]}...", flush=True)
return ""
if not _validate_git_ref(base_ref):
print(
f"[Context] Invalid base ref rejected: {base_ref[:50]}...", flush=True
)
return ""
if not _validate_git_ref(head_ref):
print(
f"[Context] Invalid head ref rejected: {head_ref[:50]}...", flush=True
)
return ""
try:
proc = await asyncio.create_subprocess_exec(
"git",
@@ -880,9 +664,8 @@ class PRContextGatherer:
# Start from the directory containing the source file
base_dir = source_path.parent
# Resolve relative path - MUST prepend project_dir to resolve correctly
# when CWD is different from project root (e.g., running from apps/backend/)
resolved = (self.project_dir / base_dir / import_path).resolve()
# Resolve relative path
resolved = (base_dir / import_path).resolve()
# Try common extensions if no extension provided
if not resolved.suffix:
@@ -966,17 +749,14 @@ class FollowupContextGatherer:
project_dir: Path,
pr_number: int,
previous_review: PRReviewResult, # Forward reference
repo: str | None = None,
):
self.project_dir = Path(project_dir)
self.pr_number = pr_number
self.previous_review = previous_review
self.repo = repo
self.gh_client = GHClient(
project_dir=self.project_dir,
default_timeout=30.0,
max_retries=3,
repo=repo,
)
async def gather(self) -> FollowupReviewContext:
@@ -1046,7 +826,6 @@ class FollowupContextGatherer:
previous_review=self.previous_review,
previous_commit_sha=previous_sha,
current_commit_sha=current_sha,
error=f"Failed to compare commits: {e}",
)
# Extract data from comparison
@@ -1078,15 +857,6 @@ class FollowupContextGatherer:
print(f"[Followup] Error fetching comments: {e}", flush=True)
comments = {"review_comments": [], "issue_comments": []}
# Get formal PR reviews since last review (from Cursor, CodeRabbit, etc.)
try:
pr_reviews = await self.gh_client.get_reviews_since(
self.pr_number, self.previous_review.reviewed_at
)
except Exception as e:
print(f"[Followup] Error fetching PR reviews: {e}", flush=True)
pr_reviews = []
# Separate AI bot comments from contributor comments
ai_comments = []
contributor_comments = []
@@ -1109,33 +879,8 @@ class FollowupContextGatherer:
else:
contributor_comments.append(comment)
# Separate AI bot reviews from contributor reviews
ai_reviews = []
contributor_reviews = []
for review in pr_reviews:
author = ""
if isinstance(review.get("user"), dict):
author = review["user"].get("login", "").lower()
is_ai_bot = any(pattern in author for pattern in AI_BOT_PATTERNS.keys())
if is_ai_bot:
ai_reviews.append(review)
else:
contributor_reviews.append(review)
# Combine AI comments and reviews for reporting
total_ai_feedback = len(ai_comments) + len(ai_reviews)
total_contributor_feedback = len(contributor_comments) + len(
contributor_reviews
)
print(
f"[Followup] Found {total_contributor_feedback} contributor feedback "
f"({len(contributor_comments)} comments, {len(contributor_reviews)} reviews), "
f"{total_ai_feedback} AI feedback "
f"({len(ai_comments)} comments, {len(ai_reviews)} reviews)",
f"[Followup] Found {len(contributor_comments)} contributor comments, {len(ai_comments)} AI comments",
flush=True,
)
@@ -1147,8 +892,6 @@ class FollowupContextGatherer:
commits_since_review=commits,
files_changed_since_review=files_changed,
diff_since_review=diff_since_review,
contributor_comments_since_review=contributor_comments
+ contributor_reviews,
contributor_comments_since_review=contributor_comments,
ai_bot_comments_since_review=ai_comments,
pr_reviews_since_review=pr_reviews,
)
+29 -97
View File
@@ -1,10 +1,9 @@
"""
File Locking for Concurrent Operations
=====================================
======================================
Thread-safe and process-safe file locking utilities for GitHub automation.
Uses fcntl.flock() on Unix systems and msvcrt.locking() on Windows for proper
cross-process locking.
Uses fcntl.flock() on Unix systems for proper cross-process locking.
Example Usage:
# Simple file locking
@@ -15,79 +14,20 @@ Example Usage:
# Atomic write with locking
async with locked_write("path/to/file.json", timeout=5.0) as f:
json.dump(data, f)
"""
from __future__ import annotations
import asyncio
import fcntl
import json
import os
import tempfile
import time
import warnings
from collections.abc import Callable
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from typing import Any
_IS_WINDOWS = os.name == "nt"
_WINDOWS_LOCK_SIZE = 1024 * 1024
try:
import fcntl # type: ignore
except ImportError: # pragma: no cover
fcntl = None
try:
import msvcrt # type: ignore
except ImportError: # pragma: no cover
msvcrt = None
def _try_lock(fd: int, exclusive: bool) -> None:
if _IS_WINDOWS:
if msvcrt is None:
raise FileLockError("msvcrt is required for file locking on Windows")
if not exclusive:
warnings.warn(
"Shared file locks are not supported on Windows; using exclusive lock",
RuntimeWarning,
stacklevel=3,
)
msvcrt.locking(fd, msvcrt.LK_NBLCK, _WINDOWS_LOCK_SIZE)
return
if fcntl is None:
raise FileLockError(
"fcntl is required for file locking on non-Windows platforms"
)
lock_mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
fcntl.flock(fd, lock_mode | fcntl.LOCK_NB)
def _unlock(fd: int) -> None:
if _IS_WINDOWS:
if msvcrt is None:
warnings.warn(
"msvcrt unavailable; cannot unlock file descriptor",
RuntimeWarning,
stacklevel=3,
)
return
msvcrt.locking(fd, msvcrt.LK_UNLCK, _WINDOWS_LOCK_SIZE)
return
if fcntl is None:
warnings.warn(
"fcntl unavailable; cannot unlock file descriptor",
RuntimeWarning,
stacklevel=3,
)
return
fcntl.flock(fd, fcntl.LOCK_UN)
class FileLockError(Exception):
"""Raised when file locking operations fail."""
@@ -103,8 +43,7 @@ class FileLockTimeout(FileLockError):
class FileLock:
"""
Cross-process file lock using platform-specific locking (fcntl.flock on Unix,
msvcrt.locking on Windows).
Cross-process file lock using fcntl.flock().
Supports both sync and async context managers for flexible usage.
@@ -150,22 +89,22 @@ class FileLock:
self._fd = os.open(str(self._lock_file), os.O_CREAT | os.O_RDWR)
# Try to acquire lock with timeout
lock_mode = fcntl.LOCK_EX if self.exclusive else fcntl.LOCK_SH
start_time = time.time()
while True:
try:
# Non-blocking lock attempt
_try_lock(self._fd, self.exclusive)
fcntl.flock(self._fd, lock_mode | fcntl.LOCK_NB)
return # Lock acquired
except (BlockingIOError, OSError):
except BlockingIOError:
# Lock held by another process
elapsed = time.time() - start_time
if elapsed >= self.timeout:
os.close(self._fd)
self._fd = None
raise FileLockTimeout(
f"Failed to acquire lock on {self.filepath} within "
f"{self.timeout}s"
f"Failed to acquire lock on {self.filepath} within {self.timeout}s"
)
# Wait a bit before retrying
@@ -175,7 +114,7 @@ class FileLock:
"""Release the file lock."""
if self._fd is not None:
try:
_unlock(self._fd)
fcntl.flock(self._fd, fcntl.LOCK_UN)
os.close(self._fd)
except Exception:
pass # Best effort cleanup
@@ -202,12 +141,12 @@ class FileLock:
async def __aenter__(self):
"""Async context manager entry."""
# Run blocking lock acquisition in thread pool
await asyncio.get_running_loop().run_in_executor(None, self._acquire_lock)
await asyncio.get_event_loop().run_in_executor(None, self._acquire_lock)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await asyncio.get_running_loop().run_in_executor(None, self._release_lock)
await asyncio.get_event_loop().run_in_executor(None, self._release_lock)
return False
@@ -253,9 +192,7 @@ def atomic_write(filepath: str | Path, mode: str = "w"):
@asynccontextmanager
async def locked_write(
filepath: str | Path, timeout: float = 5.0, mode: str = "w"
) -> Any:
async def locked_write(filepath: str | Path, timeout: float = 5.0, mode: str = "w"):
"""
Async context manager combining file locking and atomic writes.
@@ -282,7 +219,7 @@ async def locked_write(
try:
# Atomic write in thread pool (since it uses sync file I/O)
fd, tmp_path = await asyncio.get_running_loop().run_in_executor(
fd, tmp_path = await asyncio.get_event_loop().run_in_executor(
None,
lambda: tempfile.mkstemp(
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
@@ -292,20 +229,20 @@ async def locked_write(
try:
# Open temp file and yield to caller
f = os.fdopen(fd, mode)
try:
yield f
finally:
f.close()
yield f
# Ensure file is closed before rename
f.close()
# Atomic replace
await asyncio.get_running_loop().run_in_executor(
await asyncio.get_event_loop().run_in_executor(
None, os.replace, tmp_path, filepath
)
except Exception:
# Clean up temp file on error
try:
await asyncio.get_running_loop().run_in_executor(
await asyncio.get_event_loop().run_in_executor(
None, os.unlink, tmp_path
)
except Exception:
@@ -318,7 +255,7 @@ async def locked_write(
@asynccontextmanager
async def locked_read(filepath: str | Path, timeout: float = 5.0) -> Any:
async def locked_read(filepath: str | Path, timeout: float = 5.0):
"""
Async context manager for locked file reading.
@@ -401,10 +338,7 @@ async def locked_json_read(filepath: str | Path, timeout: float = 5.0) -> Any:
async def locked_json_update(
filepath: str | Path,
updater: Callable[[Any], Any],
timeout: float = 5.0,
indent: int = 2,
filepath: str | Path, updater: callable, timeout: float = 5.0, indent: int = 2
) -> Any:
"""
Helper for atomic read-modify-write of JSON files.
@@ -439,19 +373,17 @@ async def locked_json_update(
try:
# Read current data
def _read_json():
if filepath.exists():
with open(filepath) as f:
return json.load(f)
return None
data = await asyncio.get_running_loop().run_in_executor(None, _read_json)
if filepath.exists():
with open(filepath) as f:
data = json.load(f)
else:
data = None
# Apply update function
updated_data = updater(data)
# Write atomically
fd, tmp_path = await asyncio.get_running_loop().run_in_executor(
fd, tmp_path = await asyncio.get_event_loop().run_in_executor(
None,
lambda: tempfile.mkstemp(
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
@@ -462,13 +394,13 @@ async def locked_json_update(
with os.fdopen(fd, "w") as f:
json.dump(updated_data, f, indent=indent)
await asyncio.get_running_loop().run_in_executor(
await asyncio.get_event_loop().run_in_executor(
None, os.replace, tmp_path, filepath
)
except Exception:
try:
await asyncio.get_running_loop().run_in_executor(
await asyncio.get_event_loop().run_in_executor(
None, os.unlink, tmp_path
)
except Exception:
-161
View File
@@ -84,7 +84,6 @@ class GHClient:
default_timeout: float = 30.0,
max_retries: int = 3,
enable_rate_limiting: bool = True,
repo: str | None = None,
):
"""
Initialize GitHub CLI client.
@@ -94,14 +93,11 @@ class GHClient:
default_timeout: Default timeout in seconds for commands
max_retries: Maximum number of retry attempts
enable_rate_limiting: Whether to enforce rate limiting (default: True)
repo: Repository in 'owner/repo' format. If provided, uses -R flag
instead of inferring from git remotes.
"""
self.project_dir = Path(project_dir)
self.default_timeout = default_timeout
self.max_retries = max_retries
self.enable_rate_limiting = enable_rate_limiting
self.repo = repo
# Initialize rate limiter singleton
if enable_rate_limiting:
@@ -258,28 +254,6 @@ class GHClient:
# Should never reach here, but for type safety
raise GHCommandError(f"gh {args[0]} failed after {self.max_retries} attempts")
# =========================================================================
# Helper methods
# =========================================================================
def _add_repo_flag(self, args: list[str]) -> list[str]:
"""
Add -R flag to command args if repo is configured.
This ensures gh CLI uses the correct repository instead of
inferring from git remotes, which can fail with multiple remotes
or when working in worktrees.
Args:
args: Command arguments list
Returns:
Modified args list with -R flag if repo is set
"""
if self.repo:
return args + ["-R", self.repo]
return args
# =========================================================================
# Convenience methods for common gh commands
# =========================================================================
@@ -321,7 +295,6 @@ class GHClient:
"--json",
",".join(json_fields),
]
args = self._add_repo_flag(args)
result = await self.run(args)
return json.loads(result.stdout)
@@ -361,7 +334,6 @@ class GHClient:
"--json",
",".join(json_fields),
]
args = self._add_repo_flag(args)
result = await self.run(args)
return json.loads(result.stdout)
@@ -380,7 +352,6 @@ class GHClient:
PRTooLargeError: If PR exceeds GitHub's 20,000 line diff limit
"""
args = ["pr", "diff", str(pr_number)]
args = self._add_repo_flag(args)
try:
result = await self.run(args)
return result.stdout
@@ -425,7 +396,6 @@ class GHClient:
args.append("--comment")
args.extend(["--body", body])
args = self._add_repo_flag(args)
await self.run(args)
return 0 # gh CLI doesn't return review ID
@@ -604,7 +574,6 @@ class GHClient:
args.extend(["--subject", commit_title])
if commit_message:
args.extend(["--body", commit_message])
args = self._add_repo_flag(args)
await self.run(args)
@@ -617,7 +586,6 @@ class GHClient:
body: Comment body
"""
args = ["pr", "comment", str(pr_number), "--body", body]
args = self._add_repo_flag(args)
await self.run(args)
async def pr_get_assignees(self, pr_number: int) -> list[str]:
@@ -727,73 +695,6 @@ class GHClient:
"issue_comments": issue_comments,
}
async def get_reviews_since(
self, pr_number: int, since_timestamp: str
) -> list[dict]:
"""
Get all PR reviews (formal review submissions) since a timestamp.
This fetches formal reviews submitted via the GitHub review mechanism,
which is different from review comments (inline comments on files).
Reviews from AI tools like Cursor, CodeRabbit, Greptile etc. are
submitted as formal reviews with body text containing their findings.
Args:
pr_number: PR number
since_timestamp: ISO timestamp to filter from (e.g., "2025-12-25T10:30:00Z")
Returns:
List of review objects with fields:
- id: Review ID
- user: User who submitted the review
- body: Review body text (contains AI findings)
- state: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING
- submitted_at: When the review was submitted
- commit_id: Commit SHA the review was made on
"""
# Fetch all reviews for the PR
# Note: The reviews endpoint doesn't support 'since' parameter,
# so we fetch all and filter client-side
reviews_endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews"
reviews_args = ["api", "--method", "GET", reviews_endpoint]
reviews_result = await self.run(reviews_args, raise_on_error=False)
reviews = []
if reviews_result.returncode == 0:
try:
all_reviews = json.loads(reviews_result.stdout)
# Filter reviews submitted after the timestamp
from datetime import datetime, timezone
# Parse since_timestamp, handling both naive and aware formats
since_dt = datetime.fromisoformat(
since_timestamp.replace("Z", "+00:00")
)
# Ensure since_dt is timezone-aware (assume UTC if naive)
if since_dt.tzinfo is None:
since_dt = since_dt.replace(tzinfo=timezone.utc)
for review in all_reviews:
submitted_at = review.get("submitted_at", "")
if submitted_at:
try:
review_dt = datetime.fromisoformat(
submitted_at.replace("Z", "+00:00")
)
# Ensure review_dt is also timezone-aware
if review_dt.tzinfo is None:
review_dt = review_dt.replace(tzinfo=timezone.utc)
if review_dt > since_dt:
reviews.append(review)
except ValueError:
# If we can't parse the date, include the review
reviews.append(review)
except json.JSONDecodeError:
logger.warning(f"Failed to parse reviews for PR #{pr_number}")
return reviews
async def get_pr_head_sha(self, pr_number: int) -> str | None:
"""
Get the current HEAD SHA of a PR.
@@ -810,65 +711,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, status, conclusion
- passing: Number of passing checks
- failing: Number of failing checks
- pending: Number of pending checks
- failed_checks: List of failed check names
"""
try:
args = ["pr", "checks", str(pr_number), "--json", "name,state,conclusion"]
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()
conclusion = check.get("conclusion", "").upper()
name = check.get("name", "Unknown")
if state == "COMPLETED":
if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
passing += 1
elif conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED"):
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),
}
+1 -46
View File
@@ -221,14 +221,6 @@ class PRReviewFinding:
)
redundant_with: str | None = None # Reference to duplicate code (file:line)
# NEW: 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_confidence: float | None = None # Confidence of validation (0.0-1.0)
validation_explanation: str | None = None # Why finding was validated/dismissed
def to_dict(self) -> dict:
return {
"id": self.id,
@@ -245,11 +237,6 @@ class PRReviewFinding:
"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_confidence": self.validation_confidence,
"validation_explanation": self.validation_explanation,
}
@classmethod
@@ -269,11 +256,6 @@ class PRReviewFinding:
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_confidence=data.get("validation_confidence"),
validation_explanation=data.get("validation_explanation"),
)
@@ -393,13 +375,6 @@ class PRReviewResult:
default_factory=list
) # New issues in recent commits
# Posted findings tracking (for frontend state sync)
has_posted_findings: bool = False # True if any findings have been posted to GitHub
posted_finding_ids: list[str] = field(
default_factory=list
) # IDs of posted findings
posted_at: str | None = None # Timestamp when findings were posted
def to_dict(self) -> dict:
return {
"pr_number": self.pr_number,
@@ -426,10 +401,6 @@ class PRReviewResult:
"resolved_findings": self.resolved_findings,
"unresolved_findings": self.unresolved_findings,
"new_findings_since_last_review": self.new_findings_since_last_review,
# Posted findings tracking
"has_posted_findings": self.has_posted_findings,
"posted_finding_ids": self.posted_finding_ids,
"posted_at": self.posted_at,
}
@classmethod
@@ -472,10 +443,6 @@ class PRReviewResult:
new_findings_since_last_review=data.get(
"new_findings_since_last_review", []
),
# Posted findings tracking
has_posted_findings=data.get("has_posted_findings", False),
posted_finding_ids=data.get("posted_finding_ids", []),
posted_at=data.get("posted_at"),
)
async def save(self, github_dir: Path) -> None:
@@ -558,13 +525,6 @@ class FollowupReviewContext:
contributor_comments_since_review: list[dict] = field(default_factory=list)
ai_bot_comments_since_review: list[dict] = field(default_factory=list)
# PR reviews since last review (formal review submissions from Cursor, CodeRabbit, etc.)
# These are different from comments - they're full review submissions with body text
pr_reviews_since_review: list[dict] = field(default_factory=list)
# Error flag - if set, context gathering failed and data may be incomplete
error: str | None = None
@dataclass
class TriageResult:
@@ -809,12 +769,7 @@ class GitHubRunnerConfig:
auto_post_reviews: bool = False
allow_fix_commits: bool = True
review_own_prs: bool = False # Whether bot can review its own PRs
use_orchestrator_review: bool = (
True # DEPRECATED: No longer used, kept for config compatibility
)
use_parallel_orchestrator: bool = (
True # Use SDK subagent parallel orchestrator (default)
)
use_orchestrator_review: bool = True # Use new Opus 4.5 orchestrating agent
# Model settings
model: str = "claude-sonnet-4-20250514"
+24 -173
View File
@@ -129,7 +129,6 @@ class GitHubOrchestrator:
default_timeout=30.0,
max_retries=3,
enable_rate_limiting=True,
repo=config.repo,
)
# Initialize bot detector for preventing infinite loops
@@ -277,16 +276,12 @@ class GitHubOrchestrator:
# PR REVIEW WORKFLOW
# =========================================================================
async def review_pr(
self, pr_number: int, force_review: bool = False
) -> PRReviewResult:
async def review_pr(self, pr_number: int) -> PRReviewResult:
"""
Perform AI-powered review of a pull request.
Args:
pr_number: The PR number to review
force_review: If True, bypass the "already reviewed" check and force a new review.
Useful for re-validating a PR or testing the review system.
Returns:
PRReviewResult with findings and overall assessment
@@ -305,9 +300,7 @@ class GitHubOrchestrator:
try:
# Gather PR context
print("[DEBUG orchestrator] Creating context gatherer...", flush=True)
gatherer = PRContextGatherer(
self.project_dir, pr_number, repo=self.config.repo
)
gatherer = PRContextGatherer(self.project_dir, pr_number)
print("[DEBUG orchestrator] Gathering PR context...", flush=True)
pr_context = await gatherer.gather()
@@ -325,34 +318,11 @@ class GitHubOrchestrator:
commits=pr_context.commits,
)
# Allow forcing a review to bypass "already reviewed" check
if should_skip and force_review and "Already reviewed" in skip_reason:
print(
f"[BOT DETECTION] Force review requested - bypassing: {skip_reason}",
flush=True,
)
should_skip = False
if should_skip:
print(
f"[BOT DETECTION] Skipping PR #{pr_number}: {skip_reason}",
flush=True,
)
# If skipping because "Already reviewed", return the existing review
# instead of creating a new empty "skipped" result
if "Already reviewed" in skip_reason:
existing_review = PRReviewResult.load(self.github_dir, pr_number)
if existing_review:
print(
"[BOT DETECTION] Returning existing review (no new commits)",
flush=True,
)
# Don't overwrite - return the existing review as-is
# The frontend will see "no new commits" via the newCommitsCheck
return existing_review
# For other skip reasons (bot-authored, cooling off), create a skip result
result = PRReviewResult(
pr_number=pr_number,
repo=self.config.repo,
@@ -389,17 +359,9 @@ class GitHubOrchestrator:
pr_number=pr_number,
)
# Check CI status
ci_status = await self.gh_client.get_pr_checks(pr_number)
print(
f"[DEBUG orchestrator] CI status: {ci_status.get('passing', 0)} passing, "
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending",
flush=True,
)
# Generate verdict (now includes CI status)
# Generate verdict
verdict, verdict_reasoning, blockers = self._generate_verdict(
findings, structural_issues, ai_triages, ci_status
findings, structural_issues, ai_triages
)
print(
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
@@ -570,30 +532,6 @@ class GitHubOrchestrator:
)
followup_context = await gatherer.gather()
# Check if context gathering failed
if followup_context.error:
print(
f"[Followup] Context gathering failed: {followup_context.error}",
flush=True,
)
# Return an error result instead of silently returning incomplete data
result = PRReviewResult(
pr_number=pr_number,
repo=self.config.repo,
success=False,
findings=[],
summary=f"Follow-up review failed: {followup_context.error}",
overall_status="comment",
verdict=MergeVerdict.NEEDS_REVISION,
verdict_reasoning=f"Context gathering failed: {followup_context.error}",
error=followup_context.error,
reviewed_commit_sha=followup_context.current_commit_sha
or previous_review.reviewed_commit_sha,
is_followup_review=True,
)
await result.save(self.github_dir)
return result
# Check if there are new commits
if not followup_context.commits_since_review:
print(
@@ -625,80 +563,20 @@ class GitHubOrchestrator:
pr_number=pr_number,
)
# Use parallel orchestrator for follow-up if enabled
if self.config.use_parallel_orchestrator:
print(
"[AI] Using parallel orchestrator for follow-up review (SDK subagents)...",
flush=True,
)
try:
from .services.parallel_followup_reviewer import (
ParallelFollowupReviewer,
)
except (ImportError, ValueError, SystemError):
from services.parallel_followup_reviewer import (
ParallelFollowupReviewer,
)
# Run follow-up review
reviewer = FollowupReviewer(
project_dir=self.project_dir,
github_dir=self.github_dir,
config=self.config,
progress_callback=lambda p: self._report_progress(
p.get("phase", "analyzing"),
p.get("progress", 50),
p.get("message", "Reviewing..."),
pr_number=pr_number,
),
)
reviewer = ParallelFollowupReviewer(
project_dir=self.project_dir,
github_dir=self.github_dir,
config=self.config,
progress_callback=lambda p: self._report_progress(
p.phase if hasattr(p, "phase") else p.get("phase", "analyzing"),
p.progress if hasattr(p, "progress") else p.get("progress", 50),
p.message
if hasattr(p, "message")
else p.get("message", "Reviewing..."),
pr_number=pr_number,
),
)
result = await reviewer.review(followup_context)
else:
# Fall back to sequential follow-up reviewer
reviewer = FollowupReviewer(
project_dir=self.project_dir,
github_dir=self.github_dir,
config=self.config,
progress_callback=lambda p: self._report_progress(
p.get("phase", "analyzing"),
p.get("progress", 50),
p.get("message", "Reviewing..."),
pr_number=pr_number,
),
)
result = await reviewer.review_followup(followup_context)
# Check CI status and override verdict if failing
ci_status = await self.gh_client.get_pr_checks(pr_number)
failed_checks = ci_status.get("failed_checks", [])
if failed_checks:
print(
f"[Followup] CI checks failing: {failed_checks}",
flush=True,
)
# Override verdict if CI is failing
if result.verdict in (
MergeVerdict.READY_TO_MERGE,
MergeVerdict.MERGE_WITH_CHANGES,
):
result.verdict = MergeVerdict.BLOCKED
result.verdict_reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
"Fix CI before merge."
)
result.overall_status = "request_changes"
# Add CI failures to blockers
for check_name in failed_checks:
if f"CI Failed: {check_name}" not in result.blockers:
result.blockers.append(f"CI Failed: {check_name}")
# Update summary to reflect CI status
ci_warning = (
f"\n\n**⚠️ CI Status:** {len(failed_checks)} check(s) failing: "
f"{', '.join(failed_checks)}"
)
if ci_warning not in result.summary:
result.summary += ci_warning
result = await reviewer.review_followup(followup_context)
# Save result
await result.save(self.github_dir)
@@ -729,22 +607,17 @@ 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]
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM]
low = [f for f in findings if f.severity == ReviewSeverity.LOW]
# NEW: Verification failures are ALWAYS blockers (even if not critical severity)
verification_failures = [
@@ -775,11 +648,6 @@ class GitHubOrchestrator:
ai_critical = [t for t in ai_triages if t.verdict == AICommentVerdict.CRITICAL]
# Build blockers list with NEW categories first
# CI failures block merging
failed_checks = ci_status.get("failed_checks", [])
for check_name in failed_checks:
blockers.append(f"CI Failed: {check_name}")
# NEW: Verification failures block merging
for f in verification_failures:
note = f" - {f.verification_note}" if f.verification_note else ""
@@ -812,17 +680,10 @@ class GitHubOrchestrator:
)
blockers.append(f"{t.tool_name}: {summary}")
# Determine verdict with CI, verification and redundancy checks
# Determine verdict with NEW verification and redundancy checks
if blockers:
# CI failures are always blockers
if failed_checks:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
"Fix CI before merge."
)
# NEW: Prioritize verification failures
elif verification_failures:
if verification_failures:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: Cannot verify {len(verification_failures)} claim(s) in PR. "
@@ -845,19 +706,9 @@ class GitHubOrchestrator:
else:
verdict = MergeVerdict.NEEDS_REVISION
reasoning = f"{len(blockers)} issues must be addressed"
elif high or medium:
# High and Medium severity findings block merge
verdict = MergeVerdict.NEEDS_REVISION
total = len(high) + len(medium)
reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)"
if low:
reasoning += f", {len(low)} suggestions"
elif low:
# Only Low severity suggestions - safe to merge (non-blocking)
verdict = MergeVerdict.READY_TO_MERGE
reasoning = (
f"No blocking issues. {len(low)} non-blocking suggestion(s) to consider"
)
elif high:
verdict = MergeVerdict.MERGE_WITH_CHANGES
reasoning = f"{len(high)} high-priority issues to address"
else:
verdict = MergeVerdict.READY_TO_MERGE
reasoning = "No blocking issues found"
@@ -56,7 +56,6 @@ class GitHubProvider:
self._gh_client = GHClient(
project_dir=project_dir,
enable_rate_limiting=self.enable_rate_limiting,
repo=self._repo,
)
@property
+45 -123
View File
@@ -46,13 +46,6 @@ import os
import sys
from pathlib import Path
# Fix Windows console encoding for Unicode output (emojis, special chars)
if sys.platform == "win32":
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -86,70 +79,34 @@ def print_progress(callback: ProgressCallback) -> None:
def get_config(args) -> GitHubRunnerConfig:
"""Build config from CLI args and environment."""
import shutil
import subprocess
token = args.token or os.environ.get("GITHUB_TOKEN", "")
bot_token = args.bot_token or os.environ.get("GITHUB_BOT_TOKEN")
repo = args.repo or os.environ.get("GITHUB_REPO", "")
# Find gh CLI - use shutil.which for cross-platform support
gh_path = shutil.which("gh")
if not gh_path and sys.platform == "win32":
# Fallback: check common Windows installation paths
common_paths = [
r"C:\Program Files\GitHub CLI\gh.exe",
r"C:\Program Files (x86)\GitHub CLI\gh.exe",
os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"),
]
for path in common_paths:
if os.path.exists(path):
gh_path = path
break
if os.environ.get("DEBUG"):
print(f"[DEBUG] gh CLI path: {gh_path}", flush=True)
print(
f"[DEBUG] PATH env: {os.environ.get('PATH', 'NOT SET')[:200]}...",
flush=True,
)
if not token and gh_path:
if not token:
# Try to get from gh CLI
try:
result = subprocess.run(
[gh_path, "auth", "token"],
capture_output=True,
text=True,
)
if result.returncode == 0:
token = result.stdout.strip()
except FileNotFoundError:
pass # gh not installed or not in PATH
import subprocess
if not repo and gh_path:
result = subprocess.run(
["gh", "auth", "token"],
capture_output=True,
text=True,
)
if result.returncode == 0:
token = result.stdout.strip()
if not repo:
# Try to detect from git remote
try:
result = subprocess.run(
[
gh_path,
"repo",
"view",
"--json",
"nameWithOwner",
"-q",
".nameWithOwner",
],
cwd=args.project,
capture_output=True,
text=True,
)
if result.returncode == 0:
repo = result.stdout.strip()
elif os.environ.get("DEBUG"):
print(f"[DEBUG] gh repo view failed: {result.stderr}", flush=True)
except FileNotFoundError:
pass # gh not installed or not in PATH
import subprocess
result = subprocess.run(
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
cwd=args.project,
capture_output=True,
text=True,
)
if result.returncode == 0:
repo = result.stdout.strip()
if not token:
print("Error: No GitHub token found. Set GITHUB_TOKEN or run 'gh auth login'")
@@ -176,44 +133,27 @@ async def cmd_review_pr(args) -> int:
import sys
# Force unbuffered output so Electron sees it in real-time
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(line_buffering=True)
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
debug = os.environ.get("DEBUG")
if debug:
print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True)
print(f"[DEBUG] Project directory: {args.project}", flush=True)
print("[DEBUG] Building config...", flush=True)
print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True)
print(f"[DEBUG] Project directory: {args.project}", flush=True)
print("[DEBUG] Building config...", flush=True)
config = get_config(args)
print(f"[DEBUG] Config built: repo={config.repo}, model={config.model}", flush=True)
if debug:
print(
f"[DEBUG] Config built: repo={config.repo}, model={config.model}",
flush=True,
)
print("[DEBUG] Creating orchestrator...", flush=True)
print("[DEBUG] Creating orchestrator...", flush=True)
orchestrator = GitHubOrchestrator(
project_dir=args.project,
config=config,
progress_callback=print_progress,
)
print("[DEBUG] Orchestrator created", flush=True)
if debug:
print("[DEBUG] Orchestrator created", flush=True)
print(
f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True
)
# Pass force_review flag if --force was specified
force_review = getattr(args, "force", False)
result = await orchestrator.review_pr(args.pr_number, force_review=force_review)
if debug:
print(f"[DEBUG] review_pr returned, success={result.success}", flush=True)
print(f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True)
result = await orchestrator.review_pr(args.pr_number)
print(f"[DEBUG] review_pr returned, success={result.success}", flush=True)
if result.success:
print(f"\n{'=' * 60}")
@@ -242,38 +182,28 @@ async def cmd_followup_review_pr(args) -> int:
import sys
# Force unbuffered output so Electron sees it in real-time
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(line_buffering=True)
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
debug = os.environ.get("DEBUG")
if debug:
print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True)
print(f"[DEBUG] Project directory: {args.project}", flush=True)
print("[DEBUG] Building config...", flush=True)
print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True)
print(f"[DEBUG] Project directory: {args.project}", flush=True)
print("[DEBUG] Building config...", flush=True)
config = get_config(args)
print(f"[DEBUG] Config built: repo={config.repo}, model={config.model}", flush=True)
if debug:
print(
f"[DEBUG] Config built: repo={config.repo}, model={config.model}",
flush=True,
)
print("[DEBUG] Creating orchestrator...", flush=True)
print("[DEBUG] Creating orchestrator...", flush=True)
orchestrator = GitHubOrchestrator(
project_dir=args.project,
config=config,
progress_callback=print_progress,
)
print("[DEBUG] Orchestrator created", flush=True)
if debug:
print("[DEBUG] Orchestrator created", flush=True)
print(
f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...",
flush=True,
)
print(
f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...",
flush=True,
)
try:
result = await orchestrator.followup_review_pr(args.pr_number)
@@ -281,10 +211,7 @@ async def cmd_followup_review_pr(args) -> int:
print(f"\nFollow-up review failed: {e}")
return 1
if debug:
print(
f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True
)
print(f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True)
if result.success:
print(f"\n{'=' * 60}")
@@ -683,11 +610,6 @@ def main():
action="store_true",
help="Automatically post review to GitHub",
)
review_parser.add_argument(
"--force",
action="store_true",
help="Force a new review even if commit was already reviewed",
)
# followup-review-pr command
followup_parser = subparsers.add_parser(
@@ -1,75 +0,0 @@
"""
Category Mapping Utilities
===========================
Shared utilities for mapping AI-generated category names to valid ReviewCategory enum values.
This module provides a centralized category mapping system used across all PR reviewers
(orchestrator, follow-up, parallel) to ensure consistent category normalization.
"""
from __future__ import annotations
try:
from ..models import ReviewCategory
except (ImportError, ValueError, SystemError):
from models import ReviewCategory
# Map AI-generated category names to valid ReviewCategory enum values
CATEGORY_MAPPING: dict[str, ReviewCategory] = {
# Direct matches (already valid ReviewCategory values)
"security": ReviewCategory.SECURITY,
"quality": ReviewCategory.QUALITY,
"style": ReviewCategory.STYLE,
"test": ReviewCategory.TEST,
"docs": ReviewCategory.DOCS,
"pattern": ReviewCategory.PATTERN,
"performance": ReviewCategory.PERFORMANCE,
"redundancy": ReviewCategory.REDUNDANCY,
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
# AI-generated alternatives that need mapping
"logic": ReviewCategory.QUALITY, # Logic errors → quality
"codebase_fit": ReviewCategory.PATTERN, # Codebase fit → pattern adherence
"correctness": ReviewCategory.QUALITY, # Code correctness → quality
"consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence
"testing": ReviewCategory.TEST, # Testing → test
"documentation": ReviewCategory.DOCS, # Documentation → docs
"bug": ReviewCategory.QUALITY, # Bug → quality
"error_handling": ReviewCategory.QUALITY, # Error handling → quality
"maintainability": ReviewCategory.QUALITY, # Maintainability → quality
"readability": ReviewCategory.STYLE, # Readability → style
"best_practices": ReviewCategory.PATTERN, # Best practices → pattern (hyphen normalized to underscore)
"architecture": ReviewCategory.PATTERN, # Architecture → pattern
"complexity": ReviewCategory.QUALITY, # Complexity → quality
"dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy
"unused": ReviewCategory.REDUNDANCY, # Unused code → redundancy
# Follow-up specific mappings
"regression": ReviewCategory.QUALITY, # Regression → quality
"incomplete_fix": ReviewCategory.QUALITY, # Incomplete fix → quality
}
def map_category(raw_category: str) -> ReviewCategory:
"""
Map an AI-generated category string to a valid ReviewCategory enum.
Args:
raw_category: Raw category string from AI (e.g., "best-practices", "logic", "security")
Returns:
ReviewCategory: Normalized category enum value. Defaults to QUALITY if unknown.
Examples:
>>> map_category("security")
ReviewCategory.SECURITY
>>> map_category("best-practices")
ReviewCategory.PATTERN
>>> map_category("unknown-category")
ReviewCategory.QUALITY
"""
# Normalize: lowercase, strip whitespace, replace hyphens with underscores
normalized = raw_category.lower().strip().replace("-", "_")
# Look up in mapping, default to QUALITY for unknown categories
return CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
@@ -16,6 +16,7 @@ Supports both:
from __future__ import annotations
import hashlib
import json
import logging
import re
from datetime import datetime
@@ -33,9 +34,7 @@ try:
ReviewCategory,
ReviewSeverity,
)
from .category_utils import map_category
from .prompt_manager import PromptManager
from .pydantic_models import FollowupReviewResponse
except (ImportError, ValueError, SystemError):
from models import (
MergeVerdict,
@@ -44,12 +43,21 @@ except (ImportError, ValueError, SystemError):
ReviewCategory,
ReviewSeverity,
)
from services.category_utils import map_category
from services.prompt_manager import PromptManager
from services.pydantic_models import FollowupReviewResponse
logger = logging.getLogger(__name__)
# Category mapping for AI responses
_CATEGORY_MAPPING = {
"security": ReviewCategory.SECURITY,
"quality": ReviewCategory.QUALITY,
"logic": ReviewCategory.QUALITY,
"test": ReviewCategory.TEST,
"docs": ReviewCategory.DOCS,
"pattern": ReviewCategory.PATTERN,
"performance": ReviewCategory.PERFORMANCE,
}
# Severity mapping for AI responses
_SEVERITY_MAPPING = {
"critical": ReviewSeverity.CRITICAL,
@@ -277,21 +285,17 @@ class FollowupReviewer:
resolved.append(finding)
else:
# File was modified but the specific line wasn't clearly changed
# Mark as unresolved - the contributor needs to address the actual issue
# "Benefit of the doubt" was wrong - if the line wasn't changed, the issue persists
unresolved.append(finding)
# Consider it potentially resolved (benefit of the doubt)
# Could be more sophisticated with AST analysis
resolved.append(finding)
return resolved, unresolved
def _line_appears_changed(self, file: str, line: int | None, diff: str) -> bool:
def _line_appears_changed(self, file: str, line: int, diff: str) -> bool:
"""Check if a specific line appears to have been changed in the diff."""
if not diff:
return False
# Handle None or invalid line numbers (legacy data)
if line is None or line <= 0:
return True # Assume changed if line unknown
# Look for the file in the diff
file_marker = f"--- a/{file}"
if file_marker not in diff:
@@ -442,20 +446,11 @@ class FollowupReviewer:
high_unresolved = sum(
1 for f in unresolved_findings if f.severity == ReviewSeverity.HIGH
)
medium_unresolved = sum(
1 for f in unresolved_findings if f.severity == ReviewSeverity.MEDIUM
)
low_unresolved = sum(
1 for f in unresolved_findings if f.severity == ReviewSeverity.LOW
)
critical_new = sum(
1 for f in new_findings if f.severity == ReviewSeverity.CRITICAL
)
high_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.HIGH)
medium_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.MEDIUM)
low_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.LOW)
# Critical and High are always blockers
for f in unresolved_findings:
if f.severity in [ReviewSeverity.CRITICAL, ReviewSeverity.HIGH]:
blockers.append(f"Unresolved: {f.title} ({f.file}:{f.line})")
@@ -471,25 +466,17 @@ class FollowupReviewer:
f"Still blocked by {critical_unresolved + critical_new} critical issues "
f"({critical_unresolved} unresolved, {critical_new} new)"
)
elif (
high_unresolved > 0
or high_new > 0
or medium_unresolved > 0
or medium_new > 0
):
# High and Medium severity findings block merge
elif high_unresolved > 0 or high_new > 0:
verdict = MergeVerdict.NEEDS_REVISION
total_blocking = high_unresolved + high_new + medium_unresolved + medium_new
reasoning = (
f"{total_blocking} issue(s) must be addressed "
f"({high_unresolved + medium_unresolved} unresolved, {high_new + medium_new} new)"
f"{high_unresolved + high_new} high-priority issues "
f"({high_unresolved} unresolved, {high_new} new)"
)
elif low_unresolved > 0 or low_new > 0:
# Only Low severity suggestions remaining - safe to merge (non-blocking)
verdict = MergeVerdict.READY_TO_MERGE
elif len(unresolved_findings) > 0 or len(new_findings) > 0:
verdict = MergeVerdict.MERGE_WITH_CHANGES
reasoning = (
f"{resolved_count} issues resolved. "
f"{low_unresolved + low_new} non-blocking suggestion(s) to consider."
f"{len(unresolved_findings)} remaining, {len(new_findings)} new minor issues."
)
else:
verdict = MergeVerdict.READY_TO_MERGE
@@ -551,10 +538,7 @@ class FollowupReviewer:
unresolved: list[PRReviewFinding],
) -> dict[str, Any] | None:
"""
Run AI-powered follow-up review using structured outputs.
Uses Claude Agent SDK's native structured output support to guarantee
valid JSON responses matching the FollowupReviewResponse schema.
Run AI-powered follow-up review using the prompt template.
Returns parsed AI response with finding resolutions and new findings,
or None if AI review fails.
@@ -564,7 +548,7 @@ class FollowupReviewer:
)
# Build the context for the AI
prompt_template = self.prompt_manager.get_followup_review_prompt()
prompt = self.prompt_manager.get_followup_review_prompt()
# Format previous findings for the prompt
previous_findings_text = "\n".join(
@@ -597,19 +581,9 @@ class FollowupReviewer:
]
)
# Format PR reviews (formal review submissions from Cursor, CodeRabbit, etc.)
# These often contain detailed findings in the body, so we include more content
pr_reviews_text = "\n\n".join(
[
f"**@{r.get('user', {}).get('login', 'unknown')}** ({r.get('state', 'COMMENTED')}):\n{r.get('body', '')[:2000]}"
for r in context.pr_reviews_since_review
if r.get("body", "").strip() # Only include reviews with body content
]
)
# Build the full message
user_message = f"""
{prompt_template}
{prompt}
---
@@ -639,107 +613,51 @@ class FollowupReviewer:
### AI BOT COMMENTS SINCE LAST REVIEW:
{ai_comments_text if ai_comments_text else "No AI bot comments."}
### PR REVIEWS SINCE LAST REVIEW (CodeRabbit, Gemini Code Assist, Cursor, etc.):
{pr_reviews_text if pr_reviews_text else "No PR reviews since last review."}
---
**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc. that may have identified issues in the recent changes. You should:
1. Consider their findings when evaluating the code
2. Create new findings for valid issues they identified that haven't been addressed
3. Note if the recent commits addressed concerns raised in these reviews
Analyze this follow-up review context and provide your structured response.
Please analyze this follow-up review context and provide your response in the JSON format specified in the prompt.
"""
try:
# Use Claude Agent SDK query() with structured outputs
# Reference: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
from claude_agent_sdk import ClaudeAgentOptions, query
from phase_config import get_thinking_budget
# Use ClaudeSDKClient directly for simple message calls
# (no agent tools needed, just a single query/response)
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
model = self.config.model or "claude-sonnet-4-5-20250929"
thinking_level = self.config.thinking_level or "medium"
thinking_budget = get_thinking_budget(thinking_level)
# Debug: Log the schema being sent
schema = FollowupReviewResponse.model_json_schema()
logger.debug(
f"[Followup] Using output_format schema: {list(schema.get('properties', {}).keys())}"
)
print(f"[Followup] SDK query with output_format, model={model}", flush=True)
# Iterate through messages from the query
# Note: max_turns=2 because structured output uses a tool call + response
async for message in query(
prompt=user_message,
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt="You are a code review assistant. Analyze the provided context and provide structured feedback.",
system_prompt="You are a code review assistant. Analyze the provided context and respond with valid JSON.",
allowed_tools=[],
max_turns=2, # Need 2 turns for structured output tool call
max_thinking_tokens=thinking_budget,
output_format={
"type": "json_schema",
"schema": schema,
},
),
):
msg_type = type(message).__name__
max_turns=1,
max_thinking_tokens=2048,
)
)
# SDK delivers structured output via ToolUseBlock named 'StructuredOutput'
# in an AssistantMessage
if msg_type == "AssistantMessage":
content = getattr(message, "content", [])
for block in content:
block_type = type(block).__name__
if block_type == "ToolUseBlock":
tool_name = getattr(block, "name", "")
if tool_name == "StructuredOutput":
# Extract structured data from tool input
structured_data = getattr(block, "input", None)
if structured_data:
logger.info(
"[Followup] Found StructuredOutput tool use"
)
print(
"[Followup] Using SDK structured output",
flush=True,
)
# Validate with Pydantic and convert
result = FollowupReviewResponse.model_validate(
structured_data
)
return self._convert_structured_to_internal(result)
response_text = ""
async with client:
await client.query(user_message)
# Also check for direct structured_output attribute (SDK validated JSON)
if (
hasattr(message, "structured_output")
and message.structured_output
):
logger.info(
"[Followup] Found structured_output attribute on message"
)
print(
"[Followup] Using SDK structured output (direct attribute)",
flush=True,
)
result = FollowupReviewResponse.model_validate(
message.structured_output
)
return self._convert_structured_to_internal(result)
async for msg in client.receive_response():
msg_type = type(msg).__name__
logger.debug(f"AI response message type: {msg_type}")
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
logger.debug(f" Content block type: {block_type}")
if hasattr(block, "text"):
response_text += block.text
elif hasattr(block, "thinking"):
# Skip thinking blocks - we only want the final text
logger.debug(" (skipping thinking block)")
# Handle ResultMessage for errors
if msg_type == "ResultMessage":
subtype = getattr(message, "subtype", None)
if subtype == "error_max_structured_output_retries":
logger.warning(
"Claude could not produce valid structured output after retries"
)
return None
if not response_text:
logger.warning("AI returned empty response (no text blocks found)")
return None
logger.warning("No structured output received from AI")
return None
logger.debug(f"AI response text (first 500 chars): {response_text[:500]}")
return self._parse_ai_response(response_text)
except ValueError as e:
# OAuth token not found
@@ -747,69 +665,96 @@ Analyze this follow-up review context and provide your structured response.
print("AI review failed: No OAuth token found", flush=True)
return None
except Exception as e:
logger.error(f"AI review with structured output failed: {e}")
logger.error(f"AI review failed: {e}")
return None
def _convert_structured_to_internal(
self, result: FollowupReviewResponse
) -> dict[str, Any]:
"""
Convert Pydantic FollowupReviewResponse to internal dict format.
def _parse_ai_response(self, response_text: str) -> dict[str, Any] | None:
"""Parse the AI response JSON."""
# Extract JSON from response (may be wrapped in markdown code blocks)
json_match = re.search(r"```json\s*(.*?)\s*```", response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
else:
# Try to find raw JSON
json_match = re.search(r"\{[\s\S]*\}", response_text)
if json_match:
json_str = json_match.group(0)
else:
logger.warning("No JSON found in AI response")
return None
Converts Pydantic finding models to PRReviewFinding dataclass objects
for compatibility with existing codebase.
"""
# Convert new_findings to PRReviewFinding objects
new_findings = []
for f in result.new_findings:
new_findings.append(
PRReviewFinding(
id=f.id,
severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.MEDIUM),
category=map_category(f.category),
title=f.title,
description=f.description,
file=f.file,
line=f.line,
suggested_fix=f.suggested_fix,
fixable=f.fixable,
)
)
try:
data = json.loads(json_str)
# Convert comment_findings to PRReviewFinding objects
comment_findings = []
for f in result.comment_findings:
comment_findings.append(
PRReviewFinding(
id=f.id,
severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.LOW),
category=map_category(f.category),
title=f.title,
description=f.description,
file=f.file,
line=f.line,
suggested_fix=f.suggested_fix,
fixable=f.fixable,
)
)
# Convert new_findings to PRReviewFinding objects
new_findings = []
for f in data.get("new_findings", []):
try:
new_findings.append(
PRReviewFinding(
id=f.get(
"id",
hashlib.md5(
f.get("title", "").encode(), usedforsecurity=False
).hexdigest()[:12],
),
severity=_SEVERITY_MAPPING.get(
f.get("severity", "medium").lower(),
ReviewSeverity.MEDIUM,
),
category=_CATEGORY_MAPPING.get(
f.get("category", "quality").lower(),
ReviewCategory.QUALITY,
),
title=f.get("title", "Untitled finding"),
description=f.get("description", ""),
file=f.get("file", ""),
line=f.get("line", 0),
suggested_fix=f.get("suggested_fix"),
)
)
except Exception as e:
logger.warning(f"Failed to parse finding: {e}")
# Convert finding_resolutions to dict format
finding_resolutions = [
{
"finding_id": r.finding_id,
"status": r.status,
"resolution_notes": r.resolution_notes,
# Convert comment_findings similarly
comment_findings = []
for f in data.get("comment_findings", []):
try:
comment_findings.append(
PRReviewFinding(
id=f.get(
"id",
hashlib.md5(
f.get("title", "").encode(), usedforsecurity=False
).hexdigest()[:12],
),
severity=_SEVERITY_MAPPING.get(
f.get("severity", "low").lower(), ReviewSeverity.LOW
),
category=_CATEGORY_MAPPING.get(
f.get("category", "quality").lower(),
ReviewCategory.QUALITY,
),
title=f.get("title", "Comment needs attention"),
description=f.get("description", ""),
file=f.get("file", ""),
line=f.get("line", 0),
)
)
except Exception as e:
logger.warning(f"Failed to parse comment finding: {e}")
return {
"finding_resolutions": data.get("finding_resolutions", []),
"new_findings": new_findings,
"comment_findings": comment_findings,
"verdict": data.get("verdict"),
"verdict_reasoning": data.get("verdict_reasoning"),
}
for r in result.finding_resolutions
]
return {
"finding_resolutions": finding_resolutions,
"new_findings": new_findings,
"comment_findings": comment_findings,
"verdict": result.verdict,
"verdict_reasoning": result.verdict_reasoning,
}
except json.JSONDecodeError as e:
logger.error(f"Failed to parse AI response JSON: {e}")
return None
def _apply_ai_resolutions(
self,
@@ -0,0 +1,912 @@
"""
Orchestrating PR Reviewer
==========================
Strategic PR review system using a single Opus 4.5 orchestrating agent
that makes human-like decisions about where to focus review effort.
Replaces the fixed multi-pass system with adaptive, risk-based review.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
try:
from ...core.client import create_client
from ..context_gatherer import PRContext
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
)
from .review_tools import (
check_coverage,
get_file_content,
run_tests,
spawn_deep_analysis,
spawn_quality_review,
spawn_security_review,
verify_path_exists,
)
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext
from core.client import create_client
from models import (
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
)
from services.review_tools import (
check_coverage,
get_file_content,
run_tests,
spawn_deep_analysis,
spawn_quality_review,
spawn_security_review,
verify_path_exists,
)
logger = logging.getLogger(__name__)
# Map AI-generated category names to valid ReviewCategory enum values
# The AI sometimes generates categories that aren't in our enum
_CATEGORY_MAPPING = {
# Direct matches (already valid)
"security": ReviewCategory.SECURITY,
"quality": ReviewCategory.QUALITY,
"style": ReviewCategory.STYLE,
"test": ReviewCategory.TEST,
"docs": ReviewCategory.DOCS,
"pattern": ReviewCategory.PATTERN,
"performance": ReviewCategory.PERFORMANCE,
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
"redundancy": ReviewCategory.REDUNDANCY,
# AI-generated alternatives that need mapping
"correctness": ReviewCategory.QUALITY, # Logic/code correctness → quality
"consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence
"testing": ReviewCategory.TEST, # Testing → test
"documentation": ReviewCategory.DOCS, # Documentation → docs
"bug": ReviewCategory.QUALITY, # Bug → quality
"logic": ReviewCategory.QUALITY, # Logic error → quality
"error_handling": ReviewCategory.QUALITY, # Error handling → quality
"maintainability": ReviewCategory.QUALITY, # Maintainability → quality
"readability": ReviewCategory.STYLE, # Readability → style
"best_practices": ReviewCategory.PATTERN, # Best practices → pattern
"best-practices": ReviewCategory.PATTERN, # With hyphen
"architecture": ReviewCategory.PATTERN, # Architecture → pattern
"complexity": ReviewCategory.QUALITY, # Complexity → quality
"dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy
"unused": ReviewCategory.REDUNDANCY, # Unused → redundancy
}
def _map_category(category_str: str) -> ReviewCategory:
"""
Map an AI-generated category string to a valid ReviewCategory enum.
Falls back to QUALITY if the category is unknown.
"""
normalized = category_str.lower().strip().replace("-", "_")
return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
class OrchestratorReviewer:
"""
Strategic PR reviewer using Opus 4.5 for orchestration.
Makes human-like decisions about:
- Which files are high-risk and need deep review
- When to spawn focused subagents vs quick scan
- Whether to run tests/coverage checks
- Final verdict based on aggregated findings
"""
def __init__(
self,
project_dir: Path,
github_dir: Path,
config: GitHubRunnerConfig,
progress_callback=None,
):
self.project_dir = Path(project_dir)
self.github_dir = Path(github_dir)
self.config = config
self.progress_callback = progress_callback
# Token usage tracking
self.total_tokens = 0
self.MAX_TOTAL_BUDGET = 150_000
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
"""Report progress if callback is set."""
if self.progress_callback:
import sys
if "orchestrator" in sys.modules:
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
else:
try:
from ..orchestrator import ProgressCallback
except ImportError:
from orchestrator import ProgressCallback
self.progress_callback(
ProgressCallback(
phase=phase, progress=progress, message=message, **kwargs
)
)
async def review(self, context: PRContext) -> PRReviewResult:
"""
Main review entry point.
Args:
context: Full PR context with all files and patches
Returns:
PRReviewResult with findings and verdict
"""
logger.info(
f"[Orchestrator] Starting strategic review for PR #{context.pr_number}"
)
try:
self._report_progress(
"orchestrating",
20,
"Orchestrator analyzing PR structure...",
pr_number=context.pr_number,
)
# Build orchestrator prompt with tool definitions
prompt = self._build_orchestrator_prompt(context)
# Create Opus 4.5 client with extended thinking
project_root = (
self.project_dir.parent.parent
if self.project_dir.name == "backend"
else self.project_dir
)
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model="claude-opus-4-5-20251101", # Opus for strategic thinking
agent_type="pr_reviewer", # Read-only - no bash, no edits
max_thinking_tokens=10000, # High budget for strategy
)
self._report_progress(
"orchestrating",
30,
"Orchestrator making strategic decisions...",
pr_number=context.pr_number,
)
# Run orchestrator session with tool calling
all_findings = []
test_result = None
result_text = ""
tool_calls_made = []
logger.info(f"[Orchestrator] Sending prompt (length: {len(prompt)} chars)")
logger.debug(f"[Orchestrator] Prompt preview: {prompt[:500]}...")
async with client:
await client.query(prompt)
async for msg in client.receive_response():
msg_type = type(msg).__name__
logger.debug(f"[Orchestrator] Received message type: {msg_type}")
# Handle tool calls from orchestrator
if msg_type == "ToolUseBlock" or (
hasattr(msg, "type") and msg.type == "tool_use"
):
tool_name = (
msg.name
if hasattr(msg, "name")
else msg.tool_use.name
if hasattr(msg, "tool_use")
else "unknown"
)
tool_calls_made.append(tool_name)
logger.info(f"[Orchestrator] Tool call detected: {tool_name}")
tool_result = await self._handle_tool_call(msg, context)
# Tools already executed, agent will receive results
logger.debug(
f"[Orchestrator] Tool result: {str(tool_result)[:200]}..."
)
# Track findings from subagents
if isinstance(tool_result, dict):
if "findings" in tool_result:
findings_count = len(tool_result["findings"])
logger.info(
f"[Orchestrator] Tool returned {findings_count} findings"
)
all_findings.extend(tool_result["findings"])
if "test_result" in tool_result:
test_result = tool_result["test_result"]
logger.info(
f"[Orchestrator] Tool returned test result: {test_result.get('passed', 'unknown')}"
)
# Track token usage from response
if hasattr(msg, "usage"):
usage = msg.usage
tokens_used = getattr(usage, "input_tokens", 0) + getattr(
usage, "output_tokens", 0
)
self.total_tokens += tokens_used
logger.debug(
f"[Orchestrator] Token usage: +{tokens_used} (total: {self.total_tokens})"
)
# Collect final orchestrator output
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
if hasattr(block, "text"):
result_text += block.text
logger.debug(
f"[Orchestrator] Received text block (length: {len(block.text)})"
)
logger.info(
f"[Orchestrator] Session complete. Tool calls made: {tool_calls_made}"
)
logger.info(
f"[Orchestrator] Final text response length: {len(result_text)}"
)
logger.debug(f"[Orchestrator] Final text preview: {result_text[:500]}...")
# CRITICAL DEBUG: Print to ensure visibility
print(
f"[Orchestrator] Session complete. Tool calls: {tool_calls_made}",
flush=True,
)
print(
f"[Orchestrator] Final text length: {len(result_text)} chars",
flush=True,
)
print("[Orchestrator] ===== FULL OUTPUT START =====", flush=True)
print(result_text, flush=True)
print("[Orchestrator] ===== FULL OUTPUT END =====", flush=True)
self._report_progress(
"finalizing",
80,
"Generating verdict...",
pr_number=context.pr_number,
)
# Parse orchestrator's final output
orchestrator_findings = self._parse_orchestrator_output(result_text)
all_findings.extend(orchestrator_findings)
# Deduplicate findings
unique_findings = self._deduplicate_findings(all_findings)
logger.info(
f"[Orchestrator] Review complete: {len(unique_findings)} findings"
)
# Generate verdict
verdict, verdict_reasoning, blockers = self._generate_verdict(
unique_findings, test_result
)
# Generate summary
summary = self._generate_summary(
verdict=verdict,
verdict_reasoning=verdict_reasoning,
blockers=blockers,
findings=unique_findings,
test_result=test_result,
)
# Map verdict to overall_status
if verdict == MergeVerdict.BLOCKED:
overall_status = "request_changes"
elif verdict == MergeVerdict.NEEDS_REVISION:
overall_status = "request_changes"
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
overall_status = "comment"
else:
overall_status = "approve"
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
success=True,
findings=unique_findings,
summary=summary,
overall_status=overall_status,
verdict=verdict,
verdict_reasoning=verdict_reasoning,
blockers=blockers,
)
print(
f"[Orchestrator] Returning PRReviewResult with {len(result.findings)} findings",
flush=True,
)
print(
f"[Orchestrator] Verdict: {result.verdict.value if result.verdict else 'None'}",
flush=True,
)
self._report_progress(
"complete", 100, "Review complete!", pr_number=context.pr_number
)
return result
except Exception as e:
logger.error(f"[Orchestrator] Review failed: {e}", exc_info=True)
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
success=False,
error=str(e),
)
return result
async def _handle_tool_call(self, tool_msg, context: PRContext) -> dict[str, Any]:
"""
Handle tool calls from orchestrator.
The orchestrator can call tools to spawn subagents, run tests, etc.
"""
# Extract tool name and arguments based on message type
if hasattr(tool_msg, "name"):
tool_name = tool_msg.name
tool_args = tool_msg.input if hasattr(tool_msg, "input") else {}
elif hasattr(tool_msg, "tool_use"):
tool_name = tool_msg.tool_use.name
tool_args = tool_msg.tool_use.input
else:
logger.warning("[Orchestrator] Unknown tool message format")
return {}
logger.info(f"[Orchestrator] Tool call: {tool_name}")
# Check token budget
if self.total_tokens > self.MAX_TOTAL_BUDGET:
logger.warning("[Orchestrator] Token budget exceeded, skipping tool")
return {"error": "Token budget exceeded"}
try:
# Dispatch to appropriate tool
if tool_name == "spawn_security_review":
findings = await spawn_security_review(
files=tool_args.get("files", []),
focus_areas=tool_args.get("focus_areas", []),
pr_context=context,
project_dir=self.project_dir,
github_dir=self.github_dir,
)
return {"findings": [f.__dict__ for f in findings]}
elif tool_name == "spawn_quality_review":
findings = await spawn_quality_review(
files=tool_args.get("files", []),
focus_areas=tool_args.get("focus_areas", []),
pr_context=context,
project_dir=self.project_dir,
github_dir=self.github_dir,
)
return {"findings": [f.__dict__ for f in findings]}
elif tool_name == "spawn_deep_analysis":
findings = await spawn_deep_analysis(
files=tool_args.get("files", []),
focus_question=tool_args.get("focus_question", ""),
pr_context=context,
project_dir=self.project_dir,
github_dir=self.github_dir,
)
return {"findings": [f.__dict__ for f in findings]}
elif tool_name == "run_tests":
test_result = await run_tests(
project_dir=self.project_dir,
test_paths=tool_args.get("test_paths"),
)
return {"test_result": test_result.__dict__}
elif tool_name == "check_coverage":
coverage = await check_coverage(
project_dir=self.project_dir,
changed_files=[f.path for f in context.changed_files],
)
return (
{"coverage": coverage.__dict__} if coverage else {"coverage": None}
)
elif tool_name == "verify_path_exists":
path_result = await verify_path_exists(
project_dir=self.project_dir,
path=tool_args.get("path", ""),
)
return {"path_check": path_result.__dict__}
elif tool_name == "get_file_content":
content = await get_file_content(
project_dir=self.project_dir,
file_path=tool_args.get("file_path", ""),
)
return {"content": content}
else:
logger.warning(f"[Orchestrator] Unknown tool: {tool_name}")
return {"error": f"Unknown tool: {tool_name}"}
except Exception as e:
logger.error(f"[Orchestrator] Tool {tool_name} failed: {e}")
return {"error": str(e)}
def _build_orchestrator_prompt(self, context: PRContext) -> str:
"""Build full prompt for orchestrator with PR context and tool definitions."""
# Load orchestrator prompt
prompt_file = (
Path(__file__).parent.parent.parent.parent
/ "prompts"
/ "github"
/ "pr_orchestrator.md"
)
if prompt_file.exists():
base_prompt = prompt_file.read_text()
else:
logger.warning("Orchestrator prompt not found!")
base_prompt = "You are a PR reviewer. Review the provided PR."
# Build PR context
files_list = []
for file in context.changed_files: # Show ALL files
files_list.append(
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
)
# Build composite diff from patches (use individual file patches when diff_truncated)
patches = []
files_with_patches = 0
MAX_DIFF_CHARS = 200_000 # Increase limit to 200K chars for large PRs
for file in context.changed_files: # Process ALL files, not just first 50
if file.patch:
patches.append(f"\n### File: {file.path}\n{file.patch}")
files_with_patches += 1
diff_content = "\n".join(patches)
# Check if diff needs truncation
if len(diff_content) > MAX_DIFF_CHARS:
logger.warning(
f"[Orchestrator] Diff truncated from {len(diff_content)} to {MAX_DIFF_CHARS} chars"
)
diff_content = (
diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated due to size)"
)
logger.info(
f"[Orchestrator] Built context: {len(context.changed_files)} files total, {files_with_patches} with patches, {len(diff_content)} chars diff"
)
# Add truncation warning if needed
truncation_note = ""
if len(diff_content) >= MAX_DIFF_CHARS or context.diff_truncated:
truncation_note = f"""
** IMPORTANT:** This PR is very large. The diff shown below may be truncated.
- Files with patches: {files_with_patches}/{len(context.changed_files)}
- Use `get_file_content(file_path)` tool to fetch full content of specific files you want to review in depth.
"""
pr_context = f"""
---
## PR Context for Review
**PR Number:** {context.pr_number}
**Title:** {context.title}
**Author:** {context.author}
**Base:** {context.base_branch} **Head:** {context.head_branch}
**Files Changed:** {len(context.changed_files)} files
**Total Changes:** +{context.total_additions}/-{context.total_deletions} lines
{truncation_note}
### Description
{context.description}
### All Changed Files
{chr(10).join(files_list)}
### Code Changes ({files_with_patches} files with patches)
```diff
{diff_content}
```
---
Now perform your strategic review and use the available tools to spawn subagents, run tests, etc. as needed.
"""
return base_prompt + pr_context
def _parse_orchestrator_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from orchestrator's final output."""
findings = []
logger.debug(f"[Orchestrator] Parsing output (length: {len(output)})")
print(
f"[Orchestrator] PARSING OUTPUT - Length: {len(output)} chars", flush=True
)
try:
# Strip markdown code blocks if present
# AI often wraps JSON in ```json ... ```
import re
# Find JSON in code blocks first
code_block_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```"
code_block_match = re.search(code_block_pattern, output)
if code_block_match:
# Extract JSON from inside code block
json_candidate = code_block_match.group(1)
logger.debug(
f"[Orchestrator] Found JSON in code block (length: {len(json_candidate)})"
)
try:
response_data = json.loads(json_candidate)
findings_data = response_data.get("findings", [])
logger.info(
f"[Orchestrator] Parsed {len(findings_data)} findings from code block"
)
print(
f"[Orchestrator] Parsed JSON from code block - Verdict: {response_data.get('verdict', 'unknown')}",
flush=True,
)
return self._extract_findings_from_data(findings_data)
except json.JSONDecodeError:
logger.debug(
"[Orchestrator] Code block JSON parse failed, trying raw extraction"
)
# Look for JSON object in output (orchestrator outputs full object, not just array)
start = output.find("{")
# Find matching closing brace by counting braces
if start != -1:
brace_count = 0
end = -1
for i in range(start, len(output)):
if output[i] == "{":
brace_count += 1
elif output[i] == "}":
brace_count -= 1
if brace_count == 0:
end = i
break
logger.debug(
f"[Orchestrator] JSON object positions: start={start}, end={end}"
)
if end != -1:
json_str = output[start : end + 1]
logger.debug(
f"[Orchestrator] Extracted JSON string (length: {len(json_str)})"
)
logger.debug(f"[Orchestrator] JSON preview: {json_str[:200]}...")
# Parse full orchestrator response
response_data = json.loads(json_str)
logger.info(
f"[Orchestrator] Parsed orchestrator response: {response_data.get('verdict', 'unknown')}"
)
print(
f"[Orchestrator] Parsed JSON object - Verdict: {response_data.get('verdict', 'unknown')}",
flush=True,
)
# Extract findings array from response
findings_data = response_data.get("findings", [])
logger.info(
f"[Orchestrator] Found {len(findings_data)} finding(s) in response"
)
print(
f"[Orchestrator] Extracted {len(findings_data)} findings from response",
flush=True,
)
# Process findings from JSON object
for idx, data in enumerate(findings_data):
# Generate unique ID for this finding
import hashlib
finding_id = hashlib.md5(
f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
# Map category using flexible mapping (handles AI-generated values)
category = _map_category(data.get("category", "quality"))
# Map severity with fallback
try:
severity = ReviewSeverity(
data.get("severity", "medium").lower()
)
except ValueError:
severity = ReviewSeverity.MEDIUM
finding = PRReviewFinding(
id=finding_id,
file=data.get("file", "unknown"),
line=data.get("line", 0),
title=data.get("title", "Untitled"),
description=data.get("description", ""),
category=category,
severity=severity,
suggested_fix=data.get(
"suggestion", data.get("suggested_fix", "")
),
confidence=data.get("confidence", 85) / 100.0
if data.get("confidence", 85) > 1
else data.get("confidence", 0.85),
)
findings.append(finding)
logger.debug(
f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})"
)
print(
f"[Orchestrator] Processed {len(findings)} findings from JSON object",
flush=True,
)
else:
logger.warning(
"[Orchestrator] Could not find matching closing brace"
)
return findings
elif output.find("[") != -1:
# Fallback: Try to parse as array (old format)
start = output.find("[")
end = output.rfind("]")
logger.debug(
f"[Orchestrator] Fallback to array parsing: start={start}, end={end}"
)
if start != -1 and end != -1:
json_str = output[start : end + 1]
logger.debug(
f"[Orchestrator] Extracted JSON array (length: {len(json_str)})"
)
findings_data = json.loads(json_str)
logger.info(
f"[Orchestrator] Parsed {len(findings_data)} finding(s) from array"
)
for idx, data in enumerate(findings_data):
# Generate unique ID for this finding
import hashlib
finding_id = hashlib.md5(
f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
# Map category using flexible mapping (handles AI-generated values)
category = _map_category(data.get("category", "quality"))
# Map severity with fallback
try:
severity = ReviewSeverity(
data.get("severity", "medium").lower()
)
except ValueError:
severity = ReviewSeverity.MEDIUM
finding = PRReviewFinding(
id=finding_id,
file=data.get("file", "unknown"),
line=data.get("line", 0),
title=data.get("title", "Untitled"),
description=data.get("description", ""),
category=category,
severity=severity,
suggested_fix=data.get(
"suggestion", data.get("suggested_fix", "")
),
confidence=data.get("confidence", 85) / 100.0
if data.get("confidence", 85) > 1
else data.get("confidence", 0.85),
)
findings.append(finding)
logger.debug(
f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})"
)
else:
logger.warning("[Orchestrator] No JSON array found in output")
except Exception as e:
logger.error(f"[Orchestrator] Failed to parse output: {e}", exc_info=True)
logger.info(f"[Orchestrator] Parsed {len(findings)} total findings from output")
return findings
def _extract_findings_from_data(
self, findings_data: list[dict]
) -> list[PRReviewFinding]:
"""
Extract PRReviewFinding objects from parsed JSON findings data.
Args:
findings_data: List of finding dictionaries from JSON
Returns:
List of PRReviewFinding objects
"""
import hashlib
findings = []
for data in findings_data:
# Generate unique ID for this finding
finding_id = hashlib.md5(
f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
# Map category using flexible mapping (handles AI-generated values)
category = _map_category(data.get("category", "quality"))
# Map severity with fallback
try:
severity = ReviewSeverity(data.get("severity", "medium").lower())
except ValueError:
severity = ReviewSeverity.MEDIUM
finding = PRReviewFinding(
id=finding_id,
file=data.get("file", "unknown"),
line=data.get("line", 0),
title=data.get("title", "Untitled"),
description=data.get("description", ""),
category=category,
severity=severity,
suggested_fix=data.get("suggestion", data.get("suggested_fix", "")),
confidence=data.get("confidence", 85) / 100.0
if data.get("confidence", 85) > 1
else data.get("confidence", 0.85),
)
findings.append(finding)
logger.debug(
f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})"
)
return findings
def _deduplicate_findings(
self, findings: list[PRReviewFinding]
) -> list[PRReviewFinding]:
"""Remove duplicate findings."""
seen = set()
unique = []
for f in findings:
key = (f.file, f.line, f.title.lower().strip())
if key not in seen:
seen.add(key)
unique.append(f)
return unique
def _generate_verdict(
self, findings: list[PRReviewFinding], test_result
) -> tuple[MergeVerdict, str, list[str]]:
"""Generate merge verdict based on findings and test results."""
blockers = []
# Count by severity
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
# Tests failing is always a blocker
if test_result and not test_result.passed:
blockers.append(f"Tests failing: {test_result.error or 'Unknown error'}")
# Critical findings are blockers
for f in critical:
blockers.append(f"Critical: {f.title} ({f.file}:{f.line})")
# Determine verdict
if blockers or (test_result and not test_result.passed):
verdict = MergeVerdict.BLOCKED
reasoning = f"Blocked by {len(blockers)} critical issue(s)"
elif high:
verdict = MergeVerdict.NEEDS_REVISION
reasoning = f"{len(high)} high-priority issues must be addressed"
elif len(findings) > 0:
verdict = MergeVerdict.MERGE_WITH_CHANGES
reasoning = f"{len(findings)} issues to address"
else:
verdict = MergeVerdict.READY_TO_MERGE
reasoning = "No blocking issues found"
return verdict, reasoning, blockers
def _generate_summary(
self,
verdict: MergeVerdict,
verdict_reasoning: str,
blockers: list[str],
findings: list[PRReviewFinding],
test_result,
) -> str:
"""Generate PR review summary."""
verdict_emoji = {
MergeVerdict.READY_TO_MERGE: "",
MergeVerdict.MERGE_WITH_CHANGES: "🟡",
MergeVerdict.NEEDS_REVISION: "🟠",
MergeVerdict.BLOCKED: "🔴",
}
lines = [
f"### Merge Verdict: {verdict_emoji.get(verdict, '')} {verdict.value.upper().replace('_', ' ')}",
verdict_reasoning,
"",
]
# Test results
if test_result:
if test_result.passed:
lines.append("✅ **Tests**: All tests passing")
else:
lines.append(
f"❌ **Tests**: Failed - {test_result.error or 'See logs'}"
)
lines.append("")
# Blockers
if blockers:
lines.append("### 🚨 Blocking Issues")
for blocker in blockers:
lines.append(f"- {blocker}")
lines.append("")
# Findings summary
if findings:
by_severity = {}
for f in findings:
severity = f.severity.value
if severity not in by_severity:
by_severity[severity] = []
by_severity[severity].append(f)
lines.append("### Findings Summary")
for severity in ["critical", "high", "medium", "low"]:
if severity in by_severity:
count = len(by_severity[severity])
lines.append(f"- **{severity.capitalize()}**: {count} issue(s)")
lines.append("")
lines.append("---")
lines.append("_Generated by Auto Claude Orchestrating PR Reviewer (Opus 4.5)_")
return "\n".join(lines)
@@ -1,859 +0,0 @@
"""
Parallel Follow-up PR Reviewer
===============================
PR follow-up reviewer using Claude Agent SDK subagents for parallel specialist analysis.
The orchestrator analyzes incremental changes and delegates to specialized agents:
- resolution-verifier: Verifies previous findings are addressed
- new-code-reviewer: Reviews new code for issues
- comment-analyzer: Processes contributor and AI feedback
Key Design:
- AI decides which agents to invoke (NOT programmatic rules)
- Subagents defined via SDK `agents={}` parameter
- SDK handles parallel execution automatically
- User-configured model from frontend settings (no hardcoding)
"""
from __future__ import annotations
import hashlib
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..models import FollowupReviewContext
from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from .category_utils import map_category
from .pydantic_models import ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from core.client import create_client
from models import (
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from phase_config import get_thinking_budget
from services.category_utils import map_category
from services.pydantic_models import ParallelFollowupResponse
from services.sdk_utils import process_sdk_stream
logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
# Severity mapping for AI responses
_SEVERITY_MAPPING = {
"critical": ReviewSeverity.CRITICAL,
"high": ReviewSeverity.HIGH,
"medium": ReviewSeverity.MEDIUM,
"low": ReviewSeverity.LOW,
}
def _map_severity(severity_str: str) -> ReviewSeverity:
"""Map severity string to ReviewSeverity enum."""
return _SEVERITY_MAPPING.get(severity_str.lower(), ReviewSeverity.MEDIUM)
class ParallelFollowupReviewer:
"""
Follow-up PR reviewer using SDK subagents for parallel specialist analysis.
The orchestrator:
1. Analyzes incremental changes since last review
2. Delegates to appropriate specialist agents (SDK handles parallel execution)
3. Synthesizes findings into a final merge verdict
Specialist Agents:
- resolution-verifier: Verifies previous findings are addressed
- new-code-reviewer: Reviews new code for issues
- comment-analyzer: Processes contributor and AI feedback
Model Configuration:
- Orchestrator uses user-configured model from frontend settings
- Specialist agents use model="inherit" (same as orchestrator)
"""
def __init__(
self,
project_dir: Path,
github_dir: Path,
config: GitHubRunnerConfig,
progress_callback=None,
):
self.project_dir = Path(project_dir)
self.github_dir = Path(github_dir)
self.config = config
self.progress_callback = progress_callback
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
"""Report progress if callback is set."""
if self.progress_callback:
import sys
if "orchestrator" in sys.modules:
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
else:
try:
from ..orchestrator import ProgressCallback
except ImportError:
from orchestrator import ProgressCallback
self.progress_callback(
ProgressCallback(
phase=phase, progress=progress, message=message, **kwargs
)
)
def _load_prompt(self, filename: str) -> str:
"""Load a prompt file from the prompts/github directory."""
prompt_file = (
Path(__file__).parent.parent.parent.parent / "prompts" / "github" / filename
)
if prompt_file.exists():
return prompt_file.read_text(encoding="utf-8")
logger.warning(f"Prompt file not found: {prompt_file}")
return ""
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
"""
Define specialist agents for follow-up review.
Each agent has:
- description: When the orchestrator should invoke this agent
- prompt: System prompt for the agent
- tools: Tools the agent can use (read-only for PR review)
- model: "inherit" = use same model as orchestrator (user's choice)
"""
# Load agent prompts from files
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(
description=(
"Resolution verification specialist. Use to verify whether previous "
"findings have been addressed. Analyzes diffs to determine if issues "
"are truly fixed, partially fixed, or still unresolved. "
"Invoke when: There are previous findings to verify."
),
prompt=resolution_prompt
or "You verify whether previous findings are resolved.",
tools=["Read", "Grep", "Glob"],
model="inherit",
),
"new-code-reviewer": AgentDefinition(
description=(
"New code analysis specialist. Reviews code added since last review "
"for security, logic, quality issues, and regressions. "
"Invoke when: There are substantial code changes (>50 lines diff) or "
"changes to security-sensitive areas."
),
prompt=newcode_prompt or "You review new code for issues.",
tools=["Read", "Grep", "Glob"],
model="inherit",
),
"comment-analyzer": AgentDefinition(
description=(
"Comment and feedback analyst. Processes contributor comments and "
"AI tool reviews (CodeRabbit, Cursor, Gemini, etc.) to identify "
"unanswered questions and valid concerns. "
"Invoke when: There are comments or formal reviews since last review."
),
prompt=comment_prompt or "You analyze comments and feedback.",
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:
"""Format previous findings for the prompt."""
previous_findings = context.previous_review.findings
if not previous_findings:
return "No previous findings to verify."
lines = []
for f in previous_findings:
lines.append(
f"- **{f.id}** [{f.severity.value}] {f.title}\n"
f" File: {f.file}:{f.line}\n"
f" {f.description[:200]}..."
)
return "\n".join(lines)
def _format_commits(self, context: FollowupReviewContext) -> str:
"""Format new commits for the prompt."""
if not context.commits_since_review:
return "No new commits."
lines = []
for commit in context.commits_since_review[:20]: # Limit to 20 commits
sha = commit.get("sha", "")[:7]
message = commit.get("commit", {}).get("message", "").split("\n")[0]
author = commit.get("commit", {}).get("author", {}).get("name", "unknown")
lines.append(f"- `{sha}` by {author}: {message}")
return "\n".join(lines)
def _format_comments(self, context: FollowupReviewContext) -> str:
"""Format contributor comments for the prompt."""
if not context.contributor_comments_since_review:
return "No contributor comments since last review."
lines = []
for comment in context.contributor_comments_since_review[:15]:
author = comment.get("user", {}).get("login", "unknown")
body = comment.get("body", "")[:300]
lines.append(f"**@{author}**: {body}")
return "\n\n".join(lines)
def _format_ai_reviews(self, context: FollowupReviewContext) -> str:
"""Format AI bot reviews and comments for the prompt."""
ai_content = []
# AI bot comments
for comment in context.ai_bot_comments_since_review[:10]:
author = comment.get("user", {}).get("login", "unknown")
body = comment.get("body", "")[:500]
ai_content.append(f"**{author}** (comment):\n{body}")
# Formal PR reviews from AI tools
for review in context.pr_reviews_since_review[:5]:
author = review.get("user", {}).get("login", "unknown")
body = review.get("body", "")[:1000]
state = review.get("state", "unknown")
ai_content.append(f"**{author}** ({state}):\n{body}")
if not ai_content:
return "No AI tool feedback since last review."
return "\n\n---\n\n".join(ai_content)
def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str:
"""Build full prompt for orchestrator with follow-up context."""
# Load orchestrator prompt
base_prompt = self._load_prompt("pr_followup_orchestrator.md")
if not base_prompt:
base_prompt = "You are a follow-up PR reviewer. Verify resolutions and find new issues."
# Build context sections
previous_findings = self._format_previous_findings(context)
commits = self._format_commits(context)
contributor_comments = self._format_comments(context)
ai_reviews = self._format_ai_reviews(context)
# Truncate diff if too long
MAX_DIFF_CHARS = 100_000
diff_content = context.diff_since_review
if len(diff_content) > MAX_DIFF_CHARS:
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
followup_context = f"""
---
## Follow-up Review Context
**PR Number:** {context.pr_number}
**Previous Review Commit:** {context.previous_commit_sha[:8]}
**Current HEAD:** {context.current_commit_sha[:8]}
**New Commits:** {len(context.commits_since_review)}
**Files Changed:** {len(context.files_changed_since_review)}
### Previous Review Summary
{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."}
### Previous Findings to Verify
{previous_findings}
### New Commits Since Last Review
{commits}
### Files Changed Since Last Review
{chr(10).join(f"- {f}" for f in context.files_changed_since_review[:30])}
### Contributor Comments Since Last Review
{contributor_comments}
### AI Tool Feedback Since Last Review
{ai_reviews}
### Diff Since Last Review
```diff
{diff_content}
```
---
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.
"""
return base_prompt + followup_context
async def review(self, context: FollowupReviewContext) -> PRReviewResult:
"""
Main follow-up review entry point.
Args:
context: Follow-up context with incremental changes
Returns:
PRReviewResult with findings and verdict
"""
logger.info(
f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}"
)
try:
self._report_progress(
"orchestrating",
35,
"Parallel orchestrator analyzing follow-up...",
pr_number=context.pr_number,
)
# Build orchestrator prompt
prompt = self._build_orchestrator_prompt(context)
# 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"
thinking_level = self.config.thinking_level or "medium"
thinking_budget = get_thinking_budget(thinking_level)
logger.info(
f"[ParallelFollowup] Using model={model}, "
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
)
# Create client with subagents defined
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model=model,
agent_type="pr_followup_parallel",
max_thinking_tokens=thinking_budget,
agents=self._define_specialist_agents(),
output_format={
"type": "json_schema",
"schema": ParallelFollowupResponse.model_json_schema(),
},
)
self._report_progress(
"orchestrating",
40,
"Orchestrator delegating to specialist agents...",
pr_number=context.pr_number,
)
# Run orchestrator session using shared SDK stream processor
async with client:
await client.query(prompt)
print(
f"[ParallelFollowup] Running orchestrator ({model})...",
flush=True,
)
# Process SDK stream with shared utility
stream_result = await process_sdk_stream(
client=client,
context_name="ParallelFollowup",
)
# Check for stream processing errors
if stream_result.get("error"):
logger.error(
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
)
raise RuntimeError(
f"SDK stream processing failed: {stream_result['error']}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
agents_invoked = stream_result["agents_invoked"]
msg_count = stream_result["msg_count"]
self._report_progress(
"finalizing",
50,
"Synthesizing follow-up findings...",
pr_number=context.pr_number,
)
# Parse findings from output
if structured_output:
result_data = self._parse_structured_output(structured_output, context)
else:
result_data = self._parse_text_output(result_text, context)
# Extract data
findings = result_data.get("findings", [])
resolved_ids = result_data.get("resolved_ids", [])
unresolved_ids = result_data.get("unresolved_ids", [])
new_finding_ids = result_data.get("new_finding_ids", [])
verdict = result_data.get("verdict", MergeVerdict.NEEDS_REVISION)
verdict_reasoning = result_data.get("verdict_reasoning", "")
# Use agents from structured output (more reliable than streaming detection)
agents_from_result = result_data.get("agents_invoked", [])
final_agents = agents_from_result if agents_from_result else agents_invoked
logger.info(
f"[ParallelFollowup] Session complete. Agents invoked: {final_agents}"
)
print(
f"[ParallelFollowup] Complete. Agents invoked: {final_agents}",
flush=True,
)
# Deduplicate findings
unique_findings = self._deduplicate_findings(findings)
logger.info(
f"[ParallelFollowup] Review complete: {len(unique_findings)} findings, "
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,
verdict_reasoning=verdict_reasoning,
resolved_count=len(resolved_ids),
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
if verdict == MergeVerdict.BLOCKED:
overall_status = "request_changes"
elif verdict == MergeVerdict.NEEDS_REVISION:
overall_status = "request_changes"
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
overall_status = "comment"
else:
overall_status = "approve"
# Generate blockers from critical/high/medium severity findings
# (Medium also blocks merge in our strict quality gates approach)
blockers = []
for finding in unique_findings:
if finding.severity in (
ReviewSeverity.CRITICAL,
ReviewSeverity.HIGH,
ReviewSeverity.MEDIUM,
):
blockers.append(f"{finding.category.value}: {finding.title}")
result = PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
success=True,
findings=unique_findings,
summary=summary,
overall_status=overall_status,
verdict=verdict,
verdict_reasoning=verdict_reasoning,
blockers=blockers,
reviewed_commit_sha=context.current_commit_sha,
is_followup_review=True,
previous_review_id=context.previous_review.review_id
or context.previous_review.pr_number,
resolved_findings=resolved_ids,
unresolved_findings=unresolved_ids,
new_findings_since_last_review=new_finding_ids,
)
self._report_progress(
"analyzed",
60,
"Follow-up analysis complete",
pr_number=context.pr_number,
)
return result
except Exception as e:
logger.error(f"[ParallelFollowup] Review failed: {e}", exc_info=True)
print(f"[ParallelFollowup] Error: {e}", flush=True)
return PRReviewResult(
pr_number=context.pr_number,
repo=self.config.repo,
success=False,
findings=[],
summary=f"Follow-up review failed: {e}",
overall_status="comment",
verdict=MergeVerdict.NEEDS_REVISION,
verdict_reasoning=f"Review failed: {e}",
blockers=[str(e)],
is_followup_review=True,
reviewed_commit_sha=context.current_commit_sha,
)
def _parse_structured_output(
self, data: dict, context: FollowupReviewContext
) -> dict:
"""Parse structured output from ParallelFollowupResponse."""
try:
# Validate with Pydantic
response = ParallelFollowupResponse.model_validate(data)
# Log agents from structured output
agents_from_output = response.agents_invoked or []
if agents_from_output:
print(
f"[ParallelFollowup] Specialist agents invoked: {', '.join(agents_from_output)}",
flush=True,
)
for agent in agents_from_output:
print(f"[Agent:{agent}] Analysis complete", flush=True)
findings = []
resolved_ids = []
unresolved_ids = []
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
if rv.status in ("unresolved", "cant_verify"):
# Find original finding
original = next(
(
f
for f in context.previous_review.findings
if f.id == rv.finding_id
),
None,
)
if original:
# Check if we have validation evidence
validation = validation_map.get(rv.finding_id)
validation_status = None
validation_evidence = None
validation_confidence = None
validation_explanation = None
if validation:
validation_status = validation.validation_status
validation_evidence = validation.code_evidence
validation_confidence = validation.confidence
validation_explanation = validation.explanation
findings.append(
PRReviewFinding(
id=rv.finding_id,
severity=original.severity,
category=original.category,
title=f"[UNRESOLVED] {original.title}",
description=f"{original.description}\n\nResolution note: {rv.evidence}",
file=original.file,
line=original.line,
suggested_fix=original.suggested_fix,
fixable=original.fixable,
validation_status=validation_status,
validation_evidence=validation_evidence,
validation_confidence=validation_confidence,
validation_explanation=validation_explanation,
)
)
# Process new findings
for nf in response.new_findings:
finding_id = nf.id or self._generate_finding_id(
nf.file, nf.line, nf.title
)
new_finding_ids.append(finding_id)
findings.append(
PRReviewFinding(
id=finding_id,
severity=_map_severity(nf.severity),
category=map_category(nf.category),
title=nf.title,
description=nf.description,
file=nf.file,
line=nf.line,
suggested_fix=nf.suggested_fix,
fixable=nf.fixable,
)
)
# Process comment findings
for cf in response.comment_findings:
finding_id = cf.id or self._generate_finding_id(
cf.file, cf.line, cf.title
)
new_finding_ids.append(finding_id)
findings.append(
PRReviewFinding(
id=finding_id,
severity=_map_severity(cf.severity),
category=map_category(cf.category),
title=f"[FROM COMMENTS] {cf.title}",
description=cf.description,
file=cf.file,
line=cf.line,
suggested_fix=cf.suggested_fix,
fixable=cf.fixable,
)
)
# Map verdict
verdict_map = {
"READY_TO_MERGE": MergeVerdict.READY_TO_MERGE,
"MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES,
"NEEDS_REVISION": MergeVerdict.NEEDS_REVISION,
"BLOCKED": MergeVerdict.BLOCKED,
}
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, "
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved, "
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}",
flush=True,
)
return {
"findings": findings,
"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,
}
except Exception as e:
logger.warning(f"[ParallelFollowup] Failed to parse structured output: {e}")
return self._create_empty_result()
def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict:
"""Parse text output when structured output fails."""
logger.warning("[ParallelFollowup] Falling back to text parsing")
# Simple heuristic parsing
findings = []
# Look for verdict keywords
text_lower = text.lower()
if "ready to merge" in text_lower or "approve" in text_lower:
verdict = MergeVerdict.READY_TO_MERGE
elif "blocked" in text_lower or "critical" in text_lower:
verdict = MergeVerdict.BLOCKED
elif "needs revision" in text_lower or "request changes" in text_lower:
verdict = MergeVerdict.NEEDS_REVISION
else:
verdict = MergeVerdict.MERGE_WITH_CHANGES
return {
"findings": findings,
"resolved_ids": [],
"unresolved_ids": [],
"new_finding_ids": [],
"verdict": verdict,
"verdict_reasoning": text[:500] if text else "Unable to parse response",
}
def _create_empty_result(self) -> dict:
"""Create empty result structure."""
return {
"findings": [],
"resolved_ids": [],
"unresolved_ids": [],
"new_finding_ids": [],
"verdict": MergeVerdict.NEEDS_REVISION,
"verdict_reasoning": "Unable to parse review results",
}
def _generate_finding_id(self, file: str, line: int, title: str) -> str:
"""Generate a unique finding ID."""
content = f"{file}:{line}:{title}"
return f"FU-{hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8].upper()}"
def _deduplicate_findings(
self, findings: list[PRReviewFinding]
) -> list[PRReviewFinding]:
"""Remove duplicate findings."""
seen = set()
unique = []
for f in findings:
key = (f.file, f.line, f.title.lower().strip())
if key not in seen:
seen.add(key)
unique.append(f)
return unique
def _generate_summary(
self,
verdict: MergeVerdict,
verdict_reasoning: str,
resolved_count: int,
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 = {
MergeVerdict.READY_TO_MERGE: "",
MergeVerdict.MERGE_WITH_CHANGES: "⚠️",
MergeVerdict.NEEDS_REVISION: "🔄",
MergeVerdict.BLOCKED: "🚫",
}
emoji = status_emoji.get(verdict, "📝")
agents_str = (
", ".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}
### Review Process
Agents invoked: {agents_str}
---
*This is an AI-generated follow-up review using parallel specialist analysis with finding validation.*
"""
return summary
File diff suppressed because it is too large Load Diff
@@ -277,22 +277,19 @@ class PRReviewEngine:
Returns:
Tuple of (findings, structural_issues, ai_triages, quick_scan_summary)
"""
# Use parallel orchestrator with SDK subagents if enabled
if self.config.use_parallel_orchestrator:
print(
"[AI] Using parallel orchestrator PR review (SDK subagents)...",
flush=True,
)
# Use orchestrating agent if enabled
if self.config.use_orchestrator_review:
print("[AI] Using orchestrating PR review agent (Opus 4.5)...", flush=True)
self._report_progress(
"orchestrating",
10,
"Starting parallel orchestrator review...",
"Starting orchestrating review...",
pr_number=context.pr_number,
)
from .parallel_orchestrator_reviewer import ParallelOrchestratorReviewer
from .orchestrator_reviewer import OrchestratorReviewer
orchestrator = ParallelOrchestratorReviewer(
orchestrator = OrchestratorReviewer(
project_dir=self.project_dir,
github_dir=self.github_dir,
config=self.config,
@@ -302,16 +299,22 @@ class PRReviewEngine:
result = await orchestrator.review(context)
print(
f"[PR Review Engine] Parallel orchestrator returned {len(result.findings)} findings",
f"[PR Review Engine] Orchestrator returned {len(result.findings)} findings",
flush=True,
)
# Convert PRReviewResult to expected format
# Orchestrator doesn't use structural_issues or ai_triages
quick_scan_summary = {
"verdict": result.verdict.value if result.verdict else "unknown",
"findings_count": len(result.findings),
"strategy": "parallel_orchestrator",
"strategy": "orchestrating_agent",
}
print(
f"[PR Review Engine] Returning tuple with {len(result.findings)} findings",
flush=True,
)
return (result.findings, [], [], quick_scan_summary)
# Fall back to multi-pass review
@@ -473,7 +476,7 @@ class PRReviewEngine:
/ "pr_structural.md"
)
if prompt_file.exists():
prompt = prompt_file.read_text(encoding="utf-8")
prompt = prompt_file.read_text()
else:
prompt = self.prompt_manager.get_review_pass_prompt(ReviewPass.STRUCTURAL)
@@ -524,7 +527,7 @@ class PRReviewEngine:
/ "pr_ai_triage.md"
)
if prompt_file.exists():
prompt = prompt_file.read_text(encoding="utf-8")
prompt = prompt_file.read_text()
else:
prompt = self.prompt_manager.get_review_pass_prompt(
ReviewPass.AI_COMMENT_TRIAGE
@@ -268,7 +268,7 @@ Output JSON array of structural issues:
```
""",
ReviewPass.AI_COMMENT_TRIAGE: """
You are triaging comments from other AI code review tools (CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc).
You are triaging comments from other AI code review tools (CodeRabbit, Cursor, Greptile, etc).
For each AI comment, determine:
- CRITICAL: Genuine issue that must be addressed before merge
@@ -298,7 +298,7 @@ Output JSON array:
"""Get the main PR review prompt."""
prompt_file = self.prompts_dir / "pr_reviewer.md"
if prompt_file.exists():
return prompt_file.read_text(encoding="utf-8")
return prompt_file.read_text()
return self._get_default_pr_review_prompt()
def _get_default_pr_review_prompt(self) -> str:
@@ -338,7 +338,7 @@ Be specific and actionable. Focus on significant issues, not nitpicks.
"""Get the follow-up PR review prompt."""
prompt_file = self.prompts_dir / "pr_followup.md"
if prompt_file.exists():
return prompt_file.read_text(encoding="utf-8")
return prompt_file.read_text()
return self._get_default_followup_review_prompt()
def _get_default_followup_review_prompt(self) -> str:
@@ -380,7 +380,7 @@ Output JSON:
"""Get the issue triage prompt."""
prompt_file = self.prompts_dir / "issue_triager.md"
if prompt_file.exists():
return prompt_file.read_text(encoding="utf-8")
return prompt_file.read_text()
return self._get_default_triage_prompt()
def _get_default_triage_prompt(self) -> str:
@@ -1,703 +0,0 @@
"""
Pydantic Models for Structured AI Outputs
==========================================
These models define JSON schemas for Claude Agent SDK structured outputs.
Used to guarantee valid, validated JSON from AI responses in PR reviews.
Usage:
from claude_agent_sdk import query
from .pydantic_models import FollowupReviewResponse
async for message in query(
prompt="...",
options={
"output_format": {
"type": "json_schema",
"schema": FollowupReviewResponse.model_json_schema()
}
}
):
if hasattr(message, 'structured_output'):
result = FollowupReviewResponse.model_validate(message.structured_output)
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# =============================================================================
# Common Finding Types
# =============================================================================
class BaseFinding(BaseModel):
"""Base class for all finding types."""
id: str = Field(description="Unique identifier for this finding")
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
title: str = Field(description="Brief issue title (max 80 chars)")
description: str = Field(description="Detailed explanation of the issue")
file: str = Field(description="File path where issue was found")
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")
class SecurityFinding(BaseFinding):
"""A security vulnerability finding."""
category: Literal["security"] = Field(
default="security", description="Always 'security' for security findings"
)
class QualityFinding(BaseFinding):
"""A code quality or redundancy finding."""
category: Literal[
"redundancy", "quality", "test", "performance", "pattern", "docs"
] = Field(description="Issue category")
redundant_with: str | None = Field(
None, description="Reference to duplicate code (file:line) if redundant"
)
class DeepAnalysisFinding(BaseFinding):
"""A finding from deep analysis with verification info."""
category: Literal[
"verification_failed",
"redundancy",
"quality",
"pattern",
"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"
)
class StructuralIssue(BaseModel):
"""A structural issue with the PR."""
id: str = Field(description="Unique identifier")
issue_type: Literal[
"feature_creep", "scope_creep", "architecture_violation", "poor_structure"
] = Field(description="Type of structural issue")
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity"
)
title: str = Field(description="Brief issue title")
description: str = Field(description="Detailed explanation")
impact: str = Field(description="Why this matters")
suggestion: str = Field(description="How to fix")
class AICommentTriage(BaseModel):
"""Triage result for an AI tool comment."""
comment_id: int = Field(description="GitHub comment ID")
tool_name: str = Field(
description="AI tool name (CodeRabbit, Cursor, Greptile, etc.)"
)
verdict: Literal[
"critical", "important", "nice_to_have", "trivial", "false_positive"
] = Field(description="Verdict on the comment")
reasoning: str = Field(description="Why this verdict was chosen")
response_comment: str | None = Field(
None, description="Optional comment to post in reply"
)
# =============================================================================
# Follow-up Review Response
# =============================================================================
class FindingResolution(BaseModel):
"""Resolution status for a previous finding."""
finding_id: str = Field(description="ID of the previous finding")
status: Literal["resolved", "unresolved"] = Field(description="Resolution status")
resolution_notes: str | None = Field(
None, description="Notes on how it was resolved"
)
class FollowupFinding(BaseModel):
"""A new finding from follow-up review (simpler than initial review)."""
id: str = Field(description="Unique identifier for this finding")
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
description="Issue category"
)
title: str = Field(description="Brief issue title")
description: str = Field(description="Detailed explanation of the issue")
file: str = Field(description="File path where issue was found")
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")
class FollowupReviewResponse(BaseModel):
"""Complete response schema for follow-up PR review."""
finding_resolutions: list[FindingResolution] = Field(
default_factory=list, description="Status of each previous finding"
)
new_findings: list[FollowupFinding] = Field(
default_factory=list,
description="New issues found in changes since last review",
)
comment_findings: list[FollowupFinding] = Field(
default_factory=list, description="Issues found in contributor comments"
)
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
verdict_reasoning: str = Field(description="Explanation for the verdict")
# =============================================================================
# Initial Review Responses (Multi-Pass)
# =============================================================================
class QuickScanResult(BaseModel):
"""Result from the quick scan pass."""
purpose: str = Field(description="Brief description of what the PR claims to do")
actual_changes: str = Field(
description="Brief description of what the code actually does"
)
purpose_match: bool = Field(
description="Whether actual changes match the claimed purpose"
)
purpose_match_note: str | None = Field(
None, description="Explanation if purpose doesn't match actual changes"
)
risk_areas: list[str] = Field(
default_factory=list, description="Areas needing careful review"
)
red_flags: list[str] = Field(
default_factory=list, description="Obvious issues or concerns"
)
requires_deep_verification: bool = Field(
description="Whether deep verification is needed"
)
complexity: Literal["low", "medium", "high"] = Field(description="PR complexity")
class SecurityPassResult(BaseModel):
"""Result from the security pass - array of security findings."""
findings: list[SecurityFinding] = Field(
default_factory=list, description="Security vulnerabilities found"
)
class QualityPassResult(BaseModel):
"""Result from the quality pass - array of quality findings."""
findings: list[QualityFinding] = Field(
default_factory=list, description="Quality and redundancy issues found"
)
class DeepAnalysisResult(BaseModel):
"""Result from the deep analysis pass."""
findings: list[DeepAnalysisFinding] = Field(
default_factory=list,
description="Deep analysis findings with verification info",
)
class StructuralPassResult(BaseModel):
"""Result from the structural pass."""
issues: list[StructuralIssue] = Field(
default_factory=list, description="Structural issues found"
)
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Structural verdict")
verdict_reasoning: str = Field(description="Explanation for the verdict")
class AICommentTriageResult(BaseModel):
"""Result from AI comment triage pass."""
triages: list[AICommentTriage] = Field(
default_factory=list, description="Triage results for each AI comment"
)
# =============================================================================
# Issue Triage Response
# =============================================================================
class IssueTriageResponse(BaseModel):
"""Response for issue triage."""
category: Literal[
"bug",
"feature",
"documentation",
"question",
"duplicate",
"spam",
"feature_creep",
] = Field(description="Issue category")
confidence: float = Field(
ge=0.0, le=1.0, description="Confidence in the categorization (0.0-1.0)"
)
priority: Literal["high", "medium", "low"] = Field(description="Issue priority")
labels_to_add: list[str] = Field(
default_factory=list, description="Labels to add to the issue"
)
labels_to_remove: list[str] = Field(
default_factory=list, description="Labels to remove from the issue"
)
is_duplicate: bool = Field(False, description="Whether this is a duplicate issue")
duplicate_of: int | None = Field(
None, description="Issue number this duplicates (if duplicate)"
)
is_spam: bool = Field(False, description="Whether this is spam")
is_feature_creep: bool = Field(
False, description="Whether this bundles multiple unrelated features"
)
suggested_breakdown: list[str] = Field(
default_factory=list,
description="Suggested breakdown if feature creep detected",
)
comment: str | None = Field(None, description="Optional bot comment to post")
# =============================================================================
# Orchestrator Review Response
# =============================================================================
class OrchestratorFinding(BaseModel):
"""A finding from the orchestrator review."""
file: str = Field(description="File path where issue was found")
line: int = Field(0, description="Line number of the issue")
title: str = Field(description="Brief issue title")
description: str = Field(description="Detailed explanation of the issue")
category: Literal[
"security",
"quality",
"style",
"docs",
"redundancy",
"verification_failed",
"pattern",
"performance",
"logic",
"test",
] = Field(description="Issue category")
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
suggestion: str | None = Field(None, description="How to fix this issue")
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."""
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
verdict_reasoning: str = Field(description="Explanation for the verdict")
findings: list[OrchestratorFinding] = Field(
default_factory=list, description="Issues found during review"
)
summary: str = Field(description="Brief summary of the review")
# =============================================================================
# Parallel Orchestrator Review Response (SDK Subagents)
# =============================================================================
class LogicFinding(BaseFinding):
"""A logic/correctness finding from the logic review agent."""
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"
)
actual_output: str | None = Field(None, description="What the buggy code produces")
expected_output: str | None = Field(
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."""
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"
)
codebase_pattern: str | None = Field(
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."""
id: str = Field(description="Unique identifier for this finding")
file: str = Field(description="File path where issue was found")
line: int = Field(0, description="Line number of the issue")
end_line: int | None = Field(None, description="End line for multi-line issues")
title: str = Field(description="Brief issue title (max 80 chars)")
description: str = Field(description="Detailed explanation of the issue")
category: Literal[
"security",
"quality",
"logic",
"codebase_fit",
"test",
"docs",
"redundancy",
"pattern",
"performance",
] = Field(description="Issue category")
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
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")
source_agents: list[str] = Field(
default_factory=list,
description="Which agents reported this finding",
)
cross_validated: bool = Field(
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."""
agreed_findings: list[str] = Field(
default_factory=list,
description="Finding IDs that multiple agents agreed on",
)
conflicting_findings: list[str] = Field(
default_factory=list,
description="Finding IDs where agents disagreed",
)
resolution_notes: str | None = Field(
None, description="Notes on how conflicts were resolved"
)
class ParallelOrchestratorResponse(BaseModel):
"""Complete response schema for parallel orchestrator PR review."""
analysis_summary: str = Field(
description="Brief summary of what was analyzed and why agents were chosen"
)
agents_invoked: list[str] = Field(
default_factory=list,
description="List of agent names that were invoked",
)
findings: list[ParallelOrchestratorFinding] = Field(
default_factory=list, description="All findings from synthesis"
)
agent_agreement: AgentAgreement = Field(
default_factory=AgentAgreement,
description="Information about agent agreement on findings",
)
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
description="Overall PR verdict"
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
# =============================================================================
# Parallel Follow-up Review Response (SDK Subagents for Follow-up)
# =============================================================================
class ResolutionVerification(BaseModel):
"""AI-verified resolution status for a previous finding."""
finding_id: str = Field(description="ID of the previous finding")
status: Literal["resolved", "partially_resolved", "unresolved", "cant_verify"] = (
Field(description="Resolution status after AI verification")
)
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."""
id: str = Field(description="Unique identifier for this finding")
file: str = Field(description="File path where issue was found")
line: int = Field(0, description="Line number of the issue")
end_line: int | None = Field(None, description="End line for multi-line issues")
title: str = Field(description="Brief issue title (max 80 chars)")
description: str = Field(description="Detailed explanation of the issue")
category: Literal[
"security",
"quality",
"logic",
"test",
"docs",
"regression",
"incomplete_fix",
] = Field(description="Issue category")
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
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")
source_agent: str = Field(
description="Which agent reported this finding (resolution/newcode/comment)"
)
related_to_previous: str | None = Field(
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."""
comment_id: str = Field(description="Identifier for the comment")
author: str = Field(description="Comment author")
is_ai_bot: bool = Field(description="Whether this is from an AI tool")
requires_response: bool = Field(description="Whether this comment needs a response")
sentiment: Literal["question", "concern", "suggestion", "praise", "neutral"] = (
Field(description="Comment sentiment/type")
)
summary: str = Field(description="Brief summary of the comment")
action_needed: str | None = Field(None, description="What action is needed if any")
class ParallelFollowupResponse(BaseModel):
"""Complete response schema for parallel follow-up PR review."""
# Analysis metadata
analysis_summary: str = Field(
description="Brief summary of what was analyzed in this follow-up"
)
agents_invoked: list[str] = Field(
default_factory=list,
description="List of agent names that were invoked",
)
commits_analyzed: int = Field(0, description="Number of new commits analyzed")
files_changed: int = Field(
0, description="Number of files changed since last review"
)
# Resolution verification (from resolution-verifier agent)
resolution_verifications: list[ResolutionVerification] = Field(
default_factory=list,
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,
description="New issues found in changes since last review",
)
# Comment analysis (from comment-analyzer agent)
comment_analyses: list[CommentAnalysis] = Field(
default_factory=list,
description="Analysis of contributor and AI comments",
)
comment_findings: list[ParallelFollowupFinding] = Field(
default_factory=list,
description="Issues identified from comment analysis",
)
# Agent agreement tracking
agent_agreement: AgentAgreement = Field(
default_factory=AgentAgreement,
description="Information about agent agreement on findings",
)
# Verdict
verdict: Literal[
"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.
"""
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 = issue IS real, keep as unresolved; "
"dismissed_false_positive = original finding was incorrect, remove; "
"needs_human_review = cannot determine with confidence"
)
)
code_evidence: str = Field(
min_length=1,
description=(
"REQUIRED: Exact code snippet examined from the file. "
"Must be actual code, not a description."
),
)
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 of why the finding is valid/invalid. "
"Must reference specific code and explain the reasoning."
),
)
confidence: float = Field(
ge=0.0,
le=1.0,
description=(
"Confidence in the validation result (0.0-1.0). "
"Must be >= 0.80 to dismiss as false positive, >= 0.70 to confirm valid."
),
)
@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 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"
)
)
@@ -18,20 +18,50 @@ try:
from ...analysis.test_discovery import TestDiscovery
from ...core.client import create_client
from ..context_gatherer import PRContext
from ..models import PRReviewFinding, ReviewSeverity
from .category_utils import map_category
from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity
except (ImportError, ValueError, SystemError):
from analysis.test_discovery import TestDiscovery
from category_utils import map_category
from context_gatherer import PRContext
from core.client import create_client
from models import PRReviewFinding, ReviewSeverity
from models import PRReviewFinding, ReviewCategory, ReviewSeverity
logger = logging.getLogger(__name__)
# Use shared category mapping from category_utils
_map_category = map_category
# Map AI-generated category names to valid ReviewCategory enum values
_CATEGORY_MAPPING = {
# Direct matches
"security": ReviewCategory.SECURITY,
"quality": ReviewCategory.QUALITY,
"style": ReviewCategory.STYLE,
"test": ReviewCategory.TEST,
"docs": ReviewCategory.DOCS,
"pattern": ReviewCategory.PATTERN,
"performance": ReviewCategory.PERFORMANCE,
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
"redundancy": ReviewCategory.REDUNDANCY,
# AI-generated alternatives
"correctness": ReviewCategory.QUALITY,
"consistency": ReviewCategory.PATTERN,
"testing": ReviewCategory.TEST,
"documentation": ReviewCategory.DOCS,
"bug": ReviewCategory.QUALITY,
"logic": ReviewCategory.QUALITY,
"error_handling": ReviewCategory.QUALITY,
"maintainability": ReviewCategory.QUALITY,
"readability": ReviewCategory.STYLE,
"best_practices": ReviewCategory.PATTERN,
"architecture": ReviewCategory.PATTERN,
"complexity": ReviewCategory.QUALITY,
"dead_code": ReviewCategory.REDUNDANCY,
"unused": ReviewCategory.REDUNDANCY,
}
def _map_category(category_str: str) -> ReviewCategory:
"""Map an AI-generated category string to a valid ReviewCategory enum."""
normalized = category_str.lower().strip().replace("-", "_")
return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
@dataclass
@@ -106,7 +136,7 @@ async def spawn_security_review(
/ "pr_security_agent.md"
)
if prompt_file.exists():
base_prompt = prompt_file.read_text(encoding="utf-8")
base_prompt = prompt_file.read_text()
else:
logger.warning("Security agent prompt not found, using fallback")
base_prompt = _get_fallback_security_prompt()
@@ -192,7 +222,7 @@ async def spawn_quality_review(
/ "pr_quality_agent.md"
)
if prompt_file.exists():
base_prompt = prompt_file.read_text(encoding="utf-8")
base_prompt = prompt_file.read_text()
else:
logger.warning("Quality agent prompt not found, using fallback")
base_prompt = _get_fallback_quality_prompt()
@@ -474,7 +504,7 @@ async def get_file_content(
try:
full_path = project_dir / file_path
if full_path.exists():
return full_path.read_text(encoding="utf-8")
return full_path.read_text()
return ""
except Exception as e:
logger.error(f"[Orchestrator] Failed to read {file_path}: {e}")
@@ -1,348 +0,0 @@
"""
SDK Stream Processing Utilities
================================
Shared utilities for processing Claude Agent SDK response streams.
This module extracts common SDK message processing patterns used across
parallel orchestrator and follow-up reviewers.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Callable
from typing import Any
logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
async def process_sdk_stream(
client: Any,
on_thinking: Callable[[str], None] | None = None,
on_tool_use: Callable[[str, str, dict[str, Any]], None] | None = None,
on_tool_result: Callable[[str, bool, Any], None] | None = None,
on_text: Callable[[str], None] | None = None,
on_structured_output: Callable[[dict[str, Any]], None] | None = None,
context_name: str = "SDK",
) -> dict[str, Any]:
"""
Process SDK response stream with customizable callbacks.
This function handles the common pattern of:
- Tracking thinking blocks
- Tracking tool invocations (especially Task/subagent calls)
- Tracking tool results
- Collecting text output
- Extracting structured output
Args:
client: Claude SDK client with receive_response() method
on_thinking: Callback for thinking blocks - receives thinking text
on_tool_use: Callback for tool invocations - receives (tool_name, tool_id, tool_input)
on_tool_result: Callback for tool results - receives (tool_id, is_error, result_content)
on_text: Callback for text output - receives text string
on_structured_output: Callback for structured output - receives dict
context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup")
Returns:
Dictionary with:
- result_text: Accumulated text output
- structured_output: Final structured output (if any)
- agents_invoked: List of agent names invoked via Task tool
- msg_count: Total message count
- subagent_tool_ids: Mapping of tool_id -> agent_name
- error: Error message if stream processing failed (None on success)
"""
result_text = ""
structured_output = None
agents_invoked = []
msg_count = 0
stream_error = None
# Track subagent tool IDs to log their results
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
print(f"[{context_name}] Processing SDK stream...", flush=True)
if DEBUG_MODE:
print(f"[DEBUG {context_name}] Awaiting response stream...", flush=True)
try:
async for msg in client.receive_response():
try:
msg_type = type(msg).__name__
msg_count += 1
if DEBUG_MODE:
# Log every message type for visibility
msg_details = ""
if hasattr(msg, "type"):
msg_details = f" (type={msg.type})"
print(
f"[DEBUG {context_name}] Message #{msg_count}: {msg_type}{msg_details}",
flush=True,
)
# Track thinking blocks
if msg_type == "ThinkingBlock" or (
hasattr(msg, "type") and msg.type == "thinking"
):
thinking_text = getattr(msg, "thinking", "") or getattr(
msg, "text", ""
)
if thinking_text:
print(
f"[{context_name}] AI thinking: {len(thinking_text)} chars",
flush=True,
)
if DEBUG_MODE:
# Show first 200 chars of thinking
preview = thinking_text[:200].replace("\n", " ")
print(
f"[DEBUG {context_name}] Thinking preview: {preview}...",
flush=True,
)
# Invoke callback
if on_thinking:
on_thinking(thinking_text)
# Track subagent invocations (Task tool calls)
if msg_type == "ToolUseBlock" or (
hasattr(msg, "type") and msg.type == "tool_use"
):
tool_name = getattr(msg, "name", "")
tool_id = getattr(msg, "id", "unknown")
tool_input = getattr(msg, "input", {})
if DEBUG_MODE:
print(
f"[DEBUG {context_name}] Tool call: {tool_name} (id={tool_id})",
flush=True,
)
if tool_name == "Task":
# Extract which agent was invoked
agent_name = tool_input.get("subagent_type", "unknown")
agents_invoked.append(agent_name)
# Track this tool ID to log its result later
subagent_tool_ids[tool_id] = agent_name
print(
f"[{context_name}] Invoked agent: {agent_name}", flush=True
)
elif tool_name == "StructuredOutput":
if tool_input:
# Warn if overwriting existing structured output
if structured_output is not None:
logger.warning(
f"[{context_name}] Multiple StructuredOutput blocks received, "
f"overwriting previous output"
)
structured_output = tool_input
print(
f"[{context_name}] Received structured output",
flush=True,
)
# Invoke callback
if on_structured_output:
on_structured_output(tool_input)
elif DEBUG_MODE:
# Log other tool calls in debug mode
print(
f"[DEBUG {context_name}] Other tool: {tool_name}",
flush=True,
)
# Invoke callback for all tool uses
if on_tool_use:
on_tool_use(tool_name, tool_id, tool_input)
# Track tool results
if msg_type == "ToolResultBlock" or (
hasattr(msg, "type") and msg.type == "tool_result"
):
tool_id = getattr(msg, "tool_use_id", "unknown")
is_error = getattr(msg, "is_error", False)
result_content = getattr(msg, "content", "")
# Handle list of content blocks
if isinstance(result_content, list):
result_content = " ".join(
str(getattr(c, "text", c)) for c in result_content
)
# Check if this is a subagent result
if tool_id in subagent_tool_ids:
agent_name = subagent_tool_ids[tool_id]
status = "ERROR" if is_error else "complete"
result_preview = (
str(result_content)[:600].replace("\n", " ").strip()
)
print(
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
flush=True,
)
elif DEBUG_MODE:
status = "ERROR" if is_error else "OK"
print(
f"[DEBUG {context_name}] Tool result: {tool_id} [{status}]",
flush=True,
)
# Invoke callback
if on_tool_result:
on_tool_result(tool_id, is_error, result_content)
# Collect text output and check for tool uses in content blocks
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
# Check for tool use blocks within content
if (
block_type == "ToolUseBlock"
or getattr(block, "type", "") == "tool_use"
):
tool_name = getattr(block, "name", "")
tool_id = getattr(block, "id", "unknown")
tool_input = getattr(block, "input", {})
if tool_name == "Task":
agent_name = tool_input.get("subagent_type", "unknown")
if agent_name not in agents_invoked:
agents_invoked.append(agent_name)
subagent_tool_ids[tool_id] = agent_name
print(
f"[{context_name}] Invoking agent: {agent_name}",
flush=True,
)
elif tool_name == "StructuredOutput":
if tool_input:
# Warn if overwriting existing structured output
if structured_output is not None:
logger.warning(
f"[{context_name}] Multiple StructuredOutput blocks received, "
f"overwriting previous output"
)
structured_output = tool_input
# Invoke callback
if on_structured_output:
on_structured_output(tool_input)
# Invoke callback
if on_tool_use:
on_tool_use(tool_name, tool_id, tool_input)
# Collect text
if hasattr(block, "text"):
result_text += block.text
# Always print text content preview (not just in DEBUG_MODE)
text_preview = block.text[:500].replace("\n", " ").strip()
if text_preview:
print(
f"[{context_name}] AI response: {text_preview}{'...' if len(block.text) > 500 else ''}",
flush=True,
)
# Invoke callback
if on_text:
on_text(block.text)
# Check for StructuredOutput in content (legacy check)
if getattr(block, "name", "") == "StructuredOutput":
structured_data = getattr(block, "input", None)
if structured_data:
# Warn if overwriting existing structured output
if structured_output is not None:
logger.warning(
f"[{context_name}] Multiple StructuredOutput blocks received, "
f"overwriting previous output"
)
structured_output = structured_data
# Invoke callback
if on_structured_output:
on_structured_output(structured_data)
# Check for structured_output attribute
if hasattr(msg, "structured_output") and msg.structured_output:
# Warn if overwriting existing structured output
if structured_output is not None:
logger.warning(
f"[{context_name}] Multiple StructuredOutput blocks received, "
f"overwriting previous output"
)
structured_output = msg.structured_output
# Invoke callback
if on_structured_output:
on_structured_output(msg.structured_output)
# Check for tool results in UserMessage (subagent results come back here)
if msg_type == "UserMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
# Check for tool result blocks
if (
block_type == "ToolResultBlock"
or getattr(block, "type", "") == "tool_result"
):
tool_id = getattr(block, "tool_use_id", "unknown")
is_error = getattr(block, "is_error", False)
result_content = getattr(block, "content", "")
# Handle list of content blocks
if isinstance(result_content, list):
result_content = " ".join(
str(getattr(c, "text", c)) for c in result_content
)
# Check if this is a subagent result
if tool_id in subagent_tool_ids:
agent_name = subagent_tool_ids[tool_id]
status = "ERROR" if is_error else "complete"
result_preview = (
str(result_content)[:600].replace("\n", " ").strip()
)
print(
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
flush=True,
)
# Invoke callback
if on_tool_result:
on_tool_result(tool_id, is_error, result_content)
except (AttributeError, TypeError, KeyError) as msg_error:
# Log individual message processing errors but continue
logger.warning(
f"[{context_name}] Error processing message #{msg_count}: {msg_error}"
)
if DEBUG_MODE:
print(
f"[DEBUG {context_name}] Message processing error: {msg_error}",
flush=True,
)
# Continue processing subsequent messages
except Exception as e:
# Log stream-level errors
stream_error = str(e)
logger.error(f"[{context_name}] SDK stream processing failed: {e}")
print(f"[{context_name}] ERROR: Stream processing failed: {e}", flush=True)
if DEBUG_MODE:
print(
f"[DEBUG {context_name}] Session ended. Total messages: {msg_count}",
flush=True,
)
print(f"[{context_name}] Session ended. Total messages: {msg_count}", flush=True)
return {
"result_text": result_text,
"structured_output": structured_output,
"agents_invoked": agents_invoked,
"msg_count": msg_count,
"subagent_tool_ids": subagent_tool_ids,
"error": stream_error,
}
@@ -6,18 +6,11 @@ Tests the BotDetector class to ensure it correctly prevents infinite loops.
"""
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Use direct file import to avoid package import issues
_github_dir = Path(__file__).parent
if str(_github_dir) not in sys.path:
sys.path.insert(0, str(_github_dir))
from bot_detection import BotDetectionState, BotDetector
@@ -132,15 +125,13 @@ class TestBotDetection:
def test_get_last_commit_sha(self, mock_bot_detector):
"""Test extracting last commit SHA."""
# GitHub API returns commits in chronological order (oldest first, newest last)
# So commits[-1] is the LATEST commit
commits = [
{"oid": "abc123"}, # Oldest commit
{"oid": "def456"}, # Latest commit
{"oid": "abc123"},
{"oid": "def456"},
]
sha = mock_bot_detector.get_last_commit_sha(commits)
assert sha == "def456" # Should return the LAST (latest) commit
assert sha == "abc123"
# Test with sha field instead of oid
commits_with_sha = [{"sha": "xyz789"}]
@@ -152,16 +143,13 @@ class TestBotDetection:
class TestCoolingOff:
"""Test cooling off period.
Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs.
"""
"""Test cooling off period."""
def test_within_cooling_off(self, mock_bot_detector):
"""Test PR within cooling off period."""
# Set last review to 30 seconds ago (within 1 minute cooling off)
half_min_ago = datetime.now() - timedelta(seconds=30)
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
# Set last review to 5 minutes ago
five_min_ago = datetime.now() - timedelta(minutes=5)
mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat()
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
@@ -170,9 +158,9 @@ class TestCoolingOff:
def test_outside_cooling_off(self, mock_bot_detector):
"""Test PR outside cooling off period."""
# Set last review to 2 minutes ago (outside 1 minute cooling off)
two_min_ago = datetime.now() - timedelta(minutes=2)
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
# Set last review to 15 minutes ago
fifteen_min_ago = datetime.now() - timedelta(minutes=15)
mock_bot_detector.state.last_review_times["123"] = fifteen_min_ago.isoformat()
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
@@ -241,16 +229,11 @@ class TestShouldSkipReview:
assert "bot user" in reason
def test_skip_bot_commit(self, mock_bot_detector):
"""Test skipping PR with bot commit as the latest commit."""
"""Test skipping PR with bot commit."""
pr_data = {"author": {"login": "alice"}}
# GitHub API returns commits in chronological order (oldest first, newest last)
# So commits[-1] is the LATEST commit - which is the bot commit
commits = [
{"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice)
{
"author": {"login": "test-bot"},
"oid": "def456",
}, # Latest commit (by bot)
{"author": {"login": "test-bot"}, "oid": "abc123"}, # Latest is bot
{"author": {"login": "alice"}, "oid": "def456"},
]
should_skip, reason = mock_bot_detector.should_skip_pr_review(
@@ -264,9 +247,9 @@ class TestShouldSkipReview:
def test_skip_cooling_off(self, mock_bot_detector):
"""Test skipping during cooling off period."""
# Set last review to 30 seconds ago (within 1 minute cooling off)
half_min_ago = datetime.now() - timedelta(seconds=30)
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
# Set last review to 5 minutes ago
five_min_ago = datetime.now() - timedelta(minutes=5)
mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat()
pr_data = {"author": {"login": "alice"}}
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
@@ -366,7 +349,7 @@ class TestStateManagement:
assert stats["review_own_prs"] is False
assert stats["total_prs_tracked"] == 2
assert stats["total_reviews_performed"] == 3
assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing
assert stats["cooling_off_minutes"] == 10
class TestEdgeCases:

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