@polycode-projects/the-mechanical-code-talker 0.9.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.mjs CHANGED
@@ -51,9 +51,9 @@ import { uuidv7 } from "./uuid.mjs";
51
51
  import { createTelemetry } from "./telemetry.mjs";
52
52
  import * as defaultSource from "./source.mjs";
53
53
  import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
54
- import { finish } from "./finish.mjs";
55
- import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE } from "./ask-vocab.mjs";
56
- import { COUNTERFACTUAL_RE } from "./interpret/normalize.mjs";
54
+ import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
55
+ import { VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND } from "./ask-vocab.mjs";
56
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames } from "./interpret/normalize.mjs";
57
57
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
58
58
 
59
59
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
@@ -333,6 +333,14 @@ export function asBareCommand(line) {
333
333
  // unconditionally): the predicate-find grammar's own shape wins regardless of
334
334
  // word count, see the precedence note above.
335
335
  if (fl === "find" && looksLikePredicateFind(restTok)) return null;
336
+ // "describe it"/"describe that" (0.9.13 Tier-1 playtest): a bare PRONOUN argument
337
+ // to /describe has no antecedent at this layer — dispatchTool("tmct_describe", …)
338
+ // does its own name-only resolveSymbol lookup with no notion of the standing
339
+ // focus, so routing it here as a bare command produced a raw "no such symbol"
340
+ // failure. Defer to the ordinary pipeline instead (return null): it reaches
341
+ // describeWrapperAnswer's rescue lane, which DOES resolve a bare pronoun against
342
+ // the standing focus. A named argument ("describe Widget") is untouched.
343
+ if (fl === "describe" && DESCRIBE_PRONOUN_RE.test(rest)) return null;
336
344
  // A NO-ARGUMENT command word ("untested") with trailing words is NOT a command
337
345
  // call — the /untested tool takes no argument and would silently drop the qualifier,
338
346
  // listing MODULES for "untested classes". "untested classes" / "untested modules"
@@ -386,9 +394,72 @@ function countableKinds(graph) {
386
394
  * by the ask engine's anaphora node, never the header-count path. */
387
395
  const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
388
396
 
397
+ /** An IMPLICIT anaphoric count with NO explicit "of them/those/these" at all —
398
+ * "how many are tested", "and how many are tested" (Tier-2 playtest, 5th
399
+ * pass). A fluent staccato follow-up after a just-given list naturally elides
400
+ * the pronoun a fuller phrasing ("how many of those are tested") carries —
401
+ * ANAPHORA_COUNT_RE above requires that explicit "of them/those/these" and
402
+ * never fires for this shape, so answerCount's own bare noun-scan greedily
403
+ * (and wrongly) captured the linking verb ITSELF as the counted noun ("how
404
+ * many ARE tested" -> noun="are") and answered the nonsensical "I can't
405
+ * count 'are'." Gated on real content after the linking verb (`(?!there\b)`)
406
+ * so a genuinely bare "how many are there" (no antecedent, no predicate to
407
+ * filter on) is untouched — that one's existing "I can't count 'are'" nudge
408
+ * is arguably the more honest answer to a query naming nothing at all. */
409
+ const IMPLICIT_ANAPHORA_COUNT_RE = /^(?:(?:and|so|then|also)\s+)?how many (?:are|is|were|was)\s+(?!there\b)(\S.*)$/i;
410
+
411
+ /** "have"/"has"/"holds"/"hold" are excluded from RESTRICTOR_VERB_RE below —
412
+ * DELIBERATELY treated as non-restrictor cues here, not a bug fix skipped. Ask-
413
+ * vocab's VERB_TO_KIND maps them to "defines" unconditionally, but the graph's
414
+ * actual "have" semantics are subject-type-dependent (a Module "has" things it
415
+ * defines; a Class "has" things it contains) — found live (0.9.14 Tier-2 playtest,
416
+ * third pass, numeric/quantifier relation touches) that ask.mjs's own engine
417
+ * resolves the two surface forms of the SAME query ("what methods does Widget
418
+ * have" vs "which methods does Widget have") to DIFFERENT, inconsistent kinds (one
419
+ * correctly reaches "contains", the other wrongly reaches "defines" and returns an
420
+ * honest-but-wrong zero) — a genuine, pre-existing ambiguity in the core clause
421
+ * grammar, orthogonal to dialogue flow/routing and out of this cycle's scope.
422
+ * Deferring a "have" tail to the ask engine here would just trade one wrong-answer
423
+ * risk for another rather than fixing anything, so it stays on the existing
424
+ * bare-count path (unchanged behavior, no new regression) until a dedicated fix
425
+ * teaches the grammar to pick "defines" vs "contains" by the resolved subject's
426
+ * own class. */
427
+ const AMBIGUOUS_HAVE_VERBS = new Set(["have", "has", "holds", "hold"]);
428
+
429
+ /** A "how many <kind> …" tail carries a genuine RESTRICTOR clause — not filler — iff
430
+ * it names a real relation verb (active, from VERB_TO_KIND, or passive-participle,
431
+ * from PASSIVE_PARTICIPLE_TO_KIND — both ask-vocab.mjs's closed vocabulary, the
432
+ * same one ask.mjs's own clause grammar reads), minus the ambiguous "have" family
433
+ * above. Matching on the VERB specifically (not "any non-stopword word") matters:
434
+ * a tail's own OBJECT NAME is also non-stopword content ("how many methods does
435
+ * WIDGET have" — "Widget" alone isn't a restrictor cue), so a bare
436
+ * content-word test would misfire on every qualified count regardless of verb. */
437
+ const RESTRICTOR_VERB_RE = new RegExp(
438
+ `\\b(?:${
439
+ [...Object.keys(VERB_TO_KIND), ...Object.keys(PASSIVE_PARTICIPLE_TO_KIND)]
440
+ .filter((v) => !AMBIGUOUS_HAVE_VERBS.has(v))
441
+ .sort((a, b) => b.length - a.length)
442
+ .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
443
+ .join("|")
444
+ })\\b`,
445
+ "i",
446
+ );
447
+
389
448
  /** Recognise a count/aggregate question and answer it from the graph header, or
390
449
  * null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
391
- * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
450
+ * "count [the] X", "number of X". An unknown kind lists what it CAN count.
451
+ *
452
+ * A RESTRICTOR tail ("how many modules IMPORT app/lib/a.mjs", "how many classes
453
+ * INHERIT FROM Base") is NOT a bare header count — found live (0.9.14 Tier-2
454
+ * playtest, third pass, numeric/quantifier relation touches): this regex only ever
455
+ * captured the noun immediately after "how many" and silently discarded everything
456
+ * after it, so a qualified count fell back to the UNQUALIFIED class total ("how many
457
+ * modules import app/lib/a.mjs" answered "8 modules" — the whole-graph module count —
458
+ * instead of the 3 that actually import it). ask.mjs's own AGGREGATE node
459
+ * (parseAggregate) already evaluates a restrictor tail correctly via parseSetPhrase,
460
+ * so once the tail names a real relation verb (RESTRICTOR_VERB_RE), decline here and
461
+ * let the turn fall through to the real ask engine instead of returning a misleading
462
+ * bare total. */
392
463
  export function answerCount(graph, query) {
393
464
  if (!graph) return null;
394
465
  // ANAPHORIC counts ("how many of those are tested", "count them", "how many of
@@ -397,10 +468,15 @@ export function answerCount(graph, query) {
397
468
  // this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
398
469
  // discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
399
470
  if (ANAPHORA_COUNT_RE.test(String(query))) return null;
471
+ // The elliptical sibling above (no explicit "of them/those" at all) — same
472
+ // decline, same reason: this is a reference to the PREVIOUS answer's set,
473
+ // not a graph kind named "are"/"is"/"were"/"was".
474
+ if (IMPLICIT_ANAPHORA_COUNT_RE.test(String(query).trim())) return null;
400
475
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
401
476
  if (!m) return null;
402
477
  const noun = m[1].toLowerCase();
403
478
  const cls = COUNT_NOUNS[noun];
479
+ if (cls && RESTRICTOR_VERB_RE.test(String(query).slice(m.index + m[0].length))) return null;
404
480
  if (!cls) {
405
481
  return `I can't count "${noun}". I count: ${countableKinds(graph).join(", ")}. ` +
406
482
  `Try "how many classes are there".`;
@@ -519,8 +595,24 @@ const IDENTITY_PHRASES = [
519
595
  /^(tell me about|introduce) yourself\??$/i, /^what is this thing\??$/i,
520
596
  /^what am i (talking|speaking|chatting) (to|with)\??$/i,
521
597
  /^you are what\??$/i, /^what thing (are|is) you\??$/i,
522
- /^explain( to me)? what (you are|this is)\??$/i,
598
+ // "explain [to me|please]* what (you are|this is)" in EITHER word order — a
599
+ // fluent-but-non-native speaker plausibly types the question-form "what is
600
+ // this" after "explain" as readily as the statement-form "this is" (SKILL_
601
+ // CHAT_PLAYTEST §3b's own ESL examples: "explain please what is this" used
602
+ // to fall through this regex to the grammar wall because only the statement
603
+ // order was declared).
604
+ /^explain(?:\s+(?:to me|please))*\s+what\s+(?:is\s+(?:this|it|you)|(?:you are|this is|it is))\??$/i,
523
605
  /^whoami\??$/i,
606
+ // "hru" ("how are you") — GLUED texting shorthand: no word boundary inside it
607
+ // for a contraction pass (fuzzyConversationalMatch's SHORTHAND_CONTRACTIONS)
608
+ // to split on, so it earns its own closed-set entry instead, same as GREET/
609
+ // THANKS' hand-curated slang. Routed to identity-self (not a fake "doing
610
+ // great!" performance, nor the generic greeting card) — an honest "what I am"
611
+ // answer is the closest real thing tmct has to say to "how are you". "wyd"
612
+ // ("what are you doing") is deliberately NOT given a matching entry: it isn't
613
+ // an identity question and forcing one would be a fabricated route; it falls
614
+ // through to the honest generic orientation card same as before.
615
+ /^hru\??$/i,
524
616
  ];
525
617
  /** "Are you an LLM/AI/bot" — tmct's actual positioning (no LLM, deterministic) is
526
618
  * a genuinely different, more specific answer than the generic self-description,
@@ -723,13 +815,37 @@ function classifyConversational(phrase) {
723
815
  if (phrase === "who are you" || phrase === "what are you" || phrase === "what is your name") return "identity";
724
816
  return "capability";
725
817
  }
818
+ /** Standalone-token texting shorthand for this lane ONLY: "r"→"are", "u"→"you",
819
+ * word-boundary matched so a substring inside a real word ("your", "sure",
820
+ * "minute") is never touched. This is the SAME normalization class as
821
+ * ask-vocab.mjs's CONTRACTIONS table (word-boundary, case-insensitive,
822
+ * longest-key-first — see interpret/normalize.mjs's tableRe), but deliberately
823
+ * NOT routed through that shared table/normalizeQuery: those feed ask.mjs's
824
+ * code-graph grammar pipeline, where a bare "u"/"r" plausibly collides with a
825
+ * real dotted identifier ("u.mjs" as a module name) — and conversationalTurn()
826
+ * never calls normalizeQuery at all, so extending the shared table wouldn't
827
+ * even reach this lane. Scoped locally to the fuzzy-conversational tier
828
+ * instead, applied BEFORE the candidate lookup below, so "waht r u"/"wat r u"
829
+ * first become "waht are you"/"wat are you" — within the existing bounded
830
+ * edit-distance of "who are you"/"what are you" — and resolve exactly the way
831
+ * a plain-English typo does. GLUED shorthand ("hru", "wyd") has no word
832
+ * boundary to split on and is NOT reached by this pass; see IDENTITY_PHRASES
833
+ * for "hru"'s separate closed-set entry. */
834
+ const SHORTHAND_CONTRACTIONS = { r: "are", u: "you" };
835
+ const SHORTHAND_CONTRACTION_RE = /\b(r|u)\b/gi;
836
+ function expandShorthandContractions(text) {
837
+ return text.replace(SHORTHAND_CONTRACTION_RE, (m) => SHORTHAND_CONTRACTIONS[m.toLowerCase()]);
838
+ }
839
+
726
840
  /** UNIQUE within-bound fuzzy match of the whole trimmed line against
727
- * CONVERSATIONAL_PHRASES — the "helo"/"thnx"/"wat r u"/"byee" tier. Restricted to
728
- * short (≤4-word), non-code-ish inputs (looksCodeish, shared with
841
+ * CONVERSATIONAL_PHRASES — the "helo"/"thnx"/"byee" tier, plus (after shorthand
842
+ * contraction expansion above) "waht r u"/"wat r u"-style texting shorthand.
843
+ * Restricted to short (≤4-word), non-code-ish inputs (looksCodeish, shared with
729
844
  * isConversational) so a genuine near-miss structural question is never grabbed;
730
845
  * a distance tie is refused, never guessed (same discipline as fuzzyVocabWord). */
731
846
  function fuzzyConversationalMatch(raw) {
732
- const q = collapseRuns(raw.toLowerCase().replace(/[.!?]+$/, "").trim());
847
+ const expanded = expandShorthandContractions(raw);
848
+ const q = collapseRuns(expanded.toLowerCase().replace(/[.!?]+$/, "").trim());
733
849
  const words = q.split(/\s+/).filter(Boolean);
734
850
  if (!words.length || words.length > 4 || looksCodeish(raw, q)) return null;
735
851
  return fuzzyMatchInSet(q, CONVERSATIONAL_PHRASES, Math.min(2, fuzzyBound(q)));
@@ -1104,10 +1220,22 @@ function assertCandidates(payload) {
1104
1220
  if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
1105
1221
  return [...new Set(out)];
1106
1222
  }
1107
- /** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint. */
1223
+ /** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint.
1224
+ * BUG 2 fix (2026-07-08): the article was hardcoded to "a" regardless of Y's
1225
+ * vowel sound ("every monkey is a animal" — ungrammatical for a vowel-initial
1226
+ * Y), which made the suggestion silently WRONG for exactly the cases where a
1227
+ * correction is most useful. Real a/an agreement now reuses finish.mjs's own
1228
+ * beginsWithVowelSound + the SAME grammar-rules.toml "article" rule
1229
+ * (spelling-vowel/consonant exceptions included) rather than reimplementing
1230
+ * vowel-sound detection a second time. */
1108
1231
  function teachSuggestion(payload) {
1109
1232
  const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
1110
- return m ? `every ${m[1].toLowerCase()} is a ${m[2].toLowerCase()}` : null;
1233
+ if (!m) return null;
1234
+ const subject = m[1].toLowerCase();
1235
+ const object = m[2].toLowerCase();
1236
+ const articleRule = grammarRules().find((r) => r.kind === "article");
1237
+ const article = articleRule && beginsWithVowelSound(object, articleRule) ? "an" : "a";
1238
+ return `every ${subject} is ${article} ${object}`;
1111
1239
  }
1112
1240
 
1113
1241
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
@@ -1152,10 +1280,52 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1152
1280
  }
1153
1281
  }
1154
1282
  }
1283
+ // BUG 2 fix (2026-07-08): compare the CORRECTED suggestion against a
1284
+ // normalized (trimmed, whitespace-collapsed, lowercased) form of what the
1285
+ // user actually typed, not the raw payload — so trivial formatting
1286
+ // differences never manufacture a spurious "did you mean". With
1287
+ // teachSuggestion's article now grammatically correct (above), this
1288
+ // equality guard's original intent is restored rather than replaced: it
1289
+ // suppresses the hint exactly when X and Y themselves are already spelled
1290
+ // in the canonical "every X is a Y" shape (nothing useful to add), and
1291
+ // shows it whenever the corrected form differs — including the wrong-
1292
+ // article case ("every monkey is a animal") that used to be silently
1293
+ // suppressed because the OLD teachSuggestion's own hardcoded "a" matched
1294
+ // the user's mistake byte-for-byte.
1295
+ const normalizedPayload = String(payload).trim().toLowerCase().replace(/\s+/g, " ");
1155
1296
  const suggestion = teachSuggestion(payload);
1156
- const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
1297
+ const did = suggestion && suggestion !== normalizedPayload ? ` Did you mean: "${suggestion}"?` : "";
1298
+ // Honest miss reason (2026-07-08, "separately, not a bug" clarification): when
1299
+ // the payload structurally fits the ACE fragment but names word(s) outside
1300
+ // tmct's closed 180-word lexicon (lexicon-core.json), parseAce already
1301
+ // reports exactly which tokens are unrecognized as `residue` — assertTurn's
1302
+ // loop above discards it on a miss. Re-derive it here (same lexicon, same
1303
+ // candidate sentences) so the miss message can NAME the word(s), rather than
1304
+ // leaving the user to guess whether the problem was grammar shape or
1305
+ // vocabulary. A payload that doesn't fit the fragment AT ALL (parseAce
1306
+ // returns null, no residue) gets the plain generic message — genuinely a
1307
+ // shape mismatch, not an unrecognized-word one. This does NOT widen the
1308
+ // lexicon itself: "redis"/"monkey"/"animal" still fail to store; the
1309
+ // message now says why.
1310
+ let unknown = [];
1311
+ if (memoryDir) {
1312
+ try {
1313
+ const { parseAce } = await import("./grammar/ace.mjs");
1314
+ let lex = lexicon;
1315
+ if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
1316
+ for (const cand of assertCandidates(payload)) {
1317
+ const parse = parseAce(cand, lex);
1318
+ if (parse?.residue?.length) { unknown = [...new Set(parse.residue.map((w) => String(w).toLowerCase()))]; break; }
1319
+ }
1320
+ } catch { /* lexicon unavailable — fall through to the generic message */ }
1321
+ }
1322
+ const why = unknown.length
1323
+ ? ` I don't recognize ${joinList(unknown.map((w) => `"${w}"`))} as ${unknown.length === 1 ? "a word" : "words"} I know — `
1324
+ + "I can only teach facts using tmct's own code-vocabulary nouns (like module, class, function…), "
1325
+ + "not arbitrary new terms."
1326
+ : "";
1157
1327
  return {
1158
- text: 'I couldn\'t store that I remember facts in the shape "every X is a Y", where X and Y are '
1328
+ text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
1159
1329
  + `words I know.${did} Type /memory to see what I already remember.`,
1160
1330
  via: "teach-miss", miss: true,
1161
1331
  };
@@ -1177,13 +1347,18 @@ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|rep
1177
1347
  * vocabulary seeding either hasn't run or produced nothing, so the hook makes NO
1178
1348
  * term-specific promise (an unconditionally-true pointer: the teach lane and
1179
1349
  * `tmct init` both work with zero preconditions), rather than suggesting a
1180
- * vocabulary example that would be guaranteed to miss right after being offered. */
1350
+ * vocabulary example that would be guaranteed to miss right after being offered.
1351
+ * The no-code-graph branch's teach example is a CONCRETE pair from the closed
1352
+ * ACE lexicon (playtest: an abstract "every X is a Y" invites a curious user to
1353
+ * substitute intuitive-but-unknown words — "every cache is a thing" — which the
1354
+ * closed lexicon then rejects; "every bug is an issue" is confirmed to parse and
1355
+ * store, see test/chatflow-tier0.test.mjs). */
1181
1356
  async function memorySummary(memoryDir, graph) {
1182
1357
  const rows = memoryDir ? await memoryFacts(memoryDir) : [];
1183
1358
  if (!rows.length) {
1184
1359
  const hook = moduleCountOf(graph) > 0
1185
1360
  ? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
1186
- : 'run `tmct init` to seed a starter vocabulary, or teach me directly with "every X is a Y"';
1361
+ : 'run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue"';
1187
1362
  return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
1188
1363
  }
1189
1364
  const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
@@ -1306,6 +1481,56 @@ const IMPERATIVE_NUDGE_RE =
1306
1481
  /^(?:please\s+)?(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?)?(?:make|write|create|add|generate|implement|fix|refactor)\b(?=.*\b(?:tests?|code|functions?|methods?|modules?|class(?:es)?|files?|it)\b)/i;
1307
1482
  const WHY_UNTESTED_RE = /^why\s+(?:is|are)(?:n't|\s+not)?\s+(.+?)\s+(?:untested|not\s+tested|uncovered)$/i;
1308
1483
 
1484
+ // #5(g) OUT-OF-DOMAIN PERSONAL-ASSISTANT NUDGE (BUG 3 fix, 2026-07-08): "what
1485
+ // time is it" / "what's the weather" / "what day is it" — obviously not an
1486
+ // attempted code-graph query at all (no structural noun/verb), but 4+ words
1487
+ // with no dotted/camelCase/"()" token, so it slips past BOTH looksCodeish and
1488
+ // isConversational's ≤3-word catch-all, straight to the raw grammar wall
1489
+ // ("couldn't parse this as a graph question. Try: ...") — a dead-end per
1490
+ // SKILL_CHAT_PLAYTEST.md §0 ("every turn either answers, or gives a guiding
1491
+ // nudge... a turn that does neither is a dead-end"). A small closed set, same
1492
+ // discipline as RISK_NUDGE_RE/OPINION_NUDGE_RE above: this is a genuine
1493
+ // capability ceiling (tmct has no clock/calendar/weather capability) — the
1494
+ // fix is an honest decline pointing back at what tmct actually does, never a
1495
+ // fabricated time/date/weather answer.
1496
+ // "what(?:'s|s|\s+is)" also accepts the bare "whats" spelling — the same
1497
+ // informal contraction ask-vocab.mjs's own CONTRACTIONS table maps to "what
1498
+ // is" for the graph-query path; nudgeAnswer sees the raw (not contraction-
1499
+ // normalized) query text, so it earns its own tolerance here too.
1500
+ const PERSONAL_ASSISTANT_NUDGE_RE = new RegExp(
1501
+ "^(?:"
1502
+ + "what\\s+time\\s+is\\s+it(?:\\s+(?:now|right\\s+now))?"
1503
+ + "|what(?:'s|s|\\s+is)\\s+the\\s+time(?:\\s+(?:now|right\\s+now))?"
1504
+ + "|what\\s+day\\s+is\\s+it(?:\\s+today)?"
1505
+ + "|what(?:'s|s|\\s+is)\\s+(?:the\\s+)?(?:day|date)(?:\\s+today)?"
1506
+ + "|what(?:'s|s|\\s+is)\\s+today'?s\\s+date"
1507
+ + "|what(?:'s|s|\\s+is)\\s+the\\s+weather(?:\\s+like)?(?:\\s+(?:today|outside))?"
1508
+ + "|how'?s\\s+the\\s+weather(?:\\s+like)?(?:\\s+(?:today|outside))?"
1509
+ + ")\\??$",
1510
+ "i",
1511
+ );
1512
+
1513
+ /** STACCATO NEGATION ("not X", "not X then", "except X") — SKILL_CHAT_PLAYTEST
1514
+ * Tier-2, 5th pass: a rapid-fire rejection of a specific item, with no verb at
1515
+ * all — the bare-connective sibling of STACCATO_PRONOUN_RE/STACCATO_SWAP_RE
1516
+ * (below), but with no positive alternative named. Two flavors, BOTH
1517
+ * genuinely unanswerable as a real graph query (never fabricated):
1518
+ * - a BARE pronoun rejection ("not that one", "not those", "not it") names
1519
+ * no alternative at all — what the user DOES want instead is known only
1520
+ * to them, not derivable from the graph.
1521
+ * - a NAMED rejection ("not app/lib/b.mjs", "not Widget then") names a real
1522
+ * candidate to EXCLUDE from a just-given list, but excluding a member
1523
+ * from a prior result set is a capability the engine genuinely doesn't
1524
+ * have yet (verified live: even the fully-spelled "which of those is not
1525
+ * X" doesn't compile — parsePredicateFilter has no negation branch).
1526
+ * Before this, both fell to the generic orientation card (a short,
1527
+ * non-codeish turn trips isConversational's ≤3-word catch-all) or the raw
1528
+ * grammar wall (a codeish one, e.g. a path) — neither names what actually
1529
+ * went wrong. This is an honest, GUIDING nudge (§0), never a fabricated
1530
+ * filtered answer and never a bare wall. */
1531
+ const STACCATO_NEGATION_RE = /^(?:and\s+)?(?:not|except(?:\s+for)?)\s+(.+?)(?:\s+then|\s+though)?[?.!]*$/i;
1532
+ const NEGATION_PRONOUN_RE = /^(?:it|that|this|those|them)(?:\s+ones?)?$/i;
1533
+
1309
1534
  /** The <name> a nudge shows: the focus label when the query leans on a pronoun (or
1310
1535
  * gave us nothing better), else the captured subject; "<name>" as the placeholder. */
1311
1536
  function nudgeName(captured, focus) {
@@ -1320,6 +1545,10 @@ function nudgeName(captured, focus) {
1320
1545
  * short-miss rewrite). */
1321
1546
  function nudgeAnswer(query, focus) {
1322
1547
  const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1548
+ if (PERSONAL_ASSISTANT_NUDGE_RE.test(q)) {
1549
+ return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
1550
+ + 'Ask me about code structure ("which modules import <name>") or try "what is a cache".';
1551
+ }
1323
1552
  if (OPINION_NUDGE_RE.test(q)) {
1324
1553
  const name = focus?.label || "<name>";
1325
1554
  return "I don't hold opinions — I read structure, not quality. I can show what an opinion would rest on: "
@@ -1341,6 +1570,16 @@ function nudgeAnswer(query, focus) {
1341
1570
  return "I don't write code — I read a graph of it. "
1342
1571
  + `/tests ${name} shows what covers it; "untested modules" shows the gaps.`;
1343
1572
  }
1573
+ const neg = q.match(STACCATO_NEGATION_RE);
1574
+ if (neg) {
1575
+ const term = neg[1].trim();
1576
+ if (NEGATION_PRONOUN_RE.test(term)) {
1577
+ const name = focus?.label || "Widget";
1578
+ return `not sure what you'd like instead of ${focus?.label || "that"} — name it directly, e.g. "what calls ${name}".`;
1579
+ }
1580
+ return "I can't filter a previous list by exclusion yet — ask the positive shape directly "
1581
+ + `(e.g. "which modules import <name>"), or ask about ${term} on its own.`;
1582
+ }
1344
1583
  return null;
1345
1584
  }
1346
1585
 
@@ -1683,6 +1922,46 @@ const FACT_PREDICATE_PHRASES = {
1683
1922
  };
1684
1923
  const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
1685
1924
 
1925
+ // ---- BUG 1 fix (2026-07-08): "what is a tree used for" filters to JUST the
1926
+ // UsedFor facts, instead of grammar.mjs's meta-whatis template's lazy tail
1927
+ // swallowing "tree used for" whole as one literal term (a guaranteed
1928
+ // vocabulary-lookup miss — "tree used for" names no class/predicate). Reuses
1929
+ // FACT_PREDICATE_PHRASES itself as the marker vocabulary (no second table):
1930
+ // every phrase that reads as "<copula> <marker>" (e.g. "is used for", "is
1931
+ // part of") derives a trailing marker ("used for", "part of") a "what is a
1932
+ // <subject> <marker>" question can end on, since the leading "is" is already
1933
+ // consumed by the template's own "what is" anchor. Phrases with no leading
1934
+ // is/are copula ("can", "causes", "requires", "has", …) don't fit that
1935
+ // question shape at all and are correctly excluded automatically — this is a
1936
+ // DERIVATION, not a curated subset. The single-letter "a" (from rdf:type's
1937
+ // bare "is a") is excluded explicitly: too short to anchor on without a real
1938
+ // risk of eating a genuine multi-word subject ending in "a".
1939
+ const TRAILING_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
1940
+ .map(([predicate, phrase]) => {
1941
+ const m = /^(?:is|are)\s+(.+)$/i.exec(phrase);
1942
+ return m ? { predicate, marker: m[1].trim().toLowerCase() } : null;
1943
+ })
1944
+ .filter((e) => e && e.marker.length > 1)
1945
+ .sort((a, b) => b.marker.length - a.marker.length); // longest marker first
1946
+
1947
+ /** Split a meta-shaped term into {subject, predicate}: "tree used for" ->
1948
+ * {subject:"tree", predicate:"mgx:usedFor"} when the term ends in a known
1949
+ * TRAILING_PREDICATE_MARKERS marker with a non-empty subject ahead of it;
1950
+ * otherwise {subject: term, predicate: null} (the term stands as-is — the
1951
+ * ordinary undifferentiated "what is a X" behavior). Pure, no I/O. */
1952
+ function splitMetaPredicate(term) {
1953
+ const t = String(term || "").trim();
1954
+ const lower = t.toLowerCase();
1955
+ for (const { marker, predicate } of TRAILING_PREDICATE_MARKERS) {
1956
+ if (lower === marker) continue; // no subject left to the left of the marker
1957
+ if (lower.endsWith(` ${marker}`)) {
1958
+ const subject = t.slice(0, t.length - marker.length).trim();
1959
+ if (subject) return { subject, predicate };
1960
+ }
1961
+ }
1962
+ return { subject: t, predicate: null };
1963
+ }
1964
+
1686
1965
  /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
1687
1966
  * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
1688
1967
  * source cited — NEVER "i learned: …", which over-claims and anthropomorphises
@@ -1866,9 +2145,29 @@ async function factAnswer(memoryDir, query, envelope, miss) {
1866
2145
  if (m) metaTerm = m[1];
1867
2146
  }
1868
2147
  if (metaTerm) {
1869
- const variants = factTermVariants(normFactTerm, metaTerm);
1870
- const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
1871
- if (!hits.length) return null;
2148
+ // BUG 1 fix: "what is a tree used for" parses (grammar.mjs T5) to the
2149
+ // WHOLE tail "tree used for" as one literal term — split off a trailing
2150
+ // FACT_PREDICATE_PHRASES marker (if any) so the real subject ("tree") is
2151
+ // matched against fact subjects, and — the actual bug — the result is
2152
+ // FILTERED to just that one predicate (mgx:usedFor) instead of every
2153
+ // relation about the subject undifferentiated.
2154
+ const { subject, predicate } = splitMetaPredicate(metaTerm);
2155
+ const variants = factTermVariants(normFactTerm, subject);
2156
+ const subjectHits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
2157
+ const hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
2158
+ if (!hits.length) {
2159
+ // The subject itself is known, but not under this specific relation —
2160
+ // an honest, specific "no" rather than falling through to the generic
2161
+ // "isn't a term in this graph's own vocabulary" wall (which would be
2162
+ // actively misleading here: the subject IS a known term).
2163
+ if (predicate && subjectHits.length) {
2164
+ return {
2165
+ text: `I don't have any "${FACT_PREDICATE_PHRASES[predicate]}" facts about ${subject}.`,
2166
+ replace: miss,
2167
+ };
2168
+ }
2169
+ return null;
2170
+ }
1872
2171
  const lines = hits.map(renderFactLine);
1873
2172
  const shown = lines.slice(0, FACT_ANSWER_CAP);
1874
2173
  const rest = lines.slice(FACT_ANSWER_CAP);
@@ -2273,9 +2572,28 @@ async function recallSummary(memoryDir) {
2273
2572
  /** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
2274
2573
  * turn's question with X swapped in. */
2275
2574
  const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
2276
- /** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
2277
- * Capitalized symbol) the subject "what about X" replaces. */
2278
- const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
2575
+ /** A code-ish name token in a prior query (a path/dotted name, a Capitalized
2576
+ * symbol, or a lowerCamelCase identifier like `saveStore`/`createTask`) the
2577
+ * subject "what about X" replaces. The lowerCamelCase alternative (0.9.13
2578
+ * Tier-1 playtest) closes a real drill-down gap: a chain focused on a FUNCTION
2579
+ * ("what does saveStore call") has no Capitalized/path token at all, so "what
2580
+ * about X" after it used to fall straight through to the honest-miss instead
2581
+ * of continuing the shape — a mid-word capital never occurs in plain English,
2582
+ * so this is a safe, unambiguous code-identifier signal. */
2583
+ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
2584
+
2585
+ /** STACCATO SWAP CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): the bare-
2586
+ * connective sibling of WHAT_ABOUT_RE — "and Widget?", "also app/lib/b.mjs" —
2587
+ * with no "about" at all. A rapid-fire drill-down chain naturally shortens
2588
+ * to this once the shape is established ("what calls app/lib/a.mjs" -> "and
2589
+ * Widget?" meaning "and what calls Widget?"). Unlike WHAT_ABOUT_RE's
2590
+ * explicit question framing, a bare connective is otherwise too ambiguous
2591
+ * with ordinary discourse ("and then?", "so what") to safely reinterpret as
2592
+ * a subject swap — discourseRewrite below only trusts this shape when the
2593
+ * captured word is ITSELF unambiguously code-ish (NAME_TOKEN_RE): a path, a
2594
+ * Capitalized symbol, or lowerCamelCase. A plain word ("and stuff?") never
2595
+ * matches and falls through unchanged. */
2596
+ const STACCATO_SWAP_RE = /^(?:and|also|so|then|now)\s+(.+?)[?.!\s]*$/i;
2279
2597
 
2280
2598
  /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
2281
2599
  * turn's question shape across the turn boundary — re-asking it with X in place of
@@ -2284,10 +2602,18 @@ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
2284
2602
  * no prior query or no name token to swap (→ the ordinary honest miss stands). */
2285
2603
  function discourseRewrite(query, last) {
2286
2604
  const m = String(query).match(WHAT_ABOUT_RE);
2287
- if (!m || !last?.query) return null;
2605
+ let newSubj;
2606
+ if (m) {
2607
+ newSubj = m[1].trim();
2608
+ } else {
2609
+ const sm = String(query).match(STACCATO_SWAP_RE);
2610
+ const cand = sm?.[1]?.trim();
2611
+ if (!cand || !NAME_TOKEN_RE.test(cand)) return null;
2612
+ newSubj = cand;
2613
+ }
2614
+ if (!last?.query) return null;
2288
2615
  const prevQ = String(last.query);
2289
2616
  if (!NAME_TOKEN_RE.test(prevQ)) return null;
2290
- const newSubj = m[1].trim();
2291
2617
  return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
2292
2618
  }
2293
2619
 
@@ -2405,11 +2731,61 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
2405
2731
  * "imports"), which the RELATION force must never preempt (frozen case
2406
2732
  * am-meta-imports). Gated downstream by CONCEPT_CLASS / RELATION_TERM, so a real
2407
2733
  * entity name declines here. */
2734
+ // "tel" -> "tell" (0.9.14 Tier-2 playtest): the dropped-letter typo of THIS
2735
+ // lane's own anchor word — "tel me about calls" used to miss the "^tell me
2736
+ // about …" regex entirely and fall through to a bogus "no module matching
2737
+ // 'tel me'" search. "tell" is not itself part of ask.mjs's code-graph grammar
2738
+ // (VERB_TO_KIND/ENTITY_TO_TYPE/anchor words), so it can't live in the shared
2739
+ // ask-vocab.mjs MISSPELLINGS table (test/ask-vocab.test.mjs enforces every
2740
+ // correction value is grammar-owned) — same reasoning as chat.mjs's own
2741
+ // SHORTHAND_CONTRACTIONS above: scoped locally to the lane that owns the word.
2742
+ // Word-boundary matched so "hotel"/"intel" are untouched.
2743
+ const VAGUE_TOUCH_TEL_RE = /\btel\b/i;
2744
+ /** "explain X" / "please explain X" / "kindly explain X" / "explain X to me" /
2745
+ * "explain X please" — a bare vague-touch shape, sibling of WHAT_ABOUT_RE
2746
+ * above. Named (not inlined) so both vagueTouchTermOf (term extraction) and
2747
+ * the isConversational-catch-all exemption (below, deduceGoalFromParsed's
2748
+ * neighbourhood) can test the SAME shape. */
2749
+ const EXPLAIN_TOUCH_RE = /^(?:please\s+|kindly\s+)*explain\s+(?:to\s+me\s+)?(?:an?\s+|the\s+)?(.+?)(?:\s+(?:to\s+me|please))?[?.!\s]*$/i;
2408
2750
  function vagueTouchTermOf(query) {
2409
- const q = String(query).trim();
2410
- const m = q.match(/^tell me about\s+(?:an?\s+)?(.+?)[?.!\s]*$/i)
2411
- || q.match(/^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i);
2412
- return m ? m[1].trim() : null;
2751
+ // typo-correct the ANCHOR words only ("waht about calls" -> "what about
2752
+ // calls") this shape has no ask()-grammar envelope to lean on for typo
2753
+ // tolerance (unlike metaTermOf's "what is a X", which mostly gets it for
2754
+ // free off envelope.parsed once ask() itself has normalized). Then peel the
2755
+ // SAME closed greeting/thanks/modal-wrapper preambles ask()'s own grammar
2756
+ // already peels (0.9.14 Tier-2 playtest §3b spot-check: "cheers, what about
2757
+ // imports then" and "could you kindly tell me about the calls" both used to
2758
+ // fall through to a bogus object search) — applyPreambleFrames alone, NOT
2759
+ // the full normalizeQuery pipeline, which also runs subordination/
2760
+ // conditional rewrites that turn "tell me about X" into "about X" (its own
2761
+ // bridge frame), breaking this very regex.
2762
+ let q = correctMisspellings(String(query).trim());
2763
+ q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
2764
+ q = applyPreambleFrames(q);
2765
+ const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
2766
+ || q.match(/^(?:(?:and|so|but|ok|okay|now|then|kindly)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)(?:\s+then|\s+though)?[?.!\s]*$/i)
2767
+ // "explain X" (0.9.14 Tier-2 playtest, second pass, §3b formal/ESL angle)
2768
+ // — a bare "explain <term>" is at least as natural a vague touch as "tell
2769
+ // me about X", but had no recognized shape at all: normalize.mjs's own
2770
+ // EXPLAIN_WRAPPER_RE only unwraps a WH-QUESTION remainder ("explain
2771
+ // please where is it defined" -> a real structural question), so a bare
2772
+ // noun remainder like "cochange" was never its territory. A leading
2773
+ // "please"/"kindly" also broke the STRUCTURAL pipeline's own
2774
+ // EXPLAIN_WRAPPER_RE (anchored to start with "explain" literally),
2775
+ // sending the whole turn to the wrong lane.
2776
+ || q.match(EXPLAIN_TOUCH_RE);
2777
+ if (!m) return null;
2778
+ // A trailing meta-noun naming WHAT KIND of thing the touched word already is
2779
+ // (0.9.14 Tier-2 playtest, second pass): "tell me about the cochange
2780
+ // relation" / "what about the calls relationship" / "what about the imports
2781
+ // edges" used to capture the WHOLE tail ("cochange relation") as the term —
2782
+ // RELATION_TERM's closed dict has no multi-word entries, so the relation
2783
+ // force declined and the query fell through to the grammar wall. Stripped
2784
+ // for both callers (conceptTermOf's noun touch and relationTermOf's edge
2785
+ // touch): a noun concept is never phrased with this tail ("tell me about
2786
+ // the Class relation" isn't natural), so it's safe either way.
2787
+ const term = m[1].trim().replace(/\s+(?:relations?|relationships?|edges?)$/i, "").trim();
2788
+ return term || null;
2413
2789
  }
2414
2790
 
2415
2791
  function conceptTermOf(query, envelope) {
@@ -2426,14 +2802,37 @@ function conceptTermOf(query, envelope) {
2426
2802
  function relationTermOf(query, envelope) {
2427
2803
  const base = vagueTouchTermOf(query);
2428
2804
  if (base) return base;
2429
- const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2805
+ // same typo-correction as vagueTouchTermOf above ("waht calls are there" ->
2806
+ // "what calls are there") — these openers are chat.mjs-only shapes with no
2807
+ // ask()-grammar envelope to inherit normalization from (0.9.14 Tier-2
2808
+ // playtest: "waht calls are there" used to hit the grammar wall outright).
2809
+ const q = correctMisspellings(String(query).trim()).toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2430
2810
  let m;
2431
- // "what are the imports", "what is the containment", "what are all the calls"
2432
- if ((m = q.match(/^what\s+(?:are|is)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
2433
- // "what calls are there", "what imports are there"
2434
- if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
2811
+ // "what are the imports", "what is the containment", "what are all the calls",
2812
+ // and the texting-shorthand "r" for "are" (0.9.14 Tier-2 playtest §3b spot-check:
2813
+ // "what r the calls" narrowly scoped to this closed shape, same judgment call
2814
+ // as chat.mjs's own SHORTHAND_CONTRACTIONS for the identity lane: "r" only reads
2815
+ // as "are" right after "what" in one of these curated anchor shapes, so a real
2816
+ // one-letter identifier is never at risk).
2817
+ if ((m = q.match(/^what\s+(?:are|is|r)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
2818
+ // "what calls are there", "what imports are there", "what calls r there"
2819
+ if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+(?:are|r)\s+there$/))) return m[1];
2435
2820
  // "what is calling", "what is importing" (bare gerund, no object)
2436
2821
  if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
2822
+ // STACCATO RELATION-CHAIN CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): a
2823
+ // rapid-fire short follow-up inside an EXISTING relation-touch chain — "and
2824
+ // calls?", "also tests", "so inherits", "then contains" — has no "about"/
2825
+ // "is"/"are" at all, just a bare connective + the relation word. Without
2826
+ // this, the bare word fell straight through to ask()'s own raw grammar,
2827
+ // which parsed the leading connective ITSELF as the object term (e.g. "and
2828
+ // calls" read as kind=calls object="and", silently resolving "and" via the
2829
+ // standing focus/contextId fallback into an unrelated, honestly-empty-but-
2830
+ // wrong answer) or, worse, matched no shape at all and hit the grammar
2831
+ // wall outright. Scoped to RELATION_TERM's own closed dict downstream (this
2832
+ // function's caller, relationForceAnswer), so an unrelated word or a real
2833
+ // entity name ("and Widget?", "so that") safely falls through unchanged —
2834
+ // only a genuine, already-known relation word is swept up.
2835
+ if ((m = q.match(/^(?:and|also|so|then|now)\s+([a-z][a-z-]*)$/))) return m[1];
2437
2836
  // THE SINGULAR META FORM — "what is a test" / "what is an import". The whole meta
2438
2837
  // shape used to be excluded here to keep the frozen am-meta-imports ambiguity case
2439
2838
  // ("what does imports mean") out; but that case is a DIFFERENT shape (ambiguousParse
@@ -2451,26 +2850,61 @@ function relationTermOf(query, envelope) {
2451
2850
  }
2452
2851
 
2453
2852
  /** A closed "describe"-intent wrapper: "can you describe X for me", "could you
2454
- * tell me about X", "tell me more about X" attempt tmct_describe(X). Found
2455
- * live (playtest sprint round 2, SKILL_PLAYTEST_SPRINT.md): a describe-intent
2456
- * question wrapped in an ordinary polite request ("can you tell me more about
2457
- * Controller") fell all the way to the generic wall despite naming a real,
2458
- * just-listed entity nothing recognized the wrapper at all. Same closed
2459
- * lead-in-alternation discipline as GREETING_PREAMBLE_RE/THANKS_PREAMBLE_RE
2460
- * (normalize.mjs). Deliberately used only as a LAST-RESORT lane (see its call
2461
- * site below) "tell me about X" is ALSO the relation/concept force's own
2462
- * trigger phrase for enumerable concepts ("tell me about inheritance"), so
2463
- * this must never run before those have had their chance. Trails an optional
2464
- * "please" as well as "for me" (playtest sprint round 3): this lane reads the
2465
- * RAW turn text, not normalize.mjs's FILLER_WORDS-stripped one, so "could you
2466
- * tell me more about Router please" needs its own trailing-politeness strip. */
2853
+ * tell me about X", "tell me more about X", "what about X" → attempt
2854
+ * tmct_describe(X). Found live (playtest sprint round 2,
2855
+ * SKILL_PLAYTEST_SPRINT.md): a describe-intent question wrapped in an
2856
+ * ordinary polite request ("can you tell me more about Controller") fell all
2857
+ * the way to the generic wall despite naming a real, just-listed entity —
2858
+ * nothing recognized the wrapper at all. Same closed lead-in-alternation
2859
+ * discipline as GREETING_PREAMBLE_RE/THANKS_PREAMBLE_RE (normalize.mjs).
2860
+ * Deliberately used only as a LAST-RESORT lane (see its call site below)
2861
+ * "tell me about X" is ALSO the relation/concept force's own trigger phrase
2862
+ * for enumerable concepts ("tell me about inheritance"), and "what about X"
2863
+ * is ALSO discourseRewrite's own trigger for continuing an ask()-shaped prior
2864
+ * turn this must never run before those have had their chance. Trails an
2865
+ * optional "please" as well as "for me" (playtest sprint round 3): this lane
2866
+ * reads the RAW turn text, not normalize.mjs's FILLER_WORDS-stripped one, so
2867
+ * "could you tell me more about Router please" needs its own trailing-
2868
+ * politeness strip.
2869
+ * "what about X" (0.9.13 Tier-1 playtest): reaches this lane specifically
2870
+ * when the PRIOR turn was itself a describe-shaped question ("describe Task"
2871
+ * isn't an ask()-grammar verb, so discourseRewrite's "describe <X>" rewrite
2872
+ * can never parse and always misses) — a drill-down chain that opens with
2873
+ * "describe X" (the README's own example) used to dead-end on the very next
2874
+ * "what about it"/"what about Y" turn. */
2467
2875
  const DESCRIBE_WRAPPER_RE =
2468
- /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
2469
-
2470
- async function describeWrapperAnswer(query, { config, source }) {
2471
- const m = DESCRIBE_WRAPPER_RE.exec(String(query || "").trim());
2472
- const term = m?.[1]?.trim();
2876
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe|what(?:'s|\s+is)?\s+about)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
2877
+
2878
+ /** Bare focus pronouns this lane resolves against the STANDING focus (0.9.13
2879
+ * Tier-1 playtest) "describe that" / "tell me about it" after a prior turn
2880
+ * set the focus. Never a guess: no standing focus → the lane declines (null),
2881
+ * same as any unresolvable term. */
2882
+ const DESCRIBE_PRONOUN_RE = /^(?:it|that|this|those|them)$/i;
2883
+
2884
+ /** STACCATO PRONOUN CONTINUATION (0.9.15 Tier-2 playtest, 4th pass): a rapid-
2885
+ * fire short follow-up naming no verb at all — "and that?", "also this",
2886
+ * "so it" — the bare-connective sibling of DESCRIBE_WRAPPER_RE's "what about
2887
+ * it"/"describe that". Without this, "what calls X" -> "and that?" fell to
2888
+ * the generic orientation card (isConversational's ≤3-word catch-all caught
2889
+ * it, and DESCRIBE_WRAPPER_RE requires an actual "about"/"describe" anchor
2890
+ * word this shape never has) even though the immediately-prior turn had just
2891
+ * set a real focus a sibling phrasing ("what about it") already resolves
2892
+ * against cleanly. An optional trailing "one"/"ones" (Tier-2 playtest, 5th
2893
+ * pass — "also that one?", "and those ones") is at least as natural as the
2894
+ * bare pronoun and carries no extra meaning beyond it: the capture group
2895
+ * stays the pronoun alone, so DESCRIBE_PRONOUN_RE's downstream test is
2896
+ * unaffected either way. */
2897
+ const STACCATO_PRONOUN_RE = /^(?:and|also|so|then|now)\s+(it|that|this|those|them)(?:\s+ones?)?\s*\??$/i;
2898
+
2899
+ async function describeWrapperAnswer(query, { config, source, focus }) {
2900
+ const q = String(query || "").trim();
2901
+ const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
2902
+ let term = m?.[1]?.trim();
2473
2903
  if (!term) return null;
2904
+ if (DESCRIBE_PRONOUN_RE.test(term)) {
2905
+ if (!focus?.label) return null; // no standing focus to resolve against — honest decline
2906
+ term = focus.label;
2907
+ }
2474
2908
  try {
2475
2909
  const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source });
2476
2910
  return text ? { text } : null;
@@ -2583,7 +3017,29 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2583
3017
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
2584
3018
  // prior shape with X swapped in; everything else parses verbatim. The record and
2585
3019
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
2586
- const askQuery = discourseRewrite(query, last) ?? query;
3020
+ let askQuery = discourseRewrite(query, last) ?? query;
3021
+ // IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
3022
+ // "and how many are tested" drops the "of those/them" a fuller phrasing carries
3023
+ // — ask()'s own anaphora node (parseAnaphora) already understands "how many of
3024
+ // those are tested" perfectly, it simply never SEES this elliptical spelling
3025
+ // (ANAPHORA_TRIGGERS requires an explicit pronoun). Insert the elided "of
3026
+ // those" here, the same way discourseRewrite rewrites "what about X" —
3027
+ // UNCONDITIONALLY (not gated on `prev.length`): a genuinely bare "how many
3028
+ // are tested" with no antecedent at all still reaches the anaphora node this
3029
+ // way, which itself honestly degrades to "needs a previous answer to refer
3030
+ // to" (evalAnaphora's own no-prev branch) — a strictly better outcome than
3031
+ // leaving the raw ellipsis unrewritten, which used to fall through to the
3032
+ // ordinary clause grammar and misparse "and" as the object ('no module
3033
+ // matching "and many" found').
3034
+ if (IMPLICIT_ANAPHORA_COUNT_RE.test(String(askQuery).trim())) {
3035
+ // Strip the leading connective too ("and how many are tested" -> "how many
3036
+ // of those are tested") — left in place, it breaks the anaphora node's own
3037
+ // AGGREGATE_TRIGGERS match on "how many" (anchored at the string start),
3038
+ // silently degrading the count into a bare list of the filtered set.
3039
+ askQuery = String(askQuery).trim()
3040
+ .replace(/^(?:and|so|then|also)\s+/i, "")
3041
+ .replace(/how many\s+/i, "how many of those ");
3042
+ }
2587
3043
  // W2: the explicit recall forms are answered from memory's folded blocks, never
2588
3044
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
2589
3045
  if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
@@ -2698,7 +3154,53 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2698
3154
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
2699
3155
  }
2700
3156
  }
2701
- if (!handled && miss && !envelope?.parsed && isConversational(query)) {
3157
+ // "what about X" with a genuine PRIOR turn to continue (0.9.13 Tier-1 playtest)
3158
+ // is exempt from the conversational catch-all even when short/non-codeish
3159
+ // ("what about that", "what about Task" — no dotted/camel token, ≤3 words):
3160
+ // isConversational() can't see that ask() ALREADY tried discourseRewrite above
3161
+ // and that the describe-wrapper rescue (4d) hasn't had its turn yet — without
3162
+ // this exemption, EVERY "what about X" continuation whose prior turn was itself
3163
+ // a describe-shaped question (discourseRewrite can't rewrite "describe X", so it
3164
+ // always misses) or whose swapped-in subject is a bare Capitalized/pronoun term
3165
+ // fell straight to the generic orientation card instead of reaching (4d).
3166
+ // Same exemption for the bare-connective sibling shape ("and Widget?", "also
3167
+ // app/lib/b.mjs" — no "about" at all, STACCATO_SWAP_RE above), gated the
3168
+ // SAME way discourseRewrite gates it: the swapped-in word must itself be
3169
+ // unambiguously code-ish, so ordinary discourse ("and then?", "so what")
3170
+ // never trips this exemption.
3171
+ const staccatoSwapMatch = String(query).match(STACCATO_SWAP_RE);
3172
+ const isStaccatoSwap = !!(last?.query && staccatoSwapMatch && NAME_TOKEN_RE.test(staccatoSwapMatch[1]?.trim() || ""));
3173
+ const isWhatAboutContinuation = !!(last?.query && WHAT_ABOUT_RE.test(String(query))) || isStaccatoSwap;
3174
+ // Same exemption for the sibling shape "describe it"/"tell me about that"
3175
+ // (0.9.13 Tier-1 playtest): a bare-pronoun describe/tell-me-about is exactly
3176
+ // as short and non-codeish as "what about it", and needs the SAME deferral to
3177
+ // reach describeWrapperAnswer's now-focus-aware pronoun resolution (4d) —
3178
+ // WITHOUT this, "describe Widget" -> "describe that" (a natural drill-down
3179
+ // re-ask) fell to the orientation card even though the standing focus made it
3180
+ // perfectly answerable. Gated on an actual standing focus, same honest-decline
3181
+ // discipline as describeWrapperAnswer itself.
3182
+ const describeWrapperMatch = DESCRIBE_WRAPPER_RE.exec(String(query).trim()) || STACCATO_PRONOUN_RE.exec(String(query).trim());
3183
+ const isDescribePronounContinuation = !!(focus?.label && describeWrapperMatch && DESCRIBE_PRONOUN_RE.test(describeWrapperMatch[1]?.trim() || ""));
3184
+ // A bare/wrapped "explain X" (0.9.14 Tier-2 playtest, second pass) needs the
3185
+ // SAME deferral, and for a stronger reason than the two above: "explain"
3186
+ // isn't a VERB_TO_KIND word at all, so ask() never even ATTEMPTS a parse
3187
+ // (envelope.parsed is null unconditionally for this shape, not merely on a
3188
+ // miss) — a short "explain cochange" (2 words) or politeness-wrapped
3189
+ // "please explain cochange" (3 words) always trips isConversational's ≤3-
3190
+ // word heuristic and never once reaches the relation/concept force below,
3191
+ // which is squarely built to answer exactly this shape. Unlike the two
3192
+ // exemptions above, this one needs no prior-turn/focus context — "explain
3193
+ // X" is a complete, self-contained ask on its own.
3194
+ const isExplainTouch = EXPLAIN_TOUCH_RE.test(String(query).trim());
3195
+ // Staccato negation ("not that one", "not Widget then" — Tier-2, 5th pass)
3196
+ // needs the SAME deferral: "not those" (2 words) / "not that one" (3 words)
3197
+ // both trip isConversational's ≤3-word catch-all before nudgeAnswer's own
3198
+ // STACCATO_NEGATION_RE branch (4c, below) ever gets a turn. Gated on the
3199
+ // shape alone (not a focus/prev precondition) — nudgeAnswer's negation
3200
+ // branch ALWAYS returns a tailored nudge for this shape, never null, so
3201
+ // deferring here never strands the turn with nothing having claimed it.
3202
+ const isStaccatoNegation = STACCATO_NEGATION_RE.test(String(query).trim());
3203
+ if (!handled && miss && !envelope?.parsed && isConversational(query) && !isWhatAboutContinuation && !isDescribePronounContinuation && !isExplainTouch && !isStaccatoNegation) {
2702
3204
  // A conversational miss (a greeting, "what can you do", a very short non-code
2703
3205
  // line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
2704
3206
  // Bug B1 (0.8.2 follow-up): this branch carries via:"template" and never
@@ -2898,7 +3400,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2898
3400
  // for what would otherwise become the generic wall, never a competing route:
2899
3401
  // it only claims the turn if /describe actually resolves the captured term.
2900
3402
  if (miss && recordMiss && via === "composed") {
2901
- const described = await describeWrapperAnswer(query, { config, source });
3403
+ const described = await describeWrapperAnswer(query, { config, source, focus: newFocus });
2902
3404
  if (described) {
2903
3405
  answer = described.text; via = "describe"; recordMiss = false;
2904
3406
  note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
@@ -2975,7 +3477,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2975
3477
  : (envelope
2976
3478
  ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
2977
3479
  : (pending ? { traversal: null, matches: [], pending } : null));
2978
- return { answer, logLines, record, focus: newFocus, detail };
3480
+ // MULTI-HOP STACCATO CHAIN CONTINUATION (Tier-2 playtest, 5th pass): when
3481
+ // discourseRewrite actually substituted a new subject into the PRIOR
3482
+ // query's shape ("and Widget?" -> "what calls Widget") and the rewritten
3483
+ // query STRUCTURALLY PARSED (envelope.parsed stood — a real AST, whether it
3484
+ // went on to a hit or an honest empty; "miss" in this engine's own
3485
+ // convention covers BOTH a genuine grammar failure AND a structurally valid
3486
+ // empty result, so `recordMiss` alone can't distinguish them here), thread
3487
+ // the RECONSTRUCTED positive query forward as the effective `last.query`
3488
+ // the NEXT turn's own discourseRewrite reads — not the raw staccato text
3489
+ // itself. Without this, a 3rd staccato swap in a row ("what calls X" ->
3490
+ // "and Widget?" -> "and Button?") tried to rewrite off "and Widget?" (the
3491
+ // 2nd turn's own verbatim staccato input, which has no clause shape of its
3492
+ // own), corrupting the 3rd swap into a nonsense re-ask ("and Button?" with
3493
+ // "Widget" replaced by "Button" — never a real query) instead of correctly
3494
+ // continuing from "what calls Widget". The verbatim text stays on
3495
+ // `record.query`/the transcript untouched; only the swap-chain
3496
+ // CONTINUATION base changes.
3497
+ const effectiveQuery = (askQuery !== query && envelope?.parsed) ? askQuery : null;
3498
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery };
2979
3499
  }
2980
3500
 
2981
3501
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -3202,7 +3722,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
3202
3722
  // the PRE-narration finished result — see withNarration's docblock for why.
3203
3723
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
3204
3724
  const finished = finish(result, { graph });
3205
- const nextLast = { query: line, answer: finished.answer, detail: finished.detail ?? null };
3725
+ // runAsk's own effectiveQuery (set only when discourseRewrite substituted a
3726
+ // new subject AND the rewrite produced a genuine non-miss answer) takes
3727
+ // over as the continuation base for the NEXT turn's own discourseRewrite —
3728
+ // see runAsk's docblock above its return statement. Every other turn type
3729
+ // (commands, plain counts, misses) carries no such field, so `line` — the
3730
+ // existing, unchanged behavior — stands.
3731
+ const nextLast = { query: finished.effectiveQuery ?? line, answer: finished.answer, detail: finished.detail ?? null };
3206
3732
  return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
3207
3733
  };
3208
3734
 
@@ -3364,11 +3890,19 @@ async function hasSeededVocabulary(repo) {
3364
3890
  * seed.enabled=false, or corpus load failure), offering it would be a lie worse
3365
3891
  * than no example — swap to an unconditionally-true pointer instead (the teach
3366
3892
  * lane and `tmct init` both work with zero preconditions). Computed ONCE per
3367
- * session (createSession), not per turn. */
3893
+ * session (createSession), not per turn.
3894
+ * The unseeded branch's teach clause is a CONCRETE pair too, for the same
3895
+ * reason `cache` is concrete in the seeded branch: playtest found that an
3896
+ * abstract "every X is a Y" invites a curious user to fill X/Y with an
3897
+ * intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
3898
+ * the closed ACE lexicon) and hit the teach-miss dead-end right after being
3899
+ * offered the pattern. "every bug is an issue" is confirmed to parse and
3900
+ * store (both `bug` and `issue` are declared lexicon nouns — see
3901
+ * test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
3368
3902
  function vocabExampleHint(seeded) {
3369
3903
  return seeded
3370
3904
  ? 'Try "what is a cache" for general vocabulary.'
3371
- : 'Run `tmct init` to seed a starter vocabulary, or teach me directly with "every X is a Y".';
3905
+ : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
3372
3906
  }
3373
3907
 
3374
3908
  /** Trim a focus label for the prompt so a long module path can't run the line off. */