@hone-ai/cli 1.32.0 → 1.33.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/hone-cli.js +123 -67
- package/lib/auto-verify.js +21 -1
- package/lib/emit-pr.js +1 -0
- package/lib/materialize-diff.js +142 -17
- package/lib/mcp-tools.js +28 -7
- package/lib/poll-decision.js +16 -1
- package/lib/run-report.js +111 -6
- package/lib/run-target.js +97 -0
- package/lib/verify-patch.js +88 -3
- package/package.json +1 -1
package/lib/materialize-diff.js
CHANGED
|
@@ -58,14 +58,26 @@ function blockBody(raw) {
|
|
|
58
58
|
* lets git build the diff — so the count stays git's, exactly as for whole-file
|
|
59
59
|
* blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
|
|
60
60
|
*/
|
|
61
|
-
const EDIT_BLOCK_RE =
|
|
61
|
+
const EDIT_BLOCK_RE = new RegExp(
|
|
62
|
+
'^[ \\t]*```[ \\t]*edit[ \\t]*:[ \\t]*([^\\n`]+?)[ \\t]*\\r?\\n(' +
|
|
63
|
+
// HC-GF-109: a framed body ends at the fence after its `>>>>>>> NEW` line, so a NEW holding a fence line of its own
|
|
64
|
+
// (a docstring example, a Markdown file) is not cut short, and it never runs on into the next block.
|
|
65
|
+
'<{3,}[ \\t]*OLD[ \\t]*\\r?\\n(?:(?!^[ \\t]*```[ \\t]*(?:file|edit)[ \\t]*:)[\\s\\S])*?^>{3,}[ \\t]*NEW[ \\t]*\\r?\\n' +
|
|
66
|
+
// Anything else runs to the first fence line, and extractFileBlocks reports it as malformed.
|
|
67
|
+
'|(?:(?!^[ \\t]*```)[\\s\\S])*?' +
|
|
68
|
+
')^[ \\t]*```[ \\t]*\\r?$',
|
|
69
|
+
'gim');
|
|
70
|
+
|
|
71
|
+
/** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
|
|
72
|
+
const EDIT_OPEN_RE = /^[ \t]*```[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
|
|
62
73
|
|
|
63
74
|
/**
|
|
64
75
|
* The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
|
|
65
76
|
* Whitespace after the marker word is tolerated; the bodies are captured verbatim
|
|
66
|
-
* (indentation is part of the anchor).
|
|
77
|
+
* (indentation is part of the anchor). An empty NEW deletes the anchor (HC-GF-109) — it used to
|
|
78
|
+
* fail this split, and the block was skipped without a word.
|
|
67
79
|
*/
|
|
68
|
-
const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n([\s\S]*?)\r?\n
|
|
80
|
+
const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?>{3,}[ \t]*NEW[ \t]*\r?$/m;
|
|
69
81
|
|
|
70
82
|
/** A `DELETE: <path>` line inside the ## Changed Files section. */
|
|
71
83
|
const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
@@ -89,6 +101,8 @@ class MaterializeError extends Error {
|
|
|
89
101
|
* @param {string} output raw step_4 output
|
|
90
102
|
* @returns {{
|
|
91
103
|
* files: Array<{ path: string, contents: string }>,
|
|
104
|
+
* edits: Array<{ path: string, oldString: string, newString: string }>, every edit block, in order (HC-GF-109)
|
|
105
|
+
* malformed: Array<{ path: string, reason: string }>, edit blocks that could not be read
|
|
92
106
|
* deletes: string[],
|
|
93
107
|
* noChanges: boolean,
|
|
94
108
|
* reason: string|null
|
|
@@ -96,7 +110,7 @@ class MaterializeError extends Error {
|
|
|
96
110
|
*/
|
|
97
111
|
function extractFileBlocks(output) {
|
|
98
112
|
const text = String(output || '');
|
|
99
|
-
const empty = { files: [], edits: [], deletes: [], noChanges: false, reason: null };
|
|
113
|
+
const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
|
|
100
114
|
|
|
101
115
|
// Scope everything to the ## Changed Files section if present, so a code
|
|
102
116
|
// sample elsewhere in the prose is never mistaken for an intended file.
|
|
@@ -105,8 +119,8 @@ function extractFileBlocks(output) {
|
|
|
105
119
|
|
|
106
120
|
// Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
|
|
107
121
|
// declaration only counts when there is NO file AND NO edit block to apply.
|
|
108
|
-
const hasBlock = (FILE_BLOCK_RE.test(scope) ||
|
|
109
|
-
FILE_BLOCK_RE.lastIndex = 0;
|
|
122
|
+
const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
|
|
123
|
+
FILE_BLOCK_RE.lastIndex = 0; EDIT_OPEN_RE.lastIndex = 0;
|
|
110
124
|
const noChange = scope.match(/NO CHANGES\b[ \t]*[—:-]?[ \t]*([^\n]*)/i);
|
|
111
125
|
if (noChange && !hasBlock) {
|
|
112
126
|
return { ...empty, noChanges: true, reason: (noChange[1] || '').trim() || null };
|
|
@@ -123,16 +137,33 @@ function extractFileBlocks(output) {
|
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
// HC-019n-followup-27: region edits for files too big to bundle whole.
|
|
140
|
+
// HC-GF-109: EVERY edit block for a path, in order. This loop used to keep the first and drop the rest ("first edit
|
|
141
|
+
// wins") while the code-builder prompt asks for `edit:` on a file changed in a few places, so 12 of 24 stored edit
|
|
142
|
+
// blocks never reached the patch, and verify-patch type-checked a settings.py missing four of its five edits.
|
|
126
143
|
const edits = [];
|
|
127
|
-
const
|
|
144
|
+
const malformed = [];
|
|
145
|
+
const editKeys = new Set();
|
|
146
|
+
const parsedAt = new Set();
|
|
128
147
|
for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
|
|
148
|
+
parsedAt.add(m.index);
|
|
129
149
|
const rel = normalizeRepoPath(m[1]);
|
|
130
150
|
if (!rel) continue;
|
|
131
|
-
if (seen.has(rel)
|
|
151
|
+
if (seen.has(rel)) continue; // a whole-file block wins
|
|
132
152
|
const split = EDIT_SPLIT_RE.exec(m[2]);
|
|
133
|
-
if (!split)
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
|
|
154
|
+
malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const edit = { path: rel, oldString: split[1], newString: split[2] || '' };
|
|
158
|
+
const key = JSON.stringify([edit.path, edit.oldString, edit.newString]);
|
|
159
|
+
if (editKeys.has(key)) continue; // the same block emitted twice is one change, not two
|
|
160
|
+
editKeys.add(key);
|
|
161
|
+
edits.push(edit);
|
|
162
|
+
}
|
|
163
|
+
for (const m of scope.matchAll(EDIT_OPEN_RE)) {
|
|
164
|
+
if (parsedAt.has(m.index)) continue;
|
|
165
|
+
const rel = normalizeRepoPath(m[1]);
|
|
166
|
+
if (rel && !seen.has(rel)) malformed.push({ path: rel, reason: 'no closing fence' });
|
|
136
167
|
}
|
|
137
168
|
|
|
138
169
|
const deletes = [];
|
|
@@ -141,7 +172,7 @@ function extractFileBlocks(output) {
|
|
|
141
172
|
if (rel && !deletes.includes(rel)) deletes.push(rel);
|
|
142
173
|
}
|
|
143
174
|
|
|
144
|
-
return { files, edits, deletes, noChanges: false, reason: null };
|
|
175
|
+
return { files, edits, deletes, malformed, noChanges: false, reason: null };
|
|
145
176
|
}
|
|
146
177
|
|
|
147
178
|
/**
|
|
@@ -181,6 +212,30 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
181
212
|
return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
|
|
182
213
|
}
|
|
183
214
|
|
|
215
|
+
/**
|
|
216
|
+
* HC-GF-109 — apply every region edit for ONE file, as one change.
|
|
217
|
+
*
|
|
218
|
+
* The edits apply in the order the builder wrote them, each to the result of the ones before, and each OLD must occur
|
|
219
|
+
* exactly once at the moment it is applied — the single-edit rule, never a guess. Order is load-bearing in the stored
|
|
220
|
+
* builds: in run 1a8940af attempt 1 one edit's anchor was text the previous edit had inserted. Replayed over the 24
|
|
221
|
+
* stored edit blocks at each run's base commit, in-order application applies all of them, while finding every anchor
|
|
222
|
+
* in the original file rejects that build. An anchor an earlier edit changed fails as not found, and one an earlier NEW
|
|
223
|
+
* duplicated fails as ambiguous — both loud, and the message names the edit.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} fileContents real full file contents
|
|
226
|
+
* @param {Array<{oldString: string, newString: string}>} edits in the order the builder wrote them
|
|
227
|
+
* @param {string} repoPath for error messages
|
|
228
|
+
* @returns {string} the candidate file contents
|
|
229
|
+
* @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
|
|
230
|
+
*/
|
|
231
|
+
function applyEdits(fileContents, edits, repoPath) {
|
|
232
|
+
let out = String(fileContents);
|
|
233
|
+
edits.forEach((e, i) => {
|
|
234
|
+
out = applyEdit(out, e.oldString, e.newString, edits.length > 1 ? `${repoPath} (edit ${i + 1} of ${edits.length})` : repoPath);
|
|
235
|
+
});
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
184
239
|
/** Non-overlapping occurrence count of `needle` in `hay` (literal, not regex). */
|
|
185
240
|
function countOccurrences(hay, needle) {
|
|
186
241
|
if (!needle) return 0;
|
|
@@ -211,6 +266,16 @@ function countOccurrences(hay, needle) {
|
|
|
211
266
|
function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
|
|
212
267
|
const parts = [];
|
|
213
268
|
|
|
269
|
+
// HC-GF-109: an edit block that could not be read is work that would otherwise vanish from the patch. Refuse the whole
|
|
270
|
+
// patch, as for a bad anchor: a partial patch passes verification and review as if it were the build.
|
|
271
|
+
const malformed = blocks.malformed || [];
|
|
272
|
+
if (malformed.length > 0) {
|
|
273
|
+
const more = malformed.length > 1 ? ` (and ${malformed.length - 1} more)` : '';
|
|
274
|
+
throw new MaterializeError('malformed_edit',
|
|
275
|
+
`${malformed[0].path}: edit block could not be read: ${malformed[0].reason}${more}. ` +
|
|
276
|
+
'Frame every region edit as <<<<<<< OLD, =======, >>>>>>> NEW lines inside its own fence.');
|
|
277
|
+
}
|
|
278
|
+
|
|
214
279
|
// 1. Whole-file blocks (followup-23 Option A) — unchanged behaviour.
|
|
215
280
|
for (const f of blocks.files) {
|
|
216
281
|
const isNew = !exists(f.path);
|
|
@@ -225,18 +290,25 @@ function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
|
|
|
225
290
|
}
|
|
226
291
|
|
|
227
292
|
// 2. Region edits (followup-27) — apply OLD→NEW to the REAL file, git-diff it.
|
|
293
|
+
// HC-GF-109: all of one file's edits together, one diff per file. Each edit used to be diffed against HEAD on its
|
|
294
|
+
// own, and only the first edit per file ever reached this loop.
|
|
295
|
+
const editsByPath = new Map();
|
|
228
296
|
for (const e of blocks.edits || []) {
|
|
229
|
-
|
|
297
|
+
if (!editsByPath.has(e.path)) editsByPath.set(e.path, []);
|
|
298
|
+
editsByPath.get(e.path).push(e);
|
|
299
|
+
}
|
|
300
|
+
for (const [repoPath, edits] of editsByPath) {
|
|
301
|
+
const real = readFile(repoPath);
|
|
230
302
|
if (real == null) {
|
|
231
303
|
throw new MaterializeError('file_not_found',
|
|
232
|
-
`${
|
|
304
|
+
`${repoPath}: cannot apply edit — file not found in HEAD`);
|
|
233
305
|
}
|
|
234
|
-
const candidate =
|
|
306
|
+
const candidate = applyEdits(real, edits, repoPath); // throws typed on 0/many, naming the edit
|
|
235
307
|
const t = tmpFile(candidate.endsWith('\n') ? candidate : candidate + '\n');
|
|
236
308
|
try {
|
|
237
309
|
const built = buildFileDiff({
|
|
238
|
-
repoPath
|
|
239
|
-
runGitDiff: () => runGitDiff(
|
|
310
|
+
repoPath, isNew: false,
|
|
311
|
+
runGitDiff: () => runGitDiff(repoPath, t.path),
|
|
240
312
|
});
|
|
241
313
|
if (built.changed) parts.push(built.diff);
|
|
242
314
|
} finally { t.cleanup(); }
|
|
@@ -372,6 +444,55 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
|
372
444
|
};
|
|
373
445
|
}
|
|
374
446
|
|
|
447
|
+
/**
|
|
448
|
+
* HC-GF-077 — the materialization IO for a diff that will be applied to HEAD.
|
|
449
|
+
*
|
|
450
|
+
* nodeMaterializeIO reads the working copy, and buildDiff hands git the original as a working-copy PATH.
|
|
451
|
+
* Every patch verb then applies the result to HEAD (verify-patch and emit-pr in a worktree cut from HEAD,
|
|
452
|
+
* check-patch now against an index read from HEAD). An editor leaves its edits in the working copy — the
|
|
453
|
+
* normal Path B flow — so the diff described a base it was never applied to. Measured on real runs: a
|
|
454
|
+
* byte-identical editor tree made verify-patch report "nothing to verify" with exit 0 for a failing suite,
|
|
455
|
+
* and a near-identical one made verify-patch and emit-pr report `does_not_apply` while check-patch said
|
|
456
|
+
* clean (6 of 6 runs).
|
|
457
|
+
*
|
|
458
|
+
* Same interface and the same temp-file/git-diff machinery as nodeMaterializeIO; only the three reads of
|
|
459
|
+
* the original change: existence (`git cat-file -e`), contents (`git show`), and the original handed to
|
|
460
|
+
* `git diff --no-index` (HEAD's blob in a temp file). Paths are repo-root-relative, as artifact paths are.
|
|
461
|
+
*/
|
|
462
|
+
function headMaterializeIO({ cwd = process.cwd(), rev = 'HEAD' } = {}) {
|
|
463
|
+
const { execFileSync } = require('node:child_process');
|
|
464
|
+
const { gitEnv } = require('./git-env');
|
|
465
|
+
const base = nodeMaterializeIO({ cwd });
|
|
466
|
+
const opts = { cwd, env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] };
|
|
467
|
+
const show = (rel) => {
|
|
468
|
+
try { return execFileSync('git', ['show', `${rev}:${rel}`], { ...opts, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); }
|
|
469
|
+
catch { return null; }
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
...base,
|
|
473
|
+
exists: (rel) => { try { execFileSync('git', ['cat-file', '-e', `${rev}:${rel}`], opts); return true; } catch { return false; } },
|
|
474
|
+
readFile: show,
|
|
475
|
+
runGitDiff: (origArg, candPath) => {
|
|
476
|
+
if (origArg === '/dev/null') return base.runGitDiff(origArg, candPath);
|
|
477
|
+
const body = show(origArg);
|
|
478
|
+
if (body == null) return base.runGitDiff('/dev/null', candPath);
|
|
479
|
+
const t = base.tmpFile(body);
|
|
480
|
+
try { return base.runGitDiff(t.path, candPath); } finally { t.cleanup(); }
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* HC-GF-109 — whether parsed step output carries anything the patch verbs must materialize. One definition for
|
|
487
|
+
* check-patch, verify-patch and emit-pr, and it counts an edit block that failed to parse, so that output fails loudly
|
|
488
|
+
* in buildDiff instead of falling through to the legacy prose path.
|
|
489
|
+
*/
|
|
490
|
+
function hasMaterializableWork(blocks) {
|
|
491
|
+
if (!blocks) return false;
|
|
492
|
+
return blocks.files.length > 0 || (blocks.edits || []).length > 0 ||
|
|
493
|
+
(blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
|
|
494
|
+
}
|
|
495
|
+
|
|
375
496
|
module.exports = {
|
|
376
497
|
extractFileBlocks,
|
|
377
498
|
normalizeRepoPath,
|
|
@@ -379,7 +500,11 @@ module.exports = {
|
|
|
379
500
|
buildFileDiff,
|
|
380
501
|
buildDiff,
|
|
381
502
|
nodeMaterializeIO,
|
|
503
|
+
headMaterializeIO,
|
|
382
504
|
applyEdit,
|
|
505
|
+
applyEdits,
|
|
506
|
+
hasMaterializableWork,
|
|
507
|
+
EDIT_OPEN_RE,
|
|
383
508
|
countOccurrences,
|
|
384
509
|
ensureTrailingNewline,
|
|
385
510
|
MaterializeError,
|
package/lib/mcp-tools.js
CHANGED
|
@@ -116,11 +116,20 @@ async function listWorkflows({ status, limit, cwd } = {}) {
|
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// HC-GF-106: `run-story <storyId> --kill|--approve` now resolves a story ID to that story's latest run.
|
|
120
|
+
// Right for a person at a terminal; wrong behind a tool whose contract is an explicit run ID, where a
|
|
121
|
+
// story ID is an agent's guess at a target. It used to fail only by accident (the server 404'd it), so
|
|
122
|
+
// the tools that ACT on a run refuse anything that is not a run ID before shelling out.
|
|
123
|
+
const { isRunId } = require('./run-target');
|
|
124
|
+
const notARunId = (runId) =>
|
|
125
|
+
`runId must be a run ID (a UUID); ${JSON.stringify(String(runId))} is not. Get one from hone_run_story or hone_list_workflows.`;
|
|
126
|
+
|
|
119
127
|
// hone_kill_workflow — kill ONE run by explicit id (DESTRUCTIVE, irreversible).
|
|
120
128
|
// runId is schema-required: an agent must never guess a target for a kill —
|
|
121
129
|
// explicit id or nothing (memo D3 refutation). Server 409s non-killable states.
|
|
122
130
|
async function killWorkflow({ runId, cwd } = {}) {
|
|
123
131
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
132
|
+
if (!isRunId(String(runId))) return { isError: true, detail: notARunId(runId) };
|
|
124
133
|
const r = await runHone(['run-story', String(runId), '--kill'], { cwd });
|
|
125
134
|
if (r.error) return { isError: true, detail: r.error };
|
|
126
135
|
return {
|
|
@@ -201,8 +210,10 @@ async function verifyPatch({ runId, report = false, cwd } = {}) {
|
|
|
201
210
|
const r = await runHone(args, { cwd, timeoutMs: 600000 });
|
|
202
211
|
if (r.error) return { isError: true, detail: r.error };
|
|
203
212
|
const data = parseLastJson(r.stdout);
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
// HC-GF-077: `passed` is the verdict's, never the exit code's. verify-patch exited 0 for a run on which nothing
|
|
214
|
+
// executed, and this reported it to the editor agent as `passed: true, detail: 'tests passed'`.
|
|
215
|
+
const verdict = data && (data.verdict || data.result) ? (data.verdict || data.result) : 'unknown';
|
|
216
|
+
return { isError: false, verdict, passed: verdict === 'pass', exitCode: r.exitCode, report: data, detail: (data && data.detail) || (data ? `verdict: ${verdict}` : 'no verdict could be read from verify-patch') };
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
// hone_check_patch — does step_4's diff apply cleanly? (throwaway git apply --check)
|
|
@@ -235,8 +246,9 @@ async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
|
|
|
235
246
|
const r = await runHone(args, { cwd, timeoutMs: 600000 });
|
|
236
247
|
if (r.error) return { isError: true, detail: r.error };
|
|
237
248
|
const data = parseLastJson(r.stdout);
|
|
238
|
-
|
|
239
|
-
|
|
249
|
+
// HC-GF-077: exit 0 includes `disabled` (ci.gate: none) — a gate nobody ran is not a green one.
|
|
250
|
+
const verdict = data && data.verdict ? data.verdict : 'unknown';
|
|
251
|
+
return { isError: false, verdict, passed: verdict === 'pass', exitCode: r.exitCode, report: data, detail: (data && data.detail) || (data ? `verdict: ${verdict}` : 'no verdict could be read from verify-pr') };
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
// hone_agent_eval — test the adopter's own agents (HC-COMM-011), optional report.
|
|
@@ -263,9 +275,15 @@ async function showStep({ runId, stepKey, cwd } = {}) {
|
|
|
263
275
|
}
|
|
264
276
|
|
|
265
277
|
// hone_approve — approve a paused human gate (run-story --approve).
|
|
266
|
-
async function approve({ runId, stepKey, cwd } = {}) {
|
|
278
|
+
async function approve({ runId, stepKey, acknowledgeAbort = false, acknowledgeFailedVerdict = false, cwd } = {}) {
|
|
267
279
|
if (!runId || !stepKey) return { isError: true, detail: 'runId and stepKey are required' };
|
|
268
|
-
|
|
280
|
+
if (!isRunId(String(runId))) return { isError: true, detail: notARunId(runId) }; // HC-GF-106: never approve a guessed run
|
|
281
|
+
// HC-GF-069 / HC-GF-074: an editor agent must be able to pass the same explicit acknowledgements a human can —
|
|
282
|
+
// and only when asked; the refusals exist because an unattended approver defeats a pause.
|
|
283
|
+
const args = ['run-story', String(runId), '--approve', String(stepKey)];
|
|
284
|
+
if (acknowledgeAbort === true) args.push('--acknowledge-abort');
|
|
285
|
+
if (acknowledgeFailedVerdict === true) args.push('--acknowledge-failed-verdict');
|
|
286
|
+
const r = await runHone(args, { cwd });
|
|
269
287
|
if (r.error) return { isError: true, detail: r.error };
|
|
270
288
|
return { isError: !r.ok, exitCode: r.exitCode, detail: (r.ok ? `approved ${stepKey}` : (r.stderr || r.stdout || 'approve failed')).slice(0, 500) };
|
|
271
289
|
}
|
|
@@ -343,7 +361,10 @@ const TOOLS = [
|
|
|
343
361
|
name: 'hone_approve',
|
|
344
362
|
description: 'Approve a paused human gate on a run so it advances (e.g. stepKey "step_4").',
|
|
345
363
|
readOnly: false,
|
|
346
|
-
inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string' }
|
|
364
|
+
inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string' },
|
|
365
|
+
acknowledgeAbort: { type: 'boolean', default: false, description: 'Approve a step the agent aborted (read its reason first).' },
|
|
366
|
+
acknowledgeFailedVerdict: { type: 'boolean', default: false, description: 'Approve a step whose verification did not pass under enforce (read the failing gates first).' } },
|
|
367
|
+
required: ['runId', 'stepKey'] },
|
|
347
368
|
handler: approve,
|
|
348
369
|
},
|
|
349
370
|
{
|
package/lib/poll-decision.js
CHANGED
|
@@ -27,11 +27,15 @@
|
|
|
27
27
|
|
|
28
28
|
const { isEditorParked } = require('./editor-park');
|
|
29
29
|
|
|
30
|
+
/** HC-GF-075: equal to server/src/services/validator-exhaustion.js EXHAUSTION_PREFIX (pinned by test). */
|
|
31
|
+
const EXHAUSTION_PREFIX = 'GARBAGE_OUTPUT at ';
|
|
32
|
+
|
|
30
33
|
const POLL_OUTCOMES = Object.freeze({
|
|
31
34
|
COMPLETED: 'completed',
|
|
32
35
|
TERMINAL_FAILURE: 'terminal_failure',
|
|
33
36
|
EDITOR_PARKED: 'editor_parked',
|
|
34
37
|
HUMAN_GATE: 'human_gate',
|
|
38
|
+
EXHAUSTED: 'exhausted',
|
|
35
39
|
RUNNING: 'running',
|
|
36
40
|
});
|
|
37
41
|
|
|
@@ -40,6 +44,8 @@ const EXIT = Object.freeze({
|
|
|
40
44
|
[POLL_OUTCOMES.COMPLETED]: 0,
|
|
41
45
|
[POLL_OUTCOMES.TERMINAL_FAILURE]: 1,
|
|
42
46
|
[POLL_OUTCOMES.HUMAN_GATE]: 2,
|
|
47
|
+
// HC-GF-075: a human decision, so the same contract as a gate — adopter CI already handles 2.
|
|
48
|
+
[POLL_OUTCOMES.EXHAUSTED]: 2,
|
|
43
49
|
[POLL_OUTCOMES.EDITOR_PARKED]: 3,
|
|
44
50
|
[POLL_OUTCOMES.RUNNING]: null,
|
|
45
51
|
});
|
|
@@ -73,6 +79,15 @@ function classifyPollState(status) {
|
|
|
73
79
|
stepKey: s.currentStep, agent: step && step.agent,
|
|
74
80
|
});
|
|
75
81
|
}
|
|
82
|
+
// HC-GF-075: a step that used up its format retries is a decision, not a gate on an
|
|
83
|
+
// artifact — approving it starts another cycle of the step. Classified BEFORE the gate so
|
|
84
|
+
// the CLI does not auto-verify the rejected attempt and report a verdict as if it were the
|
|
85
|
+
// build. The prefix must equal the server's EXHAUSTION_PREFIX; a test pins the two.
|
|
86
|
+
if (String(s.errorMessage || '').startsWith(`${EXHAUSTION_PREFIX}${s.currentStep} `)) {
|
|
87
|
+
return decide(POLL_OUTCOMES.EXHAUSTED, {
|
|
88
|
+
stepKey: s.currentStep, agent: step && step.agent, errorMessage: s.errorMessage,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
76
91
|
// HC-GF-064 keys its at-most-once verification on the ATTEMPT: a retried step is a
|
|
77
92
|
// new artifact and deserves a new verdict, while re-verifying the SAME attempt on
|
|
78
93
|
// every tick is the unbounded advisory loop that story had to fix before merge.
|
|
@@ -89,4 +104,4 @@ function classifyPollState(status) {
|
|
|
89
104
|
});
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
module.exports = { classifyPollState, POLL_OUTCOMES, EXIT };
|
|
107
|
+
module.exports = { classifyPollState, POLL_OUTCOMES, EXIT, EXHAUSTION_PREFIX };
|
package/lib/run-report.js
CHANGED
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
20
20
|
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* HC-GF-008: the verification verdicts that leave nothing outstanding. `disabled` is verify-pr's record of
|
|
24
|
+
* an explicit `ci.gate: none`. Everything else — including a value this file has never seen — is an item,
|
|
25
|
+
* so a renamed or added verdict becomes a visible line rather than silence.
|
|
26
|
+
*/
|
|
27
|
+
const CLEAR_VERIFICATION_VERDICTS = ['pass', 'disabled'];
|
|
28
|
+
/** The ones that gate the script exit. `unresolved` gates because an absent command used to report `fail`. */
|
|
29
|
+
const GATING_VERIFICATION_VERDICTS = ['fail', 'timeout', 'unresolved'];
|
|
30
|
+
|
|
22
31
|
function pad(s, n) { return String(s).padEnd(n); }
|
|
23
32
|
|
|
24
33
|
/**
|
|
@@ -38,6 +47,37 @@ function durationOf(startedAt, endedAt) {
|
|
|
38
47
|
* @param {object} run the GET /:runId projection
|
|
39
48
|
* @param {{cost?: {metered:number, notional:number}}} [extra]
|
|
40
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* HC-GF-046(b): the error codes on which approving is REFUSED — and nothing else.
|
|
52
|
+
*
|
|
53
|
+
* This must equal exactly what `checkGuardrails` emits, because the report tells the
|
|
54
|
+
* operator "approving will be refused" for these. The first version was a hand-typed list
|
|
55
|
+
* written from memory, and one of its four entries was right: MAX_STEPS_EXCEEDED was the
|
|
56
|
+
* wrong string (the real code is MAX_TOTAL_STEPS_EXCEEDED), BUDGET_EXCEEDED could never
|
|
57
|
+
* match the prefixed TOKEN_/BATCH_BUDGET_EXCEEDED past the ^ anchor, and GARBAGE_OUTPUT is
|
|
58
|
+
* not a guardrail refusal at all — approveGate accepts it. It is not an ordinary gate either;
|
|
59
|
+
* see EXHAUSTED_RE below.
|
|
60
|
+
*
|
|
61
|
+
* Exported so a test pins it equal to the enforcer's own reason codes. The enforcer lives in
|
|
62
|
+
* the server package and this in the CLI package, so they cannot share a constant at
|
|
63
|
+
* runtime; the equality is asserted in CI instead, which is where drift would be caught.
|
|
64
|
+
*/
|
|
65
|
+
const BLOCKING_REASON_CODES = Object.freeze(['MAX_RETRIES_EXCEEDED', 'MAX_TOTAL_STEPS_EXCEEDED']);
|
|
66
|
+
const BLOCKING_REASON_RE = new RegExp(`^(${BLOCKING_REASON_CODES.join('|')})[^\\n]*`);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* HC-GF-046(b), corrected in review: a step that used up its format retries.
|
|
70
|
+
*
|
|
71
|
+
* This first shipped as an ordinary gate ("a gate is waiting on your judgment"), on the claim
|
|
72
|
+
* that approving past it started a real attempt — a3, a4 and a5 on run 5f1a7f75. An independent
|
|
73
|
+
* review refuted that from the rows: a3 was the automatic retry, a4 and a5 were route-backs from
|
|
74
|
+
* step_5, and the one approval of that pause re-ran nothing. approveGate closes the step's
|
|
75
|
+
* in-flight rows and moves on, so later steps read the most recent EARLIER attempt — on
|
|
76
|
+
* 5f1a7f75, the build step_5 had already rejected. "Gate" invites exactly that. HC-GF-075 then
|
|
77
|
+
* changed what approving does — it retries the step — and this line says so.
|
|
78
|
+
*/
|
|
79
|
+
const EXHAUSTED_RE = /^GARBAGE_OUTPUT at (step_[0-9a-z]+)/;
|
|
80
|
+
|
|
41
81
|
function buildReport(run, extra = {}) {
|
|
42
82
|
const cfg = run.config || {};
|
|
43
83
|
const steps = Array.isArray(run.steps) ? run.steps : [];
|
|
@@ -61,15 +101,29 @@ function buildReport(run, extra = {}) {
|
|
|
61
101
|
// A resumable pause — a local rate-limit window, for instance — is not a refusal, and
|
|
62
102
|
// silencing a real gate on those would be the over-reach that makes the surface
|
|
63
103
|
// untrustworthy in the other direction.
|
|
64
|
-
const blocked = String(run.errorMessage || '')
|
|
65
|
-
|
|
66
|
-
if (
|
|
104
|
+
const blocked = String(run.errorMessage || '').match(BLOCKING_REASON_RE);
|
|
105
|
+
const exhausted = String(run.errorMessage || '').match(EXHAUSTED_RE);
|
|
106
|
+
if (run.approvalRefusal) {
|
|
107
|
+
// HC-GF-074: rendered from the server's own decision, never re-derived from verdict records here.
|
|
108
|
+
needsYou.push({
|
|
109
|
+
what: `run CANNOT be approved at ${run.currentStep} as it stands — ${String(run.approvalRefusal.message).slice(0, 300)}`,
|
|
110
|
+
how: `fix and re-verify (hone verify-patch ${run.runId} --report), or hone run-story ${run.runId} --approve ${run.currentStep} --acknowledge-failed-verdict`,
|
|
111
|
+
});
|
|
112
|
+
} else if (blocked) {
|
|
67
113
|
needsYou.push({
|
|
68
114
|
what: `run CANNOT proceed at ${run.currentStep} — ${blocked[0].slice(0, 160)}. `
|
|
69
115
|
+ 'Approving will be refused by the guardrail, so this is a decision, not a gate',
|
|
70
116
|
how: `hone run-story ${run.runId} --kill (or raise guardrails.max_retries_per_step `
|
|
71
117
|
+ 'in .pipeline-config.yml and re-run the story)',
|
|
72
118
|
});
|
|
119
|
+
} else if (exhausted) {
|
|
120
|
+
const s = exhausted[1];
|
|
121
|
+
needsYou.push({
|
|
122
|
+
what: `run is paused at ${s} — ${s} used up its format retries. Approving starts another cycle `
|
|
123
|
+
+ `of ${s} with a fresh format budget; it does not accept an earlier attempt`,
|
|
124
|
+
how: `hone show step ${run.runId} ${s} (then --approve ${s} to retry, or --kill; `
|
|
125
|
+
+ 'guardrails.max_retries_per_step caps the cycles)',
|
|
126
|
+
});
|
|
73
127
|
} else {
|
|
74
128
|
needsYou.push({
|
|
75
129
|
what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
|
|
@@ -169,6 +223,35 @@ function buildReport(run, extra = {}) {
|
|
|
169
223
|
});
|
|
170
224
|
}
|
|
171
225
|
|
|
226
|
+
// HC-GF-050: a cycle that changed nothing, and attempts escalating toward the ceiling. Computed by the server from every
|
|
227
|
+
// attempt (GET /:runId `convergence`) and rendered here, never re-derived. ADVISORY: a new signal reports before it gates
|
|
228
|
+
// (HC-026c), and a story that legitimately needs three cycles must not flip a script's exit status. When cycles were
|
|
229
|
+
// compared and none was flagged, say what was compared — absence must not read as not-checked.
|
|
230
|
+
const conv = run.convergence;
|
|
231
|
+
if (conv) {
|
|
232
|
+
for (const u of conv.unchanged || []) {
|
|
233
|
+
needsYou.push({
|
|
234
|
+
what: `a cycle changed nothing — ${u.detail}`,
|
|
235
|
+
how: `hone show step ${run.runId} step_4 --attempt ${u.attempts[u.attempts.length - 1]}`,
|
|
236
|
+
howLabel: 'inspect',
|
|
237
|
+
advisory: true,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (conv.escalating) {
|
|
241
|
+
needsYou.push({
|
|
242
|
+
what: `each build attempt runs longer and produces more — ${conv.escalating.detail}`,
|
|
243
|
+
how: `hone show step ${run.runId} step_4 --attempt ${conv.escalating.attempts[conv.escalating.attempts.length - 1]}`,
|
|
244
|
+
howLabel: 'inspect',
|
|
245
|
+
advisory: true,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const ex = conv.examined || {};
|
|
249
|
+
if (!(conv.unchanged || []).length && !conv.escalating && (ex.reviewCycles || ex.repeatedFailurePairs)) {
|
|
250
|
+
worthKnowing.push(`convergence: compared ${ex.reviewCycles} review cycle(s) and ${ex.repeatedFailurePairs} repeated failure(s) `
|
|
251
|
+
+ `across ${ex.builds} build attempt(s) — each rebuild changed materially (${ex.criteria})`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
172
255
|
// 1d. HC-GF-041: two diagnostics the orchestrator has stamped since HC-019n-followup-41
|
|
173
256
|
// and -43, which NOTHING has ever read on this path — the run payload carried only
|
|
174
257
|
// token counts until the `diagnostics` projection was added. HC-GF-016's row specifies
|
|
@@ -191,6 +274,18 @@ function buildReport(run, extra = {}) {
|
|
|
191
274
|
worthKnowing.push(
|
|
192
275
|
`${s.stepKey} dropped composed guardrail section(s) on a retry and was accepted (budget spent): ${d.composed_missing.join(', ')}`);
|
|
193
276
|
}
|
|
277
|
+
// HC-GF-059: this build contains files the attempt did not write. Advisory — they are the previous build's files,
|
|
278
|
+
// unchanged, and whoever approves should read them as that build's content.
|
|
279
|
+
if (d.carried_forward && Array.isArray(d.carried_forward.paths) && d.carried_forward.paths.length) {
|
|
280
|
+
needsYou.push({
|
|
281
|
+
what: `${s.stepKey} attempt ${s.attempt} did not re-emit ${d.carried_forward.paths.length} planned file(s); they were `
|
|
282
|
+
+ `carried forward unchanged from attempt ${d.carried_forward.from_attempt} — review them as that build's content`,
|
|
283
|
+
detail: d.carried_forward.paths.map(String),
|
|
284
|
+
how: `hone show step ${run.runId} ${s.stepKey} --attempt ${s.attempt}`,
|
|
285
|
+
howLabel: 'inspect',
|
|
286
|
+
advisory: true,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
194
289
|
}
|
|
195
290
|
|
|
196
291
|
// 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
|
|
@@ -205,12 +300,15 @@ function buildReport(run, extra = {}) {
|
|
|
205
300
|
const lastVerification = verifications[verifications.length - 1] || null;
|
|
206
301
|
if (lastVerification) {
|
|
207
302
|
const bad = (lastVerification.gates || []).filter(g => ACTIONABLE_GATE_VERDICTS.includes(g.verdict));
|
|
208
|
-
|
|
303
|
+
// HC-GF-008: "not a pass" is the enforcer's rule (verification-refusal `failingVerdict`), so it is the
|
|
304
|
+
// report's. Listing the failing values instead left `does_not_apply`, `error` — and the new `unresolved`,
|
|
305
|
+
// which an absent command used to report as a gating `fail` — rendering "nothing outstanding".
|
|
306
|
+
const failed = !CLEAR_VERIFICATION_VERDICTS.includes(lastVerification.verdict);
|
|
209
307
|
if (failed || bad.length) {
|
|
210
308
|
const stepKey = lastVerification.stepKey || 'step_4';
|
|
211
309
|
needsYou.push({
|
|
212
310
|
what: failed
|
|
213
|
-
? `verification ${lastVerification.verdict} on ${stepKey}`
|
|
311
|
+
? `${lastVerification.verdict === 'unresolved' ? 'verification could not run' : `verification ${lastVerification.verdict}`} on ${stepKey}`
|
|
214
312
|
+ `${lastVerification.detail ? ` — ${lastVerification.detail}` : ''}`
|
|
215
313
|
: `${bad.length} gate(s) not passing on ${stepKey}`,
|
|
216
314
|
// Sub-lines carry the per-gate verdicts, so one glance separates
|
|
@@ -220,6 +318,10 @@ function buildReport(run, extra = {}) {
|
|
|
220
318
|
+ `${g.command ? ` [${g.command}]` : ''}`),
|
|
221
319
|
how: `hone verify-patch ${run.runId} ${stepKey} --keep`,
|
|
222
320
|
howLabel: 'reproduce',
|
|
321
|
+
// Report before gating (HC-026c): verdicts this report used to swallow are shown in full but do not flip
|
|
322
|
+
// the script exit yet. `unresolved` is not one of them — a report must not stop gating a run on which
|
|
323
|
+
// nothing was run.
|
|
324
|
+
...(failed && !bad.length && !GATING_VERIFICATION_VERDICTS.includes(lastVerification.verdict) ? { advisory: true } : {}),
|
|
223
325
|
});
|
|
224
326
|
}
|
|
225
327
|
}
|
|
@@ -302,6 +404,8 @@ function buildReport(run, extra = {}) {
|
|
|
302
404
|
// HC-GF-065: what the exit code should count. Advisory items are shown but do not
|
|
303
405
|
// gate, so a new signal cannot silently change the meaning of a script's exit status.
|
|
304
406
|
gatingCount: needsYou.filter(i => !i.advisory).length,
|
|
407
|
+
// HC-GF-050: carried so the empty NEEDS YOU can name convergence among what was checked.
|
|
408
|
+
convergence: run.convergence || null,
|
|
305
409
|
};
|
|
306
410
|
}
|
|
307
411
|
|
|
@@ -344,6 +448,7 @@ function renderReport(r) {
|
|
|
344
448
|
checked.push('verification');
|
|
345
449
|
for (const g of last.gates || []) checked.push(g.name);
|
|
346
450
|
}
|
|
451
|
+
if (r.convergence) checked.push('convergence');
|
|
347
452
|
// HC-GF-065: the empty state is only reachable when something was actually CHECKED.
|
|
348
453
|
// With no verdict there is nothing executed against the diff, so "nothing outstanding"
|
|
349
454
|
// asserts knowledge the report does not have — the missing verification is now an
|
|
@@ -384,4 +489,4 @@ function renderReport(r) {
|
|
|
384
489
|
return L.join('\n');
|
|
385
490
|
}
|
|
386
491
|
|
|
387
|
-
module.exports = { buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
|
|
492
|
+
module.exports = { BLOCKING_REASON_CODES, buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
|