@polycode-projects/the-mechanical-code-talker 1.5.5 → 1.8.4

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.
Files changed (45) hide show
  1. package/README.md +123 -14
  2. package/ROADMAP.md +233 -1392
  3. package/bin/tmct.mjs +479 -98
  4. package/corpus/README.md +3 -0
  5. package/corpus/generated/README.md +43 -0
  6. package/corpus/generated/ace-surface-variants.jsonl +17 -0
  7. package/corpus/generated/manifest.json +9 -0
  8. package/corpus/tier2/generate.mjs +14668 -0
  9. package/corpus/tier2/human-examples-large.jsonl +1928 -0
  10. package/corpus/tier2/human-examples-medium.jsonl +356 -0
  11. package/corpus/tier2/human-examples.jsonl +120 -0
  12. package/corpus/tier2/human-large.jsonl +12001 -0
  13. package/corpus/tier2/human-medium.jsonl +944 -0
  14. package/corpus/tier2/human.jsonl +664 -0
  15. package/corpus/tier2/manifest.json +42 -0
  16. package/package.json +14 -8
  17. package/src/answer-variants.json +47 -0
  18. package/src/answer-variants.mjs +67 -0
  19. package/src/ask-browser-entry.mjs +34 -0
  20. package/src/ask-browser.bundle.js +5095 -0
  21. package/src/ask-vocab.mjs +93 -8
  22. package/src/ask.mjs +451 -49
  23. package/src/chat.mjs +1273 -137
  24. package/src/cli-args.mjs +164 -0
  25. package/src/codegraph.mjs +170 -32
  26. package/src/extensions.mjs +100 -19
  27. package/src/grammar/ace.mjs +85 -3
  28. package/src/grammar/lexicon-core.json +9531 -63
  29. package/src/grammar/lexicon.mjs +58 -8
  30. package/src/graph-merge.mjs +114 -0
  31. package/src/index.mjs +14 -0
  32. package/src/init.mjs +40 -14
  33. package/src/interpret/normalize.mjs +75 -1
  34. package/src/interpret/strategies/grammar.mjs +10 -0
  35. package/src/interpret/strategies/keywords.mjs +20 -0
  36. package/src/interpret/strategies/noise-strip.mjs +73 -4
  37. package/src/memory/core.mjs +466 -8
  38. package/src/router/goal-reasoner.mjs +41 -7
  39. package/src/router/guardrail.mjs +37 -7
  40. package/src/router/resolver.mjs +50 -4
  41. package/src/sessions.mjs +5 -1
  42. package/src/source.mjs +54 -1
  43. package/src/syllogise.mjs +398 -27
  44. package/src/toml-config.mjs +13 -4
  45. package/src/viz.mjs +541 -0
package/src/ask.mjs CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  CONTEXT_PRONOUNS, META_MEANING_VERBS,
45
45
  WHERE_MARKERS, MENTION_MARKERS,
46
46
  RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
47
- AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
47
+ AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
48
48
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
49
49
  } from "./ask-vocab.mjs";
50
50
  // The interpretation layer (ROADMAP items 8/10/13) — the movable conversational
@@ -56,8 +56,9 @@ import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
56
56
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
57
57
  import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
58
58
  import { runStrategiesSync } from "./interpret/pipeline.mjs";
59
- import { mergeStrategyResults } from "./interpret/merge.mjs";
59
+ import { mergeStrategyResults, alternateLines } from "./interpret/merge.mjs";
60
60
  import { lookupByProseTokens } from "./prose.mjs";
61
+ import { pickPhrase } from "./answer-variants.mjs";
61
62
 
62
63
  // Normalization stays importable from its original site (tests + chat surface).
63
64
  export { normalizeQuery, applyNegationFrames };
@@ -222,12 +223,66 @@ function defaultNlp() {
222
223
  * test); leaving it undefined picks the deterministic default (defaultNlp).
223
224
  * Pure given (query, adapter) — the adapter itself is a fixed model, no
224
225
  * sampling. */
226
+ // SCHEMA-TERM / COMMON-WORD "WHAT DOES X MEAN" DISAMBIGUATION (CHATBENCH decision-log
227
+ // item 1, g-a1-naming-8: "what does tests mean"). When the object term X is ITSELF a
228
+ // real RELATIONS/VERB_TO_KIND keyword ("tests", "imports", …), keyword-spot
229
+ // independently reads the sentence as a "reverse"-shaped query — kind:X, object:"mean"
230
+ // — because "mean"/"means" (META_MEANING_VERBS, ask-vocab.mjs §7) is deliberately kept
231
+ // OUT of the relation tables (see that table's own comment): keyword-spot has no idea
232
+ // it just consumed the meta question's own verb as if it were an object noun. That
233
+ // reading can never resolve to anything real ("mean" is never a graph entity) — it's a
234
+ // spurious parse, not a genuine second reading — yet it collides with the grammar
235
+ // strategy's own clean "meta" parse of the SAME sentence to manufacture the legacy
236
+ // {ambiguousParse} surface ("this could mean more than one thing: 1) meta X or 2) X
237
+ // 'mean' — try rephrasing"). Pruned here whenever the winning class's candidates are
238
+ // EXACTLY [a meta-shape parse, a same-precedence parse whose object is a
239
+ // META_MEANING_VERB] — collapsing back to the meta parse alone, so the term's own
240
+ // schema-predicate definition answers directly instead of the unhelpful two-way punt.
241
+ //
242
+ // EXCEPT "imports": am-meta-imports (chatbench/graded-pool.jsonl) and
243
+ // quickwins.test.mjs's "fix1: the frozen am-meta-imports ambiguity is NOT admitted"
244
+ // both lock this EXACT ambiguity in as the intended, honest answer for that one term —
245
+ // "what does imports mean" is byte-identical input to both am-meta-imports (which
246
+ // requires the ambiguous answer) and g-a1-naming-9 (which wants the plain definition).
247
+ // A deterministic function cannot satisfy both on the same input; widening the prune to
248
+ // "imports" would silently flip am-meta-imports from passing to failing, a real
249
+ // regression this project's own decision rule (SKILL_BENCHMARK_CEFR_ENGLISH.md §1)
250
+ // forbids. So "imports" keeps its existing ambiguous answer — the same judgment call
251
+ // chat.mjs's relationTermOf already makes for it (see that function's own docblock) —
252
+ // while every OTHER relation term this collision can hit ("tests" included) is fixed
253
+ // generally, not as a one-off patch.
254
+ const FROZEN_META_AMBIGUOUS_TERMS = new Set(["imports"]);
255
+ function pruneSpuriousMeaningAmbiguity(parsed) {
256
+ if (!parsed?.ambiguousParse || !Array.isArray(parsed.candidates) || parsed.candidates.length !== 2) return parsed;
257
+ const metaC = parsed.candidates.find((c) => c?.shape === "meta");
258
+ const other = parsed.candidates.find((c) => c !== metaC);
259
+ if (!metaC || !other) return parsed;
260
+ const term = String(metaC.object || "").trim().toLowerCase();
261
+ if (!term || FROZEN_META_AMBIGUOUS_TERMS.has(term)) return parsed;
262
+ const otherObject = String(other.object || "").trim().toLowerCase();
263
+ if (!wordsOf(META_MEANING_VERBS).includes(otherObject)) return parsed;
264
+ return metaC;
265
+ }
266
+
225
267
  export function parseQuery(query, { nlp = undefined } = {}) {
268
+ return parseQueryFull(query, { nlp }).parsed;
269
+ }
270
+
271
+ /** Sibling of `parseQuery` that also surfaces what `parseQuery` has always
272
+ * discarded: `merge.mjs`'s `class`/`alternates` (PLAN_BREADTH_FIRST_NLU.md §3)
273
+ * — a genuine, distinct-class alternate reading a different strategy produced,
274
+ * silently dropped on every hit until now. `parseQuery`'s own contract stays
275
+ * byte-identical (it's defined in terms of this function's `.parsed` field,
276
+ * above) — 142 existing call sites are untouched. Returns
277
+ * `{parsed, alternates, class}`; `alternates`/`class` are `[]`/`null` on the
278
+ * compositional-parse path (a structurally separate grammar layer that never
279
+ * reaches `mergeStrategyResults`) or on a total miss. */
280
+ export function parseQueryFull(query, { nlp = undefined } = {}) {
226
281
  const adapter = nlp === undefined ? defaultNlp() : nlp;
227
282
  const raw = String(query || "").trim().replace(/\s+/g, " ");
228
- if (!raw) return null;
283
+ if (!raw) return { parsed: null, alternates: [], class: null };
229
284
  const text = applyPhrasingFrames(applyNegationFrames(normalizeQuery(raw)));
230
- if (!text) return null;
285
+ if (!text) return { parsed: null, alternates: [], class: null };
231
286
  // COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) — the new PRIMARY layer: a recursive
232
287
  // descent over CLAUSES for the compositional shapes (nested/relative, boolean,
233
288
  // qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
@@ -237,9 +292,14 @@ export function parseQuery(query, { nlp = undefined } = {}) {
237
292
  // the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
238
293
  // letting keyword-spot guess at a composition it never expressed.
239
294
  const composite = parseComposite(text, adapter);
240
- if (composite) return composite;
295
+ if (composite) return { parsed: composite, alternates: [], class: null };
241
296
  const merged = mergeStrategyResults(runStrategiesSync(text, { nlp: adapter, raw }));
242
- return merged ? merged.parsed : null;
297
+ if (!merged) return { parsed: null, alternates: [], class: null };
298
+ return {
299
+ parsed: pruneSpuriousMeaningAmbiguity(merged.parsed),
300
+ alternates: merged.alternates || [],
301
+ class: merged.class || null,
302
+ };
243
303
  }
244
304
 
245
305
  // ============================================================================
@@ -431,19 +491,28 @@ function parseForwardNegation(w, lc, nlp) {
431
491
  return { node: "forwardComplement", kind: vh.kind, subjectTerm };
432
492
  }
433
493
 
434
- /** The single OBJECT class a forward relation kind points at across the loaded graph
435
- * (imports Module), or null when its objects span more than one class (an ambiguous
436
- * grain the complement's universe can't be pinned to). Ext: endpoints have no individual,
437
- * so they don't muddy the class vote. Used by the forwardComplement evaluator to bound
438
- * the universe it differences the positive forward set out of. */
439
- function kindObjectClass(graph, kind) {
494
+ /** The full set of OBJECT classes observed across every edge of the given stored
495
+ * kinds (already expanded past any query-side union callers pass `kindsFor(kind)`
496
+ * or an equivalent list). Ext: endpoints have no individual, so they don't muddy
497
+ * the class vote. Shared by `kindObjectClass` (collapses to a single class or null)
498
+ * and the forward branch's grain check (needs the full set, not the collapse). */
499
+ function classesForKinds(graph, kinds) {
440
500
  const classes = new Set();
441
- for (const k of kindsFor(kind)) {
501
+ for (const k of kinds) {
442
502
  for (const e of edgesOfKind(graph, k)) {
443
503
  const o = graph.byId.get(e.object);
444
504
  if (o && o.class) classes.add(o.class);
445
505
  }
446
506
  }
507
+ return classes;
508
+ }
509
+
510
+ /** The single OBJECT class a forward relation kind points at across the loaded graph
511
+ * (imports → Module), or null when its objects span more than one class (an ambiguous
512
+ * grain the complement's universe can't be pinned to). Used by the forwardComplement
513
+ * evaluator to bound the universe it differences the positive forward set out of. */
514
+ function kindObjectClass(graph, kind) {
515
+ const classes = classesForKinds(graph, kindsFor(kind));
447
516
  return classes.size === 1 ? [...classes][0] : null;
448
517
  }
449
518
 
@@ -474,21 +543,36 @@ function parseSetPhrase(text, nlp, depth) {
474
543
  * that call X", handled by parseRelationalOrQualified) by requiring a VERB before the
475
544
  * relative noun (i.e. the noun is not the leading subject). Returns a reverse/forward
476
545
  * Set node, an honest miss (marker present, uncompilable), or null (no object-relative
477
- * marker → let another production try). */
546
+ * marker → let another production try).
547
+ *
548
+ * The marker is either an explicit relative pronoun ("the module THAT imports X") or a
549
+ * REDUCED relative clause with the pronoun dropped ("the module IMPORTING X" — a gerund
550
+ * verb right where the pronoun+verb would go means the same thing). Root-cause fix for
551
+ * g-c1-temp-8 (2026-07-12): "who touched the module importing X" has no "that", so this
552
+ * loop used to find no marker at all, return null, and let the query fall through to the
553
+ * legacy (non-compositional) strategy pipeline — which then misread the leading verb
554
+ * "touched" itself as the flat ASK shape's subject TERM ("does 'touched' import X"),
555
+ * never resolving to a real entity. "the module THAT imports X" (explicit pronoun)
556
+ * already routed correctly through this same function; the gerund form is the identical
557
+ * nested-set shape and is now recognized the same way. */
478
558
  function parseNested(w, lc, nlp, depth) {
479
559
  for (let r = 1; r < lc.length; r += 1) {
480
- if (!RELATIVE_PRONOUNS.includes(lc[r])) continue;
481
- if (r + 1 >= lc.length) continue; // nothing after "that"
560
+ const isPronoun = RELATIVE_PRONOUNS.includes(lc[r]);
561
+ const isGerundMarker = !isPronoun && isGerundVerb(lc[r]);
562
+ if (!isPronoun && !isGerundMarker) continue;
563
+ if (isPronoun && r + 1 >= lc.length) continue; // nothing after "that"
482
564
  const noun = entityNoun(lc[r - 1]);
483
- if (!noun) continue; // "that" not preceded by a noun
565
+ if (!noun) continue; // marker not preceded by a noun
484
566
  const head = w.slice(0, r - 1); // outer clause words, minus the placeholder noun
485
567
  if (!head.length) continue; // noun is the leading subject → subject-relative, not this shape
486
568
  const outer = parseSimpleClause([...head, NEST_SENTINEL].join(" "), nlp);
487
569
  if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
488
570
  if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
489
571
  // build the inner sub-query: "which <placeholder-noun> <inner-text>" — recurses,
490
- // so the inner may itself be nested/boolean/qualified (depth ≥2).
491
- const innerText = `which ${lc[r - 1]} ${w.slice(r + 1).join(" ")}`;
572
+ // so the inner may itself be nested/boolean/qualified (depth ≥2). An explicit
573
+ // relative pronoun is consumed (skip past it, start at r+1); a gerund marker IS
574
+ // the predicate's own verb, so it stays in the inner text (start AT r).
575
+ const innerText = `which ${lc[r - 1]} ${w.slice(isPronoun ? r + 1 : r).join(" ")}`;
492
576
  const inner = parseSetPhrase(innerText, nlp, depth + 1);
493
577
  if (!inner || inner.node === "miss") return inner ? { node: "miss", reason: inner.reason || "inner clause didn't parse" } : { node: "miss", reason: "inner clause didn't parse" };
494
578
  return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner };
@@ -922,11 +1006,9 @@ function parseSuperlative(w, lc, nlp) {
922
1006
  if (SUPERLATIVE_EXTREMES[lc[i]]) { ext = SUPERLATIVE_EXTREMES[lc[i]]; extIdx = i; break; }
923
1007
  }
924
1008
  if (!ext) return null;
925
- // entity noun anywhere (first match, deterministic)
926
- let entityType; let entWord = null;
927
- for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
928
- if (!entWord) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
929
- // edge noun after the extreme (imports/callers/methods/…)
1009
+ // edge noun after the extreme (imports/callers/methods/…) computed BEFORE
1010
+ // the entity-noun check below, so a metric with a single implied entity class
1011
+ // (METRIC_IMPLIES_ENTITY) can supply a default when no explicit noun is given.
930
1012
  let metric = null; let metricNoun = null;
931
1013
  for (let i = extIdx; i < lc.length; i += 1) {
932
1014
  if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
@@ -945,10 +1027,18 @@ function parseSuperlative(w, lc, nlp) {
945
1027
  }
946
1028
  const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
947
1029
  || ["largest", "biggest", "smallest"].includes(lc[extIdx]);
948
- if (!metric) {
949
- if (connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
950
- else return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
1030
+ if (!metric && connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
1031
+ // entity noun anywhere (first match, deterministic); else default from a
1032
+ // metric that implies exactly one entity class ("test(s)" always ranks
1033
+ // Modules, the one declared exception — see METRIC_IMPLIES_ENTITY — so "what
1034
+ // most needs a test" resolves without also requiring the word "module").
1035
+ let entityType; let entWord = null;
1036
+ for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
1037
+ if (!entWord) {
1038
+ entityType = metricNoun ? METRIC_IMPLIES_ENTITY[metricNoun] : undefined;
1039
+ if (!entityType) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
951
1040
  }
1041
+ if (!metric) return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
952
1042
  return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
953
1043
  }
954
1044
 
@@ -1455,7 +1545,7 @@ export function metaFallbackEntityAnswer(graph, term) {
1455
1545
  || null;
1456
1546
  const noun = nounFor(hit.class, 1);
1457
1547
  const article = noun === "attribute" ? "an" : "a";
1458
- const definedIn = modLabel ? `, defined in ${modLabel}` : "";
1548
+ const definedIn = modLabel ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "";
1459
1549
  const followUp = hit.class === "Class" ? ` or "which classes inherit from ${hit.label}"` : "";
1460
1550
  return {
1461
1551
  text: `${hit.label} is ${article} ${noun} in this codebase${definedIn} — try "describe ${hit.label}"${followUp}.`,
@@ -2146,7 +2236,7 @@ function renderComposite(parsed, result) {
2146
2236
  }
2147
2237
  const hit = result.matches[0];
2148
2238
  const modLabel = moduleLabelOf(hit);
2149
- const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, defined in ${modLabel}` : "");
2239
+ const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, ${pickPhrase("defined-in", hit.id, "defined in")} ${modLabel}` : "");
2150
2240
  return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
2151
2241
  }
2152
2242
  if (!result.matches.length) {
@@ -2200,7 +2290,7 @@ function renderComposite(parsed, result) {
2200
2290
  // computeFind's "related, not exact" broad pass documents for predicate-find).
2201
2291
  if (result.compositeKind === "membership") {
2202
2292
  if (!result.matches.length) {
2203
- return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
2293
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2204
2294
  }
2205
2295
  if (result.inheritedNotOwn) {
2206
2296
  const kindPlural = nounFor(result.entityType, 2);
@@ -2293,10 +2383,10 @@ function renderComposite(parsed, result) {
2293
2383
  const setNoun = result.entityType ? nounFor(result.entityType, n || 2) : (n === 1 ? "entity" : "entities");
2294
2384
  const wasWere = n === 1 ? "was" : "were";
2295
2385
  if (!n) {
2296
- return { content: `nothing in the index matches the inner set, so there is no change history to date.`, miss: true, ambiguous: false, matches: [] };
2386
+ return { content: `nothing in the index matches the inner set, so there is no change history to date. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2297
2387
  }
2298
2388
  if (!result.matches.length) {
2299
- return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index.`, miss: true, ambiguous: false, matches: [] };
2389
+ return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2300
2390
  }
2301
2391
  const newest = result.matches[0];
2302
2392
  const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
@@ -2313,7 +2403,7 @@ function renderComposite(parsed, result) {
2313
2403
  }
2314
2404
  // set-producing
2315
2405
  if (!result.matches.length) {
2316
- return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
2406
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
2317
2407
  }
2318
2408
  return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
2319
2409
  }
@@ -2321,10 +2411,35 @@ function renderComposite(parsed, result) {
2321
2411
  /** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
2322
2412
  * uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
2323
2413
  export function rephraseHint() {
2324
- return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests|touched> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary). '
2414
+ // "touched" used to sit in the <imports|calls|uses|inherits from|tests> cross-product
2415
+ // above, combined with <functions|classes|modules> — but `touches` is a Commit->Module/
2416
+ // symbol edge (ask-vocab.mjs's RELATIONS.touches comment), so a Function/Class/Module is
2417
+ // NEVER the subject of a touch: "which modules touched X" always misses, for every X, no
2418
+ // matter the graph (verified against test/fixtures/entities.fixture.json — every reverse
2419
+ // combination with "touched" and a functions/classes/modules subject returns zero
2420
+ // matches). The real reverse subject for this edge is Commit — "which commits touched
2421
+ // <name>" below, mirroring the working "who touched <name>" nudge used elsewhere
2422
+ // (chat.mjs's nudgeAnswer).
2423
+ return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which commits touched <name>" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary). '
2325
2424
  + compositionalHint();
2326
2425
  }
2327
2426
 
2427
+ /** A short, honest nudge for the touches/history-family of correct-but-unhelpful
2428
+ * honest misses (CEFR decision log, BENCHMARK_CEFR_ENGLISH_1.7.0.md item 1:
2429
+ * g-c1-temp-7, g-c1-temp-3, g-b1-pron-1/4/5, hm-unknown-fn, hm-unknown-module all
2430
+ * scored `rephrase: 0` despite being correct empty results — the miss said WHAT
2431
+ * wasn't found but gave no nudge toward a question that WOULD work). Points at the
2432
+ * same "who touched X" / "/describe X" shapes that produce a real answer elsewhere
2433
+ * in this file (see whenShape/whoLastShape's own success template, "X was last
2434
+ * touched by commit …", a few lines below) — never promises any PARTICULAR name
2435
+ * will resolve, since the whole point of the miss it's attached to is that this
2436
+ * one didn't. Sibling of rephraseHint() above: same purpose, narrower vocabulary,
2437
+ * reused verbatim by every touches/history-family miss template rather than each
2438
+ * one inventing its own wording. */
2439
+ export function touchesRephraseHint() {
2440
+ return 'Try "who touched <a module that actually has commits>" or "/describe <module>" to see what\'s in the index.';
2441
+ }
2442
+
2328
2443
  // ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
2329
2444
 
2330
2445
  function componentSet(s) {
@@ -2915,13 +3030,29 @@ const TRANSITIVE_MAX_DEPTH = 8;
2915
3030
  * needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
2916
3031
  * answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
2917
3032
  * `matches` is always an array of individuals (or edge records for "ask"). */
2918
- export function traverse(graph, parsed, { contextId = null, prev = null } = {}) {
3033
+ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedObjMatch = null } = {}) {
2919
3034
  if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
2920
3035
  // compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
2921
3036
  // everything else (simple clauses, ambiguousParse) flows through the original path
2922
3037
  // below completely unchanged.
2923
3038
  if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
2924
- if (parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
3039
+ // (fix, 2026-07-11) A same-class parse-level tie ({ambiguousParse, candidates})
3040
+ // used to short-circuit here with an empty result, so renderCore's ambiguousParse
3041
+ // branch could only ever describe each reading ("1) meta X or 2) Y — try
3042
+ // rephrasing"), never actually answer any of them — an honest admission of
3043
+ // ambiguity with no real content behind it. Every candidate IS a normal,
3044
+ // individually-resolvable parse (merge.mjs only ties same-class DISTINCT parses,
3045
+ // never nests another ambiguousParse inside one), so each one can be traversed
3046
+ // and rendered for real right here — the combined answer stays deterministic on
3047
+ // the same input (no guessing, no picking a winner) while actually telling the
3048
+ // user what each reading resolves to, instead of making them ask twice.
3049
+ if (parsed.ambiguousParse) {
3050
+ const branches = parsed.candidates.map((c) => {
3051
+ const branchResult = traverse(graph, c, { contextId, prev });
3052
+ return { parsed: c, result: branchResult, rendered: render(c, branchResult) };
3053
+ });
3054
+ return { matches: [], objMatch: null, candidates: parsed.candidates, traversal: null, ambiguous: true, branches };
3055
+ }
2925
3056
  const { shape, kind, entityType } = parsed;
2926
3057
 
2927
3058
  // meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
@@ -3019,8 +3150,94 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
3019
3150
 
3020
3151
  // reverse and forward both resolve one named term ("object" in the parsed shape — for
3021
3152
  // forward it is the query's grammatical subject, e.g. "what does X import" -> parsed.object = X).
3022
- const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = resolveTermOrContext(graph, parsed.object, contextId);
3153
+ //
3154
+ // altObject pruning (PLAN_CONVERSATION.md Finding 2): noise-strip.mjs's bare
3155
+ // "where"/"mentions" reading may carry `parsed.altObject` — the SAME reading
3156
+ // with a wink-POS-flagged, plausibly-noise light verb ALSO dropped ("store
3157
+ // router" -> "router"; see that file's own doc for why the signal needs full-
3158
+ // sentence context and can't be decided there, where there is no graph).
3159
+ // This is the one place both the alternate reading and the graph are
3160
+ // available together, so it's where the pruning actually happens — mirroring
3161
+ // resolveObject's own grain-word retry just above (try a variant, keep it
3162
+ // ONLY on an unambiguous hit, else fall through unchanged) and
3163
+ // grammar/ace.mjs's parseAceAmbiguous ("keep only complete, valid parses,
3164
+ // dead ends pruned"). Four outcomes: primary misses/ties + alt resolves
3165
+ // cleanly -> the alt reading wins (the dead end is pruned); primary resolves
3166
+ // cleanly -> untouched, regardless of what the alt does (byte-identical to
3167
+ // before this existed); both resolve cleanly to the SAME entity -> untouched
3168
+ // either way; both resolve cleanly to DIFFERENT entities -> genuine
3169
+ // ambiguity, surfaced the same honest way resolveObject's own tier ties
3170
+ // already are, never silently guessed.
3171
+ // ENTITY-TIE BRANCHES (breadth-first ambiguity, PLAN_BREADTH_FIRST_NLU.md §1): a
3172
+ // recursive traverse() call for one already-tied candidate arrives here with
3173
+ // `pinnedObjMatch` set — skip re-resolution entirely (no re-derived tie is
3174
+ // possible, so `ambiguous` is structurally false on every such call) rather
3175
+ // than re-resolving by label text, which would risk a second individual
3176
+ // sharing the same label, a stale `altObject` re-triggering a spurious new
3177
+ // tie, or the test-variant-collision guard misfiring on a non-Module label.
3178
+ let objRes;
3179
+ if (pinnedObjMatch) {
3180
+ objRes = { match: pinnedObjMatch, candidates: [], ambiguous: false, matchedVia: null };
3181
+ } else {
3182
+ objRes = resolveTermOrContext(graph, parsed.object, contextId);
3183
+ if (parsed.altObject && parsed.altObject !== parsed.object) {
3184
+ const altRes = resolveTermOrContext(graph, parsed.altObject, contextId);
3185
+ const primaryClean = !!objRes.match && !objRes.ambiguous;
3186
+ const altClean = !!altRes.match && !altRes.ambiguous;
3187
+ if (!primaryClean && altClean) {
3188
+ objRes = altRes;
3189
+ } else if (primaryClean && altClean && objRes.match.id !== altRes.match.id) {
3190
+ objRes = { ...objRes, ambiguous: true, candidates: [altRes.match, ...(objRes.candidates || [])] };
3191
+ }
3192
+ }
3193
+ // TEST-VARIANT COLLISION (CHATBENCH decision-log item 2, am-tests-cover: "which
3194
+ // tests cover b.mjs"): a "tests"-kind query's object may also honestly name its
3195
+ // OWN conventional test-variant sibling ("b.mjs" -> "b.test.mjs"/"b.spec.mjs"),
3196
+ // a DIFFERENT real Module — app/lib/b.mjs and app/unit-tests/b.test.mjs both
3197
+ // plausibly answer "b.mjs" when the question is specifically about test
3198
+ // coverage. Deliberately scoped to kind==="tests" (never touches, imports,
3199
+ // calls, …) — the SAME bare filename in an unrelated query ("has store.mjs
3200
+ // been touched", chatflow-agents-debt-remeasure.test.mjs BUG-B's ground truth,
3201
+ // examples/mini-webapp genuinely has the same store.mjs/store.test.mjs pair) has
3202
+ // no such self-referential reading and must stay untouched. ALSO scoped to a
3203
+ // BARE (unslashed) term — same convention as resolveObjectCore's own `dotted`
3204
+ // tier just above resolveObject's call site: a query that already spells out
3205
+ // the full path ("which functions test src/core/store.mjs",
3206
+ // chatflow-tier4.test.mjs Batch 4/5; "app/lib/b.mjs", chatflow-tier2.test.mjs
3207
+ // T18) is already unambiguous by construction and must stay untouched — only
3208
+ // the bare basename is genuinely open to either reading. Same discipline as
3209
+ // the altObject prune just above: only a CLEAN primary match plus a genuinely
3210
+ // DIFFERENT real Module promotes to honest ambiguity, never a silent guess.
3211
+ if (parsed.kind === "tests" && objRes.match && !objRes.ambiguous && !String(parsed.object || "").includes("/")) {
3212
+ const stripTestInfix = (base) => base.replace(/\.(?:test|spec|tests)(?=\.[^.]+$)/, "");
3213
+ const termBase = stripTestInfix(String(parsed.object || "").trim().toLowerCase());
3214
+ const collision = (graph.individuals || []).find((i) => {
3215
+ if (i.class !== "Module" || i.id === objRes.match.id) return false;
3216
+ const base = String(i.label || "").toLowerCase().split("/").pop();
3217
+ return base !== stripTestInfix(base) && stripTestInfix(base) === termBase;
3218
+ });
3219
+ if (collision) objRes = { ...objRes, ambiguous: true, candidates: [collision, ...(objRes.candidates || [])] };
3220
+ }
3221
+ }
3222
+ const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = objRes;
3023
3223
  if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
3224
+ // BREADTH-FIRST ENTITY-TIE RESOLUTION (PLAN_BREADTH_FIRST_NLU.md §1): mirrors the
3225
+ // parsed.ambiguousParse branch above — every tied candidate is independently
3226
+ // traversed and rendered for real (via the pinnedObjMatch short-circuit just
3227
+ // above), never left as a bare name list. Capped at OVERFLOW_CAP BEFORE
3228
+ // traversing (not just before rendering) to avoid wasted work on candidates
3229
+ // that won't be shown. One accepted, narrow trade-off: a general reverse-case
3230
+ // query whose grain-refine retry below would previously have silently
3231
+ // recovered to a different class-correct match now short-circuits to branches
3232
+ // instead — not exercised by any pinned test/bench case today.
3233
+ if (ambiguous) {
3234
+ const pool = uniqueById([objMatch, ...(candidates || [])]).slice(0, OVERFLOW_CAP);
3235
+ const branches = pool.map((c) => {
3236
+ const branchResult = traverse(graph, parsed, { contextId, prev, pinnedObjMatch: c });
3237
+ return { candidate: c, result: branchResult, rendered: render(parsed, branchResult) };
3238
+ });
3239
+ return { matches: [], objMatch, candidates, traversal: null, ambiguous: true, branches };
3240
+ }
3024
3241
 
3025
3242
  // where: "where is X [defined]" (2026-07-02 query families) — the resolved
3026
3243
  // entity IS the answer; render() reads its class + site attribute ("path:
@@ -3109,11 +3326,52 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
3109
3326
  const fwdSibling = SYMBOL_GRAIN_SIBLING[kind];
3110
3327
  const subjIsFineSymbol = !!(fwdSibling && objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
3111
3328
  const fwdKinds = subjIsFineSymbol ? [...new Set([...kindsFor(kind), fwdSibling])] : kindsFor(kind);
3329
+ // FORWARD GRAIN CHECK (PLAN_CONVERSATION.md Finding 3): entityType flows in from
3330
+ // parseKeywordSpot for every forward query, but until now was consulted ONLY by
3331
+ // the commit-as-subject flip above — every other forward query scanned blind,
3332
+ // regardless of what class the kind's edges actually target. A kind whose real
3333
+ // observed target classes never include the asked entityType (nor its
3334
+ // FINE_CLASS_SIBLING family partner) can never honestly answer it — "what
3335
+ // modules does X have" via `defines`, whose real targets are only
3336
+ // {Class,Attribute,Method,Function}, never Module — so this is an honest decline,
3337
+ // not a blind filter that would silently produce a false empty. "Change" is the
3338
+ // touches-family wildcard pseudo-type (no individual is ever classed "Change"),
3339
+ // exempted the same way the reverse branch exempts it (see its own comment above).
3340
+ if (entityType && entityType !== "Change") {
3341
+ const wantClasses = classesForKinds(graph, fwdKinds);
3342
+ const siblingClass = FINE_CLASS_SIBLING[entityType];
3343
+ if (!wantClasses.has(entityType) && !(siblingClass && wantClasses.has(siblingClass))) {
3344
+ return {
3345
+ matches: [], objMatch, candidates, ambiguous, matchedVia,
3346
+ forwardGrainMiss: true, wantClasses: [...wantClasses],
3347
+ traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label} (grain mismatch: this "${kind}" relation never targets a ${entityType})`,
3348
+ };
3349
+ }
3350
+ }
3112
3351
  const edges = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
3113
3352
  const targets = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
3114
3353
  // dedupe only on the widened scan — the coarse-only path keeps its exact shape.
3115
- const matches = subjIsFineSymbol ? uniqueById(targets) : targets;
3116
- return { matches, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
3354
+ const deduped = subjIsFineSymbol ? uniqueById(targets) : targets;
3355
+ // PER-TRAVERSAL GRAIN FILTER (same Finding 3 fix): once grain passes above (or
3356
+ // entityType is null/"Change"), still keep only the matches of the ASKED class —
3357
+ // mirrors the reverse branch's own subjects.filter + sibling-widen-on-empty
3358
+ // fallback just below, so a forward answer never leaks a wrong-class match once
3359
+ // an entityType was actually asked for ("which functions does saveStore call"
3360
+ // must not render a Class as if it were a function).
3361
+ let matches = deduped;
3362
+ let filterNote = "";
3363
+ if (entityType && entityType !== "Change") {
3364
+ matches = deduped.filter((m) => m.class === entityType);
3365
+ const siblingClass = FINE_CLASS_SIBLING[entityType];
3366
+ if (!matches.length && siblingClass) {
3367
+ const widened = deduped.filter((m) => m.class === siblingClass);
3368
+ if (widened.length) {
3369
+ matches = widened;
3370
+ filterNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
3371
+ }
3372
+ }
3373
+ }
3374
+ return { matches, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}${filterNote}`, ambiguous, matchedVia };
3117
3375
  }
3118
3376
 
3119
3377
  // reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
@@ -3338,6 +3596,57 @@ function describeParse(p) {
3338
3596
  return `${ent}${p.kind} "${obj}"`;
3339
3597
  }
3340
3598
 
3599
+ /** PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive) — the canonical
3600
+ * restatement of what a query was understood to mean, in BOTH forms the
3601
+ * operator asked for: an English gloss in tmct's own preferred phrasing
3602
+ * (`english`) and a compact, machine-parsable notation of the same
3603
+ * structured fact (`machine`) — a simple `shape(kind, args...)` call form,
3604
+ * not raw JSON, so it stays human-readable at a glance too. Every clause is
3605
+ * a plain template read off `parsed`'s own already-compiled fields, never
3606
+ * generated — the same discipline as `describeParse`/`render()`. Returns
3607
+ * `null` when there's nothing to canonicalize (`parsed` itself is null).
3608
+ * Scoped to the flat query shapes `traverse()`/`render()` operate on; a
3609
+ * compositional AST (`parsed.node`) gets an honest, coarser fallback —
3610
+ * full per-node-type canonicalization is real future work, not silently
3611
+ * faked here. */
3612
+ function canonicalOf(parsed) {
3613
+ if (!parsed) return null;
3614
+ if (parsed.ambiguousParse) {
3615
+ return {
3616
+ english: `ambiguous: ${parsed.candidates.map(describeParse).join(" — or — ")}`,
3617
+ machine: `ambiguousParse(${parsed.candidates.map((c) => canonicalOf(c)?.machine).join(", ")})`,
3618
+ };
3619
+ }
3620
+ if (parsed.node) {
3621
+ // Compositional AST — coarse, honest fallback (see docblock above).
3622
+ return { english: `a compositional query (${parsed.node})`, machine: `composite(${parsed.node})` };
3623
+ }
3624
+ const q = (s) => JSON.stringify(String(s ?? ""));
3625
+ const args = [];
3626
+ if (parsed.kind) args.push(parsed.kind);
3627
+ if (parsed.entityType) args.push(`entityType=${parsed.entityType}`);
3628
+ if (parsed.modifier && parsed.modifier !== "direct") args.push(`modifier=${parsed.modifier}`);
3629
+ if (parsed.subject != null) args.push(`subject=${q(parsed.subject)}`);
3630
+ if (parsed.object != null) args.push(q(parsed.object));
3631
+ const machine = `${parsed.shape}(${args.join(", ")})`;
3632
+ let english;
3633
+ if (parsed.shape === "ask") {
3634
+ english = `does "${parsed.subject}" ${verbFor(parsed.kind)} "${parsed.object}"?`;
3635
+ } else if (parsed.shape === "meta") {
3636
+ english = `what does "${parsed.object}" mean, in this graph's own vocabulary?`;
3637
+ } else if (parsed.shape === "mentions") {
3638
+ english = `where is "${parsed.object}" mentioned?`;
3639
+ } else if (parsed.shape === "where") {
3640
+ english = `where is "${parsed.object}" defined?`;
3641
+ } else {
3642
+ const ent = parsed.entityType ? nounFor(parsed.entityType, 2) + " that " : "";
3643
+ english = parsed.shape === "forward"
3644
+ ? `what "${parsed.object}" itself ${verbFor(parsed.kind)}`
3645
+ : `${ent}${verbFor(parsed.kind)} "${parsed.object}"`;
3646
+ }
3647
+ return { english, machine };
3648
+ }
3649
+
3341
3650
  /** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
3342
3651
  * Every branch is a template, not generation — §5's grouping/pluralization/overflow rules.
3343
3652
  * A tier-5 fuzzy object resolution is ANNOUNCED, not silent: the answer is prefixed
@@ -3357,6 +3666,21 @@ function renderCore(parsed, result) {
3357
3666
  }
3358
3667
  if (parsed.node) return renderComposite(parsed, result);
3359
3668
  if (parsed.ambiguousParse) {
3669
+ // Each branch was actually resolved in traverse() above (never guessed at) —
3670
+ // show every reading's real, effective answer, not just its one-line label, so
3671
+ // the same input always reproduces the same full multi-reading answer (copy the
3672
+ // same prompt back in and get the identical result, not a coin flip).
3673
+ if (result.branches && result.branches.length) {
3674
+ const options = result.branches
3675
+ .map((b, i) => `${i + 1}) as ${describeParse(b.parsed)}: ${b.rendered.content}`)
3676
+ .join("\n");
3677
+ return {
3678
+ content: `this could mean more than one thing:\n${options}\n(ask one of these directly, or try rephrasing more specifically, to get just that reading)`,
3679
+ miss: false, ambiguous: true, candidates: parsed.candidates.map(describeParse),
3680
+ };
3681
+ }
3682
+ // Fallback (no `result.branches` — e.g. a caller invoking render() directly
3683
+ // with a hand-built result, bypassing traverse()): the old bare-label listing.
3360
3684
  const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
3361
3685
  return {
3362
3686
  content: `this could mean more than one thing: ${options} — try rephrasing more specifically.`,
@@ -3388,6 +3712,19 @@ function renderCore(parsed, result) {
3388
3712
  miss: true, ambiguous: false,
3389
3713
  };
3390
3714
  }
3715
+ // forward-shape honest miss (PLAN_CONVERSATION.md Finding 3): the resolved SUBJECT
3716
+ // is real, but the asked entityType can never appear among this kind's own real
3717
+ // target classes at all — distinct from wrongGrainMiss above (which is about the
3718
+ // resolved OBJECT term's class on the reverse side) and from the plain forward
3719
+ // zero-hit template below (which is a genuinely empty edge scan, not a class the
3720
+ // relation can never produce).
3721
+ if (result.forwardGrainMiss) {
3722
+ const wantNouns = result.wantClasses.map((c) => nounFor(c, 2));
3723
+ return {
3724
+ content: `${result.objMatch.label}'s "${verbFor(parsed.kind)}" relation in this index never produces ${nounFor(parsed.entityType, 2)} — only ${listJoin(wantNouns)}.`,
3725
+ miss: true, ambiguous: false,
3726
+ };
3727
+ }
3391
3728
  if (parsed.shape === "meta") {
3392
3729
  if (!result.objMatch) {
3393
3730
  return {
@@ -3443,7 +3780,7 @@ function renderCore(parsed, result) {
3443
3780
  const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
3444
3781
  : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
3445
3782
  return {
3446
- content: `no ${what} matching "${parsed.object}" found in the index.`,
3783
+ content: `no ${what} matching "${parsed.object}" found in the index. ${touchesRephraseHint()}`,
3447
3784
  miss: true, ambiguous: false, candidates: [],
3448
3785
  };
3449
3786
  }
@@ -3457,9 +3794,16 @@ function renderCore(parsed, result) {
3457
3794
  const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
3458
3795
  const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
3459
3796
  const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
3797
+ const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
3798
+ // BREADTH-FIRST (PLAN_BREADTH_FIRST_NLU.md §1): strictly additive to `lead` —
3799
+ // every currently-pinned assertion (test/chat-cefr-1.6.1-decision-log.test.mjs,
3800
+ // chatbench/graded-pool.jsonl's am-tests-cover) is a substring check against
3801
+ // `lead` alone, so appending each branch's real answer never breaks a pin.
3802
+ const content = (result.branches && result.branches.length)
3803
+ ? `${lead}\n${result.branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
3804
+ : lead;
3460
3805
  return {
3461
- content: `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those.`,
3462
- miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
3806
+ content, miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
3463
3807
  };
3464
3808
  }
3465
3809
  // where: the resolved entity's own location, cited off the site attribute.
@@ -3473,6 +3817,14 @@ function renderCore(parsed, result) {
3473
3817
  }
3474
3818
  const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
3475
3819
  if (m) {
3820
+ // "is defined in" stays UNVARIED here on purpose: chatbench/graded-pool-max.jsonl
3821
+ // pins this exact substring as ground truth for "where is X defined" cases
3822
+ // (g-a1-svo-7/12/18/22/40/43, g-a2-noise-svo-3/9/13/14/15 — 2 of which,
3823
+ // g-a1-svo-12 and g-a2-noise-svo-13, are in the promoted always-run subset),
3824
+ // and SKILL_BENCHMARK_CEFR_ENGLISH.md declares that pool append-only/never
3825
+ // edited mid-arc. The other two "defined in" call sites (metaFallbackEntityAnswer
3826
+ // and the composite exists-hit above) answer a DIFFERENT query shape ("what is a
3827
+ // X" / "is there a X"), so they carry the variety instead.
3476
3828
  const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
3477
3829
  return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
3478
3830
  }
@@ -3486,7 +3838,7 @@ function renderCore(parsed, result) {
3486
3838
  if (result.whenShape) {
3487
3839
  const subject = result.objMatch.label;
3488
3840
  if (!result.matches.length) {
3489
- return { content: `no recorded commit touches ${subject} in this index.`, miss: true, ambiguous: false };
3841
+ return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false };
3490
3842
  }
3491
3843
  const newest = result.matches[0];
3492
3844
  const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
@@ -3499,7 +3851,7 @@ function renderCore(parsed, result) {
3499
3851
  const msg = (newest.attributes || []).find((a) => a.key === "message")?.value || "";
3500
3852
  const day = String(date).slice(0, 10);
3501
3853
  if (newest.id === result.objMatch.id) {
3502
- return { content: `commit ${newest.label} is dated ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
3854
+ return { content: `commit ${newest.label} ${pickPhrase("is-dated", newest.id, "is dated")} ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
3503
3855
  }
3504
3856
  const more = result.matches.length - 1;
3505
3857
  return {
@@ -3515,7 +3867,7 @@ function renderCore(parsed, result) {
3515
3867
  if (result.whoLastShape) {
3516
3868
  const subject = result.objMatch.label;
3517
3869
  if (!result.matches.length) {
3518
- return { content: `no recorded commit touches ${subject} in this index.`, miss: true, ambiguous: false };
3870
+ return { content: `no recorded commit touches ${subject} in this index. ${touchesRephraseHint()}`, miss: true, ambiguous: false };
3519
3871
  }
3520
3872
  const newest = result.matches[0];
3521
3873
  const author = (newest.attributes || []).find((a) => a.key === "author")?.value;
@@ -3600,7 +3952,7 @@ function renderCore(parsed, result) {
3600
3952
  // append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
3601
3953
  const entityWord = nounFor(parsed.entityType || "Module", 2);
3602
3954
  return {
3603
- content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}.`,
3955
+ content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint()}`,
3604
3956
  miss: true, ambiguous: false,
3605
3957
  };
3606
3958
  }
@@ -4020,7 +4372,7 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4020
4372
  return {
4021
4373
  content: rephraseHint(),
4022
4374
  tmct_ask: {
4023
- mechanical: true, parsed: null, matches: [], traversal: null,
4375
+ mechanical: true, parsed: null, canonical: null, matches: [], traversal: null,
4024
4376
  miss: true, ambiguous: false, matchedVia: null, help: true, relaxed: null,
4025
4377
  },
4026
4378
  };
@@ -4030,7 +4382,8 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4030
4382
  // rest of the pipeline (direct parse, relaxation, resolveObject) never has to know
4031
4383
  // this phrase existed — see substituteLastCommitPhrase's own doc above.
4032
4384
  query = substituteLastCommitPhrase(graph, query);
4033
- const direct = parseQuery(query, { nlp });
4385
+ const directFull = parseQueryFull(query, { nlp });
4386
+ const direct = directFull.parsed;
4034
4387
  // The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
4035
4388
  // compositional {node:"miss"}, or an unresolved named term) — a clean hit, an
4036
4389
  // ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
@@ -4045,14 +4398,63 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4045
4398
  const rendered = render(parsed, result);
4046
4399
  // If relaxation materially rewrote the query and produced a real answer, note it
4047
4400
  // lightly (terse, honest) so the reader knows how the question was read.
4048
- const content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
4401
+ let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
4049
4402
  ? `read as "${relaxed.to}" — ${rendered.content}`
4050
4403
  : rendered.content;
4404
+ // ALTERNATES ON HITS (breadth-first, PLAN_BREADTH_FIRST_NLU.md §3): a genuine
4405
+ // distinct-class alternate reading from a different strategy (merge.mjs's
4406
+ // `alternates`) used to be silently discarded on every call, including a real
4407
+ // hit — surface it now, answered for real via the same traverse()+render()
4408
+ // idiom the ambiguousParse/entity-tie branches already use. Scoped tightly to
4409
+ // the ONE case this is unambiguously safe: the direct parse (untouched by
4410
+ // relaxation, itself not already an ambiguous/miss result) produced a genuine
4411
+ // answer. Relaxation rewrote the query, so `directFull`'s alternates no
4412
+ // longer describe the question actually answered — skipped rather than shown
4413
+ // stale.
4414
+ //
4415
+ // REAL-ANSWER-ONLY, never the bare "ask it that way" pointer (live-caught,
4416
+ // 2026-07-11): a lower-precedence strategy's "alternate" is often pure noise,
4417
+ // not a genuine second reading — e.g. keyword-spot misreading a stripped
4418
+ // filler phrase as the query's SUBJECT ("hey man which modules import X" ->
4419
+ // an "ask" parse with subject:"hey man"). That never resolves to a real graph
4420
+ // entity, so `alternateLines`'s default "if you mean X then ask it that way"
4421
+ // fallback would surface exactly the low-value dead-end nudge this whole plan
4422
+ // exists to eliminate — worse, it's non-deterministic across equivalent
4423
+ // phrasings (test/interpret.test.mjs's own noise-strip parity check caught
4424
+ // this: "hey man which modules import X" must answer byte-identically to the
4425
+ // clean phrasing, and a garbage alternate broke that). So: compute each
4426
+ // alternate's real answer directly (not via alternateLines' fallback-prone
4427
+ // default) and only ever append lines for alternates that resolved to
4428
+ // something real — an alternate that can't be answered is dropped silently,
4429
+ // never padded with an unhelpful pointer.
4430
+ if (!relaxed && !rendered.miss && !rendered.ambiguous && directFull.alternates.length) {
4431
+ const answered = directFull.alternates
4432
+ .map((a) => {
4433
+ const altResult = traverse(graph, a.parsed, { contextId, prev });
4434
+ const altRendered = render(a.parsed, altResult);
4435
+ return altRendered.miss ? null : { a, text: altRendered.content };
4436
+ })
4437
+ .filter(Boolean);
4438
+ if (answered.length) {
4439
+ const lines = alternateLines(answered.map((x) => x.a), {
4440
+ answerFor: (a) => answered.find((x) => x.a === a)?.text || null,
4441
+ });
4442
+ content = `${content}\n${lines.join("\n")}`;
4443
+ }
4444
+ }
4051
4445
  return {
4052
4446
  content,
4053
4447
  tmct_ask: {
4054
4448
  mechanical: true,
4055
4449
  parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
4450
+ // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
4451
+ // restatement of what the request was understood to mean, ALWAYS present
4452
+ // when anything parsed at all — not gated on ambiguity/miss the way the
4453
+ // ambiguity-branch labels are. `english` is the human-readable gloss in
4454
+ // tmct's own phrasing; `machine` is the same fact in a compact,
4455
+ // machine-parsable notation (a plain `shape(kind, args...)` call form).
4456
+ // Both are read straight off `parsed` — never generated.
4457
+ canonical: canonicalOf(parsed),
4056
4458
  matches: (result.matches || []).map((m) => ({
4057
4459
  id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
4058
4460
  })),