tlc-claude-code 2.2.1 → 2.4.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 (49) hide show
  1. package/.claude/agents/builder.md +17 -0
  2. package/.claude/commands/tlc/audit.md +12 -0
  3. package/.claude/commands/tlc/autofix.md +31 -0
  4. package/.claude/commands/tlc/build.md +98 -24
  5. package/.claude/commands/tlc/coverage.md +31 -0
  6. package/.claude/commands/tlc/discuss.md +31 -0
  7. package/.claude/commands/tlc/docs.md +31 -0
  8. package/.claude/commands/tlc/edge-cases.md +31 -0
  9. package/.claude/commands/tlc/guard.md +9 -0
  10. package/.claude/commands/tlc/init.md +12 -1
  11. package/.claude/commands/tlc/plan.md +31 -0
  12. package/.claude/commands/tlc/quick.md +31 -0
  13. package/.claude/commands/tlc/review.md +50 -0
  14. package/.claude/hooks/tlc-session-init.sh +14 -3
  15. package/CODING-STANDARDS.md +217 -10
  16. package/bin/setup-autoupdate.js +316 -87
  17. package/bin/setup-autoupdate.test.js +454 -34
  18. package/package.json +1 -1
  19. package/scripts/project-docs.js +1 -1
  20. package/server/lib/careful-patterns.js +142 -0
  21. package/server/lib/careful-patterns.test.js +164 -0
  22. package/server/lib/cli-dispatcher.js +98 -0
  23. package/server/lib/cli-dispatcher.test.js +249 -0
  24. package/server/lib/command-router.js +171 -0
  25. package/server/lib/command-router.test.js +336 -0
  26. package/server/lib/field-report.js +92 -0
  27. package/server/lib/field-report.test.js +195 -0
  28. package/server/lib/orchestration/worktree-manager.js +133 -0
  29. package/server/lib/orchestration/worktree-manager.test.js +198 -0
  30. package/server/lib/overdrive-command.js +31 -9
  31. package/server/lib/overdrive-command.test.js +25 -26
  32. package/server/lib/prompt-packager.js +98 -0
  33. package/server/lib/prompt-packager.test.js +185 -0
  34. package/server/lib/review-fixer.js +107 -0
  35. package/server/lib/review-fixer.test.js +152 -0
  36. package/server/lib/routing-command.js +159 -0
  37. package/server/lib/routing-command.test.js +290 -0
  38. package/server/lib/scope-checker.js +127 -0
  39. package/server/lib/scope-checker.test.js +175 -0
  40. package/server/lib/skill-validator.js +165 -0
  41. package/server/lib/skill-validator.test.js +289 -0
  42. package/server/lib/standards/standards-injector.js +6 -0
  43. package/server/lib/task-router-config.js +142 -0
  44. package/server/lib/task-router-config.test.js +428 -0
  45. package/server/lib/test-selector.js +127 -0
  46. package/server/lib/test-selector.test.js +172 -0
  47. package/server/setup.sh +271 -271
  48. package/server/templates/CLAUDE.md +6 -0
  49. package/server/templates/CODING-STANDARDS.md +356 -10
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Field Report Tests
3
+ *
4
+ * Tests for agent self-rating field report module.
5
+ * Reports are filed when quality is below threshold.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+
10
+ import {
11
+ shouldFileReport,
12
+ createFieldReport,
13
+ formatReportEntry,
14
+ appendReport,
15
+ } from './field-report.js';
16
+
17
+ const FIXED_DATE = '2026-03-26T12:00:00.000Z';
18
+ const nowFn = () => new Date(FIXED_DATE);
19
+
20
+ describe('field-report', () => {
21
+ describe('shouldFileReport', () => {
22
+ it('returns false for rating 10', () => {
23
+ expect(shouldFileReport(10)).toBe(false);
24
+ });
25
+
26
+ it('returns false for rating 9', () => {
27
+ expect(shouldFileReport(9)).toBe(false);
28
+ });
29
+
30
+ it('returns false for rating 8', () => {
31
+ expect(shouldFileReport(8)).toBe(false);
32
+ });
33
+
34
+ it('returns true for rating 7', () => {
35
+ expect(shouldFileReport(7)).toBe(true);
36
+ });
37
+
38
+ it('returns true for rating 0', () => {
39
+ expect(shouldFileReport(0)).toBe(true);
40
+ });
41
+
42
+ it('returns true for rating 1', () => {
43
+ expect(shouldFileReport(1)).toBe(true);
44
+ });
45
+
46
+ it('returns true for rating 5', () => {
47
+ expect(shouldFileReport(5)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe('createFieldReport', () => {
52
+ it('returns markdown with all fields', () => {
53
+ const report = createFieldReport({
54
+ skill: 'tlc:build',
55
+ rating: 5,
56
+ issue: 'Tests were flaky',
57
+ suggestion: 'Add retry logic',
58
+ now: nowFn,
59
+ });
60
+
61
+ expect(report).toContain('tlc:build');
62
+ expect(report).toContain('5/10');
63
+ expect(report).toContain('Tests were flaky');
64
+ expect(report).toContain('Add retry logic');
65
+ expect(report).toContain('**Suggestion:**');
66
+ });
67
+
68
+ it('includes CRITICAL marker for rating 0', () => {
69
+ const report = createFieldReport({
70
+ skill: 'tlc:review',
71
+ rating: 0,
72
+ issue: 'Complete failure',
73
+ suggestion: 'Start over',
74
+ now: nowFn,
75
+ });
76
+
77
+ expect(report).toContain('CRITICAL');
78
+ expect(report).toContain('0/10');
79
+ });
80
+
81
+ it('does not include CRITICAL marker for rating above 0', () => {
82
+ const report = createFieldReport({
83
+ skill: 'tlc:build',
84
+ rating: 3,
85
+ issue: 'Poor quality',
86
+ suggestion: 'Improve',
87
+ now: nowFn,
88
+ });
89
+
90
+ expect(report).not.toContain('CRITICAL');
91
+ });
92
+
93
+ it('includes ISO date string', () => {
94
+ const report = createFieldReport({
95
+ skill: 'tlc:build',
96
+ rating: 5,
97
+ issue: 'Flaky',
98
+ suggestion: 'Fix it',
99
+ now: nowFn,
100
+ });
101
+
102
+ expect(report).toContain('2026-03-26');
103
+ });
104
+
105
+ it('follows expected heading format', () => {
106
+ const report = createFieldReport({
107
+ skill: 'tlc:build',
108
+ rating: 5,
109
+ issue: 'Tests were flaky',
110
+ suggestion: 'Add retry logic',
111
+ now: nowFn,
112
+ });
113
+
114
+ expect(report).toMatch(
115
+ /^## 2026-03-26T12:00:00\.000Z tlc:build 5\/10 — Tests were flaky/
116
+ );
117
+ });
118
+ });
119
+
120
+ describe('formatReportEntry', () => {
121
+ it('returns properly formatted markdown section', () => {
122
+ const report = createFieldReport({
123
+ skill: 'tlc:build',
124
+ rating: 4,
125
+ issue: 'Slow tests',
126
+ suggestion: 'Parallelize',
127
+ now: nowFn,
128
+ });
129
+
130
+ const entry = formatReportEntry(report);
131
+
132
+ expect(entry).toContain('## 2026-03-26');
133
+ expect(entry).toContain('tlc:build');
134
+ expect(entry).toContain('4/10');
135
+ expect(entry).toContain('Slow tests');
136
+ expect(entry).toContain('**Suggestion:** Parallelize');
137
+ // Entry should end with a newline
138
+ expect(entry.endsWith('\n')).toBe(true);
139
+ });
140
+
141
+ it('returns the report unchanged if already formatted', () => {
142
+ const report = createFieldReport({
143
+ skill: 'tlc:build',
144
+ rating: 6,
145
+ issue: 'Minor issue',
146
+ suggestion: 'Tweak config',
147
+ now: nowFn,
148
+ });
149
+
150
+ const entry = formatReportEntry(report);
151
+ // formatReportEntry should ensure trailing newline
152
+ expect(entry).toBe(report.trimEnd() + '\n');
153
+ });
154
+ });
155
+
156
+ describe('appendReport', () => {
157
+ it('appends to existing content with separator', () => {
158
+ const report = createFieldReport({
159
+ skill: 'tlc:build',
160
+ rating: 5,
161
+ issue: 'Flaky tests',
162
+ suggestion: 'Add retries',
163
+ now: nowFn,
164
+ });
165
+
166
+ const result = appendReport('existing content', report);
167
+
168
+ expect(result).toContain('existing content');
169
+ expect(result).toContain(report.trim());
170
+ // Existing content comes first
171
+ expect(result.indexOf('existing content')).toBeLessThan(
172
+ result.indexOf('tlc:build')
173
+ );
174
+ // Has separator between existing and new
175
+ expect(result).toMatch(/existing content\n\n---\n\n/);
176
+ });
177
+
178
+ it('creates fresh content with header when existing is empty', () => {
179
+ const report = createFieldReport({
180
+ skill: 'tlc:review',
181
+ rating: 3,
182
+ issue: 'Missed edge case',
183
+ suggestion: 'Check boundaries',
184
+ now: nowFn,
185
+ });
186
+
187
+ const result = appendReport('', report);
188
+
189
+ expect(result).toContain('# Field Reports');
190
+ expect(result).toContain(report.trim());
191
+ // No separator before first report
192
+ expect(result).not.toMatch(/---\n\n##/);
193
+ });
194
+ });
195
+ });
@@ -108,6 +108,139 @@ export function removeWorktree(name, { exec } = {}) {
108
108
  exec(`git branch -D ${branch}`);
109
109
  }
110
110
 
111
+ /**
112
+ * Create an integration branch for a phase.
113
+ * All worktrees for this phase branch off and merge back into this branch,
114
+ * producing a single clean PR to main.
115
+ * @param {number} phase - Phase number
116
+ * @param {{ exec: Function, baseBranch?: string }} options
117
+ * @returns {{ branch: string, phase: number }}
118
+ */
119
+ export function createIntegrationBranch(phase, { exec, baseBranch = 'main' } = {}) {
120
+ const branch = `phase/${phase}`;
121
+
122
+ try {
123
+ exec(`git checkout -b ${branch} ${baseBranch}`);
124
+ } catch (err) {
125
+ // Only fall back if branch already exists — surface other errors
126
+ const msg = err && err.message ? err.message : String(err);
127
+ if (msg.includes('already exists')) {
128
+ exec(`git checkout ${branch}`);
129
+ } else {
130
+ throw err;
131
+ }
132
+ }
133
+
134
+ return { branch, phase };
135
+ }
136
+
137
+ /**
138
+ * Get the list of files changed by a branch relative to a base.
139
+ * @param {string} branch - Branch to check
140
+ * @param {string} baseBranch - Base branch to diff against
141
+ * @param {{ exec: Function }} options
142
+ * @returns {string[]} List of changed file paths
143
+ */
144
+ export function getChangedFiles(branch, baseBranch, { exec } = {}) {
145
+ const output = exec(`git diff --name-only ${baseBranch}...${branch}`);
146
+ return output.split('\n').filter(f => f.trim() !== '');
147
+ }
148
+
149
+ /**
150
+ * Merge all worktrees into the integration branch sequentially.
151
+ * After each merge, rebases remaining worktrees onto the updated integration branch.
152
+ * Orders worktrees by file overlap — disjoint worktrees merge first to minimize conflicts.
153
+ * @param {Array<{ name: string, branch: string, path: string }>} worktrees
154
+ * @param {string} integrationBranch
155
+ * @param {{ exec: Function }} options
156
+ * @returns {{ merged: string[], conflicts: string[], integrationBranch: string }}
157
+ */
158
+ export function mergeAllWorktrees(worktrees, integrationBranch, { exec } = {}) {
159
+ const merged = [];
160
+ const conflicts = [];
161
+
162
+ if (worktrees.length === 0) {
163
+ return { merged, conflicts, integrationBranch };
164
+ }
165
+
166
+ // Collect changed files per worktree for overlap analysis
167
+ const fileMap = new Map();
168
+ for (const wt of worktrees) {
169
+ try {
170
+ const files = getChangedFiles(wt.branch, integrationBranch, { exec });
171
+ fileMap.set(wt.name, new Set(files));
172
+ } catch {
173
+ fileMap.set(wt.name, new Set());
174
+ }
175
+ }
176
+
177
+ // Sort: worktrees with fewer file overlaps with others merge first
178
+ const sorted = [...worktrees].sort((a, b) => {
179
+ const aFiles = fileMap.get(a.name) || new Set();
180
+ const bFiles = fileMap.get(b.name) || new Set();
181
+ const allOtherFiles = new Set();
182
+ for (const [name, files] of fileMap) {
183
+ if (name !== a.name && name !== b.name) {
184
+ for (const f of files) allOtherFiles.add(f);
185
+ }
186
+ }
187
+ const aOverlap = [...aFiles].filter(f => allOtherFiles.has(f)).length;
188
+ const bOverlap = [...bFiles].filter(f => allOtherFiles.has(f)).length;
189
+ return aOverlap - bOverlap;
190
+ });
191
+
192
+ for (let i = 0; i < sorted.length; i++) {
193
+ const wt = sorted[i];
194
+ const remaining = sorted.slice(i + 1);
195
+
196
+ // Merge this worktree into the integration branch
197
+ let mergeSuccess = false;
198
+ try {
199
+ exec(`git checkout ${integrationBranch}`);
200
+ } catch (checkoutErr) {
201
+ // Checkout failure is an operational error, not a merge conflict — rethrow
202
+ throw new Error(`Failed to checkout ${integrationBranch}: ${checkoutErr.message || checkoutErr}`);
203
+ }
204
+
205
+ try {
206
+ exec(`git merge ${wt.branch}`);
207
+ mergeSuccess = true;
208
+ } catch (mergeErr) {
209
+ const msg = mergeErr && mergeErr.message ? mergeErr.message : String(mergeErr);
210
+ if (msg.includes('CONFLICT') || msg.includes('conflict') || msg.includes('Merge conflict')) {
211
+ // Content conflict — abort and skip this worktree
212
+ try { exec('git merge --abort'); } catch { /* already clean */ }
213
+ conflicts.push(wt.name);
214
+ } else {
215
+ // Operational error (dirty tree, wrong branch name, etc.) — rethrow
216
+ try { exec('git merge --abort'); } catch { /* best effort */ }
217
+ throw new Error(`Failed to merge ${wt.branch}: ${msg}`);
218
+ }
219
+ }
220
+
221
+ if (mergeSuccess) {
222
+ // Clean up the merged worktree — use --force to handle untracked artifacts
223
+ try { exec(`git worktree remove --force "${wt.path}"`); } catch { /* best effort */ }
224
+ try { exec(`git branch -D ${wt.branch}`); } catch { /* best effort */ }
225
+ merged.push(wt.name);
226
+
227
+ // Rebase remaining worktrees onto the updated integration branch
228
+ // Must use git -C <worktree-path> because the branch is checked out there
229
+ // Quote paths to handle spaces/special characters
230
+ for (const rem of remaining) {
231
+ try {
232
+ exec(`git -C "${rem.path}" rebase ${integrationBranch}`);
233
+ } catch {
234
+ // Rebase conflict — abort and let it be handled when this worktree's turn comes
235
+ try { exec(`git -C "${rem.path}" rebase --abort`); } catch { /* already clean */ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ return { merged, conflicts, integrationBranch };
242
+ }
243
+
111
244
  /**
112
245
  * Remove worktrees whose last commit is older than maxAge milliseconds.
113
246
  * @param {{ exec: Function, maxAge?: number }} options
@@ -6,6 +6,9 @@ import {
6
6
  removeWorktree,
7
7
  cleanupStale,
8
8
  sanitizeName,
9
+ createIntegrationBranch,
10
+ mergeAllWorktrees,
11
+ getChangedFiles,
9
12
  } from './worktree-manager.js';
10
13
 
11
14
  describe('worktree-manager', () => {
@@ -126,4 +129,199 @@ describe('worktree-manager', () => {
126
129
  expect(removed).not.toContain('new-task');
127
130
  });
128
131
  });
132
+
133
+ describe('createIntegrationBranch', () => {
134
+ it('creates branch off base and returns branch name', () => {
135
+ exec.mockReturnValue('');
136
+ const result = createIntegrationBranch(42, { exec });
137
+ expect(result.branch).toBe('phase/42');
138
+ expect(result.phase).toBe(42);
139
+ const cmds = exec.mock.calls.map(c => c[0]);
140
+ expect(cmds.some(c => c.includes('git checkout -b phase/42'))).toBe(true);
141
+ });
142
+
143
+ it('uses custom base branch', () => {
144
+ exec.mockReturnValue('');
145
+ const result = createIntegrationBranch(5, { exec, baseBranch: 'develop' });
146
+ expect(result.branch).toBe('phase/5');
147
+ const cmds = exec.mock.calls.map(c => c[0]);
148
+ expect(cmds.some(c => c.includes('develop'))).toBe(true);
149
+ });
150
+
151
+ it('checks out existing integration branch if it already exists', () => {
152
+ exec.mockImplementation((cmd) => {
153
+ if (cmd.includes('git checkout -b')) throw new Error('fatal: a branch named phase/42 already exists');
154
+ return '';
155
+ });
156
+ const result = createIntegrationBranch(42, { exec });
157
+ expect(result.branch).toBe('phase/42');
158
+ const cmds = exec.mock.calls.map(c => c[0]);
159
+ expect(cmds.some(c => c === 'git checkout phase/42')).toBe(true);
160
+ });
161
+
162
+ it('throws on non-exists errors (e.g., missing base branch)', () => {
163
+ exec.mockImplementation((cmd) => {
164
+ if (cmd.includes('git checkout -b')) throw new Error('fatal: not a valid object name: nonexistent');
165
+ return '';
166
+ });
167
+ expect(() => createIntegrationBranch(42, { exec, baseBranch: 'nonexistent' }))
168
+ .toThrow('not a valid object name');
169
+ });
170
+ });
171
+
172
+ describe('getChangedFiles', () => {
173
+ it('returns list of files changed by a branch', () => {
174
+ exec.mockReturnValue('src/auth.ts\nsrc/user.ts\n');
175
+ const files = getChangedFiles('worktree-task-1', 'phase/42', { exec });
176
+ expect(files).toEqual(['src/auth.ts', 'src/user.ts']);
177
+ });
178
+
179
+ it('returns empty array when no changes', () => {
180
+ exec.mockReturnValue('');
181
+ const files = getChangedFiles('worktree-task-1', 'phase/42', { exec });
182
+ expect(files).toEqual([]);
183
+ });
184
+ });
185
+
186
+ describe('mergeAllWorktrees', () => {
187
+ it('merges worktrees sequentially into integration branch', () => {
188
+ const worktrees = [
189
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
190
+ { name: 'task-2', branch: 'worktree-task-2', path: '/repo/.claude/worktrees/task-2' },
191
+ ];
192
+ exec.mockReturnValue('');
193
+
194
+ const result = mergeAllWorktrees(worktrees, 'phase/42', { exec });
195
+
196
+ expect(result.merged).toEqual(['task-1', 'task-2']);
197
+ expect(result.conflicts).toEqual([]);
198
+ expect(result.integrationBranch).toBe('phase/42');
199
+ });
200
+
201
+ it('rebases remaining worktrees after each merge', () => {
202
+ const worktrees = [
203
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
204
+ { name: 'task-2', branch: 'worktree-task-2', path: '/repo/.claude/worktrees/task-2' },
205
+ { name: 'task-3', branch: 'worktree-task-3', path: '/repo/.claude/worktrees/task-3' },
206
+ ];
207
+ exec.mockReturnValue('');
208
+
209
+ mergeAllWorktrees(worktrees, 'phase/42', { exec });
210
+
211
+ // After merging task-1, should rebase task-2 and task-3 using git -C <path>
212
+ const cmds = exec.mock.calls.map(c => c[0]);
213
+ const rebaseCmds = cmds.filter(c => c.includes('rebase'));
214
+ expect(rebaseCmds.length).toBeGreaterThanOrEqual(2);
215
+ // Must use git -C to rebase within the worktree (branch is checked out there)
216
+ expect(rebaseCmds.every(c => c.includes('git -C'))).toBe(true);
217
+ });
218
+
219
+ it('continues merging others when one worktree conflicts', () => {
220
+ const worktrees = [
221
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
222
+ { name: 'task-2', branch: 'worktree-task-2', path: '/repo/.claude/worktrees/task-2' },
223
+ { name: 'task-3', branch: 'worktree-task-3', path: '/repo/.claude/worktrees/task-3' },
224
+ ];
225
+
226
+ exec.mockImplementation((cmd) => {
227
+ // task-2 merge conflicts
228
+ if (cmd.includes('git merge worktree-task-2')) throw new Error('CONFLICT');
229
+ // task-2 rebase also fails (expected after conflict)
230
+ if (cmd.includes('git rebase') && cmd.includes('worktree-task-2')) throw new Error('CONFLICT');
231
+ return '';
232
+ });
233
+
234
+ const result = mergeAllWorktrees(worktrees, 'phase/42', { exec });
235
+
236
+ expect(result.merged).toContain('task-1');
237
+ expect(result.merged).toContain('task-3');
238
+ expect(result.conflicts).toContain('task-2');
239
+ });
240
+
241
+ it('cleans up worktrees after successful merge', () => {
242
+ const worktrees = [
243
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
244
+ ];
245
+ exec.mockReturnValue('');
246
+
247
+ mergeAllWorktrees(worktrees, 'phase/42', { exec });
248
+
249
+ const cmds = exec.mock.calls.map(c => c[0]);
250
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
251
+ expect(cmds.some(c => c.includes('git branch -D worktree-task-1'))).toBe(true);
252
+ });
253
+
254
+ it('preserves conflicting worktrees for manual resolution', () => {
255
+ const worktrees = [
256
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
257
+ ];
258
+
259
+ exec.mockImplementation((cmd) => {
260
+ if (cmd.includes('git merge')) throw new Error('CONFLICT');
261
+ return '';
262
+ });
263
+
264
+ mergeAllWorktrees(worktrees, 'phase/42', { exec });
265
+
266
+ const cmds = exec.mock.calls.map(c => c[0]);
267
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(false);
268
+ });
269
+
270
+ it('throws on non-conflict merge errors (dirty tree, wrong branch)', () => {
271
+ const worktrees = [
272
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
273
+ ];
274
+
275
+ exec.mockImplementation((cmd) => {
276
+ if (cmd.includes('git merge')) throw new Error('fatal: not something we can merge');
277
+ return '';
278
+ });
279
+
280
+ expect(() => mergeAllWorktrees(worktrees, 'phase/42', { exec }))
281
+ .toThrow('Failed to merge');
282
+ });
283
+
284
+ it('orders worktrees by file overlap — disjoint first', () => {
285
+ const worktrees = [
286
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
287
+ { name: 'task-2', branch: 'worktree-task-2', path: '/repo/.claude/worktrees/task-2' },
288
+ { name: 'task-3', branch: 'worktree-task-3', path: '/repo/.claude/worktrees/task-3' },
289
+ ];
290
+
291
+ exec.mockImplementation((cmd) => {
292
+ // task-1 and task-3 touch the same file, task-2 is disjoint
293
+ if (cmd.includes('diff --name-only') && cmd.includes('worktree-task-1')) return 'src/shared.ts\nsrc/a.ts\n';
294
+ if (cmd.includes('diff --name-only') && cmd.includes('worktree-task-2')) return 'src/b.ts\n';
295
+ if (cmd.includes('diff --name-only') && cmd.includes('worktree-task-3')) return 'src/shared.ts\nsrc/c.ts\n';
296
+ return '';
297
+ });
298
+
299
+ const result = mergeAllWorktrees(worktrees, 'phase/42', { exec });
300
+
301
+ // task-2 (disjoint) should merge before task-1 or task-3 (overlapping)
302
+ const mergeOrder = result.merged;
303
+ const task2Index = mergeOrder.indexOf('task-2');
304
+ expect(task2Index).toBeLessThan(mergeOrder.length - 1);
305
+ });
306
+
307
+ it('returns empty result for empty worktree list', () => {
308
+ const result = mergeAllWorktrees([], 'phase/42', { exec });
309
+ expect(result.merged).toEqual([]);
310
+ expect(result.conflicts).toEqual([]);
311
+ });
312
+
313
+ it('handles single worktree without rebase', () => {
314
+ const worktrees = [
315
+ { name: 'task-1', branch: 'worktree-task-1', path: '/repo/.claude/worktrees/task-1' },
316
+ ];
317
+ exec.mockReturnValue('');
318
+
319
+ const result = mergeAllWorktrees(worktrees, 'phase/42', { exec });
320
+
321
+ expect(result.merged).toEqual(['task-1']);
322
+ const cmds = exec.mock.calls.map(c => c[0]);
323
+ // No rebase needed for a single worktree
324
+ expect(cmds.some(c => c.includes('git rebase'))).toBe(false);
325
+ });
326
+ });
129
327
  });
@@ -10,7 +10,7 @@
10
10
  * Use --agents N to limit parallelism to specific number
11
11
  *
12
12
  * Opus 4.6 Multi-Agent Features:
13
- * - Model selection per agent (opus/sonnet/haiku) based on task complexity
13
+ * - Model selection per agent (always opus for quality)
14
14
  * - Agent resumption via `resume` parameter for retry/continuation
15
15
  * - TaskOutput for non-blocking progress checks on background agents
16
16
  * - TaskStop for cancelling stuck agents
@@ -32,13 +32,13 @@ const AGENT_TYPES = {
32
32
  };
33
33
 
34
34
  /**
35
- * Model tiers for cost/capability optimization (Opus 4.6)
36
- * Agents are assigned models based on task complexity.
35
+ * Model tiers (Opus 4.6)
36
+ * All tasks use opus — cheaper models produce lower quality that costs more in fix cycles.
37
37
  */
38
38
  const MODEL_TIERS = {
39
- HEAVY: 'opus', // Complex multi-file features, architectural work
40
- STANDARD: 'sonnet', // Normal implementation tasks (default)
41
- LIGHT: 'haiku', // Simple tasks: config, boilerplate, single-file changes
39
+ HEAVY: 'opus',
40
+ STANDARD: 'opus',
41
+ LIGHT: 'opus',
42
42
  };
43
43
 
44
44
  /**
@@ -79,7 +79,7 @@ function estimateTaskComplexity(task) {
79
79
  * Get model for a task based on its complexity
80
80
  * @param {Object} task - Task object
81
81
  * @param {string} [modelOverride] - Force a specific model
82
- * @returns {string} Model name (opus, sonnet, haiku)
82
+ * @returns {string} Model name (always opus)
83
83
  */
84
84
  function getModelForTask(task, modelOverride) {
85
85
  if (modelOverride) {
@@ -127,7 +127,7 @@ function parseOverdriveArgs(args = '') {
127
127
  options.mode = parts[++i];
128
128
  } else if (part === '--model' && parts[i + 1]) {
129
129
  const model = parts[++i].toLowerCase();
130
- if (['opus', 'sonnet', 'haiku'].includes(model)) {
130
+ if (['opus'].includes(model)) {
131
131
  options.model = model;
132
132
  }
133
133
  } else if (part === '--max-turns' && parts[i + 1]) {
@@ -305,8 +305,15 @@ function formatOverdrivePlan(plan) {
305
305
  lines.push('All agents spawned simultaneously via Task tool (Opus 4.6 multi-agent).');
306
306
  lines.push('Each agent works independently until completion.');
307
307
  lines.push('');
308
+ lines.push('**Integration Branch:**');
309
+ lines.push(`- All worktrees branch off \`phase/${plan.phase}\` (not main)`);
310
+ lines.push('- After all agents complete, worktrees merge sequentially into the integration branch');
311
+ lines.push('- Disjoint worktrees merge first, overlapping ones last');
312
+ lines.push('- Remaining worktrees rebase onto integration branch after each merge');
313
+ lines.push('- Single PR from integration branch to main');
314
+ lines.push('');
308
315
  lines.push('**Capabilities:**');
309
- lines.push('- Model selection per task complexity (opus/sonnet/haiku)');
316
+ lines.push('- All agents use opus for maximum quality');
310
317
  lines.push('- Agent resumption for failed tasks (resume parameter)');
311
318
  lines.push('- Non-blocking progress checks (TaskOutput block=false)');
312
319
  lines.push('- Agent cancellation (TaskStop) for stuck agents');
@@ -448,12 +455,21 @@ async function executeOverdriveCommand(args = '', context = {}) {
448
455
  const allPrompts = agentAssignments.flatMap(a => a.prompts);
449
456
  const taskCalls = generateTaskCalls(allPrompts);
450
457
 
458
+ // Integration branch name for this phase
459
+ const integrationBranch = `phase/${options.phase}`;
460
+
451
461
  return {
452
462
  success: true,
453
463
  plan,
454
464
  taskCalls,
465
+ integrationBranch,
455
466
  output: formatOverdrivePlan(plan),
456
467
  instructions: `
468
+ BEFORE SPAWNING AGENTS:
469
+ 1. Create integration branch: git checkout -b ${integrationBranch} main
470
+ (If it already exists: git checkout ${integrationBranch})
471
+ 2. All worktrees will branch from ${integrationBranch}, not main.
472
+
457
473
  EXECUTE NOW: Spawn ${agentCount} agents in parallel using the Task tool (Opus 4.6).
458
474
 
459
475
  ${taskCalls.map((tc, i) => `
@@ -471,6 +487,12 @@ Task(
471
487
  CRITICAL: Call ALL Task tools in a SINGLE message to run them in parallel.
472
488
  Do NOT wait between spawns. Fire them all at once.
473
489
 
490
+ AFTER ALL AGENTS COMPLETE:
491
+ 1. List worktrees for THIS phase only (filter by "phase-${options.phase}-task")
492
+ 2. Merge all worktrees into ${integrationBranch} using mergeAllWorktrees()
493
+ 3. Run full test suite on ${integrationBranch}
494
+ 4. Create single PR from ${integrationBranch} to main
495
+
474
496
  MONITORING: Use TaskOutput(task_id, block=false) to check progress.
475
497
  STUCK AGENT: Use TaskStop(task_id) to cancel, then resume with Task(resume=agent_id).
476
498
  FAILED AGENT: Use Task(resume=agent_id) to continue from where it left off.