@polycode-projects/the-mechanical-code-talker 1.0.1 → 1.0.3

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 +24 -3
  3. package/src/chat.mjs +245 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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
@@ -912,8 +912,19 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
912
912
  // auxiliary in that position ("what DID commit X touch") is left for the existing
913
913
  // parser, not mistaken for a term.
914
914
  const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
915
+ // CASCADE_NOISE_SET excluded alongside STOPWORDS (Tier-2 playtest, cycle 8):
916
+ // "what about classes"/"how about the modules" used to reach here with
917
+ // "about" sitting right where a real qualifying adjective would ("payment"
918
+ // in "list payment modules") — framed (past "what") and immediately before
919
+ // a known noun ("classes") — and get misread as a fuzzy find TERM ("no
920
+ // classes found matching 'about'") instead of the topic-lead-in filler it
921
+ // is. CASCADE_NOISE already curates "about" for exactly this reading (see
922
+ // its own docblock, ask-vocab.mjs) — checking it here too lets a real
923
+ // unknown qualifier ("shiny"/"payment") still reach the find fallback while
924
+ // a known no-graph-meaning filler word falls through to the ordinary bare-
925
+ // kind-noun path instead (the cascade's own noise-strip terminal rule).
915
926
  if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i])
916
- && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i])) {
927
+ && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && !CASCADE_NOISE_SET.has(lc[i])) {
917
928
  return { node: "find", entityType: nextNoun.entityType, term: w[i] };
918
929
  }
919
930
  return null; // no subject entity → not this shape
@@ -2408,10 +2419,20 @@ function renderCore(parsed, result) {
2408
2419
  if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
2409
2420
  // name what kind of thing was looked for: a sha-shaped term was checked against
2410
2421
  // the commit namespace, a dotted slash-free term against symbol labels — a
2411
- // generic "no module matching" would misreport both.
2422
+ // generic "no module matching" would misreport both. Those two TERM-SHAPE
2423
+ // reads keep priority (a bare "a.mjs" is deliberately read as a symbol-ish
2424
+ // shorthand regardless of the stated entity type — frozen by
2425
+ // test/chat.test.mjs's "no symbol matching \"a.mjs\"" case); only the
2426
+ // remaining catch-all default (unconditionally "module") is replaced by the
2427
+ // parsed AST's own entityType when one is present (Tier-2 playtest, cycle 8
2428
+ // — "which classes inherit from Widget" used to say "no MODULE matching
2429
+ // 'Widget'" even though entityType="Class" was sitting right there unused,
2430
+ // and "Widget" is neither sha- nor dotted-symbol-shaped so the catch-all is
2431
+ // exactly what fired).
2412
2432
  const objText = String(parsed.object || "").trim();
2433
+ const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
2413
2434
  const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
2414
- : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : "module");
2435
+ : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : fallback);
2415
2436
  return {
2416
2437
  content: `no ${what} matching "${parsed.object}" found in the index.`,
2417
2438
  miss: true, ambiguous: false, candidates: [],
package/src/chat.mjs CHANGED
@@ -1426,6 +1426,31 @@ function teachSuggestion(payload) {
1426
1426
  return `every ${subject} is ${article} ${object}`;
1427
1427
  }
1428
1428
 
1429
+ /** PRONOUN-SUBJECT GUARD (2026-07-08, operator repro): "remember you are a
1430
+ * womble" and the literal "every you is a womble" both used to reach
1431
+ * teachSuggestion/unknownSubjectFallback treating "you" like an ordinary
1432
+ * unknown common noun — producing the nonsensical "did you mean: every you
1433
+ * is a womble" hint (teachSuggestion), or, worse, a SILENT direct-write via
1434
+ * unknownSubjectFallback whenever the object happened to resolve as a known
1435
+ * noun/adjective (e.g. "he is a doctor" would have stored the bogus fact
1436
+ * "he rdfs:subClassOf doctor"). A personal pronoun is never a valid class-
1437
+ * membership subject for ANY object — "every <pronoun> is a Y" isn't
1438
+ * coherent English no matter what Y is, so this is a grammatical category
1439
+ * error, not "new vocabulary" the unknown-subject free pass exists for.
1440
+ * Checked FIRST in teachLane, before any other recognizer gets a look at
1441
+ * the payload (bare OR remember-wrapped surface, so it fires uniformly
1442
+ * across entry points), and short-circuits with its own honest, distinct
1443
+ * decline — never the generic "every X is a Y" miss text, and never a "did
1444
+ * you mean" guess.
1445
+ *
1446
+ * Deliberately limited to the seven UNAMBIGUOUS personal pronouns (you/i/
1447
+ * it/they/he/she/we) — this/that/these/those are excluded on purpose: they
1448
+ * double as legitimate demonstrative entity references elsewhere in this
1449
+ * file (DESCRIBE_PRONOUN_RE, NEGATION_PRONOUN_RE et al.), and a claim about
1450
+ * a demonstrated entity ("that is a bug", pointing at something real) is a
1451
+ * much closer call than "every you is a womble" — not this bug's territory. */
1452
+ const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+(?:is|are|am)\b/i;
1453
+
1429
1454
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1430
1455
  const rawInput = String(query).trim();
1431
1456
  const m = rawInput.match(TEACH_RE);
@@ -1441,6 +1466,24 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1441
1466
  const raw = stripYour(rawInput);
1442
1467
  const wrapped = stripYour(wrappedInput);
1443
1468
 
1469
+ // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
1470
+ // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
1471
+ // lanes below do) before anything else in this function, so a pronoun
1472
+ // subject NEVER reaches teachSuggestion's "did you mean" hint or
1473
+ // unknownSubjectFallback's direct-write path — see TEACH_PRONOUN_RE's own
1474
+ // docblock above for why.
1475
+ const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
1476
+ const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
1477
+ if (pronounMatch) {
1478
+ const pronoun = pronounMatch[1];
1479
+ return {
1480
+ text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
1481
+ + `I remember facts in the shape "every X is a Y", where X is a specific noun, not a pronoun. `
1482
+ + "Type /memory to see what I already remember.",
1483
+ via: "teach-miss", miss: true,
1484
+ };
1485
+ }
1486
+
1444
1487
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
1445
1488
  // form is double-gated: a Capitalized name AND no interrogative lead, so the
1446
1489
  // "who owns <X>" READ question and ordinary prose never land a fact here.
@@ -1766,6 +1809,24 @@ const PERSONAL_ASSISTANT_NUDGE_RE = new RegExp(
1766
1809
  const STACCATO_NEGATION_RE = /^(?:and\s+)?(?:not|except(?:\s+for)?)\s+(.+?)(?:\s+then|\s+though)?[?.!]*$/i;
1767
1810
  const NEGATION_PRONOUN_RE = /^(?:it|that|this|those|them)(?:\s+ones?)?$/i;
1768
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
+
1769
1830
  /** The <name> a nudge shows: the focus label when the query leans on a pronoun (or
1770
1831
  * gave us nothing better), else the captured subject; "<name>" as the placeholder. */
1771
1832
  function nudgeName(captured, focus) {
@@ -1815,6 +1876,31 @@ function nudgeAnswer(query, focus) {
1815
1876
  return "I can't filter a previous list by exclusion yet — ask the positive shape directly "
1816
1877
  + `(e.g. "which modules import <name>"), or ask about ${term} on its own.`;
1817
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
+ }
1818
1904
  return null;
1819
1905
  }
1820
1906
 
@@ -2830,6 +2916,22 @@ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][
2830
2916
  * matches and falls through unchanged. */
2831
2917
  const STACCATO_SWAP_RE = /^(?:and|also|so|then|now)\s+(.+?)[?.!\s]*$/i;
2832
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
+
2833
2935
  /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
2834
2936
  * turn's question shape across the turn boundary — re-asking it with X in place of
2835
2937
  * the previous subject/object. Returns the reconstructed query (parsed like any
@@ -2848,8 +2950,51 @@ function discourseRewrite(query, last) {
2848
2950
  }
2849
2951
  if (!last?.query) return null;
2850
2952
  const prevQ = String(last.query);
2851
- if (!NAME_TOKEN_RE.test(prevQ)) return null;
2852
- 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;
2853
2998
  }
2854
2999
 
2855
3000
  // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
@@ -3252,7 +3397,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3252
3397
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
3253
3398
  // prior shape with X swapped in; everything else parses verbatim. The record and
3254
3399
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
3255
- let askQuery = discourseRewrite(query, last) ?? query;
3400
+ let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last) ?? query;
3256
3401
  // IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
3257
3402
  // "and how many are tested" drops the "of those/them" a fuller phrasing carries
3258
3403
  // — ask()'s own anaphora node (parseAnaphora) already understands "how many of
@@ -3336,14 +3481,47 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3336
3481
  // it becomes the new focus so a follow-up "what calls it" can reuse it.
3337
3482
  let resolvedIds = [];
3338
3483
  let newFocus = focus;
3339
- 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) {
3340
3506
  const obj = envelope.parsed.object;
3341
3507
  // A PRONOUN object ("it"/"this") was already resolved against the focus via
3342
3508
  // contextId — the resolved antecedent IS the focus. Re-resolving the literal
3343
3509
  // pronoun string is the CHATBENCH_0.7.1 B1-pron bug: "it" substring-matches the
3344
3510
  // "Commit" schema node (label contains "it"), so the focus jumped off the module
3345
- // to a Commit and the NEXT "it" bound wrong. Reuse the focus directly instead.
3346
- 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);
3347
3525
  if (ent) {
3348
3526
  resolvedIds = [ent.id];
3349
3527
  // Class-gate the focus update: a Commit/Session/schema object never displaces a
@@ -3353,6 +3531,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3353
3531
  } else if (!isPronoun(obj)) {
3354
3532
  note(trace, `intermediate: object "${obj}" did NOT resolve to a graph entity — this is why an otherwise-parsed query still misses`);
3355
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
+ }
3356
3551
  }
3357
3552
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
3358
3553
  const miss = envelope ? !!envelope.miss : true;
@@ -3441,7 +3636,50 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3441
3636
  // branch ALWAYS returns a tailored nudge for this shape, never null, so
3442
3637
  // deferring here never strands the turn with nothing having claimed it.
3443
3638
  const isStaccatoNegation = STACCATO_NEGATION_RE.test(String(query).trim());
3444
- 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) {
3445
3683
  // A conversational miss (a greeting, "what can you do", a very short non-code
3446
3684
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
3447
3685
  // Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never