@hone-ai/cli 1.32.0 → 1.34.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.
@@ -37,7 +37,18 @@ const path = require('node:path');
37
37
  * perfectly valid. Line-anchoring is also strictly better for a body that
38
38
  * contains an indented or info-tagged fence of its own.
39
39
  */
40
- const FILE_BLOCK_RE = /^[ \t]*```[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*\r?$/gim;
40
+ /**
41
+ * HC-GF-127: the fence is 3 OR MORE backticks, and the closer must MATCH the opener (backreference).
42
+ *
43
+ * It used to be exactly three, so a block closed on the first ``` line inside it: a README carrying
44
+ * a ```js sample ended AT the sample, the rest was lost, and `malformed` came back empty — the
45
+ * artifact looked valid and materialized to the wrong bytes. Widening the opener alone would not
46
+ * help; only a matching closer lets a wider fence carry content that contains a narrower one.
47
+ *
48
+ * 3-backtick blocks parse exactly as before: `\1` on a 3-backtick opener IS the old literal. Pinned
49
+ * against 48 artifacts captured from real runs.
50
+ */
51
+ const FILE_BLOCK_RE = /^[ \t]*(`{3,})[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*\1[ \t]*\r?$/gim;
41
52
 
42
53
  /**
43
54
  * The line-anchored closer necessarily absorbs the newline that terminates the
@@ -58,14 +69,26 @@ function blockBody(raw) {
58
69
  * lets git build the diff — so the count stays git's, exactly as for whole-file
59
70
  * blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
60
71
  */
61
- const EDIT_BLOCK_RE = /```[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)\r?\n```/gi;
72
+ const EDIT_BLOCK_RE = new RegExp(
73
+ '^[ \\t]*```[ \\t]*edit[ \\t]*:[ \\t]*([^\\n`]+?)[ \\t]*\\r?\\n(' +
74
+ // HC-GF-109: a framed body ends at the fence after its `>>>>>>> NEW` line, so a NEW holding a fence line of its own
75
+ // (a docstring example, a Markdown file) is not cut short, and it never runs on into the next block.
76
+ '<{3,}[ \\t]*OLD[ \\t]*\\r?\\n(?:(?!^[ \\t]*```[ \\t]*(?:file|edit)[ \\t]*:)[\\s\\S])*?^>{3,}[ \\t]*NEW[ \\t]*\\r?\\n' +
77
+ // Anything else runs to the first fence line, and extractFileBlocks reports it as malformed.
78
+ '|(?:(?!^[ \\t]*```)[\\s\\S])*?' +
79
+ ')^[ \\t]*```[ \\t]*\\r?$',
80
+ 'gim');
81
+
82
+ /** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
83
+ const EDIT_OPEN_RE = /^[ \t]*`{3,}[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
62
84
 
63
85
  /**
64
86
  * The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
65
87
  * Whitespace after the marker word is tolerated; the bodies are captured verbatim
66
- * (indentation is part of the anchor).
88
+ * (indentation is part of the anchor). An empty NEW deletes the anchor (HC-GF-109) — it used to
89
+ * fail this split, and the block was skipped without a word.
67
90
  */
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;
91
+ 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
92
 
70
93
  /** A `DELETE: <path>` line inside the ## Changed Files section. */
71
94
  const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
@@ -89,6 +112,8 @@ class MaterializeError extends Error {
89
112
  * @param {string} output raw step_4 output
90
113
  * @returns {{
91
114
  * files: Array<{ path: string, contents: string }>,
115
+ * edits: Array<{ path: string, oldString: string, newString: string }>, every edit block, in order (HC-GF-109)
116
+ * malformed: Array<{ path: string, reason: string }>, edit blocks that could not be read
92
117
  * deletes: string[],
93
118
  * noChanges: boolean,
94
119
  * reason: string|null
@@ -96,7 +121,7 @@ class MaterializeError extends Error {
96
121
  */
97
122
  function extractFileBlocks(output) {
98
123
  const text = String(output || '');
99
- const empty = { files: [], edits: [], deletes: [], noChanges: false, reason: null };
124
+ const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
100
125
 
101
126
  // Scope everything to the ## Changed Files section if present, so a code
102
127
  // sample elsewhere in the prose is never mistaken for an intended file.
@@ -105,8 +130,8 @@ function extractFileBlocks(output) {
105
130
 
106
131
  // Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
107
132
  // 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;
133
+ const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
134
+ FILE_BLOCK_RE.lastIndex = 0; EDIT_OPEN_RE.lastIndex = 0;
110
135
  const noChange = scope.match(/NO CHANGES\b[ \t]*[—:-]?[ \t]*([^\n]*)/i);
111
136
  if (noChange && !hasBlock) {
112
137
  return { ...empty, noChanges: true, reason: (noChange[1] || '').trim() || null };
@@ -115,24 +140,42 @@ function extractFileBlocks(output) {
115
140
  const files = [];
116
141
  const seen = new Set();
117
142
  for (const m of scope.matchAll(FILE_BLOCK_RE)) {
118
- const rel = normalizeRepoPath(m[1]);
143
+ // HC-GF-127: m[1] is the fence itself now, so the path and body moved to m[2]/m[3].
144
+ const rel = normalizeRepoPath(m[2]);
119
145
  if (!rel) continue; // rejected (traversal/absolute) — see below
120
146
  if (seen.has(rel)) continue; // first block wins on a dup path
121
147
  seen.add(rel);
122
- files.push({ path: rel, contents: blockBody(m[2]) });
148
+ files.push({ path: rel, contents: blockBody(m[3]) });
123
149
  }
124
150
 
125
151
  // HC-019n-followup-27: region edits for files too big to bundle whole.
152
+ // HC-GF-109: EVERY edit block for a path, in order. This loop used to keep the first and drop the rest ("first edit
153
+ // wins") while the code-builder prompt asks for `edit:` on a file changed in a few places, so 12 of 24 stored edit
154
+ // blocks never reached the patch, and verify-patch type-checked a settings.py missing four of its five edits.
126
155
  const edits = [];
127
- const editSeen = new Set();
156
+ const malformed = [];
157
+ const editKeys = new Set();
158
+ const parsedAt = new Set();
128
159
  for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
160
+ parsedAt.add(m.index);
129
161
  const rel = normalizeRepoPath(m[1]);
130
162
  if (!rel) continue;
131
- if (seen.has(rel) || editSeen.has(rel)) continue; // a whole-file block wins; first edit wins
163
+ if (seen.has(rel)) continue; // a whole-file block wins
132
164
  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] });
165
+ if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
166
+ malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
167
+ continue;
168
+ }
169
+ const edit = { path: rel, oldString: split[1], newString: split[2] || '' };
170
+ const key = JSON.stringify([edit.path, edit.oldString, edit.newString]);
171
+ if (editKeys.has(key)) continue; // the same block emitted twice is one change, not two
172
+ editKeys.add(key);
173
+ edits.push(edit);
174
+ }
175
+ for (const m of scope.matchAll(EDIT_OPEN_RE)) {
176
+ if (parsedAt.has(m.index)) continue;
177
+ const rel = normalizeRepoPath(m[1]);
178
+ if (rel && !seen.has(rel)) malformed.push({ path: rel, reason: 'no closing fence' });
136
179
  }
137
180
 
138
181
  const deletes = [];
@@ -141,7 +184,7 @@ function extractFileBlocks(output) {
141
184
  if (rel && !deletes.includes(rel)) deletes.push(rel);
142
185
  }
143
186
 
144
- return { files, edits, deletes, noChanges: false, reason: null };
187
+ return { files, edits, deletes, malformed, noChanges: false, reason: null };
145
188
  }
146
189
 
147
190
  /**
@@ -181,6 +224,30 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
181
224
  return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
182
225
  }
183
226
 
227
+ /**
228
+ * HC-GF-109 — apply every region edit for ONE file, as one change.
229
+ *
230
+ * The edits apply in the order the builder wrote them, each to the result of the ones before, and each OLD must occur
231
+ * exactly once at the moment it is applied — the single-edit rule, never a guess. Order is load-bearing in the stored
232
+ * builds: in run 1a8940af attempt 1 one edit's anchor was text the previous edit had inserted. Replayed over the 24
233
+ * stored edit blocks at each run's base commit, in-order application applies all of them, while finding every anchor
234
+ * in the original file rejects that build. An anchor an earlier edit changed fails as not found, and one an earlier NEW
235
+ * duplicated fails as ambiguous — both loud, and the message names the edit.
236
+ *
237
+ * @param {string} fileContents real full file contents
238
+ * @param {Array<{oldString: string, newString: string}>} edits in the order the builder wrote them
239
+ * @param {string} repoPath for error messages
240
+ * @returns {string} the candidate file contents
241
+ * @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
242
+ */
243
+ function applyEdits(fileContents, edits, repoPath) {
244
+ let out = String(fileContents);
245
+ edits.forEach((e, i) => {
246
+ out = applyEdit(out, e.oldString, e.newString, edits.length > 1 ? `${repoPath} (edit ${i + 1} of ${edits.length})` : repoPath);
247
+ });
248
+ return out;
249
+ }
250
+
184
251
  /** Non-overlapping occurrence count of `needle` in `hay` (literal, not regex). */
185
252
  function countOccurrences(hay, needle) {
186
253
  if (!needle) return 0;
@@ -211,6 +278,16 @@ function countOccurrences(hay, needle) {
211
278
  function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
212
279
  const parts = [];
213
280
 
281
+ // HC-GF-109: an edit block that could not be read is work that would otherwise vanish from the patch. Refuse the whole
282
+ // patch, as for a bad anchor: a partial patch passes verification and review as if it were the build.
283
+ const malformed = blocks.malformed || [];
284
+ if (malformed.length > 0) {
285
+ const more = malformed.length > 1 ? ` (and ${malformed.length - 1} more)` : '';
286
+ throw new MaterializeError('malformed_edit',
287
+ `${malformed[0].path}: edit block could not be read: ${malformed[0].reason}${more}. ` +
288
+ 'Frame every region edit as <<<<<<< OLD, =======, >>>>>>> NEW lines inside its own fence.');
289
+ }
290
+
214
291
  // 1. Whole-file blocks (followup-23 Option A) — unchanged behaviour.
215
292
  for (const f of blocks.files) {
216
293
  const isNew = !exists(f.path);
@@ -225,18 +302,25 @@ function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
225
302
  }
226
303
 
227
304
  // 2. Region edits (followup-27) — apply OLD→NEW to the REAL file, git-diff it.
305
+ // HC-GF-109: all of one file's edits together, one diff per file. Each edit used to be diffed against HEAD on its
306
+ // own, and only the first edit per file ever reached this loop.
307
+ const editsByPath = new Map();
228
308
  for (const e of blocks.edits || []) {
229
- const real = readFile(e.path);
309
+ if (!editsByPath.has(e.path)) editsByPath.set(e.path, []);
310
+ editsByPath.get(e.path).push(e);
311
+ }
312
+ for (const [repoPath, edits] of editsByPath) {
313
+ const real = readFile(repoPath);
230
314
  if (real == null) {
231
315
  throw new MaterializeError('file_not_found',
232
- `${e.path}: cannot apply edit — file not found in the working tree`);
316
+ `${repoPath}: cannot apply edit — file not found in HEAD`);
233
317
  }
234
- const candidate = applyEdit(real, e.oldString, e.newString, e.path); // throws typed on 0/many
318
+ const candidate = applyEdits(real, edits, repoPath); // throws typed on 0/many, naming the edit
235
319
  const t = tmpFile(candidate.endsWith('\n') ? candidate : candidate + '\n');
236
320
  try {
237
321
  const built = buildFileDiff({
238
- repoPath: e.path, isNew: false,
239
- runGitDiff: () => runGitDiff(e.path, t.path),
322
+ repoPath, isNew: false,
323
+ runGitDiff: () => runGitDiff(repoPath, t.path),
240
324
  });
241
325
  if (built.changed) parts.push(built.diff);
242
326
  } finally { t.cleanup(); }
@@ -372,6 +456,55 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
372
456
  };
373
457
  }
374
458
 
459
+ /**
460
+ * HC-GF-077 — the materialization IO for a diff that will be applied to HEAD.
461
+ *
462
+ * nodeMaterializeIO reads the working copy, and buildDiff hands git the original as a working-copy PATH.
463
+ * Every patch verb then applies the result to HEAD (verify-patch and emit-pr in a worktree cut from HEAD,
464
+ * check-patch now against an index read from HEAD). An editor leaves its edits in the working copy — the
465
+ * normal Path B flow — so the diff described a base it was never applied to. Measured on real runs: a
466
+ * byte-identical editor tree made verify-patch report "nothing to verify" with exit 0 for a failing suite,
467
+ * and a near-identical one made verify-patch and emit-pr report `does_not_apply` while check-patch said
468
+ * clean (6 of 6 runs).
469
+ *
470
+ * Same interface and the same temp-file/git-diff machinery as nodeMaterializeIO; only the three reads of
471
+ * the original change: existence (`git cat-file -e`), contents (`git show`), and the original handed to
472
+ * `git diff --no-index` (HEAD's blob in a temp file). Paths are repo-root-relative, as artifact paths are.
473
+ */
474
+ function headMaterializeIO({ cwd = process.cwd(), rev = 'HEAD' } = {}) {
475
+ const { execFileSync } = require('node:child_process');
476
+ const { gitEnv } = require('./git-env');
477
+ const base = nodeMaterializeIO({ cwd });
478
+ const opts = { cwd, env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] };
479
+ const show = (rel) => {
480
+ try { return execFileSync('git', ['show', `${rev}:${rel}`], { ...opts, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); }
481
+ catch { return null; }
482
+ };
483
+ return {
484
+ ...base,
485
+ exists: (rel) => { try { execFileSync('git', ['cat-file', '-e', `${rev}:${rel}`], opts); return true; } catch { return false; } },
486
+ readFile: show,
487
+ runGitDiff: (origArg, candPath) => {
488
+ if (origArg === '/dev/null') return base.runGitDiff(origArg, candPath);
489
+ const body = show(origArg);
490
+ if (body == null) return base.runGitDiff('/dev/null', candPath);
491
+ const t = base.tmpFile(body);
492
+ try { return base.runGitDiff(t.path, candPath); } finally { t.cleanup(); }
493
+ },
494
+ };
495
+ }
496
+
497
+ /**
498
+ * HC-GF-109 — whether parsed step output carries anything the patch verbs must materialize. One definition for
499
+ * check-patch, verify-patch and emit-pr, and it counts an edit block that failed to parse, so that output fails loudly
500
+ * in buildDiff instead of falling through to the legacy prose path.
501
+ */
502
+ function hasMaterializableWork(blocks) {
503
+ if (!blocks) return false;
504
+ return blocks.files.length > 0 || (blocks.edits || []).length > 0 ||
505
+ (blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
506
+ }
507
+
375
508
  module.exports = {
376
509
  extractFileBlocks,
377
510
  normalizeRepoPath,
@@ -379,7 +512,11 @@ module.exports = {
379
512
  buildFileDiff,
380
513
  buildDiff,
381
514
  nodeMaterializeIO,
515
+ headMaterializeIO,
382
516
  applyEdit,
517
+ applyEdits,
518
+ hasMaterializableWork,
519
+ EDIT_OPEN_RE,
383
520
  countOccurrences,
384
521
  ensureTrailingNewline,
385
522
  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 {
@@ -155,18 +164,39 @@ async function stepInstruction({ runId, cwd } = {}) {
155
164
  // hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
156
165
  // travels via a temp file (1MB-class payloads must not ride argv); the file
157
166
  // is best-effort deleted after the shell-out.
158
- async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd } = {}) {
167
+ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
159
168
  if (!runId) return { isError: true, detail: 'runId is required' };
160
169
  if (!stepKey) return { isError: true, detail: 'stepKey is required' };
161
- if (typeof output !== 'string' || output.trim() === '') {
162
- return { isError: true, detail: 'output is required (the complete step output text)' };
170
+
171
+ // HC-GF-130: DELEGATE the from-tree render to the CLI rather than rendering here and submitting
172
+ // the result with `--file`. That is what shipped in HC-GF-091, and HC-GF-092's guard — which
173
+ // refuses `--contract 2` without `--from-tree` — turned the one caller that HAD built from the
174
+ // tree away. The guard reads the flag, not the provenance, so the adopter's primary path was
175
+ // blocked while the CLI fallback worked. Delegating also removes a second producer of the same
176
+ // artifact, which is the shape HC-GF-127 had to unpick across three parsers.
177
+ const hasOutput = typeof output === 'string' && output.trim() !== '';
178
+ if (fromTree && hasOutput) {
179
+ return { isError: true, detail: 'pass output OR fromTree, not both: they are two different claims about where the build is' };
163
180
  }
164
- const tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
165
- try {
181
+ if (!fromTree && !hasOutput) {
182
+ return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
183
+ }
184
+
185
+ const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
186
+ let tmp = null;
187
+ if (fromTree) {
188
+ base.push('--from-tree');
189
+ // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
190
+ if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
191
+ } else {
192
+ // Output travels via a temp file — 1MB-class payloads must not ride argv.
193
+ tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
166
194
  fsSync.writeFileSync(tmp, output, 'utf8');
167
- const r = await runHone(
168
- ['submit-step', String(runId), String(stepKey), '--file', tmp, '--contract', String(contractVersion), '--format', 'json'],
169
- { cwd });
195
+ base.push('--file', tmp);
196
+ }
197
+
198
+ try {
199
+ const r = await module.exports._internal.runHone(base, { cwd });
170
200
  if (r.error) return { isError: true, detail: r.error };
171
201
  const data = parseLastJson(r.stdout);
172
202
  return {
@@ -179,7 +209,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd }
179
209
  : (r.stderr || r.stdout || 'submission failed').slice(0, 500),
180
210
  };
181
211
  } finally {
182
- try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
212
+ if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
183
213
  }
184
214
  }
185
215
 
@@ -201,8 +231,10 @@ async function verifyPatch({ runId, report = false, cwd } = {}) {
201
231
  const r = await runHone(args, { cwd, timeoutMs: 600000 });
202
232
  if (r.error) return { isError: true, detail: r.error };
203
233
  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') };
234
+ // HC-GF-077: `passed` is the verdict's, never the exit code's. verify-patch exited 0 for a run on which nothing
235
+ // executed, and this reported it to the editor agent as `passed: true, detail: 'tests passed'`.
236
+ const verdict = data && (data.verdict || data.result) ? (data.verdict || data.result) : 'unknown';
237
+ 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
238
  }
207
239
 
208
240
  // hone_check_patch — does step_4's diff apply cleanly? (throwaway git apply --check)
@@ -235,8 +267,9 @@ async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
235
267
  const r = await runHone(args, { cwd, timeoutMs: 600000 });
236
268
  if (r.error) return { isError: true, detail: r.error };
237
269
  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') };
270
+ // HC-GF-077: exit 0 includes `disabled` (ci.gate: none) — a gate nobody ran is not a green one.
271
+ const verdict = data && data.verdict ? data.verdict : 'unknown';
272
+ 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
273
  }
241
274
 
242
275
  // hone_agent_eval — test the adopter's own agents (HC-COMM-011), optional report.
@@ -263,9 +296,15 @@ async function showStep({ runId, stepKey, cwd } = {}) {
263
296
  }
264
297
 
265
298
  // hone_approve — approve a paused human gate (run-story --approve).
266
- async function approve({ runId, stepKey, cwd } = {}) {
299
+ async function approve({ runId, stepKey, acknowledgeAbort = false, acknowledgeFailedVerdict = false, cwd } = {}) {
267
300
  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 });
301
+ if (!isRunId(String(runId))) return { isError: true, detail: notARunId(runId) }; // HC-GF-106: never approve a guessed run
302
+ // HC-GF-069 / HC-GF-074: an editor agent must be able to pass the same explicit acknowledgements a human can —
303
+ // and only when asked; the refusals exist because an unattended approver defeats a pause.
304
+ const args = ['run-story', String(runId), '--approve', String(stepKey)];
305
+ if (acknowledgeAbort === true) args.push('--acknowledge-abort');
306
+ if (acknowledgeFailedVerdict === true) args.push('--acknowledge-failed-verdict');
307
+ const r = await runHone(args, { cwd });
269
308
  if (r.error) return { isError: true, detail: r.error };
270
309
  return { isError: !r.ok, exitCode: r.exitCode, detail: (r.ok ? `approved ${stepKey}` : (r.stderr || r.stdout || 'approve failed')).slice(0, 500) };
271
310
  }
@@ -343,7 +382,10 @@ const TOOLS = [
343
382
  name: 'hone_approve',
344
383
  description: 'Approve a paused human gate on a run so it advances (e.g. stepKey "step_4").',
345
384
  readOnly: false,
346
- inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string' } }, required: ['runId', 'stepKey'] },
385
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string' },
386
+ acknowledgeAbort: { type: 'boolean', default: false, description: 'Approve a step the agent aborted (read its reason first).' },
387
+ acknowledgeFailedVerdict: { type: 'boolean', default: false, description: 'Approve a step whose verification did not pass under enforce (read the failing gates first).' } },
388
+ required: ['runId', 'stepKey'] },
347
389
  handler: approve,
348
390
  },
349
391
  {
@@ -411,10 +453,12 @@ const TOOLS = [
411
453
  properties: {
412
454
  runId: { type: 'string' },
413
455
  stepKey: { type: 'string' },
414
- output: { type: 'string' },
456
+ output: { type: 'string', description: 'The complete step output text. Omit when fromTree is true.' },
415
457
  contractVersion: { type: 'string', default: '1' },
458
+ fromTree: { type: 'boolean', default: false, description: 'HC-GF-091: render the artifact from the working tree against HEAD — write the files with your own tools first, then submit. Removes the single-response output ceiling.' },
459
+ testReport: { type: 'string', description: 'With fromTree: your test-run report, carried verbatim. Omit if you did not run them; it is never invented for you.' },
416
460
  },
417
- required: ['runId', 'stepKey', 'output'],
461
+ required: ['runId', 'stepKey'],
418
462
  },
419
463
  handler: submitStep,
420
464
  },
@@ -434,4 +478,6 @@ module.exports = {
434
478
  killWorkflow,
435
479
  runStory, status, verifyPatch, checkPatch, showStep, approve, emitPr, verifyPr, agentEval, derive, sync, doctor,
436
480
  runHone, parseRunStarted, parseLastJson, honeBin, readHoneConfig, TOOLS,
481
+ // HC-GF-130: exposed so a test can observe the argv handed to the CLI without running it.
482
+ _internal: { runHone },
437
483
  };
@@ -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 };