specdacular 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +3 -3
  2. package/bin/install.js +3 -1
  3. package/bin/specd.js +135 -0
  4. package/commands/specd.best-practices.md +75 -0
  5. package/commands/specd.docs.md +81 -0
  6. package/commands/specd.docs.review.md +80 -0
  7. package/commands/specd.generate-skills.learn.md +65 -0
  8. package/commands/specd.new-runner-task.md +52 -0
  9. package/commands/specd.new.md +6 -6
  10. package/commands/specd.runner-status.md +27 -0
  11. package/package.json +11 -3
  12. package/runner/main/agent/parser.js +39 -0
  13. package/runner/main/agent/runner.js +137 -0
  14. package/runner/main/agent/template.js +16 -0
  15. package/runner/main/bootstrap.js +69 -0
  16. package/runner/main/db.js +45 -0
  17. package/runner/main/index.js +103 -0
  18. package/runner/main/ipc.js +72 -0
  19. package/runner/main/notifications/telegram.js +45 -0
  20. package/runner/main/orchestrator.js +193 -0
  21. package/runner/main/paths.js +36 -0
  22. package/runner/main/pipeline/resolver.js +20 -0
  23. package/runner/main/pipeline/sequencer.js +42 -0
  24. package/runner/main/server/api.js +125 -0
  25. package/runner/main/server/index.js +33 -0
  26. package/runner/main/server/websocket.js +24 -0
  27. package/runner/main/state/manager.js +83 -0
  28. package/runner/main/template-manager.js +41 -0
  29. package/runner/main/test/agent-parser.test.js +44 -0
  30. package/runner/main/test/bootstrap.test.js +58 -0
  31. package/runner/main/test/db.test.js +72 -0
  32. package/runner/main/test/paths.test.js +29 -0
  33. package/runner/main/test/state-manager.test.js +72 -0
  34. package/runner/main/test/template-manager.test.js +66 -0
  35. package/runner/main/worktree/manager.js +95 -0
  36. package/runner/package.json +22 -0
  37. package/runner/preload.js +19 -0
  38. package/specdacular/HELP.md +14 -11
  39. package/specdacular/agents/best-practices-researcher.md +271 -0
  40. package/specdacular/references/load-context.md +4 -7
  41. package/specdacular/templates/orchestrator/CONCERNS.md +1 -1
  42. package/specdacular/templates/orchestrator/PROJECTS.md +3 -4
  43. package/specdacular/templates/tasks/PLAN.md +2 -2
  44. package/specdacular/workflows/best-practices.md +472 -0
  45. package/specdacular/workflows/context-add.md +16 -30
  46. package/specdacular/workflows/context-manual-review.md +7 -7
  47. package/specdacular/workflows/docs-review.md +273 -0
  48. package/specdacular/workflows/docs.md +420 -0
  49. package/specdacular/workflows/generate-learn-skill.md +214 -0
  50. package/specdacular/workflows/new.md +5 -4
  51. package/specdacular/workflows/orchestrator/new.md +4 -4
  52. package/specdacular/workflows/orchestrator/plan.md +6 -6
  53. package/commands/specd.codebase.map.md +0 -72
  54. package/commands/specd.codebase.review.md +0 -39
  55. package/specdacular/workflows/map-codebase.md +0 -715
@@ -0,0 +1,137 @@
1
+ import { spawn } from 'child_process';
2
+ import { EventEmitter } from 'events';
3
+ import { createWriteStream } from 'fs';
4
+ import { StreamParser } from './parser.js';
5
+
6
+ export class AgentRunner extends EventEmitter {
7
+ constructor({ cmd, input_mode, output_format, system_prompt, timeout, stuck_timeout }) {
8
+ super();
9
+ this.cmd = cmd;
10
+ this.inputMode = input_mode || 'stdin';
11
+ this.outputFormat = output_format || 'stream_json';
12
+ this.systemPrompt = system_prompt || '';
13
+ this.timeout = (timeout || 3600) * 1000;
14
+ this.stuckTimeout = (stuck_timeout || 1800) * 1000;
15
+ }
16
+
17
+ async run(prompt, { cwd, logPath } = {}) {
18
+ return new Promise((resolve, reject) => {
19
+ const fullPrompt = this.systemPrompt ? `${this.systemPrompt}\n\n${prompt}` : prompt;
20
+ const args = this.cmd.split(' ').slice(1);
21
+ const bin = this.cmd.split(' ')[0];
22
+
23
+ const proc = spawn(bin, args, {
24
+ cwd,
25
+ shell: true,
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ env: { ...process.env },
28
+ });
29
+
30
+ const logStream = logPath ? createWriteStream(logPath, { flags: 'a' }) : null;
31
+ let lastOutputAt = Date.now();
32
+ let result = null;
33
+
34
+ const parser = new StreamParser();
35
+ parser.on('status', (s) => {
36
+ lastOutputAt = Date.now();
37
+ this.emit('status', s);
38
+ });
39
+ parser.on('result', (r) => {
40
+ lastOutputAt = Date.now();
41
+ result = r;
42
+ this.emit('result', r);
43
+ });
44
+ parser.on('output', (line) => {
45
+ lastOutputAt = Date.now();
46
+ this.emit('output', line);
47
+ });
48
+
49
+ const handleLine = (line) => {
50
+ if (logStream) logStream.write(line + '\n');
51
+
52
+ if (this.outputFormat === 'stream_json') {
53
+ try {
54
+ const event = JSON.parse(line);
55
+ if (event.type === 'assistant' && event.message?.content) {
56
+ for (const block of event.message.content) {
57
+ if (block.type === 'text') {
58
+ for (const textLine of block.text.split('\n')) {
59
+ parser.feed(textLine);
60
+ }
61
+ }
62
+ }
63
+ } else if (event.type === 'result' && event.result) {
64
+ for (const block of event.result) {
65
+ if (block.type === 'text') {
66
+ for (const textLine of block.text.split('\n')) {
67
+ parser.feed(textLine);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ parser.feed(line);
74
+ }
75
+ } else {
76
+ parser.feed(line);
77
+ }
78
+ };
79
+
80
+ let stdout = '';
81
+ proc.stdout.on('data', (chunk) => {
82
+ stdout += chunk.toString();
83
+ const lines = stdout.split('\n');
84
+ stdout = lines.pop();
85
+ for (const line of lines) {
86
+ if (line.trim()) handleLine(line.trim());
87
+ }
88
+ });
89
+
90
+ proc.stderr.on('data', (chunk) => {
91
+ if (logStream) logStream.write(`[stderr] ${chunk}`);
92
+ });
93
+
94
+ if (this.inputMode === 'stdin') {
95
+ proc.stdin.write(fullPrompt);
96
+ proc.stdin.end();
97
+ }
98
+
99
+ // Global timeout
100
+ const globalTimer = setTimeout(() => {
101
+ proc.kill('SIGTERM');
102
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
103
+ }, this.timeout);
104
+
105
+ // Stuck detection
106
+ const stuckCheck = setInterval(() => {
107
+ if (Date.now() - lastOutputAt > this.stuckTimeout) {
108
+ this.emit('error', new Error('Agent stuck — no output'));
109
+ proc.kill('SIGTERM');
110
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
111
+ }
112
+ }, 30000);
113
+
114
+ proc.on('close', (code) => {
115
+ clearTimeout(globalTimer);
116
+ clearInterval(stuckCheck);
117
+ if (logStream) logStream.end();
118
+ if (stdout.trim()) handleLine(stdout.trim());
119
+
120
+ if (result) {
121
+ resolve(result);
122
+ } else if (code === 0) {
123
+ resolve({ status: 'success', summary: 'Agent completed without explicit result' });
124
+ } else {
125
+ resolve({ status: 'failure', summary: `Agent exited with code ${code}` });
126
+ }
127
+ });
128
+
129
+ proc.on('error', (err) => {
130
+ clearTimeout(globalTimer);
131
+ clearInterval(stuckCheck);
132
+ if (logStream) logStream.end();
133
+ reject(err);
134
+ });
135
+ });
136
+ }
137
+ }
@@ -0,0 +1,16 @@
1
+ export function resolveTemplate(template, variables) {
2
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
3
+ const value = path.trim().split('.').reduce((obj, key) => obj?.[key], variables);
4
+ return value !== undefined ? String(value) : match;
5
+ });
6
+ }
7
+
8
+ export function buildTemplateContext(task, stage, pipeline, paths) {
9
+ return {
10
+ task: { id: task.id, name: task.name, spec: task.spec || '' },
11
+ stage: { name: stage.stage, index: stage.index, total: stage.total },
12
+ pipeline: { name: pipeline.name },
13
+ status_file: paths?.statusJson || '',
14
+ log_dir: paths?.logsDir || '',
15
+ };
16
+ }
@@ -0,0 +1,69 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const DEFAULT_CONFIG = {
5
+ server: { port: 3700 },
6
+ notifications: { telegram: { enabled: false } },
7
+ defaults: {
8
+ pipeline: 'default',
9
+ failure_policy: 'skip',
10
+ timeout: 3600,
11
+ stuck_timeout: 1800,
12
+ max_parallel: 1,
13
+ },
14
+ };
15
+
16
+ const DEFAULT_AGENTS = {
17
+ 'claude-planner': {
18
+ cmd: 'claude -p --dangerously-skip-permissions',
19
+ input_mode: 'stdin',
20
+ output_format: 'stream_json',
21
+ system_prompt: 'You are a feature planner working on: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nResearch the codebase thoroughly, then create a detailed implementation plan.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
22
+ },
23
+ 'claude-implementer': {
24
+ cmd: 'claude -p --dangerously-skip-permissions',
25
+ input_mode: 'stdin',
26
+ output_format: 'stream_json',
27
+ system_prompt: 'You are an implementer working on: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nImplement the plan from the previous stage. Write clean, tested code.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
28
+ },
29
+ 'claude-reviewer': {
30
+ cmd: 'claude -p --dangerously-skip-permissions',
31
+ input_mode: 'stdin',
32
+ output_format: 'stream_json',
33
+ system_prompt: 'You are a code reviewer for: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nReview the implementation from the previous stage. Check for bugs, style, tests.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
34
+ },
35
+ };
36
+
37
+ const DEFAULT_PIPELINE = {
38
+ name: 'default',
39
+ stages: [
40
+ { stage: 'plan', agent: 'claude-planner', critical: true },
41
+ { stage: 'implement', agent: 'claude-implementer', critical: true },
42
+ { stage: 'review', agent: 'claude-reviewer', on_fail: 'retry', max_retries: 2 },
43
+ ],
44
+ };
45
+
46
+ function writeIfMissing(filePath, data) {
47
+ if (!existsSync(filePath)) {
48
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
49
+ }
50
+ }
51
+
52
+ export async function bootstrap(paths) {
53
+ // Create directories
54
+ mkdirSync(paths.agentTemplatesDir, { recursive: true });
55
+ mkdirSync(paths.pipelineTemplatesDir, { recursive: true });
56
+ mkdirSync(paths.projectsDir, { recursive: true });
57
+
58
+ // Write default files
59
+ writeIfMissing(paths.db, { projects: [] });
60
+ writeIfMissing(paths.config, DEFAULT_CONFIG);
61
+
62
+ // Write default agent templates
63
+ for (const [name, agent] of Object.entries(DEFAULT_AGENTS)) {
64
+ writeIfMissing(join(paths.agentTemplatesDir, `${name}.json`), agent);
65
+ }
66
+
67
+ // Write default pipeline template
68
+ writeIfMissing(join(paths.pipelineTemplatesDir, 'default.json'), DEFAULT_PIPELINE);
69
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { randomUUID } from 'crypto';
3
+
4
+ export class ProjectDB {
5
+ constructor(dbPath) {
6
+ this.dbPath = dbPath;
7
+ this.data = JSON.parse(readFileSync(dbPath, 'utf-8'));
8
+ }
9
+
10
+ register(name, folderPath) {
11
+ const project = {
12
+ id: randomUUID().slice(0, 8),
13
+ name,
14
+ path: folderPath,
15
+ active: true,
16
+ registeredAt: new Date().toISOString(),
17
+ };
18
+ this.data.projects.push(project);
19
+ this._save();
20
+ return project;
21
+ }
22
+
23
+ unregister(id) {
24
+ this.data.projects = this.data.projects.filter(p => p.id !== id);
25
+ this._save();
26
+ }
27
+
28
+ get(id) {
29
+ return this.data.projects.find(p => p.id === id) || null;
30
+ }
31
+
32
+ findByPath(folderPath) {
33
+ return this.data.projects.find(p =>
34
+ folderPath === p.path || folderPath.startsWith(p.path + '/')
35
+ ) || null;
36
+ }
37
+
38
+ list() {
39
+ return this.data.projects;
40
+ }
41
+
42
+ _save() {
43
+ writeFileSync(this.dbPath, JSON.stringify(this.data, null, 2));
44
+ }
45
+ }
@@ -0,0 +1,103 @@
1
+ // runner/main/index.js
2
+ import { app, BrowserWindow } from 'electron';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { Paths } from './paths.js';
9
+ import { bootstrap } from './bootstrap.js';
10
+ import { ProjectDB } from './db.js';
11
+ import { Orchestrator } from './orchestrator.js';
12
+ import { createServer } from './server/index.js';
13
+ import { setupIpc } from './ipc.js';
14
+
15
+ let mainWindow;
16
+ const paths = new Paths();
17
+ const orchestrators = new Map();
18
+ let db;
19
+ let config;
20
+ let server;
21
+
22
+ function getContext() {
23
+ return { db, config, paths, orchestrators };
24
+ }
25
+
26
+ async function initBackend() {
27
+ await bootstrap(paths);
28
+
29
+ db = new ProjectDB(paths.db);
30
+ config = JSON.parse(readFileSync(paths.config, 'utf-8'));
31
+
32
+ // Initialize orchestrators for active projects
33
+ for (const project of db.list().filter(p => p.active)) {
34
+ // Ensure project.json exists (may not if registered via CLI only)
35
+ const projectPaths = paths.forProject(project.id);
36
+ mkdirSync(projectPaths.dir, { recursive: true });
37
+ if (!existsSync(projectPaths.projectJson)) {
38
+ writeFileSync(projectPaths.projectJson, JSON.stringify({
39
+ name: project.name,
40
+ path: project.path,
41
+ registeredAt: project.registeredAt,
42
+ }, null, 2));
43
+ }
44
+
45
+ const orch = new Orchestrator({ projectId: project.id, paths, config });
46
+ orch.init();
47
+ orchestrators.set(project.id, orch);
48
+ }
49
+
50
+ // Start API server
51
+ server = createServer(getContext);
52
+ const port = config.server?.port || 3700;
53
+ await server.start(port);
54
+
55
+ // Wire orchestrators to WebSocket
56
+ for (const orch of orchestrators.values()) {
57
+ server.wireOrchestrator(orch);
58
+ }
59
+
60
+ // Start orchestrator loops
61
+ for (const orch of orchestrators.values()) {
62
+ orch.startLoop().catch(err => console.error('Loop error:', err));
63
+ }
64
+ }
65
+
66
+ function createWindow() {
67
+ mainWindow = new BrowserWindow({
68
+ width: 1200,
69
+ height: 800,
70
+ webPreferences: {
71
+ preload: join(__dirname, '..', 'preload.js'),
72
+ contextIsolation: true,
73
+ nodeIntegration: false,
74
+ },
75
+ });
76
+
77
+ if (process.env.NODE_ENV === 'development') {
78
+ mainWindow.loadURL('http://localhost:5173');
79
+ } else {
80
+ mainWindow.loadFile(join(__dirname, '..', 'renderer', 'dist', 'index.html'));
81
+ }
82
+ }
83
+
84
+ app.whenReady().then(async () => {
85
+ setupIpc(getContext);
86
+ await initBackend();
87
+ createWindow();
88
+ });
89
+
90
+ app.on('window-all-closed', () => {
91
+ if (process.platform !== 'darwin') app.quit();
92
+ });
93
+
94
+ app.on('activate', () => {
95
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
96
+ });
97
+
98
+ app.on('before-quit', () => {
99
+ for (const orch of orchestrators.values()) {
100
+ orch.stop();
101
+ }
102
+ if (server) server.stop();
103
+ });
@@ -0,0 +1,72 @@
1
+ // runner/main/ipc.js
2
+ import { ipcMain } from 'electron';
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ export function setupIpc(getContext) {
7
+ ipcMain.handle('get-projects', () => {
8
+ const { db, orchestrators } = getContext();
9
+ return db.list().map(p => {
10
+ const orch = orchestrators.get(p.id);
11
+ const tasks = orch ? orch.getTasks() : [];
12
+ return {
13
+ ...p,
14
+ taskCounts: {
15
+ total: tasks.length,
16
+ ready: tasks.filter(t => t.status === 'ready').length,
17
+ running: tasks.filter(t => t.status === 'in_progress').length,
18
+ done: tasks.filter(t => t.status === 'done').length,
19
+ failed: tasks.filter(t => t.status === 'failed').length,
20
+ },
21
+ };
22
+ });
23
+ });
24
+
25
+ ipcMain.handle('get-project-status', (event, projectId) => {
26
+ const { orchestrators } = getContext();
27
+ const orch = orchestrators.get(projectId);
28
+ if (!orch) return null;
29
+ return orch.stateManager.getState();
30
+ });
31
+
32
+ ipcMain.handle('get-tasks', (event, projectId) => {
33
+ const { orchestrators } = getContext();
34
+ const orch = orchestrators.get(projectId);
35
+ if (!orch) return [];
36
+ return orch.getTasks();
37
+ });
38
+
39
+ ipcMain.handle('get-task', (event, projectId, taskId) => {
40
+ const { orchestrators } = getContext();
41
+ const orch = orchestrators.get(projectId);
42
+ if (!orch) return null;
43
+ return orch.getTask(taskId);
44
+ });
45
+
46
+ ipcMain.handle('create-task', (event, projectId, taskData) => {
47
+ const { orchestrators } = getContext();
48
+ const orch = orchestrators.get(projectId);
49
+ if (!orch) return null;
50
+ return orch.createTask(taskData);
51
+ });
52
+
53
+ ipcMain.handle('retry-task', (event, projectId, taskId) => {
54
+ const { orchestrators } = getContext();
55
+ const orch = orchestrators.get(projectId);
56
+ if (!orch) return null;
57
+ return orch.updateTask(taskId, { status: 'ready' });
58
+ });
59
+
60
+ ipcMain.handle('get-task-logs', (event, projectId, taskId) => {
61
+ const { paths } = getContext();
62
+ const logPath = join(paths.forProject(projectId).logsDir, `${taskId}.log`);
63
+ if (!existsSync(logPath)) return { lines: [] };
64
+ const content = readFileSync(logPath, 'utf-8');
65
+ return { lines: content.split('\n').slice(-200) };
66
+ });
67
+
68
+ ipcMain.handle('get-config', () => {
69
+ const { config } = getContext();
70
+ return config;
71
+ });
72
+ }
@@ -0,0 +1,45 @@
1
+ // runner/main/notifications/telegram.js
2
+ import { request } from 'https';
3
+
4
+ export class TelegramNotifier {
5
+ constructor({ bot_token, chat_id, notify_on }) {
6
+ this.botToken = bot_token;
7
+ this.chatId = chat_id;
8
+ this.notifyOn = new Set(notify_on || ['task_complete', 'task_failed']);
9
+ }
10
+
11
+ async onTaskComplete(taskId, taskName, summary) {
12
+ if (!this.notifyOn.has('task_complete')) return;
13
+ await this._send(`✅ *Task Complete*\n*${taskName}* (${taskId})\n${summary}`);
14
+ }
15
+
16
+ async onTaskFailed(taskId, taskName, stage, error) {
17
+ if (!this.notifyOn.has('task_failed')) return;
18
+ await this._send(`❌ *Task Failed*\n*${taskName}* (${taskId})\nStage: ${stage}\nError: ${error}`);
19
+ }
20
+
21
+ async onStuck(taskId, taskName, stage) {
22
+ if (!this.notifyOn.has('task_stuck')) return;
23
+ await this._send(`⚠️ *Agent Stuck*\n*${taskName}* (${taskId})\nStage: ${stage}`);
24
+ }
25
+
26
+ async onNeedsInput(taskId, taskName, stage, question) {
27
+ if (!this.notifyOn.has('needs_input')) return;
28
+ await this._send(`❓ *Input Needed*\n*${taskName}* (${taskId})\nStage: ${stage}\n${question}`);
29
+ }
30
+
31
+ _send(text) {
32
+ return new Promise((resolve) => {
33
+ const data = JSON.stringify({ chat_id: this.chatId, text, parse_mode: 'Markdown' });
34
+ const req = request({
35
+ hostname: 'api.telegram.org',
36
+ path: `/bot${this.botToken}/sendMessage`,
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
39
+ }, resolve);
40
+ req.on('error', (err) => { console.error('Telegram error:', err.message); resolve(); });
41
+ req.write(data);
42
+ req.end();
43
+ });
44
+ }
45
+ }
@@ -0,0 +1,193 @@
1
+ // runner/main/orchestrator.js
2
+ import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { EventEmitter } from 'events';
5
+ import { StateManager } from './state/manager.js';
6
+ import { TemplateManager } from './template-manager.js';
7
+ import { AgentRunner } from './agent/runner.js';
8
+ import { StageSequencer } from './pipeline/sequencer.js';
9
+ import { resolvePipeline } from './pipeline/resolver.js';
10
+ import { resolveTemplate, buildTemplateContext } from './agent/template.js';
11
+ import { WorktreeManager } from './worktree/manager.js';
12
+
13
+ export class Orchestrator extends EventEmitter {
14
+ constructor({ projectId, paths, config }) {
15
+ super();
16
+ this.projectId = projectId;
17
+ this.paths = paths;
18
+ this.config = config;
19
+ this.projectPaths = paths.forProject(projectId);
20
+ this.stateManager = new StateManager(this.projectPaths.statusJson);
21
+ this.templateManager = new TemplateManager(paths);
22
+ this.running = false;
23
+ this.runningTasks = new Set();
24
+ this.worktreeManager = null;
25
+ }
26
+
27
+ init() {
28
+ // Ensure project dirs exist
29
+ mkdirSync(this.projectPaths.tasksDir, { recursive: true });
30
+ mkdirSync(this.projectPaths.logsDir, { recursive: true });
31
+
32
+ // Load project config
33
+ const projectJson = JSON.parse(readFileSync(this.projectPaths.projectJson, 'utf-8'));
34
+ this.projectPath = projectJson.path;
35
+
36
+ // Init worktree manager if parallel
37
+ const maxParallel = this.config.defaults?.max_parallel || 1;
38
+ if (maxParallel > 1 && existsSync(join(this.projectPath, '.git'))) {
39
+ this.worktreeManager = new WorktreeManager(this.projectPath);
40
+ }
41
+
42
+ // Forward state events
43
+ this.stateManager.on('change', (event) => {
44
+ this.emit('change', { ...event, project: this.projectId });
45
+ });
46
+ }
47
+
48
+ getTasks() {
49
+ const tasksDir = this.projectPaths.tasksDir;
50
+ if (!existsSync(tasksDir)) return [];
51
+ return readdirSync(tasksDir)
52
+ .filter(f => f.endsWith('.json'))
53
+ .map(f => JSON.parse(readFileSync(join(tasksDir, f), 'utf-8')));
54
+ }
55
+
56
+ getTask(taskId) {
57
+ const taskPath = join(this.projectPaths.tasksDir, `${taskId}.json`);
58
+ if (!existsSync(taskPath)) return null;
59
+ return JSON.parse(readFileSync(taskPath, 'utf-8'));
60
+ }
61
+
62
+ createTask(task) {
63
+ mkdirSync(this.projectPaths.tasksDir, { recursive: true });
64
+ const taskPath = join(this.projectPaths.tasksDir, `${task.id}.json`);
65
+ writeFileSync(taskPath, JSON.stringify(task, null, 2));
66
+ return task;
67
+ }
68
+
69
+ updateTask(taskId, updates) {
70
+ const task = this.getTask(taskId);
71
+ if (!task) return null;
72
+ const updated = { ...task, ...updates };
73
+ const taskPath = join(this.projectPaths.tasksDir, `${taskId}.json`);
74
+ writeFileSync(taskPath, JSON.stringify(updated, null, 2));
75
+ return updated;
76
+ }
77
+
78
+ pickNextTask() {
79
+ const tasks = this.getTasks();
80
+ const completedIds = new Set(
81
+ Object.entries(this.stateManager.getState().tasks)
82
+ .filter(([, t]) => t.status === 'done')
83
+ .map(([id]) => id)
84
+ );
85
+
86
+ return tasks
87
+ .filter(t => t.status === 'ready' && !this.runningTasks.has(t.id))
88
+ .filter(t => (t.depends_on || []).every(dep => completedIds.has(dep)))
89
+ .sort((a, b) => (a.priority || 99) - (b.priority || 99))[0] || null;
90
+ }
91
+
92
+ async runTask(task) {
93
+ const agents = this.templateManager.getAgents(this.projectId);
94
+ const pipelines = this.templateManager.getPipelines(this.projectId);
95
+ const pipelineName = task.pipeline || this.config.defaults?.pipeline || 'default';
96
+ const pipeline = resolvePipeline(pipelineName, pipelines, task.stage_overrides, this.config.defaults);
97
+
98
+ this.runningTasks.add(task.id);
99
+ this.stateManager.registerTask(task.id, { name: task.name, pipeline: pipelineName });
100
+ this.stateManager.updateTaskStatus(task.id, 'in_progress');
101
+ this.updateTask(task.id, { status: 'in_progress' });
102
+
103
+ // Determine working directory
104
+ const workingDir = task.working_dir || '.';
105
+ let cwd = join(this.projectPath, workingDir);
106
+ let worktreePath = null;
107
+
108
+ if (this.worktreeManager && existsSync(join(cwd, '.git'))) {
109
+ worktreePath = this.worktreeManager.create(task.id);
110
+ cwd = worktreePath;
111
+ }
112
+
113
+ const logPath = join(this.projectPaths.logsDir, `${task.id}.log`);
114
+
115
+ const sequencer = new StageSequencer({
116
+ stages: pipeline.stages,
117
+ createRunner: (stage) => {
118
+ const agentDef = agents[stage.agent];
119
+ if (!agentDef) throw new Error(`Agent not found: ${stage.agent}`);
120
+
121
+ const context = buildTemplateContext(task, stage, pipeline, this.projectPaths);
122
+ const resolvedPrompt = resolveTemplate(agentDef.system_prompt || '', context);
123
+
124
+ const runner = new AgentRunner({
125
+ ...agentDef,
126
+ system_prompt: resolvedPrompt,
127
+ timeout: stage.timeout,
128
+ stuck_timeout: this.config.defaults?.stuck_timeout,
129
+ });
130
+
131
+ runner.on('status', (s) => this.stateManager.updateLiveProgress(task.id, s));
132
+ runner.on('output', () => this.stateManager.persist());
133
+
134
+ return { run: () => runner.run(task.spec || task.description || task.name, { cwd, logPath }) };
135
+ },
136
+ onStageStart: async (stage, attempt) => {
137
+ this.stateManager.startStage(task.id, { stage: stage.stage, agent: stage.agent });
138
+ this.stateManager.persist();
139
+ },
140
+ onStageComplete: async (stage, result, attempt) => {
141
+ this.stateManager.completeStage(task.id, result.status, result.summary);
142
+ this.stateManager.persist();
143
+ },
144
+ });
145
+
146
+ const result = await sequencer.run();
147
+
148
+ const finalStatus = result.status === 'success' ? 'done' : 'failed';
149
+ this.stateManager.updateTaskStatus(task.id, finalStatus);
150
+ this.stateManager.persist();
151
+ this.updateTask(task.id, { status: finalStatus });
152
+
153
+ // Create PR if worktree has changes
154
+ if (worktreePath && this.worktreeManager.hasChanges(task.id)) {
155
+ const summary = result.results.map(r => `- ${r.stage}: ${r.summary}`).join('\n');
156
+ const prUrl = this.worktreeManager.createPR(task.id, task.name, summary);
157
+ if (prUrl) this.stateManager.setPrUrl(task.id, prUrl);
158
+ this.stateManager.persist();
159
+ }
160
+
161
+ // Cleanup worktree
162
+ if (worktreePath) {
163
+ this.worktreeManager.remove(task.id);
164
+ }
165
+
166
+ this.runningTasks.delete(task.id);
167
+ return result;
168
+ }
169
+
170
+ async startLoop(interval = 5000) {
171
+ this.running = true;
172
+
173
+ while (this.running) {
174
+ const maxParallel = this.config.defaults?.max_parallel || 1;
175
+ const available = maxParallel - this.runningTasks.size;
176
+
177
+ for (let i = 0; i < available; i++) {
178
+ const task = this.pickNextTask();
179
+ if (!task) break;
180
+ // Fire and forget — runs in parallel
181
+ this.runTask(task).catch(err => {
182
+ console.error(`Task ${task.id} failed:`, err);
183
+ });
184
+ }
185
+
186
+ await new Promise(r => setTimeout(r, interval));
187
+ }
188
+ }
189
+
190
+ stop() {
191
+ this.running = false;
192
+ }
193
+ }