@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 +7 -0
- package/hone-cli.js +169 -50
- package/lib/admin-request.js +49 -0
- package/lib/block-grammar.js +35 -0
- package/lib/bundle-context.js +7 -12
- package/lib/call-sites.js +4 -3
- package/lib/context-reserve.js +2 -18
- package/lib/default-api.js +109 -0
- package/lib/editor-park.js +8 -2
- package/lib/materialize-diff.js +32 -13
- package/lib/mcp-tools.js +17 -5
- package/lib/path-refusal.js +79 -0
- package/lib/review-diff-check.js +262 -0
- package/lib/review-diff.js +2 -2
- package/lib/run-report.js +75 -20
- package/lib/tree-artifact.js +425 -73
- package/package.json +1 -1
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
|
-
|
|
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>',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ->
|
|
3106
|
-
// usable message when no token is configured. Re-deriving
|
|
3107
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>', '
|
|
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
|
-
|
|
7483
|
-
const apiBase = (
|
|
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
|
-
|
|
7638
|
-
const apiBase = (
|
|
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
|
|
7661
|
-
|
|
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
|
|
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 };
|
package/lib/block-grammar.js
CHANGED
|
@@ -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
|
};
|
package/lib/bundle-context.js
CHANGED
|
@@ -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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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) ||
|
|
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/
|
|
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 {
|
|
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 (
|
|
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; }
|