@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 +152 -48
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +230 -9
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +20 -3
- package/lib/judge-provider.js +20 -11
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/security-scanner.js +7 -4
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
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
|
-
//
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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:
|
|
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
|
-
|
|
5830
|
-
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
7443
|
-
//
|
|
7444
|
-
|
|
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-
|
|
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:
|
|
7540
|
+
timeout: 300000,
|
|
7461
7541
|
});
|
|
7462
|
-
|
|
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-
|
|
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
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
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
|
-
|
|
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 };
|