specdacular 0.12.0 → 0.13.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.
@@ -1,24 +0,0 @@
1
- // runner/main/server/websocket.js
2
- import { WebSocketServer } from 'ws';
3
-
4
- export class WsBroadcaster {
5
- constructor(server) {
6
- this.wss = new WebSocketServer({ server });
7
- this.wss.on('connection', (ws) => {
8
- ws.send(JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() }));
9
- });
10
- }
11
-
12
- broadcast(event) {
13
- const data = JSON.stringify(event);
14
- for (const client of this.wss.clients) {
15
- if (client.readyState === 1) {
16
- client.send(data);
17
- }
18
- }
19
- }
20
-
21
- close() {
22
- this.wss.close();
23
- }
24
- }
@@ -1,83 +0,0 @@
1
- import { writeFileSync, existsSync, readFileSync } from 'fs';
2
- import { EventEmitter } from 'events';
3
-
4
- export class StateManager extends EventEmitter {
5
- constructor(statusPath) {
6
- super();
7
- this.statusPath = statusPath;
8
- this.state = existsSync(statusPath)
9
- ? JSON.parse(readFileSync(statusPath, 'utf-8'))
10
- : { started_at: new Date().toISOString(), tasks: {} };
11
- }
12
-
13
- getState() {
14
- return this.state;
15
- }
16
-
17
- registerTask(taskId, { name, pipeline }) {
18
- this.state.tasks[taskId] = {
19
- name,
20
- status: 'queued',
21
- current_stage: null,
22
- pipeline,
23
- pr_url: null,
24
- stages: [],
25
- };
26
- this._emit('task_registered', { taskId, name });
27
- }
28
-
29
- updateTaskStatus(taskId, status) {
30
- this.state.tasks[taskId].status = status;
31
- this._emit('task_status_changed', { taskId, status });
32
- }
33
-
34
- startStage(taskId, { stage, agent }) {
35
- const task = this.state.tasks[taskId];
36
- task.current_stage = stage;
37
- task.stages.push({
38
- stage,
39
- agent,
40
- status: 'running',
41
- started_at: new Date().toISOString(),
42
- duration: null,
43
- summary: null,
44
- live_progress: null,
45
- });
46
- this._emit('stage_started', { taskId, stage, agent });
47
- }
48
-
49
- updateLiveProgress(taskId, progress) {
50
- const task = this.state.tasks[taskId];
51
- const currentStage = task.stages[task.stages.length - 1];
52
- if (currentStage) {
53
- currentStage.live_progress = progress;
54
- currentStage.last_output_at = new Date().toISOString();
55
- }
56
- this._emit('live_progress', { taskId, progress });
57
- }
58
-
59
- completeStage(taskId, status, summary) {
60
- const task = this.state.tasks[taskId];
61
- const currentStage = task.stages[task.stages.length - 1];
62
- if (currentStage) {
63
- currentStage.status = status;
64
- currentStage.summary = summary;
65
- const started = new Date(currentStage.started_at);
66
- currentStage.duration = Math.round((Date.now() - started.getTime()) / 1000);
67
- }
68
- this._emit('stage_completed', { taskId, stage: currentStage?.stage, status, summary });
69
- }
70
-
71
- setPrUrl(taskId, prUrl) {
72
- this.state.tasks[taskId].pr_url = prUrl;
73
- this._emit('pr_created', { taskId, prUrl });
74
- }
75
-
76
- persist() {
77
- writeFileSync(this.statusPath, JSON.stringify(this.state, null, 2));
78
- }
79
-
80
- _emit(type, data) {
81
- this.emit('change', { type, ...data });
82
- }
83
- }
@@ -1,41 +0,0 @@
1
- import { readdirSync, readFileSync, existsSync } from 'fs';
2
- import { join, basename } from 'path';
3
-
4
- export class TemplateManager {
5
- constructor(paths) {
6
- this.paths = paths;
7
- }
8
-
9
- getAgents(projectId) {
10
- const global = this._loadDir(this.paths.agentTemplatesDir);
11
- if (!projectId) return global;
12
-
13
- const projectAgentsDir = join(this.paths.projectsDir, projectId, 'agents');
14
- if (!existsSync(projectAgentsDir)) return global;
15
-
16
- const overrides = this._loadDir(projectAgentsDir);
17
- return { ...global, ...overrides };
18
- }
19
-
20
- getPipelines(projectId) {
21
- const global = this._loadDir(this.paths.pipelineTemplatesDir);
22
- if (!projectId) return global;
23
-
24
- const projectPipelinesDir = join(this.paths.projectsDir, projectId, 'pipelines');
25
- if (!existsSync(projectPipelinesDir)) return global;
26
-
27
- const overrides = this._loadDir(projectPipelinesDir);
28
- return { ...global, ...overrides };
29
- }
30
-
31
- _loadDir(dir) {
32
- if (!existsSync(dir)) return {};
33
- const result = {};
34
- for (const file of readdirSync(dir)) {
35
- if (!file.endsWith('.json')) continue;
36
- const name = basename(file, '.json');
37
- result[name] = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
38
- }
39
- return result;
40
- }
41
- }
@@ -1,44 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import { strict as a } from 'node:assert';
3
- import { StreamParser } from '../agent/parser.js';
4
-
5
- describe('StreamParser', () => {
6
- it('parses specd-status blocks', () => {
7
- const parser = new StreamParser();
8
- const statuses = [];
9
- parser.on('status', (s) => statuses.push(s));
10
-
11
- parser.feed('some output');
12
- parser.feed('```specd-status');
13
- parser.feed('{"progress":"Working","percent":50}');
14
- parser.feed('```');
15
-
16
- a.equal(statuses.length, 1);
17
- a.equal(statuses[0].progress, 'Working');
18
- a.equal(statuses[0].percent, 50);
19
- });
20
-
21
- it('parses specd-result blocks', () => {
22
- const parser = new StreamParser();
23
- const results = [];
24
- parser.on('result', (r) => results.push(r));
25
-
26
- parser.feed('```specd-result');
27
- parser.feed('{"status":"success","summary":"Done"}');
28
- parser.feed('```');
29
-
30
- a.equal(results.length, 1);
31
- a.equal(results[0].status, 'success');
32
- });
33
-
34
- it('emits output for non-block lines', () => {
35
- const parser = new StreamParser();
36
- const lines = [];
37
- parser.on('output', (l) => lines.push(l));
38
-
39
- parser.feed('hello world');
40
-
41
- a.equal(lines.length, 1);
42
- a.equal(lines[0], 'hello world');
43
- });
44
- });
@@ -1,58 +0,0 @@
1
- import { describe, it, before, after } from 'node:test';
2
- import { strict as a } from 'node:assert';
3
- import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
4
- import { join } from 'path';
5
- import { tmpdir } from 'os';
6
- import { bootstrap } from '../bootstrap.js';
7
- import { Paths } from '../paths.js';
8
-
9
- describe('bootstrap', () => {
10
- let tempDir;
11
- let paths;
12
-
13
- before(() => {
14
- tempDir = mkdtempSync(join(tmpdir(), 'specd-test-'));
15
- paths = new Paths(tempDir);
16
- });
17
-
18
- after(() => {
19
- rmSync(tempDir, { recursive: true, force: true });
20
- });
21
-
22
- it('creates all required directories and default files', async () => {
23
- await bootstrap(paths);
24
-
25
- a.ok(existsSync(paths.templatesDir));
26
- a.ok(existsSync(paths.agentTemplatesDir));
27
- a.ok(existsSync(paths.pipelineTemplatesDir));
28
- a.ok(existsSync(paths.projectsDir));
29
-
30
- a.ok(existsSync(paths.db));
31
- const db = JSON.parse(readFileSync(paths.db, 'utf-8'));
32
- a.deepEqual(db, { projects: [] });
33
-
34
- a.ok(existsSync(paths.config));
35
- const config = JSON.parse(readFileSync(paths.config, 'utf-8'));
36
- a.equal(config.server.port, 3700);
37
-
38
- // Default agent templates exist
39
- a.ok(existsSync(join(paths.agentTemplatesDir, 'claude-planner.json')));
40
- a.ok(existsSync(join(paths.agentTemplatesDir, 'claude-implementer.json')));
41
- a.ok(existsSync(join(paths.agentTemplatesDir, 'claude-reviewer.json')));
42
-
43
- // Default pipeline template exists
44
- a.ok(existsSync(join(paths.pipelineTemplatesDir, 'default.json')));
45
- });
46
-
47
- it('does not overwrite existing files on second run', async () => {
48
- const config = JSON.parse(readFileSync(paths.config, 'utf-8'));
49
- config.server.port = 9999;
50
- const { writeFileSync } = await import('fs');
51
- writeFileSync(paths.config, JSON.stringify(config));
52
-
53
- await bootstrap(paths);
54
-
55
- const reloaded = JSON.parse(readFileSync(paths.config, 'utf-8'));
56
- a.equal(reloaded.server.port, 9999);
57
- });
58
- });
@@ -1,72 +0,0 @@
1
- import { describe, it, before, after, beforeEach } from 'node:test';
2
- import { strict as a } from 'node:assert';
3
- import { mkdtempSync, rmSync, writeFileSync } from 'fs';
4
- import { join } from 'path';
5
- import { tmpdir } from 'os';
6
- import { ProjectDB } from '../db.js';
7
-
8
- describe('ProjectDB', () => {
9
- let tempDir;
10
- let dbPath;
11
- let db;
12
-
13
- before(() => {
14
- tempDir = mkdtempSync(join(tmpdir(), 'specd-db-test-'));
15
- dbPath = join(tempDir, 'db.json');
16
- });
17
-
18
- beforeEach(() => {
19
- writeFileSync(dbPath, JSON.stringify({ projects: [] }));
20
- db = new ProjectDB(dbPath);
21
- });
22
-
23
- after(() => {
24
- rmSync(tempDir, { recursive: true, force: true });
25
- });
26
-
27
- it('registers a project', () => {
28
- const project = db.register('my-project', '/Users/victor/work/my-project');
29
- a.equal(project.name, 'my-project');
30
- a.equal(project.path, '/Users/victor/work/my-project');
31
- a.equal(project.active, true);
32
- a.ok(project.id);
33
- a.ok(project.registeredAt);
34
- });
35
-
36
- it('lists projects', () => {
37
- db.register('proj-a', '/a');
38
- db.register('proj-b', '/b');
39
- const list = db.list();
40
- a.equal(list.length, 2);
41
- });
42
-
43
- it('gets a project by id', () => {
44
- const project = db.register('my-project', '/path');
45
- const found = db.get(project.id);
46
- a.equal(found.name, 'my-project');
47
- });
48
-
49
- it('finds a project by path', () => {
50
- db.register('my-project', '/Users/victor/work/my-project');
51
- const found = db.findByPath('/Users/victor/work/my-project');
52
- a.equal(found.name, 'my-project');
53
- });
54
-
55
- it('finds a project by subdirectory path', () => {
56
- db.register('my-project', '/Users/victor/work');
57
- const found = db.findByPath('/Users/victor/work/repo-a');
58
- a.equal(found.name, 'my-project');
59
- });
60
-
61
- it('unregisters a project', () => {
62
- const project = db.register('my-project', '/path');
63
- db.unregister(project.id);
64
- a.equal(db.list().length, 0);
65
- });
66
-
67
- it('persists to disk', () => {
68
- db.register('my-project', '/path');
69
- const db2 = new ProjectDB(dbPath);
70
- a.equal(db2.list().length, 1);
71
- });
72
- });
@@ -1,29 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import { strict as a } from 'node:assert';
3
- import { Paths } from '../paths.js';
4
-
5
- describe('Paths', () => {
6
- it('returns app data dir based on platform', () => {
7
- const paths = new Paths('/tmp/test-specd');
8
- a.equal(paths.root, '/tmp/test-specd');
9
- a.equal(paths.db, '/tmp/test-specd/db.json');
10
- a.equal(paths.config, '/tmp/test-specd/config.json');
11
- a.equal(paths.templatesDir, '/tmp/test-specd/templates');
12
- a.equal(paths.agentTemplatesDir, '/tmp/test-specd/templates/agents');
13
- a.equal(paths.pipelineTemplatesDir, '/tmp/test-specd/templates/pipelines');
14
- a.equal(paths.projectsDir, '/tmp/test-specd/projects');
15
- a.equal(paths.electronDir, '/tmp/test-specd/electron');
16
- });
17
-
18
- it('returns project-specific paths', () => {
19
- const paths = new Paths('/tmp/test-specd');
20
- const pp = paths.forProject('abc123');
21
- a.equal(pp.dir, '/tmp/test-specd/projects/abc123');
22
- a.equal(pp.projectJson, '/tmp/test-specd/projects/abc123/project.json');
23
- a.equal(pp.statusJson, '/tmp/test-specd/projects/abc123/status.json');
24
- a.equal(pp.tasksDir, '/tmp/test-specd/projects/abc123/tasks');
25
- a.equal(pp.logsDir, '/tmp/test-specd/projects/abc123/logs');
26
- a.equal(pp.agentsDir, '/tmp/test-specd/projects/abc123/agents');
27
- a.equal(pp.pipelinesDir, '/tmp/test-specd/projects/abc123/pipelines');
28
- });
29
- });
@@ -1,72 +0,0 @@
1
- import { describe, it, before, after, beforeEach } from 'node:test';
2
- import { strict as a } from 'node:assert';
3
- import { mkdtempSync, rmSync, readFileSync } from 'fs';
4
- import { join } from 'path';
5
- import { tmpdir } from 'os';
6
- import { StateManager } from '../state/manager.js';
7
-
8
- describe('StateManager', () => {
9
- let tempDir;
10
- let statusPath;
11
- let sm;
12
-
13
- before(() => {
14
- tempDir = mkdtempSync(join(tmpdir(), 'specd-state-test-'));
15
- });
16
-
17
- beforeEach(() => {
18
- statusPath = join(tempDir, `status-${Date.now()}.json`);
19
- sm = new StateManager(statusPath);
20
- });
21
-
22
- after(() => {
23
- rmSync(tempDir, { recursive: true, force: true });
24
- });
25
-
26
- it('registers a task', () => {
27
- sm.registerTask('task-001', { name: 'Add dark mode', pipeline: 'default' });
28
- const state = sm.getState();
29
- a.equal(state.tasks['task-001'].name, 'Add dark mode');
30
- a.equal(state.tasks['task-001'].status, 'queued');
31
- });
32
-
33
- it('updates task status', () => {
34
- sm.registerTask('task-001', { name: 'Test', pipeline: 'default' });
35
- sm.updateTaskStatus('task-001', 'in_progress');
36
- a.equal(sm.getState().tasks['task-001'].status, 'in_progress');
37
- });
38
-
39
- it('tracks stages', () => {
40
- sm.registerTask('task-001', { name: 'Test', pipeline: 'default' });
41
- sm.startStage('task-001', { stage: 'plan', agent: 'claude-planner' });
42
- const task = sm.getState().tasks['task-001'];
43
- a.equal(task.current_stage, 'plan');
44
- a.equal(task.stages.length, 1);
45
- a.equal(task.stages[0].status, 'running');
46
- });
47
-
48
- it('completes stages with duration', () => {
49
- sm.registerTask('task-001', { name: 'Test', pipeline: 'default' });
50
- sm.startStage('task-001', { stage: 'plan', agent: 'claude-planner' });
51
- sm.completeStage('task-001', 'success', 'Planned it');
52
- const stage = sm.getState().tasks['task-001'].stages[0];
53
- a.equal(stage.status, 'success');
54
- a.equal(stage.summary, 'Planned it');
55
- a.ok(stage.duration >= 0);
56
- });
57
-
58
- it('persists to disk', () => {
59
- sm.registerTask('task-001', { name: 'Test', pipeline: 'default' });
60
- sm.persist();
61
- const data = JSON.parse(readFileSync(statusPath, 'utf-8'));
62
- a.ok(data.tasks['task-001']);
63
- });
64
-
65
- it('emits change events', () => {
66
- const events = [];
67
- sm.on('change', (e) => events.push(e));
68
- sm.registerTask('task-001', { name: 'Test', pipeline: 'default' });
69
- a.equal(events.length, 1);
70
- a.equal(events[0].type, 'task_registered');
71
- });
72
- });
@@ -1,66 +0,0 @@
1
- import { describe, it, before, after } from 'node:test';
2
- import { strict as a } from 'node:assert';
3
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
4
- import { join } from 'path';
5
- import { tmpdir } from 'os';
6
- import { TemplateManager } from '../template-manager.js';
7
- import { Paths } from '../paths.js';
8
-
9
- describe('TemplateManager', () => {
10
- let tempDir;
11
- let paths;
12
- let tm;
13
-
14
- before(() => {
15
- tempDir = mkdtempSync(join(tmpdir(), 'specd-tm-test-'));
16
- paths = new Paths(tempDir);
17
-
18
- // Create global templates
19
- mkdirSync(paths.agentTemplatesDir, { recursive: true });
20
- mkdirSync(paths.pipelineTemplatesDir, { recursive: true });
21
-
22
- writeFileSync(
23
- join(paths.agentTemplatesDir, 'claude-planner.json'),
24
- JSON.stringify({ cmd: 'claude -p', system_prompt: 'global planner' })
25
- );
26
- writeFileSync(
27
- join(paths.pipelineTemplatesDir, 'default.json'),
28
- JSON.stringify({ name: 'default', stages: [{ stage: 'plan', agent: 'claude-planner' }] })
29
- );
30
-
31
- // Create per-project override
32
- const projectAgentsDir = join(paths.projectsDir, 'proj1', 'agents');
33
- mkdirSync(projectAgentsDir, { recursive: true });
34
- writeFileSync(
35
- join(projectAgentsDir, 'claude-planner.json'),
36
- JSON.stringify({ cmd: 'claude -p --model opus', system_prompt: 'custom planner' })
37
- );
38
-
39
- tm = new TemplateManager(paths);
40
- });
41
-
42
- after(() => {
43
- rmSync(tempDir, { recursive: true, force: true });
44
- });
45
-
46
- it('loads global agents', () => {
47
- const agents = tm.getAgents();
48
- a.equal(agents['claude-planner'].system_prompt, 'global planner');
49
- });
50
-
51
- it('loads global pipelines', () => {
52
- const pipelines = tm.getPipelines();
53
- a.equal(pipelines['default'].stages.length, 1);
54
- });
55
-
56
- it('returns per-project agent override when present', () => {
57
- const agents = tm.getAgents('proj1');
58
- a.equal(agents['claude-planner'].system_prompt, 'custom planner');
59
- a.equal(agents['claude-planner'].cmd, 'claude -p --model opus');
60
- });
61
-
62
- it('falls back to global when no per-project override', () => {
63
- const agents = tm.getAgents('proj-no-overrides');
64
- a.equal(agents['claude-planner'].system_prompt, 'global planner');
65
- });
66
- });
@@ -1,95 +0,0 @@
1
- // runner/main/worktree/manager.js
2
- import { execSync, execFileSync } from 'child_process';
3
- import { existsSync, mkdirSync, rmSync } from 'fs';
4
- import { join, basename } from 'path';
5
- import { tmpdir } from 'os';
6
-
7
- export class WorktreeManager {
8
- constructor(repoDir) {
9
- this.repoDir = repoDir;
10
- this.worktreesDir = join(tmpdir(), 'specd-worktrees', basename(repoDir));
11
- this.active = new Map();
12
- }
13
-
14
- create(taskId) {
15
- const branch = `specd/${taskId}`;
16
- const worktreePath = join(this.worktreesDir, taskId);
17
-
18
- mkdirSync(this.worktreesDir, { recursive: true });
19
-
20
- // Create branch from current HEAD
21
- try {
22
- execSync(`git branch ${branch}`, { cwd: this.repoDir, stdio: 'pipe' });
23
- } catch {
24
- // Branch may already exist
25
- }
26
-
27
- execSync(`git worktree add "${worktreePath}" ${branch}`, {
28
- cwd: this.repoDir,
29
- stdio: 'pipe',
30
- });
31
-
32
- this.active.set(taskId, worktreePath);
33
- return worktreePath;
34
- }
35
-
36
- remove(taskId, deleteBranch = false) {
37
- const worktreePath = this.active.get(taskId);
38
- if (!worktreePath) return;
39
-
40
- execSync(`git worktree remove "${worktreePath}" --force`, {
41
- cwd: this.repoDir,
42
- stdio: 'pipe',
43
- });
44
-
45
- if (deleteBranch) {
46
- try {
47
- execSync(`git branch -D specd/${taskId}`, { cwd: this.repoDir, stdio: 'pipe' });
48
- } catch {
49
- // Branch may not exist
50
- }
51
- }
52
-
53
- this.active.delete(taskId);
54
- }
55
-
56
- hasChanges(taskId) {
57
- const worktreePath = this.active.get(taskId);
58
- if (!worktreePath) return false;
59
-
60
- try {
61
- const base = execSync('git merge-base HEAD main', { cwd: worktreePath, encoding: 'utf-8' }).trim();
62
- const head = execSync('git rev-parse HEAD', { cwd: worktreePath, encoding: 'utf-8' }).trim();
63
- return base !== head;
64
- } catch {
65
- return false;
66
- }
67
- }
68
-
69
- createPR(taskId, taskName, summary) {
70
- const worktreePath = this.active.get(taskId);
71
- if (!worktreePath) return null;
72
-
73
- const branch = `specd/${taskId}`;
74
-
75
- try {
76
- execFileSync('git', ['push', '-u', 'origin', branch], { cwd: worktreePath, stdio: 'pipe' });
77
- const prUrl = execFileSync(
78
- 'gh', ['pr', 'create', '--title', taskName, '--body', summary, '--head', branch],
79
- { cwd: worktreePath, encoding: 'utf-8' }
80
- ).trim();
81
- return prUrl;
82
- } catch (err) {
83
- console.error(`PR creation failed for ${taskId}:`, err.message);
84
- return null;
85
- }
86
- }
87
-
88
- getPath(taskId) {
89
- return this.active.get(taskId) || null;
90
- }
91
-
92
- listActive() {
93
- return [...this.active.keys()];
94
- }
95
- }
@@ -1,20 +0,0 @@
1
- {
2
- "name": "@specd/runner",
3
- "version": "0.2.0",
4
- "description": "Specd Runner — Electron desktop app for autonomous agent orchestration",
5
- "type": "module",
6
- "main": "main/index.js",
7
- "scripts": {
8
- "start": "electron .",
9
- "dev": "NODE_ENV=development electron .",
10
- "build:renderer": "cd renderer && npm run build"
11
- },
12
- "dependencies": {
13
- "electron": "^34.0.0",
14
- "express": "^4.21.0",
15
- "ws": "^8.18.0"
16
- },
17
- "engines": {
18
- "node": ">=18"
19
- }
20
- }
package/runner/preload.js DELETED
@@ -1,19 +0,0 @@
1
- const { contextBridge, ipcRenderer } = require('electron');
2
-
3
- const ALLOWED_CHANNELS = new Set([
4
- 'get-projects', 'get-project-status', 'get-tasks',
5
- 'get-task', 'create-task', 'retry-task', 'get-task-logs', 'get-config',
6
- ]);
7
-
8
- contextBridge.exposeInMainWorld('specd', {
9
- invoke: (channel, ...args) => {
10
- if (!ALLOWED_CHANNELS.has(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
11
- return ipcRenderer.invoke(channel, ...args);
12
- },
13
- on: (channel, callback) => {
14
- if (!ALLOWED_CHANNELS.has(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
15
- const subscription = (_event, ...args) => callback(...args);
16
- ipcRenderer.on(channel, subscription);
17
- return () => ipcRenderer.removeListener(channel, subscription);
18
- },
19
- });