@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 +259 -214
- package/lib/asset-fetch-list.js +7 -3
- package/lib/bundle-paths.js +118 -18
- package/lib/doctor-admin-merge.js +11 -3
- package/lib/git-remote.js +103 -0
- package/lib/judge-provider.js +39 -34
- package/lib/mcp-tools.js +42 -1
- package/lib/operator-question.js +94 -0
- package/lib/run-context.js +4 -3
- package/lib/run-report.js +29 -0
- package/lib/run-target.js +65 -12
- package/lib/story-provenance.js +19 -12
- package/package.json +1 -1
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
|
|
220
|
-
.option('--provider <name>', 'Judge provider:
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
3247
|
-
|
|
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
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
5565
|
-
.option('--provider <name>', 'LLM-judge provider:
|
|
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
|
|
5649
|
-
//
|
|
5650
|
-
//
|
|
5651
|
-
//
|
|
5652
|
-
//
|
|
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
|
-
//
|
|
5760
|
-
//
|
|
5761
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
5838
|
-
//
|
|
5839
|
-
// `cli/lib/release-review-config.js
|
|
5840
|
-
//
|
|
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
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
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 (
|
|
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
|
-
|
|
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 '
|
|
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
|
-
//
|
|
5945
|
-
//
|
|
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
|
|
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
|
|
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', `${
|
|
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
|
|
6135
|
-
const
|
|
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/${
|
|
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(
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
7284
|
-
|
|
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
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
7473
|
-
//
|
|
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
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
9091
|
+
artifactLine);
|
|
9047
9092
|
process.exit(exitCode);
|
|
9048
9093
|
});
|
|
9049
9094
|
|