create-claude-workspace 1.1.151 → 2.0.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 (56) hide show
  1. package/README.md +33 -1
  2. package/dist/index.js +29 -56
  3. package/dist/scheduler/agents/health-checker.mjs +98 -0
  4. package/dist/scheduler/agents/health-checker.spec.js +143 -0
  5. package/dist/scheduler/agents/orchestrator.mjs +149 -0
  6. package/dist/scheduler/agents/orchestrator.spec.js +87 -0
  7. package/dist/scheduler/agents/prompt-builder.mjs +204 -0
  8. package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
  9. package/dist/scheduler/agents/worker-pool.mjs +137 -0
  10. package/dist/scheduler/agents/worker-pool.spec.js +45 -0
  11. package/dist/scheduler/git/ci-watcher.mjs +93 -0
  12. package/dist/scheduler/git/ci-watcher.spec.js +35 -0
  13. package/dist/scheduler/git/manager.mjs +228 -0
  14. package/dist/scheduler/git/manager.spec.js +198 -0
  15. package/dist/scheduler/git/release.mjs +117 -0
  16. package/dist/scheduler/git/release.spec.js +175 -0
  17. package/dist/scheduler/index.mjs +309 -0
  18. package/dist/scheduler/index.spec.js +72 -0
  19. package/dist/scheduler/integration.spec.js +289 -0
  20. package/dist/scheduler/loop.mjs +435 -0
  21. package/dist/scheduler/loop.spec.js +139 -0
  22. package/dist/scheduler/state/session.mjs +14 -0
  23. package/dist/scheduler/state/session.spec.js +36 -0
  24. package/dist/scheduler/state/state.mjs +102 -0
  25. package/dist/scheduler/state/state.spec.js +175 -0
  26. package/dist/scheduler/tasks/inbox.mjs +98 -0
  27. package/dist/scheduler/tasks/inbox.spec.js +168 -0
  28. package/dist/scheduler/tasks/parser.mjs +228 -0
  29. package/dist/scheduler/tasks/parser.spec.js +303 -0
  30. package/dist/scheduler/tasks/queue.mjs +152 -0
  31. package/dist/scheduler/tasks/queue.spec.js +223 -0
  32. package/dist/scheduler/types.mjs +20 -0
  33. package/dist/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
  34. package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -0
  35. package/dist/scheduler/util/memory.mjs +126 -0
  36. package/dist/scheduler/util/memory.spec.js +165 -0
  37. package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
  38. package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
  39. package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
  40. package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
  41. package/package.json +3 -4
  42. package/dist/scripts/autonomous.mjs +0 -492
  43. package/dist/scripts/autonomous.spec.js +0 -46
  44. package/dist/scripts/docker-run.mjs +0 -462
  45. package/dist/scripts/integration.spec.js +0 -108
  46. package/dist/scripts/lib/formatter.mjs +0 -309
  47. package/dist/scripts/lib/formatter.spec.js +0 -262
  48. package/dist/scripts/lib/state.mjs +0 -44
  49. package/dist/scripts/lib/state.spec.js +0 -59
  50. package/dist/template/.claude/docker/.dockerignore +0 -8
  51. package/dist/template/.claude/docker/Dockerfile +0 -54
  52. package/dist/template/.claude/docker/docker-compose.yml +0 -22
  53. package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
  54. /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
  55. /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
  56. /package/dist/{scripts/lib → scheduler/util}/idle-poll.spec.js +0 -0
@@ -0,0 +1,228 @@
1
+ // ─── Pure parser: TODO.md → structured Task[] ───
2
+ // Zero side effects. All I/O happens in the caller.
3
+ // ─── Patterns ───
4
+ const PHASE_HEADING = /^##\s+Phase\s+(\d+)\s*[:\-–—]\s*(.*)$/;
5
+ const TASK_LINE = /^- \[([ x~])\]\s+(.+)$/;
6
+ const ISSUE_MARKER_GH = /<!--\s*#(\d+)\s*-->/;
7
+ const ISSUE_MARKER_CU = /<!--\s*cu:(\S+)\s*-->/;
8
+ const METADATA_LINE = /^\s{2,}- ([A-Za-z][A-Za-z \-]*[A-Za-z]):\s*(.+)$/;
9
+ // ─── Status mapping ───
10
+ function parseCheckbox(char) {
11
+ if (char === 'x')
12
+ return 'done';
13
+ if (char === '~')
14
+ return 'skipped';
15
+ return 'todo';
16
+ }
17
+ // ─── Title extraction ───
18
+ function parseTitle(raw) {
19
+ // Remove bold markers: **Title** — description
20
+ let title = raw;
21
+ // Remove issue markers
22
+ title = title.replace(/<!--.*?-->/g, '').trim();
23
+ // Remove completion markers: ✅ a1b2c3d (2026-03-05)
24
+ title = title.replace(/✅\s*\w+\s*\(\d{4}-\d{2}-\d{2}\)\s*$/, '').trim();
25
+ // Remove strikethrough: ~~**Title**~~ — SKIPPED: ...
26
+ title = title.replace(/^~~(.+?)~~/, '$1').trim();
27
+ // Extract just the bold title part: **Title** — description → Title
28
+ const boldMatch = title.match(/^\*\*(.+?)\*\*/);
29
+ if (boldMatch)
30
+ return boldMatch[1].trim();
31
+ // Fallback: take text before em dash
32
+ const dashIdx = title.search(/\s[—–-]\s/);
33
+ if (dashIdx > 0)
34
+ return title.slice(0, dashIdx).trim();
35
+ return title.trim();
36
+ }
37
+ // ─── Issue marker extraction ───
38
+ function parseIssueMarker(raw) {
39
+ const gh = raw.match(ISSUE_MARKER_GH);
40
+ if (gh)
41
+ return `#${gh[1]}`;
42
+ const cu = raw.match(ISSUE_MARKER_CU);
43
+ if (cu)
44
+ return `cu:${cu[1]}`;
45
+ return null;
46
+ }
47
+ function parseType(raw) {
48
+ const lower = raw.trim().toLowerCase();
49
+ if (lower === 'frontend')
50
+ return 'frontend';
51
+ if (lower === 'backend')
52
+ return 'backend';
53
+ if (lower === 'fullstack')
54
+ return 'fullstack';
55
+ return 'fullstack';
56
+ }
57
+ function parseComplexity(raw) {
58
+ const upper = raw.trim().toUpperCase();
59
+ if (upper === 'S')
60
+ return 'S';
61
+ if (upper === 'M')
62
+ return 'M';
63
+ if (upper === 'L')
64
+ return 'L';
65
+ return 'M';
66
+ }
67
+ function parseDependsOn(raw, phase) {
68
+ const trimmed = raw.trim();
69
+ if (trimmed.toLowerCase() === 'nothing' || trimmed === '-' || trimmed === '')
70
+ return [];
71
+ return trimmed
72
+ .split(/[,/]/)
73
+ .map(dep => dep.trim())
74
+ .filter(dep => dep.length > 0 && dep.toLowerCase() !== 'nothing');
75
+ }
76
+ function inferChangelogCategory(title, status) {
77
+ if (status === 'skipped')
78
+ return 'none';
79
+ const lower = title.toLowerCase();
80
+ if (lower.startsWith('fix') || lower.includes('bug'))
81
+ return 'fixed';
82
+ if (lower.startsWith('refactor') || lower.startsWith('chore') || lower.startsWith('update config'))
83
+ return 'none';
84
+ if (lower.includes('breaking') || lower.includes('remove') || lower.includes('drop'))
85
+ return 'breaking';
86
+ if (lower.includes('change') || lower.includes('rename') || lower.includes('migrate'))
87
+ return 'changed';
88
+ return 'added';
89
+ }
90
+ // ─── Main parser ───
91
+ export function parseTodoMd(content) {
92
+ const lines = content.split('\n');
93
+ const tasks = [];
94
+ let currentPhase = -1;
95
+ let pendingTask = null;
96
+ let pendingMetadata = {};
97
+ function flushTask() {
98
+ if (!pendingTask)
99
+ return;
100
+ const meta = resolveMetadata(pendingMetadata);
101
+ const title = parseTitle(pendingTask.raw);
102
+ const id = `p${pendingTask.phase}-${tasks.filter(t => t.phase === pendingTask.phase).length + 1}`;
103
+ tasks.push({
104
+ id,
105
+ title,
106
+ phase: pendingTask.phase,
107
+ type: meta.type,
108
+ complexity: meta.complexity,
109
+ status: pendingTask.status,
110
+ dependsOn: meta.dependsOn,
111
+ issueMarker: pendingTask.issueMarker,
112
+ kitUpgrade: meta.kitUpgrade,
113
+ lineNumber: pendingTask.lineNumber,
114
+ changelog: inferChangelogCategory(title, pendingTask.status),
115
+ });
116
+ pendingTask = null;
117
+ pendingMetadata = {};
118
+ }
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = lines[i];
121
+ // Phase heading
122
+ const phaseMatch = line.match(PHASE_HEADING);
123
+ if (phaseMatch) {
124
+ flushTask();
125
+ currentPhase = parseInt(phaseMatch[1], 10);
126
+ continue;
127
+ }
128
+ // Task line
129
+ const taskMatch = line.match(TASK_LINE);
130
+ if (taskMatch && currentPhase >= 0) {
131
+ flushTask();
132
+ pendingTask = {
133
+ raw: taskMatch[2],
134
+ status: parseCheckbox(taskMatch[1]),
135
+ lineNumber: i + 1,
136
+ phase: currentPhase,
137
+ issueMarker: parseIssueMarker(taskMatch[2]),
138
+ };
139
+ continue;
140
+ }
141
+ // Metadata line (indented sub-item under a task)
142
+ if (pendingTask) {
143
+ const metaMatch = line.match(METADATA_LINE);
144
+ if (metaMatch) {
145
+ pendingMetadata[metaMatch[1]] = metaMatch[2];
146
+ continue;
147
+ }
148
+ // Non-metadata, non-empty line that isn't a continuation → flush
149
+ if (line.trim() !== '' && !line.match(/^\s{2,}-\s/) && !line.match(/^\s{4,}/)) {
150
+ flushTask();
151
+ }
152
+ }
153
+ }
154
+ // Flush last pending task
155
+ flushTask();
156
+ return tasks;
157
+ }
158
+ function resolveMetadata(meta) {
159
+ return {
160
+ type: meta['Type'] ? parseType(meta['Type']) : 'fullstack',
161
+ complexity: meta['Complexity'] ? parseComplexity(meta['Complexity']) : 'M',
162
+ dependsOn: meta['Depends on'] ? parseDependsOn(meta['Depends on'], 0) : [],
163
+ kitUpgrade: 'Kit-Upgrade' in meta,
164
+ };
165
+ }
166
+ // ─── Dependency resolution helpers ───
167
+ /**
168
+ * Resolves human-readable dependency references ("Phase 0", "Create auth types")
169
+ * to task IDs. Returns unresolved references as-is for error reporting.
170
+ */
171
+ export function resolveDependencies(tasks) {
172
+ const titleToId = new Map();
173
+ const phaseToIds = new Map();
174
+ for (const task of tasks) {
175
+ titleToId.set(task.title.toLowerCase(), task.id);
176
+ const phaseList = phaseToIds.get(task.phase) ?? [];
177
+ phaseList.push(task.id);
178
+ phaseToIds.set(task.phase, phaseList);
179
+ }
180
+ const resolved = new Map();
181
+ for (const task of tasks) {
182
+ const deps = [];
183
+ for (const dep of task.dependsOn) {
184
+ // "Phase N" → all tasks in that phase
185
+ const phaseRef = dep.match(/^Phase\s+(\d+)$/i);
186
+ if (phaseRef) {
187
+ const phaseNum = parseInt(phaseRef[1], 10);
188
+ const phaseTasks = phaseToIds.get(phaseNum) ?? [];
189
+ deps.push(...phaseTasks);
190
+ continue;
191
+ }
192
+ // Exact title match (case-insensitive)
193
+ const byTitle = titleToId.get(dep.toLowerCase());
194
+ if (byTitle) {
195
+ deps.push(byTitle);
196
+ continue;
197
+ }
198
+ // Partial title match (contains)
199
+ let found = false;
200
+ for (const [title, id] of titleToId) {
201
+ if (title.includes(dep.toLowerCase()) || dep.toLowerCase().includes(title)) {
202
+ deps.push(id);
203
+ found = true;
204
+ break;
205
+ }
206
+ }
207
+ // Unresolved — keep as-is (error will be caught by task-queue validation)
208
+ if (!found)
209
+ deps.push(`unresolved:${dep}`);
210
+ }
211
+ resolved.set(task.id, deps);
212
+ }
213
+ return resolved;
214
+ }
215
+ /**
216
+ * Updates a single task's checkbox in TODO.md content.
217
+ * Returns the updated content string.
218
+ */
219
+ export function updateTaskCheckbox(content, lineNumber, newStatus) {
220
+ const lines = content.split('\n');
221
+ const idx = lineNumber - 1;
222
+ if (idx < 0 || idx >= lines.length)
223
+ return content;
224
+ const line = lines[idx];
225
+ const checkbox = newStatus === 'done' ? '[x]' : newStatus === 'skipped' ? '[~]' : '[ ]';
226
+ lines[idx] = line.replace(/\[([ x~])\]/, checkbox);
227
+ return lines.join('\n');
228
+ }
@@ -0,0 +1,303 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseTodoMd, resolveDependencies, updateTaskCheckbox } from './parser.mjs';
3
+ // ─── Test fixtures ───
4
+ const MINIMAL_TODO = `# TODO.md
5
+
6
+ ## Phase 0: Foundation
7
+
8
+ - [ ] **Set up project scaffolding** — Nx workspace, ESLint, Prettier
9
+ - Type: fullstack
10
+ - Complexity: M
11
+ - Depends on: nothing
12
+ `;
13
+ const FULL_TODO = `# TODO.md
14
+
15
+ > Auto-generated from PRODUCT.md by technical-planner agent.
16
+
17
+ ## Phase 0: Foundation
18
+ Infrastructure and tooling.
19
+
20
+ - [x] **Set up Nx workspace** — monorepo scaffolding ✅ a1b2c3d (2026-03-15) <!-- #1 -->
21
+ - Type: fullstack
22
+ - Files: \`workspace.json\`, \`nx.json\`
23
+ - Depends on: nothing
24
+ - Acceptance: nx build works
25
+ - Complexity: S
26
+
27
+ - [~] ~~**Legacy migration tool**~~ — SKIPPED: blocked by missing API
28
+ - Type: backend
29
+ - Complexity: M
30
+ - Depends on: nothing
31
+
32
+ ## Phase 1: User Authentication
33
+ Registration, login, sessions.
34
+
35
+ - [ ] **Create auth domain types** — User, Session interfaces <!-- #5 -->
36
+ - Type: backend
37
+ - Files: \`libs/auth/domain/\`
38
+ - Depends on: Phase 0
39
+ - Acceptance: TypeScript compiles
40
+ - Complexity: S
41
+
42
+ - [ ] **Implement registration flow** — email, password, DB <!-- #6 -->
43
+ - Type: fullstack
44
+ - Files: \`apps/api/src/routes/auth.ts\`, \`apps/frontend/src/pages/register.tsx\`
45
+ - Depends on: Create auth domain types
46
+ - Acceptance: User can register
47
+ - Complexity: M
48
+
49
+ - [ ] **OAuth integration** — GitHub OAuth provider <!-- #7 -->
50
+ - Type: backend
51
+ - Depends on: Phase 0
52
+ - Complexity: S
53
+
54
+ ## Phase 2: Dashboard
55
+ User dashboard with analytics.
56
+
57
+ - [ ] **Dashboard layout** — responsive grid <!-- #10 -->
58
+ - Type: frontend
59
+ - Depends on: Phase 1
60
+ - Complexity: L
61
+
62
+ - [ ] **Upgrade sharp@0.32 → 0.33** — major version, review breaking changes
63
+ - Type: backend
64
+ - Depends on: nothing
65
+ - Complexity: S
66
+ - Kit-Upgrade: v2.1.0
67
+ `;
68
+ const CLICKUP_TODO = `# TODO.md
69
+
70
+ ## Phase 0: Setup
71
+
72
+ - [ ] **Init workspace** — scaffold everything <!-- cu:abc123def -->
73
+ - Type: fullstack
74
+ - Complexity: S
75
+ - Depends on: nothing
76
+ `;
77
+ // ─── parseTodoMd ───
78
+ describe('parseTodoMd', () => {
79
+ it('parses a minimal TODO with one task', () => {
80
+ const tasks = parseTodoMd(MINIMAL_TODO);
81
+ expect(tasks).toHaveLength(1);
82
+ expect(tasks[0]).toMatchObject({
83
+ id: 'p0-1',
84
+ title: 'Set up project scaffolding',
85
+ phase: 0,
86
+ type: 'fullstack',
87
+ complexity: 'M',
88
+ status: 'todo',
89
+ dependsOn: [],
90
+ issueMarker: null,
91
+ kitUpgrade: false,
92
+ });
93
+ });
94
+ it('parses all tasks from a full TODO', () => {
95
+ const tasks = parseTodoMd(FULL_TODO);
96
+ expect(tasks).toHaveLength(7);
97
+ });
98
+ it('parses phase numbers correctly', () => {
99
+ const tasks = parseTodoMd(FULL_TODO);
100
+ expect(tasks.filter(t => t.phase === 0)).toHaveLength(2);
101
+ expect(tasks.filter(t => t.phase === 1)).toHaveLength(3);
102
+ expect(tasks.filter(t => t.phase === 2)).toHaveLength(2);
103
+ });
104
+ it('parses checkbox states', () => {
105
+ const tasks = parseTodoMd(FULL_TODO);
106
+ expect(tasks[0].status).toBe('done');
107
+ expect(tasks[1].status).toBe('skipped');
108
+ expect(tasks[2].status).toBe('todo');
109
+ });
110
+ it('parses task types', () => {
111
+ const tasks = parseTodoMd(FULL_TODO);
112
+ expect(tasks[0].type).toBe('fullstack');
113
+ expect(tasks[1].type).toBe('backend');
114
+ expect(tasks[2].type).toBe('backend');
115
+ expect(tasks[3].type).toBe('fullstack');
116
+ });
117
+ it('parses complexity levels', () => {
118
+ const tasks = parseTodoMd(FULL_TODO);
119
+ expect(tasks[0].complexity).toBe('S');
120
+ expect(tasks[1].complexity).toBe('M');
121
+ expect(tasks[5].complexity).toBe('L');
122
+ });
123
+ it('extracts GitHub issue markers', () => {
124
+ const tasks = parseTodoMd(FULL_TODO);
125
+ expect(tasks[0].issueMarker).toBe('#1');
126
+ expect(tasks[2].issueMarker).toBe('#5');
127
+ expect(tasks[1].issueMarker).toBeNull();
128
+ });
129
+ it('extracts ClickUp issue markers', () => {
130
+ const tasks = parseTodoMd(CLICKUP_TODO);
131
+ expect(tasks[0].issueMarker).toBe('cu:abc123def');
132
+ });
133
+ it('parses raw dependency references', () => {
134
+ const tasks = parseTodoMd(FULL_TODO);
135
+ // "nothing" → empty
136
+ expect(tasks[0].dependsOn).toEqual([]);
137
+ // "Phase 0" → kept as string for resolution
138
+ expect(tasks[2].dependsOn).toEqual(['Phase 0']);
139
+ // "Create auth domain types" → kept as string
140
+ expect(tasks[3].dependsOn).toEqual(['Create auth domain types']);
141
+ });
142
+ it('detects kit-upgrade tasks', () => {
143
+ const tasks = parseTodoMd(FULL_TODO);
144
+ const kitTask = tasks.find(t => t.kitUpgrade);
145
+ expect(kitTask).toBeDefined();
146
+ expect(kitTask.title).toBe('Upgrade sharp@0.32 → 0.33');
147
+ });
148
+ it('extracts clean titles from bold markdown', () => {
149
+ const tasks = parseTodoMd(FULL_TODO);
150
+ expect(tasks[0].title).toBe('Set up Nx workspace');
151
+ expect(tasks[2].title).toBe('Create auth domain types');
152
+ });
153
+ it('extracts clean titles from skipped (strikethrough) tasks', () => {
154
+ const tasks = parseTodoMd(FULL_TODO);
155
+ expect(tasks[1].title).toBe('Legacy migration tool');
156
+ });
157
+ it('assigns sequential IDs per phase', () => {
158
+ const tasks = parseTodoMd(FULL_TODO);
159
+ expect(tasks[0].id).toBe('p0-1');
160
+ expect(tasks[1].id).toBe('p0-2');
161
+ expect(tasks[2].id).toBe('p1-1');
162
+ expect(tasks[3].id).toBe('p1-2');
163
+ expect(tasks[4].id).toBe('p1-3');
164
+ expect(tasks[5].id).toBe('p2-1');
165
+ expect(tasks[6].id).toBe('p2-2');
166
+ });
167
+ it('records line numbers for checkbox updates', () => {
168
+ const tasks = parseTodoMd(MINIMAL_TODO);
169
+ expect(tasks[0].lineNumber).toBeGreaterThan(0);
170
+ // Verify the line number points to the actual task line
171
+ const lines = MINIMAL_TODO.split('\n');
172
+ expect(lines[tasks[0].lineNumber - 1]).toMatch(/^- \[ \]/);
173
+ });
174
+ it('infers changelog category from title', () => {
175
+ const tasks = parseTodoMd(FULL_TODO);
176
+ // "Set up Nx workspace" (done) → added
177
+ expect(tasks[0].changelog).toBe('added');
178
+ // "Legacy migration tool" (skipped) → none
179
+ expect(tasks[1].changelog).toBe('none');
180
+ });
181
+ it('handles empty content', () => {
182
+ expect(parseTodoMd('')).toEqual([]);
183
+ });
184
+ it('handles content with no phases', () => {
185
+ expect(parseTodoMd('# Just a heading\nSome text.')).toEqual([]);
186
+ });
187
+ it('handles tasks without metadata', () => {
188
+ const noMeta = `## Phase 0: Stuff\n- [ ] **Do something** — a task\n`;
189
+ const tasks = parseTodoMd(noMeta);
190
+ expect(tasks).toHaveLength(1);
191
+ expect(tasks[0].type).toBe('fullstack');
192
+ expect(tasks[0].complexity).toBe('M');
193
+ });
194
+ it('handles multiple depends separated by /', () => {
195
+ const multiDep = `## Phase 1: X
196
+ - [ ] **Task A** — first
197
+ - Type: backend
198
+ - Complexity: S
199
+ - Depends on: Auth types / Session manager
200
+ `;
201
+ const tasks = parseTodoMd(multiDep);
202
+ expect(tasks[0].dependsOn).toEqual(['Auth types', 'Session manager']);
203
+ });
204
+ it('handles multiple depends separated by comma', () => {
205
+ const multiDep = `## Phase 1: X
206
+ - [ ] **Task B** — second
207
+ - Type: backend
208
+ - Complexity: S
209
+ - Depends on: Auth types, Session manager
210
+ `;
211
+ const tasks = parseTodoMd(multiDep);
212
+ expect(tasks[0].dependsOn).toEqual(['Auth types', 'Session manager']);
213
+ });
214
+ });
215
+ // ─── resolveDependencies ───
216
+ describe('resolveDependencies', () => {
217
+ it('resolves "Phase N" to all tasks in that phase', () => {
218
+ const tasks = parseTodoMd(FULL_TODO);
219
+ const resolved = resolveDependencies(tasks);
220
+ // "Create auth domain types" depends on Phase 0 → p0-1, p0-2
221
+ const authDeps = resolved.get('p1-1');
222
+ expect(authDeps).toContain('p0-1');
223
+ expect(authDeps).toContain('p0-2');
224
+ });
225
+ it('resolves exact title references', () => {
226
+ const tasks = parseTodoMd(FULL_TODO);
227
+ const resolved = resolveDependencies(tasks);
228
+ // "Implement registration flow" depends on "Create auth domain types" → p1-1
229
+ const regDeps = resolved.get('p1-2');
230
+ expect(regDeps).toContain('p1-1');
231
+ });
232
+ it('resolves partial title matches', () => {
233
+ const todo = `## Phase 0: Setup
234
+ - [ ] **Create authentication domain types** — interfaces
235
+ - Type: backend
236
+ - Complexity: S
237
+ - Depends on: nothing
238
+ - [ ] **Implement login** — flow
239
+ - Type: backend
240
+ - Complexity: M
241
+ - Depends on: authentication domain
242
+ `;
243
+ const tasks = parseTodoMd(todo);
244
+ const resolved = resolveDependencies(tasks);
245
+ const loginDeps = resolved.get('p0-2');
246
+ expect(loginDeps).toContain('p0-1');
247
+ });
248
+ it('marks unresolvable dependencies', () => {
249
+ const todo = `## Phase 0: Setup
250
+ - [ ] **Task A** — something
251
+ - Type: backend
252
+ - Complexity: S
253
+ - Depends on: Non-existent task
254
+ `;
255
+ const tasks = parseTodoMd(todo);
256
+ const resolved = resolveDependencies(tasks);
257
+ expect(resolved.get('p0-1')[0]).toBe('unresolved:Non-existent task');
258
+ });
259
+ it('returns empty deps for tasks with no dependencies', () => {
260
+ const tasks = parseTodoMd(MINIMAL_TODO);
261
+ const resolved = resolveDependencies(tasks);
262
+ expect(resolved.get('p0-1')).toEqual([]);
263
+ });
264
+ });
265
+ // ─── updateTaskCheckbox ───
266
+ describe('updateTaskCheckbox', () => {
267
+ it('marks a todo task as done', () => {
268
+ const tasks = parseTodoMd(MINIMAL_TODO);
269
+ const updated = updateTaskCheckbox(MINIMAL_TODO, tasks[0].lineNumber, 'done');
270
+ expect(updated).toContain('- [x] **Set up project scaffolding**');
271
+ });
272
+ it('marks a todo task as skipped', () => {
273
+ const tasks = parseTodoMd(MINIMAL_TODO);
274
+ const updated = updateTaskCheckbox(MINIMAL_TODO, tasks[0].lineNumber, 'skipped');
275
+ expect(updated).toContain('- [~] **Set up project scaffolding**');
276
+ });
277
+ it('marks a done task back to todo', () => {
278
+ const doneTodo = `## Phase 0: X\n- [x] **Done task** — was done\n`;
279
+ const updated = updateTaskCheckbox(doneTodo, 2, 'todo');
280
+ expect(updated).toContain('- [ ] **Done task**');
281
+ });
282
+ it('returns content unchanged for invalid line number', () => {
283
+ expect(updateTaskCheckbox(MINIMAL_TODO, 999, 'done')).toBe(MINIMAL_TODO);
284
+ expect(updateTaskCheckbox(MINIMAL_TODO, 0, 'done')).toBe(MINIMAL_TODO);
285
+ expect(updateTaskCheckbox(MINIMAL_TODO, -1, 'done')).toBe(MINIMAL_TODO);
286
+ });
287
+ it('preserves all other lines', () => {
288
+ const tasks = parseTodoMd(FULL_TODO);
289
+ const updated = updateTaskCheckbox(FULL_TODO, tasks[2].lineNumber, 'done');
290
+ const originalLines = FULL_TODO.split('\n');
291
+ const updatedLines = updated.split('\n');
292
+ expect(updatedLines).toHaveLength(originalLines.length);
293
+ // Only the target line should change
294
+ for (let i = 0; i < originalLines.length; i++) {
295
+ if (i === tasks[2].lineNumber - 1) {
296
+ expect(updatedLines[i]).not.toBe(originalLines[i]);
297
+ }
298
+ else {
299
+ expect(updatedLines[i]).toBe(originalLines[i]);
300
+ }
301
+ }
302
+ });
303
+ });
@@ -0,0 +1,152 @@
1
+ // ─── Dependency resolution and task scheduling ───
2
+ import { resolveDependencies } from './parser.mjs';
3
+ // ─── Build dependency graph ───
4
+ export function buildGraph(tasks) {
5
+ const resolved = resolveDependencies(tasks);
6
+ const taskMap = new Map();
7
+ const depsMap = new Map();
8
+ for (const task of tasks) {
9
+ taskMap.set(task.id, task);
10
+ const deps = resolved.get(task.id) ?? [];
11
+ // Filter out unresolved deps and self-references
12
+ depsMap.set(task.id, deps.filter(d => !d.startsWith('unresolved:') && d !== task.id));
13
+ }
14
+ return {
15
+ tasks: taskMap,
16
+ runnable() {
17
+ const result = [];
18
+ for (const task of taskMap.values()) {
19
+ if (task.status !== 'todo')
20
+ continue;
21
+ const deps = depsMap.get(task.id) ?? [];
22
+ const allSatisfied = deps.every(depId => {
23
+ const dep = taskMap.get(depId);
24
+ return dep && (dep.status === 'done' || dep.status === 'skipped');
25
+ });
26
+ if (allSatisfied)
27
+ result.push(task);
28
+ }
29
+ // Kit-upgrade tasks first, then by phase, then by complexity (S before M before L)
30
+ return result.sort((a, b) => {
31
+ if (a.kitUpgrade !== b.kitUpgrade)
32
+ return a.kitUpgrade ? -1 : 1;
33
+ if (a.phase !== b.phase)
34
+ return a.phase - b.phase;
35
+ return complexityOrder(a.complexity) - complexityOrder(b.complexity);
36
+ });
37
+ },
38
+ findCycle() {
39
+ return detectCycle(taskMap, depsMap);
40
+ },
41
+ };
42
+ }
43
+ // ─── Parallel batching ───
44
+ /**
45
+ * Groups runnable tasks that have no mutual dependencies
46
+ * and can safely run in parallel in separate worktrees.
47
+ */
48
+ export function getParallelBatches(runnable) {
49
+ if (runnable.length === 0)
50
+ return [];
51
+ if (runnable.length === 1)
52
+ return [runnable];
53
+ // Simple strategy: tasks in different phases can't run in parallel
54
+ // (phase N+1 may depend on phase N results).
55
+ // Within the same phase, all runnable tasks are independent by definition
56
+ // (their deps are already satisfied).
57
+ const byPhase = new Map();
58
+ for (const task of runnable) {
59
+ const phase = byPhase.get(task.phase) ?? [];
60
+ phase.push(task);
61
+ byPhase.set(task.phase, phase);
62
+ }
63
+ // Only return tasks from the lowest phase
64
+ const lowestPhase = Math.min(...byPhase.keys());
65
+ const batch = byPhase.get(lowestPhase) ?? [];
66
+ return batch.length > 0 ? [batch] : [];
67
+ }
68
+ // ─── Cycle detection (DFS) ───
69
+ function detectCycle(tasks, deps) {
70
+ const visited = new Set();
71
+ const inStack = new Set();
72
+ const path = [];
73
+ function dfs(id) {
74
+ if (inStack.has(id)) {
75
+ // Found a cycle — extract it
76
+ const cycleStart = path.indexOf(id);
77
+ return [...path.slice(cycleStart), id];
78
+ }
79
+ if (visited.has(id))
80
+ return null;
81
+ visited.add(id);
82
+ inStack.add(id);
83
+ path.push(id);
84
+ for (const dep of deps.get(id) ?? []) {
85
+ if (!tasks.has(dep))
86
+ continue; // Skip unresolvable deps
87
+ const cycle = dfs(dep);
88
+ if (cycle)
89
+ return cycle;
90
+ }
91
+ path.pop();
92
+ inStack.delete(id);
93
+ return null;
94
+ }
95
+ for (const id of tasks.keys()) {
96
+ const cycle = dfs(id);
97
+ if (cycle)
98
+ return cycle;
99
+ }
100
+ return null;
101
+ }
102
+ // ─── Helpers ───
103
+ function complexityOrder(c) {
104
+ if (c === 'S')
105
+ return 0;
106
+ if (c === 'M')
107
+ return 1;
108
+ return 2; // L
109
+ }
110
+ /**
111
+ * Returns tasks that are blocked (have unsatisfied deps).
112
+ */
113
+ export function getBlockedTasks(graph) {
114
+ const blocked = [];
115
+ for (const task of graph.tasks.values()) {
116
+ if (task.status !== 'todo')
117
+ continue;
118
+ // If it's todo but not runnable, it's blocked
119
+ const runnable = graph.runnable();
120
+ if (!runnable.find(t => t.id === task.id)) {
121
+ blocked.push(task);
122
+ }
123
+ }
124
+ return blocked;
125
+ }
126
+ /**
127
+ * Check if all tasks in a phase are done or skipped.
128
+ */
129
+ export function isPhaseComplete(tasks, phase) {
130
+ const phaseTasks = tasks.filter(t => t.phase === phase);
131
+ if (phaseTasks.length === 0)
132
+ return false;
133
+ return phaseTasks.every(t => t.status === 'done' || t.status === 'skipped');
134
+ }
135
+ /**
136
+ * Get the next phase number (lowest phase with remaining todo tasks).
137
+ */
138
+ export function getNextPhase(tasks) {
139
+ const phases = [...new Set(tasks.map(t => t.phase))].sort((a, b) => a - b);
140
+ for (const phase of phases) {
141
+ if (tasks.some(t => t.phase === phase && t.status === 'todo')) {
142
+ return phase;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ /**
148
+ * Check if all tasks across all phases are done or skipped.
149
+ */
150
+ export function isProjectComplete(tasks) {
151
+ return tasks.length > 0 && tasks.every(t => t.status === 'done' || t.status === 'skipped');
152
+ }