cluttry 1.5.1 → 2.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 (41) hide show
  1. package/README.md +79 -17
  2. package/dist/commands/finish.d.ts +5 -0
  3. package/dist/commands/finish.d.ts.map +1 -1
  4. package/dist/commands/finish.js +346 -3
  5. package/dist/commands/finish.js.map +1 -1
  6. package/dist/commands/list.d.ts +2 -1
  7. package/dist/commands/list.d.ts.map +1 -1
  8. package/dist/commands/list.js +69 -25
  9. package/dist/commands/list.js.map +1 -1
  10. package/dist/commands/spawn.d.ts +3 -0
  11. package/dist/commands/spawn.d.ts.map +1 -1
  12. package/dist/commands/spawn.js +158 -26
  13. package/dist/commands/spawn.js.map +1 -1
  14. package/dist/index.js +19 -3
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/config.d.ts.map +1 -1
  17. package/dist/lib/config.js +39 -0
  18. package/dist/lib/config.js.map +1 -1
  19. package/dist/lib/env.d.ts +38 -0
  20. package/dist/lib/env.d.ts.map +1 -0
  21. package/dist/lib/env.js +166 -0
  22. package/dist/lib/env.js.map +1 -0
  23. package/dist/lib/git.d.ts +17 -1
  24. package/dist/lib/git.d.ts.map +1 -1
  25. package/dist/lib/git.js +44 -2
  26. package/dist/lib/git.js.map +1 -1
  27. package/dist/lib/hooks.d.ts +43 -0
  28. package/dist/lib/hooks.d.ts.map +1 -0
  29. package/dist/lib/hooks.js +120 -0
  30. package/dist/lib/hooks.js.map +1 -0
  31. package/dist/lib/secrets.d.ts +54 -0
  32. package/dist/lib/secrets.d.ts.map +1 -1
  33. package/dist/lib/secrets.js +140 -0
  34. package/dist/lib/secrets.js.map +1 -1
  35. package/dist/lib/session.d.ts +14 -0
  36. package/dist/lib/session.d.ts.map +1 -1
  37. package/dist/lib/session.js +4 -1
  38. package/dist/lib/session.js.map +1 -1
  39. package/dist/lib/types.d.ts +37 -3
  40. package/dist/lib/types.d.ts.map +1 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cluttry
2
2
 
3
- AI session lifecycle in git worktrees.
3
+ **Worktrees for coding agents. Safe by default.**
4
4
 
5
5
  **CLI command:** `cry`
6
6
 
@@ -14,11 +14,11 @@ npm install -g cluttry
14
14
  cd your-repo
15
15
  cry init
16
16
 
17
- # One command: create worktree → launch Claude → finish when done
18
- cry feat-login claude --finish-on-exit
17
+ # One command: create worktree → launch Claude → auto-finish when done
18
+ cry feat-login claude --auto
19
19
  ```
20
20
 
21
- That's it. When Claude exits, you'll see a menu to commit, create a PR, and clean up.
21
+ That's it. When Claude exits, cry automatically commits, creates a PR, and cleans up.
22
22
 
23
23
  ## The Lifecycle
24
24
 
@@ -42,7 +42,10 @@ cry feat-auth claude
42
42
  # Explicit
43
43
  cry spawn feat-auth --new --agent claude
44
44
 
45
- # Full lifecycle in one command
45
+ # Full autopilot: agent exits → commit → PR → cleanup
46
+ cry feat-auth claude --auto
47
+
48
+ # Interactive finish menu when agent exits
46
49
  cry feat-auth claude --finish-on-exit
47
50
  ```
48
51
 
@@ -50,7 +53,7 @@ cry feat-auth claude --finish-on-exit
50
53
 
51
54
  Your AI agent works in the isolated worktree. Each worktree has:
52
55
  - Its own branch
53
- - Copy of your `.env` and secret files
56
+ - Copy of your `.env` and secret files (or injected env vars)
54
57
  - Independent git state
55
58
 
56
59
  Run multiple sessions in parallel—each in its own terminal.
@@ -64,10 +67,13 @@ cry finish
64
67
  ```
65
68
 
66
69
  Interactive flow:
67
- 1. Shows session summary (branch, commits, diff stats)
68
- 2. If dirty: offers to commit (with suggested message from branch name)
69
- 3. Pushes branch and creates PR via `gh` CLI
70
- 4. Offers cleanup prompt
70
+ 1. Runs `preFinish` hooks (tests, lint, etc.)
71
+ 2. Shows session summary (branch, commits, diff stats)
72
+ 3. If dirty: offers to commit (with suggested message from branch name)
73
+ 4. Pushes branch and creates PR via `gh` CLI
74
+ 5. Offers merge options (PR only, local merge, or gh merge)
75
+ 6. Runs `postFinish` hooks
76
+ 7. Offers cleanup prompt
71
77
 
72
78
  Non-interactive:
73
79
  ```bash
@@ -113,21 +119,54 @@ After `cry init`, edit `.cry.json`:
113
119
  {
114
120
  "include": [".env", ".env.*", "config/secrets.json"],
115
121
  "defaultMode": "copy",
116
- "hooks": { "postCreate": ["npm install"] },
122
+ "hooks": {
123
+ "postCreate": ["npm install"],
124
+ "preFinish": ["npm test", "npm run lint"],
125
+ "postFinish": ["echo 'PR created!'"],
126
+ "preMerge": ["npm run build"]
127
+ },
117
128
  "agentCommand": "claude"
118
129
  }
119
130
  ```
120
131
 
121
132
  | Key | Description |
122
133
  |-----|-------------|
123
- | `include` | Glob patterns for files to copy to worktrees |
124
- | `defaultMode` | `copy`, `symlink`, or `none` |
134
+ | `include` | Glob patterns for files to copy/inject to worktrees |
135
+ | `defaultMode` | `copy`, `symlink`, `inject`, or `none` |
125
136
  | `hooks.postCreate` | Commands to run after spawn |
137
+ | `hooks.preFinish` | Commands to run before finish (tests, lint) |
138
+ | `hooks.postFinish` | Commands to run after PR creation |
139
+ | `hooks.preMerge` | Commands to run before merge attempts |
126
140
  | `agentCommand` | Agent CLI command (default: `claude`) |
127
141
  | `editorCommand` | Editor command (default: `code`) |
142
+ | `injectNonEnv` | For inject mode: `skip` or `symlink` non-dotenv files |
143
+ | `agents` | Agent presets (see below) |
128
144
 
129
145
  Machine-specific overrides go in `.cry.local.json` (gitignored).
130
146
 
147
+ ### Agent Presets
148
+
149
+ Configure per-agent behavior:
150
+
151
+ ```json
152
+ {
153
+ "agents": {
154
+ "claude": {
155
+ "command": "claude",
156
+ "deny": [".env", ".env.*"],
157
+ "finishOnExitDefault": true
158
+ },
159
+ "cursor": {
160
+ "command": "cursor",
161
+ "args": ["."],
162
+ "finishOnExitDefault": false
163
+ }
164
+ }
165
+ }
166
+ ```
167
+
168
+ Built-in presets for `claude` and `cursor` are provided. Override or add your own.
169
+
131
170
  ## Security Model
132
171
 
133
172
  **Tracked files are never copied.** This is enforced, not optional.
@@ -151,14 +190,20 @@ cry explain-copy
151
190
  cry spawn feat-test --new --dry-run
152
191
  ```
153
192
 
154
- ### Copy vs Symlink
193
+ ### Copy vs Symlink vs Inject
155
194
 
156
195
  | Mode | Behavior |
157
196
  |------|----------|
158
197
  | `copy` | Independent copies. Safe default. |
159
198
  | `symlink` | Linked to original. Changes sync everywhere. |
199
+ | `inject` | **No files copied.** Env vars injected into commands. Safest for AI agents. |
160
200
  | `none` | Nothing copied. Set up secrets manually. |
161
201
 
202
+ **Inject mode** is recommended when running AI agents:
203
+ - Parses `.env` files and injects variables into hooks and agent commands
204
+ - No secret files exist in the worktree for the agent to read
205
+ - Non-dotenv files are skipped (or optionally symlinked via `injectNonEnv: "symlink"`)
206
+
162
207
  ## Commands
163
208
 
164
209
  ### Session Lifecycle
@@ -191,10 +236,13 @@ cry spawn feat-test --new --dry-run
191
236
 
192
237
  ```
193
238
  -n, --new Create new branch
194
- -a, --agent <agent> Launch agent (claude, cursor, none)
239
+ -a, --agent <agent> Launch agent (claude, cursor, or custom preset)
195
240
  --finish-on-exit Show finish menu when agent exits
241
+ --auto Autopilot: auto-commit, PR, cleanup when agent exits
242
+ --auto-merge With --auto: also merge PR via gh
243
+ --auto-commit-message With --auto: custom commit message
196
244
  --base-branch <branch> PR target branch
197
- -m, --mode <mode> Secret handling (copy, symlink, none)
245
+ -m, --mode <mode> Secret handling (copy, symlink, inject, none)
198
246
  -r, --run <cmd> Run command after spawn
199
247
  --dry-run Preview without creating
200
248
  ```
@@ -203,6 +251,10 @@ cry spawn feat-test --new --dry-run
203
251
 
204
252
  ```
205
253
  -m, --message <msg> Commit with message (non-interactive)
254
+ --skip-hooks Skip all hooks (preFinish, postFinish, preMerge)
255
+ --merge Merge locally into base branch after PR
256
+ --pr-merge Merge PR via GitHub (gh pr merge)
257
+ --no-merge Skip merge prompt (PR only)
206
258
  --cleanup Auto-cleanup after PR
207
259
  --skip-commit Skip commit step
208
260
  --non-interactive Never prompt
@@ -252,7 +304,17 @@ brew install gh && gh auth login
252
304
 
253
305
  ### How do I prevent AI from reading secrets?
254
306
 
255
- For Claude Code, add to `.clauderc`:
307
+ **Best option: Use inject mode** (no files copied to worktree):
308
+ ```bash
309
+ cry spawn feat-auth --new --agent claude --mode inject
310
+ ```
311
+
312
+ Or configure it as default in `.cry.json`:
313
+ ```json
314
+ { "defaultMode": "inject" }
315
+ ```
316
+
317
+ For Claude Code specifically, you can also add to `.clauderc`:
256
318
  ```json
257
319
  { "deny": [".env", ".env.*"] }
258
320
  ```
@@ -15,6 +15,11 @@ export interface FinishOptions {
15
15
  deleteBranch?: boolean;
16
16
  message?: string;
17
17
  skipCommit?: boolean;
18
+ skipHooks?: boolean;
19
+ merge?: boolean;
20
+ prMerge?: boolean;
21
+ noMerge?: boolean;
22
+ cherryPick?: boolean;
18
23
  }
19
24
  export declare function finish(options: FinishOptions): Promise<void>;
20
25
  //# sourceMappingURL=finish.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"finish.d.ts","sourceRoot":"","sources":["../../src/commands/finish.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA0BH,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA2sBD,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAwMlE"}
1
+ {"version":3,"file":"finish.d.ts","sourceRoot":"","sources":["../../src/commands/finish.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAgCH,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAi4BD,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAoYlE"}
@@ -6,8 +6,10 @@
6
6
  */
7
7
  import { createInterface } from 'node:readline';
8
8
  import { execSync, spawnSync } from 'node:child_process';
9
- import { isGitRepo, getRepoRoot, getCurrentBranch, git, removeWorktree, deleteBranch, getDefaultBranch, getUpstreamBranch, } from '../lib/git.js';
10
- import { findSessionForCwd, findMainRepoRoot, deleteSession, } from '../lib/session.js';
9
+ import { isGitRepo, getRepoRoot, getCurrentBranch, git, removeWorktree, deleteBranch, isWorktreeDirty, getDefaultBranch, getUpstreamBranch, getCommitsAhead, cherryPick, cherryPickAbort, } from '../lib/git.js';
10
+ import { findSessionForCwd, findMainRepoRoot, deleteSession, updateSessionManifest, } from '../lib/session.js';
11
+ import { runHooks } from '../lib/hooks.js';
12
+ import { getMergedConfig } from '../lib/config.js';
11
13
  import * as out from '../lib/output.js';
12
14
  import { fail, errors, printError } from '../lib/errors.js';
13
15
  /**
@@ -399,6 +401,171 @@ function createPullRequest(branch, baseBranch, cwd) {
399
401
  return { success: false, error: error.message };
400
402
  }
401
403
  }
404
+ /**
405
+ * Perform local merge in the main worktree
406
+ */
407
+ function performLocalMerge(mainRepoRoot, sessionBranch, baseBranch, dryRun) {
408
+ if (dryRun) {
409
+ out.log(out.fmt.dim('[dry-run] Would perform local merge:'));
410
+ out.log(out.fmt.dim(` 1. cd ${mainRepoRoot}`));
411
+ out.log(out.fmt.dim(` 2. git fetch origin && git checkout ${baseBranch}`));
412
+ out.log(out.fmt.dim(` 3. git merge --no-ff ${sessionBranch}`));
413
+ out.log(out.fmt.dim(` 4. git push origin ${baseBranch}`));
414
+ return { success: true };
415
+ }
416
+ try {
417
+ // Check if main worktree is clean
418
+ if (isWorktreeDirty(mainRepoRoot)) {
419
+ return { success: false, error: 'Main worktree has uncommitted changes. Please commit or stash them first.' };
420
+ }
421
+ // Save current branch to restore on failure
422
+ let originalBranch = null;
423
+ try {
424
+ originalBranch = getCurrentBranch(mainRepoRoot);
425
+ }
426
+ catch {
427
+ // May be in detached HEAD
428
+ }
429
+ // Fetch and checkout base branch
430
+ out.log(`Fetching and checking out ${out.fmt.branch(baseBranch)}...`);
431
+ try {
432
+ git(['fetch', 'origin'], mainRepoRoot);
433
+ }
434
+ catch {
435
+ // Fetch may fail if no remote, continue anyway
436
+ }
437
+ git(['checkout', baseBranch], mainRepoRoot);
438
+ // Try to merge
439
+ out.log(`Merging ${out.fmt.branch(sessionBranch)} into ${out.fmt.branch(baseBranch)}...`);
440
+ try {
441
+ git(['merge', '--no-ff', sessionBranch, '-m', `Merge branch '${sessionBranch}'`], mainRepoRoot);
442
+ }
443
+ catch (mergeError) {
444
+ // Conflict detected - abort and restore
445
+ out.error('Merge conflict detected! Aborting merge...');
446
+ try {
447
+ git(['merge', '--abort'], mainRepoRoot);
448
+ }
449
+ catch {
450
+ // Abort may fail if merge wasn't in progress
451
+ }
452
+ if (originalBranch && originalBranch !== baseBranch) {
453
+ try {
454
+ git(['checkout', originalBranch], mainRepoRoot);
455
+ }
456
+ catch {
457
+ // Best effort restore
458
+ }
459
+ }
460
+ return { success: false, error: 'Merge conflicts detected. Please resolve manually or use PR workflow.' };
461
+ }
462
+ // Push to origin
463
+ out.log(`Pushing ${out.fmt.branch(baseBranch)} to origin...`);
464
+ try {
465
+ git(['push', 'origin', baseBranch], mainRepoRoot);
466
+ out.success('Local merge and push completed');
467
+ }
468
+ catch (pushError) {
469
+ out.warn('Merge succeeded locally but push failed. You may need to push manually.');
470
+ }
471
+ return { success: true };
472
+ }
473
+ catch (error) {
474
+ return { success: false, error: error.message };
475
+ }
476
+ }
477
+ /**
478
+ * Perform cherry-pick of commits to the base branch in main worktree
479
+ */
480
+ function performCherryPickToBase(mainRepoRoot, _sessionBranch, baseBranch, commits, dryRun) {
481
+ if (dryRun) {
482
+ out.log(out.fmt.dim('[dry-run] Would perform cherry-pick:'));
483
+ out.log(out.fmt.dim(` 1. cd ${mainRepoRoot}`));
484
+ out.log(out.fmt.dim(` 2. git fetch origin && git checkout ${baseBranch}`));
485
+ for (const commit of commits) {
486
+ out.log(out.fmt.dim(` 3. git cherry-pick ${commit.sha} # ${commit.message}`));
487
+ }
488
+ out.log(out.fmt.dim(` 4. git push origin ${baseBranch}`));
489
+ return { success: true };
490
+ }
491
+ try {
492
+ // Check if main worktree is clean
493
+ if (isWorktreeDirty(mainRepoRoot)) {
494
+ return { success: false, error: 'Main worktree has uncommitted changes. Please commit or stash them first.' };
495
+ }
496
+ // Save current branch to restore on failure
497
+ let originalBranch = null;
498
+ try {
499
+ originalBranch = getCurrentBranch(mainRepoRoot);
500
+ }
501
+ catch {
502
+ // May be in detached HEAD
503
+ }
504
+ // Fetch and checkout base branch
505
+ out.log(`Fetching and checking out ${out.fmt.branch(baseBranch)}...`);
506
+ try {
507
+ git(['fetch', 'origin'], mainRepoRoot);
508
+ }
509
+ catch {
510
+ // Fetch may fail if no remote, continue anyway
511
+ }
512
+ git(['checkout', baseBranch], mainRepoRoot);
513
+ // Cherry-pick each commit
514
+ out.log(`Cherry-picking ${commits.length} commit(s) onto ${out.fmt.branch(baseBranch)}...`);
515
+ for (const commit of commits) {
516
+ out.log(` Cherry-picking ${out.fmt.dim(commit.sha)} ${commit.message}`);
517
+ if (!cherryPick(commit.sha, mainRepoRoot)) {
518
+ out.error(`Cherry-pick conflict on commit ${commit.sha}! Aborting...`);
519
+ cherryPickAbort(mainRepoRoot);
520
+ // Try to restore original branch
521
+ if (originalBranch && originalBranch !== baseBranch) {
522
+ try {
523
+ git(['checkout', originalBranch], mainRepoRoot);
524
+ }
525
+ catch {
526
+ // Best effort restore
527
+ }
528
+ }
529
+ return { success: false, error: `Cherry-pick conflict on commit ${commit.sha}. Please resolve manually or use PR workflow.` };
530
+ }
531
+ }
532
+ out.success(`Successfully cherry-picked ${commits.length} commit(s)`);
533
+ // Push to origin
534
+ out.log(`Pushing ${out.fmt.branch(baseBranch)} to origin...`);
535
+ try {
536
+ git(['push', 'origin', baseBranch], mainRepoRoot);
537
+ out.success('Cherry-pick and push completed');
538
+ }
539
+ catch {
540
+ out.warn('Cherry-pick succeeded locally but push failed. You may need to push manually.');
541
+ }
542
+ return { success: true };
543
+ }
544
+ catch (error) {
545
+ return { success: false, error: error.message };
546
+ }
547
+ }
548
+ /**
549
+ * Merge PR using gh CLI
550
+ */
551
+ function performPrMerge(branch, cwd) {
552
+ try {
553
+ const result = spawnSync('gh', ['pr', 'merge', '--merge', '--delete-branch', branch], {
554
+ cwd,
555
+ encoding: 'utf-8',
556
+ stdio: 'pipe',
557
+ });
558
+ if (result.status === 0) {
559
+ return { success: true };
560
+ }
561
+ else {
562
+ return { success: false, error: result.stderr || 'Unknown error' };
563
+ }
564
+ }
565
+ catch (error) {
566
+ return { success: false, error: error.message };
567
+ }
568
+ }
402
569
  /**
403
570
  * Show git diff in pager
404
571
  */
@@ -659,9 +826,28 @@ export async function finish(options) {
659
826
  }
660
827
  // Print summary
661
828
  printSummary(summary);
829
+ // Load config for hooks
830
+ const mainRepoRoot = findMainRepoRoot(cwd);
831
+ const config = mainRepoRoot ? getMergedConfig(mainRepoRoot) : null;
662
832
  const isDirty = !summary.status.clean;
663
833
  const hasCommits = summary.commits.ahead > 0;
664
834
  const dryRun = options.dryRun ?? false;
835
+ // Run preFinish hooks
836
+ if (!options.skipHooks && config && config.hooks.preFinish.length > 0) {
837
+ if (dryRun) {
838
+ out.log(out.fmt.dim('[dry-run] Would run preFinish hooks:'));
839
+ for (const hook of config.hooks.preFinish) {
840
+ out.log(out.fmt.dim(` - ${hook}`));
841
+ }
842
+ }
843
+ else {
844
+ const hookResult = await runHooks('preFinish', config.hooks.preFinish, { cwd });
845
+ if (!hookResult.success) {
846
+ out.error('preFinish hooks failed. Aborting finish.');
847
+ process.exit(1);
848
+ }
849
+ }
850
+ }
665
851
  // Handle dirty state
666
852
  if (isDirty) {
667
853
  // Skip commit entirely if --skip-commit is set
@@ -741,7 +927,54 @@ export async function finish(options) {
741
927
  const ghAvailable = isGhAvailable();
742
928
  const hasOrigin = hasOriginRemote(cwd);
743
929
  const canCreatePr = ghAvailable && hasOrigin;
744
- if (updatedHasCommits || options.pr) {
930
+ // Cherry-pick workflow (alternative to PR)
931
+ if (options.cherryPick) {
932
+ out.newline();
933
+ if (!updatedHasCommits) {
934
+ out.log(out.fmt.dim('No commits to cherry-pick.'));
935
+ }
936
+ else if (!mainRepoRoot) {
937
+ out.error('Could not find main repository root for cherry-pick.');
938
+ process.exit(1);
939
+ }
940
+ else {
941
+ out.header('Cherry-Pick to Base Branch');
942
+ out.newline();
943
+ const commits = getCommitsAhead(summary.baseBranch, cwd);
944
+ if (commits.length === 0) {
945
+ out.log(out.fmt.dim('No commits ahead of base branch.'));
946
+ }
947
+ else {
948
+ out.log(`Found ${commits.length} commit(s) to cherry-pick:`);
949
+ for (const commit of commits.slice(0, 5)) {
950
+ out.log(` ${out.fmt.dim(commit.sha)} ${commit.message}`);
951
+ }
952
+ if (commits.length > 5) {
953
+ out.log(out.fmt.dim(` ... and ${commits.length - 5} more`));
954
+ }
955
+ out.newline();
956
+ const cherryPickResult = performCherryPickToBase(mainRepoRoot, summary.branch, summary.baseBranch, commits, dryRun);
957
+ if (!cherryPickResult.success) {
958
+ out.error(`Cherry-pick failed: ${cherryPickResult.error}`);
959
+ process.exit(1);
960
+ }
961
+ // Update session manifest
962
+ if (sessionId && mainRepoRoot) {
963
+ updateSessionManifest(mainRepoRoot, sessionId, {
964
+ status: 'finished',
965
+ lastActiveAt: new Date().toISOString(),
966
+ lastFinishResult: {
967
+ success: true,
968
+ prCreated: false,
969
+ checksRan: false,
970
+ message: 'Cherry-picked to base branch',
971
+ },
972
+ });
973
+ }
974
+ }
975
+ }
976
+ }
977
+ else if (updatedHasCommits || options.pr) {
745
978
  out.newline();
746
979
  if (dryRun) {
747
980
  out.log(out.fmt.dim('[dry-run] Would push branch and create PR'));
@@ -760,6 +993,19 @@ export async function finish(options) {
760
993
  const prResult = createPullRequest(summary.branch, summary.baseBranch, cwd);
761
994
  if (prResult.success) {
762
995
  out.success(`PR created: ${prResult.url}`);
996
+ // Update session manifest with PR URL
997
+ if (sessionId && mainRepoRoot) {
998
+ updateSessionManifest(mainRepoRoot, sessionId, {
999
+ prUrl: prResult.url,
1000
+ status: 'finished',
1001
+ lastActiveAt: new Date().toISOString(),
1002
+ lastFinishResult: {
1003
+ success: true,
1004
+ prCreated: true,
1005
+ checksRan: false,
1006
+ },
1007
+ });
1008
+ }
763
1009
  }
764
1010
  else {
765
1011
  // PR might already exist
@@ -769,6 +1015,88 @@ export async function finish(options) {
769
1015
  else {
770
1016
  out.warn(`Could not create PR: ${prResult.error}`);
771
1017
  }
1018
+ // Update session manifest with finish result
1019
+ if (sessionId && mainRepoRoot) {
1020
+ updateSessionManifest(mainRepoRoot, sessionId, {
1021
+ status: prResult.error?.includes('already exists') ? 'finished' : 'error',
1022
+ lastActiveAt: new Date().toISOString(),
1023
+ lastFinishResult: {
1024
+ success: prResult.error?.includes('already exists') ?? false,
1025
+ prCreated: false,
1026
+ checksRan: false,
1027
+ message: prResult.error,
1028
+ },
1029
+ });
1030
+ }
1031
+ }
1032
+ // Merge options
1033
+ let mergeAction = 'done';
1034
+ // Handle non-interactive merge flags
1035
+ if (options.merge) {
1036
+ mergeAction = 'local';
1037
+ }
1038
+ else if (options.prMerge) {
1039
+ mergeAction = 'gh';
1040
+ }
1041
+ else if (!options.noMerge && !options.nonInteractive) {
1042
+ // Interactive merge menu
1043
+ out.newline();
1044
+ mergeAction = await promptChoice('What would you like to do next?', [
1045
+ { key: 'p', label: 'Done (PR only)', value: 'done' },
1046
+ { key: 'm', label: 'Merge locally into base branch', value: 'local' },
1047
+ { key: 'g', label: 'Merge PR via GitHub (gh pr merge)', value: 'gh' },
1048
+ { key: 'x', label: 'Cancel', value: 'cancel' },
1049
+ ]);
1050
+ }
1051
+ // Handle merge action
1052
+ if (mergeAction === 'cancel') {
1053
+ out.log('Cancelled.');
1054
+ process.exit(0);
1055
+ }
1056
+ if (mergeAction === 'local' || mergeAction === 'gh') {
1057
+ // Run preMerge hooks
1058
+ if (!options.skipHooks && config && config.hooks.preMerge.length > 0) {
1059
+ if (dryRun) {
1060
+ out.log(out.fmt.dim('[dry-run] Would run preMerge hooks:'));
1061
+ for (const hook of config.hooks.preMerge) {
1062
+ out.log(out.fmt.dim(` - ${hook}`));
1063
+ }
1064
+ }
1065
+ else {
1066
+ const hookResult = await runHooks('preMerge', config.hooks.preMerge, { cwd });
1067
+ if (!hookResult.success) {
1068
+ out.error('preMerge hooks failed. Merge aborted.');
1069
+ process.exit(1);
1070
+ }
1071
+ }
1072
+ }
1073
+ // Perform the merge
1074
+ if (mergeAction === 'local') {
1075
+ out.newline();
1076
+ out.header('Local Merge');
1077
+ const mergeResult = performLocalMerge(mainRepoRoot, summary.branch, summary.baseBranch, dryRun);
1078
+ if (!mergeResult.success) {
1079
+ out.error(`Local merge failed: ${mergeResult.error}`);
1080
+ process.exit(1);
1081
+ }
1082
+ }
1083
+ else if (mergeAction === 'gh') {
1084
+ out.newline();
1085
+ out.log('Merging PR via GitHub...');
1086
+ if (dryRun) {
1087
+ out.log(out.fmt.dim(`[dry-run] Would run: gh pr merge --merge --delete-branch ${summary.branch}`));
1088
+ }
1089
+ else {
1090
+ const mergeResult = performPrMerge(summary.branch, cwd);
1091
+ if (mergeResult.success) {
1092
+ out.success('PR merged and branch deleted via GitHub');
1093
+ }
1094
+ else {
1095
+ out.error(`PR merge failed: ${mergeResult.error}`);
1096
+ process.exit(1);
1097
+ }
1098
+ }
1099
+ }
772
1100
  }
773
1101
  }
774
1102
  else if (!ghAvailable) {
@@ -783,6 +1111,21 @@ export async function finish(options) {
783
1111
  else {
784
1112
  out.log(out.fmt.dim('No commits to push.'));
785
1113
  }
1114
+ // Run postFinish hooks
1115
+ if (!options.skipHooks && config && config.hooks.postFinish.length > 0) {
1116
+ if (dryRun) {
1117
+ out.log(out.fmt.dim('[dry-run] Would run postFinish hooks:'));
1118
+ for (const hook of config.hooks.postFinish) {
1119
+ out.log(out.fmt.dim(` - ${hook}`));
1120
+ }
1121
+ }
1122
+ else {
1123
+ const hookResult = await runHooks('postFinish', config.hooks.postFinish, { cwd });
1124
+ if (!hookResult.success) {
1125
+ out.warn('postFinish hooks failed, but finish will continue.');
1126
+ }
1127
+ }
1128
+ }
786
1129
  // Cleanup prompt
787
1130
  if (options.noCleanup) {
788
1131
  // Skip cleanup entirely