@hone-ai/cli 1.38.1 → 1.40.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.
@@ -74,6 +74,60 @@ function fencedSpans(text) {
74
74
  return { blocks, open: cur };
75
75
  }
76
76
 
77
+ /**
78
+ * The inline code spans of ONE line, as ranges (HC-GF-341 rules, extracted by HC-GF-292 so there is ONE walker).
79
+ *
80
+ * CommonMark's rule, on a single line: a run of N backticks opens a span that closes at the next run of EXACTLY
81
+ * N backticks; a run with no equal-length closer on the line is literal text, masks nothing and does not stop the
82
+ * scan. OUTSIDE a span a backtick after an odd number of backslashes is escaped (it cannot open, nor be part of an
83
+ * opening run); INSIDE a span a backslash is only a character. A span never crosses a line break.
84
+ *
85
+ * LINEAR: the line is cut into backtick runs once, the runs of each length are listed in order, and an opener asks
86
+ * for the first later run of its length with a cursor that only moves forward. A backreference regex backtracks
87
+ * across every longer run and is quadratic on a line of mixed runs, and the line is agent-written text.
88
+ *
89
+ * `from`/`to` address `line`: [from, to) covers the delimiters too; the content is
90
+ * `line.slice(from + openLen, to - openLen)`.
91
+ *
92
+ * @returns {Array<{from:number, to:number, openLen:number}>} in ascending order, disjoint
93
+ */
94
+ function inlineCodeSpans(line) {
95
+ const spans = [];
96
+ if (typeof line !== 'string' || line.indexOf('`') === -1) return spans;
97
+ const starts = [];
98
+ const lens = [];
99
+ for (let i = line.indexOf('`'); i !== -1;) {
100
+ let j = i + 1;
101
+ while (j < line.length && line.charCodeAt(j) === 96) j++;
102
+ starts.push(i);
103
+ lens.push(j - i);
104
+ i = line.indexOf('`', j);
105
+ }
106
+ const runsOfLength = new Map(); // length -> { at: ascending run indices, p: cursor }
107
+ for (let k = 0; k < starts.length; k++) {
108
+ let entry = runsOfLength.get(lens[k]);
109
+ if (!entry) { entry = { at: [], p: 0 }; runsOfLength.set(lens[k], entry); }
110
+ entry.at.push(k);
111
+ }
112
+ const closerFor = (width, after) => {
113
+ const entry = runsOfLength.get(width);
114
+ if (!entry) return -1;
115
+ while (entry.p < entry.at.length && entry.at[entry.p] <= after) entry.p++;
116
+ return entry.p < entry.at.length ? entry.at[entry.p] : -1;
117
+ };
118
+ for (let k = 0; k < starts.length;) {
119
+ let slashes = 0;
120
+ for (let q = starts[k] - 1; q >= 0 && line.charCodeAt(q) === 92; q--) slashes++;
121
+ const escaped = slashes & 1; // 1: the run's first backtick is a literal one
122
+ const width = lens[k] - escaped;
123
+ const closer = width > 0 ? closerFor(width, k) : -1;
124
+ if (closer === -1) { k++; continue; }
125
+ spans.push({ from: starts[k] + escaped, to: starts[closer] + lens[closer], openLen: width });
126
+ k = closer + 1;
127
+ }
128
+ return spans;
129
+ }
130
+
77
131
  /**
78
132
  * A `file:` block as one regex: group 1 the fence, 2 the path, 3 the body.
79
133
  *
@@ -117,6 +171,7 @@ module.exports = {
117
171
  fenceWidth,
118
172
  closesFence,
119
173
  fencedSpans,
174
+ inlineCodeSpans,
120
175
  DIRECTIVE_RE,
121
176
  FILE_BLOCK_RE,
122
177
  EDIT_BLOCK_RE,
@@ -17,14 +17,24 @@
17
17
  * mergeContexts(existing, added)
18
18
  * Pure. Dedupes context names and preserves first-seen order so a
19
19
  * pre-existing custom check (e.g. 'lint') is preserved when Hone
20
- * appends 'ai-code-review'.
20
+ * appends the checks it requires.
21
+ *
22
+ * pruneLegacyChecks(contexts, { reportedChecks, keep })
23
+ * Pure. Removes LEGACY_REQUIRED_CHECK ('ai-code-review'), which earlier
24
+ * setups required and no served workflow reports (HC-GF-348), unless a
25
+ * workflow in the repository reports it or the caller requires it.
26
+ * Removes nothing else.
21
27
  *
22
28
  * parseGhError(stderr, exitCode)
23
29
  * Pure. Classifies a `gh` invocation failure into a stable kind:
24
30
  * no-gh / no-auth / no-admin / plan-restricted / org-ruleset / unknown.
25
31
  *
26
32
  * installBranchProtection({ exec?, log?, opts? })
27
- * Orchestrator. Returns { status, reason?, branch?, manualCommand? }
33
+ * Orchestrator. opts.requiredChecks is the list `hone setup` reads from
34
+ * the installed workflow (cli/lib/required-checks.js) and may be empty;
35
+ * opts.reportedChecks is every check name the repository's workflows
36
+ * report (null = unknown, which keeps the legacy check).
37
+ * Returns { status, reason?, branch?, manualCommand? }
28
38
  * where status ∈ 'installed' | 'skipped' | 'failed'. Never throws,
29
39
  * never exits — degrades gracefully so `hone setup` always returns 0.
30
40
  *
@@ -34,7 +44,14 @@
34
44
  * invocations during testing.
35
45
  */
36
46
 
37
- const DEFAULT_REQUIRED_CHECKS = ['ai-code-review'];
47
+ // HC-GF-348: no check is required by default. Setup passes the checks the repository's own workflow
48
+ // reports (cli/lib/required-checks.js); an empty list is valid — the PUT still requires a review and
49
+ // sets enforce_admins. This used to be ['ai-code-review'], a check nothing reports, which held every PR.
50
+ const DEFAULT_REQUIRED_CHECKS = [];
51
+
52
+ // The one context an earlier `hone setup` installed that no served workflow reports. Re-running setup
53
+ // removes it (pruneLegacyChecks), so a repository already held by it is released.
54
+ const LEGACY_REQUIRED_CHECK = 'ai-code-review';
38
55
 
39
56
  // ─────────────────────────────────────────────────────────────────────
40
57
  // defaultExec — production exec, wraps spawnSync
@@ -59,7 +76,8 @@ function buildPayload({ contexts = [], requireReviews } = {}) {
59
76
  for (const c of contexts) {
60
77
  if (!seen.has(c)) { seen.add(c); dedupedContexts.push(c); }
61
78
  }
62
- const reviewCount = typeof requireReviews === 'number' && requireReviews > 0
79
+ // 0 is a real answer since HC-GF-353 (a repository with one writer); anything that is not a count is 1.
80
+ const reviewCount = Number.isInteger(requireReviews) && requireReviews >= 0
63
81
  ? requireReviews
64
82
  : 1;
65
83
  return {
@@ -90,13 +108,27 @@ function mergeContexts(existing, added) {
90
108
  return out;
91
109
  }
92
110
 
111
+ // ─────────────────────────────────────────────────────────────────────
112
+ // pruneLegacyChecks — pure (HC-GF-348)
113
+ // ─────────────────────────────────────────────────────────────────────
114
+ function pruneLegacyChecks(contexts, { reportedChecks = [], keep = [] } = {}) {
115
+ const arr = Array.isArray(contexts) ? contexts : [];
116
+ // null: the repository's workflows could not all be read, so whether one reports it is unknown.
117
+ if (reportedChecks == null) return arr.slice();
118
+ const reported = Array.isArray(reportedChecks) ? reportedChecks : [];
119
+ const kept = Array.isArray(keep) ? keep : [];
120
+ if (reported.includes(LEGACY_REQUIRED_CHECK) || kept.includes(LEGACY_REQUIRED_CHECK)) return arr.slice();
121
+ return arr.filter((c) => c !== LEGACY_REQUIRED_CHECK);
122
+ }
123
+
93
124
  // ─────────────────────────────────────────────────────────────────────
94
125
  // parseGhError — pure
95
126
  // ─────────────────────────────────────────────────────────────────────
96
127
  function parseGhError(stderr = '', exitCode = 0) {
97
128
  const s = String(stderr || '');
98
129
  // gh binary missing entirely
99
- if (exitCode === 127 || /command not found|gh:.*not found/i.test(s)) {
130
+ // Not GitHub's own `gh: Not Found (HTTP 404)`, which the shell's wording also matched.
131
+ if (exitCode === 127 || /command not found/i.test(s) || (/gh:.*not found/i.test(s) && !/\(HTTP \d{3}\)/.test(s))) {
100
132
  return { kind: 'no-gh', message: 'gh CLI not installed (or not on PATH)' };
101
133
  }
102
134
  // gh installed but not authenticated
@@ -128,28 +160,67 @@ function parseGhError(stderr = '', exitCode = 0) {
128
160
  // ─────────────────────────────────────────────────────────────────────
129
161
  // buildManualCommand — produces a copy-paste shell snippet for the user
130
162
  // ─────────────────────────────────────────────────────────────────────
131
- function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main' } = {}) {
132
- const contextLines = contexts
133
- .map(c => ` -f 'required_status_checks[contexts][]=${c}' \\`)
134
- .join('\n');
163
+ function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main', requireReviews } = {}) {
164
+ // HC-GF-348: the exact payload setup PUTs, sent as JSON. `-f` fields cannot carry an empty contexts
165
+ // list (now the default) or a null `restrictions` — `-f 'restrictions='` sent the empty string.
135
166
  return [
136
- `gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection \\`,
137
- ` -f 'required_status_checks[strict]=true' \\`,
138
- contextLines,
139
- ` -f 'enforce_admins=true' \\`,
140
- ` -f 'required_pull_request_reviews[required_approving_review_count]=1' \\`,
141
- ` -f 'restrictions='`,
167
+ `gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection --input - <<'JSON'`,
168
+ JSON.stringify(buildPayload({ contexts, requireReviews }), null, 2),
169
+ 'JSON',
142
170
  ].join('\n');
143
171
  }
144
172
 
173
+ // ─────────────────────────────────────────────────────────────────────
174
+ // resolveChecks — the required list, which may depend on the branch (HC-GF-354)
175
+ // ─────────────────────────────────────────────────────────────────────
176
+ // Setup's list depends on the protected branch, which only `gh repo view` names. On the paths that return
177
+ // before that, the printed manual command is a template, so the list is resolved with branch null. A
178
+ // resolver that throws requires nothing rather than failing the install.
179
+ function resolveChecks(requiredChecks, branch) {
180
+ if (typeof requiredChecks === 'function') {
181
+ try {
182
+ const r = requiredChecks(branch);
183
+ return Array.isArray(r) ? r : [];
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+ return Array.isArray(requiredChecks) ? requiredChecks : DEFAULT_REQUIRED_CHECKS;
189
+ }
190
+
191
+ // ─────────────────────────────────────────────────────────────────────
192
+ // countWriters — how many people can push (HC-GF-353)
193
+ // ─────────────────────────────────────────────────────────────────────
194
+ function countWriters(exec, owner, repo) {
195
+ let r;
196
+ try {
197
+ r = exec('gh', ['api', '--paginate', `repos/${owner}/${repo}/collaborators?per_page=100`]);
198
+ } catch (e) {
199
+ return { count: null, reason: `gh could not be run: ${e && e.message}` };
200
+ }
201
+ if (r.status !== 0) return { count: null, reason: parseGhError(r.stderr, r.status).message };
202
+ let list;
203
+ try {
204
+ // --paginate concatenates one JSON array per page: `[...][...]`.
205
+ const pages = JSON.parse(`[${String(r.stdout || '').trim().replace(/\]\s*\[/g, '],[')}]`);
206
+ list = pages.length && pages.every(Array.isArray) ? pages.flat() : null;
207
+ } catch {
208
+ list = null;
209
+ }
210
+ if (!Array.isArray(list) || list.length === 0) return { count: null, reason: 'the collaborator list could not be read' };
211
+ const n = list.filter((c) => c && c.permissions && (c.permissions.push === true || c.permissions.admin === true)).length;
212
+ // Nobody with a readable push or admin permission is an answer that could not be read, not "zero
213
+ // writers": it must never remove the review.
214
+ if (n === 0) return { count: null, reason: 'no collaborator in the list has a readable push or admin permission' };
215
+ return { count: n };
216
+ }
217
+
145
218
  // ─────────────────────────────────────────────────────────────────────
146
219
  // installBranchProtection — orchestrator
147
220
  // ─────────────────────────────────────────────────────────────────────
148
221
  async function installBranchProtection({ exec = defaultExec, log = () => {}, opts = {} } = {}) {
149
- const requiredChecks = (opts.requiredChecks && opts.requiredChecks.length)
150
- ? opts.requiredChecks
151
- : DEFAULT_REQUIRED_CHECKS;
152
- const requireReviews = opts.requireReviews || 1;
222
+ // Not given: unknown, which keeps the legacy check. Only a caller that read the workflows can prune it.
223
+ const reportedChecks = opts.reportedChecks === undefined ? null : opts.reportedChecks;
153
224
 
154
225
  // 1. Honor opt-out — short-circuit before any subprocess call
155
226
  if (opts.skip) {
@@ -159,6 +230,12 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
159
230
  };
160
231
  }
161
232
 
233
+ // An explicit list — empty included — is what the caller requires (HC-GF-348); a function of the
234
+ // protected branch (HC-GF-354); none given: the default. `requiredChecks` is the answer before the
235
+ // branch is known: for a function that is nothing, since a check it would name might never report on
236
+ // the real branch, and the manual command printed on those paths is a template the user edits.
237
+ const requiredChecks = typeof opts.requiredChecks === 'function' ? [] : resolveChecks(opts.requiredChecks, null);
238
+
162
239
  // 2. Resolve git remote
163
240
  const remote = exec('git', ['remote', 'get-url', 'origin']);
164
241
  if (remote.status !== 0) {
@@ -206,10 +283,23 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
206
283
  };
207
284
  }
208
285
 
286
+ const branchChecks = resolveChecks(opts.requiredChecks, branch);
287
+
209
288
  // 4. GET existing protection (404 = none, 200 = merge)
210
289
  const protPath = `repos/${owner}/${repo}/branches/${branch}/protection`;
211
290
  const existing = exec('gh', ['api', protPath]);
212
291
  let existingContexts = [];
292
+ // HC-GF-348: only "Branch not protected" (HTTP 404) means there is nothing to merge. Any other failure
293
+ // to read the protection must not be taken for "none": the PUT replaces the contexts, so it would drop
294
+ // every check the repository already requires — and with this story the computed list can be empty.
295
+ if (existing.status !== 0 && !/Branch not protected/i.test(`${existing.stderr || ''}${existing.stdout || ''}`)) {
296
+ const err = parseGhError(existing.stderr, existing.status);
297
+ return {
298
+ status: 'failed',
299
+ reason: `could not read the existing branch protection, so it was left unchanged: ${err.message}`,
300
+ manualCommand: buildManualCommand({ contexts: branchChecks, owner, repo, branch }),
301
+ };
302
+ }
213
303
  if (existing.status === 0) {
214
304
  try {
215
305
  const e = JSON.parse(existing.stdout || '{}');
@@ -220,11 +310,32 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
220
310
  existingContexts = [];
221
311
  }
222
312
  }
223
- // (non-zero exit is treated as "no existing protection" — the PUT below will succeed if the user has permissions)
224
313
 
225
- // 5. Build merged payload
226
- const mergedContexts = mergeContexts(existingContexts, requiredChecks);
227
- const payload = buildPayload({ contexts: mergedContexts, requireReviews });
314
+ // 5. Build merged payload; drop the legacy check an earlier setup installed (HC-GF-348)
315
+ const mergedContexts = pruneLegacyChecks(mergeContexts(existingContexts, branchChecks), {
316
+ reportedChecks, keep: branchChecks,
317
+ });
318
+
319
+ // HC-GF-353: GitHub does not count a PR author's approval of their own PR, so one required review on a
320
+ // repository with a single writer makes every PR unmergeable. An explicit opts.requireReviews wins. A
321
+ // count that could not be established keeps 1: failing open on human review would be the regression.
322
+ let reviewCount = 1;
323
+ let reviewReason;
324
+ if (Number.isInteger(opts.requireReviews) && opts.requireReviews >= 0) {
325
+ reviewCount = opts.requireReviews;
326
+ reviewReason = 'set by the caller';
327
+ } else {
328
+ const w = countWriters(exec, owner, repo);
329
+ if (w.count == null) {
330
+ reviewReason = `the number of people who can push could not be counted (${w.reason}), so one review is still required`;
331
+ } else if (w.count === 1) {
332
+ reviewCount = 0;
333
+ reviewReason = 'one person can push, and GitHub does not count a pull request author\'s own approval; a pull request is still required';
334
+ } else {
335
+ reviewReason = `${w.count} people can push`;
336
+ }
337
+ }
338
+ const payload = buildPayload({ contexts: mergedContexts, requireReviews: reviewCount });
228
339
 
229
340
  // 6. PUT
230
341
  const put = exec(
@@ -233,7 +344,7 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
233
344
  { input: JSON.stringify(payload) }
234
345
  );
235
346
  if (put.status === 0) {
236
- return { status: 'installed', branch };
347
+ return { status: 'installed', branch, requiredChecks: mergedContexts, reviewCount, reviewReason };
237
348
  }
238
349
 
239
350
  // 7. PUT failed — classify and return graceful failure
@@ -241,16 +352,21 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
241
352
  return {
242
353
  status: 'failed',
243
354
  reason: err.message,
244
- manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch }),
355
+ manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch, requireReviews: reviewCount }),
245
356
  };
246
357
  }
247
358
 
248
359
  module.exports = {
249
360
  buildPayload,
250
361
  mergeContexts,
362
+ pruneLegacyChecks,
363
+ resolveChecks,
364
+ countWriters,
251
365
  parseGhError,
252
366
  installBranchProtection,
253
367
  // Internal helpers exposed for inspection / future reuse
254
368
  buildManualCommand,
369
+ defaultExec,
255
370
  DEFAULT_REQUIRED_CHECKS,
371
+ LEGACY_REQUIRED_CHECK,
256
372
  };
@@ -256,8 +256,20 @@ function extractPathRefs(text) {
256
256
  return out;
257
257
  }
258
258
 
259
- /** Backticked spans in a story, longest first — the longest is the most specific anchor. */
260
- const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
259
+ /**
260
+ * Backticked spans in a story, PAIRED FIRST and length-unbounded; `storyTermsOf` applies the
261
+ * 3-to-200 rule to each paired span. HC-GF-188-followup-2.
262
+ *
263
+ * A BOUND INSIDE THE MATCH DECIDES WHICH BACKTICKS PAIR. At `` `x`, and `foo` `` the opening
264
+ * backtick of `x` could not start a 3-character match, so its CLOSING backtick did: the span ran
265
+ * to `foo`'s OPENING backtick, the "term" was `, and`, and `foo` was lost because no backtick was
266
+ * left to open it. A span over 200 characters, and an empty pair, shift the pairing the same way.
267
+ *
268
+ * The pairing is the one `maskCodeSpans` uses below: one rule for what a code span is, and the
269
+ * filter decides which spans can anchor. Fences are NOT masked here, which would widen the change
270
+ * past what the row measured.
271
+ */
272
+ const BACKTICKED_RE = /`([^`\n]*)`/g;
261
273
 
262
274
  /**
263
275
  * The terms a story quotes that could locate code, longest first — one copy of the filter.
@@ -269,7 +281,8 @@ const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
269
281
  function storyTermsOf(storyText) {
270
282
  return [...String(storyText || '').matchAll(BACKTICKED_RE)]
271
283
  .map((m) => m[1].trim())
272
- .filter((c) => c.length >= 3
284
+ // Both bounds read the TRIMMED span; the ceiling used to live in the regex, on the raw span.
285
+ .filter((c) => c.length >= 3 && c.length <= 200
273
286
  && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
274
287
  && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
275
288
  .sort((a, b) => b.length - a.length);
@@ -325,6 +338,17 @@ function anchorLineForStory(content, storyText) {
325
338
  *
326
339
  * The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
327
340
  * gets the operator a window near it, which beats none.
341
+ *
342
+ * HC-GF-203-followup-1: a MENTION is the term standing whole, not the term's letters inside a
343
+ * longer identifier. The fallback was a bare `indexOf`, so `info` landed in `information`,
344
+ * `auth` in `authorize` and `Promise.all` in `Promise.allSettled` — and each such offset became
345
+ * a site in `anchorSitesForStory`, spending one of its 6 slots and a window on text that does
346
+ * not contain the term. Measured 2026-10-07 at the production cap over every roadmap row
347
+ * against cli/hone-cli.js and server/src/services/orchestrator.js: 187 of 6,210 sites, in 161
348
+ * of 1,224 windowed excerpts, 106 of them at the 6-site limit, where the wasted slot cost
349
+ * another term its window. A term that never stands whole is now -1, exactly like a term
350
+ * the file lacks: HC-GF-188's rule, "could not anchor" stays distinguishable from "anchored
351
+ * here", applies to a window around `information` as much as to one around line 1.
328
352
  */
329
353
  /**
330
354
  * HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
@@ -355,6 +379,11 @@ function anchorSitesForStory(content, storyText, max = 6) {
355
379
  if (def === -1) continue;
356
380
  const t = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
357
381
  const offsets = [def];
382
+ // The STRICT form on purpose, unlike `wholeTerm`. HC-GF-206-followup-5 measured `wholeTerm` here:
383
+ // a term with a punctuation edge (`/prompts/`, `.json`) then takes every occurrence as a site and
384
+ // crowds the rest out of the 6 — 74 of 1,218 excerpts changed, 9 terms gained a window, 28 lost one.
385
+ // With the strict form such a term gets its first whole occurrence (`def`), and the marker names
386
+ // it when no window shows it.
358
387
  for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
359
388
  for (const off of offsets) {
360
389
  const line = text.slice(0, off).split('\n').length;
@@ -370,8 +399,29 @@ function anchorSitesForStory(content, storyText, max = 6) {
370
399
  return { sites, collapsed };
371
400
  }
372
401
 
402
+ /**
403
+ * A term standing WHOLE — HC-GF-203-followup-1: no identifier character touches an edge of the term
404
+ * that is itself an identifier character. `checkBudget(` stands whole in `checkBudget(orgId)` — its
405
+ * own `(` is the boundary on that side — and `info` does not in `information`.
406
+ *
407
+ * HC-GF-206-followup-5: one copy, read by `definitionIndexOf` and `unanchoredTerms`. With the strict
408
+ * `(?<![\w$])…(?![\w$])` in `unanchoredTerms`, the two disagreed on every term with a punctuation
409
+ * edge: `definitionIndexOf` found `checkBudget(` and the marker said nothing, because the strict
410
+ * form calls it absent. `anchorSitesForStory`'s scan for further hits deliberately keeps the strict
411
+ * form; see there.
412
+ *
413
+ * @returns {{t: string, before: string, after: string}} the escaped term and the two lookarounds
414
+ */
415
+ function wholeTerm(term) {
416
+ return {
417
+ t: term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
418
+ before: /^[\w$]/.test(term) ? '(?<![\\w$])' : '',
419
+ after: /[\w$]$/.test(term) ? '(?![\\w$])' : '',
420
+ };
421
+ }
422
+
373
423
  function definitionIndexOf(text, term) {
374
- const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
424
+ const { t, before, after } = wholeTerm(term);
375
425
  // Ordered by how strongly each shape asserts "this is where it lives".
376
426
  const DEFINITION_FORMS = [
377
427
  `\\.command\\(\\s*['"\`]${t}\\b`, // CLI command registration
@@ -379,13 +429,16 @@ function definitionIndexOf(text, term) {
379
429
  `class\\s+${t}\\b`, // class declaration
380
430
  `(?:const|let|var)\\s+${t}\\s*=`, // binding
381
431
  `exports\\.${t}\\s*=`, // named export
382
- `${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
432
+ // The one form with nothing in front of the term: without `before`, `name` is "defined"
433
+ // by `basename = (` and `run` by `rerun: function`.
434
+ `${before}${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
383
435
  ];
384
436
  for (const form of DEFINITION_FORMS) {
385
437
  const m = new RegExp(form).exec(text);
386
438
  if (m) return m.index;
387
439
  }
388
- return text.indexOf(term);
440
+ const mention = new RegExp(`${before}${t}${after}`).exec(text);
441
+ return mention ? mention.index : -1;
389
442
  }
390
443
 
391
444
  /**
@@ -402,9 +455,17 @@ function definitionIndexOf(text, term) {
402
455
  * Judged against the FINAL windows, not the site list, so it holds whatever dropped the term — the
403
456
  * site limit, the budget, a collapse — and needs no knowledge of how the layout was chosen.
404
457
  *
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.
458
+ * A term counts as occurring only where it stands whole (`wholeTerm`); reporting `info` inside
459
+ * `information` as "`info` is at line N" would send an agent to a line that does not contain it.
460
+ * `definitionIndexOf` applies the same rule since HC-GF-203-followup-1 (its fallback was a bare
461
+ * `indexOf`); the check here stays, because this is what names a term to the agent.
462
+ *
463
+ * HC-GF-206-followup-5: this read the strict form, which calls every term with a punctuation edge
464
+ * absent unless that edge happens to sit next to a non-identifier character. Measured 2026-10-07 at
465
+ * the production cap over every roadmap row against cli/hone-cli.js and orchestrator.js: 44 such
466
+ * terms in 40 of 1,218 windowed excerpts were in the file, in no window, and not named; and 42
467
+ * were named as outside every window while a window showed them (`.md` in `README.md`). After: 0 and
468
+ * 0, with every window byte-identical and no term pushed out of the marker or the notice.
408
469
  *
409
470
  * @param {string} content full file contents
410
471
  * @param {string} storyText
@@ -428,11 +489,10 @@ function unanchoredTerms(content, storyText, ranges) {
428
489
  for (const term of new Set(storyTermsOf(storyText))) {
429
490
  const def = definitionIndexOf(text, term);
430
491
  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));
492
+ const { t, before, after } = wholeTerm(term);
493
+ const mentions = [...text.matchAll(new RegExp(`${before}${t}${after}`, 'g'))].map((m) => lineOf(m.index));
433
494
  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`.
495
+ // Covered only by a whole occurrence.
436
496
  if (mentions.some(covered)) continue;
437
497
  const defLine = lineOf(def);
438
498
  out.push({ term, line: mentions.includes(defLine) ? defLine : mentions[0] });
@@ -877,6 +937,10 @@ const LABEL_RE = /\*\*([A-Z][^*\n]{0,80}?):\*\*/g;
877
937
  * Fences are masked FIRST so a backtick inside a fence cannot pair with one
878
938
  * outside it.
879
939
  *
940
+ * HC-GF-188-followup-2: `BACKTICKED_RE` (read by `storyTermsOf`) pairs backticks the same way,
941
+ * `[^`\n]*` between two of them. Change the pairing in one place and change it in the other, or
942
+ * the two will disagree about what a code span is.
943
+ *
880
944
  * @param {string} text
881
945
  * @returns {string} same length as the input
882
946
  */