@hone-ai/cli 1.38.1 → 1.40.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 +218 -157
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +641 -53
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +77 -13
- 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/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +20 -11
- package/lib/learnings-sync.js +2 -1
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/run-context.js +21 -0
- package/lib/security-scanner.js +7 -4
- package/lib/story-line.js +48 -12
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -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, storyDir: storyDirIn, isGitRev } = require('./lib/run-target'); // HC-GF-270-followup-1, -2, -3
|
|
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
|
|
|
@@ -595,6 +598,10 @@ program
|
|
|
595
598
|
.option('--e2e', 'Also install Playwright E2E framework (use with --install-tests)')
|
|
596
599
|
.option('--no-e2e', 'Skip Playwright even when --install-tests is set')
|
|
597
600
|
.option('--no-branch-protection', 'Skip installing GitHub branch protection on the default branch (H-001)')
|
|
601
|
+
// HC-GF-337g: opt-in. The positive flag MUST be declared first — declared second, Commander
|
|
602
|
+
// defaults aiReviewer to true and every adopter gets the reviewer again (cli/lib/reviewer-optin.js).
|
|
603
|
+
.option('--ai-reviewer', 'Install the AI code reviewer workflow (runs on your own ANTHROPIC_API_KEY repo secret; carries the H-019 learnings gate)')
|
|
604
|
+
.option('--no-ai-reviewer', 'Do not install the AI code reviewer (the default)')
|
|
598
605
|
.option('--refresh', 'Re-scan platform metadata without re-running full setup (HC-013c)')
|
|
599
606
|
.option('--ci-gate <mode>', 'HC-RC-004: CI gating mode (github | local | mixed | none). Skips interactive prompt.')
|
|
600
607
|
.action(async (opts) => {
|
|
@@ -714,6 +721,15 @@ program
|
|
|
714
721
|
fs.mkdirSync(workflowsDir, { recursive: true });
|
|
715
722
|
fs.mkdirSync(scriptsEntDir, { recursive: true });
|
|
716
723
|
|
|
724
|
+
// HC-GF-337g: the reviewer is opt-in. Its scripts are left out of the list without the flag, and
|
|
725
|
+
// that absence is the whole opt-out: setup-ai-pipeline.sh installs ai-review.yml either way (its
|
|
726
|
+
// other gates — secret scan, tracker, metrics check, E2E healer — are not the reviewer's), copies
|
|
727
|
+
// the script, the learnings gate and the ESM package.json only when the script arrived, stamps the
|
|
728
|
+
// workflow so its ai-review job runs only then, and generate_config records ai_review.installed
|
|
729
|
+
// from that same file.
|
|
730
|
+
const { shouldInstallReviewer, reviewerNotice, workflowReviewerState } = require('./lib/reviewer-optin');
|
|
731
|
+
const installReviewer = shouldInstallReviewer(opts);
|
|
732
|
+
|
|
717
733
|
// Build parallel fetch list
|
|
718
734
|
const fetches = [
|
|
719
735
|
// Agent stubs and skill files. HC-GF-136's gate found `key`, `promise` and `write` here
|
|
@@ -747,38 +763,42 @@ program
|
|
|
747
763
|
promise: client.get('/scripts/copilot-instructions').then(r => r.data),
|
|
748
764
|
write: (data) => fs.writeFileSync(path.join(enterpriseGH, 'copilot-instructions.md'), data),
|
|
749
765
|
},
|
|
750
|
-
// AI review workflow template
|
|
766
|
+
// AI review workflow template — installed with or without the reviewer (HC-GF-337g)
|
|
751
767
|
{
|
|
752
768
|
key: 'workflows/ai-review.yml',
|
|
753
769
|
promise: client.get('/scripts/workflow-template', { responseType: 'text', headers: { Accept: 'text/yaml' } }).then(r => r.data),
|
|
754
770
|
write: (data) => fs.writeFileSync(path.join(workflowsDir, 'ai-review.yml'), data),
|
|
755
771
|
},
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
772
|
+
// HC-GF-337g: the reviewer's three entries, requested only on `--ai-reviewer` — not fetched and
|
|
773
|
+
// discarded, so a repository that did not opt in makes no reviewer request at all.
|
|
774
|
+
...(installReviewer ? [
|
|
775
|
+
// AI reviewer script
|
|
776
|
+
{
|
|
777
|
+
key: 'scripts/ai-reviewer.js',
|
|
778
|
+
promise: client.get('/scripts/reviewer-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
|
|
779
|
+
write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'ai-reviewer.js'), data),
|
|
780
|
+
},
|
|
781
|
+
// H-007: Learnings gate helper (imported by ai-reviewer at CI time).
|
|
782
|
+
// Both files are ESM. The .github/scripts/package.json (next entry below)
|
|
783
|
+
// declares ESM scope for this directory so node treats both .js files as
|
|
784
|
+
// ES modules regardless of the adopter's root package.json (see #16).
|
|
785
|
+
{
|
|
786
|
+
key: 'scripts/check-learnings-gate.js',
|
|
787
|
+
promise: client.get('/scripts/learnings-gate-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
|
|
788
|
+
write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'check-learnings-gate.js'), data),
|
|
789
|
+
},
|
|
790
|
+
// H-007: Subdir package.json declares ESM scope for .github/scripts/.
|
|
791
|
+
// Both ai-reviewer.js and check-learnings-gate.js use `import` syntax;
|
|
792
|
+
// without this file, node treats them as CommonJS (the npm-init default)
|
|
793
|
+
// and the workflow fails with `Cannot use import statement outside a
|
|
794
|
+
// module`. Subdir package.json scope OVERRIDES the adopter's root —
|
|
795
|
+
// adopter's main project module semantics are unaffected. See #16.
|
|
796
|
+
{
|
|
797
|
+
key: 'scripts/package.json',
|
|
798
|
+
promise: Promise.resolve('{"type": "module"}\n'),
|
|
799
|
+
write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'package.json'), data),
|
|
800
|
+
},
|
|
801
|
+
] : []),
|
|
782
802
|
// H-004: Auto-promote workflow template (installed at setup time;
|
|
783
803
|
// adopters customize {{BRANCHES}} via .pipeline-config.yml's
|
|
784
804
|
// ci.trigger_branches setting via setup-ai-pipeline.sh substitution).
|
|
@@ -838,6 +858,12 @@ program
|
|
|
838
858
|
process.stdout.write(` ⚠ ${key} — ${result.reason?.message || 'failed'}\n`);
|
|
839
859
|
}
|
|
840
860
|
});
|
|
861
|
+
// HC-GF-337g: read both facts NOW — what was received (the file generate_config records from),
|
|
862
|
+
// and whether an earlier setup left a reviewer — and report them after the script has run (or,
|
|
863
|
+
// with --dry-run, as what it would do). No adopter file is deleted.
|
|
864
|
+
const reviewerReceived = fs.existsSync(path.join(scriptsEntDir, 'ai-reviewer.js'));
|
|
865
|
+
const reviewerAlreadyPresent = !installReviewer &&
|
|
866
|
+
fs.existsSync(path.join(process.cwd(), '.github', 'scripts', 'ai-reviewer.js'));
|
|
841
867
|
|
|
842
868
|
// 6. Download setup-ai-pipeline.sh, verify HMAC, write to tmp
|
|
843
869
|
let scriptContent;
|
|
@@ -954,6 +980,22 @@ program
|
|
|
954
980
|
process.exit(1);
|
|
955
981
|
}
|
|
956
982
|
|
|
983
|
+
// HC-GF-337g: after the script, so "installed" is never claimed before the installer ran. A dry
|
|
984
|
+
// run reports what it would do; a real run reports what is now in the repository — including the
|
|
985
|
+
// workflow, which an earlier setup may have left without the job that skips without a key.
|
|
986
|
+
const reviewerWorkflowPath = path.join(process.cwd(), '.github', 'workflows', 'ai-review.yml');
|
|
987
|
+
console.log(reviewerNotice(
|
|
988
|
+
opts.dryRun ? reviewerReceived
|
|
989
|
+
: reviewerReceived && fs.existsSync(path.join(process.cwd(), '.github', 'scripts', 'ai-reviewer.js')),
|
|
990
|
+
{
|
|
991
|
+
requested: installReviewer, alreadyPresent: reviewerAlreadyPresent, dryRun: !!opts.dryRun,
|
|
992
|
+
// A dry run writes nothing, so a missing workflow is "absent" only if its template did not arrive.
|
|
993
|
+
workflow: opts.dryRun && !fs.existsSync(reviewerWorkflowPath) && fs.existsSync(path.join(workflowsDir, 'ai-review.yml'))
|
|
994
|
+
? 'off'
|
|
995
|
+
: workflowReviewerState(fs.existsSync(reviewerWorkflowPath) ? fs.readFileSync(reviewerWorkflowPath, 'utf8') : null),
|
|
996
|
+
},
|
|
997
|
+
));
|
|
998
|
+
|
|
957
999
|
// HC-GF-320: every phase below writes (CLAUDE.md, config grounding, test
|
|
958
1000
|
// packages, GitHub branch protection). A dry run stops here and says what
|
|
959
1001
|
// they would do.
|
|
@@ -1126,17 +1168,51 @@ program
|
|
|
1126
1168
|
opts.branchProtection === false ||
|
|
1127
1169
|
/^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || '');
|
|
1128
1170
|
|
|
1171
|
+
// HC-GF-348: require only what this repository's workflows report, under the names read from them.
|
|
1172
|
+
// HC-GF-354: the served secret scan too, and only checks whose workflow is sure to run on pull requests
|
|
1173
|
+
// to the protected branch — which only `gh repo view` names, so the list is a function of the branch.
|
|
1174
|
+
// HC-GF-352: plus `ci.required_checks`, under the same two rules. Every name the workflows report is
|
|
1175
|
+
// passed too, so the legacy `ai-code-review` an earlier setup required is removed only when nothing
|
|
1176
|
+
// in the repository reports it. HC-GF-353: the review count is decided inside, from the writers.
|
|
1129
1177
|
const { installBranchProtection } = require('./lib/branch-protection');
|
|
1178
|
+
const { requiredChecksPlan, reportedChecksInDir } = require('./lib/required-checks');
|
|
1179
|
+
const protWorkflowsDir = path.join(process.cwd(), '.github', 'workflows');
|
|
1180
|
+
const protReviewerYml = path.join(protWorkflowsDir, 'ai-review.yml');
|
|
1181
|
+
const protReviewerText = fs.existsSync(protReviewerYml) ? fs.readFileSync(protReviewerYml, 'utf8') : null;
|
|
1182
|
+
const protConfigYml = path.join(process.cwd(), '.github', '.pipeline-config.yml');
|
|
1183
|
+
// Unreadable config: no configured checks, rather than a failed setup after everything was written.
|
|
1184
|
+
let protConfigText = null;
|
|
1185
|
+
try { protConfigText = fs.existsSync(protConfigYml) ? fs.readFileSync(protConfigYml, 'utf8') : null; } catch { protConfigText = null; }
|
|
1186
|
+
let protPlan = null;
|
|
1130
1187
|
const protResult = await installBranchProtection({
|
|
1131
1188
|
log: console.log,
|
|
1132
1189
|
opts: {
|
|
1133
1190
|
skip: skipBranchProtection,
|
|
1134
|
-
requiredChecks:
|
|
1191
|
+
requiredChecks: (branch) => {
|
|
1192
|
+
const plan = requiredChecksPlan({
|
|
1193
|
+
reviewerState: workflowReviewerState(protReviewerText),
|
|
1194
|
+
reviewerWorkflowText: protReviewerText,
|
|
1195
|
+
workflowsDir: protWorkflowsDir,
|
|
1196
|
+
configText: protConfigText,
|
|
1197
|
+
branch,
|
|
1198
|
+
});
|
|
1199
|
+
if (branch != null) protPlan = plan;
|
|
1200
|
+
return plan.required;
|
|
1201
|
+
},
|
|
1202
|
+
reportedChecks: reportedChecksInDir(protWorkflowsDir),
|
|
1135
1203
|
},
|
|
1136
1204
|
});
|
|
1137
1205
|
console.log('');
|
|
1138
1206
|
if (protResult.status === 'installed') {
|
|
1139
1207
|
console.log(` ✓ Branch protection installed on '${protResult.branch}'`);
|
|
1208
|
+
const req = protResult.requiredChecks || [];
|
|
1209
|
+
console.log(req.length
|
|
1210
|
+
? ` Required status checks: ${req.join(', ')}`
|
|
1211
|
+
: ' No status check is required.');
|
|
1212
|
+
console.log(` Required approving reviews: ${protResult.reviewCount} (${protResult.reviewReason})`);
|
|
1213
|
+
for (const d of (protPlan && protPlan.dropped) || []) {
|
|
1214
|
+
console.log(` Not required: ${d.name} — ${d.reason}`);
|
|
1215
|
+
}
|
|
1140
1216
|
} else if (protResult.status === 'skipped') {
|
|
1141
1217
|
console.log(` ⚠ Branch protection skipped: ${protResult.reason}`);
|
|
1142
1218
|
if (protResult.manualCommand) {
|
|
@@ -1177,45 +1253,6 @@ function describeSandboxDisposition(runConfig) {
|
|
|
1177
1253
|
}
|
|
1178
1254
|
}
|
|
1179
1255
|
|
|
1180
|
-
function detectExistingTestFramework(repoRoot) {
|
|
1181
|
-
const pkgPath = path.join(repoRoot, 'package.json');
|
|
1182
|
-
if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
|
|
1183
|
-
|
|
1184
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
1185
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1186
|
-
|
|
1187
|
-
// Detect stack
|
|
1188
|
-
let stack = 'node';
|
|
1189
|
-
if (allDeps['react']) stack = allDeps['vite'] ? 'react-vite' : 'react';
|
|
1190
|
-
if (allDeps['next']) stack = 'nextjs';
|
|
1191
|
-
if (allDeps['@nestjs/core']) stack = 'nestjs';
|
|
1192
|
-
|
|
1193
|
-
// Detect existing test framework
|
|
1194
|
-
let testFw = null;
|
|
1195
|
-
if (allDeps['vitest']) testFw = 'vitest';
|
|
1196
|
-
else if (allDeps['jest']) testFw = 'jest';
|
|
1197
|
-
else if (allDeps['mocha']) testFw = 'mocha';
|
|
1198
|
-
|
|
1199
|
-
// Detect Playwright
|
|
1200
|
-
const hasPlaywright = !!allDeps['@playwright/test'];
|
|
1201
|
-
|
|
1202
|
-
// Detect package manager
|
|
1203
|
-
let pkgMgr = 'npm';
|
|
1204
|
-
if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) pkgMgr = 'yarn';
|
|
1205
|
-
if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) pkgMgr = 'pnpm';
|
|
1206
|
-
|
|
1207
|
-
// Detect existing configs
|
|
1208
|
-
const hasTestConfig = ['vitest.config.js', 'vitest.config.ts', 'jest.config.js', 'jest.config.ts', 'jest.config.cjs']
|
|
1209
|
-
.some(f => fs.existsSync(path.join(repoRoot, f)));
|
|
1210
|
-
const hasPlaywrightConfig = fs.existsSync(path.join(repoRoot, 'playwright.config.js'))
|
|
1211
|
-
|| fs.existsSync(path.join(repoRoot, 'playwright.config.ts'));
|
|
1212
|
-
|
|
1213
|
-
// Detect existing test script
|
|
1214
|
-
const hasTestScript = !!pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1';
|
|
1215
|
-
|
|
1216
|
-
return { stack, testFw, hasPlaywright, pkgMgr, hasTestConfig, hasPlaywrightConfig, hasTestScript, pkg };
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
1256
|
function getInstallPackages(stack) {
|
|
1220
1257
|
const packages = {
|
|
1221
1258
|
'react-vite': {
|
|
@@ -2727,7 +2764,15 @@ program
|
|
|
2727
2764
|
if (!e || !e.id || !e.status) continue;
|
|
2728
2765
|
// Map E13-A-L1 → E13-A (story id strips trailing -L<n>)
|
|
2729
2766
|
const storyId = String(e.id).replace(/-L\d+$/i, '');
|
|
2730
|
-
|
|
2767
|
+
// HC-GF-270-followup-4: the id and the status come from the server — the id is a file name an org member
|
|
2768
|
+
// uploaded — and they choose and rewrite a local file. An id that is not a story id is skipped and the
|
|
2769
|
+
// path is contained; the status must be one word, because it is written into the YAML as text.
|
|
2770
|
+
if (!isStoryId(storyId) || !/^[a-z][a-z_-]{0,31}$/.test(String(e.status))) {
|
|
2771
|
+
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 })}`);
|
|
2772
|
+
skippedCount++;
|
|
2773
|
+
continue;
|
|
2774
|
+
}
|
|
2775
|
+
const yamlPath = `${storyDirIn(learningsRoot, storyId)}.yml`;
|
|
2731
2776
|
if (!fs.existsSync(yamlPath)) {
|
|
2732
2777
|
console.log(` ⚠ no local learnings file for ${storyId} — skip`);
|
|
2733
2778
|
skippedCount++;
|
|
@@ -3890,63 +3935,6 @@ function scanForSpecs(dir, repoRoot, results) {
|
|
|
3890
3935
|
} catch {}
|
|
3891
3936
|
}
|
|
3892
3937
|
|
|
3893
|
-
function classifySpec(file, repoRoot) {
|
|
3894
|
-
let content = '';
|
|
3895
|
-
try { content = fs.readFileSync(file.fullPath, 'utf8'); } catch { }
|
|
3896
|
-
|
|
3897
|
-
const hasPage = /page\.(goto|click|locator|fill|waitFor|getBy)/.test(content);
|
|
3898
|
-
const hasRequest = /request\.(get|post|put|delete|patch)\(/.test(content);
|
|
3899
|
-
const hasExpectResponse = /response\.(status|json|ok|body)/.test(content) ||
|
|
3900
|
-
/expect\(resp/.test(content);
|
|
3901
|
-
|
|
3902
|
-
// Detect type
|
|
3903
|
-
let type = 'UNKNOWN';
|
|
3904
|
-
if (hasPage && (hasRequest || hasExpectResponse)) type = 'HYBRID';
|
|
3905
|
-
else if (hasPage) type = 'BROWSER';
|
|
3906
|
-
else if (hasRequest || hasExpectResponse) type = 'API';
|
|
3907
|
-
else if (/import.*playwright|from.*@playwright/.test(content)) type = 'BROWSER';
|
|
3908
|
-
else if (/import.*supertest|from.*supertest/.test(content)) type = 'API';
|
|
3909
|
-
|
|
3910
|
-
// Detect module from filename or content
|
|
3911
|
-
// Detect module — filename takes priority over content, most specific first
|
|
3912
|
-
let module = 'general';
|
|
3913
|
-
const name = file.name.toLowerCase();
|
|
3914
|
-
const contentLower = content.toLowerCase();
|
|
3915
|
-
|
|
3916
|
-
// Pass 1: check filename (strongest signal)
|
|
3917
|
-
if (/auth|login|signup|oauth|session/.test(name)) module = 'auth';
|
|
3918
|
-
else if (/trade|order|execution/.test(name)) module = 'trading';
|
|
3919
|
-
else if (/position|portfolio|pnl|p&l/.test(name)) module = 'portfolio';
|
|
3920
|
-
else if (/alert|notification/.test(name)) module = 'alerts';
|
|
3921
|
-
else if (/dashboard|widget|overview/.test(name)) module = 'dashboard';
|
|
3922
|
-
else if (/scanner|scan|market/.test(name)) module = 'market';
|
|
3923
|
-
else if (/setting|config|preference/.test(name)) module = 'settings';
|
|
3924
|
-
|
|
3925
|
-
// Pass 2: fallback to content (if filename didn't match)
|
|
3926
|
-
if (module === 'general') {
|
|
3927
|
-
if (/login|authenticate|sign.?in/.test(contentLower)) module = 'auth';
|
|
3928
|
-
else if (/trading|order|execute/.test(contentLower)) module = 'trading';
|
|
3929
|
-
else if (/portfolio/.test(contentLower)) module = 'portfolio';
|
|
3930
|
-
else if (/alert|notification/.test(contentLower)) module = 'alerts';
|
|
3931
|
-
else if (/dashboard/.test(contentLower)) module = 'dashboard';
|
|
3932
|
-
else if (/market|scanner/.test(contentLower)) module = 'market';
|
|
3933
|
-
}
|
|
3934
|
-
|
|
3935
|
-
// Determine destination
|
|
3936
|
-
let destination;
|
|
3937
|
-
if (type === 'API') {
|
|
3938
|
-
destination = `e2e/api/${file.name}`;
|
|
3939
|
-
} else {
|
|
3940
|
-
destination = `e2e/regression/${module}/${file.name}`;
|
|
3941
|
-
}
|
|
3942
|
-
|
|
3943
|
-
// Smoke candidate: login, auth, critical navigation, app loads
|
|
3944
|
-
const smoke = /login|auth|app.load|critical|smoke|health/.test(name) ||
|
|
3945
|
-
/login|authenticate/.test(contentLower);
|
|
3946
|
-
|
|
3947
|
-
return { ...file, type, module, destination, smoke };
|
|
3948
|
-
}
|
|
3949
|
-
|
|
3950
3938
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
3951
3939
|
function bundleDirectory(dir, root, files, maxFiles, currentCount) {
|
|
3952
3940
|
if (currentCount >= maxFiles) return;
|
|
@@ -5908,13 +5896,12 @@ program
|
|
|
5908
5896
|
},
|
|
5909
5897
|
timeout: 120000,
|
|
5910
5898
|
});
|
|
5911
|
-
const text = data.content?.[0]?.text;
|
|
5912
5899
|
// An empty answer is a provider failure the runner records as an error, not a scenario
|
|
5913
|
-
// that failed its assertions.
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
return text;
|
|
5900
|
+
// that failed its assertions. HC-GF-337f: read the first TEXT block, not block 0, so a response
|
|
5901
|
+
// that opens with a thinking block is not an empty one; a refusal names its category.
|
|
5902
|
+
const r = require('./lib/anthropic-response').readAnthropicText(data, { label: 'claude-haiku', allowPartial: true }); // a cut-off answer fails its keywords
|
|
5903
|
+
if (!r.ok) throw new Error(r.message);
|
|
5904
|
+
return r.text;
|
|
5918
5905
|
};
|
|
5919
5906
|
} else {
|
|
5920
5907
|
console.error(`✗ Invalid --provider: ${opts.provider}. Use 'claude-haiku'.`);
|
|
@@ -6172,6 +6159,8 @@ function runContextIo(apiConfig) {
|
|
|
6172
6159
|
},
|
|
6173
6160
|
recentlyModifiedOverlap: (touched) =>
|
|
6174
6161
|
require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
|
|
6162
|
+
// HC-GF-217: the compound symbols the story names, searched in THIS checkout (the server has none to search).
|
|
6163
|
+
callSites: (storyText) => require('./lib/call-sites').collectCallSites({ cwd, storyText }),
|
|
6175
6164
|
};
|
|
6176
6165
|
}
|
|
6177
6166
|
|
|
@@ -6737,7 +6726,7 @@ process.on('SIGINT', () => {
|
|
|
6737
6726
|
program
|
|
6738
6727
|
.command('queue-stories')
|
|
6739
6728
|
.description('Queue a batch of stories for autonomous (overnight) execution')
|
|
6740
|
-
.option('--file <path>', 'Text file with one story
|
|
6729
|
+
.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)')
|
|
6741
6730
|
.option('--repo <name>', 'Repository name applied to every story (default: directory name)')
|
|
6742
6731
|
.option('--branch <name>', 'Git branch applied to every story (default: current branch)')
|
|
6743
6732
|
.option('--status <batchId>', 'Show status of a previously submitted batch')
|
|
@@ -6934,11 +6923,12 @@ program
|
|
|
6934
6923
|
|
|
6935
6924
|
// HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
|
|
6936
6925
|
// cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
|
|
6926
|
+
// HC-GF-351: and `paths:a.js,b.js` for the files that story's context must hold.
|
|
6937
6927
|
const lines = storyIds.map(line => {
|
|
6938
6928
|
const parsed = parseStoryLine(line);
|
|
6939
6929
|
if (parsed.error) {
|
|
6940
6930
|
console.error(parsed.error);
|
|
6941
|
-
console.error(` Expected: "STORY-ID"
|
|
6931
|
+
console.error(` Expected: "STORY-ID", optionally followed by depends:STORY-B,STORY-C and/or paths:a.js,b.js`);
|
|
6942
6932
|
process.exit(1);
|
|
6943
6933
|
}
|
|
6944
6934
|
return parsed;
|
|
@@ -6979,7 +6969,8 @@ program
|
|
|
6979
6969
|
await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
|
|
6980
6970
|
while (nextLine < lines.length) {
|
|
6981
6971
|
const i = nextLine++;
|
|
6982
|
-
|
|
6972
|
+
// HC-GF-351: a line's `paths:` is that story's --include-paths, resolved by the same code.
|
|
6973
|
+
resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId, { includePaths: lines[i].paths });
|
|
6983
6974
|
}
|
|
6984
6975
|
}));
|
|
6985
6976
|
// One summary line per story, and every warning labelled with its story. The info lines
|
|
@@ -6987,7 +6978,9 @@ program
|
|
|
6987
6978
|
for (const ctx of resolved) {
|
|
6988
6979
|
const { storyId } = ctx;
|
|
6989
6980
|
const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
|
|
6990
|
-
|
|
6981
|
+
const cs = ctx.config.call_sites;
|
|
6982
|
+
const csNote = cs ? ` · ${cs.symbols.length} symbol(s) searched for call sites${cs.symbols.some((s) => s.status !== 'complete') ? ' (not all whole)' : ''}` : '';
|
|
6983
|
+
console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled${csNote}`);
|
|
6991
6984
|
printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
|
|
6992
6985
|
printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
|
|
6993
6986
|
}
|
|
@@ -7290,6 +7283,8 @@ program
|
|
|
7290
7283
|
const raw = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
|
|
7291
7284
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
7292
7285
|
} catch {
|
|
7286
|
+
// HC-GF-270-followup-4: the review covers a different range from here on, so it says so.
|
|
7287
|
+
console.error(`⚠ git could not diff ${baseRef}...HEAD — reviewing the last 10 commits (HEAD~10) instead`);
|
|
7293
7288
|
try {
|
|
7294
7289
|
const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
|
|
7295
7290
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
@@ -7343,7 +7338,7 @@ program
|
|
|
7343
7338
|
}
|
|
7344
7339
|
|
|
7345
7340
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
7346
|
-
const providerLabel = 'Anthropic Opus (claude-opus-
|
|
7341
|
+
const providerLabel = 'Anthropic Opus (claude-opus-5-5)';
|
|
7347
7342
|
if (!apiKey) {
|
|
7348
7343
|
console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
|
|
7349
7344
|
console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
@@ -7355,10 +7350,13 @@ program
|
|
|
7355
7350
|
try {
|
|
7356
7351
|
// HC-GF-270-followup-3: each changed file is ONE argument after `--`. They were wrapped in single quotes
|
|
7357
7352
|
// inside a shell string, so a file a PR author named with a `'` in it ran as a command.
|
|
7358
|
-
|
|
7353
|
+
// HC-GF-270-followup-4: and each is read as a literal path. Without --literal-pathspecs a file named `*.js`
|
|
7354
|
+
// matched every .js file, and one named `:(evil)x.js` made git exit, which fell back to HEAD~10 silently.
|
|
7355
|
+
diffContent = execFileSync('git', ['--literal-pathspecs', 'diff', `${baseRef}...HEAD`, '--', ...filesToReview], {
|
|
7359
7356
|
encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024,
|
|
7360
7357
|
});
|
|
7361
7358
|
} catch {
|
|
7359
|
+
console.error(`⚠ git could not diff the changed files against ${baseRef} — reviewing the last 10 commits (HEAD~10) instead`);
|
|
7362
7360
|
try {
|
|
7363
7361
|
diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024 });
|
|
7364
7362
|
} catch (e) {
|
|
@@ -7450,7 +7448,7 @@ program
|
|
|
7450
7448
|
normalizeCacheFlag,
|
|
7451
7449
|
} = require('./lib/release-review-cache');
|
|
7452
7450
|
const cacheEnabled = normalizeCacheFlag(opts.cache);
|
|
7453
|
-
const modelForCache = 'claude-opus-
|
|
7451
|
+
const modelForCache = 'claude-opus-5-5';
|
|
7454
7452
|
let cacheContentHash = null;
|
|
7455
7453
|
if (cacheEnabled) {
|
|
7456
7454
|
try {
|
|
@@ -7513,15 +7511,23 @@ program
|
|
|
7513
7511
|
}
|
|
7514
7512
|
|
|
7515
7513
|
// 6. Call LLM (provider-branched, HC-080a-spike)
|
|
7516
|
-
//
|
|
7517
|
-
//
|
|
7518
|
-
|
|
7514
|
+
// HC-GF-337f: 16000, not the 4096 the HC-080a-spike comparison with GPT-4.1 held constant (that comparison
|
|
7515
|
+
// ended when Opus became the only provider). Claude Opus 5.5 always thinks, and thinking tokens share
|
|
7516
|
+
// max_tokens, so a small cap can be spent before any text is written. A 16000-token answer can take
|
|
7517
|
+
// longer than 120 s, so the request timeout is 300 s.
|
|
7518
|
+
const MAX_OUTPUT_TOKENS = 16000;
|
|
7519
7519
|
const startedAt = Date.now();
|
|
7520
7520
|
try {
|
|
7521
7521
|
let responseText, inputTokens, outputTokens, modelLabel;
|
|
7522
7522
|
|
|
7523
|
+
// Thinking and effort are two SIBLING fields: `thinking: {type: 'adaptive'}` and
|
|
7524
|
+
// `output_config: {effort: 'medium'}` (medium is this model's default; it is sent so the choice is
|
|
7525
|
+
// visible). Thinking cannot be disabled on this model (HTTP 400), and no sampling parameter or
|
|
7526
|
+
// forced tool_choice is sent: both are a 400 here as well.
|
|
7523
7527
|
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
7524
|
-
model: 'claude-opus-
|
|
7528
|
+
model: 'claude-opus-5-5',
|
|
7529
|
+
thinking: { type: 'adaptive' },
|
|
7530
|
+
output_config: { effort: 'medium' },
|
|
7525
7531
|
max_tokens: MAX_OUTPUT_TOKENS,
|
|
7526
7532
|
system: systemPrompt,
|
|
7527
7533
|
messages: [{ role: 'user', content: userPrompt }],
|
|
@@ -7531,12 +7537,15 @@ program
|
|
|
7531
7537
|
'anthropic-version': '2023-06-01',
|
|
7532
7538
|
'content-type': 'application/json',
|
|
7533
7539
|
},
|
|
7534
|
-
timeout:
|
|
7540
|
+
timeout: 300000,
|
|
7535
7541
|
});
|
|
7536
|
-
|
|
7542
|
+
// The first TEXT block, never block 0: a response that opens with a thinking block is not an empty
|
|
7543
|
+
// one (the HC-GF-085 defect on this side). The classification is kept for the failure message.
|
|
7544
|
+
const read = require('./lib/anthropic-response').readAnthropicText(data, { label: `--provider ${opts.provider}` });
|
|
7545
|
+
responseText = read.ok ? read.text : '';
|
|
7537
7546
|
inputTokens = data.usage?.input_tokens || 0;
|
|
7538
7547
|
outputTokens = data.usage?.output_tokens || 0;
|
|
7539
|
-
modelLabel = 'claude-opus-
|
|
7548
|
+
modelLabel = 'claude-opus-5-5';
|
|
7540
7549
|
|
|
7541
7550
|
const elapsedMs = Date.now() - startedAt;
|
|
7542
7551
|
|
|
@@ -7544,16 +7553,22 @@ program
|
|
|
7544
7553
|
// choices/content (content-filter trip, soft quota limit, model alias
|
|
7545
7554
|
// typo). Without this guard the CLI would silently exit 0 and the CI
|
|
7546
7555
|
// artifact would look like a clean pass — a false-negative on the
|
|
7547
|
-
// entire review.
|
|
7556
|
+
// entire review. HC-GF-337f: the cause is the classified one (a refusal with its category, a
|
|
7557
|
+
// cut-off at max_tokens, or no text block), not a guess.
|
|
7548
7558
|
if (!responseText || responseText.trim().length === 0) {
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7559
|
+
// A review cut off at max_tokens is NOT a review: exit 0 here means "deploy", and a cut-off JSON
|
|
7560
|
+
// review can pass the loose CRITICAL / DO_NOT_DEPLOY match. It gets its own status. `kind` and
|
|
7561
|
+
// `category` are additive, so a refusal can be told from an empty response in the CI artifact.
|
|
7562
|
+
const truncated = read.kind === 'truncated';
|
|
7563
|
+
console.error(`${truncated ? 'Incomplete' : 'Empty'} response from --provider ${opts.provider}. ${read.message}`);
|
|
7564
|
+
emitStatusEnvelope(truncated ? 'truncated' : 'empty_response', {
|
|
7552
7565
|
resolvedBase: baseRef,
|
|
7553
7566
|
model: modelLabel,
|
|
7554
7567
|
inputTokens,
|
|
7555
7568
|
outputTokens,
|
|
7556
7569
|
elapsedMs: Date.now() - startedAt,
|
|
7570
|
+
kind: read.kind || null,
|
|
7571
|
+
category: read.category ?? null,
|
|
7557
7572
|
});
|
|
7558
7573
|
process.exit(1);
|
|
7559
7574
|
}
|
|
@@ -8432,10 +8447,16 @@ program
|
|
|
8432
8447
|
// The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
|
|
8433
8448
|
// check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
|
|
8434
8449
|
// decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
|
|
8435
|
-
//
|
|
8450
|
+
// HC-GF-363: a step_4 attempt whose verify-patch verdict is a measured `fail` under verification.gate: enforce is sent back
|
|
8451
|
+
// to the builder with the failing tests as the reason (POST /orchestrate/:runId/reject), at most twice per run, counted from
|
|
8452
|
+
// the server's own rows; the third failure is named for a person.
|
|
8453
|
+
// Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you (HC-GF-351: or every
|
|
8454
|
+
// unfinished run is waiting on a person, named with its step and why), 3 deadline.
|
|
8436
8455
|
program
|
|
8437
8456
|
.command('follow-batch <batchId>')
|
|
8438
|
-
|
|
8457
|
+
// Short text for the top-level `hone --help` (HC-GF-363): the full text below is shown by `hone follow-batch --help`.
|
|
8458
|
+
.summary('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')
|
|
8459
|
+
.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. Under verification.gate: enforce, a step_4 attempt whose verify-patch verdict is a failed test run is rejected back to the builder with the failing tests (HC-GF-363), at most twice per run; the two rejects use step_4\'s retry budget (max_retries_per_step), and a run refused before you upgraded is rejected on the first poll')
|
|
8439
8460
|
.option('--poll-interval <sec>', 'Seconds between polls', '30')
|
|
8440
8461
|
.option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
|
|
8441
8462
|
.option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
|
|
@@ -8450,7 +8471,7 @@ program
|
|
|
8450
8471
|
console.error(badBatch);
|
|
8451
8472
|
process.exit(EXIT.FATAL);
|
|
8452
8473
|
}
|
|
8453
|
-
const { spawn, spawnSync } = require('child_process');
|
|
8474
|
+
const { spawn, spawnSync, execFileSync } = require('child_process');
|
|
8454
8475
|
const positive = (raw, flag, unit) => {
|
|
8455
8476
|
const n = Number(raw);
|
|
8456
8477
|
if (!Number.isFinite(n) || n <= 0) {
|
|
@@ -8481,12 +8502,52 @@ program
|
|
|
8481
8502
|
const io = {
|
|
8482
8503
|
fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
|
|
8483
8504
|
fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
|
|
8484
|
-
|
|
8505
|
+
// `capture` also returns the child's stdout (the tail of it), for the one verb whose answer the loop reads
|
|
8506
|
+
// (queue-stories, HC-GF-351). Either way the child's output reaches this process's own stdout.
|
|
8507
|
+
runVerb: (argv, { capture = false } = {}) => new Promise((resolve) => {
|
|
8485
8508
|
const child = spawn(process.execPath, [process.argv[1], ...argv],
|
|
8486
|
-
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
8487
|
-
|
|
8488
|
-
child.on('
|
|
8509
|
+
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', capture ? 'pipe' : 'inherit', 'inherit'] });
|
|
8510
|
+
let stdout = '';
|
|
8511
|
+
if (capture) child.stdout.on('data', (d) => { process.stdout.write(d); stdout = (stdout + d).slice(-262144); });
|
|
8512
|
+
child.on('error', (e) => resolve({ code: null, error: e, stdout }));
|
|
8513
|
+
child.on('close', (code) => resolve({ code, stdout }));
|
|
8489
8514
|
}),
|
|
8515
|
+
// HC-GF-351: what a re-serve needs. The regular files git tracks in THIS checkout, listed with their modes so
|
|
8516
|
+
// a tracked symlink (which the bundler would follow out of the checkout) is not among them. Run where the
|
|
8517
|
+
// bundler runs, so the paths are the ones it resolves.
|
|
8518
|
+
trackedPaths: () => require('./lib/context-reserve').trackedRegularFiles(execFileSync('git', ['ls-files', '-z', '-s'], {
|
|
8519
|
+
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
|
|
8520
|
+
})),
|
|
8521
|
+
// Where a requested path resolves on the disk NOW, relative to this checkout, or null: the index cannot see a
|
|
8522
|
+
// file or a directory that was replaced by a symlink after the commit, and the bundler reads through it.
|
|
8523
|
+
realPath: (rel) => require('./lib/context-reserve').repoRealPath(process.cwd(), rel),
|
|
8524
|
+
// Was this story served already? A kill that failed, or a crash between the queue and the kill, leaves no marker
|
|
8525
|
+
// and only a newer run of the same story, so the server's own run list is asked (GET /orchestrate?storyId=&repoName=).
|
|
8526
|
+
priorServe: (action) => require('./lib/context-reserve').findServe({
|
|
8527
|
+
listRuns: async (storyId, repo) => (await client.get(apiPath('orchestrate', [], { storyId, repoName: repo, limit: 100 }))).data.runs,
|
|
8528
|
+
listBatches: async () => (await client.get(orchestratePath(['batch'], { limit: 20 }))).data.batches,
|
|
8529
|
+
fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
|
|
8530
|
+
}, action),
|
|
8531
|
+
// One line, in a directory only this invocation can read, for `queue-stories --file`.
|
|
8532
|
+
writeStoriesFile: (text) => {
|
|
8533
|
+
const t = require('./lib/private-temp').privateTempFile('hone-reserve', 'stories.txt');
|
|
8534
|
+
fs.writeFileSync(t.file, text, { mode: 0o600 });
|
|
8535
|
+
return t;
|
|
8536
|
+
},
|
|
8537
|
+
killRun: async (runId, reason) => {
|
|
8538
|
+
if (!require('./lib/run-target').isRunId(runId)) throw new Error('not a run id');
|
|
8539
|
+
await client.post(orchestratePath([runId, 'kill']), { reason });
|
|
8540
|
+
},
|
|
8541
|
+
// HC-GF-363: send a step_4 attempt that failed verification under enforce back to the builder. The loop re-reads
|
|
8542
|
+
// the run and counts its earlier rejects from `fetchStep` (an attempt's stored reject reason) before it calls this.
|
|
8543
|
+
rejectRun: async (runId, body) => {
|
|
8544
|
+
if (!require('./lib/run-target').isRunId(runId)) throw new Error('not a run id');
|
|
8545
|
+
return (await client.post(orchestratePath([runId, 'reject']), body)).data;
|
|
8546
|
+
},
|
|
8547
|
+
fetchStep: async (runId, stepKey, attempt) => {
|
|
8548
|
+
if (!require('./lib/run-target').isRunId(runId)) throw new Error('not a run id');
|
|
8549
|
+
return (await client.get(orchestratePath([runId, 'step', stepKey], { attempt }))).data;
|
|
8550
|
+
},
|
|
8490
8551
|
// The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
|
|
8491
8552
|
verifyDecision: (run) => {
|
|
8492
8553
|
let assessment;
|