rafcode 2.5.0 → 2.5.1-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 (38) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RAF/ahwzmc-echo-forge/decisions.md +15 -0
  3. package/RAF/ahwzmc-echo-forge/input.md +4 -0
  4. package/RAF/ahwzmc-echo-forge/outcomes/01-change-low-effort-default-to-sonnet.md +57 -0
  5. package/RAF/ahwzmc-echo-forge/outcomes/02-add-no-worktree-flag.md +79 -0
  6. package/RAF/ahwzmc-echo-forge/outcomes/03-update-readme.md +75 -0
  7. package/RAF/ahwzmc-echo-forge/plans/01-change-low-effort-default-to-sonnet.md +57 -0
  8. package/RAF/ahwzmc-echo-forge/plans/02-add-no-worktree-flag.md +51 -0
  9. package/RAF/ahwzmc-echo-forge/plans/03-update-readme.md +48 -0
  10. package/RAF/aifqwf-fix-amend-commit-again/decisions.md +7 -0
  11. package/RAF/aifqwf-fix-amend-commit-again/input.md +2 -0
  12. package/RAF/aifqwf-fix-amend-commit-again/outcomes/01-update-effort-mapping-defaults.md +35 -0
  13. package/RAF/aifqwf-fix-amend-commit-again/outcomes/02-fix-amend-worktree-commit.md +50 -0
  14. package/RAF/aifqwf-fix-amend-commit-again/plans/01-update-effort-mapping-defaults.md +37 -0
  15. package/RAF/aifqwf-fix-amend-commit-again/plans/02-fix-amend-worktree-commit.md +55 -0
  16. package/README.md +26 -12
  17. package/dist/commands/do.d.ts.map +1 -1
  18. package/dist/commands/do.js +1 -0
  19. package/dist/commands/do.js.map +1 -1
  20. package/dist/commands/plan.d.ts.map +1 -1
  21. package/dist/commands/plan.js +10 -3
  22. package/dist/commands/plan.js.map +1 -1
  23. package/dist/core/git.d.ts.map +1 -1
  24. package/dist/core/git.js +20 -4
  25. package/dist/core/git.js.map +1 -1
  26. package/dist/types/config.js +2 -2
  27. package/dist/types/config.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/commands/do.ts +1 -0
  30. package/src/commands/plan.ts +10 -3
  31. package/src/core/git.ts +23 -4
  32. package/src/prompts/config-docs.md +7 -7
  33. package/src/types/config.ts +2 -2
  34. package/tests/unit/commit-planning-artifacts-worktree.test.ts +113 -0
  35. package/tests/unit/commit-planning-artifacts.test.ts +1 -1
  36. package/tests/unit/config-command.test.ts +2 -2
  37. package/tests/unit/config.test.ts +14 -14
  38. package/tests/unit/worktree-flag-override.test.ts +186 -0
@@ -73,6 +73,7 @@ export function createPlanCommand(): Command {
73
73
  .option('--sonnet', 'Use Sonnet model (shorthand for --model sonnet)')
74
74
  .option('-y, --auto', "Skip Claude's permission prompts for file operations")
75
75
  .option('-w, --worktree', 'Create a git worktree for isolated planning')
76
+ .option('--no-worktree', 'Disable worktree mode (overrides config)')
76
77
  .option('-r, --resume <identifier>', 'Resume a planning session for an existing project')
77
78
  .action(async (projectName: string | undefined, options: PlanCommandOptions) => {
78
79
  // Validate and resolve model option
@@ -318,8 +319,12 @@ async function runPlanCommand(projectName?: string, model?: string, autoMode: bo
318
319
  logger.info(` - plans/${planFile}`);
319
320
  }
320
321
 
321
- // Commit planning artifacts (input.md and decisions.md)
322
- await commitPlanningArtifacts(projectPath, worktreePath ? { cwd: worktreePath } : undefined);
322
+ // Commit planning artifacts (input.md, decisions.md, and plan files)
323
+ const planAbsolutePaths = planFiles.map((f) => path.join(plansDir, f));
324
+ await commitPlanningArtifacts(projectPath, {
325
+ cwd: worktreePath ?? undefined,
326
+ additionalFiles: planAbsolutePaths,
327
+ });
323
328
 
324
329
  logger.newline();
325
330
  if (worktreeMode) {
@@ -632,10 +637,12 @@ async function runAmendCommand(identifier: string, model?: string, autoMode: boo
632
637
  logger.info(` - plans/${planFile}`);
633
638
  }
634
639
 
635
- // Commit planning artifacts (input.md, decisions.md only plan files committed during execution)
640
+ // Commit planning artifacts (input.md, decisions.md, and new plan files)
641
+ const newPlanAbsolutePaths = newPlanFiles.map((f) => path.join(plansDir, f));
636
642
  await commitPlanningArtifacts(projectPath, {
637
643
  cwd: worktreePath ?? undefined,
638
644
  isAmend: true,
645
+ additionalFiles: newPlanAbsolutePaths,
639
646
  });
640
647
 
641
648
  logger.newline();
package/src/core/git.ts CHANGED
@@ -227,6 +227,9 @@ export function isFileCommittedInHead(filePath: string): boolean {
227
227
  */
228
228
  export async function commitPlanningArtifacts(projectPath: string, options?: { cwd?: string; additionalFiles?: string[]; isAmend?: boolean }): Promise<void> {
229
229
  const execCwd = options?.cwd;
230
+ const execOpts = execCwd ? { cwd: execCwd } : {};
231
+
232
+ logger.debug(`commitPlanningArtifacts: projectPath=${projectPath}, cwd=${execCwd ?? 'process.cwd()'}, isAmend=${options?.isAmend ?? false}`);
230
233
 
231
234
  // Check if we're in a git repository
232
235
  if (!isGitRepo(execCwd)) {
@@ -268,6 +271,20 @@ export async function commitPlanningArtifacts(projectPath: string, options?: { c
268
271
  ? absoluteFiles.map(f => path.relative(execCwd, f))
269
272
  : absoluteFiles;
270
273
 
274
+ logger.debug(`commitPlanningArtifacts: staging files: ${filesToStage.join(', ')}`);
275
+
276
+ // Check git status before staging to understand the current state
277
+ try {
278
+ const preStatus = execSync('git status --porcelain', {
279
+ encoding: 'utf-8',
280
+ stdio: 'pipe',
281
+ ...execOpts,
282
+ }).trim();
283
+ logger.debug(`commitPlanningArtifacts: pre-stage git status:\n${preStatus || '(clean)'}`);
284
+ } catch {
285
+ logger.debug('commitPlanningArtifacts: could not get pre-stage git status');
286
+ }
287
+
271
288
  // Stage each file individually so one missing file doesn't block the others
272
289
  let stagedCount = 0;
273
290
  for (const file of filesToStage) {
@@ -275,7 +292,7 @@ export async function commitPlanningArtifacts(projectPath: string, options?: { c
275
292
  execSync(`git add -- "${file}"`, {
276
293
  encoding: 'utf-8',
277
294
  stdio: 'pipe',
278
- ...(execCwd ? { cwd: execCwd } : {}),
295
+ ...execOpts,
279
296
  });
280
297
  stagedCount++;
281
298
  } catch (error) {
@@ -294,19 +311,21 @@ export async function commitPlanningArtifacts(projectPath: string, options?: { c
294
311
  const stagedStatus = execSync('git diff --cached --name-only', {
295
312
  encoding: 'utf-8',
296
313
  stdio: 'pipe',
297
- ...(execCwd ? { cwd: execCwd } : {}),
314
+ ...execOpts,
298
315
  }).trim();
299
316
 
300
317
  if (!stagedStatus) {
301
- logger.debug('No changes to planning artifacts to commit');
318
+ logger.debug('No changes to planning artifacts to commit (git add succeeded but nothing changed in index)');
302
319
  return;
303
320
  }
304
321
 
322
+ logger.debug(`commitPlanningArtifacts: staged files: ${stagedStatus}`);
323
+
305
324
  // Commit the staged files
306
325
  execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
307
326
  encoding: 'utf-8',
308
327
  stdio: 'pipe',
309
- ...(execCwd ? { cwd: execCwd } : {}),
328
+ ...execOpts,
310
329
  });
311
330
 
312
331
  logger.debug(`Committed planning artifacts: ${commitMessage}`);
@@ -49,8 +49,8 @@ Maps task complexity labels (in plan frontmatter) to Claude models. When a plan
49
49
 
50
50
  | Key | Default | Description |
51
51
  |-----|---------|-------------|
52
- | `effortMapping.low` | `"haiku"` | Model for low-complexity tasks |
53
- | `effortMapping.medium` | `"sonnet"` | Model for medium-complexity tasks |
52
+ | `effortMapping.low` | `"sonnet"` | Model for low-complexity tasks |
53
+ | `effortMapping.medium` | `"opus"` | Model for medium-complexity tasks |
54
54
  | `effortMapping.high` | `"opus"` | Model for high-complexity tasks |
55
55
 
56
56
  Values must be a short alias (`"sonnet"`, `"haiku"`, `"opus"`) or a full model ID.
@@ -61,11 +61,11 @@ Example:
61
61
  ```json
62
62
  {
63
63
  "models": { "execute": "sonnet" },
64
- "effortMapping": { "low": "haiku", "medium": "sonnet", "high": "opus" }
64
+ "effortMapping": { "low": "sonnet", "medium": "opus", "high": "opus" }
65
65
  }
66
66
  ```
67
- - Task with `effort: low` → haiku (under ceiling)
68
- - Task with `effort: medium` → sonnet (at ceiling)
67
+ - Task with `effort: low` → sonnet (at ceiling)
68
+ - Task with `effort: medium` → sonnet (capped to ceiling, not opus)
69
69
  - Task with `effort: high` → sonnet (capped to ceiling, not opus)
70
70
 
71
71
  ### `timeout` — Task Timeout
@@ -217,8 +217,8 @@ Uses Sonnet for planning and caps task execution at Sonnet (tasks with `effort:
217
217
  "config": "sonnet"
218
218
  },
219
219
  "effortMapping": {
220
- "low": "haiku",
221
- "medium": "sonnet",
220
+ "low": "sonnet",
221
+ "medium": "opus",
222
222
  "high": "opus"
223
223
  },
224
224
  "timeout": 60,
@@ -69,8 +69,8 @@ export const DEFAULT_CONFIG: RafConfig = {
69
69
  config: 'sonnet',
70
70
  },
71
71
  effortMapping: {
72
- low: 'haiku',
73
- medium: 'sonnet',
72
+ low: 'sonnet',
73
+ medium: 'opus',
74
74
  high: 'opus',
75
75
  },
76
76
  timeout: 60,
@@ -179,6 +179,50 @@ describe('commitPlanningArtifacts - worktree integration', () => {
179
179
  expect(committedFiles).not.toContain(`RAF/${projectFolder}/plans/02-new-task.md`);
180
180
  });
181
181
 
182
+ it('should commit amend artifacts with plan files as additionalFiles in worktree', async () => {
183
+ const projectFolder = 'aatest-my-project';
184
+
185
+ // Create initial project and commit
186
+ createInitialProject(repoDir, projectFolder);
187
+ execSync('git add -A', { cwd: repoDir, stdio: 'pipe' });
188
+ execSync('git commit -m "Initial plan"', { cwd: repoDir, stdio: 'pipe' });
189
+
190
+ // Create worktree
191
+ worktreePath = createWorktreeForProject(repoDir, projectFolder);
192
+ const wtProjectPath = path.join(worktreePath, 'RAF', projectFolder);
193
+
194
+ // Simulate amend: update files and create new plan
195
+ fs.writeFileSync(
196
+ path.join(wtProjectPath, 'input.md'),
197
+ 'original input\n\n---\n\nnew task description'
198
+ );
199
+ fs.writeFileSync(
200
+ path.join(wtProjectPath, 'decisions.md'),
201
+ '# Decisions\n\n## Q1?\nA1\n\n## Q2?\nA2'
202
+ );
203
+ fs.writeFileSync(
204
+ path.join(wtProjectPath, 'plans', '02-new-task.md'),
205
+ '# Task: New Task'
206
+ );
207
+
208
+ // Call commitPlanningArtifacts with plan files as additionalFiles (as plan.ts now does)
209
+ await commitPlanningArtifacts(wtProjectPath, {
210
+ cwd: worktreePath,
211
+ isAmend: true,
212
+ additionalFiles: [path.join(wtProjectPath, 'plans', '02-new-task.md')],
213
+ });
214
+
215
+ // Verify commit was made with Amend prefix
216
+ const lastMsg = getLastCommitMessage(worktreePath);
217
+ expect(lastMsg).toMatch(/RAF\[aatest\] Amend: my-project/);
218
+
219
+ // Verify all files are in the commit (input, decisions, AND plan files)
220
+ const committedFiles = getLastCommitFiles(worktreePath);
221
+ expect(committedFiles).toContain(`RAF/${projectFolder}/input.md`);
222
+ expect(committedFiles).toContain(`RAF/${projectFolder}/decisions.md`);
223
+ expect(committedFiles).toContain(`RAF/${projectFolder}/plans/02-new-task.md`);
224
+ });
225
+
182
226
  it('should commit after worktree recreation from branch', async () => {
183
227
  const projectFolder = 'aatest-my-project';
184
228
 
@@ -247,6 +291,75 @@ describe('commitPlanningArtifacts - worktree integration', () => {
247
291
  expect(committedFiles).not.toContain(`RAF/${projectFolder}/plans/02-new-task.md`);
248
292
  });
249
293
 
294
+ it('should commit all artifacts after worktree recreation with additionalFiles', async () => {
295
+ const projectFolder = 'aatest-my-project';
296
+
297
+ // Create initial project, commit, and create initial worktree
298
+ createInitialProject(repoDir, projectFolder);
299
+ execSync('git add -A', { cwd: repoDir, stdio: 'pipe' });
300
+ execSync('git commit -m "Initial plan"', { cwd: repoDir, stdio: 'pipe' });
301
+
302
+ // Create worktree
303
+ const initialWtPath = createWorktreeForProject(repoDir, projectFolder);
304
+ const initialWtProjectPath = path.join(initialWtPath, 'RAF', projectFolder);
305
+
306
+ // Commit something on the worktree branch (simulating initial plan commit + task execution)
307
+ fs.writeFileSync(
308
+ path.join(initialWtProjectPath, 'decisions.md'),
309
+ '# Decisions\n\n## Q1?\nA1\n\n## Q2?\nA2'
310
+ );
311
+ execSync('git add -A', { cwd: initialWtPath, stdio: 'pipe' });
312
+ execSync('git commit -m "Plan commit on branch"', { cwd: initialWtPath, stdio: 'pipe' });
313
+
314
+ // Remove the worktree (simulating cleanup after execution)
315
+ execSync(`git worktree remove "${initialWtPath}" --force`, {
316
+ cwd: repoDir,
317
+ stdio: 'pipe',
318
+ });
319
+ worktreePaths.splice(worktreePaths.indexOf(initialWtPath), 1);
320
+
321
+ // Recreate worktree from existing branch (simulating amend --worktree)
322
+ const recreatedWtPath = path.join(makeTempDir('raf-wt-recreated-'), projectFolder);
323
+ execSync(`git worktree add "${recreatedWtPath}" "${projectFolder}"`, {
324
+ cwd: repoDir,
325
+ stdio: 'pipe',
326
+ });
327
+ worktreePaths.push(recreatedWtPath);
328
+
329
+ const recreatedProjectPath = path.join(recreatedWtPath, 'RAF', projectFolder);
330
+
331
+ // Simulate amend: update input.md, update decisions.md, create new plan
332
+ fs.writeFileSync(
333
+ path.join(recreatedProjectPath, 'input.md'),
334
+ 'original input\n\n---\n\namend task description'
335
+ );
336
+ fs.writeFileSync(
337
+ path.join(recreatedProjectPath, 'decisions.md'),
338
+ '# Decisions\n\n## Q1?\nA1\n\n## Q2?\nA2\n\n## Q3?\nA3'
339
+ );
340
+ fs.writeFileSync(
341
+ path.join(recreatedProjectPath, 'plans', '02-new-task.md'),
342
+ '# Task: New Task'
343
+ );
344
+
345
+ // Call commitPlanningArtifacts with plan files as additionalFiles (as plan.ts now does)
346
+ await commitPlanningArtifacts(recreatedProjectPath, {
347
+ cwd: recreatedWtPath,
348
+ isAmend: true,
349
+ additionalFiles: [path.join(recreatedProjectPath, 'plans', '02-new-task.md')],
350
+ });
351
+
352
+ // Verify commit was made
353
+ const lastMsg = getLastCommitMessage(recreatedWtPath);
354
+ expect(lastMsg).toMatch(/RAF\[aatest\] Amend: my-project/);
355
+
356
+ // Verify all files are in the commit (including plan files)
357
+ const committedFiles = getLastCommitFiles(recreatedWtPath);
358
+ expect(committedFiles).toContain(`RAF/${projectFolder}/input.md`);
359
+ expect(committedFiles).toContain(`RAF/${projectFolder}/decisions.md`);
360
+ expect(committedFiles).toContain(`RAF/${projectFolder}/plans/02-new-task.md`);
361
+ });
362
+
250
363
  it('should work when only some files have changed', async () => {
251
364
  const projectFolder = 'aatest-my-project';
252
365
 
@@ -130,7 +130,7 @@ describe('commitPlanningArtifacts', () => {
130
130
 
131
131
  // Should log debug message and not throw
132
132
  expect(mockLogger.debug).toHaveBeenCalledWith(
133
- 'No changes to planning artifacts to commit'
133
+ 'No changes to planning artifacts to commit (git add succeeded but nothing changed in index)'
134
134
  );
135
135
  expect(mockLogger.warn).not.toHaveBeenCalled();
136
136
  });
@@ -77,7 +77,7 @@ describe('Config Command', () => {
77
77
  });
78
78
 
79
79
  it('should accept valid config with effortMapping override', () => {
80
- const config = { effortMapping: { low: 'haiku', medium: 'sonnet' } };
80
+ const config = { effortMapping: { low: 'sonnet', medium: 'opus' } };
81
81
  expect(() => validateConfig(config)).not.toThrow();
82
82
  });
83
83
 
@@ -204,7 +204,7 @@ describe('Config Command', () => {
204
204
  // These are the values that runConfigSession uses when config loading fails
205
205
  expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
206
206
  // effortMapping defaults used for per-task model resolution
207
- expect(DEFAULT_CONFIG.effortMapping.medium).toBe('sonnet');
207
+ expect(DEFAULT_CONFIG.effortMapping.medium).toBe('opus');
208
208
  });
209
209
 
210
210
  it('should be able to read raw file contents even when config is invalid JSON', () => {
@@ -91,7 +91,7 @@ describe('Config', () => {
91
91
  it('should accept a full valid config', () => {
92
92
  const config = {
93
93
  models: { plan: 'opus', execute: 'haiku' },
94
- effortMapping: { low: 'haiku', medium: 'sonnet', high: 'opus' },
94
+ effortMapping: { low: 'sonnet', medium: 'opus', high: 'opus' },
95
95
  timeout: 30,
96
96
  maxRetries: 5,
97
97
  autoCommit: false,
@@ -274,7 +274,7 @@ describe('Config', () => {
274
274
 
275
275
  const config = resolveConfig(configPath);
276
276
  expect(config.effortMapping.medium).toBe('opus');
277
- expect(config.effortMapping.low).toBe('haiku'); // default preserved
277
+ expect(config.effortMapping.low).toBe('sonnet'); // default preserved
278
278
  expect(config.effortMapping.high).toBe('opus'); // default preserved
279
279
  });
280
280
 
@@ -368,7 +368,7 @@ describe('Config', () => {
368
368
  fs.writeFileSync(configPath, JSON.stringify({ effortMapping: { high: 'sonnet' } }));
369
369
  const config = resolveConfig(configPath);
370
370
  expect(config.effortMapping.high).toBe('sonnet');
371
- expect(config.effortMapping.low).toBe('haiku'); // default preserved
371
+ expect(config.effortMapping.low).toBe('sonnet'); // default preserved
372
372
  });
373
373
 
374
374
  it('getCommitFormat returns correct format', () => {
@@ -401,8 +401,8 @@ describe('Config', () => {
401
401
  });
402
402
 
403
403
  it('should have all effortMapping levels defined', () => {
404
- expect(DEFAULT_CONFIG.effortMapping.low).toBe('haiku');
405
- expect(DEFAULT_CONFIG.effortMapping.medium).toBe('sonnet');
404
+ expect(DEFAULT_CONFIG.effortMapping.low).toBe('sonnet');
405
+ expect(DEFAULT_CONFIG.effortMapping.medium).toBe('opus');
406
406
  expect(DEFAULT_CONFIG.effortMapping.high).toBe('opus');
407
407
  });
408
408
 
@@ -483,9 +483,9 @@ describe('Config', () => {
483
483
  expect(DEFAULT_CONFIG.models.prGeneration).toBe('sonnet');
484
484
  });
485
485
 
486
- it('should default effortMapping to haiku/sonnet/opus', () => {
487
- expect(DEFAULT_CONFIG.effortMapping.low).toBe('haiku');
488
- expect(DEFAULT_CONFIG.effortMapping.medium).toBe('sonnet');
486
+ it('should default effortMapping to sonnet/opus/opus', () => {
487
+ expect(DEFAULT_CONFIG.effortMapping.low).toBe('sonnet');
488
+ expect(DEFAULT_CONFIG.effortMapping.medium).toBe('opus');
489
489
  expect(DEFAULT_CONFIG.effortMapping.high).toBe('opus');
490
490
  });
491
491
 
@@ -596,8 +596,8 @@ describe('Config', () => {
596
596
  const config = resolveConfig(configPath);
597
597
  expect(config.effortMapping.high).toBe('sonnet');
598
598
  // Others should remain at defaults
599
- expect(config.effortMapping.low).toBe('haiku');
600
- expect(config.effortMapping.medium).toBe('sonnet');
599
+ expect(config.effortMapping.low).toBe('sonnet');
600
+ expect(config.effortMapping.medium).toBe('opus');
601
601
  });
602
602
 
603
603
  it('should use custom commit format when configured', () => {
@@ -711,8 +711,8 @@ describe('Config', () => {
711
711
  const configPath = path.join(tempDir, 'default.json');
712
712
  // Use default config
713
713
  const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
714
- expect(config.effortMapping.low).toBe('haiku');
715
- expect(config.effortMapping.medium).toBe('sonnet');
714
+ expect(config.effortMapping.low).toBe('sonnet');
715
+ expect(config.effortMapping.medium).toBe('opus');
716
716
  expect(config.effortMapping.high).toBe('opus');
717
717
  });
718
718
  });
@@ -721,8 +721,8 @@ describe('Config', () => {
721
721
  it('should accept valid effortMapping config', () => {
722
722
  expect(() => validateConfig({
723
723
  effortMapping: {
724
- low: 'haiku',
725
- medium: 'sonnet',
724
+ low: 'sonnet',
725
+ medium: 'opus',
726
726
  high: 'opus',
727
727
  },
728
728
  })).not.toThrow();
@@ -0,0 +1,186 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { Command } from 'commander';
5
+ import { createPlanCommand } from '../../src/commands/plan.js';
6
+ import { createDoCommand } from '../../src/commands/do.js';
7
+ import { getWorktreeDefault, resetConfigCache, saveConfig } from '../../src/utils/config.js';
8
+
9
+ describe('Worktree Flag Override', () => {
10
+ let tempDir: string;
11
+ let originalHome: string | undefined;
12
+
13
+ beforeEach(() => {
14
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-worktree-flag-test-'));
15
+ originalHome = process.env.HOME;
16
+ process.env.HOME = tempDir;
17
+ resetConfigCache();
18
+ });
19
+
20
+ afterEach(() => {
21
+ process.env.HOME = originalHome;
22
+ fs.rmSync(tempDir, { recursive: true, force: true });
23
+ resetConfigCache();
24
+ });
25
+
26
+ describe('Commander.js --no-worktree flag parsing', () => {
27
+ it('should parse --worktree as true', () => {
28
+ const planCommand = createPlanCommand();
29
+ // Use parseOptions instead of parse to avoid running the action
30
+ planCommand.parseOptions(['--worktree']);
31
+ const opts = planCommand.opts();
32
+ expect(opts.worktree).toBe(true);
33
+ });
34
+
35
+ it('should parse --no-worktree as false', () => {
36
+ const planCommand = createPlanCommand();
37
+ planCommand.parseOptions(['--no-worktree']);
38
+ const opts = planCommand.opts();
39
+ expect(opts.worktree).toBe(false);
40
+ });
41
+
42
+ it('should parse omitted flag as undefined', () => {
43
+ const planCommand = createPlanCommand();
44
+ planCommand.parseOptions([]);
45
+ const opts = planCommand.opts();
46
+ expect(opts.worktree).toBeUndefined();
47
+ });
48
+
49
+ it('should parse --worktree for do command as true', () => {
50
+ const doCommand = createDoCommand();
51
+ doCommand.parseOptions(['--worktree']);
52
+ const opts = doCommand.opts();
53
+ expect(opts.worktree).toBe(true);
54
+ });
55
+
56
+ it('should parse --no-worktree for do command as false', () => {
57
+ const doCommand = createDoCommand();
58
+ doCommand.parseOptions(['--no-worktree']);
59
+ const opts = doCommand.opts();
60
+ expect(opts.worktree).toBe(false);
61
+ });
62
+
63
+ it('should parse omitted flag for do command as undefined', () => {
64
+ const doCommand = createDoCommand();
65
+ doCommand.parseOptions([]);
66
+ const opts = doCommand.opts();
67
+ expect(opts.worktree).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe('Config resolution with --no-worktree flag', () => {
72
+ it('should resolve to true when --worktree flag is passed (regardless of config)', () => {
73
+ // Simulate: options.worktree = true (from --worktree flag)
74
+ const options = { worktree: true };
75
+ // With nullish coalescing, explicit true takes precedence
76
+ const resolved = options.worktree ?? getWorktreeDefault();
77
+ expect(resolved).toBe(true);
78
+ });
79
+
80
+ it('should resolve to false when --no-worktree flag is passed (regardless of config)', () => {
81
+ // Simulate: options.worktree = false (from --no-worktree flag)
82
+ const options = { worktree: false };
83
+ // With nullish coalescing, explicit false takes precedence
84
+ const resolved = options.worktree ?? getWorktreeDefault();
85
+ expect(resolved).toBe(false);
86
+ });
87
+
88
+ it('should resolve to config default when no flag is passed', () => {
89
+ // Simulate: options.worktree = undefined (no flag passed)
90
+ const options = { worktree: undefined };
91
+ // With nullish coalescing, undefined falls back to getWorktreeDefault()
92
+ const resolved = options.worktree ?? getWorktreeDefault();
93
+ // We can't assert a specific value here since it depends on the user's actual config
94
+ expect(typeof resolved).toBe('boolean');
95
+ });
96
+ });
97
+
98
+ describe('Tri-state behavior verification', () => {
99
+ it('should correctly handle all three states (true/false/undefined) in plan command', () => {
100
+ // State 1: --worktree (explicit true)
101
+ const planCmd1 = createPlanCommand();
102
+ planCmd1.parseOptions(['--worktree']);
103
+ const opts1 = planCmd1.opts();
104
+ expect(opts1.worktree).toBe(true);
105
+ const resolved1 = opts1.worktree ?? getWorktreeDefault();
106
+ expect(resolved1).toBe(true);
107
+
108
+ // State 2: --no-worktree (explicit false)
109
+ const planCmd2 = createPlanCommand();
110
+ planCmd2.parseOptions(['--no-worktree']);
111
+ const opts2 = planCmd2.opts();
112
+ expect(opts2.worktree).toBe(false);
113
+ const resolved2 = opts2.worktree ?? getWorktreeDefault();
114
+ expect(resolved2).toBe(false);
115
+
116
+ // State 3: omitted (undefined, falls back to config)
117
+ const planCmd3 = createPlanCommand();
118
+ planCmd3.parseOptions([]);
119
+ const opts3 = planCmd3.opts();
120
+ expect(opts3.worktree).toBeUndefined();
121
+ const resolved3 = opts3.worktree ?? getWorktreeDefault();
122
+ // Should be a boolean (actual value depends on config)
123
+ expect(typeof resolved3).toBe('boolean');
124
+ });
125
+
126
+ it('should correctly handle all three states (true/false/undefined) in do command', () => {
127
+ // State 1: --worktree (explicit true)
128
+ const doCmd1 = createDoCommand();
129
+ doCmd1.parseOptions(['--worktree']);
130
+ const opts1 = doCmd1.opts();
131
+ expect(opts1.worktree).toBe(true);
132
+ const resolved1 = opts1.worktree ?? getWorktreeDefault();
133
+ expect(resolved1).toBe(true);
134
+
135
+ // State 2: --no-worktree (explicit false)
136
+ const doCmd2 = createDoCommand();
137
+ doCmd2.parseOptions(['--no-worktree']);
138
+ const opts2 = doCmd2.opts();
139
+ expect(opts2.worktree).toBe(false);
140
+ const resolved2 = opts2.worktree ?? getWorktreeDefault();
141
+ expect(resolved2).toBe(false);
142
+
143
+ // State 3: omitted (undefined, falls back to config)
144
+ const doCmd3 = createDoCommand();
145
+ doCmd3.parseOptions([]);
146
+ const opts3 = doCmd3.opts();
147
+ expect(opts3.worktree).toBeUndefined();
148
+ const resolved3 = opts3.worktree ?? getWorktreeDefault();
149
+ // Should be a boolean (actual value depends on config)
150
+ expect(typeof resolved3).toBe('boolean');
151
+ });
152
+ });
153
+
154
+ describe('Override semantics', () => {
155
+ it('--no-worktree should override config default (explicit false takes precedence)', () => {
156
+ const planCommand = createPlanCommand();
157
+ planCommand.parseOptions(['--no-worktree']);
158
+ const opts = planCommand.opts();
159
+ const resolved = opts.worktree ?? getWorktreeDefault();
160
+
161
+ expect(opts.worktree).toBe(false); // Flag sets explicit false
162
+ expect(resolved).toBe(false); // Final result is false (flag takes precedence)
163
+ });
164
+
165
+ it('--worktree should override config default (explicit true takes precedence)', () => {
166
+ const doCommand = createDoCommand();
167
+ doCommand.parseOptions(['--worktree']);
168
+ const opts = doCommand.opts();
169
+ const resolved = opts.worktree ?? getWorktreeDefault();
170
+
171
+ expect(opts.worktree).toBe(true); // Flag sets explicit true
172
+ expect(resolved).toBe(true); // Final result is true (flag takes precedence)
173
+ });
174
+
175
+ it('omitting flag should fall back to config default', () => {
176
+ const planCommand = createPlanCommand();
177
+ planCommand.parseOptions([]);
178
+ const opts = planCommand.opts();
179
+ const resolved = opts.worktree ?? getWorktreeDefault();
180
+
181
+ expect(opts.worktree).toBeUndefined(); // Flag not set
182
+ expect(typeof resolved).toBe('boolean'); // Falls back to config (which is a boolean)
183
+ expect(resolved).toBe(getWorktreeDefault()); // Final result matches config
184
+ });
185
+ });
186
+ });