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.
- package/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +98 -24
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +50 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/CODING-STANDARDS.md +217 -10
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/setup.sh +271 -271
- package/server/templates/CLAUDE.md +6 -0
- 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
|
|
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
|
|
36
|
-
*
|
|
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',
|
|
40
|
-
STANDARD: '
|
|
41
|
-
LIGHT: '
|
|
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
|
|
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'
|
|
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('-
|
|
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.
|