@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 +282 -309
- package/lib/batch-follow.js +418 -51
- package/lib/bundle-paths.js +168 -21
- package/lib/call-sites.js +489 -0
- package/lib/classify-spec.js +68 -0
- package/lib/context-reserve.js +270 -0
- package/lib/detect-test-framework.js +54 -0
- package/lib/doctor-admin-merge.js +11 -3
- package/lib/git-remote.js +103 -0
- package/lib/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +39 -34
- package/lib/learnings-sync.js +2 -1
- package/lib/mcp-tools.js +42 -1
- package/lib/run-context.js +25 -3
- package/lib/run-target.js +34 -2
- package/lib/story-line.js +48 -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
|
|
@@ -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
|
|
221
|
-
.option('--provider <name>', 'Judge provider:
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3252
|
-
|
|
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
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
5570
|
-
.option('--provider <name>', 'LLM-judge provider:
|
|
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
|
|
5654
|
-
//
|
|
5655
|
-
//
|
|
5656
|
-
//
|
|
5657
|
-
//
|
|
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
|
-
//
|
|
5765
|
-
//
|
|
5766
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
5843
|
-
//
|
|
5844
|
-
// `cli/lib/release-review-config.js
|
|
5845
|
-
//
|
|
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
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
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 (
|
|
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
|
-
|
|
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 '
|
|
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
|
-
//
|
|
5950
|
-
//
|
|
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
|
|
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', `${
|
|
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
|
|
6136
|
-
const
|
|
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/${
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
7303
|
-
|
|
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
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
7492
|
-
//
|
|
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
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8485
|
-
child.on('
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
9048
|
+
artifactLine);
|
|
9076
9049
|
process.exit(exitCode);
|
|
9077
9050
|
});
|
|
9078
9051
|
|