@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
@@ -32,7 +32,36 @@
32
32
  * Extensions mirror FILE_PATH_RE exactly. The trailing `\b` mirrors the
33
33
  * followup-13c fix so `.jsonl` is not truncated to `.js`.
34
34
  */
35
- const BARE_FILE_RE = /(?<![\w/.-])([a-zA-Z_][\w-]*\.(?:py|js|ts|jsx|tsx|go|rs|java|rb|sh|sql))\b(?::(\d+))?/g;
35
+ // ── HC-GF-202: what counts as a file reference ───────────────────────────────
36
+ // Widening this is deliberate and load-bearing: it decides what EVERY run
37
+ // bundles. It was measured, not guessed — three live runs of a story whose whole
38
+ // deliverable was a `Makefile` recipe bundled the Makefile zero times, because
39
+ // every pattern here required an extension from CODE_EXT.
40
+ //
41
+ // CONFIG_EXT and EXTENSIONLESS are the files a build story actually names.
42
+ // DOTFILES are listed explicitly rather than by a leading-dot rule, which would
43
+ // match `.env` and every other secret-shaped name.
44
+ // Markdown is deliberately absent: docs are not the source context an agent
45
+ // needs to write code, and every story mentions a `.md` file.
46
+ const CODE_EXT = 'py|js|ts|jsx|tsx|go|rs|java|rb|sh|sql';
47
+ // `yml`, `yaml` and `json` are deliberately ABSENT. HC-019n-followup-12 excluded
48
+ // them with a stated reason — bundling prose blows the context window — and a
49
+ // regression pins `ops/vals.yml` as not-detected. HC-GF-202's row asks for YAML
50
+ // config to be visible; that collides with a decision made for a measured cost,
51
+ // and the row's own acceptance is the EXTENSIONLESS half. `json` is excluded on
52
+ // the same logic plus a second: lockfiles are the largest files in most repos.
53
+ // A story that genuinely needs one names it with --include-paths, which HC-GF-202
54
+ // also repairs. Revisit only with a measurement of what it costs.
55
+ const CONFIG_EXT = 'toml|cfg|ini|conf|mk';
56
+ const FILE_EXT = `${CODE_EXT}|${CONFIG_EXT}`;
57
+ // Case-sensitive on purpose: `Makefile` is a filename, `makefile approach` is prose.
58
+ const EXTENSIONLESS = 'Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Jenkinsfile|Vagrantfile|Justfile|Brewfile';
59
+ const DOTFILES = '\\.(?:gitignore|dockerignore|gitattributes|gitmodules|editorconfig|npmrc|nvmrc)';
60
+
61
+ const BARE_FILE_RE = new RegExp(
62
+ `(?<![\\w/.-])((?:[a-zA-Z_][\\w-]*\\.(?:${FILE_EXT})|(?:${EXTENSIONLESS})|(?:${DOTFILES})))\\b(?::(\\d+))?`,
63
+ 'g',
64
+ );
36
65
 
37
66
  /**
38
67
  * Extract bare filename references (no directory segment) with an optional
@@ -102,6 +131,60 @@ function windowAroundLine(content, line, radiusChars) {
102
131
  return { excerpt, startLine, endLine, totalLines: total };
103
132
  }
104
133
 
134
+ /**
135
+ * A window over explicit line bounds, for merging two that intersect.
136
+ * `windowAroundLine` centres on a line with a CHAR radius; a merge needs the
137
+ * union of two LINE ranges instead. HC-GF-206.
138
+ */
139
+ function windowBetweenLines(content, startLine, endLine) {
140
+ const all = String(content).split('\n');
141
+ const total = all.length;
142
+ const s = Math.max(1, Math.min(total, Number(startLine) || 1));
143
+ const e = Math.max(s, Math.min(total, Number(endLine) || s));
144
+ const excerpt = all.slice(s - 1, e).join('\n');
145
+ return { excerpt, startLine: s, endLine: e, totalLines: total };
146
+ }
147
+
148
+ /**
149
+ * Collapse candidate windows whose LINE RANGES intersect into their union.
150
+ *
151
+ * HC-GF-206: the near-duplicate collapse upstream compares ANCHOR distance
152
+ * (< 40 lines), but window width is set by `radius`, far larger at a realistic
153
+ * cap — so two anchors 50 lines apart pass that check and produce windows that
154
+ * overlap almost entirely. The budget then buys the same bytes twice while
155
+ * distant regions are dropped for want of room, which is the exact failure
156
+ * HC-GF-204 was written to prevent. Cap-dependent and perverse: radius grows
157
+ * with the budget, so a larger cap wastes proportionally more of it.
158
+ *
159
+ * Merge rather than drop: the second window usually extends past the first, so
160
+ * discarding it would lose real ground. The union keeps both and costs less
161
+ * than the two separately.
162
+ *
163
+ * The while-loop matters. Merging A and B can produce a range that now
164
+ * intersects C, accepted earlier; a single pass would leave that pair behind.
165
+ */
166
+ function mergeIntersectingWindows(content, entries) {
167
+ const out = [];
168
+ for (const entry of entries) {
169
+ let cur = entry;
170
+ let i;
171
+ while ((i = out.findIndex((o) =>
172
+ cur.w.startLine <= o.w.endLine && o.w.startLine <= cur.w.endLine)) !== -1) {
173
+ const o = out.splice(i, 1)[0];
174
+ cur = {
175
+ w: windowBetweenLines(content,
176
+ Math.min(o.w.startLine, cur.w.startLine),
177
+ Math.max(o.w.endLine, cur.w.endLine)),
178
+ // Keep the earlier-listed (better-ranked) anchor as the region's label.
179
+ anchor: o.anchor != null ? o.anchor : cur.anchor,
180
+ line: o.line,
181
+ };
182
+ }
183
+ out.push(cur);
184
+ }
185
+ return out;
186
+ }
187
+
105
188
  /**
106
189
  * Resolve a bare filename against the repo's tracked file list.
107
190
  *
@@ -136,6 +219,620 @@ function alreadyCovered(candidate, existing) {
136
219
  return false;
137
220
  }
138
221
 
222
+
223
+ /**
224
+ * Full paths WITH an optional `:line` — HC-GF-188.
225
+ *
226
+ * The CLI's own FILE_PATH_RE ends at the extension, so a story writing
227
+ * `server/src/routes/_all.js:712` had its `:712` discarded and the windowing
228
+ * HC-019n-followup-27 built could never fire for it. A bare ref already carried its line
229
+ * (BARE_FILE_RE above captures one); a full path did not, and full paths are what stories
230
+ * normally write — so the capability fired on the rarer shape and never on the usual one.
231
+ *
232
+ * The path half of the pattern is UNCHANGED from the CLI's regex, deliberately. This decides
233
+ * what every run bundles, and widening it silently changes the context every agent receives.
234
+ * Only the optional `(?::(\d+))?` tail is new.
235
+ *
236
+ * @param {string} text
237
+ * @returns {Array<{path: string, line: number|null}>} order-preserving, de-duplicated by path
238
+ */
239
+ // HC-GF-202: the first segment may now start with a dot (`.github/...`) and the
240
+ // last may be extensionless (`scripts/Makefile`). `.github/.pipeline-config.yml`
241
+ // drives every run and was invisible to this pattern.
242
+ const PATH_REF_RE = new RegExp(
243
+ `(?<![\\w/])(\\.?[a-zA-Z_][\\w-]*\\/(?:[\\w.-]+\\/)*(?:[\\w.-]+\\.(?:${FILE_EXT})|(?:${EXTENSIONLESS})))\\b(?::(\\d+))?`,
244
+ 'g',
245
+ );
246
+
247
+ function extractPathRefs(text) {
248
+ if (!text || typeof text !== 'string') return [];
249
+ const seen = new Set();
250
+ const out = [];
251
+ for (const m of text.matchAll(PATH_REF_RE)) {
252
+ if (seen.has(m[1])) continue;
253
+ seen.add(m[1]);
254
+ out.push({ path: m[1], line: m[2] ? Number(m[2]) : null });
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /** Backticked spans in a story, longest first — the longest is the most specific anchor. */
260
+ const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
261
+
262
+ /**
263
+ * The line of the first thing the STORY quoted that actually occurs in this file — HC-GF-188.
264
+ *
265
+ * A story names its target in two ways. `path:line` states it outright; more often the story
266
+ * quotes the code, as HC-GF-126 does — "`server/src/routes/_all.js` reports
267
+ * `Math.ceil((expiresMs - Date.now()) / 86400000)`". Both name the same region, and only the
268
+ * first was usable.
269
+ *
270
+ * RETURNS null RATHER THAN A DEFAULT. Falling back to line 1 would produce a confident window
271
+ * around the wrong region, which reads to every downstream agent exactly like a correct one —
272
+ * and the operator would be told the file was anchored. "Could not anchor" has to stay
273
+ * distinguishable from "anchored here".
274
+ *
275
+ * Candidates are tried longest-first: a quoted expression is a far stronger locator than a
276
+ * quoted identifier that may appear in a hundred places. Path-shaped and whitespace-only spans
277
+ * are skipped — a story always quotes the file's own path, and matching that would anchor on
278
+ * whatever line happened to mention it.
279
+ *
280
+ * @param {string} content full file contents
281
+ * @param {string} storyText
282
+ * @returns {{line: number, anchor: string}|null}
283
+ */
284
+ function anchorLineForStory(content, storyText) {
285
+ const text = String(content || '');
286
+ const story = String(storyText || '');
287
+ if (!text || !story) return null;
288
+
289
+ const candidates = [...story.matchAll(BACKTICKED_RE)]
290
+ .map((m) => m[1].trim())
291
+ .filter((c) => c.length >= 3
292
+ && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
293
+ && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
294
+ .sort((a, b) => b.length - a.length);
295
+
296
+ for (const anchor of candidates) {
297
+ const at = definitionIndexOf(text, anchor);
298
+ if (at === -1) continue;
299
+ return { line: text.slice(0, at).split('\n').length, anchor };
300
+ }
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Where a term is DEFINED, falling back to where it is first mentioned — HC-GF-203.
306
+ *
307
+ * `indexOf` alone was the whole bug. A term that names something appears once at its
308
+ * definition and many times in prose, comments and call sites, so the first occurrence is
309
+ * the least likely of those to be the one a story means — and the gap grows with the file.
310
+ * Measured 2026-09-20: `verify-patch` occurs 22 times in `cli/hone-cli.js`; the first is a
311
+ * comment at line 5744, the `.command(…)` definition is at 7849, and the 400-line window
312
+ * around the comment carried neither the command nor the call site the story named. The
313
+ * planner refused, correctly, and no flag could fix it.
314
+ *
315
+ * The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
316
+ * gets the operator a window near it, which beats none.
317
+ */
318
+ function anchorSitesForStory(content, storyText, max = 6) {
319
+ // HC-GF-204: every region a story's terms point at, best-first. `anchorLineForStory`
320
+ // answers "where does this live"; a story also needs "where is it USED", and on run
321
+ // ad4a561f the planner had the definition and refused for want of the call sites.
322
+ const text = String(content || '');
323
+ const story = String(storyText || '');
324
+ if (!text || !story) return [];
325
+ const candidates = [...story.matchAll(BACKTICKED_RE)]
326
+ .map((m) => m[1].trim())
327
+ .filter((c) => c.length >= 3
328
+ && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
329
+ && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
330
+ .sort((a, b) => b.length - a.length);
331
+
332
+ const seenLine = new Set();
333
+ const sites = [];
334
+ for (const anchor of candidates) {
335
+ const def = definitionIndexOf(text, anchor);
336
+ if (def === -1) continue;
337
+ const t = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
338
+ const offsets = [def];
339
+ for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
340
+ for (const off of offsets) {
341
+ const line = text.slice(0, off).split('\n').length;
342
+ // Collapse near-duplicates: two hits 20 lines apart share one window.
343
+ if ([...seenLine].some((l) => Math.abs(l - line) < 40)) continue;
344
+ seenLine.add(line);
345
+ sites.push({ line, anchor });
346
+ if (sites.length >= max) return sites;
347
+ }
348
+ }
349
+ return sites;
350
+ }
351
+
352
+ function definitionIndexOf(text, term) {
353
+ const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
354
+ // Ordered by how strongly each shape asserts "this is where it lives".
355
+ const DEFINITION_FORMS = [
356
+ `\\.command\\(\\s*['"\`]${t}\\b`, // CLI command registration
357
+ `(?:async\\s+)?function\\s+${t}\\b`, // function declaration
358
+ `class\\s+${t}\\b`, // class declaration
359
+ `(?:const|let|var)\\s+${t}\\s*=`, // binding
360
+ `exports\\.${t}\\s*=`, // named export
361
+ `${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
362
+ ];
363
+ for (const form of DEFINITION_FORMS) {
364
+ const m = new RegExp(form).exec(text);
365
+ if (m) return m.index;
366
+ }
367
+ return text.indexOf(term);
368
+ }
369
+
370
+ /**
371
+ * Decide what a bundled file should actually contain — HC-GF-188.
372
+ *
373
+ * One function so the DECISION is testable and mutable. Inline in the bundler it was three
374
+ * branches nobody could reach from a test, which is how the blind head-slice survived.
375
+ *
376
+ * `notice` is the operator's half and is the point of this story as much as the excerpt is. The
377
+ * run that exposed this printed `bundled 4 codebase file(s) … (35383 chars)` and nothing else,
378
+ * while the payload carried a truncation marker that only the AGENT could read. All three agents
379
+ * diagnosed the cut correctly and refused; the person watching had no idea it had happened.
380
+ *
381
+ * HC-GF-288: `lines` carries EVERY stated line for the file, best-first; `line` is the
382
+ * one-line form and is appended to it. One line per file could not express the operator's
383
+ * `--include-paths f.js:N` for a file the story had already cited as `f.js:M` — the CLI kept
384
+ * M, discarded N, and printed nothing. Each stated line is a floor, and one this cannot
385
+ * honour comes back in `unhonoured` so the caller can say which, and why.
386
+ *
387
+ * @param {{content: string, cap: number, line?: number|null, lines?: number[], storyText?: string, path?: string}} input
388
+ * @returns {{mode: 'full'|'window'|'truncate', content: string, notice: string|null, anchor?: string,
389
+ * unhonoured: Array<{line: number, reason: 'past-eof'|'budget', totalLines: number}>}}
390
+ */
391
+ function planFileExcerpt({ content, cap, line = null, lines = null, storyText = '', path: filePath = 'file' } = {}) {
392
+ const text = String(content || '');
393
+ const limit = Number(cap);
394
+ const totalLines = text.split('\n').length;
395
+ const requested = [...new Set([...(Array.isArray(lines) ? lines : []), line]
396
+ .map(Number).filter((n) => Number.isInteger(n) && n > 0))];
397
+ // A line past the end is not clamped to the last line: `windowAroundLine` would do exactly
398
+ // that, and a window at EOF presented as "centered on referenced line 9000" is a confident
399
+ // wrong region. It is dropped and reported instead.
400
+ const stated = requested.filter((n) => n <= totalLines);
401
+ const unhonoured = requested.filter((n) => n > totalLines)
402
+ .map((n) => ({ line: n, reason: 'past-eof', totalLines }));
403
+ if (text.length <= limit) return { mode: 'full', content: text, notice: null, unhonoured };
404
+
405
+ // An explicit line wins: the author stated it, the anchor is inferred from prose.
406
+ const hit = stated.length ? { line: stated[0], anchor: null } : anchorLineForStory(text, storyText);
407
+
408
+ if (hit) {
409
+ // The 800 is headroom for the marker text. Clamped: `cap` is a parameter now, and the
410
+ // inherited formula goes NEGATIVE below 800, which collapses the window to nothing — an
411
+ // empty excerpt presented as a successful anchor. A window that slightly overruns a very
412
+ // small cap is strictly better than one that shows nothing at the site it claims to show.
413
+ // HC-GF-204: a story's code sits in SEVERAL regions of a large file, so inferred
414
+ // anchors spread the budget across every region found — "one window that is merely
415
+ // the BEST region still leaves the planner refusing for want of the others".
416
+ //
417
+ // HC-GF-216: that sentence is true of an EXPLICIT line too, and treating "stated"
418
+ // as "sufficient" is what made `line` exclusive. Measured 2026-09-20 on run
419
+ // de47094f, server/src/services/orchestrator.js (218,011 chars, cap 25,000): with
420
+ // the explicit line, ONE window [1,281] carrying the declaration and NEITHER
421
+ // consumer; without it, FIVE windows [13,95] [3876,3938] [576,648] [1606,1735]
422
+ // [3768,3852] carrying the declaration at 64, the `EDITOR_SIDE_STEPS.includes`
423
+ // consumer at 1701 and the export at 3923. step_1 then refused for call sites the
424
+ // UN-hinted bundle would have supplied — naming the line made the bundle strictly
425
+ // worse, and the shortfall notice below is what tells operators to name it.
426
+ //
427
+ // So the stated line SEEDS the site list rather than replacing it: it is at index 0,
428
+ // hence budgeted first, and `mergeIntersectingWindows` unions it with any inferred
429
+ // window it overlaps. No sort, no priority field — array order already says it.
430
+ // HC-GF-288: every stated line seeds it, in the caller's order.
431
+ const inferred = anchorSitesForStory(text, storyText);
432
+ const sites = [...stated.map((l) => ({ line: l, anchor: null })), ...inferred];
433
+ const chosen = sites.length ? sites : [hit];
434
+ const why = (a, l) => (a ? `anchored on \`${a}\`` : `centered on referenced line ${l}`);
435
+ const header = (w, a, l) =>
436
+ `# [HC-019n-followup-27: windowed excerpt, lines ${w.startLine}-${w.endLine} of ${w.totalLines} `
437
+ + `(${why(a, l)}); you were NOT shown the whole file — emit an \`edit:\` block `
438
+ + `with a unique OLD anchor from this excerpt, NOT a whole-file \`file:\` block]\n`;
439
+ const footer = `\n# [end windowed excerpt of ${filePath}]`;
440
+ // Budget the MARKER too. Each window carries a header and a footer, and counting only
441
+ // the excerpt overran a 25,000 cap by 1,647 chars across six regions — measured. The cap
442
+ // is a promise to the caller about total size.
443
+ // HC-GF-288 review: at its REAL length. A flat 280 undercounted it — the header alone is
444
+ // 232 chars for `centered on referenced line N`, more for a long anchor, and the footer
445
+ // carries the path — so six windows of server/src/services/orchestrator.js came to
446
+ // 25,039 chars against a 25,000 cap once the radius stopped leaving slack to hide it.
447
+ // The +1 is the newline that joins windows.
448
+ const costOf = (e) => header(e.w, e.anchor, e.line).length + e.w.excerpt.length + footer.length + 1;
449
+ // The radius is sized before any window exists, so it prices the marker at its widest:
450
+ // the longest label among the sites, with line numbers at the file's own width.
451
+ const widest = { startLine: totalLines, endLine: totalLines, totalLines };
452
+ const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
453
+ + footer.length + 1;
454
+ // HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
455
+ // own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
456
+ // LAST region was always dropped however much room the rest left. Measured on
457
+ // cli/hone-cli.js at cap 25,000 with stated lines [6180, 2000, 6580] and no inferred
458
+ // sites: 16,836 chars spent and 6580 dropped. `(n - 1)` keeps the single-window size
459
+ // exactly what it was.
460
+ let radius = Math.max(200,
461
+ Math.floor((limit - 800 - (chosen.length - 1) * markerCost) / (2 * chosen.length)));
462
+
463
+ const windows = [];
464
+ // HC-GF-216 review: a STATED line is a FLOOR, not a preference.
465
+ //
466
+ // Seeding it at index 0 was not enough — `mergeIntersectingWindows` returns its
467
+ // own order, so index 0 never reached the budget loop. Measured at cap 900 with
468
+ // line 7: 476 chars of 900 spent and the stated region dropped anyway, failing
469
+ // HC-GF-203 (`cli/lib/bundle-anchor.test.js:109`).
470
+ //
471
+ // Floor over preference because the two failures are not symmetric. A dropped
472
+ // INFERRED region surfaces as a planner refusal the operator can act on. A dropped
473
+ // STATED region is silent: the bundle is well-formed, the notice says a region was
474
+ // dropped, and nothing says the dropped one is the one that was asked for. That is
475
+ // HC-GF-205's lesson — the silence was the expensive part.
476
+ //
477
+ // No conflict with HC-GF-203's other pin (`:117`, a definition beats a mention):
478
+ // that ranks INFERRED sites against each other. This governs stated vs inferred.
479
+ // Different layers, so both hold.
480
+ //
481
+ // Bounded by construction: `radius` already shrinks with site count and floors at
482
+ // 200 chars, so the reserved window takes its share plus its marker, never the cap.
483
+ // Identify a stated entry by the RANGE it covers, not its anchor: mergeIntersectingWindows
484
+ // unions an overlapping inferred window into it and the merged entry inherits the
485
+ // OTHER site's anchor label, so `anchor === null` no longer finds it. Measured:
486
+ // candidate {line:7, anchor:null, 1-15} merges with {line:1, "thing", 1-10} and
487
+ // comes back as {line:7, anchor:"thing", 1-15}. HC-GF-288: by range, because with
488
+ // several stated lines a merge can also carry one stated line under ANOTHER's `line`.
489
+ const covers = (w, l) => w.startLine <= l && l <= w.endLine;
490
+ const rankOf = (c) => stated.findIndex((l) => covers(c.w, l));
491
+ // Restore the stated identity the merge erased. `anchor: null` is what makes the
492
+ // notice read "centered on referenced line N" rather than "anchored on `term`"
493
+ // (see `why` below), and an operator who named a line needs to see it was honoured.
494
+ // Reporting someone else's anchor for the window they asked for is the silence
495
+ // HC-GF-205 was filed about. Stated entries go first, in the caller's order; the
496
+ // rest keep merge order.
497
+ const layout = (r) => {
498
+ // HC-GF-206: merge intersecting windows BEFORE budgeting. The old dedupe was
499
+ // `usedLines.has(w.startLine)` — an exact start-line match, which two windows
500
+ // overlapping by all but a few lines never trigger.
501
+ const candidates = chosen.map((site) => ({
502
+ w: windowAroundLine(text, site.line, r),
503
+ anchor: site.anchor,
504
+ line: site.line,
505
+ }));
506
+ return mergeIntersectingWindows(text, candidates)
507
+ .map((c, i) => {
508
+ const k = rankOf(c);
509
+ return k === -1 ? { c, k: Infinity, i } : { c: { ...c, anchor: null, line: stated[k] }, k, i };
510
+ })
511
+ .sort((a, b) => (a.k - b.k) || (a.i - b.i))
512
+ .map(({ c, k }) => ({ ...c, stated: k !== Infinity }));
513
+ };
514
+ // Spend the budget in priority order. Stated entries come first, so a stated region is
515
+ // only ever dropped when the stated regions alone exceed the cap.
516
+ const budget = (ord) => {
517
+ const kept = [];
518
+ let spent = 0;
519
+ let skipped = 0;
520
+ for (const site of ord) {
521
+ const cost = costOf(site);
522
+ if (spent + cost > limit) {
523
+ // A dropped stated region is reported by LINE below, not counted here: the
524
+ // advice attached to this count — "name them as path:<line>" — is advice the
525
+ // operator has already followed for those.
526
+ if (!site.stated) skipped += 1;
527
+ continue;
528
+ }
529
+ spent += cost;
530
+ kept.push({ w: site.w, anchor: site.anchor, line: site.line });
531
+ }
532
+ return { kept, skipped };
533
+ };
534
+ // HC-GF-288 review: the radius is an ESTIMATE. Each window also snaps outward to whole
535
+ // lines — 50-180 chars per window on ordinary source, up to 10K on a file of long lines
536
+ // — which is not known until the window exists. Measured: six stated lines of
537
+ // cli/hone-cli.js left 4,192 chars unspent and still dropped one whose window needed
538
+ // 4,203. A stated line is a floor, so when the estimate loses one, search for the WIDEST
539
+ // radius that keeps them all.
540
+ //
541
+ // Of the layouts tried, keep the one that honours more stated lines — never "the last
542
+ // one tried". When even the narrowest windows cannot hold every stated line, narrowing
543
+ // frees room that an earlier window can take, and it lost 5 stated lines where the
544
+ // estimate lost 2 (a review probe, cap 12,000, seven lines of a file with 10K-char
545
+ // lines). A fixed number of shrink passes was also too few there: it stopped short and
546
+ // the fallback below overran a 25,000 cap by 409 chars. Only the estimate and the
547
+ // minimum are compared in that case; a radius between them can occasionally keep one
548
+ // more, which a scan would find at the cost of more layouts.
549
+ //
550
+ // On a tie, a layout that kept ANY window beats one that kept none, before width is
551
+ // considered: a layout with no window sends the result to the fallback, which overruns
552
+ // the cap by construction. Third review: preferring width alone discarded a 1,066-char
553
+ // layout for a 33,002-char fallback at cap 12,000.
554
+ let best = null;
555
+ const better = (a, b) => (a.honoured - b.honoured)
556
+ || (Number(a.plan.kept.length > 0) - Number(b.plan.kept.length > 0))
557
+ || (a.r - b.r);
558
+ const tryRadius = (r) => {
559
+ const plan = budget(layout(r));
560
+ const honoured = stated.filter((l) => plan.kept.some(({ w }) => covers(w, l))).length;
561
+ const cand = { r, plan, honoured };
562
+ if (!best || better(cand, best) > 0) best = cand;
563
+ return honoured === stated.length;
564
+ };
565
+ if (!tryRadius(radius) && radius > 200 && tryRadius(200)) {
566
+ // All stated lines fit at 200 but not at the estimate: binary-search between them.
567
+ // Bounded by log2(radius) layouts — measured at most 12 layouts and 228 ms on a
568
+ // 1.6 MB file of long lines, a few ms on ordinary source.
569
+ let lo = 200;
570
+ let hi = radius - 1;
571
+ while (lo < hi) {
572
+ const mid = Math.ceil((lo + hi) / 2);
573
+ if (tryRadius(mid)) lo = mid; else hi = mid - 1;
574
+ }
575
+ }
576
+ radius = best.r;
577
+ windows.push(...best.plan.kept);
578
+ const dropped = best.plan.skipped;
579
+ if (!windows.length) {
580
+ // Nothing fits at any radius tried — one line alone is longer than the cap. Build the
581
+ // window at the MINIMUM radius, so the overrun is that line and no more: at the
582
+ // estimate, a 30,000-char line at cap 25,000 came back as 42,380 chars.
583
+ const w = windowAroundLine(text, chosen[0].line, 200);
584
+ windows.push({ w, anchor: chosen[0].anchor, line: chosen[0].line });
585
+ }
586
+ // HC-GF-288: which stated lines no kept window contains. Only the budget can do that
587
+ // here — every stated line seeded a window, and a merge never shrinks one.
588
+ const lost = stated.filter((l) => !windows.some(({ w }) => covers(w, l)));
589
+ for (const l of lost) unhonoured.push({ line: l, reason: 'budget', totalLines });
590
+
591
+ const ranges = windows.map(({ w }) => `${w.startLine}-${w.endLine}`).join(', ');
592
+ const because = why(windows[0].anchor, windows[0].line);
593
+ // HC-CI-016: a partial result reported as complete is the defect. If the budget
594
+ // could not cover every region found, the operator is TOLD how many were left out.
595
+ const shortfall = (dropped > 0
596
+ // HC-GF-216: "to bundle one directly" described the pre-fix behaviour and was the
597
+ // mechanism that taught operators to narrow their own bundles. A stated line now
598
+ // ANCHORS the spread, so the advice says so.
599
+ ? ` — ${dropped} further region(s) DROPPED for budget; name them as ${filePath}:<line> to anchor a region directly`
600
+ : '')
601
+ + (lost.length > 0
602
+ ? ` — stated line(s) ${lost.join(', ')} DROPPED for budget; name fewer regions in ${filePath}`
603
+ : '');
604
+
605
+ return {
606
+ mode: 'window',
607
+ unhonoured,
608
+ anchor: windows[0].anchor || null,
609
+ content: windows.map(({ w, anchor, line: siteLine }) =>
610
+ header(w, anchor, siteLine) + w.excerpt + footer).join('\n'),
611
+ notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — bundled lines `
612
+ + `${ranges} of ${windows[0].w.totalLines}, ${because}${shortfall}`,
613
+ };
614
+ }
615
+
616
+ const dropped = text.length - limit;
617
+ return {
618
+ mode: 'truncate',
619
+ unhonoured,
620
+ content: text.slice(0, limit)
621
+ + `\n\n# [HC-019n-followup-12: truncated at ${limit} chars; full file is ${text.length} chars]`,
622
+ notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
623
+ + `DROPPED and nothing in the story text matched inside the file, so the region you care about `
624
+ + `may not be in the bundle. Name it as ${filePath}:<line>, or quote the code, to anchor it.`,
625
+ };
626
+ }
627
+
628
+ // ── HC-GF-229: is a path a story CITES, or one it intends to CHANGE? ─────────
629
+ //
630
+ // DELIBERATE SECOND COPY. The identical block lives in
631
+ // server/src/services/story-context.js. The CLI cannot read the roadmap and the
632
+ // server cannot read the adopter's disk, and there is no shared lib between the
633
+ // two packages — so the classification is duplicated on purpose, exactly as the
634
+ // extraction layer beneath it (HC-GF-247) already is. The agreement pin in
635
+ // tests/regression/HC-019n-followup-18-story-context-and-patch.test.js is what
636
+ // keeps them equal: widening one side alone turns it red.
637
+ //
638
+ // THE RULE IS DECLARATION, NOT POSITION. A row may name its change targets
639
+ // outright with a `**Targets:**` label; everything else the row names is
640
+ // evidence. Absent a declaration the answer is byte-identical to today's
641
+ // behaviour, and that is what makes the lost-file class structurally
642
+ // impossible: a row that declares nothing cannot lose anything.
643
+ //
644
+ // Classifying by POSITION — a path appearing only inside the `**Premise:**`
645
+ // span is evidence — was built, measured and REJECTED: 28 losing rows and 32
646
+ // lost files over 261 completed rows, including cli/lib/bundle-paths.js for
647
+ // HC-GF-203/204/206, the file all three exist to edit, while 5,000 regression
648
+ // tests passed over it. See docs/architecture/HC-GF-229-classifier-design.md.
649
+ // Do not re-attempt it.
650
+
651
+ /**
652
+ * Inline bold labels. WIDENED on purpose: the rejected build matched the exact
653
+ * literal `**Premise:**` and missed 11 of 144 labels in use
654
+ * (`**Premise (measured 2026-09-18):**`, `**Premise for the correction:**`).
655
+ * The leading `[A-Z]` is deliberate — `**targets:**` in lowercase prose is a
656
+ * mention, not a declaration, and loosening case widens the false-positive
657
+ * surface onto every bold span in the corpus.
658
+ */
659
+ const LABEL_RE = /\*\*([A-Z][^*\n]{0,80}?):\*\*/g;
660
+
661
+ /**
662
+ * Blank out inline `code` spans and fenced blocks, PRESERVING OFFSETS.
663
+ *
664
+ * The known trap for this story, and this story's own row is the instance: it
665
+ * contains `` `**Targets:**` `` inside a code span while documenting the
666
+ * convention, and a naive detector reads that as a real declaration — the
667
+ * defect this story exists to fix, reproduced one level up on labels instead
668
+ * of paths.
669
+ *
670
+ * MASKING RATHER THAN DELETING IS LOAD-BEARING. Every non-newline character is
671
+ * replaced by a space, so `maskCodeSpans(t).length === t.length` and every
672
+ * index computed on the masked text addresses the same character of the
673
+ * original. That is what lets span bounds and token occurrences share one
674
+ * coordinate system with no second index map. Newlines survive so paragraph
675
+ * structure — which ends a span, below — is unchanged.
676
+ *
677
+ * Fences are masked FIRST so a backtick inside a fence cannot pair with one
678
+ * outside it.
679
+ *
680
+ * @param {string} text
681
+ * @returns {string} same length as the input
682
+ */
683
+ function maskCodeSpans(text) {
684
+ const src = String(text || '');
685
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
686
+ return src.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank);
687
+ }
688
+
689
+ /** Index of the blank line that ends the paragraph starting at `from`. */
690
+ function paragraphEnd(text, from) {
691
+ const m = /\n[ \t]*\n/.exec(text.slice(from));
692
+ return m ? from + m.index : text.length;
693
+ }
694
+
695
+ /**
696
+ * Spans of text governed by a label whose name satisfies `namePredicate`.
697
+ *
698
+ * A span ends at the NEXT label or at the next BLANK LINE, whichever comes
699
+ * first. The next-label half is inherited from the replay harness; the blank
700
+ * line half is the correction. A roadmap row is one line, so the two rules are
701
+ * identical over the corpus — but a story BODY is not, and a declaration that
702
+ * ran to end-of-text because no second label followed would silently
703
+ * re-classify the next paragraph's prose citations as change targets, which is
704
+ * the widening this story exists to stop.
705
+ *
706
+ * @param {string} masked output of maskCodeSpans — offsets match the original
707
+ * @param {(name: string) => boolean} namePredicate
708
+ * @returns {Array<[number, number]>} half-open [start, end) ranges
709
+ */
710
+ function labelSpans(masked, namePredicate) {
711
+ const labels = [...masked.matchAll(new RegExp(LABEL_RE.source, 'g'))];
712
+ const spans = [];
713
+ labels.forEach((lab, i) => {
714
+ if (!namePredicate(lab[1])) return;
715
+ const nextLabel = i + 1 < labels.length ? labels[i + 1].index : masked.length;
716
+ const para = paragraphEnd(masked, lab.index + lab[0].length);
717
+ spans.push([lab.index, Math.min(nextLabel, para)]);
718
+ });
719
+ return spans;
720
+ }
721
+
722
+ /** The `**Targets…:**` spans of a story, code spans already excluded. */
723
+ function targetSpans(text) {
724
+ return labelSpans(maskCodeSpans(String(text || '')), (n) => /^Targets\b/.test(n));
725
+ }
726
+
727
+ /** Every index at which `token` literally occurs. */
728
+ function occurrencesOf(text, token) {
729
+ const at = [];
730
+ if (!token) return at;
731
+ let i = text.indexOf(token);
732
+ while (i !== -1) { at.push(i); i = text.indexOf(token, i + 1); }
733
+ return at;
734
+ }
735
+
736
+ function indexInSpans(i, spans) {
737
+ return spans.some(([a, b]) => i >= a && i < b);
738
+ }
739
+
740
+ /**
741
+ * The literal the ROW wrote, not the path it resolved to.
742
+ *
743
+ * Load-bearing for bare references. `resolveBareName` turns `orchestrator.js`
744
+ * into `server/src/services/orchestrator.js`, and that resolved path occurs
745
+ * nowhere in the row text — so matching on it would find zero occurrences and
746
+ * silently make every bare-ref citation evidence regardless of what the row
747
+ * declared.
748
+ */
749
+ function tokenOf(entry) {
750
+ if (typeof entry === 'string') return entry;
751
+ if (!entry || typeof entry !== 'object') return '';
752
+ return String(entry.token || entry.path || '');
753
+ }
754
+
755
+ /**
756
+ * Partition the paths a story names into change targets and evidence.
757
+ *
758
+ * ADDITIVE. This wraps extraction rather than replacing it: extractPathRefs /
759
+ * extractBareRefs return exactly what they returned before, so every existing
760
+ * consumer of the shared chokepoint is untouched by construction. A caller
761
+ * that never calls this function behaves exactly as today.
762
+ *
763
+ * `named` carries whatever the caller resolved — `{path, token}` entries, or
764
+ * plain path strings. Entries are returned BY IDENTITY, so the caller keeps
765
+ * any fields (`line`, `via`) it attached.
766
+ *
767
+ * THREE OUTCOMES:
768
+ *
769
+ * no declaration targets = named, declared false. Today's behaviour,
770
+ * byte for byte. This is the structural guarantee.
771
+ * declaration resolves targets = the declared paths, evidence = the rest.
772
+ * One in-span occurrence is enough, so a path named in
773
+ * both the declaration and the premise is a TARGET.
774
+ * declaration resolves
775
+ * to nothing targets = named, declared false, plus a diagnostic.
776
+ * Returning [] here would hand back an empty Scope —
777
+ * the HC-GF-073 blind-bundle failure, and the one
778
+ * outcome the acceptance forbids outright. A malformed
779
+ * declaration is an AUTHORING error, and the costs are
780
+ * asymmetric: degrading to the status quo loses
781
+ * nothing, bundling nothing loses everything. The
782
+ * diagnostic exists so that degradation is not silent —
783
+ * callers should surface it.
784
+ *
785
+ * Pure and I/O-free.
786
+ *
787
+ * @param {string} text the story/row text, as written
788
+ * @param {Array<{path: string, token?: string}|string>} named
789
+ * @returns {{targets: Array, evidence: Array, declared: boolean, diagnostic: string|null}}
790
+ */
791
+ function classifyStoryPaths(text, named) {
792
+ const list = Array.isArray(named) ? named.slice() : [];
793
+ const raw = String(text || '');
794
+ const masked = maskCodeSpans(raw);
795
+ const spans = labelSpans(masked, (n) => /^Targets\b/.test(n));
796
+ if (!spans.length) return { targets: list, evidence: [], declared: false, diagnostic: null };
797
+
798
+ const targets = [];
799
+ const evidence = [];
800
+ for (const entry of list) {
801
+ // SPANS FROM THE MASKED TEXT, OCCURRENCES FROM THE ORIGINAL. This is the whole
802
+ // reason maskCodeSpans preserves offsets, and getting it wrong made the feature
803
+ // inert on the corpus's actual writing convention: roadmap rows write paths
804
+ // inside backticks, masking blanks those to spaces, so every declared path
805
+ // scored ZERO occurrences, fell to evidence, and the declared-but-empty branch
806
+ // handed back `named` with declared:false. A row declaring
807
+ // `**Targets:** \`cli/lib/bundle-paths.js\`` bundled exactly what it bundles
808
+ // today — a sanitizer applied one layer too wide, silently disabling the
809
+ // capability it was protecting.
810
+ //
811
+ // The mask exists to stop a LABEL inside a code span reading as a declaration.
812
+ // It was never meant to hide PATHS. Residual, and accepted: a path written
813
+ // inside a code span that sits INSIDE a real Targets span now counts as a
814
+ // target, which is what the author meant by writing it there.
815
+ const hit = occurrencesOf(raw, tokenOf(entry)).some((i) => indexInSpans(i, spans));
816
+ (hit ? targets : evidence).push(entry);
817
+ }
818
+ if (!targets.length) {
819
+ return {
820
+ targets: list,
821
+ evidence: [],
822
+ declared: false,
823
+ diagnostic: list.length
824
+ ? `row declares Targets but none of its ${list.length} named path(s) resolved inside the `
825
+ + 'declaration — bundling all of them (status quo)'
826
+ : 'row declares Targets and names no paths',
827
+ };
828
+ }
829
+ return { targets, evidence, declared: true, diagnostic: null };
830
+ }
831
+
139
832
  module.exports = {
140
833
  BARE_FILE_RE, extractBareNames, extractBareRefs, resolveBareName, alreadyCovered, windowAroundLine,
834
+ PATH_REF_RE, extractPathRefs, anchorLineForStory, planFileExcerpt, definitionIndexOf, anchorSitesForStory,
835
+ windowBetweenLines, mergeIntersectingWindows,
836
+ // HC-GF-229 — classification. Pinned byte-for-byte against the server copy.
837
+ LABEL_RE, maskCodeSpans, targetSpans, classifyStoryPaths,
141
838
  };