@hone-ai/cli 1.39.0 → 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
@@ -598,6 +598,10 @@ program
598
598
  .option('--e2e', 'Also install Playwright E2E framework (use with --install-tests)')
599
599
  .option('--no-e2e', 'Skip Playwright even when --install-tests is set')
600
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)')
601
605
  .option('--refresh', 'Re-scan platform metadata without re-running full setup (HC-013c)')
602
606
  .option('--ci-gate <mode>', 'HC-RC-004: CI gating mode (github | local | mixed | none). Skips interactive prompt.')
603
607
  .action(async (opts) => {
@@ -717,6 +721,15 @@ program
717
721
  fs.mkdirSync(workflowsDir, { recursive: true });
718
722
  fs.mkdirSync(scriptsEntDir, { recursive: true });
719
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
+
720
733
  // Build parallel fetch list
721
734
  const fetches = [
722
735
  // Agent stubs and skill files. HC-GF-136's gate found `key`, `promise` and `write` here
@@ -750,38 +763,42 @@ program
750
763
  promise: client.get('/scripts/copilot-instructions').then(r => r.data),
751
764
  write: (data) => fs.writeFileSync(path.join(enterpriseGH, 'copilot-instructions.md'), data),
752
765
  },
753
- // AI review workflow template
766
+ // AI review workflow template — installed with or without the reviewer (HC-GF-337g)
754
767
  {
755
768
  key: 'workflows/ai-review.yml',
756
769
  promise: client.get('/scripts/workflow-template', { responseType: 'text', headers: { Accept: 'text/yaml' } }).then(r => r.data),
757
770
  write: (data) => fs.writeFileSync(path.join(workflowsDir, 'ai-review.yml'), data),
758
771
  },
759
- // AI reviewer script
760
- {
761
- key: 'scripts/ai-reviewer.js',
762
- promise: client.get('/scripts/reviewer-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
763
- write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'ai-reviewer.js'), data),
764
- },
765
- // H-007: Learnings gate helper (imported by ai-reviewer at CI time).
766
- // Both files are ESM. The .github/scripts/package.json (next entry below)
767
- // declares ESM scope for this directory so node treats both .js files as
768
- // ES modules regardless of the adopter's root package.json (see #16).
769
- {
770
- key: 'scripts/check-learnings-gate.js',
771
- promise: client.get('/scripts/learnings-gate-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
772
- write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'check-learnings-gate.js'), data),
773
- },
774
- // H-007: Subdir package.json declares ESM scope for .github/scripts/.
775
- // Both ai-reviewer.js and check-learnings-gate.js use `import` syntax;
776
- // without this file, node treats them as CommonJS (the npm-init default)
777
- // and the workflow fails with `Cannot use import statement outside a
778
- // module`. Subdir package.json scope OVERRIDES the adopter's root —
779
- // adopter's main project module semantics are unaffected. See #16.
780
- {
781
- key: 'scripts/package.json',
782
- promise: Promise.resolve('{"type": "module"}\n'),
783
- write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'package.json'), data),
784
- },
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
+ ] : []),
785
802
  // H-004: Auto-promote workflow template (installed at setup time;
786
803
  // adopters customize {{BRANCHES}} via .pipeline-config.yml's
787
804
  // ci.trigger_branches setting via setup-ai-pipeline.sh substitution).
@@ -841,6 +858,12 @@ program
841
858
  process.stdout.write(` ⚠ ${key} — ${result.reason?.message || 'failed'}\n`);
842
859
  }
843
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'));
844
867
 
845
868
  // 6. Download setup-ai-pipeline.sh, verify HMAC, write to tmp
846
869
  let scriptContent;
@@ -957,6 +980,22 @@ program
957
980
  process.exit(1);
958
981
  }
959
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
+
960
999
  // HC-GF-320: every phase below writes (CLAUDE.md, config grounding, test
961
1000
  // packages, GitHub branch protection). A dry run stops here and says what
962
1001
  // they would do.
@@ -1129,17 +1168,51 @@ program
1129
1168
  opts.branchProtection === false ||
1130
1169
  /^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || '');
1131
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.
1132
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;
1133
1187
  const protResult = await installBranchProtection({
1134
1188
  log: console.log,
1135
1189
  opts: {
1136
1190
  skip: skipBranchProtection,
1137
- 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),
1138
1203
  },
1139
1204
  });
1140
1205
  console.log('');
1141
1206
  if (protResult.status === 'installed') {
1142
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
+ }
1143
1216
  } else if (protResult.status === 'skipped') {
1144
1217
  console.log(` ⚠ Branch protection skipped: ${protResult.reason}`);
1145
1218
  if (protResult.manualCommand) {
@@ -5823,13 +5896,12 @@ program
5823
5896
  },
5824
5897
  timeout: 120000,
5825
5898
  });
5826
- const text = data.content?.[0]?.text;
5827
5899
  // 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;
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;
5833
5905
  };
5834
5906
  } else {
5835
5907
  console.error(`✗ Invalid --provider: ${opts.provider}. Use 'claude-haiku'.`);
@@ -7266,7 +7338,7 @@ program
7266
7338
  }
7267
7339
 
7268
7340
  const apiKey = process.env.ANTHROPIC_API_KEY;
7269
- const providerLabel = 'Anthropic Opus (claude-opus-4-8)';
7341
+ const providerLabel = 'Anthropic Opus (claude-opus-5-5)';
7270
7342
  if (!apiKey) {
7271
7343
  console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
7272
7344
  console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
@@ -7376,7 +7448,7 @@ program
7376
7448
  normalizeCacheFlag,
7377
7449
  } = require('./lib/release-review-cache');
7378
7450
  const cacheEnabled = normalizeCacheFlag(opts.cache);
7379
- const modelForCache = 'claude-opus-4-8';
7451
+ const modelForCache = 'claude-opus-5-5';
7380
7452
  let cacheContentHash = null;
7381
7453
  if (cacheEnabled) {
7382
7454
  try {
@@ -7439,15 +7511,23 @@ program
7439
7511
  }
7440
7512
 
7441
7513
  // 6. Call LLM (provider-branched, HC-080a-spike)
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.
7444
- 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;
7445
7519
  const startedAt = Date.now();
7446
7520
  try {
7447
7521
  let responseText, inputTokens, outputTokens, modelLabel;
7448
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.
7449
7527
  const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
7450
- model: 'claude-opus-4-8',
7528
+ model: 'claude-opus-5-5',
7529
+ thinking: { type: 'adaptive' },
7530
+ output_config: { effort: 'medium' },
7451
7531
  max_tokens: MAX_OUTPUT_TOKENS,
7452
7532
  system: systemPrompt,
7453
7533
  messages: [{ role: 'user', content: userPrompt }],
@@ -7457,12 +7537,15 @@ program
7457
7537
  'anthropic-version': '2023-06-01',
7458
7538
  'content-type': 'application/json',
7459
7539
  },
7460
- timeout: 120000,
7540
+ timeout: 300000,
7461
7541
  });
7462
- 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 : '';
7463
7546
  inputTokens = data.usage?.input_tokens || 0;
7464
7547
  outputTokens = data.usage?.output_tokens || 0;
7465
- modelLabel = 'claude-opus-4-8';
7548
+ modelLabel = 'claude-opus-5-5';
7466
7549
 
7467
7550
  const elapsedMs = Date.now() - startedAt;
7468
7551
 
@@ -7470,16 +7553,22 @@ program
7470
7553
  // choices/content (content-filter trip, soft quota limit, model alias
7471
7554
  // typo). Without this guard the CLI would silently exit 0 and the CI
7472
7555
  // artifact would look like a clean pass — a false-negative on the
7473
- // 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.
7474
7558
  if (!responseText || responseText.trim().length === 0) {
7475
- console.error(`Empty response from --provider ${opts.provider}. ` +
7476
- `Possible causes: content filter, quota exhausted, or model name rejected.`);
7477
- 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', {
7478
7565
  resolvedBase: baseRef,
7479
7566
  model: modelLabel,
7480
7567
  inputTokens,
7481
7568
  outputTokens,
7482
7569
  elapsedMs: Date.now() - startedAt,
7570
+ kind: read.kind || null,
7571
+ category: read.category ?? null,
7483
7572
  });
7484
7573
  process.exit(1);
7485
7574
  }
@@ -8358,11 +8447,16 @@ program
8358
8447
  // The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
8359
8448
  // check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
8360
8449
  // decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
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.
8361
8453
  // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you (HC-GF-351: or every
8362
8454
  // unfinished run is waiting on a person, named with its step and why), 3 deadline.
8363
8455
  program
8364
8456
  .command('follow-batch <batchId>')
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')
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')
8366
8460
  .option('--poll-interval <sec>', 'Seconds between polls', '30')
8367
8461
  .option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
8368
8462
  .option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
@@ -8444,6 +8538,16 @@ program
8444
8538
  if (!require('./lib/run-target').isRunId(runId)) throw new Error('not a run id');
8445
8539
  await client.post(orchestratePath([runId, 'kill']), { reason });
8446
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
+ },
8447
8551
  // The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
8448
8552
  verifyDecision: (run) => {
8449
8553
  let assessment;
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-337f — read an Anthropic Messages response without losing half of it.
4
+ *
5
+ * Every CLI path used to take `data.content[0].text`. Once a model can emit a thinking block
6
+ * FIRST, block 0 is `{type: 'thinking'}` and that expression is `undefined`: an empty review or
7
+ * verdict reported as a content filter or a quota trip. The HC-GF-085 defect, on the CLI side.
8
+ *
9
+ * Returns a discriminated result; the caller owns its own exit semantics (skill-eval throws inside
10
+ * callLLM, release-review emits a status envelope, the judge throws so the scenario is an error and
11
+ * not a failed criterion). Nothing here throws, so the outcomes can be asserted in one table.
12
+ *
13
+ * { ok: true, text, stopReason }
14
+ * { ok: false, kind: 'refusal', category, stopReason, message } // HTTP 200, stop_reason "refusal"
15
+ * { ok: false, kind: 'truncated', stopReason, partialText, message } // cut off at the cap; partialText is null when there is none
16
+ * { ok: false, kind: 'no_text', stopReason, message } // blocks, but none with text
17
+ *
18
+ * Order is part of the contract: a refusal is decided FIRST (it can arrive with a thinking block and
19
+ * no text, and must never read as `no_text`). Then `max_tokens`: a response cut off at the cap is
20
+ * `truncated` EVEN WHEN it carries text, because a cut-off answer can be well-formed and wrong (a review
21
+ * cut off before its verdict). A caller whose success means approval (release-review) therefore gets
22
+ * the safe answer by default. A caller for which a partial answer is harmless (a judge verdict that
23
+ * defaults to NO, a skill-eval answer that fails its keywords) opts in with `allowPartial: true` and
24
+ * then gets the text back as `ok`. The first non-blank text block wins otherwise.
25
+ *
26
+ * Every non-ok message names the label, `stop_reason:` and, when the API gave one, the refusal category.
27
+ * Messages begin `<label> returned no text (stop_reason: ...)` when there is no text at all: callers and
28
+ * tests match that prefix.
29
+ *
30
+ * ADOPTER SURFACE (HC-LC-001): this file ships in the published CLI. It is a pure classifier: no
31
+ * I/O, no local Claude binary.
32
+ *
33
+ * @param {object} data the parsed response body
34
+ * @param {{label?: string, allowPartial?: boolean}} [opts] label: who is asking, so three call sites read
35
+ * differently in a log. allowPartial: accept text from a response cut off at max_tokens (default false).
36
+ */
37
+ function readAnthropicText(data, { label, allowPartial = false } = {}) {
38
+ const who = label || 'the provider';
39
+ const stopReason = (data && data.stop_reason) || 'unknown';
40
+ const category = (data && data.stop_details && data.stop_details.category) || null;
41
+ const blocks = Array.isArray(data && data.content) ? data.content : [];
42
+ const cause = `stop_reason: ${stopReason}${category ? `, category: ${category}` : ''}`;
43
+
44
+ if (stopReason === 'refusal') {
45
+ return { ok: false, kind: 'refusal', category, stopReason, message: `${who} returned no text (${cause}): the model declined the request` };
46
+ }
47
+ const block = blocks.find((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim().length > 0);
48
+ if (stopReason === 'max_tokens' && !(block && allowPartial)) {
49
+ const message = block
50
+ ? `${who} returned an incomplete answer (${cause}): it was cut off at max_tokens and may be missing its ending; raise max_tokens`
51
+ : `${who} returned no text (${cause}): the answer was cut off at max_tokens before any text, thinking included; raise max_tokens`;
52
+ return { ok: false, kind: 'truncated', stopReason, partialText: block ? block.text : null, message };
53
+ }
54
+ if (block) return { ok: true, text: block.text, stopReason };
55
+ return { ok: false, kind: 'no_text', stopReason, message: `${who} returned no text (${cause})` };
56
+ }
57
+
58
+ module.exports = { readAnthropicText };