@polycode-projects/the-mechanical-code-talker 1.8.10 → 1.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -360,8 +360,8 @@ on the chat's hot path.
360
360
  ## What tmct deliberately is NOT
361
361
 
362
362
  - **It is not an indexer.** tmct keeps no codebase index of its own. It
363
- consumes a graph via a provider seam (`fetchEntities` and friends); producing
364
- a code graph is out of scope. tmct's job is the *conversation*.
363
+ consumes a graph via a provider seam (`fetchEntities` and friends) — building
364
+ that graph is a different tool's job. tmct's job is the *conversation*.
365
365
  - **It is not a reasoning model.** Where it "reasons", it does so by
366
366
  *calculation* surfaced as prose ("there are a lot of tests for a codebase of
367
367
  that size"). It is deterministic, explainable, and cheap. Even its forward-chaining
package/ROADMAP.md CHANGED
@@ -18,8 +18,9 @@ Declared, forward-looking goals — not yet achieved, stated here so they steer
18
18
  getting silently traded away by inherited caution:
19
19
 
20
20
  - **Reach for Llama-3-level natural language fluency.** Not by putting an LLM in the product path
21
- (still permanent, see "Explicitly out of scope") by growing rich template/surface-realization
22
- variety, so an answer shape has many valid phrasings instead of one fixed slot-fill.
21
+ (tmct will never do that — see "What tmct will never do"). Instead, by growing rich
22
+ template/surface-realization variety, so an answer shape has many valid phrasings instead of one
23
+ fixed slot-fill.
23
24
  - **Resolve ambiguity breadth-first, always.** Every genuinely valid reading gets its own real answer
24
25
  restated in full, never a bare "could mean X or Y — try rephrasing" punt, bounded only by existing
25
26
  clipping/pagination limits. Landed for both ambiguity shapes tmct has: parse-level ties
@@ -219,11 +220,13 @@ original question, citing what was just learned. Strictly opt-in, offline defaul
219
220
  Prerequisites: the provenance-trust policy must extend to `via:"learned:web"`, never silently
220
221
  blending web-sourced facts with graph/operator facts.
221
222
 
222
- ## Explicitly out of scope
223
+ ## What tmct will never do
223
224
 
224
- - No AWS, no benchmark rig — a published npm library + CLI with a static GitLab Pages home page only.
225
- - No auto-publish a version release is gated on a deliberate version-bump commit.
226
- - No MCP server, no LLM in the product path permanent, not "for now."
225
+ - Run on AWS or maintain a benchmark rig — tmct is a published npm library + CLI with a static
226
+ GitLab Pages home page, nothing more.
227
+ - Publish automatically a version release always requires a deliberate version-bump commit.
228
+ - Run an MCP server or put an LLM in the product path — a core identity decision, decided once,
229
+ not revisited.
227
230
 
228
231
  ## Design docs
229
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.8.10",
3
+ "version": "1.8.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
package/src/ask.mjs CHANGED
@@ -1571,9 +1571,23 @@ function membershipOwnSet(graph, id, entityType) {
1571
1571
  * path prefix with no exact node of its own — see directoryScopeModules's own
1572
1572
  * doc) or a single container individual, exactly like the "membership" case's own
1573
1573
  * pre-item-6 resolution — extracted unchanged so evalSet's plain path and the
1574
- * composite/disclosure path (evalMembershipComposite) can never drift. */
1575
- function resolveMembershipOwner(graph, term) {
1576
- const r = resolveObject(graph, term);
1574
+ * composite/disclosure path (evalMembershipComposite) can never drift.
1575
+ *
1576
+ * `contextId` (HANDOVER.md 2026-07-12 finding, fast-loop round 4): this used to
1577
+ * call bare `resolveObject(graph, term)` with no context-pronoun notion at all —
1578
+ * "methods of that"/"attributes of it" reached resolveObjectCore's ordinary
1579
+ * mechanical tiers with the raw pronoun string, an honest miss at best and, at
1580
+ * worst, a false-positive substring hit (a 2-4 letter pronoun is a near-certain
1581
+ * accidental substring of SOME real label — the exact
1582
+ * STACCATO_LEAKED_CONNECTIVES trap chat.mjs documents for "it"/"and"). Routed
1583
+ * through resolveTermOrContext instead — the SAME contextId-aware resolution
1584
+ * evalQualCheck and traverse()'s reverse/forward shapes already use for
1585
+ * subject-/object-position pronouns — so a pronoun binds to the standing focus
1586
+ * and a non-pronoun term resolves byte-identically to before (resolveTermOrContext
1587
+ * falls through to the same bare `resolveObject` call for anything that isn't a
1588
+ * CONTEXT_PRONOUNS member). */
1589
+ function resolveMembershipOwner(graph, term, contextId = null) {
1590
+ const r = resolveTermOrContext(graph, term, contextId);
1577
1591
  if (!(r.match && r.tier === 1)) {
1578
1592
  const dirMods = directoryScopeModules(graph, term);
1579
1593
  if (dirMods.length) return { kind: "dir", mods: dirMods };
@@ -1854,7 +1868,7 @@ function evalSet(graph, ast, opts) {
1854
1868
  // node match (tier 1 — a real file/symbol named that) still wins outright
1855
1869
  // (unchanged single-container-node behavior, e.g. "methods in widget.mjs");
1856
1870
  // only when there is no exact match do we try directory-prefix scope first.
1857
- const owner = resolveMembershipOwner(graph, ast.term);
1871
+ const owner = resolveMembershipOwner(graph, ast.term, opts && opts.contextId);
1858
1872
  if (owner.kind === "dir") {
1859
1873
  if (!ast.entityType || ast.entityType === "Module") return owner.mods;
1860
1874
  const ids = new Set(owner.mods.map((m) => m.id));
@@ -1977,8 +1991,14 @@ function evalAnaphora(graph, ast, opts) {
1977
1991
  // commit-history kinds are excluded so "connections" reads as the code-structure
1978
1992
  // degree a developer means, not every recorded touch.
1979
1993
  const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
1980
- /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}). */
1981
- function degreeMetric(graph, ind, metric) {
1994
+ /** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}).
1995
+ * Exported (2026-07-12, HANDOVER "bare 'how many X' fails for edge-nominalized
1996
+ * nouns" fix) so chat.mjs's answerEdgeCount can compute the SAME per-entity
1997
+ * degree for a single named entity ("how many callers does X have") that this
1998
+ * file's own evalSuperlative already uses to rank every entity of a class
1999
+ * ("which module has the most callers") — one metric definition
2000
+ * (EDGE_NOUN_TO_METRIC), one degree computation, two call sites. */
2001
+ export function degreeMetric(graph, ind, metric) {
1982
2002
  const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
1983
2003
  let n = 0;
1984
2004
  for (const k of kinds) for (const e of edgesOfKind(graph, k)) {
@@ -2088,7 +2108,7 @@ function evalMembershipComposite(graph, ast, opts) {
2088
2108
  const filterFn = qualNode
2089
2109
  ? (ind) => qualNode.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]))
2090
2110
  : null;
2091
- const owner = resolveMembershipOwner(graph, memNode.term);
2111
+ const owner = resolveMembershipOwner(graph, memNode.term, opts && opts.contextId);
2092
2112
  if (owner.kind === "dir") {
2093
2113
  let objs;
2094
2114
  if (!entityType || entityType === "Module") objs = owner.mods;
@@ -3426,7 +3446,24 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3426
3446
  widenNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
3427
3447
  }
3428
3448
  }
3429
- return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
3449
+ // "touches" up-refine composability (HANDOVER 2026-07-12, Class-to-module
3450
+ // up-refinement): an EMPTY touchesSymbol lookup on a resolved CLASS is not
3451
+ // decisive the way it is for a Function/Method — a Class reads naturally as
3452
+ // "the file/unit that holds it", so "who touched <Class>" with no recorded
3453
+ // symbol-precise touch should still answer from the class's containing
3454
+ // module's real touches, rather than a confident-looking-but-possibly-wrong
3455
+ // "nothing touched it". Fall through to the grain-aware up-refine below
3456
+ // ONLY for that Class case. Deliberately NOT widened to every
3457
+ // FINE_ENTITY_TYPES member: "how many commits touched fnAlpha" (a Function)
3458
+ // is pinned elsewhere (ask-combo.test.mjs's grain-aware COUNT lever) to stay
3459
+ // an honest 0 rather than a module-grain false hit — symbol-level counting
3460
+ // precision for functions/methods is a deliberate, separate guarantee this
3461
+ // change must not erode. `calls` is untouched either way: an empty
3462
+ // callsSymbol result stays decisive (call parsing isn't a best-effort
3463
+ // heuristic the way commit-diff symbol attribution is).
3464
+ if (matches.length || !(kind === "touches" && objMatch.class === "Class")) {
3465
+ return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
3466
+ }
3430
3467
  }
3431
3468
 
3432
3469
  // §grain-aware object resolution (Bug C+D, HANDOVER follow-up #2, checked BEFORE
@@ -3457,13 +3494,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3457
3494
  gCandidates = retry.candidates;
3458
3495
  gAmbiguous = retry.ambiguous;
3459
3496
  gMatchedVia = retry.matchedVia;
3460
- } else if ((kind === "tests" || kind === "cochange") && gObjMatch.class !== "Module") {
3461
- // (2) tests/cochange are always Module->Moduleno same-grain alternative
3462
- // exists (the retry above genuinely found nothing), but the resolved
3463
- // fine-grain entity (a Function, say) DOES live in a module, and that
3464
- // module is the real, honest subject of a tests/cochange question ("does
3465
- // createTask have tests" fixes Bug D). Up-refine via the same moduleIdOf
3466
- // qualHolds's "tested" case already uses (see its divergence comment above).
3497
+ } else if (wantClass === "Module") {
3498
+ // (2) up-refine to the containing module driven by kindObjectClass
3499
+ // itself (any kind whose real object-class is ALWAYS Module: tests,
3500
+ // cochange, imports, touches, ), not a hardcoded kind name list, so a
3501
+ // kind newly recorded as Module->Module in the graph gets this for free.
3502
+ // No same-grain alternative exists here (the retry above genuinely found
3503
+ // nothing), but the resolved fine-grain entity (a Function/Class, say)
3504
+ // DOES live in a module, and that module is the real, honest subject of
3505
+ // the question ("does createTask have tests" — Bug D; "who touched Bar",
3506
+ // "what modules import Bar" — the same up-refine extended past
3507
+ // tests/cochange, HANDOVER 2026-07-12). `calls` computes to Module here
3508
+ // too, but never actually reaches this branch with a wrong-grain object:
3509
+ // the symbolKind branch above already intercepts every Class/Function/…
3510
+ // object for `calls` unconditionally (its empty-result IS decisive, see
3511
+ // that branch's own comment), so this is inert-but-correct for it. Up-
3512
+ // refine via the same moduleIdOf qualHolds's "tested" case already uses
3513
+ // (see its divergence comment above).
3467
3514
  const mid = moduleIdOf(graph, gObjMatch);
3468
3515
  const mod = mid && graph.byId.get(mid);
3469
3516
  if (mod) {
package/src/chat.mjs CHANGED
@@ -57,7 +57,7 @@ import { rankByBiasThenTrust } from "./memory/bias.mjs";
57
57
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
58
58
  import {
59
59
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
60
- stripTrailingScopeFiller, stripTrailingDiscourseTag,
60
+ stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS,
61
61
  } from "./ask-vocab.mjs";
62
62
  import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
63
63
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
@@ -564,6 +564,113 @@ export function answerCount(graph, query) {
564
564
  return `${n} ${classNoun(cls, n)}.`;
565
565
  }
566
566
 
567
+ /** singular+plural display forms for the edge-nominalized nouns answerEdgeCount
568
+ * (below) actually answers — a small subset of EDGE_NOUN_TO_METRIC's keys, the
569
+ * ones that read as a real countable noun ("3 callers.") rather than a
570
+ * participle only natural in a superlative ("most USED", not "how many used").
571
+ * A key with no entry here echoes the user's own word unchanged (safe default,
572
+ * same fallback CLASS_LABELS/classNoun use above). */
573
+ const EDGE_NOUN_LABELS = {
574
+ test: ["test", "tests"], tests: ["test", "tests"],
575
+ importers: ["importer", "importers"], dependents: ["dependent", "dependents"],
576
+ callers: ["caller", "callers"], callees: ["callee", "callees"],
577
+ calls: ["call", "calls"], imports: ["import", "imports"],
578
+ dependencies: ["dependency", "dependencies"], members: ["member", "members"],
579
+ subclasses: ["subclass", "subclasses"], connections: ["connection", "connections"],
580
+ edges: ["edge", "edges"],
581
+ };
582
+ const edgeCountNoun = (noun, n) => {
583
+ const [s, p] = EDGE_NOUN_LABELS[noun] || [noun, noun];
584
+ return n === 1 ? s : p;
585
+ };
586
+
587
+ /** Pull the named entity out of a per-entity edge-count tail — the text after
588
+ * "how many <edge-noun>" — recognising exactly the two closed shapes such a
589
+ * tail actually takes:
590
+ * - "<verb> <entity>" ("cover src/x.mjs", "import Widget") — verb drawn
591
+ * from ask-vocab.mjs's RELATIONS[metric.kind].verbs (the SAME verb list
592
+ * the relation clause grammar itself reads), longest-first so a
593
+ * multi-word verb ("depends on") matches whole rather than a short
594
+ * prefix stealing part of the entity name.
595
+ * - "does/do/did <entity> have/has/had/got" ("does X have") — safe to
596
+ * treat as unambiguous HERE even though answerCount's own
597
+ * AMBIGUOUS_HAVE_VERBS guard deliberately excludes "have" elsewhere:
598
+ * that guard exists because "have" maps to either defines/contains
599
+ * depending on the SUBJECT's class, but an edge-nominalized noun's
600
+ * metric.dir is fixed by the NOUN itself ("importers" is always dir
601
+ * "in"), so there is no analogous ambiguity to worry about here.
602
+ * Returns the trimmed entity term, or null (no recognizable shape — an
603
+ * honest decline, not a guess) — the caller then leaves the existing "I
604
+ * can't count" message from answerCount standing. */
605
+ function extractEdgeCountEntity(tail, metric) {
606
+ const t = String(tail || "").trim().replace(/[?.!]+$/, "").trim();
607
+ if (!t) return null;
608
+ const haveM = t.match(/^(?:does|do|did)\s+(.+?)\s+(?:have|has|had|got)$/i);
609
+ if (haveM && haveM[1].trim()) return haveM[1].trim();
610
+ if (metric.kind !== "*" && RELATIONS[metric.kind]) {
611
+ const verbs = [...RELATIONS[metric.kind].verbs].sort((a, b) => b.length - a.length);
612
+ for (const v of verbs) {
613
+ const re = new RegExp(`^${v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+(.+)$`, "i");
614
+ const m = t.match(re);
615
+ if (m && m[1].trim()) return m[1].trim();
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+
621
+ /** Bare "how many <edge-noun> <verb> <entity>" / "how many <edge-noun> does
622
+ * <entity> have" (HANDOVER "bare 'how many X' fails for edge-nominalized
623
+ * nouns", 2026-07-12 fix) — "how many tests cover X", "how many importers
624
+ * does X have", "how many callers does X have". answerCount's own COUNT_NOUNS
625
+ * table only maps a counted noun to a graph INDIVIDUAL CLASS (Module/Class/…);
626
+ * an edge-nominalized noun like "tests"/"importers"/"callers" names an EDGE
627
+ * KIND instead — ask-vocab.mjs's EDGE_NOUN_TO_METRIC, the SAME table the
628
+ * working superlative lane ("which module has the most tests") already reads
629
+ * — so it was never in COUNT_NOUNS and answerCount short-circuited straight
630
+ * to "I can't count 'tests'" without ever consulting that table. This reuses
631
+ * the SAME per-entity degree computation the superlative lane's own
632
+ * evalSuperlative uses to rank every entity of a class (ask.mjs's
633
+ * degreeMetric, exported for exactly this) — just read for the ONE named
634
+ * entity instead of sorting all of them.
635
+ *
636
+ * Checked BEFORE answerCount in runTurn (same precedence pattern as
637
+ * answerMemoryCount/answerQuantifierRecall above) so it gets first look;
638
+ * declines (returns null, letting answerCount's existing message stand)
639
+ * whenever:
640
+ * - the noun isn't edge-nominalized at all (a COUNT_NOUNS class, or truly
641
+ * unknown), or
642
+ * - no entity term could be extracted from the tail
643
+ * (extractEdgeCountEntity), or
644
+ * - the extracted term doesn't resolve to exactly one graph entity.
645
+ *
646
+ * SCOPED to the per-entity case only: a bare "how many tests are there" (no
647
+ * named entity in the tail) has nothing for extractEdgeCountEntity to pull
648
+ * out, so it declines here and keeps answerCount's existing "I can't count
649
+ * 'tests'" honest miss — what a GLOBAL edge count would even mean (every
650
+ * test edge in the graph? distinct test modules? distinct tested modules?)
651
+ * is a genuine, undecided design question, out of this fix's scope. */
652
+ async function answerEdgeCount(graph, query) {
653
+ if (!graph) return null;
654
+ const q = String(query);
655
+ if (ANAPHORA_COUNT_RE.test(q) || IMPLICIT_ANAPHORA_COUNT_RE.test(q.trim())) return null;
656
+ const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
657
+ if (!m) return null;
658
+ const noun = m[1].toLowerCase();
659
+ if (COUNT_NOUNS[noun]) return null; // a real graph class — answerCount owns it
660
+ const metric = EDGE_NOUN_TO_METRIC[noun];
661
+ if (!metric) return null; // not edge-nominalized either — answerCount's "I can't count" stands
662
+ const term = extractEdgeCountEntity(q.slice(m.index + m[0].length), metric);
663
+ if (!term) return null; // no per-entity phrasing recognized — scoped out (see docblock)
664
+ const entity = await resolveEntity(graph, term);
665
+ if (!entity) return null; // unresolved/ambiguous entity — honest decline, not a guess
666
+ const ind = graph.byId?.get?.(entity.id);
667
+ if (!ind) return null;
668
+ let degreeMetric;
669
+ try { ({ degreeMetric } = await import("./ask.mjs")); } catch { return null; }
670
+ const n = degreeMetric(graph, ind, metric);
671
+ return `${n} ${edgeCountNoun(noun, n)}.`;
672
+ }
673
+
567
674
  /** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
568
675
  * is remembered, "how many types are there" counts as many types as there are
569
676
  * classes — the asserted object noun inherits the subject class's cardinality.
@@ -869,6 +976,29 @@ export function isConversational(query) {
869
976
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
870
977
  }
871
978
 
979
+ /** Scoped exemption for the bare-meta-fact lane (2b/2c, further down this file)
980
+ * ONLY — never a change to looksCodeish()/isConversational() themselves, and
981
+ * never used for the generic orientation-card fallback. HANDOVER.md 2026-07-12
982
+ * finding: a bare "what is TaskController?" (CamelCase COMPOUND class name, no
983
+ * article) hits looksCodeish()'s `/[a-z][A-Z]/` branch, so isConversational()
984
+ * returns false and the whole isConversationalCandidate gate — including the
985
+ * bare-meta-fact lookup that "what is a TaskController" (articled, via its own
986
+ * T5 structural parse) already resolves through — never runs. Single-word/
987
+ * lowercased names ("Widget", "taskcontroller") have no lowercase-to-uppercase
988
+ * transition so they were never affected; only two-word-shaped compounds were.
989
+ * This re-tests the SAME non-CamelCase codeish reasons (paths, dotted refs,
990
+ * `()` calls, STRUCT_WORDS) looksCodeish already covers, so a genuine near-miss
991
+ * structural question ("what is foo.bar()", "what is import") is UNCHANGED —
992
+ * still excluded here, still falls through to its existing (better) miss
993
+ * handling, never the friendly orientation card. */
994
+ function isBareCamelCaseMetaQuestion(query) {
995
+ const raw = String(query).trim();
996
+ const q = raw.toLowerCase().replace(/[.!?]+$/, "").trim();
997
+ const nonCamelCodeish = /[_./]|\(\)/.test(raw) || q.split(/\s+/).some((w) => STRUCT_WORDS.has(w));
998
+ if (nonCamelCodeish || q.split(/\s+/).filter(Boolean).length > 3) return false;
999
+ return BARE_WHATIS_RE.test(raw) || IS_ADJECTIVE_YESNO_RE.test(raw);
1000
+ }
1001
+
872
1002
  // ---- the response-template library (W1: templates → render path) ----
873
1003
  // The WORDING of the conversational/orientation surfaces lives in
874
1004
  // data/templates/responses.jsonl (corpus/templates.mjs) — the template library is
@@ -3076,7 +3206,17 @@ async function memorySummary(memoryDir, graph) {
3076
3206
  // both already exist. CASE-PRESERVING: module paths/symbol names are
3077
3207
  // case-sensitive, so this reads the ORIGINAL query text, never metaLane's
3078
3208
  // lowercased `q` (authorLane's same discipline, just above/below).
3079
- const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
3209
+ /** A trailing intensifier/filler adverb tacked onto "do"/"does" ("what does the
3210
+ * store module do exactly?", "...do exactly", "what X does really") — fast
3211
+ * loop round 8 (ESL/filler-phrasing angle): the closed-form anchor below used
3212
+ * to require "do"/"does" to be the LAST word before the optional "?", so this
3213
+ * one extra word past it hit the raw grammar wall even though the shared
3214
+ * FILLER_WORDS/normalizeQuery pass (used elsewhere in the file) never sees
3215
+ * this lane's case-preserving text at all. Mirrors MODULE_ORIENT_POLITENESS_RE
3216
+ * just below: closed, optional, single-lane blast radius — a bare "what does
3217
+ * X do" still matches with this suffix empty. */
3218
+ const TRAILING_ADVERB_RE = "(?:\\s+(?:exactly|really|actually|anyway))?";
3219
+ const MODULE_ORIENT_RE = new RegExp(`^what\\s+does\\s+(.+?)\\s+do${TRAILING_ADVERB_RE}\\??$`, "i");
3080
3220
  /** The SUBJECT-FIRST word order of the SAME question ("what saveStore does" vs
3081
3221
  * "what does saveStore do") — Tier 6 playtest, §3b surface-variation axis: a
3082
3222
  * perfectly natural alternate phrasing of an ALREADY-recognized intent that
@@ -3086,7 +3226,7 @@ const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
3086
3226
  * UNIQUE graph entity or this lane declines) is what keeps this loose an
3087
3227
  * ending safe — a syntactic match against a term that isn't a real entity
3088
3228
  * simply falls through unchanged, same as every other lane in this file. */
3089
- const MODULE_ORIENT_SVO_RE = /^what\s+(.+?)\s+does\??$/i;
3229
+ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB_RE}\\??$`, "i");
3090
3230
  // Seonix Batch 3 (3a) — purpose/identity phrasing: "whats X for"/"what's X
3091
3231
  // about"/"what is X for", the sibling of "what does X do" that asks for the
3092
3232
  // SAME module-grain overview. Deliberately does NOT claim the literal noun
@@ -7209,7 +7349,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7209
7349
  } catch { /* leave false — the ordinary path decides */ }
7210
7350
  }
7211
7351
  }
7212
- const isConversationalCandidate = !handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
7352
+ const conversationalCandidateBaseGate = !handled && miss && !envelope?.parsed && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus;
7353
+ const isConversationalCandidate = conversationalCandidateBaseGate && isConversational(query);
7213
7354
  // BUG 2 fix (2026-07-09): "what is X" with NO article ("what is john") is BOTH
7214
7355
  // conversational-shaped (≤3 words, no code-ish token — isConversational() would
7215
7356
  // claim it) AND a legitimate bare meta/fact-lookup form (BARE_WHATIS_RE —
@@ -7240,8 +7381,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7240
7381
  // chance to run before the orientation card claims the turn.
7241
7382
  const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
7242
7383
  const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
7384
+ // HANDOVER.md 2026-07-12 CamelCase finding: `isBareCamelCaseMetaQuestion` (see
7385
+ // its own docblock, above isConversational) OR's in alongside
7386
+ // isConversationalCandidate for THIS lane only — a bare "what is TaskController"
7387
+ // (CamelCase compound, no article) is otherwise excluded solely because
7388
+ // isConversational()'s codeish check fires on the CamelCase transition, even
7389
+ // though every other precondition (miss, no structural parse, no continuation
7390
+ // in flight) already holds. Shares the SAME base gate as isConversationalCandidate
7391
+ // (conversationalCandidateBaseGate) so it's never looser. Scoped to this `if`
7392
+ // alone: the `else if (isConversationalCandidate)` orientation-card fallback
7393
+ // further down is UNCHANGED, so a CamelCase term with no real hit still falls
7394
+ // through to its existing miss handling, never the generic orientation card.
7395
+ const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(query);
7243
7396
  let bareMetaHit = null;
7244
- if (isConversationalCandidate && (bareWhatisShape || isAdjectiveShape)) {
7397
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape)) {
7245
7398
  if (memoryDir) {
7246
7399
  bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
7247
7400
  ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
@@ -8282,6 +8435,19 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8282
8435
  return withLast(plainTurn(workingLine, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
8283
8436
  }
8284
8437
  }
8438
+ // Edge-nominalized "how many X" counts ("how many tests cover Y", "how many
8439
+ // callers does Y have") — checked BEFORE answerCount (same precedence
8440
+ // pattern as answerQuantifierRecall/answerMemoryCount above): answerCount's
8441
+ // own COUNT_NOUNS table doesn't know these nouns at all and would otherwise
8442
+ // short-circuit straight to "I can't count 'tests'" before this lane ever
8443
+ // got a turn. Declines (null) for anything answerCount should own, or a bare
8444
+ // global count with no named entity — see answerEdgeCount's own docblock.
8445
+ const edgeCount = await answerEdgeCount(graph, workingLine);
8446
+ if (edgeCount != null) {
8447
+ note(trace, 'goal: get a per-entity count of an edge-nominalized kind ("how many tests cover X", "how many callers does X have")');
8448
+ note(trace, "lane: answerEdgeCount — matched an EDGE_NOUN_TO_METRIC noun with a resolvable named entity, answered via the same degreeMetric the superlative lane uses");
8449
+ return withLast(plainTurn(workingLine, edgeCount, { via: "count", focus }), "get a per-entity edge count");
8450
+ }
8285
8451
  // Aggregate/count questions are answered mechanically off the loaded graph header,
8286
8452
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
8287
8453
  const count = answerCount(graph, workingLine);