@hone-ai/cli 1.38.0 → 1.39.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.
@@ -259,6 +259,22 @@ function extractPathRefs(text) {
259
259
  /** Backticked spans in a story, longest first — the longest is the most specific anchor. */
260
260
  const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
261
261
 
262
+ /**
263
+ * The terms a story quotes that could locate code, longest first — one copy of the filter.
264
+ * HC-GF-206-followup-3: three readers now ask this question (`anchorLineForStory`,
265
+ * `anchorSitesForStory` and `unanchoredTerms`), and two hand-typed copies of it is how one
266
+ * reader ends up judging a different set of terms than the others. Path-shaped spans and the
267
+ * extensionless filenames are dropped: a story always quotes the file's own path.
268
+ */
269
+ function storyTermsOf(storyText) {
270
+ return [...String(storyText || '').matchAll(BACKTICKED_RE)]
271
+ .map((m) => m[1].trim())
272
+ .filter((c) => c.length >= 3
273
+ && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
274
+ && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
275
+ .sort((a, b) => b.length - a.length);
276
+ }
277
+
262
278
  /**
263
279
  * The line of the first thing the STORY quoted that actually occurs in this file — HC-GF-188.
264
280
  *
@@ -286,12 +302,7 @@ function anchorLineForStory(content, storyText) {
286
302
  const story = String(storyText || '');
287
303
  if (!text || !story) return null;
288
304
 
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);
305
+ const candidates = storyTermsOf(story);
295
306
 
296
307
  for (const anchor of candidates) {
297
308
  const at = definitionIndexOf(text, anchor);
@@ -314,6 +325,17 @@ function anchorLineForStory(content, storyText) {
314
325
  *
315
326
  * The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
316
327
  * gets the operator a window near it, which beats none.
328
+ *
329
+ * HC-GF-203-followup-1: a MENTION is the term standing whole, not the term's letters inside a
330
+ * longer identifier. The fallback was a bare `indexOf`, so `info` landed in `information`,
331
+ * `auth` in `authorize` and `Promise.all` in `Promise.allSettled` — and each such offset became
332
+ * a site in `anchorSitesForStory`, spending one of its 6 slots and a window on text that does
333
+ * not contain the term. Measured 2026-10-07 at the production cap over every roadmap row
334
+ * against cli/hone-cli.js and server/src/services/orchestrator.js: 187 of 6,210 sites, in 161
335
+ * of 1,224 windowed excerpts, 106 of them at the 6-site limit, where the wasted slot cost
336
+ * another term its window. A term that never stands whole is now -1, exactly like a term
337
+ * the file lacks: HC-GF-188's rule, "could not anchor" stays distinguishable from "anchored
338
+ * here", applies to a window around `information` as much as to one around line 1.
317
339
  */
318
340
  /**
319
341
  * HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
@@ -334,12 +356,7 @@ function anchorSitesForStory(content, storyText, max = 6) {
334
356
  const text = String(content || '');
335
357
  const story = String(storyText || '');
336
358
  if (!text || !story) return { sites: [], collapsed: [] };
337
- const candidates = [...story.matchAll(BACKTICKED_RE)]
338
- .map((m) => m[1].trim())
339
- .filter((c) => c.length >= 3
340
- && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
341
- && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
342
- .sort((a, b) => b.length - a.length);
359
+ const candidates = storyTermsOf(story);
343
360
 
344
361
  const seenLine = new Set();
345
362
  const sites = [];
@@ -349,6 +366,11 @@ function anchorSitesForStory(content, storyText, max = 6) {
349
366
  if (def === -1) continue;
350
367
  const t = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
351
368
  const offsets = [def];
369
+ // The STRICT form on purpose, unlike `wholeTerm`. HC-GF-206-followup-5 measured `wholeTerm` here:
370
+ // a term with a punctuation edge (`/prompts/`, `.json`) then takes every occurrence as a site and
371
+ // crowds the rest out of the 6 — 74 of 1,218 excerpts changed, 9 terms gained a window, 28 lost one.
372
+ // With the strict form such a term gets its first whole occurrence (`def`), and the marker names
373
+ // it when no window shows it.
352
374
  for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
353
375
  for (const off of offsets) {
354
376
  const line = text.slice(0, off).split('\n').length;
@@ -364,8 +386,29 @@ function anchorSitesForStory(content, storyText, max = 6) {
364
386
  return { sites, collapsed };
365
387
  }
366
388
 
389
+ /**
390
+ * A term standing WHOLE — HC-GF-203-followup-1: no identifier character touches an edge of the term
391
+ * that is itself an identifier character. `checkBudget(` stands whole in `checkBudget(orgId)` — its
392
+ * own `(` is the boundary on that side — and `info` does not in `information`.
393
+ *
394
+ * HC-GF-206-followup-5: one copy, read by `definitionIndexOf` and `unanchoredTerms`. With the strict
395
+ * `(?<![\w$])…(?![\w$])` in `unanchoredTerms`, the two disagreed on every term with a punctuation
396
+ * edge: `definitionIndexOf` found `checkBudget(` and the marker said nothing, because the strict
397
+ * form calls it absent. `anchorSitesForStory`'s scan for further hits deliberately keeps the strict
398
+ * form; see there.
399
+ *
400
+ * @returns {{t: string, before: string, after: string}} the escaped term and the two lookarounds
401
+ */
402
+ function wholeTerm(term) {
403
+ return {
404
+ t: term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
405
+ before: /^[\w$]/.test(term) ? '(?<![\\w$])' : '',
406
+ after: /[\w$]$/.test(term) ? '(?![\\w$])' : '',
407
+ };
408
+ }
409
+
367
410
  function definitionIndexOf(text, term) {
368
- const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
411
+ const { t, before, after } = wholeTerm(term);
369
412
  // Ordered by how strongly each shape asserts "this is where it lives".
370
413
  const DEFINITION_FORMS = [
371
414
  `\\.command\\(\\s*['"\`]${t}\\b`, // CLI command registration
@@ -373,13 +416,98 @@ function definitionIndexOf(text, term) {
373
416
  `class\\s+${t}\\b`, // class declaration
374
417
  `(?:const|let|var)\\s+${t}\\s*=`, // binding
375
418
  `exports\\.${t}\\s*=`, // named export
376
- `${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
419
+ // The one form with nothing in front of the term: without `before`, `name` is "defined"
420
+ // by `basename = (` and `run` by `rerun: function`.
421
+ `${before}${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
377
422
  ];
378
423
  for (const form of DEFINITION_FORMS) {
379
424
  const m = new RegExp(form).exec(text);
380
425
  if (m) return m.index;
381
426
  }
382
- return text.indexOf(term);
427
+ const mention = new RegExp(`${before}${t}${after}`).exec(text);
428
+ return mention ? mention.index : -1;
429
+ }
430
+
431
+ /**
432
+ * The story's terms that occur in this file but in NONE of the windows bundled — HC-GF-206-followup-3.
433
+ *
434
+ * `anchorSitesForStory` stops at 6 sites, and terms are tried longest first, so every hit after
435
+ * the sixth site — including every hit of a shorter term — is neither a site nor a collapsed hit.
436
+ * A window can also be dropped for budget with its term in it. The notice counted neither, and
437
+ * nothing told the agent: a term absent from the excerpt reads exactly like a term absent from the
438
+ * FILE. Measured 2026-10-05 at the production cap over every roadmap row against cli/hone-cli.js and
439
+ * server/src/services/orchestrator.js: 425 of 1,198 windowed excerpts left out 901 whole-word terms,
440
+ * none named.
441
+ *
442
+ * Judged against the FINAL windows, not the site list, so it holds whatever dropped the term — the
443
+ * site limit, the budget, a collapse — and needs no knowledge of how the layout was chosen.
444
+ *
445
+ * A term counts as occurring only where it stands whole (`wholeTerm`); reporting `info` inside
446
+ * `information` as "`info` is at line N" would send an agent to a line that does not contain it.
447
+ * `definitionIndexOf` applies the same rule since HC-GF-203-followup-1 (its fallback was a bare
448
+ * `indexOf`); the check here stays, because this is what names a term to the agent.
449
+ *
450
+ * HC-GF-206-followup-5: this read the strict form, which calls every term with a punctuation edge
451
+ * absent unless that edge happens to sit next to a non-identifier character. Measured 2026-10-07 at
452
+ * the production cap over every roadmap row against cli/hone-cli.js and orchestrator.js: 44 such
453
+ * terms in 40 of 1,218 windowed excerpts were in the file, in no window, and not named; and 42
454
+ * were named as outside every window while a window showed them (`.md` in `README.md`). After: 0 and
455
+ * 0, with every window byte-identical and no term pushed out of the marker or the notice.
456
+ *
457
+ * @param {string} content full file contents
458
+ * @param {string} storyText
459
+ * @param {Array<[number, number]>} ranges the bundled windows' [startLine, endLine], 1-indexed
460
+ * @returns {Array<{term: string, line: number}>} story order (longest first); `line` is the
461
+ * definition's line when the definition is a whole-word occurrence, else the first mention
462
+ */
463
+ function unanchoredTerms(content, storyText, ranges) {
464
+ const text = String(content || '');
465
+ if (!text) return [];
466
+ const starts = [0];
467
+ for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
468
+ const lineOf = (off) => {
469
+ let lo = 0;
470
+ let hi = starts.length - 1;
471
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; }
472
+ return lo + 1;
473
+ };
474
+ const covered = (l) => ranges.some(([a, b]) => a <= l && l <= b);
475
+ const out = [];
476
+ for (const term of new Set(storyTermsOf(storyText))) {
477
+ const def = definitionIndexOf(text, term);
478
+ if (def === -1) continue;
479
+ const { t, before, after } = wholeTerm(term);
480
+ const mentions = [...text.matchAll(new RegExp(`${before}${t}${after}`, 'g'))].map((m) => lineOf(m.index));
481
+ if (!mentions.length) continue;
482
+ // Covered only by a whole occurrence.
483
+ if (mentions.some(covered)) continue;
484
+ const defLine = lineOf(def);
485
+ out.push({ term, line: mentions.includes(defLine) ? defLine : mentions[0] });
486
+ }
487
+ return out;
488
+ }
489
+
490
+ /**
491
+ * The excerpt's first line when story terms were left out — HC-GF-206-followup-3. It goes FIRST
492
+ * because step_0's premise check keeps only the first 2,000 chars of each file
493
+ * (`STEP_0_CODE_CAP_PER_FILE`, server/src/services/orchestrator.js), and "this symbol already
494
+ * exists" is exactly what that step looks for. It must fit the room the windows left under the cap:
495
+ * terms are listed longest first and the tail is folded into "+N more"; with no room for even that,
496
+ * it is omitted and only the operator's notice carries the list.
497
+ */
498
+ function unanchoredMarker(unanchored, room) {
499
+ if (!unanchored.length) return '';
500
+ const head = `# [HC-GF-206-followup-3: ${unanchored.length} term(s) the story names occur in this file `
501
+ + 'OUTSIDE every excerpt below: ';
502
+ const tail = '. They exist; you were not shown them, so do not conclude they are absent.]\n';
503
+ const items = unanchored.map(({ term, line }) => `\`${term}\` (line ${line})`);
504
+ for (let k = items.length; k >= 0; k -= 1) {
505
+ const rest = items.length - k;
506
+ const list = items.slice(0, k).join(', ') + (rest ? `${k ? ', ' : ''}+${rest} more` : '');
507
+ const marker = head + list + tail;
508
+ if (marker.length <= room) return marker;
509
+ }
510
+ return '';
383
511
  }
384
512
 
385
513
  /**
@@ -401,7 +529,9 @@ function definitionIndexOf(text, term) {
401
529
  *
402
530
  * @param {{content: string, cap: number, line?: number|null, lines?: number[], storyText?: string, path?: string}} input
403
531
  * @returns {{mode: 'full'|'window'|'truncate', content: string, notice: string|null, anchor?: string,
404
- * unhonoured: Array<{line: number, reason: 'past-eof'|'budget', totalLines: number}>}}
532
+ * unhonoured: Array<{line: number, reason: 'past-eof'|'budget', totalLines: number}>,
533
+ * unanchored: Array<{term: string, line: number}>}} — HC-GF-206-followup-3: story terms the file
534
+ * has that no bundled window carries; always empty outside `window` mode
405
535
  */
406
536
  function planFileExcerpt({ content, cap, line = null, lines = null, storyText = '', path: filePath = 'file' } = {}) {
407
537
  const text = String(content || '');
@@ -415,7 +545,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
415
545
  const stated = requested.filter((n) => n <= totalLines);
416
546
  const unhonoured = requested.filter((n) => n > totalLines)
417
547
  .map((n) => ({ line: n, reason: 'past-eof', totalLines }));
418
- if (text.length <= limit) return { mode: 'full', content: text, notice: null, unhonoured };
548
+ if (text.length <= limit) return { mode: 'full', content: text, notice: null, unhonoured, unanchored: [] };
419
549
 
420
550
  // An explicit line wins: the author stated it, the anchor is inferred from prose.
421
551
  const hit = stated.length ? { line: stated[0], anchor: null } : anchorLineForStory(text, storyText);
@@ -698,14 +828,30 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
698
828
  ? ` — ${unreached} near-duplicate hit(s) collapsed into a window that did not reach them`
699
829
  : '');
700
830
 
831
+ const body = windows.map(({ w, anchor, line: siteLine }) =>
832
+ header(w, anchor, siteLine) + w.excerpt + footer).join('\n');
833
+ // HC-GF-206-followup-3: a story term the file has and no window carries is NAMED, to the agent
834
+ // first and the operator second. Only the room the windows left is spent: the layout above is
835
+ // untouched, so what every run already bundled stays exactly what it was.
836
+ const unanchored = unanchoredTerms(text, storyText, windows.map(({ w }) => [w.startLine, w.endLine]));
837
+ const marker = unanchoredMarker(unanchored, limit - body.length);
838
+ const SHOWN = 8;
839
+ const termsNote = unanchored.length
840
+ ? ` — ${unanchored.length} story term(s) in the file but in no bundled window: `
841
+ + unanchored.slice(0, SHOWN).map(({ term, line: l }) => `\`${term}\` (line ${l})`).join(', ')
842
+ + (unanchored.length > SHOWN ? `, +${unanchored.length - SHOWN} more` : '')
843
+ + `; name them as ${filePath}:<line> to anchor them`
844
+ + (marker ? '' : ' (the excerpt had no room left to tell the agent)')
845
+ : '';
846
+
701
847
  return {
702
848
  mode: 'window',
703
849
  unhonoured,
850
+ unanchored,
704
851
  anchor: windows[0].anchor || null,
705
- content: windows.map(({ w, anchor, line: siteLine }) =>
706
- header(w, anchor, siteLine) + w.excerpt + footer).join('\n'),
852
+ content: marker + body,
707
853
  notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — bundled lines `
708
- + `${ranges} of ${windows[0].w.totalLines}, ${because}${shortfall}`,
854
+ + `${ranges} of ${windows[0].w.totalLines}, ${because}${shortfall}${termsNote}`,
709
855
  };
710
856
  }
711
857
 
@@ -718,6 +864,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
718
864
  return {
719
865
  mode: 'truncate',
720
866
  unhonoured,
867
+ unanchored: [], // reached only when no story term occurs in the file at all
721
868
  content: text.slice(0, keep) + marker(keep),
722
869
  notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
723
870
  + `DROPPED and nothing in the story text matched inside the file, so the region you care about `
@@ -932,7 +1079,7 @@ function classifyStoryPaths(text, named) {
932
1079
  module.exports = {
933
1080
  BARE_FILE_RE, extractBareNames, extractBareRefs, resolveBareName, alreadyCovered, windowAroundLine,
934
1081
  PATH_REF_RE, extractPathRefs, anchorLineForStory, planFileExcerpt, definitionIndexOf, anchorSitesForStory,
935
- windowBetweenLines, mergeIntersectingWindows,
1082
+ windowBetweenLines, mergeIntersectingWindows, storyTermsOf, unanchoredTerms, unanchoredMarker,
936
1083
  // HC-GF-229 — classification. Pinned byte-for-byte against the server copy.
937
1084
  LABEL_RE, maskCodeSpans, targetSpans, classifyStoryPaths,
938
1085
  };