tlc-claude-code 2.4.3 → 2.4.4

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 (40) hide show
  1. package/.claude/commands/tlc/build.md +7 -5
  2. package/.claude/commands/tlc/recall.md +59 -87
  3. package/.claude/commands/tlc/remember.md +76 -71
  4. package/.claude/commands/tlc/review.md +76 -21
  5. package/.claude/hooks/tlc-capture-exchange.sh +50 -21
  6. package/.claude/hooks/tlc-session-init.sh +30 -0
  7. package/bin/init.js +12 -3
  8. package/package.json +1 -1
  9. package/server/lib/capture/classifier.js +71 -0
  10. package/server/lib/capture/classifier.test.js +71 -0
  11. package/server/lib/capture/claude-capture.js +140 -0
  12. package/server/lib/capture/claude-capture.test.js +152 -0
  13. package/server/lib/capture/codex-capture.js +79 -0
  14. package/server/lib/capture/codex-capture.test.js +161 -0
  15. package/server/lib/capture/codex-event-parser.js +76 -0
  16. package/server/lib/capture/codex-event-parser.test.js +83 -0
  17. package/server/lib/capture/ensure-ready.js +56 -0
  18. package/server/lib/capture/ensure-ready.test.js +135 -0
  19. package/server/lib/capture/envelope.js +77 -0
  20. package/server/lib/capture/envelope.test.js +169 -0
  21. package/server/lib/capture/extractor.js +51 -0
  22. package/server/lib/capture/extractor.test.js +92 -0
  23. package/server/lib/capture/generic-capture.js +96 -0
  24. package/server/lib/capture/generic-capture.test.js +171 -0
  25. package/server/lib/capture/index.js +117 -0
  26. package/server/lib/capture/index.test.js +263 -0
  27. package/server/lib/capture/redactor.js +68 -0
  28. package/server/lib/capture/redactor.test.js +93 -0
  29. package/server/lib/capture/spool-processor.js +155 -0
  30. package/server/lib/capture/spool-processor.test.js +278 -0
  31. package/server/lib/health-check.js +255 -0
  32. package/server/lib/health-check.test.js +243 -0
  33. package/server/lib/orchestration/cli-dispatch.js +200 -0
  34. package/server/lib/orchestration/cli-dispatch.test.js +242 -0
  35. package/server/lib/orchestration/prompt-builder.js +118 -0
  36. package/server/lib/orchestration/prompt-builder.test.js +200 -0
  37. package/server/lib/orchestration/standalone-compat.js +39 -0
  38. package/server/lib/orchestration/standalone-compat.test.js +144 -0
  39. package/server/lib/orchestration/worktree-manager.js +43 -0
  40. package/server/lib/orchestration/worktree-manager.test.js +50 -0
@@ -0,0 +1,118 @@
1
+ const path = require('path');
2
+ const { packagePrompt } = require('../prompt-packager.js');
3
+
4
+ function formatBulletList(items = []) {
5
+ return items.map((item) => ` - ${item}`).join('\n');
6
+ }
7
+
8
+ function buildTaskPrompt({ goal, files = [], criteria = [], testCases = [] }) {
9
+ return [
10
+ `Goal: ${goal}`,
11
+ '',
12
+ 'Files to work with:',
13
+ formatBulletList(files),
14
+ '',
15
+ 'Acceptance criteria:',
16
+ formatBulletList(criteria),
17
+ '',
18
+ 'Test cases:',
19
+ formatBulletList(testCases),
20
+ '',
21
+ 'Methodology: Write tests first (red → green → refactor). You MUST write the test before implementing any code. Test-first is required.',
22
+ ].join('\n');
23
+ }
24
+
25
+ function buildContextPrompt({
26
+ agentPrompt,
27
+ projectDoc,
28
+ planDoc,
29
+ codingStandards,
30
+ files,
31
+ tokenBudget = 100000,
32
+ }) {
33
+ return packagePrompt({
34
+ agentPrompt,
35
+ projectDoc,
36
+ planDoc,
37
+ codingStandards,
38
+ files,
39
+ tokenBudget,
40
+ });
41
+ }
42
+
43
+ function readOptionalFile(fsImpl, filePath) {
44
+ if (!fsImpl.existsSync(filePath)) {
45
+ return null;
46
+ }
47
+ return fsImpl.readFileSync(filePath, 'utf-8');
48
+ }
49
+
50
+ function findPlanPath(projectDir, phase, fsImpl) {
51
+ if (!phase) {
52
+ return path.join(projectDir, 'PLAN.md');
53
+ }
54
+
55
+ const phasesDir = path.join(projectDir, '.planning', 'phases');
56
+ const exactPath = path.join(phasesDir, `${phase}-PLAN.md`);
57
+ if (fsImpl.existsSync(exactPath)) {
58
+ return exactPath;
59
+ }
60
+
61
+ if (!fsImpl.existsSync(phasesDir)) {
62
+ return path.join(projectDir, 'PLAN.md');
63
+ }
64
+
65
+ try {
66
+ const padded = String(phase).padStart(2, '0');
67
+ const match = fsImpl.readdirSync(phasesDir).find(
68
+ (name) =>
69
+ (name.startsWith(`${padded}-`) || name.startsWith(`${phase}-`)) &&
70
+ name.endsWith('-PLAN.md')
71
+ );
72
+
73
+ if (match) {
74
+ return path.join(phasesDir, match);
75
+ }
76
+ } catch {
77
+ return path.join(projectDir, 'PLAN.md');
78
+ }
79
+
80
+ return path.join(projectDir, 'PLAN.md');
81
+ }
82
+
83
+ function readRelevantFiles(fsImpl, projectDir, filePaths = []) {
84
+ return filePaths.reduce((acc, filePath) => {
85
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
86
+ if (!fsImpl.existsSync(absolutePath)) {
87
+ return acc;
88
+ }
89
+
90
+ acc.push({
91
+ path: filePath,
92
+ content: fsImpl.readFileSync(absolutePath, 'utf-8'),
93
+ });
94
+ return acc;
95
+ }, []);
96
+ }
97
+
98
+ function buildFullPrompt({ task, projectDir, phase, fs: fsImpl = require('fs') }) {
99
+ const agentPrompt = buildTaskPrompt(task);
100
+ const projectDoc = readOptionalFile(fsImpl, path.join(projectDir, 'PROJECT.md'));
101
+ const planDoc = readOptionalFile(fsImpl, findPlanPath(projectDir, phase, fsImpl));
102
+ const codingStandards = readOptionalFile(fsImpl, path.join(projectDir, 'CODING-STANDARDS.md'));
103
+ const files = readRelevantFiles(fsImpl, projectDir, task.files);
104
+
105
+ return buildContextPrompt({
106
+ agentPrompt,
107
+ projectDoc,
108
+ planDoc,
109
+ codingStandards,
110
+ files,
111
+ });
112
+ }
113
+
114
+ module.exports = {
115
+ buildTaskPrompt,
116
+ buildContextPrompt,
117
+ buildFullPrompt,
118
+ };
@@ -0,0 +1,200 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { createRequire } from 'module';
5
+ import { describe, it, expect, afterEach } from 'vitest';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ const { packagePrompt } = require('../prompt-packager.js');
10
+ const { buildTaskPrompt, buildContextPrompt, buildFullPrompt } = require('./prompt-builder.js');
11
+
12
+ const tempDirs = [];
13
+
14
+ function makeTempProject() {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-prompt-builder-'));
16
+ tempDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (tempDirs.length > 0) {
22
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ describe('prompt-builder', () => {
27
+ describe('buildTaskPrompt', () => {
28
+ it('matches the existing dispatcher prompt format and includes TDD instructions', () => {
29
+ const result = buildTaskPrompt({
30
+ goal: 'Implement login',
31
+ files: ['src/login.js', 'src/login.test.js'],
32
+ criteria: ['Supports valid credentials', 'Rejects bad passwords'],
33
+ testCases: ['logs in a valid user', 'rejects an invalid user'],
34
+ });
35
+
36
+ expect(result).toBe([
37
+ 'Goal: Implement login',
38
+ '',
39
+ 'Files to work with:',
40
+ ' - src/login.js',
41
+ ' - src/login.test.js',
42
+ '',
43
+ 'Acceptance criteria:',
44
+ ' - Supports valid credentials',
45
+ ' - Rejects bad passwords',
46
+ '',
47
+ 'Test cases:',
48
+ ' - logs in a valid user',
49
+ ' - rejects an invalid user',
50
+ '',
51
+ 'Methodology: Write tests first (red → green → refactor). You MUST write the test before implementing any code. Test-first is required.',
52
+ ].join('\n'));
53
+ });
54
+
55
+ it('supports empty file, criteria, and test case lists', () => {
56
+ const result = buildTaskPrompt({ goal: 'Implement logout' });
57
+
58
+ expect(result).toContain('Goal: Implement logout');
59
+ expect(result).toContain('Files to work with:\n');
60
+ expect(result).toContain('Acceptance criteria:\n');
61
+ expect(result).toContain('Test cases:\n');
62
+ expect(result).toContain('Write tests first');
63
+ });
64
+ });
65
+
66
+ describe('buildContextPrompt', () => {
67
+ it('delegates to packagePrompt with the provided options', () => {
68
+ const input = {
69
+ agentPrompt: 'Do the task',
70
+ projectDoc: '# Project',
71
+ planDoc: '# Plan',
72
+ codingStandards: '# Standards',
73
+ files: [
74
+ { path: 'b.js', content: 'b' },
75
+ { path: 'a.js', content: 'a' },
76
+ ],
77
+ tokenBudget: 500,
78
+ };
79
+
80
+ expect(buildContextPrompt(input)).toBe(packagePrompt(input));
81
+ });
82
+ });
83
+
84
+ describe('buildFullPrompt', () => {
85
+ it('reads project docs and relevant files, then packages the full prompt', () => {
86
+ const projectDir = makeTempProject();
87
+ fs.mkdirSync(path.join(projectDir, '.planning', 'phases'), { recursive: true });
88
+ fs.writeFileSync(path.join(projectDir, 'PROJECT.md'), '# Project\nShip it');
89
+ fs.writeFileSync(path.join(projectDir, 'CODING-STANDARDS.md'), '# Standards\nWrite tests');
90
+ fs.writeFileSync(path.join(projectDir, '.planning', 'phases', '1-PLAN.md'), '# Phase 1\nDo it');
91
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
92
+ fs.writeFileSync(path.join(projectDir, 'src', 'feature.js'), 'module.exports = { feature: true };');
93
+ fs.writeFileSync(path.join(projectDir, 'src', 'feature.test.js'), 'test("feature", () => {});');
94
+
95
+ const result = buildFullPrompt({
96
+ task: {
97
+ goal: 'Build feature',
98
+ files: ['src/feature.js', 'src/feature.test.js'],
99
+ criteria: ['Feature works'],
100
+ testCases: ['Feature passes'],
101
+ },
102
+ projectDir,
103
+ phase: 1,
104
+ });
105
+
106
+ expect(result).toContain('--- Task ---');
107
+ expect(result).toContain('Goal: Build feature');
108
+ expect(result).toContain('--- PROJECT.md ---');
109
+ expect(result).toContain('# Project\nShip it');
110
+ expect(result).toContain('--- Current Phase Plan ---');
111
+ expect(result).toContain('# Phase 1\nDo it');
112
+ expect(result).toContain('--- Coding Standards ---');
113
+ expect(result).toContain('# Standards\nWrite tests');
114
+ expect(result).toContain('--- src/feature.js ---');
115
+ expect(result).toContain('module.exports = { feature: true };');
116
+ expect(result).toContain('--- src/feature.test.js ---');
117
+ });
118
+
119
+ it('resolves padded or prefixed phase plan filenames', () => {
120
+ const projectDir = makeTempProject();
121
+ fs.mkdirSync(path.join(projectDir, '.planning', 'phases'), { recursive: true });
122
+ fs.writeFileSync(path.join(projectDir, 'PROJECT.md'), '# Project');
123
+ fs.writeFileSync(path.join(projectDir, 'CODING-STANDARDS.md'), '# Standards');
124
+ fs.writeFileSync(path.join(projectDir, '.planning', 'phases', '02-api-PLAN.md'), '# Phase 2');
125
+
126
+ const result = buildFullPrompt({
127
+ task: { goal: 'Build API' },
128
+ projectDir,
129
+ phase: 2,
130
+ });
131
+
132
+ expect(result).toContain('--- Current Phase Plan ---');
133
+ expect(result).toContain('# Phase 2');
134
+ });
135
+
136
+ it('skips missing docs and missing task files instead of throwing', () => {
137
+ const projectDir = makeTempProject();
138
+
139
+ const result = buildFullPrompt({
140
+ task: {
141
+ goal: 'Build minimal task',
142
+ files: ['missing.js'],
143
+ },
144
+ projectDir,
145
+ phase: 3,
146
+ });
147
+
148
+ expect(result).toContain('--- Task ---');
149
+ expect(result).toContain('Goal: Build minimal task');
150
+ expect(result).not.toContain('--- PROJECT.md ---');
151
+ expect(result).not.toContain('--- Current Phase Plan ---');
152
+ expect(result).not.toContain('--- Coding Standards ---');
153
+ expect(result).not.toContain('--- Relevant Files ---');
154
+ });
155
+
156
+ it('supports injected fs implementations', () => {
157
+ const fakeFs = {
158
+ existsSync(targetPath) {
159
+ return [
160
+ '/project/PROJECT.md',
161
+ '/project/CODING-STANDARDS.md',
162
+ '/project/.planning/phases',
163
+ '/project/.planning/phases/4-build-PLAN.md',
164
+ '/project/src/app.js',
165
+ ].includes(targetPath);
166
+ },
167
+ readdirSync(targetPath) {
168
+ if (targetPath === '/project/.planning/phases') {
169
+ return ['4-build-PLAN.md'];
170
+ }
171
+ return [];
172
+ },
173
+ readFileSync(targetPath) {
174
+ const contents = {
175
+ '/project/PROJECT.md': '# Project',
176
+ '/project/CODING-STANDARDS.md': '# Standards',
177
+ '/project/.planning/phases/4-build-PLAN.md': '# Phase 4',
178
+ '/project/src/app.js': 'console.log("app");',
179
+ };
180
+ return contents[targetPath];
181
+ },
182
+ };
183
+
184
+ const result = buildFullPrompt({
185
+ task: {
186
+ goal: 'Build app',
187
+ files: ['src/app.js'],
188
+ },
189
+ projectDir: '/project',
190
+ phase: 4,
191
+ fs: fakeFs,
192
+ });
193
+
194
+ expect(result).toContain('# Project');
195
+ expect(result).toContain('# Standards');
196
+ expect(result).toContain('# Phase 4');
197
+ expect(result).toContain('--- src/app.js ---');
198
+ });
199
+ });
200
+ });
@@ -0,0 +1,39 @@
1
+ const path = require('path');
2
+
3
+ function guessProvider(binary) {
4
+ const basename = path.basename(String(binary || '')).toLowerCase();
5
+
6
+ if (['codex', 'claude', 'gemini', 'ollama'].includes(basename)) {
7
+ return basename;
8
+ }
9
+
10
+ return basename;
11
+ }
12
+
13
+ async function executeCli(prompt, endpoint, options = {}) {
14
+ const { dispatch } = require('./cli-dispatch');
15
+ const provider = guessProvider(endpoint && endpoint.binary);
16
+ const result = await dispatch({
17
+ ...options,
18
+ prompt,
19
+ provider,
20
+ // Preserve the original binary path so custom/absolute paths still work
21
+ binaryOverride: endpoint && endpoint.binary,
22
+ flags: Array.isArray(endpoint && endpoint.cli_flags) ? endpoint.cli_flags : [],
23
+ });
24
+
25
+ return {
26
+ response: result.stdout,
27
+ provider: endpoint && endpoint.name,
28
+ latency_ms: result.duration,
29
+ };
30
+ }
31
+
32
+ function buildTaskPrompt(task) {
33
+ return require('./prompt-builder').buildTaskPrompt(task);
34
+ }
35
+
36
+ module.exports = {
37
+ executeCli,
38
+ buildTaskPrompt,
39
+ };
@@ -0,0 +1,144 @@
1
+ import path from 'path';
2
+ import { describe, it, expect, vi, afterEach } from 'vitest';
3
+
4
+ const cliDispatch = require('./cli-dispatch.js');
5
+ const promptBuilder = require('./prompt-builder.js');
6
+ const { executeCli, buildTaskPrompt } = require('./standalone-compat.js');
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe('standalone-compat', () => {
13
+ describe('executeCli', () => {
14
+ it('delegates to cli-dispatch with provider guessed from codex binary', async () => {
15
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
16
+ stdout: 'ok',
17
+ duration: 42,
18
+ });
19
+
20
+ await executeCli(
21
+ 'Ship it',
22
+ {
23
+ binary: 'codex',
24
+ cli_flags: ['--model', 'gpt-5.4'],
25
+ name: 'Codex CLI',
26
+ },
27
+ {
28
+ timeout: 5000,
29
+ worktreePath: '/tmp/worktree',
30
+ }
31
+ );
32
+
33
+ expect(dispatchSpy).toHaveBeenCalledWith({
34
+ prompt: 'Ship it',
35
+ provider: 'codex',
36
+ binaryOverride: 'codex',
37
+ flags: ['--model', 'gpt-5.4'],
38
+ timeout: 5000,
39
+ worktreePath: '/tmp/worktree',
40
+ });
41
+ });
42
+
43
+ it('guesses provider from the binary basename for known CLIs', async () => {
44
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
45
+ stdout: 'done',
46
+ duration: 12,
47
+ });
48
+
49
+ await executeCli('Review this', {
50
+ binary: path.join('/usr', 'local', 'bin', 'claude'),
51
+ cli_flags: ['--permission-mode', 'auto'],
52
+ name: 'Claude Desktop',
53
+ });
54
+
55
+ expect(dispatchSpy).toHaveBeenCalledWith({
56
+ prompt: 'Review this',
57
+ provider: 'claude',
58
+ binaryOverride: path.join('/usr', 'local', 'bin', 'claude'),
59
+ flags: ['--permission-mode', 'auto'],
60
+ });
61
+ });
62
+
63
+ it('passes through empty flags when cli_flags is not provided', async () => {
64
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
65
+ stdout: 'done',
66
+ duration: 7,
67
+ });
68
+
69
+ await executeCli('Run', {
70
+ binary: 'gemini',
71
+ name: 'Gemini CLI',
72
+ });
73
+
74
+ expect(dispatchSpy).toHaveBeenCalledWith({
75
+ prompt: 'Run',
76
+ provider: 'gemini',
77
+ binaryOverride: 'gemini',
78
+ flags: [],
79
+ });
80
+ });
81
+
82
+ it('falls back to the binary basename for unknown providers and reshapes the result', async () => {
83
+ vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
84
+ stdout: 'custom output',
85
+ duration: 88,
86
+ });
87
+
88
+ const result = await executeCli('Custom task', {
89
+ binary: '/opt/tools/custom-runner',
90
+ cli_flags: ['--fast'],
91
+ name: 'Standalone Custom',
92
+ });
93
+
94
+ expect(result).toEqual({
95
+ response: 'custom output',
96
+ provider: 'Standalone Custom',
97
+ latency_ms: 88,
98
+ });
99
+ });
100
+
101
+ it('lets explicit options like spawn pass through unchanged', async () => {
102
+ const spawn = vi.fn();
103
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
104
+ stdout: 'ok',
105
+ duration: 3,
106
+ });
107
+
108
+ await executeCli(
109
+ 'Test',
110
+ {
111
+ binary: 'ollama',
112
+ cli_flags: ['llama3.2'],
113
+ name: 'Ollama',
114
+ },
115
+ { spawn }
116
+ );
117
+
118
+ expect(dispatchSpy).toHaveBeenCalledWith({
119
+ prompt: 'Test',
120
+ provider: 'ollama',
121
+ binaryOverride: 'ollama',
122
+ flags: ['llama3.2'],
123
+ spawn,
124
+ });
125
+ });
126
+ });
127
+
128
+ describe('buildTaskPrompt', () => {
129
+ it('delegates directly to prompt-builder and returns its string', () => {
130
+ const task = {
131
+ goal: 'Implement feature',
132
+ files: ['src/feature.js'],
133
+ criteria: ['It works'],
134
+ testCases: ['passes tests'],
135
+ };
136
+ const buildSpy = vi.spyOn(promptBuilder, 'buildTaskPrompt').mockReturnValue('prompt text');
137
+
138
+ const result = buildTaskPrompt(task);
139
+
140
+ expect(buildSpy).toHaveBeenCalledWith(task);
141
+ expect(result).toBe('prompt text');
142
+ });
143
+ });
144
+ });
@@ -16,6 +16,26 @@ export function sanitizeName(name) {
16
16
  .replace(/-{2,}/g, '-');
17
17
  }
18
18
 
19
+ /**
20
+ * Build a short, stable task slug from a task name.
21
+ * Uses the first 4 words, lowercases, hyphenates, and limits to 30 chars.
22
+ * @param {string} taskName
23
+ * @returns {string}
24
+ */
25
+ export function sanitizeTaskName(taskName) {
26
+ const words = String(taskName)
27
+ .trim()
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .filter(Boolean)
31
+ .slice(0, 4);
32
+
33
+ const truncated = words.join('-').slice(0, 30);
34
+ return sanitizeName(truncated)
35
+ .replace(/^-+|-+$/g, '')
36
+ .replace(/-{2,}/g, '-');
37
+ }
38
+
19
39
  /**
20
40
  * Create a git worktree for the given name.
21
41
  * @param {string} name - Worktree name (will be sanitized)
@@ -47,6 +67,29 @@ export function createWorktree(name, { exec, maxConcurrent } = {}) {
47
67
  return { name: safe, branch, path };
48
68
  }
49
69
 
70
+ /**
71
+ * Create a git worktree named for a phase/task combination.
72
+ * @param {number|string} phase
73
+ * @param {number|string} taskNumber
74
+ * @param {string} taskName
75
+ * @param {{ exec: Function, baseBranch?: string }} options
76
+ * @returns {{ name: string, branch: string, path: string }}
77
+ */
78
+ export function createTaskWorktree(phase, taskNumber, taskName, { exec, baseBranch } = {}) {
79
+ const taskSlug = sanitizeTaskName(taskName);
80
+ const rawName = taskSlug
81
+ ? `phase-${phase}-task-${taskNumber}-${taskSlug}`
82
+ : `phase-${phase}-task-${taskNumber}`;
83
+ const name = sanitizeName(rawName);
84
+ const branch = `worktree-${name}`;
85
+ const path = `.claude/worktrees/${name}`;
86
+ const startPoint = baseBranch || exec('git branch --show-current').trim();
87
+
88
+ exec(`git worktree add -b ${branch} ${path} ${startPoint}`);
89
+
90
+ return { name, branch, path };
91
+ }
92
+
50
93
  /**
51
94
  * List active worktrees under .claude/worktrees/.
52
95
  * @param {{ exec: Function }} options
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import {
3
3
  createWorktree,
4
+ createTaskWorktree,
4
5
  listWorktrees,
5
6
  mergeWorktree,
6
7
  removeWorktree,
@@ -58,6 +59,55 @@ describe('worktree-manager', () => {
58
59
  });
59
60
  });
60
61
 
62
+ describe('createTaskWorktree', () => {
63
+ it('builds a phase/task-based worktree name', () => {
64
+ exec.mockImplementation((cmd) => {
65
+ if (cmd === 'git branch --show-current') return 'feature/current\n';
66
+ return '';
67
+ });
68
+
69
+ const result = createTaskWorktree(30, 5, 'Config Size', { exec });
70
+
71
+ expect(result).toEqual({
72
+ name: 'phase-30-task-5-config-size',
73
+ branch: 'worktree-phase-30-task-5-config-size',
74
+ path: '.claude/worktrees/phase-30-task-5-config-size',
75
+ });
76
+ expect(exec).toHaveBeenCalledWith(
77
+ 'git worktree add -b worktree-phase-30-task-5-config-size .claude/worktrees/phase-30-task-5-config-size feature/current'
78
+ );
79
+ });
80
+
81
+ it('sanitizes task name to first four words and max 30 chars', () => {
82
+ exec.mockImplementation((cmd) => {
83
+ if (cmd === 'git branch --show-current') return 'main\n';
84
+ return '';
85
+ });
86
+
87
+ const result = createTaskWorktree(
88
+ 12,
89
+ 9,
90
+ 'Configuration synchronization validation instrumentation extra trailing words',
91
+ { exec }
92
+ );
93
+
94
+ expect(result.name).toBe('phase-12-task-9-configuration-synchronization');
95
+ expect(result.branch).toBe('worktree-phase-12-task-9-configuration-synchronization');
96
+ });
97
+
98
+ it('uses a provided base branch instead of the current branch', () => {
99
+ exec.mockReturnValue('');
100
+
101
+ const result = createTaskWorktree(7, 2, 'Ship Metrics', { exec, baseBranch: 'release/7' });
102
+
103
+ expect(result.name).toBe('phase-7-task-2-ship-metrics');
104
+ expect(exec).toHaveBeenCalledTimes(1);
105
+ expect(exec).toHaveBeenCalledWith(
106
+ 'git worktree add -b worktree-phase-7-task-2-ship-metrics .claude/worktrees/phase-7-task-2-ship-metrics release/7'
107
+ );
108
+ });
109
+ });
110
+
61
111
  describe('listWorktrees', () => {
62
112
  it('returns active worktrees with branch and path', () => {
63
113
  exec.mockReturnValue(