@hone-ai/cli 1.38.0 → 1.39.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
@@ -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
@@ -33,13 +33,16 @@ const { parseTimeoutSeconds } = require('./lib/timeout-option.js');
33
33
  // HC-GF-143 batch: both mappings were computed inline and asserted by nothing (HC-GF-136).
34
34
  const { promotePayload } = require('./lib/promote-payload');
35
35
  const { assetFetchList } = require('./lib/asset-fetch-list');
36
+ // HC-CI-019-followup-3: no suite asserted a field of either, and this file cannot be required.
37
+ const { detectExistingTestFramework } = require('./lib/detect-test-framework');
38
+ const { classifySpec } = require('./lib/classify-spec');
36
39
 
37
40
  const pkg = require('./package.json');
38
41
  const { parseReviewJSON } = require('./lib/parse-review-json');
39
42
  const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
40
43
  const { gitEnv } = require('./lib/git-env');
41
44
  const { collectIncludePaths } = require('./lib/include-paths');
42
- const { runArgsError, orchestratePath, apiPath } = require('./lib/run-target'); // HC-GF-270-followup-1, -2
45
+ const { runArgsError, orchestratePath, apiPath, storyDir: storyDirIn, isGitRev, isStoryId } = require('./lib/run-target'); // HC-GF-270-followup-1, -2, -3, -4
43
46
  const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
44
47
  const program = new Command();
45
48
 
@@ -217,8 +220,8 @@ program
217
220
  .option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary|stateful)', 'adversarial')
218
221
  .option('--target <name>', 'Run against a specific agent target (default: all configured)')
219
222
  .option('--output <format>', 'Output format (text|json)', 'text')
220
- .option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
221
- .option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
223
+ .option('--judge', 'Add the NLI judge layer (faithfulness only): claims are checked for entailment against the probe context. Needs --provider claude (paid)')
224
+ .option('--provider <name>', 'Judge provider: claude (paid Sonnet, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
222
225
  .option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
223
226
  .option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
224
227
  .option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
@@ -922,11 +925,14 @@ program
922
925
  console.log(' to switch to local-mode (real safety net at $0 CI cost).');
923
926
  }
924
927
 
925
- const flags = [
926
- `--source "${path.join(tmpDir, 'enterprise-github')}"`,
927
- opts.dryRun ? '--dry-run' : '',
928
- isNonInteractive ? '--non-interactive' : '',
929
- ].filter(Boolean).join(' ');
928
+ // HC-GF-270-followup-3: an argument list, not a shell string — the script path and the source directory
929
+ // reach bash as the arguments they are, whatever characters a temp path holds.
930
+ const setupArgs = [
931
+ scriptPath,
932
+ '--source', path.join(tmpDir, 'enterprise-github'),
933
+ ...(opts.dryRun ? ['--dry-run'] : []),
934
+ ...(isNonInteractive ? ['--non-interactive'] : []),
935
+ ];
930
936
 
931
937
  // HC-RC-004 pass-1 (HIGH-1): when source==='auto-detect', LEAVE
932
938
  // CI_GATE unset so the bash script's existing detected_ci_default
@@ -941,7 +947,7 @@ program
941
947
  // after detection (apply_stack_override) and refuses an unsupported value.
942
948
  if (opts.stack) setupEnv.HONE_STACK_OVERRIDE = String(opts.stack).toLowerCase();
943
949
  try {
944
- execSync(`bash "${scriptPath}" ${flags}`, {
950
+ execFileSync('bash', setupArgs, {
945
951
  stdio: 'inherit',
946
952
  cwd: process.cwd(),
947
953
  env: setupEnv,
@@ -1174,45 +1180,6 @@ function describeSandboxDisposition(runConfig) {
1174
1180
  }
1175
1181
  }
1176
1182
 
1177
- function detectExistingTestFramework(repoRoot) {
1178
- const pkgPath = path.join(repoRoot, 'package.json');
1179
- if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
1180
-
1181
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1182
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1183
-
1184
- // Detect stack
1185
- let stack = 'node';
1186
- if (allDeps['react']) stack = allDeps['vite'] ? 'react-vite' : 'react';
1187
- if (allDeps['next']) stack = 'nextjs';
1188
- if (allDeps['@nestjs/core']) stack = 'nestjs';
1189
-
1190
- // Detect existing test framework
1191
- let testFw = null;
1192
- if (allDeps['vitest']) testFw = 'vitest';
1193
- else if (allDeps['jest']) testFw = 'jest';
1194
- else if (allDeps['mocha']) testFw = 'mocha';
1195
-
1196
- // Detect Playwright
1197
- const hasPlaywright = !!allDeps['@playwright/test'];
1198
-
1199
- // Detect package manager
1200
- let pkgMgr = 'npm';
1201
- if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) pkgMgr = 'yarn';
1202
- if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) pkgMgr = 'pnpm';
1203
-
1204
- // Detect existing configs
1205
- const hasTestConfig = ['vitest.config.js', 'vitest.config.ts', 'jest.config.js', 'jest.config.ts', 'jest.config.cjs']
1206
- .some(f => fs.existsSync(path.join(repoRoot, f)));
1207
- const hasPlaywrightConfig = fs.existsSync(path.join(repoRoot, 'playwright.config.js'))
1208
- || fs.existsSync(path.join(repoRoot, 'playwright.config.ts'));
1209
-
1210
- // Detect existing test script
1211
- const hasTestScript = !!pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1';
1212
-
1213
- return { stack, testFw, hasPlaywright, pkgMgr, hasTestConfig, hasPlaywrightConfig, hasTestScript, pkg };
1214
- }
1215
-
1216
1183
  function getInstallPackages(stack) {
1217
1184
  const packages = {
1218
1185
  'react-vite': {
@@ -1332,7 +1299,12 @@ async function installTestFramework(repoRoot, opts) {
1332
1299
  return installPythonTestFramework(repoRoot, detected, pkgs, opts);
1333
1300
  }
1334
1301
 
1335
- const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
1302
+ // HC-GF-270-followup-3: the installer and its arguments as a list, run without a shell. npm, yarn and pnpm
1303
+ // are `.cmd` shims on Windows, which Node will only spawn through a shell — so these two calls (constant
1304
+ // arguments only) take one there and nowhere else. No `env`: an install keeps the ambient toolchain (HC-GF-144).
1305
+ const INSTALL_ARGV = { yarn: ['yarn', 'add', '-D'], pnpm: ['pnpm', 'add', '-D'], npm: ['npm', 'install', '-D'] };
1306
+ const [installBin, ...installPre] = INSTALL_ARGV[detected.pkgMgr] || INSTALL_ARGV.npm;
1307
+ const installShell = process.platform === 'win32';
1336
1308
 
1337
1309
  // ── Unit test framework ────────────────────────────────
1338
1310
  if (detected.testFw) {
@@ -1340,7 +1312,7 @@ async function installTestFramework(repoRoot, opts) {
1340
1312
  } else {
1341
1313
  console.log(` Stack: ${detected.stack} → Installing: ${pkgs.unit.join(', ')}`);
1342
1314
  try {
1343
- execSync(`${installCmd} ${pkgs.unit.join(' ')}`, { stdio: 'inherit', cwd: repoRoot });
1315
+ execFileSync(installBin, [...installPre, ...pkgs.unit], { stdio: 'inherit', cwd: repoRoot, shell: installShell });
1344
1316
  console.log(` ✓ Unit test framework installed`);
1345
1317
  } catch (e) {
1346
1318
  console.error(` ✗ Install failed: ${e.message}`);
@@ -1422,7 +1394,7 @@ async function installTestFramework(repoRoot, opts) {
1422
1394
  } else {
1423
1395
  console.log(` Installing Playwright...`);
1424
1396
  try {
1425
- execSync(`${installCmd} @playwright/test`, { stdio: 'inherit', cwd: repoRoot });
1397
+ execFileSync(installBin, [...installPre, '@playwright/test'], { stdio: 'inherit', cwd: repoRoot, shell: installShell });
1426
1398
  console.log(` ✓ @playwright/test installed`);
1427
1399
  console.log(` Installing Chromium browser...`);
1428
1400
  execSync('npx playwright install chromium', { stdio: 'inherit', cwd: repoRoot });
@@ -2719,7 +2691,15 @@ program
2719
2691
  if (!e || !e.id || !e.status) continue;
2720
2692
  // Map E13-A-L1 → E13-A (story id strips trailing -L<n>)
2721
2693
  const storyId = String(e.id).replace(/-L\d+$/i, '');
2722
- const yamlPath = path.join(learningsRoot, `${storyId}.yml`);
2694
+ // HC-GF-270-followup-4: the id and the status come from the server — the id is a file name an org member
2695
+ // uploaded — and they choose and rewrite a local file. An id that is not a story id is skipped and the
2696
+ // path is contained; the status must be one word, because it is written into the YAML as text.
2697
+ if (!isStoryId(storyId) || !/^[a-z][a-z_-]{0,31}$/.test(String(e.status))) {
2698
+ console.log(` ⚠ skipped a report entry whose id is not a story id or whose status is not one word: ${JSON.stringify({ id: e.id, status: e.status })}`);
2699
+ skippedCount++;
2700
+ continue;
2701
+ }
2702
+ const yamlPath = `${storyDirIn(learningsRoot, storyId)}.yml`;
2723
2703
  if (!fs.existsSync(yamlPath)) {
2724
2704
  console.log(` ⚠ no local learnings file for ${storyId} — skip`);
2725
2705
  skippedCount++;
@@ -3228,6 +3208,7 @@ program
3228
3208
  .option('--max-files <n>', 'Max source files for derive (default: 15)', '15')
3229
3209
  .option('--skip-derive', 'Skip the derive step (setup + sync only)')
3230
3210
  .action(async (opts) => {
3211
+ refuseBadRunArgs([{ label: '--max-files', kind: 'count', value: opts.maxFiles }]); // HC-GF-270-followup-3
3231
3212
  const startTime = Date.now();
3232
3213
 
3233
3214
  console.log('');
@@ -3248,10 +3229,8 @@ program
3248
3229
  if (opts.e2e !== false) setupArgs.push('--e2e');
3249
3230
  if (opts.noE2e) setupArgs.push('--no-e2e');
3250
3231
 
3251
- const { execSync: ex } = require('child_process');
3252
- const cliPath = __filename;
3253
- const setupCmd = `node "${cliPath}" ${setupArgs.join(' ')}`;
3254
- ex(setupCmd, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3232
+ // HC-GF-270-followup-3: this CLI re-run by the node running it, with an argument list — no shell.
3233
+ execFileSync(process.execPath, [__filename, ...setupArgs], { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3255
3234
  } catch (e) {
3256
3235
  console.error('Setup failed:', e.message);
3257
3236
  process.exit(1);
@@ -3263,10 +3242,10 @@ program
3263
3242
  console.log('━━━ Step 2/3: Derive Domain Skills ━━━━━━━━━━━━');
3264
3243
  console.log('');
3265
3244
  try {
3266
- const { execSync: ex } = require('child_process');
3267
- const cliPath = __filename;
3268
- const deriveCmd = `node "${cliPath}" derive --max-files ${opts.maxFiles}`;
3269
- ex(deriveCmd, { stdio: 'inherit', cwd: process.cwd(), env: process.env, timeout: 600_000 });
3245
+ // HC-GF-270-followup-3: `--max-files` was pasted into a shell string, so `--max-files '15;touch x'`
3246
+ // ran `touch x`. It is checked as a count when adopt starts, and reaches derive as one argument.
3247
+ execFileSync(process.execPath, [__filename, 'derive', '--max-files', String(opts.maxFiles)],
3248
+ { stdio: 'inherit', cwd: process.cwd(), env: process.env, timeout: 600_000 });
3270
3249
  } catch (e) {
3271
3250
  console.warn('');
3272
3251
  console.warn('⚠ Derive failed (non-fatal):', e.message?.slice(0, 100));
@@ -3283,9 +3262,7 @@ program
3283
3262
  console.log('━━━ Step 3/3: Sync Prompts ━━━━━━━━━━━━━━━━━━━━');
3284
3263
  console.log('');
3285
3264
  try {
3286
- const { execSync: ex } = require('child_process');
3287
- const cliPath = __filename;
3288
- ex(`node "${cliPath}" sync`, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3265
+ execFileSync(process.execPath, [__filename, 'sync'], { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3289
3266
  } catch (e) {
3290
3267
  console.warn('⚠ Sync failed:', e.message);
3291
3268
  }
@@ -3295,12 +3272,10 @@ program
3295
3272
  // mismatches surface immediately instead of silently breaking
3296
3273
  // Gate 3 / Pizza Tracker on the first PR.
3297
3274
  try {
3298
- const { execSync: ex } = require('child_process');
3299
- const cliPath = __filename;
3300
3275
  console.log('');
3301
3276
  console.log('━━━ Pattern detection (H-018) ━━━━━━━━━━━━━━━━━━');
3302
3277
  console.log('');
3303
- ex(`node "${cliPath}" check-patterns`, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3278
+ execFileSync(process.execPath, [__filename, 'check-patterns'], { stdio: 'inherit', cwd: process.cwd(), env: process.env });
3304
3279
  } catch (e) {
3305
3280
  // Exit 1 from check-patterns means drift detected — the user
3306
3281
  // already saw the table, so just note that adoption succeeded
@@ -3887,63 +3862,6 @@ function scanForSpecs(dir, repoRoot, results) {
3887
3862
  } catch {}
3888
3863
  }
3889
3864
 
3890
- function classifySpec(file, repoRoot) {
3891
- let content = '';
3892
- try { content = fs.readFileSync(file.fullPath, 'utf8'); } catch { }
3893
-
3894
- const hasPage = /page\.(goto|click|locator|fill|waitFor|getBy)/.test(content);
3895
- const hasRequest = /request\.(get|post|put|delete|patch)\(/.test(content);
3896
- const hasExpectResponse = /response\.(status|json|ok|body)/.test(content) ||
3897
- /expect\(resp/.test(content);
3898
-
3899
- // Detect type
3900
- let type = 'UNKNOWN';
3901
- if (hasPage && (hasRequest || hasExpectResponse)) type = 'HYBRID';
3902
- else if (hasPage) type = 'BROWSER';
3903
- else if (hasRequest || hasExpectResponse) type = 'API';
3904
- else if (/import.*playwright|from.*@playwright/.test(content)) type = 'BROWSER';
3905
- else if (/import.*supertest|from.*supertest/.test(content)) type = 'API';
3906
-
3907
- // Detect module from filename or content
3908
- // Detect module — filename takes priority over content, most specific first
3909
- let module = 'general';
3910
- const name = file.name.toLowerCase();
3911
- const contentLower = content.toLowerCase();
3912
-
3913
- // Pass 1: check filename (strongest signal)
3914
- if (/auth|login|signup|oauth|session/.test(name)) module = 'auth';
3915
- else if (/trade|order|execution/.test(name)) module = 'trading';
3916
- else if (/position|portfolio|pnl|p&l/.test(name)) module = 'portfolio';
3917
- else if (/alert|notification/.test(name)) module = 'alerts';
3918
- else if (/dashboard|widget|overview/.test(name)) module = 'dashboard';
3919
- else if (/scanner|scan|market/.test(name)) module = 'market';
3920
- else if (/setting|config|preference/.test(name)) module = 'settings';
3921
-
3922
- // Pass 2: fallback to content (if filename didn't match)
3923
- if (module === 'general') {
3924
- if (/login|authenticate|sign.?in/.test(contentLower)) module = 'auth';
3925
- else if (/trading|order|execute/.test(contentLower)) module = 'trading';
3926
- else if (/portfolio/.test(contentLower)) module = 'portfolio';
3927
- else if (/alert|notification/.test(contentLower)) module = 'alerts';
3928
- else if (/dashboard/.test(contentLower)) module = 'dashboard';
3929
- else if (/market|scanner/.test(contentLower)) module = 'market';
3930
- }
3931
-
3932
- // Determine destination
3933
- let destination;
3934
- if (type === 'API') {
3935
- destination = `e2e/api/${file.name}`;
3936
- } else {
3937
- destination = `e2e/regression/${module}/${file.name}`;
3938
- }
3939
-
3940
- // Smoke candidate: login, auth, critical navigation, app loads
3941
- const smoke = /login|auth|app.load|critical|smoke|health/.test(name) ||
3942
- /login|authenticate/.test(contentLower);
3943
-
3944
- return { ...file, type, module, destination, smoke };
3945
- }
3946
-
3947
3865
  // ── Helpers ───────────────────────────────────────────────────────────────────
3948
3866
  function bundleDirectory(dir, root, files, maxFiles, currentCount) {
3949
3867
  if (currentCount >= maxFiles) return;
@@ -4176,6 +4094,8 @@ metricsCmd
4176
4094
  .description('Bootstrap .github/metrics/<STORY-ID>.yml from pipeline metadata + git + gh')
4177
4095
  .option('--force', 'Overwrite an existing scorecard (default: preserve)')
4178
4096
  .action(async (storyIdArg, opts) => {
4097
+ // HC-GF-270-followup-3: the id names a directory this reads and a file it writes; `../` must not leave them.
4098
+ refuseBadRunArgs([{ label: '<storyId>', kind: 'storyId', value: storyIdArg, optional: true }]);
4179
4099
  const ps = require('./lib/pipeline-status');
4180
4100
  const mc = require('./lib/metrics-collect');
4181
4101
  const repoRoot = process.cwd();
@@ -4198,8 +4118,11 @@ metricsCmd
4198
4118
  process.exit(1);
4199
4119
  }
4200
4120
  }
4121
+ // HC-GF-270-followup-3: an id read from the branch name gets the check a typed one got — the branch
4122
+ // pattern has no length cap, so storyDir would otherwise throw a stack trace instead of refusing.
4123
+ refuseBadRunArgs([{ label: 'the story id read from the branch name', kind: 'storyId', value: storyId }]);
4201
4124
 
4202
- const storyDir = path.join(pipelineRoot, storyId);
4125
+ const storyDir = storyDirIn(pipelineRoot, storyId);
4203
4126
  const metaPath = path.join(storyDir, 'metadata.yml');
4204
4127
  if (!fs.existsSync(metaPath)) {
4205
4128
  console.error(`No pipeline metadata for ${storyId} — checked ${metaPath}`);
@@ -4222,7 +4145,7 @@ metricsCmd
4222
4145
 
4223
4146
  // Idempotency: preserve existing scorecard unless --force
4224
4147
  fs.mkdirSync(metricsRoot, { recursive: true });
4225
- const outPath = path.join(metricsRoot, `${storyId}.yml`);
4148
+ const outPath = `${storyDirIn(metricsRoot, storyId)}.yml`;
4226
4149
  if (fs.existsSync(outPath) && !opts.force) {
4227
4150
  console.error(`${outPath} already exists. Re-run with --force to overwrite.`);
4228
4151
  console.error('(Default behavior: preserve any hand-written or agent-appended fields.)');
@@ -4236,17 +4159,28 @@ metricsCmd
4236
4159
  } catch { /* leave undefined */ }
4237
4160
 
4238
4161
  // Files changed — best-effort
4162
+ // HC-GF-270-followup-3: the base comes from metadata.yml — repository content — and reaches git as one
4163
+ // argument. A base git would read as an option (`--output=…` writes a file) is skipped, not run.
4239
4164
  let filesChanged = [];
4240
- try {
4241
- const diff = execSync(`git diff --name-only ${parsed.base || 'develop'}...HEAD`, { encoding: 'utf8' });
4242
- filesChanged = diff.split('\n').map(s => s.trim()).filter(Boolean);
4243
- } catch { /* leave empty */ }
4165
+ const base = String(parsed.base || 'develop');
4166
+ if (isGitRev(base)) {
4167
+ try {
4168
+ const diff = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { encoding: 'utf8' });
4169
+ filesChanged = diff.split('\n').map(s => s.trim()).filter(Boolean);
4170
+ } catch { /* leave empty */ }
4171
+ } else {
4172
+ console.error(` ⚠ metadata.yml base ${JSON.stringify(base)} is not a git revision; files_changed left empty`);
4173
+ }
4244
4174
 
4245
4175
  // PR info — best-effort, gh may be missing or no PR yet (E4/E5 graceful)
4246
4176
  let prInfo = {};
4247
4177
  try {
4248
4178
  const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
4249
- const pr = execSync(`gh pr list --head ${branch} --json number --jq '.[0].number' 2>/dev/null`, { encoding: 'utf8' }).trim();
4179
+ // HC-GF-270-followup-3: `--head=<branch>` is one argument whatever the branch name holds (`;`, `$(` and `(`
4180
+ // are legal in one); stderr is dropped by stdio rather than by a shell redirect.
4181
+ // A network call: bounded like classify-story's gh call, so a hung gh leaves pr_number unset instead of hanging.
4182
+ const pr = execFileSync('gh', ['pr', 'list', `--head=${branch}`, '--json', 'number', '--jq', '.[0].number'],
4183
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 30_000 }).trim();
4250
4184
  if (pr && /^\d+$/.test(pr)) prInfo = { pr_number: parseInt(pr, 10) };
4251
4185
  } catch { /* leave empty — graceful */ }
4252
4186
 
@@ -5389,12 +5323,26 @@ program
5389
5323
  .option('--artifact-dir <dir>', 'write the artifact here instead of .github/pipeline/<STORY-ID>/ (a CI gate must not leave files in the checkout)')
5390
5324
  .option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
5391
5325
  .action(async (cmdOpts) => {
5326
+ // HC-GF-270-followup-3: the story id names the artifact directory and the learnings file; the base reaches
5327
+ // git. Refused before anything runs — with exit 1 ("could not run"), because 2 here means a BLOCKER fired
5328
+ // (STEP_5B_BLOCKED) and verify-pr and the skill-audit gate read it that way.
5329
+ const badArg = runArgsError([
5330
+ { label: '--story-id', kind: 'storyId', value: cmdOpts.storyId, optional: true },
5331
+ { label: '--base', kind: 'gitRev', value: cmdOpts.base || 'develop' },
5332
+ ]);
5333
+ if (badArg) {
5334
+ console.error(`step-5b: ${badArg}`);
5335
+ process.exit(1);
5336
+ }
5392
5337
  const { runAssertions } = require('./lib/skill-assertions');
5393
5338
  const { renderStep5bArtifact } = require('./lib/skill-audit-render');
5394
5339
  const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
5395
5340
  const { parseLearningsFile } = require('./lib/learnings-parse');
5396
- const { execSync } = require('node:child_process');
5341
+ const { execSync, execFileSync } = require('node:child_process');
5397
5342
  const repoRoot = process.cwd();
5343
+ // HC-GF-270-followup-3: git reads with an argument list — the base is one argument, never shell text.
5344
+ const gitRead = (args) => execFileSync('git', args,
5345
+ { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5398
5346
 
5399
5347
  // 1. Resolve story id (CLI flag wins; else parse branch name)
5400
5348
  let branchName = '';
@@ -5407,18 +5355,22 @@ program
5407
5355
  console.error(`step-5b: cannot determine story id from branch "${branchName}". Pass --story-id explicitly. Skipping audit.`);
5408
5356
  process.exit(0); // exit 0 — informational
5409
5357
  }
5358
+ // HC-GF-270-followup-3: an id read from the branch name gets the check --story-id got (the pattern has no length cap).
5359
+ const derivedIdError = runArgsError([{ label: 'the story id read from the branch name', kind: 'storyId', value: storyId }]);
5360
+ if (derivedIdError) {
5361
+ console.error(`step-5b: ${derivedIdError}. Pass --story-id explicitly.`);
5362
+ process.exit(1);
5363
+ }
5410
5364
 
5411
5365
  // 2. Capture PR diff (defensive — empty diff → no findings)
5412
5366
  const baseBranch = cmdOpts.base || 'develop';
5413
5367
  let diff = '';
5414
5368
  try {
5415
- diff = execSync(`git diff origin/${baseBranch}...HEAD`,
5416
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5369
+ diff = gitRead(['diff', `origin/${baseBranch}...HEAD`]);
5417
5370
  } catch {
5418
5371
  // Fallback: no remote tracking — try local base
5419
5372
  try {
5420
- diff = execSync(`git diff ${baseBranch}...HEAD`,
5421
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5373
+ diff = gitRead(['diff', `${baseBranch}...HEAD`]);
5422
5374
  } catch { /* leave empty */ }
5423
5375
  }
5424
5376
 
@@ -5427,8 +5379,7 @@ program
5427
5379
  let commitMessages;
5428
5380
  for (const range of [`origin/${baseBranch}..HEAD`, `${baseBranch}..HEAD`]) {
5429
5381
  try {
5430
- commitMessages = execSync(`git log --format=%B ${range}`,
5431
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5382
+ commitMessages = gitRead(['log', '--format=%B', range]);
5432
5383
  break;
5433
5384
  } catch { /* try the next range */ }
5434
5385
  }
@@ -5482,7 +5433,9 @@ program
5482
5433
  baseBranch,
5483
5434
  referencedSkills,
5484
5435
  });
5485
- const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : path.join(repoRoot, '.github/pipeline', storyId);
5436
+ // `--artifact-dir` is the deliberate escape hatch (a CI gate writes outside the checkout); the default is
5437
+ // the story's own directory, which a story id cannot leave (HC-GF-270-followup-3).
5438
+ const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : storyDirIn(path.join(repoRoot, '.github/pipeline'), storyId);
5486
5439
  fs.mkdirSync(outDir, { recursive: true });
5487
5440
  const outPath = path.join(outDir, 'step-5b-skill-audit.md');
5488
5441
  fs.writeFileSync(outPath, artifact);
@@ -5566,8 +5519,8 @@ program
5566
5519
  .option('--contracts', 'Run contract validation between pipeline agents')
5567
5520
  .option('--snapshot', 'Save current eval + contract results as regression baseline')
5568
5521
  .option('--regression', 'Compare current results against saved baseline (detect drift)')
5569
- .option('--judge', 'Run LLM-as-judge scenarios (default provider: free gh-models via GITHUB_TOKEN)')
5570
- .option('--provider <name>', 'LLM-judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
5522
+ .option('--judge', 'Run LLM-as-judge scenarios. Needs --provider claude (paid); the free gh-models default was retired 2026-07-30')
5523
+ .option('--provider <name>', 'LLM-judge provider: claude (paid Sonnet, needs ANTHROPIC_API_KEY). There is no default')
5571
5524
  .option('--evidence-mode <mode>', 'HC-RC-001 editor-LLM evidence transfer: "local" writes .hone/eval-evidence.json (signed); "off" disables (default)')
5572
5525
  .action(async (opts) => {
5573
5526
  const path = require('path');
@@ -5650,14 +5603,11 @@ program
5650
5603
  process.exit(0);
5651
5604
  }
5652
5605
 
5653
- // HC-019n-followup-33 (pipeline-standards G6): the LLM-judge lane must have a
5654
- // ZERO-COST path (feedback_pipeline_llm_cost_reduction — every LLM-cost gate
5655
- // needs a free/near-free route so adopters aren't double-billed). DEFAULT to
5656
- // GH Models (free inference via GITHUB_TOKEN); paid Claude Sonnet is opt-in
5657
- // via `--provider claude`. Mirrors `hone skill-eval`'s provider convention.
5658
- // HC-COMM-011-followup-3: provider wiring extracted to lib/judge-provider.js
5659
- // (shared with `hone agent-eval --judge`). Same zero-cost default (gh-models)
5660
- // + Sonnet-not-Opus paid path; the retired dated Sonnet id must not appear
5606
+ // HC-019n-followup-33 gave the judge lane a free default (GitHub Models). HC-GF-340: GitHub
5607
+ // retired it on 2026-07-30, so there is no default now and no free path; the operator names
5608
+ // `--provider claude` (paid Sonnet) or gets one sentence saying why.
5609
+ // HC-COMM-011-followup-3: provider wiring lives in lib/judge-provider.js (shared with
5610
+ // `hone agent-eval --judge`); the retired dated Sonnet id must not appear
5661
5611
  // (pinned by opus-4-8-upgrade.test.js).
5662
5612
  const { buildJudgeCallLLM } = require('./lib/judge-provider');
5663
5613
  const built = buildJudgeCallLLM(opts.provider, { axios });
@@ -5761,13 +5711,13 @@ program
5761
5711
  // expected_output_format heuristics. Two systems, two different
5762
5712
  // questions; coexist.
5763
5713
  //
5764
- // Provider defaults to gh-models (free GH PAT inference) per the
5765
- // [Pipeline LLM Cost Reduction] memory — adopters must not be
5766
- // double-billed for what their pipeline already invoked.
5714
+ // HC-GF-340: there is no default provider. It used to be gh-models (free GH PAT inference), which
5715
+ // GitHub retired on 2026-07-30; the only one that works now is paid, and an adopter must not be
5716
+ // billed by a default they never chose. `--no-llm` (the dry run) needs none.
5767
5717
  program
5768
5718
  .command('skill-eval <skillName>')
5769
5719
  .description('Run derived-skill eval scenarios against an LLM (HC-010d-followup-1)')
5770
- .option('--provider <name>', 'LLM provider: gh-models (default, $0) | claude-haiku (paid)', 'gh-models')
5720
+ .option('--provider <name>', 'LLM provider: claude-haiku (paid, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
5771
5721
  .option('--scenario <id>', 'Run a single scenario by id (e.g., SF-DEV-EVAL-001)')
5772
5722
  .option('--tag <tag>', 'Filter scenarios by HC-010c rule-id tag (e.g., SF-SEC-001)')
5773
5723
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
@@ -5836,62 +5786,26 @@ program
5836
5786
  process.exit(0);
5837
5787
  }
5838
5788
 
5839
- // Provider wiring — gh-models default. Inline + per-provider so a
5840
- // future provider addition lives in one switch.
5789
+ // Provider wiring. Inline + per-provider so a future provider addition lives in one switch.
5841
5790
  const axios = require('axios');
5842
- // Pass-2 review caught the original 32000-char slice collided with
5843
- // GH Models' documented 8000-token request-body cap (see
5844
- // `cli/lib/release-review-config.js:49-68`). A real adopter
5845
- // SKILL.md after years of derivations is plausibly 20-40K chars;
5846
- // 32K leaves zero budget for scenario.input + JSON envelope, so
5847
- // GH Models returns HTTP 400 and every scenario errors. 8000
5848
- // chars ≈ ~2000 tokens for the system slot, leaving ~6000 tokens
5849
- // for scenario.input + envelope — comfortable under GH Models'
5850
- // cap while still preserving enough of the skill body to evaluate
5851
- // adopter patterns.
5791
+ // 8000 chars (~2000 tokens) for the system slot leaves the rest of the request for
5792
+ // scenario.input + the JSON envelope. The cap was sized for GitHub Models' 8000-token
5793
+ // request-body limit (see `cli/lib/release-review-config.js`); it is kept so a skill body
5794
+ // that grew large cannot crowd out the scenario now that claude-haiku is the only provider.
5852
5795
  const MAX_SKILL_PROMPT_CHARS = 8000;
5853
5796
  let apiKey, modelLabel, callLLM;
5854
- if (opts.provider === 'gh-models') {
5855
- apiKey = process.env.GITHUB_TOKEN;
5856
- if (!apiKey) {
5857
- console.error('✗ GITHUB_TOKEN not set. Required for --provider gh-models.');
5858
- console.error(' In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
5859
- // Exit 2 — operator config error, NOT a skill regression.
5860
- // Pass-2 review caught: exit 1 collided with the CI-gate exit
5861
- // code for eval failures, so a missing secret was reported
5862
- // as "skill regression" by the CI gate. The convention from
5863
- // the regression at line 172-188 is exit 2 for operator
5864
- // errors, exit 1 for eval failures.
5865
- process.exit(2);
5866
- }
5867
- modelLabel = 'openai/gpt-4.1';
5868
- callLLM = async (systemPrompt, userPrompt) => {
5869
- const { data } = await axios.post(
5870
- 'https://models.github.ai/inference/chat/completions',
5871
- {
5872
- model: modelLabel,
5873
- messages: [
5874
- { role: 'system', content: systemPrompt.slice(0, MAX_SKILL_PROMPT_CHARS) },
5875
- { role: 'user', content: userPrompt },
5876
- ],
5877
- max_tokens: 2048,
5878
- },
5879
- {
5880
- headers: {
5881
- 'Authorization': `Bearer ${apiKey}`,
5882
- 'Content-Type': 'application/json',
5883
- },
5884
- timeout: 120000,
5885
- }
5886
- );
5887
- return data.choices?.[0]?.message?.content || '';
5888
- };
5797
+ if (opts.provider === undefined || opts.provider === 'gh-models') {
5798
+ // HC-GF-340: refuse before any request. Exit 2 — operator config error, NOT a skill
5799
+ // regression (exit 1 is the CI-gate code for eval failures).
5800
+ console.error(`✗ ${require('./lib/judge-provider').providerRequiredMessage('--provider claude-haiku')}`);
5801
+ process.exit(2);
5889
5802
  } else if (opts.provider === 'claude-haiku') {
5890
5803
  apiKey = process.env.ANTHROPIC_API_KEY;
5891
5804
  if (!apiKey) {
5892
5805
  console.error('✗ ANTHROPIC_API_KEY not set. Required for --provider claude-haiku.');
5893
5806
  console.error(' Set: export ANTHROPIC_API_KEY=sk-ant-...');
5894
- // Exit 2 — operator config error (see gh-models branch).
5807
+ // Exit 2 — operator config error, NOT a skill regression (exit 1 is the CI-gate code
5808
+ // for eval failures).
5895
5809
  process.exit(2);
5896
5810
  }
5897
5811
  modelLabel = 'claude-haiku-4-5-20251001';
@@ -5909,10 +5823,16 @@ program
5909
5823
  },
5910
5824
  timeout: 120000,
5911
5825
  });
5912
- return data.content?.[0]?.text || '';
5826
+ const text = data.content?.[0]?.text;
5827
+ // An empty answer is a provider failure the runner records as an error, not a scenario
5828
+ // that failed its assertions.
5829
+ if (typeof text !== 'string' || !text.trim()) {
5830
+ throw new Error(`claude-haiku returned no text (stop_reason: ${data.stop_reason || 'unknown'})`);
5831
+ }
5832
+ return text;
5913
5833
  };
5914
5834
  } else {
5915
- console.error(`✗ Invalid --provider: ${opts.provider}. Use 'gh-models' or 'claude-haiku'.`);
5835
+ console.error(`✗ Invalid --provider: ${opts.provider}. Use 'claude-haiku'.`);
5916
5836
  process.exit(2);
5917
5837
  }
5918
5838
 
@@ -5945,10 +5865,9 @@ program
5945
5865
 
5946
5866
  console.log(formatResults(summary, opts.format));
5947
5867
 
5948
- // Exit 1 on any non-pass so CI (HC-019f-style gate) can wire this
5949
- // as a required check. Per the [Pipeline LLM Cost Reduction]
5950
- // memory, this gate can run in CI on the gh-models default with
5951
- // zero adopter cost.
5868
+ // Exit 1 on any non-pass so CI (HC-019f-style gate) can wire this as a required check. It is
5869
+ // paid now (HC-GF-340: the free gh-models default was retired), so a CI gate needs an
5870
+ // ANTHROPIC_API_KEY secret and a decision to spend.
5952
5871
  process.exit(summary.failed + summary.errors > 0 ? 1 : 0);
5953
5872
  });
5954
5873
 
@@ -5997,6 +5916,7 @@ program
5997
5916
  .description('Fetch the editor-mode instruction for a run parked awaiting_editor (HC-COMM-010 B3)')
5998
5917
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
5999
5918
  .action(async (runId, opts) => {
5919
+ refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-3
6000
5920
  const config = getConfig();
6001
5921
  const client = api(config);
6002
5922
  try {
@@ -6028,6 +5948,10 @@ program
6028
5948
  .option('--contract <version>', 'Instruction contract version (from step-instruction)', '1')
6029
5949
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
6030
5950
  .action(async (runId, stepKey, opts) => {
5951
+ refuseBadRunArgs([ // HC-GF-270-followup-3: both become path segments of the submission
5952
+ { label: '<runId>', kind: 'runId', value: runId },
5953
+ { label: '<stepKey>', kind: 'stepKey', value: stepKey },
5954
+ ]);
6031
5955
  const config = getConfig();
6032
5956
  const client = api(config);
6033
5957
  try {
@@ -6082,6 +6006,19 @@ function runContextIo(apiConfig) {
6082
6006
  const abs = path.resolve(cwd, rel);
6083
6007
  return abs.startsWith(cwd + path.sep) ? abs : null;
6084
6008
  };
6009
+ // HC-GF-283: where this checkout's issues live — host, owner, repo — resolved ONCE and shared by
6010
+ // both gh calls, so the text and the trust cannot come from different hosts. An SSH alias is
6011
+ // resolved with `ssh -G` (prints config, never connects). A failure rejects both calls: nothing is
6012
+ // fetched rather than gh falling back to its default host.
6013
+ let issueRepoP = null;
6014
+ const issueRepo = () => issueRepoP || (issueRepoP = (async () => {
6015
+ const { resolveRemote } = require('./lib/git-remote');
6016
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6017
+ const { execFile } = require('child_process');
6018
+ return resolveRemote(remoteUrl, {
6019
+ sshConfig: async (alias) => (await require('util').promisify(execFile)('ssh', ['-G', alias], { encoding: 'utf8', timeout: 10_000 })).stdout,
6020
+ });
6021
+ })());
6085
6022
  return {
6086
6023
  // Every subprocess this adapter starts carries a timeout: a hung git or gh must fail open with a
6087
6024
  // message, not stall the command (node-developer hard rule 5). The module turns each failure into
@@ -6110,15 +6047,15 @@ function runContextIo(apiConfig) {
6110
6047
  return fs.readFileSync(abs, 'utf8');
6111
6048
  },
6112
6049
  // Async (execFile, no shell) so queue-stories can resolve several issues at once.
6050
+ // HC-GF-283: `--repo HOST/OWNER/REPO`. Without the host gh used its default one, so a GitHub
6051
+ // Enterprise remote behind an SSH alias read the issue from github.com.
6113
6052
  fetchIssue: async (issueNumber) => {
6114
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6115
- const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6116
- if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6053
+ const { host, owner, repo } = await issueRepo();
6117
6054
  const { execFile } = require('child_process');
6118
6055
  let stdout;
6119
6056
  try {
6120
6057
  ({ stdout } = await require('util').promisify(execFile)('gh',
6121
- ['issue', 'view', String(issueNumber), '--repo', `${m[1]}/${m[2]}`, '--json', 'title,body,url'],
6058
+ ['issue', 'view', String(issueNumber), '--repo', `${host}/${owner}/${repo}`, '--json', 'title,body,url'],
6122
6059
  { encoding: 'utf8', timeout: 30_000 }));
6123
6060
  } catch (e) {
6124
6061
  // execFile keeps gh's stderr on the error — its first line is the actionable part ("gh auth
@@ -6132,14 +6069,12 @@ function runContextIo(apiConfig) {
6132
6069
  // HC-GF-270: who wrote the issue. run-context.js classifies a failure and story-provenance.js
6133
6070
  // decides trust; this only fetches. execFile, not a shell string: owner/repo come from the remote.
6134
6071
  fetchIssueProvenance: async (issueNumber) => {
6135
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6136
- const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6137
- if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6138
- const where = { owner: m[1], repo: m[2], remoteUrl };
6072
+ const { host, owner, repo, remoteUrl } = await issueRepo();
6073
+ const where = { owner, repo, remoteUrl, host };
6139
6074
  try {
6140
6075
  const { execFile } = require('child_process');
6141
6076
  const { stdout } = await require('util').promisify(execFile)('gh',
6142
- ['api', `repos/${m[1]}/${m[2]}/issues/${issueNumber}`],
6077
+ ['api', `repos/${owner}/${repo}/issues/${issueNumber}`, '--hostname', host],
6143
6078
  { encoding: 'utf8', timeout: 20_000 });
6144
6079
  return { ...where, issue: JSON.parse(stdout) };
6145
6080
  } catch (e) {
@@ -6152,6 +6087,8 @@ function runContextIo(apiConfig) {
6152
6087
  },
6153
6088
  recentlyModifiedOverlap: (touched) =>
6154
6089
  require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
6090
+ // HC-GF-217: the compound symbols the story names, searched in THIS checkout (the server has none to search).
6091
+ callSites: (storyText) => require('./lib/call-sites').collectCallSites({ cwd, storyText }),
6155
6092
  };
6156
6093
  }
6157
6094
 
@@ -6717,7 +6654,7 @@ process.on('SIGINT', () => {
6717
6654
  program
6718
6655
  .command('queue-stories')
6719
6656
  .description('Queue a batch of stories for autonomous (overnight) execution')
6720
- .option('--file <path>', 'Text file with one story ID per line (cap: 50 lines, 16KB)')
6657
+ .option('--file <path>', 'Text file with one story per line (cap: 50 lines, 16KB): STORY-ID, then optionally depends:STORY-B,STORY-C and paths:a.js,b.js (files to bundle into that story, as run-story --include-paths does)')
6721
6658
  .option('--repo <name>', 'Repository name applied to every story (default: directory name)')
6722
6659
  .option('--branch <name>', 'Git branch applied to every story (default: current branch)')
6723
6660
  .option('--status <batchId>', 'Show status of a previously submitted batch')
@@ -6914,11 +6851,12 @@ program
6914
6851
 
6915
6852
  // HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
6916
6853
  // cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
6854
+ // HC-GF-351: and `paths:a.js,b.js` for the files that story's context must hold.
6917
6855
  const lines = storyIds.map(line => {
6918
6856
  const parsed = parseStoryLine(line);
6919
6857
  if (parsed.error) {
6920
6858
  console.error(parsed.error);
6921
- console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
6859
+ console.error(` Expected: "STORY-ID", optionally followed by depends:STORY-B,STORY-C and/or paths:a.js,b.js`);
6922
6860
  process.exit(1);
6923
6861
  }
6924
6862
  return parsed;
@@ -6959,7 +6897,8 @@ program
6959
6897
  await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
6960
6898
  while (nextLine < lines.length) {
6961
6899
  const i = nextLine++;
6962
- resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
6900
+ // HC-GF-351: a line's `paths:` is that story's --include-paths, resolved by the same code.
6901
+ resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId, { includePaths: lines[i].paths });
6963
6902
  }
6964
6903
  }));
6965
6904
  // One summary line per story, and every warning labelled with its story. The info lines
@@ -6967,7 +6906,9 @@ program
6967
6906
  for (const ctx of resolved) {
6968
6907
  const { storyId } = ctx;
6969
6908
  const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
6970
- console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
6909
+ const cs = ctx.config.call_sites;
6910
+ const csNote = cs ? ` · ${cs.symbols.length} symbol(s) searched for call sites${cs.symbols.some((s) => s.status !== 'complete') ? ' (not all whole)' : ''}` : '';
6911
+ console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled${csNote}`);
6971
6912
  printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
6972
6913
  printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
6973
6914
  }
@@ -7208,12 +7149,12 @@ program
7208
7149
 
7209
7150
  program
7210
7151
  .command('release-review')
7211
- .description('Holistic code review of all changed files before deployment (default: GH Models, $0)')
7152
+ .description('Holistic code review of all changed files before deployment (needs --provider opus, paid; the free GH Models default was retired 2026-07-30)')
7212
7153
  .option('--base <branch>', 'Base branch to diff against', 'main')
7213
7154
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7214
7155
  .option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
7215
7156
  .option('--max-files <n>', 'Max source files to include in review', '40')
7216
- .option('--provider <name>', 'LLM provider: gh-models (default, $0) | opus (legacy, paid)', 'gh-models')
7157
+ .option('--provider <name>', 'LLM provider: opus (paid, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
7217
7158
  .option('--cache <mode>', 'HC-RC-002-followup-1 content-hash cache: on | off (default on)', 'on')
7218
7159
  .action(async (opts) => {
7219
7160
  const { execSync } = require('child_process');
@@ -7251,14 +7192,27 @@ program
7251
7192
  // CI step passes `--base origin/main` and the previous code prefixed
7252
7193
  // again → `origin/origin/main` → git rejected → silent fallback to
7253
7194
  // `HEAD~10`. Both lanes reviewed the wrong diff in PR #313 first run.
7195
+ // HC-GF-270-followup-3: the base gets the check step-5b and verify-pr give theirs. resolveBaseRef prefixes
7196
+ // `origin/`, so an option-shaped base cannot reach git as an option — but git would fail on it and the
7197
+ // review would fall back to HEAD~10 without saying so.
7198
+ const badBase = runArgsError([{ label: '--base', kind: 'gitRev', value: opts.base }]);
7199
+ if (badBase) {
7200
+ console.error(badBase);
7201
+ emitStatusEnvelope('refused', { error: badBase });
7202
+ process.exit(2);
7203
+ }
7254
7204
  const baseRef = resolveBaseRef(opts.base);
7255
7205
 
7256
7206
  // 1. Get changed files
7257
7207
  let changedFiles;
7258
7208
  try {
7259
- const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7209
+ // HC-GF-270-followup-3: argument lists, no shell. baseRef always starts `origin/` (resolveBaseRef), so it
7210
+ // cannot be read as an option.
7211
+ const raw = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7260
7212
  changedFiles = raw.trim().split('\n').filter(Boolean);
7261
7213
  } catch {
7214
+ // HC-GF-270-followup-4: the review covers a different range from here on, so it says so.
7215
+ console.error(`⚠ git could not diff ${baseRef}...HEAD — reviewing the last 10 commits (HEAD~10) instead`);
7262
7216
  try {
7263
7217
  const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
7264
7218
  changedFiles = raw.trim().split('\n').filter(Boolean);
@@ -7299,37 +7253,38 @@ program
7299
7253
  }
7300
7254
 
7301
7255
  // 3. Provider validation + credential check
7302
- if (opts.provider !== 'opus' && opts.provider !== 'gh-models') {
7303
- console.error(`Invalid --provider: ${opts.provider}. Use 'opus' or 'gh-models'.`);
7256
+ // HC-GF-340: gh-models (the old default) is retired, and no provider is a default now, so an
7257
+ // unnamed or retired one is refused with the cause instead of calling an endpoint that
7258
+ // answers "OK" and reporting "empty response ... content filter, quota or model name".
7259
+ if (opts.provider === undefined || opts.provider === 'gh-models') {
7260
+ console.error(require('./lib/judge-provider').providerRequiredMessage('--provider opus'));
7261
+ process.exit(1);
7262
+ }
7263
+ if (opts.provider !== 'opus') {
7264
+ console.error(`Invalid --provider: ${opts.provider}. Use 'opus'.`);
7304
7265
  process.exit(1);
7305
7266
  }
7306
7267
 
7307
- let apiKey, providerLabel;
7308
- if (opts.provider === 'gh-models') {
7309
- apiKey = process.env.GITHUB_TOKEN;
7310
- providerLabel = 'GitHub Models (openai/gpt-4.1)';
7311
- if (!apiKey) {
7312
- console.error('GITHUB_TOKEN not set. Required for --provider gh-models.');
7313
- console.error('In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
7314
- process.exit(1);
7315
- }
7316
- } else {
7317
- apiKey = process.env.ANTHROPIC_API_KEY;
7318
- providerLabel = 'Anthropic Opus (claude-opus-4-8)';
7319
- if (!apiKey) {
7320
- console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
7321
- console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
7322
- process.exit(1);
7323
- }
7268
+ const apiKey = process.env.ANTHROPIC_API_KEY;
7269
+ const providerLabel = 'Anthropic Opus (claude-opus-4-8)';
7270
+ if (!apiKey) {
7271
+ console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
7272
+ console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
7273
+ process.exit(1);
7324
7274
  }
7325
7275
 
7326
7276
  // 4. Build the diff content (truncated per-file to stay within context)
7327
7277
  let diffContent;
7328
7278
  try {
7329
- diffContent = execSync(`git diff ${baseRef}...HEAD -- ${filesToReview.map(f => `'${f}'`).join(' ')}`, {
7279
+ // HC-GF-270-followup-3: each changed file is ONE argument after `--`. They were wrapped in single quotes
7280
+ // inside a shell string, so a file a PR author named with a `'` in it ran as a command.
7281
+ // HC-GF-270-followup-4: and each is read as a literal path. Without --literal-pathspecs a file named `*.js`
7282
+ // matched every .js file, and one named `:(evil)x.js` made git exit, which fell back to HEAD~10 silently.
7283
+ diffContent = execFileSync('git', ['--literal-pathspecs', 'diff', `${baseRef}...HEAD`, '--', ...filesToReview], {
7330
7284
  encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024,
7331
7285
  });
7332
7286
  } catch {
7287
+ console.error(`⚠ git could not diff the changed files against ${baseRef} — reviewing the last 10 commits (HEAD~10) instead`);
7333
7288
  try {
7334
7289
  diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024 });
7335
7290
  } catch (e) {
@@ -7338,9 +7293,7 @@ program
7338
7293
  }
7339
7294
  }
7340
7295
 
7341
- // Provider-specific truncation. GH Models GPT-4.1 has an 8K-token
7342
- // request-body cap via models.github.ai/inference — 100K chars overflows.
7343
- // See cli/lib/release-review-config.js for the budget math.
7296
+ // Provider-specific truncation; see cli/lib/release-review-config.js for the budget math.
7344
7297
  const MAX_DIFF_CHARS = getMaxDiffChars(opts.provider);
7345
7298
  if (diffContent.length > MAX_DIFF_CHARS) {
7346
7299
  diffContent = diffContent.slice(0, MAX_DIFF_CHARS) +
@@ -7423,9 +7376,7 @@ program
7423
7376
  normalizeCacheFlag,
7424
7377
  } = require('./lib/release-review-cache');
7425
7378
  const cacheEnabled = normalizeCacheFlag(opts.cache);
7426
- const modelForCache = opts.provider === 'gh-models'
7427
- ? 'openai/gpt-4.1'
7428
- : 'claude-opus-4-8';
7379
+ const modelForCache = 'claude-opus-4-8';
7429
7380
  let cacheContentHash = null;
7430
7381
  if (cacheEnabled) {
7431
7382
  try {
@@ -7488,59 +7439,30 @@ program
7488
7439
  }
7489
7440
 
7490
7441
  // 6. Call LLM (provider-branched, HC-080a-spike)
7491
- // max_tokens is held SYMMETRIC across providers so the HC-080a-spike
7492
- // comparison measures model capability, not output budget. 4096 is the
7493
- // safe ceiling for openai/gpt-4.1 via GH Models; Opus supports more but
7494
- // running it with 4096 keeps the comparison apples-to-apples.
7442
+ // max_tokens stays at 4096, the value the HC-080a-spike comparison with GPT-4.1 held constant,
7443
+ // so a review's output budget has not changed now that Opus is the only provider.
7495
7444
  const MAX_OUTPUT_TOKENS = 4096;
7496
7445
  const startedAt = Date.now();
7497
7446
  try {
7498
7447
  let responseText, inputTokens, outputTokens, modelLabel;
7499
7448
 
7500
- if (opts.provider === 'gh-models') {
7501
- // GitHub Models — free LLM inference using GITHUB_TOKEN. Mirrors the
7502
- // path used by server/scripts/ai-reviewer.js for per-PR review.
7503
- const { data } = await axios.post(
7504
- 'https://models.github.ai/inference/chat/completions',
7505
- {
7506
- model: 'openai/gpt-4.1',
7507
- messages: [
7508
- { role: 'system', content: systemPrompt },
7509
- { role: 'user', content: userPrompt },
7510
- ],
7511
- max_tokens: MAX_OUTPUT_TOKENS,
7512
- },
7513
- {
7514
- headers: {
7515
- 'Authorization': `Bearer ${apiKey}`,
7516
- 'Content-Type': 'application/json',
7517
- },
7518
- timeout: 120000,
7519
- }
7520
- );
7521
- responseText = data.choices?.[0]?.message?.content || '';
7522
- inputTokens = data.usage?.prompt_tokens || 0;
7523
- outputTokens = data.usage?.completion_tokens || 0;
7524
- modelLabel = 'openai/gpt-4.1';
7525
- } else {
7526
- const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
7527
- model: 'claude-opus-4-8',
7528
- max_tokens: MAX_OUTPUT_TOKENS,
7529
- system: systemPrompt,
7530
- messages: [{ role: 'user', content: userPrompt }],
7531
- }, {
7532
- headers: {
7533
- 'x-api-key': apiKey,
7534
- 'anthropic-version': '2023-06-01',
7535
- 'content-type': 'application/json',
7536
- },
7537
- timeout: 120000,
7538
- });
7539
- responseText = data.content?.[0]?.text || '';
7540
- inputTokens = data.usage?.input_tokens || 0;
7541
- outputTokens = data.usage?.output_tokens || 0;
7542
- modelLabel = 'claude-opus-4-8';
7543
- }
7449
+ const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
7450
+ model: 'claude-opus-4-8',
7451
+ max_tokens: MAX_OUTPUT_TOKENS,
7452
+ system: systemPrompt,
7453
+ messages: [{ role: 'user', content: userPrompt }],
7454
+ }, {
7455
+ headers: {
7456
+ 'x-api-key': apiKey,
7457
+ 'anthropic-version': '2023-06-01',
7458
+ 'content-type': 'application/json',
7459
+ },
7460
+ timeout: 120000,
7461
+ });
7462
+ responseText = data.content?.[0]?.text || '';
7463
+ inputTokens = data.usage?.input_tokens || 0;
7464
+ outputTokens = data.usage?.output_tokens || 0;
7465
+ modelLabel = 'claude-opus-4-8';
7544
7466
 
7545
7467
  const elapsedMs = Date.now() - startedAt;
7546
7468
 
@@ -8436,10 +8358,11 @@ program
8436
8358
  // The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
8437
8359
  // check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
8438
8360
  // decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
8439
- // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
8361
+ // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you (HC-GF-351: or every
8362
+ // unfinished run is waiting on a person, named with its step and why), 3 deadline.
8440
8363
  program
8441
8364
  .command('follow-batch <batchId>')
8442
- .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
8365
+ .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098). A run hard-paused for files it was not given is re-served once with them (HC-GF-351); every pause it cannot act on is named, and it exits 2 when only people are left to act')
8443
8366
  .option('--poll-interval <sec>', 'Seconds between polls', '30')
8444
8367
  .option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
8445
8368
  .option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
@@ -8447,7 +8370,14 @@ program
8447
8370
  .option('--repo <name>', 'Repository name the batch was queued under (default: this directory\'s name)')
8448
8371
  .action(async (batchId, opts) => {
8449
8372
  const { followBatch, EXIT } = require('./lib/batch-follow');
8450
- const { spawn, spawnSync } = require('child_process');
8373
+ // HC-GF-270-followup-3: refused before the preflight git/gh calls and the config read — with EXIT.FATAL (1),
8374
+ // because 2 is EXIT.NEEDS_YOU, which a caller reads as "the batch needs a human".
8375
+ const badBatch = runArgsError([{ label: '<batchId>', kind: 'batchId', value: batchId }]);
8376
+ if (badBatch) {
8377
+ console.error(badBatch);
8378
+ process.exit(EXIT.FATAL);
8379
+ }
8380
+ const { spawn, spawnSync, execFileSync } = require('child_process');
8451
8381
  const positive = (raw, flag, unit) => {
8452
8382
  const n = Number(raw);
8453
8383
  if (!Number.isFinite(n) || n <= 0) {
@@ -8478,12 +8408,42 @@ program
8478
8408
  const io = {
8479
8409
  fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8480
8410
  fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
8481
- runVerb: (argv) => new Promise((resolve) => {
8411
+ // `capture` also returns the child's stdout (the tail of it), for the one verb whose answer the loop reads
8412
+ // (queue-stories, HC-GF-351). Either way the child's output reaches this process's own stdout.
8413
+ runVerb: (argv, { capture = false } = {}) => new Promise((resolve) => {
8482
8414
  const child = spawn(process.execPath, [process.argv[1], ...argv],
8483
- { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
8484
- child.on('error', (e) => resolve({ code: null, error: e }));
8485
- child.on('close', (code) => resolve({ code }));
8415
+ { cwd: process.cwd(), env: process.env, stdio: ['ignore', capture ? 'pipe' : 'inherit', 'inherit'] });
8416
+ let stdout = '';
8417
+ if (capture) child.stdout.on('data', (d) => { process.stdout.write(d); stdout = (stdout + d).slice(-262144); });
8418
+ child.on('error', (e) => resolve({ code: null, error: e, stdout }));
8419
+ child.on('close', (code) => resolve({ code, stdout }));
8486
8420
  }),
8421
+ // HC-GF-351: what a re-serve needs. The regular files git tracks in THIS checkout, listed with their modes so
8422
+ // a tracked symlink (which the bundler would follow out of the checkout) is not among them. Run where the
8423
+ // bundler runs, so the paths are the ones it resolves.
8424
+ trackedPaths: () => require('./lib/context-reserve').trackedRegularFiles(execFileSync('git', ['ls-files', '-z', '-s'], {
8425
+ cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
8426
+ })),
8427
+ // Where a requested path resolves on the disk NOW, relative to this checkout, or null: the index cannot see a
8428
+ // file or a directory that was replaced by a symlink after the commit, and the bundler reads through it.
8429
+ realPath: (rel) => require('./lib/context-reserve').repoRealPath(process.cwd(), rel),
8430
+ // Was this story served already? A kill that failed, or a crash between the queue and the kill, leaves no marker
8431
+ // and only a newer run of the same story, so the server's own run list is asked (GET /orchestrate?storyId=&repoName=).
8432
+ priorServe: (action) => require('./lib/context-reserve').findServe({
8433
+ listRuns: async (storyId, repo) => (await client.get(apiPath('orchestrate', [], { storyId, repoName: repo, limit: 100 }))).data.runs,
8434
+ listBatches: async () => (await client.get(orchestratePath(['batch'], { limit: 20 }))).data.batches,
8435
+ fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8436
+ }, action),
8437
+ // One line, in a directory only this invocation can read, for `queue-stories --file`.
8438
+ writeStoriesFile: (text) => {
8439
+ const t = require('./lib/private-temp').privateTempFile('hone-reserve', 'stories.txt');
8440
+ fs.writeFileSync(t.file, text, { mode: 0o600 });
8441
+ return t;
8442
+ },
8443
+ killRun: async (runId, reason) => {
8444
+ if (!require('./lib/run-target').isRunId(runId)) throw new Error('not a run id');
8445
+ await client.post(orchestratePath([runId, 'kill']), { reason });
8446
+ },
8487
8447
  // The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
8488
8448
  verifyDecision: (run) => {
8489
8449
  let assessment;
@@ -8947,7 +8907,13 @@ program
8947
8907
  .option('--format <fmt>', 'pretty or json', 'pretty')
8948
8908
  .action(async (prOrBranch, opts) => {
8949
8909
  // HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
8950
- refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
8910
+ // HC-GF-270-followup-3: so are the story id, which names the directory step-5c-ci.md is written to, and the
8911
+ // base, which reaches git through step-5b.
8912
+ refuseBadRunArgs([
8913
+ { label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true },
8914
+ { label: '--story-id', kind: 'storyId', value: opts.storyId, optional: true },
8915
+ { label: '--base', kind: 'gitRev', value: opts.base || 'develop' },
8916
+ ]);
8951
8917
  const { execFileSync, spawnSync } = require('child_process');
8952
8918
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8953
8919
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -8974,6 +8940,9 @@ program
8974
8940
  let branch = '';
8975
8941
  try { branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* */ }
8976
8942
  const storyId = opts.storyId || extractStoryIdFromBranch(branch) || extractStoryIdFromBranch(prOrBranch) || 'unknown-story';
8943
+ // HC-GF-270-followup-3: an id read from the branch or the ref gets the check --story-id got (the pattern has no
8944
+ // length cap) — before the CI command runs, and so the artifact write below cannot throw.
8945
+ refuseBadRunArgs([{ label: 'the story id read from the branch name', kind: 'storyId', value: storyId }]);
8977
8946
 
8978
8947
  // 2. CI gate mode: --mode > ci.gate config > github.
8979
8948
  let configGate = 'github';
@@ -9030,12 +8999,16 @@ program
9030
8999
 
9031
9000
  const verdict = ciGateVerdict({ mode, gh, local });
9032
9001
 
9033
- // 5. Write the real step-5c-ci.md artifact.
9002
+ // 5. Write the real step-5c-ci.md artifact. Best-effort, but the report below names the file only if it was
9003
+ // written (HC-GF-270-followup-3: a failed write used to print the path anyway).
9004
+ let artifactLine = ` → .github/pipeline/${storyId}/step-5c-ci.md`;
9034
9005
  try {
9035
- const dir = path.join(repoRoot, '.github/pipeline', storyId);
9006
+ const dir = storyDirIn(path.join(repoRoot, '.github/pipeline'), storyId); // HC-GF-270-followup-3
9036
9007
  fs.mkdirSync(dir, { recursive: true });
9037
9008
  fs.writeFileSync(path.join(dir, 'step-5c-ci.md'), renderStep5cArtifact({ storyId, mode, prRef: prOrBranch, gh, local, verdict }));
9038
- } catch { /* best-effort */ }
9009
+ } catch (e) {
9010
+ artifactLine = ` ⚠ step-5c-ci.md not written: ${e.message}`;
9011
+ }
9039
9012
 
9040
9013
  // 6. Closed loop (HC-019n-followup-36): report the CI-gate verdict to the
9041
9014
  // server. This RECORDS against step_5c — which is a post-PR, CLI-produced
@@ -9072,7 +9045,7 @@ program
9072
9045
  { ok: exitCode === EXIT.PASS, exitCode, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
9073
9046
  `${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
9074
9047
  ` skill audit: ${opts.skipSkillAudit ? 'skipped' : skillAudit.blocked ? `✗ BLOCKER finding(s) — ${skillAudit.artifact}` : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
9075
- ` → .github/pipeline/${storyId}/step-5c-ci.md`);
9048
+ artifactLine);
9076
9049
  process.exit(exitCode);
9077
9050
  });
9078
9051