@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 +7 -0
- package/hone-cli.js +191 -55
- package/lib/admin-request.js +49 -0
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +23 -7
- package/lib/block-grammar.js +35 -0
- package/lib/bundle-context.js +7 -12
- package/lib/bundle-limits.js +10 -0
- package/lib/call-sites.js +213 -29
- package/lib/context-reserve.js +13 -26
- 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/operator-question.js +9 -4
- package/lib/path-refusal.js +79 -0
- package/lib/review-diff-check.js +262 -0
- package/lib/review-diff.js +188 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +165 -20
- package/lib/tree-artifact.js +440 -55
- 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,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>', '
|
|
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
|
-
|
|
7466
|
-
const apiBase = (
|
|
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
|
-
|
|
7621
|
-
const apiBase = (
|
|
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
|
|
7644
|
-
|
|
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
|
|
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 };
|
package/lib/architect-config.js
CHANGED
|
@@ -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');
|
package/lib/batch-follow.js
CHANGED
|
@@ -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
|
|
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}
|
|
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') {
|