@hone-ai/cli 1.40.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,26 +6057,62 @@ 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;
6068
+ let reviewDiff = null;
6069
+ let warned = false;
6041
6070
  if (opts.fromTree) {
6042
6071
  const { renderFromTree } = require('./lib/tree-artifact');
6043
- const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
6072
+ const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport, contract: String(opts.contract), stepKey });
6044
6073
  output = rendered.text;
6045
6074
  // Say what was left out BEFORE the submission, not after: a file the tree has and the
6046
6075
  // artifact does not is a decision the operator has to be able to see.
6047
6076
  for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
6048
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
+ }
6083
+ // HC-GF-390: for a build, the base diff of each file emitted whole whose original the reviewers saw only in
6084
+ // windows. Sent beside the artifact, stored apart from it, and never a reason this submit fails.
6085
+ if (stepKey === 'step_4') {
6086
+ try {
6087
+ const { computeReviewDiff } = require('./lib/review-diff');
6088
+ reviewDiff = computeReviewDiff({ repoRoot: process.cwd(), rendered,
6089
+ warn: (line) => { warned = true; console.error(line); } });
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
+ }
6098
+ if (reviewDiff) {
6099
+ console.error(`review diff: ${reviewDiff.stats.files} file(s), ${reviewDiff.notes.length} note(s), against ${reviewDiff.headSha.slice(0, 12)}`);
6100
+ for (const n of reviewDiff.notes) console.error(` review diff note: ${n}`);
6101
+ } else {
6102
+ console.error(warned ? 'review diff: none (see above)' : 'review diff: none (no modified file over the bundle cap)');
6103
+ }
6104
+ }
6049
6105
  } else {
6050
6106
  output = fs.readFileSync(opts.file, 'utf8');
6051
6107
  }
6052
6108
  const { data } = await client.post(
6053
6109
  orchestratePath([runId, 'step', stepKey, 'editor-output']),
6054
- { contractVersion: opts.contract, output }
6110
+ { contractVersion: opts.contract, output,
6111
+ ...(reviewDiff ? { reviewDiff: require('./lib/review-diff').toWire(reviewDiff) } : {}) }
6055
6112
  );
6056
6113
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6057
6114
  console.log(`Step ${data.stepKey} completed (executed_by: ${data.executedBy}); gate: ${data.gateResult}.`);
6115
+ if (data.reviewDiffIgnored) console.error(`review diff not stored: ${data.reviewDiffIgnored} (the reviewers see the files unverified, as before)`);
6058
6116
  console.log(`Approve when reviewed: hone run-story ${runId} --approve ${data.stepKey}`);
6059
6117
  console.log('Verification is unchanged — check-patch/verify-patch judge this output, not trust.');
6060
6118
  } catch (e) {
@@ -6107,10 +6165,6 @@ function runContextIo(apiConfig) {
6107
6165
  e2eSpecMode: () => pc.readE2eSpecMode(cwd),
6108
6166
  verificationGate: () => pc.readVerificationGateConfig(cwd),
6109
6167
  verificationGateSources: () => pc.readVerificationGateSources(cwd),
6110
- readPlanText: () => {
6111
- const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
6112
- return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
6113
- },
6114
6168
  exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
6115
6169
  isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
6116
6170
  readFile: (rel) => {
@@ -6200,6 +6254,7 @@ program
6200
6254
  .option('--include-paths <paths>', 'Source file paths to bundle into the codebase context (HC-019n-followup-12). Comma-separated, and the flag may be repeated; both forms accumulate. Augments auto-detected paths from the issue body.', collectIncludePaths, [])
6201
6255
  .option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
6202
6256
  .option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
6257
+ .option('--consult-architect', 'HC-GF-397: run the architect consult (step_0b) even when the groomer says architect_engaged: false. It can only add the consult, never skip it.')
6203
6258
  .action(async (storyIdOrRunId, opts) => {
6204
6259
  const config = getConfig();
6205
6260
  const client = api(config);
@@ -6678,6 +6733,7 @@ program
6678
6733
  explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
6679
6734
  includePaths: opts.includePaths,
6680
6735
  axes: opts.axes,
6736
+ consultArchitect: opts.consultArchitect,
6681
6737
  });
6682
6738
  printRunContextNotes(storyCtx.notes.pre);
6683
6739
  printRunContextNotes(repoCtx.notes);
@@ -7227,7 +7283,7 @@ program
7227
7283
  .option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
7228
7284
  .option('--max-files <n>', 'Max source files to include in review', '40')
7229
7285
  .option('--provider <name>', 'LLM provider: opus (paid, needs ANTHROPIC_API_KEY). gh-models was retired 2026-07-30; there is no default')
7230
- .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')
7231
7287
  .action(async (opts) => {
7232
7288
  const { execSync } = require('child_process');
7233
7289
  const fs = require('fs');
@@ -7461,10 +7517,34 @@ program
7461
7517
  banner(`Cache disabled: contentHash computation failed: ${e.message}`);
7462
7518
  }
7463
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
+ };
7464
7545
  if (cacheEnabled && cacheContentHash) {
7465
- const config = getConfig();
7466
- const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
7467
- 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();
7468
7548
  const cached = await lookupReviewCache({
7469
7549
  axios,
7470
7550
  apiBase,
@@ -7506,6 +7586,15 @@ program
7506
7586
  ? cached.response
7507
7587
  : JSON.stringify(cached.response, null, 2));
7508
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
+ }
7509
7598
  process.exit(0);
7510
7599
  }
7511
7600
  }
@@ -7617,9 +7706,8 @@ program
7617
7706
  // Total tokens billed for this run = inputTokens + outputTokens —
7618
7707
  // those are the tokens a future hit would save.
7619
7708
  if (cacheEnabled && cacheContentHash) {
7620
- const config = getConfig();
7621
- const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
7622
- 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();
7623
7711
  // Cache the parsed JSON when available (cleaner replay), otherwise
7624
7712
  // wrap the raw text in { raw } so the cache always stores an object.
7625
7713
  const responseForCache = parsed && typeof parsed === 'object'
@@ -7640,28 +7728,8 @@ program
7640
7728
  }).catch(() => { /* logged inside helper */ });
7641
7729
  }
7642
7730
 
7643
- // 8. Exit code — defense in depth:
7644
- // (a) structured check against the parsed JSON, then
7645
- // (b) loose substring check on the raw response (catches LLMs that
7646
- // emit markdown-formatted CRITICAL findings without strict JSON).
7647
- let hasCritical = false, doNotDeploy = false;
7648
- if (parsed) {
7649
- hasCritical = (parsed.summary?.critical || 0) > 0 ||
7650
- (Array.isArray(parsed.findings) &&
7651
- parsed.findings.some(f => String(f.severity).toUpperCase() === 'CRITICAL'));
7652
- doNotDeploy = parsed.recommendation === 'DO_NOT_DEPLOY';
7653
- }
7654
- if (!hasCritical) {
7655
- // Loose match — case-insensitive, no quote requirement. False-positive
7656
- // tolerance is acceptable here because the cost of a false-positive is
7657
- // "CI shows yellow, human looks at the report"; the cost of a false-
7658
- // negative is "real CRITICAL bug ships to prod undetected".
7659
- hasCritical = /\bCRITICAL\b/.test(responseText);
7660
- }
7661
- if (!doNotDeploy) {
7662
- doNotDeploy = /\bDO_NOT_DEPLOY\b/.test(responseText);
7663
- }
7664
- if (hasCritical || doNotDeploy) {
7731
+ // 8. Exit code (blocksDeploy, above).
7732
+ if (blocksDeploy(parsed, responseText)) {
7665
7733
  banner('');
7666
7734
  banner('CRITICAL issues found. Fix before deploying.');
7667
7735
  process.exit(1);
@@ -7698,7 +7766,7 @@ program
7698
7766
  const showCmd = program.command('show').description('Inspect orchestrator step output (HC-019n-followup-6)');
7699
7767
  showCmd
7700
7768
  .command('step <workflowId> <stepKey>')
7701
- .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)')
7702
7770
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7703
7771
  .option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
7704
7772
  .action(async (workflowId, stepKey, opts) => {
@@ -7757,6 +7825,38 @@ showCmd
7757
7825
  console.log(`── output (the LLM's actual response) ──────────────────`);
7758
7826
  console.log(s.output || '(no output recorded — step may not have completed)');
7759
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
+ }
7760
7860
  } catch (e) {
7761
7861
  const msg = e.response?.data?.error || e.message;
7762
7862
  console.error(`hone show step failed: ${msg}`);
@@ -7952,6 +8052,8 @@ program
7952
8052
  // fail-open this gate exists to close.
7953
8053
  let runStoryId = null;
7954
8054
  let runFetchFailed = false;
8055
+ // HC-GF-390-followup-1: undefined = the server sent no `reviewDiff` key; `{stored}` or `{reason}` otherwise.
8056
+ let reviewDiffFetch;
7955
8057
  try {
7956
8058
  const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7957
8059
  const r = await client.get(url);
@@ -7975,6 +8077,22 @@ program
7975
8077
  runConfig = runR.data?.config || {};
7976
8078
  runStoryId = runR.data?.storyId || runR.data?.story_id || null;
7977
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
+ }
7978
8096
  } catch (e) {
7979
8097
  const msg = e.response?.data?.error || e.message;
7980
8098
  emit({ ok: false, reason: 'fetch_failed', detail: msg },
@@ -8089,6 +8207,21 @@ program
8089
8207
  let sandboxTeardown = [];
8090
8208
  const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
8091
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
+ }
8092
8225
  try {
8093
8226
  execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
8094
8227
  { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
@@ -8308,7 +8441,9 @@ program
8308
8441
 
8309
8442
  emit(
8310
8443
  { ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
8311
- 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 },
8312
8447
  pretty);
8313
8448
  exitCode = v.exitCode;
8314
8449
  reportVerdict = v.verdict; reportDetail = v.detail;
@@ -8395,6 +8530,7 @@ program
8395
8530
  attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
8396
8531
  headSha,
8397
8532
  gates: reportGates.length ? reportGates : undefined,
8533
+ reviewDiffCheck,
8398
8534
  }, require('./lib/fresh-connection').freshConnection());
8399
8535
  if (opts.format !== 'json') {
8400
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 };
@@ -20,6 +20,11 @@
20
20
  *
21
21
  * Pure helper — caller wraps fs.readFileSync + path resolution so the
22
22
  * helper is unit-testable with stubs.
23
+ *
24
+ * HC-GF-030: NO PRODUCTION CODE CALLS THIS ANY MORE. `architect_consulted` and `checklist_b_completed`
25
+ * are server-owned (workflow-store.js SERVER_OWNED_CONFIG_KEYS; run-context.js SERVER_OWNED_KEYS): the
26
+ * server writes them when its step_0b consult is approved, and a plan file's claim is not evidence that
27
+ * a consult ran on THIS story. The module stays as the documented reader of the plan's shape.
23
28
  */
24
29
 
25
30
  const path = require('node:path');
@@ -59,9 +59,11 @@ const FORBIDDEN_FLAGS = Object.freeze(['--trust-source', '--skip-verify', '--rea
59
59
  * Pause reasons that mean a child is NOT at a gate waiting for its step_4 output to be judged. `GET /:runId`
60
60
  * sets `awaitingApproval` on ANY paused run with a current step, so without this a child paused by a breaker,
61
61
  * a budget, an agent's hard pause or a guardrail would look like a gate, and the loop would run its patch.
62
- * Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan.
62
+ * Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan, and
63
+ * architect-consult.js CONSULT_REQUIRED_CODE (HC-GF-030): a run the consult guard refused at a step it had no
64
+ * consult for. Its attempt is `pending` and has no output, and approving it is refused by the server.
63
65
  */
64
- const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|hard_pause at )/;
66
+ const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|ARCHITECT_CONSULT_REQUIRED:|hard_pause at )/;
65
67
 
66
68
  /** What each child exit code means, for the action line. verify-patch.js and emit-pr.js EXIT maps. */
67
69
  const CHECK_EXIT = { 0: 'applies', 1: 'no diff', 2: 'does not apply' };
@@ -393,6 +395,9 @@ function priorWhy(prior, runId) {
393
395
  if (prior.state === 'adopted') {
394
396
  return `a newer run of this story already exists${b ? ` (batch ${b})` : ''}, queued by an earlier re-serve or by you, so it is not re-served again`;
395
397
  }
398
+ if (prior.state === 'inherited') {
399
+ return `this run is already the re-serve of ${prior.fromRunId ? `run ${prior.fromRunId}` : 'an earlier run'}${b ? ` (it is in batch ${b})` : ''}, so it is not re-served a second time`;
400
+ }
396
401
  if (prior.state === 'queue-failed') return 're-serving it failed earlier in this run; nothing more is tried';
397
402
  return `it names files again after being re-served as batch ${b}; a second re-serve is not tried`;
398
403
  }
@@ -450,7 +455,7 @@ function stuckReason(waiting) {
450
455
  * @param {Record<string, object>} details runId → `GET /orchestrate/:runId`, for paused runs and runs to emit
451
456
  * @param {{emitted: Set<string>, reserved?: Map<string, {batchId: string|null, state: string}>}} memory `emitted`: runs
452
457
  * whose emit-pr already ran in this process; `reserved`: story → what became of its re-serve (`state`: served,
453
- * kill-failed, queue-failed or adopted)
458
+ * kill-failed, queue-failed, adopted or inherited)
454
459
  * @param {{repoName?: string|null, openPr?: boolean, tracked?: Set<string>|null, trackedRetry?: string|null,
455
460
  * realPath?: ((file: string) => string|null)|null}} [ctx] this checkout, so a run for another repository is
456
461
  * not touched; `tracked`: the regular files git tracks here, which a request is checked against (`trackedRetry`:
@@ -594,7 +599,7 @@ function planTick(batch, details, memory, ctx = {}) {
594
599
  * crash between the queue and the kill, leaves no marker, and only a newer run of the same story shows it happened.
595
600
  * @returns {Promise<{lines: string[], needsYou: boolean, batchId: string|null, state: string|null, retry?: boolean}>}
596
601
  * `batchId`: the batch the story was queued as, set whenever it was, even when the kill then failed; `state`: what
597
- * became of the re-serve (served, kill-failed, queue-failed, adopted); `retry`: nothing was decided, ask again
602
+ * became of the re-serve (served, kill-failed, queue-failed, adopted = a newer run exists, inherited = this run is itself the re-serve); `retry`: nothing was decided, ask again
598
603
  */
599
604
  async function reserve(action, io, tag) {
600
605
  if (io.priorServe) {
@@ -606,12 +611,16 @@ async function reserve(action, io, tag) {
606
611
  needsYou: false, batchId: null, state: null, retry: true };
607
612
  }
608
613
  if (prior && prior.kind === 'unknown') {
609
- return { lines: [`${tag} not re-served: could not tell whether the story was served already; the paused run was left alone`],
614
+ return { lines: [`${tag} not re-served: could not tell whether the story was served already${prior.why ? ` (${prior.why})` : ''}; the paused run was left alone`],
610
615
  needsYou: true, batchId: null, state: 'queue-failed' };
611
616
  }
617
+ if (prior && prior.kind === 'earlier') {
618
+ return { lines: [`${tag} not re-served: this run is already the re-serve of run ${prior.runId} (it is in batch ${prior.batchId}), so it is not re-served a second time`],
619
+ needsYou: true, batchId: prior.batchId || null, state: 'inherited', fromRunId: prior.runId };
620
+ }
612
621
  if (prior) {
613
622
  const where = prior.batchId ? ` in batch ${prior.batchId}` : '';
614
- return { lines: [`${tag} not re-served: run ${prior.runId}${where} is this story's ${prior.kind === 'later' ? 'newer' : 'earlier, re-served'} run; following it`],
623
+ return { lines: [`${tag} not re-served: a newer run of this story exists (run ${prior.runId}${where})${prior.batchId ? '; following it' : '; its batch was not found, so it is not followed'}`],
615
624
  needsYou: true, batchId: prior.batchId || null, state: 'adopted' };
616
625
  }
617
626
  }
@@ -695,6 +704,13 @@ async function autoReject(action, io, tag) {
695
704
  const status = statusOf(e);
696
705
  const f = fatal(e, `rejecting ${where}`);
697
706
  if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
707
+ // HC-GF-323-followup-2: the server caps rejects at step_4's max_retries_per_step, counting a person's rejects too, so
708
+ // its refusal can come before this client's own bound. That 409 is not "the run moved on": the run is still paused
709
+ // at the failed attempt, and only a person can approve, kill or raise the guardrail.
710
+ if (status === 409 && e.response.data && e.response.data.code === 'REJECT_RETRIES_EXHAUSTED') {
711
+ const note = `${where} failed verification and the server refused the reject: ${oneLine(e.response.data.error, 160)}`;
712
+ return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
713
+ }
698
714
  if (status === 409) return { lines: [`${tag} ${where} was not rejected: the run moved on (409); this does not count`], needsYou: false, state: 'moved' };
699
715
  if (status === 400) {
700
716
  const note = `${where} failed verification and the server refused the reject (400: ${oneLine(e.response.data && e.response.data.error, 120)})`;
@@ -889,7 +905,7 @@ async function followBatch(batchId, io, opts = {}) {
889
905
  }
890
906
  if (action.kind === 'reserve') {
891
907
  if (!out.retry) {
892
- memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state });
908
+ memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state, fromRunId: out.fromRunId });
893
909
  if (out.batchId && !followed.includes(out.batchId)) { followed.push(out.batchId); grew = true; }
894
910
  }
895
911
  } else if (action.kind === 'emit' || action.kind === 'draft') {