Linting gods are happy

This commit is contained in:
AndyMik90
2025-12-15 21:10:27 +01:00
parent 142cd67c88
commit 3fc1592ce6
149 changed files with 7147 additions and 3857 deletions
+4
View File
@@ -2,6 +2,10 @@
.DS_Store
Thumbs.db
# Environment files (contain API keys)
.env
.env.local
# Git worktrees (used by auto-build parallel mode)
.worktrees/
+9 -7
View File
@@ -32,25 +32,25 @@ export default tseslint.config(
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-empty-object-type': 'off', // Allow empty interfaces for extensibility
'@typescript-eslint/no-unsafe-function-type': 'off', // Allow Function type in mocks
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
// React
...react.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/display-name': 'off',
'react/no-unescaped-entities': 'off',
// React Hooks
...reactHooks.configs.recommended.rules,
// React Hooks - only classic rules, no compiler rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/set-state-in-effect': 'warn', // Downgrade to warning
// General
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'warn',
'no-unused-expressions': 'warn'
'no-unused-expressions': 'warn',
'@typescript-eslint/no-require-imports': 'warn'
}
},
{
@@ -59,6 +59,9 @@ export default tseslint.config(
globals: {
...globals.node
}
},
rules: {
'@typescript-eslint/no-require-imports': 'off'
}
},
{
@@ -74,4 +77,3 @@ export default tseslint.config(
ignores: ['out/**', 'dist/**', '.eslintrc.cjs', 'eslint.config.mjs', 'node_modules/**']
}
);
+4 -2
View File
@@ -54,8 +54,8 @@
"lucide-react": "^0.560.0",
"motion": "^12.23.26",
"node-pty": "^1.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-resizable-panels": "^3.0.6",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
@@ -68,6 +68,7 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -81,6 +82,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
"jsdom": "^26.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
+725 -364
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -14,6 +14,7 @@ export const app = {
};
return paths[name] || '/tmp';
}),
getAppPath: vi.fn(() => '/tmp/test-app'),
getVersion: vi.fn(() => '0.1.0'),
isPackaged: false,
on: vi.fn(),
@@ -115,15 +115,18 @@ describe('IPC Bridge Integration', () => {
const createTask = electronAPI['createTask'] as (
projectId: string,
title: string,
desc: string
desc: string,
metadata?: unknown
) => Promise<unknown>;
await createTask('project-id', 'Task Title', 'Task description');
// Fourth argument is optional metadata (undefined when not provided)
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
'task:create',
'project-id',
'Task Title',
'Task description'
'Task description',
undefined
);
});
@@ -29,18 +29,27 @@ vi.mock('child_process', () => ({
spawn: vi.fn(() => mockProcess)
}));
// Auto-claude source path (for getAutoBuildSourcePath to find)
const AUTO_CLAUDE_SOURCE = path.join(TEST_DIR, 'auto-claude-source');
// Setup test directories
function setupTestDirs(): void {
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
mkdirSync(path.join(TEST_PROJECT_PATH, 'auto-claude'), { recursive: true });
// Create auto-claude source directory that getAutoBuildSourcePath looks for
mkdirSync(AUTO_CLAUDE_SOURCE, { recursive: true });
// Create VERSION file (required by getAutoBuildSourcePath)
writeFileSync(path.join(AUTO_CLAUDE_SOURCE, 'VERSION'), '1.0.0');
// Create mock spec_runner.py
writeFileSync(
path.join(TEST_PROJECT_PATH, 'auto-claude', 'spec_runner.py'),
path.join(AUTO_CLAUDE_SOURCE, 'spec_runner.py'),
'# Mock spec runner\nprint("Starting spec creation")'
);
// Create mock run.py
writeFileSync(
path.join(TEST_PROJECT_PATH, 'auto-claude', 'run.py'),
path.join(AUTO_CLAUDE_SOURCE, 'run.py'),
'# Mock run.py\nprint("Starting task execution")'
);
}
@@ -75,6 +84,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
expect(spawn).toHaveBeenCalledWith(
@@ -85,7 +95,7 @@ describe('Subprocess Spawn Integration', () => {
'Test task description'
]),
expect.objectContaining({
cwd: TEST_PROJECT_PATH,
cwd: AUTO_CLAUDE_SOURCE, // Process runs from auto-claude source directory
env: expect.objectContaining({
PYTHONUNBUFFERED: '1'
})
@@ -98,13 +108,14 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
expect(spawn).toHaveBeenCalledWith(
'python3',
expect.arrayContaining([expect.stringContaining('run.py'), '--spec', 'spec-001']),
expect.objectContaining({
cwd: TEST_PROJECT_PATH
cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory
})
);
});
@@ -114,6 +125,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
expect(spawn).toHaveBeenCalledWith(
@@ -125,24 +137,27 @@ describe('Subprocess Spawn Integration', () => {
'--qa'
]),
expect.objectContaining({
cwd: TEST_PROJECT_PATH
cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory
})
);
});
it('should include parallel options when specified', async () => {
it('should accept parallel options without affecting spawn args', async () => {
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
const { spawn } = await import('child_process');
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
parallel: true,
workers: 4
});
// Should spawn normally - parallel options don't affect CLI args anymore
expect(spawn).toHaveBeenCalledWith(
'python3',
expect.arrayContaining(['--parallel', '4']),
expect.arrayContaining([expect.stringContaining('run.py'), '--spec', 'spec-001']),
expect.any(Object)
);
});
@@ -151,6 +166,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const logHandler = vi.fn();
manager.on('log', logHandler);
@@ -166,6 +182,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const logHandler = vi.fn();
manager.on('log', logHandler);
@@ -181,6 +198,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const exitHandler = vi.fn();
manager.on('exit', exitHandler);
@@ -189,13 +207,15 @@ describe('Subprocess Spawn Integration', () => {
// Simulate process exit
mockProcess.emit('exit', 0);
expect(exitHandler).toHaveBeenCalledWith('task-1', 0);
// Exit event includes taskId, exit code, and process type
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
});
it('should emit error event when process errors', async () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
const errorHandler = vi.fn();
manager.on('error', errorHandler);
@@ -211,6 +231,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(manager.isRunning('task-1')).toBe(true);
@@ -235,6 +256,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
expect(manager.getRunningTasks()).toHaveLength(0);
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
@@ -249,7 +271,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure('/custom/python3');
manager.configure('/custom/python3', AUTO_CLAUDE_SOURCE);
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
@@ -264,6 +286,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
@@ -276,6 +299,7 @@ describe('Subprocess Spawn Integration', () => {
const { AgentManager } = await import('../../main/agent');
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
// Start another process for same task
+18 -4
View File
@@ -10,11 +10,25 @@ export const TEST_DATA_DIR = '/tmp/auto-claude-ui-tests';
// Create fresh test directory before each test
beforeEach(() => {
if (existsSync(TEST_DATA_DIR)) {
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
// Use a unique subdirectory per test to avoid race conditions in parallel tests
const testId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const testDir = path.join(TEST_DATA_DIR, testId);
try {
if (existsSync(TEST_DATA_DIR)) {
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
} catch {
// Ignore errors if directory is in use by another parallel test
// Each test uses unique subdirectory anyway
}
try {
mkdirSync(TEST_DATA_DIR, { recursive: true });
mkdirSync(path.join(TEST_DATA_DIR, 'store'), { recursive: true });
} catch {
// Ignore errors if directory already exists from another parallel test
}
mkdirSync(TEST_DATA_DIR, { recursive: true });
mkdirSync(path.join(TEST_DATA_DIR, 'store'), { recursive: true });
});
// Clean up test directory after each test
@@ -43,6 +43,7 @@ vi.mock('electron', () => {
if (name === 'userData') return path.join(TEST_DIR, 'userData');
return TEST_DIR;
}),
getAppPath: vi.fn(() => TEST_DIR),
getVersion: vi.fn(() => '0.1.0'),
isPackaged: false
},
@@ -302,12 +303,15 @@ describe('IPC Handlers', () => {
const { setupIpcHandlers } = await import('../ipc-handlers');
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
// Add a project first
// Create .auto-claude directory first (before adding project so it gets detected)
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs'), { recursive: true });
// Add a project - it will detect .auto-claude
const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH);
const projectId = (addResult as { data: { id: string } }).data.id;
// Create a spec directory with implementation plan
const specDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '001-test-feature');
// Create a spec directory with implementation plan in .auto-claude/specs
const specDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-feature');
mkdirSync(specDir, { recursive: true });
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify({
feature: 'Test Feature',
@@ -352,10 +356,13 @@ describe('IPC Handlers', () => {
});
});
it('should create task and start spec creation', async () => {
it('should create task in backlog status', async () => {
const { setupIpcHandlers } = await import('../ipc-handlers');
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
// Create .auto-claude directory first (before adding project so it gets detected)
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs'), { recursive: true });
// Add a project first
const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH);
const projectId = (addResult as { data: { id: string } }).data.id;
@@ -369,7 +376,9 @@ describe('IPC Handlers', () => {
);
expect(result).toHaveProperty('success', true);
expect(mockAgentManager.startSpecCreation).toHaveBeenCalled();
// Task is created in backlog status, spec creation starts when task:start is called
const task = (result as { data: { status: string } }).data;
expect(task.status).toBe('backlog');
});
});
@@ -462,12 +471,13 @@ describe('IPC Handlers', () => {
const { setupIpcHandlers } = await import('../ipc-handlers');
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
mockAgentManager.emit('exit', 'task-1', 0);
// Exit event with task-execution processType should result in human_review status
mockAgentManager.emit('exit', 'task-1', 0, 'task-execution');
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:statusChange',
'task-1',
'ai_review'
'human_review'
);
});
});
@@ -83,15 +83,15 @@ describe('ProjectStore', () => {
});
it('should detect auto-claude directory if present', async () => {
// Create auto-claude directory
mkdirSync(path.join(TEST_PROJECT_PATH, 'auto-claude'), { recursive: true });
// Create .auto-claude directory (the data directory, not source code)
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true });
const { ProjectStore } = await import('../project-store');
const store = new ProjectStore();
const project = store.addProject(TEST_PROJECT_PATH);
expect(project.autoBuildPath).toBe('auto-claude');
expect(project.autoBuildPath).toBe('.auto-claude');
});
it('should set empty autoBuildPath if not present', async () => {
@@ -278,8 +278,8 @@ describe('ProjectStore', () => {
});
it('should read tasks from filesystem correctly', async () => {
// Create spec directory structure
const specsDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '001-test-feature');
// Create spec directory structure in .auto-claude (the data directory)
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-feature');
mkdirSync(specsDir, { recursive: true });
const plan = {
@@ -325,7 +325,7 @@ describe('ProjectStore', () => {
});
it('should determine status as backlog when no subtasks completed', async () => {
const specsDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '002-pending');
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '002-pending');
mkdirSync(specsDir, { recursive: true });
const plan = {
@@ -364,7 +364,7 @@ describe('ProjectStore', () => {
});
it('should determine status as ai_review when all subtasks completed', async () => {
const specsDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '003-complete');
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '003-complete');
mkdirSync(specsDir, { recursive: true });
const plan = {
@@ -403,7 +403,7 @@ describe('ProjectStore', () => {
});
it('should determine status as human_review when QA report rejected', async () => {
const specsDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '004-rejected');
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-rejected');
mkdirSync(specsDir, { recursive: true });
const plan = {
@@ -445,8 +445,9 @@ describe('ProjectStore', () => {
expect(tasks[0].status).toBe('human_review');
});
it('should determine status as done when QA report approved', async () => {
const specsDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '005-approved');
it('should determine status as human_review when QA report approved', async () => {
// QA approval moves task to human_review (user needs to review before marking done)
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-approved');
mkdirSync(specsDir, { recursive: true });
const plan = {
@@ -485,6 +486,47 @@ describe('ProjectStore', () => {
const project = store.addProject(TEST_PROJECT_PATH);
const tasks = store.getTasks(project.id);
expect(tasks[0].status).toBe('human_review');
expect(tasks[0].reviewReason).toBe('completed');
});
it('should determine status as done when plan status is explicitly done', async () => {
// User explicitly marking task as done via drag-and-drop sets status to done
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '006-done');
mkdirSync(specsDir, { recursive: true });
const plan = {
feature: 'Done Feature',
workflow_type: 'feature',
services_involved: [],
status: 'done', // Explicitly set by user
phases: [
{
phase: 1,
name: 'Phase 1',
type: 'implementation',
subtasks: [
{ id: 'subtask-1', description: 'Subtask 1', status: 'completed' }
]
}
],
final_acceptance: [],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
spec_file: 'spec.md'
};
writeFileSync(
path.join(specsDir, 'implementation_plan.json'),
JSON.stringify(plan)
);
const { ProjectStore } = await import('../project-store');
const store = new ProjectStore();
const project = store.addProject(TEST_PROJECT_PATH);
const tasks = store.getTasks(project.id);
expect(tasks[0].status).toBe('done');
});
});
@@ -91,7 +91,7 @@ export function registerContextHandlers(
memoryStatus = {
enabled: true,
available: true,
database: memoryState.database || 'auto_build_memory',
database: memoryState.database || 'auto_claude_memory',
host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost',
port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10)
};
@@ -157,7 +157,7 @@ export function registerContextHandlers(
// Get Graphiti connection details from project .env or process.env
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_claude_memory';
if (graphitiEnabled && hasOpenAI) {
memoryStatus = {
@@ -393,7 +393,7 @@ export function registerContextHandlers(
// Get Graphiti connection details from project .env or process.env
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_claude_memory';
if (!graphitiEnabled) {
return {
@@ -122,7 +122,7 @@ ${existingVars['OPENAI_API_KEY'] ? `OPENAI_API_KEY=${existingVars['OPENAI_API_KE
${existingVars['GRAPHITI_FALKORDB_HOST'] ? `GRAPHITI_FALKORDB_HOST=${existingVars['GRAPHITI_FALKORDB_HOST']}` : '# GRAPHITI_FALKORDB_HOST=localhost'}
${existingVars['GRAPHITI_FALKORDB_PORT'] ? `GRAPHITI_FALKORDB_PORT=${existingVars['GRAPHITI_FALKORDB_PORT']}` : '# GRAPHITI_FALKORDB_PORT=6380'}
${existingVars['GRAPHITI_FALKORDB_PASSWORD'] ? `GRAPHITI_FALKORDB_PASSWORD=${existingVars['GRAPHITI_FALKORDB_PASSWORD']}` : '# GRAPHITI_FALKORDB_PASSWORD='}
${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHITI_DATABASE']}` : '# GRAPHITI_DATABASE=auto_build_memory'}
${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHITI_DATABASE']}` : '# GRAPHITI_DATABASE=auto_claude_memory'}
`;
return content;
@@ -173,7 +173,7 @@ export class TerminalNameGenerator extends EventEmitter {
suggestedProfile: rateLimitDetection.suggestedProfile?.name
});
const rateLimitInfo = createSDKRateLimitInfo('terminal-name-generator', rateLimitDetection);
const rateLimitInfo = createSDKRateLimitInfo('other', rateLimitDetection);
this.emit('sdk-rate-limit', rateLimitInfo);
}
@@ -1,6 +1,8 @@
/**
* Unit tests for TaskEditDialog component
* Tests edit functionality, form validation, and integration with task-store
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useTaskStore, persistUpdateTask } from '../stores/task-store';
@@ -350,7 +350,7 @@ export function Context({ projectId }: ContextProps) {
{memoryStatus?.available ? (
<>
<div className="grid gap-3 sm:grid-cols-3 text-sm">
<InfoItem label="Database" value={memoryStatus.database || 'auto_build_memory'} />
<InfoItem label="Database" value={memoryStatus.database || 'auto_claude_memory'} />
<InfoItem label="Host" value={`${memoryStatus.host}:${memoryStatus.port}`} />
{memoryState && (
<InfoItem label="Episodes" value={memoryState.episode_count.toString()} />
@@ -1294,7 +1294,7 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">Database Name</Label>
<Input
placeholder="auto_build_memory"
placeholder="auto_claude_memory"
value={envConfig.graphitiDatabase || ''}
onChange={(e) => updateEnvConfig({ graphitiDatabase: e.target.value })}
/>
@@ -298,7 +298,7 @@ export function SecuritySettings({
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">Database Name</Label>
<Input
placeholder="auto_build_memory"
placeholder="auto_claude_memory"
value={envConfig.graphitiDatabase || ''}
onChange={(e) => updateEnvConfig({ graphitiDatabase: e.target.value })}
/>
@@ -1,10 +1,14 @@
/**
* @vitest-environment jsdom
*/
/**
* Unit tests for useVirtualizedTree hook
* Tests flattenTree function and visible items computation
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { flattenTree, useVirtualizedTree } from '../useVirtualizedTree';
import { flattenTree, useVirtualizedTree, FlattenedNode } from '../useVirtualizedTree';
import { useFileExplorerStore } from '../../stores/file-explorer-store';
import type { FileNode } from '../../../shared/types';
@@ -450,7 +454,7 @@ describe('useVirtualizedTree', () => {
const { result } = renderHook(() => useVirtualizedTree(ROOT_PATH));
expect(result.current.flattenedNodes).toHaveLength(3);
expect(result.current.flattenedNodes.map((n) => n.node.name)).toEqual([
expect(result.current.flattenedNodes.map((n: FlattenedNode) => n.node.name)).toEqual([
'dir1',
'child1.ts',
'dir2',
@@ -294,6 +294,11 @@ const browserMockAPI: ElectronAPI = {
console.log('[Browser Mock] invokeClaudeInTerminal called');
},
generateTerminalName: async () => ({
success: true,
data: 'Mock Terminal'
}),
// Terminal session management
getTerminalSessions: async () => ({
success: true,
@@ -713,6 +718,14 @@ const browserMockAPI: ElectronAPI = {
}
}),
saveChangelogImage: async () => ({
success: true,
data: {
relativePath: 'images/mock-image.png',
url: 'file:///mock/path/images/mock-image.png'
}
}),
readExistingChangelog: async () => ({
success: true,
data: {
+1
View File
@@ -153,6 +153,7 @@ export interface ElectronAPI {
sendTerminalInput: (id: string, data: string) => void;
resizeTerminal: (id: string, cols: number, rows: number) => void;
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
// Terminal session management (persistence/restore)
getTerminalSessions: (projectPath: string) => Promise<IPCResult<TerminalSession[]>>;
+6 -6
View File
@@ -192,7 +192,7 @@ CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
# docker run -d -p 8000:8000 \
# -e DATABASE_TYPE=falkordb \
# -e OPENAI_API_KEY=$OPENAI_API_KEY \
# -e FALKORDB_URI=redis://host.docker.internal:6380 \
# -e FALKORDB_URI=redis://host.docker.internal:6379 \
# falkordb/graphiti-knowledge-graph-mcp
#
# See: https://docs.falkordb.com/agentic-memory/graphiti-mcp-server.html
@@ -211,19 +211,19 @@ CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
# GRAPHITI: FalkorDB Connection Settings
# =============================================================================
# Configure the FalkorDB graph database connection.
# Start FalkorDB: docker run -p 6380:6379 falkordb/falkordb:latest
# Start FalkorDB: docker run -p 6379:6379 falkordb/falkordb:latest
# FalkorDB Host (default: localhost)
# GRAPHITI_FALKORDB_HOST=localhost
# FalkorDB Port (default: 6380)
# GRAPHITI_FALKORDB_PORT=6380
# FalkorDB Port (default: 6379)
# GRAPHITI_FALKORDB_PORT=6379
# FalkorDB Password (default: empty)
# GRAPHITI_FALKORDB_PASSWORD=
# Graph Database Name (default: auto_build_memory)
# GRAPHITI_DATABASE=auto_build_memory
# Graph Database Name (default: auto_claude_memory)
# GRAPHITI_DATABASE=auto_claude_memory
# =============================================================================
# GRAPHITI: Miscellaneous Settings
+18 -18
View File
@@ -19,27 +19,27 @@ All logic has been refactored into focused modules for better maintainability.
# Re-export everything from the agents module to maintain backwards compatibility
from agents import (
# Main API
run_autonomous_agent,
run_followup_planner,
# Memory functions
debug_memory_system_status,
get_graphiti_context,
save_session_memory,
save_session_to_graphiti,
# Session management
run_agent_session,
post_session_processing,
# Utility functions
get_latest_commit,
get_commit_count,
load_implementation_plan,
find_subtask_in_plan,
find_phase_for_subtask,
sync_plan_to_source,
# Constants
AUTO_CONTINUE_DELAY_SECONDS,
HUMAN_INTERVENTION_FILE,
# Memory functions
debug_memory_system_status,
find_phase_for_subtask,
find_subtask_in_plan,
get_commit_count,
get_graphiti_context,
# Utility functions
get_latest_commit,
load_implementation_plan,
post_session_processing,
# Session management
run_agent_session,
# Main API
run_autonomous_agent,
run_followup_planner,
save_session_memory,
save_session_to_graphiti,
sync_plan_to_source,
)
# Ensure all exports are available at module level
+11 -12
View File
@@ -13,8 +13,12 @@ This module provides:
"""
# Main agent functions (public API)
# Constants
from .base import (
AUTO_CONTINUE_DELAY_SECONDS,
HUMAN_INTERVENTION_FILE,
)
from .coder import run_autonomous_agent
from .planner import run_followup_planner
# Memory functions
from .memory import (
@@ -23,29 +27,24 @@ from .memory import (
save_session_memory,
save_session_to_graphiti, # Backwards compatibility
)
from .planner import run_followup_planner
# Session management
from .session import (
run_agent_session,
post_session_processing,
run_agent_session,
)
# Utility functions
from .utils import (
get_latest_commit,
get_commit_count,
load_implementation_plan,
find_subtask_in_plan,
find_phase_for_subtask,
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
# Constants
from .base import (
AUTO_CONTINUE_DELAY_SECONDS,
HUMAN_INTERVENTION_FILE,
)
__all__ = [
# Main API
"run_autonomous_agent",
-2
View File
@@ -6,8 +6,6 @@ Shared imports, types, and constants used across agent modules.
"""
import logging
from pathlib import Path
from typing import Optional
# Configure logging
logger = logging.getLogger(__name__)
+96 -62
View File
@@ -8,60 +8,60 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
import asyncio
import logging
from pathlib import Path
from typing import Optional
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
from .session import run_agent_session, post_session_processing
from .memory import debug_memory_system_status, get_graphiti_context
from .utils import (
get_latest_commit,
get_commit_count,
load_implementation_plan,
find_phase_for_subtask,
sync_plan_to_source,
)
from client import create_client
from recovery import RecoveryManager
from progress import (
print_session_header,
print_progress_summary,
print_build_complete_banner,
count_subtasks,
count_subtasks_detailed,
is_build_complete,
get_next_subtask,
get_current_phase,
)
from prompt_generator import (
generate_subtask_prompt,
generate_planner_prompt,
load_subtask_context,
format_context_for_prompt,
)
from prompts import is_first_run
from linear_updater import (
is_linear_enabled,
LinearTaskState,
linear_task_started,
is_linear_enabled,
linear_build_complete,
linear_task_started,
linear_task_stuck,
)
from ui import (
Icons,
icon,
box,
bold,
muted,
highlight,
print_status,
print_key_value,
StatusManager,
BuildState,
from progress import (
count_subtasks,
count_subtasks_detailed,
get_current_phase,
get_next_subtask,
is_build_complete,
print_build_complete_banner,
print_progress_summary,
print_session_header,
)
from prompt_generator import (
format_context_for_prompt,
generate_planner_prompt,
generate_subtask_prompt,
load_subtask_context,
)
from prompts import is_first_run
from recovery import RecoveryManager
from task_logger import (
LogPhase,
get_task_logger,
)
from ui import (
BuildState,
Icons,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_key_value,
print_status,
)
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
from .memory import debug_memory_system_status, get_graphiti_context
from .session import post_session_processing, run_agent_session
from .utils import (
find_phase_for_subtask,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
logger = logging.getLogger(__name__)
@@ -70,9 +70,9 @@ async def run_autonomous_agent(
project_dir: Path,
spec_dir: Path,
model: str,
max_iterations: Optional[int] = None,
max_iterations: int | None = None,
verbose: bool = False,
source_spec_dir: Optional[Path] = None,
source_spec_dir: Path | None = None,
) -> None:
"""
Run the autonomous agent loop with automatic memory management.
@@ -130,7 +130,9 @@ async def run_autonomous_agent(
is_planning_phase = False
if first_run:
print_status("Fresh start - will use Planner Agent to create implementation plan", "info")
print_status(
"Fresh start - will use Planner Agent to create implementation plan", "info"
)
content = [
bold(f"{icon(Icons.GEAR)} PLANNER SESSION"),
"",
@@ -148,7 +150,9 @@ async def run_autonomous_agent(
# Start planning phase in task logger
if task_logger:
task_logger.start_phase(LogPhase.PLANNING, "Starting implementation planning...")
task_logger.start_phase(
LogPhase.PLANNING, "Starting implementation planning..."
)
# Update Linear to "In Progress" when build starts
if linear_task and linear_task.task_id:
@@ -195,9 +199,9 @@ async def run_autonomous_agent(
if pause_content:
print(f"\nMessage: {pause_content}")
print(f"\nTo resume, delete the PAUSE file:")
print("\nTo resume, delete the PAUSE file:")
print(f" rm {pause_file}")
print(f"\nThen run again:")
print("\nThen run again:")
print(f" python auto-claude/run.py --spec {spec_dir.name}")
return
@@ -231,7 +235,9 @@ async def run_autonomous_agent(
subtask_id=subtask_id,
subtask_desc=next_subtask.get("description") if next_subtask else None,
phase_name=phase_name,
attempt=recovery_manager.get_attempt_count(subtask_id) + 1 if subtask_id else 1,
attempt=recovery_manager.get_attempt_count(subtask_id) + 1
if subtask_id
else 1,
)
# Capture state before session for post-processing
@@ -256,8 +262,14 @@ async def run_autonomous_agent(
is_planning_phase = False
current_log_phase = LogPhase.CODING
if task_logger:
task_logger.end_phase(LogPhase.PLANNING, success=True, message="Implementation plan created")
task_logger.start_phase(LogPhase.CODING, "Starting implementation...")
task_logger.end_phase(
LogPhase.PLANNING,
success=True,
message="Implementation plan created",
)
task_logger.start_phase(
LogPhase.CODING, "Starting implementation..."
)
if not next_subtask:
print("No pending subtasks found - build may be complete!")
@@ -265,7 +277,11 @@ async def run_autonomous_agent(
# Get attempt count for recovery context
attempt_count = recovery_manager.get_attempt_count(subtask_id)
recovery_hints = recovery_manager.get_recovery_hints(subtask_id) if attempt_count > 0 else None
recovery_hints = (
recovery_manager.get_recovery_hints(subtask_id)
if attempt_count > 0
else None
)
# Find the phase for this subtask
plan = load_implementation_plan(spec_dir)
@@ -287,7 +303,9 @@ async def run_autonomous_agent(
prompt += "\n\n" + format_context_for_prompt(context)
# Retrieve and append Graphiti memory context (if enabled)
graphiti_context = await get_graphiti_context(spec_dir, project_dir, next_subtask)
graphiti_context = await get_graphiti_context(
spec_dir, project_dir, next_subtask
)
if graphiti_context:
prompt += "\n\n" + graphiti_context
print_status("Graphiti memory context loaded", "success")
@@ -312,7 +330,9 @@ async def run_autonomous_agent(
# === POST-SESSION PROCESSING (100% reliable) ===
if subtask_id and not first_run:
linear_is_enabled = linear_task is not None and linear_task.task_id is not None
linear_is_enabled = (
linear_task is not None and linear_task.task_id is not None
)
success = await post_session_processing(
spec_dir=spec_dir,
project_dir=project_dir,
@@ -330,11 +350,13 @@ async def run_autonomous_agent(
attempt_count = recovery_manager.get_attempt_count(subtask_id)
if not success and attempt_count >= 3:
recovery_manager.mark_subtask_stuck(
subtask_id,
f"Failed after {attempt_count} attempts"
subtask_id, f"Failed after {attempt_count} attempts"
)
print()
print_status(f"Subtask {subtask_id} marked as STUCK after {attempt_count} attempts", "error")
print_status(
f"Subtask {subtask_id} marked as STUCK after {attempt_count} attempts",
"error",
)
print(muted("Consider: manual intervention or skipping this subtask"))
# Record stuck subtask in Linear (if enabled)
@@ -357,7 +379,11 @@ async def run_autonomous_agent(
# End coding phase in task logger
if task_logger:
task_logger.end_phase(LogPhase.CODING, success=True, message="All subtasks completed successfully")
task_logger.end_phase(
LogPhase.CODING,
success=True,
message="All subtasks completed successfully",
)
# Notify Linear that build is complete (moving to QA)
if linear_task and linear_task.task_id:
@@ -367,7 +393,11 @@ async def run_autonomous_agent(
break
elif status == "continue":
print(muted(f"\nAgent will auto-continue in {AUTO_CONTINUE_DELAY_SECONDS}s..."))
print(
muted(
f"\nAgent will auto-continue in {AUTO_CONTINUE_DELAY_SECONDS}s..."
)
)
print_progress_summary(spec_dir)
# Update state back to building
@@ -376,12 +406,16 @@ async def run_autonomous_agent(
# Show next subtask info
next_subtask = get_next_subtask(spec_dir)
if next_subtask:
subtask_id = next_subtask.get('id')
print(f"\nNext: {highlight(subtask_id)} - {next_subtask.get('description')}")
subtask_id = next_subtask.get("id")
print(
f"\nNext: {highlight(subtask_id)} - {next_subtask.get('description')}"
)
attempt_count = recovery_manager.get_attempt_count(subtask_id)
if attempt_count > 0:
print_status(f"WARNING: {attempt_count} previous attempt(s)", "warning")
print_status(
f"WARNING: {attempt_count} previous attempt(s)", "warning"
)
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
+134 -71
View File
@@ -9,19 +9,18 @@ Handles session memory storage using dual-layer approach:
import logging
from pathlib import Path
from typing import Optional
from graphiti_config import is_graphiti_enabled, get_graphiti_status
from memory import save_session_insights as save_file_based_memory
from debug import (
debug,
debug_detailed,
debug_success,
debug_error,
debug_warning,
debug_section,
debug_success,
debug_warning,
is_debug_enabled,
)
from graphiti_config import get_graphiti_status, is_graphiti_enabled
from memory import save_session_insights as save_file_based_memory
logger = logging.getLogger(__name__)
@@ -40,36 +39,50 @@ def debug_memory_system_status() -> None:
# Get Graphiti status
graphiti_status = get_graphiti_status()
debug("memory", "Memory system configuration",
primary_system="Graphiti" if graphiti_status.get("available") else "File-based (fallback)",
graphiti_enabled=graphiti_status.get("enabled"),
graphiti_available=graphiti_status.get("available"))
debug(
"memory",
"Memory system configuration",
primary_system="Graphiti"
if graphiti_status.get("available")
else "File-based (fallback)",
graphiti_enabled=graphiti_status.get("enabled"),
graphiti_available=graphiti_status.get("available"),
)
if graphiti_status.get("enabled"):
debug_detailed("memory", "Graphiti configuration",
host=graphiti_status.get("host"),
port=graphiti_status.get("port"),
database=graphiti_status.get("database"),
llm_provider=graphiti_status.get("llm_provider"),
embedder_provider=graphiti_status.get("embedder_provider"))
debug_detailed(
"memory",
"Graphiti configuration",
host=graphiti_status.get("host"),
port=graphiti_status.get("port"),
database=graphiti_status.get("database"),
llm_provider=graphiti_status.get("llm_provider"),
embedder_provider=graphiti_status.get("embedder_provider"),
)
if not graphiti_status.get("available"):
debug_warning("memory", "Graphiti not available",
reason=graphiti_status.get("reason"),
errors=graphiti_status.get("errors"))
debug_warning(
"memory",
"Graphiti not available",
reason=graphiti_status.get("reason"),
errors=graphiti_status.get("errors"),
)
debug("memory", "Will use file-based memory as fallback")
else:
debug_success("memory", "Graphiti ready as PRIMARY memory system")
else:
debug("memory", "Graphiti disabled, using file-based memory only",
note="Set GRAPHITI_ENABLED=true to enable Graphiti")
debug(
"memory",
"Graphiti disabled, using file-based memory only",
note="Set GRAPHITI_ENABLED=true to enable Graphiti",
)
async def get_graphiti_context(
spec_dir: Path,
project_dir: Path,
subtask: dict,
) -> Optional[str]:
) -> str | None:
"""
Retrieve relevant context from Graphiti for the current subtask.
@@ -85,9 +98,12 @@ async def get_graphiti_context(
Formatted context string or None if unavailable
"""
if is_debug_enabled():
debug("memory", "Retrieving Graphiti context for subtask",
subtask_id=subtask.get("id", "unknown"),
subtask_desc=subtask.get("description", "")[:100])
debug(
"memory",
"Retrieving Graphiti context for subtask",
subtask_id=subtask.get("id", "unknown"),
subtask_desc=subtask.get("description", "")[:100],
)
if not is_graphiti_enabled():
if is_debug_enabled():
@@ -117,9 +133,12 @@ async def get_graphiti_context(
return None
if is_debug_enabled():
debug_detailed("memory", "Searching Graphiti knowledge graph",
query=query[:200],
num_results=5)
debug_detailed(
"memory",
"Searching Graphiti knowledge graph",
query=query[:200],
num_results=5,
)
# Get relevant context
context_items = await memory.get_relevant_context(query, num_results=5)
@@ -130,9 +149,12 @@ async def get_graphiti_context(
await memory.close()
if is_debug_enabled():
debug("memory", "Graphiti context retrieval complete",
context_items_found=len(context_items) if context_items else 0,
session_history_found=len(session_history) if session_history else 0)
debug(
"memory",
"Graphiti context retrieval complete",
context_items_found=len(context_items) if context_items else 0,
session_history_found=len(session_history) if session_history else 0,
)
if not context_items and not session_history:
if is_debug_enabled():
@@ -162,8 +184,9 @@ async def get_graphiti_context(
sections.append("")
if is_debug_enabled():
debug_success("memory", "Graphiti context formatted",
total_sections=len(sections))
debug_success(
"memory", "Graphiti context formatted", total_sections=len(sections)
)
return "\n".join(sections)
@@ -184,7 +207,7 @@ async def save_session_memory(
session_num: int,
success: bool,
subtasks_completed: list[str],
discoveries: Optional[dict] = None,
discoveries: dict | None = None,
) -> tuple[bool, str]:
"""
Save session insights to memory.
@@ -210,17 +233,21 @@ async def save_session_memory(
# Debug: Log memory save start
if is_debug_enabled():
debug_section("memory", f"Saving Session {session_num} Memory")
debug("memory", "Memory save initiated",
subtask_id=subtask_id,
session_num=session_num,
success=success,
subtasks_completed=subtasks_completed,
spec_dir=str(spec_dir))
debug(
"memory",
"Memory save initiated",
subtask_id=subtask_id,
session_num=session_num,
success=success,
subtasks_completed=subtasks_completed,
spec_dir=str(spec_dir),
)
# Build insights structure (same format for both storage systems)
insights = {
"subtasks_completed": subtasks_completed,
"discoveries": discoveries or {
"discoveries": discoveries
or {
"files_understood": {},
"patterns_found": [],
"gotchas_encountered": [],
@@ -237,15 +264,18 @@ async def save_session_memory(
graphiti_enabled = is_graphiti_enabled()
if is_debug_enabled():
graphiti_status = get_graphiti_status()
debug("memory", "Graphiti status check",
enabled=graphiti_status.get("enabled"),
available=graphiti_status.get("available"),
host=graphiti_status.get("host"),
port=graphiti_status.get("port"),
database=graphiti_status.get("database"),
llm_provider=graphiti_status.get("llm_provider"),
embedder_provider=graphiti_status.get("embedder_provider"),
reason=graphiti_status.get("reason") or "OK")
debug(
"memory",
"Graphiti status check",
enabled=graphiti_status.get("enabled"),
available=graphiti_status.get("available"),
host=graphiti_status.get("host"),
port=graphiti_status.get("port"),
database=graphiti_status.get("database"),
llm_provider=graphiti_status.get("llm_provider"),
embedder_provider=graphiti_status.get("embedder_provider"),
reason=graphiti_status.get("reason") or "OK",
)
# PRIMARY: Try Graphiti if enabled
if graphiti_enabled:
@@ -258,9 +288,12 @@ async def save_session_memory(
memory = GraphitiMemory(spec_dir, project_dir)
if is_debug_enabled():
debug_detailed("memory", "GraphitiMemory instance created",
is_enabled=memory.is_enabled,
group_id=getattr(memory, 'group_id', 'unknown'))
debug_detailed(
"memory",
"GraphitiMemory instance created",
is_enabled=memory.is_enabled,
group_id=getattr(memory, "group_id", "unknown"),
)
if memory.is_enabled:
if is_debug_enabled():
@@ -270,7 +303,10 @@ async def save_session_memory(
if discoveries and discoveries.get("file_insights"):
# Rich insights from insight_extractor
if is_debug_enabled():
debug("memory", "Using save_structured_insights (rich data available)")
debug(
"memory",
"Using save_structured_insights (rich data available)",
)
result = await memory.save_structured_insights(discoveries)
else:
# Fallback to basic session insights
@@ -279,20 +315,33 @@ async def save_session_memory(
await memory.close()
if result:
logger.info(f"Session {session_num} insights saved to Graphiti (primary)")
logger.info(
f"Session {session_num} insights saved to Graphiti (primary)"
)
if is_debug_enabled():
debug_success("memory", f"Session {session_num} saved to Graphiti (PRIMARY)",
storage_type="graphiti",
subtasks_saved=len(subtasks_completed))
debug_success(
"memory",
f"Session {session_num} saved to Graphiti (PRIMARY)",
storage_type="graphiti",
subtasks_saved=len(subtasks_completed),
)
return True, "graphiti"
else:
logger.warning("Graphiti save returned False, falling back to file-based")
logger.warning(
"Graphiti save returned False, falling back to file-based"
)
if is_debug_enabled():
debug_warning("memory", "Graphiti save returned False, using FALLBACK")
debug_warning(
"memory", "Graphiti save returned False, using FALLBACK"
)
else:
logger.warning("Graphiti memory not enabled, falling back to file-based")
logger.warning(
"Graphiti memory not enabled, falling back to file-based"
)
if is_debug_enabled():
debug_warning("memory", "GraphitiMemory.is_enabled=False, using FALLBACK")
debug_warning(
"memory", "GraphitiMemory.is_enabled=False, using FALLBACK"
)
except ImportError as e:
logger.debug("Graphiti packages not installed, falling back to file-based")
@@ -313,18 +362,26 @@ async def save_session_memory(
try:
memory_dir = spec_dir / "memory" / "session_insights"
if is_debug_enabled():
debug_detailed("memory", "File-based memory path",
memory_dir=str(memory_dir),
session_file=f"session_{session_num:03d}.json")
debug_detailed(
"memory",
"File-based memory path",
memory_dir=str(memory_dir),
session_file=f"session_{session_num:03d}.json",
)
save_file_based_memory(spec_dir, session_num, insights)
logger.info(f"Session {session_num} insights saved to file-based memory (fallback)")
logger.info(
f"Session {session_num} insights saved to file-based memory (fallback)"
)
if is_debug_enabled():
debug_success("memory", f"Session {session_num} saved to file-based (FALLBACK)",
storage_type="file",
file_path=str(memory_dir / f"session_{session_num:03d}.json"),
subtasks_saved=len(subtasks_completed))
debug_success(
"memory",
f"Session {session_num} saved to file-based (FALLBACK)",
storage_type="file",
file_path=str(memory_dir / f"session_{session_num:03d}.json"),
subtasks_saved=len(subtasks_completed),
)
return True, "file"
except Exception as e:
logger.error(f"File-based memory save also failed: {e}")
@@ -341,10 +398,16 @@ async def save_session_to_graphiti(
session_num: int,
success: bool,
subtasks_completed: list[str],
discoveries: Optional[dict] = None,
discoveries: dict | None = None,
) -> bool:
"""Backwards compatibility wrapper for save_session_memory."""
result, _ = await save_session_memory(
spec_dir, project_dir, subtask_id, session_num, success, subtasks_completed, discoveries
spec_dir,
project_dir,
subtask_id,
session_num,
success,
subtasks_completed,
discoveries,
)
return result
+21 -17
View File
@@ -8,24 +8,24 @@ Handles follow-up planner sessions for adding new subtasks to completed specs.
import logging
from pathlib import Path
from .base import AUTO_CONTINUE_DELAY_SECONDS
from .session import run_agent_session
from client import create_client
from ui import (
Icons,
icon,
box,
bold,
muted,
highlight,
print_status,
StatusManager,
BuildState,
)
from task_logger import (
LogPhase,
get_task_logger,
)
from ui import (
BuildState,
Icons,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_status,
)
from .session import run_agent_session
logger = logging.getLogger(__name__)
@@ -60,8 +60,8 @@ async def run_followup_planner(
Returns:
bool: True if planning completed successfully
"""
from prompts import get_followup_planner_prompt
from implementation_plan import ImplementationPlan
from prompts import get_followup_planner_prompt
# Initialize status manager for ccstatusline
status_manager = StatusManager(project_dir)
@@ -109,7 +109,7 @@ async def run_followup_planner(
task_logger.end_phase(
LogPhase.PLANNING,
success=(status != "error"),
message="Follow-up planning session completed"
message="Follow-up planning session completed",
)
if status == "error":
@@ -148,14 +148,18 @@ async def run_followup_planner(
return True
else:
print()
print_status("Warning: No pending subtasks found after planning", "warning")
print_status(
"Warning: No pending subtasks found after planning", "warning"
)
print(muted("The planner may not have added new subtasks."))
print(muted("Check implementation_plan.json manually."))
status_manager.update(state=BuildState.PAUSED)
return False
else:
print()
print_status("Error: implementation_plan.json not found after planning", "error")
print_status(
"Error: implementation_plan.json not found after planning", "error"
)
status_manager.update(state=BuildState.ERROR)
return False
+72 -35
View File
@@ -8,39 +8,38 @@ memory updates, recovery tracking, and Linear integration.
import logging
from pathlib import Path
from typing import Optional
from claude_agent_sdk import ClaudeSDKClient
from .utils import (
get_latest_commit,
get_commit_count,
load_implementation_plan,
find_subtask_in_plan,
sync_plan_to_source,
)
from .memory import save_session_memory
from recovery import RecoveryManager
from progress import (
is_build_complete,
count_subtasks_detailed,
)
from insight_extractor import extract_session_insights
from linear_updater import (
linear_subtask_completed,
linear_subtask_failed,
)
from ui import (
muted,
print_status,
print_key_value,
StatusManager,
from progress import (
count_subtasks_detailed,
is_build_complete,
)
from recovery import RecoveryManager
from task_logger import (
LogPhase,
LogEntryType,
LogPhase,
get_task_logger,
)
from insight_extractor import extract_session_insights
from ui import (
StatusManager,
muted,
print_key_value,
print_status,
)
from .memory import save_session_memory
from .utils import (
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
logger = logging.getLogger(__name__)
@@ -50,12 +49,12 @@ async def post_session_processing(
project_dir: Path,
subtask_id: str,
session_num: int,
commit_before: Optional[str],
commit_before: str | None,
commit_count_before: int,
recovery_manager: RecoveryManager,
linear_enabled: bool = False,
status_manager: Optional[StatusManager] = None,
source_spec_dir: Optional[Path] = None,
status_manager: StatusManager | None = None,
source_spec_dir: Path | None = None,
) -> bool:
"""
Process session results and update memory automatically.
@@ -181,7 +180,9 @@ async def post_session_processing(
if storage_type == "graphiti":
print_status("Session saved to Graphiti memory", "success")
else:
print_status("Session saved to file-based memory (fallback)", "info")
print_status(
"Session saved to file-based memory (fallback)", "info"
)
else:
print_status("Failed to save session memory", "warning")
except Exception as e:
@@ -205,7 +206,9 @@ async def post_session_processing(
# Still record commit if one was made (partial progress)
if commit_after and commit_after != commit_before:
recovery_manager.record_good_commit(commit_after, subtask_id)
print_status(f"Recorded partial progress commit: {commit_after[:8]}", "info")
print_status(
f"Recorded partial progress commit: {commit_after[:8]}", "info"
)
# Record Linear session result (if enabled)
if linear_enabled:
@@ -251,7 +254,9 @@ async def post_session_processing(
else:
# Subtask still pending or failed
print_status(f"Subtask {subtask_id} not completed (status: {subtask_status})", "error")
print_status(
f"Subtask {subtask_id} not completed (status: {subtask_status})", "error"
)
recovery_manager.record_attempt(
subtask_id=subtask_id,
@@ -352,7 +357,12 @@ async def run_agent_session(
print(block.text, end="", flush=True)
# Log text to task logger (persist without double-printing)
if task_logger and block.text.strip():
task_logger.log(block.text, LogEntryType.TEXT, phase, print_to_console=False)
task_logger.log(
block.text,
LogEntryType.TEXT,
phase,
print_to_console=False,
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input = None
@@ -378,7 +388,9 @@ async def run_agent_session(
# Log tool start (handles printing too)
if task_logger:
task_logger.tool_start(tool_name, tool_input, phase, print_to_console=True)
task_logger.tool_start(
tool_name, tool_input, phase, print_to_console=True
)
else:
print(f"\n[Tool: {tool_name}]", flush=True)
@@ -403,14 +415,26 @@ async def run_agent_session(
if "blocked" in str(result_content).lower():
print(f" [BLOCKED] {result_content}", flush=True)
if task_logger and current_tool:
task_logger.tool_end(current_tool, success=False, result="BLOCKED", detail=str(result_content), phase=phase)
task_logger.tool_end(
current_tool,
success=False,
result="BLOCKED",
detail=str(result_content),
phase=phase,
)
elif is_error:
# Show errors (truncated)
error_str = str(result_content)[:500]
print(f" [Error] {error_str}", flush=True)
if task_logger and current_tool:
# Store full error in detail for expandable view
task_logger.tool_end(current_tool, success=False, result=error_str[:100], detail=str(result_content), phase=phase)
task_logger.tool_end(
current_tool,
success=False,
result=error_str[:100],
detail=str(result_content),
phase=phase,
)
else:
# Tool succeeded
if verbose:
@@ -422,12 +446,25 @@ async def run_agent_session(
# Store full result in detail for expandable view (only for certain tools)
# Skip storing for very large outputs like Glob results
detail_content = None
if current_tool in ("Read", "Grep", "Bash", "Edit", "Write"):
if current_tool in (
"Read",
"Grep",
"Bash",
"Edit",
"Write",
):
result_str = str(result_content)
# Only store if not too large (detail truncation happens in logger)
if len(result_str) < 50000: # 50KB max before truncation
if (
len(result_str) < 50000
): # 50KB max before truncation
detail_content = result_str
task_logger.tool_end(current_tool, success=True, detail=detail_content, phase=phase)
task_logger.tool_end(
current_tool,
success=True,
detail=detail_content,
phase=phase,
)
current_tool = None
+42 -33
View File
@@ -21,36 +21,42 @@ def test_imports():
# Test base module
from agents import base
assert hasattr(base, 'AUTO_CONTINUE_DELAY_SECONDS')
assert hasattr(base, 'HUMAN_INTERVENTION_FILE')
assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS")
assert hasattr(base, "HUMAN_INTERVENTION_FILE")
print(" ✓ agents.base")
# Test utils module
from agents import utils
assert hasattr(utils, 'get_latest_commit')
assert hasattr(utils, 'load_implementation_plan')
assert hasattr(utils, "get_latest_commit")
assert hasattr(utils, "load_implementation_plan")
print(" ✓ agents.utils")
# Test memory module
from agents import memory
assert hasattr(memory, 'save_session_memory')
assert hasattr(memory, 'get_graphiti_context')
assert hasattr(memory, "save_session_memory")
assert hasattr(memory, "get_graphiti_context")
print(" ✓ agents.memory")
# Test session module
from agents import session
assert hasattr(session, 'run_agent_session')
assert hasattr(session, 'post_session_processing')
assert hasattr(session, "run_agent_session")
assert hasattr(session, "post_session_processing")
print(" ✓ agents.session")
# Test planner module
from agents import planner
assert hasattr(planner, 'run_followup_planner')
assert hasattr(planner, "run_followup_planner")
print(" ✓ agents.planner")
# Test coder module
from agents import coder
assert hasattr(coder, 'run_autonomous_agent')
assert hasattr(coder, "run_autonomous_agent")
print(" ✓ agents.coder")
print("\n✓ All module imports successful!\n")
@@ -64,14 +70,14 @@ def test_public_api():
import agents
required_functions = [
'run_autonomous_agent',
'run_followup_planner',
'save_session_memory',
'get_graphiti_context',
'run_agent_session',
'post_session_processing',
'get_latest_commit',
'load_implementation_plan',
"run_autonomous_agent",
"run_followup_planner",
"save_session_memory",
"get_graphiti_context",
"run_agent_session",
"post_session_processing",
"get_latest_commit",
"load_implementation_plan",
]
for func_name in required_functions:
@@ -89,16 +95,18 @@ def test_backwards_compatibility():
import agent
required_functions = [
'run_autonomous_agent',
'run_followup_planner',
'save_session_memory',
'save_session_to_graphiti',
'run_agent_session',
'post_session_processing',
"run_autonomous_agent",
"run_followup_planner",
"save_session_memory",
"save_session_to_graphiti",
"run_agent_session",
"post_session_processing",
]
for func_name in required_functions:
assert hasattr(agent, func_name), f"Missing function in agent module: {func_name}"
assert hasattr(agent, func_name), (
f"Missing function in agent module: {func_name}"
)
print(f" ✓ agent.{func_name}")
print("\n✓ Backwards compatibility maintained!\n")
@@ -109,16 +117,17 @@ def test_module_structure():
print("Testing module structure...")
from pathlib import Path
agents_dir = Path(__file__).parent
required_files = [
'__init__.py',
'base.py',
'utils.py',
'memory.py',
'session.py',
'planner.py',
'coder.py',
"__init__.py",
"base.py",
"utils.py",
"memory.py",
"session.py",
"planner.py",
"coder.py",
]
for filename in required_files:
@@ -129,7 +138,7 @@ def test_module_structure():
print("\n✓ Module structure correct!\n")
if __name__ == '__main__':
if __name__ == "__main__":
try:
test_module_structure()
test_imports()
+6 -7
View File
@@ -10,12 +10,11 @@ import logging
import shutil
import subprocess
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> Optional[str]:
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
try:
result = subprocess.run(
@@ -45,7 +44,7 @@ def get_commit_count(project_dir: Path) -> int:
return 0
def load_implementation_plan(spec_dir: Path) -> Optional[dict]:
def load_implementation_plan(spec_dir: Path) -> dict | None:
"""Load the implementation plan JSON."""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
@@ -53,11 +52,11 @@ def load_implementation_plan(spec_dir: Path) -> Optional[dict]:
try:
with open(plan_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
def find_subtask_in_plan(plan: dict, subtask_id: str) -> Optional[dict]:
def find_subtask_in_plan(plan: dict, subtask_id: str) -> dict | None:
"""Find a subtask by ID in the plan."""
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
@@ -66,7 +65,7 @@ def find_subtask_in_plan(plan: dict, subtask_id: str) -> Optional[dict]:
return None
def find_phase_for_subtask(plan: dict, subtask_id: str) -> Optional[dict]:
def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
"""Find the phase containing a subtask."""
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
@@ -75,7 +74,7 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> Optional[dict]:
return None
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Optional[Path]) -> bool:
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
"""
Sync implementation_plan.json from worktree back to source spec directory.
+74 -42
View File
@@ -16,20 +16,22 @@ Example:
python ai_analyzer_runner.py --skip-cache
"""
from pathlib import Path
from typing import Optional
import json
import time
import asyncio
import json
import os
import time
from datetime import datetime
from pathlib import Path
try:
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
CLAUDE_SDK_AVAILABLE = True
except ImportError:
CLAUDE_SDK_AVAILABLE = False
print("⚠️ Warning: claude-agent-sdk not available. Install with: pip install claude-agent-sdk")
print(
"⚠️ Warning: claude-agent-sdk not available. Install with: pip install claude-agent-sdk"
)
class AIAnalyzerRunner:
@@ -49,9 +51,7 @@ class AIAnalyzerRunner:
self.cache_dir.mkdir(parents=True, exist_ok=True)
async def run_full_analysis(
self,
skip_cache: bool = False,
selected_analyzers: Optional[list[str]] = None
self, skip_cache: bool = False, selected_analyzers: list[str] | None = None
) -> dict:
"""
Run all AI analyzers.
@@ -85,7 +85,7 @@ class AIAnalyzerRunner:
# Estimate cost before running
cost_estimate = self._estimate_cost()
print(f"\n📊 Cost Estimate:")
print("\n📊 Cost Estimate:")
print(f" Tokens: ~{cost_estimate['estimated_tokens']:,}")
print(f" Cost: ~${cost_estimate['estimated_cost_usd']:.4f} USD")
print(f" Files: {cost_estimate['files_to_analyze']}")
@@ -104,7 +104,7 @@ class AIAnalyzerRunner:
"architecture",
"security",
"performance",
"code_quality"
"code_quality",
]
analyzers_to_run = selected_analyzers if selected_analyzers else all_analyzers
@@ -174,10 +174,12 @@ class AIAnalyzerRunner:
routes = service_data.get("api", {}).get("routes", [])
models = service_data.get("database", {}).get("models", {})
routes_str = "\n".join([
f" - {r['methods']} {r['path']} (in {r['file']})"
for r in routes[:10] # Limit to top 10
])
routes_str = "\n".join(
[
f" - {r['methods']} {r['path']} (in {r['file']})"
for r in routes[:10] # Limit to top 10
]
)
models_str = "\n".join([f" - {name}" for name in list(models.keys())[:10]])
@@ -225,7 +227,7 @@ Use Read, Grep, and Glob tools to analyze the codebase. Focus on actual code, no
service_name, service_data = next(iter(services.items()))
routes = service_data.get("api", {}).get("routes", [])
prompt = f"""Analyze the business logic in this project.
prompt = """Analyze the business logic in this project.
Identify the key business workflows (payment processing, user registration, data sync, etc.).
For each workflow:
@@ -235,19 +237,19 @@ For each workflow:
4. What happens on success vs failure?
Output JSON:
{{
{
"workflows": [
{{
{
"name": "User Registration",
"trigger": "POST /users",
"steps": ["validate input", "create user", "send email", "return token"],
"business_rules": ["email must be unique", "password min 8 chars"],
"error_handling": "rolls back transaction on failure"
}}
}
],
"key_business_rules": [],
"score": 80
}}
}
Use Read and Grep to analyze actual code logic."""
@@ -282,7 +284,9 @@ Output JSON:
Analyze the actual code structure using Read, Grep, and Glob."""
result = await self._run_claude_query(prompt)
return self._parse_json_response(result, {"score": 0, "architecture_style": "unknown"})
return self._parse_json_response(
result, {"score": 0, "architecture_style": "unknown"}
)
async def _analyze_security(self) -> dict:
"""Analyze security vulnerabilities."""
@@ -395,9 +399,7 @@ Use Read and Glob to analyze code structure."""
"""
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
if not oauth_token:
raise ValueError(
"CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token"
)
raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set. Run: claude setup-token")
# Create minimal security settings
settings = {
@@ -490,7 +492,7 @@ Use Read and Glob to analyze code structure."""
end_idx = response.rfind("}")
if start_idx >= 0 and end_idx > start_idx:
try:
return json.loads(response[start_idx:end_idx + 1])
return json.loads(response[start_idx : end_idx + 1])
except json.JSONDecodeError:
pass
@@ -504,7 +506,7 @@ Use Read and Glob to analyze code structure."""
return {
"estimated_tokens": 0,
"estimated_cost_usd": 0.0,
"files_to_analyze": 0
"files_to_analyze": 0,
}
# Count items from programmatic analysis
@@ -518,10 +520,18 @@ Use Read and Glob to analyze code structure."""
# Count Python files in project
python_files = list(self.project_dir.glob("**/*.py"))
total_files = len([f for f in python_files if ".venv" not in str(f) and "node_modules" not in str(f)])
total_files = len(
[
f
for f in python_files
if ".venv" not in str(f) and "node_modules" not in str(f)
]
)
# Rough estimation: each route = 500 tokens, each model = 300 tokens, each file scan = 200 tokens
estimated_tokens = (total_routes * 500) + (total_models * 300) + (total_files * 200)
estimated_tokens = (
(total_routes * 500) + (total_models * 300) + (total_files * 200)
)
# Claude Sonnet pricing: $9.00 per 1M tokens (input)
cost_per_1m_tokens = 9.00
@@ -532,7 +542,7 @@ Use Read and Glob to analyze code structure."""
"estimated_cost_usd": estimated_cost,
"files_to_analyze": total_files,
"routes_count": total_routes,
"models_count": total_models
"models_count": total_models,
}
def print_summary(self, insights: dict):
@@ -550,7 +560,14 @@ Use Read and Glob to analyze code structure."""
# Print each analyzer's score
print("\n🤖 Analyzer Scores:")
analyzers = ["code_relationships", "business_logic", "architecture", "security", "performance", "code_quality"]
analyzers = [
"code_relationships",
"business_logic",
"architecture",
"security",
"performance",
"code_quality",
]
for name in analyzers:
if name in insights and "error" not in insights[name]:
score = insights[name].get("score", 0)
@@ -562,14 +579,18 @@ Use Read and Glob to analyze code structure."""
if vulns:
print(f"\n🔒 Security: Found {len(vulns)} vulnerabilities")
for vuln in vulns[:3]:
print(f" - [{vuln.get('severity', 'unknown')}] {vuln.get('type', 'Unknown')}")
print(
f" - [{vuln.get('severity', 'unknown')}] {vuln.get('type', 'Unknown')}"
)
if "performance" in insights and "bottlenecks" in insights["performance"]:
bottlenecks = insights["performance"]["bottlenecks"]
if bottlenecks:
print(f"\n⚡ Performance: Found {len(bottlenecks)} bottlenecks")
for bn in bottlenecks[:3]:
print(f" - {bn.get('type', 'Unknown')} in {bn.get('location', 'unknown')}")
print(
f" - {bn.get('type', 'Unknown')} in {bn.get('location', 'unknown')}"
)
def main():
@@ -577,14 +598,26 @@ def main():
import argparse
parser = argparse.ArgumentParser(description="AI-Enhanced Project Analyzer")
parser.add_argument("--project-dir", type=Path, default=Path.cwd(),
help="Project directory to analyze")
parser.add_argument("--index", type=str, default="comprehensive_analysis.json",
help="Path to programmatic analysis JSON")
parser.add_argument("--skip-cache", action="store_true",
help="Skip cached results and re-analyze")
parser.add_argument("--analyzers", nargs="+",
help="Run only specific analyzers (code_relationships, business_logic, etc.)")
parser.add_argument(
"--project-dir",
type=Path,
default=Path.cwd(),
help="Project directory to analyze",
)
parser.add_argument(
"--index",
type=str,
default="comprehensive_analysis.json",
help="Path to programmatic analysis JSON",
)
parser.add_argument(
"--skip-cache", action="store_true", help="Skip cached results and re-analyze"
)
parser.add_argument(
"--analyzers",
nargs="+",
help="Run only specific analyzers (code_relationships, business_logic, etc.)",
)
args = parser.parse_args()
@@ -603,8 +636,7 @@ def main():
# Run async analysis
insights = asyncio.run(
analyzer.run_full_analysis(
skip_cache=args.skip_cache,
selected_analyzers=args.analyzers
skip_cache=args.skip_cache, selected_analyzers=args.analyzers
)
)
+3 -1
View File
@@ -51,7 +51,9 @@ def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
return results
def analyze_service(project_dir: Path, service_name: str, output_file: Path | None = None) -> dict:
def analyze_service(
project_dir: Path, service_name: str, output_file: Path | None = None
) -> dict:
"""
Analyze a specific service within a project.
+52 -13
View File
@@ -7,7 +7,6 @@ Provides common constants, utilities, and base functionality shared across all a
import json
from pathlib import Path
from typing import Any
# Directories to skip during analysis
SKIP_DIRS = {
@@ -41,17 +40,47 @@ SKIP_DIRS = {
# Common service directory names
SERVICE_INDICATORS = {
"backend", "frontend", "api", "web", "app", "server", "client",
"worker", "workers", "services", "packages", "apps", "libs",
"scraper", "crawler", "proxy", "gateway", "admin", "dashboard",
"mobile", "desktop", "cli", "sdk", "core", "shared", "common",
"backend",
"frontend",
"api",
"web",
"app",
"server",
"client",
"worker",
"workers",
"services",
"packages",
"apps",
"libs",
"scraper",
"crawler",
"proxy",
"gateway",
"admin",
"dashboard",
"mobile",
"desktop",
"cli",
"sdk",
"core",
"shared",
"common",
}
# Files that indicate a service root
SERVICE_ROOT_FILES = {
"package.json", "requirements.txt", "pyproject.toml", "Cargo.toml",
"go.mod", "Gemfile", "composer.json", "pom.xml", "build.gradle",
"Makefile", "Dockerfile",
"package.json",
"requirements.txt",
"pyproject.toml",
"Cargo.toml",
"go.mod",
"Gemfile",
"composer.json",
"pom.xml",
"build.gradle",
"Makefile",
"Dockerfile",
}
@@ -69,7 +98,7 @@ class BaseAnalyzer:
"""Read a file relative to the analyzer's path."""
try:
return (self.path / path).read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
return ""
def _read_json(self, path: str) -> dict | None:
@@ -88,7 +117,7 @@ class BaseAnalyzer:
return "string"
# Boolean
if value.lower() in ['true', 'false', '1', '0', 'yes', 'no']:
if value.lower() in ["true", "false", "1", "0", "yes", "no"]:
return "boolean"
# Number
@@ -96,15 +125,25 @@ class BaseAnalyzer:
return "number"
# URL
if value.startswith(('http://', 'https://', 'postgres://', 'postgresql://', 'mysql://', 'mongodb://', 'redis://')):
if value.startswith(
(
"http://",
"https://",
"postgres://",
"postgresql://",
"mysql://",
"mongodb://",
"redis://",
)
):
return "url"
# Email
if '@' in value and '.' in value:
if "@" in value and "." in value:
return "email"
# Path
if '/' in value or '\\' in value:
if "/" in value or "\\" in value:
return "path"
return "string"
+154 -109
View File
@@ -39,9 +39,16 @@ class ContextAnalyzer(BaseAnalyzer):
# 1. Parse .env files
env_files = [
".env", ".env.local", ".env.development", ".env.production",
".env.dev", ".env.prod", ".env.test", ".env.staging",
"config/.env", "../.env"
".env",
".env.local",
".env.development",
".env.production",
".env.dev",
".env.prod",
".env.test",
".env.staging",
"config/.env",
"../.env",
]
for env_file in env_files:
@@ -49,22 +56,31 @@ class ContextAnalyzer(BaseAnalyzer):
if not content:
continue
for line in content.split('\n'):
for line in content.split("\n"):
line = line.strip()
if not line or line.startswith('#'):
if not line or line.startswith("#"):
continue
# Parse KEY=value or KEY="value" or KEY='value'
match = re.match(r'^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$', line)
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$", line)
if match:
key = match.group(1)
value = match.group(2).strip().strip('"').strip("'")
# Detect if sensitive
is_sensitive = any(keyword in key.lower() for keyword in [
'secret', 'key', 'password', 'token', 'api_key',
'private', 'credential', 'auth'
])
is_sensitive = any(
keyword in key.lower()
for keyword in [
"secret",
"key",
"password",
"token",
"api_key",
"private",
"credential",
"auth",
]
)
# Detect type
var_type = self._infer_env_var_type(value)
@@ -73,18 +89,20 @@ class ContextAnalyzer(BaseAnalyzer):
"value": "<REDACTED>" if is_sensitive else value,
"source": env_file,
"type": var_type,
"sensitive": is_sensitive
"sensitive": is_sensitive,
}
# 2. Parse .env.example to find required variables
example_content = self._read_file(".env.example") or self._read_file(".env.sample")
example_content = self._read_file(".env.example") or self._read_file(
".env.sample"
)
if example_content:
for line in example_content.split('\n'):
for line in example_content.split("\n"):
line = line.strip()
if not line or line.startswith('#'):
if not line or line.startswith("#"):
continue
match = re.match(r'^([A-Z_][A-Z0-9_]*)\s*=', line)
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=", line)
if match:
key = match.group(1)
required_vars.add(key)
@@ -94,8 +112,11 @@ class ContextAnalyzer(BaseAnalyzer):
"value": None,
"source": ".env.example",
"type": "string",
"sensitive": any(k in key.lower() for k in ['secret', 'key', 'password', 'token']),
"required": True
"sensitive": any(
k in key.lower()
for k in ["secret", "key", "password", "token"]
),
"required": True,
}
# 3. Parse docker-compose.yml environment section
@@ -106,19 +127,19 @@ class ContextAnalyzer(BaseAnalyzer):
# Look for environment variables in docker-compose
in_env_section = False
for line in content.split('\n'):
if 'environment:' in line:
for line in content.split("\n"):
if "environment:" in line:
in_env_section = True
continue
if in_env_section:
# Check if we left the environment section
if line and not line.startswith((' ', '\t', '-')):
if line and not line.startswith((" ", "\t", "-")):
in_env_section = False
continue
# Parse - KEY=value or - KEY
match = re.match(r'^\s*-\s*([A-Z_][A-Z0-9_]*)', line)
match = re.match(r"^\s*-\s*([A-Z_][A-Z0-9_]*)", line)
if match:
key = match.group(1)
if key not in env_vars:
@@ -126,14 +147,21 @@ class ContextAnalyzer(BaseAnalyzer):
"value": None,
"source": compose_file,
"type": "string",
"sensitive": False
"sensitive": False,
}
# 4. Scan code for os.getenv() / process.env usage to find optional vars
entry_files = [
"app.py", "main.py", "config.py", "settings.py",
"src/config.py", "src/settings.py",
"index.js", "index.ts", "config.js", "config.ts"
"app.py",
"main.py",
"config.py",
"settings.py",
"src/config.py",
"src/settings.py",
"index.js",
"index.ts",
"config.js",
"config.ts",
]
for entry_file in entry_files:
@@ -150,7 +178,7 @@ class ContextAnalyzer(BaseAnalyzer):
# JavaScript: process.env.VAR
js_patterns = [
r'process\.env\.([A-Z_][A-Z0-9_]*)',
r"process\.env\.([A-Z_][A-Z0-9_]*)",
]
for pattern in python_patterns + js_patterns:
@@ -162,21 +190,24 @@ class ContextAnalyzer(BaseAnalyzer):
"value": None,
"source": f"code:{entry_file}",
"type": "string",
"sensitive": any(k in var_name.lower() for k in ['secret', 'key', 'password', 'token']),
"required": False
"sensitive": any(
k in var_name.lower()
for k in ["secret", "key", "password", "token"]
),
"required": False,
}
# Mark required vs optional
for key in env_vars:
if 'required' not in env_vars[key]:
env_vars[key]['required'] = key in required_vars
if "required" not in env_vars[key]:
env_vars[key]["required"] = key in required_vars
if env_vars:
self.analysis["environment"] = {
"variables": env_vars,
"required_count": len(required_vars),
"optional_count": len(optional_vars),
"detected_count": len(env_vars)
"detected_count": len(env_vars),
}
def detect_external_services(self) -> None:
@@ -193,7 +224,7 @@ class ContextAnalyzer(BaseAnalyzer):
"payments": [],
"storage": [],
"auth_providers": [],
"monitoring": []
"monitoring": [],
}
# Get all dependencies
@@ -202,7 +233,7 @@ class ContextAnalyzer(BaseAnalyzer):
# Python dependencies
if self._exists("requirements.txt"):
content = self._read_file("requirements.txt")
all_deps.update(re.findall(r'^([a-zA-Z0-9_-]+)', content, re.MULTILINE))
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
# Node.js dependencies
pkg = self._read_json("package.json")
@@ -224,15 +255,12 @@ class ContextAnalyzer(BaseAnalyzer):
"redis-py": "redis",
"ioredis": "redis",
"sqlite3": "sqlite",
"better-sqlite3": "sqlite"
"better-sqlite3": "sqlite",
}
for dep, db_type in db_indicators.items():
if dep in all_deps:
services["databases"].append({
"type": db_type,
"client": dep
})
services["databases"].append({"type": db_type, "client": dep})
# Cache services
cache_indicators = ["redis", "memcached", "node-cache"]
@@ -248,15 +276,12 @@ class ContextAnalyzer(BaseAnalyzer):
"kafka-python": "kafka",
"kafkajs": "kafka",
"amqplib": "rabbitmq",
"amqp": "rabbitmq"
"amqp": "rabbitmq",
}
for dep, queue_type in queue_indicators.items():
if dep in all_deps:
services["message_queues"].append({
"type": queue_type,
"client": dep
})
services["message_queues"].append({"type": queue_type, "client": dep})
# Email services
email_indicators = {
@@ -264,30 +289,24 @@ class ContextAnalyzer(BaseAnalyzer):
"@sendgrid/mail": "sendgrid",
"nodemailer": "smtp",
"mailgun": "mailgun",
"postmark": "postmark"
"postmark": "postmark",
}
for dep, email_type in email_indicators.items():
if dep in all_deps:
services["email"].append({
"provider": email_type,
"client": dep
})
services["email"].append({"provider": email_type, "client": dep})
# Payment processors
payment_indicators = {
"stripe": "stripe",
"paypal": "paypal",
"square": "square",
"braintree": "braintree"
"braintree": "braintree",
}
for dep, payment_type in payment_indicators.items():
if dep in all_deps:
services["payments"].append({
"provider": payment_type,
"client": dep
})
services["payments"].append({"provider": payment_type, "client": dep})
# Storage services
storage_indicators = {
@@ -295,15 +314,12 @@ class ContextAnalyzer(BaseAnalyzer):
"@aws-sdk/client-s3": "aws_s3",
"aws-sdk": "aws_s3",
"@google-cloud/storage": "google_cloud_storage",
"azure-storage-blob": "azure_blob_storage"
"azure-storage-blob": "azure_blob_storage",
}
for dep, storage_type in storage_indicators.items():
if dep in all_deps:
services["storage"].append({
"provider": storage_type,
"client": dep
})
services["storage"].append({"provider": storage_type, "client": dep})
# Auth providers
auth_indicators = {
@@ -313,15 +329,12 @@ class ContextAnalyzer(BaseAnalyzer):
"jsonwebtoken": "jwt",
"passport": "oauth",
"next-auth": "oauth",
"@auth/core": "oauth"
"@auth/core": "oauth",
}
for dep, auth_type in auth_indicators.items():
if dep in all_deps:
services["auth_providers"].append({
"type": auth_type,
"client": dep
})
services["auth_providers"].append({"type": auth_type, "client": dep})
# Monitoring/observability
monitoring_indicators = {
@@ -331,15 +344,12 @@ class ContextAnalyzer(BaseAnalyzer):
"newrelic": "new_relic",
"loguru": "logging",
"winston": "logging",
"pino": "logging"
"pino": "logging",
}
for dep, monitoring_type in monitoring_indicators.items():
if dep in all_deps:
services["monitoring"].append({
"type": monitoring_type,
"client": dep
})
services["monitoring"].append({"type": monitoring_type, "client": dep})
# Remove empty categories
services = {k: v for k, v in services.items() if v}
@@ -357,7 +367,7 @@ class ContextAnalyzer(BaseAnalyzer):
"strategies": [],
"libraries": [],
"user_model": None,
"middleware": []
"middleware": [],
}
# Scan for auth libraries in dependencies
@@ -365,7 +375,7 @@ class ContextAnalyzer(BaseAnalyzer):
if self._exists("requirements.txt"):
content = self._read_file("requirements.txt")
all_deps.update(re.findall(r'^([a-zA-Z0-9_-]+)', content, re.MULTILINE))
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
pkg = self._read_json("package.json")
if pkg:
@@ -396,8 +406,12 @@ class ContextAnalyzer(BaseAnalyzer):
# Find user model
user_model_files = [
"models/user.py", "models/User.py", "app/models/user.py",
"models/user.ts", "models/User.ts", "src/models/user.ts"
"models/user.py",
"models/User.py",
"app/models/user.py",
"models/user.ts",
"models/User.ts",
"src/models/user.ts",
]
for model_file in user_model_files:
@@ -413,10 +427,14 @@ class ContextAnalyzer(BaseAnalyzer):
try:
content = py_file.read_text()
# Find custom decorators
if '@require' in content or '@login_required' in content or '@authenticate' in content:
decorators = re.findall(r'@(\w*(?:require|auth|login)\w*)', content)
if (
"@require" in content
or "@login_required" in content
or "@authenticate" in content
):
decorators = re.findall(r"@(\w*(?:require|auth|login)\w*)", content)
auth_decorators.update(decorators)
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
if auth_decorators:
@@ -440,13 +458,15 @@ class ContextAnalyzer(BaseAnalyzer):
if self._exists("alembic.ini") or self._exists("alembic"):
migration_info = {
"tool": "alembic",
"directory": "alembic/versions" if self._exists("alembic/versions") else "alembic",
"directory": "alembic/versions"
if self._exists("alembic/versions")
else "alembic",
"config_file": "alembic.ini",
"commands": {
"upgrade": "alembic upgrade head",
"downgrade": "alembic downgrade -1",
"create": "alembic revision --autogenerate -m 'message'"
}
"create": "alembic revision --autogenerate -m 'message'",
},
}
# Django migrations
@@ -455,11 +475,13 @@ class ContextAnalyzer(BaseAnalyzer):
if migration_dirs:
migration_info = {
"tool": "django",
"directories": [str(d.relative_to(self.path)) for d in migration_dirs],
"directories": [
str(d.relative_to(self.path)) for d in migration_dirs
],
"commands": {
"migrate": "python manage.py migrate",
"makemigrations": "python manage.py makemigrations"
}
"makemigrations": "python manage.py makemigrations",
},
}
# Knex (Node.js)
@@ -471,8 +493,8 @@ class ContextAnalyzer(BaseAnalyzer):
"commands": {
"migrate": "knex migrate:latest",
"rollback": "knex migrate:rollback",
"create": "knex migrate:make migration_name"
}
"create": "knex migrate:make migration_name",
},
}
# TypeORM migrations
@@ -483,8 +505,8 @@ class ContextAnalyzer(BaseAnalyzer):
"commands": {
"run": "typeorm migration:run",
"revert": "typeorm migration:revert",
"create": "typeorm migration:create"
}
"create": "typeorm migration:create",
},
}
# Prisma migrations
@@ -496,8 +518,8 @@ class ContextAnalyzer(BaseAnalyzer):
"commands": {
"migrate": "prisma migrate deploy",
"dev": "prisma migrate dev",
"create": "prisma migrate dev --name migration_name"
}
"create": "prisma migrate dev --name migration_name",
},
}
if migration_info:
@@ -512,23 +534,27 @@ class ContextAnalyzer(BaseAnalyzer):
jobs_info = {}
# Celery (Python)
celery_files = list(self.path.glob("**/celery.py")) + list(self.path.glob("**/tasks.py"))
celery_files = list(self.path.glob("**/celery.py")) + list(
self.path.glob("**/tasks.py")
)
if celery_files:
tasks = []
for task_file in celery_files:
try:
content = task_file.read_text()
# Find @celery.task or @shared_task decorators
task_pattern = r'@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)'
task_pattern = r"@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)"
task_matches = re.findall(task_pattern, content)
for task_name in task_matches:
tasks.append({
"name": task_name,
"file": str(task_file.relative_to(self.path))
})
tasks.append(
{
"name": task_name,
"file": str(task_file.relative_to(self.path)),
}
)
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
if tasks:
@@ -536,17 +562,22 @@ class ContextAnalyzer(BaseAnalyzer):
"system": "celery",
"tasks": tasks,
"total_tasks": len(tasks),
"worker_command": "celery -A app worker"
"worker_command": "celery -A app worker",
}
# BullMQ (Node.js)
elif self._exists("package.json"):
pkg = self._read_json("package.json")
if pkg and ("bullmq" in pkg.get("dependencies", {}) or "bull" in pkg.get("dependencies", {})):
if pkg and (
"bullmq" in pkg.get("dependencies", {})
or "bull" in pkg.get("dependencies", {})
):
jobs_info = {
"system": "bullmq" if "bullmq" in pkg.get("dependencies", {}) else "bull",
"system": "bullmq"
if "bullmq" in pkg.get("dependencies", {})
else "bull",
"tasks": [],
"worker_command": "node worker.js"
"worker_command": "node worker.js",
}
# Sidekiq (Ruby)
@@ -555,7 +586,7 @@ class ContextAnalyzer(BaseAnalyzer):
if "sidekiq" in gemfile.lower():
jobs_info = {
"system": "sidekiq",
"worker_command": "bundle exec sidekiq"
"worker_command": "bundle exec sidekiq",
}
if jobs_info:
@@ -576,7 +607,7 @@ class ContextAnalyzer(BaseAnalyzer):
"auto_generated": True,
"docs_url": "/docs",
"redoc_url": "/redoc",
"openapi_url": "/openapi.json"
"openapi_url": "/openapi.json",
}
# Swagger/OpenAPI for Node.js
@@ -588,7 +619,7 @@ class ContextAnalyzer(BaseAnalyzer):
docs_info = {
"type": "openapi",
"library": "swagger-ui-express",
"docs_url": "/api-docs"
"docs_url": "/api-docs",
}
# GraphQL
@@ -596,12 +627,18 @@ class ContextAnalyzer(BaseAnalyzer):
pkg = self._read_json("package.json")
if pkg:
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
if "graphql" in deps or "apollo-server" in deps or "@apollo/server" in deps:
if (
"graphql" in deps
or "apollo-server" in deps
or "@apollo/server" in deps
):
if not docs_info:
docs_info = {}
docs_info["graphql"] = {
"playground_url": "/graphql",
"library": "apollo-server" if "apollo-server" in deps else "graphql"
"library": "apollo-server"
if "apollo-server" in deps
else "graphql",
}
if docs_info:
@@ -618,13 +655,19 @@ class ContextAnalyzer(BaseAnalyzer):
# Health check endpoints (look in routes)
if "api" in self.analysis:
routes = self.analysis["api"].get("routes", [])
health_routes = [r for r in routes if "health" in r["path"].lower() or "ping" in r["path"].lower()]
health_routes = [
r
for r in routes
if "health" in r["path"].lower() or "ping" in r["path"].lower()
]
if health_routes:
monitoring_info["health_checks"] = [r["path"] for r in health_routes]
# Prometheus metrics - look for actual Prometheus imports/usage, not just keywords
all_files = list(self.path.glob("**/*.py"))[:30] + list(self.path.glob("**/*.js"))[:30]
all_files = (
list(self.path.glob("**/*.py"))[:30] + list(self.path.glob("**/*.js"))[:30]
)
for file_path in all_files:
# Skip analyzer files to avoid self-detection
if "analyzers" in str(file_path) or "analyzer.py" in str(file_path):
@@ -638,20 +681,22 @@ class ContextAnalyzer(BaseAnalyzer):
"import prometheus_client",
"prometheus_client.",
"@app.route('/metrics')", # Flask
"app.get('/metrics'", # Express/Fastify
"router.get('/metrics'", # Express Router
"app.get('/metrics'", # Express/Fastify
"router.get('/metrics'", # Express Router
]
if any(pattern in content for pattern in prometheus_patterns):
monitoring_info["metrics_endpoint"] = "/metrics"
monitoring_info["metrics_type"] = "prometheus"
break
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# APM tools (already detected in external_services, just reference here)
if "services" in self.analysis and "monitoring" in self.analysis["services"]:
monitoring_info["apm_tools"] = [s["type"] for s in self.analysis["services"]["monitoring"]]
monitoring_info["apm_tools"] = [
s["type"] for s in self.analysis["services"]["monitoring"]
]
if monitoring_info:
self.analysis["monitoring"] = monitoring_info
+58 -38
View File
@@ -51,11 +51,13 @@ class DatabaseDetector(BaseAnalyzer):
for file_path in py_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Find class definitions that inherit from Base or db.Model
class_pattern = r'class\s+(\w+)\([^)]*(?:Base|db\.Model|DeclarativeBase)[^)]*\):'
class_pattern = (
r"class\s+(\w+)\([^)]*(?:Base|db\.Model|DeclarativeBase)[^)]*\):"
)
matches = re.finditer(class_pattern, content)
for match in matches:
@@ -63,31 +65,37 @@ class DatabaseDetector(BaseAnalyzer):
# Extract table name if defined
table_match = re.search(r'__tablename__\s*=\s*["\'](\w+)["\']', content)
table_name = table_match.group(1) if table_match else model_name.lower() + 's'
table_name = (
table_match.group(1) if table_match else model_name.lower() + "s"
)
# Extract columns
fields = {}
column_pattern = r'(\w+)\s*=\s*Column\((.*?)\)'
column_matches = re.finditer(column_pattern, content[match.end():match.end() + 2000])
column_pattern = r"(\w+)\s*=\s*Column\((.*?)\)"
column_matches = re.finditer(
column_pattern, content[match.end() : match.end() + 2000]
)
for col_match in column_matches:
field_name = col_match.group(1)
field_def = col_match.group(2)
# Detect field properties
is_primary = 'primary_key=True' in field_def
is_unique = 'unique=True' in field_def
is_nullable = 'nullable=False' not in field_def
is_primary = "primary_key=True" in field_def
is_unique = "unique=True" in field_def
is_nullable = "nullable=False" not in field_def
# Extract type
type_match = re.search(r'(Integer|String|Text|Boolean|DateTime|Float|JSON)', field_def)
type_match = re.search(
r"(Integer|String|Text|Boolean|DateTime|Float|JSON)", field_def
)
field_type = type_match.group(1) if type_match else "Unknown"
fields[field_name] = {
"type": field_type,
"primary_key": is_primary,
"unique": is_unique,
"nullable": is_nullable
"nullable": is_nullable,
}
if fields: # Only add if we found fields
@@ -95,7 +103,7 @@ class DatabaseDetector(BaseAnalyzer):
"table": table_name,
"fields": fields,
"file": str(file_path.relative_to(self.path)),
"orm": "SQLAlchemy"
"orm": "SQLAlchemy",
}
return models
@@ -103,16 +111,18 @@ class DatabaseDetector(BaseAnalyzer):
def _detect_django_models(self) -> dict:
"""Detect Django models."""
models = {}
model_files = list(self.path.glob("**/models.py")) + list(self.path.glob("**/models/*.py"))
model_files = list(self.path.glob("**/models.py")) + list(
self.path.glob("**/models/*.py")
)
for file_path in model_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Find class definitions that inherit from models.Model
class_pattern = r'class\s+(\w+)\(models\.Model\):'
class_pattern = r"class\s+(\w+)\(models\.Model\):"
matches = re.finditer(class_pattern, content)
for match in matches:
@@ -121,8 +131,10 @@ class DatabaseDetector(BaseAnalyzer):
# Extract fields
fields = {}
field_pattern = r'(\w+)\s*=\s*models\.(\w+Field)\((.*?)\)'
field_matches = re.finditer(field_pattern, content[match.end():match.end() + 2000])
field_pattern = r"(\w+)\s*=\s*models\.(\w+Field)\((.*?)\)"
field_matches = re.finditer(
field_pattern, content[match.end() : match.end() + 2000]
)
for field_match in field_matches:
field_name = field_match.group(1)
@@ -131,8 +143,8 @@ class DatabaseDetector(BaseAnalyzer):
fields[field_name] = {
"type": field_type,
"unique": 'unique=True' in field_args,
"nullable": 'null=True' in field_args
"unique": "unique=True" in field_args,
"nullable": "null=True" in field_args,
}
if fields:
@@ -140,7 +152,7 @@ class DatabaseDetector(BaseAnalyzer):
"table": table_name,
"fields": fields,
"file": str(file_path.relative_to(self.path)),
"orm": "Django"
"orm": "Django",
}
return models
@@ -155,11 +167,11 @@ class DatabaseDetector(BaseAnalyzer):
try:
content = schema_file.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
return models
# Find model definitions
model_pattern = r'model\s+(\w+)\s*\{([^}]+)\}'
model_pattern = r"model\s+(\w+)\s*\{([^}]+)\}"
matches = re.finditer(model_pattern, content, re.MULTILINE)
for match in matches:
@@ -168,7 +180,7 @@ class DatabaseDetector(BaseAnalyzer):
fields = {}
# Parse fields: id Int @id @default(autoincrement())
field_pattern = r'(\w+)\s+(\w+)([^/\n]*)'
field_pattern = r"(\w+)\s+(\w+)([^/\n]*)"
field_matches = re.finditer(field_pattern, model_body)
for field_match in field_matches:
@@ -178,9 +190,9 @@ class DatabaseDetector(BaseAnalyzer):
fields[field_name] = {
"type": field_type,
"primary_key": '@id' in field_attrs,
"unique": '@unique' in field_attrs,
"nullable": '?' in field_type
"primary_key": "@id" in field_attrs,
"unique": "@unique" in field_attrs,
"nullable": "?" in field_type,
}
if fields:
@@ -188,7 +200,7 @@ class DatabaseDetector(BaseAnalyzer):
"table": model_name.lower(),
"fields": fields,
"file": "prisma/schema.prisma",
"orm": "Prisma"
"orm": "Prisma",
}
return models
@@ -196,16 +208,18 @@ class DatabaseDetector(BaseAnalyzer):
def _detect_typeorm_models(self) -> dict:
"""Detect TypeORM entities."""
models = {}
ts_files = list(self.path.glob("**/*.entity.ts")) + list(self.path.glob("**/entities/*.ts"))
ts_files = list(self.path.glob("**/*.entity.ts")) + list(
self.path.glob("**/entities/*.ts")
)
for file_path in ts_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Find @Entity() class declarations
entity_pattern = r'@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)'
entity_pattern = r"@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)"
matches = re.finditer(entity_pattern, content)
for match in matches:
@@ -213,7 +227,9 @@ class DatabaseDetector(BaseAnalyzer):
# Extract columns
fields = {}
column_pattern = r'@(PrimaryGeneratedColumn|Column)\(([^)]*)\)\s+(\w+):\s*(\w+)'
column_pattern = (
r"@(PrimaryGeneratedColumn|Column)\(([^)]*)\)\s+(\w+):\s*(\w+)"
)
column_matches = re.finditer(column_pattern, content)
for col_match in column_matches:
@@ -225,7 +241,7 @@ class DatabaseDetector(BaseAnalyzer):
fields[field_name] = {
"type": field_type,
"primary_key": decorator == "PrimaryGeneratedColumn",
"unique": 'unique: true' in options
"unique": "unique: true" in options,
}
if fields:
@@ -233,7 +249,7 @@ class DatabaseDetector(BaseAnalyzer):
"table": model_name.lower(),
"fields": fields,
"file": str(file_path.relative_to(self.path)),
"orm": "TypeORM"
"orm": "TypeORM",
}
return models
@@ -241,12 +257,14 @@ class DatabaseDetector(BaseAnalyzer):
def _detect_drizzle_models(self) -> dict:
"""Detect Drizzle ORM schemas."""
models = {}
schema_files = list(self.path.glob("**/schema.ts")) + list(self.path.glob("**/db/schema.ts"))
schema_files = list(self.path.glob("**/schema.ts")) + list(
self.path.glob("**/db/schema.ts")
)
for file_path in schema_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Find table definitions: export const users = pgTable('users', {...})
@@ -261,7 +279,7 @@ class DatabaseDetector(BaseAnalyzer):
"table": table_name,
"fields": {}, # Would need more parsing for fields
"file": str(file_path.relative_to(self.path)),
"orm": "Drizzle"
"orm": "Drizzle",
}
return models
@@ -269,12 +287,14 @@ class DatabaseDetector(BaseAnalyzer):
def _detect_mongoose_models(self) -> dict:
"""Detect Mongoose models."""
models = {}
model_files = list(self.path.glob("**/models/*.js")) + list(self.path.glob("**/models/*.ts"))
model_files = list(self.path.glob("**/models/*.js")) + list(
self.path.glob("**/models/*.ts")
)
for file_path in model_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Find mongoose.model() or new Schema()
@@ -288,7 +308,7 @@ class DatabaseDetector(BaseAnalyzer):
"table": model_name.lower(),
"fields": {},
"file": str(file_path.relative_to(self.path)),
"orm": "Mongoose"
"orm": "Mongoose",
}
return models
+2 -2
View File
@@ -225,9 +225,9 @@ class FrameworkAnalyzer(BaseAnalyzer):
# Scripts
scripts = pkg.get("scripts", {})
if "dev" in scripts:
self.analysis["dev_command"] = f"npm run dev"
self.analysis["dev_command"] = "npm run dev"
elif "start" in scripts:
self.analysis["dev_command"] = f"npm run start"
self.analysis["dev_command"] = "npm run start"
def _detect_go_framework(self, content: str) -> None:
"""Detect Go framework."""
+68 -34
View File
@@ -76,32 +76,49 @@ class PortDetector(BaseAnalyzer):
def _detect_port_in_entry_points(self) -> int | None:
"""Detect port in entry point files."""
entry_files = [
"app.py", "main.py", "server.py", "__main__.py", "asgi.py", "wsgi.py",
"src/app.py", "src/main.py", "src/server.py",
"index.js", "index.ts", "server.js", "server.ts", "main.js", "main.ts",
"src/index.js", "src/index.ts", "src/server.js", "src/server.ts",
"main.go", "cmd/main.go", "src/main.rs",
"app.py",
"main.py",
"server.py",
"__main__.py",
"asgi.py",
"wsgi.py",
"src/app.py",
"src/main.py",
"src/server.py",
"index.js",
"index.ts",
"server.js",
"server.ts",
"main.js",
"main.ts",
"src/index.js",
"src/index.ts",
"src/server.js",
"src/server.ts",
"main.go",
"cmd/main.go",
"src/main.rs",
]
# Patterns to search for ports
patterns = [
# Python: uvicorn.run(app, host="0.0.0.0", port=8050)
r'uvicorn\.run\([^)]*port\s*=\s*(\d+)',
r"uvicorn\.run\([^)]*port\s*=\s*(\d+)",
# Python: app.run(port=8050, host="0.0.0.0")
r'\.run\([^)]*port\s*=\s*(\d+)',
r"\.run\([^)]*port\s*=\s*(\d+)",
# Python: port = 8050 or PORT = 8050
r'^\s*[Pp][Oo][Rr][Tt]\s*=\s*(\d+)',
r"^\s*[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
# Python: os.getenv("PORT", 8050) or os.environ.get("PORT", 8050)
r'getenv\(\s*["\']PORT["\']\s*,\s*(\d+)',
r'environ\.get\(\s*["\']PORT["\']\s*,\s*(\d+)',
# JavaScript/TypeScript: app.listen(8050)
r'\.listen\(\s*(\d+)',
r"\.listen\(\s*(\d+)",
# JavaScript/TypeScript: const PORT = 8050 or let port = 8050
r'(?:const|let|var)\s+[Pp][Oo][Rr][Tt]\s*=\s*(\d+)',
r"(?:const|let|var)\s+[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
# JavaScript/TypeScript: process.env.PORT || 8050
r'process\.env\.PORT\s*\|\|\s*(\d+)',
r"process\.env\.PORT\s*\|\|\s*(\d+)",
# JavaScript/TypeScript: Number(process.env.PORT) || 8050
r'Number\(process\.env\.PORT\)\s*\|\|\s*(\d+)',
r"Number\(process\.env\.PORT\)\s*\|\|\s*(\d+)",
# Go: :8050 or ":8050"
r':\s*(\d+)(?:["\s]|$)',
# Rust: .bind("127.0.0.1:8050")
@@ -130,15 +147,20 @@ class PortDetector(BaseAnalyzer):
def _detect_port_in_env_files(self) -> int | None:
"""Detect port in environment files."""
env_files = [
".env", ".env.local", ".env.development", ".env.dev",
"config/.env", "config/.env.local", "../.env",
".env",
".env.local",
".env.development",
".env.dev",
"config/.env",
"config/.env.local",
"../.env",
]
patterns = [
r'^\s*PORT\s*=\s*(\d+)',
r'^\s*API_PORT\s*=\s*(\d+)',
r'^\s*SERVER_PORT\s*=\s*(\d+)',
r'^\s*APP_PORT\s*=\s*(\d+)',
r"^\s*PORT\s*=\s*(\d+)",
r"^\s*API_PORT\s*=\s*(\d+)",
r"^\s*SERVER_PORT\s*=\s*(\d+)",
r"^\s*APP_PORT\s*=\s*(\d+)",
]
for env_file in env_files:
@@ -161,8 +183,10 @@ class PortDetector(BaseAnalyzer):
def _detect_port_in_docker_compose(self) -> int | None:
"""Detect port from docker-compose.yml mappings."""
compose_files = [
"docker-compose.yml", "docker-compose.yaml",
"../docker-compose.yml", "../docker-compose.yaml",
"docker-compose.yml",
"docker-compose.yaml",
"../docker-compose.yml",
"../docker-compose.yaml",
]
service_name = self.path.name.lower()
@@ -179,20 +203,24 @@ class PortDetector(BaseAnalyzer):
in_service = False
in_ports = False
for line in content.split('\n'):
for line in content.split("\n"):
# Check if we're in the right service block
if re.match(rf'^\s*{re.escape(service_name)}\s*:', line):
if re.match(rf"^\s*{re.escape(service_name)}\s*:", line):
in_service = True
continue
# Check if we hit another service
if in_service and re.match(r'^\s*\w+\s*:', line) and 'ports:' not in line:
if (
in_service
and re.match(r"^\s*\w+\s*:", line)
and "ports:" not in line
):
in_service = False
in_ports = False
continue
# Check if we're in the ports section
if in_service and 'ports:' in line:
if in_service and "ports:" in line:
in_ports = True
continue
@@ -212,9 +240,15 @@ class PortDetector(BaseAnalyzer):
def _detect_port_in_config_files(self) -> int | None:
"""Detect port in configuration files."""
config_files = [
"config.py", "settings.py", "config/settings.py", "src/config.py",
"config.json", "settings.json", "config/config.json",
"config.toml", "settings.toml",
"config.py",
"settings.py",
"config/settings.py",
"src/config.py",
"config.json",
"settings.json",
"config/config.json",
"config.toml",
"settings.toml",
]
for config_file in config_files:
@@ -224,7 +258,7 @@ class PortDetector(BaseAnalyzer):
# Python config patterns
patterns = [
r'[Pp][Oo][Rr][Tt]\s*=\s*(\d+)',
r"[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
r'["\']port["\']\s*:\s*(\d+)',
]
@@ -252,9 +286,9 @@ class PortDetector(BaseAnalyzer):
# e.g., "dev": "next dev -p 3001"
# e.g., "start": "node server.js --port 8050"
patterns = [
r'-p\s+(\d+)',
r'--port\s+(\d+)',
r'PORT=(\d+)',
r"-p\s+(\d+)",
r"--port\s+(\d+)",
r"PORT=(\d+)",
]
for script in scripts.values():
@@ -278,9 +312,9 @@ class PortDetector(BaseAnalyzer):
script_files = ["Makefile", "start.sh", "run.sh", "dev.sh"]
patterns = [
r'PORT=(\d+)',
r'--port\s+(\d+)',
r'-p\s+(\d+)',
r"PORT=(\d+)",
r"--port\s+(\d+)",
r"-p\s+(\d+)",
]
for script_file in script_files:
@@ -8,7 +8,7 @@ Analyzes entire projects, detecting monorepo structures, services, infrastructur
from pathlib import Path
from typing import Any
from .base import SKIP_DIRS, SERVICE_INDICATORS, SERVICE_ROOT_FILES
from .base import SERVICE_INDICATORS, SERVICE_ROOT_FILES, SKIP_DIRS
from .service_analyzer import ServiceAnalyzer
@@ -47,11 +47,15 @@ class ProjectAnalyzer:
for indicator in monorepo_indicators:
if (self.project_dir / indicator).exists():
self.index["project_type"] = "monorepo"
self.index["monorepo_tool"] = indicator.replace(".json", "").replace(".yaml", "")
self.index["monorepo_tool"] = indicator.replace(".json", "").replace(
".yaml", ""
)
return
# Check for packages/apps directories
if (self.project_dir / "packages").exists() or (self.project_dir / "apps").exists():
if (self.project_dir / "packages").exists() or (
self.project_dir / "apps"
).exists():
self.index["project_type"] = "monorepo"
return
@@ -100,10 +104,14 @@ class ProjectAnalyzer:
has_root_file = any((item / f).exists() for f in SERVICE_ROOT_FILES)
is_service_name = item.name.lower() in SERVICE_INDICATORS
if has_root_file or (location == self.project_dir and is_service_name):
if has_root_file or (
location == self.project_dir and is_service_name
):
analyzer = ServiceAnalyzer(item, item.name)
service_info = analyzer.analyze()
if service_info.get("language"): # Only include if we detected something
if service_info.get(
"language"
): # Only include if we detected something
services[item.name] = service_info
else:
# Single project - analyze root
@@ -134,10 +142,14 @@ class ProjectAnalyzer:
# Docker directory
docker_dir = self.project_dir / "docker"
if docker_dir.exists():
dockerfiles = list(docker_dir.glob("Dockerfile*")) + list(docker_dir.glob("*.Dockerfile"))
dockerfiles = list(docker_dir.glob("Dockerfile*")) + list(
docker_dir.glob("*.Dockerfile")
)
if dockerfiles:
infra["docker_directory"] = "docker/"
infra["dockerfiles"] = [str(f.relative_to(self.project_dir)) for f in dockerfiles]
infra["dockerfiles"] = [
str(f.relative_to(self.project_dir)) for f in dockerfiles
]
# CI/CD
if (self.project_dir / ".github" / "workflows").exists():
@@ -178,7 +190,11 @@ class ProjectAnalyzer:
continue
if in_services:
# Service names are at 2-space indent
if line.startswith(" ") and not line.startswith(" ") and line.strip().endswith(":"):
if (
line.startswith(" ")
and not line.startswith(" ")
and line.strip().endswith(":")
):
service_name = line.strip().rstrip(":")
services.append(service_name)
elif line and not line.startswith(" "):
@@ -204,12 +220,23 @@ class ProjectAnalyzer:
conventions["python_formatting"] = "Black"
# JavaScript/TypeScript linting
eslint_files = [".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yml", "eslint.config.js"]
eslint_files = [
".eslintrc",
".eslintrc.js",
".eslintrc.json",
".eslintrc.yml",
"eslint.config.js",
]
if any((self.project_dir / f).exists() for f in eslint_files):
conventions["js_linting"] = "ESLint"
# Prettier
prettier_files = [".prettierrc", ".prettierrc.js", ".prettierrc.json", "prettier.config.js"]
prettier_files = [
".prettierrc",
".prettierrc.js",
".prettierrc.json",
"prettier.config.js",
]
if any((self.project_dir / f).exists() for f in prettier_files):
conventions["formatting"] = "Prettier"
@@ -259,5 +286,5 @@ class ProjectAnalyzer:
def _read_file(self, path: str) -> str:
try:
return (self.project_dir / path).read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
return ""
+141 -91
View File
@@ -11,7 +11,6 @@ Detects API routes and endpoints across different frameworks:
import re
from pathlib import Path
from typing import Any
from .base import BaseAnalyzer
@@ -57,41 +56,57 @@ class RouteDetector(BaseAnalyzer):
for file_path in files_to_check:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Pattern: @app.get("/path") or @router.post("/path", dependencies=[...])
patterns = [
(r'@(?:app|router)\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']', 'decorator'),
(r'@(?:app|router)\.api_route\(["\']([^"\']+)["\'][^)]*methods\s*=\s*\[([^\]]+)\]', 'api_route'),
(
r'@(?:app|router)\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']',
"decorator",
),
(
r'@(?:app|router)\.api_route\(["\']([^"\']+)["\'][^)]*methods\s*=\s*\[([^\]]+)\]',
"api_route",
),
]
for pattern, pattern_type in patterns:
matches = re.finditer(pattern, content, re.MULTILINE)
for match in matches:
if pattern_type == 'decorator':
if pattern_type == "decorator":
method = match.group(1).upper()
path = match.group(2)
methods = [method]
else:
path = match.group(1)
methods_str = match.group(2)
methods = [m.strip().strip('"').strip("'").upper() for m in methods_str.split(',')]
methods = [
m.strip().strip('"').strip("'").upper()
for m in methods_str.split(",")
]
# Check if route requires auth (has Depends in the decorator)
line_start = content.rfind('\n', 0, match.start()) + 1
line_end = content.find('\n', match.end())
route_definition = content[line_start:line_end if line_end != -1 else len(content)]
line_start = content.rfind("\n", 0, match.start()) + 1
line_end = content.find("\n", match.end())
route_definition = content[
line_start : line_end if line_end != -1 else len(content)
]
requires_auth = 'Depends' in route_definition or 'require' in route_definition.lower()
requires_auth = (
"Depends" in route_definition
or "require" in route_definition.lower()
)
routes.append({
"path": path,
"methods": methods,
"file": str(file_path.relative_to(self.path)),
"framework": "FastAPI",
"requires_auth": requires_auth
})
routes.append(
{
"path": path,
"methods": methods,
"file": str(file_path.relative_to(self.path)),
"framework": "FastAPI",
"requires_auth": requires_auth,
}
)
return routes
@@ -103,7 +118,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in files_to_check:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Pattern: @app.route("/path", methods=["GET", "POST"])
@@ -115,22 +130,30 @@ class RouteDetector(BaseAnalyzer):
methods_str = match.group(2)
if methods_str:
methods = [m.strip().strip('"').strip("'").upper() for m in methods_str.split(',')]
methods = [
m.strip().strip('"').strip("'").upper()
for m in methods_str.split(",")
]
else:
methods = ["GET"] # Flask default
# Check for @login_required decorator
decorator_start = content.rfind('@', 0, match.start())
decorator_section = content[decorator_start:match.end()]
requires_auth = 'login_required' in decorator_section or 'require' in decorator_section.lower()
decorator_start = content.rfind("@", 0, match.start())
decorator_section = content[decorator_start : match.end()]
requires_auth = (
"login_required" in decorator_section
or "require" in decorator_section.lower()
)
routes.append({
"path": path,
"methods": methods,
"file": str(file_path.relative_to(self.path)),
"framework": "Flask",
"requires_auth": requires_auth
})
routes.append(
{
"path": path,
"methods": methods,
"file": str(file_path.relative_to(self.path)),
"framework": "Flask",
"requires_auth": requires_auth,
}
)
return routes
@@ -142,7 +165,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in url_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Pattern: path('users/<int:id>/', views.user_detail)
@@ -156,55 +179,66 @@ class RouteDetector(BaseAnalyzer):
for match in matches:
path = match.group(1)
routes.append({
"path": f"/{path}" if not path.startswith('/') else path,
"methods": ["GET", "POST"], # Django allows both by default
"file": str(file_path.relative_to(self.path)),
"framework": "Django",
"requires_auth": False # Can't easily detect without middleware analysis
})
routes.append(
{
"path": f"/{path}" if not path.startswith("/") else path,
"methods": ["GET", "POST"], # Django allows both by default
"file": str(file_path.relative_to(self.path)),
"framework": "Django",
"requires_auth": False, # Can't easily detect without middleware analysis
}
)
return routes
def _detect_express_routes(self) -> list[dict]:
"""Detect Express/Fastify/Koa routes."""
routes = []
files_to_check = list(self.path.glob("**/*.js")) + list(self.path.glob("**/*.ts"))
files_to_check = list(self.path.glob("**/*.js")) + list(
self.path.glob("**/*.ts")
)
for file_path in files_to_check:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Pattern: app.get('/path', handler) or router.post('/path', middleware, handler)
pattern = r'(?:app|router)\.(get|post|put|delete|patch|use)\(["\']([^"\']+)["\']'
pattern = (
r'(?:app|router)\.(get|post|put|delete|patch|use)\(["\']([^"\']+)["\']'
)
matches = re.finditer(pattern, content)
for match in matches:
method = match.group(1).upper()
path = match.group(2)
if method == 'USE':
if method == "USE":
# .use() is middleware, might be a route prefix
continue
# Check for auth middleware in the route definition
line_start = content.rfind('\n', 0, match.start()) + 1
line_end = content.find('\n', match.end())
route_line = content[line_start:line_end if line_end != -1 else len(content)]
line_start = content.rfind("\n", 0, match.start()) + 1
line_end = content.find("\n", match.end())
route_line = content[
line_start : line_end if line_end != -1 else len(content)
]
requires_auth = any(keyword in route_line.lower() for keyword in [
'auth', 'authenticate', 'protect', 'require'
])
requires_auth = any(
keyword in route_line.lower()
for keyword in ["auth", "authenticate", "protect", "require"]
)
routes.append({
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Express",
"requires_auth": requires_auth
})
routes.append(
{
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Express",
"requires_auth": requires_auth,
}
)
return routes
@@ -223,45 +257,57 @@ class RouteDetector(BaseAnalyzer):
route_path = "/" + str(relative_path).replace("\\", "/")
# Convert [id] to :id
route_path = re.sub(r'\[([^\]]+)\]', r':\1', route_path)
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
try:
content = route_file.read_text()
# Detect exported methods: export async function GET(request)
methods = re.findall(r'export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)', content)
methods = re.findall(
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)",
content,
)
if methods:
routes.append({
"path": route_path,
"methods": methods,
"file": str(route_file.relative_to(self.path)),
"framework": "Next.js",
"requires_auth": 'auth' in content.lower()
})
except (IOError, UnicodeDecodeError):
routes.append(
{
"path": route_path,
"methods": methods,
"file": str(route_file.relative_to(self.path)),
"framework": "Next.js",
"requires_auth": "auth" in content.lower(),
}
)
except (OSError, UnicodeDecodeError):
continue
# Next.js Pages Router (pages/api directory)
pages_api = self.path / "pages" / "api"
if pages_api.exists():
for api_file in pages_api.glob("**/*.{ts,js,tsx,jsx}"):
if api_file.name.startswith('_'):
if api_file.name.startswith("_"):
continue
# Convert file path to route
relative_path = api_file.relative_to(pages_api)
route_path = "/api/" + str(relative_path.with_suffix('')).replace("\\", "/")
route_path = "/api/" + str(relative_path.with_suffix("")).replace(
"\\", "/"
)
# Convert [id] to :id
route_path = re.sub(r'\[([^\]]+)\]', r':\1', route_path)
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
routes.append({
"path": route_path,
"methods": ["GET", "POST"], # Next.js API routes handle all methods
"file": str(api_file.relative_to(self.path)),
"framework": "Next.js",
"requires_auth": False
})
routes.append(
{
"path": route_path,
"methods": [
"GET",
"POST",
], # Next.js API routes handle all methods
"file": str(api_file.relative_to(self.path)),
"framework": "Next.js",
"requires_auth": False,
}
)
return routes
@@ -273,7 +319,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in go_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Gin: r.GET("/path", handler)
@@ -287,13 +333,15 @@ class RouteDetector(BaseAnalyzer):
method = match.group(1).upper()
path = match.group(2)
routes.append({
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Go",
"requires_auth": False
})
routes.append(
{
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Go",
"requires_auth": False,
}
)
return routes
@@ -305,14 +353,14 @@ class RouteDetector(BaseAnalyzer):
for file_path in rust_files:
try:
content = file_path.read_text()
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Axum: .route("/path", get(handler))
# Actix: web::get().to(handler)
patterns = [
r'\.route\(["\']([^"\']+)["\'],\s*(get|post|put|delete|patch)',
r'web::(get|post|put|delete|patch)\(\)',
r"web::(get|post|put|delete|patch)\(\)",
]
for pattern in patterns:
@@ -325,12 +373,14 @@ class RouteDetector(BaseAnalyzer):
path = "/" # Can't determine path from web:: syntax
method = match.group(1).upper()
routes.append({
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Rust",
"requires_auth": False
})
routes.append(
{
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Rust",
"requires_auth": False,
}
)
return routes
+44 -13
View File
@@ -71,13 +71,17 @@ class ServiceAnalyzer(BaseAnalyzer):
self.analysis["type"] = "frontend"
elif any(kw in name_lower for kw in ["backend", "api", "server", "service"]):
self.analysis["type"] = "backend"
elif any(kw in name_lower for kw in ["worker", "job", "queue", "task", "celery"]):
elif any(
kw in name_lower for kw in ["worker", "job", "queue", "task", "celery"]
):
self.analysis["type"] = "worker"
elif any(kw in name_lower for kw in ["scraper", "crawler", "spider"]):
self.analysis["type"] = "scraper"
elif any(kw in name_lower for kw in ["proxy", "gateway", "router"]):
self.analysis["type"] = "proxy"
elif any(kw in name_lower for kw in ["lib", "shared", "common", "core", "utils"]):
elif any(
kw in name_lower for kw in ["lib", "shared", "common", "core", "utils"]
):
self.analysis["type"] = "library"
else:
# Try to infer from language and content if name doesn't match
@@ -90,7 +94,10 @@ class ServiceAnalyzer(BaseAnalyzer):
has_main_module = (self.path / "__main__.py").exists()
# Check for agent/automation framework patterns
has_agent_files = any((self.path / f).exists() for f in ["agent.py", "agents", "runner.py", "runners"])
has_agent_files = any(
(self.path / f).exists()
for f in ["agent.py", "agents", "runner.py", "runners"]
)
if has_run_py or has_main_py or has_main_module or has_agent_files:
# It's a backend tool/framework/CLI
@@ -145,13 +152,33 @@ class ServiceAnalyzer(BaseAnalyzer):
def _find_entry_points(self) -> None:
"""Find main entry point files."""
entry_patterns = [
"main.py", "app.py", "__main__.py", "server.py", "wsgi.py", "asgi.py",
"index.ts", "index.js", "main.ts", "main.js", "server.ts", "server.js",
"app.ts", "app.js", "src/index.ts", "src/index.js", "src/main.ts",
"src/app.ts", "src/server.ts", "src/App.tsx", "src/App.jsx",
"pages/_app.tsx", "pages/_app.js", # Next.js
"main.go", "cmd/main.go",
"src/main.rs", "src/lib.rs",
"main.py",
"app.py",
"__main__.py",
"server.py",
"wsgi.py",
"asgi.py",
"index.ts",
"index.js",
"main.ts",
"main.js",
"server.ts",
"server.js",
"app.ts",
"app.js",
"src/index.ts",
"src/index.js",
"src/main.ts",
"src/app.ts",
"src/server.ts",
"src/App.tsx",
"src/App.jsx",
"pages/_app.tsx",
"pages/_app.js", # Next.js
"main.go",
"cmd/main.go",
"src/main.rs",
"src/lib.rs",
]
for pattern in entry_patterns:
@@ -233,8 +260,12 @@ class ServiceAnalyzer(BaseAnalyzer):
self.analysis["api"] = {
"routes": routes,
"total_routes": len(routes),
"methods": list(set(method for r in routes for method in r.get("methods", []))),
"protected_routes": [r["path"] for r in routes if r.get("requires_auth")]
"methods": list(
set(method for r in routes for method in r.get("methods", []))
),
"protected_routes": [
r["path"] for r in routes if r.get("requires_auth")
],
}
def _detect_database_models(self) -> None:
@@ -246,7 +277,7 @@ class ServiceAnalyzer(BaseAnalyzer):
self.analysis["database"] = {
"models": models,
"total_models": len(models),
"model_names": list(models.keys())
"model_names": list(models.keys()),
}
def _detect_external_services(self) -> None:
+128 -118
View File
@@ -32,10 +32,11 @@ Usage:
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
try:
from claude_agent_sdk import tool, create_sdk_mcp_server
from claude_agent_sdk import create_sdk_mcp_server, tool
SDK_TOOLS_AVAILABLE = True
except ImportError:
SDK_TOOLS_AVAILABLE = False
@@ -47,6 +48,7 @@ except ImportError:
# Tool Definitions
# =============================================================================
def _create_tools(spec_dir: Path, project_dir: Path):
"""Create all custom tools with the given spec and project directories."""
@@ -61,7 +63,7 @@ def _create_tools(spec_dir: Path, project_dir: Path):
@tool(
"update_subtask_status",
"Update the status of a subtask in implementation_plan.json. Use this when completing or starting a subtask.",
{"subtask_id": str, "status": str, "notes": str}
{"subtask_id": str, "status": str, "notes": str},
)
async def update_subtask_status(args: dict[str, Any]) -> dict[str, Any]:
"""Update subtask status in the implementation plan."""
@@ -72,23 +74,27 @@ def _create_tools(spec_dir: Path, project_dir: Path):
valid_statuses = ["pending", "in_progress", "completed", "failed"]
if status not in valid_statuses:
return {
"content": [{
"type": "text",
"text": f"Error: Invalid status '{status}'. Must be one of: {valid_statuses}"
}]
"content": [
{
"type": "text",
"text": f"Error: Invalid status '{status}'. Must be one of: {valid_statuses}",
}
]
}
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return {
"content": [{
"type": "text",
"text": "Error: implementation_plan.json not found"
}]
"content": [
{
"type": "text",
"text": "Error: implementation_plan.json not found",
}
]
}
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
# Find and update the subtask
@@ -107,10 +113,12 @@ def _create_tools(spec_dir: Path, project_dir: Path):
if not subtask_found:
return {
"content": [{
"type": "text",
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan"
}]
"content": [
{
"type": "text",
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan",
}
]
}
# Update plan metadata
@@ -120,25 +128,28 @@ def _create_tools(spec_dir: Path, project_dir: Path):
json.dump(plan, f, indent=2)
return {
"content": [{
"type": "text",
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}'"
}]
"content": [
{
"type": "text",
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}'",
}
]
}
except json.JSONDecodeError as e:
return {
"content": [{
"type": "text",
"text": f"Error: Invalid JSON in implementation_plan.json: {e}"
}]
"content": [
{
"type": "text",
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
}
]
}
except Exception as e:
return {
"content": [{
"type": "text",
"text": f"Error updating subtask status: {e}"
}]
"content": [
{"type": "text", "text": f"Error updating subtask status: {e}"}
]
}
tools.append(update_subtask_status)
@@ -149,7 +160,7 @@ def _create_tools(spec_dir: Path, project_dir: Path):
@tool(
"get_build_progress",
"Get the current build progress including completed subtasks, pending subtasks, and next subtask to work on.",
{}
{},
)
async def get_build_progress(args: dict[str, Any]) -> dict[str, Any]:
"""Get current build progress."""
@@ -157,14 +168,16 @@ def _create_tools(spec_dir: Path, project_dir: Path):
if not plan_file.exists():
return {
"content": [{
"type": "text",
"text": "No implementation plan found. Run the planner first."
}]
"content": [
{
"type": "text",
"text": "No implementation plan found. Run the planner first.",
}
]
}
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
stats = {
@@ -206,17 +219,21 @@ def _create_tools(spec_dir: Path, project_dir: Path):
"phase": phase_name,
}
phases_summary.append(f" {phase_name}: {phase_stats['completed']}/{phase_stats['total']}")
phases_summary.append(
f" {phase_name}: {phase_stats['completed']}/{phase_stats['total']}"
)
progress_pct = (stats["completed"] / stats["total"] * 100) if stats["total"] > 0 else 0
progress_pct = (
(stats["completed"] / stats["total"] * 100) if stats["total"] > 0 else 0
)
result = f"""Build Progress: {stats['completed']}/{stats['total']} subtasks ({progress_pct:.0f}%)
result = f"""Build Progress: {stats["completed"]}/{stats["total"]} subtasks ({progress_pct:.0f}%)
Status breakdown:
Completed: {stats['completed']}
In Progress: {stats['in_progress']}
Pending: {stats['pending']}
Failed: {stats['failed']}
Completed: {stats["completed"]}
In Progress: {stats["in_progress"]}
Pending: {stats["pending"]}
Failed: {stats["failed"]}
Phases:
{chr(10).join(phases_summary)}"""
@@ -225,25 +242,19 @@ Phases:
result += f"""
Next subtask to work on:
ID: {next_subtask['id']}
Phase: {next_subtask['phase']}
Description: {next_subtask['description']}"""
ID: {next_subtask["id"]}
Phase: {next_subtask["phase"]}
Description: {next_subtask["description"]}"""
elif stats["completed"] == stats["total"]:
result += "\n\nAll subtasks completed! Build is ready for QA."
return {
"content": [{
"type": "text",
"text": result
}]
}
return {"content": [{"type": "text", "text": result}]}
except Exception as e:
return {
"content": [{
"type": "text",
"text": f"Error reading build progress: {e}"
}]
"content": [
{"type": "text", "text": f"Error reading build progress: {e}"}
]
}
tools.append(get_build_progress)
@@ -254,7 +265,7 @@ Next subtask to work on:
@tool(
"record_discovery",
"Record a codebase discovery to session memory. Use this when you learn something important about the codebase.",
{"file_path": str, "description": str, "category": str}
{"file_path": str, "description": str, "category": str},
)
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
"""Record a discovery to the codebase map."""
@@ -270,7 +281,7 @@ Next subtask to work on:
try:
# Load existing map or create new
if codebase_map_file.exists():
with open(codebase_map_file, "r") as f:
with open(codebase_map_file) as f:
codebase_map = json.load(f)
else:
codebase_map = {
@@ -290,18 +301,17 @@ Next subtask to work on:
json.dump(codebase_map, f, indent=2)
return {
"content": [{
"type": "text",
"text": f"Recorded discovery for '{file_path}': {description}"
}]
"content": [
{
"type": "text",
"text": f"Recorded discovery for '{file_path}': {description}",
}
]
}
except Exception as e:
return {
"content": [{
"type": "text",
"text": f"Error recording discovery: {e}"
}]
"content": [{"type": "text", "text": f"Error recording discovery: {e}"}]
}
tools.append(record_discovery)
@@ -312,7 +322,7 @@ Next subtask to work on:
@tool(
"record_gotcha",
"Record a gotcha or pitfall to avoid. Use this when you encounter something that future sessions should know.",
{"gotcha": str, "context": str}
{"gotcha": str, "context": str},
)
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
"""Record a gotcha to session memory."""
@@ -334,22 +344,16 @@ Next subtask to work on:
with open(gotchas_file, "a") as f:
if not gotchas_file.exists() or gotchas_file.stat().st_size == 0:
f.write("# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n")
f.write(
"# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n"
)
f.write(entry)
return {
"content": [{
"type": "text",
"text": f"Recorded gotcha: {gotcha}"
}]
}
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
except Exception as e:
return {
"content": [{
"type": "text",
"text": f"Error recording gotcha: {e}"
}]
"content": [{"type": "text", "text": f"Error recording gotcha: {e}"}]
}
tools.append(record_gotcha)
@@ -360,7 +364,7 @@ Next subtask to work on:
@tool(
"get_session_context",
"Get context from previous sessions including discoveries, gotchas, and patterns.",
{}
{},
)
async def get_session_context(args: dict[str, Any]) -> dict[str, Any]:
"""Get accumulated session context."""
@@ -368,10 +372,12 @@ Next subtask to work on:
if not memory_dir.exists():
return {
"content": [{
"type": "text",
"text": "No session memory found. This appears to be the first session."
}]
"content": [
{
"type": "text",
"text": "No session memory found. This appears to be the first session.",
}
]
}
result_parts = []
@@ -380,7 +386,7 @@ Next subtask to work on:
codebase_map_file = memory_dir / "codebase_map.json"
if codebase_map_file.exists():
try:
with open(codebase_map_file, "r") as f:
with open(codebase_map_file) as f:
codebase_map = json.load(f)
discoveries = codebase_map.get("discovered_files", {})
@@ -400,7 +406,9 @@ Next subtask to work on:
if content.strip():
result_parts.append("\n## Gotchas")
# Take last 1000 chars to avoid too much context
result_parts.append(content[-1000:] if len(content) > 1000 else content)
result_parts.append(
content[-1000:] if len(content) > 1000 else content
)
except Exception:
pass
@@ -411,24 +419,20 @@ Next subtask to work on:
content = patterns_file.read_text()
if content.strip():
result_parts.append("\n## Patterns")
result_parts.append(content[-1000:] if len(content) > 1000 else content)
result_parts.append(
content[-1000:] if len(content) > 1000 else content
)
except Exception:
pass
if not result_parts:
return {
"content": [{
"type": "text",
"text": "No session context available yet."
}]
"content": [
{"type": "text", "text": "No session context available yet."}
]
}
return {
"content": [{
"type": "text",
"text": "\n".join(result_parts)
}]
}
return {"content": [{"type": "text", "text": "\n".join(result_parts)}]}
tools.append(get_session_context)
@@ -438,7 +442,7 @@ Next subtask to work on:
@tool(
"update_qa_status",
"Update the QA sign-off status in implementation_plan.json. Use after QA review.",
{"status": str, "issues": str, "tests_passed": str}
{"status": str, "issues": str, "tests_passed": str},
)
async def update_qa_status(args: dict[str, Any]) -> dict[str, Any]:
"""Update QA status in the implementation plan."""
@@ -446,22 +450,32 @@ Next subtask to work on:
issues_str = args.get("issues", "[]")
tests_str = args.get("tests_passed", "{}")
valid_statuses = ["pending", "in_review", "approved", "rejected", "fixes_applied"]
valid_statuses = [
"pending",
"in_review",
"approved",
"rejected",
"fixes_applied",
]
if status not in valid_statuses:
return {
"content": [{
"type": "text",
"text": f"Error: Invalid QA status '{status}'. Must be one of: {valid_statuses}"
}]
"content": [
{
"type": "text",
"text": f"Error: Invalid QA status '{status}'. Must be one of: {valid_statuses}",
}
]
}
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return {
"content": [{
"type": "text",
"text": "Error: implementation_plan.json not found"
}]
"content": [
{
"type": "text",
"text": "Error: implementation_plan.json not found",
}
]
}
try:
@@ -476,7 +490,7 @@ Next subtask to work on:
except json.JSONDecodeError:
tests_passed = {}
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
# Get current QA session number
@@ -509,18 +523,17 @@ Next subtask to work on:
json.dump(plan, f, indent=2)
return {
"content": [{
"type": "text",
"text": f"Updated QA status to '{status}' (session {qa_session})"
}]
"content": [
{
"type": "text",
"text": f"Updated QA status to '{status}' (session {qa_session})",
}
]
}
except Exception as e:
return {
"content": [{
"type": "text",
"text": f"Error updating QA status: {e}"
}]
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
}
tools.append(update_qa_status)
@@ -532,6 +545,7 @@ Next subtask to work on:
# Public API
# =============================================================================
def create_auto_claude_mcp_server(spec_dir: Path, project_dir: Path):
"""
Create an MCP server with auto-claude custom tools.
@@ -548,11 +562,7 @@ def create_auto_claude_mcp_server(spec_dir: Path, project_dir: Path):
tools = _create_tools(spec_dir, project_dir)
return create_sdk_mcp_server(
name="auto-claude",
version="1.0.0",
tools=tools
)
return create_sdk_mcp_server(name="auto-claude", version="1.0.0", tools=tools)
# Tool name constants for easy reference
+45 -22
View File
@@ -26,11 +26,12 @@ import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any
# Try to import yaml, fall back gracefully
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
@@ -54,8 +55,8 @@ class CIWorkflow:
"""
name: str
trigger: List[str] = field(default_factory=list)
steps: List[str] = field(default_factory=list)
trigger: list[str] = field(default_factory=list)
steps: list[str] = field(default_factory=list)
test_related: bool = False
@@ -74,11 +75,11 @@ class CIConfig:
"""
ci_system: str
config_files: List[str] = field(default_factory=list)
test_commands: Dict[str, str] = field(default_factory=dict)
coverage_command: Optional[str] = None
workflows: List[CIWorkflow] = field(default_factory=list)
environment_variables: List[str] = field(default_factory=list)
config_files: list[str] = field(default_factory=list)
test_commands: dict[str, str] = field(default_factory=dict)
coverage_command: str | None = None
workflows: list[CIWorkflow] = field(default_factory=list)
environment_variables: list[str] = field(default_factory=list)
# =============================================================================
@@ -99,9 +100,9 @@ class CIDiscovery:
def __init__(self) -> None:
"""Initialize CI discovery."""
self._cache: Dict[str, Optional[CIConfig]] = {}
self._cache: dict[str, CIConfig | None] = {}
def discover(self, project_dir: Path) -> Optional[CIConfig]:
def discover(self, project_dir: Path) -> CIConfig | None:
"""
Discover CI configuration in the project.
@@ -150,10 +151,14 @@ class CIDiscovery:
"""Parse GitHub Actions workflow files."""
result = CIConfig(ci_system="github_actions")
workflow_files = list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
workflow_files = list(workflows_dir.glob("*.yml")) + list(
workflows_dir.glob("*.yaml")
)
for wf_file in workflow_files:
result.config_files.append(str(wf_file.relative_to(workflows_dir.parent.parent)))
result.config_files.append(
str(wf_file.relative_to(workflows_dir.parent.parent))
)
try:
content = wf_file.read_text()
@@ -242,7 +247,18 @@ class CIDiscovery:
return result
# Parse jobs (top-level keys that aren't special keywords)
special_keys = {"stages", "variables", "image", "services", "before_script", "after_script", "cache", "include", "default", "workflow"}
special_keys = {
"stages",
"variables",
"image",
"services",
"before_script",
"after_script",
"cache",
"include",
"default",
"workflow",
}
for key, value in data.items():
if key.startswith(".") or key in special_keys:
@@ -264,7 +280,8 @@ class CIDiscovery:
result.workflows.append(
CIWorkflow(
name=key,
trigger=job_config.get("only", []) or job_config.get("rules", []),
trigger=job_config.get("only", [])
or job_config.get("rules", []),
steps=script,
test_related=test_related,
)
@@ -364,7 +381,9 @@ class CIDiscovery:
steps.append(cmd)
self._extract_test_commands(cmd, result)
if any(kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]):
if any(
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
):
test_related = True
# Extract stage names
@@ -386,7 +405,7 @@ class CIDiscovery:
return result
def _parse_yaml(self, content: str) -> Optional[Dict]:
def _parse_yaml(self, content: str) -> dict | None:
"""Parse YAML content, with fallback to basic parsing if yaml not available."""
if HAS_YAML:
try:
@@ -410,7 +429,11 @@ class CIDiscovery:
result.coverage_command = cmd.strip()
# Node.js test commands
if "npm test" in cmd_lower or "yarn test" in cmd_lower or "pnpm test" in cmd_lower:
if (
"npm test" in cmd_lower
or "yarn test" in cmd_lower
or "pnpm test" in cmd_lower
):
if "unit" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
@@ -441,7 +464,7 @@ class CIDiscovery:
if "unit" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
def to_dict(self, result: CIConfig) -> Dict[str, Any]:
def to_dict(self, result: CIConfig) -> dict[str, Any]:
"""Convert result to dictionary for JSON serialization."""
return {
"ci_system": result.ci_system,
@@ -470,7 +493,7 @@ class CIDiscovery:
# =============================================================================
def discover_ci(project_dir: Path) -> Optional[CIConfig]:
def discover_ci(project_dir: Path) -> CIConfig | None:
"""
Convenience function to discover CI configuration.
@@ -484,7 +507,7 @@ def discover_ci(project_dir: Path) -> Optional[CIConfig]:
return discovery.discover(project_dir)
def get_ci_test_commands(project_dir: Path) -> Dict[str, str]:
def get_ci_test_commands(project_dir: Path) -> dict[str, str]:
"""
Get test commands from CI configuration.
@@ -501,7 +524,7 @@ def get_ci_test_commands(project_dir: Path) -> Dict[str, str]:
return {}
def get_ci_system(project_dir: Path) -> Optional[str]:
def get_ci_system(project_dir: Path) -> str | None:
"""
Get the CI system name if configured.
@@ -545,7 +568,7 @@ def main() -> None:
else:
print(f"CI System: {result.ci_system}")
print(f"Config Files: {', '.join(result.config_files)}")
print(f"\nTest Commands:")
print("\nTest Commands:")
for test_type, cmd in result.test_commands.items():
print(f" {test_type}: {cmd}")
if result.coverage_command:
+80 -41
View File
@@ -9,7 +9,6 @@ import asyncio
import json
import sys
from pathlib import Path
from typing import Optional
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
@@ -18,38 +17,36 @@ if str(_PARENT_DIR) not in sys.path:
# Import only what we need at module level
# Heavy imports are lazy-loaded in functions to avoid import errors
from progress import count_subtasks, print_paused_banner, is_build_complete
from progress import count_subtasks, is_build_complete, print_paused_banner
from review import ReviewState
from ui import (
BuildState,
Icons,
icon,
box,
success,
error,
warning,
info,
muted,
highlight,
bold,
print_status,
select_menu,
MenuOption,
StatusManager,
BuildState,
bold,
box,
error,
highlight,
icon,
muted,
print_status,
select_menu,
success,
warning,
)
from workspace import (
WorkspaceMode,
choose_workspace,
setup_workspace,
finalize_workspace,
handle_workspace_choice,
check_existing_build,
choose_workspace,
finalize_workspace,
get_existing_build_worktree,
handle_workspace_choice,
setup_workspace,
)
from worktree import WorktreeManager
def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> Optional[str]:
def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
"""
Collect a follow-up task description from the user.
@@ -117,7 +114,9 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> Optional[str]
if choice == "file":
# Read from file
print()
print(f"{icon(Icons.DOCUMENT)} Enter the path to your task description file:")
print(
f"{icon(Icons.DOCUMENT)} Enter the path to your task description file:"
)
try:
file_path_str = input(f" {icon(Icons.POINTER)} ").strip()
except (KeyboardInterrupt, EOFError):
@@ -139,23 +138,27 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> Optional[str]
followup_task = file_path.read_text().strip()
if followup_task:
print_status(
f"Loaded {len(followup_task)} characters from file", "success"
f"Loaded {len(followup_task)} characters from file",
"success",
)
else:
print()
print_status(
"File is empty. Please provide a file with task description.", "error"
"File is empty. Please provide a file with task description.",
"error",
)
retry_count += 1
continue
else:
print_status(f"File not found: {file_path}", "error")
print(muted(f" Check that the path is correct and the file exists."))
print(
muted(" Check that the path is correct and the file exists.")
)
retry_count += 1
continue
except PermissionError:
print_status(f"Permission denied: cannot read {file_path_str}", "error")
print(muted(f" Check file permissions and try again."))
print(muted(" Check file permissions and try again."))
retry_count += 1
continue
except Exception as e:
@@ -244,6 +247,7 @@ def handle_followup_command(
"""
# Lazy imports to avoid loading heavy modules
from agent import run_followup_planner
from .utils import print_banner, validate_environment
print_banner()
@@ -273,7 +277,11 @@ def handle_followup_command(
completed, total = count_subtasks(spec_dir)
pending = total - completed
print()
print(error(f"{icon(Icons.ERROR)} Build not complete ({completed}/{total} subtasks)."))
print(
error(
f"{icon(Icons.ERROR)} Build not complete ({completed}/{total} subtasks)."
)
)
print()
content = [
f"There are still {pending} pending subtask(s) to complete.",
@@ -291,7 +299,7 @@ def handle_followup_command(
# Check for prior follow-ups (for sequential follow-up context)
prior_followup_count = 0
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan_data = json.load(f)
phases = plan_data.get("phases", [])
# Count phases that look like follow-up phases (name contains "Follow" or high phase number)
@@ -311,7 +319,11 @@ def handle_followup_command(
)
)
else:
print(success(f"{icon(Icons.SUCCESS)} Build is complete. Ready for follow-up tasks."))
print(
success(
f"{icon(Icons.SUCCESS)} Build is complete. Ready for follow-up tasks."
)
)
# Collect follow-up task from user
followup_task = collect_followup_task(spec_dir)
@@ -381,7 +393,7 @@ def handle_build_command(
project_dir: Path,
spec_dir: Path,
model: str,
max_iterations: Optional[int],
max_iterations: int | None,
verbose: bool,
force_isolated: bool,
force_direct: bool,
@@ -408,11 +420,12 @@ def handle_build_command(
from agent import run_autonomous_agent, sync_plan_to_source
from debug import (
debug,
debug_info,
debug_section,
debug_success,
debug_info,
)
from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment
print_banner()
@@ -437,7 +450,11 @@ def handle_build_command(
if force_bypass_approval:
# User explicitly bypassed approval check
print()
print(warning(f"{icon(Icons.WARNING)} WARNING: Bypassing approval check with --force"))
print(
warning(
f"{icon(Icons.WARNING)} WARNING: Bypassing approval check with --force"
)
)
print(muted("This spec has not been approved for building."))
print()
else:
@@ -467,7 +484,9 @@ def handle_build_command(
print()
sys.exit(1)
else:
debug_success("run.py", "Review approval validated", approved_by=review_state.approved_by)
debug_success(
"run.py", "Review approval validated", approved_by=review_state.approved_by
)
# Check for existing build
if get_existing_build_worktree(project_dir, spec_dir.name):
@@ -568,12 +587,16 @@ def handle_build_command(
print("\nSome issues require manual attention.")
print(f"See: {spec_dir / 'qa_report.md'}")
print(f"Or: {spec_dir / 'QA_FIX_REQUEST.md'}")
print(f"\nResume QA: python auto-claude/run.py --spec {spec_dir.name} --qa\n")
print(
f"\nResume QA: python auto-claude/run.py --spec {spec_dir.name} --qa\n"
)
# Sync implementation plan to main project after QA
# This ensures the main project has the latest status (human_review)
if sync_plan_to_source(spec_dir, source_spec_dir):
debug_info("run.py", "Implementation plan synced to main project after QA")
debug_info(
"run.py", "Implementation plan synced to main project after QA"
)
except KeyboardInterrupt:
print("\n\nQA validation paused.")
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
@@ -583,13 +606,20 @@ def handle_build_command(
# This happens AFTER QA validation so the worktree still exists
if worktree_manager:
choice = finalize_workspace(
project_dir, spec_dir.name, worktree_manager, auto_continue=auto_continue
project_dir,
spec_dir.name,
worktree_manager,
auto_continue=auto_continue,
)
handle_workspace_choice(
choice, project_dir, spec_dir.name, worktree_manager
)
handle_workspace_choice(choice, project_dir, spec_dir.name, worktree_manager)
except KeyboardInterrupt:
# Print paused banner
print_paused_banner(spec_dir, spec_dir.name, has_worktree=bool(worktree_manager))
print_paused_banner(
spec_dir, spec_dir.name, has_worktree=bool(worktree_manager)
)
# Update status file
status_manager = StatusManager(project_dir)
@@ -648,7 +678,9 @@ def handle_build_command(
if choice == "file":
# Read from file
print()
print(f"{icon(Icons.DOCUMENT)} Enter the path to your instructions file:")
print(
f"{icon(Icons.DOCUMENT)} Enter the path to your instructions file:"
)
file_path_input = input(f" {icon(Icons.POINTER)} ").strip()
if file_path_input:
@@ -657,7 +689,10 @@ def handle_build_command(
file_path = Path(file_path_input).expanduser().resolve()
if file_path.exists():
human_input = file_path.read_text().strip()
print_status(f"Loaded {len(human_input)} characters from file", "success")
print_status(
f"Loaded {len(human_input)} characters from file",
"success",
)
else:
print_status(f"File not found: {file_path}", "error")
except Exception as e:
@@ -686,7 +721,9 @@ def handle_build_command(
lines.append(line)
except KeyboardInterrupt:
print()
print_status("Exiting without saving instructions...", "warning")
print_status(
"Exiting without saving instructions...", "warning"
)
status_manager.set_inactive()
sys.exit(0)
@@ -702,7 +739,9 @@ def handle_build_command(
"",
f"Saved to: {highlight(str(input_file.name))}",
"",
muted("The agent will read and follow these instructions when you resume."),
muted(
"The agent will read and follow these instructions when you resume."
),
]
print()
print(box(content, width=70, style="heavy"))
+22 -20
View File
@@ -20,29 +20,29 @@ from ui import (
icon,
)
from .utils import (
setup_environment,
get_project_dir,
find_spec,
print_banner,
DEFAULT_MODEL,
)
from .spec_commands import print_specs_list
from .workspace_commands import (
handle_merge_command,
handle_review_command,
handle_discard_command,
handle_list_worktrees_command,
handle_cleanup_worktrees_command,
from .build_commands import (
handle_build_command,
handle_followup_command,
)
from .qa_commands import (
handle_qa_command,
handle_qa_status_command,
handle_review_status_command,
handle_qa_command,
)
from .build_commands import (
handle_followup_command,
handle_build_command,
from .spec_commands import print_specs_list
from .utils import (
DEFAULT_MODEL,
find_spec,
get_project_dir,
print_banner,
setup_environment,
)
from .workspace_commands import (
handle_cleanup_worktrees_command,
handle_discard_command,
handle_list_worktrees_command,
handle_merge_command,
handle_review_command,
)
@@ -238,7 +238,7 @@ def main() -> None:
args = parse_args()
# Import debug functions after environment setup
from debug import debug, debug_section, debug_error, debug_success
from debug import debug, debug_error, debug_section, debug_success
debug_section("run.py", "Starting Auto-Build Framework")
debug("run.py", "Arguments parsed", args=vars(args))
@@ -252,7 +252,9 @@ def main() -> None:
# 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")
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:
+8 -4
View File
@@ -16,18 +16,18 @@ if str(_PARENT_DIR) not in sys.path:
from progress import count_subtasks
from qa_loop import (
run_qa_validation_loop,
should_run_qa,
is_qa_approved,
print_qa_status,
run_qa_validation_loop,
should_run_qa,
)
from review import ReviewState, display_review_status
from ui import (
Icons,
icon,
info,
success,
warning,
info,
)
from .utils import print_banner, validate_environment
@@ -61,7 +61,11 @@ def handle_review_status_command(spec_dir: Path) -> None:
if review_state.is_approval_valid(spec_dir):
print(success(f"{icon(Icons.SUCCESS)} Ready to build - approval is valid."))
elif review_state.approved:
print(warning(f"{icon(Icons.WARNING)} Spec changed since approval - re-review required."))
print(
warning(
f"{icon(Icons.WARNING)} Spec changed since approval - re-review required."
)
)
else:
print(info(f"{icon(Icons.INFO)} Review required before building."))
print()
+11 -10
View File
@@ -7,7 +7,6 @@ CLI commands for managing specs (listing, finding, etc.)
import sys
from pathlib import Path
from typing import Optional
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
@@ -79,15 +78,17 @@ def list_specs(project_dir: Path, dev_mode: bool = False) -> list[dict]:
if has_build:
status = f"{status} (has build)"
specs.append({
"number": number,
"name": name,
"folder": folder_name,
"path": spec_folder,
"status": status,
"progress": progress,
"has_build": has_build,
})
specs.append(
{
"number": number,
"name": name,
"folder": folder_name,
"path": spec_folder,
"status": status,
"progress": progress,
"has_build": has_build,
}
)
return specs
+16 -12
View File
@@ -8,7 +8,6 @@ Shared utility functions for the Auto Claude CLI.
import os
import sys
from pathlib import Path
from typing import Optional
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
@@ -16,20 +15,17 @@ if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from dotenv import load_dotenv
from graphiti_config import get_graphiti_status
from init import init_auto_claude_dir
from linear_updater import is_linear_enabled
from linear_integration import LinearManager
from linear_updater import is_linear_enabled
from ui import (
Icons,
icon,
box,
bold,
box,
icon,
muted,
)
from workspace import get_existing_build_worktree
# Configuration
DEFAULT_MODEL = "claude-opus-4-5-20251101"
@@ -81,7 +77,9 @@ def get_specs_dir(project_dir: Path, dev_mode: bool = False) -> Path:
return project_dir / ".auto-claude" / "specs"
def find_spec(project_dir: Path, spec_identifier: str, dev_mode: bool = False) -> Optional[Path]:
def find_spec(
project_dir: Path, spec_identifier: str, dev_mode: bool = False
) -> Path | None:
"""
Find a spec by number or full name.
@@ -140,12 +138,16 @@ def validate_environment(spec_dir: Path) -> bool:
if is_linear_enabled():
print("Linear integration: ENABLED")
# Show Linear project status if initialized
project_dir = spec_dir.parent.parent # auto-claude/specs/001-name -> project root
project_dir = (
spec_dir.parent.parent
) # auto-claude/specs/001-name -> project root
linear_manager = LinearManager(spec_dir, project_dir)
if linear_manager.is_initialized:
summary = linear_manager.get_progress_summary()
print(f" Project: {summary.get('project_name', 'Unknown')}")
print(f" Issues: {summary.get('mapped_subtasks', 0)}/{summary.get('total_subtasks', 0)} mapped")
print(
f" Issues: {summary.get('mapped_subtasks', 0)}/{summary.get('total_subtasks', 0)} mapped"
)
else:
print(" Status: Will be initialized during planner session")
else:
@@ -158,7 +160,9 @@ def validate_environment(spec_dir: Path) -> bool:
print(f" Database: {graphiti_status['database']}")
print(f" Host: {graphiti_status['host']}:{graphiti_status['port']}")
elif graphiti_status["enabled"]:
print(f"Graphiti memory: CONFIGURED but unavailable ({graphiti_status['reason']})")
print(
f"Graphiti memory: CONFIGURED but unavailable ({graphiti_status['reason']})"
)
else:
print("Graphiti memory: DISABLED (set GRAPHITI_ENABLED=true to enable)")
@@ -178,7 +182,7 @@ def print_banner() -> None:
print(box(content, width=70, style="heavy"))
def get_project_dir(provided_dir: Optional[Path]) -> Path:
def get_project_dir(provided_dir: Path | None) -> Path:
"""
Determine the project directory.
+9 -5
View File
@@ -18,17 +18,19 @@ from ui import (
icon,
)
from workspace import (
merge_existing_build,
review_existing_build,
cleanup_all_worktrees,
discard_existing_build,
list_all_worktrees,
cleanup_all_worktrees,
merge_existing_build,
review_existing_build,
)
from .utils import print_banner
def handle_merge_command(project_dir: Path, spec_name: str, no_commit: bool = False) -> None:
def handle_merge_command(
project_dir: Path, spec_name: str, no_commit: bool = False
) -> None:
"""
Handle the --merge command.
@@ -94,7 +96,9 @@ def handle_list_worktrees_command(project_dir: Path) -> None:
print(" To review: python auto-claude/run.py --spec <name> --review")
print(" To discard: python auto-claude/run.py --spec <name> --discard")
print()
print(" To cleanup all worktrees: python auto-claude/run.py --cleanup-worktrees")
print(
" To cleanup all worktrees: python auto-claude/run.py --cleanup-worktrees"
)
print()
+13 -12
View File
@@ -9,16 +9,17 @@ import json
import os
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher
from security import bash_security_hook
from linear_updater import is_linear_enabled
from auto_claude_tools import (
create_auto_claude_mcp_server,
get_allowed_tools as get_agent_allowed_tools,
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 linear_updater import is_linear_enabled
from security import bash_security_hook
def is_graphiti_mcp_enabled() -> bool:
@@ -77,11 +78,11 @@ CONTEXT7_TOOLS = [
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set)
# See: https://docs.falkordb.com/agentic-memory/graphiti-mcp-server.html
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
"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
]
# Built-in tools
@@ -213,7 +214,7 @@ def create_client(
mcp_servers["linear"] = {
"type": "http",
"url": "https://mcp.linear.app/mcp",
"headers": {"Authorization": f"Bearer {linear_api_key}"}
"headers": {"Authorization": f"Bearer {linear_api_key}"},
}
# Add Graphiti MCP server if enabled
+166 -46
View File
@@ -28,16 +28,14 @@ The context builder will:
import asyncio
import json
import os
import re
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Optional
from dataclasses import dataclass, field, asdict
# Import graphiti providers for optional historical hints
try:
from graphiti_providers import get_graph_hints, is_graphiti_enabled
GRAPHITI_AVAILABLE = True
except ImportError:
GRAPHITI_AVAILABLE = False
@@ -45,26 +43,55 @@ except ImportError:
def is_graphiti_enabled() -> bool:
return False
async def get_graph_hints(query: str, project_id: str, max_results: int = 10) -> list:
async def get_graph_hints(
query: str, project_id: str, max_results: int = 10
) -> list:
return []
# Directories to skip
SKIP_DIRS = {
"node_modules", ".git", "__pycache__", ".venv", "venv", "dist", "build",
".next", ".nuxt", "target", "vendor", ".idea", ".vscode", "auto-claude",
".pytest_cache", ".mypy_cache", "coverage", ".turbo", ".cache",
"node_modules",
".git",
"__pycache__",
".venv",
"venv",
"dist",
"build",
".next",
".nuxt",
"target",
"vendor",
".idea",
".vscode",
"auto-claude",
".pytest_cache",
".mypy_cache",
"coverage",
".turbo",
".cache",
}
# File extensions to search
CODE_EXTENSIONS = {
".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte",
".go", ".rs", ".rb", ".php",
".py",
".js",
".jsx",
".ts",
".tsx",
".vue",
".svelte",
".go",
".rs",
".rb",
".php",
}
@dataclass
class FileMatch:
"""A file that matched the search criteria."""
path: str
service: str
reason: str
@@ -75,13 +102,16 @@ class FileMatch:
@dataclass
class TaskContext:
"""Complete context for a task."""
task_description: str
scoped_services: list[str]
files_to_modify: list[dict]
files_to_reference: list[dict]
patterns_discovered: dict[str, str]
service_contexts: dict[str, dict]
graph_hints: list[dict] = field(default_factory=list) # Historical hints from Graphiti
graph_hints: list[dict] = field(
default_factory=list
) # Historical hints from Graphiti
class ContextBuilder:
@@ -119,6 +149,7 @@ class ContextBuilder:
# Try to create one
from analyzer import analyze_project
return analyze_project(self.project_dir)
def build_context(
@@ -171,7 +202,9 @@ class ContextBuilder:
)
# Categorize matches
files_to_modify, files_to_reference = self._categorize_matches(all_matches, task)
files_to_modify, files_to_reference = self._categorize_matches(
all_matches, task
)
# Discover patterns from reference files
patterns = self._discover_patterns(files_to_reference, keywords)
@@ -196,8 +229,12 @@ class ContextBuilder:
return TaskContext(
task_description=task,
scoped_services=services,
files_to_modify=[asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify],
files_to_reference=[asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference],
files_to_modify=[
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify
],
files_to_reference=[
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference
],
patterns_discovered=patterns,
service_contexts=service_contexts,
graph_hints=graph_hints,
@@ -256,7 +293,9 @@ class ContextBuilder:
)
# Categorize matches
files_to_modify, files_to_reference = self._categorize_matches(all_matches, task)
files_to_modify, files_to_reference = self._categorize_matches(
all_matches, task
)
# Discover patterns from reference files
patterns = self._discover_patterns(files_to_reference, keywords)
@@ -269,8 +308,12 @@ class ContextBuilder:
return TaskContext(
task_description=task,
scoped_services=services,
files_to_modify=[asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify],
files_to_reference=[asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference],
files_to_modify=[
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify
],
files_to_reference=[
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference
],
patterns_discovered=patterns,
service_contexts=service_contexts,
graph_hints=graph_hints,
@@ -292,13 +335,23 @@ class ContextBuilder:
# Check service type relevance
service_type = service_info.get("type", "")
if service_type == "backend" and any(kw in task_lower for kw in ["api", "endpoint", "route", "database", "model"]):
if service_type == "backend" and any(
kw in task_lower
for kw in ["api", "endpoint", "route", "database", "model"]
):
score += 5
if service_type == "frontend" and any(kw in task_lower for kw in ["ui", "component", "page", "button", "form"]):
if service_type == "frontend" and any(
kw in task_lower for kw in ["ui", "component", "page", "button", "form"]
):
score += 5
if service_type == "worker" and any(kw in task_lower for kw in ["job", "task", "queue", "background", "async"]):
if service_type == "worker" and any(
kw in task_lower
for kw in ["job", "task", "queue", "background", "async"]
):
score += 5
if service_type == "scraper" and any(kw in task_lower for kw in ["scrape", "crawl", "fetch", "parse"]):
if service_type == "scraper" and any(
kw in task_lower for kw in ["scrape", "crawl", "fetch", "parse"]
):
score += 5
# Check framework relevance
@@ -320,7 +373,9 @@ class ContextBuilder:
for name, info in services.items():
if info.get("type") == "backend" and "backend" not in [s for s in default]:
default.append(name)
elif info.get("type") == "frontend" and "frontend" not in [s for s in default]:
elif info.get("type") == "frontend" and "frontend" not in [
s for s in default
]:
default.append(name)
return default[:2] if default else list(services.keys())[:2]
@@ -328,17 +383,69 @@ class ContextBuilder:
"""Extract search keywords from task description."""
# Remove common words
stopwords = {
"a", "an", "the", "to", "for", "of", "in", "on", "at", "by", "with",
"and", "or", "but", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "could",
"should", "may", "might", "must", "can", "this", "that", "these",
"those", "i", "you", "we", "they", "it", "add", "create", "make",
"implement", "build", "fix", "update", "change", "modify", "when",
"if", "then", "else", "new", "existing",
"a",
"an",
"the",
"to",
"for",
"of",
"in",
"on",
"at",
"by",
"with",
"and",
"or",
"but",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"can",
"this",
"that",
"these",
"those",
"i",
"you",
"we",
"they",
"it",
"add",
"create",
"make",
"implement",
"build",
"fix",
"update",
"change",
"modify",
"when",
"if",
"then",
"else",
"new",
"existing",
}
# Tokenize and filter
words = re.findall(r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', task.lower())
words = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", task.lower())
keywords = [w for w in words if w not in stopwords and len(w) > 2]
# Deduplicate while preserving order
@@ -365,7 +472,7 @@ class ContextBuilder:
for file_path in self._iter_code_files(service_path):
try:
content = file_path.read_text(errors='ignore')
content = file_path.read_text(errors="ignore")
content_lower = content.lower()
# Score this file
@@ -381,7 +488,7 @@ class ContextBuilder:
matching_keywords.append(keyword)
# Find matching lines (first 3 per keyword)
lines = content.split('\n')
lines = content.split("\n")
found = 0
for i, line in enumerate(lines, 1):
if keyword in line.lower() and found < 3:
@@ -390,15 +497,17 @@ class ContextBuilder:
if score > 0:
rel_path = str(file_path.relative_to(self.project_dir))
matches.append(FileMatch(
path=rel_path,
service=service_name,
reason=f"Contains: {', '.join(matching_keywords)}",
relevance_score=score,
matching_lines=matching_lines[:5], # Top 5 lines
))
matches.append(
FileMatch(
path=rel_path,
service=service_name,
reason=f"Contains: {', '.join(matching_keywords)}",
relevance_score=score,
matching_lines=matching_lines[:5], # Top 5 lines
)
)
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
# Sort by relevance
@@ -424,7 +533,16 @@ class ContextBuilder:
to_reference = []
# Keywords that suggest modification
modify_keywords = ["add", "create", "implement", "fix", "update", "change", "modify", "new"]
modify_keywords = [
"add",
"create",
"implement",
"fix",
"update",
"change",
"modify",
"new",
]
task_lower = task.lower()
is_modification = any(kw in task_lower for kw in modify_keywords)
@@ -463,26 +581,28 @@ class ContextBuilder:
for match in reference_files[:5]: # Analyze top 5 reference files
try:
file_path = self.project_dir / match.path
content = file_path.read_text(errors='ignore')
content = file_path.read_text(errors="ignore")
# Look for common patterns
for keyword in keywords:
if keyword in content.lower():
# Extract a snippet around the keyword
lines = content.split('\n')
lines = content.split("\n")
for i, line in enumerate(lines):
if keyword in line.lower():
# Get context (3 lines before and after)
start = max(0, i - 3)
end = min(len(lines), i + 4)
snippet = '\n'.join(lines[start:end])
snippet = "\n".join(lines[start:end])
pattern_key = f"{keyword}_pattern"
if pattern_key not in patterns:
patterns[pattern_key] = f"From {match.path}:\n{snippet[:300]}"
patterns[pattern_key] = (
f"From {match.path}:\n{snippet[:300]}"
)
break
except (IOError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError):
continue
return patterns
+50 -30
View File
@@ -14,14 +14,14 @@ The critique system ensures:
- Implementation matches subtask requirements
"""
from dataclasses import dataclass, field
from typing import Optional
import re
from dataclasses import dataclass, field
@dataclass
class CritiqueResult:
"""Result of a self-critique evaluation."""
passes: bool
issues: list[str] = field(default_factory=list)
improvements_made: list[str] = field(default_factory=list)
@@ -48,9 +48,7 @@ class CritiqueResult:
def generate_critique_prompt(
subtask: dict,
files_modified: list[str],
patterns_from: list[str]
subtask: dict, files_modified: list[str], patterns_from: list[str]
) -> str:
"""
Generate a critique prompt for the agent to self-evaluate.
@@ -82,7 +80,7 @@ This is NOT optional - it's a required quality gate.
Review your implementation against these criteria:
**Pattern Adherence:**
- [ ] Follows patterns from reference files exactly: {', '.join(patterns_from) if patterns_from else 'N/A'}
- [ ] Follows patterns from reference files exactly: {", ".join(patterns_from) if patterns_from else "N/A"}
- [ ] Variable naming matches codebase conventions
- [ ] Imports organized correctly (grouped, sorted)
- [ ] Code style consistent with existing files
@@ -108,13 +106,13 @@ Review your implementation against these criteria:
### STEP 2: Implementation Completeness
**Files Modified:**
Expected: {', '.join(files_to_modify) if files_to_modify else 'None'}
Actual: {', '.join(files_modified) if files_modified else 'None'}
Expected: {", ".join(files_to_modify) if files_to_modify else "None"}
Actual: {", ".join(files_modified) if files_modified else "None"}
- [ ] All files_to_modify were actually modified
- [ ] No unexpected files were modified
**Files Created:**
Expected: {', '.join(files_to_create) if files_to_create else 'None'}
Expected: {", ".join(files_to_create) if files_to_create else "None"}
- [ ] All files_to_create were actually created
- [ ] Files follow naming conventions
@@ -182,56 +180,78 @@ def parse_critique_response(response: str) -> CritiqueResult:
passes = False
# Extract PROCEED verdict
proceed_match = re.search(r'\*\*PROCEED:\*\*\s*\[?\s*(YES|NO)', response, re.IGNORECASE)
proceed_match = re.search(
r"\*\*PROCEED:\*\*\s*\[?\s*(YES|NO)", response, re.IGNORECASE
)
if proceed_match:
passes = proceed_match.group(1).upper() == "YES"
# Extract issues from Step 3
issues_section = re.search(
r'### STEP 3:.*?Potential Issues.*?\n\n(.*?)(?=###|\Z)',
r"### STEP 3:.*?Potential Issues.*?\n\n(.*?)(?=###|\Z)",
response,
re.DOTALL | re.IGNORECASE
re.DOTALL | re.IGNORECASE,
)
if issues_section:
issue_lines = issues_section.group(1).strip().split('\n')
issue_lines = issues_section.group(1).strip().split("\n")
for line in issue_lines:
line = line.strip()
if not line or line.startswith('---'):
if not line or line.startswith("---"):
continue
# Remove list markers
issue = re.sub(r'^\d+\.\s*|\*\s*|-\s*', '', line).strip()
issue = re.sub(r"^\d+\.\s*|\*\s*|-\s*", "", line).strip()
# Skip if it's a placeholder or indicates no issues
if (issue and
issue.lower() not in ['none', 'none identified', 'no issues', 'no concerns'] and
issue not in ['[Issue 1, or "None identified"]', '[Issue 2, if any]', '[Issue 3, if any]']):
if (
issue
and issue.lower()
not in ["none", "none identified", "no issues", "no concerns"]
and issue
not in [
'[Issue 1, or "None identified"]',
"[Issue 2, if any]",
"[Issue 3, if any]",
]
):
issues.append(issue)
# Extract improvements from Step 4
improvements_section = re.search(
r'### STEP 4:.*?Improvements Made.*?\n\n(.*?)(?=###|\Z)',
r"### STEP 4:.*?Improvements Made.*?\n\n(.*?)(?=###|\Z)",
response,
re.DOTALL | re.IGNORECASE
re.DOTALL | re.IGNORECASE,
)
if improvements_section:
improvement_lines = improvements_section.group(1).strip().split('\n')
improvement_lines = improvements_section.group(1).strip().split("\n")
for line in improvement_lines:
line = line.strip()
if not line or line.startswith('---'):
if not line or line.startswith("---"):
continue
# Remove list markers
improvement = re.sub(r'^\d+\.\s*|\*\s*|-\s*', '', line).strip()
improvement = re.sub(r"^\d+\.\s*|\*\s*|-\s*", "", line).strip()
# Skip if it's a placeholder or indicates no improvements
if (improvement and
improvement.lower() not in ['none', 'no fixes needed', 'no improvements', 'n/a'] and
improvement not in ['[Improvement 1, or "No fixes needed"]', '[Improvement 2, if applicable]', '[Improvement 3, if applicable]']):
if (
improvement
and improvement.lower()
not in ["none", "no fixes needed", "no improvements", "n/a"]
and improvement
not in [
'[Improvement 1, or "No fixes needed"]',
"[Improvement 2, if applicable]",
"[Improvement 3, if applicable]",
]
):
improvements.append(improvement)
# Extract confidence level as recommendation
confidence_match = re.search(r'\*\*CONFIDENCE:\*\*\s*\[?\s*(High|Medium|Low)', response, re.IGNORECASE)
confidence_match = re.search(
r"\*\*CONFIDENCE:\*\*\s*\[?\s*(High|Medium|Low)", response, re.IGNORECASE
)
if confidence_match:
confidence = confidence_match.group(1)
if confidence.lower() != 'high':
recommendations.append(f"Confidence level: {confidence} - consider additional review")
if confidence.lower() != "high":
recommendations.append(
f"Confidence level: {confidence} - consider additional review"
)
return CritiqueResult(
passes=passes,
@@ -319,7 +339,7 @@ if __name__ == "__main__":
# Generate prompt
prompt = generate_critique_prompt(subtask, files_modified, subtask["patterns_from"])
print(prompt)
print("\n" + "="*80 + "\n")
print("\n" + "=" * 80 + "\n")
# Simulate a critique response
sample_response = """
+49 -22
View File
@@ -17,14 +17,15 @@ Usage:
debug_verbose("client", "Full request payload", payload=data)
"""
import json
import os
import sys
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from functools import wraps
import time
from datetime import datetime
from functools import wraps
from pathlib import Path
from typing import Any
# ANSI color codes for terminal output
class Colors:
@@ -33,15 +34,15 @@ class Colors:
DIM = "\033[2m"
# Debug colors
DEBUG = "\033[36m" # Cyan
DEBUG = "\033[36m" # Cyan
DEBUG_DIM = "\033[96m" # Light cyan
TIMESTAMP = "\033[90m" # Gray
MODULE = "\033[33m" # Yellow
KEY = "\033[35m" # Magenta
VALUE = "\033[37m" # White
SUCCESS = "\033[32m" # Green
WARNING = "\033[33m" # Yellow
ERROR = "\033[31m" # Red
MODULE = "\033[33m" # Yellow
KEY = "\033[35m" # Magenta
VALUE = "\033[37m" # White
SUCCESS = "\033[32m" # Green
WARNING = "\033[33m" # Yellow
ERROR = "\033[31m" # Red
def _get_debug_enabled() -> bool:
@@ -58,7 +59,7 @@ def _get_debug_level() -> int:
return 1
def _get_log_file() -> Optional[Path]:
def _get_log_file() -> Path | None:
"""Get optional log file path."""
log_file = os.environ.get("DEBUG_LOG_FILE")
if log_file:
@@ -107,7 +108,8 @@ def _write_log(message: str, to_file: bool = True) -> None:
log_file.parent.mkdir(parents=True, exist_ok=True)
# Strip ANSI codes for file output
import re
clean_message = re.sub(r'\033\[[0-9;]*m', '', message)
clean_message = re.sub(r"\033\[[0-9;]*m", "", message)
with open(log_file, "a") as f:
f.write(clean_message + "\n")
except Exception:
@@ -250,6 +252,7 @@ def debug_timer(module: str):
def my_function():
...
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
@@ -262,14 +265,24 @@ def debug_timer(module: str):
try:
result = func(*args, **kwargs)
elapsed = time.time() - start
debug_success(module, f"Completed {func.__name__}()", elapsed_ms=f"{elapsed*1000:.1f}ms")
debug_success(
module,
f"Completed {func.__name__}()",
elapsed_ms=f"{elapsed * 1000:.1f}ms",
)
return result
except Exception as e:
elapsed = time.time() - start
debug_error(module, f"Failed {func.__name__}()", error=str(e), elapsed_ms=f"{elapsed*1000:.1f}ms")
debug_error(
module,
f"Failed {func.__name__}()",
error=str(e),
elapsed_ms=f"{elapsed * 1000:.1f}ms",
)
raise
return wrapper
return decorator
@@ -282,6 +295,7 @@ def debug_async_timer(module: str):
async def my_async_function():
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
@@ -294,14 +308,24 @@ def debug_async_timer(module: str):
try:
result = await func(*args, **kwargs)
elapsed = time.time() - start
debug_success(module, f"Completed {func.__name__}()", elapsed_ms=f"{elapsed*1000:.1f}ms")
debug_success(
module,
f"Completed {func.__name__}()",
elapsed_ms=f"{elapsed * 1000:.1f}ms",
)
return result
except Exception as e:
elapsed = time.time() - start
debug_error(module, f"Failed {func.__name__}()", error=str(e), elapsed_ms=f"{elapsed*1000:.1f}ms")
debug_error(
module,
f"Failed {func.__name__}()",
error=str(e),
elapsed_ms=f"{elapsed * 1000:.1f}ms",
)
raise
return wrapper
return decorator
@@ -311,10 +335,13 @@ def debug_env_status() -> None:
return
debug_section("debug", "Debug Mode Enabled")
debug("debug", "Environment configuration",
DEBUG=os.environ.get("DEBUG", "not set"),
DEBUG_LEVEL=_get_debug_level(),
DEBUG_LOG_FILE=os.environ.get("DEBUG_LOG_FILE", "not set"))
debug(
"debug",
"Environment configuration",
DEBUG=os.environ.get("DEBUG", "not set"),
DEBUG_LEVEL=_get_debug_level(),
DEBUG_LOG_FILE=os.environ.get("DEBUG_LOG_FILE", "not set"),
)
# Print status on import if debug is enabled
+6 -6
View File
@@ -14,15 +14,15 @@ graphiti_memory.py module.
from .graphiti import GraphitiMemory
from .schema import (
GroupIdMode,
MAX_CONTEXT_RESULTS,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_CODEBASE_DISCOVERY,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_TASK_OUTCOME,
EPISODE_TYPE_QA_RESULT,
EPISODE_TYPE_HISTORICAL_CONTEXT,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_QA_RESULT,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
GroupIdMode,
)
# Re-export for convenience
+9 -6
View File
@@ -6,7 +6,6 @@ Handles database connection, initialization, and lifecycle management.
import logging
from datetime import datetime, timezone
from typing import Optional
from graphiti_config import GraphitiConfig, GraphitiState
@@ -44,7 +43,7 @@ class GraphitiClient:
"""Check if client is initialized."""
return self._initialized
async def initialize(self, state: Optional[GraphitiState] = None) -> bool:
async def initialize(self, state: GraphitiState | None = None) -> bool:
"""
Initialize the Graphiti client with configured providers.
@@ -64,16 +63,18 @@ class GraphitiClient:
# Import our provider factory
from graphiti_providers import (
create_llm_client,
create_embedder,
ProviderError,
ProviderNotInstalled,
create_embedder,
create_llm_client,
)
# Create providers using factory pattern
try:
self._llm_client = create_llm_client(self.config)
logger.info(f"Created LLM client for provider: {self.config.llm_provider}")
logger.info(
f"Created LLM client for provider: {self.config.llm_provider}"
)
except ProviderNotInstalled as e:
logger.warning(f"LLM provider packages not installed: {e}")
return False
@@ -83,7 +84,9 @@ class GraphitiClient:
try:
self._embedder = create_embedder(self.config)
logger.info(f"Created embedder for provider: {self.config.embedder_provider}")
logger.info(
f"Created embedder for provider: {self.config.embedder_provider}"
)
except ProviderNotInstalled as e:
logger.warning(f"Embedder provider packages not installed: {e}")
return False
+21 -12
View File
@@ -12,14 +12,13 @@ import hashlib
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from graphiti_config import GraphitiConfig, GraphitiState
from .client import GraphitiClient
from .queries import GraphitiQueries
from .schema import MAX_CONTEXT_RESULTS, GroupIdMode
from .search import GraphitiSearch
from .schema import GroupIdMode, MAX_CONTEXT_RESULTS
logger = logging.getLogger(__name__)
@@ -61,12 +60,12 @@ class GraphitiMemory:
self.project_dir = project_dir
self.group_id_mode = group_id_mode
self.config = GraphitiConfig.from_env()
self.state: Optional[GraphitiState] = None
self.state: GraphitiState | None = None
# Component modules
self._client: Optional[GraphitiClient] = None
self._queries: Optional[GraphitiQueries] = None
self._search: Optional[GraphitiSearch] = None
self._client: GraphitiClient | None = None
self._queries: GraphitiQueries | None = None
self._search: GraphitiSearch | None = None
self._available = False
@@ -78,7 +77,9 @@ class GraphitiMemory:
# Log provider configuration if enabled
if self._available:
logger.info(f"Graphiti configured with providers: {self.config.get_provider_summary()}")
logger.info(
f"Graphiti configured with providers: {self.config.get_provider_summary()}"
)
@property
def is_enabled(self) -> bool:
@@ -106,7 +107,9 @@ class GraphitiMemory:
"""
if self.group_id_mode == GroupIdMode.PROJECT:
project_name = self.project_dir.name
path_hash = hashlib.md5(str(self.project_dir.resolve()).encode()).hexdigest()[:8]
path_hash = hashlib.md5(
str(self.project_dir.resolve()).encode()
).hexdigest()[:8]
return f"project_{project_name}_{path_hash}"
else:
return self.spec_dir.name
@@ -253,13 +256,15 @@ class GraphitiMemory:
task_id: str,
success: bool,
outcome: str,
metadata: Optional[dict] = None,
metadata: dict | None = None,
) -> bool:
"""Save a task outcome for learning from past successes/failures."""
if not await self._ensure_initialized():
return False
result = await self._queries.add_task_outcome(task_id, success, outcome, metadata)
result = await self._queries.add_task_outcome(
task_id, success, outcome, metadata
)
if result and self.state:
self.state.episode_count += 1
@@ -331,11 +336,15 @@ class GraphitiMemory:
"enabled": self.is_enabled,
"initialized": self.is_initialized,
"database": self.config.database if self.is_enabled else None,
"host": f"{self.config.falkordb_host}:{self.config.falkordb_port}" if self.is_enabled else None,
"host": f"{self.config.falkordb_host}:{self.config.falkordb_port}"
if self.is_enabled
else None,
"group_id": self.group_id,
"group_id_mode": self.group_id_mode,
"llm_provider": self.config.llm_provider if self.is_enabled else None,
"embedder_provider": self.config.embedder_provider if self.is_enabled else None,
"embedder_provider": self.config.embedder_provider
if self.is_enabled
else None,
"episode_count": self.state.episode_count if self.state else 0,
"last_session": self.state.last_session if self.state else None,
"errors": len(self.state.error_log) if self.state else 0,
+30 -11
View File
@@ -7,13 +7,12 @@ Handles episode storage, retrieval, and filtering operations.
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from .schema import (
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_CODEBASE_DISCOVERY,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
)
@@ -76,7 +75,9 @@ class GraphitiQueries:
group_id=self.group_id,
)
logger.info(f"Saved session {session_num} insights to Graphiti (group: {self.group_id})")
logger.info(
f"Saved session {session_num} insights to Graphiti (group: {self.group_id})"
)
return True
except Exception as e:
@@ -202,7 +203,7 @@ class GraphitiQueries:
task_id: str,
success: bool,
outcome: str,
metadata: Optional[dict] = None,
metadata: dict | None = None,
) -> bool:
"""
Save a task outcome for learning from past successes/failures.
@@ -296,9 +297,19 @@ class GraphitiQueries:
for pattern in insights.get("patterns_discovered", []):
total_count += 1
try:
pattern_text = pattern.get("pattern", "") if isinstance(pattern, dict) else str(pattern)
applies_to = pattern.get("applies_to", "") if isinstance(pattern, dict) else ""
example = pattern.get("example", "") if isinstance(pattern, dict) else ""
pattern_text = (
pattern.get("pattern", "")
if isinstance(pattern, dict)
else str(pattern)
)
applies_to = (
pattern.get("applies_to", "")
if isinstance(pattern, dict)
else ""
)
example = (
pattern.get("example", "") if isinstance(pattern, dict) else ""
)
episode_content = {
"type": EPISODE_TYPE_PATTERN,
@@ -325,9 +336,17 @@ class GraphitiQueries:
for gotcha in insights.get("gotchas_discovered", []):
total_count += 1
try:
gotcha_text = gotcha.get("gotcha", "") if isinstance(gotcha, dict) else str(gotcha)
trigger = gotcha.get("trigger", "") if isinstance(gotcha, dict) else ""
solution = gotcha.get("solution", "") if isinstance(gotcha, dict) else ""
gotcha_text = (
gotcha.get("gotcha", "")
if isinstance(gotcha, dict)
else str(gotcha)
)
trigger = (
gotcha.get("trigger", "") if isinstance(gotcha, dict) else ""
)
solution = (
gotcha.get("solution", "") if isinstance(gotcha, dict) else ""
)
episode_content = {
"type": EPISODE_TYPE_GOTCHA,
+2 -1
View File
@@ -23,5 +23,6 @@ RETRY_DELAY_SECONDS = 1
class GroupIdMode:
"""Group ID modes for Graphiti memory scoping."""
SPEC = "spec" # Each spec gets its own namespace
SPEC = "spec" # Each spec gets its own namespace
PROJECT = "project" # All specs share project-wide context
+46 -24
View File
@@ -8,12 +8,11 @@ import hashlib
import json
import logging
from pathlib import Path
from typing import Optional
from .schema import (
MAX_CONTEXT_RESULTS,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
GroupIdMode,
)
@@ -75,7 +74,9 @@ class GraphitiSearch:
# In spec mode, optionally include project context too
if self.group_id_mode == GroupIdMode.SPEC and include_project_context:
project_name = self.project_dir.name
path_hash = hashlib.md5(str(self.project_dir.resolve()).encode()).hexdigest()[:8]
path_hash = hashlib.md5(
str(self.project_dir.resolve()).encode()
).hexdigest()[:8]
project_group_id = f"project_{project_name}_{path_hash}"
if project_group_id != self.group_id:
group_ids.append(project_group_id)
@@ -89,15 +90,23 @@ class GraphitiSearch:
context_items = []
for result in results:
# Extract content from result
content = getattr(result, 'content', None) or getattr(result, 'fact', None) or str(result)
content = (
getattr(result, "content", None)
or getattr(result, "fact", None)
or str(result)
)
context_items.append({
"content": content,
"score": getattr(result, 'score', 0.0),
"type": getattr(result, 'type', 'unknown'),
})
context_items.append(
{
"content": content,
"score": getattr(result, "score", 0.0),
"type": getattr(result, "type", "unknown"),
}
)
logger.info(f"Found {len(context_items)} relevant context items for: {query[:50]}...")
logger.info(
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
)
return context_items
except Exception as e:
@@ -128,20 +137,27 @@ class GraphitiSearch:
sessions = []
for result in results:
content = getattr(result, 'content', None) or getattr(result, 'fact', None)
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
if content and EPISODE_TYPE_SESSION_INSIGHT in str(content):
try:
data = json.loads(content) if isinstance(content, str) else content
if data.get('type') == EPISODE_TYPE_SESSION_INSIGHT:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_SESSION_INSIGHT:
# Filter by spec if requested
if spec_only and data.get('spec_id') != self.spec_context_id:
if (
spec_only
and data.get("spec_id") != self.spec_context_id
):
continue
sessions.append(data)
except (json.JSONDecodeError, TypeError):
continue
# Sort by session number and return latest
sessions.sort(key=lambda x: x.get('session_number', 0), reverse=True)
sessions.sort(key=lambda x: x.get("session_number", 0), reverse=True)
return sessions[:limit]
except Exception as e:
@@ -172,17 +188,23 @@ class GraphitiSearch:
outcomes = []
for result in results:
content = getattr(result, 'content', None) or getattr(result, 'fact', None)
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
if content and EPISODE_TYPE_TASK_OUTCOME in str(content):
try:
data = json.loads(content) if isinstance(content, str) else content
if data.get('type') == EPISODE_TYPE_TASK_OUTCOME:
outcomes.append({
"task_id": data.get("task_id"),
"success": data.get("success"),
"outcome": data.get("outcome"),
"score": getattr(result, 'score', 0.0),
})
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_TASK_OUTCOME:
outcomes.append(
{
"task_id": data.get("task_id"),
"success": data.get("success"),
"outcome": data.get("outcome"),
"score": getattr(result, "score", 0.0),
}
)
except (json.JSONDecodeError, TypeError):
continue
+64 -36
View File
@@ -44,7 +44,7 @@ Environment Variables:
GRAPHITI_FALKORDB_HOST: FalkorDB host (default: localhost)
GRAPHITI_FALKORDB_PORT: FalkorDB port (default: 6380)
GRAPHITI_FALKORDB_PASSWORD: FalkorDB password (default: empty)
GRAPHITI_DATABASE: Graph database name (default: auto_build_memory)
GRAPHITI_DATABASE: Graph database name (default: auto_claude_memory)
GRAPHITI_TELEMETRY_ENABLED: Set to "false" to disable telemetry (default: true)
"""
@@ -54,13 +54,12 @@ from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional, List
from typing import Optional
# Default configuration values
DEFAULT_FALKORDB_HOST = "localhost"
DEFAULT_FALKORDB_PORT = 6380
DEFAULT_DATABASE = "auto_build_memory"
DEFAULT_DATABASE = "auto_claude_memory"
DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"
# Graphiti state marker file (stores connection info and status)
@@ -78,6 +77,7 @@ EPISODE_TYPE_HISTORICAL_CONTEXT = "historical_context"
class LLMProvider(str, Enum):
"""Supported LLM providers for Graphiti."""
OPENAI = "openai"
ANTHROPIC = "anthropic"
AZURE_OPENAI = "azure_openai"
@@ -86,6 +86,7 @@ class LLMProvider(str, Enum):
class EmbedderProvider(str, Enum):
"""Supported embedder providers for Graphiti."""
OPENAI = "openai"
VOYAGE = "voyage"
AZURE_OPENAI = "azure_openai"
@@ -142,19 +143,17 @@ class GraphitiConfig:
# Provider selection
llm_provider = os.environ.get("GRAPHITI_LLM_PROVIDER", "openai").lower()
embedder_provider = os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", "openai").lower()
embedder_provider = os.environ.get(
"GRAPHITI_EMBEDDER_PROVIDER", "openai"
).lower()
# FalkorDB connection settings
falkordb_host = os.environ.get(
"GRAPHITI_FALKORDB_HOST",
DEFAULT_FALKORDB_HOST
)
falkordb_host = os.environ.get("GRAPHITI_FALKORDB_HOST", DEFAULT_FALKORDB_HOST)
try:
falkordb_port = int(os.environ.get(
"GRAPHITI_FALKORDB_PORT",
str(DEFAULT_FALKORDB_PORT)
))
falkordb_port = int(
os.environ.get("GRAPHITI_FALKORDB_PORT", str(DEFAULT_FALKORDB_PORT))
)
except ValueError:
falkordb_port = DEFAULT_FALKORDB_PORT
@@ -168,17 +167,23 @@ class GraphitiConfig:
# OpenAI settings
openai_api_key = os.environ.get("OPENAI_API_KEY", "")
openai_model = os.environ.get("OPENAI_MODEL", "gpt-5-mini")
openai_embedding_model = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
openai_embedding_model = os.environ.get(
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
)
# Anthropic settings
anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
anthropic_model = os.environ.get("GRAPHITI_ANTHROPIC_MODEL", "claude-sonnet-4-5-latest")
anthropic_model = os.environ.get(
"GRAPHITI_ANTHROPIC_MODEL", "claude-sonnet-4-5-latest"
)
# Azure OpenAI settings
azure_openai_api_key = os.environ.get("AZURE_OPENAI_API_KEY", "")
azure_openai_base_url = os.environ.get("AZURE_OPENAI_BASE_URL", "")
azure_openai_llm_deployment = os.environ.get("AZURE_OPENAI_LLM_DEPLOYMENT", "")
azure_openai_embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT", "")
azure_openai_embedding_deployment = os.environ.get(
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT", ""
)
# Voyage AI settings
voyage_api_key = os.environ.get("VOYAGE_API_KEY", "")
@@ -250,7 +255,11 @@ class GraphitiConfig:
elif self.llm_provider == "anthropic":
return bool(self.anthropic_api_key)
elif self.llm_provider == "azure_openai":
return bool(self.azure_openai_api_key and self.azure_openai_base_url and self.azure_openai_llm_deployment)
return bool(
self.azure_openai_api_key
and self.azure_openai_base_url
and self.azure_openai_llm_deployment
)
elif self.llm_provider == "ollama":
return bool(self.ollama_llm_model)
return False
@@ -262,12 +271,16 @@ class GraphitiConfig:
elif self.embedder_provider == "voyage":
return bool(self.voyage_api_key)
elif self.embedder_provider == "azure_openai":
return bool(self.azure_openai_api_key and self.azure_openai_base_url and self.azure_openai_embedding_deployment)
return bool(
self.azure_openai_api_key
and self.azure_openai_base_url
and self.azure_openai_embedding_deployment
)
elif self.embedder_provider == "ollama":
return bool(self.ollama_embedding_model and self.ollama_embedding_dim)
return False
def get_validation_errors(self) -> List[str]:
def get_validation_errors(self) -> list[str]:
"""Get list of validation errors for current configuration."""
errors = []
@@ -286,9 +299,13 @@ class GraphitiConfig:
if not self.azure_openai_api_key:
errors.append("Azure OpenAI LLM provider requires AZURE_OPENAI_API_KEY")
if not self.azure_openai_base_url:
errors.append("Azure OpenAI LLM provider requires AZURE_OPENAI_BASE_URL")
errors.append(
"Azure OpenAI LLM provider requires AZURE_OPENAI_BASE_URL"
)
if not self.azure_openai_llm_deployment:
errors.append("Azure OpenAI LLM provider requires AZURE_OPENAI_LLM_DEPLOYMENT")
errors.append(
"Azure OpenAI LLM provider requires AZURE_OPENAI_LLM_DEPLOYMENT"
)
elif self.llm_provider == "ollama":
if not self.ollama_llm_model:
errors.append("Ollama LLM provider requires OLLAMA_LLM_MODEL")
@@ -304,14 +321,22 @@ class GraphitiConfig:
errors.append("Voyage embedder provider requires VOYAGE_API_KEY")
elif self.embedder_provider == "azure_openai":
if not self.azure_openai_api_key:
errors.append("Azure OpenAI embedder provider requires AZURE_OPENAI_API_KEY")
errors.append(
"Azure OpenAI embedder provider requires AZURE_OPENAI_API_KEY"
)
if not self.azure_openai_base_url:
errors.append("Azure OpenAI embedder provider requires AZURE_OPENAI_BASE_URL")
errors.append(
"Azure OpenAI embedder provider requires AZURE_OPENAI_BASE_URL"
)
if not self.azure_openai_embedding_deployment:
errors.append("Azure OpenAI embedder provider requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
errors.append(
"Azure OpenAI embedder provider requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT"
)
elif self.embedder_provider == "ollama":
if not self.ollama_embedding_model:
errors.append("Ollama embedder provider requires OLLAMA_EMBEDDING_MODEL")
errors.append(
"Ollama embedder provider requires OLLAMA_EMBEDDING_MODEL"
)
if not self.ollama_embedding_dim:
errors.append("Ollama embedder provider requires OLLAMA_EMBEDDING_DIM")
else:
@@ -333,16 +358,17 @@ class GraphitiConfig:
@dataclass
class GraphitiState:
"""State of Graphiti integration for an auto-claude spec."""
initialized: bool = False
database: Optional[str] = None
database: str | None = None
indices_built: bool = False
created_at: Optional[str] = None
last_session: Optional[int] = None
created_at: str | None = None
last_session: int | None = None
episode_count: int = 0
error_log: list = field(default_factory=list)
# V2 additions
llm_provider: Optional[str] = None
embedder_provider: Optional[str] = None
llm_provider: str | None = None
embedder_provider: str | None = None
def to_dict(self) -> dict:
return {
@@ -385,17 +411,19 @@ class GraphitiState:
return None
try:
with open(marker_file, "r") as f:
with open(marker_file) as f:
return cls.from_dict(json.load(f))
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
def record_error(self, error_msg: str) -> None:
"""Record an error in the state."""
self.error_log.append({
"timestamp": datetime.now().isoformat(),
"error": error_msg[:500], # Limit error message length
})
self.error_log.append(
{
"timestamp": datetime.now().isoformat(),
"error": error_msg[:500], # Limit error message length
}
)
# Keep only last 10 errors
self.error_log = self.error_log[-10:]
+13 -13
View File
@@ -24,20 +24,19 @@ see graphiti/graphiti.py.
"""
from pathlib import Path
from typing import Optional
# Re-export from modular system
from graphiti import (
EPISODE_TYPE_CODEBASE_DISCOVERY,
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_HISTORICAL_CONTEXT,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_QA_RESULT,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
GraphitiMemory,
GroupIdMode,
MAX_CONTEXT_RESULTS,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_CODEBASE_DISCOVERY,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_TASK_OUTCOME,
EPISODE_TYPE_QA_RESULT,
EPISODE_TYPE_HISTORICAL_CONTEXT,
)
# Import config utilities
@@ -89,7 +88,7 @@ async def test_graphiti_connection() -> tuple[bool, str]:
try:
from graphiti_core import Graphiti
from graphiti_core.driver.falkordb_driver import FalkorDriver
from graphiti_providers import create_llm_client, create_embedder, ProviderError
from graphiti_providers import ProviderError, create_embedder, create_llm_client
# Create providers
try:
@@ -136,10 +135,9 @@ async def test_provider_configuration() -> dict:
Dict with test results for each component
"""
from graphiti_providers import (
test_llm_connection,
test_embedder_connection,
test_llm_connection,
test_ollama_connection,
validate_embedding_config,
)
config = GraphitiConfig.from_env()
@@ -163,7 +161,9 @@ async def test_provider_configuration() -> dict:
# Extra test for Ollama
if config.llm_provider == "ollama" or config.embedder_provider == "ollama":
ollama_success, ollama_msg = await test_ollama_connection(config.ollama_base_url)
ollama_success, ollama_msg = await test_ollama_connection(
config.ollama_base_url
)
results["ollama_test"] = {"success": ollama_success, "message": ollama_msg}
return results
+55 -22
View File
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from pathlib import Path
from graphiti_config import GraphitiConfig
logger = logging.getLogger(__name__)
@@ -32,11 +33,13 @@ logger = logging.getLogger(__name__)
class ProviderError(Exception):
"""Raised when a provider cannot be initialized."""
pass
class ProviderNotInstalled(ProviderError):
"""Raised when required packages for a provider are not installed."""
pass
@@ -44,6 +47,7 @@ class ProviderNotInstalled(ProviderError):
# LLM Client Factory
# ============================================================================
def create_llm_client(config: "GraphitiConfig") -> Any:
"""
Create an LLM client based on the configured provider.
@@ -77,8 +81,8 @@ def create_llm_client(config: "GraphitiConfig") -> Any:
def _create_openai_llm_client(config: "GraphitiConfig") -> Any:
"""Create OpenAI LLM client."""
try:
from graphiti_core.llm_client.openai_client import OpenAIClient
from graphiti_core.llm_client.config import LLMConfig
from graphiti_core.llm_client.openai_client import OpenAIClient
except ImportError as e:
raise ProviderNotInstalled(
f"OpenAI provider requires graphiti-core. "
@@ -97,9 +101,9 @@ def _create_openai_llm_client(config: "GraphitiConfig") -> Any:
# GPT-5 family and o1/o3 models support reasoning/verbosity params
model_lower = config.openai_model.lower()
supports_reasoning = (
model_lower.startswith("gpt-5") or
model_lower.startswith("o1") or
model_lower.startswith("o3")
model_lower.startswith("gpt-5")
or model_lower.startswith("o1")
or model_lower.startswith("o3")
)
if supports_reasoning:
@@ -136,9 +140,9 @@ def _create_anthropic_llm_client(config: "GraphitiConfig") -> Any:
def _create_azure_openai_llm_client(config: "GraphitiConfig") -> Any:
"""Create Azure OpenAI LLM client."""
try:
from openai import AsyncOpenAI
from graphiti_core.llm_client.azure_openai_client import AzureOpenAILLMClient
from graphiti_core.llm_client.config import LLMConfig
from openai import AsyncOpenAI
except ImportError as e:
raise ProviderNotInstalled(
f"Azure OpenAI provider requires graphiti-core and openai. "
@@ -151,7 +155,9 @@ def _create_azure_openai_llm_client(config: "GraphitiConfig") -> Any:
if not config.azure_openai_base_url:
raise ProviderError("Azure OpenAI provider requires AZURE_OPENAI_BASE_URL")
if not config.azure_openai_llm_deployment:
raise ProviderError("Azure OpenAI provider requires AZURE_OPENAI_LLM_DEPLOYMENT")
raise ProviderError(
"Azure OpenAI provider requires AZURE_OPENAI_LLM_DEPLOYMENT"
)
azure_client = AsyncOpenAI(
base_url=config.azure_openai_base_url,
@@ -169,8 +175,8 @@ def _create_azure_openai_llm_client(config: "GraphitiConfig") -> Any:
def _create_ollama_llm_client(config: "GraphitiConfig") -> Any:
"""Create Ollama LLM client (using OpenAI-compatible interface)."""
try:
from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient
from graphiti_core.llm_client.config import LLMConfig
from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient
except ImportError as e:
raise ProviderNotInstalled(
f"Ollama provider requires graphiti-core. "
@@ -200,6 +206,7 @@ def _create_ollama_llm_client(config: "GraphitiConfig") -> Any:
# Embedder Factory
# ============================================================================
def create_embedder(config: "GraphitiConfig") -> Any:
"""
Create an embedder based on the configured provider.
@@ -255,7 +262,7 @@ def _create_openai_embedder(config: "GraphitiConfig") -> Any:
def _create_voyage_embedder(config: "GraphitiConfig") -> Any:
"""Create Voyage AI embedder (commonly used with Anthropic LLM)."""
try:
from graphiti_core.embedder.voyage import VoyageEmbedder, VoyageAIConfig
from graphiti_core.embedder.voyage import VoyageAIConfig, VoyageEmbedder
except ImportError as e:
raise ProviderNotInstalled(
f"Voyage embedder requires graphiti-core[voyage]. "
@@ -277,8 +284,8 @@ def _create_voyage_embedder(config: "GraphitiConfig") -> Any:
def _create_azure_openai_embedder(config: "GraphitiConfig") -> Any:
"""Create Azure OpenAI embedder."""
try:
from openai import AsyncOpenAI
from graphiti_core.embedder.azure_openai import AzureOpenAIEmbedderClient
from openai import AsyncOpenAI
except ImportError as e:
raise ProviderNotInstalled(
f"Azure OpenAI embedder requires graphiti-core and openai. "
@@ -291,7 +298,9 @@ def _create_azure_openai_embedder(config: "GraphitiConfig") -> Any:
if not config.azure_openai_base_url:
raise ProviderError("Azure OpenAI embedder requires AZURE_OPENAI_BASE_URL")
if not config.azure_openai_embedding_deployment:
raise ProviderError("Azure OpenAI embedder requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
raise ProviderError(
"Azure OpenAI embedder requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT"
)
azure_client = AsyncOpenAI(
base_url=config.azure_openai_base_url,
@@ -337,7 +346,10 @@ def _create_ollama_embedder(config: "GraphitiConfig") -> Any:
# Cross-Encoder / Reranker Factory (Optional)
# ============================================================================
def create_cross_encoder(config: "GraphitiConfig", llm_client: Any = None) -> Optional[Any]:
def create_cross_encoder(
config: "GraphitiConfig", llm_client: Any = None
) -> Any | None:
"""
Create a cross-encoder/reranker for improved search quality.
@@ -359,7 +371,9 @@ def create_cross_encoder(config: "GraphitiConfig", llm_client: Any = None) -> Op
return None
try:
from graphiti_core.cross_encoder.openai_reranker_client import OpenAIRerankerClient
from graphiti_core.cross_encoder.openai_reranker_client import (
OpenAIRerankerClient,
)
from graphiti_core.llm_client.config import LLMConfig
except ImportError:
logger.debug("Cross-encoder not available (optional)")
@@ -408,7 +422,7 @@ EMBEDDING_DIMENSIONS = {
}
def get_expected_embedding_dim(model: str) -> Optional[int]:
def get_expected_embedding_dim(model: str) -> int | None:
"""
Get the expected embedding dimension for a known model.
@@ -458,8 +472,8 @@ def validate_embedding_config(config: "GraphitiConfig") -> tuple[bool, str]:
)
else:
return False, (
f"Ollama embedder requires OLLAMA_EMBEDDING_DIM. "
f"Check your model's documentation for the correct dimension."
"Ollama embedder requires OLLAMA_EMBEDDING_DIM. "
"Check your model's documentation for the correct dimension."
)
# Check for known dimension mismatches
@@ -467,12 +481,16 @@ def validate_embedding_config(config: "GraphitiConfig") -> tuple[bool, str]:
expected = get_expected_embedding_dim(config.openai_embedding_model)
# OpenAI handles this automatically, just log info
if expected:
logger.debug(f"OpenAI embedding model '{config.openai_embedding_model}' has dimension {expected}")
logger.debug(
f"OpenAI embedding model '{config.openai_embedding_model}' has dimension {expected}"
)
elif provider == "voyage":
expected = get_expected_embedding_dim(config.voyage_embedding_model)
if expected:
logger.debug(f"Voyage embedding model '{config.voyage_embedding_model}' has dimension {expected}")
logger.debug(
f"Voyage embedding model '{config.voyage_embedding_model}' has dimension {expected}"
)
return True, "Embedding configuration valid"
@@ -481,6 +499,7 @@ def validate_embedding_config(config: "GraphitiConfig") -> tuple[bool, str]:
# Provider Health Checks
# ============================================================================
async def test_llm_connection(config: "GraphitiConfig") -> tuple[bool, str]:
"""
Test if LLM provider is reachable.
@@ -494,7 +513,10 @@ async def test_llm_connection(config: "GraphitiConfig") -> tuple[bool, str]:
try:
llm_client = create_llm_client(config)
# Most clients don't have a ping method, so just verify creation succeeded
return True, f"LLM client created successfully for provider: {config.llm_provider}"
return (
True,
f"LLM client created successfully for provider: {config.llm_provider}",
)
except ProviderNotInstalled as e:
return False, str(e)
except ProviderError as e:
@@ -520,7 +542,10 @@ async def test_embedder_connection(config: "GraphitiConfig") -> tuple[bool, str]
try:
embedder = create_embedder(config)
return True, f"Embedder created successfully for provider: {config.embedder_provider}"
return (
True,
f"Embedder created successfully for provider: {config.embedder_provider}",
)
except ProviderNotInstalled as e:
return False, str(e)
except ProviderError as e:
@@ -529,7 +554,9 @@ async def test_embedder_connection(config: "GraphitiConfig") -> tuple[bool, str]
return False, f"Failed to create embedder: {e}"
async def test_ollama_connection(base_url: str = "http://localhost:11434") -> tuple[bool, str]:
async def test_ollama_connection(
base_url: str = "http://localhost:11434",
) -> tuple[bool, str]:
"""
Test if Ollama server is running and reachable.
@@ -545,8 +572,8 @@ async def test_ollama_connection(base_url: str = "http://localhost:11434") -> tu
import aiohttp
except ImportError:
# Fall back to sync request
import urllib.request
import urllib.error
import urllib.request
try:
# Normalize URL (remove /v1 suffix if present)
@@ -572,7 +599,9 @@ async def test_ollama_connection(base_url: str = "http://localhost:11434") -> tu
url = url[:-3]
async with aiohttp.ClientSession() as session:
async with session.get(f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)) as response:
async with session.get(
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
return True, f"Ollama is running at {url}"
return False, f"Ollama returned status {response.status}"
@@ -588,6 +617,7 @@ async def test_ollama_connection(base_url: str = "http://localhost:11434") -> tu
# Re-exports and Convenience Functions
# ============================================================================
def is_graphiti_enabled() -> bool:
"""
Check if Graphiti memory integration is available and configured.
@@ -596,6 +626,7 @@ def is_graphiti_enabled() -> bool:
Returns True if GRAPHITI_ENABLED=true and provider credentials are valid.
"""
from graphiti_config import is_graphiti_enabled as _is_graphiti_enabled
return _is_graphiti_enabled()
@@ -634,6 +665,7 @@ async def get_graph_hints(
try:
from pathlib import Path
from graphiti_memory import GraphitiMemory, GroupIdMode
# Determine project directory from project_id or use current dir
@@ -643,6 +675,7 @@ async def get_graph_hints(
if spec_dir is None:
# Create a temporary spec dir for the query
import tempfile
spec_dir = Path(tempfile.mkdtemp(prefix="graphiti_query_"))
# Create memory instance with project-level scope for cross-spec hints
+4 -4
View File
@@ -10,12 +10,12 @@ This module provides components for generating and managing project ideas:
- Types: Type definitions and dataclasses
"""
from .types import IdeationPhaseResult, IdeationConfig
from .runner import IdeationOrchestrator
from .generator import IdeationGenerator
from .analyzer import ProjectAnalyzer
from .prioritizer import IdeaPrioritizer
from .formatter import IdeationFormatter
from .generator import IdeationGenerator
from .prioritizer import IdeaPrioritizer
from .runner import IdeationOrchestrator
from .types import IdeationConfig, IdeationPhaseResult
__all__ = [
"IdeationOrchestrator",
+19 -10
View File
@@ -10,18 +10,17 @@ Gathers project context including:
"""
import json
from pathlib import Path
from typing import Dict, List, Optional
import sys
from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from graphiti_providers import get_graph_hints, is_graphiti_enabled
from debug import (
debug_success,
debug_warning,
)
from graphiti_providers import get_graph_hints, is_graphiti_enabled
class ProjectAnalyzer:
@@ -39,7 +38,7 @@ class ProjectAnalyzer:
self.include_roadmap = include_roadmap
self.include_kanban = include_kanban
def gather_context(self) -> Dict:
def gather_context(self) -> dict:
"""Gather context from project for ideation."""
context = {
"existing_features": [],
@@ -66,7 +65,9 @@ class ProjectAnalyzer:
# Get roadmap context if enabled
if self.include_roadmap:
roadmap_path = self.project_dir / ".auto-claude" / "roadmap" / "roadmap.json"
roadmap_path = (
self.project_dir / ".auto-claude" / "roadmap" / "roadmap.json"
)
if roadmap_path.exists():
try:
with open(roadmap_path) as f:
@@ -81,7 +82,9 @@ class ProjectAnalyzer:
pass
# Also check discovery for audience
discovery_path = self.project_dir / ".auto-claude" / "roadmap" / "roadmap_discovery.json"
discovery_path = (
self.project_dir / ".auto-claude" / "roadmap" / "roadmap_discovery.json"
)
if discovery_path.exists() and not context["target_audience"]:
try:
with open(discovery_path) as f:
@@ -91,7 +94,9 @@ class ProjectAnalyzer:
# Also get existing features
current_state = discovery.get("current_state", {})
context["existing_features"] = current_state.get("existing_features", [])
context["existing_features"] = current_state.get(
"existing_features", []
)
except (json.JSONDecodeError, KeyError):
pass
@@ -116,7 +121,7 @@ class ProjectAnalyzer:
return context
async def get_graph_hints(self, ideation_type: str) -> List[Dict]:
async def get_graph_hints(self, ideation_type: str) -> list[dict]:
"""Get graph hints for a specific ideation type from Graphiti.
This runs in parallel with ideation agents to provide historical context.
@@ -142,8 +147,12 @@ class ProjectAnalyzer:
project_id=str(self.project_dir),
max_results=5,
)
debug_success("ideation_analyzer", f"Got {len(hints)} graph hints for {ideation_type}")
debug_success(
"ideation_analyzer", f"Got {len(hints)} graph hints for {ideation_type}"
)
return hints
except Exception as e:
debug_warning("ideation_analyzer", f"Graph hints failed for {ideation_type}: {e}")
debug_warning(
"ideation_analyzer", f"Graph hints failed for {ideation_type}: {e}"
)
return []
+29 -14
View File
@@ -5,10 +5,9 @@ Formats and merges ideation outputs into a cohesive ideation.json file.
"""
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional
import sys
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -25,8 +24,8 @@ class IdeationFormatter:
def merge_ideation_outputs(
self,
enabled_types: List[str],
context_data: Dict,
enabled_types: list[str],
context_data: dict,
append: bool = False,
) -> tuple[Path, int]:
"""Merge all ideation outputs into a single ideation.json.
@@ -43,7 +42,9 @@ class IdeationFormatter:
with open(ideation_file) as f:
existing_session = json.load(f)
existing_ideas = existing_session.get("ideas", [])
print_status(f"Preserving {len(existing_ideas)} existing ideas", "info")
print_status(
f"Preserving {len(existing_ideas)} existing ideas", "info"
)
except json.JSONDecodeError:
pass
@@ -68,18 +69,28 @@ class IdeationFormatter:
if append and existing_ideas:
# Keep existing ideas that are NOT from the types we just generated
preserved_ideas = [
idea for idea in existing_ideas
if idea.get("type") not in enabled_types
idea for idea in existing_ideas if idea.get("type") not in enabled_types
]
all_ideas = preserved_ideas + new_ideas
print_status(f"Merged: {len(preserved_ideas)} preserved + {len(new_ideas)} new = {len(all_ideas)} total", "info")
print_status(
f"Merged: {len(preserved_ideas)} preserved + {len(new_ideas)} new = {len(all_ideas)} total",
"info",
)
else:
all_ideas = new_ideas
# Create merged ideation session
# Preserve session ID and generated_at if appending
session_id = existing_session.get("id") if existing_session else f"ideation-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
generated_at = existing_session.get("generated_at") if existing_session else datetime.now().isoformat()
session_id = (
existing_session.get("id")
if existing_session
else f"ideation-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
)
generated_at = (
existing_session.get("generated_at")
if existing_session
else datetime.now().isoformat()
)
ideation_session = {
"id": session_id,
@@ -105,20 +116,24 @@ class IdeationFormatter:
for idea in all_ideas:
idea_type = idea.get("type", "unknown")
idea_status = idea.get("status", "draft")
ideation_session["summary"]["by_type"][idea_type] = \
ideation_session["summary"]["by_type"][idea_type] = (
ideation_session["summary"]["by_type"].get(idea_type, 0) + 1
ideation_session["summary"]["by_status"][idea_status] = \
)
ideation_session["summary"]["by_status"][idea_status] = (
ideation_session["summary"]["by_status"].get(idea_status, 0) + 1
)
with open(ideation_file, "w") as f:
json.dump(ideation_session, f, indent=2)
action = "Updated" if append else "Created"
print_status(f"{action} ideation.json ({len(all_ideas)} total ideas)", "success")
print_status(
f"{action} ideation.json ({len(all_ideas)} total ideas)", "success"
)
return ideation_file, len(all_ideas)
def load_context(self) -> Dict:
def load_context(self) -> dict:
"""Load context data from ideation_context.json."""
context_file = self.output_dir / "ideation_context.json"
context_data = {}
+8 -7
View File
@@ -10,10 +10,8 @@ Uses Claude agents to generate ideas of different types:
- Code quality
"""
import asyncio
from pathlib import Path
from typing import Optional, Dict
import sys
from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -21,7 +19,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from client import create_client
from ui import print_status
# Ideation types
IDEATION_TYPES = [
"code_improvements",
@@ -106,7 +103,9 @@ class IdeationGenerator:
if block_type == "TextBlock" and hasattr(block, "text"):
response_text += block.text
print(block.text, end="", flush=True)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
elif block_type == "ToolUseBlock" and hasattr(
block, "name"
):
print(f"\n[Tool: {block.name}]", flush=True)
print()
@@ -190,7 +189,9 @@ Write the fixed JSON to the file now.
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
print(block.text, end="", flush=True)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
elif block_type == "ToolUseBlock" and hasattr(
block, "name"
):
print(f"\n[Recovery Tool: {block.name}]", flush=True)
print()
@@ -200,7 +201,7 @@ Write the fixed JSON to the file now.
print_status(f"Recovery agent error: {e}", "error")
return False
def get_prompt_file(self, ideation_type: str) -> Optional[str]:
def get_prompt_file(self, ideation_type: str) -> str | None:
"""Get the prompt file for a specific ideation type."""
return IDEATION_TYPE_PROMPTS.get(ideation_type)
+37 -18
View File
@@ -5,19 +5,18 @@ Validates ideation output files and ensures they meet quality standards.
"""
import json
from pathlib import Path
from typing import Dict
import sys
from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from debug import (
debug_detailed,
debug_warning,
debug_verbose,
debug_success,
debug_error,
debug_success,
debug_verbose,
debug_warning,
)
@@ -27,14 +26,20 @@ class IdeaPrioritizer:
def __init__(self, output_dir: Path):
self.output_dir = Path(output_dir)
def validate_ideation_output(self, output_file: Path, ideation_type: str) -> Dict:
def validate_ideation_output(self, output_file: Path, ideation_type: str) -> dict:
"""Validate ideation output file and return validation result."""
debug_detailed("ideation_prioritizer", f"Validating output for {ideation_type}",
output_file=str(output_file))
debug_detailed(
"ideation_prioritizer",
f"Validating output for {ideation_type}",
output_file=str(output_file),
)
if not output_file.exists():
debug_warning("ideation_prioritizer", "Output file does not exist",
output_file=str(output_file))
debug_warning(
"ideation_prioritizer",
"Output file does not exist",
output_file=str(output_file),
)
return {
"success": False,
"error": "Output file does not exist",
@@ -45,16 +50,23 @@ class IdeaPrioritizer:
try:
content = output_file.read_text()
data = json.loads(content)
debug_verbose("ideation_prioritizer", "Parsed JSON successfully",
keys=list(data.keys()))
debug_verbose(
"ideation_prioritizer",
"Parsed JSON successfully",
keys=list(data.keys()),
)
# Check for correct key
ideas = data.get(ideation_type, [])
# Also check for common incorrect key "ideas"
if not ideas and "ideas" in data:
debug_warning("ideation_prioritizer", "Wrong JSON key detected",
expected=ideation_type, found="ideas")
debug_warning(
"ideation_prioritizer",
"Wrong JSON key detected",
expected=ideation_type,
found="ideas",
)
return {
"success": False,
"error": f"Wrong JSON key: found 'ideas' but expected '{ideation_type}'",
@@ -63,8 +75,11 @@ class IdeaPrioritizer:
}
if len(ideas) >= 1:
debug_success("ideation_prioritizer", f"Validation passed for {ideation_type}",
ideas_count=len(ideas))
debug_success(
"ideation_prioritizer",
f"Validation passed for {ideation_type}",
ideas_count=len(ideas),
)
return {
"success": True,
"error": None,
@@ -72,7 +87,9 @@ class IdeaPrioritizer:
"count": len(ideas),
}
else:
debug_warning("ideation_prioritizer", f"No ideas found for {ideation_type}")
debug_warning(
"ideation_prioritizer", f"No ideas found for {ideation_type}"
)
return {
"success": False,
"error": f"No {ideation_type} ideas found in output",
@@ -85,6 +102,8 @@ class IdeaPrioritizer:
return {
"success": False,
"error": f"Invalid JSON: {e}",
"current_content": output_file.read_text() if output_file.exists() else "",
"current_content": output_file.read_text()
if output_file.exists()
else "",
"count": 0,
}
+163 -92
View File
@@ -14,36 +14,33 @@ import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, List
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from ui import (
Icons,
icon,
box,
muted,
print_status,
print_key_value,
print_section,
)
from debug import (
debug,
debug_section,
debug_success,
debug_warning,
)
from graphiti_providers import is_graphiti_enabled
from init import init_auto_claude_dir
from ui import (
Icons,
box,
icon,
muted,
print_key_value,
print_section,
print_status,
)
from .analyzer import ProjectAnalyzer
from .formatter import IdeationFormatter
from .generator import IDEATION_TYPE_LABELS, IDEATION_TYPES, IdeationGenerator
from .prioritizer import IdeaPrioritizer
# Import ideation components
from .types import IdeationPhaseResult, IdeationConfig
from .generator import IdeationGenerator, IDEATION_TYPES, IDEATION_TYPE_LABELS
from .analyzer import ProjectAnalyzer
from .prioritizer import IdeaPrioritizer
from .formatter import IdeationFormatter
from .types import IdeationPhaseResult
# Configuration
MAX_RETRIES = 3
@@ -55,8 +52,8 @@ class IdeationOrchestrator:
def __init__(
self,
project_dir: Path,
output_dir: Optional[Path] = None,
enabled_types: Optional[List[str]] = None,
output_dir: Path | None = None,
enabled_types: list[str] | None = None,
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
@@ -154,12 +151,16 @@ class IdeationOrchestrator:
if not is_graphiti_enabled():
print_status("Graphiti not enabled, skipping graph hints", "info")
with open(hints_file, "w") as f:
json.dump({
"enabled": False,
"reason": "Graphiti not configured",
"hints_by_type": {},
"created_at": datetime.now().isoformat(),
}, f, indent=2)
json.dump(
{
"enabled": False,
"reason": "Graphiti not configured",
"hints_by_type": {},
"created_at": datetime.now().isoformat(),
},
f,
indent=2,
)
return IdeationPhaseResult(
phase="graph_hints",
ideation_type=None,
@@ -196,15 +197,22 @@ class IdeationOrchestrator:
# Save hints
with open(hints_file, "w") as f:
json.dump({
"enabled": True,
"hints_by_type": hints_by_type,
"total_hints": total_hints,
"created_at": datetime.now().isoformat(),
}, f, indent=2)
json.dump(
{
"enabled": True,
"hints_by_type": hints_by_type,
"total_hints": total_hints,
"created_at": datetime.now().isoformat(),
},
f,
indent=2,
)
if total_hints > 0:
print_status(f"Retrieved {total_hints} graph hints across {len(self.enabled_types)} types", "success")
print_status(
f"Retrieved {total_hints} graph hints across {len(self.enabled_types)} types",
"success",
)
else:
print_status("No relevant graph hints found", "info")
@@ -235,7 +243,7 @@ class IdeationOrchestrator:
with open(hints_file) as f:
hints_data = json.load(f)
graph_hints = hints_data.get("hints_by_type", {})
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
pass
# Write context file
@@ -257,10 +265,12 @@ class IdeationOrchestrator:
with open(context_file, "w") as f:
json.dump(context_data, f, indent=2)
print_status(f"Created ideation_context.json", "success")
print_status("Created ideation_context.json", "success")
print_key_value("Tech Stack", ", ".join(context["tech_stack"][:5]) or "Unknown")
print_key_value("Planned Features", str(len(context["planned_features"])))
print_key_value("Target Audience", context["target_audience"] or "Not specified")
print_key_value(
"Target Audience", context["target_audience"] or "Not specified"
)
if graph_hints:
total_hints = sum(len(h) for h in graph_hints.values())
print_key_value("Graph Hints", str(total_hints))
@@ -284,24 +294,30 @@ class IdeationOrchestrator:
# Check if we can copy existing index
if auto_build_index.exists():
import shutil
shutil.copy(auto_build_index, project_index)
print_status("Copied existing project_index.json", "success")
return IdeationPhaseResult("project_index", None, True, [str(project_index)], 0, [], 0)
return IdeationPhaseResult(
"project_index", None, True, [str(project_index)], 0, [], 0
)
if project_index.exists() and not self.refresh:
print_status("project_index.json already exists", "success")
return IdeationPhaseResult("project_index", None, True, [str(project_index)], 0, [], 0)
return IdeationPhaseResult(
"project_index", None, True, [str(project_index)], 0, [], 0
)
# Run analyzer
print_status("Running project analyzer...", "progress")
success, output = self._run_script(
"analyzer.py",
["--output", str(project_index)]
"analyzer.py", ["--output", str(project_index)]
)
if success and project_index.exists():
print_status("Created project_index.json", "success")
return IdeationPhaseResult("project_index", None, True, [str(project_index)], 0, [], 0)
return IdeationPhaseResult(
"project_index", None, True, [str(project_index)], 0, [], 0
)
return IdeationPhaseResult("project_index", None, False, [], 0, [output], 1)
@@ -331,7 +347,10 @@ class IdeationOrchestrator:
if count >= 1:
# Valid ideas exist, skip regeneration
print_status(f"{ideation_type}_ideas.json already exists ({count} ideas)", "success")
print_status(
f"{ideation_type}_ideas.json already exists ({count} ideas)",
"success",
)
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
@@ -343,15 +362,24 @@ class IdeationOrchestrator:
)
else:
# File exists but has no valid ideas - needs regeneration
print_status(f"{ideation_type}_ideas.json exists but has 0 ideas, regenerating...", "warning")
print_status(
f"{ideation_type}_ideas.json exists but has 0 ideas, regenerating...",
"warning",
)
except (json.JSONDecodeError, KeyError):
# Invalid file - will regenerate
print_status(f"{ideation_type}_ideas.json exists but is invalid, regenerating...", "warning")
print_status(
f"{ideation_type}_ideas.json exists but is invalid, regenerating...",
"warning",
)
errors = []
# First attempt: run the full ideation agent
print_status(f"Running {self.generator.get_type_label(ideation_type)} agent...", "progress")
print_status(
f"Running {self.generator.get_type_label(ideation_type)} agent...",
"progress",
)
context = f"""
**Ideation Context**: {self.output_dir / "ideation_context.json"}
@@ -369,10 +397,15 @@ Output your ideas to {output_file.name}.
)
# Validate the output
validation_result = self.prioritizer.validate_ideation_output(output_file, ideation_type)
validation_result = self.prioritizer.validate_ideation_output(
output_file, ideation_type
)
if validation_result["success"]:
print_status(f"Created {output_file.name} ({validation_result['count']} ideas)", "success")
print_status(
f"Created {output_file.name} ({validation_result['count']} ideas)",
"success",
)
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
@@ -387,21 +420,28 @@ Output your ideas to {output_file.name}.
# Recovery attempts: show the current state and ask AI to fix it
for recovery_attempt in range(MAX_RETRIES - 1):
print_status(f"Running recovery agent (attempt {recovery_attempt + 1})...", "warning")
print_status(
f"Running recovery agent (attempt {recovery_attempt + 1})...", "warning"
)
recovery_success = await self.generator.run_recovery_agent(
output_file,
ideation_type,
validation_result["error"],
validation_result.get("current_content", "")
validation_result.get("current_content", ""),
)
if recovery_success:
# Re-validate after recovery
validation_result = self.prioritizer.validate_ideation_output(output_file, ideation_type)
validation_result = self.prioritizer.validate_ideation_output(
output_file, ideation_type
)
if validation_result["success"]:
print_status(f"Recovery successful: {output_file.name} ({validation_result['count']} ideas)", "success")
print_status(
f"Recovery successful: {output_file.name} ({validation_result['count']} ideas)",
"success",
)
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
@@ -412,7 +452,9 @@ Output your ideas to {output_file.name}.
retries=recovery_attempt + 1,
)
else:
errors.append(f"Recovery {recovery_attempt + 1}: {validation_result['error']}")
errors.append(
f"Recovery {recovery_attempt + 1}: {validation_result['error']}"
)
else:
errors.append(f"Recovery {recovery_attempt + 1}: Agent failed to run")
@@ -449,7 +491,9 @@ Output your ideas to {output_file.name}.
retries=0,
)
async def _run_ideation_type_with_streaming(self, ideation_type: str) -> IdeationPhaseResult:
async def _run_ideation_type_with_streaming(
self, ideation_type: str
) -> IdeationPhaseResult:
"""Run a single ideation type and stream results when complete."""
result = await self.phase_ideation_type(ideation_type)
@@ -467,22 +511,27 @@ Output your ideas to {output_file.name}.
"""Run the complete ideation generation process."""
debug_section("ideation_runner", "Starting Ideation Generation")
debug("ideation_runner", "Configuration",
project_dir=str(self.project_dir),
output_dir=str(self.output_dir),
model=self.model,
enabled_types=self.enabled_types,
refresh=self.refresh,
append=self.append)
debug(
"ideation_runner",
"Configuration",
project_dir=str(self.project_dir),
output_dir=str(self.output_dir),
model=self.model,
enabled_types=self.enabled_types,
refresh=self.refresh,
append=self.append,
)
print(box(
f"Project: {self.project_dir}\n"
f"Output: {self.output_dir}\n"
f"Model: {self.model}\n"
f"Types: {', '.join(self.enabled_types)}",
title="IDEATION GENERATOR",
style="heavy"
))
print(
box(
f"Project: {self.project_dir}\n"
f"Output: {self.output_dir}\n"
f"Model: {self.model}\n"
f"Types: {', '.join(self.enabled_types)}",
title="IDEATION GENERATOR",
style="heavy",
)
)
results = []
@@ -512,10 +561,17 @@ Output your ideas to {output_file.name}.
# Note: hints_result.success is always True (graceful degradation)
# Phase 3: Run all ideation types IN PARALLEL
debug("ideation_runner", "Starting Phase 3: Generating Ideas",
types=self.enabled_types, parallel=True)
debug(
"ideation_runner",
"Starting Phase 3: Generating Ideas",
types=self.enabled_types,
parallel=True,
)
print_section("PHASE 3: GENERATING IDEAS (PARALLEL)", Icons.SUBTASK)
print_status(f"Starting {len(self.enabled_types)} ideation agents in parallel...", "progress")
print_status(
f"Starting {len(self.enabled_types)} ideation agents in parallel...",
"progress",
)
# Create tasks for all enabled types
ideation_tasks = [
@@ -530,22 +586,33 @@ Output your ideas to {output_file.name}.
for i, result in enumerate(ideation_results):
ideation_type = self.enabled_types[i]
if isinstance(result, Exception):
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed with exception: {result}", "error")
results.append(IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=False,
output_files=[],
ideas_count=0,
errors=[str(result)],
retries=0,
))
print_status(
f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed with exception: {result}",
"error",
)
results.append(
IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=False,
output_files=[],
ideas_count=0,
errors=[str(result)],
retries=0,
)
)
else:
results.append(result)
if result.success:
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]}: {result.ideas_count} ideas", "success")
print_status(
f"{IDEATION_TYPE_LABELS[ideation_type]}: {result.ideas_count} ideas",
"success",
)
else:
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed", "warning")
print_status(
f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed",
"warning",
)
for err in result.errors:
print(f" {muted('Error:')} {err}")
@@ -564,14 +631,18 @@ Output your ideas to {output_file.name}.
summary = ideation.get("summary", {})
by_type = summary.get("by_type", {})
print(box(
f"Total Ideas: {len(ideas)}\n\n"
f"By Type:\n" +
"\n".join(f" {icon(Icons.ARROW_RIGHT)} {IDEATION_TYPE_LABELS.get(t, t)}: {c}"
for t, c in by_type.items()) +
f"\n\nIdeation saved to: {ideation_file}",
title=f"{icon(Icons.SUCCESS)} IDEATION COMPLETE",
style="heavy"
))
print(
box(
f"Total Ideas: {len(ideas)}\n\n"
f"By Type:\n"
+ "\n".join(
f" {icon(Icons.ARROW_RIGHT)} {IDEATION_TYPE_LABELS.get(t, t)}: {c}"
for t, c in by_type.items()
)
+ f"\n\nIdeation saved to: {ideation_file}",
title=f"{icon(Icons.SUCCESS)} IDEATION COMPLETE",
style="heavy",
)
)
return True
+5 -4
View File
@@ -4,16 +4,16 @@ Type definitions for the ideation module.
Contains dataclasses and type definitions used throughout ideation components.
"""
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, List
@dataclass
class IdeationPhaseResult:
"""Result of an ideation phase execution."""
phase: str
ideation_type: Optional[str]
ideation_type: str | None
success: bool
output_files: list[str]
ideas_count: int
@@ -24,9 +24,10 @@ class IdeationPhaseResult:
@dataclass
class IdeationConfig:
"""Configuration for ideation generation."""
project_dir: Path
output_dir: Path
enabled_types: List[str]
enabled_types: list[str]
include_roadmap_context: bool = True
include_kanban_context: bool = True
max_ideas_per_type: int = 5
+3 -2
View File
@@ -28,17 +28,18 @@ sys.path.insert(0, str(Path(__file__).parent))
# Load .env file
from dotenv import load_dotenv
env_file = Path(__file__).parent / ".env"
if env_file.exists():
load_dotenv(env_file)
# Import from refactored modules
from ideation import (
IdeationOrchestrator,
IdeationConfig,
IdeationOrchestrator,
IdeationPhaseResult,
)
from ideation.generator import IDEATION_TYPES, IDEATION_TYPE_LABELS
from ideation.generator import IDEATION_TYPE_LABELS, IDEATION_TYPES
# Re-export for backward compatibility
__all__ = [
+106 -65
View File
@@ -18,66 +18,77 @@ Workflow Types:
"""
import json
from dataclasses import dataclass, field, asdict
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Optional
class WorkflowType(str, Enum):
"""Types of workflows with different phase structures."""
FEATURE = "feature" # Multi-service feature (phases = services)
REFACTOR = "refactor" # Stage-based (add new, migrate, remove old)
FEATURE = "feature" # Multi-service feature (phases = services)
REFACTOR = "refactor" # Stage-based (add new, migrate, remove old)
INVESTIGATION = "investigation" # Bug hunting (investigate, hypothesize, fix)
MIGRATION = "migration" # Data migration (prepare, test, execute, cleanup)
SIMPLE = "simple" # Single-service, minimal overhead
DEVELOPMENT = "development" # General development work
ENHANCEMENT = "enhancement" # Improving existing features
MIGRATION = "migration" # Data migration (prepare, test, execute, cleanup)
SIMPLE = "simple" # Single-service, minimal overhead
DEVELOPMENT = "development" # General development work
ENHANCEMENT = "enhancement" # Improving existing features
class PhaseType(str, Enum):
"""Types of phases within a workflow."""
SETUP = "setup" # Project scaffolding, environment setup
SETUP = "setup" # Project scaffolding, environment setup
IMPLEMENTATION = "implementation" # Writing code
INVESTIGATION = "investigation" # Research, debugging, analysis
INTEGRATION = "integration" # Wiring services together
CLEANUP = "cleanup" # Removing old code, polish
INVESTIGATION = "investigation" # Research, debugging, analysis
INTEGRATION = "integration" # Wiring services together
CLEANUP = "cleanup" # Removing old code, polish
class SubtaskStatus(str, Enum):
"""Status of a subtask."""
PENDING = "pending" # Not started
IN_PROGRESS = "in_progress" # Currently being worked on
COMPLETED = "completed" # Completed successfully (matches JSON format)
BLOCKED = "blocked" # Can't start (dependency not met or undefined)
FAILED = "failed" # Attempted but failed
PENDING = "pending" # Not started
IN_PROGRESS = "in_progress" # Currently being worked on
COMPLETED = "completed" # Completed successfully (matches JSON format)
BLOCKED = "blocked" # Can't start (dependency not met or undefined)
FAILED = "failed" # Attempted but failed
class VerificationType(str, Enum):
"""How to verify a subtask is complete."""
COMMAND = "command" # Run a shell command
API = "api" # Make an API request
BROWSER = "browser" # Browser automation check
COMPONENT = "component" # Component renders correctly
MANUAL = "manual" # Requires human verification
NONE = "none" # No verification needed (investigation)
COMMAND = "command" # Run a shell command
API = "api" # Make an API request
BROWSER = "browser" # Browser automation check
COMPONENT = "component" # Component renders correctly
MANUAL = "manual" # Requires human verification
NONE = "none" # No verification needed (investigation)
@dataclass
class Verification:
"""How to verify a subtask is complete."""
type: VerificationType
run: Optional[str] = None # Command to run
url: Optional[str] = None # URL for API/browser tests
method: Optional[str] = None # HTTP method for API tests
expect_status: Optional[int] = None # Expected HTTP status
expect_contains: Optional[str] = None # Expected content
scenario: Optional[str] = None # Description for browser/manual tests
run: str | None = None # Command to run
url: str | None = None # URL for API/browser tests
method: str | None = None # HTTP method for API tests
expect_status: int | None = None # Expected HTTP status
expect_contains: str | None = None # Expected content
scenario: str | None = None # Description for browser/manual tests
def to_dict(self) -> dict:
result = {"type": self.type.value}
for key in ["run", "url", "method", "expect_status", "expect_contains", "scenario"]:
for key in [
"run",
"url",
"method",
"expect_status",
"expect_contains",
"scenario",
]:
val = getattr(self, key)
if val is not None:
result[key] = val
@@ -99,13 +110,14 @@ class Verification:
@dataclass
class Subtask:
"""A single unit of implementation work."""
id: str
description: str
status: SubtaskStatus = SubtaskStatus.PENDING
# Scoping
service: Optional[str] = None # Which service (backend, frontend, worker)
all_services: bool = False # True for integration subtasks
service: str | None = None # Which service (backend, frontend, worker)
all_services: bool = False # True for integration subtasks
# Files
files_to_modify: list[str] = field(default_factory=list)
@@ -113,19 +125,19 @@ class Subtask:
patterns_from: list[str] = field(default_factory=list)
# Verification
verification: Optional[Verification] = None
verification: Verification | None = None
# For investigation subtasks
expected_output: Optional[str] = None # Knowledge/decision output
actual_output: Optional[str] = None # What was discovered
expected_output: str | None = None # Knowledge/decision output
actual_output: str | None = None # What was discovered
# Tracking
started_at: Optional[str] = None
completed_at: Optional[str] = None
session_id: Optional[int] = None # Which session completed this
started_at: str | None = None
completed_at: str | None = None
session_id: int | None = None # Which session completed this
# Self-Critique
critique_result: Optional[dict] = None # Results from self-critique before completion
critique_result: dict | None = None # Results from self-critique before completion
def to_dict(self) -> dict:
result = {
@@ -192,14 +204,14 @@ class Subtask:
self.completed_at = None
self.actual_output = None
def complete(self, output: Optional[str] = None):
def complete(self, output: str | None = None):
"""Mark subtask as done."""
self.status = SubtaskStatus.COMPLETED
self.completed_at = datetime.now().isoformat()
if output:
self.actual_output = output
def fail(self, reason: Optional[str] = None):
def fail(self, reason: str | None = None):
"""Mark subtask as failed."""
self.status = SubtaskStatus.FAILED
self.completed_at = None # Clear to maintain consistency (failed != completed)
@@ -210,6 +222,7 @@ class Subtask:
@dataclass
class Phase:
"""A group of subtasks with dependencies."""
phase: int
name: str
type: PhaseType = PhaseType.IMPLEMENTATION
@@ -279,6 +292,7 @@ class Phase:
@dataclass
class ImplementationPlan:
"""Complete implementation plan for a feature/task."""
feature: str
workflow_type: WorkflowType = WorkflowType.FEATURE
services_involved: list[str] = field(default_factory=list)
@@ -286,17 +300,17 @@ class ImplementationPlan:
final_acceptance: list[str] = field(default_factory=list)
# Metadata
created_at: Optional[str] = None
updated_at: Optional[str] = None
spec_file: Optional[str] = None
created_at: str | None = None
updated_at: str | None = None
spec_file: str | None = None
# Task status (synced with UI)
# status: backlog, in_progress, ai_review, human_review, done
# planStatus: pending, in_progress, review, completed
status: Optional[str] = None
planStatus: Optional[str] = None
recoveryNote: Optional[str] = None
qa_signoff: Optional[dict] = None
status: str | None = None
planStatus: str | None = None
recoveryNote: str | None = None
qa_signoff: dict | None = None
def to_dict(self) -> dict:
result = {
@@ -328,14 +342,19 @@ class ImplementationPlan:
workflow_type = WorkflowType(workflow_type_str)
except ValueError:
# Unknown workflow type - default to FEATURE
print(f"Warning: Unknown workflow_type '{workflow_type_str}', defaulting to 'feature'")
print(
f"Warning: Unknown workflow_type '{workflow_type_str}', defaulting to 'feature'"
)
workflow_type = WorkflowType.FEATURE
return cls(
feature=data["feature"],
workflow_type=workflow_type,
services_involved=data.get("services_involved", []),
phases=[Phase.from_dict(p, idx + 1) for idx, p in enumerate(data.get("phases", []))],
phases=[
Phase.from_dict(p, idx + 1)
for idx, p in enumerate(data.get("phases", []))
],
final_acceptance=data.get("final_acceptance", []),
created_at=data.get("created_at"),
updated_at=data.get("updated_at"),
@@ -379,9 +398,13 @@ class ImplementationPlan:
self.planStatus = "pending"
return
completed_count = sum(1 for s in all_subtasks if s.status == SubtaskStatus.COMPLETED)
completed_count = sum(
1 for s in all_subtasks if s.status == SubtaskStatus.COMPLETED
)
failed_count = sum(1 for s in all_subtasks if s.status == SubtaskStatus.FAILED)
in_progress_count = sum(1 for s in all_subtasks if s.status == SubtaskStatus.IN_PROGRESS)
in_progress_count = sum(
1 for s in all_subtasks if s.status == SubtaskStatus.IN_PROGRESS
)
total_count = len(all_subtasks)
# Determine status based on subtask states
@@ -433,7 +456,7 @@ class ImplementationPlan:
return available
def get_next_subtask(self) -> Optional[tuple[Phase, Subtask]]:
def get_next_subtask(self) -> tuple[Phase, Subtask] | None:
"""Get the next subtask to work on, respecting dependencies."""
for phase in self.get_available_phases():
pending = phase.get_pending_subtasks()
@@ -445,12 +468,14 @@ class ImplementationPlan:
"""Get overall progress statistics."""
total_subtasks = sum(len(p.subtasks) for p in self.phases)
done_subtasks = sum(
1 for p in self.phases
1
for p in self.phases
for s in p.subtasks
if s.status == SubtaskStatus.COMPLETED
)
failed_subtasks = sum(
1 for p in self.phases
1
for p in self.phases
for s in p.subtasks
if s.status == SubtaskStatus.FAILED
)
@@ -463,7 +488,9 @@ class ImplementationPlan:
"total_subtasks": total_subtasks,
"completed_subtasks": done_subtasks,
"failed_subtasks": failed_subtasks,
"percent_complete": round(100 * done_subtasks / total_subtasks, 1) if total_subtasks > 0 else 0,
"percent_complete": round(100 * done_subtasks / total_subtasks, 1)
if total_subtasks > 0
else 0,
"is_complete": done_subtasks == total_subtasks and failed_subtasks == 0,
}
@@ -477,16 +504,20 @@ class ImplementationPlan:
f"Phases: {progress['completed_phases']}/{progress['total_phases']} complete",
]
if progress['failed_subtasks'] > 0:
lines.append(f"Failed: {progress['failed_subtasks']} subtasks need attention")
if progress["failed_subtasks"] > 0:
lines.append(
f"Failed: {progress['failed_subtasks']} subtasks need attention"
)
if progress['is_complete']:
if progress["is_complete"]:
lines.append("Status: COMPLETE - Ready for final acceptance testing")
else:
next_work = self.get_next_subtask()
if next_work:
phase, subtask = next_work
lines.append(f"Next: Phase {phase.phase} ({phase.name}) - {subtask.description}")
lines.append(
f"Next: Phase {phase.phase} ({phase.name}) - {subtask.description}"
)
else:
lines.append("Status: BLOCKED - No available subtasks")
@@ -581,8 +612,8 @@ class ImplementationPlan:
# Check if plan is actually in a completed/reviewable state
is_completed = (
self.status in completed_statuses or
self.planStatus in completed_plan_statuses
self.status in completed_statuses
or self.planStatus in completed_plan_statuses
)
# Also check if all subtasks are actually completed
@@ -778,7 +809,10 @@ if __name__ == "__main__":
"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"},
"verification": {
"type": "command",
"run": "flask db upgrade",
},
},
{
"id": "avatar-endpoint",
@@ -786,7 +820,11 @@ if __name__ == "__main__":
"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"},
"verification": {
"type": "api",
"method": "POST",
"url": "/api/users/avatar",
},
},
],
},
@@ -825,7 +863,10 @@ if __name__ == "__main__":
"id": "e2e-wiring",
"all_services": True,
"description": "Connect frontend → backend → worker",
"verification": {"type": "browser", "scenario": "Upload → Process → Display"},
"verification": {
"type": "browser",
"scenario": "Upload → Process → Display",
},
},
],
},
+5 -1
View File
@@ -32,7 +32,11 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
for line in lines:
line_stripped = line.strip()
# Match both ".auto-claude" and ".auto-claude/"
if line_stripped == entry or line_stripped == entry_normalized or line_stripped == entry_normalized + "/":
if (
line_stripped == entry
or line_stripped == entry_normalized
or line_stripped == entry_normalized + "/"
):
return False # Already exists
# Entry doesn't exist, append it
+41 -29
View File
@@ -14,7 +14,7 @@ import logging
import os
import subprocess
from pathlib import Path
from typing import Any, Optional
from typing import Any
logger = logging.getLogger(__name__)
@@ -43,10 +43,11 @@ def get_extraction_model() -> str:
# Git Helpers
# =============================================================================
def get_session_diff(
project_dir: Path,
commit_before: Optional[str],
commit_after: Optional[str],
commit_before: str | None,
commit_after: str | None,
) -> str:
"""
Get the git diff between two commits.
@@ -77,7 +78,9 @@ def get_session_diff(
if len(diff) > MAX_DIFF_CHARS:
# Truncate and add note
diff = diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)"
diff = (
diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)"
)
return diff if diff else "(Empty diff)"
@@ -91,8 +94,8 @@ def get_session_diff(
def get_changed_files(
project_dir: Path,
commit_before: Optional[str],
commit_after: Optional[str],
commit_before: str | None,
commit_after: str | None,
) -> list[str]:
"""
Get list of files changed between two commits.
@@ -126,8 +129,8 @@ def get_changed_files(
def get_commit_messages(
project_dir: Path,
commit_before: Optional[str],
commit_after: Optional[str],
commit_before: str | None,
commit_after: str | None,
) -> str:
"""Get commit messages between two commits."""
if not commit_before or not commit_after or commit_before == commit_after:
@@ -152,13 +155,14 @@ def get_commit_messages(
# Input Gathering
# =============================================================================
def gather_extraction_inputs(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
commit_before: Optional[str],
commit_after: Optional[str],
commit_before: str | None,
commit_after: str | None,
success: bool,
recovery_manager: Any,
) -> dict:
@@ -249,6 +253,7 @@ def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]:
# LLM Extraction
# =============================================================================
def _build_extraction_prompt(inputs: dict) -> str:
"""Build the prompt for insight extraction."""
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
@@ -267,24 +272,24 @@ Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discove
## SESSION DATA
### Subtask
- **ID**: {inputs['subtask_id']}
- **Description**: {inputs['subtask_description']}
- **Session Number**: {inputs['session_num']}
- **Outcome**: {'SUCCESS' if inputs['success'] else 'FAILED'}
- **ID**: {inputs["subtask_id"]}
- **Description**: {inputs["subtask_description"]}
- **Session Number**: {inputs["session_num"]}
- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"}
### Files Changed
{chr(10).join(f'- {f}' for f in inputs['changed_files']) if inputs['changed_files'] else '(No files changed)'}
{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"}
### Commit Messages
{inputs['commit_messages']}
{inputs["commit_messages"]}
### Git Diff
```diff
{inputs['diff']}
{inputs["diff"]}
```
### Previous Attempts
{_format_attempt_history(inputs['attempt_history'])}
{_format_attempt_history(inputs["attempt_history"])}
---
@@ -311,7 +316,7 @@ def _format_attempt_history(attempts: list[dict]) -> str:
return "\n".join(lines)
async def run_insight_extraction(inputs: dict) -> Optional[dict]:
async def run_insight_extraction(inputs: dict) -> dict | None:
"""
Run the insight extraction using Anthropic API.
@@ -341,9 +346,7 @@ async def run_insight_extraction(inputs: dict) -> Optional[dict]:
message = client.messages.create(
model=model,
max_tokens=4096,
messages=[
{"role": "user", "content": prompt}
],
messages=[{"role": "user", "content": prompt}],
)
# Extract text content
@@ -360,7 +363,7 @@ async def run_insight_extraction(inputs: dict) -> Optional[dict]:
return None
def parse_insights(response_text: str) -> Optional[dict]:
def parse_insights(response_text: str) -> dict | None:
"""
Parse the LLM response into structured insights.
@@ -412,13 +415,14 @@ def parse_insights(response_text: str) -> Optional[dict]:
# Main Entry Point
# =============================================================================
async def extract_session_insights(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
commit_before: Optional[str],
commit_after: Optional[str],
commit_before: str | None,
commit_after: str | None,
success: bool,
recovery_manager: Any,
) -> dict:
@@ -519,10 +523,18 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Test insight extraction")
parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory")
parser.add_argument("--project-dir", type=Path, required=True, help="Project directory")
parser.add_argument("--commit-before", type=str, required=True, help="Commit before session")
parser.add_argument("--commit-after", type=str, required=True, help="Commit after session")
parser.add_argument("--subtask-id", type=str, default="test-subtask", help="Subtask ID")
parser.add_argument(
"--project-dir", type=Path, required=True, help="Project directory"
)
parser.add_argument(
"--commit-before", type=str, required=True, help="Commit before session"
)
parser.add_argument(
"--commit-after", type=str, required=True, help="Commit after session"
)
parser.add_argument(
"--subtask-id", type=str, default="test-subtask", help="Subtask ID"
)
args = parser.parse_args()
+70 -33
View File
@@ -5,19 +5,20 @@ Insights Runner - AI chat for codebase insights using Claude SDK
This script provides an AI-powered chat interface for asking questions
about a codebase. It can also suggest tasks based on the conversation.
"""
import argparse
import asyncio
import json
import os
import sys
import json
import argparse
from pathlib import Path
from typing import Optional
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent))
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
SDK_AVAILABLE = True
except ImportError:
SDK_AVAILABLE = False
@@ -27,9 +28,9 @@ except ImportError:
from debug import (
debug,
debug_detailed,
debug_success,
debug_error,
debug_section,
debug_success,
)
@@ -50,8 +51,10 @@ def load_project_context(project_dir: str) -> str:
"services": list(index.get("services", {}).keys()),
"infrastructure": index.get("infrastructure", {}),
}
context_parts.append(f"## Project Structure\n```json\n{json.dumps(summary, indent=2)}\n```")
except Exception as e:
context_parts.append(
f"## Project Structure\n```json\n{json.dumps(summary, indent=2)}\n```"
)
except Exception:
pass
# Load roadmap if available
@@ -62,9 +65,14 @@ def load_project_context(project_dir: str) -> str:
roadmap = json.load(f)
# Summarize roadmap
features = roadmap.get("features", [])
feature_summary = [{"title": f.get("title", ""), "status": f.get("status", "")} for f in features[:10]]
context_parts.append(f"## Roadmap Features\n```json\n{json.dumps(feature_summary, indent=2)}\n```")
except Exception as e:
feature_summary = [
{"title": f.get("title", ""), "status": f.get("status", "")}
for f in features[:10]
]
context_parts.append(
f"## Roadmap Features\n```json\n{json.dumps(feature_summary, indent=2)}\n```"
)
except Exception:
pass
# Load existing tasks
@@ -74,11 +82,17 @@ def load_project_context(project_dir: str) -> str:
task_dirs = [d for d in tasks_path.iterdir() if d.is_dir()]
task_names = [d.name for d in task_dirs[:10]]
if task_names:
context_parts.append(f"## Existing Tasks/Specs\n- " + "\n- ".join(task_names))
except Exception as e:
context_parts.append(
"## Existing Tasks/Specs\n- " + "\n- ".join(task_names)
)
except Exception:
pass
return "\n\n".join(context_parts) if context_parts else "No project context available yet."
return (
"\n\n".join(context_parts)
if context_parts
else "No project context available yet."
)
def build_system_prompt(project_dir: str) -> str:
@@ -116,7 +130,10 @@ async def run_with_sdk(project_dir: str, message: str, history: list) -> None:
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
if not oauth_token:
print("CLAUDE_CODE_OAUTH_TOKEN not set, falling back to simple mode", file=sys.stderr)
print(
"CLAUDE_CODE_OAUTH_TOKEN not set, falling back to simple mode",
file=sys.stderr,
)
run_simple(project_dir, message, history)
return
@@ -164,15 +181,19 @@ Current question: {message}"""
async for msg in client.receive_response():
msg_type = type(msg).__name__
debug_detailed("insights_runner", f"Received message", msg_type=msg_type)
debug_detailed("insights_runner", "Received message", msg_type=msg_type)
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
debug_detailed("insights_runner", f"Processing block", block_type=block_type)
debug_detailed(
"insights_runner", "Processing block", block_type=block_type
)
if block_type == "TextBlock" and hasattr(block, "text"):
text = block.text
debug_detailed("insights_runner", f"Text block", text_length=len(text))
debug_detailed(
"insights_runner", "Text block", text_length=len(text)
)
# Print text with newline to ensure proper line separation for parsing
print(text, flush=True)
response_text += text
@@ -197,23 +218,34 @@ Current question: {message}"""
tool_input = inp["path"]
current_tool = tool_name
print(f"__TOOL_START__:{json.dumps({'name': tool_name, 'input': tool_input})}", flush=True)
print(
f"__TOOL_START__:{json.dumps({'name': tool_name, 'input': tool_input})}",
flush=True,
)
elif msg_type == "ToolResult":
# Tool finished executing
if current_tool:
print(f"__TOOL_END__:{json.dumps({'name': current_tool})}", flush=True)
print(
f"__TOOL_END__:{json.dumps({'name': current_tool})}",
flush=True,
)
current_tool = None
# Ensure we have a newline at the end
if response_text and not response_text.endswith('\n'):
if response_text and not response_text.endswith("\n"):
print()
debug("insights_runner", "Response complete", response_length=len(response_text))
debug(
"insights_runner",
"Response complete",
response_length=len(response_text),
)
except Exception as e:
print(f"Error using Claude SDK: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
run_simple(project_dir, message, history)
@@ -242,25 +274,27 @@ Assistant:"""
try:
# Try to use claude CLI with --print for simple output
result = subprocess.run(
['claude', '--print', '-p', full_prompt],
["claude", "--print", "-p", full_prompt],
capture_output=True,
text=True,
cwd=project_dir,
timeout=120
timeout=120,
)
if result.returncode == 0:
print(result.stdout)
else:
# Fallback response if claude CLI fails
print(f"I apologize, but I encountered an issue processing your request. "
f"Please ensure Claude CLI is properly configured.\n\n"
f"Your question was: {message}\n\n"
f"Based on the project context available, I can help you with:\n"
f"- Understanding the codebase structure\n"
f"- Suggesting improvements\n"
f"- Planning new features\n\n"
f"Please try again or check your Claude CLI configuration.")
print(
f"I apologize, but I encountered an issue processing your request. "
f"Please ensure Claude CLI is properly configured.\n\n"
f"Your question was: {message}\n\n"
f"Based on the project context available, I can help you with:\n"
f"- Understanding the codebase structure\n"
f"- Suggesting improvements\n"
f"- Planning new features\n\n"
f"Please try again or check your Claude CLI configuration."
)
except subprocess.TimeoutExpired:
print("Request timed out. Please try a shorter query.")
@@ -282,9 +316,12 @@ def main():
project_dir = args.project_dir
user_message = args.message
debug("insights_runner", "Arguments",
project_dir=project_dir,
message_length=len(user_message))
debug(
"insights_runner",
"Arguments",
project_dir=project_dir,
message_length=len(user_message),
)
try:
history = json.loads(args.history)
+22 -21
View File
@@ -13,7 +13,6 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
# Linear Status Constants (map to Linear workflow states)
STATUS_TODO = "Todo"
STATUS_IN_PROGRESS = "In Progress"
@@ -22,11 +21,11 @@ STATUS_BLOCKED = "Blocked" # For stuck subtasks
STATUS_CANCELED = "Canceled"
# Linear Priority Constants (1=Urgent, 4=Low, 0=No priority)
PRIORITY_URGENT = 1 # Core infrastructure, blockers
PRIORITY_HIGH = 2 # Primary features, dependencies
PRIORITY_MEDIUM = 3 # Secondary features
PRIORITY_LOW = 4 # Polish, nice-to-haves
PRIORITY_NONE = 0 # No priority set
PRIORITY_URGENT = 1 # Core infrastructure, blockers
PRIORITY_HIGH = 2 # Primary features, dependencies
PRIORITY_MEDIUM = 3 # Secondary features
PRIORITY_LOW = 4 # Polish, nice-to-haves
PRIORITY_NONE = 0 # No priority set
# Subtask status to Linear status mapping
SUBTASK_TO_LINEAR_STATUS = {
@@ -40,10 +39,10 @@ SUBTASK_TO_LINEAR_STATUS = {
# Linear labels for categorization
LABELS = {
"phase": "phase", # Phase label prefix (e.g., "phase-1")
"service": "service", # Service label prefix (e.g., "service-backend")
"stuck": "stuck", # Mark stuck subtasks
"auto_build": "auto-claude", # All auto-claude issues
"phase": "phase", # Phase label prefix (e.g., "phase-1")
"service": "service", # Service label prefix (e.g., "service-backend")
"stuck": "stuck", # Mark stuck subtasks
"auto_build": "auto-claude", # All auto-claude issues
"needs_review": "needs-review",
}
@@ -57,11 +56,12 @@ META_ISSUE_TITLE = "[META] Build Progress Tracker"
@dataclass
class LinearConfig:
"""Configuration for Linear integration."""
api_key: str
team_id: Optional[str] = None
project_id: Optional[str] = None
project_name: Optional[str] = None
meta_issue_id: Optional[str] = None
team_id: str | None = None
project_id: str | None = None
project_name: str | None = None
meta_issue_id: str | None = None
enabled: bool = True
@classmethod
@@ -84,13 +84,14 @@ class LinearConfig:
@dataclass
class LinearProjectState:
"""State of a Linear project for an auto-claude spec."""
initialized: bool = False
team_id: Optional[str] = None
project_id: Optional[str] = None
project_name: Optional[str] = None
meta_issue_id: Optional[str] = None
team_id: str | None = None
project_id: str | None = None
project_name: str | None = None
meta_issue_id: str | None = None
total_issues: int = 0
created_at: Optional[str] = None
created_at: str | None = None
issue_mapping: dict = None # subtask_id -> issue_id mapping
def __post_init__(self):
@@ -136,9 +137,9 @@ class LinearProjectState:
return None
try:
with open(marker_file, "r") as f:
with open(marker_file) as f:
return cls.from_dict(json.load(f))
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
+21 -29
View File
@@ -19,23 +19,17 @@ import json
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
from linear_config import (
LABELS,
STATUS_BLOCKED,
LinearConfig,
LinearProjectState,
LINEAR_PROJECT_MARKER,
META_ISSUE_TITLE,
LABELS,
get_linear_status,
get_priority_for_phase,
format_subtask_description,
format_session_comment,
format_stuck_subtask_comment,
STATUS_TODO,
STATUS_IN_PROGRESS,
STATUS_DONE,
STATUS_BLOCKED,
format_subtask_description,
get_linear_status,
get_priority_for_phase,
)
@@ -63,7 +57,7 @@ class LinearManager:
self.spec_dir = spec_dir
self.project_dir = project_dir
self.config = LinearConfig.from_env()
self.state: Optional[LinearProjectState] = None
self.state: LinearProjectState | None = None
self._mcp_available = False
# Load existing state if available
@@ -88,7 +82,7 @@ class LinearManager:
"""Check if Linear project has been initialized for this spec."""
return self.state is not None and self.state.initialized
def get_issue_id(self, subtask_id: str) -> Optional[str]:
def get_issue_id(self, subtask_id: str) -> str | None:
"""
Get the Linear issue ID for a subtask.
@@ -157,16 +151,16 @@ class LinearManager:
self.state.meta_issue_id = meta_issue_id
self.state.save(self.spec_dir)
def load_implementation_plan(self) -> Optional[dict]:
def load_implementation_plan(self) -> dict | None:
"""Load the implementation plan from spec directory."""
plan_file = self.spec_dir / "implementation_plan.json"
if not plan_file.exists():
return None
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
def get_subtasks_for_sync(self) -> list[dict]:
@@ -189,13 +183,15 @@ class LinearManager:
phase_name = phase.get("name", f"Phase {phase_num}")
for subtask in phase.get("subtasks", []):
subtasks.append({
**subtask,
"phase_num": phase_num,
"phase_name": phase_name,
"total_phases": total_phases,
"phase_depends_on": phase.get("depends_on", []),
})
subtasks.append(
{
**subtask,
"phase_num": phase_num,
"phase_name": phase_name,
"total_phases": total_phases,
"phase_depends_on": phase.get("depends_on", []),
}
)
return subtasks
@@ -216,8 +212,7 @@ class LinearManager:
# Determine priority based on phase position
priority = get_priority_for_phase(
subtask.get("phase_num", 1),
subtask.get("total_phases", 1)
subtask.get("phase_num", 1), subtask.get("total_phases", 1)
)
# Build labels list
@@ -347,10 +342,7 @@ class LinearManager:
}
subtasks = self.get_subtasks_for_sync()
mapped = sum(
1 for s in subtasks
if self.get_issue_id(s.get("id", ""))
)
mapped = sum(1 for s in subtasks if self.get_issue_id(s.get("id", "")))
return {
"enabled": self.is_enabled,
+13 -12
View File
@@ -29,7 +29,6 @@ from typing import Optional
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
# Linear status constants (matching Valma AI team setup)
STATUS_TODO = "Todo"
STATUS_IN_PROGRESS = "In Progress"
@@ -53,11 +52,12 @@ LINEAR_TOOLS = [
@dataclass
class LinearTaskState:
"""State of a Linear task for an auto-claude spec."""
task_id: Optional[str] = None
task_title: Optional[str] = None
team_id: Optional[str] = None
task_id: str | None = None
task_title: str | None = None
team_id: str | None = None
status: str = STATUS_TODO
created_at: Optional[str] = None
created_at: str | None = None
def to_dict(self) -> dict:
return {
@@ -92,9 +92,9 @@ class LinearTaskState:
return None
try:
with open(state_file, "r") as f:
with open(state_file) as f:
return cls.from_dict(json.load(f))
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
@@ -130,7 +130,7 @@ def _create_linear_client() -> ClaudeSDKClient:
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp",
"headers": {"Authorization": f"Bearer {linear_api_key}"}
"headers": {"Authorization": f"Bearer {linear_api_key}"},
}
},
max_turns=10, # Should complete in 1-3 turns
@@ -138,7 +138,7 @@ def _create_linear_client() -> ClaudeSDKClient:
)
async def _run_linear_agent(prompt: str) -> Optional[str]:
async def _run_linear_agent(prompt: str) -> str | None:
"""
Run a focused mini-agent for a Linear operation.
@@ -173,8 +173,8 @@ async def _run_linear_agent(prompt: str) -> Optional[str]:
async def create_linear_task(
spec_dir: Path,
title: str,
description: Optional[str] = None,
) -> Optional[LinearTaskState]:
description: str | None = None,
) -> LinearTaskState | None:
"""
Create a new Linear task for a spec.
@@ -317,7 +317,7 @@ async def add_linear_comment(
return False
# Escape any quotes in the comment
safe_comment = comment.replace('"', '\\"').replace('\n', '\\n')
safe_comment = comment.replace('"', '\\"').replace("\n", "\\n")
prompt = f"""Add a comment to Linear issue:
@@ -338,6 +338,7 @@ Confirm when done.
# === Convenience functions for specific transitions ===
async def linear_task_started(spec_dir: Path) -> bool:
"""
Mark task as started (In Progress).
+37 -22
View File
@@ -91,7 +91,7 @@ import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
# Configure logging
logger = logging.getLogger(__name__)
@@ -101,6 +101,7 @@ logger = logging.getLogger(__name__)
# Graphiti Integration Helpers
# =============================================================================
def is_graphiti_memory_enabled() -> bool:
"""
Check if Graphiti memory integration is available.
@@ -114,12 +115,13 @@ def is_graphiti_memory_enabled() -> bool:
"""
try:
from graphiti_config import is_graphiti_enabled
return is_graphiti_enabled()
except ImportError:
return False
def _get_graphiti_memory(spec_dir: Path, project_dir: Optional[Path] = None):
def _get_graphiti_memory(spec_dir: Path, project_dir: Path | None = None):
"""
Get a GraphitiMemory instance if available.
@@ -135,6 +137,7 @@ def _get_graphiti_memory(spec_dir: Path, project_dir: Optional[Path] = None):
try:
from graphiti_memory import GraphitiMemory
if project_dir is None:
project_dir = spec_dir.parent.parent
return GraphitiMemory(spec_dir, project_dir)
@@ -161,7 +164,7 @@ async def _save_to_graphiti_async(
spec_dir: Path,
session_num: int,
insights: dict,
project_dir: Optional[Path] = None,
project_dir: Path | None = None,
) -> bool:
"""
Save session insights to Graphiti (async helper).
@@ -205,6 +208,7 @@ async def _save_to_graphiti_async(
# File-Based Memory Functions
# =============================================================================
def get_memory_dir(spec_dir: Path) -> Path:
"""
Get the memory directory for a spec, creating it if needed.
@@ -275,14 +279,15 @@ def save_session_insights(spec_dir: Path, session_num: int, insights: dict) -> N
"session_number": session_num,
"timestamp": datetime.now(timezone.utc).isoformat(),
"subtasks_completed": insights.get("subtasks_completed", []),
"discoveries": insights.get("discoveries", {
"files_understood": {},
"patterns_found": [],
"gotchas_encountered": []
}),
"discoveries": insights.get(
"discoveries",
{"files_understood": {}, "patterns_found": [], "gotchas_encountered": []},
),
"what_worked": insights.get("what_worked", []),
"what_failed": insights.get("what_failed", []),
"recommendations_for_next_session": insights.get("recommendations_for_next_session", []),
"recommendations_for_next_session": insights.get(
"recommendations_for_next_session", []
),
}
# Write to file (always use file-based storage)
@@ -320,9 +325,9 @@ def load_all_insights(spec_dir: Path) -> list[dict]:
insights = []
for session_file in session_files:
try:
with open(session_file, "r") as f:
with open(session_file) as f:
insights.append(json.load(f))
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
# Skip corrupted files
continue
@@ -350,9 +355,9 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
# Load existing map or create new
if map_file.exists():
try:
with open(map_file, "r") as f:
with open(map_file) as f:
codebase_map = json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
codebase_map = {}
else:
codebase_map = {}
@@ -365,7 +370,9 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
codebase_map["_metadata"] = {}
codebase_map["_metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
codebase_map["_metadata"]["total_files"] = len([k for k in codebase_map.keys() if k != "_metadata"])
codebase_map["_metadata"]["total_files"] = len(
[k for k in codebase_map.keys() if k != "_metadata"]
)
# Write back
with open(map_file, "w") as f:
@@ -377,7 +384,7 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
graphiti = _get_graphiti_memory(spec_dir)
if graphiti:
_run_async(graphiti.save_codebase_discoveries(discoveries))
logger.info(f"Codebase discoveries also saved to Graphiti")
logger.info("Codebase discoveries also saved to Graphiti")
except Exception as e:
logger.warning(f"Graphiti codebase save failed: {e}")
@@ -400,14 +407,14 @@ def load_codebase_map(spec_dir: Path) -> dict[str, str]:
return {}
try:
with open(map_file, "r") as f:
with open(map_file) as f:
codebase_map = json.load(f)
# Remove metadata before returning
codebase_map.pop("_metadata", None)
return codebase_map
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return {}
@@ -608,6 +615,7 @@ def clear_memory(spec_dir: Path) -> None:
if memory_dir.exists():
import shutil
shutil.rmtree(memory_dir)
@@ -627,7 +635,14 @@ if __name__ == "__main__":
)
parser.add_argument(
"--action",
choices=["summary", "list-insights", "list-map", "list-patterns", "list-gotchas", "clear"],
choices=[
"summary",
"list-insights",
"list-map",
"list-patterns",
"list-gotchas",
"clear",
],
default="summary",
help="Action to perform",
)
@@ -649,11 +664,11 @@ if __name__ == "__main__":
print(f"Patterns: {summary['total_patterns']}")
print(f"Gotchas: {summary['total_gotchas']}")
if summary['recent_insights']:
if summary["recent_insights"]:
print("\nRecent sessions:")
for insight in summary['recent_insights']:
session_num = insight.get('session_number')
subtasks = len(insight.get('subtasks_completed', []))
for insight in summary["recent_insights"]:
session_num = insight.get("session_number")
subtasks = len(insight.get("subtasks_completed", []))
print(f" Session {session_num}: {subtasks} subtasks completed")
elif args.action == "list-insights":
+131 -66
View File
@@ -22,23 +22,23 @@ import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from implementation_plan import (
ImplementationPlan,
Phase,
Subtask,
Verification,
WorkflowType,
PhaseType,
Subtask,
SubtaskStatus,
Verification,
VerificationType,
WorkflowType,
)
@dataclass
class PlannerContext:
"""Context gathered for planning."""
spec_content: str
project_index: dict
task_context: dict
@@ -53,7 +53,7 @@ class ImplementationPlanner:
def __init__(self, spec_dir: Path):
self.spec_dir = spec_dir
self.context: Optional[PlannerContext] = None
self.context: PlannerContext | None = None
def load_context(self) -> PlannerContext:
"""Load all context files from spec directory."""
@@ -105,11 +105,11 @@ class ImplementationPlanner:
4. Keyword-based detection - Last resort fallback
"""
type_mapping = {
'feature': WorkflowType.FEATURE,
'refactor': WorkflowType.REFACTOR,
'investigation': WorkflowType.INVESTIGATION,
'migration': WorkflowType.MIGRATION,
'simple': WorkflowType.SIMPLE,
"feature": WorkflowType.FEATURE,
"refactor": WorkflowType.REFACTOR,
"investigation": WorkflowType.INVESTIGATION,
"migration": WorkflowType.MIGRATION,
"simple": WorkflowType.SIMPLE,
}
# 1. Check requirements.json (user's explicit intent)
@@ -149,19 +149,19 @@ class ImplementationPlanner:
content_lower = spec_content.lower()
type_mapping = {
'feature': WorkflowType.FEATURE,
'refactor': WorkflowType.REFACTOR,
'investigation': WorkflowType.INVESTIGATION,
'migration': WorkflowType.MIGRATION,
'simple': WorkflowType.SIMPLE,
"feature": WorkflowType.FEATURE,
"refactor": WorkflowType.REFACTOR,
"investigation": WorkflowType.INVESTIGATION,
"migration": WorkflowType.MIGRATION,
"simple": WorkflowType.SIMPLE,
}
# Check for explicit workflow type declaration in spec
# Look for patterns like "**Type**: feature" or "Type: refactor"
explicit_type_patterns = [
r'\*\*type\*\*:\s*(\w+)', # **Type**: feature
r'type:\s*(\w+)', # Type: feature
r'workflow\s*type:\s*(\w+)', # Workflow Type: feature
r"\*\*type\*\*:\s*(\w+)", # **Type**: feature
r"type:\s*(\w+)", # Type: feature
r"workflow\s*type:\s*(\w+)", # Workflow Type: feature
]
for pattern in explicit_type_patterns:
@@ -173,25 +173,51 @@ class ImplementationPlanner:
# FALLBACK: Keyword-based detection (only if no explicit type found)
# Investigation indicators
investigation_keywords = ["bug", "fix", "issue", "broken", "not working", "investigate", "debug"]
investigation_keywords = [
"bug",
"fix",
"issue",
"broken",
"not working",
"investigate",
"debug",
]
if any(kw in content_lower for kw in investigation_keywords):
# Check if it's clearly a bug investigation
if "unknown" in content_lower or "intermittent" in content_lower or "random" in content_lower:
if (
"unknown" in content_lower
or "intermittent" in content_lower
or "random" in content_lower
):
return WorkflowType.INVESTIGATION
# Refactor indicators - only match if the INTENT is to refactor, not incidental mentions
# These should be in headings or task descriptions, not implementation notes
refactor_keywords = ["migrate", "refactor", "convert", "upgrade", "replace", "move from", "transition"]
refactor_keywords = [
"migrate",
"refactor",
"convert",
"upgrade",
"replace",
"move from",
"transition",
]
# Check if refactor keyword appears in a heading or workflow type context
for line in spec_content.split('\n'):
for line in spec_content.split("\n"):
line_lower = line.lower().strip()
# Only trigger on headings or explicit task descriptions
if line_lower.startswith(('#', '**', '- [ ]', '- [x]')):
if line_lower.startswith(("#", "**", "- [ ]", "- [x]")):
if any(kw in line_lower for kw in refactor_keywords):
return WorkflowType.REFACTOR
# Migration indicators (data)
migration_keywords = ["data migration", "migrate data", "import", "export", "batch"]
migration_keywords = [
"data migration",
"migrate data",
"import",
"export",
"batch",
]
if any(kw in content_lower for kw in migration_keywords):
return WorkflowType.MIGRATION
@@ -208,7 +234,7 @@ class ImplementationPlanner:
# Remove common prefixes
for prefix in ["Specification:", "Spec:", "Feature:"]:
if title.startswith(prefix):
title = title[len(prefix):].strip()
title = title[len(prefix) :].strip()
return title
return "Unnamed Feature"
@@ -223,7 +249,9 @@ class ImplementationPlanner:
# Try to infer service from path if not specified
if service == "unknown":
for svc_name, svc_info in self.context.project_index.get("services", {}).items():
for svc_name, svc_info in self.context.project_index.get(
"services", {}
).items():
svc_path = svc_info.get("path", svc_name)
if path.startswith(svc_path) or path.startswith(f"{svc_name}/"):
service = svc_name
@@ -319,27 +347,47 @@ class ImplementationPlanner:
subtask_type = "code"
if "model" in path.lower() or "schema" in path.lower():
subtask_type = "model"
elif "route" in path.lower() or "endpoint" in path.lower() or "api" in path.lower():
elif (
"route" in path.lower()
or "endpoint" in path.lower()
or "api" in path.lower()
):
subtask_type = "endpoint"
elif "component" in path.lower() or path.endswith(".tsx") or path.endswith(".jsx"):
elif (
"component" in path.lower()
or path.endswith(".tsx")
or path.endswith(".jsx")
):
subtask_type = "component"
elif "task" in path.lower() or "worker" in path.lower() or "celery" in path.lower():
elif (
"task" in path.lower()
or "worker" in path.lower()
or "celery" in path.lower()
):
subtask_type = "task"
subtask_id = Path(path).stem.replace(".", "-").lower()
subtasks.append(Subtask(
id=f"{service}-{subtask_id}",
description=f"Modify {path}: {reason}" if reason else f"Update {path}",
service=service,
files_to_modify=[path],
patterns_from=patterns,
verification=self._create_verification(service, subtask_type),
))
subtasks.append(
Subtask(
id=f"{service}-{subtask_id}",
description=f"Modify {path}: {reason}"
if reason
else f"Update {path}",
service=service,
files_to_modify=[path],
patterns_from=patterns,
verification=self._create_verification(service, subtask_type),
)
)
# Determine dependencies
depends_on = []
service_type = self.context.project_index.get("services", {}).get(service, {}).get("type", "")
service_type = (
self.context.project_index.get("services", {})
.get(service, {})
.get("type", "")
)
if service_type in ["worker", "celery", "jobs"] and backend_phase:
depends_on = [backend_phase]
@@ -367,32 +415,34 @@ class ImplementationPlanner:
phase_num += 1
integration_depends = list(range(1, phase_num))
phases.append(Phase(
phase=phase_num,
name="Integration",
type=PhaseType.INTEGRATION,
depends_on=integration_depends,
subtasks=[
Subtask(
id="integration-wiring",
description="Wire all services together",
all_services=True,
verification=Verification(
type=VerificationType.BROWSER,
scenario="End-to-end flow works",
phases.append(
Phase(
phase=phase_num,
name="Integration",
type=PhaseType.INTEGRATION,
depends_on=integration_depends,
subtasks=[
Subtask(
id="integration-wiring",
description="Wire all services together",
all_services=True,
verification=Verification(
type=VerificationType.BROWSER,
scenario="End-to-end flow works",
),
),
),
Subtask(
id="integration-testing",
description="Verify complete feature works",
all_services=True,
verification=Verification(
type=VerificationType.BROWSER,
scenario="All acceptance criteria met",
Subtask(
id="integration-testing",
description="Verify complete feature works",
all_services=True,
verification=Verification(
type=VerificationType.BROWSER,
scenario="All acceptance criteria met",
),
),
),
],
))
],
)
)
# Extract final acceptance from spec
final_acceptance = self._extract_acceptance_criteria()
@@ -420,7 +470,9 @@ class ImplementationPlanner:
id="add-logging",
description="Add detailed logging around suspected problem areas",
expected_output="Logs capture relevant state changes and events",
files_to_modify=[f.get("path", "") for f in self.context.files_to_modify[:3]],
files_to_modify=[
f.get("path", "") for f in self.context.files_to_modify[:3]
],
),
Subtask(
id="create-repro",
@@ -514,8 +566,13 @@ class ImplementationPlanner:
Subtask(
id="add-new-implementation",
description="Implement new system alongside existing",
files_to_modify=[f.get("path", "") for f in self.context.files_to_modify],
patterns_from=[f.get("path", "") for f in self.context.files_to_reference[:3]],
files_to_modify=[
f.get("path", "") for f in self.context.files_to_modify
],
patterns_from=[
f.get("path", "")
for f in self.context.files_to_reference[:3]
],
verification=Verification(
type=VerificationType.COMMAND,
run="echo 'New system added - both old and new should work'",
@@ -597,7 +654,15 @@ class ImplementationPlanner:
for line in self.context.spec_content.split("\n"):
# Look for success criteria or acceptance sections
if any(header in line.lower() for header in ["success criteria", "acceptance", "done when", "complete when"]):
if any(
header in line.lower()
for header in [
"success criteria",
"acceptance",
"done when",
"complete when",
]
):
in_criteria_section = True
continue
+90 -63
View File
@@ -24,12 +24,12 @@ import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class PredictedIssue:
"""A potential issue that might occur during implementation."""
category: str # "integration", "pattern", "edge_case", "security", "performance"
description: str
likelihood: str # "high", "medium", "low"
@@ -47,6 +47,7 @@ class PredictedIssue:
@dataclass
class PreImplementationChecklist:
"""Complete checklist for a subtask before implementation."""
subtask_id: str
subtask_description: str
predicted_issues: list[PredictedIssue] = field(default_factory=list)
@@ -83,31 +84,31 @@ class BugPredictor:
"integration",
"CORS configuration missing or incorrect",
"high",
"Check existing CORS setup in similar endpoints and ensure new routes are included"
"Check existing CORS setup in similar endpoints and ensure new routes are included",
),
PredictedIssue(
"security",
"Authentication middleware not applied",
"high",
"Verify auth decorator is applied if endpoint requires authentication"
"Verify auth decorator is applied if endpoint requires authentication",
),
PredictedIssue(
"pattern",
"Response format doesn't match API conventions",
"medium",
"Check existing endpoints for response structure (e.g., {\"data\": ..., \"error\": ...})"
'Check existing endpoints for response structure (e.g., {"data": ..., "error": ...})',
),
PredictedIssue(
"edge_case",
"Missing input validation",
"high",
"Add validation for all user inputs to prevent invalid data and SQL injection"
"Add validation for all user inputs to prevent invalid data and SQL injection",
),
PredictedIssue(
"edge_case",
"Error handling not comprehensive",
"medium",
"Handle edge cases: missing fields, invalid types, database errors, etc."
"Handle edge cases: missing fields, invalid types, database errors, etc.",
),
],
"database_model": [
@@ -115,25 +116,25 @@ class BugPredictor:
"integration",
"Database migration not created or run",
"high",
"Create migration after model changes and run db upgrade before testing"
"Create migration after model changes and run db upgrade before testing",
),
PredictedIssue(
"pattern",
"Field naming doesn't match conventions",
"medium",
"Check existing models for naming style (snake_case, timestamps, etc.)"
"Check existing models for naming style (snake_case, timestamps, etc.)",
),
PredictedIssue(
"edge_case",
"Missing indexes on frequently queried fields",
"low",
"Add indexes for foreign keys and fields used in WHERE clauses"
"Add indexes for foreign keys and fields used in WHERE clauses",
),
PredictedIssue(
"pattern",
"Relationship configuration incorrect",
"medium",
"Check existing relationships for backref and cascade patterns"
"Check existing relationships for backref and cascade patterns",
),
],
"frontend_component": [
@@ -141,31 +142,31 @@ class BugPredictor:
"integration",
"API client not used correctly",
"high",
"Use existing ApiClient or hook pattern, don't call fetch() directly"
"Use existing ApiClient or hook pattern, don't call fetch() directly",
),
PredictedIssue(
"pattern",
"State management doesn't follow conventions",
"medium",
"Follow existing hook patterns (useState, useEffect, custom hooks)"
"Follow existing hook patterns (useState, useEffect, custom hooks)",
),
PredictedIssue(
"edge_case",
"Loading and error states not handled",
"high",
"Show loading indicator during async operations and display errors to users"
"Show loading indicator during async operations and display errors to users",
),
PredictedIssue(
"pattern",
"Styling doesn't match design system",
"low",
"Use existing CSS classes or styled components from the design system"
"Use existing CSS classes or styled components from the design system",
),
PredictedIssue(
"edge_case",
"Form validation missing",
"medium",
"Add client-side validation before submission and show helpful error messages"
"Add client-side validation before submission and show helpful error messages",
),
],
"celery_task": [
@@ -173,25 +174,25 @@ class BugPredictor:
"integration",
"Task not registered with Celery app",
"high",
"Import task in celery app initialization or __init__.py"
"Import task in celery app initialization or __init__.py",
),
PredictedIssue(
"pattern",
"Arguments not JSON-serializable",
"high",
"Use only JSON-serializable arguments (no objects, use IDs instead)"
"Use only JSON-serializable arguments (no objects, use IDs instead)",
),
PredictedIssue(
"edge_case",
"Retry logic not implemented",
"medium",
"Add retry decorator for network/external service failures"
"Add retry decorator for network/external service failures",
),
PredictedIssue(
"integration",
"Task not called from correct location",
"medium",
"Call with .delay() or .apply_async() after database commit"
"Call with .delay() or .apply_async() after database commit",
),
],
"authentication": [
@@ -199,19 +200,19 @@ class BugPredictor:
"security",
"Password not hashed",
"high",
"Use bcrypt or similar for password hashing, never store plaintext"
"Use bcrypt or similar for password hashing, never store plaintext",
),
PredictedIssue(
"security",
"Token not validated properly",
"high",
"Verify token signature and expiration on every request"
"Verify token signature and expiration on every request",
),
PredictedIssue(
"security",
"Session not invalidated on logout",
"medium",
"Clear session/token on logout and after password changes"
"Clear session/token on logout and after password changes",
),
],
"database_query": [
@@ -219,19 +220,19 @@ class BugPredictor:
"performance",
"N+1 query problem",
"medium",
"Use eager loading (joinedload/selectinload) for relationships"
"Use eager loading (joinedload/selectinload) for relationships",
),
PredictedIssue(
"security",
"SQL injection vulnerability",
"high",
"Use parameterized queries, never concatenate user input into SQL"
"Use parameterized queries, never concatenate user input into SQL",
),
PredictedIssue(
"edge_case",
"Large result sets not paginated",
"medium",
"Add pagination for queries that could return many results"
"Add pagination for queries that could return many results",
),
],
"file_upload": [
@@ -239,19 +240,19 @@ class BugPredictor:
"security",
"File type not validated",
"high",
"Validate file extension and MIME type, don't trust user input"
"Validate file extension and MIME type, don't trust user input",
),
PredictedIssue(
"security",
"File size not limited",
"high",
"Set maximum file size to prevent DoS attacks"
"Set maximum file size to prevent DoS attacks",
),
PredictedIssue(
"edge_case",
"Uploaded files not cleaned up on error",
"low",
"Use try/finally or context managers to ensure cleanup"
"Use try/finally or context managers to ensure cleanup",
),
],
}
@@ -265,10 +266,10 @@ class BugPredictor:
content = self.gotchas_file.read_text()
# Parse markdown list items
for line in content.split('\n'):
for line in content.split("\n"):
line = line.strip()
if line.startswith('-') or line.startswith('*'):
gotcha = line.lstrip('-*').strip()
if line.startswith("-") or line.startswith("*"):
gotcha = line.lstrip("-*").strip()
if gotcha:
gotchas.append(gotcha)
@@ -284,15 +285,15 @@ class BugPredictor:
# Parse markdown sections
current_pattern = None
for line in content.split('\n'):
for line in content.split("\n"):
line = line.strip()
if line.startswith('##'):
if line.startswith("##"):
# Pattern heading
current_pattern = line.lstrip('#').strip()
current_pattern = line.lstrip("#").strip()
elif line and current_pattern:
# Pattern detail
if line.startswith('-') or line.startswith('*'):
detail = line.lstrip('-*').strip()
if line.startswith("-") or line.startswith("*"):
detail = line.lstrip("-*").strip()
patterns.append(f"{current_pattern}: {detail}")
return patterns
@@ -306,7 +307,7 @@ class BugPredictor:
with open(self.history_file) as f:
history = json.load(f)
return history.get("attempts", [])
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return []
def _detect_work_type(self, subtask: dict) -> list[str]:
@@ -322,13 +323,18 @@ class BugPredictor:
service = subtask.get("service", "").lower()
# API endpoint detection
if any(kw in description for kw in ["endpoint", "api", "route", "request", "response"]):
if any(
kw in description
for kw in ["endpoint", "api", "route", "request", "response"]
):
work_types.append("api_endpoint")
if any("routes" in f or "api" in f for f in files):
work_types.append("api_endpoint")
# Database model detection
if any(kw in description for kw in ["model", "database", "migration", "schema"]):
if any(
kw in description for kw in ["model", "database", "migration", "schema"]
):
work_types.append("database_model")
if any("models" in f or "migration" in f for f in files):
work_types.append("database_model")
@@ -336,7 +342,7 @@ class BugPredictor:
# Frontend component detection
if service in ["frontend", "web", "ui"]:
work_types.append("frontend_component")
if any(f.endswith(('.tsx', '.jsx', '.vue', '.svelte')) for f in files):
if any(f.endswith((".tsx", ".jsx", ".vue", ".svelte")) for f in files):
work_types.append("frontend_component")
# Celery task detection
@@ -346,7 +352,10 @@ class BugPredictor:
work_types.append("celery_task")
# Authentication detection
if any(kw in description for kw in ["auth", "login", "password", "token", "session"]):
if any(
kw in description
for kw in ["auth", "login", "password", "token", "session"]
):
work_types.append("authentication")
# Database query detection
@@ -384,12 +393,14 @@ class BugPredictor:
for failure in similar_failures:
failure_reason = failure.get("failure_reason", "")
if failure_reason:
issues.append(PredictedIssue(
"pattern",
f"Similar subtask failed: {failure_reason}",
"high",
f"Review the failed attempt in memory/attempt_history.json"
))
issues.append(
PredictedIssue(
"pattern",
f"Similar subtask failed: {failure_reason}",
"high",
"Review the failed attempt in memory/attempt_history.json",
)
)
# Deduplicate by description
seen = set()
@@ -421,7 +432,9 @@ class BugPredictor:
return []
subtask_desc = subtask.get("description", "").lower()
subtask_files = set(subtask.get("files_to_modify", []) + subtask.get("files_to_create", []))
subtask_files = set(
subtask.get("files_to_modify", []) + subtask.get("files_to_create", [])
)
similar = []
for attempt in history:
@@ -437,8 +450,8 @@ class BugPredictor:
score = 0
# Description keyword overlap
subtask_keywords = set(re.findall(r'\w+', subtask_desc))
attempt_keywords = set(re.findall(r'\w+', attempt_desc))
subtask_keywords = set(re.findall(r"\w+", subtask_desc))
attempt_keywords = set(re.findall(r"\w+", attempt_desc))
common_keywords = subtask_keywords & attempt_keywords
if common_keywords:
score += len(common_keywords)
@@ -449,12 +462,14 @@ class BugPredictor:
score += len(common_files) * 3 # Files are stronger signal
if score > 2: # Threshold for similarity
similar.append({
"subtask_id": attempt.get("subtask_id"),
"description": attempt.get("subtask_description"),
"failure_reason": attempt.get("error_message", "Unknown error"),
"similarity_score": score,
})
similar.append(
{
"subtask_id": attempt.get("subtask_id"),
"description": attempt.get("subtask_description"),
"failure_reason": attempt.get("error_message", "Unknown error"),
"similarity_score": score,
}
)
# Sort by similarity
similar.sort(key=lambda x: x["similarity_score"], reverse=True)
@@ -489,7 +504,10 @@ class BugPredictor:
if any(wt.replace("_", " ") in pattern_lower for wt in work_types):
relevant_patterns.append(pattern)
# Or if it mentions any file being modified
elif any(f.split('/')[-1] in pattern_lower for f in subtask.get("files_to_modify", [])):
elif any(
f.split("/")[-1] in pattern_lower
for f in subtask.get("files_to_modify", [])
):
relevant_patterns.append(pattern)
checklist.patterns_to_follow = relevant_patterns[:5] # Top 5
@@ -504,7 +522,10 @@ class BugPredictor:
for gotcha in gotchas:
gotcha_lower = gotcha.lower()
# Check relevance to current subtask
if any(kw in gotcha_lower for kw in subtask.get("description", "").lower().split()):
if any(
kw in gotcha_lower
for kw in subtask.get("description", "").lower().split()
):
relevant_gotchas.append(gotcha)
elif any(wt.replace("_", " ") in gotcha_lower for wt in work_types):
relevant_gotchas.append(gotcha)
@@ -542,7 +563,9 @@ class BugPredictor:
"""
lines = []
lines.append(f"## Pre-Implementation Checklist: {checklist.subtask_description}")
lines.append(
f"## Pre-Implementation Checklist: {checklist.subtask_description}"
)
lines.append("")
# Predicted issues
@@ -584,8 +607,10 @@ class BugPredictor:
lines.append("")
for file_path in checklist.files_to_reference:
# Extract filename and suggest what to look for
filename = file_path.split('/')[-1]
lines.append(f"- `{file_path}` - Check for similar patterns and code style")
filename = file_path.split("/")[-1]
lines.append(
f"- `{file_path}` - Check for similar patterns and code style"
)
lines.append("")
# Verification reminders
@@ -600,7 +625,9 @@ class BugPredictor:
lines.append("### Before You Start Implementing")
lines.append("")
lines.append("- [ ] I have read and understood all predicted issues above")
lines.append("- [ ] I have reviewed the reference files to understand existing patterns")
lines.append(
"- [ ] I have reviewed the reference files to understand existing patterns"
)
lines.append("- [ ] I know how to prevent the high-likelihood issues")
lines.append("- [ ] I understand the verification requirements")
lines.append("")
@@ -649,7 +676,7 @@ if __name__ == "__main__":
"method": "POST",
"url": "/api/users/avatar",
"expect_status": 200,
}
},
}
checklist_md = generate_subtask_checklist(spec_dir, demo_subtask)
+43 -40
View File
@@ -10,28 +10,19 @@ Enhanced with colored output, icons, and better visual formatting.
import json
from pathlib import Path
from typing import Optional
from ui import (
Icons,
icon,
color,
Color,
success,
error,
warning,
info,
muted,
highlight,
bold,
box,
divider,
progress_bar,
print_header,
print_section,
print_status,
highlight,
icon,
muted,
print_phase_status,
print_key_value,
print_status,
progress_bar,
success,
warning,
)
@@ -51,7 +42,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
return 0, 0
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
total = 0
@@ -64,7 +55,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
completed += 1
return completed, total
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return 0, 0
@@ -89,7 +80,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
return result
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
for phase in plan.get("phases", []):
@@ -102,7 +93,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
result["pending"] += 1
return result
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return result
@@ -190,19 +181,23 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
# Phase summary
try:
with open(spec_dir / "implementation_plan.json", "r") as f:
with open(spec_dir / "implementation_plan.json") as f:
plan = json.load(f)
print("\nPhases:")
for phase in plan.get("phases", []):
phase_subtasks = phase.get("subtasks", [])
phase_completed = sum(1 for s in phase_subtasks if s.get("status") == "completed")
phase_completed = sum(
1 for s in phase_subtasks if s.get("status") == "completed"
)
phase_total = len(phase_subtasks)
phase_name = phase.get("name", phase.get("id", "Unknown"))
if phase_completed == phase_total:
status = "complete"
elif phase_completed > 0 or any(s.get("status") == "in_progress" for s in phase_subtasks):
elif phase_completed > 0 or any(
s.get("status") == "in_progress" for s in phase_subtasks
):
status = "in_progress"
else:
# Check if blocked by dependencies
@@ -212,7 +207,9 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
for p in plan.get("phases", []):
if p.get("id") == dep_id or p.get("phase") == dep_id:
p_subtasks = p.get("subtasks", [])
if not all(s.get("status") == "completed" for s in p_subtasks):
if not all(
s.get("status") == "completed" for s in p_subtasks
):
all_deps_complete = False
break
status = "pending" if all_deps_complete else "blocked"
@@ -228,9 +225,11 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
next_desc = next_subtask.get("description", "")
if len(next_desc) > 60:
next_desc = next_desc[:57] + "..."
print(f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}")
print(
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
pass
else:
print()
@@ -302,7 +301,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
}
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
summary = {
@@ -342,18 +341,20 @@ def get_plan_summary(spec_dir: Path) -> dict:
else:
summary["pending_subtasks"] += 1
phase_info["subtasks"].append({
"id": subtask.get("id"),
"description": subtask.get("description"),
"status": status,
"service": subtask.get("service"),
})
phase_info["subtasks"].append(
{
"id": subtask.get("id"),
"description": subtask.get("description"),
"status": status,
"service": subtask.get("service"),
}
)
summary["phases"].append(phase_info)
return summary
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return {
"workflow_type": None,
"total_phases": 0,
@@ -366,7 +367,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
}
def get_current_phase(spec_dir: Path) -> Optional[dict]:
def get_current_phase(spec_dir: Path) -> dict | None:
"""Get the current phase being worked on."""
plan_file = spec_dir / "implementation_plan.json"
@@ -374,7 +375,7 @@ def get_current_phase(spec_dir: Path) -> Optional[dict]:
return None
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
for phase in plan.get("phases", []):
@@ -386,13 +387,15 @@ def get_current_phase(spec_dir: Path) -> Optional[dict]:
"id": phase.get("id"),
"phase": phase.get("phase"),
"name": phase.get("name"),
"completed": sum(1 for s in subtasks if s.get("status") == "completed"),
"completed": sum(
1 for s in subtasks if s.get("status") == "completed"
),
"total": len(subtasks),
}
return None
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
@@ -412,7 +415,7 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
return None
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
phases = plan.get("phases", [])
@@ -448,7 +451,7 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
return None
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
+2 -2
View File
@@ -50,7 +50,7 @@ from typing import Optional
def get_or_create_profile(
project_dir: Path,
spec_dir: Optional[Path] = None,
spec_dir: Path | None = None,
force_reanalyze: bool = False,
) -> SecurityProfile:
"""
@@ -100,7 +100,7 @@ def is_command_allowed(
return False, f"Command '{command}' is not in the allowed commands for this project"
def needs_validation(command: str) -> Optional[str]:
def needs_validation(command: str) -> str | None:
"""
Check if a command needs extra validation.
+12 -11
View File
@@ -10,17 +10,16 @@ import hashlib
import json
from datetime import datetime
from pathlib import Path
from typing import Optional
from .command_registry import (
BASE_COMMANDS,
LANGUAGE_COMMANDS,
PACKAGE_MANAGER_COMMANDS,
FRAMEWORK_COMMANDS,
DATABASE_COMMANDS,
INFRASTRUCTURE_COMMANDS,
CLOUD_COMMANDS,
CODE_QUALITY_COMMANDS,
DATABASE_COMMANDS,
FRAMEWORK_COMMANDS,
INFRASTRUCTURE_COMMANDS,
LANGUAGE_COMMANDS,
PACKAGE_MANAGER_COMMANDS,
VERSION_MANAGER_COMMANDS,
)
from .config_parser import ConfigParser
@@ -44,7 +43,7 @@ class ProjectAnalyzer:
PROFILE_FILENAME = ".auto-claude-security.json"
def __init__(self, project_dir: Path, spec_dir: Optional[Path] = None):
def __init__(self, project_dir: Path, spec_dir: Path | None = None):
"""
Initialize analyzer.
@@ -63,17 +62,17 @@ class ProjectAnalyzer:
return self.spec_dir / self.PROFILE_FILENAME
return self.project_dir / self.PROFILE_FILENAME
def load_profile(self) -> Optional[SecurityProfile]:
def load_profile(self) -> SecurityProfile | None:
"""Load existing profile if it exists."""
profile_path = self.get_profile_path()
if not profile_path.exists():
return None
try:
with open(profile_path, "r") as f:
with open(profile_path) as f:
data = json.load(f)
return SecurityProfile.from_dict(data)
except (json.JSONDecodeError, IOError, KeyError):
except (OSError, json.JSONDecodeError, KeyError):
return None
def save_profile(self, profile: SecurityProfile) -> None:
@@ -239,7 +238,9 @@ class ProjectAnalyzer:
"""Detect code quality tools (backward compatibility)."""
detector = StackDetector(self.project_dir)
detector.detect_code_quality_tools()
self.profile.detected_stack.code_quality_tools = detector.stack.code_quality_tools
self.profile.detected_stack.code_quality_tools = (
detector.stack.code_quality_tools
)
def _detect_version_managers(self) -> None:
"""Detect version managers (backward compatibility)."""
+323 -101
View File
@@ -12,39 +12,139 @@ tailored security profiles.
BASE_COMMANDS = {
# Core shell
"echo", "printf", "cat", "head", "tail", "less", "more",
"ls", "pwd", "cd", "pushd", "popd",
"cp", "mv", "mkdir", "rmdir", "touch", "ln",
"find", "fd", "grep", "egrep", "fgrep", "rg", "ag",
"sort", "uniq", "cut", "tr", "sed", "awk", "gawk",
"wc", "diff", "cmp", "comm",
"tee", "xargs", "read",
"file", "stat", "tree", "du", "df",
"which", "whereis", "type", "command",
"date", "time", "sleep", "timeout", "watch",
"true", "false", "test", "[", "[[",
"env", "printenv", "export", "unset", "set", "source", ".",
"eval", "exec", "exit", "return", "break", "continue",
"sh", "bash", "zsh",
"echo",
"printf",
"cat",
"head",
"tail",
"less",
"more",
"ls",
"pwd",
"cd",
"pushd",
"popd",
"cp",
"mv",
"mkdir",
"rmdir",
"touch",
"ln",
"find",
"fd",
"grep",
"egrep",
"fgrep",
"rg",
"ag",
"sort",
"uniq",
"cut",
"tr",
"sed",
"awk",
"gawk",
"wc",
"diff",
"cmp",
"comm",
"tee",
"xargs",
"read",
"file",
"stat",
"tree",
"du",
"df",
"which",
"whereis",
"type",
"command",
"date",
"time",
"sleep",
"timeout",
"watch",
"true",
"false",
"test",
"[",
"[[",
"env",
"printenv",
"export",
"unset",
"set",
"source",
".",
"eval",
"exec",
"exit",
"return",
"break",
"continue",
"sh",
"bash",
"zsh",
# Archives
"tar", "zip", "unzip", "gzip", "gunzip",
"tar",
"zip",
"unzip",
"gzip",
"gunzip",
# Network (read-only)
"curl", "wget", "ping", "host", "dig",
"curl",
"wget",
"ping",
"host",
"dig",
# Git (always needed)
"git", "gh",
"git",
"gh",
# Process management (with validation in security.py)
"ps", "pgrep", "lsof", "jobs",
"kill", "pkill", "killall", # Validated for safe targets only
"ps",
"pgrep",
"lsof",
"jobs",
"kill",
"pkill",
"killall", # Validated for safe targets only
# File operations (with validation in security.py)
"rm", "chmod", # Validated for safe operations only
"rm",
"chmod", # Validated for safe operations only
# Text tools
"paste", "join", "split", "fold", "fmt", "nl", "rev", "shuf",
"column", "expand", "unexpand", "iconv",
"paste",
"join",
"split",
"fold",
"fmt",
"nl",
"rev",
"shuf",
"column",
"expand",
"unexpand",
"iconv",
# Misc safe
"clear", "reset", "man", "help", "uname", "whoami", "id",
"basename", "dirname", "realpath", "readlink", "mktemp",
"bc", "expr", "let", "seq", "yes",
"jq", "yq",
"clear",
"reset",
"man",
"help",
"uname",
"whoami",
"id",
"basename",
"dirname",
"realpath",
"readlink",
"mktemp",
"bc",
"expr",
"let",
"seq",
"yes",
"jq",
"yq",
}
# =============================================================================
@@ -65,67 +165,133 @@ VALIDATED_COMMANDS = {
LANGUAGE_COMMANDS = {
"python": {
"python", "python3", "pip", "pip3", "pipx",
"ipython", "jupyter", "notebook",
"pdb", "pudb", # debuggers
"python",
"python3",
"pip",
"pip3",
"pipx",
"ipython",
"jupyter",
"notebook",
"pdb",
"pudb", # debuggers
},
"javascript": {
"node", "npm", "npx",
"node",
"npm",
"npx",
},
"typescript": {
"tsc", "ts-node", "tsx",
"tsc",
"ts-node",
"tsx",
},
"rust": {
"cargo", "rustc", "rustup", "rustfmt", "clippy",
"cargo",
"rustc",
"rustup",
"rustfmt",
"clippy",
"rust-analyzer",
},
"go": {
"go", "gofmt", "golint", "gopls",
"go-outline", "gocode", "gotests",
"go",
"gofmt",
"golint",
"gopls",
"go-outline",
"gocode",
"gotests",
},
"ruby": {
"ruby", "gem", "irb", "erb",
"ruby",
"gem",
"irb",
"erb",
},
"php": {
"php", "composer",
"php",
"composer",
},
"java": {
"java", "javac", "jar",
"mvn", "maven", "gradle", "gradlew", "ant",
"java",
"javac",
"jar",
"mvn",
"maven",
"gradle",
"gradlew",
"ant",
},
"kotlin": {
"kotlin", "kotlinc",
"kotlin",
"kotlinc",
},
"scala": {
"scala", "scalac", "sbt",
"scala",
"scalac",
"sbt",
},
"csharp": {
"dotnet", "nuget", "msbuild",
"dotnet",
"nuget",
"msbuild",
},
"c": {
"gcc", "g++", "clang", "clang++",
"make", "cmake", "ninja", "meson",
"ld", "ar", "nm", "objdump", "strip",
"gcc",
"g++",
"clang",
"clang++",
"make",
"cmake",
"ninja",
"meson",
"ld",
"ar",
"nm",
"objdump",
"strip",
},
"cpp": {
"gcc", "g++", "clang", "clang++",
"make", "cmake", "ninja", "meson",
"ld", "ar", "nm", "objdump", "strip",
"gcc",
"g++",
"clang",
"clang++",
"make",
"cmake",
"ninja",
"meson",
"ld",
"ar",
"nm",
"objdump",
"strip",
},
"elixir": {
"elixir", "mix", "iex",
"elixir",
"mix",
"iex",
},
"haskell": {
"ghc", "ghci", "cabal", "stack",
"ghc",
"ghci",
"cabal",
"stack",
},
"lua": {
"lua", "luac", "luarocks",
"lua",
"luac",
"luarocks",
},
"perl": {
"perl", "cpan", "cpanm",
"perl",
"cpan",
"cpanm",
},
"swift": {
"swift", "swiftc", "xcodebuild",
"swift",
"swiftc",
"xcodebuild",
},
"zig": {
"zig",
@@ -176,7 +342,6 @@ FRAMEWORK_COMMANDS = {
"pyramid": {"pserve", "pyramid"},
"sanic": {"sanic"},
"aiohttp": {"aiohttp"},
# Python data/ML
"celery": {"celery"},
"dramatiq": {"dramatiq"},
@@ -189,7 +354,6 @@ FRAMEWORK_COMMANDS = {
"gradio": {"gradio"},
"panel": {"panel"},
"dash": {"dash"},
# Python testing/linting
"pytest": {"pytest", "py.test"},
"unittest": {"python", "python3"},
@@ -206,12 +370,10 @@ FRAMEWORK_COMMANDS = {
"bandit": {"bandit"},
"coverage": {"coverage"},
"pre-commit": {"pre-commit"},
# Python DB migrations
"alembic": {"alembic"},
"flask-migrate": {"flask"},
"django-migrations": {"django-admin"},
# Node.js frameworks
"nextjs": {"next"},
"nuxt": {"nuxt", "nuxi"},
@@ -242,7 +404,6 @@ FRAMEWORK_COMMANDS = {
"capacitor": {"cap", "capacitor"},
"expo": {"expo", "eas"},
"react-native": {"react-native", "npx"},
# Node.js build tools
"vite": {"vite"},
"webpack": {"webpack", "webpack-cli"},
@@ -254,7 +415,6 @@ FRAMEWORK_COMMANDS = {
"lerna": {"lerna"},
"rush": {"rush"},
"changesets": {"changeset"},
# Node.js testing/linting
"jest": {"jest"},
"vitest": {"vitest"},
@@ -272,14 +432,12 @@ FRAMEWORK_COMMANDS = {
"tslint": {"tslint"},
"standard": {"standard"},
"xo": {"xo"},
# Node.js ORMs/Database tools (also in DATABASE_COMMANDS for when detected via DB)
"prisma": {"prisma", "npx"},
"drizzle": {"drizzle-kit", "npx"},
"typeorm": {"typeorm", "npx"},
"sequelize": {"sequelize", "npx"},
"knex": {"knex", "npx"},
# Ruby frameworks
"rails": {"rails", "rake", "spring"},
"sinatra": {"sinatra", "rackup"},
@@ -287,7 +445,6 @@ FRAMEWORK_COMMANDS = {
"rspec": {"rspec"},
"minitest": {"rake"},
"rubocop": {"rubocop"},
# PHP frameworks
"laravel": {"artisan", "sail"},
"symfony": {"symfony", "console"},
@@ -296,21 +453,18 @@ FRAMEWORK_COMMANDS = {
"phpunit": {"phpunit"},
"phpstan": {"phpstan"},
"psalm": {"psalm"},
# Rust frameworks
"actix": {"cargo"},
"rocket": {"cargo"},
"axum": {"cargo"},
"warp": {"cargo"},
"tokio": {"cargo"},
# Go frameworks
"gin": {"go"},
"echo": {"go"},
"fiber": {"go"},
"chi": {"go"},
"buffalo": {"buffalo"},
# Elixir/Erlang
"phoenix": {"mix", "iex"},
"ecto": {"mix"},
@@ -322,35 +476,65 @@ FRAMEWORK_COMMANDS = {
DATABASE_COMMANDS = {
"postgresql": {
"psql", "pg_dump", "pg_restore", "pg_dumpall",
"createdb", "dropdb", "createuser", "dropuser",
"pg_ctl", "postgres", "initdb", "pg_isready",
"psql",
"pg_dump",
"pg_restore",
"pg_dumpall",
"createdb",
"dropdb",
"createuser",
"dropuser",
"pg_ctl",
"postgres",
"initdb",
"pg_isready",
},
"mysql": {
"mysql", "mysqldump", "mysqlimport", "mysqladmin",
"mysqlcheck", "mysqlshow",
"mysql",
"mysqldump",
"mysqlimport",
"mysqladmin",
"mysqlcheck",
"mysqlshow",
},
"mariadb": {
"mysql", "mariadb", "mysqldump", "mariadb-dump",
"mysql",
"mariadb",
"mysqldump",
"mariadb-dump",
},
"mongodb": {
"mongosh", "mongo", "mongod", "mongos",
"mongodump", "mongorestore", "mongoexport", "mongoimport",
"mongosh",
"mongo",
"mongod",
"mongos",
"mongodump",
"mongorestore",
"mongoexport",
"mongoimport",
},
"redis": {
"redis-cli", "redis-server", "redis-benchmark",
"redis-cli",
"redis-server",
"redis-benchmark",
},
"sqlite": {
"sqlite3", "sqlite",
"sqlite3",
"sqlite",
},
"cassandra": {
"cqlsh", "cassandra", "nodetool",
"cqlsh",
"cassandra",
"nodetool",
},
"elasticsearch": {
"elasticsearch", "curl", # ES uses REST API
"elasticsearch",
"curl", # ES uses REST API
},
"neo4j": {
"cypher-shell", "neo4j", "neo4j-admin",
"cypher-shell",
"neo4j",
"neo4j-admin",
},
"dynamodb": {
"aws", # DynamoDB uses AWS CLI
@@ -359,31 +543,40 @@ DATABASE_COMMANDS = {
"cockroach",
},
"clickhouse": {
"clickhouse-client", "clickhouse-local",
"clickhouse-client",
"clickhouse-local",
},
"influxdb": {
"influx", "influxd",
"influx",
"influxd",
},
"timescaledb": {
"psql", # TimescaleDB uses PostgreSQL
},
"prisma": {
"prisma", "npx",
"prisma",
"npx",
},
"drizzle": {
"drizzle-kit", "npx",
"drizzle-kit",
"npx",
},
"typeorm": {
"typeorm", "npx",
"typeorm",
"npx",
},
"sequelize": {
"sequelize", "npx",
"sequelize",
"npx",
},
"knex": {
"knex", "npx",
"knex",
"npx",
},
"sqlalchemy": {
"alembic", "python", "python3",
"alembic",
"python",
"python3",
},
}
@@ -393,28 +586,45 @@ DATABASE_COMMANDS = {
INFRASTRUCTURE_COMMANDS = {
"docker": {
"docker", "docker-compose", "docker-buildx",
"dockerfile", "dive", # Dockerfile analysis
"docker",
"docker-compose",
"docker-buildx",
"dockerfile",
"dive", # Dockerfile analysis
},
"podman": {
"podman", "podman-compose", "buildah",
"podman",
"podman-compose",
"buildah",
},
"kubernetes": {
"kubectl", "k9s", "kubectx", "kubens",
"kustomize", "kubeseal", "kubeadm",
"kubectl",
"k9s",
"kubectx",
"kubens",
"kustomize",
"kubeseal",
"kubeadm",
},
"helm": {
"helm", "helmfile",
"helm",
"helmfile",
},
"terraform": {
"terraform", "terragrunt", "tflint", "tfsec",
"terraform",
"terragrunt",
"tflint",
"tfsec",
},
"pulumi": {
"pulumi",
},
"ansible": {
"ansible", "ansible-playbook", "ansible-galaxy",
"ansible-vault", "ansible-lint",
"ansible",
"ansible-playbook",
"ansible-galaxy",
"ansible-vault",
"ansible-lint",
},
"vagrant": {
"vagrant",
@@ -454,19 +664,29 @@ INFRASTRUCTURE_COMMANDS = {
CLOUD_COMMANDS = {
"aws": {
"aws", "sam", "cdk", "amplify", "eb", # AWS CLI, SAM, CDK, Amplify, Elastic Beanstalk
"aws",
"sam",
"cdk",
"amplify",
"eb", # AWS CLI, SAM, CDK, Amplify, Elastic Beanstalk
},
"gcp": {
"gcloud", "gsutil", "bq", "firebase",
"gcloud",
"gsutil",
"bq",
"firebase",
},
"azure": {
"az", "func", # Azure CLI, Azure Functions
"az",
"func", # Azure CLI, Azure Functions
},
"vercel": {
"vercel", "vc",
"vercel",
"vc",
},
"netlify": {
"netlify", "ntl",
"netlify",
"ntl",
},
"heroku": {
"heroku",
@@ -475,13 +695,15 @@ CLOUD_COMMANDS = {
"railway",
},
"fly": {
"fly", "flyctl",
"fly",
"flyctl",
},
"render": {
"render",
},
"cloudflare": {
"wrangler", "cloudflared",
"wrangler",
"cloudflared",
},
"digitalocean": {
"doctl",
+8 -8
View File
@@ -7,9 +7,9 @@ Utilities for reading and parsing project configuration files
"""
import json
import tomllib
from pathlib import Path
from typing import Optional
import tomllib
class ConfigParser:
@@ -24,15 +24,15 @@ class ConfigParser:
"""
self.project_dir = Path(project_dir).resolve()
def read_json(self, filename: str) -> Optional[dict]:
def read_json(self, filename: str) -> dict | None:
"""Read a JSON file from project root."""
try:
with open(self.project_dir / filename, "r") as f:
with open(self.project_dir / filename) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def read_toml(self, filename: str) -> Optional[dict]:
def read_toml(self, filename: str) -> dict | None:
"""Read a TOML file from project root."""
try:
with open(self.project_dir / filename, "rb") as f:
@@ -40,12 +40,12 @@ class ConfigParser:
except (FileNotFoundError, tomllib.TOMLDecodeError):
return None
def read_text(self, filename: str) -> Optional[str]:
def read_text(self, filename: str) -> str | None:
"""Read a text file from project root."""
try:
with open(self.project_dir / filename, "r") as f:
with open(self.project_dir / filename) as f:
return f.read()
except (FileNotFoundError, IOError):
except (OSError, FileNotFoundError):
return None
def file_exists(self, *paths: str) -> bool:
+9 -4
View File
@@ -8,6 +8,7 @@ Detects frameworks and libraries from package dependencies
import re
from pathlib import Path
from .config_parser import ConfigParser
@@ -134,7 +135,7 @@ class FrameworkDetector:
if "project" in toml:
for dep in toml["project"].get("dependencies", []):
# Parse "package>=1.0" style
match = re.match(r'^([a-zA-Z0-9_-]+)', dep)
match = re.match(r"^([a-zA-Z0-9_-]+)", dep)
if match:
python_deps.add(match.group(1).lower())
@@ -142,18 +143,22 @@ class FrameworkDetector:
if "project" in toml and "optional-dependencies" in toml["project"]:
for group_deps in toml["project"]["optional-dependencies"].values():
for dep in group_deps:
match = re.match(r'^([a-zA-Z0-9_-]+)', dep)
match = re.match(r"^([a-zA-Z0-9_-]+)", dep)
if match:
python_deps.add(match.group(1).lower())
# Parse requirements.txt
for req_file in ["requirements.txt", "requirements-dev.txt", "requirements/dev.txt"]:
for req_file in [
"requirements.txt",
"requirements-dev.txt",
"requirements/dev.txt",
]:
content = self.parser.read_text(req_file)
if content:
for line in content.splitlines():
line = line.strip()
if line and not line.startswith("#") and not line.startswith("-"):
match = re.match(r'^([a-zA-Z0-9_-]+)', line)
match = re.match(r"^([a-zA-Z0-9_-]+)", line)
if match:
python_deps.add(match.group(1).lower())
+8 -5
View File
@@ -6,12 +6,13 @@ Core data structures for representing technology stacks,
custom scripts, and security profiles.
"""
from dataclasses import dataclass, field, asdict
from dataclasses import asdict, dataclass, field
@dataclass
class TechnologyStack:
"""Detected technologies in a project."""
languages: list[str] = field(default_factory=list)
package_managers: list[str] = field(default_factory=list)
frameworks: list[str] = field(default_factory=list)
@@ -25,6 +26,7 @@ class TechnologyStack:
@dataclass
class CustomScripts:
"""Detected custom scripts in the project."""
npm_scripts: list[str] = field(default_factory=list)
make_targets: list[str] = field(default_factory=list)
poetry_scripts: list[str] = field(default_factory=list)
@@ -35,6 +37,7 @@ class CustomScripts:
@dataclass
class SecurityProfile:
"""Complete security profile for a project."""
# Command sets
base_commands: set[str] = field(default_factory=set)
stack_commands: set[str] = field(default_factory=set)
@@ -53,10 +56,10 @@ class SecurityProfile:
def get_all_allowed_commands(self) -> set[str]:
"""Get the complete set of allowed commands."""
return (
self.base_commands |
self.stack_commands |
self.script_commands |
self.custom_commands
self.base_commands
| self.stack_commands
| self.script_commands
| self.custom_commands
)
def to_dict(self) -> dict:
+43 -10
View File
@@ -7,6 +7,7 @@ infrastructure tools, and cloud providers from project files.
"""
from pathlib import Path
from .config_parser import ConfigParser
from .models import TechnologyStack
@@ -44,7 +45,14 @@ class StackDetector:
def detect_languages(self) -> None:
"""Detect programming languages used."""
# Python
if self.parser.file_exists("*.py", "**/*.py", "pyproject.toml", "requirements.txt", "setup.py", "Pipfile"):
if self.parser.file_exists(
"*.py",
"**/*.py",
"pyproject.toml",
"requirements.txt",
"setup.py",
"Pipfile",
):
self.stack.languages.append("python")
# JavaScript
@@ -52,7 +60,9 @@ class StackDetector:
self.stack.languages.append("javascript")
# TypeScript
if self.parser.file_exists("*.ts", "*.tsx", "**/*.ts", "**/*.tsx", "tsconfig.json"):
if self.parser.file_exists(
"*.ts", "*.tsx", "**/*.ts", "**/*.tsx", "tsconfig.json"
):
self.stack.languages.append("typescript")
# Rust
@@ -88,7 +98,9 @@ class StackDetector:
self.stack.languages.append("csharp")
# C/C++
if self.parser.file_exists("*.c", "*.h", "**/*.c", "**/*.h", "CMakeLists.txt", "Makefile"):
if self.parser.file_exists(
"*.c", "*.h", "**/*.c", "**/*.h", "CMakeLists.txt", "Makefile"
):
self.stack.languages.append("c")
if self.parser.file_exists("*.cpp", "*.hpp", "*.cc", "**/*.cpp", "**/*.hpp"):
self.stack.languages.append("cpp")
@@ -182,7 +194,12 @@ class StackDetector:
self.stack.databases.append("sqlite")
# Check Docker Compose for database services
for compose_file in ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]:
for compose_file in [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
]:
content = self.parser.read_text(compose_file)
if content:
content_lower = content.lower()
@@ -203,7 +220,9 @@ class StackDetector:
def detect_infrastructure(self) -> None:
"""Detect infrastructure tools."""
# Docker
if self.parser.file_exists("Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".dockerignore"):
if self.parser.file_exists(
"Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".dockerignore"
):
self.stack.infrastructure.append("docker")
# Podman
@@ -211,16 +230,20 @@ class StackDetector:
self.stack.infrastructure.append("podman")
# Kubernetes
if self.parser.file_exists("k8s/", "kubernetes/", "*.yaml") or self.parser.glob_files("**/deployment.yaml"):
if self.parser.file_exists(
"k8s/", "kubernetes/", "*.yaml"
) or self.parser.glob_files("**/deployment.yaml"):
# Check if YAML files contain k8s resources
for yaml_file in self.parser.glob_files("**/*.yaml") + self.parser.glob_files("**/*.yml"):
for yaml_file in self.parser.glob_files(
"**/*.yaml"
) + self.parser.glob_files("**/*.yml"):
try:
with open(yaml_file) as f:
content = f.read()
if "apiVersion:" in content and "kind:" in content:
self.stack.infrastructure.append("kubernetes")
break
except IOError:
except OSError:
pass
# Helm
@@ -249,11 +272,21 @@ class StackDetector:
def detect_cloud_providers(self) -> None:
"""Detect cloud provider usage."""
# AWS
if self.parser.file_exists("aws/", ".aws/", "serverless.yml", "sam.yaml", "template.yaml", "cdk.json", "amplify.yml"):
if self.parser.file_exists(
"aws/",
".aws/",
"serverless.yml",
"sam.yaml",
"template.yaml",
"cdk.json",
"amplify.yml",
):
self.stack.cloud_providers.append("aws")
# GCP
if self.parser.file_exists("app.yaml", ".gcloudignore", "firebase.json", ".firebaserc"):
if self.parser.file_exists(
"app.yaml", ".gcloudignore", "firebase.json", ".firebaserc"
):
self.stack.cloud_providers.append("gcp")
# Azure
+6 -3
View File
@@ -9,6 +9,7 @@ command allowlists.
import re
from pathlib import Path
from .config_parser import ConfigParser
from .models import CustomScripts
@@ -73,11 +74,11 @@ class StructureAnalyzer:
for line in content.splitlines():
# Match target definitions like "target:" or "target: deps"
match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:', line)
match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:", line)
if match:
target = match.group(1)
# Skip common internal targets
if not target.startswith('.'):
if not target.startswith("."):
self.custom_scripts.make_targets.append(target)
if self.custom_scripts.make_targets:
@@ -96,7 +97,9 @@ class StructureAnalyzer:
# PEP 621 scripts
if "project" in toml and "scripts" in toml["project"]:
self.custom_scripts.poetry_scripts.extend(list(toml["project"]["scripts"].keys()))
self.custom_scripts.poetry_scripts.extend(
list(toml["project"]["scripts"].keys())
)
def _detect_shell_scripts(self) -> None:
"""Detect shell scripts in root directory."""
+9 -9
View File
@@ -29,29 +29,29 @@ needed for the detected tech stack, while blocking dangerous operations.
# Re-export all public API from the project module
from project import (
# Command registries
BASE_COMMANDS,
VALIDATED_COMMANDS,
CustomScripts,
# Main classes
ProjectAnalyzer,
SecurityProfile,
TechnologyStack,
CustomScripts,
# Utility functions
get_or_create_profile,
is_command_allowed,
needs_validation,
# Command registries
BASE_COMMANDS,
VALIDATED_COMMANDS,
)
# Also re-export command registries for backward compatibility
from project.command_registry import (
LANGUAGE_COMMANDS,
PACKAGE_MANAGER_COMMANDS,
FRAMEWORK_COMMANDS,
DATABASE_COMMANDS,
INFRASTRUCTURE_COMMANDS,
CLOUD_COMMANDS,
CODE_QUALITY_COMMANDS,
DATABASE_COMMANDS,
FRAMEWORK_COMMANDS,
INFRASTRUCTURE_COMMANDS,
LANGUAGE_COMMANDS,
PACKAGE_MANAGER_COMMANDS,
VERSION_MANAGER_COMMANDS,
)
+18 -13
View File
@@ -14,7 +14,6 @@ This approach:
import json
from pathlib import Path
from typing import Optional
def get_relative_spec_path(spec_dir: Path, project_dir: Path) -> str:
@@ -80,7 +79,7 @@ def generate_subtask_prompt(
subtask: dict,
phase: dict,
attempt_count: int = 0,
recovery_hints: Optional[list[str]] = None,
recovery_hints: list[str] | None = None,
) -> str:
"""
Generate a minimal, focused prompt for implementing a single subtask.
@@ -117,7 +116,7 @@ def generate_subtask_prompt(
sections.append(f"""# Subtask Implementation Task
**Subtask ID:** `{subtask_id}`
**Phase:** {phase.get('name', phase.get('id', 'Unknown'))}
**Phase:** {phase.get("name", phase.get("id", "Unknown"))}
**Service:** {service}
## Description
@@ -167,9 +166,9 @@ You MUST use a DIFFERENT approach than previous attempts.
if v_type == "command":
sections.append(f"""Run this command to verify:
```bash
{verification.get('command', 'echo "No command specified"')}
{verification.get("command", 'echo "No command specified"')}
```
Expected: {verification.get('expected', 'Success')}
Expected: {verification.get("expected", "Success")}
""")
elif v_type == "api":
method = verification.get("method", "GET")
@@ -178,7 +177,7 @@ Expected: {verification.get('expected', 'Success')}
expected_status = verification.get("expected_status", 200)
sections.append(f"""Test the API endpoint:
```bash
curl -X {method} {url} -H "Content-Type: application/json" {f'-d \'{json.dumps(body)}\'' if body else ''}
curl -X {method} {url} -H "Content-Type: application/json" {f"-d '{json.dumps(body)}'" if body else ""}
```
Expected status: {expected_status}
""")
@@ -202,7 +201,7 @@ Verify:""")
sections.append(f"**Manual Verification:**\n{instructions}\n")
# Instructions
sections.append("""## Instructions
sections.append(f"""## Instructions
1. **Read the pattern files** to understand code style and conventions
2. **Read the files to modify** (if any) to understand current implementation
@@ -211,7 +210,7 @@ Verify:""")
5. **Commit your changes:**
```bash
git add .
git commit -m "auto-claude: {subtask_id} - {short_description}"
git commit -m "auto-claude: {subtask_id} - {description[:50]}"
```
6. **Update the plan** - set this subtask's status to "completed" in implementation_plan.json
@@ -229,7 +228,7 @@ Before marking complete, verify:
- Focus ONLY on this subtask - don't modify unrelated code
- If verification fails, FIX IT before committing
- If you encounter a blocker, document it in build-progress.txt
""".format(subtask_id=subtask_id, short_description=description[:50]))
""")
# Note: Linear updates are now handled by Python orchestrator via linear_updater.py
# Agents no longer need to call Linear MCP tools directly
@@ -237,7 +236,7 @@ Before marking complete, verify:
return "\n".join(sections)
def generate_planner_prompt(spec_dir: Path, project_dir: Optional[Path] = None) -> str:
def generate_planner_prompt(spec_dir: Path, project_dir: Path | None = None) -> str:
"""
Generate the planner prompt (used only once at start).
This is a simplified version that focuses on plan creation.
@@ -256,7 +255,9 @@ def generate_planner_prompt(spec_dir: Path, project_dir: Optional[Path] = None)
if planner_file.exists():
prompt = planner_file.read_text()
else:
prompt = "Read spec.md and create implementation_plan.json with phases and subtasks."
prompt = (
"Read spec.md and create implementation_plan.json with phases and subtasks."
)
# Use project_dir for relative paths, or infer from spec_dir
if project_dir is None:
@@ -323,7 +324,9 @@ def load_subtask_context(
lines = full_path.read_text().split("\n")
if len(lines) > max_file_lines:
content = "\n".join(lines[:max_file_lines])
content += f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)"
content += (
f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)"
)
else:
content = "\n".join(lines)
context["patterns"][pattern_path] = content
@@ -338,7 +341,9 @@ def load_subtask_context(
lines = full_path.read_text().split("\n")
if len(lines) > max_file_lines:
content = "\n".join(lines[:max_file_lines])
content += f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)"
content += (
f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)"
)
else:
content = "\n".join(lines)
context["files_to_modify"][file_path] = content
+3 -4
View File
@@ -8,7 +8,6 @@ Functions for loading agent prompts from markdown files.
import json
from pathlib import Path
# Directory containing prompt files
PROMPTS_DIR = Path(__file__).parent / "prompts"
@@ -175,7 +174,7 @@ Subtasks with previous attempts:
return ""
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return ""
@@ -248,7 +247,7 @@ def is_first_run(spec_dir: Path) -> bool:
return True
try:
with open(plan_file, "r") as f:
with open(plan_file) as f:
plan = json.load(f)
# Check if there are any phases with subtasks
@@ -259,6 +258,6 @@ def is_first_run(spec_dir: Path) -> bool:
# Check if any phase has subtasks
total_subtasks = sum(len(phase.get("subtasks", [])) for phase in phases)
return total_subtasks == 0
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
# If we can't read the file, treat as first run
return True
+24 -29
View File
@@ -21,39 +21,42 @@ Module structure:
"""
# Configuration constants
from .loop import MAX_QA_ITERATIONS
from .report import RECURRING_ISSUE_THRESHOLD, ISSUE_SIMILARITY_THRESHOLD
# Main loop
from .loop import run_qa_validation_loop
# Criteria & status
from .criteria import (
load_implementation_plan,
save_implementation_plan,
get_qa_iteration_count,
get_qa_signoff_status,
is_fixes_applied,
is_qa_approved,
is_qa_rejected,
is_fixes_applied,
get_qa_iteration_count,
should_run_qa,
should_run_fixes,
load_implementation_plan,
print_qa_status,
save_implementation_plan,
should_run_fixes,
should_run_qa,
)
from .fixer import (
load_qa_fixer_prompt,
run_qa_fixer_session,
)
# Main loop
from .loop import MAX_QA_ITERATIONS, run_qa_validation_loop
# Report & tracking
from .report import (
get_iteration_history,
record_iteration,
has_recurring_issues,
get_recurring_issue_summary,
escalate_to_human,
create_manual_test_plan,
check_test_discovery,
is_no_test_project,
ISSUE_SIMILARITY_THRESHOLD,
RECURRING_ISSUE_THRESHOLD,
_issue_similarity,
# Private functions exposed for testing
_normalize_issue_key,
_issue_similarity,
check_test_discovery,
create_manual_test_plan,
escalate_to_human,
get_iteration_history,
get_recurring_issue_summary,
has_recurring_issues,
is_no_test_project,
record_iteration,
)
# Agent sessions
@@ -61,10 +64,6 @@ from .reviewer import (
load_qa_reviewer_prompt,
run_qa_agent_session,
)
from .fixer import (
load_qa_fixer_prompt,
run_qa_fixer_session,
)
# Public API
__all__ = [
@@ -72,10 +71,8 @@ __all__ = [
"MAX_QA_ITERATIONS",
"RECURRING_ISSUE_THRESHOLD",
"ISSUE_SIMILARITY_THRESHOLD",
# Main loop
"run_qa_validation_loop",
# Criteria & status
"load_implementation_plan",
"save_implementation_plan",
@@ -87,7 +84,6 @@ __all__ = [
"should_run_qa",
"should_run_fixes",
"print_qa_status",
# Report & tracking
"get_iteration_history",
"record_iteration",
@@ -99,7 +95,6 @@ __all__ = [
"is_no_test_project",
"_normalize_issue_key",
"_issue_similarity",
# Agent sessions
"load_qa_reviewer_prompt",
"run_qa_agent_session",
+16 -13
View File
@@ -6,19 +6,16 @@ Manages acceptance criteria validation and status tracking.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from progress import count_subtasks, is_build_complete
from progress import is_build_complete
# =============================================================================
# IMPLEMENTATION PLAN I/O
# =============================================================================
def load_implementation_plan(spec_dir: Path) -> Optional[dict]:
def load_implementation_plan(spec_dir: Path) -> dict | None:
"""Load the implementation plan JSON."""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
@@ -26,7 +23,7 @@ def load_implementation_plan(spec_dir: Path) -> Optional[dict]:
try:
with open(plan_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
@@ -37,7 +34,7 @@ def save_implementation_plan(spec_dir: Path, plan: dict) -> bool:
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
return True
except IOError:
except OSError:
return False
@@ -46,7 +43,7 @@ def save_implementation_plan(spec_dir: Path, plan: dict) -> bool:
# =============================================================================
def get_qa_signoff_status(spec_dir: Path) -> Optional[dict]:
def get_qa_signoff_status(spec_dir: Path) -> dict | None:
"""Get the current QA sign-off status from implementation plan."""
plan = load_implementation_plan(spec_dir)
if not plan:
@@ -75,7 +72,9 @@ def is_fixes_applied(spec_dir: Path) -> bool:
status = get_qa_signoff_status(spec_dir)
if not status:
return False
return status.get("status") == "fixes_applied" and status.get("ready_for_qa_revalidation", False)
return status.get("status") == "fixes_applied" and status.get(
"ready_for_qa_revalidation", False
)
def get_qa_iteration_count(spec_dir: Path) -> int:
@@ -153,12 +152,16 @@ def print_qa_status(spec_dir: Path) -> None:
if qa_status == "approved":
tests = status.get("tests_passed", {})
print(f"Tests: Unit {tests.get('unit', '?')}, Integration {tests.get('integration', '?')}, E2E {tests.get('e2e', '?')}")
print(
f"Tests: Unit {tests.get('unit', '?')}, Integration {tests.get('integration', '?')}, E2E {tests.get('e2e', '?')}"
)
elif qa_status == "rejected":
issues = status.get("issues_found", [])
print(f"Issues Found: {len(issues)}")
for issue in issues[:3]: # Show first 3
print(f" - {issue.get('title', 'Unknown')}: {issue.get('type', 'unknown')}")
print(
f" - {issue.get('title', 'Unknown')}: {issue.get('type', 'unknown')}"
)
if len(issues) > 3:
print(f" ... and {len(issues) - 3} more")
@@ -166,11 +169,11 @@ def print_qa_status(spec_dir: Path) -> None:
history = get_iteration_history(spec_dir)
if history:
summary = get_recurring_issue_summary(history)
print(f"\nIteration History:")
print("\nIteration History:")
print(f" Total iterations: {len(history)}")
print(f" Approved: {summary.get('iterations_approved', 0)}")
print(f" Rejected: {summary.get('iterations_rejected', 0)}")
if summary.get("most_common"):
print(f" Most common issues:")
print(" Most common issues:")
for issue in summary["most_common"][:3]:
print(f" - {issue['title']} ({issue['occurrences']} occurrences)")
+35 -9
View File
@@ -8,14 +8,13 @@ Runs QA fixer sessions to resolve issues identified by the reviewer.
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient
from task_logger import (
LogPhase,
LogEntryType,
LogPhase,
get_task_logger,
)
from .criteria import get_qa_signoff_status
from .criteria import get_qa_signoff_status
# Configuration
QA_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
@@ -61,7 +60,7 @@ async def run_qa_fixer_session(
"""
print(f"\n{'=' * 70}")
print(f" QA FIXER SESSION {fix_session}")
print(f" Applying fixes from QA_FIX_REQUEST.md...")
print(" Applying fixes from QA_FIX_REQUEST.md...")
print(f"{'=' * 70}\n")
# Get task logger for streaming markers
@@ -99,7 +98,12 @@ async def run_qa_fixer_session(
print(block.text, end="", flush=True)
# Log text to task logger (persist without double-printing)
if task_logger and block.text.strip():
task_logger.log(block.text, LogEntryType.TEXT, LogPhase.VALIDATION, print_to_console=False)
task_logger.log(
block.text,
LogEntryType.TEXT,
LogPhase.VALIDATION,
print_to_console=False,
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input = None
@@ -120,7 +124,12 @@ async def run_qa_fixer_session(
# Log tool start (handles printing)
if task_logger:
task_logger.tool_start(tool_name, tool_input, LogPhase.VALIDATION, print_to_console=True)
task_logger.tool_start(
tool_name,
tool_input,
LogPhase.VALIDATION,
print_to_console=True,
)
else:
print(f"\n[Fixer Tool: {tool_name}]", flush=True)
@@ -145,7 +154,13 @@ async def run_qa_fixer_session(
print(f" [Error] {error_str}", flush=True)
if task_logger and current_tool:
# Store full error in detail for expandable view
task_logger.tool_end(current_tool, success=False, result=error_str[:100], detail=str(result_content), phase=LogPhase.VALIDATION)
task_logger.tool_end(
current_tool,
success=False,
result=error_str[:100],
detail=str(result_content),
phase=LogPhase.VALIDATION,
)
else:
if verbose:
result_str = str(result_content)[:200]
@@ -155,11 +170,22 @@ async def run_qa_fixer_session(
if task_logger and current_tool:
# Store full result in detail for expandable view
detail_content = None
if current_tool in ("Read", "Grep", "Bash", "Edit", "Write"):
if current_tool in (
"Read",
"Grep",
"Bash",
"Edit",
"Write",
):
result_str = str(result_content)
if len(result_str) < 50000:
detail_content = result_str
task_logger.tool_end(current_tool, success=True, detail=detail_content, phase=LogPhase.VALIDATION)
task_logger.tool_end(
current_tool,
success=True,
detail=detail_content,
phase=LogPhase.VALIDATION,
)
current_tool = None
+50 -25
View File
@@ -9,38 +9,37 @@ approval or max iterations.
import time as time_module
from pathlib import Path
from progress import count_subtasks, is_build_complete
from client import create_client
from linear_updater import (
is_linear_enabled,
LinearTaskState,
linear_qa_started,
is_linear_enabled,
linear_qa_approved,
linear_qa_rejected,
linear_qa_max_iterations,
linear_qa_rejected,
linear_qa_started,
)
from progress import count_subtasks, is_build_complete
from task_logger import (
LogPhase,
get_task_logger,
)
from client import create_client
from .criteria import (
is_qa_approved,
get_qa_iteration_count,
get_qa_signoff_status,
is_qa_approved,
)
from .fixer import run_qa_fixer_session
from .report import (
get_iteration_history,
record_iteration,
has_recurring_issues,
get_recurring_issue_summary,
escalate_to_human,
is_no_test_project,
create_manual_test_plan,
escalate_to_human,
get_iteration_history,
get_recurring_issue_summary,
has_recurring_issues,
is_no_test_project,
record_iteration,
)
from .reviewer import run_qa_agent_session
from .fixer import run_qa_fixer_session
# Configuration
MAX_QA_ITERATIONS = 50
@@ -155,7 +154,11 @@ async def run_qa_validation_loop(
# End validation phase successfully
if task_logger:
task_logger.end_phase(LogPhase.VALIDATION, success=True, message="QA validation passed - all criteria met")
task_logger.end_phase(
LogPhase.VALIDATION,
success=True,
message="QA validation passed - all criteria met",
)
# Update Linear: QA approved, awaiting human review
if linear_task and linear_task.task_id:
@@ -172,16 +175,22 @@ async def run_qa_validation_loop(
current_issues = qa_status.get("issues_found", []) if qa_status else []
# Record rejected iteration
record_iteration(spec_dir, qa_iteration, "rejected", current_issues, iteration_duration)
record_iteration(
spec_dir, qa_iteration, "rejected", current_issues, iteration_duration
)
# Check for recurring issues
history = get_iteration_history(spec_dir)
has_recurring, recurring_issues = has_recurring_issues(current_issues, history)
has_recurring, recurring_issues = has_recurring_issues(
current_issues, history
)
if has_recurring:
from .report import RECURRING_ISSUE_THRESHOLD
print(f"\n⚠️ Recurring issues detected ({len(recurring_issues)} issue(s) appeared {RECURRING_ISSUE_THRESHOLD}+ times)")
print(
f"\n⚠️ Recurring issues detected ({len(recurring_issues)} issue(s) appeared {RECURRING_ISSUE_THRESHOLD}+ times)"
)
print("Escalating to human review due to recurring issues...")
# Create escalation file
@@ -192,13 +201,15 @@ async def run_qa_validation_loop(
task_logger.end_phase(
LogPhase.VALIDATION,
success=False,
message=f"QA escalated to human after {qa_iteration} iterations due to recurring issues"
message=f"QA escalated to human after {qa_iteration} iterations due to recurring issues",
)
# Update Linear
if linear_task and linear_task.task_id:
await linear_qa_max_iterations(spec_dir, qa_iteration)
print("\nLinear: Task marked as needing human intervention (recurring issues)")
print(
"\nLinear: Task marked as needing human intervention (recurring issues)"
)
return False
@@ -224,14 +235,24 @@ async def run_qa_validation_loop(
if fix_status == "error":
print(f"\n❌ Fixer encountered error: {fix_response}")
record_iteration(spec_dir, qa_iteration, "error", [{"title": "Fixer error", "description": fix_response}])
record_iteration(
spec_dir,
qa_iteration,
"error",
[{"title": "Fixer error", "description": fix_response}],
)
break
print("\n✅ Fixes applied. Re-running QA validation...")
elif status == "error":
print(f"\n❌ QA error: {response}")
record_iteration(spec_dir, qa_iteration, "error", [{"title": "QA error", "description": response}])
record_iteration(
spec_dir,
qa_iteration,
"error",
[{"title": "QA error", "description": response}],
)
print("Retrying...")
# Max iterations reached without approval
@@ -245,18 +266,22 @@ async def run_qa_validation_loop(
history = get_iteration_history(spec_dir)
summary = get_recurring_issue_summary(history)
if summary["total_issues"] > 0:
print(f"\n📊 Iteration Summary:")
print("\n📊 Iteration Summary:")
print(f" Total iterations: {len(history)}")
print(f" Total issues found: {summary['total_issues']}")
print(f" Unique issues: {summary['unique_issues']}")
if summary.get("most_common"):
print(f" Most common issues:")
print(" Most common issues:")
for issue in summary["most_common"][:3]:
print(f" - {issue['title']} ({issue['occurrences']} occurrences)")
# End validation phase as failed
if task_logger:
task_logger.end_phase(LogPhase.VALIDATION, success=False, message=f"QA validation incomplete after {qa_iteration} iterations")
task_logger.end_phase(
LogPhase.VALIDATION,
success=False,
message=f"QA validation incomplete after {qa_iteration} iterations",
)
# Show the fix request file if it exists
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
+49 -47
View File
@@ -11,11 +11,10 @@ from collections import Counter
from datetime import datetime, timezone
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from .criteria import load_implementation_plan, save_implementation_plan
# Configuration
RECURRING_ISSUE_THRESHOLD = 3 # Escalate if same issue appears this many times
ISSUE_SIMILARITY_THRESHOLD = 0.8 # Consider issues "same" if similarity >= this
@@ -26,7 +25,7 @@ ISSUE_SIMILARITY_THRESHOLD = 0.8 # Consider issues "same" if similarity >= this
# =============================================================================
def get_iteration_history(spec_dir: Path) -> List[Dict[str, Any]]:
def get_iteration_history(spec_dir: Path) -> list[dict[str, Any]]:
"""
Get the full iteration history from implementation_plan.json.
@@ -43,8 +42,8 @@ def record_iteration(
spec_dir: Path,
iteration: int,
status: str,
issues: List[Dict[str, Any]],
duration_seconds: Optional[float] = None,
issues: list[dict[str, Any]],
duration_seconds: float | None = None,
) -> bool:
"""
Record a QA iteration to the history.
@@ -101,7 +100,7 @@ def record_iteration(
# =============================================================================
def _normalize_issue_key(issue: Dict[str, Any]) -> str:
def _normalize_issue_key(issue: dict[str, Any]) -> str:
"""
Create a normalized key for issue comparison.
@@ -114,12 +113,12 @@ def _normalize_issue_key(issue: Dict[str, Any]) -> str:
# Remove common prefixes/suffixes that might differ between iterations
for prefix in ["error:", "issue:", "bug:", "fix:"]:
if title.startswith(prefix):
title = title[len(prefix):].strip()
title = title[len(prefix) :].strip()
return f"{title}|{file}|{line}"
def _issue_similarity(issue1: Dict[str, Any], issue2: Dict[str, Any]) -> float:
def _issue_similarity(issue1: dict[str, Any], issue2: dict[str, Any]) -> float:
"""
Calculate similarity between two issues.
@@ -135,10 +134,10 @@ def _issue_similarity(issue1: Dict[str, Any], issue2: Dict[str, Any]) -> float:
def has_recurring_issues(
current_issues: List[Dict[str, Any]],
history: List[Dict[str, Any]],
current_issues: list[dict[str, Any]],
history: list[dict[str, Any]],
threshold: int = RECURRING_ISSUE_THRESHOLD,
) -> Tuple[bool, List[Dict[str, Any]]]:
) -> tuple[bool, list[dict[str, Any]]]:
"""
Check if any current issues have appeared repeatedly in history.
@@ -169,17 +168,19 @@ def has_recurring_issues(
occurrence_count += 1
if occurrence_count >= threshold:
recurring.append({
**current,
"occurrence_count": occurrence_count,
})
recurring.append(
{
**current,
"occurrence_count": occurrence_count,
}
)
return len(recurring) > 0, recurring
def get_recurring_issue_summary(
history: List[Dict[str, Any]],
) -> Dict[str, Any]:
history: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Analyze iteration history for issue patterns.
@@ -194,14 +195,17 @@ def get_recurring_issue_summary(
return {"total_issues": 0, "unique_issues": 0, "most_common": []}
# Group similar issues
issue_groups: Dict[str, List[Dict[str, Any]]] = {}
issue_groups: dict[str, list[dict[str, Any]]] = {}
for issue in all_issues:
key = _normalize_issue_key(issue)
matched = False
for existing_key in issue_groups:
if SequenceMatcher(None, key, existing_key).ratio() >= ISSUE_SIMILARITY_THRESHOLD:
if (
SequenceMatcher(None, key, existing_key).ratio()
>= ISSUE_SIMILARITY_THRESHOLD
):
issue_groups[existing_key].append(issue)
matched = True
break
@@ -210,19 +214,17 @@ def get_recurring_issue_summary(
issue_groups[key] = [issue]
# Find most common issues
sorted_groups = sorted(
issue_groups.items(),
key=lambda x: len(x[1]),
reverse=True
)
sorted_groups = sorted(issue_groups.items(), key=lambda x: len(x[1]), reverse=True)
most_common = []
for key, issues in sorted_groups[:5]: # Top 5
most_common.append({
"title": issues[0].get("title", key),
"file": issues[0].get("file"),
"occurrences": len(issues),
})
most_common.append(
{
"title": issues[0].get("title", key),
"file": issues[0].get("file"),
"occurrences": len(issues),
}
)
# Calculate statistics
approved_count = sum(1 for r in history if r.get("status") == "approved")
@@ -245,7 +247,7 @@ def get_recurring_issue_summary(
async def escalate_to_human(
spec_dir: Path,
recurring_issues: List[Dict[str, Any]],
recurring_issues: list[dict[str, Any]],
iteration: int,
) -> None:
"""
@@ -272,9 +274,9 @@ async def escalate_to_human(
## Summary
- **Total QA Iterations**: {len(history)}
- **Total Issues Found**: {summary['total_issues']}
- **Unique Issues**: {summary['unique_issues']}
- **Fix Success Rate**: {summary['fix_success_rate']:.1%}
- **Total Issues Found**: {summary["total_issues"]}
- **Unique Issues**: {summary["unique_issues"]}
- **Fix Success Rate**: {summary["fix_success_rate"]:.1%}
## Recurring Issues
@@ -283,13 +285,13 @@ These issues have appeared {RECURRING_ISSUE_THRESHOLD}+ times without being reso
"""
for i, issue in enumerate(recurring_issues, 1):
content += f"""### {i}. {issue.get('title', 'Unknown Issue')}
content += f"""### {i}. {issue.get("title", "Unknown Issue")}
- **File**: {issue.get('file', 'N/A')}
- **Line**: {issue.get('line', 'N/A')}
- **Type**: {issue.get('type', 'N/A')}
- **Occurrences**: {issue.get('occurrence_count', 'N/A')}
- **Description**: {issue.get('description', 'No description')}
- **File**: {issue.get("file", "N/A")}
- **Line**: {issue.get("line", "N/A")}
- **Type**: {issue.get("type", "N/A")}
- **Occurrences**: {issue.get("occurrence_count", "N/A")}
- **Description**: {issue.get("description", "No description")}
"""
@@ -446,7 +448,7 @@ _Add any observations or issues found during testing_
# =============================================================================
def check_test_discovery(spec_dir: Path) -> Optional[Dict[str, Any]]:
def check_test_discovery(spec_dir: Path) -> dict[str, Any] | None:
"""
Check if test discovery has been run and what frameworks were found.
@@ -460,7 +462,7 @@ def check_test_discovery(spec_dir: Path) -> Optional[Dict[str, Any]]:
try:
with open(discovery_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
return None
@@ -509,12 +511,12 @@ def is_no_test_project(spec_dir: Path, project_dir: Path) -> bool:
# Check if directory has test files
for f in test_path.iterdir():
if f.is_file() and (
f.name.startswith("test_") or
f.name.endswith("_test.py") or
f.name.endswith(".spec.js") or
f.name.endswith(".spec.ts") or
f.name.endswith(".test.js") or
f.name.endswith(".test.ts")
f.name.startswith("test_")
or f.name.endswith("_test.py")
or f.name.endswith(".spec.js")
or f.name.endswith(".spec.ts")
or f.name.endswith(".test.js")
or f.name.endswith(".test.ts")
):
return False
+35 -9
View File
@@ -9,14 +9,13 @@ acceptance criteria.
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient
from task_logger import (
LogPhase,
LogEntryType,
LogPhase,
get_task_logger,
)
from .criteria import get_qa_signoff_status
from .criteria import get_qa_signoff_status
# Configuration
QA_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
@@ -65,7 +64,7 @@ async def run_qa_agent_session(
"""
print(f"\n{'=' * 70}")
print(f" QA REVIEWER SESSION {qa_session}")
print(f" Validating all acceptance criteria...")
print(" Validating all acceptance criteria...")
print(f"{'=' * 70}\n")
# Get task logger for streaming markers
@@ -99,7 +98,12 @@ async def run_qa_agent_session(
print(block.text, end="", flush=True)
# Log text to task logger (persist without double-printing)
if task_logger and block.text.strip():
task_logger.log(block.text, LogEntryType.TEXT, LogPhase.VALIDATION, print_to_console=False)
task_logger.log(
block.text,
LogEntryType.TEXT,
LogPhase.VALIDATION,
print_to_console=False,
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input = None
@@ -118,7 +122,12 @@ async def run_qa_agent_session(
# Log tool start (handles printing)
if task_logger:
task_logger.tool_start(tool_name, tool_input, LogPhase.VALIDATION, print_to_console=True)
task_logger.tool_start(
tool_name,
tool_input,
LogPhase.VALIDATION,
print_to_console=True,
)
else:
print(f"\n[QA Tool: {tool_name}]", flush=True)
@@ -143,7 +152,13 @@ async def run_qa_agent_session(
print(f" [Error] {error_str}", flush=True)
if task_logger and current_tool:
# Store full error in detail for expandable view
task_logger.tool_end(current_tool, success=False, result=error_str[:100], detail=str(result_content), phase=LogPhase.VALIDATION)
task_logger.tool_end(
current_tool,
success=False,
result=error_str[:100],
detail=str(result_content),
phase=LogPhase.VALIDATION,
)
else:
if verbose:
result_str = str(result_content)[:200]
@@ -153,11 +168,22 @@ async def run_qa_agent_session(
if task_logger and current_tool:
# Store full result in detail for expandable view
detail_content = None
if current_tool in ("Read", "Grep", "Bash", "Edit", "Write"):
if current_tool in (
"Read",
"Grep",
"Bash",
"Edit",
"Write",
):
result_str = str(result_content)
if len(result_str) < 50000:
detail_content = result_str
task_logger.tool_end(current_tool, success=True, detail=detail_content, phase=LogPhase.VALIDATION)
task_logger.tool_end(
current_tool,
success=True,
detail=detail_content,
phase=LogPhase.VALIDATION,
)
current_tool = None
+22 -30
View File
@@ -24,43 +24,39 @@ Enhanced features:
# Re-export everything from the qa package for backward compatibility
from qa import (
ISSUE_SIMILARITY_THRESHOLD,
# Configuration
MAX_QA_ITERATIONS,
RECURRING_ISSUE_THRESHOLD,
ISSUE_SIMILARITY_THRESHOLD,
# Main loop
run_qa_validation_loop,
# Criteria & status
load_implementation_plan,
save_implementation_plan,
get_qa_signoff_status,
is_qa_approved,
is_qa_rejected,
is_fixes_applied,
get_qa_iteration_count,
should_run_qa,
should_run_fixes,
print_qa_status,
_issue_similarity,
_normalize_issue_key,
check_test_discovery,
create_manual_test_plan,
escalate_to_human,
# Report & tracking
get_iteration_history,
record_iteration,
has_recurring_issues,
get_qa_iteration_count,
get_qa_signoff_status,
get_recurring_issue_summary,
escalate_to_human,
create_manual_test_plan,
check_test_discovery,
has_recurring_issues,
is_fixes_applied,
is_no_test_project,
_normalize_issue_key,
_issue_similarity,
is_qa_approved,
is_qa_rejected,
# Criteria & status
load_implementation_plan,
load_qa_fixer_prompt,
# Agent sessions
load_qa_reviewer_prompt,
print_qa_status,
record_iteration,
run_qa_agent_session,
load_qa_fixer_prompt,
run_qa_fixer_session,
# Main loop
run_qa_validation_loop,
save_implementation_plan,
should_run_fixes,
should_run_qa,
)
# Maintain original __all__ for explicit exports
@@ -69,10 +65,8 @@ __all__ = [
"MAX_QA_ITERATIONS",
"RECURRING_ISSUE_THRESHOLD",
"ISSUE_SIMILARITY_THRESHOLD",
# Main loop
"run_qa_validation_loop",
# Criteria & status
"load_implementation_plan",
"save_implementation_plan",
@@ -84,7 +78,6 @@ __all__ = [
"should_run_qa",
"should_run_fixes",
"print_qa_status",
# Report & tracking
"get_iteration_history",
"record_iteration",
@@ -96,7 +89,6 @@ __all__ = [
"is_no_test_project",
"_normalize_issue_key",
"_issue_similarity",
# Agent sessions
"load_qa_reviewer_prompt",
"run_qa_agent_session",
+82 -63
View File
@@ -19,21 +19,22 @@ from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional
class FailureType(Enum):
"""Types of failures that can occur during autonomous builds."""
BROKEN_BUILD = "broken_build" # Code doesn't compile/run
BROKEN_BUILD = "broken_build" # Code doesn't compile/run
VERIFICATION_FAILED = "verification_failed" # Subtask verification failed
CIRCULAR_FIX = "circular_fix" # Same fix attempted multiple times
CONTEXT_EXHAUSTED = "context_exhausted" # Ran out of context mid-subtask
CIRCULAR_FIX = "circular_fix" # Same fix attempted multiple times
CONTEXT_EXHAUSTED = "context_exhausted" # Ran out of context mid-subtask
UNKNOWN = "unknown"
@dataclass
class RecoveryAction:
"""Action to take in response to a failure."""
action: str # "rollback", "retry", "skip", "escalate"
target: str # commit hash, subtask id, or message
reason: str
@@ -82,8 +83,8 @@ class RecoveryManager:
"stuck_subtasks": [],
"metadata": {
"created_at": datetime.now().isoformat(),
"last_updated": datetime.now().isoformat()
}
"last_updated": datetime.now().isoformat(),
},
}
with open(self.attempt_history_file, "w") as f:
json.dump(initial_data, f, indent=2)
@@ -95,8 +96,8 @@ class RecoveryManager:
"last_good_commit": None,
"metadata": {
"created_at": datetime.now().isoformat(),
"last_updated": datetime.now().isoformat()
}
"last_updated": datetime.now().isoformat(),
},
}
with open(self.build_commits_file, "w") as f:
json.dump(initial_data, f, indent=2)
@@ -104,11 +105,11 @@ class RecoveryManager:
def _load_attempt_history(self) -> dict:
"""Load attempt history from JSON file."""
try:
with open(self.attempt_history_file, "r") as f:
with open(self.attempt_history_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
self._init_attempt_history()
with open(self.attempt_history_file, "r") as f:
with open(self.attempt_history_file) as f:
return json.load(f)
def _save_attempt_history(self, data: dict) -> None:
@@ -120,11 +121,11 @@ class RecoveryManager:
def _load_build_commits(self) -> dict:
"""Load build commits from JSON file."""
try:
with open(self.build_commits_file, "r") as f:
with open(self.build_commits_file) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
except (OSError, json.JSONDecodeError):
self._init_build_commits()
with open(self.build_commits_file, "r") as f:
with open(self.build_commits_file) as f:
return json.load(f)
def _save_build_commits(self, data: dict) -> None:
@@ -148,25 +149,31 @@ class RecoveryManager:
# Check for broken build indicators
build_errors = [
"syntax error", "compilation error", "module not found",
"import error", "cannot find module", "unexpected token",
"indentation error", "parse error"
"syntax error",
"compilation error",
"module not found",
"import error",
"cannot find module",
"unexpected token",
"indentation error",
"parse error",
]
if any(be in error_lower for be in build_errors):
return FailureType.BROKEN_BUILD
# Check for verification failures
verification_errors = [
"verification failed", "expected", "assertion",
"test failed", "status code"
"verification failed",
"expected",
"assertion",
"test failed",
"status code",
]
if any(ve in error_lower for ve in verification_errors):
return FailureType.VERIFICATION_FAILED
# Check for context exhaustion
context_errors = [
"context", "token limit", "maximum length"
]
context_errors = ["context", "token limit", "maximum length"]
if any(ce in error_lower for ce in context_errors):
return FailureType.CONTEXT_EXHAUSTED
@@ -196,7 +203,7 @@ class RecoveryManager:
session: int,
success: bool,
approach: str,
error: Optional[str] = None
error: str | None = None,
) -> None:
"""
Record an attempt at a subtask.
@@ -212,10 +219,7 @@ class RecoveryManager:
# Initialize subtask entry if it doesn't exist
if subtask_id not in history["subtasks"]:
history["subtasks"][subtask_id] = {
"attempts": [],
"status": "pending"
}
history["subtasks"][subtask_id] = {"attempts": [], "status": "pending"}
# Add the attempt
attempt = {
@@ -223,7 +227,7 @@ class RecoveryManager:
"timestamp": datetime.now().isoformat(),
"approach": approach,
"success": success,
"error": error
"error": error,
}
history["subtasks"][subtask_id]["attempts"].append(attempt)
@@ -258,16 +262,31 @@ class RecoveryManager:
recent_attempts = attempts[-3:] if len(attempts) >= 3 else attempts
# Extract key terms from current approach (ignore common words)
stop_words = {'with', 'using', 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'trying'}
stop_words = {
"with",
"using",
"the",
"a",
"an",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"trying",
}
current_keywords = set(
word for word in current_approach.lower().split()
if word not in stop_words
word for word in current_approach.lower().split() if word not in stop_words
)
similar_count = 0
for attempt in recent_attempts:
attempt_keywords = set(
word for word in attempt["approach"].lower().split()
word
for word in attempt["approach"].lower().split()
if word not in stop_words
)
@@ -287,9 +306,7 @@ class RecoveryManager:
return similar_count >= 2
def determine_recovery_action(
self,
failure_type: FailureType,
subtask_id: str
self, failure_type: FailureType, subtask_id: str
) -> RecoveryAction:
"""
Decide what to do based on failure type and history.
@@ -310,13 +327,13 @@ class RecoveryManager:
return RecoveryAction(
action="rollback",
target=last_good,
reason=f"Build broken in subtask {subtask_id}, rolling back to working state"
reason=f"Build broken in subtask {subtask_id}, rolling back to working state",
)
else:
return RecoveryAction(
action="escalate",
target=subtask_id,
reason="Build broken and no good commit found to rollback to"
reason="Build broken and no good commit found to rollback to",
)
elif failure_type == FailureType.VERIFICATION_FAILED:
@@ -325,13 +342,13 @@ class RecoveryManager:
return RecoveryAction(
action="retry",
target=subtask_id,
reason=f"Verification failed, retry with different approach (attempt {attempt_count + 1}/3)"
reason=f"Verification failed, retry with different approach (attempt {attempt_count + 1}/3)",
)
else:
return RecoveryAction(
action="skip",
target=subtask_id,
reason=f"Verification failed after {attempt_count} attempts, marking as stuck"
reason=f"Verification failed after {attempt_count} attempts, marking as stuck",
)
elif failure_type == FailureType.CIRCULAR_FIX:
@@ -339,7 +356,7 @@ class RecoveryManager:
return RecoveryAction(
action="skip",
target=subtask_id,
reason="Circular fix detected - same approach tried multiple times"
reason="Circular fix detected - same approach tried multiple times",
)
elif failure_type == FailureType.CONTEXT_EXHAUSTED:
@@ -347,7 +364,7 @@ class RecoveryManager:
return RecoveryAction(
action="continue",
target=subtask_id,
reason="Context exhausted, will commit progress and continue in next session"
reason="Context exhausted, will commit progress and continue in next session",
)
else: # UNKNOWN
@@ -356,16 +373,16 @@ class RecoveryManager:
return RecoveryAction(
action="retry",
target=subtask_id,
reason=f"Unknown error, retrying (attempt {attempt_count + 1}/2)"
reason=f"Unknown error, retrying (attempt {attempt_count + 1}/2)",
)
else:
return RecoveryAction(
action="escalate",
target=subtask_id,
reason=f"Unknown error persists after {attempt_count} attempts"
reason=f"Unknown error persists after {attempt_count} attempts",
)
def get_last_good_commit(self) -> Optional[str]:
def get_last_good_commit(self) -> str | None:
"""
Find the most recent commit where build was working.
@@ -388,7 +405,7 @@ class RecoveryManager:
commit_record = {
"hash": commit_hash,
"subtask_id": subtask_id,
"timestamp": datetime.now().isoformat()
"timestamp": datetime.now().isoformat(),
}
commits["commits"].append(commit_record)
@@ -413,7 +430,7 @@ class RecoveryManager:
cwd=self.project_dir,
capture_output=True,
text=True,
check=True
check=True,
)
return True
except subprocess.CalledProcessError as e:
@@ -434,11 +451,13 @@ class RecoveryManager:
"subtask_id": subtask_id,
"reason": reason,
"escalated_at": datetime.now().isoformat(),
"attempt_count": self.get_attempt_count(subtask_id)
"attempt_count": self.get_attempt_count(subtask_id),
}
# Check if already in stuck list
existing = [s for s in history["stuck_subtasks"] if s["subtask_id"] == subtask_id]
existing = [
s for s in history["stuck_subtasks"] if s["subtask_id"] == subtask_id
]
if not existing:
history["stuck_subtasks"].append(stuck_entry)
@@ -469,7 +488,9 @@ class RecoveryManager:
Subtask history dict with attempts
"""
history = self._load_attempt_history()
return history["subtasks"].get(subtask_id, {"attempts": [], "status": "pending"})
return history["subtasks"].get(
subtask_id, {"attempts": [], "status": "pending"}
)
def get_recovery_hints(self, subtask_id: str) -> list[str]:
"""
@@ -500,8 +521,12 @@ class RecoveryManager:
# Add guidance
if len(attempts) >= 2:
hints.append("\n⚠️ IMPORTANT: Try a DIFFERENT approach than previous attempts")
hints.append("Consider: different library, different pattern, or simpler implementation")
hints.append(
"\n⚠️ IMPORTANT: Try a DIFFERENT approach than previous attempts"
)
hints.append(
"Consider: different library, different pattern, or simpler implementation"
)
return hints
@@ -522,15 +547,11 @@ class RecoveryManager:
# Clear attempt history
if subtask_id in history["subtasks"]:
history["subtasks"][subtask_id] = {
"attempts": [],
"status": "pending"
}
history["subtasks"][subtask_id] = {"attempts": [], "status": "pending"}
# Remove from stuck subtasks
history["stuck_subtasks"] = [
s for s in history["stuck_subtasks"]
if s["subtask_id"] != subtask_id
s for s in history["stuck_subtasks"] if s["subtask_id"] != subtask_id
]
self._save_attempt_history(history)
@@ -538,12 +559,10 @@ class RecoveryManager:
# Utility functions for integration with agent.py
def check_and_recover(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
error: Optional[str] = None
) -> Optional[RecoveryAction]:
spec_dir: Path, project_dir: Path, subtask_id: str, error: str | None = None
) -> RecoveryAction | None:
"""
Check if recovery is needed and return appropriate action.
@@ -583,5 +602,5 @@ def get_recovery_context(spec_dir: Path, project_dir: Path, subtask_id: str) ->
"attempt_count": manager.get_attempt_count(subtask_id),
"hints": manager.get_recovery_hints(subtask_id),
"subtask_history": manager.get_subtask_history(subtask_id),
"stuck_subtasks": manager.get_stuck_subtasks()
"stuck_subtasks": manager.get_stuck_subtasks(),
}
+1 -16
View File
@@ -41,26 +41,11 @@ from pathlib import Path
# Re-export all public APIs from the review package
from review import (
# State management
ReviewState,
get_review_status_summary,
REVIEW_STATE_FILE,
# Display functions
display_spec_summary,
display_plan_summary,
display_review_status,
# Review orchestration
ReviewChoice,
# Display functions
run_review_checkpoint,
open_file_in_editor,
get_review_menu_options,
prompt_feedback,
# Utilities
extract_section,
extract_table_rows,
truncate_text,
)
from ui import print_status

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