@hone-ai/cli 1.37.0 → 1.38.1

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
@@ -23,7 +23,7 @@ const fs = require('fs');
23
23
  const path = require('path');
24
24
  const os = require('os');
25
25
  const crypto = require('crypto');
26
- const { execSync } = require('child_process');
26
+ const { execSync, execFileSync } = require('child_process');
27
27
 
28
28
  // HC-GF-144: verdictEnv lives in cli/lib so it is importable and can be tested by BEHAVIOUR.
29
29
  // A source-regex asserting the variable name matched this very comment and passed while the
@@ -39,7 +39,8 @@ const { parseReviewJSON } = require('./lib/parse-review-json');
39
39
  const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
40
40
  const { gitEnv } = require('./lib/git-env');
41
41
  const { collectIncludePaths } = require('./lib/include-paths');
42
- const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
42
+ const { runArgsError, orchestratePath, apiPath, storyDir: storyDirIn, isGitRev } = require('./lib/run-target'); // HC-GF-270-followup-1, -2, -3
43
+ const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
43
44
  const program = new Command();
44
45
 
45
46
  // HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
@@ -216,8 +217,8 @@ program
216
217
  .option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary|stateful)', 'adversarial')
217
218
  .option('--target <name>', 'Run against a specific agent target (default: all configured)')
218
219
  .option('--output <format>', 'Output format (text|json)', 'text')
219
- .option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
220
- .option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
220
+ .option('--judge', 'Add the NLI judge layer (faithfulness only): claims are checked for entailment against the probe context. Needs --provider claude (paid)')
221
+ .option('--provider <name>', 'Judge provider: claude (paid Sonnet, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
221
222
  .option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
222
223
  .option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
223
224
  .option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
@@ -921,11 +922,14 @@ program
921
922
  console.log(' to switch to local-mode (real safety net at $0 CI cost).');
922
923
  }
923
924
 
924
- const flags = [
925
- `--source "${path.join(tmpDir, 'enterprise-github')}"`,
926
- opts.dryRun ? '--dry-run' : '',
927
- isNonInteractive ? '--non-interactive' : '',
928
- ].filter(Boolean).join(' ');
925
+ // HC-GF-270-followup-3: an argument list, not a shell string — the script path and the source directory
926
+ // reach bash as the arguments they are, whatever characters a temp path holds.
927
+ const setupArgs = [
928
+ scriptPath,
929
+ '--source', path.join(tmpDir, 'enterprise-github'),
930
+ ...(opts.dryRun ? ['--dry-run'] : []),
931
+ ...(isNonInteractive ? ['--non-interactive'] : []),
932
+ ];
929
933
 
930
934
  // HC-RC-004 pass-1 (HIGH-1): when source==='auto-detect', LEAVE
931
935
  // CI_GATE unset so the bash script's existing detected_ci_default
@@ -940,7 +944,7 @@ program
940
944
  // after detection (apply_stack_override) and refuses an unsupported value.
941
945
  if (opts.stack) setupEnv.HONE_STACK_OVERRIDE = String(opts.stack).toLowerCase();
942
946
  try {
943
- execSync(`bash "${scriptPath}" ${flags}`, {
947
+ execFileSync('bash', setupArgs, {
944
948
  stdio: 'inherit',
945
949
  cwd: process.cwd(),
946
950
  env: setupEnv,
@@ -1331,7 +1335,12 @@ async function installTestFramework(repoRoot, opts) {
1331
1335
  return installPythonTestFramework(repoRoot, detected, pkgs, opts);
1332
1336
  }
1333
1337
 
1334
- const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
1338
+ // HC-GF-270-followup-3: the installer and its arguments as a list, run without a shell. npm, yarn and pnpm
1339
+ // are `.cmd` shims on Windows, which Node will only spawn through a shell — so these two calls (constant
1340
+ // arguments only) take one there and nowhere else. No `env`: an install keeps the ambient toolchain (HC-GF-144).
1341
+ const INSTALL_ARGV = { yarn: ['yarn', 'add', '-D'], pnpm: ['pnpm', 'add', '-D'], npm: ['npm', 'install', '-D'] };
1342
+ const [installBin, ...installPre] = INSTALL_ARGV[detected.pkgMgr] || INSTALL_ARGV.npm;
1343
+ const installShell = process.platform === 'win32';
1335
1344
 
1336
1345
  // ── Unit test framework ────────────────────────────────
1337
1346
  if (detected.testFw) {
@@ -1339,7 +1348,7 @@ async function installTestFramework(repoRoot, opts) {
1339
1348
  } else {
1340
1349
  console.log(` Stack: ${detected.stack} → Installing: ${pkgs.unit.join(', ')}`);
1341
1350
  try {
1342
- execSync(`${installCmd} ${pkgs.unit.join(' ')}`, { stdio: 'inherit', cwd: repoRoot });
1351
+ execFileSync(installBin, [...installPre, ...pkgs.unit], { stdio: 'inherit', cwd: repoRoot, shell: installShell });
1343
1352
  console.log(` ✓ Unit test framework installed`);
1344
1353
  } catch (e) {
1345
1354
  console.error(` ✗ Install failed: ${e.message}`);
@@ -1421,7 +1430,7 @@ async function installTestFramework(repoRoot, opts) {
1421
1430
  } else {
1422
1431
  console.log(` Installing Playwright...`);
1423
1432
  try {
1424
- execSync(`${installCmd} @playwright/test`, { stdio: 'inherit', cwd: repoRoot });
1433
+ execFileSync(installBin, [...installPre, '@playwright/test'], { stdio: 'inherit', cwd: repoRoot, shell: installShell });
1425
1434
  console.log(` ✓ @playwright/test installed`);
1426
1435
  console.log(` Installing Chromium browser...`);
1427
1436
  execSync('npx playwright install chromium', { stdio: 'inherit', cwd: repoRoot });
@@ -1754,7 +1763,7 @@ program
1754
1763
  await sleep(pollMs);
1755
1764
  process.stdout.write('.');
1756
1765
  try {
1757
- const r = await client.get(`/derive/${jobId}`);
1766
+ const r = await client.get(apiPath('derive', [jobId]));
1758
1767
  if (r.data.status === 'complete') {
1759
1768
  result = r.data;
1760
1769
  break;
@@ -1991,6 +2000,10 @@ program
1991
2000
  .requiredOption('--job <jobId>', 'Job ID returned by "hone derive --refresh"')
1992
2001
  .option('--changes <mode>', 'Which changes to apply: all (default)', 'all')
1993
2002
  .action(async (opts) => {
2003
+ // HC-GF-270-followup-2: this GETs the job named by `--job` and then POSTs its approval, so the id is
2004
+ // checked before any config read or request: `drv_x/../../orchestrate/<run>/approve?` became a POST
2005
+ // to another endpoint's approval.
2006
+ refuseBadRunArgs([{ label: '--job', kind: 'deriveJobId', value: opts.job }]);
1994
2007
  const config = getConfig();
1995
2008
  const client = api(config);
1996
2009
 
@@ -1999,7 +2012,7 @@ program
1999
2012
  // Verify job is ready
2000
2013
  let jobData;
2001
2014
  try {
2002
- const r = await client.get(`/derive/${opts.job}`);
2015
+ const r = await client.get(apiPath('derive', [opts.job]));
2003
2016
  jobData = r.data;
2004
2017
  } catch (e) {
2005
2018
  console.error(`Failed to fetch job: ${e.response?.data?.error || e.message}`);
@@ -2017,7 +2030,7 @@ program
2017
2030
 
2018
2031
  // Apply changes
2019
2032
  try {
2020
- const r = await client.post(`/derive/${opts.job}/approve`, {
2033
+ const r = await client.post(apiPath('derive', [opts.job, 'approve']), {
2021
2034
  approvedChanges: opts.changes,
2022
2035
  });
2023
2036
  console.log('✓ Changes approved and applied');
@@ -2513,6 +2526,7 @@ program
2513
2526
  .option('--with-learnings-report', 'Also fetch promoted-learnings report + update local YAML status (H-013)')
2514
2527
  .option('--force', 'Overwrite local edits (default: skip files with local changes — H-024)')
2515
2528
  .action(async (opts) => {
2529
+ refuseBadRunArgs([{ label: '--repo', kind: 'pathName', value: opts.repo, optional: true }]); // HC-GF-270-followup-2
2516
2530
  const config = getConfig();
2517
2531
  const client = api(config);
2518
2532
  const repoName = opts.repo || path.basename(process.cwd());
@@ -2548,7 +2562,7 @@ program
2548
2562
  // ── 1. Sync derived skills ──────────────────────────────────────────────
2549
2563
  if (!opts.agentsOnly) {
2550
2564
  try {
2551
- const r = await client.get(`/skills/${repoName}/derived`);
2565
+ const r = await client.get(apiPath('skills', [repoName, 'derived']));
2552
2566
  const { skills } = r.data;
2553
2567
  if (!skills.length) {
2554
2568
  console.log('No derived skills found. Run "hone derive" first.');
@@ -2628,7 +2642,7 @@ program
2628
2642
  content = editorStubContent(agent);
2629
2643
  console.log(` → ${agent}: editor mode — syncing runtime stub (full prompt served ephemerally)`);
2630
2644
  } else {
2631
- const r = await client.get(`/prompts/${agent}`, {
2645
+ const r = await client.get(apiPath('prompts', [agent]), {
2632
2646
  responseType: 'text',
2633
2647
  headers: { Accept: 'text/markdown' },
2634
2648
  });
@@ -2939,8 +2953,7 @@ program
2939
2953
  // The default request is byte-identical to the one that shipped — no query string at all —
2940
2954
  // so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
2941
2955
  // parameter appears only when the operator asked for a status other than the default.
2942
- const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
2943
- const { data } = await client.get(`/learnings/promoted${qs}`);
2956
+ const { data } = await client.get(apiPath('learnings', ['promoted'], { status: status === 'promoted' ? undefined : status }));
2944
2957
  const all = Array.isArray(data.learnings) ? data.learnings : [];
2945
2958
  // The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
2946
2959
  // router found no target; a row promoted before that story carries neither field at all.
@@ -3223,6 +3236,7 @@ program
3223
3236
  .option('--max-files <n>', 'Max source files for derive (default: 15)', '15')
3224
3237
  .option('--skip-derive', 'Skip the derive step (setup + sync only)')
3225
3238
  .action(async (opts) => {
3239
+ refuseBadRunArgs([{ label: '--max-files', kind: 'count', value: opts.maxFiles }]); // HC-GF-270-followup-3
3226
3240
  const startTime = Date.now();
3227
3241
 
3228
3242
  console.log('');
@@ -3243,10 +3257,8 @@ program
3243
3257
  if (opts.e2e !== false) setupArgs.push('--e2e');
3244
3258
  if (opts.noE2e) setupArgs.push('--no-e2e');
3245
3259
 
3246
- const { execSync: ex } = require('child_process');
3247
- const cliPath = __filename;
3248
- const setupCmd = `node "${cliPath}" ${setupArgs.join(' ')}`;
3249
- ex(setupCmd, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3260
+ // HC-GF-270-followup-3: this CLI re-run by the node running it, with an argument list — no shell.
3261
+ execFileSync(process.execPath, [__filename, ...setupArgs], { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3250
3262
  } catch (e) {
3251
3263
  console.error('Setup failed:', e.message);
3252
3264
  process.exit(1);
@@ -3258,10 +3270,10 @@ program
3258
3270
  console.log('━━━ Step 2/3: Derive Domain Skills ━━━━━━━━━━━━');
3259
3271
  console.log('');
3260
3272
  try {
3261
- const { execSync: ex } = require('child_process');
3262
- const cliPath = __filename;
3263
- const deriveCmd = `node "${cliPath}" derive --max-files ${opts.maxFiles}`;
3264
- ex(deriveCmd, { stdio: 'inherit', cwd: process.cwd(), env: process.env, timeout: 600_000 });
3273
+ // HC-GF-270-followup-3: `--max-files` was pasted into a shell string, so `--max-files '15;touch x'`
3274
+ // ran `touch x`. It is checked as a count when adopt starts, and reaches derive as one argument.
3275
+ execFileSync(process.execPath, [__filename, 'derive', '--max-files', String(opts.maxFiles)],
3276
+ { stdio: 'inherit', cwd: process.cwd(), env: process.env, timeout: 600_000 });
3265
3277
  } catch (e) {
3266
3278
  console.warn('');
3267
3279
  console.warn('⚠ Derive failed (non-fatal):', e.message?.slice(0, 100));
@@ -3278,9 +3290,7 @@ program
3278
3290
  console.log('━━━ Step 3/3: Sync Prompts ━━━━━━━━━━━━━━━━━━━━');
3279
3291
  console.log('');
3280
3292
  try {
3281
- const { execSync: ex } = require('child_process');
3282
- const cliPath = __filename;
3283
- ex(`node "${cliPath}" sync`, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3293
+ execFileSync(process.execPath, [__filename, 'sync'], { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3284
3294
  } catch (e) {
3285
3295
  console.warn('⚠ Sync failed:', e.message);
3286
3296
  }
@@ -3290,12 +3300,10 @@ program
3290
3300
  // mismatches surface immediately instead of silently breaking
3291
3301
  // Gate 3 / Pizza Tracker on the first PR.
3292
3302
  try {
3293
- const { execSync: ex } = require('child_process');
3294
- const cliPath = __filename;
3295
3303
  console.log('');
3296
3304
  console.log('━━━ Pattern detection (H-018) ━━━━━━━━━━━━━━━━━━');
3297
3305
  console.log('');
3298
- ex(`node "${cliPath}" check-patterns`, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3306
+ execFileSync(process.execPath, [__filename, 'check-patterns'], { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3299
3307
  } catch (e) {
3300
3308
  // Exit 1 from check-patterns means drift detected — the user
3301
3309
  // already saw the table, so just note that adoption succeeded
@@ -4171,6 +4179,8 @@ metricsCmd
4171
4179
  .description('Bootstrap .github/metrics/<STORY-ID>.yml from pipeline metadata + git + gh')
4172
4180
  .option('--force', 'Overwrite an existing scorecard (default: preserve)')
4173
4181
  .action(async (storyIdArg, opts) => {
4182
+ // HC-GF-270-followup-3: the id names a directory this reads and a file it writes; `../` must not leave them.
4183
+ refuseBadRunArgs([{ label: '<storyId>', kind: 'storyId', value: storyIdArg, optional: true }]);
4174
4184
  const ps = require('./lib/pipeline-status');
4175
4185
  const mc = require('./lib/metrics-collect');
4176
4186
  const repoRoot = process.cwd();
@@ -4193,8 +4203,11 @@ metricsCmd
4193
4203
  process.exit(1);
4194
4204
  }
4195
4205
  }
4206
+ // HC-GF-270-followup-3: an id read from the branch name gets the check a typed one got — the branch
4207
+ // pattern has no length cap, so storyDir would otherwise throw a stack trace instead of refusing.
4208
+ refuseBadRunArgs([{ label: 'the story id read from the branch name', kind: 'storyId', value: storyId }]);
4196
4209
 
4197
- const storyDir = path.join(pipelineRoot, storyId);
4210
+ const storyDir = storyDirIn(pipelineRoot, storyId);
4198
4211
  const metaPath = path.join(storyDir, 'metadata.yml');
4199
4212
  if (!fs.existsSync(metaPath)) {
4200
4213
  console.error(`No pipeline metadata for ${storyId} — checked ${metaPath}`);
@@ -4217,7 +4230,7 @@ metricsCmd
4217
4230
 
4218
4231
  // Idempotency: preserve existing scorecard unless --force
4219
4232
  fs.mkdirSync(metricsRoot, { recursive: true });
4220
- const outPath = path.join(metricsRoot, `${storyId}.yml`);
4233
+ const outPath = `${storyDirIn(metricsRoot, storyId)}.yml`;
4221
4234
  if (fs.existsSync(outPath) && !opts.force) {
4222
4235
  console.error(`${outPath} already exists. Re-run with --force to overwrite.`);
4223
4236
  console.error('(Default behavior: preserve any hand-written or agent-appended fields.)');
@@ -4231,17 +4244,28 @@ metricsCmd
4231
4244
  } catch { /* leave undefined */ }
4232
4245
 
4233
4246
  // Files changed — best-effort
4247
+ // HC-GF-270-followup-3: the base comes from metadata.yml — repository content — and reaches git as one
4248
+ // argument. A base git would read as an option (`--output=…` writes a file) is skipped, not run.
4234
4249
  let filesChanged = [];
4235
- try {
4236
- const diff = execSync(`git diff --name-only ${parsed.base || 'develop'}...HEAD`, { encoding: 'utf8' });
4237
- filesChanged = diff.split('\n').map(s => s.trim()).filter(Boolean);
4238
- } catch { /* leave empty */ }
4250
+ const base = String(parsed.base || 'develop');
4251
+ if (isGitRev(base)) {
4252
+ try {
4253
+ const diff = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { encoding: 'utf8' });
4254
+ filesChanged = diff.split('\n').map(s => s.trim()).filter(Boolean);
4255
+ } catch { /* leave empty */ }
4256
+ } else {
4257
+ console.error(` ⚠ metadata.yml base ${JSON.stringify(base)} is not a git revision; files_changed left empty`);
4258
+ }
4239
4259
 
4240
4260
  // PR info — best-effort, gh may be missing or no PR yet (E4/E5 graceful)
4241
4261
  let prInfo = {};
4242
4262
  try {
4243
4263
  const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
4244
- const pr = execSync(`gh pr list --head ${branch} --json number --jq '.[0].number' 2>/dev/null`, { encoding: 'utf8' }).trim();
4264
+ // HC-GF-270-followup-3: `--head=<branch>` is one argument whatever the branch name holds (`;`, `$(` and `(`
4265
+ // are legal in one); stderr is dropped by stdio rather than by a shell redirect.
4266
+ // A network call: bounded like classify-story's gh call, so a hung gh leaves pr_number unset instead of hanging.
4267
+ const pr = execFileSync('gh', ['pr', 'list', `--head=${branch}`, '--json', 'number', '--jq', '.[0].number'],
4268
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 30_000 }).trim();
4245
4269
  if (pr && /^\d+$/.test(pr)) prInfo = { pr_number: parseInt(pr, 10) };
4246
4270
  } catch { /* leave empty — graceful */ }
4247
4271
 
@@ -5384,12 +5408,26 @@ program
5384
5408
  .option('--artifact-dir <dir>', 'write the artifact here instead of .github/pipeline/<STORY-ID>/ (a CI gate must not leave files in the checkout)')
5385
5409
  .option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
5386
5410
  .action(async (cmdOpts) => {
5411
+ // HC-GF-270-followup-3: the story id names the artifact directory and the learnings file; the base reaches
5412
+ // git. Refused before anything runs — with exit 1 ("could not run"), because 2 here means a BLOCKER fired
5413
+ // (STEP_5B_BLOCKED) and verify-pr and the skill-audit gate read it that way.
5414
+ const badArg = runArgsError([
5415
+ { label: '--story-id', kind: 'storyId', value: cmdOpts.storyId, optional: true },
5416
+ { label: '--base', kind: 'gitRev', value: cmdOpts.base || 'develop' },
5417
+ ]);
5418
+ if (badArg) {
5419
+ console.error(`step-5b: ${badArg}`);
5420
+ process.exit(1);
5421
+ }
5387
5422
  const { runAssertions } = require('./lib/skill-assertions');
5388
5423
  const { renderStep5bArtifact } = require('./lib/skill-audit-render');
5389
5424
  const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
5390
5425
  const { parseLearningsFile } = require('./lib/learnings-parse');
5391
- const { execSync } = require('node:child_process');
5426
+ const { execSync, execFileSync } = require('node:child_process');
5392
5427
  const repoRoot = process.cwd();
5428
+ // HC-GF-270-followup-3: git reads with an argument list — the base is one argument, never shell text.
5429
+ const gitRead = (args) => execFileSync('git', args,
5430
+ { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5393
5431
 
5394
5432
  // 1. Resolve story id (CLI flag wins; else parse branch name)
5395
5433
  let branchName = '';
@@ -5402,18 +5440,22 @@ program
5402
5440
  console.error(`step-5b: cannot determine story id from branch "${branchName}". Pass --story-id explicitly. Skipping audit.`);
5403
5441
  process.exit(0); // exit 0 — informational
5404
5442
  }
5443
+ // HC-GF-270-followup-3: an id read from the branch name gets the check --story-id got (the pattern has no length cap).
5444
+ const derivedIdError = runArgsError([{ label: 'the story id read from the branch name', kind: 'storyId', value: storyId }]);
5445
+ if (derivedIdError) {
5446
+ console.error(`step-5b: ${derivedIdError}. Pass --story-id explicitly.`);
5447
+ process.exit(1);
5448
+ }
5405
5449
 
5406
5450
  // 2. Capture PR diff (defensive — empty diff → no findings)
5407
5451
  const baseBranch = cmdOpts.base || 'develop';
5408
5452
  let diff = '';
5409
5453
  try {
5410
- diff = execSync(`git diff origin/${baseBranch}...HEAD`,
5411
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5454
+ diff = gitRead(['diff', `origin/${baseBranch}...HEAD`]);
5412
5455
  } catch {
5413
5456
  // Fallback: no remote tracking — try local base
5414
5457
  try {
5415
- diff = execSync(`git diff ${baseBranch}...HEAD`,
5416
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5458
+ diff = gitRead(['diff', `${baseBranch}...HEAD`]);
5417
5459
  } catch { /* leave empty */ }
5418
5460
  }
5419
5461
 
@@ -5422,8 +5464,7 @@ program
5422
5464
  let commitMessages;
5423
5465
  for (const range of [`origin/${baseBranch}..HEAD`, `${baseBranch}..HEAD`]) {
5424
5466
  try {
5425
- commitMessages = execSync(`git log --format=%B ${range}`,
5426
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5467
+ commitMessages = gitRead(['log', '--format=%B', range]);
5427
5468
  break;
5428
5469
  } catch { /* try the next range */ }
5429
5470
  }
@@ -5477,7 +5518,9 @@ program
5477
5518
  baseBranch,
5478
5519
  referencedSkills,
5479
5520
  });
5480
- const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : path.join(repoRoot, '.github/pipeline', storyId);
5521
+ // `--artifact-dir` is the deliberate escape hatch (a CI gate writes outside the checkout); the default is
5522
+ // the story's own directory, which a story id cannot leave (HC-GF-270-followup-3).
5523
+ const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : storyDirIn(path.join(repoRoot, '.github/pipeline'), storyId);
5481
5524
  fs.mkdirSync(outDir, { recursive: true });
5482
5525
  const outPath = path.join(outDir, 'step-5b-skill-audit.md');
5483
5526
  fs.writeFileSync(outPath, artifact);
@@ -5561,8 +5604,8 @@ program
5561
5604
  .option('--contracts', 'Run contract validation between pipeline agents')
5562
5605
  .option('--snapshot', 'Save current eval + contract results as regression baseline')
5563
5606
  .option('--regression', 'Compare current results against saved baseline (detect drift)')
5564
- .option('--judge', 'Run LLM-as-judge scenarios (default provider: free gh-models via GITHUB_TOKEN)')
5565
- .option('--provider <name>', 'LLM-judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
5607
+ .option('--judge', 'Run LLM-as-judge scenarios. Needs --provider claude (paid); the free gh-models default was retired 2026-07-30')
5608
+ .option('--provider <name>', 'LLM-judge provider: claude (paid Sonnet, needs ANTHROPIC_API_KEY). There is no default')
5566
5609
  .option('--evidence-mode <mode>', 'HC-RC-001 editor-LLM evidence transfer: "local" writes .hone/eval-evidence.json (signed); "off" disables (default)')
5567
5610
  .action(async (opts) => {
5568
5611
  const path = require('path');
@@ -5645,14 +5688,11 @@ program
5645
5688
  process.exit(0);
5646
5689
  }
5647
5690
 
5648
- // HC-019n-followup-33 (pipeline-standards G6): the LLM-judge lane must have a
5649
- // ZERO-COST path (feedback_pipeline_llm_cost_reduction — every LLM-cost gate
5650
- // needs a free/near-free route so adopters aren't double-billed). DEFAULT to
5651
- // GH Models (free inference via GITHUB_TOKEN); paid Claude Sonnet is opt-in
5652
- // via `--provider claude`. Mirrors `hone skill-eval`'s provider convention.
5653
- // HC-COMM-011-followup-3: provider wiring extracted to lib/judge-provider.js
5654
- // (shared with `hone agent-eval --judge`). Same zero-cost default (gh-models)
5655
- // + Sonnet-not-Opus paid path; the retired dated Sonnet id must not appear
5691
+ // HC-019n-followup-33 gave the judge lane a free default (GitHub Models). HC-GF-340: GitHub
5692
+ // retired it on 2026-07-30, so there is no default now and no free path; the operator names
5693
+ // `--provider claude` (paid Sonnet) or gets one sentence saying why.
5694
+ // HC-COMM-011-followup-3: provider wiring lives in lib/judge-provider.js (shared with
5695
+ // `hone agent-eval --judge`); the retired dated Sonnet id must not appear
5656
5696
  // (pinned by opus-4-8-upgrade.test.js).
5657
5697
  const { buildJudgeCallLLM } = require('./lib/judge-provider');
5658
5698
  const built = buildJudgeCallLLM(opts.provider, { axios });
@@ -5756,13 +5796,13 @@ program
5756
5796
  // expected_output_format heuristics. Two systems, two different
5757
5797
  // questions; coexist.
5758
5798
  //
5759
- // Provider defaults to gh-models (free GH PAT inference) per the
5760
- // [Pipeline LLM Cost Reduction] memory — adopters must not be
5761
- // double-billed for what their pipeline already invoked.
5799
+ // HC-GF-340: there is no default provider. It used to be gh-models (free GH PAT inference), which
5800
+ // GitHub retired on 2026-07-30; the only one that works now is paid, and an adopter must not be
5801
+ // billed by a default they never chose. `--no-llm` (the dry run) needs none.
5762
5802
  program
5763
5803
  .command('skill-eval <skillName>')
5764
5804
  .description('Run derived-skill eval scenarios against an LLM (HC-010d-followup-1)')
5765
- .option('--provider <name>', 'LLM provider: gh-models (default, $0) | claude-haiku (paid)', 'gh-models')
5805
+ .option('--provider <name>', 'LLM provider: claude-haiku (paid, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
5766
5806
  .option('--scenario <id>', 'Run a single scenario by id (e.g., SF-DEV-EVAL-001)')
5767
5807
  .option('--tag <tag>', 'Filter scenarios by HC-010c rule-id tag (e.g., SF-SEC-001)')
5768
5808
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
@@ -5831,62 +5871,26 @@ program
5831
5871
  process.exit(0);
5832
5872
  }
5833
5873
 
5834
- // Provider wiring — gh-models default. Inline + per-provider so a
5835
- // future provider addition lives in one switch.
5874
+ // Provider wiring. Inline + per-provider so a future provider addition lives in one switch.
5836
5875
  const axios = require('axios');
5837
- // Pass-2 review caught the original 32000-char slice collided with
5838
- // GH Models' documented 8000-token request-body cap (see
5839
- // `cli/lib/release-review-config.js:49-68`). A real adopter
5840
- // SKILL.md after years of derivations is plausibly 20-40K chars;
5841
- // 32K leaves zero budget for scenario.input + JSON envelope, so
5842
- // GH Models returns HTTP 400 and every scenario errors. 8000
5843
- // chars ≈ ~2000 tokens for the system slot, leaving ~6000 tokens
5844
- // for scenario.input + envelope — comfortable under GH Models'
5845
- // cap while still preserving enough of the skill body to evaluate
5846
- // adopter patterns.
5876
+ // 8000 chars (~2000 tokens) for the system slot leaves the rest of the request for
5877
+ // scenario.input + the JSON envelope. The cap was sized for GitHub Models' 8000-token
5878
+ // request-body limit (see `cli/lib/release-review-config.js`); it is kept so a skill body
5879
+ // that grew large cannot crowd out the scenario now that claude-haiku is the only provider.
5847
5880
  const MAX_SKILL_PROMPT_CHARS = 8000;
5848
5881
  let apiKey, modelLabel, callLLM;
5849
- if (opts.provider === 'gh-models') {
5850
- apiKey = process.env.GITHUB_TOKEN;
5851
- if (!apiKey) {
5852
- console.error('✗ GITHUB_TOKEN not set. Required for --provider gh-models.');
5853
- console.error(' In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
5854
- // Exit 2 — operator config error, NOT a skill regression.
5855
- // Pass-2 review caught: exit 1 collided with the CI-gate exit
5856
- // code for eval failures, so a missing secret was reported
5857
- // as "skill regression" by the CI gate. The convention from
5858
- // the regression at line 172-188 is exit 2 for operator
5859
- // errors, exit 1 for eval failures.
5860
- process.exit(2);
5861
- }
5862
- modelLabel = 'openai/gpt-4.1';
5863
- callLLM = async (systemPrompt, userPrompt) => {
5864
- const { data } = await axios.post(
5865
- 'https://models.github.ai/inference/chat/completions',
5866
- {
5867
- model: modelLabel,
5868
- messages: [
5869
- { role: 'system', content: systemPrompt.slice(0, MAX_SKILL_PROMPT_CHARS) },
5870
- { role: 'user', content: userPrompt },
5871
- ],
5872
- max_tokens: 2048,
5873
- },
5874
- {
5875
- headers: {
5876
- 'Authorization': `Bearer ${apiKey}`,
5877
- 'Content-Type': 'application/json',
5878
- },
5879
- timeout: 120000,
5880
- }
5881
- );
5882
- return data.choices?.[0]?.message?.content || '';
5883
- };
5882
+ if (opts.provider === undefined || opts.provider === 'gh-models') {
5883
+ // HC-GF-340: refuse before any request. Exit 2 — operator config error, NOT a skill
5884
+ // regression (exit 1 is the CI-gate code for eval failures).
5885
+ console.error(`✗ ${require('./lib/judge-provider').providerRequiredMessage('--provider claude-haiku')}`);
5886
+ process.exit(2);
5884
5887
  } else if (opts.provider === 'claude-haiku') {
5885
5888
  apiKey = process.env.ANTHROPIC_API_KEY;
5886
5889
  if (!apiKey) {
5887
5890
  console.error('✗ ANTHROPIC_API_KEY not set. Required for --provider claude-haiku.');
5888
5891
  console.error(' Set: export ANTHROPIC_API_KEY=sk-ant-...');
5889
- // Exit 2 — operator config error (see gh-models branch).
5892
+ // Exit 2 — operator config error, NOT a skill regression (exit 1 is the CI-gate code
5893
+ // for eval failures).
5890
5894
  process.exit(2);
5891
5895
  }
5892
5896
  modelLabel = 'claude-haiku-4-5-20251001';
@@ -5904,10 +5908,16 @@ program
5904
5908
  },
5905
5909
  timeout: 120000,
5906
5910
  });
5907
- return data.content?.[0]?.text || '';
5911
+ const text = data.content?.[0]?.text;
5912
+ // An empty answer is a provider failure the runner records as an error, not a scenario
5913
+ // that failed its assertions.
5914
+ if (typeof text !== 'string' || !text.trim()) {
5915
+ throw new Error(`claude-haiku returned no text (stop_reason: ${data.stop_reason || 'unknown'})`);
5916
+ }
5917
+ return text;
5908
5918
  };
5909
5919
  } else {
5910
- console.error(`✗ Invalid --provider: ${opts.provider}. Use 'gh-models' or 'claude-haiku'.`);
5920
+ console.error(`✗ Invalid --provider: ${opts.provider}. Use 'claude-haiku'.`);
5911
5921
  process.exit(2);
5912
5922
  }
5913
5923
 
@@ -5940,10 +5950,9 @@ program
5940
5950
 
5941
5951
  console.log(formatResults(summary, opts.format));
5942
5952
 
5943
- // Exit 1 on any non-pass so CI (HC-019f-style gate) can wire this
5944
- // as a required check. Per the [Pipeline LLM Cost Reduction]
5945
- // memory, this gate can run in CI on the gh-models default with
5946
- // zero adopter cost.
5953
+ // Exit 1 on any non-pass so CI (HC-019f-style gate) can wire this as a required check. It is
5954
+ // paid now (HC-GF-340: the free gh-models default was retired), so a CI gate needs an
5955
+ // ANTHROPIC_API_KEY secret and a decision to spend.
5947
5956
  process.exit(summary.failed + summary.errors > 0 ? 1 : 0);
5948
5957
  });
5949
5958
 
@@ -5958,11 +5967,7 @@ program
5958
5967
  const config = getConfig();
5959
5968
  const client = api(config);
5960
5969
  try {
5961
- const params = new URLSearchParams();
5962
- if (opts.status) params.set('status', opts.status);
5963
- if (opts.limit) params.set('limit', opts.limit);
5964
- const qs = params.toString();
5965
- const { data } = await client.get(`/orchestrate${qs ? `?${qs}` : ''}`);
5970
+ const { data } = await client.get(apiPath('orchestrate', [], { status: opts.status, limit: opts.limit }));
5966
5971
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5967
5972
  if (!data.runs.length) { console.log('No workflow runs found.'); return; }
5968
5973
  const age = (ts) => {
@@ -5996,6 +6001,7 @@ program
5996
6001
  .description('Fetch the editor-mode instruction for a run parked awaiting_editor (HC-COMM-010 B3)')
5997
6002
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
5998
6003
  .action(async (runId, opts) => {
6004
+ refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-3
5999
6005
  const config = getConfig();
6000
6006
  const client = api(config);
6001
6007
  try {
@@ -6027,6 +6033,10 @@ program
6027
6033
  .option('--contract <version>', 'Instruction contract version (from step-instruction)', '1')
6028
6034
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
6029
6035
  .action(async (runId, stepKey, opts) => {
6036
+ refuseBadRunArgs([ // HC-GF-270-followup-3: both become path segments of the submission
6037
+ { label: '<runId>', kind: 'runId', value: runId },
6038
+ { label: '<stepKey>', kind: 'stepKey', value: stepKey },
6039
+ ]);
6030
6040
  const config = getConfig();
6031
6041
  const client = api(config);
6032
6042
  try {
@@ -6081,6 +6091,19 @@ function runContextIo(apiConfig) {
6081
6091
  const abs = path.resolve(cwd, rel);
6082
6092
  return abs.startsWith(cwd + path.sep) ? abs : null;
6083
6093
  };
6094
+ // HC-GF-283: where this checkout's issues live — host, owner, repo — resolved ONCE and shared by
6095
+ // both gh calls, so the text and the trust cannot come from different hosts. An SSH alias is
6096
+ // resolved with `ssh -G` (prints config, never connects). A failure rejects both calls: nothing is
6097
+ // fetched rather than gh falling back to its default host.
6098
+ let issueRepoP = null;
6099
+ const issueRepo = () => issueRepoP || (issueRepoP = (async () => {
6100
+ const { resolveRemote } = require('./lib/git-remote');
6101
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6102
+ const { execFile } = require('child_process');
6103
+ return resolveRemote(remoteUrl, {
6104
+ sshConfig: async (alias) => (await require('util').promisify(execFile)('ssh', ['-G', alias], { encoding: 'utf8', timeout: 10_000 })).stdout,
6105
+ });
6106
+ })());
6084
6107
  return {
6085
6108
  // Every subprocess this adapter starts carries a timeout: a hung git or gh must fail open with a
6086
6109
  // message, not stall the command (node-developer hard rule 5). The module turns each failure into
@@ -6109,15 +6132,15 @@ function runContextIo(apiConfig) {
6109
6132
  return fs.readFileSync(abs, 'utf8');
6110
6133
  },
6111
6134
  // Async (execFile, no shell) so queue-stories can resolve several issues at once.
6135
+ // HC-GF-283: `--repo HOST/OWNER/REPO`. Without the host gh used its default one, so a GitHub
6136
+ // Enterprise remote behind an SSH alias read the issue from github.com.
6112
6137
  fetchIssue: async (issueNumber) => {
6113
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6114
- const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6115
- if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6138
+ const { host, owner, repo } = await issueRepo();
6116
6139
  const { execFile } = require('child_process');
6117
6140
  let stdout;
6118
6141
  try {
6119
6142
  ({ stdout } = await require('util').promisify(execFile)('gh',
6120
- ['issue', 'view', String(issueNumber), '--repo', `${m[1]}/${m[2]}`, '--json', 'title,body,url'],
6143
+ ['issue', 'view', String(issueNumber), '--repo', `${host}/${owner}/${repo}`, '--json', 'title,body,url'],
6121
6144
  { encoding: 'utf8', timeout: 30_000 }));
6122
6145
  } catch (e) {
6123
6146
  // execFile keeps gh's stderr on the error — its first line is the actionable part ("gh auth
@@ -6131,14 +6154,12 @@ function runContextIo(apiConfig) {
6131
6154
  // HC-GF-270: who wrote the issue. run-context.js classifies a failure and story-provenance.js
6132
6155
  // decides trust; this only fetches. execFile, not a shell string: owner/repo come from the remote.
6133
6156
  fetchIssueProvenance: async (issueNumber) => {
6134
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6135
- const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6136
- if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6137
- const where = { owner: m[1], repo: m[2], remoteUrl };
6157
+ const { host, owner, repo, remoteUrl } = await issueRepo();
6158
+ const where = { owner, repo, remoteUrl, host };
6138
6159
  try {
6139
6160
  const { execFile } = require('child_process');
6140
6161
  const { stdout } = await require('util').promisify(execFile)('gh',
6141
- ['api', `repos/${m[1]}/${m[2]}/issues/${issueNumber}`],
6162
+ ['api', `repos/${owner}/${repo}/issues/${issueNumber}`, '--hostname', host],
6142
6163
  { encoding: 'utf8', timeout: 20_000 });
6143
6164
  return { ...where, issue: JSON.parse(stdout) };
6144
6165
  } catch (e) {
@@ -6146,7 +6167,7 @@ function runContextIo(apiConfig) {
6146
6167
  }
6147
6168
  },
6148
6169
  fetchStoryContext: async (storyId) => {
6149
- const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
6170
+ const { data } = await api(apiConfig).get(apiPath('stories', [storyId, 'context']));
6150
6171
  return data;
6151
6172
  },
6152
6173
  recentlyModifiedOverlap: (touched) =>
@@ -6419,6 +6440,16 @@ program
6419
6440
  console.log(` (auto-verify skipped: ${e.message})`);
6420
6441
  }
6421
6442
 
6443
+ // HC-GF-234: show what the step ASKED where the operator decides. Approving records
6444
+ // `approved` and moves on — it does not answer the question, and until now nothing at
6445
+ // this prompt said there was one. Printed last, right above the prompt (auto-verify's
6446
+ // output above can run to pages), and in both the gate and the exhausted branch: the
6447
+ // step's question rides on the attempt the operator is about to decide about.
6448
+ // Never throws: a print must not end a poll loop.
6449
+ try {
6450
+ for (const line of gateQuestionLines(status, d.stepKey)) console.log(line);
6451
+ } catch { /* a print must never end a poll loop */ }
6452
+
6422
6453
  if (process.stdin.isTTY) {
6423
6454
  const readline = require('readline');
6424
6455
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -6481,6 +6512,14 @@ program
6481
6512
  // step says so instead of listing a stale failed row above a completed one.
6482
6513
  const tries = (s.attempts || 1) > 1 ? ` [attempt ${s.attempt} of ${s.attempts}]` : '';
6483
6514
  console.log(` ${icon} ${s.stepKey} (${s.agent}) ${s.status}${gate}${tries}`);
6515
+ // HC-GF-234: `status: completed` / `gateResult: approved` alone cannot tell "did its
6516
+ // work" apart from "asked a question nobody answered" — batch mode has no human gate
6517
+ // on steps 0-3b, so this line is the only place that question ever surfaces here.
6518
+ const asked = normalizeQuestion(s.operatorQuestion);
6519
+ if (asked) {
6520
+ console.log(` asked ${countPhrase(asked.count)} nobody answered:`);
6521
+ for (const l of questionLines(asked, { max: 120 })) console.log(` ${l}`);
6522
+ }
6484
6523
  }
6485
6524
  if (data.awaitingApproval && isEditorParked(data)) {
6486
6525
  console.log('');
@@ -7189,12 +7228,12 @@ program
7189
7228
 
7190
7229
  program
7191
7230
  .command('release-review')
7192
- .description('Holistic code review of all changed files before deployment (default: GH Models, $0)')
7231
+ .description('Holistic code review of all changed files before deployment (needs --provider opus, paid; the free GH Models default was retired 2026-07-30)')
7193
7232
  .option('--base <branch>', 'Base branch to diff against', 'main')
7194
7233
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7195
7234
  .option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
7196
7235
  .option('--max-files <n>', 'Max source files to include in review', '40')
7197
- .option('--provider <name>', 'LLM provider: gh-models (default, $0) | opus (legacy, paid)', 'gh-models')
7236
+ .option('--provider <name>', 'LLM provider: opus (paid, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
7198
7237
  .option('--cache <mode>', 'HC-RC-002-followup-1 content-hash cache: on | off (default on)', 'on')
7199
7238
  .action(async (opts) => {
7200
7239
  const { execSync } = require('child_process');
@@ -7232,12 +7271,23 @@ program
7232
7271
  // CI step passes `--base origin/main` and the previous code prefixed
7233
7272
  // again → `origin/origin/main` → git rejected → silent fallback to
7234
7273
  // `HEAD~10`. Both lanes reviewed the wrong diff in PR #313 first run.
7274
+ // HC-GF-270-followup-3: the base gets the check step-5b and verify-pr give theirs. resolveBaseRef prefixes
7275
+ // `origin/`, so an option-shaped base cannot reach git as an option — but git would fail on it and the
7276
+ // review would fall back to HEAD~10 without saying so.
7277
+ const badBase = runArgsError([{ label: '--base', kind: 'gitRev', value: opts.base }]);
7278
+ if (badBase) {
7279
+ console.error(badBase);
7280
+ emitStatusEnvelope('refused', { error: badBase });
7281
+ process.exit(2);
7282
+ }
7235
7283
  const baseRef = resolveBaseRef(opts.base);
7236
7284
 
7237
7285
  // 1. Get changed files
7238
7286
  let changedFiles;
7239
7287
  try {
7240
- const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7288
+ // HC-GF-270-followup-3: argument lists, no shell. baseRef always starts `origin/` (resolveBaseRef), so it
7289
+ // cannot be read as an option.
7290
+ const raw = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7241
7291
  changedFiles = raw.trim().split('\n').filter(Boolean);
7242
7292
  } catch {
7243
7293
  try {
@@ -7280,34 +7330,32 @@ program
7280
7330
  }
7281
7331
 
7282
7332
  // 3. Provider validation + credential check
7283
- if (opts.provider !== 'opus' && opts.provider !== 'gh-models') {
7284
- console.error(`Invalid --provider: ${opts.provider}. Use 'opus' or 'gh-models'.`);
7333
+ // HC-GF-340: gh-models (the old default) is retired, and no provider is a default now, so an
7334
+ // unnamed or retired one is refused with the cause instead of calling an endpoint that
7335
+ // answers "OK" and reporting "empty response ... content filter, quota or model name".
7336
+ if (opts.provider === undefined || opts.provider === 'gh-models') {
7337
+ console.error(require('./lib/judge-provider').providerRequiredMessage('--provider opus'));
7338
+ process.exit(1);
7339
+ }
7340
+ if (opts.provider !== 'opus') {
7341
+ console.error(`Invalid --provider: ${opts.provider}. Use 'opus'.`);
7285
7342
  process.exit(1);
7286
7343
  }
7287
7344
 
7288
- let apiKey, providerLabel;
7289
- if (opts.provider === 'gh-models') {
7290
- apiKey = process.env.GITHUB_TOKEN;
7291
- providerLabel = 'GitHub Models (openai/gpt-4.1)';
7292
- if (!apiKey) {
7293
- console.error('GITHUB_TOKEN not set. Required for --provider gh-models.');
7294
- console.error('In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
7295
- process.exit(1);
7296
- }
7297
- } else {
7298
- apiKey = process.env.ANTHROPIC_API_KEY;
7299
- providerLabel = 'Anthropic Opus (claude-opus-4-8)';
7300
- if (!apiKey) {
7301
- console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
7302
- console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
7303
- process.exit(1);
7304
- }
7345
+ const apiKey = process.env.ANTHROPIC_API_KEY;
7346
+ const providerLabel = 'Anthropic Opus (claude-opus-4-8)';
7347
+ if (!apiKey) {
7348
+ console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
7349
+ console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
7350
+ process.exit(1);
7305
7351
  }
7306
7352
 
7307
7353
  // 4. Build the diff content (truncated per-file to stay within context)
7308
7354
  let diffContent;
7309
7355
  try {
7310
- diffContent = execSync(`git diff ${baseRef}...HEAD -- ${filesToReview.map(f => `'${f}'`).join(' ')}`, {
7356
+ // HC-GF-270-followup-3: each changed file is ONE argument after `--`. They were wrapped in single quotes
7357
+ // inside a shell string, so a file a PR author named with a `'` in it ran as a command.
7358
+ diffContent = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--', ...filesToReview], {
7311
7359
  encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024,
7312
7360
  });
7313
7361
  } catch {
@@ -7319,9 +7367,7 @@ program
7319
7367
  }
7320
7368
  }
7321
7369
 
7322
- // Provider-specific truncation. GH Models GPT-4.1 has an 8K-token
7323
- // request-body cap via models.github.ai/inference — 100K chars overflows.
7324
- // See cli/lib/release-review-config.js for the budget math.
7370
+ // Provider-specific truncation; see cli/lib/release-review-config.js for the budget math.
7325
7371
  const MAX_DIFF_CHARS = getMaxDiffChars(opts.provider);
7326
7372
  if (diffContent.length > MAX_DIFF_CHARS) {
7327
7373
  diffContent = diffContent.slice(0, MAX_DIFF_CHARS) +
@@ -7404,9 +7450,7 @@ program
7404
7450
  normalizeCacheFlag,
7405
7451
  } = require('./lib/release-review-cache');
7406
7452
  const cacheEnabled = normalizeCacheFlag(opts.cache);
7407
- const modelForCache = opts.provider === 'gh-models'
7408
- ? 'openai/gpt-4.1'
7409
- : 'claude-opus-4-8';
7453
+ const modelForCache = 'claude-opus-4-8';
7410
7454
  let cacheContentHash = null;
7411
7455
  if (cacheEnabled) {
7412
7456
  try {
@@ -7469,59 +7513,30 @@ program
7469
7513
  }
7470
7514
 
7471
7515
  // 6. Call LLM (provider-branched, HC-080a-spike)
7472
- // max_tokens is held SYMMETRIC across providers so the HC-080a-spike
7473
- // comparison measures model capability, not output budget. 4096 is the
7474
- // safe ceiling for openai/gpt-4.1 via GH Models; Opus supports more but
7475
- // running it with 4096 keeps the comparison apples-to-apples.
7516
+ // max_tokens stays at 4096, the value the HC-080a-spike comparison with GPT-4.1 held constant,
7517
+ // so a review's output budget has not changed now that Opus is the only provider.
7476
7518
  const MAX_OUTPUT_TOKENS = 4096;
7477
7519
  const startedAt = Date.now();
7478
7520
  try {
7479
7521
  let responseText, inputTokens, outputTokens, modelLabel;
7480
7522
 
7481
- if (opts.provider === 'gh-models') {
7482
- // GitHub Models — free LLM inference using GITHUB_TOKEN. Mirrors the
7483
- // path used by server/scripts/ai-reviewer.js for per-PR review.
7484
- const { data } = await axios.post(
7485
- 'https://models.github.ai/inference/chat/completions',
7486
- {
7487
- model: 'openai/gpt-4.1',
7488
- messages: [
7489
- { role: 'system', content: systemPrompt },
7490
- { role: 'user', content: userPrompt },
7491
- ],
7492
- max_tokens: MAX_OUTPUT_TOKENS,
7493
- },
7494
- {
7495
- headers: {
7496
- 'Authorization': `Bearer ${apiKey}`,
7497
- 'Content-Type': 'application/json',
7498
- },
7499
- timeout: 120000,
7500
- }
7501
- );
7502
- responseText = data.choices?.[0]?.message?.content || '';
7503
- inputTokens = data.usage?.prompt_tokens || 0;
7504
- outputTokens = data.usage?.completion_tokens || 0;
7505
- modelLabel = 'openai/gpt-4.1';
7506
- } else {
7507
- const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
7508
- model: 'claude-opus-4-8',
7509
- max_tokens: MAX_OUTPUT_TOKENS,
7510
- system: systemPrompt,
7511
- messages: [{ role: 'user', content: userPrompt }],
7512
- }, {
7513
- headers: {
7514
- 'x-api-key': apiKey,
7515
- 'anthropic-version': '2023-06-01',
7516
- 'content-type': 'application/json',
7517
- },
7518
- timeout: 120000,
7519
- });
7520
- responseText = data.content?.[0]?.text || '';
7521
- inputTokens = data.usage?.input_tokens || 0;
7522
- outputTokens = data.usage?.output_tokens || 0;
7523
- modelLabel = 'claude-opus-4-8';
7524
- }
7523
+ const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
7524
+ model: 'claude-opus-4-8',
7525
+ max_tokens: MAX_OUTPUT_TOKENS,
7526
+ system: systemPrompt,
7527
+ messages: [{ role: 'user', content: userPrompt }],
7528
+ }, {
7529
+ headers: {
7530
+ 'x-api-key': apiKey,
7531
+ 'anthropic-version': '2023-06-01',
7532
+ 'content-type': 'application/json',
7533
+ },
7534
+ timeout: 120000,
7535
+ });
7536
+ responseText = data.content?.[0]?.text || '';
7537
+ inputTokens = data.usage?.input_tokens || 0;
7538
+ outputTokens = data.usage?.output_tokens || 0;
7539
+ modelLabel = 'claude-opus-4-8';
7525
7540
 
7526
7541
  const elapsedMs = Date.now() - startedAt;
7527
7542
 
@@ -7693,6 +7708,16 @@ showCmd
7693
7708
  console.log(` startedAt: ${s.startedAt || '(not started)'}`);
7694
7709
  console.log(` completedAt: ${s.completedAt || '(not completed)'}`);
7695
7710
  if (s.tokenUsage) console.log(` tokenUsage: ${JSON.stringify(s.tokenUsage)}`);
7711
+ // HC-GF-234: `status`/`gateResult` above read identically whether this step did its
7712
+ // work or ended by asking the operator something nobody answered — surface it here so
7713
+ // an operator does not have to read raw step output to find out.
7714
+ const asked = normalizeQuestion(s.operatorQuestion);
7715
+ if (asked) {
7716
+ console.log(``);
7717
+ console.log(`── operator question(s) — asked, not answered ──────────`);
7718
+ console.log(` asked ${asked.count} question(s):`);
7719
+ for (const l of questionLines(asked)) console.log(` ${l}`);
7720
+ }
7696
7721
  if (s.errorContext) {
7697
7722
  console.log(``);
7698
7723
  console.log(`── error_context (retry feedback) ──────────────────────`);
@@ -8418,6 +8443,13 @@ program
8418
8443
  .option('--repo <name>', 'Repository name the batch was queued under (default: this directory\'s name)')
8419
8444
  .action(async (batchId, opts) => {
8420
8445
  const { followBatch, EXIT } = require('./lib/batch-follow');
8446
+ // HC-GF-270-followup-3: refused before the preflight git/gh calls and the config read — with EXIT.FATAL (1),
8447
+ // because 2 is EXIT.NEEDS_YOU, which a caller reads as "the batch needs a human".
8448
+ const badBatch = runArgsError([{ label: '<batchId>', kind: 'batchId', value: batchId }]);
8449
+ if (badBatch) {
8450
+ console.error(badBatch);
8451
+ process.exit(EXIT.FATAL);
8452
+ }
8421
8453
  const { spawn, spawnSync } = require('child_process');
8422
8454
  const positive = (raw, flag, unit) => {
8423
8455
  const n = Number(raw);
@@ -8918,7 +8950,13 @@ program
8918
8950
  .option('--format <fmt>', 'pretty or json', 'pretty')
8919
8951
  .action(async (prOrBranch, opts) => {
8920
8952
  // HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
8921
- refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
8953
+ // HC-GF-270-followup-3: so are the story id, which names the directory step-5c-ci.md is written to, and the
8954
+ // base, which reaches git through step-5b.
8955
+ refuseBadRunArgs([
8956
+ { label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true },
8957
+ { label: '--story-id', kind: 'storyId', value: opts.storyId, optional: true },
8958
+ { label: '--base', kind: 'gitRev', value: opts.base || 'develop' },
8959
+ ]);
8922
8960
  const { execFileSync, spawnSync } = require('child_process');
8923
8961
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8924
8962
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -8945,6 +8983,9 @@ program
8945
8983
  let branch = '';
8946
8984
  try { branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* */ }
8947
8985
  const storyId = opts.storyId || extractStoryIdFromBranch(branch) || extractStoryIdFromBranch(prOrBranch) || 'unknown-story';
8986
+ // HC-GF-270-followup-3: an id read from the branch or the ref gets the check --story-id got (the pattern has no
8987
+ // length cap) — before the CI command runs, and so the artifact write below cannot throw.
8988
+ refuseBadRunArgs([{ label: 'the story id read from the branch name', kind: 'storyId', value: storyId }]);
8948
8989
 
8949
8990
  // 2. CI gate mode: --mode > ci.gate config > github.
8950
8991
  let configGate = 'github';
@@ -9001,12 +9042,16 @@ program
9001
9042
 
9002
9043
  const verdict = ciGateVerdict({ mode, gh, local });
9003
9044
 
9004
- // 5. Write the real step-5c-ci.md artifact.
9045
+ // 5. Write the real step-5c-ci.md artifact. Best-effort, but the report below names the file only if it was
9046
+ // written (HC-GF-270-followup-3: a failed write used to print the path anyway).
9047
+ let artifactLine = ` → .github/pipeline/${storyId}/step-5c-ci.md`;
9005
9048
  try {
9006
- const dir = path.join(repoRoot, '.github/pipeline', storyId);
9049
+ const dir = storyDirIn(path.join(repoRoot, '.github/pipeline'), storyId); // HC-GF-270-followup-3
9007
9050
  fs.mkdirSync(dir, { recursive: true });
9008
9051
  fs.writeFileSync(path.join(dir, 'step-5c-ci.md'), renderStep5cArtifact({ storyId, mode, prRef: prOrBranch, gh, local, verdict }));
9009
- } catch { /* best-effort */ }
9052
+ } catch (e) {
9053
+ artifactLine = ` ⚠ step-5c-ci.md not written: ${e.message}`;
9054
+ }
9010
9055
 
9011
9056
  // 6. Closed loop (HC-019n-followup-36): report the CI-gate verdict to the
9012
9057
  // server. This RECORDS against step_5c — which is a post-PR, CLI-produced
@@ -9043,7 +9088,7 @@ program
9043
9088
  { ok: exitCode === EXIT.PASS, exitCode, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
9044
9089
  `${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
9045
9090
  ` skill audit: ${opts.skipSkillAudit ? 'skipped' : skillAudit.blocked ? `✗ BLOCKER finding(s) — ${skillAudit.artifact}` : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
9046
- ` → .github/pipeline/${storyId}/step-5c-ci.md`);
9091
+ artifactLine);
9047
9092
  process.exit(exitCode);
9048
9093
  });
9049
9094