@hone-ai/cli 1.31.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.
@@ -146,4 +146,42 @@ function alreadyVerified(attemptKey, verifiedAttempts) {
146
146
  return Boolean(verifiedAttempts && verifiedAttempts.has(attemptKey));
147
147
  }
148
148
 
149
- module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, VERIFIABLE_STEP };
149
+ /**
150
+ * The line printed after a verdict is posted.
151
+ *
152
+ * HC-GF-070: this used to end in a hardcoded `(advisory)`. On the acceptance run for that
153
+ * story the gate was `enforce` and the verdict AUTO-APPROVED step_4 — the run advanced
154
+ * with no human involved — while the CLI told the operator the verdict was advisory. A
155
+ * constant cannot be wrong about the run it is describing, which is the same
156
+ * cannot-fail shape as a check that never fires, arriving in the output layer: it
157
+ * understates the one case where the verdict actually changed something.
158
+ */
159
+ function verdictReportLine(verdict, gate) {
160
+ const mode = gate === 'enforce' ? 'enforce' : 'advisory';
161
+ const effect = mode === 'enforce'
162
+ ? 'enforce — a passing verdict approves this gate'
163
+ : 'advisory — records only, the gate still needs a human';
164
+ return ` → reported verdict '${verdict}' to the server (${effect})`;
165
+ }
166
+
167
+ /**
168
+ * HC-GF-008 — the line run-story prints after auto-verify's `verify-patch` exits, keyed by that exit code.
169
+ *
170
+ * It said "verification did not pass" for every non-zero exit. On TICK-002 (run 5f1a7f75) that line sat
171
+ * directly under `✗ step_4 produced no file changes to verify.` — nothing had run, because the build's
172
+ * output had been discarded (HC-GF-075). Only a failing or timed-out command is a verification that did
173
+ * not pass; every other exit is a verification that did not happen, and says which.
174
+ */
175
+ function verifyOutcomeLine(status, gate) {
176
+ const { EXIT } = require('./verify-patch');
177
+ switch (status) {
178
+ case EXIT.PASS: return ` → verified and recorded (gate: ${gate}). The gate still needs you.`;
179
+ case EXIT.TESTS_FAILED: case EXIT.TIMEOUT: return ' → verification did not pass; the gate still needs you.';
180
+ case EXIT.UNRESOLVED: return ' → verification could not run (see above) — nothing is known about this build; the gate still needs you.';
181
+ case EXIT.NO_DIFF: return ' → nothing was verified: no diff to apply, or the step output could not be fetched (see above); the gate still needs you.';
182
+ case EXIT.DOES_NOT_APPLY: return ' → nothing was verified: the diff did not apply (see above); the gate still needs you.';
183
+ default: return ` → verify-patch exited ${status} without a verdict (see above); the gate still needs you.`;
184
+ }
185
+ }
186
+
187
+ module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, verdictReportLine, verifyOutcomeLine, VERIFIABLE_STEP };
package/lib/emit-pr.js CHANGED
@@ -34,6 +34,7 @@ const EXIT = {
34
34
  TIMEOUT: 4, // --verify test command timed out
35
35
  BRANCH_CONFLICT: 5, // local branch already exists (not overwritten)
36
36
  PUBLISH_FAILED: 6, // push or gh pr create failed (no remote / no auth / rejected)
37
+ UNRESOLVED: 7, // --verify could not start the test command (HC-GF-008) — no commit made
37
38
  };
38
39
 
39
40
  /**
@@ -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 = /```[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)\r?\n```/gi;
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>{3,}[ \t]*NEW[ \t]*$/m;
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) || EDIT_BLOCK_RE.test(scope));
109
- FILE_BLOCK_RE.lastIndex = 0; EDIT_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 editSeen = new Set();
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) || editSeen.has(rel)) continue; // a whole-file block wins; first edit wins
151
+ if (seen.has(rel)) continue; // a whole-file block wins
132
152
  const split = EDIT_SPLIT_RE.exec(m[2]);
133
- if (!split) continue; // malformed edit block (no OLD/NEW markers) — skip
134
- editSeen.add(rel);
135
- edits.push({ path: rel, oldString: split[1], newString: split[2] });
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
- const real = readFile(e.path);
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
- `${e.path}: cannot apply edit — file not found in the working tree`);
304
+ `${repoPath}: cannot apply edit — file not found in HEAD`);
233
305
  }
234
- const candidate = applyEdit(real, e.oldString, e.newString, e.path); // throws typed on 0/many
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: e.path, isNew: false,
239
- runGitDiff: () => runGitDiff(e.path, t.path),
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
- const verdict = data && (data.verdict || data.result) ? (data.verdict || data.result) : (r.ok ? 'pass' : 'fail');
205
- return { isError: false, verdict, passed: r.ok, exitCode: r.exitCode, report: data, detail: (data && data.detail) || (r.ok ? 'tests passed' : 'tests failed') };
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
- const verdict = data && data.verdict ? data.verdict : (r.ok ? 'pass' : 'fail');
239
- return { isError: false, verdict, passed: r.ok, exitCode: r.exitCode, report: data, detail: (data && data.detail) || (r.ok ? 'CI green' : 'CI red') };
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
- const r = await runHone(['run-story', String(runId), '--approve', String(stepKey)], { cwd });
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' } }, required: ['runId', 'stepKey'] },
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
  {
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ /**
3
+ * poll-decision.js — HC-GF-070: what the poll loop should DO about one status payload.
4
+ *
5
+ * WHY THIS IS ITS OWN MODULE. The loop had exactly one entry point — the initial
6
+ * `run-story <storyId>` — and `--approve` returned without polling, so every automated
7
+ * flow exited blind after each gate. Making `--approve` poll means calling the same loop
8
+ * from a second place, and the only way to do that safely is for its DECISIONS to be a
9
+ * pure function of the payload rather than control flow tangled through 130 lines of
10
+ * printing, prompting and process.exit.
11
+ *
12
+ * The ordering of the branches is the load-bearing part, and each ordering below is a
13
+ * bug a hand-written driver actually shipped in this session:
14
+ *
15
+ * terminal BEFORE gate a killed run carrying a stale `awaitingApproval` offered a
16
+ * gate to a driver whose run was already dead
17
+ * park BEFORE human gate an editor-parked step is satisfied by the EDITOR; the
18
+ * server refuses `--approve` there, so a driver that read it
19
+ * as a human gate would approve into a rejection forever
20
+ * awaitingApproval required a run can be `paused` while the server decides what is
21
+ * next; treating that as a gate approves a step nobody asked
22
+ * for, which is HC-GF-069 in miniature
23
+ *
24
+ * Everything unknown classifies as RUNNING. Inventing a terminal state for a status we
25
+ * have not seen would end a live run; polling one more tick costs an interval.
26
+ */
27
+
28
+ const { isEditorParked } = require('./editor-park');
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
+
33
+ const POLL_OUTCOMES = Object.freeze({
34
+ COMPLETED: 'completed',
35
+ TERMINAL_FAILURE: 'terminal_failure',
36
+ EDITOR_PARKED: 'editor_parked',
37
+ HUMAN_GATE: 'human_gate',
38
+ EXHAUSTED: 'exhausted',
39
+ RUNNING: 'running',
40
+ });
41
+
42
+ /** Exit codes are a CONTRACT with adopter CI: 2 = human gate, 3 = editor park. */
43
+ const EXIT = Object.freeze({
44
+ [POLL_OUTCOMES.COMPLETED]: 0,
45
+ [POLL_OUTCOMES.TERMINAL_FAILURE]: 1,
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,
49
+ [POLL_OUTCOMES.EDITOR_PARKED]: 3,
50
+ [POLL_OUTCOMES.RUNNING]: null,
51
+ });
52
+
53
+ const decide = (outcome, extra = {}) => ({ outcome, exitCode: EXIT[outcome], ...extra });
54
+
55
+ /**
56
+ * @param {object|null|undefined} status a `GET /orchestrate/:runId` payload, straight
57
+ * off the network and therefore not trusted to
58
+ * have any particular shape
59
+ * @returns {{outcome:string, exitCode:number|null, stepKey?:string, agent?:string,
60
+ * attemptKey?:string, totalTokens?:number, errorMessage?:string}}
61
+ */
62
+ function classifyPollState(status) {
63
+ const s = (status && typeof status === 'object') ? status : {};
64
+ const steps = Array.isArray(s.steps) ? s.steps : [];
65
+
66
+ if (s.status === 'completed') {
67
+ return decide(POLL_OUTCOMES.COMPLETED, { totalTokens: s.totalTokens || 0 });
68
+ }
69
+ if (s.status === 'failed' || s.status === 'killed') {
70
+ return decide(POLL_OUTCOMES.TERMINAL_FAILURE, {
71
+ errorMessage: s.errorMessage || 'unknown', runStatus: s.status,
72
+ });
73
+ }
74
+
75
+ if (s.status === 'paused' && s.awaitingApproval) {
76
+ const step = steps.find((x) => x && x.stepKey === s.currentStep);
77
+ if (isEditorParked(s)) {
78
+ return decide(POLL_OUTCOMES.EDITOR_PARKED, {
79
+ stepKey: s.currentStep, agent: step && step.agent,
80
+ });
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
+ }
91
+ // HC-GF-064 keys its at-most-once verification on the ATTEMPT: a retried step is a
92
+ // new artifact and deserves a new verdict, while re-verifying the SAME attempt on
93
+ // every tick is the unbounded advisory loop that story had to fix before merge.
94
+ const attempt = (step && step.attempt) || 1;
95
+ return decide(POLL_OUTCOMES.HUMAN_GATE, {
96
+ stepKey: s.currentStep, agent: step && step.agent,
97
+ attemptKey: `${s.currentStep}#${attempt}`,
98
+ });
99
+ }
100
+
101
+ const running = steps.find((x) => x && x.status === 'running');
102
+ return decide(POLL_OUTCOMES.RUNNING, {
103
+ stepKey: running && running.stepKey, agent: running && running.agent,
104
+ });
105
+ }
106
+
107
+ module.exports = { classifyPollState, POLL_OUTCOMES, EXIT, EXHAUSTION_PREFIX };