@hone-ai/cli 1.34.0 → 1.35.1

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 (46) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1341 -795
  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/include-paths.js +155 -0
  21. package/lib/learnings-parse.js +97 -7
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/mcp-tools.js +20 -14
  24. package/lib/pipeline-config.js +182 -0
  25. package/lib/private-temp.js +52 -0
  26. package/lib/promote-payload.js +77 -0
  27. package/lib/refresh-knowledge.js +15 -6
  28. package/lib/repo-tree.js +22 -3
  29. package/lib/run-context.js +698 -0
  30. package/lib/run-target.js +66 -1
  31. package/lib/sandbox-lifecycle.js +357 -0
  32. package/lib/schedule-cron.js +1 -1
  33. package/lib/skill-assertions.js +61 -7
  34. package/lib/skill-audit-render.js +1 -1
  35. package/lib/story-classifier-extract.js +16 -8
  36. package/lib/story-classifier.js +64 -0
  37. package/lib/story-compose.js +69 -0
  38. package/lib/story-line.js +40 -0
  39. package/lib/story-provenance.js +267 -0
  40. package/lib/timeout-option.js +42 -0
  41. package/lib/untrusted-code-env.js +51 -0
  42. package/lib/verdict-env.js +31 -0
  43. package/lib/verify-patch.js +74 -4
  44. package/lib/verify-pr.js +2 -1
  45. package/lib/worktree-provision.js +62 -2
  46. package/package.json +1 -1
@@ -22,33 +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
- /**
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;
38
+ const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE } = require('./block-grammar');
52
39
 
53
40
  /**
54
41
  * The line-anchored closer necessarily absorbs the newline that terminates the
@@ -60,39 +47,6 @@ function blockBody(raw) {
60
47
  return String(raw == null ? '' : raw).replace(/\r?\n$/, '');
61
48
  }
62
49
 
63
- /**
64
- * A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
65
- * bundle whole (>25K chars, truncated/windowed), step_4 cannot emit a whole-file
66
- * block (it never saw the whole file). Instead it emits a REGION edit: a unique
67
- * OLD anchor (from the excerpt it WAS shown) and its NEW replacement, framed with
68
- * conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and
69
- * lets git build the diff — so the count stays git's, exactly as for whole-file
70
- * blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
71
- */
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;
84
-
85
- /**
86
- * The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
87
- * Whitespace after the marker word is tolerated; the bodies are captured verbatim
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.
90
- */
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;
92
-
93
- /** A `DELETE: <path>` line inside the ## Changed Files section. */
94
- const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
95
-
96
50
  /**
97
51
  * Typed error for the region-edit apply path, so a consumer can map it to a
98
52
  * distinct exit code and a message that tells step_4 how to fix its anchor.
@@ -119,15 +73,15 @@ class MaterializeError extends Error {
119
73
  * reason: string|null
120
74
  * }}
121
75
  */
122
- function extractFileBlocks(output) {
123
- 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) {
124
83
  const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
125
84
 
126
- // Scope everything to the ## Changed Files section if present, so a code
127
- // sample elsewhere in the prose is never mistaken for an intended file.
128
- const secIdx = text.search(/^##\s+Changed Files/im);
129
- const scope = secIdx === -1 ? text : text.slice(secIdx);
130
-
131
85
  // Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
132
86
  // declaration only counts when there is NO file AND NO edit block to apply.
133
87
  const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
@@ -158,10 +112,10 @@ function extractFileBlocks(output) {
158
112
  const parsedAt = new Set();
159
113
  for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
160
114
  parsedAt.add(m.index);
161
- const rel = normalizeRepoPath(m[1]);
115
+ const rel = normalizeRepoPath(m[2]); // HC-GF-290: m[1] is the fence now
162
116
  if (!rel) continue;
163
117
  if (seen.has(rel)) continue; // a whole-file block wins
164
- const split = EDIT_SPLIT_RE.exec(m[2]);
118
+ const split = EDIT_SPLIT_RE.exec(m[3]);
165
119
  if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
166
120
  malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
167
121
  continue;
@@ -224,6 +178,49 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
224
178
  return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
225
179
  }
226
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
+
227
224
  /**
228
225
  * HC-GF-109 — apply every region edit for ONE file, as one change.
229
226
  *
@@ -241,9 +238,21 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
241
238
  * @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
242
239
  */
243
240
  function applyEdits(fileContents, edits, repoPath) {
244
- let out = String(fileContents);
241
+ const base = String(fileContents);
242
+ let out = base;
245
243
  edits.forEach((e, i) => {
246
- 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
+ }
247
256
  });
248
257
  return out;
249
258
  }
@@ -429,7 +438,6 @@ function ensureTrailingNewline(s) {
429
438
  */
430
439
  function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
431
440
  const fs = require('node:fs');
432
- const os = require('node:os');
433
441
  const p = require('node:path');
434
442
  const { execFileSync } = require('node:child_process');
435
443
  const { gitEnv } = require('./git-env');
@@ -441,9 +449,10 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
441
449
  return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
442
450
  },
443
451
  tmpFile: (contents) => {
444
- const tp = p.join(os.tmpdir(), `hone-mat-${process.pid}-${Date.now()}-${n++}`);
445
- fs.writeFileSync(tp, contents);
446
- 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 };
447
456
  },
448
457
  runGitDiff: (origArg, candPath) => {
449
458
  let stdout = '', code = 0;
@@ -505,6 +514,51 @@ function hasMaterializableWork(blocks) {
505
514
  (blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
506
515
  }
507
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
+
508
562
  module.exports = {
509
563
  extractFileBlocks,
510
564
  normalizeRepoPath,
@@ -515,6 +569,10 @@ module.exports = {
515
569
  headMaterializeIO,
516
570
  applyEdit,
517
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,
518
576
  hasMaterializableWork,
519
577
  EDIT_OPEN_RE,
520
578
  countOccurrences,
package/lib/mcp-tools.js CHANGED
@@ -13,6 +13,7 @@ const { spawn } = require('node:child_process');
13
13
  const fsSync = require('node:fs');
14
14
  const os = require('node:os');
15
15
  const path = require('node:path');
16
+ const { privateTempFile } = require('./private-temp');
16
17
 
17
18
  // Resolve the CLI binary. Default `hone` (the installed bin); overridable for tests
18
19
  // / non-global installs via HONE_CLI_BIN (e.g. a path to hone-cli.js run with node).
@@ -24,12 +25,15 @@ function honeBin() {
24
25
  // default) — but WITHOUT exiting on a missing token (the MCP server must start and
25
26
  // report the problem via a preflight / hone_doctor, not die). Editor-agnostic:
26
27
  // works from any MCP client that passes HONE_TOKEN/HONE_API in the server env.
28
+ // HC-GF-304-followup-1: reports THAT a token exists, never the token. Nothing here sends one — every
29
+ // tool shells out to the CLI, which reads its own — and `token` was returned to callers that read
30
+ // only `hasToken`, one serialization away from the stderr preflight.
27
31
  function readHoneConfig() {
28
32
  let rc = {};
29
33
  try { rc = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.honerc'), 'utf8')) || {}; } catch { rc = {}; }
30
34
  const token = process.env.HONE_TOKEN || rc.token || null;
31
35
  const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
32
- return { token, apiUrl, hasToken: !!token };
36
+ return { apiUrl, hasToken: !!token };
33
37
  }
34
38
 
35
39
  // Run a `hone` subcommand, capturing stdout/stderr/exit. Never throws — returns a
@@ -163,7 +167,7 @@ async function stepInstruction({ runId, cwd } = {}) {
163
167
 
164
168
  // hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
165
169
  // travels via a temp file (1MB-class payloads must not ride argv); the file
166
- // is best-effort deleted after the shell-out.
170
+ // and its directory are best-effort deleted after the shell-out.
167
171
  async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
168
172
  if (!runId) return { isError: true, detail: 'runId is required' };
169
173
  if (!stepKey) return { isError: true, detail: 'stepKey is required' };
@@ -184,18 +188,20 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
184
188
 
185
189
  const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
186
190
  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`);
194
- fsSync.writeFileSync(tmp, output, 'utf8');
195
- base.push('--file', tmp);
196
- }
197
-
198
191
  try {
192
+ if (fromTree) {
193
+ base.push('--from-tree');
194
+ // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
195
+ if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
196
+ } else {
197
+ // Output travels via a temp file — 1MB-class payloads must not ride argv. HC-GF-304-followup-1: in a
198
+ // directory only this call created (mode 0700). The old pid-named file could not collide, but its
199
+ // name was predictable in a shared /tmp. Created inside the `try` so a failed write removes it too.
200
+ tmp = privateTempFile('hone-editor-output', 'output.txt');
201
+ fsSync.writeFileSync(tmp.file, output, 'utf8');
202
+ base.push('--file', tmp.file);
203
+ }
204
+
199
205
  const r = await module.exports._internal.runHone(base, { cwd });
200
206
  if (r.error) return { isError: true, detail: r.error };
201
207
  const data = parseLastJson(r.stdout);
@@ -209,7 +215,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
209
215
  : (r.stderr || r.stdout || 'submission failed').slice(0, 500),
210
216
  };
211
217
  } finally {
212
- if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
218
+ if (tmp) tmp.remove(); // the directory, not just the file; never throws
213
219
  }
214
220
  }
215
221
 
@@ -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,52 @@
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
+ * HC-CI-081: `remove` is a recursive delete of a computed path in a root every process of this user shares, so
17
+ * it deletes only a directory shaped like the one `mkdtempSync` made for it: a direct child of the temp root,
18
+ * named `<prefix>-` plus mkdtemp's six characters. On 2026-09-26 a local mutation run replaced the mkdtemp with
19
+ * `os.tmpdir()` and left `remove` as it was, so each call was `rm -rf $TMPDIR`. It ran outside the sandbox, in
20
+ * the tmpdir the self-hosted runners share, and deleted three other tests' directories mid-run on runner 2
21
+ * (HC-CI-063's, HC-CI-064's and HC-GF-098's; run 36211682382, attempt 1).
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ /**
29
+ * @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
30
+ * @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
31
+ * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws; a directory
32
+ * it did not make is left alone
33
+ */
34
+ function privateTempFile(prefix, name) {
35
+ const root = path.resolve(os.tmpdir());
36
+ const dir = fs.mkdtempSync(path.join(root, `${prefix}-`));
37
+ return {
38
+ file: path.join(dir, name),
39
+ remove: () => {
40
+ if (!madeBy(root, prefix, dir)) return;
41
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
42
+ },
43
+ };
44
+ }
45
+
46
+ /** Whether `dir` is what `mkdtempSync(path.join(root, prefix + '-'))` returns: that prefix, six more characters. */
47
+ function madeBy(root, prefix, dir) {
48
+ const base = path.basename(dir);
49
+ return path.dirname(dir) === root && base.startsWith(`${prefix}-`) && base.length === prefix.length + 7;
50
+ }
51
+
52
+ module.exports = { privateTempFile };