@hone-ai/cli 1.40.0 → 1.42.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.
@@ -167,6 +167,39 @@ const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n
167
167
  /** A `DELETE: <path>` line. */
168
168
  const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
169
169
 
170
+ /**
171
+ * HC-GF-404: the [from, to) of every `file:` block FILE_BLOCK_RE matches, rejected or duplicate
172
+ * paths included, in ascending order. A whole file's content is data: a line-start `edit:` opener
173
+ * or `DELETE:` line inside one is an example the file holds, never a block of its own.
174
+ */
175
+ function fileBlockSpans(text) {
176
+ const spans = [];
177
+ for (const m of String(text == null ? '' : text).matchAll(FILE_BLOCK_RE)) {
178
+ spans.push({ from: m.index, to: m.index + m[0].length });
179
+ }
180
+ return spans;
181
+ }
182
+
183
+ /**
184
+ * HC-GF-404: every match of `re` over `text` that does not start inside one of `spans`
185
+ * (fileBlockSpans() of the same text). A match that starts inside a span is not dropped after the
186
+ * fact: the scan resumes at that span's end, because a phantom match can run past the span (a nested
187
+ * edit with a fence wider than the file's) and swallow a real block after it. `re` is cloned, so the
188
+ * shared /g regexes above are never advanced.
189
+ */
190
+ function* matchesOutside(re, text, spans) {
191
+ const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
192
+ const s = String(text == null ? '' : text);
193
+ let i = 0;
194
+ let m;
195
+ while ((m = r.exec(s)) !== null) {
196
+ while (i < spans.length && spans[i].to <= m.index) i++;
197
+ if (i < spans.length && m.index >= spans[i].from) { r.lastIndex = spans[i].to; continue; }
198
+ if (m[0].length === 0) r.lastIndex++;
199
+ yield m;
200
+ }
201
+ }
202
+
170
203
  module.exports = {
171
204
  fenceWidth,
172
205
  closesFence,
@@ -178,4 +211,6 @@ module.exports = {
178
211
  EDIT_OPEN_RE,
179
212
  EDIT_SPLIT_RE,
180
213
  DELETE_RE,
214
+ fileBlockSpans,
215
+ matchesOutside,
181
216
  };
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  const path = require('path');
38
+ const { pathRefusal } = require('./path-refusal');
38
39
 
39
40
  /**
40
41
  * Repo-level files worth bundling regardless of what the story says. Deliberately
@@ -50,18 +51,12 @@ const SIGNAL_FILES = Object.freeze([
50
51
  ]);
51
52
 
52
53
  /**
53
- * A path that must never reach an agent prompt. Matched on the BASENAME so a nested
54
- * `config/.env` is caught too. The `.example`/`.sample`/`.template` suffixes are the
55
- * documented exception and are handled by testing them first.
54
+ * Is this path a credential file that must never be sent as whole content? `.env.example` is not;
55
+ * `.env.production` and `keys/deploy.key` are. HC-GF-398: the patterns live in `./path-refusal`
56
+ * (egress mode); this name stays for the callers that still use it.
56
57
  */
57
- const SECRET_BASENAMES = [/^\.env($|\.)/i, /^secrets?\./i, /\.pem$/i, /\.p12$/i, /(^|[._-])id_(rsa|dsa|ecdsa|ed25519)$/i];
58
- const SECRET_EXEMPT = /\.(example|sample|template|dist)$/i;
59
-
60
- /** Is this path a credential file? `.env.example` is not; `.env.production` is. */
61
58
  function isSecretPath(p) {
62
- const base = path.posix.basename(String(p || '').replace(/\\/g, '/'));
63
- if (SECRET_EXEMPT.test(base)) return false;
64
- return SECRET_BASENAMES.some((re) => re.test(base));
59
+ return pathRefusal(p, { mode: 'egress' }) !== null;
65
60
  }
66
61
 
67
62
  /**
@@ -146,7 +141,7 @@ function nearestPopulatedAncestor(pendingPath, repoFiles) {
146
141
  function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSiblings = 6 } = {}) {
147
142
  const present = [], pending = [], refused = [];
148
143
  for (const p of named) {
149
- if (isSecretPath(p)) { refused.push(p); continue; }
144
+ if (pathRefusal(p, { mode: 'egress' })) { refused.push(p); continue; }
150
145
  (exists(p) ? present : pending).push(p);
151
146
  }
152
147
 
@@ -168,7 +163,7 @@ function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSib
168
163
  (path.posix.extname(b) === ext) - (path.posix.extname(a) === ext));
169
164
  for (const k of ranked) {
170
165
  if (siblings.length >= maxSiblings) break;
171
- if (taken.has(k) || isSecretPath(k) || isGeneratedPath(k)) continue;
166
+ if (taken.has(k) || pathRefusal(k, { mode: 'egress' }) || isGeneratedPath(k)) continue;
172
167
  taken.add(k); siblings.push(k);
173
168
  }
174
169
  }
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-390: the leaf home of the bundle's per-file cap. run-context.js windows a file whose text is
4
+ * longer than this (bundle-paths.js `planFileExcerpt`: `text.length <= limit` is sent whole), and
5
+ * review-diff.js diffs exactly the files a reviewer saw only windows of, so both read it from here.
6
+ * run-context.js re-exports it under the same name for the tests that import it from there.
7
+ */
8
+ const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
9
+
10
+ module.exports = { BUNDLE_MAX_PER_FILE_CHARS };
package/lib/call-sites.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - A search that did not run is 'not_searched', never zero callers. `git grep` exits 1 for "no match" and 128
18
18
  * for a fatal error; only the first is an answer. A timeout, a missing git and an output overflow are
19
19
  * classified failures, not empty results.
20
- * - A line from a hidden or credential-looking path is never sent (cli/lib/context-reserve.js contentRefusal,
20
+ * - A line from a hidden or credential-looking path is never sent (cli/lib/path-refusal.js in content mode,
21
21
  * the rule a re-serve uses); it is counted, so the planner knows callers exist that it cannot see. The check
22
22
  * is made on the disk as it is now: `git grep` follows a DIRECTORY that was replaced by a symlink after the
23
23
  * commit (measured with git 2.50.1: a tracked `d/secret.txt` read from outside the checkout), so a path that
@@ -65,7 +65,8 @@
65
65
  const { execFile } = require('node:child_process');
66
66
  const path = require('node:path');
67
67
  const { gitEnv } = require('./git-env');
68
- const { contentRefusal, repoRealPath } = require('./context-reserve');
68
+ const { repoRealPath } = require('./context-reserve');
69
+ const { pathRefusal } = require('./path-refusal');
69
70
 
70
71
  const CALL_SITES_VERSION = 1;
71
72
  /** The only scope id today; the server writes its own description of it. */
@@ -73,9 +74,22 @@ const SCOPE = 'tracked-repo';
73
74
 
74
75
  // The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
75
76
  // 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. */
77
+ // HC-GF-385: 16, up from 6. Six slots spent on string keys (`step_0b`, `config`) left the functions a story changes
78
+ // NOT SEARCHED and hard-stopped step_1 four times on one story. The wire budget is held by MAX_TOTAL_HITS, not by
79
+ // the symbol count: a symbol past it is sent as its per-file list (FILES ONLY), so 16 costs what 6 did.
80
+ const MAX_SYMBOLS = 16;
81
+ /** Secret-looking names are reported, not searched; they take no slot of the 16 but are reported up to this many. */
78
82
  const MAX_REFUSED = 2;
83
+ /**
84
+ * HC-GF-385: the hit lines ALL symbols together may send, spent in rank order: today's 6 x 40, so the 448 KiB
85
+ * per-story wire budget (server/src/lib/batch-body-limit.js) and its test keep their meaning. A symbol reached after
86
+ * it is spent sends `hits: []` plus its per-file list.
87
+ */
88
+ const MAX_TOTAL_HITS = 240;
89
+ /** HC-GF-385: the definition probe ranks at most this many candidates, best-ranked first. */
90
+ const MAX_PROBE_CANDIDATES = 32;
91
+ /** HC-GF-385: the probe's own share of TOTAL_BUDGET_MS; past it the story's own order is kept, and said. */
92
+ const PROBE_TIMEOUT_MS = 8_000;
79
93
  /** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
80
94
  const MAX_FILE_LIST_FILES = 300;
81
95
  /** ...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 +111,38 @@ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc',
97
111
 
98
112
  // ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
99
113
 
100
- const BACKTICKED = /`([^`\n]{2,200})`/g;
114
+ // Backticked spans are PAIRED first, by the CommonMark rule, and the 2-to-200 rule is applied to each paired span afterwards.
115
+ // A bound INSIDE a regex decides which backticks pair: at `x`, and `foo` the opening backtick of `x` cannot start a 2-character
116
+ // 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
117
+ // extracted a wrong span set). A plain /`([^`\n]*)`/ fixed that and made the next one: after a run of TWO OR MORE backticks
118
+ // (a ``double`` span, an inline ```fence) the pairing never recovered for the rest of the line, and 7 rows lost symbols the old
119
+ // 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
120
+ // 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
121
+ // newline here, as before). Linear: the next run of the same length is computed once per run, not searched for.
122
+ // The same defect as BACKTICKED_RE in bundle-paths.js (HC-GF-188-followup-2), which has the first fix only.
123
+ function backtickedSpans(text) {
124
+ const spans = [];
125
+ for (const line of String(text || '').split('\n')) {
126
+ if (!line.includes('`')) continue;
127
+ const runs = [];
128
+ for (const m of line.matchAll(/`+/g)) runs.push({ at: m.index, len: m[0].length });
129
+ const nextSame = new Array(runs.length).fill(-1);
130
+ const lastOfLen = new Map();
131
+ for (let i = runs.length - 1; i >= 0; i--) {
132
+ nextSame[i] = lastOfLen.has(runs[i].len) ? lastOfLen.get(runs[i].len) : -1;
133
+ lastOfLen.set(runs[i].len, i);
134
+ }
135
+ for (let i = 0; i < runs.length; i++) {
136
+ const j = nextSame[i];
137
+ if (j < 0) continue; // no closer of this length: the run is literal text
138
+ let span = line.slice(runs[i].at + runs[i].len, runs[j].at);
139
+ if (span.length > 2 && span.startsWith(' ') && span.endsWith(' ') && /[^ ]/.test(span)) span = span.slice(1, -1); // CommonMark: one space each side
140
+ spans.push(span);
141
+ i = j; // resume after the closer; the runs between are inside the span
142
+ }
143
+ }
144
+ return spans;
145
+ }
101
146
  // One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
102
147
  // segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
103
148
  // Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
@@ -158,25 +203,129 @@ function symbolOfSpan(span) {
158
203
  * @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
159
204
  */
160
205
  function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
206
+ const { ranked, refusedAll } = rankCandidates(storyText);
207
+ return sliceRanked(ranked.map((c) => c.name), refusedAll, max);
208
+ }
209
+
210
+ /**
211
+ * Every candidate the story names, in the story's order (explicitly written first, then by how often it is named,
212
+ * ties in the order first named), with the facts that order was made from. Secret-looking names are split off.
213
+ * Synchronous and text-only: `pickSymbols` keeps that contract for callers this file cannot see (HC-GF-385).
214
+ * @returns {{ranked: Array<{name: string, explicit: boolean, count: number}>, refusedAll: string[]}}
215
+ */
216
+ function rankCandidates(storyText) {
161
217
  const counts = new Map();
162
218
  const explicitNames = new Set();
163
- for (const m of String(storyText || '').matchAll(BACKTICKED)) {
164
- const sym = symbolOfSpan(m[1]);
219
+ for (const span of backtickedSpans(storyText)) {
220
+ if (span.length < 2 || span.length > 200) continue; // the length rule, after pairing
221
+ const sym = symbolOfSpan(span);
165
222
  if (!sym) continue;
166
223
  counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
167
224
  if (sym.explicit) explicitNames.add(sym.name);
168
225
  }
169
226
  // Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
170
227
  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
228
  return {
176
- symbols: ranked.slice(0, max),
229
+ ranked: all.filter((n) => !SECRET_NAME.test(n)).map((name) => ({ name, explicit: explicitNames.has(name), count: counts.get(name) })),
230
+ refusedAll: all.filter((n) => SECRET_NAME.test(n)),
231
+ };
232
+ }
233
+
234
+ /** The first `max` names are searched; the rest, and the refused names past MAX_REFUSED, are reported as omitted. */
235
+ function sliceRanked(names, refusedAll, max) {
236
+ const beyond = [...names.slice(max), ...refusedAll.slice(MAX_REFUSED)];
237
+ return {
238
+ symbols: names.slice(0, max),
177
239
  omitted: beyond.slice(0, MAX_OMITTED_NAMES),
178
240
  omittedTotal: beyond.length,
179
- refused,
241
+ refused: refusedAll.slice(0, MAX_REFUSED),
242
+ };
243
+ }
244
+
245
+ // ── Ranking by definition (HC-GF-385) ───────────────────────────────────────────────────────────
246
+
247
+ /** Files whose matches never define code: documentation, and data formats (an object or YAML key is not a symbol). */
248
+ const DATA_EXTENSIONS = Object.freeze(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'lock', 'csv', 'tsv', 'xml', 'ini', 'env', 'properties']);
249
+ /** A word before `name(` that makes it a call or an expression, never a C-family definition. */
250
+ const NOT_A_RETURN_TYPE = new Set(['return', 'await', 'new', 'throw', 'yield', 'else', 'case', 'typeof', 'delete', 'void', 'in', 'of', 'do',
251
+ 'if', 'while', 'for', 'switch', 'catch', 'and', 'or', 'not', 'is', 'as', 'echo', 'print', 'puts', 'go', 'defer', 'export', 'default']);
252
+
253
+ /**
254
+ * How strongly one line defines `name`: 2 for a function, method, class or type; 1 for a top-level or exported
255
+ * constant or variable; 0 for anything else (a call, a key, a mention). Language-neutral patterns, covering
256
+ * JavaScript and TypeScript, Python, Go, Ruby, Java and the C family, and `fn`/`fun`/`func` (Rust, Kotlin, Swift).
257
+ * `name` is an identifier ([A-Za-z0-9_]), so it is safe inside a RegExp.
258
+ * @param {string} line
259
+ * @param {string} name
260
+ * @returns {0|1|2}
261
+ */
262
+ function definitionTier(line, name) {
263
+ const n = `${name}(?![A-Za-z0-9_$])`;
264
+ const t = String(line);
265
+ // A keyword that introduces a definition: `function f`, `class C`, `def f`, `def self.f`, `func (r *T) f`, `fn f`, `fun f`.
266
+ 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;
267
+ // A function value bound at top level or exported: `export const f = async (a) =>`, `const f = function`.
268
+ 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;
269
+ // A method, in a class body or an object literal: ` async f(a, b) {`, ` static get f() {`.
270
+ if (new RegExp(`^\\s*(?:(?:static|async|get|set|public|private|protected|readonly|override|abstract|final)\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^={;]+)?\\{`).test(t)) return 2;
271
+ // A C-family signature: one or more type words, then `name(`, and no `;` (a prototype or a call statement).
272
+ const sig = new RegExp(`^\\s*((?:[A-Za-z_][A-Za-z0-9_<>\\[\\],:*&?]*\\s+)+)\\*?${n}\\s*\\([^;]*$`).exec(t);
273
+ if (sig) {
274
+ const words = sig[1].trim().split(/\s+/);
275
+ if (!NOT_A_RETURN_TYPE.has(words[words.length - 1])) return 2;
276
+ }
277
+ // A top-level or exported binding of anything else: `const f =`, `export let f`, `module.exports.f =`, Go `var f`.
278
+ if (new RegExp(`^(?:export\\s+)?(?:const|let|var|val)\\s+${n}`).test(t)) return 1;
279
+ if (new RegExp(`^(?:module\\.)?exports\\.${n}\\s*=`).test(t)) return 1;
280
+ // Python and Ruby top-level assignment: `f = ...` at column 0, not `f == x`.
281
+ if (new RegExp(`^${n}\\s*=(?!=)`).test(t)) return 1;
282
+ return 0;
283
+ }
284
+
285
+ /**
286
+ * Rank candidates by whether the repository defines them, with ONE batched `git grep -F -w` over all of them.
287
+ * Functions, methods, classes and types come first, then top-level or exported constants, then names with no
288
+ * definition (string keys, config fields, words). Within a tier the story's own order holds. Any failure, timeout
289
+ * or output this cannot read keeps the story's order, and the note says so: a probe never empties the search.
290
+ * @param {Array<{name: string}>} ranked the story's order
291
+ * @returns {Promise<{ranked: Array<{name: string}>, fallback: string|null, undefinedNames: string[]}>}
292
+ */
293
+ async function rankByDefinition(ranked, { run, realPath }) {
294
+ if (ranked.length === 0) return { ranked, fallback: null, undefinedNames: [] };
295
+ const probed = ranked.slice(0, MAX_PROBE_CANDIDATES);
296
+ const argv = ['grep', '-n', '-z', '-w', '-F', '-I', '--no-color', '--no-recurse-submodules'];
297
+ for (const c of probed) argv.push('-e', c.name);
298
+ let r;
299
+ try { r = await run(argv, { timeoutMs: PROBE_TIMEOUT_MS }); } catch { r = { failure: 'the probe could not be run' }; }
300
+ const problem = grepProblem(r || {});
301
+ if (problem) return { ranked, fallback: `the definition probe gave no answer (${problem}), so the story's own order was used`, undefinedNames: [] };
302
+ const lines = r.status === 1 ? [] : parseGrepOutput(r.stdout);
303
+ 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: [] };
304
+
305
+ const tier = new Map(probed.map((c) => [c.name, 0]));
306
+ const kinds = new Map();
307
+ const wordRe = /[A-Za-z_$][A-Za-z0-9_$]*/g;
308
+ for (const hit of lines) {
309
+ let kind = kinds.get(hit.path);
310
+ if (kind === undefined) {
311
+ const ext = path.posix.extname(hit.path).slice(1).toLowerCase();
312
+ kind = DATA_EXTENSIONS.includes(ext) ? 'data' : classifyPath(hit.path, realPath);
313
+ kinds.set(hit.path, kind);
314
+ }
315
+ if (kind !== 'ok') continue; // documentation, data and withheld paths define nothing here
316
+ for (const w of new Set(String(hit.text).match(wordRe) || [])) {
317
+ if (!tier.has(w) || tier.get(w) === 2) continue;
318
+ const d = definitionTier(hit.text, w);
319
+ if (d > tier.get(w)) tier.set(w, d);
320
+ }
321
+ }
322
+ // Stable: within a tier the story's order is kept. Candidates past the probe cap keep their place after the probed.
323
+ const order = probed.map((c, i) => ({ c, i, t: tier.get(c.name) }))
324
+ .sort((a, b) => (b.t - a.t) || (a.i - b.i)).map((x) => x.c);
325
+ return {
326
+ ranked: [...order, ...ranked.slice(MAX_PROBE_CANDIDATES)],
327
+ fallback: null,
328
+ undefinedNames: probed.filter((c) => tier.get(c.name) === 0).map((c) => c.name),
180
329
  };
181
330
  }
182
331
 
@@ -239,7 +388,7 @@ function classifyPath(rel, realPath) {
239
388
  const ext = path.posix.extname(rel).slice(1).toLowerCase();
240
389
  if (PROSE_EXTENSIONS.includes(ext)) return 'prose';
241
390
  if (rel.length > MAX_PATH_CHARS || /[\x00-\x1f\x7f]/.test(rel)) return 'withheld';
242
- if (contentRefusal(rel)) return 'withheld';
391
+ if (pathRefusal(rel, { mode: 'content' })) return 'withheld';
243
392
  // A directory above the file may have been replaced by a link since the commit; git reads through it.
244
393
  let real = null;
245
394
  try { real = realPath(rel); } catch { real = null; }
@@ -288,7 +437,7 @@ function parseFileList(out) {
288
437
  * - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
289
438
  * `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
290
439
  */
291
- async function searchSymbol(symbol, { run, realPath }) {
440
+ async function searchSymbol(symbol, { run, realPath, maxHits = MAX_HITS_PER_SYMBOL }) {
292
441
  // Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
293
442
  if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
294
443
  return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
@@ -323,7 +472,9 @@ async function searchSymbol(symbol, { run, realPath }) {
323
472
  else sendable.push(hit);
324
473
  }
325
474
  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) }));
475
+ // 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.
476
+ const cap = Math.max(0, Math.min(MAX_HITS_PER_SYMBOL, Number.isSafeInteger(maxHits) ? maxHits : MAX_HITS_PER_SYMBOL));
477
+ const hits = sendable.slice(0, cap).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
327
478
  const perFile = new Map();
328
479
  for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
329
480
  const entry = {
@@ -336,7 +487,10 @@ async function searchSymbol(symbol, { run, realPath }) {
336
487
  binary,
337
488
  hits,
338
489
  };
339
- if (sendable.length > MAX_HITS_PER_SYMBOL) {
490
+ // HC-GF-385: every searched symbol with a sendable hit carries its per-file list, not only one past the hit cap.
491
+ // A symbol cut by the total-hit budget needs it to be FILES ONLY, and a whole symbol whose hit lines the server's
492
+ // render budget drops needs it to stay an enumeration at file level.
493
+ if (sendable.length > 0) {
340
494
  const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
341
495
  entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
342
496
  entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
@@ -344,11 +498,17 @@ async function searchSymbol(symbol, { run, realPath }) {
344
498
  return entry;
345
499
  }
346
500
 
347
- /** Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so. */
501
+ /**
502
+ * Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so.
503
+ * HC-GF-385: the bytes go first to the symbols whose hit lines were cut (by the per-symbol cap or the total-hit
504
+ * budget), in rank order, because for them the list IS the enumeration; then to the rest, in rank order. Spent
505
+ * in plain rank order, whole symbols at the top starved the FILES ONLY ones below them into INCOMPLETE.
506
+ */
348
507
  function fitFileLists(symbols) {
349
508
  let room = MAX_FILE_LIST_BYTES;
350
- for (const s of symbols) {
351
- if (!s.file_list) continue;
509
+ const cut = (s) => Array.isArray(s.hits) && Number.isSafeInteger(s.total) && s.hits.length < s.total - (s.withheld || 0);
510
+ const ordered = [...symbols.filter((s) => s.file_list && cut(s)), ...symbols.filter((s) => s.file_list && !cut(s))];
511
+ for (const s of ordered) {
352
512
  const kept = [];
353
513
  for (const f of s.file_list) {
354
514
  const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
@@ -379,8 +539,9 @@ const warn = (text) => ({ level: 'warn', text });
379
539
  */
380
540
  async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
381
541
  const notes = [];
382
- const picked = pickSymbols(storyText, { max: maxSymbols });
383
- if (picked.symbols.length === 0 && picked.refused.length === 0) return { callSites: null, notes };
542
+ const { ranked: storyOrder, refusedAll } = rankCandidates(storyText);
543
+ // The early return comes BEFORE the probe: a story that names nothing searchable runs no git at all.
544
+ if (storyOrder.length === 0 && refusedAll.length === 0) return { callSites: null, notes };
384
545
 
385
546
  // From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
386
547
  let root = cwd;
@@ -389,17 +550,24 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
389
550
  const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
390
551
  if (dir) root = dir;
391
552
  }
392
- const git = run || ((argv) => runGit(argv, { cwd: root }));
553
+ const git = run || ((argv, opts = {}) => runGit(argv, { cwd: root, ...opts }));
393
554
  const real = realPath || ((rel) => repoRealPath(root, rel));
394
555
  const commit = await headCommit(git);
395
- const symbols = [];
556
+ // HC-GF-385: the clock starts before the probe, so the probe's time comes out of the same TOTAL_BUDGET_MS.
396
557
  const started = now();
558
+ // Rank BEFORE the slice and compute what was omitted AFTER it, or the functions never reach the slice.
559
+ const probe = await rankByDefinition(storyOrder, { run: git, realPath: real });
560
+ const picked = sliceRanked(probe.ranked.map((c) => c.name), refusedAll, maxSymbols);
561
+ const symbols = [];
562
+ let hitsLeft = MAX_TOTAL_HITS;
397
563
  for (const symbol of picked.symbols) {
398
564
  if (now() - started > TOTAL_BUDGET_MS) {
399
565
  symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
400
566
  continue;
401
567
  }
402
- symbols.push(await searchSymbol(symbol, { run: git, realPath: real }));
568
+ const entry = await searchSymbol(symbol, { run: git, realPath: real, maxHits: hitsLeft });
569
+ if (Array.isArray(entry.hits)) hitsLeft = Math.max(0, hitsLeft - entry.hits.length);
570
+ symbols.push(entry);
403
571
  }
404
572
  for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
405
573
  fitFileLists(symbols);
@@ -413,12 +581,17 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
413
581
  omitted: picked.omitted,
414
582
  omitted_total: picked.omittedTotal,
415
583
  };
416
- notes.push(...describeCallSites(callSites));
584
+ if (probe.fallback) notes.push(warn(` ⚠ call sites: ${probe.fallback}`));
585
+ const searchedUndefined = probe.undefinedNames.filter((n) => picked.symbols.includes(n));
586
+ if (!probe.fallback && searchedUndefined.length > 0) {
587
+ 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)`));
588
+ }
589
+ notes.push(...describeCallSites(callSites, { maxSymbols }));
417
590
  return { callSites, notes };
418
591
  }
419
592
 
420
593
  /** 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) {
594
+ function describeCallSites(callSites, { maxSymbols = MAX_SYMBOLS } = {}) {
422
595
  const notes = [];
423
596
  const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
424
597
  notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
@@ -437,7 +610,10 @@ function describeCallSites(callSites) {
437
610
  binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
438
611
  ].filter(Boolean).join('; ');
439
612
  const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
440
- if (s.status === 'incomplete' && fileLevel) {
613
+ if (s.status === 'incomplete' && fileLevel && s.hits.length === 0) {
614
+ // HC-GF-385: reached after the total-hit budget was spent. The server renders it FILES ONLY.
615
+ 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` : ''}`));
616
+ } else if (s.status === 'incomplete' && fileLevel) {
441
617
  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
618
  } else if (s.status === 'incomplete') {
443
619
  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 +630,21 @@ function describeCallSites(callSites) {
454
630
  const omittedTotal = callSites.omitted_total || 0;
455
631
  if (omittedTotal > 0) {
456
632
  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}`));
633
+ notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${maxSymbols}-symbol cap — NOT SEARCHED: ${shown}`));
458
634
  }
459
635
  return notes;
460
636
  }
461
637
 
462
638
  module.exports = {
639
+ backtickedSpans,
463
640
  CALL_SITES_VERSION,
464
641
  SCOPE,
465
642
  MAX_SYMBOLS,
466
643
  MAX_REFUSED,
644
+ MAX_TOTAL_HITS,
645
+ MAX_PROBE_CANDIDATES,
646
+ PROBE_TIMEOUT_MS,
647
+ DATA_EXTENSIONS,
467
648
  MAX_FILE_LIST_FILES,
468
649
  MAX_FILE_LIST_BYTES,
469
650
  SECRET_NAME,
@@ -478,6 +659,9 @@ module.exports = {
478
659
  MAX_OUTPUT_BYTES,
479
660
  isCompoundIdentifier,
480
661
  pickSymbols,
662
+ rankCandidates,
663
+ definitionTier,
664
+ rankByDefinition,
481
665
  fitFileLists,
482
666
  runGit,
483
667
  parseGrepOutput,
@@ -31,7 +31,7 @@ const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const { remedyFor, partitionSafePaths, tokenOf, isSafeToken } = require('./hard-pause-remedy');
33
33
  const { parseIncludeTarget } = require('./include-paths');
34
- const { isSecretPath } = require('./bundle-context');
34
+ const { pathRefusal, structuralRefusal } = require('./path-refusal');
35
35
  const { isBatchId } = require('./run-target');
36
36
 
37
37
  /** The start of the reason a re-served run is killed with. The loop reads it back to find the new batch. */
@@ -67,18 +67,6 @@ function hardPauseRequest(run) {
67
67
  return remedy && remedy.stepKey === at[1] ? remedy : null;
68
68
  }
69
69
 
70
- // Credential-looking names the bundler's own refusal (isSecretPath) does not cover: a public half of a key pair,
71
- // key containers, anything the author named a secret or a credential.
72
- const SECRET_LOOKING = [/(^|[._-])id_(rsa|dsa|ecdsa|ed25519)/i, /\.(key|pfx|jks|keystore|p8)$/i, /(secret|credential)/i];
73
-
74
- /** Why a path is not a plain relative path, or null. `file` has no `:line` or `:symbol` suffix. */
75
- function structuralRefusal(file) {
76
- if (typeof file !== 'string' || file === '') return 'empty';
77
- if (/^[-/~]/.test(file)) return 'absolute, home-relative or option-like';
78
- if (/[\\\0]/.test(file)) return 'not a plain relative path';
79
- if (file.split('/').some((s) => s === '' || s === '.' || s === '..')) return 'not a plain relative path';
80
- return null;
81
- }
82
70
 
83
71
  /**
84
72
  * Why the CONTENT of a path (a plain file path, no `:line`) must not be sent to a model, or null: the three
@@ -87,11 +75,7 @@ function structuralRefusal(file) {
87
75
  * @param {string} file
88
76
  */
89
77
  function contentRefusal(file) {
90
- const plain = structuralRefusal(file);
91
- if (plain) return plain;
92
- if (isSecretPath(file) || SECRET_LOOKING.some((re) => re.test(path.posix.basename(file)))) return 'looks like a credential file';
93
- if (file.split('/').some((s) => s.startsWith('.'))) return 'a hidden file or directory';
94
- return null;
78
+ return pathRefusal(file, { mode: 'content' });
95
79
  }
96
80
 
97
81
  /**
@@ -208,21 +192,24 @@ function batchIdFromQueueOutput(stdout) {
208
192
  * status, created_at, error_message), newest first.
209
193
  * - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
210
194
  * 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}
195
+ * - `earlier`: a run of the story, older than this one, killed with `re-served as batch <id>` where <id> is the batch
196
+ * THIS run is in (`batchId`): this run is that re-serve (the loop was started on the new batch alone), and a second
197
+ * pause here is the second. A story re-served into some OTHER batch, however long ago, is history and not this
198
+ * run's lineage: it neither blocks nor justifies anything (HC-GF-351-followup-1).
199
+ * - `unknown`: the paused run's creation time is not a date or its batch id is not one, so neither can be told.
200
+ * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string, why?: string}|null}
215
201
  */
216
- function serveHistory(rows, { runId, storyId, repoName, since }) {
202
+ function serveHistory(rows, { runId, storyId, repoName, since, batchId: ownBatch }) {
217
203
  const t0 = Date.parse(since);
218
- if (!Number.isFinite(t0)) return { kind: 'unknown' };
204
+ if (!Number.isFinite(t0)) return { kind: 'unknown', why: 'its creation time is not known' };
205
+ if (!isBatchId(ownBatch)) return { kind: 'unknown', why: 'the batch it is in is not known' };
219
206
  for (const row of Array.isArray(rows) ? rows : []) {
220
207
  if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
221
208
  const t = Date.parse(row.created_at);
222
209
  if (!Number.isFinite(t)) continue;
223
210
  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 };
211
+ const reserved = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
212
+ if (reserved && reserved === ownBatch) return { kind: 'earlier', runId: row.id, batchId: reserved };
226
213
  }
227
214
  return null;
228
215
  }