-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathcommit-session-await-ci-status.ts
More file actions
1448 lines (1265 loc) · 47.5 KB
/
Copy pathcommit-session-await-ci-status.ts
File metadata and controls
1448 lines (1265 loc) · 47.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Unified Stop hook: Auto-commit, PR status check, CI waiting, and agent communication
*
* This hook performs four main functions at session end:
*
* 1. **Blocking validation checks** - Ensures clean git state:
* - Merge conflicts detection
* - Branch sync status (behind remote)
* - Claude settings validation
* - Hook file existence checks
*
* 2. **Auto-commit** - Preserves work in progress:
* - Automatically commits any uncommitted changes
* - Adds session metadata to commit message
* - Tracks commit SHA for one-time blocking
*
* 3. **Agent communication** - First-time blocking on new commits:
* - Blocks ONCE when new commits are detected without a PR
* - Tracks lastSeenCommitSha to only block on first sight of commits
* - Subsequent stops show informational message but don't block
* - Resets when PR created or progress documented via comment
*
* 4. **PR status reporting and CI waiting** - Provides PR visibility and ensures quality:
* - Checks if PR exists for current branch
* - **Waits for all CI checks to complete** (including Vercel, Supabase integrations)
* - **Blocks if any CI check fails** (10-minute timeout)
* - Fetches latest CI run status and link
* - Extracts Vercel preview URLs (web and marketing apps)
* - Detects subagent activity to skip instructions intelligently
*
* **Session state tracking:**
* - State stored in `.claude/logs/session-stops.json`
* - Tracks block count per session
* - Tracks whether progress has been documented
*
* **GitHub comment integration:**
* - Detects comments with session ID markers
* - Discovers linked issues from branch context
* - Accepts progress documentation as alternative to PR
* @module commit-session-await-status
*/
import type { StopInput, StopHookOutput } from '../shared/types/types.js';
import { createDebugLogger } from '../shared/hooks/utils/debug.js';
import { runHook } from '../shared/hooks/utils/io.js';
import { getSessionStopState, updateSessionStopState, resetSessionStopState } from '../shared/hooks/utils/session-state.js';
import { hasCommentForSession, getLinkedIssueNumber } from '../shared/hooks/utils/github-comments.js';
import {
saveOutputToLog,
formatCiChecksTable,
} from '../shared/hooks/utils/log-file.js';
import {
awaitCIWithFailFast,
getLatestCIRun as getCIRunDetails,
extractAllPreviews,
extractLinkedIssuesWithInfo,
type GroupedPreviewUrls,
type LinkedIssueInfo,
} from '../shared/hooks/utils/ci-status.js';
import { getSessionIssues, type IssueReference } from '../shared/hooks/utils/session-issues.js';
import { addPRToState } from '../shared/hooks/utils/github-state.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const execAsync = promisify(exec);
interface BranchIssueEntry {
issueNumber: number;
issueUrl: string;
createdAt: string;
createdFromPrompt: boolean;
linkedFromBranchPrefix?: boolean;
}
interface BranchIssueState {
[branchName: string]: BranchIssueEntry;
}
/**
* Load branch issue state from disk
* @param cwd - Working directory
* @returns Branch issue state object
*/
async function loadBranchIssueState(cwd: string): Promise<BranchIssueState> {
const stateFile = join(cwd, '.claude', 'logs', 'branch-issues.json');
try {
if (!existsSync(stateFile)) {
return {};
}
const data = readFileSync(stateFile, 'utf-8');
return JSON.parse(data);
} catch {
return {};
}
}
/**
* Get issue info for a branch from branch-issues.json
* @param branch - Branch name
* @param cwd - Working directory
* @returns Issue info or null
*/
async function getBranchIssueInfo(
branch: string,
cwd: string
): Promise<{ issueNumber: number; issueUrl: string } | null> {
const state = await loadBranchIssueState(cwd);
if (state[branch]) {
return {
issueNumber: state[branch].issueNumber,
issueUrl: state[branch].issueUrl,
};
}
return null;
}
// ============================================================================
// Command Execution
// ============================================================================
/**
* Execute a shell command and return the result
* @param command - Shell command to execute
* @param cwd - Working directory
* @returns Command result with success flag, stdout, and stderr
* @example
*/
async function execCommand(
command: string,
cwd: string
): Promise<{ success: boolean; stdout: string; stderr: string }> {
try {
const { stdout, stderr } = await execAsync(command, { cwd, timeout: 30000 });
return { success: true, stdout: stdout.trim(), stderr: stderr.trim() };
} catch (error: unknown) {
const err = error as { stdout?: string; stderr?: string; message?: string };
return {
success: false,
stdout: err.stdout?.trim() || '',
stderr: err.stderr?.trim() || err.message || '',
};
}
}
// ============================================================================
// Git State Checks
// ============================================================================
/**
* Check if there are uncommitted changes in the working directory
* Filters out gitignored files - only returns true for tracked/untracked non-ignored files
* @param cwd - Working directory
* @returns True if there are non-gitignored uncommitted changes
* @example
*/
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
const result = await execCommand('git status --porcelain', cwd);
if (!result.success || !result.stdout) {
return false;
}
// Filter out gitignored files
const lines = result.stdout.split('\n').filter(Boolean);
for (const line of lines) {
// Git porcelain format: XY<space>filename (XY = 2 status chars)
// But stdout.trim() may have removed a leading space from " M filename"
// Detect by checking if position 2 is a space (not trimmed) or not (trimmed)
const pathStart = (line.length >= 3 && line[2] === ' ') ? 3 : 2;
const filePath = line.slice(pathStart).split(' -> ')[0];
// Check if file is gitignored
const ignoreCheck = await execCommand(`git check-ignore -q "${filePath}"`, cwd);
if (!ignoreCheck.success) {
// File is NOT ignored - we have real uncommitted changes
return true;
}
}
// All files were gitignored
return false;
}
/**
* Get list of non-gitignored uncommitted files for staging
* @param cwd - Working directory
* @returns List of file paths to stage
* @example
*/
async function getNonIgnoredChanges(cwd: string): Promise<string[]> {
const result = await execCommand('git status --porcelain', cwd);
if (!result.success || !result.stdout) {
return [];
}
const nonIgnoredFiles: string[] = [];
const lines = result.stdout.split('\n').filter(Boolean);
for (const line of lines) {
// Git porcelain format: XY<space>filename (XY = 2 status chars)
// But stdout.trim() may have removed a leading space from " M filename"
// Detect by checking if position 2 is a space (not trimmed) or not (trimmed)
const pathStart = (line.length >= 3 && line[2] === ' ') ? 3 : 2;
const filePath = line.slice(pathStart).split(' -> ')[0];
const ignoreCheck = await execCommand(`git check-ignore -q "${filePath}"`, cwd);
if (!ignoreCheck.success) {
nonIgnoredFiles.push(filePath);
}
}
return nonIgnoredFiles;
}
/**
* Get current git branch name
* @param cwd - Working directory
* @returns Branch name or null if detached HEAD
* @example
*/
async function getCurrentBranch(cwd: string): Promise<string | null> {
const result = await execCommand('git rev-parse --abbrev-ref HEAD', cwd);
return result.success ? result.stdout : null;
}
/**
* Get current HEAD commit SHA
* @param cwd - Working directory
* @returns Full commit SHA or null if not in git repo
* @example
*/
async function getCurrentHeadSha(cwd: string): Promise<string | null> {
const result = await execCommand('git rev-parse HEAD', cwd);
return result.success ? result.stdout : null;
}
/**
* Get git repository root directory
* Normalizes cwd to repo root to ensure git commands work correctly
* even when hook is invoked from a subdirectory (e.g., subagent in apps/web/)
* @param cwd - Working directory (may be subdirectory)
* @returns Repository root path, or original cwd if not in a git repo
* @example
*/
async function getRepoRoot(cwd: string): Promise<string> {
const result = await execCommand('git rev-parse --show-toplevel', cwd);
return result.success ? result.stdout : cwd;
}
/**
* Check if there are merge conflicts in the working directory
* @param cwd - Working directory
* @returns Object with conflict status and list of conflicted files
* @example
*/
async function checkMergeConflicts(cwd: string): Promise<{
hasConflicts: boolean;
conflictedFiles: string[];
}> {
// Check git status for unmerged paths
const unmergedResult = await execCommand('git ls-files --unmerged', cwd);
const hasUnmerged = unmergedResult.stdout.length > 0;
// Get list of conflicted files
const conflictFilesResult = await execCommand('git diff --name-only --diff-filter=U', cwd);
const conflictedFiles = conflictFilesResult.stdout
? conflictFilesResult.stdout.split('\n').filter(Boolean)
: [];
return {
hasConflicts: hasUnmerged || conflictedFiles.length > 0,
conflictedFiles,
};
}
/**
* Check if branch is up to date with remote
* @param cwd - Working directory
* @returns Object with sync status, commits behind/ahead, and remote branch name
* @example
*/
async function checkBranchSync(cwd: string): Promise<{
isSynced: boolean;
behindBy: number;
aheadBy: number;
remoteBranch: string;
}> {
// Get current branch
const branchResult = await execCommand('git branch --show-current', cwd);
const currentBranch = branchResult.stdout;
if (!currentBranch) {
return {
isSynced: true,
behindBy: 0,
aheadBy: 0,
remoteBranch: '',
};
}
// Fetch latest from remote
await execCommand('git fetch', cwd);
// Get tracking branch
const trackingResult = await execCommand(
`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`,
cwd
);
if (!trackingResult.success) {
// No tracking branch set up
return {
isSynced: true,
behindBy: 0,
aheadBy: 0,
remoteBranch: '',
};
}
const remoteBranch = trackingResult.stdout;
// Check how many commits behind/ahead we are
const revListResult = await execCommand(
`git rev-list --left-right --count ${currentBranch}...${remoteBranch}`,
cwd
);
if (!revListResult.success) {
return {
isSynced: true,
behindBy: 0,
aheadBy: 0,
remoteBranch,
};
}
// Parse output: "ahead\tbehind"
const [aheadStr, behindStr] = revListResult.stdout.split('\t');
const aheadBy = parseInt(aheadStr || '0', 10);
const behindBy = parseInt(behindStr || '0', 10);
return {
isSynced: behindBy === 0,
behindBy,
aheadBy,
remoteBranch,
};
}
/**
* Get number of commits ahead of origin/main (or origin/master)
* Used to determine if a PR is needed (separate from sync check)
*/
async function getCommitsAheadOfMain(cwd: string): Promise<number> {
// Try origin/main first, then origin/master
for (const mainBranch of ['origin/main', 'origin/master']) {
const result = await execCommand(
`git rev-list --count ${mainBranch}..HEAD`,
cwd
);
if (result.success) {
return parseInt(result.stdout || '0', 10);
}
}
return 0;
}
// ============================================================================
// GitHub CLI Operations
// ============================================================================
/**
* Check if a PR exists for the current branch
*
* Uses GitHub CLI to query for existing PRs where the head branch
* matches the current branch name.
* @param branch - Current branch name
* @param cwd - Working directory
* @returns Object with PR existence status, number, URL, or error
* @example
*/
async function checkPRExists(
branch: string,
cwd: string
): Promise<{
exists: boolean;
prNumber?: number;
prUrl?: string;
error?: string;
}> {
// Check if gh CLI is available
const ghCheck = await execCommand('gh --version', cwd);
if (!ghCheck.success) {
return {
exists: false,
error: 'GitHub CLI not installed'
};
}
// Check if gh is authenticated
const authCheck = await execCommand('gh auth status', cwd);
if (!authCheck.success) {
return {
exists: false,
error: 'GitHub CLI not authenticated'
};
}
// List PRs for current branch
const prListResult = await execCommand(
`gh pr list --head ${branch} --json number,url --limit 1`,
cwd
);
if (!prListResult.success) {
return {
exists: false,
error: `gh pr list failed: ${prListResult.stderr}`
};
}
// Parse JSON output
try {
const prs = JSON.parse(prListResult.stdout);
if (Array.isArray(prs) && prs.length > 0) {
return {
exists: true,
prNumber: prs[0].number,
prUrl: prs[0].url,
};
}
return { exists: false };
} catch (parseError) {
return {
exists: false,
error: `Failed to parse gh output: ${parseError}`
};
}
}
// Local CI functions removed - using shared utilities from ci-status.ts:
// - getLatestCIRun -> getCIRunDetails
// - getVercelPreviewUrls -> extractPreviewUrls
// - waitForCIChecks -> awaitCIWithFailFast
// ============================================================================
// Subagent Activity Detection
// ============================================================================
/**
* Check if there has been recent subagent activity
*
* Detects if a subagent just stopped and may be awaiting user input.
* If true, skips PR encouragement to avoid interrupting workflow.
* @param cwd - Working directory
* @returns True if recent subagent activity detected
* @example
*/
async function hasRecentSubagentActivity(cwd: string): Promise<boolean> {
const tasksFilePath = join(cwd, '.claude', 'logs', 'subagent-tasks.json');
try {
if (!existsSync(tasksFilePath)) {
return false;
}
const content = readFileSync(tasksFilePath, 'utf-8');
const tasks = JSON.parse(content);
// If any subagent contexts exist, there's recent subagent activity
return Object.keys(tasks).length > 0;
} catch {
return false;
}
}
// ============================================================================
// Validation Checks
// ============================================================================
/**
* Run claude doctor to check for settings issues
*
* Executes `claude doctor` command and parses output for errors.
* @param cwd - Working directory
* @returns Object with health status and any issues found
* @example
*/
async function checkClaudeDoctor(cwd: string): Promise<{
healthy: boolean;
issues: string[];
error?: string;
}> {
// Check if claude CLI is available
const claudeCheck = await execCommand('claude --version', cwd);
if (!claudeCheck.success) {
return {
healthy: true, // Don't block if claude not available
issues: [],
error: 'Claude CLI not available'
};
}
// Run claude doctor
const doctorResult = await execCommand('claude doctor --json 2>&1', cwd);
// Check for known non-settings errors first
const knownNonSettingsErrors = [
'Raw mode is not supported',
'isRawModeSupported',
'Ink',
'Command failed: claude doctor',
];
const errorText = doctorResult.stderr || doctorResult.stdout || '';
const isNonSettingsError = knownNonSettingsErrors.some(
pattern => errorText.includes(pattern)
);
if (!doctorResult.success && isNonSettingsError) {
// Terminal/UI error, not a settings issue
return {
healthy: true,
issues: [],
error: 'Claude doctor failed due to terminal limitations (non-blocking)'
};
}
// Parse output
try {
// Try to parse as JSON first
if (doctorResult.stdout) {
const doctorOutput = JSON.parse(doctorResult.stdout);
// Check for errors or warnings in output
const issues: string[] = [];
if (doctorOutput.errors && Array.isArray(doctorOutput.errors)) {
issues.push(...doctorOutput.errors);
}
if (doctorOutput.warnings && Array.isArray(doctorOutput.warnings)) {
issues.push(...doctorOutput.warnings);
}
return {
healthy: issues.length === 0,
issues
};
}
// If no JSON output, check exit code
if (!doctorResult.success && !isNonSettingsError) {
return {
healthy: false,
issues: [doctorResult.stderr || 'Unknown error']
};
}
return {
healthy: true,
issues: []
};
} catch {
// If JSON parsing fails, check exit code
if (!doctorResult.success && !isNonSettingsError) {
return {
healthy: false,
issues: [doctorResult.stderr || doctorResult.stdout || 'Claude doctor failed']
};
}
return {
healthy: true,
issues: []
};
}
}
/**
* Validate all registered hooks point to real files
*
* Checks both plugin hooks and .claude/hooks directory for missing files.
* @param cwd - Working directory
* @returns Object with validation status and missing files
* @example
*/
async function validateHookFiles(cwd: string): Promise<{
valid: boolean;
missingFiles: string[];
error?: string;
}> {
const missingFiles: string[] = [];
try {
// Check .claude/settings.json for enabled plugins
const settingsPath = join(cwd, '.claude', 'settings.json');
if (!existsSync(settingsPath)) {
// No settings file, skip validation
return { valid: true, missingFiles: [] };
}
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
const enabledPlugins = settings.enabledPlugins || {};
// For each enabled plugin, check hooks
for (const [pluginName, enabled] of Object.entries(enabledPlugins)) {
if (!enabled) continue;
// Try to find plugin in cache
const pluginCachePath = join(
process.env.HOME || '/home',
'.claude',
'plugins',
'cache',
pluginName.replace('@', '/'),
'hooks',
'hooks.json'
);
if (existsSync(pluginCachePath)) {
const hooksConfig = JSON.parse(readFileSync(pluginCachePath, 'utf-8'));
if (hooksConfig.hooks) {
// Validate hook files
for (const eventHooks of Object.values(hooksConfig.hooks)) {
if (!Array.isArray(eventHooks)) continue;
for (const hookGroup of eventHooks) {
if (!hookGroup.hooks) continue;
for (const hook of hookGroup.hooks) {
if (hook.type === 'command' && hook.command) {
// Extract file path from command (remove npx tsx and ${CLAUDE_PLUGIN_ROOT})
const commandMatch = hook.command.match(/\$\{CLAUDE_PLUGIN_ROOT\}\/(.+)$/);
if (commandMatch) {
const hookFile = commandMatch[1];
const pluginDir = pluginCachePath.replace('/hooks/hooks.json', '');
const hookPath = join(pluginDir, hookFile);
if (!existsSync(hookPath)) {
missingFiles.push(`${pluginName}: ${hookFile}`);
}
}
}
}
}
}
}
}
}
// Check local .claude/hooks directory
const localHooksDir = join(cwd, '.claude', 'hooks');
if (existsSync(localHooksDir)) {
const localHooksJson = join(localHooksDir, 'hooks.json');
if (existsSync(localHooksJson)) {
const localHooksConfig = JSON.parse(readFileSync(localHooksJson, 'utf-8'));
if (localHooksConfig.hooks) {
for (const eventHooks of Object.values(localHooksConfig.hooks)) {
if (!Array.isArray(eventHooks)) continue;
for (const hookGroup of eventHooks) {
if (!hookGroup.hooks) continue;
for (const hook of hookGroup.hooks) {
if (hook.type === 'command' && hook.command) {
// For local hooks, files should be relative to .claude/hooks
const commandMatch = hook.command.match(/hooks\/(.+\.ts)$/);
if (commandMatch) {
const hookFile = commandMatch[1];
const hookPath = join(localHooksDir, hookFile);
if (!existsSync(hookPath)) {
missingFiles.push(`.claude/hooks: ${hookFile}`);
}
}
}
}
}
}
}
}
}
return {
valid: missingFiles.length === 0,
missingFiles
};
} catch (error) {
return {
valid: true, // Don't block on validation errors
missingFiles: [],
error: `Hook validation error: ${error}`
};
}
}
// ============================================================================
// Output Formatting
// ============================================================================
/**
* Format commit message with session metadata
* @param sessionId - Session ID
* @param branch - Current branch name
* @returns Formatted commit message
* @example
*/
function formatCommitMessage(sessionId: string, branch: string | null): string {
const timestamp = new Date().toISOString();
return `Session work
Auto-commit at session end to preserve work in progress.
Session-ID: ${sessionId}
Session-Timestamp: ${timestamp}${branch ? `\nBranch: ${branch}` : ''}
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`;
}
/**
* Format a linked issue for display
* @param issue - Issue info with number, url, and repo
* @param prRepo - PR's repository for comparison
* @returns Formatted issue string
*/
function formatLinkedIssue(issue: LinkedIssueInfo, prRepo: string): string {
// If same repo as PR, show just #number
// If different repo, show owner/repo#number
const prefix = issue.repo === prRepo ? `#${issue.number}` : `${issue.repo}#${issue.number}`;
const titlePart = issue.title ? ` - ${issue.title}` : '';
return `${prefix}${titlePart} → ${issue.url}`;
}
/**
* Format a session issue for display (other issues section)
* @param issue - Issue reference from session tracking
* @param currentRepo - Current repository for comparison
* @returns Formatted issue string
*/
function formatSessionIssue(issue: IssueReference, currentRepo: string): string {
// If same repo, show just #number; if different, show owner/repo#number
const prefix = issue.repo === currentRepo ? `#${issue.number}` : `${issue.repo}#${issue.number}`;
const titlePart = issue.title ? ` - ${issue.title}` : '';
return `${prefix}${titlePart} → ${issue.url}`;
}
/**
* Format PR status message when commit was made and PR exists
* @param commitSha - Commit SHA
* @param prCheck - PR details
* @param prCheck.prNumber - PR number
* @param prCheck.prUrl - PR URL
* @param ciRun - CI run details
* @param ciRun.url - CI run URL
* @param ciRun.status - CI run status
* @param ciRun.conclusion - CI run conclusion
* @param ciRun.name - CI run name
* @param groupedPreviews - Preview URLs grouped by provider
* @param linkedIssues - Issues linked from PR body (closes when merged)
* @param otherIssues - Other issues created during session but not linked to PR
* @param prRepo - PR's repository name (owner/repo)
* @returns Formatted message
* @example
*/
function formatPRStatusWithCommit(
commitSha: string,
prCheck: { prNumber: number; prUrl: string },
ciRun: { url?: string; status?: string; conclusion?: string; name?: string },
groupedPreviews: GroupedPreviewUrls,
linkedIssues: LinkedIssueInfo[],
otherIssues: IssueReference[],
prRepo: string
): string {
const ciPassed = ciRun.conclusion === 'success';
const ciFailed = ciRun.conclusion === 'failure';
let message = `✅ Auto-committed: ${commitSha}\n\n`;
// PR link (prominently displayed)
message += `📋 PR: ${prCheck.prUrl}\n`;
// CI run link (prominently displayed)
if (ciRun.url) {
const statusIcon = ciPassed ? '✅' : ciFailed ? '❌' : '⏳';
message += `🔄 CI: ${ciRun.url} ${statusIcon} ${ciRun.conclusion || ciRun.status || 'pending'}\n`;
}
// Linked issues (closes when merged) - nested under PR
if (linkedIssues.length > 0) {
message += '\n 📌 Linked Issues (closes when merged):\n';
for (const issue of linkedIssues) {
message += ` • ${formatLinkedIssue(issue, prRepo)}\n`;
}
}
// Other issues created during session (not linked to PR)
if (otherIssues.length > 0) {
message += '\n📝 Other Issues Created:\n';
for (const issue of otherIssues) {
message += ` • ${formatSessionIssue(issue, prRepo)}\n`;
}
}
// Vercel Previews
if (groupedPreviews.vercel.length > 0) {
message += '\n🔼 Vercel Previews:\n';
for (const url of groupedPreviews.vercel) {
message += ` • ${url}\n`;
}
}
// Cloudflare Previews
if (groupedPreviews.cloudflare.length > 0) {
message += '\n☁️ Cloudflare Worker Previews:\n';
for (const url of groupedPreviews.cloudflare) {
message += ` • ${url}\n`;
}
}
// Supabase Previews
if (groupedPreviews.supabase.length > 0) {
message += '\n⚡ Supabase Preview Branches:\n';
for (const url of groupedPreviews.supabase) {
message += ` • ${url}\n`;
}
}
message += '\nPress enter to continue.';
return message;
}
/**
* Format PR status info message
* @param prCheck - PR details
* @param prCheck.prNumber - PR number
* @param prCheck.prUrl - PR URL
* @param ciRun - CI run details
* @param ciRun.url - CI run URL
* @param ciRun.status - CI run status
* @param ciRun.conclusion - CI run conclusion
* @param ciRun.name - CI run name
* @param groupedPreviews - Preview URLs grouped by provider
* @param linkedIssues - Issues linked from PR body (closes when merged)
* @param otherIssues - Other issues created during session but not linked to PR
* @param prRepo - PR's repository name (owner/repo)
* @returns Formatted message
* @example
*/
function formatPRStatusInfo(
prCheck: { prNumber: number; prUrl: string },
ciRun: { url?: string; status?: string; conclusion?: string; name?: string },
groupedPreviews: GroupedPreviewUrls,
linkedIssues: LinkedIssueInfo[],
otherIssues: IssueReference[],
prRepo: string
): string {
// Header (simplified - this function only called when CI passed or pending)
let message = '✅ PR Ready for Review\n\n';
// PR link with markdown format (clickable)
message += `📋 [View PR #${prCheck.prNumber}](${prCheck.prUrl})\n`;
// CI run link with markdown format
if (ciRun.url) {
message += `🔄 [View CI Run](${ciRun.url}) ✅ success\n`;
}
// Linked issues (closes when merged) - nested under PR
if (linkedIssues.length > 0) {
message += '\n 📌 Linked Issues (closes when merged):\n';
for (const issue of linkedIssues) {
message += ` • ${formatLinkedIssue(issue, prRepo)}\n`;
}
}
// Other issues created during session (not linked to PR)
if (otherIssues.length > 0) {
message += '\n📝 Other Issues Created:\n';
for (const issue of otherIssues) {
message += ` • ${formatSessionIssue(issue, prRepo)}\n`;
}
}
// Vercel Previews
if (groupedPreviews.vercel.length > 0) {
message += '\n🔼 Vercel Previews:\n';
for (const url of groupedPreviews.vercel) {
message += ` • ${url}\n`;
}
}
// Cloudflare Previews
if (groupedPreviews.cloudflare.length > 0) {
message += '\n☁️ Cloudflare Worker Previews:\n';
for (const url of groupedPreviews.cloudflare) {
message += ` • ${url}\n`;
}
}
// Supabase Previews
if (groupedPreviews.supabase.length > 0) {
message += '\n⚡ Supabase Preview Branches:\n';
for (const url of groupedPreviews.supabase) {
message += ` • ${url}\n`;
}
}
message += '\nPress enter to continue.';
return message;
}
/**
* Format blocking error messages for various checks
* @param conflictedFiles - List of files with merge conflicts
* @returns Formatted error message
* @example
*/
function formatConflictError(conflictedFiles: string[]): string {
return [
'🚨 Merge Conflicts Detected:',
'',
`⚠️ ${conflictedFiles.length} file(s) have unresolved conflicts:`,
...conflictedFiles.map(f => ` - ${f}`),
'',
'Please resolve these conflicts before ending the session:',
' • Open conflicted files and resolve markers (<<<<<<, ======, >>>>>>)',
' • Stage resolved files: git add <file>',
' • Or use: git mergetool',
].join('\n');
}
function formatSyncError(syncCheck: { behindBy: number; aheadBy: number; remoteBranch: string }): string {
return [
'🚨 Branch Out of Sync:',
'',
`⚠️ Your branch is ${syncCheck.behindBy} commit(s) behind ${syncCheck.remoteBranch}`,
` (You are ${syncCheck.aheadBy} commit(s) ahead)`,
'',
'Please sync your branch before ending the session:',
' • Pull and merge: git pull',
' • Or rebase: git pull --rebase',
'',
'This prevents conflicts and ensures you\'re working with the latest code.',
].join('\n');
}
function formatDoctorErrors(issues: string[]): string {
return [
'🚨 Claude Code Settings Issues Detected:',
'',
...issues.map(issue => `⚠️ ${issue}`),
'',
'Please fix these settings issues before ending the session:',
' • Run: claude doctor',
' • Review and fix reported issues',
' • Check .claude/settings.json for configuration errors',
].join('\n');
}
function formatHookErrors(missingFiles: string[]): string {
return [
'🚨 Missing Hook Files Detected:',
'',
`⚠️ ${missingFiles.length} hook file(s) are missing:`,
...missingFiles.map(file => ` - ${file}`),
'',
'Please fix these hook issues before ending the session:',
' • Reinstall affected plugins: claude plugin install <plugin-name>',
' • Or remove broken plugins from .claude/settings.json',
' • Check plugin cache: ~/.claude/plugins/cache/',
].join('\n');
}
/**
* Format agent instructions for progressive blocking
* @param sessionId - Session ID
* @param branch - Current branch name
* @param issueNumber - Linked issue number (or null)
* @param issueUrl - Linked issue URL (or null)
* @param blockCount - Number of times blocked
* @param skipInstructions - Skip instructions if subagent active
* @returns Formatted agent instruction message
* @example
*/
function formatAgentInstructions(
sessionId: string,
branch: string,
issueNumber: number | null,
issueUrl: string | null,
blockCount: number,