tlc-claude-code 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +59 -50
- package/.claude/commands/tlc/review-plan.md +363 -0
- package/.claude/commands/tlc/review.md +155 -53
- package/CLAUDE.md +1 -0
- package/bin/install.js +105 -8
- package/bin/postinstall.js +60 -1
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- package/package.json +2 -2
- package/scripts/project-docs.js +1 -1
- package/server/lib/cost-tracker.test.js +49 -12
- package/server/lib/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -0
- package/server/lib/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -0
- package/server/setup.sh +271 -271
|
@@ -151,21 +151,58 @@ describe('Cost Tracker', () => {
|
|
|
151
151
|
|
|
152
152
|
describe('getWeeklyCost', () => {
|
|
153
153
|
it('aggregates days in week', () => {
|
|
154
|
-
//
|
|
154
|
+
// Use explicit UTC day strings for Mon–Fri of the current UTC week so
|
|
155
|
+
// the recorded entries are immune to local-vs-UTC boundary differences.
|
|
156
|
+
// We record two entries and then recompute the expected weekly total using
|
|
157
|
+
// the same week-window logic the implementation uses, so the assertion is
|
|
158
|
+
// always self-consistent regardless of timezone or day-of-week.
|
|
155
159
|
const now = new Date();
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
const weekStart = new Date(now);
|
|
161
|
+
weekStart.setDate(now.getDate() - now.getDay());
|
|
162
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
163
|
+
|
|
164
|
+
// Pick up to two UTC day strings from the past 7 days that fall within
|
|
165
|
+
// the implementation's [weekStart, now] window so we always have at
|
|
166
|
+
// least one in-range day (or zero if the implementation window is empty,
|
|
167
|
+
// in which case the expected total is also 0).
|
|
168
|
+
const candidateDays = [];
|
|
169
|
+
for (let offset = 1; offset <= 6; offset++) {
|
|
170
|
+
const d = new Date(now);
|
|
171
|
+
d.setUTCDate(now.getUTCDate() - offset);
|
|
172
|
+
d.setUTCHours(12, 0, 0, 0); // noon UTC avoids any day-rollover at midnight
|
|
173
|
+
const dayStr = d.toISOString().split('T')[0];
|
|
174
|
+
const dayDate = new Date(dayStr);
|
|
175
|
+
if (dayDate >= weekStart && dayDate <= now) {
|
|
176
|
+
candidateDays.push(dayStr);
|
|
177
|
+
if (candidateDays.length >= 2) break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Always record one entry for the exact current moment so that entry is
|
|
182
|
+
// trivially <= now; whether it passes the weekStart check determines the
|
|
183
|
+
// expected total.
|
|
184
|
+
const nowDayStr = now.toISOString().split('T')[0];
|
|
185
|
+
|
|
186
|
+
const allDays = [...new Set([...candidateDays, nowDayStr])];
|
|
187
|
+
let expectedTotal = 0;
|
|
188
|
+
for (const dayStr of allDays) {
|
|
189
|
+
const dayDate = new Date(dayStr);
|
|
190
|
+
const cost = 1.00;
|
|
191
|
+
recordCost(tracker, {
|
|
192
|
+
agentId: 'agent-1',
|
|
193
|
+
sessionId: 'session-1',
|
|
194
|
+
model: 'claude-3-opus',
|
|
195
|
+
provider: 'anthropic',
|
|
196
|
+
cost,
|
|
197
|
+
timestamp: `${dayStr}T12:00:00.000Z`,
|
|
198
|
+
});
|
|
199
|
+
if (dayDate >= weekStart && dayDate <= now) {
|
|
200
|
+
expectedTotal += cost;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
166
203
|
|
|
167
204
|
const weeklyCost = getWeeklyCost(tracker);
|
|
168
|
-
assert.
|
|
205
|
+
assert.strictEqual(weeklyCost, expectedTotal);
|
|
169
206
|
});
|
|
170
207
|
});
|
|
171
208
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Dispatcher
|
|
3
|
+
* Routes tasks to Claude or Codex with correct CLI flags per provider.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_PROVIDERS = new Set(['claude', 'codex']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a TDD-enforcing prompt from a task object.
|
|
10
|
+
* @param {object} task - Task with goal, files, criteria, testCases.
|
|
11
|
+
* @returns {string} Prompt string.
|
|
12
|
+
*/
|
|
13
|
+
export function buildPrompt(task) {
|
|
14
|
+
const { goal, files = [], criteria = [], testCases = [] } = task;
|
|
15
|
+
|
|
16
|
+
const fileList = files.map((f) => ` - ${f}`).join('\n');
|
|
17
|
+
const criteriaList = criteria.map((c) => ` - ${c}`).join('\n');
|
|
18
|
+
const testCaseList = testCases.map((t) => ` - ${t}`).join('\n');
|
|
19
|
+
|
|
20
|
+
return [
|
|
21
|
+
`Goal: ${goal}`,
|
|
22
|
+
'',
|
|
23
|
+
'Files to work with:',
|
|
24
|
+
fileList,
|
|
25
|
+
'',
|
|
26
|
+
'Acceptance criteria:',
|
|
27
|
+
criteriaList,
|
|
28
|
+
'',
|
|
29
|
+
'Test cases:',
|
|
30
|
+
testCaseList,
|
|
31
|
+
'',
|
|
32
|
+
'Methodology: Write tests first (red → green → refactor). You MUST write the test before implementing any code. Test-first is required.',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns sandbox CLI arguments for Codex based on the current platform.
|
|
38
|
+
* @param {object} task - Task object (reserved for future use).
|
|
39
|
+
* @param {string} platform - OS platform string (e.g. 'darwin', 'linux', 'win32').
|
|
40
|
+
* @returns {string} Sandbox flag string or empty string.
|
|
41
|
+
*/
|
|
42
|
+
export function buildSandboxArgs(_task, platform) {
|
|
43
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
44
|
+
return '--sandbox workspace-write';
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dispatch a task to the specified AI provider, returning the shell command string.
|
|
51
|
+
* @param {object} task - Task object.
|
|
52
|
+
* @param {string} worktreePath - Absolute path to the git worktree.
|
|
53
|
+
* @param {string} provider - Provider name: 'claude' or 'codex'.
|
|
54
|
+
* @returns {string} Shell command string.
|
|
55
|
+
* @throws {Error} If the provider is not supported or not available.
|
|
56
|
+
*/
|
|
57
|
+
export function dispatch(task, worktreePath, provider) {
|
|
58
|
+
if (!SUPPORTED_PROVIDERS.has(provider)) {
|
|
59
|
+
throw new Error(`Provider "${provider}" is not supported or unavailable`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prompt = buildPrompt(task);
|
|
63
|
+
// Escape shell-sensitive characters to prevent injection from task content
|
|
64
|
+
const safePrompt = prompt
|
|
65
|
+
.replace(/\\/g, '\\\\')
|
|
66
|
+
.replace(/"/g, '\\"')
|
|
67
|
+
.replace(/\$/g, '\\$')
|
|
68
|
+
.replace(/`/g, '\\`')
|
|
69
|
+
.replace(/!/g, '\\!');
|
|
70
|
+
|
|
71
|
+
if (provider === 'claude') {
|
|
72
|
+
return `claude --agent builder --worktree "${worktreePath}" -p "${safePrompt}" --permission-mode auto`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (provider === 'codex') {
|
|
76
|
+
const sandbox = buildSandboxArgs(task, process.platform);
|
|
77
|
+
const sandboxPart = sandbox ? ` ${sandbox}` : '';
|
|
78
|
+
return `codex exec --full-auto -C "${worktreePath}"${sandboxPart} "${safePrompt}"`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a round-robin scheduler over a list of providers.
|
|
84
|
+
* @param {string[]} providers - Ordered list of provider names.
|
|
85
|
+
* @param {object} [routerState] - Optional router state with availability info.
|
|
86
|
+
* @returns {{ next: () => string }} Object with a `.next()` method.
|
|
87
|
+
*/
|
|
88
|
+
export function createRoundRobin(providers, routerState) {
|
|
89
|
+
// Filter to only available providers when routerState is given
|
|
90
|
+
const available = routerState
|
|
91
|
+
? providers.filter((p) => {
|
|
92
|
+
const info = routerState.providers?.[p];
|
|
93
|
+
return info ? info.available : true;
|
|
94
|
+
})
|
|
95
|
+
: providers;
|
|
96
|
+
|
|
97
|
+
// If routerState was provided and filtered everything out, respect that — don't fall back
|
|
98
|
+
const pool = routerState ? available : providers;
|
|
99
|
+
if (pool.length === 0) {
|
|
100
|
+
// No providers available — return a robin that always throws
|
|
101
|
+
return {
|
|
102
|
+
next() { throw new Error('No available providers in router state'); },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let index = 0;
|
|
107
|
+
return {
|
|
108
|
+
next() {
|
|
109
|
+
const provider = pool[index % pool.length];
|
|
110
|
+
index += 1;
|
|
111
|
+
return provider;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
dispatch,
|
|
4
|
+
buildPrompt,
|
|
5
|
+
buildSandboxArgs,
|
|
6
|
+
createRoundRobin,
|
|
7
|
+
} from './agent-dispatcher.js';
|
|
8
|
+
|
|
9
|
+
const SAMPLE_TASK = {
|
|
10
|
+
title: 'Create user schema',
|
|
11
|
+
goal: 'Define database schema for users table',
|
|
12
|
+
files: ['src/modules/user/user.repository.js', 'src/modules/user/user.repository.test.js'],
|
|
13
|
+
criteria: ['Schema has id, email, passwordHash', 'Email is unique'],
|
|
14
|
+
testCases: ['Schema validates correct user data', 'Schema rejects duplicate emails'],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ROUTER_STATE = {
|
|
18
|
+
providers: {
|
|
19
|
+
claude: { available: true, path: '/usr/bin/claude' },
|
|
20
|
+
codex: { available: true, path: '/usr/bin/codex' },
|
|
21
|
+
gemini: { available: false, path: '' },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('agent-dispatcher', () => {
|
|
26
|
+
describe('dispatch', () => {
|
|
27
|
+
it('builds correct Claude command with worktree', () => {
|
|
28
|
+
const cmd = dispatch(SAMPLE_TASK, '/path/to/worktree', 'claude');
|
|
29
|
+
expect(cmd).toContain('claude');
|
|
30
|
+
expect(cmd).toContain('--agent builder');
|
|
31
|
+
expect(cmd).toContain('/path/to/worktree');
|
|
32
|
+
expect(cmd).toContain('--permission-mode');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('builds correct Codex command with sandbox', () => {
|
|
36
|
+
const cmd = dispatch(SAMPLE_TASK, '/path/to/worktree', 'codex');
|
|
37
|
+
expect(cmd).toContain('codex exec');
|
|
38
|
+
expect(cmd).toContain('--full-auto');
|
|
39
|
+
expect(cmd).toContain('-C "/path/to/worktree"');
|
|
40
|
+
expect(cmd).toContain('--sandbox');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('skips unavailable provider', () => {
|
|
44
|
+
expect(() => dispatch(SAMPLE_TASK, '/path', 'gemini'))
|
|
45
|
+
.toThrow(/unavailable|not supported/i);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('buildPrompt', () => {
|
|
50
|
+
it('includes acceptance criteria from task', () => {
|
|
51
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
52
|
+
expect(prompt).toContain('Schema has id, email, passwordHash');
|
|
53
|
+
expect(prompt).toContain('Email is unique');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('includes test cases from task', () => {
|
|
57
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
58
|
+
expect(prompt).toContain('Schema validates correct user data');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('includes file list', () => {
|
|
62
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
63
|
+
expect(prompt).toContain('user.repository.js');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('enforces test-first', () => {
|
|
67
|
+
const prompt = buildPrompt(SAMPLE_TASK);
|
|
68
|
+
expect(prompt.toLowerCase()).toMatch(/test.*first|red.*green|write.*test.*before/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('buildSandboxArgs', () => {
|
|
73
|
+
it('uses workspace-write on macOS', () => {
|
|
74
|
+
const args = buildSandboxArgs(SAMPLE_TASK, 'darwin');
|
|
75
|
+
expect(args).toContain('--sandbox workspace-write');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses workspace-write on Linux', () => {
|
|
79
|
+
const args = buildSandboxArgs(SAMPLE_TASK, 'linux');
|
|
80
|
+
expect(args).toContain('--sandbox workspace-write');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns empty string for unsupported platform', () => {
|
|
84
|
+
const args = buildSandboxArgs(SAMPLE_TASK, 'win32');
|
|
85
|
+
expect(args).toBe('');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('createRoundRobin', () => {
|
|
90
|
+
it('alternates between providers', () => {
|
|
91
|
+
const rr = createRoundRobin(['claude', 'codex']);
|
|
92
|
+
expect(rr.next()).toBe('claude');
|
|
93
|
+
expect(rr.next()).toBe('codex');
|
|
94
|
+
expect(rr.next()).toBe('claude');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('skips unavailable providers', () => {
|
|
98
|
+
const rr = createRoundRobin(['claude', 'codex'], ROUTER_STATE);
|
|
99
|
+
// Both available, should alternate
|
|
100
|
+
expect(rr.next()).toBe('claude');
|
|
101
|
+
expect(rr.next()).toBe('codex');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles single provider', () => {
|
|
105
|
+
const rr = createRoundRobin(['claude']);
|
|
106
|
+
expect(rr.next()).toBe('claude');
|
|
107
|
+
expect(rr.next()).toBe('claude');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator Engine
|
|
3
|
+
* Coordinates parallel agent execution with dependency-aware scheduling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses markdown plan content and extracts tasks.
|
|
8
|
+
* @param {string} planContent - Markdown plan content
|
|
9
|
+
* @returns {Array<{number: number, title: string, goal: string, files: string[], criteria: string[], testCases: string[]}>}
|
|
10
|
+
*/
|
|
11
|
+
export function parsePlanTasks(planContent) {
|
|
12
|
+
const tasks = [];
|
|
13
|
+
// Split on task headings: ### Task N: Title OR ### Task N.M: Title
|
|
14
|
+
const sections = planContent.split(/^(?=### Task [\d.]+:)/m);
|
|
15
|
+
|
|
16
|
+
for (const section of sections) {
|
|
17
|
+
const headingMatch = section.match(/^### Task ([\d.]+):\s*(.+?)(?:\s*\[.*?\])?\s*$/m);
|
|
18
|
+
if (!headingMatch) continue;
|
|
19
|
+
|
|
20
|
+
// Use integer for plain numbers (1, 2, 3), keep full string for dotted (87.1, 87.2)
|
|
21
|
+
const rawId = headingMatch[1];
|
|
22
|
+
const number = rawId.includes('.') ? rawId : parseInt(rawId, 10);
|
|
23
|
+
const title = headingMatch[2].trim();
|
|
24
|
+
|
|
25
|
+
// Goal
|
|
26
|
+
const goalMatch = section.match(/\*\*Goal:\*\*\s*(.+)/);
|
|
27
|
+
const goal = goalMatch ? goalMatch[1].trim() : '';
|
|
28
|
+
|
|
29
|
+
// Files
|
|
30
|
+
const filesMatch = section.match(/\*\*Files:\*\*\s*([\s\S]*?)(?=\*\*|---|\n##|$)/);
|
|
31
|
+
const files = filesMatch
|
|
32
|
+
? filesMatch[1].split('\n').map(l => l.replace(/^-\s*/, '').trim()).filter(Boolean)
|
|
33
|
+
: [];
|
|
34
|
+
|
|
35
|
+
// Acceptance Criteria
|
|
36
|
+
const criteriaMatch = section.match(/\*\*Acceptance Criteria:\*\*\s*([\s\S]*?)(?=\*\*|---|\n##|$)/);
|
|
37
|
+
const criteria = criteriaMatch
|
|
38
|
+
? criteriaMatch[1].split('\n').map(l => l.replace(/^-\s*\[.\]\s*/, '').replace(/^-\s*/, '').trim()).filter(Boolean)
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
// Test Cases
|
|
42
|
+
const testCasesMatch = section.match(/\*\*Test Cases:\*\*\s*([\s\S]*?)(?=\*\*|---|\n##|$)/);
|
|
43
|
+
const testCases = testCasesMatch
|
|
44
|
+
? testCasesMatch[1].split('\n').map(l => l.replace(/^-\s*/, '').trim()).filter(Boolean)
|
|
45
|
+
: [];
|
|
46
|
+
|
|
47
|
+
tasks.push({ number, title, goal, files, criteria, testCases });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return tasks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a dependency graph from tasks and plan content.
|
|
55
|
+
* @param {Array<{number: number}>} tasks
|
|
56
|
+
* @param {string} planContent
|
|
57
|
+
* @returns {Map<number, number[]>} key=taskNumber, value=array of taskNumbers it depends on
|
|
58
|
+
*/
|
|
59
|
+
export function buildDependencyGraph(tasks, planContent) {
|
|
60
|
+
const graph = new Map();
|
|
61
|
+
|
|
62
|
+
// Initialise all tasks with empty dependency lists
|
|
63
|
+
for (const task of tasks) {
|
|
64
|
+
graph.set(task.number, []);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse ## Dependencies section
|
|
68
|
+
const depsMatch = planContent.match(/## Dependencies([\s\S]*?)(?=\n## |\n*$)/);
|
|
69
|
+
if (!depsMatch) return graph;
|
|
70
|
+
|
|
71
|
+
const depsSection = depsMatch[1];
|
|
72
|
+
// Match lines like "Task N depends on Task M"
|
|
73
|
+
const lineRe = /Task ([\d.]+) depends on Task ([\d.]+)/g;
|
|
74
|
+
let match;
|
|
75
|
+
while ((match = lineRe.exec(depsSection)) !== null) {
|
|
76
|
+
const rawDep = match[1];
|
|
77
|
+
const rawReq = match[2];
|
|
78
|
+
const dependent = rawDep.includes('.') ? rawDep : parseInt(rawDep, 10);
|
|
79
|
+
const dependency = rawReq.includes('.') ? rawReq : parseInt(rawReq, 10);
|
|
80
|
+
if (!graph.has(dependent)) graph.set(dependent, []);
|
|
81
|
+
graph.get(dependent).push(dependency);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return graph;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns tasks that have no unmet dependencies and are not already completed.
|
|
89
|
+
* @param {Array<{number: number}>} tasks
|
|
90
|
+
* @param {Map<number, number[]>} graph
|
|
91
|
+
* @param {Set<number>} completedSet
|
|
92
|
+
* @returns {Array<{number: number}>}
|
|
93
|
+
*/
|
|
94
|
+
export function getIndependentTasks(tasks, graph, completedSet) {
|
|
95
|
+
return tasks.filter(task => {
|
|
96
|
+
if (completedSet.has(task.number)) return false;
|
|
97
|
+
const deps = graph.get(task.number) || [];
|
|
98
|
+
return deps.every(dep => completedSet.has(dep));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Orchestrates agent execution for a phase.
|
|
104
|
+
* @param {number} phaseNumber
|
|
105
|
+
* @param {object} options
|
|
106
|
+
* @param {boolean} [options.dryRun]
|
|
107
|
+
* @param {string} [options.planContent]
|
|
108
|
+
* @param {number} [options.maxAgents]
|
|
109
|
+
* @returns {Promise<object>}
|
|
110
|
+
*/
|
|
111
|
+
export async function orchestrate(phaseNumber, options = {}) {
|
|
112
|
+
const { dryRun = false, planContent, maxAgents } = options;
|
|
113
|
+
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
const tasks = parsePlanTasks(planContent);
|
|
116
|
+
const graph = buildDependencyGraph(tasks, planContent);
|
|
117
|
+
const independent = getIndependentTasks(tasks, graph, new Set());
|
|
118
|
+
const independentCount = independent.length;
|
|
119
|
+
return {
|
|
120
|
+
dryRun: true,
|
|
121
|
+
tasks,
|
|
122
|
+
independentCount,
|
|
123
|
+
maxAgents,
|
|
124
|
+
sequential: independentCount <= 1,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Full orchestration (not needed for current tests)
|
|
129
|
+
throw new Error('Live orchestration not yet implemented');
|
|
130
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parsePlanTasks,
|
|
4
|
+
buildDependencyGraph,
|
|
5
|
+
getIndependentTasks,
|
|
6
|
+
orchestrate,
|
|
7
|
+
} from './orchestrator.js';
|
|
8
|
+
|
|
9
|
+
const SAMPLE_PLAN = `# Phase 42: Auth — Plan
|
|
10
|
+
|
|
11
|
+
## Tasks
|
|
12
|
+
|
|
13
|
+
### Task 1: Create user schema [ ]
|
|
14
|
+
|
|
15
|
+
**Goal:** Define schema
|
|
16
|
+
|
|
17
|
+
**Files:**
|
|
18
|
+
- src/user.js
|
|
19
|
+
|
|
20
|
+
**Acceptance Criteria:**
|
|
21
|
+
- [ ] Schema works
|
|
22
|
+
|
|
23
|
+
**Test Cases:**
|
|
24
|
+
- Schema validates
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### Task 2: Login endpoint [ ]
|
|
29
|
+
|
|
30
|
+
**Goal:** POST /login
|
|
31
|
+
|
|
32
|
+
**Files:**
|
|
33
|
+
- src/login.js
|
|
34
|
+
|
|
35
|
+
**Acceptance Criteria:**
|
|
36
|
+
- [ ] Login works
|
|
37
|
+
|
|
38
|
+
**Test Cases:**
|
|
39
|
+
- Valid login returns token
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### Task 3: Session middleware [ ]
|
|
44
|
+
|
|
45
|
+
**Goal:** JWT session
|
|
46
|
+
|
|
47
|
+
**Files:**
|
|
48
|
+
- src/session.js
|
|
49
|
+
|
|
50
|
+
**Acceptance Criteria:**
|
|
51
|
+
- [ ] Session persists
|
|
52
|
+
|
|
53
|
+
**Test Cases:**
|
|
54
|
+
- Session validates token
|
|
55
|
+
|
|
56
|
+
## Dependencies
|
|
57
|
+
|
|
58
|
+
Task 2 depends on Task 1
|
|
59
|
+
Task 3 depends on Task 2
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const PARALLEL_PLAN = `# Phase 43: Utils — Plan
|
|
63
|
+
|
|
64
|
+
## Tasks
|
|
65
|
+
|
|
66
|
+
### Task 1: String helpers [ ]
|
|
67
|
+
|
|
68
|
+
**Goal:** String utilities
|
|
69
|
+
|
|
70
|
+
**Files:**
|
|
71
|
+
- src/strings.js
|
|
72
|
+
|
|
73
|
+
**Acceptance Criteria:**
|
|
74
|
+
- [ ] Helpers work
|
|
75
|
+
|
|
76
|
+
**Test Cases:**
|
|
77
|
+
- capitalize works
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### Task 2: Date helpers [ ]
|
|
82
|
+
|
|
83
|
+
**Goal:** Date utilities
|
|
84
|
+
|
|
85
|
+
**Files:**
|
|
86
|
+
- src/dates.js
|
|
87
|
+
|
|
88
|
+
**Acceptance Criteria:**
|
|
89
|
+
- [ ] Helpers work
|
|
90
|
+
|
|
91
|
+
**Test Cases:**
|
|
92
|
+
- formatDate works
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### Task 3: Number helpers [ ]
|
|
97
|
+
|
|
98
|
+
**Goal:** Number utilities
|
|
99
|
+
|
|
100
|
+
**Files:**
|
|
101
|
+
- src/numbers.js
|
|
102
|
+
|
|
103
|
+
**Acceptance Criteria:**
|
|
104
|
+
- [ ] Helpers work
|
|
105
|
+
|
|
106
|
+
**Test Cases:**
|
|
107
|
+
- clamp works
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
describe('orchestrator', () => {
|
|
111
|
+
describe('parsePlanTasks', () => {
|
|
112
|
+
it('extracts tasks from plan content', () => {
|
|
113
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
114
|
+
expect(tasks).toHaveLength(3);
|
|
115
|
+
expect(tasks[0].title).toContain('Create user schema');
|
|
116
|
+
expect(tasks[1].title).toContain('Login endpoint');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('buildDependencyGraph', () => {
|
|
121
|
+
it('identifies dependent tasks', () => {
|
|
122
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
123
|
+
const graph = buildDependencyGraph(tasks, SAMPLE_PLAN);
|
|
124
|
+
expect(graph.get(2)).toContain(1); // Task 2 depends on Task 1
|
|
125
|
+
expect(graph.get(3)).toContain(2); // Task 3 depends on Task 2
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns empty deps for independent tasks', () => {
|
|
129
|
+
const tasks = parsePlanTasks(PARALLEL_PLAN);
|
|
130
|
+
const graph = buildDependencyGraph(tasks, PARALLEL_PLAN);
|
|
131
|
+
expect(graph.get(1)).toHaveLength(0);
|
|
132
|
+
expect(graph.get(2)).toHaveLength(0);
|
|
133
|
+
expect(graph.get(3)).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('getIndependentTasks', () => {
|
|
138
|
+
it('returns all tasks when no dependencies', () => {
|
|
139
|
+
const tasks = parsePlanTasks(PARALLEL_PLAN);
|
|
140
|
+
const graph = buildDependencyGraph(tasks, PARALLEL_PLAN);
|
|
141
|
+
const independent = getIndependentTasks(tasks, graph, new Set());
|
|
142
|
+
expect(independent).toHaveLength(3);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns only root tasks when dependencies exist', () => {
|
|
146
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
147
|
+
const graph = buildDependencyGraph(tasks, SAMPLE_PLAN);
|
|
148
|
+
const independent = getIndependentTasks(tasks, graph, new Set());
|
|
149
|
+
expect(independent).toHaveLength(1);
|
|
150
|
+
expect(independent[0].title).toContain('Create user schema');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('unblocks dependent tasks when prerequisite completed', () => {
|
|
154
|
+
const tasks = parsePlanTasks(SAMPLE_PLAN);
|
|
155
|
+
const graph = buildDependencyGraph(tasks, SAMPLE_PLAN);
|
|
156
|
+
const completed = new Set([1]); // Task 1 done
|
|
157
|
+
const independent = getIndependentTasks(tasks, graph, completed);
|
|
158
|
+
expect(independent).toHaveLength(1);
|
|
159
|
+
expect(independent[0].title).toContain('Login endpoint');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('orchestrate', () => {
|
|
164
|
+
it('generates build report on dry-run', async () => {
|
|
165
|
+
const result = await orchestrate(43, {
|
|
166
|
+
dryRun: true,
|
|
167
|
+
planContent: PARALLEL_PLAN,
|
|
168
|
+
});
|
|
169
|
+
expect(result.dryRun).toBe(true);
|
|
170
|
+
expect(result.tasks).toHaveLength(3);
|
|
171
|
+
expect(result.independentCount).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('respects max concurrent limit in dry-run', async () => {
|
|
175
|
+
const result = await orchestrate(43, {
|
|
176
|
+
dryRun: true,
|
|
177
|
+
planContent: PARALLEL_PLAN,
|
|
178
|
+
maxAgents: 2,
|
|
179
|
+
});
|
|
180
|
+
expect(result.maxAgents).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('identifies sequential plan correctly', async () => {
|
|
184
|
+
const result = await orchestrate(42, {
|
|
185
|
+
dryRun: true,
|
|
186
|
+
planContent: SAMPLE_PLAN,
|
|
187
|
+
});
|
|
188
|
+
expect(result.independentCount).toBe(1);
|
|
189
|
+
expect(result.sequential).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|