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.
- package/CLAUDE.md +130 -35
- package/README.md +171 -44
- package/hooks/agent-chains.mjs +369 -0
- package/hooks/agent-templates.mjs +441 -0
- package/hooks/atomic-write.mjs +5 -3
- package/hooks/config-validator.mjs +156 -0
- package/hooks/confirmation-policy.mjs +167 -0
- package/hooks/cost-logger.mjs +32 -12
- package/hooks/cost-report.mjs +60 -114
- package/hooks/decision-ledger.mjs +3 -2
- package/hooks/dual-brain-review.mjs +249 -2
- package/hooks/dual-brain-think.mjs +294 -25
- package/hooks/enforce-tier.mjs +246 -87
- package/hooks/error-channel.mjs +68 -0
- package/hooks/failure-detector.mjs +2 -1
- package/hooks/health-check.mjs +16 -17
- package/hooks/risk-classifier.mjs +135 -2
- package/hooks/session-report.mjs +41 -71
- package/hooks/ship-captain.mjs +1176 -0
- package/hooks/ship-gate.mjs +971 -0
- package/hooks/summary-checkpoint.mjs +31 -4
- package/hooks/test-orchestrator.mjs +1975 -11
- package/install.mjs +1064 -31
- package/orchestrator.json +73 -96
- package/package.json +7 -2
|
@@ -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
|
+
}
|