Linting gods are happy
This commit is contained in:
@@ -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/
|
||||
|
||||
|
||||
@@ -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/**']
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+725
-364
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[]>>;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user