@hone-ai/cli 1.38.1 → 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.
@@ -325,6 +325,17 @@ function anchorLineForStory(content, storyText) {
325
325
  *
326
326
  * The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
327
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.
328
339
  */
329
340
  /**
330
341
  * HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
@@ -355,6 +366,11 @@ function anchorSitesForStory(content, storyText, max = 6) {
355
366
  if (def === -1) continue;
356
367
  const t = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
357
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.
358
374
  for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
359
375
  for (const off of offsets) {
360
376
  const line = text.slice(0, off).split('\n').length;
@@ -370,8 +386,29 @@ function anchorSitesForStory(content, storyText, max = 6) {
370
386
  return { sites, collapsed };
371
387
  }
372
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
+
373
410
  function definitionIndexOf(text, term) {
374
- const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
411
+ const { t, before, after } = wholeTerm(term);
375
412
  // Ordered by how strongly each shape asserts "this is where it lives".
376
413
  const DEFINITION_FORMS = [
377
414
  `\\.command\\(\\s*['"\`]${t}\\b`, // CLI command registration
@@ -379,13 +416,16 @@ function definitionIndexOf(text, term) {
379
416
  `class\\s+${t}\\b`, // class declaration
380
417
  `(?:const|let|var)\\s+${t}\\s*=`, // binding
381
418
  `exports\\.${t}\\s*=`, // named export
382
- `${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
383
422
  ];
384
423
  for (const form of DEFINITION_FORMS) {
385
424
  const m = new RegExp(form).exec(text);
386
425
  if (m) return m.index;
387
426
  }
388
- return text.indexOf(term);
427
+ const mention = new RegExp(`${before}${t}${after}`).exec(text);
428
+ return mention ? mention.index : -1;
389
429
  }
390
430
 
391
431
  /**
@@ -402,9 +442,17 @@ function definitionIndexOf(text, term) {
402
442
  * Judged against the FINAL windows, not the site list, so it holds whatever dropped the term — the
403
443
  * site limit, the budget, a collapse — and needs no knowledge of how the layout was chosen.
404
444
  *
405
- * A term counts as occurring only where it stands as a whole word. `definitionIndexOf` falls back to
406
- * a bare `indexOf`, which finds `info` inside `information`; reporting that as "`info` is at line N"
407
- * would send an agent to a line that does not contain it.
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.
408
456
  *
409
457
  * @param {string} content full file contents
410
458
  * @param {string} storyText
@@ -428,11 +476,10 @@ function unanchoredTerms(content, storyText, ranges) {
428
476
  for (const term of new Set(storyTermsOf(storyText))) {
429
477
  const def = definitionIndexOf(text, term);
430
478
  if (def === -1) continue;
431
- const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
432
- const mentions = [...text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))].map((m) => lineOf(m.index));
479
+ const { t, before, after } = wholeTerm(term);
480
+ const mentions = [...text.matchAll(new RegExp(`${before}${t}${after}`, 'g'))].map((m) => lineOf(m.index));
433
481
  if (!mentions.length) continue;
434
- // Covered only by a whole-word occurrence. The bundler may have anchored on `def` itself, but
435
- // when `def` is the indexOf fallback that window shows `information`, not `info`.
482
+ // Covered only by a whole occurrence.
436
483
  if (mentions.some(covered)) continue;
437
484
  const defLine = lineOf(def);
438
485
  out.push({ term, line: mentions.includes(defLine) ? defLine : mentions[0] });
@@ -0,0 +1,489 @@
1
+ 'use strict';
2
+ /**
3
+ * call-sites.js — HC-GF-217: the CLI searches the checkout for the symbols a story names, so the server never has to.
4
+ *
5
+ * WHY. The shared-chokepoint guardrail asks the planner to enumerate every call site of a function it changes
6
+ * "from an actual search". A server-executed agent is one text completion with no shell and no filesystem
7
+ * (`cwd: os.tmpdir()` in claude-cli-provider.js — deliberate, the server has no tools and cannot execute), so
8
+ * the guardrail demanded something no agent on that path can do, and the step paused on a condition nobody
9
+ * could meet. The agent was right to refuse: on HC-GF-293 `git grep -w ensureConnected` finds an external caller
10
+ * (server/src/services/ip-retrieval-audit.js) that was in nobody's bundle. Only this CLI, which holds the
11
+ * checkout, can search. It searches, sends the result in the run config (`config.call_sites`), and the server only
12
+ * renders it (server/src/services/call-sites.js).
13
+ *
14
+ * THE RULES THIS FILE KEEPS
15
+ * - A list is COMPLETE only when it is the whole answer. A symbol with more hits than MAX_HITS_PER_SYMBOL is
16
+ * 'incomplete' and carries its true total; a truncated list is never presented as the whole.
17
+ * - A search that did not run is 'not_searched', never zero callers. `git grep` exits 1 for "no match" and 128
18
+ * for a fatal error; only the first is an answer. A timeout, a missing git and an output overflow are
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,
21
+ * the rule a re-serve uses); it is counted, so the planner knows callers exist that it cannot see. The check
22
+ * is made on the disk as it is now: `git grep` follows a DIRECTORY that was replaced by a symlink after the
23
+ * commit (measured with git 2.50.1: a tracked `d/secret.txt` read from outside the checkout), so a path that
24
+ * does not resolve to itself inside the checkout is withheld (HC-GF-351's repoRealPath).
25
+ * - `git grep` prints `error: ...` for a file it cannot read and still exits 0 or 1 on the strength of the OTHER
26
+ * files, so a missing caller would read as COMPLETE (or "COMPLETE: 0 hits"). ANY output on stderr makes the
27
+ * symbol 'not_searched': git's exit code alone is not the answer. (Any, not just `error:` lines: a message we
28
+ * did not anticipate is exactly the one we cannot judge, and a clean grep prints nothing.)
29
+ * - A name that looks like a secret (`AWS_SECRET_ACCESS_KEY`, `STRIPE_API_KEY`) is never searched: the story text
30
+ * chooses what is grepped, and the hit LINE (`STRIPE_SECRET_KEY=sk_live_…`) would be sent to the server.
31
+ * - git is run with an argv array (execFile), never a shell string. The symbols are also restricted to
32
+ * `[A-Za-z0-9_]` identifiers, so even a hostile story cannot reach the command line.
33
+ *
34
+ * SCOPE: every file git tracks in the repository, searched from its TOP LEVEL (`git rev-parse --show-toplevel`)
35
+ * wherever `hone` ran — `git grep` from a subdirectory searches only that subtree and would still say COMPLETE — as
36
+ * it is on disk, EXCEPT documentation files (PROSE_EXTENSIONS), which are counted and not listed. Not searched at
37
+ * all: submodules (--no-recurse-submodules), the contents of tracked symbolic links (git grep does not follow
38
+ * them), tracked files deleted from the working tree, and files git treats as binary (those are COUNTED: a
39
+ * second `git grep -l` without -I names how many files match only as binary, and such a symbol is never
40
+ * COMPLETE). Not
41
+ * `codebase.source_dirs` + test dirs: that setting is the scope of the rendered TREE, and it drifts —
42
+ * `hone setup` once wrote `src/` for every Node repository (HC-DF-002) — so a search scoped by it would call a
43
+ * list complete that missed every caller outside it. Documentation is excluded because a roadmap row or a
44
+ * runbook that mentions a function is not a call site, and counting it would mark the symbols a story is about
45
+ * (the row that describes the change always names them) as incomplete on every story. Tests ARE searched: they
46
+ * are consumers the plan must update. Untracked and ignored files are not, by construction.
47
+ *
48
+ * LANGUAGE-AGNOSTIC. A symbol is a backticked span that is one identifier (optionally qualified and/or called)
49
+ * which is EITHER compound — camelCase, PascalCase, snake_case or SCREAMING_SNAKE — OR a single word the story
50
+ * writes as a call or qualified (`charge()`, `Billing.charge`, `billing::charge`, `obj->charge`; at least 3
51
+ * characters). A bare plain word (`wait`, `end`, `connect`) names too many things to be worth a list, and the cap
52
+ * would be spent on it; writing it as a call is how an operator says "this one" (a shared chokepoint is often a
53
+ * short name). Explicitly written spans are searched first. Nothing here knows a language or a Hone name. Known
54
+ * limits: ASCII identifiers only; case-sensitive, so a case-insensitive language's other spellings are not found;
55
+ * a symbol the story does not put in backticks is not searched.
56
+ *
57
+ * WIDELY USED FUNCTIONS. A real shared chokepoint has many consumers, so its hit list outgrows MAX_HITS_PER_SYMBOL.
58
+ * Past the cap the entry ALSO carries a per-file list (path + hit count) for every file with a hit; the server calls
59
+ * a symbol whose file list accounts for every file and hit COMPLETE AT FILE LEVEL, which the planner accepts as the
60
+ * enumeration. The lists of all symbols together are held to MAX_FILE_LIST_BYTES, and a cut list says so.
61
+ *
62
+ * I/O (git, the disk) is injected so the unit tests drive every branch without a repository.
63
+ */
64
+
65
+ const { execFile } = require('node:child_process');
66
+ const path = require('node:path');
67
+ const { gitEnv } = require('./git-env');
68
+ const { contentRefusal, repoRealPath } = require('./context-reserve');
69
+
70
+ const CALL_SITES_VERSION = 1;
71
+ /** The only scope id today; the server writes its own description of it. */
72
+ const SCOPE = 'tracked-repo';
73
+
74
+ // The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
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. */
78
+ const MAX_REFUSED = 2;
79
+ /** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
80
+ const MAX_FILE_LIST_FILES = 300;
81
+ /** ...and all the symbols' file lists together stay under this many bytes of JSON (server/src/lib/batch-body-limit.js budgets it). */
82
+ const MAX_FILE_LIST_BYTES = 96 * 1024;
83
+ const MAX_OMITTED_NAMES = 20;
84
+ const MAX_HITS_PER_SYMBOL = 40;
85
+ const MAX_LINE_CHARS = 160;
86
+ const MAX_PATH_CHARS = 260;
87
+ /** The longest identifier it will search; the server reads no longer one (a longer name would be dropped there). */
88
+ const MAX_SYMBOL_CHARS = 100;
89
+
90
+ const SEARCH_TIMEOUT_MS = 20_000;
91
+ /** All the symbols of one story together: a repository too big to search in this long is told so, not waited on. */
92
+ const TOTAL_BUDGET_MS = 45_000;
93
+ const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
94
+
95
+ /** Documentation, not code. Deliberately not `.txt`: CMakeLists.txt and requirements.txt hold real references. */
96
+ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc', 'asciidoc']);
97
+
98
+ // ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
99
+
100
+ const BACKTICKED = /`([^`\n]{2,200})`/g;
101
+ // One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
102
+ // segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
103
+ // Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
104
+ const NAME_SPAN = /^((?:[A-Za-z_][A-Za-z0-9_]*(?:\.|::|->|#))*)([A-Za-z_][A-Za-z0-9_]*)\s*(\([^`]*\))?$/;
105
+ /** A name that looks like a secret is never searched (its hit lines are the values). */
106
+ const SECRET_NAME = /secret|passw|token|api_?key|credential|private_?key|access_?key/i;
107
+ const SECRET_REASON = 'the name looks like a secret or credential, and the lines that mention one hold its value, so it is never searched';
108
+ /** `config.yaml`, `index.js`: a qualified span whose last segment is a file extension names a file, not a symbol. */
109
+ const FILE_EXTENSIONS = new Set(['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'htm', 'css', 'scss',
110
+ 'md', 'mdx', 'txt', 'lock', 'py', 'pyi', 'rb', 'go', 'rs', 'java', 'kt', 'swift', 'cs', 'php', 'sh', 'bash', 'zsh', 'sql', 'cpp', 'hpp',
111
+ 'cfg', 'conf', 'ini', 'env', 'log', 'csv', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'pdf', 'zip', 'tar', 'gz']);
112
+
113
+ /**
114
+ * Is this identifier compound (worth a search), as opposed to a plain word? An identifier with an inner
115
+ * underscore (`wait_for_ready`, `MAX_RETRIES`) or an inner lower-to-upper hump (`ensureConnected`, `UserService`,
116
+ * `HTTPServer`). A single capitalised word (`Property`) cannot be told from English and is not compound. Leading
117
+ * and trailing underscores do not count: `__init__` is a plain word in a protocol's clothes.
118
+ * @param {string} name
119
+ */
120
+ function isCompoundIdentifier(name) {
121
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return false;
122
+ const core = name.replace(/^_+|_+$/g, '');
123
+ if (core.length < 4) return false;
124
+ if (/[A-Za-z0-9]_+[A-Za-z0-9]/.test(core)) return true;
125
+ if (/[a-z0-9][A-Z]/.test(core)) return true;
126
+ return /[A-Z]{2,}[a-z]/.test(core);
127
+ }
128
+
129
+ /**
130
+ * What a backticked span says it is about.
131
+ * - a COMPOUND name is a symbol however it is written;
132
+ * - a single word (`charge`, `authorize`, `dispatch`) is a symbol only when the story WRITES it as one: called
133
+ * (`charge()`), or qualified (`Billing.charge`, `billing::charge`, `obj->charge`). That is how an operator says
134
+ * "this plain word is the function I mean" — the one lever for a shared chokepoint with a short name. At least
135
+ * 3 characters, not a dunder, not the extension of a file name (`config.yaml`).
136
+ * `explicit` is true for the called/qualified form of either kind; those are ranked first.
137
+ * @returns {{name: string, explicit: boolean}|null}
138
+ */
139
+ function symbolOfSpan(span) {
140
+ const m = NAME_SPAN.exec(span.trim());
141
+ if (!m) return null;
142
+ const [, qualifier, name, call] = m;
143
+ if (name.length > MAX_SYMBOL_CHARS) return null;
144
+ const explicit = qualifier !== '' || call !== undefined;
145
+ if (isCompoundIdentifier(name)) return { name, explicit };
146
+ if (!explicit || name.length < 3 || /^__.*__$/.test(name)) return null;
147
+ if (call === undefined && qualifier.endsWith('.') && FILE_EXTENSIONS.has(name.toLowerCase())) return null;
148
+ return { name, explicit };
149
+ }
150
+
151
+ /**
152
+ * The symbols a story puts in backticks: explicitly written ones (called or qualified) first, then the rest, each
153
+ * group the ones the story talks about most first (then in the order it first names them), at most `max` of them.
154
+ * Names that look like a secret are returned in `refused`, not searched and not counted against `max`. The rest are
155
+ * returned by name so the block can say what was not searched.
156
+ * @param {string} storyText
157
+ * @param {{max?: number}} [opts]
158
+ * @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
159
+ */
160
+ function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
161
+ const counts = new Map();
162
+ const explicitNames = new Set();
163
+ for (const m of String(storyText || '').matchAll(BACKTICKED)) {
164
+ const sym = symbolOfSpan(m[1]);
165
+ if (!sym) continue;
166
+ counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
167
+ if (sym.explicit) explicitNames.add(sym.name);
168
+ }
169
+ // Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
170
+ 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
+ return {
176
+ symbols: ranked.slice(0, max),
177
+ omitted: beyond.slice(0, MAX_OMITTED_NAMES),
178
+ omittedTotal: beyond.length,
179
+ refused,
180
+ };
181
+ }
182
+
183
+ // ── Running git ─────────────────────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Run `git <argv>` in `cwd`. execFile with an argv array: no shell ever sees an element of it. (`bin` is for tests.)
187
+ * @returns {Promise<{status: number|null, stdout: string, stderr: string, failure: string|null}>}
188
+ * `failure` is a stable classification of why there is no answer (never git's stderr, which can carry paths);
189
+ * `status` is git's exit code when it ran to the end; `stderr` is what git printed there, for the caller to JUDGE
190
+ * and never to repeat (a `git grep` that could not read a file says so there and still exits 0 or 1).
191
+ */
192
+ function runGit(argv, { cwd, timeoutMs = SEARCH_TIMEOUT_MS, maxBuffer = MAX_OUTPUT_BYTES, bin = 'git' } = {}) {
193
+ return new Promise((resolve) => {
194
+ execFile(bin, argv, { cwd, env: gitEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer, windowsHide: true }, (err, stdout, stderr) => {
195
+ const out = stdout || '';
196
+ const errText = stderr || '';
197
+ if (!err) return resolve({ status: 0, stdout: out, stderr: errText, failure: null });
198
+ if (err.code === 'ENOENT') return resolve({ status: null, stdout: '', stderr: '', failure: 'git is not installed or not on PATH' });
199
+ if (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
200
+ return resolve({ status: null, stdout: '', stderr: '', failure: `the search printed more than ${maxBuffer / (1024 * 1024)} MiB, too many hits to list` });
201
+ }
202
+ if (err.killed || err.signal) return resolve({ status: null, stdout: '', stderr: '', failure: `git grep timed out after ${timeoutMs / 1000}s` });
203
+ if (typeof err.code === 'number') return resolve({ status: err.code, stdout: out, stderr: errText, failure: null });
204
+ return resolve({ status: null, stdout: '', stderr: '', failure: 'git grep could not be run' });
205
+ });
206
+ });
207
+ }
208
+
209
+ /**
210
+ * `git grep -n -z` output: `path NUL line NUL text LF` per hit, read in order because a path may hold a newline.
211
+ * @returns {Array<{path: string, line: number, text: string}>|null} null when the output is not that shape
212
+ */
213
+ function parseGrepOutput(out) {
214
+ const hits = [];
215
+ let at = 0;
216
+ while (at < out.length) {
217
+ const p = out.indexOf('\0', at);
218
+ const l = p < 0 ? -1 : out.indexOf('\0', p + 1);
219
+ if (l < 0) return null;
220
+ const lineNo = Number(out.slice(p + 1, l));
221
+ if (!Number.isInteger(lineNo) || lineNo < 1) return null;
222
+ let end = out.indexOf('\n', l + 1);
223
+ if (end < 0) end = out.length;
224
+ hits.push({ path: out.slice(at, p), line: lineNo, text: out.slice(l + 1, end).replace(/\r$/, '') });
225
+ at = end + 1;
226
+ }
227
+ return hits;
228
+ }
229
+
230
+ // ── Judging what may be sent ────────────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * What one hit's path is: documentation (counted), withheld (counted, never sent) or sendable.
234
+ * @param {string} rel
235
+ * @param {(rel: string) => string|null} realPath where `rel` resolves on the disk now, relative to the checkout
236
+ * @returns {'prose'|'withheld'|'ok'}
237
+ */
238
+ function classifyPath(rel, realPath) {
239
+ const ext = path.posix.extname(rel).slice(1).toLowerCase();
240
+ if (PROSE_EXTENSIONS.includes(ext)) return 'prose';
241
+ if (rel.length > MAX_PATH_CHARS || /[\x00-\x1f\x7f]/.test(rel)) return 'withheld';
242
+ if (contentRefusal(rel)) return 'withheld';
243
+ // A directory above the file may have been replaced by a link since the commit; git reads through it.
244
+ let real = null;
245
+ try { real = realPath(rel); } catch { real = null; }
246
+ return real === rel ? 'ok' : 'withheld';
247
+ }
248
+
249
+ /** A hit's text on one line of at most MAX_LINE_CHARS, windowed on the symbol when the line is longer. */
250
+ function lineText(text, symbol) {
251
+ const t = String(text).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
252
+ if (t.length <= MAX_LINE_CHARS) return t;
253
+ const at = t.search(new RegExp(`(?<![A-Za-z0-9_])${symbol}(?![A-Za-z0-9_])`));
254
+ const start = at < 0 ? 0 : Math.max(0, at - Math.floor(MAX_LINE_CHARS / 3));
255
+ const lead = start > 0 ? '…' : '';
256
+ const room = MAX_LINE_CHARS - lead.length - 1;
257
+ const trail = start + room < t.length ? '…' : '';
258
+ return lead + t.slice(start, start + room) + trail;
259
+ }
260
+
261
+ const STDERR_REASON = 'git grep reported errors (it could not read some files), so the list may be missing callers';
262
+
263
+ /**
264
+ * Why a `git grep` run is not an answer, or null when it is one. Exit 0 (hits) and 1 (none) are answers ONLY with
265
+ * an empty stderr: git prints `error: ...` for a file it cannot read (permission, EIO) and still exits on the
266
+ * strength of the other files.
267
+ */
268
+ function grepProblem(r) {
269
+ if (r.failure) return r.failure;
270
+ if (r.status !== 0 && r.status !== 1) return `git grep failed (exit ${r.status})`;
271
+ if (typeof r.stderr === 'string' && r.stderr.trim() !== '') return STDERR_REASON;
272
+ return null;
273
+ }
274
+
275
+ /** `git grep -l -z` output: the NUL-terminated names of the files that match. null when it is not that shape. */
276
+ function parseFileList(out) {
277
+ if (out === '') return [];
278
+ if (!out.endsWith('\0')) return null;
279
+ return out.slice(0, -1).split('\0');
280
+ }
281
+
282
+ /**
283
+ * One symbol's entry for `config.call_sites.symbols`.
284
+ * - 'complete': `hits` is every hit in scope that may be sent; `withheld` more sit in paths that never are.
285
+ * - 'incomplete': more hits than MAX_HITS_PER_SYMBOL, or files that match only as binary; `hits` is the first of
286
+ * them and `total` the true count. When it is the hit cap, `file_list` names every file with a hit and its count
287
+ * (`file_list_complete` says whether that list is itself whole) — the server then calls it COMPLETE AT FILE LEVEL.
288
+ * - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
289
+ * `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
290
+ */
291
+ async function searchSymbol(symbol, { run, realPath }) {
292
+ // Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
293
+ if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
294
+ return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
295
+ }
296
+ if (SECRET_NAME.test(symbol)) return { symbol, status: 'not_searched', reason: SECRET_REASON };
297
+ const r = await run(['grep', '-n', '-w', '-F', '-I', '-z', '--no-color', '--no-recurse-submodules', '-e', symbol]);
298
+ const problem = grepProblem(r);
299
+ if (problem) return { symbol, status: 'not_searched', reason: problem };
300
+ // 1 is git grep's "no line selected": no text hit. Binary files are checked below either way.
301
+ const parsed = r.status === 1 ? [] : parseGrepOutput(r.stdout);
302
+ if (parsed === null) return { symbol, status: 'not_searched', reason: 'git grep printed output that could not be read' };
303
+
304
+ // -I skipped every file git treats as binary (.gitattributes `-diff`/`binary`, a generated stub, a NUL byte).
305
+ // The same search with -l and without -I names them: the ones that appear only there are matches we cannot list.
306
+ const b = await run(['grep', '-l', '-z', '-w', '-F', '--no-color', '--no-recurse-submodules', '-e', symbol]);
307
+ const bProblem = grepProblem(b);
308
+ if (bProblem) return { symbol, status: 'not_searched', reason: `the binary-file check failed: ${bProblem}` };
309
+ const matching = b.status === 1 ? [] : parseFileList(b.stdout);
310
+ if (matching === null) return { symbol, status: 'not_searched', reason: 'the binary-file check printed output that could not be read' };
311
+ const textFiles = new Set(parsed.map((h) => h.path));
312
+ const binary = matching.filter((f) => !textFiles.has(f)).length;
313
+
314
+ let prose = 0;
315
+ let withheld = 0;
316
+ const sendable = [];
317
+ const kinds = new Map();
318
+ for (const hit of parsed) {
319
+ let kind = kinds.get(hit.path);
320
+ if (!kind) { kind = classifyPath(hit.path, realPath); kinds.set(hit.path, kind); }
321
+ if (kind === 'prose') prose += 1;
322
+ else if (kind === 'withheld') withheld += 1;
323
+ else sendable.push(hit);
324
+ }
325
+ 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) }));
327
+ const perFile = new Map();
328
+ for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
329
+ const entry = {
330
+ symbol,
331
+ status: hits.length + withheld === total && binary === 0 ? 'complete' : 'incomplete',
332
+ total,
333
+ files: perFile.size,
334
+ withheld,
335
+ prose,
336
+ binary,
337
+ hits,
338
+ };
339
+ if (sendable.length > MAX_HITS_PER_SYMBOL) {
340
+ const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
341
+ entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
342
+ entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
343
+ }
344
+ return entry;
345
+ }
346
+
347
+ /** Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so. */
348
+ function fitFileLists(symbols) {
349
+ let room = MAX_FILE_LIST_BYTES;
350
+ for (const s of symbols) {
351
+ if (!s.file_list) continue;
352
+ const kept = [];
353
+ for (const f of s.file_list) {
354
+ const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
355
+ if (size > room) { s.file_list_complete = false; break; }
356
+ room -= size;
357
+ kept.push(f);
358
+ }
359
+ if (kept.length === 0) { delete s.file_list; s.file_list_complete = false; } else s.file_list = kept;
360
+ }
361
+ }
362
+
363
+ async function headCommit(run) {
364
+ const r = await run(['rev-parse', 'HEAD']);
365
+ const sha = r.failure || r.status !== 0 ? '' : r.stdout.trim();
366
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
367
+ }
368
+
369
+ // ── The CLI's one entry point ───────────────────────────────────────────────────────────────────
370
+
371
+ const info = (text) => ({ level: 'info', text });
372
+ const warn = (text) => ({ level: 'warn', text });
373
+
374
+ /**
375
+ * Search the checkout for every compound symbol the story names.
376
+ * @param {{cwd: string, storyText: string, run?: Function, realPath?: Function, now?: () => number, maxSymbols?: number}} o
377
+ * @returns {Promise<{callSites: object|null, notes: Array<{level: string, text: string}>}>}
378
+ * `callSites` is null when the story names no compound symbol: no field is sent and nothing changes.
379
+ */
380
+ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
381
+ const notes = [];
382
+ const picked = pickSymbols(storyText, { max: maxSymbols });
383
+ if (picked.symbols.length === 0 && picked.refused.length === 0) return { callSites: null, notes };
384
+
385
+ // From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
386
+ let root = cwd;
387
+ if (!run) {
388
+ const top = await runGit(['rev-parse', '--show-toplevel'], { cwd });
389
+ const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
390
+ if (dir) root = dir;
391
+ }
392
+ const git = run || ((argv) => runGit(argv, { cwd: root }));
393
+ const real = realPath || ((rel) => repoRealPath(root, rel));
394
+ const commit = await headCommit(git);
395
+ const symbols = [];
396
+ const started = now();
397
+ for (const symbol of picked.symbols) {
398
+ if (now() - started > TOTAL_BUDGET_MS) {
399
+ symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
400
+ continue;
401
+ }
402
+ symbols.push(await searchSymbol(symbol, { run: git, realPath: real }));
403
+ }
404
+ for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
405
+ fitFileLists(symbols);
406
+
407
+ const callSites = {
408
+ version: CALL_SITES_VERSION,
409
+ commit,
410
+ scope: SCOPE,
411
+ prose_globs: PROSE_EXTENSIONS.map((e) => `*.${e}`),
412
+ symbols,
413
+ omitted: picked.omitted,
414
+ omitted_total: picked.omittedTotal,
415
+ };
416
+ notes.push(...describeCallSites(callSites));
417
+ return { callSites, notes };
418
+ }
419
+
420
+ /** 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) {
422
+ const notes = [];
423
+ const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
424
+ notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
425
+ for (const s of callSites.symbols) {
426
+ if (s.status === 'not_searched') {
427
+ notes.push(warn(` ⚠ call sites for ${s.symbol}: NOT SEARCHED — ${s.reason}; the planner will have no enumeration of it`));
428
+ continue;
429
+ }
430
+ const files = [...new Set(s.hits.map((h) => h.path))];
431
+ const names = files.slice(0, 4).join(', ') + (s.files > 4 ? `, +${s.files - 4} more` : '');
432
+ const listable = s.total - s.withheld;
433
+ const binary = s.binary || 0;
434
+ const extra = [
435
+ s.prose ? `${s.prose} more in documentation` : '',
436
+ s.withheld ? `${s.withheld} in withheld paths` : '',
437
+ binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
438
+ ].filter(Boolean).join('; ');
439
+ const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
440
+ if (s.status === 'incomplete' && fileLevel) {
441
+ 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
+ } else if (s.status === 'incomplete') {
443
+ 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})` : ''}`));
444
+ } else if (listable === 0 && s.withheld === 0 && s.prose === 0) {
445
+ notes.push(info(` ${s.symbol}: 0 hits in any searched file (the symbol may be one this story creates, or spelled differently)`));
446
+ } else if (listable === 0 && s.withheld === 0) {
447
+ notes.push(info(` ${s.symbol}: 0 hits in code files; ${s.prose} in documentation only`));
448
+ } else if (s.withheld > 0) {
449
+ notes.push(warn(` ⚠ call sites for ${s.symbol}: COMPLETE except ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see — ${listable} listed${names ? ` (${names})` : ''}; "the only caller" cannot be claimed${s.prose ? `; ${s.prose} more in documentation` : ''}`));
450
+ } else {
451
+ notes.push(info(` ${s.symbol}: ${listable} hit${listable === 1 ? '' : 's'} in ${s.files} file${s.files === 1 ? '' : 's'}${names ? ` (${names})` : ''}${extra ? `; ${extra}` : ''}`));
452
+ }
453
+ }
454
+ const omittedTotal = callSites.omitted_total || 0;
455
+ if (omittedTotal > 0) {
456
+ 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}`));
458
+ }
459
+ return notes;
460
+ }
461
+
462
+ module.exports = {
463
+ CALL_SITES_VERSION,
464
+ SCOPE,
465
+ MAX_SYMBOLS,
466
+ MAX_REFUSED,
467
+ MAX_FILE_LIST_FILES,
468
+ MAX_FILE_LIST_BYTES,
469
+ SECRET_NAME,
470
+ MAX_OMITTED_NAMES,
471
+ MAX_HITS_PER_SYMBOL,
472
+ MAX_LINE_CHARS,
473
+ MAX_PATH_CHARS,
474
+ MAX_SYMBOL_CHARS,
475
+ PROSE_EXTENSIONS,
476
+ SEARCH_TIMEOUT_MS,
477
+ TOTAL_BUDGET_MS,
478
+ MAX_OUTPUT_BYTES,
479
+ isCompoundIdentifier,
480
+ pickSymbols,
481
+ fitFileLists,
482
+ runGit,
483
+ parseGrepOutput,
484
+ classifyPath,
485
+ lineText,
486
+ searchSymbol,
487
+ collectCallSites,
488
+ describeCallSites,
489
+ };