specdacular 0.10.1 → 0.11.0

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 +6 -2
  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,36 @@
1
+ import { join } from 'path';
2
+ import { homedir, platform } from 'os';
3
+
4
+ export class Paths {
5
+ constructor(root) {
6
+ this.root = root || Paths.defaultRoot();
7
+ }
8
+
9
+ static defaultRoot() {
10
+ if (platform() === 'darwin') {
11
+ return join(homedir(), 'Library', 'Application Support', 'Specd');
12
+ }
13
+ return join(homedir(), '.config', 'specd');
14
+ }
15
+
16
+ get db() { return join(this.root, 'db.json'); }
17
+ get config() { return join(this.root, 'config.json'); }
18
+ get templatesDir() { return join(this.root, 'templates'); }
19
+ get agentTemplatesDir() { return join(this.root, 'templates', 'agents'); }
20
+ get pipelineTemplatesDir() { return join(this.root, 'templates', 'pipelines'); }
21
+ get projectsDir() { return join(this.root, 'projects'); }
22
+ get electronDir() { return join(this.root, 'electron'); }
23
+
24
+ forProject(projectId) {
25
+ const dir = join(this.root, 'projects', projectId);
26
+ return {
27
+ dir,
28
+ projectJson: join(dir, 'project.json'),
29
+ statusJson: join(dir, 'status.json'),
30
+ tasksDir: join(dir, 'tasks'),
31
+ logsDir: join(dir, 'logs'),
32
+ agentsDir: join(dir, 'agents'),
33
+ pipelinesDir: join(dir, 'pipelines'),
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,20 @@
1
+ // runner/main/pipeline/resolver.js
2
+ export function resolvePipeline(pipelineName, pipelines, stageOverrides, defaults) {
3
+ const pipeline = pipelines[pipelineName];
4
+ if (!pipeline) throw new Error(`Pipeline not found: ${pipelineName}`);
5
+
6
+ const stages = pipeline.stages.map((stage, index) => {
7
+ const override = stageOverrides?.[stage.stage] || {};
8
+ return {
9
+ ...stage,
10
+ ...override,
11
+ timeout: stage.timeout || defaults?.timeout || 3600,
12
+ on_fail: stage.on_fail || defaults?.failure_policy || 'skip',
13
+ max_retries: stage.max_retries || 0,
14
+ index: index + 1,
15
+ total: pipeline.stages.length,
16
+ };
17
+ });
18
+
19
+ return { name: pipelineName, stages };
20
+ }
@@ -0,0 +1,42 @@
1
+ // runner/main/pipeline/sequencer.js
2
+ export class StageSequencer {
3
+ constructor({ stages, createRunner, onStageStart, onStageComplete }) {
4
+ this.stages = stages;
5
+ this.createRunner = createRunner;
6
+ this.onStageStart = onStageStart;
7
+ this.onStageComplete = onStageComplete;
8
+ }
9
+
10
+ async run() {
11
+ const results = [];
12
+
13
+ for (const stage of this.stages) {
14
+ const maxAttempts = 1 + (stage.max_retries || 0);
15
+ let stageResult = null;
16
+
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18
+ const runner = this.createRunner(stage);
19
+ await this.onStageStart(stage, attempt);
20
+
21
+ try {
22
+ stageResult = await runner.run('');
23
+ } catch (err) {
24
+ stageResult = { status: 'failure', summary: err.message };
25
+ }
26
+
27
+ await this.onStageComplete(stage, stageResult, attempt);
28
+
29
+ if (stageResult.status === 'success') break;
30
+ if (stageResult.status === 'failure' && stage.on_fail !== 'retry') break;
31
+ }
32
+
33
+ results.push({ stage: stage.stage, ...stageResult });
34
+
35
+ if (stageResult.status !== 'success' && stage.critical) {
36
+ return { status: 'failure', results, failedStage: stage.stage };
37
+ }
38
+ }
39
+
40
+ return { status: 'success', results };
41
+ }
42
+ }
@@ -0,0 +1,125 @@
1
+ // runner/main/server/api.js
2
+ import { Router } from 'express';
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { randomUUID } from 'crypto';
6
+
7
+ export function createApiRouter(getContext) {
8
+ const router = Router();
9
+
10
+ // Find project by path (for skill auto-detection) — must be before :id routes
11
+ router.get('/projects/by-path', (req, res) => {
12
+ const { db } = getContext();
13
+ const folderPath = req.query.path;
14
+ if (!folderPath) return res.status(400).json({ error: 'path query param required' });
15
+ const project = db.findByPath(folderPath);
16
+ if (!project) return res.status(404).json({ error: 'No project matches this path' });
17
+ res.json(project);
18
+ });
19
+
20
+ // List projects
21
+ router.get('/projects', (req, res) => {
22
+ const { db, orchestrators } = getContext();
23
+ const projects = db.list().map(p => {
24
+ const orch = orchestrators.get(p.id);
25
+ const tasks = orch ? orch.getTasks() : [];
26
+ return {
27
+ ...p,
28
+ taskCounts: {
29
+ total: tasks.length,
30
+ ready: tasks.filter(t => t.status === 'ready').length,
31
+ running: tasks.filter(t => t.status === 'in_progress').length,
32
+ done: tasks.filter(t => t.status === 'done').length,
33
+ failed: tasks.filter(t => t.status === 'failed').length,
34
+ },
35
+ };
36
+ });
37
+ res.json(projects);
38
+ });
39
+
40
+ // Global status
41
+ router.get('/status', (req, res) => {
42
+ const { orchestrators } = getContext();
43
+ const status = {};
44
+ for (const [id, orch] of orchestrators) {
45
+ status[id] = orch.stateManager.getState();
46
+ }
47
+ res.json(status);
48
+ });
49
+
50
+ // Project status
51
+ router.get('/projects/:id/status', (req, res) => {
52
+ const { orchestrators } = getContext();
53
+ const orch = orchestrators.get(req.params.id);
54
+ if (!orch) return res.status(404).json({ error: 'Project not found' });
55
+ res.json(orch.stateManager.getState());
56
+ });
57
+
58
+ // List tasks
59
+ router.get('/projects/:id/tasks', (req, res) => {
60
+ const { orchestrators } = getContext();
61
+ const orch = orchestrators.get(req.params.id);
62
+ if (!orch) return res.status(404).json({ error: 'Project not found' });
63
+ res.json(orch.getTasks());
64
+ });
65
+
66
+ // Get task
67
+ router.get('/projects/:id/tasks/:taskId', (req, res) => {
68
+ const { orchestrators } = getContext();
69
+ const orch = orchestrators.get(req.params.id);
70
+ if (!orch) return res.status(404).json({ error: 'Project not found' });
71
+ const task = orch.getTask(req.params.taskId);
72
+ if (!task) return res.status(404).json({ error: 'Task not found' });
73
+ res.json(task);
74
+ });
75
+
76
+ // Create task
77
+ router.post('/projects/:id/tasks', (req, res) => {
78
+ const { orchestrators } = getContext();
79
+ const orch = orchestrators.get(req.params.id);
80
+ if (!orch) return res.status(404).json({ error: 'Project not found' });
81
+ if (!req.body.name) return res.status(400).json({ error: 'name is required' });
82
+
83
+ const task = {
84
+ id: req.body.id || `task-${randomUUID().slice(0, 6)}`,
85
+ name: req.body.name,
86
+ description: req.body.description || '',
87
+ project_id: req.params.id,
88
+ working_dir: req.body.working_dir || '.',
89
+ pipeline: req.body.pipeline || 'default',
90
+ status: req.body.status || 'ready',
91
+ priority: req.body.priority || 10,
92
+ depends_on: req.body.depends_on || [],
93
+ spec: req.body.spec || '',
94
+ created_at: new Date().toISOString(),
95
+ };
96
+
97
+ orch.createTask(task);
98
+ res.status(201).json(task);
99
+ });
100
+
101
+ // Retry task
102
+ router.post('/projects/:id/tasks/:taskId/retry', (req, res) => {
103
+ const { orchestrators } = getContext();
104
+ const orch = orchestrators.get(req.params.id);
105
+ if (!orch) return res.status(404).json({ error: 'Project not found' });
106
+
107
+ const task = orch.updateTask(req.params.taskId, { status: 'ready' });
108
+ if (!task) return res.status(404).json({ error: 'Task not found' });
109
+ res.json(task);
110
+ });
111
+
112
+ // Task logs
113
+ router.get('/projects/:id/tasks/:taskId/logs', (req, res) => {
114
+ const { paths } = getContext();
115
+ const logPath = join(paths.forProject(req.params.id).logsDir, `${req.params.taskId}.log`);
116
+ if (!existsSync(logPath)) return res.status(404).json({ error: 'Log not found' });
117
+
118
+ const content = readFileSync(logPath, 'utf-8');
119
+ const lines = content.split('\n');
120
+ const tail = parseInt(req.query.tail) || 200;
121
+ res.json({ lines: lines.slice(-tail) });
122
+ });
123
+
124
+ return router;
125
+ }
@@ -0,0 +1,33 @@
1
+ // runner/main/server/index.js
2
+ import express from 'express';
3
+ import { createServer as createHttpServer } from 'http';
4
+ import { createApiRouter } from './api.js';
5
+ import { WsBroadcaster } from './websocket.js';
6
+
7
+ export function createServer(getContext) {
8
+ const app = express();
9
+ app.use(express.json());
10
+ app.use('/api', createApiRouter(getContext));
11
+
12
+ const httpServer = createHttpServer(app);
13
+ const broadcaster = new WsBroadcaster(httpServer);
14
+
15
+ return {
16
+ start(port) {
17
+ return new Promise((resolve) => {
18
+ httpServer.listen(port, '127.0.0.1', () => {
19
+ console.log(`Specd API server on http://127.0.0.1:${port}`);
20
+ resolve();
21
+ });
22
+ });
23
+ },
24
+ stop() {
25
+ broadcaster.close();
26
+ httpServer.close();
27
+ },
28
+ broadcaster,
29
+ wireOrchestrator(orch) {
30
+ orch.on('change', (event) => broadcaster.broadcast(event));
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,24 @@
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
+ }
@@ -0,0 +1,83 @@
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
+ }
@@ -0,0 +1,41 @@
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
+ }
@@ -0,0 +1,44 @@
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
+ });
@@ -0,0 +1,58 @@
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
+ });
@@ -0,0 +1,72 @@
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
+ });
@@ -0,0 +1,29 @@
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
+ });
@@ -0,0 +1,72 @@
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
+ });