@hone-ai/cli 1.9.0 → 1.11.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/hone-cli.js +617 -106
- package/lib/compare-reviews.js +279 -0
- package/lib/parse-review-json.js +87 -0
- package/lib/refresh-knowledge.js +4 -1
- package/lib/release-review-config.js +98 -0
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -25,6 +25,8 @@ const crypto = require('crypto');
|
|
|
25
25
|
const { execSync } = require('child_process');
|
|
26
26
|
|
|
27
27
|
const pkg = require('./package.json');
|
|
28
|
+
const { parseReviewJSON } = require('./lib/parse-review-json');
|
|
29
|
+
const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
|
|
28
30
|
const program = new Command();
|
|
29
31
|
|
|
30
32
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
@@ -385,7 +387,12 @@ program
|
|
|
385
387
|
process.exit(1);
|
|
386
388
|
}
|
|
387
389
|
|
|
388
|
-
// ── Phase 1b: Install
|
|
390
|
+
// ── Phase 1b: Install CLAUDE.md ──────────────────────────────────────────
|
|
391
|
+
// HC-019y: removed the install-time .github/agents/ -> .claude/agents/
|
|
392
|
+
// mirror. The bash setup script now writes agents directly to
|
|
393
|
+
// .claude/agents/ (the path Claude Code actually reads). Pre-fix the
|
|
394
|
+
// mirror ran only at install time, so post-install edits to
|
|
395
|
+
// .github/agents/ were silently dropped (OptionsFlow E34-A: 6+ PRs).
|
|
389
396
|
const repoRoot = process.cwd();
|
|
390
397
|
const claudeMdSrc = path.join(tmpDir, 'CLAUDE.md');
|
|
391
398
|
const claudeMdDst = path.join(repoRoot, 'CLAUDE.md');
|
|
@@ -400,20 +407,6 @@ program
|
|
|
400
407
|
}
|
|
401
408
|
}
|
|
402
409
|
|
|
403
|
-
// Create .claude/agents/ with symlinks or copies of .github/agents/
|
|
404
|
-
const ghAgentsDir = path.join(repoRoot, '.github', 'agents');
|
|
405
|
-
const claudeAgentsDir = path.join(repoRoot, '.claude', 'agents');
|
|
406
|
-
if (fs.existsSync(ghAgentsDir)) {
|
|
407
|
-
fs.mkdirSync(claudeAgentsDir, { recursive: true });
|
|
408
|
-
const agentFiles = fs.readdirSync(ghAgentsDir).filter(f => f.endsWith('.agent.md'));
|
|
409
|
-
for (const file of agentFiles) {
|
|
410
|
-
const src = path.join(ghAgentsDir, file);
|
|
411
|
-
const dst = path.join(claudeAgentsDir, file);
|
|
412
|
-
fs.copyFileSync(src, dst);
|
|
413
|
-
}
|
|
414
|
-
console.log(` ✓ .claude/agents/ created (${agentFiles.length} agents mirrored from .github/agents/)`);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
410
|
// ── Phase 1c: Platform grounding (HC-013b-setup) ──────────────────────
|
|
418
411
|
// Runs AFTER bash script writes .pipeline-config.yml.
|
|
419
412
|
// Discovers metadata types, detects MCP, selects doc registry URLs,
|
|
@@ -1503,7 +1496,12 @@ program
|
|
|
1503
1496
|
}
|
|
1504
1497
|
}
|
|
1505
1498
|
|
|
1506
|
-
// ── 2. Sync agent prompts into .
|
|
1499
|
+
// ── 2. Sync agent prompts into .claude/agents/*.agent.md ───────────────
|
|
1500
|
+
// HC-019y: writes directly to .claude/agents/ (the path Claude Code
|
|
1501
|
+
// actually reads). Pre-fix `hone sync` wrote to .github/agents/ and
|
|
1502
|
+
// mirrored to .claude/agents/, but the dual-directory layout caused
|
|
1503
|
+
// OptionsFlow E34-A's silent-drop trap when adopters edited the wrong
|
|
1504
|
+
// path. Mirror eliminated; .github/agents/ is now legacy.
|
|
1507
1505
|
if (!opts.skillsOnly) {
|
|
1508
1506
|
const agents = [
|
|
1509
1507
|
'story-groomer',
|
|
@@ -1518,9 +1516,16 @@ program
|
|
|
1518
1516
|
];
|
|
1519
1517
|
|
|
1520
1518
|
console.log('\nSyncing agent prompts...');
|
|
1521
|
-
const agentsDir = path.join(process.cwd(), '.
|
|
1519
|
+
const agentsDir = path.join(process.cwd(), '.claude', 'agents');
|
|
1522
1520
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
1523
1521
|
|
|
1522
|
+
// Legacy-path migration warning (one-time, non-destructive).
|
|
1523
|
+
const legacyGhAgents = path.join(process.cwd(), '.github', 'agents');
|
|
1524
|
+
if (fs.existsSync(legacyGhAgents)) {
|
|
1525
|
+
console.log(' ⚠ Legacy .github/agents/ detected — Claude Code reads .claude/agents/');
|
|
1526
|
+
console.log(' Migrate any REPO-SPECIFIC edits then `rm -rf .github/agents/`');
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1524
1529
|
let synced = 0;
|
|
1525
1530
|
for (const agent of agents) {
|
|
1526
1531
|
try {
|
|
@@ -1529,7 +1534,7 @@ program
|
|
|
1529
1534
|
headers: { Accept: 'text/markdown' },
|
|
1530
1535
|
});
|
|
1531
1536
|
const dest = path.join(agentsDir, `${agent}.agent.md`);
|
|
1532
|
-
const rel =
|
|
1537
|
+
const rel = `.claude/agents/${agent}.agent.md`;
|
|
1533
1538
|
const result = safeWrite(dest, rel, r.data);
|
|
1534
1539
|
if (result !== 'skipped') {
|
|
1535
1540
|
console.log(` ✓ ${rel}`);
|
|
@@ -1537,28 +1542,13 @@ program
|
|
|
1537
1542
|
}
|
|
1538
1543
|
} catch (e) {
|
|
1539
1544
|
if (e.response?.status === 404) {
|
|
1540
|
-
console.log(` ⚠ agents/${agent}.agent.md — prompt not seeded yet (run seed-agent-prompts.js)`);
|
|
1545
|
+
console.log(` ⚠ .claude/agents/${agent}.agent.md — prompt not seeded yet (run seed-agent-prompts.js)`);
|
|
1541
1546
|
} else {
|
|
1542
|
-
console.log(` ⚠ agents/${agent}.agent.md — ${e.message}`);
|
|
1547
|
+
console.log(` ⚠ .claude/agents/${agent}.agent.md — ${e.message}`);
|
|
1543
1548
|
}
|
|
1544
1549
|
}
|
|
1545
1550
|
}
|
|
1546
1551
|
if (synced > 0) console.log(`\nSynced ${synced} agent prompts.`);
|
|
1547
|
-
|
|
1548
|
-
// Mirror to .claude/agents/ for Claude Code. This mirror is a derived
|
|
1549
|
-
// copy of .github/agents/, so it's safe to overwrite — local edits
|
|
1550
|
-
// belong in the source-of-truth dir. We still respect skipped files:
|
|
1551
|
-
// if the source was skipped, don't overwrite the mirror either.
|
|
1552
|
-
const claudeAgentsDir = path.join(process.cwd(), '.claude', 'agents');
|
|
1553
|
-
const ghAgentsDir = path.join(process.cwd(), '.github', 'agents');
|
|
1554
|
-
if (fs.existsSync(ghAgentsDir)) {
|
|
1555
|
-
fs.mkdirSync(claudeAgentsDir, { recursive: true });
|
|
1556
|
-
const files = fs.readdirSync(ghAgentsDir).filter(f => f.endsWith('.agent.md'));
|
|
1557
|
-
for (const f of files) {
|
|
1558
|
-
fs.copyFileSync(path.join(ghAgentsDir, f), path.join(claudeAgentsDir, f));
|
|
1559
|
-
}
|
|
1560
|
-
console.log(` ✓ .claude/agents/ mirrored (${files.length} agents)`);
|
|
1561
|
-
}
|
|
1562
1552
|
}
|
|
1563
1553
|
|
|
1564
1554
|
// ── 3. Sync copilot-instructions.md from server ──────────────────────
|
|
@@ -2027,7 +2017,7 @@ program
|
|
|
2027
2017
|
console.log('╚══════════════════════════════════════════════╝');
|
|
2028
2018
|
console.log('');
|
|
2029
2019
|
console.log(' Your repo now has:');
|
|
2030
|
-
console.log(' • 9 agents (.
|
|
2020
|
+
console.log(' • 9 agents (.claude/agents/)');
|
|
2031
2021
|
console.log(' • 11 enterprise skills + domain skills');
|
|
2032
2022
|
console.log(' • CI workflow (ai-review.yml)');
|
|
2033
2023
|
console.log(' • CLAUDE.md + copilot-instructions.md');
|
|
@@ -4156,8 +4146,8 @@ program
|
|
|
4156
4146
|
const fs = require('fs');
|
|
4157
4147
|
const yaml = require('js-yaml');
|
|
4158
4148
|
|
|
4159
|
-
// Load agent prompts from seed-agent-prompts.js
|
|
4160
|
-
const seedPath = path.resolve(__dirname, '..', 'scripts', 'seed-agent-prompts.js');
|
|
4149
|
+
// Load agent prompts from seed-agent-prompts.js (HC-019n-hotfix: moved into /server/)
|
|
4150
|
+
const seedPath = path.resolve(__dirname, '..', 'server', 'scripts', 'seed-agent-prompts.js');
|
|
4161
4151
|
const { AGENT_PROMPTS } = require(seedPath);
|
|
4162
4152
|
const evalDir = opts.evalsDir || path.resolve(__dirname, '..', 'evals');
|
|
4163
4153
|
|
|
@@ -4513,7 +4503,379 @@ process.on('SIGINT', () => {
|
|
|
4513
4503
|
process.exit(0);
|
|
4514
4504
|
});
|
|
4515
4505
|
|
|
4506
|
+
// ── HC-053: Batch story queue (Epic 6 — Autonomous Mode) ─────────────────────
|
|
4507
|
+
//
|
|
4508
|
+
// Fire-and-forget submission of N stories. Each story runs as an autonomous
|
|
4509
|
+
// workflow in 'batch' mode (auto-approves early gates per workflow-dag).
|
|
4510
|
+
// One failure does NOT halt siblings.
|
|
4511
|
+
//
|
|
4512
|
+
// hone queue-stories --file stories.txt
|
|
4513
|
+
// hone queue-stories --status <batchId>
|
|
4514
|
+
// hone queue-stories --list
|
|
4515
|
+
program
|
|
4516
|
+
.command('queue-stories')
|
|
4517
|
+
.description('Queue a batch of stories for autonomous (overnight) execution')
|
|
4518
|
+
.option('--file <path>', 'Text file with one story ID per line (cap: 50 lines, 16KB)')
|
|
4519
|
+
.option('--repo <name>', 'Repository name applied to every story (default: directory name)')
|
|
4520
|
+
.option('--branch <name>', 'Git branch applied to every story (default: current branch)')
|
|
4521
|
+
.option('--status <batchId>', 'Show status of a previously submitted batch')
|
|
4522
|
+
.option('--report <batchId>', 'Fetch the morning report for a batch (markdown by default)')
|
|
4523
|
+
.option('--save <path>', 'When used with --report, save markdown to a file instead of stdout')
|
|
4524
|
+
.option('--list', 'List recent batches for this org')
|
|
4525
|
+
.option('--limit <n>', 'Number of batches to list (default 20, max 100)', '20')
|
|
4526
|
+
.option('--format <fmt>', 'Output format: pretty or json (with --report, also: md)', 'pretty')
|
|
4527
|
+
// HC-054: Night Shift mode. Conditional auto-approve at step_4/step_5
|
|
4528
|
+
// when the agent output passes isCleanOutput's positive allowlist +
|
|
4529
|
+
// inverse blocklist. Extended LLM timeout (480s), orchestrator watchdog
|
|
4530
|
+
// (900s/step), default token budget of 400K × story_count when none
|
|
4531
|
+
// explicitly set. Cap of 25 stories per overnight batch.
|
|
4532
|
+
.option('--overnight', 'Enable Night Shift mode (conditional auto-approve, longer timeouts, max 25 stories)', false)
|
|
4533
|
+
.action(async (opts) => {
|
|
4534
|
+
const config = getConfig();
|
|
4535
|
+
const client = api(config);
|
|
4536
|
+
|
|
4537
|
+
// Enforce mutually-exclusive modes — combining e.g. --list with --report
|
|
4538
|
+
// would silently drop one. Explicit error is friendlier than 'silently
|
|
4539
|
+
// did nothing' or 'silently did the wrong thing'.
|
|
4540
|
+
const modes = [];
|
|
4541
|
+
if (opts.list) modes.push('--list');
|
|
4542
|
+
if (opts.status) modes.push('--status');
|
|
4543
|
+
if (opts.report) modes.push('--report');
|
|
4544
|
+
if (opts.file) modes.push('--file');
|
|
4545
|
+
if (modes.length > 1) {
|
|
4546
|
+
console.error(`These flags are mutually exclusive: ${modes.join(', ')}`);
|
|
4547
|
+
console.error('Specify only one of --file, --status, --report, --list.');
|
|
4548
|
+
process.exit(1);
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
// ── --list ────────────────────────────────────────────────
|
|
4552
|
+
if (opts.list) {
|
|
4553
|
+
try {
|
|
4554
|
+
const { data } = await client.get(`/orchestrate/batch`, { params: { limit: opts.limit } });
|
|
4555
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
4556
|
+
if (!data.batches || data.batches.length === 0) {
|
|
4557
|
+
console.log('No batches found.');
|
|
4558
|
+
return;
|
|
4559
|
+
}
|
|
4560
|
+
console.log('');
|
|
4561
|
+
console.log('Recent batches:');
|
|
4562
|
+
for (const b of data.batches) {
|
|
4563
|
+
console.log(` ${b.batchId} status=${b.status} stories=${b.storyCount} created=${b.createdAt}`);
|
|
4564
|
+
}
|
|
4565
|
+
console.log('');
|
|
4566
|
+
} catch (e) {
|
|
4567
|
+
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
4568
|
+
process.exit(1);
|
|
4569
|
+
}
|
|
4570
|
+
return;
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
// ── --report <batchId> (HC-055) ──────────────────────────
|
|
4574
|
+
if (opts.report) {
|
|
4575
|
+
try {
|
|
4576
|
+
const format = opts.format === 'json' ? 'json' : 'md';
|
|
4577
|
+
const { data, headers } = await client.get(
|
|
4578
|
+
`/orchestrate/batch/${opts.report}/report`,
|
|
4579
|
+
{ params: { format }, responseType: 'text', transformResponse: [(d) => d] }
|
|
4580
|
+
);
|
|
4581
|
+
if (format === 'json') {
|
|
4582
|
+
// JSON path — parse and pretty-print, or save raw.
|
|
4583
|
+
if (opts.save) {
|
|
4584
|
+
fs.writeFileSync(opts.save, data, 'utf8');
|
|
4585
|
+
console.log(`Saved JSON report to ${opts.save}`);
|
|
4586
|
+
} else {
|
|
4587
|
+
try { console.log(JSON.stringify(JSON.parse(data), null, 2)); }
|
|
4588
|
+
catch { console.log(data); }
|
|
4589
|
+
}
|
|
4590
|
+
} else {
|
|
4591
|
+
// Markdown path.
|
|
4592
|
+
if (opts.save) {
|
|
4593
|
+
fs.writeFileSync(opts.save, data, 'utf8');
|
|
4594
|
+
const cached = headers['x-report-cached'] === 'true' ? ' (cached)' : '';
|
|
4595
|
+
console.log(`Saved markdown report${cached} to ${opts.save}`);
|
|
4596
|
+
} else {
|
|
4597
|
+
// Guard: axios with responseType:'text' is expected to return a
|
|
4598
|
+
// string, but a server regression or unusual proxy could send
|
|
4599
|
+
// an empty/undefined body. Don't crash trying to print it.
|
|
4600
|
+
const text = typeof data === 'string' ? data : (data == null ? '' : String(data));
|
|
4601
|
+
process.stdout.write(text);
|
|
4602
|
+
if (!text.endsWith('\n')) process.stdout.write('\n');
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
} catch (e) {
|
|
4606
|
+
if (e.response?.status === 404) {
|
|
4607
|
+
console.error('Batch not found.');
|
|
4608
|
+
} else {
|
|
4609
|
+
// responseType:'text' keeps the body as a raw string even for JSON
|
|
4610
|
+
// error responses, so e.response.data is the unparsed `{"error":...}`
|
|
4611
|
+
// and `.error` would be undefined. Parse string bodies before reading.
|
|
4612
|
+
let serverError;
|
|
4613
|
+
const rawData = e.response?.data;
|
|
4614
|
+
if (typeof rawData === 'string') {
|
|
4615
|
+
try { serverError = JSON.parse(rawData)?.error; } catch { /* not JSON */ }
|
|
4616
|
+
} else {
|
|
4617
|
+
serverError = rawData?.error;
|
|
4618
|
+
}
|
|
4619
|
+
console.error(`Failed: ${serverError || e.message}`);
|
|
4620
|
+
}
|
|
4621
|
+
process.exit(1);
|
|
4622
|
+
}
|
|
4623
|
+
return;
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
// ── --status <batchId> ────────────────────────────────────
|
|
4627
|
+
if (opts.status) {
|
|
4628
|
+
try {
|
|
4629
|
+
const { data } = await client.get(`/orchestrate/batch/${opts.status}`);
|
|
4630
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
4631
|
+
console.log('');
|
|
4632
|
+
console.log(`Batch: ${data.batchId}`);
|
|
4633
|
+
console.log(`Status: ${data.status}`);
|
|
4634
|
+
console.log(`Stories: ${data.storyCount}`);
|
|
4635
|
+
console.log(`Tokens: ${(data.totalTokens || 0).toLocaleString()}`);
|
|
4636
|
+
const c = data.counts || {};
|
|
4637
|
+
console.log(`Counts: completed=${c.completed||0} running=${c.running||0} paused=${c.paused||0} queued=${c.queued||0} failed=${c.failed||0} killed=${c.killed||0} stalled=${c.stalled||0}`);
|
|
4638
|
+
console.log('');
|
|
4639
|
+
console.log('Runs:');
|
|
4640
|
+
for (const r of (data.runs || [])) {
|
|
4641
|
+
const icon = r.status === 'completed' ? '✓'
|
|
4642
|
+
: r.status === 'running' ? '⏳'
|
|
4643
|
+
: r.status === 'paused' ? '⏸'
|
|
4644
|
+
: r.status === 'failed' ? '✗'
|
|
4645
|
+
: r.status === 'killed' ? '☠'
|
|
4646
|
+
: r.status === 'stalled' ? '⚠'
|
|
4647
|
+
: '⬜';
|
|
4648
|
+
const step = r.currentStep ? ` @${r.currentStep}` : '';
|
|
4649
|
+
const err = r.errorMessage ? ` err=${r.errorMessage}` : '';
|
|
4650
|
+
console.log(` ${icon} ${r.storyId} (${r.repoName}) ${r.status}${step}${err}`);
|
|
4651
|
+
}
|
|
4652
|
+
console.log('');
|
|
4653
|
+
} catch (e) {
|
|
4654
|
+
if (e.response?.status === 404) console.error('Batch not found.');
|
|
4655
|
+
else console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
4656
|
+
process.exit(1);
|
|
4657
|
+
}
|
|
4658
|
+
return;
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
// ── Submit a new batch from --file ─────────────────────────
|
|
4662
|
+
if (!opts.file) {
|
|
4663
|
+
console.error('Specify --file <path>, --status <batchId>, --report <batchId>, or --list.');
|
|
4664
|
+
console.error('Example: hone queue-stories --file stories.txt');
|
|
4665
|
+
process.exit(1);
|
|
4666
|
+
}
|
|
4667
|
+
|
|
4668
|
+
let storyIds;
|
|
4669
|
+
try {
|
|
4670
|
+
const stat = fs.statSync(opts.file);
|
|
4671
|
+
if (stat.size > 16 * 1024) {
|
|
4672
|
+
console.error(`File too large: ${stat.size} bytes (max 16KB).`);
|
|
4673
|
+
process.exit(1);
|
|
4674
|
+
}
|
|
4675
|
+
// Strip UTF-8 BOM (U+FEFF) that Windows editors prepend; trim()
|
|
4676
|
+
// does not remove it, so without this the first storyId becomes
|
|
4677
|
+
// an invisible-prefixed string that fails downstream lookups.
|
|
4678
|
+
const raw = fs.readFileSync(opts.file, 'utf8').replace(/^\uFEFF/, '');
|
|
4679
|
+
// HC-059: each non-comment line is `STORY-ID` OR
|
|
4680
|
+
// `STORY-ID depends:DEP1,DEP2`. Whitespace tolerant. Empty lines and
|
|
4681
|
+
// lines starting with # are ignored.
|
|
4682
|
+
storyIds = raw.split(/\r?\n/)
|
|
4683
|
+
.map(l => l.trim())
|
|
4684
|
+
.filter(l => l && !l.startsWith('#'));
|
|
4685
|
+
} catch (e) {
|
|
4686
|
+
console.error(`Cannot read --file: ${e.message}`);
|
|
4687
|
+
process.exit(1);
|
|
4688
|
+
}
|
|
4689
|
+
if (storyIds.length === 0) {
|
|
4690
|
+
console.error('No story IDs found in file.');
|
|
4691
|
+
process.exit(1);
|
|
4692
|
+
}
|
|
4693
|
+
if (storyIds.length > 50) {
|
|
4694
|
+
console.error(`Too many stories: ${storyIds.length} (max 50 per batch).`);
|
|
4695
|
+
process.exit(1);
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
const repoName = opts.repo || path.basename(process.cwd());
|
|
4699
|
+
let branch = opts.branch;
|
|
4700
|
+
if (!branch) {
|
|
4701
|
+
try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); }
|
|
4702
|
+
catch { branch = null; }
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4705
|
+
// HC-059: parse `STORY-A depends:STORY-B,STORY-C` per-line syntax. The
|
|
4706
|
+
// `depends:` token is case-sensitive and must come AFTER the storyId.
|
|
4707
|
+
// Multiple deps separated by commas, whitespace tolerant. Lines without
|
|
4708
|
+
// `depends:` yield no `dependsOn` (server validates absence vs empty).
|
|
4709
|
+
const stories = storyIds.map(line => {
|
|
4710
|
+
const depsMatch = line.match(/^(\S+)\s+depends:(\S+)\s*$/);
|
|
4711
|
+
if (depsMatch) {
|
|
4712
|
+
const [, id, depsCsv] = depsMatch;
|
|
4713
|
+
const dependsOn = depsCsv.split(',').map(s => s.trim()).filter(Boolean);
|
|
4714
|
+
return { storyId: id, repoName, branch, dependsOn };
|
|
4715
|
+
}
|
|
4716
|
+
// Reject ambiguous lines (storyId followed by garbage) \u2014 better than
|
|
4717
|
+
// silently treating `STORY-A something` as just `STORY-A`.
|
|
4718
|
+
if (/\s/.test(line)) {
|
|
4719
|
+
console.error(`Malformed line in --file: "${line}"`);
|
|
4720
|
+
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
4721
|
+
process.exit(1);
|
|
4722
|
+
}
|
|
4723
|
+
return { storyId: line, repoName, branch };
|
|
4724
|
+
});
|
|
4725
|
+
|
|
4726
|
+
// HC-054: Night Shift opt-in. config.overnight=true plumbs end-to-end
|
|
4727
|
+
// (server validates the 25-story cap + applies default token budget +
|
|
4728
|
+
// denormalizes flag into each child's workflow_runs.config).
|
|
4729
|
+
const body = { stories };
|
|
4730
|
+
if (opts.overnight) {
|
|
4731
|
+
body.config = { overnight: true };
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
try {
|
|
4735
|
+
const { data } = await client.post('/orchestrate/batch', body);
|
|
4736
|
+
|
|
4737
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
4738
|
+
|
|
4739
|
+
console.log('');
|
|
4740
|
+
console.log(`Batch queued: ${data.batchId}`);
|
|
4741
|
+
console.log(`Stories: ${data.storyCount} Enqueued: ${data.enqueued}` +
|
|
4742
|
+
(data.enqueueFailed ? ` Enqueue-failed: ${data.enqueueFailed}` : ''));
|
|
4743
|
+
console.log('');
|
|
4744
|
+
console.log('Stories will run autonomously in batch mode (early gates auto-approve).');
|
|
4745
|
+
console.log('Steps that require human approval (step_4, step_5) will pause those runs.');
|
|
4746
|
+
console.log('');
|
|
4747
|
+
console.log(`Check progress: hone queue-stories --status ${data.batchId}`);
|
|
4748
|
+
console.log(`Per-story: hone run-story <runId> --status`);
|
|
4749
|
+
console.log('');
|
|
4750
|
+
} catch (e) {
|
|
4751
|
+
if (e.response?.status === 429) {
|
|
4752
|
+
console.error(`Org concurrency limit reached: ${e.response.data.error}`);
|
|
4753
|
+
} else if (e.response?.status === 400) {
|
|
4754
|
+
console.error(`Rejected: ${e.response.data.error}`);
|
|
4755
|
+
} else {
|
|
4756
|
+
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
4757
|
+
}
|
|
4758
|
+
process.exit(1);
|
|
4759
|
+
}
|
|
4760
|
+
});
|
|
4761
|
+
|
|
4762
|
+
// ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
|
|
4763
|
+
//
|
|
4764
|
+
// Installs a parameterized .github/workflows/<name>.yml that runs `hone
|
|
4765
|
+
// queue-stories` on a cron schedule. workflow_dispatch trigger gives
|
|
4766
|
+
// on-demand execution from the GitHub Actions UI or `gh workflow run`.
|
|
4767
|
+
//
|
|
4768
|
+
// hone schedule install
|
|
4769
|
+
// hone schedule install --name overnight --cron "0 18 * * 1-5" --file stories.txt
|
|
4770
|
+
//
|
|
4771
|
+
// Future server-side cron is filed as HC-075. For now the workflow lives
|
|
4772
|
+
// in the adopter's repo, which gives them version control on the schedule
|
|
4773
|
+
// + free history in the GitHub Actions UI.
|
|
4774
|
+
program
|
|
4775
|
+
.command('schedule')
|
|
4776
|
+
.description('Manage overnight batch schedules (GitHub Actions templates)')
|
|
4777
|
+
.argument('<action>', 'Action to perform: install')
|
|
4778
|
+
.option('--name <name>', 'Schedule name (used as workflow filename)', 'overnight')
|
|
4779
|
+
.option('--cron <cron>', 'Cron expression (UTC). Default: weekdays 6pm', '0 18 * * 1-5')
|
|
4780
|
+
.option('--file <path>', 'Default stories file path (relative to repo root)', 'stories.txt')
|
|
4781
|
+
.option('--out <dir>', 'Output directory for the workflow file', '.github/workflows')
|
|
4782
|
+
.option('--force', 'Overwrite existing workflow file', false)
|
|
4783
|
+
.action(async (action, opts) => {
|
|
4784
|
+
if (action !== 'install') {
|
|
4785
|
+
console.error(`Unknown schedule action: ${action}. Supported: install`);
|
|
4786
|
+
console.error('(Server-side scheduling is filed as HC-075.)');
|
|
4787
|
+
process.exit(1);
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
// Basic cron validation — five whitespace-separated fields.
|
|
4791
|
+
// Catches the most common mistakes (six fields, single token, etc.)
|
|
4792
|
+
// without trying to validate semantics.
|
|
4793
|
+
const fields = String(opts.cron).trim().split(/\s+/);
|
|
4794
|
+
if (fields.length !== 5) {
|
|
4795
|
+
console.error(`Invalid cron expression: "${opts.cron}"`);
|
|
4796
|
+
console.error('Expected 5 fields: minute hour day-of-month month day-of-week.');
|
|
4797
|
+
console.error('GitHub Actions does NOT support aliases like @daily / @hourly — use the 5-field form.');
|
|
4798
|
+
console.error('Test with https://crontab.guru');
|
|
4799
|
+
process.exit(1);
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
// Name → workflow filename. Reject path traversal / slashes.
|
|
4803
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,40}$/i.test(opts.name)) {
|
|
4804
|
+
console.error(`Invalid --name "${opts.name}". Use letters, digits, hyphens, underscores (max 41 chars).`);
|
|
4805
|
+
process.exit(1);
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
const config = getConfig();
|
|
4809
|
+
const client = api(config);
|
|
4810
|
+
|
|
4811
|
+
// 1. Fetch the template from the server.
|
|
4812
|
+
let template;
|
|
4813
|
+
try {
|
|
4814
|
+
const { data } = await client.get('/scripts/overnight-schedule-template', {
|
|
4815
|
+
responseType: 'text', transformResponse: [(d) => d],
|
|
4816
|
+
});
|
|
4817
|
+
template = data;
|
|
4818
|
+
} catch (e) {
|
|
4819
|
+
// responseType:'text' keeps JSON error bodies as raw strings — parse
|
|
4820
|
+
// before reading .error so the user sees the server's actual message
|
|
4821
|
+
// (e.g. "overnight-schedule.yml not bundled") instead of axios's
|
|
4822
|
+
// generic "Request failed with status code 503".
|
|
4823
|
+
let serverError;
|
|
4824
|
+
const rawData = e.response?.data;
|
|
4825
|
+
if (typeof rawData === 'string') {
|
|
4826
|
+
try { serverError = JSON.parse(rawData)?.error; } catch { /* not JSON */ }
|
|
4827
|
+
} else {
|
|
4828
|
+
serverError = rawData?.error;
|
|
4829
|
+
}
|
|
4830
|
+
console.error(`Failed to fetch template: ${e.response?.status || ''} ${serverError || e.message}`);
|
|
4831
|
+
process.exit(1);
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
// 2. Substitute placeholders. Use replace-all so any future template
|
|
4835
|
+
// additions referencing the same placeholder are handled.
|
|
4836
|
+
const populated = template
|
|
4837
|
+
.replace(/\{\{NAME\}\}/g, opts.name)
|
|
4838
|
+
.replace(/\{\{CRON\}\}/g, opts.cron)
|
|
4839
|
+
.replace(/\{\{STORIES_FILE\}\}/g, opts.file);
|
|
4840
|
+
|
|
4841
|
+
// 3. Decide output path. Default writes to `.github/workflows/<name>.yml`.
|
|
4842
|
+
const outDir = path.resolve(process.cwd(), opts.out);
|
|
4843
|
+
const outFile = path.join(outDir, `${opts.name}.yml`);
|
|
4844
|
+
|
|
4845
|
+
if (fs.existsSync(outFile) && !opts.force) {
|
|
4846
|
+
console.error(`File already exists: ${outFile}`);
|
|
4847
|
+
console.error('Use --force to overwrite, or pick a different --name.');
|
|
4848
|
+
process.exit(1);
|
|
4849
|
+
}
|
|
4850
|
+
|
|
4851
|
+
// 4. Ensure output dir exists.
|
|
4852
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
4853
|
+
|
|
4854
|
+
// 5. Write the file.
|
|
4855
|
+
fs.writeFileSync(outFile, populated, 'utf8');
|
|
4856
|
+
|
|
4857
|
+
console.log('');
|
|
4858
|
+
console.log(`✓ Installed schedule: ${path.relative(process.cwd(), outFile)}`);
|
|
4859
|
+
console.log('');
|
|
4860
|
+
console.log(' Schedule: ' + opts.cron + ' (UTC)');
|
|
4861
|
+
console.log(' Stories: ' + opts.file);
|
|
4862
|
+
console.log('');
|
|
4863
|
+
console.log('Next steps:');
|
|
4864
|
+
console.log(' 1. Ensure repo secret HONE_TOKEN is set');
|
|
4865
|
+
console.log(' (Settings → Secrets and variables → Actions → New repository secret)');
|
|
4866
|
+
console.log(' 2. Commit and push the workflow file:');
|
|
4867
|
+
console.log(` git add ${path.relative(process.cwd(), outFile)} && git commit -m "feat: nightly Hone batch" && git push`);
|
|
4868
|
+
console.log(` 3. Test on-demand: gh workflow run ${opts.name}.yml --field dry_run=true`);
|
|
4869
|
+
console.log(` Or: GitHub Actions UI → "${opts.name}" → Run workflow`);
|
|
4870
|
+
console.log('');
|
|
4871
|
+
console.log('Test the cron expression at https://crontab.guru');
|
|
4872
|
+
console.log('');
|
|
4873
|
+
});
|
|
4874
|
+
|
|
4516
4875
|
// ── Release Review (pre-deployment holistic review) ──────────────────────────
|
|
4876
|
+
// parseReviewJSON is extracted to cli/lib/parse-review-json.js for unit
|
|
4877
|
+
// coverage. See that file's docstring for the three-shape extraction strategy.
|
|
4878
|
+
|
|
4517
4879
|
program
|
|
4518
4880
|
.command('release-review')
|
|
4519
4881
|
.description('Holistic code review of all changed files before deployment (runs Opus)')
|
|
@@ -4521,15 +4883,49 @@ program
|
|
|
4521
4883
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
4522
4884
|
.option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
|
|
4523
4885
|
.option('--max-files <n>', 'Max source files to include in review', '40')
|
|
4886
|
+
.option('--provider <name>', 'LLM provider: opus | gh-models (HC-080a-spike)', 'opus')
|
|
4524
4887
|
.action(async (opts) => {
|
|
4525
4888
|
const { execSync } = require('child_process');
|
|
4526
4889
|
const fs = require('fs');
|
|
4527
4890
|
const repoRoot = process.cwd();
|
|
4528
4891
|
|
|
4892
|
+
// When emitting JSON to stdout, route banner/status output to stderr so
|
|
4893
|
+
// `tee file.json` in CI captures pure JSON. Otherwise the artifact ends
|
|
4894
|
+
// up with `Hone AI — Production Review\n=====\n...` mixed in front of
|
|
4895
|
+
// the JSON envelope, breaking JSON.parse. (HC-080a-spike PR #313 data
|
|
4896
|
+
// point 1 found this — both lanes' artifacts were unparseable.)
|
|
4897
|
+
//
|
|
4898
|
+
// Normalize the format value defensively — `--format JSON` or
|
|
4899
|
+
// `--format=json ` (trailing space) would otherwise silently fall into
|
|
4900
|
+
// pretty-mode and re-pollute the artifact.
|
|
4901
|
+
const isJsonOut = String(opts.format || '').trim().toLowerCase() === 'json';
|
|
4902
|
+
const banner = (line) => (isJsonOut ? console.error(line) : console.log(line));
|
|
4903
|
+
|
|
4904
|
+
// emitStatusEnvelope is for non-success exit paths in JSON mode (no
|
|
4905
|
+
// changes, auth failure, rate limit, empty response). Ensures the
|
|
4906
|
+
// artifact is ALWAYS valid JSON, never zero-byte. The status field
|
|
4907
|
+
// tells compare-reviews.js to treat this row as a known non-finding
|
|
4908
|
+
// outcome rather than an unparseable file.
|
|
4909
|
+
const emitStatusEnvelope = (status, extra = {}) => {
|
|
4910
|
+
if (!isJsonOut) return;
|
|
4911
|
+
console.log(JSON.stringify({
|
|
4912
|
+
status,
|
|
4913
|
+
base: opts.base, // raw user input — preserved for audit
|
|
4914
|
+
provider: opts.provider,
|
|
4915
|
+
...extra,
|
|
4916
|
+
}, null, 2));
|
|
4917
|
+
};
|
|
4918
|
+
|
|
4919
|
+
// Normalize the base ref to avoid the double-`origin/` prefix bug. The
|
|
4920
|
+
// CI step passes `--base origin/main` and the previous code prefixed
|
|
4921
|
+
// again → `origin/origin/main` → git rejected → silent fallback to
|
|
4922
|
+
// `HEAD~10`. Both lanes reviewed the wrong diff in PR #313 first run.
|
|
4923
|
+
const baseRef = resolveBaseRef(opts.base);
|
|
4924
|
+
|
|
4529
4925
|
// 1. Get changed files
|
|
4530
4926
|
let changedFiles;
|
|
4531
4927
|
try {
|
|
4532
|
-
const raw = execSync(`git diff --name-only
|
|
4928
|
+
const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot });
|
|
4533
4929
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
4534
4930
|
} catch {
|
|
4535
4931
|
try {
|
|
@@ -4542,7 +4938,8 @@ program
|
|
|
4542
4938
|
}
|
|
4543
4939
|
|
|
4544
4940
|
if (changedFiles.length === 0) {
|
|
4545
|
-
|
|
4941
|
+
banner('No changed files found. Nothing to review.');
|
|
4942
|
+
emitStatusEnvelope('no_changes', { resolvedBase: baseRef });
|
|
4546
4943
|
process.exit(0);
|
|
4547
4944
|
}
|
|
4548
4945
|
|
|
@@ -4556,32 +4953,49 @@ program
|
|
|
4556
4953
|
const maxFiles = parseInt(opts.maxFiles, 10) || 40;
|
|
4557
4954
|
const filesToReview = sourceFiles.slice(0, maxFiles);
|
|
4558
4955
|
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4956
|
+
banner('');
|
|
4957
|
+
banner('Hone AI — Production Review');
|
|
4958
|
+
banner('================================');
|
|
4959
|
+
banner(`Base: ${baseRef}`);
|
|
4960
|
+
banner(`Changed files: ${changedFiles.length} total, ${sourceFiles.length} source, ${filesToReview.length} to review`);
|
|
4961
|
+
banner('');
|
|
4565
4962
|
|
|
4566
4963
|
if (opts.dryRun) {
|
|
4567
|
-
|
|
4568
|
-
for (const f of filesToReview)
|
|
4569
|
-
if (sourceFiles.length > maxFiles)
|
|
4964
|
+
banner('Source files that would be reviewed:');
|
|
4965
|
+
for (const f of filesToReview) banner(` ${f}`);
|
|
4966
|
+
if (sourceFiles.length > maxFiles) banner(` ... and ${sourceFiles.length - maxFiles} more (increase --max-files)`);
|
|
4570
4967
|
process.exit(0);
|
|
4571
4968
|
}
|
|
4572
4969
|
|
|
4573
|
-
// 3.
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
console.error('ANTHROPIC_API_KEY not set. Required for production review (Opus model).');
|
|
4577
|
-
console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
4970
|
+
// 3. Provider validation + credential check
|
|
4971
|
+
if (opts.provider !== 'opus' && opts.provider !== 'gh-models') {
|
|
4972
|
+
console.error(`Invalid --provider: ${opts.provider}. Use 'opus' or 'gh-models'.`);
|
|
4578
4973
|
process.exit(1);
|
|
4579
4974
|
}
|
|
4580
4975
|
|
|
4976
|
+
let apiKey, providerLabel;
|
|
4977
|
+
if (opts.provider === 'gh-models') {
|
|
4978
|
+
apiKey = process.env.GITHUB_TOKEN;
|
|
4979
|
+
providerLabel = 'GitHub Models (openai/gpt-4.1)';
|
|
4980
|
+
if (!apiKey) {
|
|
4981
|
+
console.error('GITHUB_TOKEN not set. Required for --provider gh-models.');
|
|
4982
|
+
console.error('In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
|
|
4983
|
+
process.exit(1);
|
|
4984
|
+
}
|
|
4985
|
+
} else {
|
|
4986
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
4987
|
+
providerLabel = 'Anthropic Opus (claude-opus-4-20250514)';
|
|
4988
|
+
if (!apiKey) {
|
|
4989
|
+
console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
|
|
4990
|
+
console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
4991
|
+
process.exit(1);
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4581
4995
|
// 4. Build the diff content (truncated per-file to stay within context)
|
|
4582
4996
|
let diffContent;
|
|
4583
4997
|
try {
|
|
4584
|
-
diffContent = execSync(`git diff
|
|
4998
|
+
diffContent = execSync(`git diff ${baseRef}...HEAD -- ${filesToReview.map(f => `'${f}'`).join(' ')}`, {
|
|
4585
4999
|
encoding: 'utf8', cwd: repoRoot, maxBuffer: 10 * 1024 * 1024,
|
|
4586
5000
|
});
|
|
4587
5001
|
} catch {
|
|
@@ -4593,10 +5007,13 @@ program
|
|
|
4593
5007
|
}
|
|
4594
5008
|
}
|
|
4595
5009
|
|
|
4596
|
-
//
|
|
4597
|
-
|
|
5010
|
+
// Provider-specific truncation. GH Models GPT-4.1 has an 8K-token
|
|
5011
|
+
// request-body cap via models.github.ai/inference — 100K chars overflows.
|
|
5012
|
+
// See cli/lib/release-review-config.js for the budget math.
|
|
5013
|
+
const MAX_DIFF_CHARS = getMaxDiffChars(opts.provider);
|
|
4598
5014
|
if (diffContent.length > MAX_DIFF_CHARS) {
|
|
4599
|
-
diffContent = diffContent.slice(0, MAX_DIFF_CHARS) +
|
|
5015
|
+
diffContent = diffContent.slice(0, MAX_DIFF_CHARS) +
|
|
5016
|
+
`\n\n[... diff truncated at ${MAX_DIFF_CHARS} chars for --provider ${opts.provider} ...]`;
|
|
4600
5017
|
}
|
|
4601
5018
|
|
|
4602
5019
|
// 5. Build the prompt
|
|
@@ -4653,72 +5070,166 @@ program
|
|
|
4653
5070
|
'Review ALL files holistically. Return findings as JSON.',
|
|
4654
5071
|
].join('\n');
|
|
4655
5072
|
|
|
4656
|
-
|
|
4657
|
-
|
|
5073
|
+
banner(`Calling ${providerLabel}...`);
|
|
5074
|
+
banner('');
|
|
4658
5075
|
|
|
4659
|
-
// 6. Call
|
|
5076
|
+
// 6. Call LLM (provider-branched, HC-080a-spike)
|
|
5077
|
+
// max_tokens is held SYMMETRIC across providers so the HC-080a-spike
|
|
5078
|
+
// comparison measures model capability, not output budget. 4096 is the
|
|
5079
|
+
// safe ceiling for openai/gpt-4.1 via GH Models; Opus supports more but
|
|
5080
|
+
// running it with 4096 keeps the comparison apples-to-apples.
|
|
5081
|
+
const MAX_OUTPUT_TOKENS = 4096;
|
|
5082
|
+
const startedAt = Date.now();
|
|
4660
5083
|
try {
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
5084
|
+
let responseText, inputTokens, outputTokens, modelLabel;
|
|
5085
|
+
|
|
5086
|
+
if (opts.provider === 'gh-models') {
|
|
5087
|
+
// GitHub Models — free LLM inference using GITHUB_TOKEN. Mirrors the
|
|
5088
|
+
// path used by server/scripts/ai-reviewer.js for per-PR review.
|
|
5089
|
+
const { data } = await axios.post(
|
|
5090
|
+
'https://models.github.ai/inference/chat/completions',
|
|
5091
|
+
{
|
|
5092
|
+
model: 'openai/gpt-4.1',
|
|
5093
|
+
messages: [
|
|
5094
|
+
{ role: 'system', content: systemPrompt },
|
|
5095
|
+
{ role: 'user', content: userPrompt },
|
|
5096
|
+
],
|
|
5097
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
5098
|
+
},
|
|
5099
|
+
{
|
|
5100
|
+
headers: {
|
|
5101
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
5102
|
+
'Content-Type': 'application/json',
|
|
5103
|
+
},
|
|
5104
|
+
timeout: 120000,
|
|
5105
|
+
}
|
|
5106
|
+
);
|
|
5107
|
+
responseText = data.choices?.[0]?.message?.content || '';
|
|
5108
|
+
inputTokens = data.usage?.prompt_tokens || 0;
|
|
5109
|
+
outputTokens = data.usage?.completion_tokens || 0;
|
|
5110
|
+
modelLabel = 'openai/gpt-4.1';
|
|
5111
|
+
} else {
|
|
5112
|
+
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
5113
|
+
model: 'claude-opus-4-20250514',
|
|
5114
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
5115
|
+
system: systemPrompt,
|
|
5116
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
5117
|
+
}, {
|
|
5118
|
+
headers: {
|
|
5119
|
+
'x-api-key': apiKey,
|
|
5120
|
+
'anthropic-version': '2023-06-01',
|
|
5121
|
+
'content-type': 'application/json',
|
|
5122
|
+
},
|
|
5123
|
+
timeout: 120000,
|
|
5124
|
+
});
|
|
5125
|
+
responseText = data.content?.[0]?.text || '';
|
|
5126
|
+
inputTokens = data.usage?.input_tokens || 0;
|
|
5127
|
+
outputTokens = data.usage?.output_tokens || 0;
|
|
5128
|
+
modelLabel = 'claude-opus-4-20250514';
|
|
5129
|
+
}
|
|
4674
5130
|
|
|
4675
|
-
const
|
|
5131
|
+
const elapsedMs = Date.now() - startedAt;
|
|
5132
|
+
|
|
5133
|
+
// Empty-response detection: some providers return HTTP 200 with empty
|
|
5134
|
+
// choices/content (content-filter trip, soft quota limit, model alias
|
|
5135
|
+
// typo). Without this guard the CLI would silently exit 0 and the CI
|
|
5136
|
+
// artifact would look like a clean pass — a false-negative on the
|
|
5137
|
+
// entire review.
|
|
5138
|
+
if (!responseText || responseText.trim().length === 0) {
|
|
5139
|
+
console.error(`Empty response from --provider ${opts.provider}. ` +
|
|
5140
|
+
`Possible causes: content filter, quota exhausted, or model name rejected.`);
|
|
5141
|
+
emitStatusEnvelope('empty_response', {
|
|
5142
|
+
resolvedBase: baseRef,
|
|
5143
|
+
model: modelLabel,
|
|
5144
|
+
inputTokens,
|
|
5145
|
+
outputTokens,
|
|
5146
|
+
elapsedMs: Date.now() - startedAt,
|
|
5147
|
+
});
|
|
5148
|
+
process.exit(1);
|
|
5149
|
+
}
|
|
4676
5150
|
|
|
4677
|
-
//
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
5151
|
+
// Parse the response once, robustly. Try the full string first (the
|
|
5152
|
+
// happy path when the LLM emits clean JSON), then fall back to greedy
|
|
5153
|
+
// brace extraction. A failed parse leaves parsed = null and downstream
|
|
5154
|
+
// code uses raw.
|
|
5155
|
+
const parsed = parseReviewJSON(responseText);
|
|
5156
|
+
|
|
5157
|
+
// 7. Output envelope + content. Envelope fields are spread LAST so an
|
|
5158
|
+
// LLM cannot rewrite audit fields (provider, model, inputTokens, etc.)
|
|
5159
|
+
// via prompt injection in the diff content.
|
|
5160
|
+
//
|
|
5161
|
+
// base (raw user input) is preserved alongside resolvedBase (the actual
|
|
5162
|
+
// git revision that was diffed) so audit trails are complete and
|
|
5163
|
+
// comparison tooling can distinguish "the operator passed `main`" from
|
|
5164
|
+
// "the operator passed `origin/main`."
|
|
5165
|
+
if (isJsonOut) {
|
|
5166
|
+
const envelope = {
|
|
5167
|
+
status: 'reviewed',
|
|
5168
|
+
base: opts.base,
|
|
5169
|
+
resolvedBase: baseRef,
|
|
5170
|
+
provider: opts.provider,
|
|
5171
|
+
totalFiles: changedFiles.length,
|
|
5172
|
+
sourceFiles: sourceFiles.length,
|
|
5173
|
+
reviewedFiles: filesToReview.length,
|
|
5174
|
+
model: modelLabel,
|
|
5175
|
+
inputTokens,
|
|
5176
|
+
outputTokens,
|
|
5177
|
+
elapsedMs,
|
|
5178
|
+
};
|
|
5179
|
+
if (parsed) {
|
|
5180
|
+
console.log(JSON.stringify({ ...parsed, ...envelope }, null, 2));
|
|
4697
5181
|
} else {
|
|
4698
|
-
console.log(JSON.stringify({ raw: responseText }, null, 2));
|
|
5182
|
+
console.log(JSON.stringify({ ...envelope, raw: responseText }, null, 2));
|
|
4699
5183
|
}
|
|
4700
5184
|
} else {
|
|
4701
5185
|
console.log(responseText);
|
|
4702
5186
|
}
|
|
4703
5187
|
|
|
4704
|
-
// 8. Exit code
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
5188
|
+
// 8. Exit code — defense in depth:
|
|
5189
|
+
// (a) structured check against the parsed JSON, then
|
|
5190
|
+
// (b) loose substring check on the raw response (catches LLMs that
|
|
5191
|
+
// emit markdown-formatted CRITICAL findings without strict JSON).
|
|
5192
|
+
let hasCritical = false, doNotDeploy = false;
|
|
5193
|
+
if (parsed) {
|
|
5194
|
+
hasCritical = (parsed.summary?.critical || 0) > 0 ||
|
|
5195
|
+
(Array.isArray(parsed.findings) &&
|
|
5196
|
+
parsed.findings.some(f => String(f.severity).toUpperCase() === 'CRITICAL'));
|
|
5197
|
+
doNotDeploy = parsed.recommendation === 'DO_NOT_DEPLOY';
|
|
5198
|
+
}
|
|
5199
|
+
if (!hasCritical) {
|
|
5200
|
+
// Loose match — case-insensitive, no quote requirement. False-positive
|
|
5201
|
+
// tolerance is acceptable here because the cost of a false-positive is
|
|
5202
|
+
// "CI shows yellow, human looks at the report"; the cost of a false-
|
|
5203
|
+
// negative is "real CRITICAL bug ships to prod undetected".
|
|
5204
|
+
hasCritical = /\bCRITICAL\b/.test(responseText);
|
|
5205
|
+
}
|
|
5206
|
+
if (!doNotDeploy) {
|
|
5207
|
+
doNotDeploy = /\bDO_NOT_DEPLOY\b/.test(responseText);
|
|
5208
|
+
}
|
|
5209
|
+
if (hasCritical || doNotDeploy) {
|
|
5210
|
+
banner('');
|
|
5211
|
+
banner('CRITICAL issues found. Fix before deploying.');
|
|
4710
5212
|
process.exit(1);
|
|
4711
5213
|
}
|
|
4712
5214
|
} catch (e) {
|
|
4713
5215
|
const status = e.response?.status;
|
|
4714
5216
|
const msg = e.response?.data?.error?.message || e.message;
|
|
4715
|
-
|
|
4716
|
-
|
|
5217
|
+
let kind = 'http_error';
|
|
5218
|
+
if (status === 401 || status === 403) {
|
|
5219
|
+
console.error(`Invalid auth for --provider ${opts.provider}. Check credential and try again.`);
|
|
5220
|
+
kind = 'auth_error';
|
|
4717
5221
|
} else if (status === 429) {
|
|
4718
|
-
console.error(
|
|
5222
|
+
console.error(`Rate limited by ${opts.provider}. Try again shortly.`);
|
|
5223
|
+
kind = 'rate_limited';
|
|
4719
5224
|
} else {
|
|
4720
|
-
console.error(`Production review failed: ${msg}`);
|
|
5225
|
+
console.error(`Production review failed (${opts.provider}): ${msg}`);
|
|
4721
5226
|
}
|
|
5227
|
+
emitStatusEnvelope(kind, {
|
|
5228
|
+
resolvedBase: baseRef,
|
|
5229
|
+
httpStatus: status || null,
|
|
5230
|
+
errorMessage: msg,
|
|
5231
|
+
elapsedMs: Date.now() - startedAt,
|
|
5232
|
+
});
|
|
4722
5233
|
process.exit(1);
|
|
4723
5234
|
}
|
|
4724
5235
|
});
|