claude-git-hooks 2.35.3 → 2.43.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.
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * File: create-pr.js
3
3
  * Purpose: Create pull request with auto-generated metadata and reviewers
4
+ *
5
+ * Headless mode (--headless): skips all interactive prompts, uses documented defaults.
6
+ * JSON output (--format json): emits single JSON line on stdout, all other output to stderr.
4
7
  */
5
8
 
6
9
  import { execSync } from 'child_process';
@@ -22,9 +25,35 @@ import {
22
25
  import { getBranchPushStatus, pushBranch } from '../utils/git-operations.js';
23
26
  import logger from '../utils/logger.js';
24
27
  import { resolveLabels } from '../utils/label-resolver.js';
25
- import { error, checkGitRepo } from './helpers.js';
28
+ import { CostTracker } from '../utils/cost-tracker.js';
29
+ import { error, fatal, checkGitRepo } from './helpers.js';
26
30
  import { recordMetric } from '../utils/metrics.js';
27
31
 
32
+ // ─── JSON Helpers ────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Emit error JSON to stdout and set exit code
36
+ * @param {string} message - Error message
37
+ * @param {number} exitCode - Process exit code (default: 1)
38
+ * @private
39
+ */
40
+ function _emitErrorJSON(message, exitCode = 1) {
41
+ process.stdout.write(`${JSON.stringify({ status: 'error', error: message })}\n`);
42
+ process.exitCode = exitCode;
43
+ }
44
+
45
+ /**
46
+ * Emit JSON result to stdout (does not exit — caller handles exit)
47
+ * @param {Object} payload - JSON payload
48
+ * @private
49
+ */
50
+ function _emitJSON(payload) {
51
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
52
+ }
53
+
54
+ // Known flags that should not be treated as branch names
55
+ const KNOWN_FLAGS = new Set(['--headless', '--format']);
56
+
28
57
  /**
29
58
  * Detect expected merge strategy from branch naming conventions
30
59
  * @param {string} sourceBranch - Source (head) branch name
@@ -45,9 +74,33 @@ function detectMergeStrategy(sourceBranch, targetBranch) {
45
74
  * @param {Array<string>} args - Command arguments
46
75
  */
47
76
  export async function runCreatePr(args) {
48
- logger.debug('create-pr', 'Starting create-pr command', { args });
77
+ // ── Parse headless/format flags ──
78
+ const headless = args.includes('--headless');
79
+ const fmtIdx = args.indexOf('--format');
80
+ const format = fmtIdx >= 0 ? args[fmtIdx + 1] : null;
81
+ const isJSON = format === 'json';
82
+ if (format && !isJSON) {
83
+ logger.warning(`Unknown --format value '${format}'. Only 'json' is supported.`);
84
+ }
85
+
86
+ // Disallowed combo: --format json without --headless
87
+ if (isJSON && !headless) {
88
+ _emitErrorJSON('--format json requires --headless', 2);
89
+ return;
90
+ }
91
+
92
+ // Activate JSON mode before any output so info/warning route to stderr
93
+ if (isJSON) logger.setJSONMode(true);
94
+
95
+ const startTime = Date.now();
96
+ const costTracker = headless ? new CostTracker() : null;
97
+ let branchWasPushed = false;
98
+ let pushedTags = [];
99
+
100
+ logger.debug('create-pr', 'Starting create-pr command', { args, headless, isJSON });
49
101
 
50
102
  if (!checkGitRepo()) {
103
+ if (isJSON) { _emitErrorJSON('Not a git repository'); return; }
51
104
  error('You are not in a Git repository.');
52
105
  logger.debug('create-pr', 'Not in a git repository, exiting');
53
106
  return;
@@ -71,7 +124,7 @@ export async function runCreatePr(args) {
71
124
  const { parseGitHubRepo } = await import('../utils/github-client.js');
72
125
 
73
126
  showInfo('🚀 Creating Pull Request...');
74
- console.log('');
127
+ if (!isJSON) console.log('');
75
128
 
76
129
  // Step 1: Validate GitHub token
77
130
  logger.debug('create-pr', 'Step 1: Validating GitHub token');
@@ -80,6 +133,10 @@ export async function runCreatePr(args) {
80
133
  logger.error('create-pr', 'GitHub authentication failed', {
81
134
  error: tokenValidation.error
82
135
  });
136
+ if (isJSON) {
137
+ _emitErrorJSON('GitHub authentication failed. Run: claude-hooks setup-github', 2);
138
+ return;
139
+ }
83
140
  showError('GitHub authentication failed');
84
141
  console.log('');
85
142
  console.log('Please configure your GitHub token:');
@@ -102,39 +159,49 @@ export async function runCreatePr(args) {
102
159
  showWarning('Token may lack "repo" scope - PR creation might fail');
103
160
  }
104
161
 
105
- // Step 2: Get or prompt for task-id (with config for pattern)
106
- logger.debug('create-pr', 'Step 2: Getting or prompting for task-id');
162
+ // Step 2: Detect current branch (fail fast before interactive prompts)
163
+ logger.debug('create-pr', 'Step 2: Getting current branch and repo info');
164
+ const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
165
+ if (!currentBranch) {
166
+ if (isJSON) {
167
+ _emitErrorJSON('Detached HEAD detected. create-pr requires a named branch.');
168
+ return;
169
+ }
170
+ fatal(
171
+ 'Detached HEAD detected. create-pr requires a named branch.\n' +
172
+ ' Check out a branch first: git checkout -b <branch-name>\n' +
173
+ ' Or, if running in CI/container: configure the orchestrator to checkout\n' +
174
+ ' the branch by name, not by SHA. See CLAUDE.md "Operational Contract".'
175
+ );
176
+ }
177
+
178
+ const repoInfo = parseGitHubRepo();
179
+
180
+ // Step 3: Get or prompt for task-id
181
+ // Decision #1: headless → prompt:false (null if not auto-detected from branch)
182
+ logger.debug('create-pr', 'Step 3: Getting or prompting for task-id');
107
183
  const taskId = await getOrPromptTaskId({
108
- prompt: true, // DO prompt for PRs (unlike commit messages)
109
- required: false, // Allow skipping
110
- config // Pass config for custom pattern
184
+ prompt: !headless, // DO prompt for PRs (unless headless)
185
+ required: false,
186
+ config
111
187
  });
112
188
  logger.debug('create-pr', 'Task ID determined', { taskId });
113
189
 
114
- // Step 3: Parse arguments and determine base branch
115
- logger.debug('create-pr', 'Step 3: Parsing arguments and determining base branch', {
190
+ // Step 4: Parse arguments and determine base branch
191
+ // Filter out known flags and their values from args before resolving base branch
192
+ logger.debug('create-pr', 'Step 4: Parsing arguments and determining base branch', {
116
193
  args
117
194
  });
118
- let baseBranchArg = args[0];
195
+ const cleanArgs = args.filter((a, i) => {
196
+ if (KNOWN_FLAGS.has(a)) return false;
197
+ if (i > 0 && args[i - 1] === '--format') return false;
198
+ return true;
199
+ });
200
+ let baseBranchArg = cleanArgs[0];
119
201
  if (baseBranchArg && /^[A-Z]{2,10}-\d+$/i.test(baseBranchArg)) {
120
- baseBranchArg = args[1];
202
+ baseBranchArg = cleanArgs[1];
121
203
  }
122
204
  const baseBranch = baseBranchArg || config.github?.pr?.defaultBase || 'develop';
123
- logger.debug('create-pr', 'Base branch determined', {
124
- baseBranch,
125
- fromConfig: !baseBranchArg
126
- });
127
-
128
- // Step 4: Get current branch and repo info
129
- logger.debug('create-pr', 'Step 4: Getting current branch and repo info');
130
- const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
131
- if (!currentBranch) {
132
- logger.error('create-pr', 'Could not determine current branch');
133
- error('Could not determine current branch');
134
- return;
135
- }
136
-
137
- const repoInfo = parseGitHubRepo();
138
205
  logger.debug('create-pr', 'Repository and branch info', {
139
206
  owner: repoInfo.owner,
140
207
  repo: repoInfo.repo,
@@ -159,9 +226,24 @@ export async function runCreatePr(args) {
159
226
  prNumber: existingPR.number,
160
227
  prUrl: existingPR.html_url
161
228
  });
229
+ if (isJSON) {
230
+ _emitJSON({
231
+ status: 'exists',
232
+ prUrl: existingPR.html_url,
233
+ prNumber: existingPR.number,
234
+ head: currentBranch,
235
+ base: baseBranch,
236
+ durationMs: Date.now() - startTime,
237
+ costs: costTracker ? costTracker.toJSON() : null
238
+ });
239
+ process.exit(0);
240
+ return;
241
+ }
162
242
  showWarning(`A PR already exists for this branch: #${existingPR.number}`);
163
- console.log(` ${existingPR.html_url}`);
164
- console.log('');
243
+ if (!isJSON) {
244
+ console.log(` ${existingPR.html_url}`);
245
+ console.log('');
246
+ }
165
247
  return;
166
248
  }
167
249
 
@@ -188,6 +270,10 @@ export async function runCreatePr(args) {
188
270
  branch: currentBranch,
189
271
  upstream: pushStatus.upstreamBranch
190
272
  });
273
+ if (isJSON) {
274
+ _emitErrorJSON(`Branch has diverged from remote. Run: git pull origin ${currentBranch}`);
275
+ return;
276
+ }
191
277
  showError('Branch has diverged from remote');
192
278
  console.log('');
193
279
  console.log('Please resolve conflicts manually:');
@@ -201,6 +287,10 @@ export async function runCreatePr(args) {
201
287
  logger.debug('create-pr', 'Auto-push disabled, aborting', {
202
288
  autoPush: pushConfig?.autoPush
203
289
  });
290
+ if (isJSON) {
291
+ _emitErrorJSON(`Branch not pushed to remote and autoPush is disabled. Run: git push -u origin ${currentBranch}`);
292
+ return;
293
+ }
204
294
  showError('Branch not pushed to remote');
205
295
  console.log('');
206
296
  console.log('Please push your branch first:');
@@ -210,7 +300,7 @@ export async function runCreatePr(args) {
210
300
  }
211
301
 
212
302
  // Show push preview
213
- if (pushConfig?.showCommits && pushStatus.unpushedCommits.length > 0) {
303
+ if (!isJSON && pushConfig?.showCommits && pushStatus.unpushedCommits.length > 0) {
214
304
  console.log('');
215
305
  showInfo('Commits to push:');
216
306
  pushStatus.unpushedCommits.forEach((commit) => {
@@ -218,13 +308,15 @@ export async function runCreatePr(args) {
218
308
  });
219
309
  }
220
310
 
221
- // Confirm with user
311
+ // Decision #2: headless → skip prompt, proceed with push
222
312
  if (pushConfig?.pushConfirm) {
223
- console.log('');
224
- const shouldPush = await promptConfirmation(
225
- `Push branch '${currentBranch}' to remote?`,
226
- true // default yes
227
- );
313
+ if (!isJSON) console.log('');
314
+ const shouldPush = headless
315
+ ? true
316
+ : await promptConfirmation(
317
+ `Push branch '${currentBranch}' to remote?`,
318
+ true
319
+ );
228
320
 
229
321
  if (!shouldPush) {
230
322
  logger.debug('create-pr', 'User declined push, aborting');
@@ -234,7 +326,7 @@ export async function runCreatePr(args) {
234
326
  }
235
327
 
236
328
  // Execute push
237
- console.log('');
329
+ if (!isJSON) console.log('');
238
330
  showInfo('Pushing branch to remote...');
239
331
  logger.debug('create-pr', 'Executing push', {
240
332
  branch: currentBranch,
@@ -250,6 +342,10 @@ export async function runCreatePr(args) {
250
342
  branch: currentBranch,
251
343
  error: pushResult.error
252
344
  });
345
+ if (isJSON) {
346
+ _emitErrorJSON(`Failed to push branch: ${pushResult.error}`);
347
+ return;
348
+ }
253
349
  showError('Failed to push branch');
254
350
  console.error('');
255
351
  console.error(` ${pushResult.error}`);
@@ -257,9 +353,10 @@ export async function runCreatePr(args) {
257
353
  process.exit(1);
258
354
  }
259
355
 
356
+ branchWasPushed = true;
260
357
  logger.debug('create-pr', 'Push completed successfully');
261
358
  showSuccess('Branch pushed successfully');
262
- console.log('');
359
+ if (!isJSON) console.log('');
263
360
  } else {
264
361
  logger.debug('create-pr', 'Branch already up-to-date with remote, skipping push');
265
362
  }
@@ -271,21 +368,28 @@ export async function runCreatePr(args) {
271
368
 
272
369
  if (!versionCheck.aligned) {
273
370
  showWarning('Version misalignment detected:');
274
- console.log('');
275
-
276
- for (const issue of versionCheck.issues) {
277
- console.log(` ${issue.source}: ${issue.version || 'not found'}`);
371
+ if (!isJSON) {
372
+ console.log('');
373
+ for (const issue of versionCheck.issues) {
374
+ console.log(` ${issue.source}: ${issue.version || 'not found'}`);
375
+ }
376
+ console.log('');
377
+ console.log('To fix version alignment:');
378
+ console.log(' claude-hooks bump-version patch --update-changelog');
379
+ console.log('');
278
380
  }
279
381
 
280
- console.log('');
281
- console.log('To fix version alignment:');
282
- console.log(' claude-hooks bump-version patch --update-changelog');
283
- console.log('');
382
+ // Decision #3: headless → continue + log warning
383
+ const shouldContinue = headless
384
+ ? true
385
+ : await promptConfirmation(
386
+ 'Continue creating PR despite version misalignment?',
387
+ false
388
+ );
284
389
 
285
- const shouldContinue = await promptConfirmation(
286
- 'Continue creating PR despite version misalignment?',
287
- false // default no
288
- );
390
+ if (headless) {
391
+ logger.warning('create-pr', 'Continuing despite version misalignment (headless)');
392
+ }
289
393
 
290
394
  if (!shouldContinue) {
291
395
  showInfo('PR creation cancelled - please align versions first');
@@ -295,17 +399,22 @@ export async function runCreatePr(args) {
295
399
 
296
400
  if (versionCheck.aligned && versionCheck.comparison === 'equal') {
297
401
  showWarning('Local version equals remote version');
298
- console.log(` Local: ${versionCheck.localVersion}`);
299
- console.log(` Remote: ${versionCheck.remoteVersion}`);
300
- console.log('');
301
- console.log('To bump version:');
302
- console.log(' claude-hooks bump-version patch # or minor/major');
303
- console.log('');
402
+ if (!isJSON) {
403
+ console.log(` Local: ${versionCheck.localVersion}`);
404
+ console.log(` Remote: ${versionCheck.remoteVersion}`);
405
+ console.log('');
406
+ console.log('To bump version:');
407
+ console.log(' claude-hooks bump-version patch # or minor/major');
408
+ console.log('');
409
+ }
304
410
 
305
- const shouldContinue = await promptConfirmation(
306
- 'Continue creating PR without version bump?',
307
- true // default yes (might be non-release PR)
308
- );
411
+ // Decision #4: headless → continue (matches default 'yes')
412
+ const shouldContinue = headless
413
+ ? true
414
+ : await promptConfirmation(
415
+ 'Continue creating PR without version bump?',
416
+ true
417
+ );
309
418
 
310
419
  if (!shouldContinue) {
311
420
  showInfo('PR creation cancelled');
@@ -315,16 +424,25 @@ export async function runCreatePr(args) {
315
424
 
316
425
  if (versionCheck.aligned && versionCheck.comparison === 'less') {
317
426
  showError('Local version is less than remote version!');
318
- console.log(` Local: ${versionCheck.localVersion}`);
319
- console.log(` Remote: ${versionCheck.remoteVersion}`);
320
- console.log('');
321
- console.log('This usually means you need to pull latest changes:');
322
- console.log(` git pull origin ${baseBranch}`);
323
- console.log('');
427
+ if (!isJSON) {
428
+ console.log(` Local: ${versionCheck.localVersion}`);
429
+ console.log(` Remote: ${versionCheck.remoteVersion}`);
430
+ console.log('');
431
+ console.log('This usually means you need to pull latest changes:');
432
+ console.log(` git pull origin ${baseBranch}`);
433
+ console.log('');
434
+ }
435
+
436
+ // Decision #5: headless → ABORT (dangerous; needs human review)
437
+ if (headless) {
438
+ const msg = `Local version (${versionCheck.localVersion}) is behind remote (${versionCheck.remoteVersion}). Run: git pull origin ${baseBranch}`;
439
+ if (isJSON) { _emitErrorJSON(msg); return; }
440
+ fatal(msg);
441
+ }
324
442
 
325
443
  const shouldContinue = await promptConfirmation(
326
444
  'Continue creating PR despite version being behind?',
327
- false // default no
445
+ false
328
446
  );
329
447
 
330
448
  if (!shouldContinue) {
@@ -362,7 +480,7 @@ export async function runCreatePr(args) {
362
480
  unpushedTags: tagComparison.localNewer
363
481
  });
364
482
 
365
- let shouldPush = false;
483
+ let shouldPushTags = false;
366
484
  let userChoice = null;
367
485
 
368
486
  // Case 1: Local tag > Remote tag → Auto-push
@@ -374,7 +492,7 @@ export async function runCreatePr(args) {
374
492
 
375
493
  showInfo(`Local tag ${latestLocalTag} is newer than remote ${latestRemoteTag}`);
376
494
  showInfo('Auto-pushing tag to remote...');
377
- shouldPush = true;
495
+ shouldPushTags = true;
378
496
 
379
497
  // Case 2: Local tag = Remote tag → Prompt with warning
380
498
  } else if (
@@ -388,27 +506,32 @@ export async function runCreatePr(args) {
388
506
  });
389
507
 
390
508
  showWarning('Local and remote tags have the same version:');
391
- console.log(` Local: ${latestLocalTag} (${localVersion})`);
392
- console.log(` Remote: ${latestRemoteTag} (${remoteVersion})`);
393
- console.log('');
394
- console.log('This might indicate the tag was already pushed.');
395
- console.log('');
509
+ if (!isJSON) {
510
+ console.log(` Local: ${latestLocalTag} (${localVersion})`);
511
+ console.log(` Remote: ${latestRemoteTag} (${remoteVersion})`);
512
+ console.log('');
513
+ console.log('This might indicate the tag was already pushed.');
514
+ console.log('');
515
+ }
396
516
 
397
- userChoice = await promptMenu(
398
- 'What would you like to do?',
399
- [
400
- { key: 'c', label: 'Continue without pushing' },
401
- { key: 'p', label: 'Force push tag' },
402
- { key: 'a', label: 'Abort PR creation' }
403
- ],
404
- 'c'
405
- );
517
+ // Decision #6: headless → continue without push (default 'c')
518
+ userChoice = headless
519
+ ? 'c'
520
+ : await promptMenu(
521
+ 'What would you like to do?',
522
+ [
523
+ { key: 'c', label: 'Continue without pushing' },
524
+ { key: 'p', label: 'Force push tag' },
525
+ { key: 'a', label: 'Abort PR creation' }
526
+ ],
527
+ 'c'
528
+ );
406
529
 
407
530
  if (userChoice === 'a') {
408
531
  showInfo('PR creation cancelled');
409
532
  process.exit(0);
410
533
  } else if (userChoice === 'p') {
411
- shouldPush = true;
534
+ shouldPushTags = true;
412
535
  }
413
536
 
414
537
  // Case 3: Local tag < Remote tag → Prompt with error
@@ -423,12 +546,21 @@ export async function runCreatePr(args) {
423
546
  });
424
547
 
425
548
  showError('Local tag is older than remote tag:');
426
- console.log(` Local: ${latestLocalTag} (${localVersion})`);
427
- console.log(` Remote: ${latestRemoteTag} (${remoteVersion})`);
428
- console.log('');
429
- console.log('This usually means you need to pull latest tags:');
430
- console.log(' git fetch --tags');
431
- console.log('');
549
+ if (!isJSON) {
550
+ console.log(` Local: ${latestLocalTag} (${localVersion})`);
551
+ console.log(` Remote: ${latestRemoteTag} (${remoteVersion})`);
552
+ console.log('');
553
+ console.log('This usually means you need to pull latest tags:');
554
+ console.log(' git fetch --tags');
555
+ console.log('');
556
+ }
557
+
558
+ // Decision #7: headless → ABORT
559
+ if (headless) {
560
+ const msg = `Local tag (${localVersion}) is older than remote (${remoteVersion}). Run: git fetch --tags`;
561
+ if (isJSON) { _emitErrorJSON(msg); return; }
562
+ fatal(msg);
563
+ }
432
564
 
433
565
  userChoice = await promptMenu(
434
566
  'What would you like to do?',
@@ -454,49 +586,62 @@ export async function runCreatePr(args) {
454
586
  });
455
587
 
456
588
  showWarning('Unable to compare tag versions:');
457
- console.log(` Local tag: ${latestLocalTag || 'none'}`);
458
- console.log(` Remote tag: ${latestRemoteTag || 'none'}`);
459
- console.log('');
460
- console.log('Unpushed tags:', tagComparison.localNewer.join(', '));
461
- console.log('');
589
+ if (!isJSON) {
590
+ console.log(` Local tag: ${latestLocalTag || 'none'}`);
591
+ console.log(` Remote tag: ${latestRemoteTag || 'none'}`);
592
+ console.log('');
593
+ console.log('Unpushed tags:', tagComparison.localNewer.join(', '));
594
+ console.log('');
595
+ }
462
596
 
463
- userChoice = await promptMenu(
464
- 'What would you like to do?',
465
- [
466
- { key: 'p', label: 'Push tags to remote' },
467
- { key: 'c', label: 'Continue without pushing' },
468
- { key: 'a', label: 'Abort PR creation' }
469
- ],
470
- 'p'
471
- );
597
+ // Decision #8: headless → continue without push
598
+ userChoice = headless
599
+ ? 'c'
600
+ : await promptMenu(
601
+ 'What would you like to do?',
602
+ [
603
+ { key: 'p', label: 'Push tags to remote' },
604
+ { key: 'c', label: 'Continue without pushing' },
605
+ { key: 'a', label: 'Abort PR creation' }
606
+ ],
607
+ 'p'
608
+ );
472
609
 
473
610
  if (userChoice === 'a') {
474
611
  showInfo('PR creation cancelled');
475
612
  process.exit(0);
476
613
  } else if (userChoice === 'p') {
477
- shouldPush = true;
614
+ shouldPushTags = true;
478
615
  }
479
616
  }
480
617
 
481
- // Execute push if decided
482
- if (shouldPush) {
483
- console.log('');
618
+ // Execute tag push if decided
619
+ if (shouldPushTags) {
620
+ if (!isJSON) console.log('');
484
621
  showInfo('Pushing tags to remote...');
485
622
 
486
623
  const pushResult = pushTagsUtil(null, tagComparison.localNewer);
487
624
 
488
625
  if (pushResult.success) {
626
+ pushedTags = tagComparison.localNewer;
489
627
  showSuccess('✓ Tags pushed successfully');
490
628
  logger.debug('create-pr', 'Tags pushed', {
491
629
  pushed: tagComparison.localNewer
492
630
  });
493
631
  } else {
494
632
  showError(`Failed to push some tags: ${pushResult.error}`);
495
- if (pushResult.failed.length > 0) {
633
+ if (!isJSON && pushResult.failed.length > 0) {
496
634
  console.log('Failed tags:');
497
635
  pushResult.failed.forEach((f) => console.log(` - ${f.tag}: ${f.error}`));
498
636
  }
499
637
 
638
+ // Decision #9: headless → ABORT
639
+ if (headless) {
640
+ const msg = `Failed to push tags: ${pushResult.error}`;
641
+ if (isJSON) { _emitErrorJSON(msg); return; }
642
+ fatal(msg);
643
+ }
644
+
500
645
  const shouldContinue = await promptConfirmation(
501
646
  'Continue creating PR despite push failure?',
502
647
  false
@@ -508,13 +653,12 @@ export async function runCreatePr(args) {
508
653
  }
509
654
  }
510
655
 
511
- console.log('');
656
+ if (!isJSON) console.log('');
512
657
  } else if (userChoice !== 'c') {
513
- // Auto-push failed or user chose to continue
514
658
  logger.debug('create-pr', 'Tag push skipped', {
515
659
  reason: 'user choice or condition'
516
660
  });
517
- console.log('');
661
+ if (!isJSON) console.log('');
518
662
  }
519
663
  } else {
520
664
  logger.debug('create-pr', 'No unpushed tags found, continuing');
@@ -529,11 +673,17 @@ export async function runCreatePr(args) {
529
673
  result: analysisResult,
530
674
  error: engineError
531
675
  } = await analyzeBranchForPR(baseBranch, {
532
- hook: 'create-pr'
676
+ hook: 'create-pr',
677
+ headless,
678
+ costTracker
533
679
  });
534
680
 
535
681
  if (!engineSuccess) {
536
682
  logger.error('create-pr', 'Failed to generate PR metadata', { error: engineError });
683
+ if (isJSON) {
684
+ _emitErrorJSON(engineError || 'Failed to generate PR metadata');
685
+ return;
686
+ }
537
687
  error(engineError || 'Failed to generate PR metadata');
538
688
  return;
539
689
  }
@@ -568,18 +718,25 @@ export async function runCreatePr(args) {
568
718
 
569
719
  if (mergeStrategy === 'unknown') {
570
720
  showWarning('Unknown branch pattern — cannot auto-detect merge strategy');
571
- console.log('');
572
- const strategyChoice = await promptMenu(
573
- 'Select merge strategy:',
574
- [
575
- { key: 's', label: 'Squash merge' },
576
- { key: 'm', label: 'Merge commit' },
577
- { key: 'k', label: 'Skip (no strategy label)' }
578
- ],
579
- 'k'
580
- );
581
- if (strategyChoice === 's') mergeStrategy = 'squash';
582
- else if (strategyChoice === 'm') mergeStrategy = 'merge-commit';
721
+
722
+ // Decision #10: headless → default to 'squash'
723
+ if (headless) {
724
+ mergeStrategy = 'squash';
725
+ logger.warning('create-pr', 'Defaulting to squash merge strategy (headless)');
726
+ } else {
727
+ if (!isJSON) console.log('');
728
+ const strategyChoice = await promptMenu(
729
+ 'Select merge strategy:',
730
+ [
731
+ { key: 's', label: 'Squash merge' },
732
+ { key: 'm', label: 'Merge commit' },
733
+ { key: 'k', label: 'Skip (no strategy label)' }
734
+ ],
735
+ 'k'
736
+ );
737
+ if (strategyChoice === 's') mergeStrategy = 'squash';
738
+ else if (strategyChoice === 'm') mergeStrategy = 'merge-commit';
739
+ }
583
740
  }
584
741
 
585
742
  // Step 9.5: Resolve labels
@@ -633,7 +790,7 @@ export async function runCreatePr(args) {
633
790
  reviewers
634
791
  };
635
792
 
636
- showPRPreview(prData);
793
+ if (!isJSON) showPRPreview(prData);
637
794
 
638
795
  let strategyLabel;
639
796
  if (mergeStrategy === 'squash') {
@@ -646,14 +803,17 @@ export async function runCreatePr(args) {
646
803
  showInfo(`Merge strategy: ${strategyLabel} (${currentBranch} → ${baseBranch})`);
647
804
 
648
805
  // Step 12: Prompt for confirmation
649
- const action = await promptMenu(
650
- 'What would you like to do?',
651
- [
652
- { key: 'c', label: 'Create PR' },
653
- { key: 'x', label: 'Cancel' }
654
- ],
655
- 'c'
656
- );
806
+ // Decision #11: headless → always create
807
+ const action = headless
808
+ ? 'c'
809
+ : await promptMenu(
810
+ 'What would you like to do?',
811
+ [
812
+ { key: 'c', label: 'Create PR' },
813
+ { key: 'x', label: 'Cancel' }
814
+ ],
815
+ 'c'
816
+ );
657
817
 
658
818
  if (action === 'x') {
659
819
  showInfo('PR creation cancelled');
@@ -710,6 +870,29 @@ export async function runCreatePr(args) {
710
870
  prNumber: result.number
711
871
  }).catch(() => {});
712
872
 
873
+ if (isJSON) {
874
+ _emitJSON({
875
+ status: 'created',
876
+ prUrl: result.html_url,
877
+ prNumber: result.number,
878
+ title: prData.title,
879
+ body: prData.body,
880
+ head: prData.head,
881
+ base: prData.base,
882
+ mergeStrategy,
883
+ labels: prData.labels,
884
+ reviewers: prData.reviewers,
885
+ taskId: taskId || null,
886
+ pushed: branchWasPushed,
887
+ tagsPushed: pushedTags,
888
+ fileCount: filesArray.length,
889
+ durationMs: Date.now() - startTime,
890
+ costs: costTracker ? costTracker.toJSON() : null
891
+ });
892
+ process.exit(0);
893
+ return;
894
+ }
895
+
713
896
  console.log('');
714
897
  showSuccess('Pull request created successfully!');
715
898
  console.log('');
@@ -724,6 +907,21 @@ export async function runCreatePr(args) {
724
907
  }
725
908
  } catch (apiError) {
726
909
  logger.error('create-pr', 'Failed to create pull request', apiError);
910
+
911
+ if (isJSON) {
912
+ _emitJSON({
913
+ status: 'error',
914
+ error: apiError.message,
915
+ errorName: apiError.name || 'Error',
916
+ head: prData.head,
917
+ base: prData.base,
918
+ durationMs: Date.now() - startTime,
919
+ costs: costTracker ? costTracker.toJSON() : null
920
+ });
921
+ process.exit(1);
922
+ return;
923
+ }
924
+
727
925
  showError('Failed to create pull request');
728
926
  console.error('');
729
927
  console.error(` ${apiError.message}`);
@@ -761,6 +959,19 @@ export async function runCreatePr(args) {
761
959
  }
762
960
  } catch (err) {
763
961
  logger.error('create-pr', 'Error creating PR', err);
962
+
963
+ if (isJSON) {
964
+ _emitJSON({
965
+ status: 'error',
966
+ error: err.message,
967
+ errorName: err.name || 'Error',
968
+ durationMs: Date.now() - startTime,
969
+ costs: costTracker ? costTracker.toJSON() : null
970
+ });
971
+ process.exit(1);
972
+ return;
973
+ }
974
+
764
975
  showError(`Error creating PR: ${err.message}`);
765
976
 
766
977
  if (err.context) {