dual-brain 4.2.0 → 4.5.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.
@@ -0,0 +1,971 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ship-gate.mjs — Ship Gate for dual-brain v4.4.
4
+ *
5
+ * Handles the "ready to ship" phase: test discovery, test execution,
6
+ * diff summarization, and PR creation.
7
+ *
8
+ * Usage:
9
+ * node hooks/ship-gate.mjs --ship --goal "..." [--run-id <path>] [--yes]
10
+ * node hooks/ship-gate.mjs --test-only
11
+ * node hooks/ship-gate.mjs --diff-only
12
+ * node hooks/ship-gate.mjs --no-pr --goal "..."
13
+ *
14
+ * Exports:
15
+ * discoverTests()
16
+ * runTests(options)
17
+ * generateDiffSummary()
18
+ * createPR(options)
19
+ */
20
+
21
+ import { existsSync, readFileSync, readdirSync } from 'fs';
22
+ import { spawnSync, execSync } from 'child_process';
23
+ import { dirname, join, resolve, basename } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const PKG_ROOT = resolve(__dirname, '..');
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function run(cmd, args = [], opts = {}) {
34
+ return spawnSync(cmd, args, {
35
+ encoding: 'utf8',
36
+ stdio: ['pipe', 'pipe', 'pipe'],
37
+ cwd: opts.cwd ?? PKG_ROOT,
38
+ timeout: opts.timeout ?? 60_000,
39
+ ...opts,
40
+ });
41
+ }
42
+
43
+ function git(...args) {
44
+ return run('git', args, { cwd: process.cwd() });
45
+ }
46
+
47
+ function readPkg() {
48
+ try {
49
+ return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'));
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function kebabCase(str) {
56
+ return str
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9\s-]/g, '')
59
+ .trim()
60
+ .replace(/\s+/g, '-')
61
+ .replace(/-+/g, '-')
62
+ .slice(0, 40)
63
+ .replace(/-$/, '');
64
+ }
65
+
66
+ function elapsed(startMs) {
67
+ const ms = Date.now() - startMs;
68
+ const s = Math.round(ms / 1000);
69
+ if (s < 60) return `${s}s`;
70
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 1. Test Discovery
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Discover which test command to run for the current project.
79
+ * @returns {{ command: string|null, framework: string|null, confidence: 'high'|'medium'|'none' }}
80
+ */
81
+ export function discoverTests() {
82
+ const cwd = process.cwd();
83
+ const pkg = readPkg();
84
+
85
+ // High confidence: package.json has a test script
86
+ if (pkg?.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1') {
87
+ // Detect framework from the script content
88
+ const script = pkg.scripts.test;
89
+ let framework = null;
90
+ if (/jest/.test(script)) framework = 'jest';
91
+ else if (/vitest/.test(script)) framework = 'vitest';
92
+ else if (/mocha/.test(script)) framework = 'mocha';
93
+ else if (/pytest/.test(script)) framework = 'pytest';
94
+ else if (/tap/.test(script)) framework = 'tap';
95
+ else if (/ava/.test(script)) framework = 'ava';
96
+ return { command: 'npm test', framework, confidence: 'high' };
97
+ }
98
+
99
+ // Medium confidence: detect framework from devDependencies
100
+ const devDeps = { ...pkg?.devDependencies, ...pkg?.dependencies };
101
+ const frameworks = [
102
+ { key: 'jest', cmd: 'npx jest' },
103
+ { key: 'vitest', cmd: 'npx vitest run' },
104
+ { key: 'mocha', cmd: 'npx mocha' },
105
+ { key: 'ava', cmd: 'npx ava' },
106
+ { key: 'tap', cmd: 'npx tap' },
107
+ ];
108
+ for (const { key, cmd } of frameworks) {
109
+ if (devDeps?.[key]) {
110
+ return { command: cmd, framework: key, confidence: 'medium' };
111
+ }
112
+ }
113
+
114
+ // Medium confidence: detect test dirs/files on disk
115
+ const testDirs = ['__tests__', 'tests', 'test'];
116
+ for (const dir of testDirs) {
117
+ if (existsSync(join(cwd, dir))) {
118
+ // Guess jest if node project, else generic
119
+ if (existsSync(join(cwd, 'package.json'))) {
120
+ return { command: 'npx jest', framework: 'jest', confidence: 'medium' };
121
+ }
122
+ }
123
+ }
124
+
125
+ // Check for pytest (Python)
126
+ if (existsSync(join(cwd, 'pytest.ini')) || existsSync(join(cwd, 'setup.cfg')) || existsSync(join(cwd, 'pyproject.toml'))) {
127
+ return { command: 'pytest', framework: 'pytest', confidence: 'medium' };
128
+ }
129
+
130
+ return { command: null, framework: null, confidence: 'none' };
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // 2. Test Runner
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Run the discovered (or provided) test command.
139
+ * @param {{ command?: string, timeout?: number }} options
140
+ * @returns {{ passed: boolean, exit_code: number, output: string, command_used: string, duration_ms: number }}
141
+ */
142
+ export function runTests(options = {}) {
143
+ const discovery = discoverTests();
144
+ const command = options.command ?? discovery.command;
145
+
146
+ if (!command) {
147
+ return {
148
+ passed: null,
149
+ exit_code: null,
150
+ output: 'No test command discovered.',
151
+ command_used: null,
152
+ duration_ms: 0,
153
+ };
154
+ }
155
+
156
+ const [bin, ...args] = command.split(' ');
157
+ const start = Date.now();
158
+ const result = spawnSync(bin, args, {
159
+ encoding: 'utf8',
160
+ stdio: ['pipe', 'pipe', 'pipe'],
161
+ cwd: process.cwd(),
162
+ timeout: options.timeout ?? 120_000,
163
+ shell: true,
164
+ });
165
+ const duration_ms = Date.now() - start;
166
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
167
+
168
+ return {
169
+ passed: result.status === 0,
170
+ exit_code: result.status ?? 1,
171
+ output,
172
+ command_used: command,
173
+ duration_ms,
174
+ };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // 3. Diff Summary
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Summarize git changes since HEAD.
183
+ * @returns {{ files_added: string[], files_modified: string[], files_deleted: string[], stats: string, summary: string }}
184
+ */
185
+ export function generateDiffSummary() {
186
+ const statResult = git('diff', '--stat', 'HEAD');
187
+ const nameStatusResult = git('diff', '--name-status', 'HEAD');
188
+
189
+ const stats = (statResult.stdout || '').trim().split('\n').pop()?.trim() || 'no changes';
190
+
191
+ const files_added = [];
192
+ const files_modified = [];
193
+ const files_deleted = [];
194
+
195
+ const nameStatus = (nameStatusResult.stdout || '').trim();
196
+ for (const line of nameStatus.split('\n').filter(Boolean)) {
197
+ const parts = line.split('\t');
198
+ if (parts.length < 2) continue;
199
+ const status = parts[0];
200
+ const file = parts[parts.length - 1];
201
+ if (!file || typeof file !== 'string') continue;
202
+ if (status.startsWith('A')) files_added.push(file);
203
+ else if (status.startsWith('D')) files_deleted.push(file);
204
+ else files_modified.push(file);
205
+ }
206
+
207
+ // Also include untracked files as "added"
208
+ const untrackedResult = git('ls-files', '--others', '--exclude-standard');
209
+ const untracked = (untrackedResult.stdout || '').trim().split('\n').filter(Boolean);
210
+ for (const f of untracked) {
211
+ if (!files_added.includes(f)) files_added.push(f);
212
+ }
213
+
214
+ // Build a factual summary from file names
215
+ const total = files_added.length + files_modified.length + files_deleted.length;
216
+ const parts = [];
217
+ if (files_added.length) parts.push(`${files_added.length} file(s) added (${files_added.map(f => basename(f)).join(', ')})`);
218
+ if (files_modified.length) parts.push(`${files_modified.length} file(s) modified (${files_modified.map(f => basename(f)).join(', ')})`);
219
+ if (files_deleted.length) parts.push(`${files_deleted.length} file(s) deleted (${files_deleted.map(f => basename(f)).join(', ')})`);
220
+
221
+ const summary = total === 0
222
+ ? 'No changes detected.'
223
+ : parts.join('; ') + '. ' + stats + '.';
224
+
225
+ return { files_added, files_modified, files_deleted, stats, summary };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // 4. PR Creation
230
+ // ---------------------------------------------------------------------------
231
+
232
+ const SENSITIVE_PATTERNS = [
233
+ /\.env(\.|$)/i,
234
+ /credentials/i,
235
+ /secrets?\.(json|yaml|yml|toml)/i,
236
+ /\.pem$/i,
237
+ /\.key$/i,
238
+ /id_rsa/i,
239
+ /\.p12$/i,
240
+ ];
241
+
242
+ function checkSensitiveFiles(files) {
243
+ return files.filter(f => SENSITIVE_PATTERNS.some(re => re.test(basename(f))));
244
+ }
245
+
246
+ function getCurrentBranch() {
247
+ const res = git('rev-parse', '--abbrev-ref', 'HEAD');
248
+ return (res.stdout || '').trim();
249
+ }
250
+
251
+ function hasUncommittedChanges() {
252
+ const status = git('status', '--porcelain');
253
+ return (status.stdout || '').trim().length > 0;
254
+ }
255
+
256
+ function confirm(question) {
257
+ // In non-interactive/CI environments, default to yes
258
+ if (!process.stdin.isTTY) return true;
259
+ process.stdout.write(question + ' [y/N] ');
260
+ // Synchronous readline via child_process
261
+ const res = spawnSync('bash', ['-c', 'read ans && echo "$ans"'], { stdio: ['inherit', 'pipe', 'inherit'], encoding: 'utf8' });
262
+ const answer = (res.stdout || '').trim().toLowerCase();
263
+ return answer === 'y' || answer === 'yes';
264
+ }
265
+
266
+ /**
267
+ * Create a PR for the current changes.
268
+ * @param {{
269
+ * goal?: string,
270
+ * run_id?: string,
271
+ * yes?: boolean,
272
+ * no_pr?: boolean,
273
+ * branch?: string,
274
+ * test_result?: object,
275
+ * gate_result?: object,
276
+ * diff_summary?: object,
277
+ * }} options
278
+ * @returns {{ pr_url?: string, branch: string, commit_hash?: string, error?: string }}
279
+ */
280
+ export async function createPR(options = {}) {
281
+ const {
282
+ goal = 'Ship changes',
283
+ run_id,
284
+ yes = false,
285
+ no_pr = false,
286
+ test_result,
287
+ gate_result,
288
+ diff_summary,
289
+ } = options;
290
+
291
+ // Check gh CLI
292
+ const ghCheck = run('which', ['gh'], { cwd: process.cwd() });
293
+ const ghAvailable = ghCheck.status === 0;
294
+
295
+ // Check remote
296
+ const remoteCheck = git('remote', '-v');
297
+ const hasRemote = (remoteCheck.stdout || '').trim().length > 0;
298
+
299
+ // Determine current branch
300
+ const currentBranch = getCurrentBranch();
301
+ const isMainBranch = currentBranch === 'main' || currentBranch === 'master';
302
+
303
+ // Create new branch if on main/master
304
+ let targetBranch = currentBranch;
305
+ if (isMainBranch) {
306
+ const slug = kebabCase(goal);
307
+ targetBranch = `dual-brain/${slug}`;
308
+ const branchRes = git('checkout', '-b', targetBranch);
309
+ if (branchRes.status !== 0) {
310
+ return { error: `Failed to create branch ${targetBranch}: ${branchRes.stderr}` };
311
+ }
312
+ console.log(`Created branch: ${targetBranch}`);
313
+ }
314
+
315
+ // Check for sensitive files before staging
316
+ const untrackedRes = git('ls-files', '--others', '--exclude-standard');
317
+ const allChangedRes = git('diff', '--name-only', 'HEAD');
318
+ const allFiles = [
319
+ ...(untrackedRes.stdout || '').trim().split('\n').filter(Boolean),
320
+ ...(allChangedRes.stdout || '').trim().split('\n').filter(Boolean),
321
+ ];
322
+ const sensitiveFiles = checkSensitiveFiles(allFiles);
323
+ if (sensitiveFiles.length > 0) {
324
+ console.warn(`\nWARNING: Sensitive files detected — will NOT be staged:\n ${sensitiveFiles.join('\n ')}\n`);
325
+ }
326
+
327
+ // Check for uncommitted changes
328
+ if (!hasUncommittedChanges()) {
329
+ return { error: 'No uncommitted changes to ship.' };
330
+ }
331
+
332
+ // Stage all (except sensitive)
333
+ if (sensitiveFiles.length > 0) {
334
+ // Add files individually, skipping sensitive
335
+ const safeFiles = allFiles.filter(f => !sensitiveFiles.includes(f));
336
+ if (safeFiles.length === 0) {
337
+ return { error: 'Only sensitive files detected — nothing safe to stage.' };
338
+ }
339
+ const addRes = git('add', '--', ...safeFiles);
340
+ if (addRes.status !== 0) {
341
+ return { error: `git add failed: ${addRes.stderr}` };
342
+ }
343
+ } else {
344
+ const addRes = git('add', '-A');
345
+ if (addRes.status !== 0) {
346
+ return { error: `git add failed: ${addRes.stderr}` };
347
+ }
348
+ }
349
+
350
+ // Build commit message
351
+ const commitMsg = buildCommitMessage(goal, diff_summary, test_result, gate_result);
352
+ const commitRes = git('commit', '-m', commitMsg);
353
+ if (commitRes.status !== 0) {
354
+ const out = (commitRes.stdout || '') + (commitRes.stderr || '');
355
+ if (/nothing to commit/i.test(out)) {
356
+ return { error: 'Nothing to commit — working tree clean.' };
357
+ }
358
+ return { error: `git commit failed: ${commitRes.stderr}` };
359
+ }
360
+
361
+ // Get commit hash
362
+ const hashRes = git('rev-parse', 'HEAD');
363
+ const commit_hash = (hashRes.stdout || '').trim();
364
+
365
+ if (no_pr) {
366
+ console.log(`Committed to branch ${targetBranch} (${commit_hash.slice(0, 8)}). --no-pr: skipping push and PR.`);
367
+ return { branch: targetBranch, commit_hash };
368
+ }
369
+
370
+ if (!hasRemote) {
371
+ return { branch: targetBranch, commit_hash, error: 'No git remote configured — skipping push and PR.' };
372
+ }
373
+
374
+ // Confirm push
375
+ if (!yes && !confirm(`Push branch ${targetBranch} and create PR?`)) {
376
+ return { branch: targetBranch, commit_hash, error: 'Aborted by user.' };
377
+ }
378
+
379
+ // Push
380
+ const pushRes = git('push', '-u', 'origin', targetBranch);
381
+ if (pushRes.status !== 0) {
382
+ return { branch: targetBranch, commit_hash, error: `git push failed: ${pushRes.stderr}` };
383
+ }
384
+
385
+ if (!ghAvailable) {
386
+ return { branch: targetBranch, commit_hash, error: 'gh CLI not available — branch pushed but PR not created.' };
387
+ }
388
+
389
+ // Build PR body
390
+ const prBody = buildPRBody({ goal, diff_summary, test_result, gate_result, run_id });
391
+ const prTitle = goal.length > 70 ? goal.slice(0, 67) + '...' : goal;
392
+
393
+ const prRes = spawnSync('gh', ['pr', 'create', '--title', prTitle, '--body', prBody], {
394
+ encoding: 'utf8',
395
+ stdio: ['pipe', 'pipe', 'pipe'],
396
+ cwd: process.cwd(),
397
+ });
398
+
399
+ if (prRes.status !== 0) {
400
+ return { branch: targetBranch, commit_hash, error: `gh pr create failed: ${prRes.stderr}` };
401
+ }
402
+
403
+ const pr_url = (prRes.stdout || '').trim();
404
+ return { pr_url, branch: targetBranch, commit_hash };
405
+ }
406
+
407
+ function buildCommitMessage(goal, diff_summary, test_result, gate_result) {
408
+ const lines = [goal];
409
+ if (diff_summary?.stats) lines.push('', diff_summary.stats);
410
+ const testStatus = test_result == null ? 'not run' : test_result.passed ? 'passed' : 'failed';
411
+ const gateStatus = gate_result?.gate ?? 'not run';
412
+ lines.push('', `Tests: ${testStatus} | Gate: ${gateStatus}`);
413
+ lines.push('', 'Generated by dual-brain Ship Captain');
414
+ return lines.join('\n');
415
+ }
416
+
417
+ function buildPRBody({ goal, diff_summary, test_result, gate_result, run_id }) {
418
+ const testStatus = test_result == null
419
+ ? 'not found'
420
+ : test_result.passed
421
+ ? `passed (${test_result.command_used})`
422
+ : `FAILED (exit ${test_result.exit_code})`;
423
+
424
+ const gateStatus = gate_result?.gate ?? 'not run';
425
+ const riskLevel = gate_result?.risk ?? 'unknown';
426
+
427
+ let runSection = 'N/A';
428
+ if (run_id) {
429
+ try {
430
+ const rec = JSON.parse(readFileSync(run_id, 'utf8'));
431
+ const completed = Array.isArray(rec.steps) ? rec.steps.filter(s => s.status === 'done').length : '?';
432
+ const total = Array.isArray(rec.steps) ? rec.steps.length : '?';
433
+ const dur = rec.duration_ms ? elapsed(Date.now() - rec.duration_ms) : '?';
434
+ runSection = `Steps completed: ${completed}/${total}\n- Duration: ${dur}\n- Run record: ${run_id}`;
435
+ } catch {
436
+ runSection = `Run record: ${run_id}`;
437
+ }
438
+ }
439
+
440
+ const changesSection = diff_summary
441
+ ? [
442
+ diff_summary.summary,
443
+ '',
444
+ diff_summary.stats,
445
+ '',
446
+ diff_summary.files_added.length ? `Added: ${diff_summary.files_added.join(', ')}` : '',
447
+ diff_summary.files_modified.length ? `Modified: ${diff_summary.files_modified.join(', ')}` : '',
448
+ diff_summary.files_deleted.length ? `Deleted: ${diff_summary.files_deleted.join(', ')}` : '',
449
+ ].filter(l => l !== undefined).join('\n')
450
+ : 'Not computed.';
451
+
452
+ return [
453
+ '## Summary',
454
+ goal,
455
+ '',
456
+ '## Changes',
457
+ changesSection,
458
+ '',
459
+ '## Quality',
460
+ `- Tests: ${testStatus}`,
461
+ `- Quality gate: ${gateStatus}`,
462
+ `- Risk level: ${riskLevel}`,
463
+ '',
464
+ '## Ship Captain Run',
465
+ `- ${runSection}`,
466
+ '',
467
+ 'Generated by dual-brain Ship Captain',
468
+ ].join('\n');
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // 5. Self-Healing Gate
473
+ // ---------------------------------------------------------------------------
474
+
475
+ /**
476
+ * Parse structured issues from quality-gate output.
477
+ * Returns an array of issue strings suitable for a fix-agent prompt.
478
+ */
479
+ function parseGateIssues(gateResult) {
480
+ const issues = [];
481
+
482
+ if (!gateResult) return issues;
483
+
484
+ // sensitivity_reasons is the most informative field
485
+ if (Array.isArray(gateResult.sensitivity_reasons) && gateResult.sensitivity_reasons.length > 0) {
486
+ issues.push(...gateResult.sensitivity_reasons);
487
+ }
488
+
489
+ // review text — may contain issue descriptions
490
+ if (gateResult.review && typeof gateResult.review === 'string') {
491
+ const trimmed = gateResult.review.trim();
492
+ if (trimmed) issues.push(trimmed);
493
+ }
494
+
495
+ // warning field
496
+ if (gateResult.warning && typeof gateResult.warning === 'string') {
497
+ issues.push(gateResult.warning);
498
+ }
499
+
500
+ // reasons array (critical risk)
501
+ if (Array.isArray(gateResult.reasons)) {
502
+ for (const r of gateResult.reasons) {
503
+ if (!issues.includes(r)) issues.push(r);
504
+ }
505
+ }
506
+
507
+ // Fallback: gate status itself as a clue
508
+ if (issues.length === 0) {
509
+ issues.push(`Quality gate status: ${gateResult.gate ?? 'issues_found'}`);
510
+ if (gateResult.risk) issues.push(`Risk level: ${gateResult.risk}`);
511
+ }
512
+
513
+ return issues;
514
+ }
515
+
516
+ /**
517
+ * Run quality gate and return its parsed result.
518
+ * Returns null if quality-gate.mjs is missing.
519
+ * Returns { gate: 'gate_failed', _parseError: true } if output is not valid JSON
520
+ * or is valid JSON but missing the required 'gate' field — fail closed, never
521
+ * treat unparseable output as success.
522
+ */
523
+ function runQualityGate() {
524
+ const qgPath = join(__dirname, 'quality-gate.mjs');
525
+ if (!existsSync(qgPath)) return null;
526
+
527
+ const qgRes = spawnSync(process.execPath, [qgPath], {
528
+ encoding: 'utf8',
529
+ stdio: ['pipe', 'pipe', 'pipe'],
530
+ cwd: process.cwd(),
531
+ timeout: 120_000,
532
+ });
533
+
534
+ const raw = (qgRes.stdout || '').trim();
535
+
536
+ let parsed;
537
+ try {
538
+ parsed = JSON.parse(raw);
539
+ } catch {
540
+ // Not valid JSON — fail closed
541
+ process.stderr.write('[ship-gate] Quality gate returned unparseable output — treating as failed\n');
542
+ process.stdout.write('Quality gate returned unparseable output — treating as failed\n');
543
+ return { gate: 'gate_failed', _parseError: true };
544
+ }
545
+
546
+ if (!parsed || typeof parsed.gate !== 'string') {
547
+ // Valid JSON but missing the required 'gate' field — fail closed
548
+ process.stderr.write('[ship-gate] Quality gate returned unparseable output — treating as failed\n');
549
+ process.stdout.write('Quality gate returned unparseable output — treating as failed\n');
550
+ return { gate: 'gate_failed', _parseError: true };
551
+ }
552
+
553
+ return parsed;
554
+ }
555
+
556
+ /**
557
+ * selfHealGate(gateResult, options) — Attempt to auto-fix quality gate issues.
558
+ *
559
+ * Ownership boundary: selfHealGate owns gate-issue healing only.
560
+ * Test failures are NOT healed here — that is ship-captain's job via selfHealTests.
561
+ * runShipGate returns 'tests_failed' without calling selfHealGate so there is no
562
+ * overlap: tests heal in captain, gate issues heal here, never both at once.
563
+ *
564
+ * Spawns a claude fix agent to address the issues, then re-runs the gate.
565
+ * Retries up to maxRetries times.
566
+ *
567
+ * @param {object} gateResult The quality gate result with gate === 'issues_found'
568
+ * @param {{ maxRetries?: number, noHeal?: boolean }} options
569
+ * @returns {{ healed: boolean, attempts: number, finalGateResult: object|null, filesFixed: string[] }}
570
+ */
571
+ export async function selfHealGate(gateResult, options = {}) {
572
+ const { maxRetries = 2, noHeal = false } = options;
573
+
574
+ if (noHeal) {
575
+ return { healed: false, attempts: 0, finalGateResult: gateResult, filesFixed: [] };
576
+ }
577
+
578
+ const issues = parseGateIssues(gateResult);
579
+ let issueText = issues.map((iss, i) => `${i + 1}. ${iss}`).join('\n');
580
+
581
+ let attempts = 0;
582
+ let currentGateResult = gateResult;
583
+ const allFilesFixed = new Set();
584
+
585
+ while (attempts < maxRetries) {
586
+ attempts++;
587
+ process.stderr.write(`[ship-gate] Quality gate found issues. Attempting auto-fix (attempt ${attempts}/${maxRetries})...\n`);
588
+ process.stdout.write(`\nQuality gate found issues. Attempting auto-fix (attempt ${attempts}/${maxRetries})...\n`);
589
+
590
+ // Capture git state BEFORE the fix agent runs
591
+ const diffStatBefore = (() => {
592
+ try {
593
+ const r = spawnSync('git', ['diff', '--stat'], { encoding: 'utf8', cwd: process.cwd() });
594
+ return (r.stdout || '').trim();
595
+ } catch { return ''; }
596
+ })();
597
+
598
+ const fixPrompt = `The quality gate found these issues in the code changes:\n\n${issueText}\n\nFix them. Do not introduce new features or refactor beyond what is needed to fix these specific issues.`;
599
+
600
+ // Spawn claude fix agent
601
+ const fixRes = spawnSync('claude', ['-p', fixPrompt], {
602
+ encoding: 'utf8',
603
+ stdio: ['pipe', 'pipe', 'pipe'],
604
+ cwd: process.cwd(),
605
+ timeout: 300_000, // 5 minutes per attempt
606
+ shell: false,
607
+ });
608
+
609
+ if (fixRes.error) {
610
+ process.stderr.write(`[ship-gate] Fix agent error: ${fixRes.error.message}\n`);
611
+ } else {
612
+ const fixStatus = fixRes.status === 0 ? 'completed' : `exited with code ${fixRes.status}`;
613
+ process.stderr.write(`[ship-gate] Fix agent ${fixStatus}.\n`);
614
+ }
615
+
616
+ // Verify edits actually happened — if nothing changed, count as failed attempt
617
+ const diffStatAfter = (() => {
618
+ try {
619
+ const r = spawnSync('git', ['diff', '--stat'], { encoding: 'utf8', cwd: process.cwd() });
620
+ return (r.stdout || '').trim();
621
+ } catch { return ''; }
622
+ })();
623
+
624
+ if (diffStatAfter === diffStatBefore) {
625
+ process.stderr.write('[ship-gate] Fix agent produced no changes — skipping retry\n');
626
+ process.stdout.write('Fix agent produced no changes — skipping retry\n');
627
+ // Count as an exhausted attempt; do not re-run the gate for zero-change attempts
628
+ continue;
629
+ }
630
+
631
+ // Record which files changed during this heal attempt
632
+ const changedLines = diffStatAfter.split('\n').filter(l => l.includes('|'));
633
+ for (const line of changedLines) {
634
+ const file = line.trim().split(/\s+/)[0];
635
+ if (file) allFilesFixed.add(file);
636
+ }
637
+
638
+ // Re-run quality gate
639
+ process.stderr.write('[ship-gate] Re-running quality gate...\n');
640
+ const newGateResult = runQualityGate();
641
+ currentGateResult = newGateResult;
642
+
643
+ // runQualityGate() now fails closed: unparseable or missing 'gate' → gate_failed
644
+ // So we only treat explicit non-failing statuses as healed.
645
+ const gateStatus = newGateResult?.gate ?? 'gate_failed';
646
+ process.stderr.write(`[ship-gate] Gate after fix: ${gateStatus}\n`);
647
+
648
+ // Healed only if gate is in a known-good state (not issues_found and not gate_failed)
649
+ if (gateStatus !== 'issues_found' && gateStatus !== 'gate_failed') {
650
+ process.stdout.write(`Auto-fix successful! Gate status: ${gateStatus}\n`);
651
+ return { healed: true, attempts, finalGateResult: newGateResult, filesFixed: [...allFilesFixed] };
652
+ }
653
+
654
+ // Update issues for next attempt if still failing
655
+ const newIssues = parseGateIssues(newGateResult);
656
+ if (newIssues.length > 0) {
657
+ const newIssueText = newIssues.map((iss, i) => `${i + 1}. ${iss}`).join('\n');
658
+ if (newIssueText !== issueText) {
659
+ process.stderr.write('[ship-gate] Issues changed after fix attempt, updating for next retry.\n');
660
+ issueText = newIssueText;
661
+ }
662
+ }
663
+ }
664
+
665
+ // All attempts exhausted
666
+ const finalIssues = parseGateIssues(currentGateResult);
667
+ process.stdout.write(`\nCould not auto-fix. Issues:\n${finalIssues.map((iss, i) => ` ${i + 1}. ${iss}`).join('\n')}\n`);
668
+
669
+ return { healed: false, attempts, finalGateResult: currentGateResult, filesFixed: [...allFilesFixed] };
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // 6. Programmatic API
674
+ // ---------------------------------------------------------------------------
675
+
676
+ /**
677
+ * Run the full ship flow programmatically.
678
+ *
679
+ * @param {{
680
+ * goal?: string,
681
+ * runId?: string,
682
+ * yes?: boolean,
683
+ * noPr?: boolean,
684
+ * runRecord?: object,
685
+ * }} options
686
+ * @returns {Promise<{
687
+ * tests: { ran: boolean, passed: boolean|null, output: string, command: string|null },
688
+ * gate: { status: string, risk: string|null, approval: string|null } | null,
689
+ * diff: { files_added: string[], files_modified: string[], files_deleted: string[], stats: string },
690
+ * pr: { url: string|null, branch: string, commit: string|null } | null,
691
+ * status: 'shipped'|'tests_failed'|'gate_failed'|'no_changes'|'pr_skipped',
692
+ * }>}
693
+ */
694
+ export async function runShipGate(options = {}) {
695
+ const {
696
+ goal = 'Ship changes',
697
+ runId,
698
+ yes = false,
699
+ noPr = false,
700
+ noHeal = false,
701
+ runRecord,
702
+ } = options;
703
+
704
+ // 1. Test discovery and execution
705
+ process.stderr.write('[ship-gate] Step 1/4: Discovering and running tests...\n');
706
+ const discovery = discoverTests();
707
+ let testResult = null;
708
+ let testsRan = false;
709
+
710
+ if (discovery.command) {
711
+ process.stderr.write(`[ship-gate] Command: ${discovery.command} (${discovery.framework ?? 'unknown'}, confidence: ${discovery.confidence})\n`);
712
+ testResult = runTests();
713
+ testsRan = true;
714
+ const status = testResult.passed ? 'PASSED' : 'FAILED';
715
+ process.stderr.write(`[ship-gate] Result: ${status} (${testResult.duration_ms}ms)\n`);
716
+ } else {
717
+ process.stderr.write('[ship-gate] No tests found — skipping.\n');
718
+ }
719
+
720
+ const testsOutput = {
721
+ ran: testsRan,
722
+ passed: testResult?.passed ?? null,
723
+ output: testResult?.output ?? '',
724
+ command: testResult?.command_used ?? discovery.command ?? null,
725
+ };
726
+
727
+ if (testsRan && !testResult.passed) {
728
+ // Return tests_failed WITHOUT attempting to heal tests here.
729
+ // Test healing is ship-captain's responsibility (selfHealTests).
730
+ // Keeping healing ownership separate prevents circular heal loops:
731
+ // - tests_failed → ship-captain heals tests → re-calls runShipGate
732
+ // - issues_found → selfHealGate heals gate issues (this file only)
733
+ return {
734
+ tests: testsOutput,
735
+ gate: null,
736
+ diff: generateDiffSummary(),
737
+ pr: null,
738
+ status: 'tests_failed',
739
+ };
740
+ }
741
+
742
+ // 2. Quality gate
743
+ process.stderr.write('[ship-gate] Step 2/4: Running quality gate...\n');
744
+ let gateResult = runQualityGate();
745
+ let healRecord = null;
746
+
747
+ if (gateResult) {
748
+ // _parseError means runQualityGate() failed closed on bad output; already printed a message
749
+ if (!gateResult._parseError) {
750
+ process.stderr.write(`[ship-gate] Gate: ${gateResult.gate} | Risk: ${gateResult.risk ?? 'N/A'}\n`);
751
+ }
752
+ } else {
753
+ // gateResult is null only when quality-gate.mjs does not exist
754
+ process.stderr.write('[ship-gate] quality-gate.mjs not found — skipping.\n');
755
+ }
756
+
757
+ // Self-heal if gate found issues
758
+ if (gateResult && gateResult.gate === 'issues_found') {
759
+ healRecord = await selfHealGate(gateResult, { maxRetries: 2, noHeal });
760
+ if (healRecord.healed) {
761
+ gateResult = healRecord.finalGateResult;
762
+ process.stderr.write(`[ship-gate] Self-heal succeeded after ${healRecord.attempts} attempt(s).\n`);
763
+ } else {
764
+ // Healing failed — mark gate as gate_failed and stop
765
+ gateResult = { ...healRecord.finalGateResult, gate: 'gate_failed' };
766
+ process.stderr.write(`[ship-gate] Self-heal failed after ${healRecord.attempts} attempt(s).\n`);
767
+ }
768
+ }
769
+
770
+ const gateOutput = gateResult
771
+ ? {
772
+ status: gateResult.gate ?? 'unknown',
773
+ risk: gateResult.risk ?? null,
774
+ approval: gateResult.approval ?? null,
775
+ heal: healRecord
776
+ ? { healed: healRecord.healed, attempts: healRecord.attempts, filesFixed: healRecord.filesFixed ?? [] }
777
+ : undefined,
778
+ }
779
+ : null;
780
+
781
+ // Fail if gate explicitly failed
782
+ if (gateResult && gateResult.gate === 'gate_failed') {
783
+ const diffSummaryEarly = generateDiffSummary();
784
+ return {
785
+ tests: testsOutput,
786
+ gate: gateOutput,
787
+ diff: diffSummaryEarly,
788
+ pr: null,
789
+ status: 'gate_failed',
790
+ };
791
+ }
792
+
793
+ // 3. Diff summary
794
+ process.stderr.write('[ship-gate] Step 3/4: Generating diff summary...\n');
795
+ const diffSummary = generateDiffSummary();
796
+ process.stderr.write(`[ship-gate] ${diffSummary.stats}\n`);
797
+
798
+ const total = diffSummary.files_added.length + diffSummary.files_modified.length + diffSummary.files_deleted.length;
799
+ if (total === 0 && diffSummary.stats === 'no changes') {
800
+ return {
801
+ tests: testsOutput,
802
+ gate: gateOutput,
803
+ diff: diffSummary,
804
+ pr: null,
805
+ status: 'no_changes',
806
+ };
807
+ }
808
+
809
+ // 4. PR
810
+ process.stderr.write('[ship-gate] Step 4/4: Creating PR...\n');
811
+
812
+ const gatePassed = !gateResult || gateResult.gate === 'pass' || gateResult.gate === 'self_check';
813
+
814
+ if (noPr || !gatePassed) {
815
+ if (!gatePassed) {
816
+ process.stderr.write('[ship-gate] Gate status requires review — skipping PR.\n');
817
+ } else {
818
+ process.stderr.write('[ship-gate] --no-pr set — skipping PR creation.\n');
819
+ }
820
+ return {
821
+ tests: testsOutput,
822
+ gate: gateOutput,
823
+ diff: diffSummary,
824
+ pr: null,
825
+ status: 'pr_skipped',
826
+ };
827
+ }
828
+
829
+ const prResult = await createPR({
830
+ goal,
831
+ run_id: runId,
832
+ yes,
833
+ no_pr: false,
834
+ test_result: testResult,
835
+ gate_result: gateResult,
836
+ diff_summary: diffSummary,
837
+ });
838
+
839
+ const prOutput = {
840
+ url: prResult.pr_url ?? null,
841
+ branch: prResult.branch ?? null,
842
+ commit: prResult.commit_hash ?? null,
843
+ error: prResult.error ?? null,
844
+ };
845
+
846
+ if (prResult.error) {
847
+ process.stderr.write(`[ship-gate] PR step error: ${prResult.error}\n`);
848
+ } else {
849
+ process.stderr.write(`[ship-gate] PR created: ${prResult.pr_url ?? 'N/A'}\n`);
850
+ }
851
+
852
+ return {
853
+ tests: testsOutput,
854
+ gate: gateOutput,
855
+ diff: diffSummary,
856
+ pr: prOutput,
857
+ status: prResult.error ? 'pr_skipped' : 'shipped',
858
+ };
859
+ }
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // 6. CLI Entry Point
863
+ // ---------------------------------------------------------------------------
864
+
865
+ async function main() {
866
+ const args = process.argv.slice(2);
867
+ const has = (flag) => args.includes(flag);
868
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
869
+
870
+ const testOnly = has('--test-only');
871
+ const diffOnly = has('--diff-only');
872
+ const ship = has('--ship');
873
+ const yes = has('--yes');
874
+ const noPR = has('--no-pr');
875
+ const noHeal = has('--no-heal');
876
+ const goal = get('--goal') ?? 'Ship changes';
877
+ const runId = get('--run-id');
878
+
879
+ if (testOnly) {
880
+ console.log('Discovering tests...');
881
+ const discovery = discoverTests();
882
+ console.log(`Framework: ${discovery.framework ?? 'none'} | Confidence: ${discovery.confidence}`);
883
+ if (!discovery.command) {
884
+ console.log('No test command found.');
885
+ process.exit(0);
886
+ }
887
+ console.log(`Running: ${discovery.command}`);
888
+ const result = runTests();
889
+ console.log(`\n--- Test Output ---\n${result.output || '(none)'}`);
890
+ console.log(`\nResult: ${result.passed ? 'PASSED' : 'FAILED'} (exit ${result.exit_code}) in ${result.duration_ms}ms`);
891
+ process.exit(result.passed ? 0 : 1);
892
+ }
893
+
894
+ if (diffOnly) {
895
+ const diff = generateDiffSummary();
896
+ console.log(`Stats: ${diff.stats}`);
897
+ console.log(`Added: ${diff.files_added.join(', ') || 'none'}`);
898
+ console.log(`Modified: ${diff.files_modified.join(', ') || 'none'}`);
899
+ console.log(`Deleted: ${diff.files_deleted.join(', ') || 'none'}`);
900
+ console.log(`\nSummary: ${diff.summary}`);
901
+ process.exit(0);
902
+ }
903
+
904
+ if (ship || noPR) {
905
+ console.log('=== Ship Gate ===\n');
906
+
907
+ const result = await runShipGate({ goal, runId, yes, noPr: noPR, noHeal });
908
+
909
+ // Surface test output if tests failed
910
+ if (result.status === 'tests_failed') {
911
+ console.log(`\nTests: FAILED`);
912
+ if (result.tests.output) {
913
+ console.log(`\n Output:\n${result.tests.output.split('\n').map(l => ' ' + l).join('\n')}`);
914
+ }
915
+ if (!yes && !confirm('\nTests failed. Continue anyway?')) {
916
+ console.log('Aborted.');
917
+ process.exit(1);
918
+ }
919
+ // Re-run with tests ignored (caller chose to continue)
920
+ const retry = await runShipGate({ goal, runId, yes: true, noPr: noPR });
921
+ return exitFromResult(retry);
922
+ }
923
+
924
+ exitFromResult(result);
925
+ } else {
926
+ // No mode specified
927
+ console.log('Usage:');
928
+ console.log(' node hooks/ship-gate.mjs --test-only');
929
+ console.log(' node hooks/ship-gate.mjs --diff-only');
930
+ console.log(' node hooks/ship-gate.mjs --ship --goal "..." [--run-id <path>] [--yes]');
931
+ console.log(' node hooks/ship-gate.mjs --no-pr --goal "..." [--yes]');
932
+ process.exit(0);
933
+ }
934
+ }
935
+
936
+ function exitFromResult(result) {
937
+ const { tests, gate, diff, pr, status } = result;
938
+
939
+ console.log('\n=== Ship Gate Complete ===');
940
+ console.log(`Status: ${status}`);
941
+
942
+ if (tests.ran) {
943
+ console.log(`Tests: ${tests.passed ? 'PASSED' : 'FAILED'} (${tests.command})`);
944
+ } else {
945
+ console.log('Tests: not found');
946
+ }
947
+
948
+ if (gate) {
949
+ console.log(`Gate: ${gate.status} | Risk: ${gate.risk ?? 'N/A'}`);
950
+ }
951
+
952
+ console.log(`Diff: ${diff.stats}`);
953
+
954
+ if (pr) {
955
+ if (pr.url) console.log(`PR: ${pr.url}`);
956
+ if (pr.branch) console.log(`Branch: ${pr.branch}`);
957
+ if (pr.commit) console.log(`Commit: ${pr.commit?.slice(0, 8)}`);
958
+ if (pr.error) console.error(`PR error: ${pr.error}`);
959
+ }
960
+
961
+ const exitCode = status === 'shipped' || status === 'pr_skipped' || status === 'no_changes' ? 0 : 1;
962
+ process.exit(exitCode);
963
+ }
964
+
965
+ // Run CLI if invoked directly
966
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
967
+ main().catch(err => {
968
+ console.error('ship-gate fatal error:', err);
969
+ process.exit(1);
970
+ });
971
+ }