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.
- package/README.md +33 -1
- package/dist/index.js +29 -56
- package/dist/scheduler/agents/health-checker.mjs +98 -0
- package/dist/scheduler/agents/health-checker.spec.js +143 -0
- package/dist/scheduler/agents/orchestrator.mjs +149 -0
- package/dist/scheduler/agents/orchestrator.spec.js +87 -0
- package/dist/scheduler/agents/prompt-builder.mjs +204 -0
- package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
- package/dist/scheduler/agents/worker-pool.mjs +137 -0
- package/dist/scheduler/agents/worker-pool.spec.js +45 -0
- package/dist/scheduler/git/ci-watcher.mjs +93 -0
- package/dist/scheduler/git/ci-watcher.spec.js +35 -0
- package/dist/scheduler/git/manager.mjs +228 -0
- package/dist/scheduler/git/manager.spec.js +198 -0
- package/dist/scheduler/git/release.mjs +117 -0
- package/dist/scheduler/git/release.spec.js +175 -0
- package/dist/scheduler/index.mjs +309 -0
- package/dist/scheduler/index.spec.js +72 -0
- package/dist/scheduler/integration.spec.js +289 -0
- package/dist/scheduler/loop.mjs +435 -0
- package/dist/scheduler/loop.spec.js +139 -0
- package/dist/scheduler/state/session.mjs +14 -0
- package/dist/scheduler/state/session.spec.js +36 -0
- package/dist/scheduler/state/state.mjs +102 -0
- package/dist/scheduler/state/state.spec.js +175 -0
- package/dist/scheduler/tasks/inbox.mjs +98 -0
- package/dist/scheduler/tasks/inbox.spec.js +168 -0
- package/dist/scheduler/tasks/parser.mjs +228 -0
- package/dist/scheduler/tasks/parser.spec.js +303 -0
- package/dist/scheduler/tasks/queue.mjs +152 -0
- package/dist/scheduler/tasks/queue.spec.js +223 -0
- package/dist/scheduler/types.mjs +20 -0
- package/dist/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
- package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -0
- package/dist/scheduler/util/memory.mjs +126 -0
- package/dist/scheduler/util/memory.spec.js +165 -0
- package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
- package/package.json +3 -4
- package/dist/scripts/autonomous.mjs +0 -492
- package/dist/scripts/autonomous.spec.js +0 -46
- package/dist/scripts/docker-run.mjs +0 -462
- package/dist/scripts/integration.spec.js +0 -108
- package/dist/scripts/lib/formatter.mjs +0 -309
- package/dist/scripts/lib/formatter.spec.js +0 -262
- package/dist/scripts/lib/state.mjs +0 -44
- package/dist/scripts/lib/state.spec.js +0 -59
- package/dist/template/.claude/docker/.dockerignore +0 -8
- package/dist/template/.claude/docker/Dockerfile +0 -54
- package/dist/template/.claude/docker/docker-compose.yml +0 -22
- package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
- /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
- /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
|
+
}
|