@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* review-diff-check.js — HC-GF-390-followup-1: does the stored review diff describe HEAD?
|
|
4
|
+
*
|
|
5
|
+
* HC-GF-390 stores, beside step_4's artifact, the CLI-reported diff against HEAD of each over-cap file the build
|
|
6
|
+
* changed, and the reviewers read "a region with no hunk is unchanged from HEAD". The server can vet a section's
|
|
7
|
+
* post-image against the stored build, but it has no repository, so it cannot vet the pre-image, the hunk offsets or
|
|
8
|
+
* the context lines. `hone verify-patch` has the clone: this re-derives each stored section from HEAD and reports
|
|
9
|
+
* whether it holds. The server renders a mismatch as withheld, so a forged region is never shown as "unchanged".
|
|
10
|
+
*
|
|
11
|
+
* Per section, stopping at the first failure:
|
|
12
|
+
* parse the strict grammar `git diff --full-index` prints for a text modification, and nothing else
|
|
13
|
+
* pre_image the `index` pre-image id is HEAD's blob for that path
|
|
14
|
+
* offset every hunk's old lines are HEAD's lines at its stated start, and every new start is what git computes
|
|
15
|
+
* (`git apply` would accept a hunk at a nearby offset, and a wrong `+c` it never reads)
|
|
16
|
+
* apply the section applies with no fuzz to HEAD, on a private index
|
|
17
|
+
* post_image the result's blob id is the `index` post-image id
|
|
18
|
+
*
|
|
19
|
+
* It never throws and never touches the real index or worktree. Exit modes of what it consumes (HC-GF-117):
|
|
20
|
+
* stored == null HANDLED: absent (the server has no review diff for that attempt).
|
|
21
|
+
* head sha malformed HANDLED: unverifiable/invalid_head.
|
|
22
|
+
* head not in this clone HANDLED: unverifiable/head_missing (`cat-file -e` exits 128).
|
|
23
|
+
* HEAD moved since submit HANDLED: unverifiable/head_moved — the diff describes another commit.
|
|
24
|
+
* no sections HANDLED: unverifiable/no_sections — nothing was checked, so nothing matched.
|
|
25
|
+
* git killed / timed out HANDLED: unverifiable/timeout. Deadline spent: unverifiable/deadline.
|
|
26
|
+
* git spawn / other throw HANDLED: unverifiable/git_error.
|
|
27
|
+
* child killed by SIGINT HANDLED: the temp dir is removed, then SIGINT is re-raised on this process. The git
|
|
28
|
+
* calls are synchronous, so no JS signal handler could run during them; the child's
|
|
29
|
+
* death is how an operator's Ctrl-C reaches this code.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { execFileSync } = require('node:child_process');
|
|
36
|
+
const { gitEnv } = require('./git-env');
|
|
37
|
+
|
|
38
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
39
|
+
const PER_CALL_MS = 15_000;
|
|
40
|
+
const DEADLINE_MS = 60_000;
|
|
41
|
+
const MAX_FILE_CHARS = 300;
|
|
42
|
+
const TMP_PREFIX = 'hone-rdc-';
|
|
43
|
+
// What a check can report. The server keeps only these (server/src/services/review-diff.js CHECK_STEPS/CHECK_REASONS,
|
|
44
|
+
// pinned equal by a regression test). `fetch_failed` and `attempt_mismatch` are set by verify-patch in hone-cli.js.
|
|
45
|
+
const STEPS = Object.freeze(['parse', 'pre_image', 'offset', 'apply', 'post_image']);
|
|
46
|
+
const REASONS = Object.freeze(['ok', 'invalid_head', 'head_missing', 'head_moved', 'no_sections', 'timeout', 'git_error',
|
|
47
|
+
'fetch_failed', 'attempt_mismatch', 'deadline', 'context_mismatch', 'new_start_mismatch']);
|
|
48
|
+
|
|
49
|
+
const INDEX_RE = /^index ([0-9a-f]{40}|[0-9a-f]{64})\.\.([0-9a-f]{40}|[0-9a-f]{64})(?: ([0-7]{6}))?$/;
|
|
50
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/;
|
|
51
|
+
const NO_NEWLINE = '\';
|
|
52
|
+
|
|
53
|
+
/** `diff --git a/<rel> b/<rel>` with the same <rel> twice, or null. A quoted path is not a plain path. */
|
|
54
|
+
function relOfDiffGit(line) {
|
|
55
|
+
const PREFIX = 'diff --git a/';
|
|
56
|
+
if (!line.startsWith(PREFIX)) return null;
|
|
57
|
+
const rest = line.slice(PREFIX.length);
|
|
58
|
+
if ((rest.length - 3) % 2 !== 0 || rest.length < 5) return null;
|
|
59
|
+
const n = (rest.length - 3) / 2;
|
|
60
|
+
const a = rest.slice(0, n);
|
|
61
|
+
if (rest.slice(n, n + 3) !== ' b/' || rest.slice(n + 3) !== a || a.startsWith('"')) return null;
|
|
62
|
+
return a;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One section of `git diff --full-index` for a text modification. Every line must be accounted for: a mode, rename or
|
|
67
|
+
* copy header, a line beyond a hunk's counts, or a hunk out of order is a parse error, because `git apply` would read
|
|
68
|
+
* some of them and a reviewer would read all of them.
|
|
69
|
+
*/
|
|
70
|
+
function parseSection(text) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
73
|
+
const rel = relOfDiffGit(lines[0] || '');
|
|
74
|
+
const out = { rel, pre: null, post: null, mode: null, hunks: [], text, error: null };
|
|
75
|
+
const fail = (why) => ({ ...out, error: why });
|
|
76
|
+
if (rel === null) return fail('not a plain `diff --git a/<path> b/<path>` line');
|
|
77
|
+
const idx = INDEX_RE.exec(lines[1] || '');
|
|
78
|
+
if (!idx) return fail('line 2 is not a full-index `index` line');
|
|
79
|
+
out.pre = idx[1]; out.post = idx[2]; out.mode = idx[3] || null;
|
|
80
|
+
// git ends these two lines with a TAB when the path contains a space (diff.c, so a patch tool can find the name).
|
|
81
|
+
const tab = rel.includes(' ') ? '\t' : '';
|
|
82
|
+
if (lines[2] !== `--- a/${rel}${tab}`) return fail('line 3 is not `--- a/<path>`');
|
|
83
|
+
if (lines[3] !== `+++ b/${rel}${tab}`) return fail('line 4 is not `+++ b/<path>`');
|
|
84
|
+
let i = 4;
|
|
85
|
+
let prevEnd = 0;
|
|
86
|
+
while (i < lines.length) {
|
|
87
|
+
const h = HUNK_RE.exec(lines[i]);
|
|
88
|
+
if (!h) return fail(`line ${i + 1} is not a hunk header`);
|
|
89
|
+
const a = Number(h[1]); const b = h[2] === undefined ? 1 : Number(h[2]);
|
|
90
|
+
const c = Number(h[3]); const d = h[4] === undefined ? 1 : Number(h[4]);
|
|
91
|
+
if (b === 0 && d === 0) return fail(`line ${i + 1}: an empty hunk`);
|
|
92
|
+
const oldBegin = b === 0 ? a : a - 1;
|
|
93
|
+
if (oldBegin < 0 || oldBegin < prevEnd) return fail(`line ${i + 1}: hunks overlap or are out of order`);
|
|
94
|
+
prevEnd = oldBegin + b;
|
|
95
|
+
i += 1;
|
|
96
|
+
const oldLines = []; const newLines = [];
|
|
97
|
+
let ob = 0; let nd = 0;
|
|
98
|
+
while (ob < b || nd < d) {
|
|
99
|
+
const l = lines[i];
|
|
100
|
+
if (l === undefined) return fail('a hunk ends before its counts');
|
|
101
|
+
const k = l[0];
|
|
102
|
+
if (k === ' ') { oldLines.push(l.slice(1)); newLines.push(l.slice(1)); ob += 1; nd += 1; }
|
|
103
|
+
else if (k === '-') { oldLines.push(l.slice(1)); ob += 1; }
|
|
104
|
+
else if (k === '+') { newLines.push(l.slice(1)); nd += 1; }
|
|
105
|
+
else return fail(`line ${i + 1} is not a hunk line`);
|
|
106
|
+
if (ob > b || nd > d) return fail(`line ${i + 1} is beyond its hunk's counts`);
|
|
107
|
+
i += 1;
|
|
108
|
+
if (lines[i] === NO_NEWLINE) {
|
|
109
|
+
if (k !== '+') oldLines.noNewlineAt = oldLines.length - 1;
|
|
110
|
+
if (k !== '-') newLines.noNewlineAt = newLines.length - 1;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out.hunks.push({ a, b, c, d, oldBegin, oldLines, newLines });
|
|
115
|
+
}
|
|
116
|
+
if (!out.hunks.length) return fail('a section with no hunk');
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Split a stored review diff on `diff --git`. Text before the first section is a section with an error. */
|
|
121
|
+
function parseSections(text) {
|
|
122
|
+
const t = String(text || '');
|
|
123
|
+
if (!t.trim()) return [];
|
|
124
|
+
const starts = [];
|
|
125
|
+
for (const m of t.matchAll(/^diff --git /gm)) starts.push(m.index);
|
|
126
|
+
const sections = [];
|
|
127
|
+
const head = t.slice(0, starts.length ? starts[0] : t.length);
|
|
128
|
+
if (head.trim()) {
|
|
129
|
+
sections.push({ rel: null, pre: null, post: null, mode: null, hunks: [], text: head,
|
|
130
|
+
error: 'text outside a diff section' });
|
|
131
|
+
}
|
|
132
|
+
for (let i = 0; i < starts.length; i++) {
|
|
133
|
+
sections.push(parseSection(t.slice(starts[i], i + 1 < starts.length ? starts[i + 1] : t.length)));
|
|
134
|
+
}
|
|
135
|
+
return sections;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** HEAD's lines, each with its own newline (the last may lack one). */
|
|
139
|
+
function linesOf(blob) { return String(blob).match(/[^\n]*\n|[^\n]+$/g) || []; }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The offsets git apply does not check. Old lines must be HEAD's lines at the stated start, byte for byte (newline
|
|
143
|
+
* included), and each new start must be what git computes from the old start and the hunks before it.
|
|
144
|
+
* @returns {null | 'context_mismatch' | 'new_start_mismatch'}
|
|
145
|
+
*/
|
|
146
|
+
function checkOffsets(section, blob) {
|
|
147
|
+
const head = linesOf(blob);
|
|
148
|
+
let delta = 0;
|
|
149
|
+
for (const h of section.hunks) {
|
|
150
|
+
if (h.oldBegin + h.b > head.length) return 'context_mismatch';
|
|
151
|
+
for (let j = 0; j < h.b; j++) {
|
|
152
|
+
const want = h.oldLines[j] + (h.oldLines.noNewlineAt === j ? '' : '\n');
|
|
153
|
+
if (head[h.oldBegin + j] !== want) return 'context_mismatch';
|
|
154
|
+
}
|
|
155
|
+
const expectC = h.a + delta + (h.b === 0 ? 1 : 0) - (h.d === 0 ? 1 : 0);
|
|
156
|
+
if (h.c !== expectC) return 'new_start_mismatch';
|
|
157
|
+
delta += h.d - h.b;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function defaultRunGit(repoRoot) {
|
|
163
|
+
return (args, { timeout, input, env } = {}) => execFileSync('git', args, {
|
|
164
|
+
cwd: repoRoot, env: gitEnv({ ...(env || {}), GIT_NO_LAZY_FETCH: '1' }), input,
|
|
165
|
+
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: Math.max(1, Math.floor(timeout || PER_CALL_MS)),
|
|
166
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class Stop extends Error {
|
|
171
|
+
constructor(result) { super(result.reason || result.status); this.result = result; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {{stored: ({headSha: string, diff: string, notes?: string[]}|null), appliedHead: string, repoRoot: string,
|
|
176
|
+
* runGit?: Function, now?: () => number, mkTemp?: () => string}} opts
|
|
177
|
+
* @returns {{status: 'match'|'mismatch'|'unverifiable'|'absent', headSha: (string|null), step: (string|null),
|
|
178
|
+
* reason: (string|null), file: (string|null)}}
|
|
179
|
+
*/
|
|
180
|
+
function checkReviewDiff({ stored, appliedHead, repoRoot, runGit, now, mkTemp } = {}) {
|
|
181
|
+
const clock = now || Date.now;
|
|
182
|
+
const deadline = clock() + DEADLINE_MS;
|
|
183
|
+
let git = runGit || defaultRunGit(repoRoot || process.cwd());
|
|
184
|
+
const headSha = stored && typeof stored.headSha === 'string' && SHA_RE.test(stored.headSha) ? stored.headSha : null;
|
|
185
|
+
const result = (status, extra = {}) => ({ status, headSha, step: null, reason: null, file: null, ...extra });
|
|
186
|
+
const unverifiable = (reason) => result('unverifiable', { reason });
|
|
187
|
+
let tmp = null;
|
|
188
|
+
let interrupted = false;
|
|
189
|
+
|
|
190
|
+
// One git call, returning its stdout: a throw becomes a Stop (timeout, deadline, git_error, SIGINT) unless
|
|
191
|
+
// `allowExit` lets a non-zero exit through as `null` for the caller to judge.
|
|
192
|
+
const call = (args, { input, env, allowExit } = {}) => {
|
|
193
|
+
const left = Math.min(PER_CALL_MS, deadline - clock());
|
|
194
|
+
if (left <= 0) throw new Stop(unverifiable('deadline'));
|
|
195
|
+
try {
|
|
196
|
+
return String(git(args, { timeout: left, input, env }));
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e && e.signal === 'SIGINT') { interrupted = true; throw new Stop(unverifiable('git_error')); }
|
|
199
|
+
if (e && (e.killed || e.code === 'ETIMEDOUT')) throw new Stop(unverifiable('timeout'));
|
|
200
|
+
if (allowExit && e && typeof e.status === 'number' && e.status !== 0) return null;
|
|
201
|
+
throw new Stop(unverifiable('git_error'));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
if (stored == null) return result('absent');
|
|
207
|
+
if (!headSha) return unverifiable('invalid_head');
|
|
208
|
+
// `git apply` skips a path outside the cwd's prefix and still exits 0, while `rev-parse :<rel>` resolves from the
|
|
209
|
+
// root: run from a subdirectory, a correct diff would read as a post_image mismatch. Every call uses the toplevel.
|
|
210
|
+
if (!runGit) git = defaultRunGit(call(['rev-parse', '--show-toplevel']).trim());
|
|
211
|
+
if (call(['cat-file', '-e', `${headSha}^{commit}`], { allowExit: true }) === null) return unverifiable('head_missing');
|
|
212
|
+
if (appliedHead !== headSha) return unverifiable('head_moved');
|
|
213
|
+
const sections = parseSections(stored.diff);
|
|
214
|
+
if (!sections.length) return unverifiable('no_sections');
|
|
215
|
+
|
|
216
|
+
tmp = (mkTemp || (() => fs.mkdtempSync(path.join(os.tmpdir(), TMP_PREFIX))))();
|
|
217
|
+
const indexEnv = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
218
|
+
call(['read-tree', headSha], { env: indexEnv });
|
|
219
|
+
|
|
220
|
+
for (const s of sections) {
|
|
221
|
+
const mismatch = (step, reason = null) => result('mismatch', { step, reason, file: s.rel });
|
|
222
|
+
if (s.error) return mismatch('parse');
|
|
223
|
+
const pre = call(['rev-parse', '--verify', '--quiet', `${headSha}:${s.rel}`], { allowExit: true });
|
|
224
|
+
if (pre === null || pre.trim() !== s.pre) return mismatch('pre_image');
|
|
225
|
+
const blob = call(['cat-file', 'blob', `${headSha}:${s.rel}`]);
|
|
226
|
+
const off = checkOffsets(s, blob);
|
|
227
|
+
if (off) return mismatch('offset', off);
|
|
228
|
+
const applied = call(['-c', 'apply.ignoreWhitespace=no', '-c', 'apply.whitespace=nowarn',
|
|
229
|
+
'apply', '--cached', '-'], { input: s.text, env: indexEnv, allowExit: true });
|
|
230
|
+
if (applied === null) return mismatch('apply');
|
|
231
|
+
const post = call(['rev-parse', '--verify', '--quiet', `:${s.rel}`], { env: indexEnv, allowExit: true });
|
|
232
|
+
if (post === null || post.trim() !== s.post) return mismatch('post_image');
|
|
233
|
+
}
|
|
234
|
+
return result('match', { reason: 'ok' });
|
|
235
|
+
} catch (e) {
|
|
236
|
+
return e instanceof Stop ? e.result : unverifiable('git_error');
|
|
237
|
+
} finally {
|
|
238
|
+
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
239
|
+
if (interrupted) process.kill(process.pid, 'SIGINT');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Exactly what verify-patch posts as `reviewDiffCheck`: the six keys, `file` bounded. `attempt` is the step_4
|
|
245
|
+
* attempt whose stored diff was fetched and checked; the server binds the check to it.
|
|
246
|
+
*/
|
|
247
|
+
function toWireCheck(r, attempt) {
|
|
248
|
+
const c = r || {};
|
|
249
|
+
return {
|
|
250
|
+
status: c.status ?? null,
|
|
251
|
+
headSha: c.headSha ?? null,
|
|
252
|
+
attempt: Number.isInteger(attempt) ? attempt : null,
|
|
253
|
+
step: c.step ?? null,
|
|
254
|
+
reason: c.reason ?? null,
|
|
255
|
+
file: typeof c.file === 'string' ? c.file.slice(0, MAX_FILE_CHARS) : null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
parseSections, checkReviewDiff, toWireCheck,
|
|
261
|
+
_internal: { parseSection, checkOffsets, linesOf, relOfDiffGit, SHA_RE, PER_CALL_MS, DEADLINE_MS, TMP_PREFIX, STEPS, REASONS },
|
|
262
|
+
};
|
package/lib/review-diff.js
CHANGED
|
@@ -27,7 +27,7 @@ const crypto = require('node:crypto');
|
|
|
27
27
|
const { execFileSync } = require('node:child_process');
|
|
28
28
|
const { gitEnv } = require('./git-env');
|
|
29
29
|
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits');
|
|
30
|
-
const {
|
|
30
|
+
const { pathRefusal } = require('./path-refusal');
|
|
31
31
|
const { FILE_BLOCK_RE } = require('./block-grammar');
|
|
32
32
|
|
|
33
33
|
const REVIEW_DIFF_MAX_CHARS = 60_000; // diff text, summed over the files included
|
|
@@ -135,7 +135,7 @@ function computeReviewDiff({ repoRoot, rendered, runGit, now = Date.now, warn =
|
|
|
135
135
|
// UTF-8 bytes >= UTF-16 length, so a size at or under the cap cannot be a length over it.
|
|
136
136
|
const size = Number(String(git(['cat-file', '-s', `${headSha}:${rel}`], t)).trim());
|
|
137
137
|
if (!Number.isFinite(size) || !overCap(size)) continue;
|
|
138
|
-
if (
|
|
138
|
+
if (pathRefusal(rel, { mode: 'egress' })) { note(rel, 'secret path; not diffed'); continue; }
|
|
139
139
|
t = left();
|
|
140
140
|
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
141
141
|
const head = String(git(['cat-file', 'blob', `${headSha}:${rel}`], t));
|
package/lib/run-report.js
CHANGED
|
@@ -81,10 +81,13 @@ const CONSULT_REQUIRED_RE = new RegExp(`^${CONSULT_REQUIRED_CODE}:[^\\n]*`);
|
|
|
81
81
|
/** The start of the message a batch run pauses with when its consult failed the mechanical check (orchestrator.js). */
|
|
82
82
|
const CONSULT_NEEDS_PERSON_RE = /^architect consult needs a person to read it[^\n]*/;
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
84
|
+
* HC-GF-379-followup-1: the PAUSE-ERA message. Before HC-GF-379, a consult whose OWN verdict was `checklist_b_completed: false`
|
|
85
|
+
* paused the run with this message. Since HC-GF-379 a false consult routes back to step_0 (workflow-dag.js
|
|
86
|
+
* FAILURE_ROUTING_TABLE.step_0b.step_0b_abort: a retry, at most twice, then the run ends failed), so no new run pauses with it;
|
|
87
|
+
* this regex is kept only for runs that paused under the old routing. Approving one is refused (an `_abort` needs an
|
|
88
|
+
* acknowledgement, and even then nothing records a false verdict), so offering `--approve` here is HC-GF-046 again: the line
|
|
89
|
+
* invites the one action the server has already decided to refuse.
|
|
90
|
+
* tests/regression/HC-GF-030-run-report.test.js pins this branch verbatim with a literal pause-era message.
|
|
88
91
|
*/
|
|
89
92
|
const CONSULT_ABORT_RE = /^step_0b: the architect consult did not complete Checklist B[^\n]*/;
|
|
90
93
|
|
|
@@ -101,6 +104,22 @@ const CONSULT_ABORT_RE = /^step_0b: the architect consult did not complete Check
|
|
|
101
104
|
*/
|
|
102
105
|
const EXHAUSTED_RE = /^GARBAGE_OUTPUT at (step_[0-9a-z]+)/;
|
|
103
106
|
|
|
107
|
+
/** HC-GF-036(b), HC-GF-379-followup-1: adverse verdicts in the agent's own vocabulary. Module scope so a test can pin
|
|
108
|
+
* `step_0b_abort` to the kind failure-router.js's step_0b detector returns. */
|
|
109
|
+
const ADVERSE_VERDICTS = Object.freeze({
|
|
110
|
+
review_reject: 'returned a FAIL review',
|
|
111
|
+
security_high: 'raised a HIGH security finding',
|
|
112
|
+
security_medium: 'raised a MEDIUM security finding',
|
|
113
|
+
test_fail: 'reported failing tests',
|
|
114
|
+
lint_fail: 'reported lint errors',
|
|
115
|
+
perf_finding: 'raised a performance finding',
|
|
116
|
+
validator_rejected: 'produced output the format validator rejected',
|
|
117
|
+
step_0b_abort: 'the architect consult answered false',
|
|
118
|
+
});
|
|
119
|
+
/** HC-GF-379: the run's error_message when the third false consult ends it (workflow-dag.js routeFailure's reason).
|
|
120
|
+
* The packages cannot share it at runtime; tests/regression/HC-GF-030-run-report.test.js pins it to the producer. */
|
|
121
|
+
const CONSULT_ROUTE_LIMIT_RE = /^max retries \((\d+)\) exceeded for step_0b:step_0b_abort$/;
|
|
122
|
+
|
|
104
123
|
function buildReport(run, extra = {}) {
|
|
105
124
|
const cfg = run.config || {};
|
|
106
125
|
const steps = Array.isArray(run.steps) ? run.steps : [];
|
|
@@ -176,10 +195,23 @@ function buildReport(run, extra = {}) {
|
|
|
176
195
|
}
|
|
177
196
|
}
|
|
178
197
|
if (run.status === 'failed') {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
198
|
+
// HC-GF-379-followup-1: a third false consult ends the run failed at step_0b. Both the row AND the route's own
|
|
199
|
+
// reason must say so: a guardrail or budget that failed step_0b after one false verdict keeps the generic line.
|
|
200
|
+
const consultRow = steps.find((x) => x && x.stepKey === 'step_0b');
|
|
201
|
+
const limit = run.currentStep === 'step_0b'
|
|
202
|
+
&& (consultRow && consultRow.diagnostics && consultRow.diagnostics.failure_record || {}).failure_kind === 'step_0b_abort'
|
|
203
|
+
&& String(run.errorMessage || '').match(CONSULT_ROUTE_LIMIT_RE);
|
|
204
|
+
if (limit && ADVERSE_VERDICTS.step_0b_abort) {
|
|
205
|
+
needsYou.push({
|
|
206
|
+
what: `run FAILED: ${ADVERSE_VERDICTS.step_0b_abort} ${Number(limit[1]) + 1} times, the route-back limit`,
|
|
207
|
+
how: `hone show step ${run.runId} step_0b (every consult attempt's findings, newest first)`,
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
needsYou.push({
|
|
211
|
+
what: `run FAILED${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
|
|
212
|
+
how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
183
215
|
}
|
|
184
216
|
// HC-GF-065: a KILLED run needs you too.
|
|
185
217
|
//
|
|
@@ -226,17 +258,9 @@ function buildReport(run, extra = {}) {
|
|
|
226
258
|
// conflation that was inflating batch failure_rates — and it points the operator at
|
|
227
259
|
// the wrong thing to fix. The verdict kinds name the agent's own vocabulary.
|
|
228
260
|
const kind = (s.diagnostics && s.diagnostics.failure_record || {}).failure_kind;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
security_medium: 'raised a MEDIUM security finding',
|
|
233
|
-
test_fail: 'reported failing tests',
|
|
234
|
-
lint_fail: 'reported lint errors',
|
|
235
|
-
perf_finding: 'raised a performance finding',
|
|
236
|
-
validator_rejected: 'produced output the format validator rejected',
|
|
237
|
-
};
|
|
238
|
-
const what = kind && VERDICT[kind]
|
|
239
|
-
? `${s.stepKey} (${s.agent || 'agent'}) ${VERDICT[kind]}`
|
|
261
|
+
if (kind === 'step_0b_abort') continue; // HC-GF-379-followup-1: reported once, in 1b′ below
|
|
262
|
+
const what = kind && ADVERSE_VERDICTS[kind]
|
|
263
|
+
? `${s.stepKey} (${s.agent || 'agent'}) ${ADVERSE_VERDICTS[kind]}`
|
|
240
264
|
: `${s.stepKey} (${s.agent || 'agent'}) ${s.status === 'failed' ? 'FAILED' : 'was REJECTED'}`;
|
|
241
265
|
needsYou.push({
|
|
242
266
|
what: `${what} — the run is recorded ${run.status}, so nothing downstream will revisit it`,
|
|
@@ -245,6 +269,35 @@ function buildReport(run, extra = {}) {
|
|
|
245
269
|
});
|
|
246
270
|
}
|
|
247
271
|
|
|
272
|
+
// 1b′. HC-GF-379-followup-1: the latest step_0b row is a false consult. Steps carry only the latest attempt, so a
|
|
273
|
+
// consult that went false and then true raises nothing here. Named once: skipped when the run-failed item or the
|
|
274
|
+
// pause-era abort branch above already names it.
|
|
275
|
+
{
|
|
276
|
+
const em = String(run.errorMessage || '');
|
|
277
|
+
const row = steps.find((x) => x && x.stepKey === 'step_0b');
|
|
278
|
+
const rowKind = row && (row.diagnostics && row.diagnostics.failure_record || {}).failure_kind;
|
|
279
|
+
const namedAbove = (run.status === 'failed' && run.currentStep === 'step_0b')
|
|
280
|
+
|| (run.status === 'paused' && run.currentStep && em.match(CONSULT_ABORT_RE));
|
|
281
|
+
if (rowKind === 'step_0b_abort' && !namedAbove) {
|
|
282
|
+
// "Paused at step_0" means the plain gate only: the final else of the paused branch above.
|
|
283
|
+
const plainGate = run.status === 'paused' && run.currentStep === 'step_0' && !run.approvalRefusal
|
|
284
|
+
&& ![BLOCKING_REASON_RE, EXHAUSTED_RE, CONSULT_REQUIRED_RE, CONSULT_NEEDS_PERSON_RE, CONSULT_ABORT_RE].some((re) => em.match(re));
|
|
285
|
+
const regrooming = run.status === 'running' || plainGate;
|
|
286
|
+
const suffix = regrooming
|
|
287
|
+
? 'the run is re-grooming with these findings'
|
|
288
|
+
: `the run is recorded ${run.status}${run.status === 'failed' && run.currentStep ? ` at ${run.currentStep}` : ''}, `
|
|
289
|
+
+ 'so nothing downstream will revisit it';
|
|
290
|
+
needsYou.push({
|
|
291
|
+
what: `step_0b: ${ADVERSE_VERDICTS.step_0b_abort} — ${suffix}`,
|
|
292
|
+
how: `hone show step ${run.runId} step_0b`,
|
|
293
|
+
howLabel: 'inspect',
|
|
294
|
+
// A run inside the consult loop needs nobody: the pipeline is answering the findings itself. Shown, not counted
|
|
295
|
+
// toward the exit code (a paused run's own gate item, when there is one, still is).
|
|
296
|
+
...(regrooming ? { advisory: true } : {}),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
248
301
|
// 1c. HC-GF-041: a retry that came back with LESS than the attempt it replaces.
|
|
249
302
|
//
|
|
250
303
|
// The newest attempt REPLACES the previous one for every downstream consumer, so this
|
|
@@ -644,4 +697,6 @@ function renderReport(r) {
|
|
|
644
697
|
return L.join('\n');
|
|
645
698
|
}
|
|
646
699
|
|
|
647
|
-
module.exports = {
|
|
700
|
+
module.exports = {
|
|
701
|
+
BLOCKING_REASON_CODES, buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS, ADVERSE_VERDICTS, CONSULT_ROUTE_LIMIT_RE,
|
|
702
|
+
};
|