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,1176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ship-captain.mjs — End-to-end executor for dual-brain v4.5.0.
4
+ *
5
+ * Orchestrates natural language goals into structured, sequentially executed
6
+ * agent tasks with durable run records, quality gate integration, tests, and PR.
7
+ *
8
+ * CLI: node hooks/ship-captain.mjs "fix the auth bug and write tests"
9
+ * node hooks/ship-captain.mjs --goal "..." [--yes] [--dry-run] [--plan-only]
10
+ * [--provider claude|gpt|auto] [--yolo] [--careful]
11
+ * [--no-pr] [--mode <profile>] [--force-execute]
12
+ *
13
+ * Exports: planExecution(goal), executeShipCaptain(goal, options), classifyGoalIntent(goal)
14
+ */
15
+
16
+ import { spawnSync } from 'child_process';
17
+ import { createInterface } from 'readline';
18
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
19
+ import { dirname, resolve } from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ import { routeVibe } from './vibe-router.mjs';
23
+ import { getTemplate, buildAgentPrompt } from './agent-templates.mjs';
24
+ import { getChain } from './agent-chains.mjs';
25
+ import { chooseProvider } from './budget-balancer.mjs';
26
+ import { runTests, discoverTests } from './ship-gate.mjs';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const RUNS_DIR = resolve(__dirname, '..', '.claude', 'runs');
30
+ const TEMPLATES_SCRIPT = resolve(__dirname, 'agent-templates.mjs');
31
+ const CHAINS_SCRIPT = resolve(__dirname, 'agent-chains.mjs');
32
+
33
+ // ─── Risk/Tier Display Helpers ─────────────────────────────────────────────
34
+
35
+ const RISK_BADGE = { low: '[low]', medium: '[med]', high: '[HIGH]', critical: '[CRIT]' };
36
+ const TIER_BADGE = { search: 'search/haiku', execute: 'execute/sonnet', think: 'think/opus' };
37
+ const PROVIDER_BADGE = { claude: 'claude', openai: 'gpt', auto: 'auto' };
38
+
39
+ // ─── Goal Intent Classification ──────────────────────────────────────────
40
+
41
+ const INTENT_RULES = [
42
+ {
43
+ intent: 'think',
44
+ patterns: [
45
+ /\bshould we\b/i,
46
+ /\bwhat'?s the best\b/i,
47
+ /\bhow should\b/i,
48
+ /\barchitecture\b/i,
49
+ /\bdesign\b/i,
50
+ /\bdecide\b/i,
51
+ /\bcompare\b/i,
52
+ /\btradeoff\b/i,
53
+ /\bthink about\b/i,
54
+ /\bevaluate\b/i,
55
+ /\bapproach\b/i,
56
+ ],
57
+ subsystem: 'dual-brain-think.mjs --question',
58
+ },
59
+ {
60
+ intent: 'review',
61
+ patterns: [
62
+ /\breview\b/i,
63
+ /\baudit\b/i,
64
+ /\bcheck for bugs\b/i,
65
+ /\bsecurity review\b/i,
66
+ /\bcode review\b/i,
67
+ /\blook at the diff\b/i,
68
+ /\breview this pr\b/i,
69
+ /\breview my changes\b/i,
70
+ ],
71
+ subsystem: 'dual-brain-review.mjs',
72
+ },
73
+ {
74
+ intent: 'explore',
75
+ patterns: [
76
+ /\bhow does\b/i,
77
+ /\bwhere is\b/i,
78
+ /\bfind\b/i,
79
+ /\bexplain\b/i,
80
+ /\bunderstand\b/i,
81
+ /\bwhat is\b/i,
82
+ /\bshow me\b/i,
83
+ ],
84
+ subsystem: 'agent-templates.mjs explorer',
85
+ },
86
+ {
87
+ intent: 'ship',
88
+ patterns: [
89
+ /\bship it\b/i,
90
+ /\bcreate pr\b/i,
91
+ /\bopen pr\b/i,
92
+ /\bpush this\b/i,
93
+ /\bget this ready\b/i,
94
+ ],
95
+ subsystem: 'ship-gate.mjs --ship',
96
+ },
97
+ ];
98
+
99
+ const EXECUTE_PATTERNS = [
100
+ /\bfix\b/i, /\bbuild\b/i, /\bwrite\b/i, /\bupdate\b/i, /\brefactor\b/i,
101
+ /\badd\b/i, /\bremove\b/i, /\bchange\b/i, /\bimplement\b/i,
102
+ ];
103
+
104
+ /**
105
+ * classifyGoalIntent(goal) — detect the user's intent and route to the right subsystem.
106
+ *
107
+ * @param {string} goal
108
+ * @returns {{ intent: 'think'|'review'|'explore'|'execute'|'ship', confidence: 'high'|'medium'|'low', reason: string }}
109
+ */
110
+ function classifyGoalIntent(goal) {
111
+ const matched = [];
112
+
113
+ for (const rule of INTENT_RULES) {
114
+ const hits = rule.patterns.filter(p => p.test(goal));
115
+ if (hits.length > 0) {
116
+ matched.push({ rule, hits });
117
+ }
118
+ }
119
+
120
+ // Multiple intent signals → lower confidence
121
+ if (matched.length > 1) {
122
+ // Pick the first match but flag lower confidence
123
+ const primary = matched[0];
124
+ return {
125
+ intent: primary.rule.intent,
126
+ confidence: 'medium',
127
+ reason: `Matched "${primary.hits[0].source}" (${matched.length} intent signals found — using primary)`,
128
+ };
129
+ }
130
+
131
+ if (matched.length === 1) {
132
+ const { rule, hits } = matched[0];
133
+ return {
134
+ intent: rule.intent,
135
+ confidence: 'high',
136
+ reason: `Matched "${hits[0].source}"`,
137
+ };
138
+ }
139
+
140
+ // No non-execute signals — check for explicit execute keywords
141
+ const executeHit = EXECUTE_PATTERNS.find(p => p.test(goal));
142
+ if (executeHit) {
143
+ return {
144
+ intent: 'execute',
145
+ confidence: 'high',
146
+ reason: `Matched execute keyword "${executeHit.source}"`,
147
+ };
148
+ }
149
+
150
+ // Fallback: execute with low confidence
151
+ return {
152
+ intent: 'execute',
153
+ confidence: 'low',
154
+ reason: 'No specific intent pattern matched — defaulting to execute pipeline',
155
+ };
156
+ }
157
+
158
+ // ─── Intent Routing ───────────────────────────────────────────────────────
159
+
160
+ const THINK_SCRIPT = resolve(__dirname, 'dual-brain-think.mjs');
161
+ const REVIEW_SCRIPT = resolve(__dirname, 'dual-brain-review.mjs');
162
+ const SHIP_GATE_SCRIPT = resolve(__dirname, 'ship-gate.mjs');
163
+
164
+ /**
165
+ * spawnIntentSubsystem — execute the subsystem that matches the detected intent.
166
+ * Returns the spawnSync result.
167
+ */
168
+ function spawnIntentSubsystem(intent, goal) {
169
+ switch (intent) {
170
+ case 'think': {
171
+ return spawnSync(process.execPath, [THINK_SCRIPT, '--question', goal], {
172
+ stdio: 'inherit',
173
+ cwd: process.cwd(),
174
+ env: process.env,
175
+ });
176
+ }
177
+ case 'review': {
178
+ return spawnSync(process.execPath, [REVIEW_SCRIPT], {
179
+ stdio: 'inherit',
180
+ cwd: process.cwd(),
181
+ env: process.env,
182
+ });
183
+ }
184
+ case 'explore': {
185
+ return spawnSync(process.execPath, [TEMPLATES_SCRIPT, '--run', 'explorer', '--question', goal], {
186
+ stdio: 'inherit',
187
+ cwd: process.cwd(),
188
+ env: process.env,
189
+ });
190
+ }
191
+ case 'ship': {
192
+ return spawnSync(process.execPath, [SHIP_GATE_SCRIPT, '--ship', '--goal', goal], {
193
+ stdio: 'inherit',
194
+ cwd: process.cwd(),
195
+ env: process.env,
196
+ });
197
+ }
198
+ default:
199
+ return null;
200
+ }
201
+ }
202
+
203
+ // ─── Template Matching ────────────────────────────────────────────────────
204
+
205
+ const TEMPLATE_KEYWORDS = [
206
+ { template: 'security-review', regex: /\b(security|audit|vulnerabilit|owasp|threat|pentest)\b/i },
207
+ { template: 'test-writer', regex: /\b(test|spec|coverage|assert|unit\s+test|write\s+tests?)\b/i },
208
+ { template: 'bug-hunter', regex: /\b(bug|fix|error|crash|broken|defect|regression|debug)\b/i },
209
+ { template: 'explorer', regex: /\b(explore|understand|find|search|locate|where|what|look)\b/i },
210
+ ];
211
+
212
+ const CHAIN_KEYWORDS = [
213
+ { chain: 'explore-then-fix', regex: /\b(explore|understand).{0,40}(fix|repair|resolve)\b/i },
214
+ { chain: 'review-and-test', regex: /\b(review|audit).{0,40}(test|spec|coverage)\b/i },
215
+ { chain: 'audit-and-plan', regex: /\b(audit|analyze).{0,40}(plan|roadmap|design)\b/i },
216
+ ];
217
+
218
+ function matchChain(taskTitle) {
219
+ for (const { chain, regex } of CHAIN_KEYWORDS) {
220
+ if (regex.test(taskTitle)) return chain;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function matchTemplate(taskTitle) {
226
+ for (const { template, regex } of TEMPLATE_KEYWORDS) {
227
+ if (regex.test(taskTitle)) return template;
228
+ }
229
+ return 'explorer';
230
+ }
231
+
232
+ // ─── Provider Resolution ──────────────────────────────────────────────────
233
+
234
+ function resolveProvider(task, forcedProvider) {
235
+ if (forcedProvider && forcedProvider !== 'auto') return forcedProvider;
236
+ try {
237
+ const rec = chooseProvider({ tier: task.tier });
238
+ return rec.provider === 'openai' ? 'gpt' : 'claude';
239
+ } catch {
240
+ return 'claude';
241
+ }
242
+ }
243
+
244
+ // ─── Mode Resolution ──────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Resolve the execution mode from argv flags and options.
248
+ * Tries to import confirmation-policy.mjs's resolveMode; falls back to inline logic.
249
+ * @param {object} opts - parsed CLI options
250
+ * @returns {string} mode string: 'yolo' | 'careful' | 'auto' | profile name
251
+ */
252
+ async function resolveMode(opts) {
253
+ // Try to use confirmation-policy if available
254
+ try {
255
+ const cpPath = resolve(__dirname, 'confirmation-policy.mjs');
256
+ if (existsSync(cpPath)) {
257
+ const { resolveMode: cpResolveMode } = await import(cpPath);
258
+ return cpResolveMode({
259
+ yolo: opts.yolo,
260
+ careful: opts.careful,
261
+ mode: opts.mode,
262
+ provider: opts.provider,
263
+ });
264
+ }
265
+ } catch {
266
+ // confirmation-policy not available yet, use inline fallback
267
+ }
268
+
269
+ if (opts.yolo) return 'yolo';
270
+ if (opts.careful) return 'careful';
271
+ if (opts.mode) return opts.mode;
272
+ return 'auto';
273
+ }
274
+
275
+ // ─── Git State Snapshot ───────────────────────────────────────────────────
276
+
277
+ function gitDiffStat() {
278
+ try {
279
+ const result = spawnSync('git', ['diff', '--stat'], { encoding: 'utf8', cwd: process.cwd() });
280
+ return (result.stdout || '').trim();
281
+ } catch {
282
+ return '';
283
+ }
284
+ }
285
+
286
+ function parseChangedFiles(diffStat) {
287
+ if (!diffStat) return [];
288
+ return diffStat
289
+ .split('\n')
290
+ .filter(line => line.includes('|') || line.match(/^\s+\S/))
291
+ .map(line => line.trim().split(/\s+/)[0])
292
+ .filter(Boolean);
293
+ }
294
+
295
+ // ─── Plan Builder ─────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * planExecution(goal) — decompose a goal into an ordered execution plan.
299
+ * Returns { goal, tasks, complexity, wave_recommendation, quality_gates, steps }
300
+ * where steps is an array of enriched step descriptors.
301
+ */
302
+ function planExecution(goal) {
303
+ const vibe = routeVibe(goal);
304
+ const { tasks, complexity, wave_recommendation, quality_gates } = vibe;
305
+
306
+ const steps = tasks.map((task, idx) => {
307
+ const chainName = matchChain(task.title);
308
+ const templateName = chainName ? null : matchTemplate(task.title);
309
+ const isHighRisk = task.risk === 'high' || task.risk === 'critical';
310
+ return {
311
+ index: idx + 1,
312
+ total: tasks.length,
313
+ task,
314
+ chainName,
315
+ templateName,
316
+ isHighRisk,
317
+ stopBefore: isHighRisk && idx > 0,
318
+ };
319
+ });
320
+
321
+ return { goal, tasks, complexity, wave_recommendation, quality_gates, steps };
322
+ }
323
+
324
+ // ─── Plan Display ─────────────────────────────────────────────────────────
325
+
326
+ function printPlan(plan, forcedProvider, intentResult, mode, forceExecute) {
327
+ const { goal, steps, complexity, quality_gates } = plan;
328
+ const width = 66;
329
+ const hr = '━'.repeat(width);
330
+
331
+ // Aggregate risk across steps for display
332
+ const allRisks = steps.map(s => s.task.risk || 'low');
333
+ const displayRisk = aggregateRiskFallback(allRisks);
334
+
335
+ // Build a short human-readable description of the intent
336
+ let intentDesc;
337
+ if (forceExecute && intentResult && intentResult.intent !== 'execute') {
338
+ intentDesc = `--force-execute (originally detected: ${intentResult.intent})`;
339
+ } else if (intentResult) {
340
+ intentDesc = intentResult.reason.replace(/^Matched execute keyword .+$/, 'fix code + build');
341
+ } else {
342
+ intentDesc = 'execute';
343
+ }
344
+
345
+ const modeLabel = mode || 'auto';
346
+ const modeDesc = modeLabel === 'auto'
347
+ ? 'auto (confirm high risk, skip low/medium)'
348
+ : modeLabel === 'yolo'
349
+ ? 'yolo (no confirmations)'
350
+ : modeLabel === 'careful'
351
+ ? 'careful (confirm every step)'
352
+ : modeLabel;
353
+
354
+ console.log(`\n${hr}`);
355
+ console.log(` Ship Captain — Execution Plan`);
356
+ console.log(`${hr}`);
357
+ console.log(` Intent: execute (${intentDesc})`);
358
+ console.log(` Route: Ship Captain pipeline (${steps.length} step${steps.length !== 1 ? 's' : ''})`);
359
+ console.log(` Risk: ${displayRisk}`);
360
+ console.log(` Mode: ${modeDesc}`);
361
+ console.log(`${hr}`);
362
+ console.log(` Goal: ${goal}`);
363
+ console.log(` Steps: ${steps.length} | Complexity: ${complexity}`);
364
+ console.log(` Quality gates: ${quality_gates.join(', ')}`);
365
+ console.log(`${hr}`);
366
+
367
+ for (const step of steps) {
368
+ const { task, chainName, templateName, stopBefore, index, total } = step;
369
+ const provider = resolveProvider(task, forcedProvider);
370
+ const riskBadge = RISK_BADGE[task.risk] || `[${task.risk}]`;
371
+ const tierLabel = TIER_BADGE[task.tier] || task.tier;
372
+ const via = chainName ? `chain:${chainName}` : `template:${templateName}`;
373
+ const stopMark = stopBefore ? ' ⚑ STOP POINT before this step' : '';
374
+
375
+ if (stopBefore) console.log(`\n ${'-'.repeat(width - 2)}`);
376
+ console.log(` Step ${index}/${total} ${riskBadge} ${tierLabel} [${provider}]`);
377
+ console.log(` Task: ${task.title}`);
378
+ console.log(` Via: ${via}`);
379
+ if (stopMark) console.log(` ${stopMark}`);
380
+ }
381
+
382
+ console.log(`${hr}\n`);
383
+ }
384
+
385
+ // ─── Step Execution ───────────────────────────────────────────────────────
386
+
387
+ function spawnTemplate(templateName, task) {
388
+ const flagArgs = ['--run', templateName];
389
+
390
+ const desc = task.title.toLowerCase();
391
+ if (templateName === 'explorer') {
392
+ flagArgs.push('--question', task.title);
393
+ } else if (templateName === 'bug-hunter') {
394
+ flagArgs.push('--area', desc);
395
+ } else if (templateName === 'test-writer') {
396
+ flagArgs.push('--file', desc);
397
+ } else if (templateName === 'security-review') {
398
+ flagArgs.push('--scope', desc);
399
+ } else {
400
+ flagArgs.push('--question', task.title);
401
+ }
402
+
403
+ return spawnSync(process.execPath, [TEMPLATES_SCRIPT, ...flagArgs], {
404
+ stdio: 'inherit',
405
+ cwd: process.cwd(),
406
+ env: process.env,
407
+ });
408
+ }
409
+
410
+ function spawnChain(chainName, task, yesFlag) {
411
+ const flagArgs = ['--run', chainName, '--question', task.title];
412
+ if (yesFlag) flagArgs.push('--yes');
413
+
414
+ return spawnSync(process.execPath, [CHAINS_SCRIPT, ...flagArgs], {
415
+ stdio: 'inherit',
416
+ cwd: process.cwd(),
417
+ env: process.env,
418
+ });
419
+ }
420
+
421
+ // ─── Interactive Prompt ───────────────────────────────────────────────────
422
+
423
+ function prompt(question) {
424
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
425
+ return new Promise((res) => {
426
+ rl.question(question, (answer) => { rl.close(); res(answer.trim()); });
427
+ });
428
+ }
429
+
430
+ async function askContinue(stepIndex, total) {
431
+ const answer = await prompt(`\n Continue to step ${stepIndex}/${total}? [Y/n] `);
432
+ return answer === '' || /^y(es)?$/i.test(answer);
433
+ }
434
+
435
+ async function askOnFailure(stepIndex) {
436
+ const answer = await prompt(`\n Step ${stepIndex} failed. [R]etry / [S]kip / [A]bort? `);
437
+ const a = answer.toLowerCase();
438
+ if (a === 'r' || a === 'retry') return 'retry';
439
+ if (a === 's' || a === 'skip') return 'skip';
440
+ return 'abort';
441
+ }
442
+
443
+ // ─── Duration Formatting ──────────────────────────────────────────────────
444
+
445
+ function fmtDuration(ms) {
446
+ if (ms < 1000) return `${ms}ms`;
447
+ const s = Math.round(ms / 1000);
448
+ if (s < 60) return `${s}s`;
449
+ const m = Math.floor(s / 60);
450
+ const rem = s % 60;
451
+ return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
452
+ }
453
+
454
+ // ─── Run Record ───────────────────────────────────────────────────────────
455
+
456
+ function makeRunId() {
457
+ return `run-${new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '')}`;
458
+ }
459
+
460
+ function writeRunRecord(record) {
461
+ mkdirSync(RUNS_DIR, { recursive: true });
462
+ const fname = `${record.id}.json`;
463
+ const fpath = resolve(RUNS_DIR, fname);
464
+ writeFileSync(fpath, JSON.stringify(record, null, 2), 'utf8');
465
+ return fpath;
466
+ }
467
+
468
+ // ─── Confirmation Policy Integration ─────────────────────────────────────
469
+
470
+ /**
471
+ * Load confirmation-policy.mjs exports if available.
472
+ * Returns null if file doesn't exist yet (other agent still building it).
473
+ */
474
+ async function loadConfirmationPolicy() {
475
+ const cpPath = resolve(__dirname, 'confirmation-policy.mjs');
476
+ if (!existsSync(cpPath)) return null;
477
+ try {
478
+ const mod = await import(cpPath);
479
+ return {
480
+ getConfirmationPolicy: mod.getConfirmationPolicy,
481
+ resolveMode: mod.resolveMode,
482
+ aggregateRisk: mod.aggregateRisk,
483
+ formatConfirmation: mod.formatConfirmation,
484
+ };
485
+ } catch {
486
+ return null;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Check whether to confirm/block a step based on confirmation policy.
492
+ * Falls back to the original stopBefore logic if policy module not available.
493
+ */
494
+ async function checkStepConfirmation(cp, { risk, mode, stepName }) {
495
+ if (!cp || !cp.getConfirmationPolicy) {
496
+ // Fallback: block on high/critical in non-yolo mode
497
+ const isHighRisk = risk === 'high' || risk === 'critical';
498
+ return {
499
+ shouldBlock: false,
500
+ shouldConfirm: isHighRisk && mode !== 'yolo',
501
+ reason: isHighRisk ? `${risk} risk step` : null,
502
+ };
503
+ }
504
+ try {
505
+ return cp.getConfirmationPolicy({ risk, mode, step: stepName });
506
+ } catch {
507
+ return { shouldBlock: false, shouldConfirm: false, reason: null };
508
+ }
509
+ }
510
+
511
+ // ─── Risk Aggregation ─────────────────────────────────────────────────────
512
+
513
+ const RISK_ORDER = ['low', 'medium', 'high', 'critical'];
514
+
515
+ function aggregateRiskFallback(risks) {
516
+ let max = 'low';
517
+ for (const r of risks) {
518
+ if (RISK_ORDER.indexOf(r) > RISK_ORDER.indexOf(max)) max = r;
519
+ }
520
+ return max;
521
+ }
522
+
523
+ // ─── Self-Healing Tests ───────────────────────────────────────────────────
524
+
525
+ /**
526
+ * selfHealTests(testResult, options) — Attempt to auto-fix failing tests.
527
+ *
528
+ * Ownership boundary: selfHealTests owns test-failure healing only.
529
+ * Quality gate issues are NOT healed here — that is selfHealGate's job (ship-gate.mjs).
530
+ * Callers (executeShipCaptain) detect tests_failed from runShipGate, call selfHealTests,
531
+ * then re-call runShipGate once tests pass. This keeps the two heal loops non-overlapping:
532
+ * - tests_failed → selfHealTests (here) → re-run full gate via runShipGate
533
+ * - gate issues_found → selfHealGate (ship-gate.mjs) → re-run gate only
534
+ *
535
+ * Spawns a claude fix agent with the test output, then re-runs the SAME test command
536
+ * (not re-discovered) to keep retries deterministic.
537
+ * Retries up to maxRetries times.
538
+ *
539
+ * @param {object} testResult The failing runTests() result
540
+ * @param {{ maxRetries?: number, noHeal?: boolean }} options
541
+ * @returns {{ healed: boolean, attempts: number, finalTestResult: object }}
542
+ */
543
+ export async function selfHealTests(testResult, options = {}) {
544
+ const { maxRetries = 2, noHeal = false } = options;
545
+
546
+ if (noHeal) {
547
+ return { healed: false, attempts: 0, finalTestResult: testResult };
548
+ }
549
+
550
+ // Pin the test command from the first result so retries are deterministic.
551
+ // Re-discovering the command on each retry could pick a different test runner
552
+ // if package.json changes during the heal loop.
553
+ const pinnedCommand = testResult.command_used ?? null;
554
+
555
+ let attempts = 0;
556
+ let currentTestResult = testResult;
557
+
558
+ while (attempts < maxRetries) {
559
+ attempts++;
560
+ console.log(`\n Self-heal attempt ${attempts}/${maxRetries}: fixing test failures...`);
561
+
562
+ // Build a concise summary of failures for the fix prompt
563
+ const outputSnippet = (currentTestResult.output || '').slice(0, 4000); // cap to avoid huge prompts
564
+ const fixPrompt = `These tests are failing:\n\n${outputSnippet}\n\nFix the code to make them pass. Do not modify the tests unless they have clear bugs. Do not introduce new features or refactor beyond what is needed to make the tests pass.`;
565
+
566
+ // Capture git state BEFORE the fix agent runs
567
+ const diffStatBefore = gitDiffStat();
568
+
569
+ // Spawn claude fix agent
570
+ const fixRes = spawnSync('claude', ['-p', fixPrompt], {
571
+ encoding: 'utf8',
572
+ stdio: ['pipe', 'pipe', 'pipe'],
573
+ cwd: process.cwd(),
574
+ timeout: 300_000, // 5 minutes per attempt
575
+ shell: false,
576
+ });
577
+
578
+ if (fixRes.error) {
579
+ console.log(` [auto-fix] Fix agent error: ${fixRes.error.message}`);
580
+ } else {
581
+ const fixStatus = fixRes.status === 0 ? 'completed' : `exited with code ${fixRes.status}`;
582
+ console.log(` [auto-fix] Fix agent ${fixStatus}.`);
583
+ }
584
+
585
+ // Verify edits actually happened — if nothing changed, skip the test re-run
586
+ // (there's nothing to re-test; running tests again would just repeat the failure)
587
+ const diffStatAfter = gitDiffStat();
588
+ if (diffStatAfter === diffStatBefore) {
589
+ console.log(' [auto-fix] Fix agent produced no changes — skipping retry');
590
+ // Count as an exhausted attempt without re-running tests
591
+ continue;
592
+ }
593
+
594
+ // Re-run the SAME test command (pinned above) — not re-discovered — for determinism
595
+ console.log(' Re-running tests after fix...');
596
+ const newTestResult = runTests({ command: pinnedCommand });
597
+ currentTestResult = newTestResult;
598
+
599
+ const status = newTestResult.passed ? 'passed' : 'still failing';
600
+ console.log(` Self-heal attempt ${attempts} result: ${status}`);
601
+
602
+ if (newTestResult.passed) {
603
+ console.log(' Auto-fix successful! Tests now pass.');
604
+ return { healed: true, attempts, finalTestResult: newTestResult };
605
+ }
606
+ }
607
+
608
+ // All attempts exhausted
609
+ console.log(`\n Could not auto-fix tests after ${attempts} attempt(s).`);
610
+ console.log(' Please fix the failing tests manually or abort.');
611
+ return { healed: false, attempts, finalTestResult: currentTestResult };
612
+ }
613
+
614
+ // ─── Ship Gate Integration ────────────────────────────────────────────────
615
+
616
+ /**
617
+ * Attempt to import and run the ship gate pipeline.
618
+ * Returns null if ship-gate.mjs doesn't export runShipGate yet.
619
+ */
620
+ async function runShipGatePipeline(goal, runRecord, options) {
621
+ const sgPath = resolve(__dirname, 'ship-gate.mjs');
622
+ if (!existsSync(sgPath)) {
623
+ return { status: 'skipped', reason: 'ship-gate.mjs not found' };
624
+ }
625
+
626
+ let runShipGate;
627
+ try {
628
+ const mod = await import(sgPath);
629
+ runShipGate = mod.runShipGate;
630
+ } catch (err) {
631
+ return { status: 'skipped', reason: `failed to import ship-gate.mjs: ${err.message}` };
632
+ }
633
+
634
+ if (typeof runShipGate !== 'function') {
635
+ // ship-gate.mjs exists but runShipGate export not added yet (other agent still building)
636
+ return { status: 'skipped', reason: 'runShipGate export not yet available in ship-gate.mjs' };
637
+ }
638
+
639
+ try {
640
+ const result = await runShipGate({
641
+ goal,
642
+ runId: runRecord.id,
643
+ yes: options.yes || options.yolo,
644
+ no_pr: options.noPr,
645
+ runRecord,
646
+ });
647
+ return result;
648
+ } catch (err) {
649
+ return { status: 'error', reason: err.message };
650
+ }
651
+ }
652
+
653
+ // ─── Main Executor ─────────────────────────────────────────────────────────
654
+
655
+ /**
656
+ * executeShipCaptain(goal, options) — Full orchestration flow.
657
+ *
658
+ * @param {string} goal
659
+ * @param {{
660
+ * yes?: boolean,
661
+ * dryRun?: boolean,
662
+ * planOnly?: boolean,
663
+ * provider?: string,
664
+ * yolo?: boolean,
665
+ * careful?: boolean,
666
+ * noPr?: boolean,
667
+ * mode?: string,
668
+ * forceExecute?: boolean,
669
+ * resumeFrom?: number,
670
+ * resumedFromId?: string,
671
+ * }} options
672
+ * @returns {object} run record
673
+ */
674
+ async function executeShipCaptain(goal, options = {}) {
675
+ const {
676
+ yes = false,
677
+ dryRun = false,
678
+ planOnly = false,
679
+ provider: forcedProvider = 'auto',
680
+ yolo = false,
681
+ careful = false,
682
+ noPr = false,
683
+ forceExecute = false,
684
+ resumeFrom = null,
685
+ resumedFromId = null,
686
+ } = options;
687
+
688
+ // ── Intent Classification ────────────────────────────────────────────────
689
+ const intentResult = classifyGoalIntent(goal);
690
+
691
+ // Route to a dedicated subsystem for non-execute intents (unless forced)
692
+ if (!forceExecute && intentResult.intent !== 'execute' && intentResult.confidence !== 'low') {
693
+ const subsystemLabel = {
694
+ think: 'dual-brain-think (architecture thinking)',
695
+ review: 'dual-brain-review (code review)',
696
+ explore: 'agent-templates explorer',
697
+ ship: 'ship-gate --ship (PR creation)',
698
+ }[intentResult.intent] || intentResult.intent;
699
+
700
+ console.log(`\n Detected intent: ${intentResult.intent} — routing to ${subsystemLabel}`);
701
+ console.log(` Reason: ${intentResult.reason}`);
702
+ console.log(` Override with: npx dual-brain do '${goal}' --force-execute\n`);
703
+
704
+ if (dryRun || planOnly) {
705
+ console.log(` [dry-run] Would route to: ${subsystemLabel}`);
706
+ return { id: null, status: 'dry_run', goal, intent: intentResult, steps: [] };
707
+ }
708
+
709
+ const result = spawnIntentSubsystem(intentResult.intent, goal);
710
+ const exitCode = result ? (result.status ?? 0) : 0;
711
+ return {
712
+ id: null,
713
+ status: exitCode === 0 ? 'completed' : 'failed',
714
+ goal,
715
+ intent: intentResult,
716
+ steps: [],
717
+ };
718
+ }
719
+
720
+ // Resolve mode (uses confirmation-policy if available)
721
+ const mode = await resolveMode({ yolo, careful, mode: options.mode, provider: forcedProvider });
722
+
723
+ // Load confirmation policy module (graceful degradation if not ready)
724
+ const cp = await loadConfirmationPolicy();
725
+
726
+ const plan = planExecution(goal);
727
+ printPlan(plan, forcedProvider, intentResult, mode, forceExecute);
728
+
729
+ if (dryRun || planOnly) {
730
+ const label = planOnly ? '--plan-only' : '--dry-run';
731
+ console.log(` [${label}] Plan displayed. Nothing executed.\n`);
732
+ return { id: null, status: 'dry_run', goal, steps: [] };
733
+ }
734
+
735
+ const runId = makeRunId();
736
+ const startedAt = new Date().toISOString();
737
+ const runRecord = {
738
+ id: runId,
739
+ goal,
740
+ status: 'running',
741
+ mode,
742
+ options: { yes, yolo, careful, noPr, mode: options.mode || null },
743
+ steps: [],
744
+ total_duration_ms: 0,
745
+ files_changed: [],
746
+ started_at: startedAt,
747
+ completed_at: null,
748
+ ship_gate: null,
749
+ ...(resumedFromId ? { resumed_from: resumedFromId } : {}),
750
+ };
751
+
752
+ const allChangedFiles = new Set();
753
+ const totalSteps = plan.steps.length;
754
+ const stepRisks = [];
755
+
756
+ // If resuming, print a header showing which step we start from
757
+ if (resumeFrom !== null && resumeFrom > 0) {
758
+ const resumeStep = plan.steps[resumeFrom];
759
+ const resumeDesc = resumeStep ? (resumeStep.task?.title || `step ${resumeFrom + 1}`) : `step ${resumeFrom + 1}`;
760
+ console.log(`\n Resuming from step ${resumeFrom + 1}/${plan.steps.length}: ${resumeDesc}\n`);
761
+ }
762
+
763
+ for (let i = 0; i < plan.steps.length; i++) {
764
+ const step = plan.steps[i];
765
+ const { task, chainName, templateName, isHighRisk, stopBefore, index } = step;
766
+ const tierLabel = TIER_BADGE[task.tier] || task.tier;
767
+ const riskBadge = RISK_BADGE[task.risk] || `[${task.risk}]`;
768
+
769
+ // Skip steps before resumeFrom — mark them as skipped-resume in the run record
770
+ if (resumeFrom !== null && i < resumeFrom) {
771
+ stepRisks.push(task.risk || 'low');
772
+ runRecord.steps.push({
773
+ task: task.title,
774
+ template: chainName || templateName,
775
+ risk: task.risk,
776
+ status: 'skipped-resume',
777
+ files_changed: [],
778
+ duration_ms: 0,
779
+ });
780
+ continue;
781
+ }
782
+
783
+ stepRisks.push(task.risk || 'low');
784
+
785
+ // ── Confirmation policy check ──────────────────────────────────────────
786
+ if (mode !== 'yolo' && (stopBefore || mode === 'careful')) {
787
+ const conf = await checkStepConfirmation(cp, {
788
+ risk: task.risk,
789
+ mode,
790
+ stepName: 'edit',
791
+ });
792
+
793
+ if (conf.shouldBlock) {
794
+ console.log(`\n [BLOCKED] Step ${index}/${totalSteps}: ${task.title}`);
795
+ console.log(` Reason: ${conf.reason || 'blocked by confirmation policy'}`);
796
+ console.log(' Use --yolo to bypass, or adjust your profile.\n');
797
+ runRecord.status = 'aborted';
798
+ runRecord.completed_at = new Date().toISOString();
799
+ runRecord.total_duration_ms = Date.now() - new Date(startedAt).getTime();
800
+ runRecord.files_changed = [...allChangedFiles];
801
+ const fpath = writeRunRecord(runRecord);
802
+ printFinalSummary(runRecord, fpath, null);
803
+ return runRecord;
804
+ }
805
+
806
+ if (conf.shouldConfirm) {
807
+ const confirmMsg = cp && cp.formatConfirmation
808
+ ? cp.formatConfirmation('edit', task.risk, conf.reason)
809
+ : `\n Continue to step ${index}/${totalSteps}? [Y/n] `;
810
+ const go = await (async () => {
811
+ if (!yes) {
812
+ const answer = await prompt(confirmMsg);
813
+ return answer === '' || /^y(es)?$/i.test(answer);
814
+ }
815
+ return true;
816
+ })();
817
+ if (!go) {
818
+ console.log('\n Aborted before step', index, '\n');
819
+ runRecord.status = 'aborted';
820
+ runRecord.completed_at = new Date().toISOString();
821
+ runRecord.total_duration_ms = Date.now() - new Date(startedAt).getTime();
822
+ runRecord.files_changed = [...allChangedFiles];
823
+ const fpath = writeRunRecord(runRecord);
824
+ printFinalSummary(runRecord, fpath, null);
825
+ return runRecord;
826
+ }
827
+ }
828
+ }
829
+
830
+ const via = chainName ? `chain:${chainName}` : `template:${templateName}`;
831
+ console.log(`\n [Step ${index}/${totalSteps}] ${task.title}... (${tierLabel}) ${riskBadge}`);
832
+ console.log(` Via: ${via}`);
833
+ console.log(' ' + '─'.repeat(62));
834
+
835
+ const statBefore = gitDiffStat();
836
+ const stepStart = Date.now();
837
+
838
+ let exitStatus = 0;
839
+ let retrying = true;
840
+ let stepStatus = 'done';
841
+
842
+ while (retrying) {
843
+ retrying = false;
844
+
845
+ let result;
846
+ if (chainName) {
847
+ result = spawnChain(chainName, task, yes || yolo);
848
+ } else {
849
+ result = spawnTemplate(templateName, task);
850
+ }
851
+
852
+ exitStatus = result.status ?? 0;
853
+
854
+ if (exitStatus !== 0) {
855
+ console.log(`\n Step ${index} exited with code ${exitStatus}.`);
856
+ if (yes || yolo) {
857
+ console.log(' [auto] Aborting on failure.');
858
+ stepStatus = 'failed';
859
+ } else {
860
+ const choice = await askOnFailure(index);
861
+ if (choice === 'retry') {
862
+ console.log(' Retrying...\n');
863
+ retrying = true;
864
+ } else if (choice === 'skip') {
865
+ console.log(' Skipping step.\n');
866
+ stepStatus = 'skipped';
867
+ } else {
868
+ stepStatus = 'failed';
869
+ console.log(' Aborting.\n');
870
+ }
871
+ }
872
+ }
873
+ }
874
+
875
+ const stepDuration = Date.now() - stepStart;
876
+ const statAfter = gitDiffStat();
877
+ const filesChanged = statAfter !== statBefore ? parseChangedFiles(statAfter) : [];
878
+ for (const f of filesChanged) allChangedFiles.add(f);
879
+
880
+ runRecord.steps.push({
881
+ task: task.title,
882
+ template: chainName || templateName,
883
+ risk: task.risk,
884
+ status: stepStatus,
885
+ files_changed: filesChanged,
886
+ duration_ms: stepDuration,
887
+ });
888
+
889
+ if (filesChanged.length > 0) {
890
+ console.log(`\n Files changed: ${filesChanged.join(', ')}`);
891
+ }
892
+ console.log(` Step ${index} ${stepStatus} in ${fmtDuration(stepDuration)}`);
893
+
894
+ if (stepStatus === 'failed') {
895
+ runRecord.status = 'failed';
896
+ runRecord.completed_at = new Date().toISOString();
897
+ runRecord.total_duration_ms = Date.now() - new Date(startedAt).getTime();
898
+ runRecord.files_changed = [...allChangedFiles];
899
+ const fpath = writeRunRecord(runRecord);
900
+ printFinalSummary(runRecord, fpath, null);
901
+ return runRecord;
902
+ }
903
+ }
904
+
905
+ runRecord.status = 'completed';
906
+ runRecord.completed_at = new Date().toISOString();
907
+ runRecord.total_duration_ms = Date.now() - new Date(startedAt).getTime();
908
+ runRecord.files_changed = [...allChangedFiles];
909
+
910
+ // ── Ship Gate Pipeline ─────────────────────────────────────────────────
911
+ // Check aggregate risk vs confirmation policy before running gate/tests/PR
912
+ const aggRisk = cp && cp.aggregateRisk
913
+ ? cp.aggregateRisk(stepRisks)
914
+ : aggregateRiskFallback(stepRisks);
915
+
916
+ runRecord.aggregate_risk = aggRisk;
917
+
918
+ // Check if gate/test/pr steps are blocked
919
+ const gateConf = await checkStepConfirmation(cp, { risk: aggRisk, mode, stepName: 'gate' });
920
+ const prConf = await checkStepConfirmation(cp, { risk: aggRisk, mode, stepName: 'pr' });
921
+
922
+ const isBlocked = (gateConf.shouldBlock || prConf.shouldBlock) && mode !== 'yolo';
923
+
924
+ let shipGateResult = null;
925
+
926
+ if (isBlocked) {
927
+ console.log('\n [WARNING] Ship gate blocked by confirmation policy.');
928
+ const reason = gateConf.shouldBlock
929
+ ? (gateConf.reason || 'critical risk requires manual review')
930
+ : (prConf.reason || 'PR creation requires manual approval');
931
+ console.log(` Reason: ${reason}`);
932
+ console.log(' Use --yolo to bypass, or run manually:');
933
+ console.log(' npx dual-brain gate');
934
+ console.log(' npx dual-brain ship\n');
935
+ runRecord.ship_gate = { status: 'blocked', reason };
936
+ } else {
937
+ // Run the full ship gate pipeline
938
+ console.log('\n Running ship gate (tests → quality gate → PR)...');
939
+ shipGateResult = await runShipGatePipeline(goal, runRecord, {
940
+ yes: yes || yolo,
941
+ noPr,
942
+ });
943
+
944
+ // Self-heal failing tests (if ship gate ran but tests failed).
945
+ // Heal ownership:
946
+ // - selfHealTests (below) owns test failures. It fixes code and re-runs tests.
947
+ // - selfHealGate (ship-gate.mjs) owns quality gate issues. It fixes issues and re-runs the gate.
948
+ // runShipGate returns 'tests_failed' without touching gate healing, so there is no
949
+ // circular heal: tests are fixed here first, then the full gate runs again fresh.
950
+ if (shipGateResult && shipGateResult.status === 'tests_failed' && shipGateResult.tests) {
951
+ const fakeTestResult = {
952
+ passed: shipGateResult.tests.passed ?? false,
953
+ output: shipGateResult.tests.output ?? '',
954
+ command_used: shipGateResult.tests.command ?? null,
955
+ exit_code: null,
956
+ duration_ms: 0,
957
+ };
958
+ const healResult = await selfHealTests(fakeTestResult, { maxRetries: 2 });
959
+ runRecord.test_heal = { healed: healResult.healed, attempts: healResult.attempts };
960
+
961
+ if (healResult.healed) {
962
+ // Tests now pass — re-run the full ship gate pipeline
963
+ console.log('\n Tests fixed — re-running ship gate...');
964
+ shipGateResult = await runShipGatePipeline(goal, runRecord, {
965
+ yes: yes || yolo,
966
+ noPr,
967
+ });
968
+ } else {
969
+ // Could not fix — prompt user to intervene or abort
970
+ if (!yes && !yolo) {
971
+ const answer = await prompt('\n Could not auto-fix tests. [C]ontinue anyway / [A]bort? ');
972
+ if (/^a(bort)?$/i.test(answer.trim())) {
973
+ console.log(' Aborted.');
974
+ runRecord.status = 'failed';
975
+ runRecord.completed_at = new Date().toISOString();
976
+ runRecord.total_duration_ms = Date.now() - new Date(startedAt).getTime();
977
+ runRecord.ship_gate = shipGateResult;
978
+ const fpath = writeRunRecord(runRecord);
979
+ printFinalSummary(runRecord, fpath, shipGateResult);
980
+ return runRecord;
981
+ }
982
+ console.log(' Continuing with failing tests...');
983
+ } else {
984
+ console.log(' [auto] Could not fix tests — continuing with failing tests (--yes/--yolo).');
985
+ }
986
+ }
987
+ }
988
+
989
+ runRecord.ship_gate = shipGateResult;
990
+
991
+ if (shipGateResult && shipGateResult.status === 'skipped') {
992
+ console.log(`\n [INFO] Ship gate skipped: ${shipGateResult.reason}`);
993
+ console.log(' Next: npx dual-brain gate (run quality gate)');
994
+ console.log(' npx dual-brain ship (create branch + PR)\n');
995
+ }
996
+ }
997
+
998
+ const fpath = writeRunRecord(runRecord);
999
+ printFinalSummary(runRecord, fpath, shipGateResult);
1000
+ return runRecord;
1001
+ }
1002
+
1003
+ // ─── Final Summary ────────────────────────────────────────────────────────
1004
+
1005
+ function printFinalSummary(record, fpath, shipGateResult) {
1006
+ const hr = '━'.repeat(50);
1007
+ const completedSteps = record.steps.filter(s => s.status === 'done' || s.status === 'skipped').length;
1008
+ const totalSteps = record.steps.length;
1009
+ const relPath = fpath
1010
+ ? fpath.replace(process.cwd() + '/', '')
1011
+ : '.claude/runs/[not written]';
1012
+
1013
+ const statusLabel = record.status === 'completed'
1014
+ ? 'Complete'
1015
+ : record.status.charAt(0).toUpperCase() + record.status.slice(1);
1016
+
1017
+ console.log(`\n${hr}`);
1018
+ console.log(` Ship Captain ${statusLabel}`);
1019
+ console.log(`${hr}`);
1020
+ console.log(` Goal: ${record.goal}`);
1021
+ console.log(` Steps: ${completedSteps}/${totalSteps} completed`);
1022
+ console.log(` Files changed: ${record.files_changed.length}`);
1023
+
1024
+ // Ship gate details (tests, gate, PR)
1025
+ if (shipGateResult && shipGateResult.status !== 'skipped') {
1026
+ // Tests
1027
+ const tests = shipGateResult.tests;
1028
+ if (tests) {
1029
+ if (tests.passed === null) {
1030
+ console.log(' Tests: not found');
1031
+ } else if (tests.passed) {
1032
+ console.log(` Tests: passed (${tests.command_used || 'npm test'})`);
1033
+ } else {
1034
+ console.log(` Tests: FAILED (exit ${tests.exit_code})`);
1035
+ }
1036
+ }
1037
+
1038
+ // Quality gate
1039
+ const gate = shipGateResult.gate;
1040
+ if (gate) {
1041
+ const gateStatus = gate.gate || gate.status || 'unknown';
1042
+ const gateRisk = gate.risk ? ` (${gate.risk} risk)` : '';
1043
+ console.log(` Quality gate: ${gateStatus}${gateRisk}`);
1044
+ }
1045
+
1046
+ // PR
1047
+ const pr = shipGateResult.pr;
1048
+ if (record.ship_gate && record.ship_gate.status === 'blocked') {
1049
+ console.log(' PR: skipped (use npx dual-brain ship)');
1050
+ } else if (noPrFlagFromRecord(record)) {
1051
+ console.log(' PR: skipped (--no-pr)');
1052
+ } else if (pr && pr.pr_url) {
1053
+ console.log(` PR: ${pr.pr_url}`);
1054
+ } else if (pr && pr.error) {
1055
+ console.log(` PR: failed — ${pr.error}`);
1056
+ } else if (pr && pr.branch) {
1057
+ console.log(` PR: skipped (use npx dual-brain ship)`);
1058
+ } else {
1059
+ console.log(' PR: skipped (use npx dual-brain ship)');
1060
+ }
1061
+ } else if (record.ship_gate && record.ship_gate.status === 'blocked') {
1062
+ console.log(' Tests: not run (blocked)');
1063
+ console.log(' Quality gate: not run (blocked)');
1064
+ console.log(' PR: skipped (use npx dual-brain ship)');
1065
+ } else {
1066
+ // Gate not run (skipped or not available)
1067
+ console.log(' Next: npx dual-brain gate (run quality gate)');
1068
+ console.log(' npx dual-brain ship (create branch + PR)');
1069
+ }
1070
+
1071
+ console.log(` Duration: ${fmtDuration(record.total_duration_ms)}`);
1072
+ console.log(` Run record: ${relPath}`);
1073
+ console.log(`${hr}\n`);
1074
+ }
1075
+
1076
+ function noPrFlagFromRecord(record) {
1077
+ // We can't easily recover noPr flag from the record alone; best effort
1078
+ return false;
1079
+ }
1080
+
1081
+ // ─── CLI Arg Parser ───────────────────────────────────────────────────────
1082
+
1083
+ function parseArgs(argv) {
1084
+ const opts = {
1085
+ goal: null,
1086
+ yes: false,
1087
+ dryRun: false,
1088
+ planOnly: false,
1089
+ provider: 'auto',
1090
+ yolo: false,
1091
+ careful: false,
1092
+ noPr: false,
1093
+ mode: null,
1094
+ forceExecute: false,
1095
+ };
1096
+ const positional = [];
1097
+
1098
+ for (let i = 0; i < argv.length; i++) {
1099
+ const a = argv[i];
1100
+ if (a === '--goal') {
1101
+ opts.goal = argv[++i];
1102
+ } else if (a === '--yes' || a === '-y') {
1103
+ opts.yes = true;
1104
+ } else if (a === '--dry-run') {
1105
+ opts.dryRun = true;
1106
+ } else if (a === '--plan-only') {
1107
+ opts.planOnly = true;
1108
+ } else if (a === '--provider') {
1109
+ opts.provider = argv[++i];
1110
+ } else if (a === '--yolo') {
1111
+ opts.yolo = true;
1112
+ } else if (a === '--careful') {
1113
+ opts.careful = true;
1114
+ } else if (a === '--no-pr') {
1115
+ opts.noPr = true;
1116
+ } else if (a === '--mode') {
1117
+ opts.mode = argv[++i];
1118
+ } else if (a === '--force-execute') {
1119
+ opts.forceExecute = true;
1120
+ } else if (!a.startsWith('--')) {
1121
+ positional.push(a);
1122
+ }
1123
+ }
1124
+
1125
+ if (!opts.goal && positional.length > 0) {
1126
+ opts.goal = positional.join(' ');
1127
+ }
1128
+
1129
+ return opts;
1130
+ }
1131
+
1132
+ // ─── Exports ──────────────────────────────────────────────────────────────
1133
+
1134
+ export { planExecution, executeShipCaptain, classifyGoalIntent };
1135
+
1136
+ // ─── CLI Entry ────────────────────────────────────────────────────────────
1137
+
1138
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
1139
+ const opts = parseArgs(process.argv.slice(2));
1140
+
1141
+ if (!opts.goal) {
1142
+ console.error(`
1143
+ Usage:
1144
+ node hooks/ship-captain.mjs "fix the auth bug and write tests"
1145
+ node hooks/ship-captain.mjs --goal "..." [--yes] [--dry-run] [--plan-only]
1146
+ [--provider claude|gpt|auto]
1147
+ [--yolo] [--careful] [--no-pr]
1148
+ [--mode <profile>] [--force-execute]
1149
+
1150
+ Intent routing (auto-detected, override with --force-execute):
1151
+ think → dual-brain-think (architecture questions)
1152
+ review → dual-brain-review (code review / audit)
1153
+ explore → agent-templates explorer (find / explain)
1154
+ ship → ship-gate --ship (create PR)
1155
+ execute → ship captain pipeline (fix / build / write / update)
1156
+ `);
1157
+ process.exit(1);
1158
+ }
1159
+
1160
+ executeShipCaptain(opts.goal, {
1161
+ yes: opts.yes,
1162
+ dryRun: opts.dryRun,
1163
+ planOnly: opts.planOnly,
1164
+ provider: opts.provider,
1165
+ yolo: opts.yolo,
1166
+ careful: opts.careful,
1167
+ noPr: opts.noPr,
1168
+ mode: opts.mode,
1169
+ forceExecute: opts.forceExecute,
1170
+ }).then((record) => {
1171
+ process.exit(record.status === 'completed' || record.status === 'dry_run' ? 0 : 1);
1172
+ }).catch((err) => {
1173
+ console.error('\n Fatal error:', err.message);
1174
+ process.exit(1);
1175
+ });
1176
+ }