@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.
- package/hone-cli.js +282 -309
- package/lib/batch-follow.js +418 -51
- package/lib/bundle-paths.js +168 -21
- package/lib/call-sites.js +489 -0
- package/lib/classify-spec.js +68 -0
- package/lib/context-reserve.js +270 -0
- package/lib/detect-test-framework.js +54 -0
- package/lib/doctor-admin-merge.js +11 -3
- package/lib/git-remote.js +103 -0
- package/lib/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +39 -34
- package/lib/learnings-sync.js +2 -1
- package/lib/mcp-tools.js +42 -1
- package/lib/run-context.js +25 -3
- package/lib/run-target.js +34 -2
- package/lib/story-line.js +48 -12
- package/lib/story-provenance.js +19 -12
- package/package.json +1 -1
package/lib/bundle-paths.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
};
|