@hone-ai/cli 1.33.0 → 1.35.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.
Files changed (48) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1289 -759
  3. package/lib/agent-eval-pacing.js +102 -0
  4. package/lib/agent-eval-probes-boundary.js +0 -0
  5. package/lib/agent-eval-probes-stateful.js +72 -0
  6. package/lib/agent-eval-score.js +70 -0
  7. package/lib/agent-executor.js +26 -4
  8. package/lib/asset-fetch-list.js +72 -0
  9. package/lib/auto-verify.js +67 -3
  10. package/lib/batch-follow.js +333 -0
  11. package/lib/block-grammar.js +126 -0
  12. package/lib/bundle-context.js +43 -4
  13. package/lib/bundle-paths.js +698 -1
  14. package/lib/bundle-source-dirs.js +79 -0
  15. package/lib/doctor-bind-default.js +192 -18
  16. package/lib/doctor-local-run.js +139 -1
  17. package/lib/emit-pr.js +39 -6
  18. package/lib/git-helpers.js +19 -8
  19. package/lib/hard-pause-remedy.js +101 -0
  20. package/lib/headroom.js +63 -0
  21. package/lib/include-paths.js +155 -0
  22. package/lib/learnings-parse.js +51 -6
  23. package/lib/materialize-diff.js +134 -64
  24. package/lib/mcp-tools.js +36 -11
  25. package/lib/pipeline-config.js +182 -0
  26. package/lib/private-temp.js +34 -0
  27. package/lib/promote-payload.js +77 -0
  28. package/lib/refresh-knowledge.js +15 -6
  29. package/lib/repo-tree.js +22 -3
  30. package/lib/run-context.js +698 -0
  31. package/lib/run-report.js +36 -0
  32. package/lib/sandbox-lifecycle.js +357 -0
  33. package/lib/schedule-cron.js +1 -1
  34. package/lib/skill-assertions.js +61 -7
  35. package/lib/skill-audit-render.js +1 -1
  36. package/lib/story-classifier-extract.js +16 -8
  37. package/lib/story-classifier.js +64 -0
  38. package/lib/story-compose.js +69 -0
  39. package/lib/story-line.js +40 -0
  40. package/lib/story-provenance.js +267 -0
  41. package/lib/timeout-option.js +42 -0
  42. package/lib/tree-artifact.js +166 -0
  43. package/lib/untrusted-code-env.js +51 -0
  44. package/lib/verdict-env.js +31 -0
  45. package/lib/verify-patch.js +74 -4
  46. package/lib/verify-pr.js +2 -1
  47. package/lib/worktree-provision.js +62 -2
  48. package/package.json +1 -1
@@ -22,22 +22,20 @@
22
22
  const path = require('node:path');
23
23
 
24
24
  /**
25
- * A ```file:<path> fenced block, capturing the path and the verbatim body.
26
- * Info-string tolerant: ```file:foo, ``` file: foo, ```FILE:foo all match.
25
+ * The block grammar — `file:` and `edit:` blocks, the OLD/NEW split, `DELETE:` lines — lives in
26
+ * block-grammar.js, a byte-identical copy of the server's, so the CLI that applies a build and the
27
+ * server that stores it read the same blocks (HC-GF-290). Its comments carry the history: HC-GF-022
28
+ * (an empty block), HC-GF-127 (a longer fence for a `file:`), HC-GF-109 (a framed edit body) and
29
+ * HC-GF-290 (a longer fence for an `edit:`).
27
30
  *
28
- * HC-GF-022: the closing fence is anchored to its OWN LINE (`^...$` under /m).
29
- * The previous form spelled the terminator `\r?\n``` `, which REQUIRED a newline
30
- * between the opener and the closer and so could not match an EMPTY block. On a
31
- * legitimately empty file (`flowforge/core/__init__.py` — every Python package
32
- * has one) the body then swallowed its own closing fence, matched the NEXT
33
- * block's opening fence as its terminator, and consumed it: the empty file was
34
- * written containing a literal ``` `, and the following file — 2,888 bytes of
35
- * pipeline logic — vanished from the diff entirely. `check-patch` reported
36
- * "applies cleanly", because the diff git built from the surviving blocks was
37
- * perfectly valid. Line-anchoring is also strictly better for a body that
38
- * contains an indented or info-tagged fence of its own.
31
+ * A `file:` block (HC-019n-followup-23) is a whole file. An `edit:` block (HC-019n-followup-27) is for
32
+ * a file too big to bundle whole (>25K chars, truncated/windowed): step_4 never saw the whole file,
33
+ * so it emits a unique OLD anchor from the excerpt it WAS shown and its NEW replacement, framed with
34
+ * conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and lets git build the
35
+ * diff — so the count stays git's, exactly as for whole-file blocks. See
36
+ * .github/pipeline/HC-019n-followup-27/architect.md.
39
37
  */
40
- const FILE_BLOCK_RE = /^[ \t]*```[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*\r?$/gim;
38
+ const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE } = require('./block-grammar');
41
39
 
42
40
  /**
43
41
  * The line-anchored closer necessarily absorbs the newline that terminates the
@@ -49,39 +47,6 @@ function blockBody(raw) {
49
47
  return String(raw == null ? '' : raw).replace(/\r?\n$/, '');
50
48
  }
51
49
 
52
- /**
53
- * A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
54
- * bundle whole (>25K chars, truncated/windowed), step_4 cannot emit a whole-file
55
- * block (it never saw the whole file). Instead it emits a REGION edit: a unique
56
- * OLD anchor (from the excerpt it WAS shown) and its NEW replacement, framed with
57
- * conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and
58
- * lets git build the diff — so the count stays git's, exactly as for whole-file
59
- * blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
60
- */
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;
73
-
74
- /**
75
- * The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
76
- * Whitespace after the marker word is tolerated; the bodies are captured verbatim
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.
79
- */
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;
81
-
82
- /** A `DELETE: <path>` line inside the ## Changed Files section. */
83
- const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
84
-
85
50
  /**
86
51
  * Typed error for the region-edit apply path, so a consumer can map it to a
87
52
  * distinct exit code and a message that tells step_4 how to fix its anchor.
@@ -108,15 +73,15 @@ class MaterializeError extends Error {
108
73
  * reason: string|null
109
74
  * }}
110
75
  */
111
- function extractFileBlocks(output) {
112
- const text = String(output || '');
76
+ /**
77
+ * HC-GF-191: the parser, over an ALREADY-SCOPED string. Split out so the same reading can be run
78
+ * twice — once over the mandated section, and once over the whole artifact when that section
79
+ * turned out to hold nothing. It used to compute its own scope, which made "what did the scope
80
+ * cost us?" a question no caller could ask.
81
+ */
82
+ function parseBlocksIn(scope) {
113
83
  const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
114
84
 
115
- // Scope everything to the ## Changed Files section if present, so a code
116
- // sample elsewhere in the prose is never mistaken for an intended file.
117
- const secIdx = text.search(/^##\s+Changed Files/im);
118
- const scope = secIdx === -1 ? text : text.slice(secIdx);
119
-
120
85
  // Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
121
86
  // declaration only counts when there is NO file AND NO edit block to apply.
122
87
  const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
@@ -129,11 +94,12 @@ function extractFileBlocks(output) {
129
94
  const files = [];
130
95
  const seen = new Set();
131
96
  for (const m of scope.matchAll(FILE_BLOCK_RE)) {
132
- const rel = normalizeRepoPath(m[1]);
97
+ // HC-GF-127: m[1] is the fence itself now, so the path and body moved to m[2]/m[3].
98
+ const rel = normalizeRepoPath(m[2]);
133
99
  if (!rel) continue; // rejected (traversal/absolute) — see below
134
100
  if (seen.has(rel)) continue; // first block wins on a dup path
135
101
  seen.add(rel);
136
- files.push({ path: rel, contents: blockBody(m[2]) });
102
+ files.push({ path: rel, contents: blockBody(m[3]) });
137
103
  }
138
104
 
139
105
  // HC-019n-followup-27: region edits for files too big to bundle whole.
@@ -146,10 +112,10 @@ function extractFileBlocks(output) {
146
112
  const parsedAt = new Set();
147
113
  for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
148
114
  parsedAt.add(m.index);
149
- const rel = normalizeRepoPath(m[1]);
115
+ const rel = normalizeRepoPath(m[2]); // HC-GF-290: m[1] is the fence now
150
116
  if (!rel) continue;
151
117
  if (seen.has(rel)) continue; // a whole-file block wins
152
- const split = EDIT_SPLIT_RE.exec(m[2]);
118
+ const split = EDIT_SPLIT_RE.exec(m[3]);
153
119
  if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
154
120
  malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
155
121
  continue;
@@ -212,6 +178,49 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
212
178
  return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
213
179
  }
214
180
 
181
+ /**
182
+ * HC-GF-265 — the two-index diagnosis. BYTE-IDENTICAL to the copy in
183
+ * server/src/services/carry-forward.js (the server cannot require the CLI package, so the
184
+ * wording is restated, not imported); parity is pinned by
185
+ * tests/regression/HC-GF-265-diagnosis-message-parity.test.js.
186
+ */
187
+ function destroyedAnchorSuffix(j, k, total) {
188
+ return `Edit ${j} of ${total} rewrote this anchor: it matched before edit ${j} was applied and not after. `
189
+ + `Merge edits ${j} and ${k} into ONE edit whose OLD is the text as it stands before either change.`;
190
+ }
191
+
192
+ /**
193
+ * HC-GF-265 — which EARLIER edit destroyed edit k's anchor. Returns a 0-based index, or null.
194
+ *
195
+ * Runs only on the failure path, so an accept/reject outcome cannot change: nothing is
196
+ * pre-checked. Edit ORDER is load-bearing (HC-GF-109, run 1a8940af), so the earlier edits are
197
+ * REPLAYED over the original base rather than matched against it — which is why run 011f04c7,
198
+ * where two edits share an OLD first line and both apply, is never touched.
199
+ *
200
+ * null means "today's message": the anchor matched at no stage of the replay (the HC-GF-222 shape,
201
+ * explicitly out of scope), the anchor is empty, or no earlier edit stopped it matching. An anchor
202
+ * absent from the ORIGINAL file is still replayed: an earlier edit may have inserted it (HC-GF-109). Never fabricates
203
+ * a pair. The LAST >0→0 transition wins: if edit j destroys the anchor and a later edit
204
+ * re-inserts it, the culprit is the edit after the re-insertion, not j.
205
+ */
206
+ function diagnoseDestroyedAnchor(baseContents, edits, k) {
207
+ const anchor = String((edits[k] && edits[k].oldString) || '');
208
+ if (!anchor || k < 1) return null;
209
+ let cur = String(baseContents);
210
+ let lastBreak = null;
211
+ for (let j = 0; j < k; j++) {
212
+ let next;
213
+ try {
214
+ next = applyEdit(cur, edits[j].oldString, edits[j].newString, '');
215
+ } catch {
216
+ return lastBreak; // replay diverged — report only what is established, never a guess
217
+ }
218
+ if (countOccurrences(cur, anchor) > 0 && countOccurrences(next, anchor) === 0) lastBreak = j;
219
+ cur = next;
220
+ }
221
+ return lastBreak;
222
+ }
223
+
215
224
  /**
216
225
  * HC-GF-109 — apply every region edit for ONE file, as one change.
217
226
  *
@@ -229,9 +238,21 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
229
238
  * @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
230
239
  */
231
240
  function applyEdits(fileContents, edits, repoPath) {
232
- let out = String(fileContents);
241
+ const base = String(fileContents);
242
+ let out = base;
233
243
  edits.forEach((e, i) => {
234
- out = applyEdit(out, e.oldString, e.newString, edits.length > 1 ? `${repoPath} (edit ${i + 1} of ${edits.length})` : repoPath);
244
+ const where = edits.length > 1 ? `${repoPath} (edit ${i + 1} of ${edits.length})` : repoPath;
245
+ try {
246
+ out = applyEdit(out, e.oldString, e.newString, where);
247
+ } catch (err) {
248
+ // HC-GF-265: only the not-found case is diagnosed. `anchor_ambiguous` already names the
249
+ // edit and is out of scope (HC-GF-109 pins its message), so it is rethrown untouched.
250
+ if (!(err instanceof MaterializeError) || err.code !== 'anchor_not_found') throw err;
251
+ const j = diagnoseDestroyedAnchor(base, edits, i);
252
+ if (j === null) throw err;
253
+ throw new MaterializeError('anchor_not_found',
254
+ `${err.message} ${destroyedAnchorSuffix(j + 1, i + 1, edits.length)}`);
255
+ }
235
256
  });
236
257
  return out;
237
258
  }
@@ -417,7 +438,6 @@ function ensureTrailingNewline(s) {
417
438
  */
418
439
  function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
419
440
  const fs = require('node:fs');
420
- const os = require('node:os');
421
441
  const p = require('node:path');
422
442
  const { execFileSync } = require('node:child_process');
423
443
  const { gitEnv } = require('./git-env');
@@ -429,9 +449,10 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
429
449
  return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
430
450
  },
431
451
  tmpFile: (contents) => {
432
- const tp = p.join(os.tmpdir(), `hone-mat-${process.pid}-${Date.now()}-${n++}`);
433
- fs.writeFileSync(tp, contents);
434
- return { path: tp, cleanup: () => { try { fs.unlinkSync(tp); } catch { /* best-effort */ } } };
452
+ // HC-GF-304: its own directory, not a name in the shared one that another local user can predict and plant.
453
+ const t = require('./private-temp').privateTempFile('hone-mat', `candidate-${n++}`);
454
+ fs.writeFileSync(t.file, contents);
455
+ return { path: t.file, cleanup: t.remove };
435
456
  },
436
457
  runGitDiff: (origArg, candPath) => {
437
458
  let stdout = '', code = 0;
@@ -493,6 +514,51 @@ function hasMaterializableWork(blocks) {
493
514
  (blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
494
515
  }
495
516
 
517
+
518
+ /**
519
+ * Read the file / edit / delete blocks an agent emitted.
520
+ *
521
+ * Scoped to `## Changed Files` when that heading exists, so a code sample elsewhere in the prose
522
+ * is never mistaken for an intended file — the guard is right and is kept.
523
+ *
524
+ * HC-GF-191: what the guard could not survive is the heading arriving AFTER the blocks. On run
525
+ * 44a0c69f the builder emitted a complete implementation under `## Implementation Code` and then a
526
+ * `## Changed Files` SUMMARY below it, so the slice began past every block: 0 files, 0 edits, and
527
+ * 0 malformed. verify-patch reported "produced no file changes to verify" for a finished build,
528
+ * with nothing anywhere saying a section had been skipped.
529
+ *
530
+ * The fallback is narrow on purpose. It fires ONLY when the scoped section yields nothing, so
531
+ * wherever the mandated section holds real blocks there is nothing to confuse them with and the
532
+ * scope still wins. And it is never silent: `scopeFallback` plus a reason name the deviation,
533
+ * because a guard that quietly changes what it read is precisely how this defect reached a run.
534
+ *
535
+ * @param {string} output raw agent artifact
536
+ * @returns {{files: Array, edits: Array, deletes: Array, malformed: Array, noChanges: boolean,
537
+ * reason: string|null, scopeFallback?: boolean}}
538
+ */
539
+ function extractFileBlocks(output) {
540
+ const text = String(output || '');
541
+ const secIdx = text.search(/^##\s+Changed Files/im);
542
+ if (secIdx === -1) return parseBlocksIn(text);
543
+
544
+ const scoped = parseBlocksIn(text.slice(secIdx));
545
+ if (hasMaterializableWork(scoped) || scoped.noChanges) return scoped;
546
+
547
+ // The mandated section held nothing. Before calling that "no changes", look at the whole
548
+ // artifact — the difference between "there is nothing to apply" and "what there is, is
549
+ // somewhere else" is the difference between an honest no-op and a lost build.
550
+ const whole = parseBlocksIn(text);
551
+ if (!hasMaterializableWork(whole)) return scoped;
552
+
553
+ return {
554
+ ...whole,
555
+ scopeFallback: true,
556
+ reason: 'blocks were found OUTSIDE the `## Changed Files` section and read from the whole '
557
+ + 'artifact instead. The code-builder contract puts them under that heading; emitting them '
558
+ + 'above it (or under another heading) would otherwise be read as "no changes".',
559
+ };
560
+ }
561
+
496
562
  module.exports = {
497
563
  extractFileBlocks,
498
564
  normalizeRepoPath,
@@ -503,6 +569,10 @@ module.exports = {
503
569
  headMaterializeIO,
504
570
  applyEdit,
505
571
  applyEdits,
572
+ // HC-GF-265: exported so the resolved pair is asserted by BEHAVIOUR and so the wording can be
573
+ // compared against carry-forward.js's copy directly, not inferred from a message regex.
574
+ destroyedAnchorSuffix,
575
+ diagnoseDestroyedAnchor,
506
576
  hasMaterializableWork,
507
577
  EDIT_OPEN_RE,
508
578
  countOccurrences,
package/lib/mcp-tools.js CHANGED
@@ -164,18 +164,39 @@ async function stepInstruction({ runId, cwd } = {}) {
164
164
  // hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
165
165
  // travels via a temp file (1MB-class payloads must not ride argv); the file
166
166
  // is best-effort deleted after the shell-out.
167
- async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd } = {}) {
167
+ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
168
168
  if (!runId) return { isError: true, detail: 'runId is required' };
169
169
  if (!stepKey) return { isError: true, detail: 'stepKey is required' };
170
- if (typeof output !== 'string' || output.trim() === '') {
171
- 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' };
172
180
  }
173
- const tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
174
- 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`);
175
194
  fsSync.writeFileSync(tmp, output, 'utf8');
176
- const r = await runHone(
177
- ['submit-step', String(runId), String(stepKey), '--file', tmp, '--contract', String(contractVersion), '--format', 'json'],
178
- { cwd });
195
+ base.push('--file', tmp);
196
+ }
197
+
198
+ try {
199
+ const r = await module.exports._internal.runHone(base, { cwd });
179
200
  if (r.error) return { isError: true, detail: r.error };
180
201
  const data = parseLastJson(r.stdout);
181
202
  return {
@@ -188,7 +209,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd }
188
209
  : (r.stderr || r.stdout || 'submission failed').slice(0, 500),
189
210
  };
190
211
  } finally {
191
- try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
212
+ if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
192
213
  }
193
214
  }
194
215
 
@@ -432,10 +453,12 @@ const TOOLS = [
432
453
  properties: {
433
454
  runId: { type: 'string' },
434
455
  stepKey: { type: 'string' },
435
- output: { type: 'string' },
456
+ output: { type: 'string', description: 'The complete step output text. Omit when fromTree is true.' },
436
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.' },
437
460
  },
438
- required: ['runId', 'stepKey', 'output'],
461
+ required: ['runId', 'stepKey'],
439
462
  },
440
463
  handler: submitStep,
441
464
  },
@@ -455,4 +478,6 @@ module.exports = {
455
478
  killWorkflow,
456
479
  runStory, status, verifyPatch, checkPatch, showStep, approve, emitPr, verifyPr, agentEval, derive, sync, doctor,
457
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 },
458
483
  };
@@ -388,6 +388,47 @@ function readExecutionMode(repoRoot) {
388
388
  return DEFAULT_EXECUTION_MODE;
389
389
  }
390
390
 
391
+ /**
392
+ * HC-GF-097: the execution mode AS WRITTEN, beside the normalized one. `readExecutionMode` maps any
393
+ * value it does not know to 'server', which is the right default for a run an operator is
394
+ * watching — and the wrong one for an unattended overnight batch, where "server" is a guess about a
395
+ * typo nobody will see. queue-stories refuses `--overnight` on an unrecognised mode, so it has to
396
+ * be able to tell `mode: edtior` from no `mode:` at all. Same candidate order as readExecutionMode.
397
+ *
398
+ * @param {string} repoRoot
399
+ * @returns {{ raw: string|null, mode: 'server'|'editor', recognized: boolean }}
400
+ * raw is null when no config declares a mode (the default is a recognised state).
401
+ */
402
+ function readExecutionModeRaw(repoRoot) {
403
+ const none = { raw: null, mode: DEFAULT_EXECUTION_MODE, recognized: true };
404
+ if (!repoRoot || typeof repoRoot !== 'string') return none;
405
+ const candidates = [
406
+ path.join(repoRoot, '.pipeline-config.yml'),
407
+ path.join(repoRoot, '.github/.pipeline-config.yml'),
408
+ ];
409
+ for (const p of candidates) {
410
+ if (!fs.existsSync(p)) continue;
411
+ let raw;
412
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
413
+ let parsed;
414
+ try {
415
+ const yaml = require('js-yaml');
416
+ parsed = yaml.load(raw);
417
+ } catch { continue; }
418
+ if (!parsed || typeof parsed !== 'object') continue;
419
+ const block = parsed.execution;
420
+ if (!block || typeof block !== 'object') continue;
421
+ if (block.mode === undefined) continue;
422
+ const written = String(block.mode == null ? '' : block.mode);
423
+ return {
424
+ raw: written,
425
+ mode: normalizeExecutionMode(block.mode),
426
+ recognized: EXECUTION_MODES.includes(written.toLowerCase().trim()),
427
+ };
428
+ }
429
+ return none;
430
+ }
431
+
391
432
  // ── HC-019n-followup-34: closed-loop verification gate ────────────────────────
392
433
  // The CLI verbs (verify-patch/verify-pr) EXECUTE and report a verdict to the
393
434
  // server. `verification.gate` controls what the server does with it:
@@ -542,7 +583,147 @@ function readAgentEvalConfig(repoRoot) {
542
583
  return { targets: [] };
543
584
  }
544
585
 
586
+
587
+ // ── HC-GF-169: the adopter-declared scratch datastore ────────────────────────────────────────
588
+ //
589
+ // `.pipeline-config.yml` had no data-layer concept, so no step could validate behaviour needing
590
+ // stored state — which is most of an adopter's backend. The contract is the one `ci.local_command`
591
+ // already uses: Hone cannot know their datastore, so the adopter declares it and the CLI executes
592
+ // it adopter-side, where `verify-patch` already runs with their credentials.
593
+ //
594
+ // THE GUARD IS THE LOAD-BEARING PART. Seeding test rows into a customer's production database is
595
+ // the worst outcome this design can produce, so `verify` is REQUIRED whenever commands are declared
596
+ // and the assertion reads the LIVE CONNECTION, never the URL. A URL can spell `_sandbox` and point
597
+ // anywhere — DNS, a pooler, a tunnel, an overridden PGDATABASE.
598
+ //
599
+ // HC-GF-183, learned from the first live run: THE DATABASE AND THE VERIFY TABLE MUST ALREADY
600
+ // EXIST. The probe runs BEFORE `create` — deliberately, because proving a target is scratch has to
601
+ // precede writing to it — so there is nothing for it to read on a fresh database. That makes
602
+ // `create` responsible for the TABLES, not the database: an adopter writing `create: createdb foo`
603
+ // is refused on their first run with nothing explaining why. Provision the database itself once,
604
+ // out of band, then let `create` build the schema inside it.
605
+ //
606
+ // And `reset` must leave the sandbox PROBEABLE. The CLI runs `reset` then `create` as the teardown
607
+ // (see cli/lib/sandbox-lifecycle.js) precisely so a reset that drops the schema cannot brick the
608
+ // next run — which is exactly what happened the first time this was declared against a real
609
+ // database.
610
+ //
611
+ // Wired since HC-GF-171: verify-patch probes, guards, seeds and tears down.
612
+ const SANDBOX_COMMAND_KEYS = Object.freeze(['create', 'migrate', 'reset']);
613
+
614
+ /**
615
+ * @param {string} repoRoot
616
+ * @returns {null | {create: string|null, migrate: string|null, reset: string|null,
617
+ * capabilities: string[], verify: {database: string, table: string, max_rows: number}}}
618
+ * @throws when a sandbox declares commands without a usable `verify` block.
619
+ */
620
+ function readSandboxConfig(repoRoot) {
621
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
622
+ for (const p of [path.join(repoRoot, '.pipeline-config.yml'),
623
+ path.join(repoRoot, '.github/.pipeline-config.yml')]) {
624
+ if (!fs.existsSync(p)) continue;
625
+ let parsed;
626
+ try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
627
+ const block = parsed && typeof parsed === 'object' ? parsed.sandbox : null;
628
+ if (!block || typeof block !== 'object') continue;
629
+
630
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
631
+ const commands = {};
632
+ for (const k of SANDBOX_COMMAND_KEYS) commands[k] = str(block[k]);
633
+ const declaresWork = SANDBOX_COMMAND_KEYS.some(k => commands[k]);
634
+
635
+ const v = block.verify && typeof block.verify === 'object' ? block.verify : null;
636
+ // HC-GF-171: `probe` is the command that PRODUCES the evidence `assertScratchTarget` demands.
637
+ // Carried through here, required at ENGAGEMENT (see cli/lib/sandbox-lifecycle.js) rather than
638
+ // at parse time: a declaration missing it is incomplete, not malformed, and the refusal is far
639
+ // more useful at the moment the sandbox would have fired — it can name what to add.
640
+ const verify = v && str(v.database) && str(v.table) && Number.isFinite(Number(v.max_rows))
641
+ ? { database: str(v.database), table: str(v.table), max_rows: Number(v.max_rows),
642
+ probe: str(v.probe) }
643
+ : null;
644
+
645
+ if (declaresWork && !verify) {
646
+ throw new Error(
647
+ `${p}: sandbox declares ${SANDBOX_COMMAND_KEYS.filter(k => commands[k]).join('/')} but no `
648
+ + 'usable `verify` block (needs database, table and max_rows). Without it the CLI cannot '
649
+ + 'prove the target is scratch before writing to it, and an optional guard on this action '
650
+ + 'is worse than no feature.');
651
+ }
652
+ if (!declaresWork && !verify) return null;
653
+
654
+ const capabilities = Array.isArray(block.capabilities)
655
+ ? block.capabilities.map(str).filter(Boolean) : [];
656
+
657
+ // HC-GF-192: `test` is the command that READS the sandbox, and `env` is what it reads the
658
+ // connection from. Carried here and required at ENGAGEMENT, for the same reason `probe` is: a
659
+ // declaration missing them is incomplete rather than malformed, and the refusal is far more
660
+ // useful at the moment the sandbox would have fired, where it can name what to add.
661
+ //
662
+ // Shape is checked in the plan, not here — `env: DATABASE_URL=…` is valid YAML (a string) and
663
+ // an invalid env map, and refusing it next to the other engagement refusals keeps one place
664
+ // that decides whether this sandbox can be used.
665
+ const test = str(block.test);
666
+ // Passed through UNVALIDATED, deliberately. An earlier draft tested the type here and then
667
+ // returned `block.env` on both branches — a guard that read like validation and changed no
668
+ // outcome, which is the shape this whole chain keeps finding. The real check is
669
+ // `plainStringMap` in sandbox-lifecycle.js, where a bad `env` becomes a refusal that names the
670
+ // fix; duplicating it here would mean two places deciding, and eventually disagreeing.
671
+ const env = block.env === undefined ? null : block.env;
672
+
673
+ return { ...commands, capabilities, verify, test, env };
674
+ }
675
+ return null;
676
+ }
677
+
678
+ /**
679
+ * Refuse unless the LIVE connection proves the target is scratch.
680
+ *
681
+ * @param {{database: string, table: string, max_rows: number}} verify what the adopter declared
682
+ * @param {{database?: string, rows?: number, url?: string}} probe what the SERVER reported
683
+ * @throws when the probe is absent, incomplete, names another database, or the target is populated.
684
+ */
685
+ function assertScratchTarget(verify, probe) {
686
+ if (!verify || typeof verify !== 'object') {
687
+ throw new Error('sandbox verify block missing — refusing to treat any target as scratch');
688
+ }
689
+ if (!probe || typeof probe !== 'object') {
690
+ throw new Error('no live probe — refusing to write. "Could not check" is not "checked and '
691
+ + 'found nothing"; run the probe or stop.');
692
+ }
693
+ // `probe.url` is deliberately ignored. It is accepted only so a caller passing it for logging
694
+ // cannot mistake this for a check of it.
695
+ const live = typeof probe.database === 'string' ? probe.database.trim() : null;
696
+ if (!live) {
697
+ throw new Error('the probe reported no database name — refusing. The name must come from the '
698
+ + 'server (e.g. `SELECT current_database()`), not from the connection string.');
699
+ }
700
+ if (live !== verify.database) {
701
+ throw new Error(
702
+ `refusing to write: the live connection reports database "${live}", but the sandbox declares `
703
+ + `"${verify.database}". A connection string can spell the sandbox name and still point `
704
+ + 'elsewhere.');
705
+ }
706
+ // A real NUMBER, not something that coerces to one. `Number(null)`, `Number('')`,
707
+ // `Number(false)` and `Number([])` are all 0, so a probe that reported nothing would read as an
708
+ // empty database and be allowed — the exact "could not check treated as checked" this refuses.
709
+ if (typeof probe.rows !== 'number' || !Number.isFinite(probe.rows)) {
710
+ throw new Error(`refusing to write to "${live}": no row count was probed `
711
+ + `(got ${JSON.stringify(probe.rows)}). A name match alone is not proof — a restored copy of `
712
+ + 'production can carry the scratch name.');
713
+ }
714
+ const rows = probe.rows;
715
+ if (rows > verify.max_rows) {
716
+ throw new Error(
717
+ `refusing to write to "${live}": ${verify.table} holds ${rows} rows, above the declared `
718
+ + `ceiling of ${verify.max_rows}. This looks like real data.`);
719
+ }
720
+ return true;
721
+ }
722
+
545
723
  module.exports = {
724
+ readSandboxConfig,
725
+ assertScratchTarget,
726
+ SANDBOX_COMMAND_KEYS,
546
727
  readSourceDirs,
547
728
  readCodebaseDefaults,
548
729
  readCIConfigDetails,
@@ -551,6 +732,7 @@ module.exports = {
551
732
  EDITOR_MODE_CONSENT_NOTE,
552
733
  normalizeExecutionMode,
553
734
  readExecutionMode,
735
+ readExecutionModeRaw,
554
736
  readStoryClassifierConfig,
555
737
  KNOWN_THRESHOLD_KEYS,
556
738
  readCIGateConfig,
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ /**
3
+ * private-temp.js — HC-GF-304. A temp file no other process can share.
4
+ *
5
+ * check-patch, verify-patch and emit-pr named their diff `os.tmpdir()/hone-<verb>-${Date.now()}.diff`. Two of
6
+ * them started in the same millisecond on one machine (two worktrees, the MCP server beside the CLI, the
7
+ * regression suite's parallel files) wrote the SAME path, so one applied, verified or committed the other's
8
+ * patch. On the runner it was caught applying HC-GF-077's `src/app.js` diff inside HC-GF-098's test repository.
9
+ * A verify-patch that reads another run's diff can report a pass for a patch that is not its run's, and under
10
+ * `verification_gate: enforce` that pass approves step_4.
11
+ *
12
+ * A clock is not an identity. `fs.mkdtempSync` creates a new directory with a random suffix, atomically, with
13
+ * mode 0700, so the file inside it belongs to exactly one invocation whatever the time, and nobody else can
14
+ * plant or replace it.
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ /**
22
+ * @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
23
+ * @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
24
+ * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws
25
+ */
26
+ function privateTempFile(prefix, name) {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
28
+ return {
29
+ file: path.join(dir, name),
30
+ remove: () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } },
31
+ };
32
+ }
33
+
34
+ module.exports = { privateTempFile };