@hone-ai/cli 1.41.0 → 1.42.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/README.md CHANGED
@@ -16,6 +16,13 @@ hone init --token <YOUR_TOKEN>
16
16
  A token is issued when your organization is provisioned. Without one the CLI installs fine but
17
17
  every server-backed command will refuse.
18
18
 
19
+ The CLI talks to `https://hone-server-production.up.railway.app` unless you point it elsewhere.
20
+ It picks the server from `hone init --api <url>` (saved to `~/.honerc` as `api`), then `HONE_API`,
21
+ then `~/.honerc`, then that default. `hone init` without `--api` keeps the `api` already in
22
+ `~/.honerc`. A value naming the retired default domain or any of its subdomains (it is no longer ours), or
23
+ one that is not an http(s) URL, is refused: `init --api` exits 2, and `HONE_API` or `~/.honerc` is
24
+ ignored with a one-line warning that names only the hostname.
25
+
19
26
  ## The three commands that matter
20
27
 
21
28
  ```bash
package/hone-cli.js CHANGED
@@ -29,10 +29,12 @@ const { execSync, execFileSync } = require('child_process');
29
29
  // A source-regex asserting the variable name matched this very comment and passed while the
30
30
  // scrub was gutted.
31
31
  const { verdictEnv } = require('./lib/verdict-env');
32
+ const { DEFAULT_API, resolveApiUrl, apiHostname } = require('./lib/default-api'); // HC-GF-396
32
33
  const { parseTimeoutSeconds } = require('./lib/timeout-option.js');
33
34
  // HC-GF-143 batch: both mappings were computed inline and asserted by nothing (HC-GF-136).
34
35
  const { promotePayload } = require('./lib/promote-payload');
35
36
  const { assetFetchList } = require('./lib/asset-fetch-list');
37
+ const { adminRequestOptions, describeAdminRedirect } = require('./lib/admin-request'); // HC-GF-406
36
38
  // HC-CI-019-followup-3: no suite asserted a field of either, and this file cannot be required.
37
39
  const { detectExistingTestFramework } = require('./lib/detect-test-framework');
38
40
  const { classifySpec } = require('./lib/classify-spec');
@@ -43,7 +45,7 @@ const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config
43
45
  const { gitEnv } = require('./lib/git-env');
44
46
  const { collectIncludePaths } = require('./lib/include-paths');
45
47
  const { runArgsError, orchestratePath, apiPath, storyDir: storyDirIn, isGitRev, isStoryId } = require('./lib/run-target'); // HC-GF-270-followup-1, -2, -3, -4
46
- const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
48
+ const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase, scrub } = require('./lib/operator-question'); // HC-GF-234
47
49
  const program = new Command();
48
50
 
49
51
  // HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
@@ -110,7 +112,8 @@ function getConfig() {
110
112
  // Priority: env var → ~/.honerc → error
111
113
  const rc = readRc();
112
114
  const token = process.env.HONE_TOKEN || rc.token;
113
- const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
115
+ // HC-GF-396: a retired or invalid HONE_API / rc.api counts as unset, with one stderr warning.
116
+ const { apiUrl } = resolveApiUrl({ env: process.env.HONE_API, rc: rc.api });
114
117
 
115
118
  if (!token) {
116
119
  console.error('Error: Hone token not found.');
@@ -1993,10 +1996,18 @@ program
1993
1996
  .command('init')
1994
1997
  .description('Save HONE_TOKEN and API URL to ~/.honerc')
1995
1998
  .option('--token <token>', 'Hone API token')
1996
- .option('--api <url>', 'Hone API base URL (default: https://api.hone.ai)')
1999
+ .option('--api <url>', `Hone API base URL (default: ${DEFAULT_API})`)
1997
2000
  .action(async (opts) => {
1998
2001
  const token = opts.token || process.env.HONE_TOKEN;
1999
- const apiUrl = opts.api || process.env.HONE_API || 'https://api.hone.ai';
2002
+ // HC-GF-396: a retired or invalid --api exits 2 before anything is sent. Without --api, init
2003
+ // resolves like every other command (HONE_API → ~/.honerc → default), so re-running it keeps a
2004
+ // valid custom rc.api instead of resetting it.
2005
+ const resolved = resolveApiUrl({ flag: opts.api, env: process.env.HONE_API, rc: readRc().api });
2006
+ if (resolved.error) {
2007
+ console.error(`Error: ${resolved.error}`);
2008
+ process.exit(2);
2009
+ }
2010
+ const apiUrl = resolved.apiUrl;
2000
2011
 
2001
2012
  if (!token) {
2002
2013
  console.error('Error: --token is required (or set HONE_TOKEN env var).');
@@ -2025,7 +2036,7 @@ program
2025
2036
  fs.writeFileSync(RC_PATH, JSON.stringify(rc, null, 2), { mode: 0o600 });
2026
2037
 
2027
2038
  console.log(`✓ Config saved to ${RC_PATH}`);
2028
- console.log(` API: ${apiUrl}`);
2039
+ console.log(` API: ${apiHostname(apiUrl)}`);
2029
2040
  console.log('');
2030
2041
  console.log('You can now run "hone setup" in any repository.');
2031
2042
  });
@@ -2493,13 +2504,12 @@ program
2493
2504
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2494
2505
  .action(async (opts) => {
2495
2506
  const rc = readRc();
2496
- const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
2507
+ const { apiUrl } = resolveApiUrl({ env: process.env.HONE_API, rc: rc.api });
2497
2508
  const adminKey = requireAdminKey();
2498
2509
 
2499
2510
  try {
2500
2511
  const { data } = await axios.get(`${apiUrl}/admin/usage`, {
2501
- headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
2502
- timeout: 15000,
2512
+ ...adminRequestOptions(adminKey, `@hone-ai/cli/${pkg.version}`),
2503
2513
  });
2504
2514
 
2505
2515
  if (opts.format === 'json') {
@@ -2544,7 +2554,10 @@ program
2544
2554
 
2545
2555
  console.log('');
2546
2556
  } catch (e) {
2547
- if (e.response?.status === 401) {
2557
+ const redirect = describeAdminRedirect(e);
2558
+ if (redirect) {
2559
+ console.error(redirect);
2560
+ } else if (e.response?.status === 401) {
2548
2561
  console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
2549
2562
  } else {
2550
2563
  console.error(`Failed to fetch admin dashboard: ${e.message}`);
@@ -3102,9 +3115,9 @@ program
3102
3115
  }
3103
3116
 
3104
3117
  const adminKey = requireAdminKey();
3105
- // `getConfig()` already resolves the base (HONE_API -> ~/.honerc -> default) and exits with a
3106
- // usable message when no token is configured. Re-deriving it here would have added a TENTH
3107
- // copy of the `api.hone.ai` literal that [[HC-GF-236]] is trying to reduce to one.
3118
+ // `getConfig()` already resolves the base through `resolveApiUrl()` (HONE_API -> ~/.honerc ->
3119
+ // default, HC-GF-396) and exits with a usable message when no token is configured. Re-deriving
3120
+ // it here would bypass the retired-host guard in cli/lib/default-api.js.
3108
3121
  const config = getConfig();
3109
3122
  const apiUrl = config.apiUrl;
3110
3123
 
@@ -3120,8 +3133,7 @@ program
3120
3133
  const { data } = await axios.post(`${apiUrl}/admin/learnings/absorb`, {
3121
3134
  orgId, ids: wanted, repo: opts.repo, absorbedIn: destination,
3122
3135
  }, {
3123
- headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
3124
- timeout: 15000,
3136
+ ...adminRequestOptions(adminKey, `@hone-ai/cli/${pkg.version}`),
3125
3137
  });
3126
3138
 
3127
3139
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
@@ -3141,7 +3153,9 @@ program
3141
3153
  if (data.skipped.length) process.exitCode = 1;
3142
3154
  } catch (e) {
3143
3155
  const status = e.response?.status;
3144
- if (status === 401) console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
3156
+ const redirect = describeAdminRedirect(e);
3157
+ if (redirect) console.error(redirect);
3158
+ else if (status === 401) console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
3145
3159
  else if (status === 429) console.error('Too many failed admin-auth attempts from this address. Wait for the window to pass.');
3146
3160
  else if (status === 503) console.error(`Server cannot absorb right now: ${e.response?.data?.error || 'unavailable'}`);
3147
3161
  else console.error(`Absorb failed: ${e.response?.data?.error || e.message}`);
@@ -3951,7 +3965,9 @@ function bundleDirectory(dir, root, files, maxFiles, currentCount) {
3951
3965
 
3952
3966
  if (entry.isDirectory()) {
3953
3967
  bundleDirectory(fullPath, root, files, maxFiles, Object.keys(files).length);
3954
- } else if (EXTENSIONS.test(entry.name)) {
3968
+ } else if (EXTENSIONS.test(entry.name) && !pathRefusal(relPath, { mode: 'egress' })) {
3969
+ // HC-GF-398: `config/secrets.yml` and `secrets.json` pass the extension filter; derive sends them to the
3970
+ // server whole, so the egress refusal applies here as it does to every other whole-content send.
3955
3971
  try {
3956
3972
  const content = fs.readFileSync(fullPath, 'utf8');
3957
3973
  if (content.length < 50_000) { // skip very large files
@@ -3974,6 +3990,7 @@ function sleep(ms) {
3974
3990
  // `require('../hone-cli.js')` — making require.main the bin wrapper,
3975
3991
  // not hone-cli.js, so the gate never fired and parse never ran.)
3976
3992
  const { splicePreserveRepoSpecific } = require('./lib/splice-repo-specific');
3993
+ const { pathRefusal } = require('./lib/path-refusal');
3977
3994
 
3978
3995
  // ── STATUS command (H-009) ────────────────────────────────────────────────────
3979
3996
  // Show pipeline state for a story (or use --all for every in-flight story).
@@ -5994,14 +6011,19 @@ program
5994
6011
  try {
5995
6012
  // HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
5996
6013
  // knows, so an older CLI — which sends nothing — keeps being served contract 1.
5997
- const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: '1,2' }));
6014
+ // HC-GF-399: contract 3 anchors edits on HEAD, so only a checkout with a commit advertises it; a
6015
+ // repository with no commit is served 2, as before. A git timeout fails here rather than guessing.
6016
+ const { editorContractsFor } = require('./lib/tree-artifact');
6017
+ const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: editorContractsFor(process.cwd()) }));
5998
6018
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5999
6019
  console.log(`Step: ${data.stepKey} (${data.agent})`);
6000
6020
  console.log(`Contract: ${data.contractVersion}`);
6001
6021
  console.log('─'.repeat(60));
6002
6022
  console.log(data.instruction);
6003
6023
  console.log('─'.repeat(60));
6004
- console.log(`Submit with: hone submit-step ${runId} ${data.stepKey} --file <output.txt>`);
6024
+ // HC-GF-399: the submit command the SERVED contract asks for — `--file` only under contract 1.
6025
+ const how = String(data.contractVersion) === '1' ? '--file <output.txt>' : `--from-tree --contract ${data.contractVersion}`;
6026
+ console.log(`Submit with: hone submit-step ${runId} ${data.stepKey} ${how}`);
6005
6027
  } catch (e) {
6006
6028
  const st = e.response?.status;
6007
6029
  if (st === 409) console.error(`No step awaiting the editor: ${e.response.data?.error || ''} (run status: ${e.response.data?.runStatus || '?'})`);
@@ -6035,6 +6057,11 @@ program
6035
6057
  console.error('Contract 2 is the from-tree contract: the instruction told the agent to write the files with its own tools. Re-run with --from-tree, or fetch a contract 1 instruction if you really did assemble the output by hand.');
6036
6058
  process.exit(1);
6037
6059
  }
6060
+ // HC-GF-399: contract 3 sends region edits proven against HEAD, which only a render from the tree can do.
6061
+ if (String(opts.contract) === '3' && !opts.fromTree) {
6062
+ console.error('Contract 3 is a from-tree contract: it sends region edits proven against HEAD from what you wrote. Re-run with --from-tree.');
6063
+ process.exit(1);
6064
+ }
6038
6065
  if (!opts.file && !opts.fromTree) { console.error('Provide the output via --file <path> (the text your editor produced), or --from-tree to render it from the working tree.'); process.exit(1); }
6039
6066
  const fs = require('fs');
6040
6067
  let output;
@@ -6042,12 +6069,17 @@ program
6042
6069
  let warned = false;
6043
6070
  if (opts.fromTree) {
6044
6071
  const { renderFromTree } = require('./lib/tree-artifact');
6045
- const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
6072
+ const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport, contract: String(opts.contract), stepKey });
6046
6073
  output = rendered.text;
6047
6074
  // Say what was left out BEFORE the submission, not after: a file the tree has and the
6048
6075
  // artifact does not is a decision the operator has to be able to see.
6049
6076
  for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
6050
6077
  console.error(`from-tree: ${rendered.files.length} file(s), ${rendered.deletes.length} deletion(s), ${rendered.skipped.length} skipped`);
6078
+ // HC-GF-399: under contract 3, what went as region edits and every file that fell back to whole, with why.
6079
+ if (rendered.edited.length || rendered.fallbacks.length) {
6080
+ console.error(`from-tree: ${rendered.edited.length} file(s) sent as region edits against ${rendered.headSha.slice(0, 12)}`);
6081
+ for (const f of rendered.fallbacks) console.error(`whole: ${f.path} (${f.reason})`);
6082
+ }
6051
6083
  // HC-GF-390: for a build, the base diff of each file emitted whole whose original the reviewers saw only in
6052
6084
  // windows. Sent beside the artifact, stored apart from it, and never a reason this submit fails.
6053
6085
  if (stepKey === 'step_4') {
@@ -6056,6 +6088,13 @@ program
6056
6088
  reviewDiff = computeReviewDiff({ repoRoot: process.cwd(), rendered,
6057
6089
  warn: (line) => { warned = true; console.error(line); } });
6058
6090
  } catch (e) { reviewDiff = null; warned = true; console.error(`review diff not sent: ${e.message}`); }
6091
+ // HC-GF-399: review-diff.js resolves HEAD itself. If that is not the commit the render was taken against,
6092
+ // HEAD moved during the submit, and the diff would describe a different base — drop it, and say so.
6093
+ if (reviewDiff && !require('./lib/tree-artifact').reviewDiffBaseMatches(reviewDiff, rendered)) {
6094
+ warned = true;
6095
+ console.error(`review diff not sent: computed against ${reviewDiff.headSha.slice(0, 12)}, the render against ${rendered.headSha.slice(0, 12)} (HEAD moved during submit)`);
6096
+ reviewDiff = null;
6097
+ }
6059
6098
  if (reviewDiff) {
6060
6099
  console.error(`review diff: ${reviewDiff.stats.files} file(s), ${reviewDiff.notes.length} note(s), against ${reviewDiff.headSha.slice(0, 12)}`);
6061
6100
  for (const n of reviewDiff.notes) console.error(` review diff note: ${n}`);
@@ -7244,7 +7283,7 @@ program
7244
7283
  .option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
7245
7284
  .option('--max-files <n>', 'Max source files to include in review', '40')
7246
7285
  .option('--provider <name>', 'LLM provider: opus (paid, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
7247
- .option('--cache <mode>', 'HC-RC-002-followup-1 content-hash cache: on | off (default on)', 'on')
7286
+ .option('--cache <mode>', 'content-hash cache: on stores the review at the Hone API (14 days) and replays it for the same diff; off sends nothing (default on)', 'on')
7248
7287
  .action(async (opts) => {
7249
7288
  const { execSync } = require('child_process');
7250
7289
  const fs = require('fs');
@@ -7478,10 +7517,34 @@ program
7478
7517
  banner(`Cache disabled: contentHash computation failed: ${e.message}`);
7479
7518
  }
7480
7519
  }
7520
+ // Exit code — defense in depth:
7521
+ // (a) structured check against the parsed JSON, then
7522
+ // (b) loose substring check on the raw response (catches LLMs that
7523
+ // emit markdown-formatted CRITICAL findings without strict JSON).
7524
+ // HC-GF-407: one decision for a fresh review and a replayed one, so a cached CRITICAL still blocks.
7525
+ const blocksDeploy = (parsed, responseText) => {
7526
+ let hasCritical = false, doNotDeploy = false;
7527
+ if (parsed) {
7528
+ hasCritical = (parsed.summary?.critical || 0) > 0 ||
7529
+ (Array.isArray(parsed.findings) &&
7530
+ parsed.findings.some(f => String(f.severity).toUpperCase() === 'CRITICAL'));
7531
+ doNotDeploy = parsed.recommendation === 'DO_NOT_DEPLOY';
7532
+ }
7533
+ if (!hasCritical) {
7534
+ // Loose match — case-insensitive, no quote requirement. False-positive
7535
+ // tolerance is acceptable here because the cost of a false-positive is
7536
+ // "CI shows yellow, human looks at the report"; the cost of a false-
7537
+ // negative is "real CRITICAL bug ships to prod undetected".
7538
+ hasCritical = /\bCRITICAL\b/.test(responseText);
7539
+ }
7540
+ if (!doNotDeploy) {
7541
+ doNotDeploy = /\bDO_NOT_DEPLOY\b/.test(responseText);
7542
+ }
7543
+ return hasCritical || doNotDeploy;
7544
+ };
7481
7545
  if (cacheEnabled && cacheContentHash) {
7482
- const config = getConfig();
7483
- const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
7484
- const token = (config && config.token) || process.env.HONE_TOKEN;
7546
+ // HC-GF-407: the cache goes to the API every other command uses, with the same token.
7547
+ const { apiUrl: apiBase, token } = getConfig();
7485
7548
  const cached = await lookupReviewCache({
7486
7549
  axios,
7487
7550
  apiBase,
@@ -7523,6 +7586,15 @@ program
7523
7586
  ? cached.response
7524
7587
  : JSON.stringify(cached.response, null, 2));
7525
7588
  }
7589
+ // The store keeps the parsed review, or `{ raw }` when the model wrote no JSON; either way the
7590
+ // serialised replay carries the text the loose check reads.
7591
+ const replay = cached.response;
7592
+ const replayParsed = replay && typeof replay === 'object' ? replay : null;
7593
+ if (blocksDeploy(replayParsed, typeof replay === 'string' ? replay : JSON.stringify(replay))) {
7594
+ banner('');
7595
+ banner('CRITICAL issues found. Fix before deploying.');
7596
+ process.exit(1);
7597
+ }
7526
7598
  process.exit(0);
7527
7599
  }
7528
7600
  }
@@ -7634,9 +7706,8 @@ program
7634
7706
  // Total tokens billed for this run = inputTokens + outputTokens —
7635
7707
  // those are the tokens a future hit would save.
7636
7708
  if (cacheEnabled && cacheContentHash) {
7637
- const config = getConfig();
7638
- const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
7639
- const token = (config && config.token) || process.env.HONE_TOKEN;
7709
+ // HC-GF-407: the cache goes to the API every other command uses, with the same token.
7710
+ const { apiUrl: apiBase, token } = getConfig();
7640
7711
  // Cache the parsed JSON when available (cleaner replay), otherwise
7641
7712
  // wrap the raw text in { raw } so the cache always stores an object.
7642
7713
  const responseForCache = parsed && typeof parsed === 'object'
@@ -7657,28 +7728,8 @@ program
7657
7728
  }).catch(() => { /* logged inside helper */ });
7658
7729
  }
7659
7730
 
7660
- // 8. Exit code — defense in depth:
7661
- // (a) structured check against the parsed JSON, then
7662
- // (b) loose substring check on the raw response (catches LLMs that
7663
- // emit markdown-formatted CRITICAL findings without strict JSON).
7664
- let hasCritical = false, doNotDeploy = false;
7665
- if (parsed) {
7666
- hasCritical = (parsed.summary?.critical || 0) > 0 ||
7667
- (Array.isArray(parsed.findings) &&
7668
- parsed.findings.some(f => String(f.severity).toUpperCase() === 'CRITICAL'));
7669
- doNotDeploy = parsed.recommendation === 'DO_NOT_DEPLOY';
7670
- }
7671
- if (!hasCritical) {
7672
- // Loose match — case-insensitive, no quote requirement. False-positive
7673
- // tolerance is acceptable here because the cost of a false-positive is
7674
- // "CI shows yellow, human looks at the report"; the cost of a false-
7675
- // negative is "real CRITICAL bug ships to prod undetected".
7676
- hasCritical = /\bCRITICAL\b/.test(responseText);
7677
- }
7678
- if (!doNotDeploy) {
7679
- doNotDeploy = /\bDO_NOT_DEPLOY\b/.test(responseText);
7680
- }
7681
- if (hasCritical || doNotDeploy) {
7731
+ // 8. Exit code (blocksDeploy, above).
7732
+ if (blocksDeploy(parsed, responseText)) {
7682
7733
  banner('');
7683
7734
  banner('CRITICAL issues found. Fix before deploying.');
7684
7735
  process.exit(1);
@@ -7715,7 +7766,7 @@ program
7715
7766
  const showCmd = program.command('show').description('Inspect orchestrator step output (HC-019n-followup-6)');
7716
7767
  showCmd
7717
7768
  .command('step <workflowId> <stepKey>')
7718
- .description('Print the actual LLM output of an orchestrator step (latest attempt by default)')
7769
+ .description('Print a step\'s model output (the latest attempt; for step_0b, every attempt, newest first)')
7719
7770
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7720
7771
  .option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
7721
7772
  .action(async (workflowId, stepKey, opts) => {
@@ -7774,6 +7825,38 @@ showCmd
7774
7825
  console.log(`── output (the LLM's actual response) ──────────────────`);
7775
7826
  console.log(s.output || '(no output recorded — step may not have completed)');
7776
7827
  console.log(`────────────────────────────────────────────────────────`);
7828
+ // HC-GF-379-followup-1: step_0b lists every consult attempt, newest first. Only when no --attempt/--prompt was given
7829
+ // and the latest attempt is a safe integer > 1 (1e300 is an integer and `n--` never moves it); capped, because
7830
+ // isSafeInteger still admits 2^53. Every past attempt's error is caught HERE: the exit(1) below is for the latest.
7831
+ // The step response carries no failure_record (orchestrate.js SELECT), so no past attempt claims a verdict.
7832
+ const latest = s.attempt;
7833
+ if (stepKey === 'step_0b' && opts.attempt === undefined && !opts.prompt && Number.isSafeInteger(latest) && latest > 1) {
7834
+ const MAX_PAST_ATTEMPTS = 50;
7835
+ const oldest = Math.max(1, latest - MAX_PAST_ATTEMPTS);
7836
+ const findingLines = (text) => {
7837
+ const ls = String(text == null ? '' : text).split('\n');
7838
+ const at = ls.findIndex((l) => /^##\s+Findings\b/i.test(l));
7839
+ if (at < 0) return null;
7840
+ const end = ls.findIndex((l, i) => i > at && /^##\s/.test(l));
7841
+ return ls.slice(at + 1, end < 0 ? ls.length : end).map((l) => scrub(l).trim()).filter(Boolean);
7842
+ };
7843
+ console.log('');
7844
+ console.log('── earlier step_0b attempts, newest first ──────────────');
7845
+ for (let n = latest - 1; n >= oldest; n--) {
7846
+ try {
7847
+ const past = (await client.get(orchestratePath([workflowId, 'step', stepKey], { attempt: String(n) }))).data || {};
7848
+ console.log(`── attempt ${n} · ${scrub(past.status || 'unknown')} · verdict not recorded ──`);
7849
+ const f = findingLines(past.output);
7850
+ if (f === null) console.log(' (no Findings section)');
7851
+ else for (const l of f) console.log(` ${l}`);
7852
+ } catch (e) {
7853
+ const err = e.response?.data?.error ?? e.message;
7854
+ const code = e.response?.status ? ` (HTTP ${e.response.status})` : '';
7855
+ console.log(`── attempt ${n} · could not be fetched${code}: ${scrub(typeof err === 'string' ? err : JSON.stringify(err))}`);
7856
+ }
7857
+ }
7858
+ if (oldest > 1) console.log(` ${oldest - 1} older attempt(s) not shown — use --attempt <n>`);
7859
+ }
7777
7860
  } catch (e) {
7778
7861
  const msg = e.response?.data?.error || e.message;
7779
7862
  console.error(`hone show step failed: ${msg}`);
@@ -7969,6 +8052,8 @@ program
7969
8052
  // fail-open this gate exists to close.
7970
8053
  let runStoryId = null;
7971
8054
  let runFetchFailed = false;
8055
+ // HC-GF-390-followup-1: undefined = the server sent no `reviewDiff` key; `{stored}` or `{reason}` otherwise.
8056
+ let reviewDiffFetch;
7972
8057
  try {
7973
8058
  const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7974
8059
  const r = await client.get(url);
@@ -7992,6 +8077,22 @@ program
7992
8077
  runConfig = runR.data?.config || {};
7993
8078
  runStoryId = runR.data?.storyId || runR.data?.story_id || null;
7994
8079
  } catch (e) { runConfig = {}; runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; }
8080
+ // HC-GF-390-followup-1: the base diff the reviewers will be shown for this build. Fetched on its own
8081
+ // connection, for step_4 only; a server that does not know `?reviewDiff=1` omits the key and nothing is sent.
8082
+ // HC-GF-390-followup-2: and only under --report — the check exists to gate what reviewers are shown, and only
8083
+ // a posted check does that. Without it the fetch, the git work and a NEEDS YOU nobody hears about are skipped.
8084
+ if (opts.stepKey === 'step_4' && opts.report) {
8085
+ try {
8086
+ const rdR = await client.get(
8087
+ orchestratePath([workflowId, 'step', 'step_4'], { attempt: verifiedAttempt, reviewDiff: '1' }),
8088
+ require('./lib/fresh-connection').freshConnection());
8089
+ if (rdR.data && Object.prototype.hasOwnProperty.call(rdR.data, 'reviewDiff')) {
8090
+ if (rdR.data.runId && !sameRun(rdR.data.runId)) reviewDiffFetch = { reason: 'fetch_failed' };
8091
+ else if (rdR.data.attempt == null || Number(rdR.data.attempt) !== verifiedAttempt) reviewDiffFetch = { reason: 'attempt_mismatch' };
8092
+ else reviewDiffFetch = { stored: rdR.data.reviewDiff };
8093
+ }
8094
+ } catch { reviewDiffFetch = { reason: 'fetch_failed' }; }
8095
+ }
7995
8096
  } catch (e) {
7996
8097
  const msg = e.response?.data?.error || e.message;
7997
8098
  emit({ ok: false, reason: 'fetch_failed', detail: msg },
@@ -8106,6 +8207,21 @@ program
8106
8207
  let sandboxTeardown = [];
8107
8208
  const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
8108
8209
  try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
8210
+ // HC-GF-390-followup-1: does the stored base diff match THIS repo's HEAD? Reported with the verdict, read only by
8211
+ // the reviewers' context. It never changes this command's verdict or exit code.
8212
+ let reviewDiffCheck;
8213
+ if (reviewDiffFetch) {
8214
+ const { checkReviewDiff, toWireCheck } = require('./lib/review-diff-check');
8215
+ const r = reviewDiffFetch.reason
8216
+ ? { status: 'unverifiable', headSha: null, step: null, reason: reviewDiffFetch.reason, file: null }
8217
+ : checkReviewDiff({ stored: reviewDiffFetch.stored, appliedHead: headSha, repoRoot: process.cwd() });
8218
+ reviewDiffCheck = toWireCheck(r, verifiedAttempt);
8219
+ if (reviewDiffCheck.status === 'mismatch' && opts.format !== 'json') {
8220
+ console.log(` NEEDS YOU: the base diff stored for the reviewers does not match HEAD ${headSha || 'unknown'} `
8221
+ + `(${reviewDiffCheck.step || 'unknown step'}${reviewDiffCheck.file ? `, ${JSON.stringify(require('./lib/operator-question').scrub(reviewDiffCheck.file))}` : ''}); `
8222
+ + 'the reviewers will not be shown it.');
8223
+ }
8224
+ }
8109
8225
  try {
8110
8226
  execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
8111
8227
  { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
@@ -8325,7 +8441,9 @@ program
8325
8441
 
8326
8442
  emit(
8327
8443
  { ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
8328
- failureCount: v.failure ? v.failure.count : undefined, logPath: logPath || undefined },
8444
+ failureCount: v.failure ? v.failure.count : undefined, logPath: logPath || undefined,
8445
+ // HC-GF-390-followup-2: null, not absent, when no check ran, so the JSON shape does not depend on --report.
8446
+ reviewDiffCheck: reviewDiffCheck ?? null },
8329
8447
  pretty);
8330
8448
  exitCode = v.exitCode;
8331
8449
  reportVerdict = v.verdict; reportDetail = v.detail;
@@ -8412,6 +8530,7 @@ program
8412
8530
  attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
8413
8531
  headSha,
8414
8532
  gates: reportGates.length ? reportGates : undefined,
8533
+ reviewDiffCheck,
8415
8534
  }, require('./lib/fresh-connection').freshConnection());
8416
8535
  if (opts.format !== 'json') {
8417
8536
  // HC-GF-070: name the gate that is actually configured. This said "(advisory)"
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ /**
3
+ * admin-request.js — HC-GF-406. Request options for calls that carry the admin key.
4
+ *
5
+ * axios follows redirects through follow-redirects, which drops `Authorization`
6
+ * on a cross-host hop but forwards every custom header. `x-admin-key` is a
7
+ * custom header, so a host that answers 302/307 to another origin received the
8
+ * key (measured on b8c8008d, axios 1.20.0: GET and POST, both statuses).
9
+ *
10
+ * The admin routes never redirect, so following one is never needed: refuse
11
+ * every redirect, same-origin included, and tell the operator where it pointed
12
+ * so they can configure the final host. `server/src/workers/derive-worker.js`
13
+ * does the same for its authenticated fetch.
14
+ */
15
+
16
+ const ADMIN_TIMEOUT_MS = 15000;
17
+
18
+ function adminRequestOptions(adminKey, userAgent) {
19
+ return {
20
+ headers: { 'x-admin-key': adminKey, 'User-Agent': userAgent },
21
+ timeout: ADMIN_TIMEOUT_MS,
22
+ maxRedirects: 0, // never forward x-admin-key to wherever a redirect points
23
+ // Pinned so a 3xx always rejects into the caller's catch, even if axios's default changes.
24
+ validateStatus: (s) => s >= 200 && s < 300,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * For an axios error caused by a refused redirect, a message naming the status
30
+ * and the target ORIGIN only (a path or query could carry anything); else null.
31
+ */
32
+ function describeAdminRedirect(err) {
33
+ const status = err?.response?.status;
34
+ if (!(status >= 300 && status < 400)) return null;
35
+ const location = err.response.headers?.location;
36
+ let target = 'an unnamed location';
37
+ if (location) {
38
+ try {
39
+ const base = err.config?.url || undefined;
40
+ target = new URL(location, base).origin;
41
+ } catch {
42
+ target = 'an unparseable location';
43
+ }
44
+ }
45
+ return `The server answered ${status}, redirecting to ${target}. The admin key was not sent there. ` +
46
+ 'Point HONE_API (or `api` in ~/.honerc) at the final host and run the command again.';
47
+ }
48
+
49
+ module.exports = { adminRequestOptions, describeAdminRedirect, ADMIN_TIMEOUT_MS };
@@ -167,6 +167,39 @@ const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n
167
167
  /** A `DELETE: <path>` line. */
168
168
  const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
169
169
 
170
+ /**
171
+ * HC-GF-404: the [from, to) of every `file:` block FILE_BLOCK_RE matches, rejected or duplicate
172
+ * paths included, in ascending order. A whole file's content is data: a line-start `edit:` opener
173
+ * or `DELETE:` line inside one is an example the file holds, never a block of its own.
174
+ */
175
+ function fileBlockSpans(text) {
176
+ const spans = [];
177
+ for (const m of String(text == null ? '' : text).matchAll(FILE_BLOCK_RE)) {
178
+ spans.push({ from: m.index, to: m.index + m[0].length });
179
+ }
180
+ return spans;
181
+ }
182
+
183
+ /**
184
+ * HC-GF-404: every match of `re` over `text` that does not start inside one of `spans`
185
+ * (fileBlockSpans() of the same text). A match that starts inside a span is not dropped after the
186
+ * fact: the scan resumes at that span's end, because a phantom match can run past the span (a nested
187
+ * edit with a fence wider than the file's) and swallow a real block after it. `re` is cloned, so the
188
+ * shared /g regexes above are never advanced.
189
+ */
190
+ function* matchesOutside(re, text, spans) {
191
+ const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
192
+ const s = String(text == null ? '' : text);
193
+ let i = 0;
194
+ let m;
195
+ while ((m = r.exec(s)) !== null) {
196
+ while (i < spans.length && spans[i].to <= m.index) i++;
197
+ if (i < spans.length && m.index >= spans[i].from) { r.lastIndex = spans[i].to; continue; }
198
+ if (m[0].length === 0) r.lastIndex++;
199
+ yield m;
200
+ }
201
+ }
202
+
170
203
  module.exports = {
171
204
  fenceWidth,
172
205
  closesFence,
@@ -178,4 +211,6 @@ module.exports = {
178
211
  EDIT_OPEN_RE,
179
212
  EDIT_SPLIT_RE,
180
213
  DELETE_RE,
214
+ fileBlockSpans,
215
+ matchesOutside,
181
216
  };
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  const path = require('path');
38
+ const { pathRefusal } = require('./path-refusal');
38
39
 
39
40
  /**
40
41
  * Repo-level files worth bundling regardless of what the story says. Deliberately
@@ -50,18 +51,12 @@ const SIGNAL_FILES = Object.freeze([
50
51
  ]);
51
52
 
52
53
  /**
53
- * A path that must never reach an agent prompt. Matched on the BASENAME so a nested
54
- * `config/.env` is caught too. The `.example`/`.sample`/`.template` suffixes are the
55
- * documented exception and are handled by testing them first.
54
+ * Is this path a credential file that must never be sent as whole content? `.env.example` is not;
55
+ * `.env.production` and `keys/deploy.key` are. HC-GF-398: the patterns live in `./path-refusal`
56
+ * (egress mode); this name stays for the callers that still use it.
56
57
  */
57
- const SECRET_BASENAMES = [/^\.env($|\.)/i, /^secrets?\./i, /\.pem$/i, /\.p12$/i, /(^|[._-])id_(rsa|dsa|ecdsa|ed25519)$/i];
58
- const SECRET_EXEMPT = /\.(example|sample|template|dist)$/i;
59
-
60
- /** Is this path a credential file? `.env.example` is not; `.env.production` is. */
61
58
  function isSecretPath(p) {
62
- const base = path.posix.basename(String(p || '').replace(/\\/g, '/'));
63
- if (SECRET_EXEMPT.test(base)) return false;
64
- return SECRET_BASENAMES.some((re) => re.test(base));
59
+ return pathRefusal(p, { mode: 'egress' }) !== null;
65
60
  }
66
61
 
67
62
  /**
@@ -146,7 +141,7 @@ function nearestPopulatedAncestor(pendingPath, repoFiles) {
146
141
  function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSiblings = 6 } = {}) {
147
142
  const present = [], pending = [], refused = [];
148
143
  for (const p of named) {
149
- if (isSecretPath(p)) { refused.push(p); continue; }
144
+ if (pathRefusal(p, { mode: 'egress' })) { refused.push(p); continue; }
150
145
  (exists(p) ? present : pending).push(p);
151
146
  }
152
147
 
@@ -168,7 +163,7 @@ function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSib
168
163
  (path.posix.extname(b) === ext) - (path.posix.extname(a) === ext));
169
164
  for (const k of ranked) {
170
165
  if (siblings.length >= maxSiblings) break;
171
- if (taken.has(k) || isSecretPath(k) || isGeneratedPath(k)) continue;
166
+ if (taken.has(k) || pathRefusal(k, { mode: 'egress' }) || isGeneratedPath(k)) continue;
172
167
  taken.add(k); siblings.push(k);
173
168
  }
174
169
  }
package/lib/call-sites.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - A search that did not run is 'not_searched', never zero callers. `git grep` exits 1 for "no match" and 128
18
18
  * for a fatal error; only the first is an answer. A timeout, a missing git and an output overflow are
19
19
  * classified failures, not empty results.
20
- * - A line from a hidden or credential-looking path is never sent (cli/lib/context-reserve.js contentRefusal,
20
+ * - A line from a hidden or credential-looking path is never sent (cli/lib/path-refusal.js in content mode,
21
21
  * the rule a re-serve uses); it is counted, so the planner knows callers exist that it cannot see. The check
22
22
  * is made on the disk as it is now: `git grep` follows a DIRECTORY that was replaced by a symlink after the
23
23
  * commit (measured with git 2.50.1: a tracked `d/secret.txt` read from outside the checkout), so a path that
@@ -65,7 +65,8 @@
65
65
  const { execFile } = require('node:child_process');
66
66
  const path = require('node:path');
67
67
  const { gitEnv } = require('./git-env');
68
- const { contentRefusal, repoRealPath } = require('./context-reserve');
68
+ const { repoRealPath } = require('./context-reserve');
69
+ const { pathRefusal } = require('./path-refusal');
69
70
 
70
71
  const CALL_SITES_VERSION = 1;
71
72
  /** The only scope id today; the server writes its own description of it. */
@@ -387,7 +388,7 @@ function classifyPath(rel, realPath) {
387
388
  const ext = path.posix.extname(rel).slice(1).toLowerCase();
388
389
  if (PROSE_EXTENSIONS.includes(ext)) return 'prose';
389
390
  if (rel.length > MAX_PATH_CHARS || /[\x00-\x1f\x7f]/.test(rel)) return 'withheld';
390
- if (contentRefusal(rel)) return 'withheld';
391
+ if (pathRefusal(rel, { mode: 'content' })) return 'withheld';
391
392
  // A directory above the file may have been replaced by a link since the commit; git reads through it.
392
393
  let real = null;
393
394
  try { real = realPath(rel); } catch { real = null; }