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.
- package/README.md +3 -3
- package/bin/install.js +3 -1
- package/bin/specd.js +135 -0
- package/commands/specd.best-practices.md +75 -0
- package/commands/specd.docs.md +81 -0
- package/commands/specd.docs.review.md +80 -0
- package/commands/specd.generate-skills.learn.md +65 -0
- package/commands/specd.new-runner-task.md +52 -0
- package/commands/specd.new.md +6 -6
- package/commands/specd.runner-status.md +27 -0
- package/package.json +11 -3
- package/runner/main/agent/parser.js +39 -0
- package/runner/main/agent/runner.js +137 -0
- package/runner/main/agent/template.js +16 -0
- package/runner/main/bootstrap.js +69 -0
- package/runner/main/db.js +45 -0
- package/runner/main/index.js +103 -0
- package/runner/main/ipc.js +72 -0
- package/runner/main/notifications/telegram.js +45 -0
- package/runner/main/orchestrator.js +193 -0
- package/runner/main/paths.js +36 -0
- package/runner/main/pipeline/resolver.js +20 -0
- package/runner/main/pipeline/sequencer.js +42 -0
- package/runner/main/server/api.js +125 -0
- package/runner/main/server/index.js +33 -0
- package/runner/main/server/websocket.js +24 -0
- package/runner/main/state/manager.js +83 -0
- package/runner/main/template-manager.js +41 -0
- package/runner/main/test/agent-parser.test.js +44 -0
- package/runner/main/test/bootstrap.test.js +58 -0
- package/runner/main/test/db.test.js +72 -0
- package/runner/main/test/paths.test.js +29 -0
- package/runner/main/test/state-manager.test.js +72 -0
- package/runner/main/test/template-manager.test.js +66 -0
- package/runner/main/worktree/manager.js +95 -0
- package/runner/package.json +22 -0
- package/runner/preload.js +19 -0
- package/specdacular/HELP.md +14 -11
- package/specdacular/agents/best-practices-researcher.md +271 -0
- package/specdacular/references/load-context.md +4 -7
- package/specdacular/templates/orchestrator/CONCERNS.md +1 -1
- package/specdacular/templates/orchestrator/PROJECTS.md +3 -4
- package/specdacular/templates/tasks/PLAN.md +2 -2
- package/specdacular/workflows/best-practices.md +472 -0
- package/specdacular/workflows/context-add.md +16 -30
- package/specdacular/workflows/context-manual-review.md +7 -7
- package/specdacular/workflows/docs-review.md +273 -0
- package/specdacular/workflows/docs.md +420 -0
- package/specdacular/workflows/generate-learn-skill.md +214 -0
- package/specdacular/workflows/new.md +5 -4
- package/specdacular/workflows/orchestrator/new.md +4 -4
- package/specdacular/workflows/orchestrator/plan.md +6 -6
- package/commands/specd.codebase.map.md +0 -72
- package/commands/specd.codebase.review.md +0 -39
- 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
|
+
}
|