@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 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
- // AI reviewer script
757
- {
758
- key: 'scripts/ai-reviewer.js',
759
- promise: client.get('/scripts/reviewer-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
760
- write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'ai-reviewer.js'), data),
761
- },
762
- // H-007: Learnings gate helper (imported by ai-reviewer at CI time).
763
- // Both files are ESM. The .github/scripts/package.json (next entry below)
764
- // declares ESM scope for this directory so node treats both .js files as
765
- // ES modules regardless of the adopter's root package.json (see #16).
766
- {
767
- key: 'scripts/check-learnings-gate.js',
768
- promise: client.get('/scripts/learnings-gate-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
769
- write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'check-learnings-gate.js'), data),
770
- },
771
- // H-007: Subdir package.json declares ESM scope for .github/scripts/.
772
- // Both ai-reviewer.js and check-learnings-gate.js use `import` syntax;
773
- // without this file, node treats them as CommonJS (the npm-init default)
774
- // and the workflow fails with `Cannot use import statement outside a
775
- // module`. Subdir package.json scope OVERRIDES the adopter's root —
776
- // adopter's main project module semantics are unaffected. See #16.
777
- {
778
- key: 'scripts/package.json',
779
- promise: Promise.resolve('{"type": "module"}\n'),
780
- write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'package.json'), data),
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: ['ai-code-review'],
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
- const yamlPath = path.join(learningsRoot, `${storyId}.yml`);
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
- if (typeof text !== 'string' || !text.trim()) {
5915
- throw new Error(`claude-haiku returned no text (stop_reason: ${data.stop_reason || 'unknown'})`);
5916
- }
5917
- return text;
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 ID per line (cap: 50 lines, 16KB)')
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" OR "STORY-ID depends:STORY-B,STORY-C"`);
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
- resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
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
- console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
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-4-8)';
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
- diffContent = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--', ...filesToReview], {
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-4-8';
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
- // max_tokens stays at 4096, the value the HC-080a-spike comparison with GPT-4.1 held constant,
7517
- // so a review's output budget has not changed now that Opus is the only provider.
7518
- const MAX_OUTPUT_TOKENS = 4096;
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-4-8',
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: 120000,
7540
+ timeout: 300000,
7535
7541
  });
7536
- responseText = data.content?.[0]?.text || '';
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-4-8';
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
- console.error(`Empty response from --provider ${opts.provider}. ` +
7550
- `Possible causes: content filter, quota exhausted, or model name rejected.`);
7551
- emitStatusEnvelope('empty_response', {
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
- // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
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
- .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
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
- runVerb: (argv) => new Promise((resolve) => {
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
- child.on('error', (e) => resolve({ code: null, error: e }));
8488
- child.on('close', (code) => resolve({ code }));
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;