@hone-ai/cli 1.39.0 → 1.41.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/lib/call-sites.js CHANGED
@@ -73,9 +73,22 @@ const SCOPE = 'tracked-repo';
73
73
 
74
74
  // The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
75
75
  // that took less would quietly turn a complete list into an incomplete one.
76
- const MAX_SYMBOLS = 6;
77
- /** Secret-looking names are reported, not searched; they take no slot of the 6 but are reported up to this many. */
76
+ // HC-GF-385: 16, up from 6. Six slots spent on string keys (`step_0b`, `config`) left the functions a story changes
77
+ // NOT SEARCHED and hard-stopped step_1 four times on one story. The wire budget is held by MAX_TOTAL_HITS, not by
78
+ // the symbol count: a symbol past it is sent as its per-file list (FILES ONLY), so 16 costs what 6 did.
79
+ const MAX_SYMBOLS = 16;
80
+ /** Secret-looking names are reported, not searched; they take no slot of the 16 but are reported up to this many. */
78
81
  const MAX_REFUSED = 2;
82
+ /**
83
+ * HC-GF-385: the hit lines ALL symbols together may send, spent in rank order: today's 6 x 40, so the 448 KiB
84
+ * per-story wire budget (server/src/lib/batch-body-limit.js) and its test keep their meaning. A symbol reached after
85
+ * it is spent sends `hits: []` plus its per-file list.
86
+ */
87
+ const MAX_TOTAL_HITS = 240;
88
+ /** HC-GF-385: the definition probe ranks at most this many candidates, best-ranked first. */
89
+ const MAX_PROBE_CANDIDATES = 32;
90
+ /** HC-GF-385: the probe's own share of TOTAL_BUDGET_MS; past it the story's own order is kept, and said. */
91
+ const PROBE_TIMEOUT_MS = 8_000;
79
92
  /** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
80
93
  const MAX_FILE_LIST_FILES = 300;
81
94
  /** ...and all the symbols' file lists together stay under this many bytes of JSON (server/src/lib/batch-body-limit.js budgets it). */
@@ -97,7 +110,38 @@ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc',
97
110
 
98
111
  // ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
99
112
 
100
- const BACKTICKED = /`([^`\n]{2,200})`/g;
113
+ // Backticked spans are PAIRED first, by the CommonMark rule, and the 2-to-200 rule is applied to each paired span afterwards.
114
+ // A bound INSIDE a regex decides which backticks pair: at `x`, and `foo` the opening backtick of `x` cannot start a 2-character
115
+ // match, so its closing one does, the "span" is `, and`, and `foo` is lost (measured 2026-10-10 over the roadmap rows: 68 of 939
116
+ // extracted a wrong span set). A plain /`([^`\n]*)`/ fixed that and made the next one: after a run of TWO OR MORE backticks
117
+ // (a ``double`` span, an inline ```fence) the pairing never recovered for the rest of the line, and 7 rows lost symbols the old
118
+ // pattern found. The rule that has no such failure is the one Markdown itself uses: a run of N backticks opens a span that the
119
+ // next run of EXACTLY N backticks closes, and a run with no such closer is literal text. Per line (a span does not cross a
120
+ // newline here, as before). Linear: the next run of the same length is computed once per run, not searched for.
121
+ // The same defect as BACKTICKED_RE in bundle-paths.js (HC-GF-188-followup-2), which has the first fix only.
122
+ function backtickedSpans(text) {
123
+ const spans = [];
124
+ for (const line of String(text || '').split('\n')) {
125
+ if (!line.includes('`')) continue;
126
+ const runs = [];
127
+ for (const m of line.matchAll(/`+/g)) runs.push({ at: m.index, len: m[0].length });
128
+ const nextSame = new Array(runs.length).fill(-1);
129
+ const lastOfLen = new Map();
130
+ for (let i = runs.length - 1; i >= 0; i--) {
131
+ nextSame[i] = lastOfLen.has(runs[i].len) ? lastOfLen.get(runs[i].len) : -1;
132
+ lastOfLen.set(runs[i].len, i);
133
+ }
134
+ for (let i = 0; i < runs.length; i++) {
135
+ const j = nextSame[i];
136
+ if (j < 0) continue; // no closer of this length: the run is literal text
137
+ let span = line.slice(runs[i].at + runs[i].len, runs[j].at);
138
+ if (span.length > 2 && span.startsWith(' ') && span.endsWith(' ') && /[^ ]/.test(span)) span = span.slice(1, -1); // CommonMark: one space each side
139
+ spans.push(span);
140
+ i = j; // resume after the closer; the runs between are inside the span
141
+ }
142
+ }
143
+ return spans;
144
+ }
101
145
  // One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
102
146
  // segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
103
147
  // Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
@@ -158,25 +202,129 @@ function symbolOfSpan(span) {
158
202
  * @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
159
203
  */
160
204
  function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
205
+ const { ranked, refusedAll } = rankCandidates(storyText);
206
+ return sliceRanked(ranked.map((c) => c.name), refusedAll, max);
207
+ }
208
+
209
+ /**
210
+ * Every candidate the story names, in the story's order (explicitly written first, then by how often it is named,
211
+ * ties in the order first named), with the facts that order was made from. Secret-looking names are split off.
212
+ * Synchronous and text-only: `pickSymbols` keeps that contract for callers this file cannot see (HC-GF-385).
213
+ * @returns {{ranked: Array<{name: string, explicit: boolean, count: number}>, refusedAll: string[]}}
214
+ */
215
+ function rankCandidates(storyText) {
161
216
  const counts = new Map();
162
217
  const explicitNames = new Set();
163
- for (const m of String(storyText || '').matchAll(BACKTICKED)) {
164
- const sym = symbolOfSpan(m[1]);
218
+ for (const span of backtickedSpans(storyText)) {
219
+ if (span.length < 2 || span.length > 200) continue; // the length rule, after pairing
220
+ const sym = symbolOfSpan(span);
165
221
  if (!sym) continue;
166
222
  counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
167
223
  if (sym.explicit) explicitNames.add(sym.name);
168
224
  }
169
225
  // Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
170
226
  const all = [...counts.keys()].sort((a, b) => (explicitNames.has(b) - explicitNames.has(a)) || (counts.get(b) - counts.get(a)));
171
- const refusedAll = all.filter((n) => SECRET_NAME.test(n));
172
- const ranked = all.filter((n) => !SECRET_NAME.test(n));
173
- const refused = refusedAll.slice(0, MAX_REFUSED);
174
- const beyond = [...ranked.slice(max), ...refusedAll.slice(MAX_REFUSED)];
175
227
  return {
176
- symbols: ranked.slice(0, max),
228
+ ranked: all.filter((n) => !SECRET_NAME.test(n)).map((name) => ({ name, explicit: explicitNames.has(name), count: counts.get(name) })),
229
+ refusedAll: all.filter((n) => SECRET_NAME.test(n)),
230
+ };
231
+ }
232
+
233
+ /** The first `max` names are searched; the rest, and the refused names past MAX_REFUSED, are reported as omitted. */
234
+ function sliceRanked(names, refusedAll, max) {
235
+ const beyond = [...names.slice(max), ...refusedAll.slice(MAX_REFUSED)];
236
+ return {
237
+ symbols: names.slice(0, max),
177
238
  omitted: beyond.slice(0, MAX_OMITTED_NAMES),
178
239
  omittedTotal: beyond.length,
179
- refused,
240
+ refused: refusedAll.slice(0, MAX_REFUSED),
241
+ };
242
+ }
243
+
244
+ // ── Ranking by definition (HC-GF-385) ───────────────────────────────────────────────────────────
245
+
246
+ /** Files whose matches never define code: documentation, and data formats (an object or YAML key is not a symbol). */
247
+ const DATA_EXTENSIONS = Object.freeze(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'lock', 'csv', 'tsv', 'xml', 'ini', 'env', 'properties']);
248
+ /** A word before `name(` that makes it a call or an expression, never a C-family definition. */
249
+ const NOT_A_RETURN_TYPE = new Set(['return', 'await', 'new', 'throw', 'yield', 'else', 'case', 'typeof', 'delete', 'void', 'in', 'of', 'do',
250
+ 'if', 'while', 'for', 'switch', 'catch', 'and', 'or', 'not', 'is', 'as', 'echo', 'print', 'puts', 'go', 'defer', 'export', 'default']);
251
+
252
+ /**
253
+ * How strongly one line defines `name`: 2 for a function, method, class or type; 1 for a top-level or exported
254
+ * constant or variable; 0 for anything else (a call, a key, a mention). Language-neutral patterns, covering
255
+ * JavaScript and TypeScript, Python, Go, Ruby, Java and the C family, and `fn`/`fun`/`func` (Rust, Kotlin, Swift).
256
+ * `name` is an identifier ([A-Za-z0-9_]), so it is safe inside a RegExp.
257
+ * @param {string} line
258
+ * @param {string} name
259
+ * @returns {0|1|2}
260
+ */
261
+ function definitionTier(line, name) {
262
+ const n = `${name}(?![A-Za-z0-9_$])`;
263
+ const t = String(line);
264
+ // A keyword that introduces a definition: `function f`, `class C`, `def f`, `def self.f`, `func (r *T) f`, `fn f`, `fun f`.
265
+ if (new RegExp(`(?:^|[^A-Za-z0-9_$.])(?:function\\*?|class|def|func|fn|fun|interface|enum|struct|trait|type|module|record|protocol)\\s+(?:self\\.)?(?:\\([^)]*\\)\\s*)?${n}`).test(t)) return 2;
266
+ // A function value bound at top level or exported: `export const f = async (a) =>`, `const f = function`.
267
+ if (new RegExp(`^(?:export\\s+)?(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\b\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\\s*=>)`).test(t)) return 2;
268
+ // A method, in a class body or an object literal: ` async f(a, b) {`, ` static get f() {`.
269
+ if (new RegExp(`^\\s*(?:(?:static|async|get|set|public|private|protected|readonly|override|abstract|final)\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^={;]+)?\\{`).test(t)) return 2;
270
+ // A C-family signature: one or more type words, then `name(`, and no `;` (a prototype or a call statement).
271
+ const sig = new RegExp(`^\\s*((?:[A-Za-z_][A-Za-z0-9_<>\\[\\],:*&?]*\\s+)+)\\*?${n}\\s*\\([^;]*$`).exec(t);
272
+ if (sig) {
273
+ const words = sig[1].trim().split(/\s+/);
274
+ if (!NOT_A_RETURN_TYPE.has(words[words.length - 1])) return 2;
275
+ }
276
+ // A top-level or exported binding of anything else: `const f =`, `export let f`, `module.exports.f =`, Go `var f`.
277
+ if (new RegExp(`^(?:export\\s+)?(?:const|let|var|val)\\s+${n}`).test(t)) return 1;
278
+ if (new RegExp(`^(?:module\\.)?exports\\.${n}\\s*=`).test(t)) return 1;
279
+ // Python and Ruby top-level assignment: `f = ...` at column 0, not `f == x`.
280
+ if (new RegExp(`^${n}\\s*=(?!=)`).test(t)) return 1;
281
+ return 0;
282
+ }
283
+
284
+ /**
285
+ * Rank candidates by whether the repository defines them, with ONE batched `git grep -F -w` over all of them.
286
+ * Functions, methods, classes and types come first, then top-level or exported constants, then names with no
287
+ * definition (string keys, config fields, words). Within a tier the story's own order holds. Any failure, timeout
288
+ * or output this cannot read keeps the story's order, and the note says so: a probe never empties the search.
289
+ * @param {Array<{name: string}>} ranked the story's order
290
+ * @returns {Promise<{ranked: Array<{name: string}>, fallback: string|null, undefinedNames: string[]}>}
291
+ */
292
+ async function rankByDefinition(ranked, { run, realPath }) {
293
+ if (ranked.length === 0) return { ranked, fallback: null, undefinedNames: [] };
294
+ const probed = ranked.slice(0, MAX_PROBE_CANDIDATES);
295
+ const argv = ['grep', '-n', '-z', '-w', '-F', '-I', '--no-color', '--no-recurse-submodules'];
296
+ for (const c of probed) argv.push('-e', c.name);
297
+ let r;
298
+ try { r = await run(argv, { timeoutMs: PROBE_TIMEOUT_MS }); } catch { r = { failure: 'the probe could not be run' }; }
299
+ const problem = grepProblem(r || {});
300
+ if (problem) return { ranked, fallback: `the definition probe gave no answer (${problem}), so the story's own order was used`, undefinedNames: [] };
301
+ const lines = r.status === 1 ? [] : parseGrepOutput(r.stdout);
302
+ if (lines === null) return { ranked, fallback: 'the definition probe printed output that could not be read, so the story\'s own order was used', undefinedNames: [] };
303
+
304
+ const tier = new Map(probed.map((c) => [c.name, 0]));
305
+ const kinds = new Map();
306
+ const wordRe = /[A-Za-z_$][A-Za-z0-9_$]*/g;
307
+ for (const hit of lines) {
308
+ let kind = kinds.get(hit.path);
309
+ if (kind === undefined) {
310
+ const ext = path.posix.extname(hit.path).slice(1).toLowerCase();
311
+ kind = DATA_EXTENSIONS.includes(ext) ? 'data' : classifyPath(hit.path, realPath);
312
+ kinds.set(hit.path, kind);
313
+ }
314
+ if (kind !== 'ok') continue; // documentation, data and withheld paths define nothing here
315
+ for (const w of new Set(String(hit.text).match(wordRe) || [])) {
316
+ if (!tier.has(w) || tier.get(w) === 2) continue;
317
+ const d = definitionTier(hit.text, w);
318
+ if (d > tier.get(w)) tier.set(w, d);
319
+ }
320
+ }
321
+ // Stable: within a tier the story's order is kept. Candidates past the probe cap keep their place after the probed.
322
+ const order = probed.map((c, i) => ({ c, i, t: tier.get(c.name) }))
323
+ .sort((a, b) => (b.t - a.t) || (a.i - b.i)).map((x) => x.c);
324
+ return {
325
+ ranked: [...order, ...ranked.slice(MAX_PROBE_CANDIDATES)],
326
+ fallback: null,
327
+ undefinedNames: probed.filter((c) => tier.get(c.name) === 0).map((c) => c.name),
180
328
  };
181
329
  }
182
330
 
@@ -288,7 +436,7 @@ function parseFileList(out) {
288
436
  * - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
289
437
  * `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
290
438
  */
291
- async function searchSymbol(symbol, { run, realPath }) {
439
+ async function searchSymbol(symbol, { run, realPath, maxHits = MAX_HITS_PER_SYMBOL }) {
292
440
  // Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
293
441
  if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
294
442
  return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
@@ -323,7 +471,9 @@ async function searchSymbol(symbol, { run, realPath }) {
323
471
  else sendable.push(hit);
324
472
  }
325
473
  const total = sendable.length + withheld;
326
- const hits = sendable.slice(0, MAX_HITS_PER_SYMBOL).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
474
+ // HC-GF-385: `maxHits` is what is left of the story's MAX_TOTAL_HITS (0 once it is spent), never more than the per-symbol cap.
475
+ const cap = Math.max(0, Math.min(MAX_HITS_PER_SYMBOL, Number.isSafeInteger(maxHits) ? maxHits : MAX_HITS_PER_SYMBOL));
476
+ const hits = sendable.slice(0, cap).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
327
477
  const perFile = new Map();
328
478
  for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
329
479
  const entry = {
@@ -336,7 +486,10 @@ async function searchSymbol(symbol, { run, realPath }) {
336
486
  binary,
337
487
  hits,
338
488
  };
339
- if (sendable.length > MAX_HITS_PER_SYMBOL) {
489
+ // HC-GF-385: every searched symbol with a sendable hit carries its per-file list, not only one past the hit cap.
490
+ // A symbol cut by the total-hit budget needs it to be FILES ONLY, and a whole symbol whose hit lines the server's
491
+ // render budget drops needs it to stay an enumeration at file level.
492
+ if (sendable.length > 0) {
340
493
  const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
341
494
  entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
342
495
  entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
@@ -344,11 +497,17 @@ async function searchSymbol(symbol, { run, realPath }) {
344
497
  return entry;
345
498
  }
346
499
 
347
- /** Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so. */
500
+ /**
501
+ * Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so.
502
+ * HC-GF-385: the bytes go first to the symbols whose hit lines were cut (by the per-symbol cap or the total-hit
503
+ * budget), in rank order, because for them the list IS the enumeration; then to the rest, in rank order. Spent
504
+ * in plain rank order, whole symbols at the top starved the FILES ONLY ones below them into INCOMPLETE.
505
+ */
348
506
  function fitFileLists(symbols) {
349
507
  let room = MAX_FILE_LIST_BYTES;
350
- for (const s of symbols) {
351
- if (!s.file_list) continue;
508
+ const cut = (s) => Array.isArray(s.hits) && Number.isSafeInteger(s.total) && s.hits.length < s.total - (s.withheld || 0);
509
+ const ordered = [...symbols.filter((s) => s.file_list && cut(s)), ...symbols.filter((s) => s.file_list && !cut(s))];
510
+ for (const s of ordered) {
352
511
  const kept = [];
353
512
  for (const f of s.file_list) {
354
513
  const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
@@ -379,8 +538,9 @@ const warn = (text) => ({ level: 'warn', text });
379
538
  */
380
539
  async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
381
540
  const notes = [];
382
- const picked = pickSymbols(storyText, { max: maxSymbols });
383
- if (picked.symbols.length === 0 && picked.refused.length === 0) return { callSites: null, notes };
541
+ const { ranked: storyOrder, refusedAll } = rankCandidates(storyText);
542
+ // The early return comes BEFORE the probe: a story that names nothing searchable runs no git at all.
543
+ if (storyOrder.length === 0 && refusedAll.length === 0) return { callSites: null, notes };
384
544
 
385
545
  // From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
386
546
  let root = cwd;
@@ -389,17 +549,24 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
389
549
  const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
390
550
  if (dir) root = dir;
391
551
  }
392
- const git = run || ((argv) => runGit(argv, { cwd: root }));
552
+ const git = run || ((argv, opts = {}) => runGit(argv, { cwd: root, ...opts }));
393
553
  const real = realPath || ((rel) => repoRealPath(root, rel));
394
554
  const commit = await headCommit(git);
395
- const symbols = [];
555
+ // HC-GF-385: the clock starts before the probe, so the probe's time comes out of the same TOTAL_BUDGET_MS.
396
556
  const started = now();
557
+ // Rank BEFORE the slice and compute what was omitted AFTER it, or the functions never reach the slice.
558
+ const probe = await rankByDefinition(storyOrder, { run: git, realPath: real });
559
+ const picked = sliceRanked(probe.ranked.map((c) => c.name), refusedAll, maxSymbols);
560
+ const symbols = [];
561
+ let hitsLeft = MAX_TOTAL_HITS;
397
562
  for (const symbol of picked.symbols) {
398
563
  if (now() - started > TOTAL_BUDGET_MS) {
399
564
  symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
400
565
  continue;
401
566
  }
402
- symbols.push(await searchSymbol(symbol, { run: git, realPath: real }));
567
+ const entry = await searchSymbol(symbol, { run: git, realPath: real, maxHits: hitsLeft });
568
+ if (Array.isArray(entry.hits)) hitsLeft = Math.max(0, hitsLeft - entry.hits.length);
569
+ symbols.push(entry);
403
570
  }
404
571
  for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
405
572
  fitFileLists(symbols);
@@ -413,12 +580,17 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
413
580
  omitted: picked.omitted,
414
581
  omitted_total: picked.omittedTotal,
415
582
  };
416
- notes.push(...describeCallSites(callSites));
583
+ if (probe.fallback) notes.push(warn(` ⚠ call sites: ${probe.fallback}`));
584
+ const searchedUndefined = probe.undefinedNames.filter((n) => picked.symbols.includes(n));
585
+ if (!probe.fallback && searchedUndefined.length > 0) {
586
+ notes.push(info(` no definition found for ${searchedUndefined.join(', ')} (ranked after defined code; it may be a key, a field, or one this story creates)`));
587
+ }
588
+ notes.push(...describeCallSites(callSites, { maxSymbols }));
417
589
  return { callSites, notes };
418
590
  }
419
591
 
420
592
  /** The operator's lines for a result: one info line per symbol that is whole, a warning for each that is not. */
421
- function describeCallSites(callSites) {
593
+ function describeCallSites(callSites, { maxSymbols = MAX_SYMBOLS } = {}) {
422
594
  const notes = [];
423
595
  const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
424
596
  notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
@@ -437,7 +609,10 @@ function describeCallSites(callSites) {
437
609
  binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
438
610
  ].filter(Boolean).join('; ');
439
611
  const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
440
- if (s.status === 'incomplete' && fileLevel) {
612
+ if (s.status === 'incomplete' && fileLevel && s.hits.length === 0) {
613
+ // HC-GF-385: reached after the total-hit budget was spent. The server renders it FILES ONLY.
614
+ notes.push(info(` ${s.symbol}: FILES ONLY — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; every file with a hit is named with its count, individual lines not sent (the ${MAX_TOTAL_HITS}-hit budget was spent on higher-ranked symbols)${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
615
+ } else if (s.status === 'incomplete' && fileLevel) {
441
616
  notes.push(info(` ${s.symbol}: COMPLETE AT FILE LEVEL — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; the first ${s.hits.length} are listed and every file with a hit is named with its count${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
442
617
  } else if (s.status === 'incomplete') {
443
618
  notes.push(warn(` ⚠ call sites for ${s.symbol}: INCOMPLETE — ${s.hits.length} of ${listable} hits listed (cap ${MAX_HITS_PER_SYMBOL}${s.file_list ? `, ${s.file_list.length} of ${s.files} files named` : ''}); the planner cannot claim an enumeration${extra ? ` (${extra})` : ''}`));
@@ -454,16 +629,21 @@ function describeCallSites(callSites) {
454
629
  const omittedTotal = callSites.omitted_total || 0;
455
630
  if (omittedTotal > 0) {
456
631
  const shown = callSites.omitted.join(', ') + (omittedTotal > callSites.omitted.length ? ', …' : '');
457
- notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${MAX_SYMBOLS}-symbol cap — NOT SEARCHED: ${shown}`));
632
+ notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${maxSymbols}-symbol cap — NOT SEARCHED: ${shown}`));
458
633
  }
459
634
  return notes;
460
635
  }
461
636
 
462
637
  module.exports = {
638
+ backtickedSpans,
463
639
  CALL_SITES_VERSION,
464
640
  SCOPE,
465
641
  MAX_SYMBOLS,
466
642
  MAX_REFUSED,
643
+ MAX_TOTAL_HITS,
644
+ MAX_PROBE_CANDIDATES,
645
+ PROBE_TIMEOUT_MS,
646
+ DATA_EXTENSIONS,
467
647
  MAX_FILE_LIST_FILES,
468
648
  MAX_FILE_LIST_BYTES,
469
649
  SECRET_NAME,
@@ -478,6 +658,9 @@ module.exports = {
478
658
  MAX_OUTPUT_BYTES,
479
659
  isCompoundIdentifier,
480
660
  pickSymbols,
661
+ rankCandidates,
662
+ definitionTier,
663
+ rankByDefinition,
481
664
  fitFileLists,
482
665
  runGit,
483
666
  parseGrepOutput,
@@ -208,21 +208,24 @@ function batchIdFromQueueOutput(stdout) {
208
208
  * status, created_at, error_message), newest first.
209
209
  * - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
210
210
  * queued but whose kill failed, or that died between the two, leaves exactly this and no marker.
211
- * - `earlier`: a run of the story killed with `re-served as batch <id>` BEFORE this one, so this run is that
212
- * re-serve (the loop was started on the new batch alone) and a second pause here is the second.
213
- * - `unknown`: the paused run's creation time is not a date, so neither can be told.
214
- * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string}|null}
211
+ * - `earlier`: a run of the story, older than this one, killed with `re-served as batch <id>` where <id> is the batch
212
+ * THIS run is in (`batchId`): this run is that re-serve (the loop was started on the new batch alone), and a second
213
+ * pause here is the second. A story re-served into some OTHER batch, however long ago, is history and not this
214
+ * run's lineage: it neither blocks nor justifies anything (HC-GF-351-followup-1).
215
+ * - `unknown`: the paused run's creation time is not a date or its batch id is not one, so neither can be told.
216
+ * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string, why?: string}|null}
215
217
  */
216
- function serveHistory(rows, { runId, storyId, repoName, since }) {
218
+ function serveHistory(rows, { runId, storyId, repoName, since, batchId: ownBatch }) {
217
219
  const t0 = Date.parse(since);
218
- if (!Number.isFinite(t0)) return { kind: 'unknown' };
220
+ if (!Number.isFinite(t0)) return { kind: 'unknown', why: 'its creation time is not known' };
221
+ if (!isBatchId(ownBatch)) return { kind: 'unknown', why: 'the batch it is in is not known' };
219
222
  for (const row of Array.isArray(rows) ? rows : []) {
220
223
  if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
221
224
  const t = Date.parse(row.created_at);
222
225
  if (!Number.isFinite(t)) continue;
223
226
  if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
224
- const batchId = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
225
- if (batchId) return { kind: 'earlier', runId: row.id, batchId };
227
+ const reserved = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
228
+ if (reserved && reserved === ownBatch) return { kind: 'earlier', runId: row.id, batchId: reserved };
226
229
  }
227
230
  return null;
228
231
  }
@@ -16,6 +16,8 @@
16
16
  * the unshipped `scripts/` tree.
17
17
  */
18
18
 
19
+ const { readAnthropicText } = require('./anthropic-response');
20
+
19
21
  /** Said by every command that used to default to GitHub Models, so the cause is one sentence. */
20
22
  const GH_MODELS_RETIRED =
21
23
  'GitHub Models (--provider gh-models), the free default, was retired by GitHub on 2026-07-30: '
@@ -38,25 +40,32 @@ function buildJudgeCallLLM(provider, { axios }) {
38
40
  return { error: 'ANTHROPIC_API_KEY required for --provider claude. Set it: export ANTHROPIC_API_KEY=sk-ant-...' };
39
41
  }
40
42
  // MODEL CHOICE (feedback_model_choice_cost_amplifier): the judge is NOT on the
41
- // OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5`), never Opus.
43
+ // OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5-5`), never Opus.
44
+ //
45
+ // HC-GF-337f: Sonnet 5.5 answers HTTP 400 to `thinking: {type: 'disabled'}`. Thinking and effort are
46
+ // TWO sibling fields, never one: `thinking: {type: 'adaptive'}` and `output_config: {effort: 'low'}`
47
+ // (a `thinking` object with an `effort` key is wrong). Effort is sent because its default is `high`
48
+ // and a judge wants `low`. Thinking tokens share `max_tokens`, hence 8192. No `temperature`, `top_p`,
49
+ // `top_k` or `tool_choice` is sent, and that is load-bearing now: a non-default sampling value or a
50
+ // forced tool choice is a 400 on this model.
42
51
  const callLLM = async (systemPrompt, userPrompt) => {
43
52
  const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
44
- model: 'claude-sonnet-5',
45
- thinking: { type: 'disabled' },
46
- max_tokens: 2048,
53
+ model: 'claude-sonnet-5-5',
54
+ thinking: { type: 'adaptive' },
55
+ output_config: { effort: 'low' },
56
+ max_tokens: 8192,
47
57
  system: systemPrompt,
48
58
  messages: [{ role: 'user', content: userPrompt }],
49
59
  }, {
50
60
  headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
51
- timeout: 60000,
61
+ timeout: 120000,
52
62
  });
53
- const text = (data.content || []).find((b) => b?.type === 'text')?.text;
54
63
  // A provider that answered with nothing is a provider failure, not a failed criterion:
55
- // returning '' here made every criterion read "could not parse response".
56
- if (typeof text !== 'string' || !text.trim()) {
57
- throw new Error(`the claude provider returned no text (stop_reason: ${data.stop_reason || 'unknown'})`);
58
- }
59
- return text;
64
+ // returning '' here made every criterion read "could not parse response". A refusal (HTTP 200,
65
+ // stop_reason "refusal") names its category so it is not read as a quota or parse problem.
66
+ const r = readAnthropicText(data, { label: 'the claude provider', allowPartial: true }); // a verdict cut off defaults to NO
67
+ if (!r.ok) throw new Error(r.message);
68
+ return r.text;
60
69
  };
61
70
  return { callLLM, provider: 'claude', paid: true };
62
71
  }
@@ -27,10 +27,15 @@
27
27
  // U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
28
28
  // \u escapes: a literal bidi character in this source would reorder how THIS file reads.
29
29
  // eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
30
- const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
31
-
30
+ const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\u{e0000}-\u{e007f}]/gu;
31
+ // A line break or tab is a space, so two words do not join; the characters above draw nothing yet change what a reader sees
32
+ // (zero-width, soft hyphen, BOM, the Unicode tag block); a lone surrogate is not a character a terminal or JSON can carry.
33
+ // Mirrors server/src/services/architect-consult.js scrubForTerminal: the two packages cannot share code, and a test pins them equal.
32
34
  function scrub(text) {
33
- return String(text == null ? '' : text).replace(TERMINAL_UNSAFE_RE, '');
35
+ return String(text == null ? '' : text)
36
+ .replace(/[\t\n\v\f\r\u0085\u2028\u2029]+/g, ' ')
37
+ .replace(TERMINAL_UNSAFE_RE, '')
38
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\ufffd');
34
39
  }
35
40
 
36
41
  /**
@@ -91,4 +96,4 @@ function gateQuestionLines(status, stepKey) {
91
96
  ];
92
97
  }
93
98
 
94
- module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
99
+ module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase, scrub };
@@ -67,8 +67,9 @@ function resolveBaseRef(base) {
67
67
  * documentation later confirms the cap is TOTAL (input + output) instead
68
68
  * of input-only, drop this number AND `max_tokens` together.
69
69
  *
70
- * Anthropic Opus `claude-opus-4-8`: 1M context window (upgraded from
71
- * the legacy 4-20250514's 200K). The 100K char cap is well below the
70
+ * Anthropic Opus `claude-opus-5-5`: 1M context window (upgraded from
71
+ * the legacy 4-20250514's 200K; HC-GF-337f moved the id from claude-opus-4-8; the 1M figure is the
72
+ * Anthropic models table's context window for claude-opus-5-5, read 2026-10-10). The 100K char cap is well below the
72
73
  * model's headroom but kept conservative — the prompt + diff together
73
74
  * comfortably fit, and bumping the cap is a separate decision.
74
75
  *