@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 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 Claude Code files (CLAUDE.md + .claude/agents/) ────
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 .github/agents/*.agent.md ───────────────
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(), '.github', 'agents');
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 = `agents/${agent}.agent.md`;
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 (.github/agents/ + .claude/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 origin/${opts.base}...HEAD`, { encoding: 'utf8', cwd: repoRoot });
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
- console.log('No changed files found. Nothing to review.');
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
- console.log('');
4560
- console.log('Hone AI — Production Review');
4561
- console.log('================================');
4562
- console.log(`Base: ${opts.base}`);
4563
- console.log(`Changed files: ${changedFiles.length} total, ${sourceFiles.length} source, ${filesToReview.length} to review`);
4564
- console.log('');
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
- console.log('Source files that would be reviewed:');
4568
- for (const f of filesToReview) console.log(` ${f}`);
4569
- if (sourceFiles.length > maxFiles) console.log(` ... and ${sourceFiles.length - maxFiles} more (increase --max-files)`);
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. Check for API key
4574
- const apiKey = process.env.ANTHROPIC_API_KEY;
4575
- if (!apiKey) {
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 origin/${opts.base}...HEAD -- ${filesToReview.map(f => `'${f}'`).join(' ')}`, {
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
- // Truncate if over 100k chars (~25k tokens) to stay within budget
4597
- const MAX_DIFF_CHARS = 100000;
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) + '\n\n[... diff truncated at 100k 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
- console.log('Calling Anthropic API (claude-opus-4-20250514)...');
4657
- console.log('');
5073
+ banner(`Calling ${providerLabel}...`);
5074
+ banner('');
4658
5075
 
4659
- // 6. Call Anthropic Messages API
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
- const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
4662
- model: 'claude-opus-4-20250514',
4663
- max_tokens: 8192,
4664
- system: systemPrompt,
4665
- messages: [{ role: 'user', content: userPrompt }],
4666
- }, {
4667
- headers: {
4668
- 'x-api-key': apiKey,
4669
- 'anthropic-version': '2023-06-01',
4670
- 'content-type': 'application/json',
4671
- },
4672
- timeout: 120000,
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 responseText = data.content?.[0]?.text || '';
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
- // 7. Parse and display results
4678
- if (opts.format === 'json') {
4679
- // Try to extract JSON from response
4680
- const jsonMatch = responseText.match(/\{[\s\S]*\}/);
4681
- if (jsonMatch) {
4682
- try {
4683
- const parsed = JSON.parse(jsonMatch[0]);
4684
- console.log(JSON.stringify({
4685
- base: opts.base,
4686
- totalFiles: changedFiles.length,
4687
- sourceFiles: sourceFiles.length,
4688
- reviewedFiles: filesToReview.length,
4689
- model: 'claude-opus-4-20250514',
4690
- inputTokens: data.usage?.input_tokens || 0,
4691
- outputTokens: data.usage?.output_tokens || 0,
4692
- ...parsed,
4693
- }, null, 2));
4694
- } catch {
4695
- console.log(JSON.stringify({ raw: responseText }, null, 2));
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 based on findings
4705
- const hasCritical = responseText.includes('"CRITICAL"') || responseText.includes('"critical"');
4706
- const recommendation = responseText.includes('DO_NOT_DEPLOY');
4707
- if (hasCritical || recommendation) {
4708
- console.log('');
4709
- console.log('CRITICAL issues found. Fix before deploying.');
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
- if (status === 401) {
4716
- console.error('Invalid ANTHROPIC_API_KEY. Check your key and try again.');
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('Rate limited by Anthropic API. Try again shortly.');
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
  });