@polycode-projects/the-mechanical-code-talker 1.0.2 → 1.0.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 (3) hide show
  1. package/package.json +3 -2
  2. package/src/ask.mjs +148 -4
  3. package/src/chat.mjs +202 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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.",
@@ -89,7 +89,8 @@
89
89
  "agentbench:run": "node agentbench/run.mjs",
90
90
  "infbench": "node infbench/generate-cases.mjs && node infbench/run.mjs",
91
91
  "audit": "npm audit --audit-level=high",
92
- "audit:fix": "npm audit fix"
92
+ "audit:fix": "npm audit fix",
93
+ "demo:build": "node scripts/build-demo-site.mjs"
93
94
  },
94
95
  "devDependencies": {
95
96
  "ink-testing-library": "^4.0.0"
package/src/ask.mjs CHANGED
@@ -311,7 +311,8 @@ function parseSimpleClause(text, nlp) {
311
311
  function parseComposite(text, nlp) {
312
312
  const w = splitWords(text);
313
313
  const lc = w.map((x) => x.toLowerCase());
314
- return parseNegation(text, nlp, 0)
314
+ return parseExistence(w, lc)
315
+ || parseNegation(text, nlp, 0)
315
316
  || parseForwardNegation(w, lc, nlp)
316
317
  || parseTemporal(w, lc, nlp, 0)
317
318
  || parseAnaphora(w, lc, nlp)
@@ -580,6 +581,72 @@ function parsePredicateFilter(words, nlp) {
580
581
  return undefined;
581
582
  }
582
583
 
584
+ /** EXISTENCE: "is there a/an <kind> [called|named <term>] [in <module>] [anywhere]"
585
+ * and "are there any <kind>(s) [called|named <term>] [in <module>]" — a genuine
586
+ * existence question ("does this kind/name exist at all", optionally scoped to a
587
+ * module), answered directly against class/kind membership rather than routed
588
+ * through the relation-verb machinery. Triage bug (2026-07-09, seonix dogfooding):
589
+ * with no dedicated recognizer, "is there a class called Store anywhere" fell
590
+ * through to the legacy keyword-spot strategy, whose lemma tier canonicalizes
591
+ * "called" -> "call" (a `calls` verb — ask-vocab.mjs) and silently answered a
592
+ * DIFFERENT question ("which classes call Store") with a confidently-wrong-shaped
593
+ * negative, even though a class named Store genuinely exists. "is there a class in
594
+ * <module>" walled out the same way — no marker in this grammar recognized it at
595
+ * all. Scoped to a tight closed shape: a leading "is there a/an" or "are there any"
596
+ * immediately followed by a recognized entity-kind noun, then ONLY "called"/"named
597
+ * <term>", "in <module>", the two combined, or an empty/"anywhere"/"at all" tail —
598
+ * anything else (a relative clause, a verb phrase: "is there a class THAT CALLS
599
+ * Store") is a genuine relationship question and is left untouched for the
600
+ * relation parsers below, never swallowed here. */
601
+ function parseExistence(w, lc) {
602
+ let i;
603
+ if (lc[0] === "is" && lc[1] === "there") i = 2;
604
+ else if (lc[0] === "are" && lc[1] === "there") i = 2;
605
+ else return null;
606
+ const article = lc[i];
607
+ if (article === "a" || article === "an" || article === "any") i += 1;
608
+ else return null;
609
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
610
+ if (!noun || noun.placeholder || !noun.entityType) return null;
611
+ const entityType = noun.entityType;
612
+ i += 1;
613
+
614
+ let rest = lc.slice(i);
615
+ let restW = w.slice(i);
616
+ // trailing filler — "anywhere" / "at all" — stripped so it never gets misread as
617
+ // a (nonexistent) module/name term below.
618
+ if (rest.length && rest[rest.length - 1] === "anywhere") {
619
+ rest = rest.slice(0, -1); restW = restW.slice(0, -1);
620
+ } else if (rest.length >= 2 && rest[rest.length - 2] === "at" && rest[rest.length - 1] === "all") {
621
+ rest = rest.slice(0, -2); restW = restW.slice(0, -2);
622
+ }
623
+
624
+ if (!rest.length) return { node: "exists", entityType, term: null, scopeModule: null };
625
+
626
+ if (rest[0] === "called" || rest[0] === "named") {
627
+ if (rest.length < 2) return { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
628
+ const inIdx = rest.indexOf("in", 1);
629
+ if (inIdx > 0) {
630
+ const term = restW.slice(1, inIdx).join(" ").trim();
631
+ const scopeModule = restW.slice(inIdx + 1).join(" ").trim();
632
+ if (!term || !scopeModule) return { node: "miss", reason: `a named existence check needs both a name and a module after "in"` };
633
+ return { node: "exists", entityType, term, scopeModule };
634
+ }
635
+ const term = restW.slice(1).join(" ").trim();
636
+ return term ? { node: "exists", entityType, term, scopeModule: null }
637
+ : { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
638
+ }
639
+
640
+ if (rest[0] === "in") {
641
+ const scopeModule = restW.slice(1).join(" ").trim();
642
+ return scopeModule ? { node: "exists", entityType, term: null, scopeModule }
643
+ : { node: "miss", reason: `"in" needs a module afterward` };
644
+ }
645
+
646
+ return null; // a relative clause / verb phrase / anything else — genuinely a
647
+ // different (relationship) question; leave it for the parsers below.
648
+ }
649
+
583
650
  /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
584
651
  * ("how many classes are there", "list functions in total", "which classes exist in
585
652
  * the index") — a count/list over a bare kind is frequently phrased with such a tail,
@@ -912,8 +979,19 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
912
979
  // auxiliary in that position ("what DID commit X touch") is left for the existing
913
980
  // parser, not mistaken for a term.
914
981
  const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
982
+ // CASCADE_NOISE_SET excluded alongside STOPWORDS (Tier-2 playtest, cycle 8):
983
+ // "what about classes"/"how about the modules" used to reach here with
984
+ // "about" sitting right where a real qualifying adjective would ("payment"
985
+ // in "list payment modules") — framed (past "what") and immediately before
986
+ // a known noun ("classes") — and get misread as a fuzzy find TERM ("no
987
+ // classes found matching 'about'") instead of the topic-lead-in filler it
988
+ // is. CASCADE_NOISE already curates "about" for exactly this reading (see
989
+ // its own docblock, ask-vocab.mjs) — checking it here too lets a real
990
+ // unknown qualifier ("shiny"/"payment") still reach the find fallback while
991
+ // a known no-graph-meaning filler word falls through to the ordinary bare-
992
+ // kind-noun path instead (the cascade's own noise-strip terminal rule).
915
993
  if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i])
916
- && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i])) {
994
+ && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && !CASCADE_NOISE_SET.has(lc[i])) {
917
995
  return { node: "find", entityType: nextNoun.entityType, term: w[i] };
918
996
  }
919
997
  return null; // no subject entity → not this shape
@@ -1468,10 +1546,42 @@ function evalSuperlative(graph, ast) {
1468
1546
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1469
1547
  }
1470
1548
 
1549
+ /** EXISTENCE eval — "is there a/an <kind> [called/named <term>] [in <module>]": a
1550
+ * direct membership/name check against the graph, never routed through the
1551
+ * relation-verb machinery. A named check resolves the term against the SAME
1552
+ * tiered resolveObject() every other named-lookup shape uses (expectedClass pins
1553
+ * the pool to the asked kind, so "is there a class called Store" can never
1554
+ * resolve to a same-named function/module); a scope clause resolves the module
1555
+ * the same way and narrows the check to that module's own `defines` edges
1556
+ * (refineToEntities — the same primitive members-of-a-module questions use). */
1557
+ function evalExists(graph, ast) {
1558
+ const { entityType, term, scopeModule } = ast;
1559
+ let scopeMatch = null;
1560
+ if (scopeModule) {
1561
+ const r = resolveObject(graph, scopeModule, { expectedClass: "Module" });
1562
+ if (!r.match) return { compositeKind: "exists", entityType, term, scopeModule, scopeMiss: true, matches: [] };
1563
+ scopeMatch = r.match;
1564
+ }
1565
+ if (term) {
1566
+ const r = resolveObject(graph, term, { expectedClass: entityType });
1567
+ const inScope = !scopeMatch || (r.match && moduleIdOf(graph, r.match) === scopeMatch.id);
1568
+ const hit = r.match && inScope;
1569
+ return {
1570
+ compositeKind: "exists", entityType, term, scopeModule, scopeMatch,
1571
+ matches: hit ? [r.match] : [],
1572
+ };
1573
+ }
1574
+ const pool = scopeMatch
1575
+ ? refineToEntities(graph, new Set([scopeMatch.id]), entityType)
1576
+ : graph.individuals.filter((i) => i.class === entityType);
1577
+ return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1578
+ }
1579
+
1471
1580
  /** Compile any compositional AST to a result object traverse() returns for the
1472
1581
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1473
1582
  export function evalComposite(graph, ast, opts = {}) {
1474
1583
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1584
+ if (ast.node === "exists") return evalExists(graph, ast);
1475
1585
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1476
1586
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1477
1587
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1521,6 +1631,30 @@ function renderComposite(parsed, result) {
1521
1631
  }
1522
1632
  return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
1523
1633
  }
1634
+ // exists: "is there a/an <kind> [called/named <term>] [in <module>]" — an
1635
+ // honest Yes/No membership check, never routed through the relation-verb
1636
+ // machinery (see parseExistence's own doc for the bug this fixes).
1637
+ if (result.compositeKind === "exists") {
1638
+ if (result.scopeMiss) {
1639
+ return { content: `no module matching "${result.scopeModule}" found in the index.`, miss: true, ambiguous: false };
1640
+ }
1641
+ const kindSingular = nounFor(result.entityType, 1);
1642
+ const kindPlural = nounFor(result.entityType, 2);
1643
+ const scopeSuffix = result.scopeMatch ? ` in ${result.scopeMatch.label}` : "";
1644
+ if (result.term) {
1645
+ if (!result.matches.length) {
1646
+ return { content: `No — no ${kindSingular} named "${result.term}" found${scopeSuffix}.`, miss: true, ambiguous: false };
1647
+ }
1648
+ const hit = result.matches[0];
1649
+ const modLabel = moduleLabelOf(hit);
1650
+ const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, defined in ${modLabel}` : "");
1651
+ return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
1652
+ }
1653
+ if (!result.matches.length) {
1654
+ return { content: `No — no ${kindPlural} found${scopeSuffix}.`, miss: true, ambiguous: false };
1655
+ }
1656
+ return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
1657
+ }
1524
1658
  if (result.compositeKind === "count") {
1525
1659
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1526
1660
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -2408,10 +2542,20 @@ function renderCore(parsed, result) {
2408
2542
  if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
2409
2543
  // name what kind of thing was looked for: a sha-shaped term was checked against
2410
2544
  // the commit namespace, a dotted slash-free term against symbol labels — a
2411
- // generic "no module matching" would misreport both.
2545
+ // generic "no module matching" would misreport both. Those two TERM-SHAPE
2546
+ // reads keep priority (a bare "a.mjs" is deliberately read as a symbol-ish
2547
+ // shorthand regardless of the stated entity type — frozen by
2548
+ // test/chat.test.mjs's "no symbol matching \"a.mjs\"" case); only the
2549
+ // remaining catch-all default (unconditionally "module") is replaced by the
2550
+ // parsed AST's own entityType when one is present (Tier-2 playtest, cycle 8
2551
+ // — "which classes inherit from Widget" used to say "no MODULE matching
2552
+ // 'Widget'" even though entityType="Class" was sitting right there unused,
2553
+ // and "Widget" is neither sha- nor dotted-symbol-shaped so the catch-all is
2554
+ // exactly what fired).
2412
2555
  const objText = String(parsed.object || "").trim();
2556
+ const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
2413
2557
  const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
2414
- : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : "module");
2558
+ : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
2415
2559
  return {
2416
2560
  content: `no ${what} matching "${parsed.object}" found in the index.`,
2417
2561
  miss: true, ambiguous: false, candidates: [],
package/src/chat.mjs CHANGED
@@ -1809,6 +1809,24 @@ const PERSONAL_ASSISTANT_NUDGE_RE = new RegExp(
1809
1809
  const STACCATO_NEGATION_RE = /^(?:and\s+)?(?:not|except(?:\s+for)?)\s+(.+?)(?:\s+then|\s+though)?[?.!]*$/i;
1810
1810
  const NEGATION_PRONOUN_RE = /^(?:it|that|this|those|them)(?:\s+ones?)?$/i;
1811
1811
 
1812
+ /** STACCATO COMPARATIVE (SKILL_CHAT_PLAYTEST Tier-2, 6th pass, cycle 8 —
1813
+ * cycle 7's own recommendation): "more than that", "which is bigger", "is
1814
+ * there anything bigger", "bigger than that" following a superlative answer
1815
+ * ("which module has the most imports" -> "src/handlers/tasks.mjs — 5").
1816
+ * Genuinely unanswerable as a real graph query, never fabricated: tmct's
1817
+ * superlative only ever names the single top (or bottom) match for a metric
1818
+ * (evalSuperlative) — it has no "runner-up"/"next ranked" or "greater than a
1819
+ * number" capability to reach for. Before this, both a short/non-codeish
1820
+ * phrasing ("more than that") and the wall these route to (once the
1821
+ * isConversational catch-all is deferred below) fell to the generic
1822
+ * orientation card or the raw grammar wall — neither says what actually went
1823
+ * wrong. Same "honest, guiding nudge, never a bare wall" discipline as
1824
+ * STACCATO_NEGATION_RE just above; the standing focus (now the superlative
1825
+ * WINNER after the chat.mjs fix that made a superlative set it) names what
1826
+ * the user can compare against directly. */
1827
+ const STACCATO_COMPARATIVE_RE =
1828
+ /^(?:(?:is\s+there\s+|what(?:'s|\s+is)\s+)?(?:anything|something)?\s*(?:more|bigger|larger|smaller|fewer|less)\s*(?:than\s+(?:that|it|this))?|which(?:\s+one)?\s+is\s+(?:bigger|smaller|larger|more|less))\s*\??$/i;
1829
+
1812
1830
  /** The <name> a nudge shows: the focus label when the query leans on a pronoun (or
1813
1831
  * gave us nothing better), else the captured subject; "<name>" as the placeholder. */
1814
1832
  function nudgeName(captured, focus) {
@@ -1858,6 +1876,31 @@ function nudgeAnswer(query, focus) {
1858
1876
  return "I can't filter a previous list by exclusion yet — ask the positive shape directly "
1859
1877
  + `(e.g. "which modules import <name>"), or ask about ${term} on its own.`;
1860
1878
  }
1879
+ if (STACCATO_COMPARATIVE_RE.test(q)) {
1880
+ const name = focus?.label;
1881
+ return "I only name the single top (or bottom) match for a metric — no runner-up ranking, no comparing against a number. "
1882
+ + (name
1883
+ ? `Ask about a specific module/class/function directly to compare it with ${name} (e.g. "how many imports does <name> have").`
1884
+ : `Ask a specific ranking directly, e.g. "which module has the most imports".`);
1885
+ }
1886
+ // A bare STACCATO PRONOUN continuation ("also that one?", "and it") with NO
1887
+ // standing focus at all (Tier-2 playtest, 6th pass, cycle 8): describeWrapperAnswer
1888
+ // (4d, below) already resolves this shape perfectly when a real focus stands
1889
+ // (T18) — it honestly DECLINES (null) when there is none, same discipline as
1890
+ // every other focus-dependent lane. Before this branch, that decline fell
1891
+ // through all the way to the generic multi-line orientation card, which names
1892
+ // nothing about what actually went wrong. Symmetric with STACCATO_NEGATION_RE's
1893
+ // own no-focus nudge just above ("not sure what you'd like instead… — name it
1894
+ // directly"): a positive pronoun with nothing to point at gets the same honest,
1895
+ // tailored decline instead of the wall. (Exposed by the STACCATO_LEAKED_CONNECTIVES
1896
+ // fix in runAsk: "what about imports" -> "and calls?" no longer silently — and
1897
+ // WRONGLY — installs a substring-matched module as focus, so a chain of two
1898
+ // vague relation touches genuinely has no antecedent for a third "also that
1899
+ // one?" to resolve — this nudge is what such a chain should always have gotten.)
1900
+ const pronounContinuation = q.match(STACCATO_PRONOUN_RE);
1901
+ if (pronounContinuation && !focus?.label) {
1902
+ return `not sure what "${pronounContinuation[1].toLowerCase()}" refers to yet — name something directly, e.g. "what calls <name>".`;
1903
+ }
1861
1904
  return null;
1862
1905
  }
1863
1906
 
@@ -2873,6 +2916,22 @@ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][
2873
2916
  * matches and falls through unchanged. */
2874
2917
  const STACCATO_SWAP_RE = /^(?:and|also|so|then|now)\s+(.+?)[?.!\s]*$/i;
2875
2918
 
2919
+ /** The five bare connective words STACCATO_SWAP_RE/relationTermOf's own STACCATO
2920
+ * branch lead with. Reused (runAsk's focus-resolution guard, below) to catch
2921
+ * the case where ask()'s OWN raw grammar, given an unstripped "and calls?",
2922
+ * happens to recognize "calls" as a verb and leaves the leading "and" as
2923
+ * parsed.object — a leaked connective, never real content, must never be fed
2924
+ * to resolveEntity (see that guard's own docblock for the concrete failure). */
2925
+ const STACCATO_LEAKED_CONNECTIVES = new Set(["and", "also", "so", "then", "now"]);
2926
+
2927
+ /** A whole-word occurrence of one of chat.mjs's own closed antecedent pronouns
2928
+ * (CONTEXT_WORDS: "it"/"this"/"that"/"here") inside a PRIOR turn's raw query
2929
+ * text — discourseRewrite's fallback swap target when that text has no real
2930
+ * NAME_TOKEN at all ("what calls it", "where is it defined"). Word-boundary
2931
+ * anchored so a real identifier merely CONTAINING one of these (e.g. a symbol
2932
+ * named `edithistory`) is never mistaken for the pronoun. */
2933
+ const PRONOUN_IN_QUERY_RE = new RegExp(`\\b(?:${[...CONTEXT_WORDS].join("|")})\\b`, "i");
2934
+
2876
2935
  /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
2877
2936
  * turn's question shape across the turn boundary — re-asking it with X in place of
2878
2937
  * the previous subject/object. Returns the reconstructed query (parsed like any
@@ -2891,8 +2950,51 @@ function discourseRewrite(query, last) {
2891
2950
  }
2892
2951
  if (!last?.query) return null;
2893
2952
  const prevQ = String(last.query);
2894
- if (!NAME_TOKEN_RE.test(prevQ)) return null;
2895
- return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
2953
+ if (NAME_TOKEN_RE.test(prevQ)) return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
2954
+ // PRONOUN-ANTECEDENT PRIOR QUERY (Tier-2 playtest, 6th pass, cycle 8, deep
2955
+ // multi-hop relation-touch chain stress-test): the prior turn can ITSELF be
2956
+ // pronoun-shaped ("what calls it", "where is it defined" — a drill-down
2957
+ // step that resolved "it" against the standing focus rather than naming an
2958
+ // entity literally) — such a query has NO NAME_TOKEN at all to swap, so the
2959
+ // rule above always declined and a perfectly natural next hop ("and Task?"
2960
+ // meaning "and what calls Task?") fell straight to the raw grammar wall.
2961
+ // Swap the bare pronoun itself in that case ("what calls it" -> "what calls
2962
+ // Task") — CONTEXT_WORDS is chat.mjs's own closed antecedent-pronoun set
2963
+ // (the same one isPronoun/the focus-reuse guard above already trust), so
2964
+ // this only ever touches a genuine referring pronoun, never a real word
2965
+ // that happens to contain "it"/"this"/"that" as a substring (whole-word
2966
+ // boundaries only).
2967
+ if (PRONOUN_IN_QUERY_RE.test(prevQ)) return prevQ.replace(PRONOUN_IN_QUERY_RE, () => newSubj);
2968
+ return null;
2969
+ }
2970
+
2971
+ /** STACCATO SUPERLATIVE REPEAT (Tier-2 playtest, 6th pass, cycle 8): "the biggest
2972
+ * one" / "which is biggest" / "which one is the biggest" / "what about the
2973
+ * biggest one" continuing a superlative last turn ("which module has the most
2974
+ * imports") names NO entity kind at all — parseSuperlative's own grammar always
2975
+ * declines that shape ("a superlative needs an entity kind (module, class,
2976
+ * function, …)"), the one piece of information every OTHER superlative phrasing
2977
+ * supplies, and unlike a plain object ("what does it import") there is no
2978
+ * pronoun slot here for the focus to fill. Rather than guess a NEW metric — a
2979
+ * bare "biggest" with an entity kind spliced in would default to the generic
2980
+ * "connections" metric (EDGE_NOUN_TO_METRIC.connections), which is NOT what
2981
+ * "the biggest one" means right after a query about imports specifically, and
2982
+ * would silently answer a different question than the one just asked — this
2983
+ * re-asks the PRIOR superlative query VERBATIM: the user is confirming/
2984
+ * repeating the same ranking in their own words, not asking a new one, so
2985
+ * replaying the exact prior text (same entityType, same metric) is the only
2986
+ * non-fabricating reading. Gated on the prior query textually naming an extreme
2987
+ * word — a bare "the biggest one" after an unrelated last turn declines (null)
2988
+ * and the ordinary honest miss stands, same discipline as discourseRewrite's
2989
+ * own NAME_TOKEN_RE gate just above. */
2990
+ const STACCATO_SUPERLATIVE_RE =
2991
+ /^(?:what about\s+)?(?:(?:and|also|so|then|now)\s+)?(?:which(?:\s+one)?\s+is\s+(?:the\s+)?|the\s+)(?:most|greatest|highest|biggest|largest|fewest|least|smallest)(?:[- ]connected)?(?:\s+ones?)?\s*\??$/i;
2992
+ const SUPERLATIVE_EXTREME_WORD_RE = /\b(?:most|greatest|highest|biggest|largest|fewest|least|smallest)\b/i;
2993
+ function superlativeRepeatRewrite(query, last) {
2994
+ if (!STACCATO_SUPERLATIVE_RE.test(String(query).trim())) return null;
2995
+ const prevQ = String(last?.query || "");
2996
+ if (!prevQ || !SUPERLATIVE_EXTREME_WORD_RE.test(prevQ)) return null;
2997
+ return prevQ;
2896
2998
  }
2897
2999
 
2898
3000
  // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
@@ -3295,7 +3397,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3295
3397
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
3296
3398
  // prior shape with X swapped in; everything else parses verbatim. The record and
3297
3399
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
3298
- let askQuery = discourseRewrite(query, last) ?? query;
3400
+ let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last) ?? query;
3299
3401
  // IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
3300
3402
  // "and how many are tested" drops the "of those/them" a fuller phrasing carries
3301
3403
  // — ask()'s own anaphora node (parseAnaphora) already understands "how many of
@@ -3379,14 +3481,47 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3379
3481
  // it becomes the new focus so a follow-up "what calls it" can reuse it.
3380
3482
  let resolvedIds = [];
3381
3483
  let newFocus = focus;
3382
- if (graph && envelope?.parsed?.object) {
3484
+ // STACCATO CONNECTIVE LEAKAGE (Tier-2 playtest, 6th pass, cycle 8, multi-hop
3485
+ // relation-touch chain stress-test): "and calls?" — the bare-connective
3486
+ // relation-chain continuation STACCATO_SWAP_RE/relationTermOf's own STACCATO
3487
+ // branch both recognize — is handed to ask() UNSTRIPPED as askQuery. When
3488
+ // ask()'s own raw grammar happens to recognize "calls" as a verb (VERB_TO_KIND),
3489
+ // the leftover "and" becomes parsed.object, and resolveObject's tier-3
3490
+ // substring match (`label.includes(tLc)`) has no minimum-length floor — a
3491
+ // 2-3 letter connective is a near-certain accidental substring of SOME real
3492
+ // label ("and" -> Controller.h-AND-le). The visible answer still looks fine
3493
+ // (relationForceAnswer, later, composes the correct generic relation text
3494
+ // over the SAME query) — but this block ran FIRST and silently rebound the
3495
+ // FOCUS to that bogus match, so the NEXT turn's pronoun ("what tests it")
3496
+ // resolved against the wrong entity and rendered a confidently WRONG empty
3497
+ // ("no tests cover it") for a module that genuinely has tests. The exact
3498
+ // same class of bug as CHATBENCH_0.7.1's "it" reuse-focus fix just below —
3499
+ // grammar scaffolding leaked into the object slot is never real content, so
3500
+ // (mirroring that fix's own discipline) any of the five closed connective
3501
+ // words STACCATO_SWAP_RE recognizes is excluded here from ever being resolved
3502
+ // as an object at all: the branch is skipped entirely, leaving the standing
3503
+ // focus untouched for the relation force (or ordinary miss) to answer over.
3504
+ const isLeakedConnective = STACCATO_LEAKED_CONNECTIVES.has(String(envelope?.parsed?.object || "").toLowerCase());
3505
+ if (graph && envelope?.parsed?.object && !isLeakedConnective) {
3383
3506
  const obj = envelope.parsed.object;
3384
3507
  // A PRONOUN object ("it"/"this") was already resolved against the focus via
3385
3508
  // contextId — the resolved antecedent IS the focus. Re-resolving the literal
3386
3509
  // pronoun string is the CHATBENCH_0.7.1 B1-pron bug: "it" substring-matches the
3387
3510
  // "Commit" schema node (label contains "it"), so the focus jumped off the module
3388
- // to a Commit and the NEXT "it" bound wrong. Reuse the focus directly instead.
3389
- const ent = (isPronoun(obj) && focus?.id) ? focus : await resolveEntity(graph, obj);
3511
+ // to a Commit and the NEXT "it" bound wrong. Reuse the focus directly instead
3512
+ // and when there is NO focus to reuse (Tier-2 playtest, 6th pass, cycle 8: a
3513
+ // bare "where is it defined"/"what does it import" with nothing standing yet,
3514
+ // or right after a superlative TIE, which deliberately never sets one — see
3515
+ // the superlative-winner branch below), never fall through to resolveEntity on
3516
+ // the raw pronoun string either: that is the EXACT SAME substring-match trap
3517
+ // (a 2-letter "it" is a near-certain accidental substring of SOME real label —
3518
+ // here, Task.t-IT-le) as STACCATO_LEAKED_CONNECTIVES fixes for "and"/"also"
3519
+ // above, just triggered by a pronoun instead of a connective. ask()'s OWN
3520
+ // evaluation already renders the honest "'it' needs a selected node…" miss in
3521
+ // this case (contextId was null); silently adopting a bogus focus as a side
3522
+ // effect here would corrupt the NEXT turn's pronoun into a confidently WRONG
3523
+ // (not just empty) answer, exactly as the connective leak did.
3524
+ const ent = isPronoun(obj) ? (focus?.id ? focus : null) : await resolveEntity(graph, obj);
3390
3525
  if (ent) {
3391
3526
  resolvedIds = [ent.id];
3392
3527
  // Class-gate the focus update: a Commit/Session/schema object never displaces a
@@ -3396,6 +3531,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3396
3531
  } else if (!isPronoun(obj)) {
3397
3532
  note(trace, `intermediate: object "${obj}" did NOT resolve to a graph entity — this is why an otherwise-parsed query still misses`);
3398
3533
  }
3534
+ } else if (graph && envelope?.parsed?.node === "superlative" && Array.isArray(envelope?.matches) && envelope.matches.length === 1) {
3535
+ // A superlative ("which module has the most imports") names no object at all —
3536
+ // the branch above never runs — so the ranked WINNER never became the focus,
3537
+ // and an immediate natural follow-up ("what does it import", "where is it
3538
+ // defined") dead-ended on "'it' needs a selected node to refer to" right after
3539
+ // the engine had just named one. Mirrors the object-resolution rule above
3540
+ // exactly: a single, unambiguous winner (no tie — a multi-way tie names no
3541
+ // one individual, so the focus is left alone rather than guessing which of
3542
+ // the tied matches the user means) becomes the new focus, class-gated the
3543
+ // same way (nextFocus). Found in Tier 2 playtest, cycle 8 (superlative
3544
+ // follow-up chains, SKILL_CHAT_PLAYTEST.md).
3545
+ const winner = envelope.matches[0];
3546
+ if (winner?.id) {
3547
+ resolvedIds = [winner.id];
3548
+ newFocus = nextFocus(graph, focus, winner);
3549
+ note(trace, `result: superlative winner ${winner.label} (${winner.id}) — becomes the new focus`);
3550
+ }
3399
3551
  }
3400
3552
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
3401
3553
  const miss = envelope ? !!envelope.miss : true;
@@ -3484,7 +3636,50 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3484
3636
  // branch ALWAYS returns a tailored nudge for this shape, never null, so
3485
3637
  // deferring here never strands the turn with nothing having claimed it.
3486
3638
  const isStaccatoNegation = STACCATO_NEGATION_RE.test(String(query).trim());
3487
- if (!handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation) {
3639
+ // Staccato comparative ("more than that", "which is bigger", "is there
3640
+ // anything bigger" — Tier-2 playtest, 6th pass, cycle 8) needs the SAME
3641
+ // deferral, for the SAME reason as isStaccatoNegation just above: these are
3642
+ // short/non-codeish and trip isConversational's ≤3-word catch-all before
3643
+ // nudgeAnswer's own STACCATO_COMPARATIVE_RE branch ever gets a turn.
3644
+ // nudgeAnswer's comparative branch ALWAYS returns a tailored nudge for this
3645
+ // shape, never null, so deferring here never strands the turn unclaimed.
3646
+ const isStaccatoComparative = STACCATO_COMPARATIVE_RE.test(String(query).trim());
3647
+ // A bare STACCATO PRONOUN continuation ("also that one?", "and it") with NO
3648
+ // standing focus (Tier-2 playtest, 6th pass, cycle 8) needs the SAME deferral:
3649
+ // nudgeAnswer's own STACCATO_PRONOUN_RE-no-focus branch (just above) ALWAYS
3650
+ // returns a tailored nudge for this exact shape, never null, so deferring
3651
+ // here never strands the turn unclaimed — see that branch's own docblock for
3652
+ // why the no-focus case must never reach the generic orientation card.
3653
+ const isStaccatoPronounNoFocus = STACCATO_PRONOUN_RE.test(String(query).trim()) && !focus?.label;
3654
+ // A vague relation touch ("what about cochange", "tell me about cochange",
3655
+ // the staccato chain continuation "and cochange?") whose relation word has NO
3656
+ // bare single-word VERB_TO_KIND form of its own needs the SAME deferral as
3657
+ // isExplainTouch just above, for the identical reason: "cochange" is the one
3658
+ // relation (ask-vocab.mjs RELATIONS.cochange) whose every registered verb
3659
+ // phrase takes a preposition ("changed WITH X", "changes together WITH X") —
3660
+ // there is no bare "cochanges X" — so ask() never gives this shape a parse to
3661
+ // hang envelope.parsed off of, unlike its siblings (imports/calls/tests/
3662
+ // inherits/contains/defines/touches/reexports all have a bare verb and so
3663
+ // already escape isConversational's ≤3-word catch-all via envelope.parsed).
3664
+ // 0.9.16 Tier-2 playtest, 6th pass: "what about cochange" as an opening turn,
3665
+ // and "and cochange?" as a mid-chain continuation after a working "what about
3666
+ // tests", both fell straight to the generic orientation card even though the
3667
+ // graph has real cochange edges and every sibling relation word already
3668
+ // flowed. Needs no prior-turn/focus context either (same as isExplainTouch) —
3669
+ // scoped to the CLOSED RELATION_TERM vocabulary (concept.mjs) via
3670
+ // relationTermOf's own gate, so an unknown word or a real entity name still
3671
+ // declines and isConversational's catch-all is untouched for it.
3672
+ let isVagueRelationTouch = false;
3673
+ {
3674
+ const relTerm = relationTermOf(String(query), envelope);
3675
+ if (relTerm) {
3676
+ try {
3677
+ const { RELATION_TERM } = await import("./concept.mjs");
3678
+ isVagueRelationTouch = !!RELATION_TERM[relTerm.toLowerCase()];
3679
+ } catch { /* leave false — the ordinary path decides */ }
3680
+ }
3681
+ }
3682
+ if (!handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation && !isVagueRelationTouch && !isStaccatoComparative && !isStaccatoPronounNoFocus) {
3488
3683
  // A conversational miss (a greeting, "what can you do", a very short non-code
3489
3684
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
3490
3685
  // Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never