@polycode-projects/the-mechanical-code-talker 1.0.8 → 1.2.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
@@ -52,7 +52,10 @@ 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
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";
55
+ import {
56
+ VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
57
+ stripTrailingScopeFiller,
58
+ } from "./ask-vocab.mjs";
56
59
  import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames } from "./interpret/normalize.mjs";
57
60
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
58
61
 
@@ -228,8 +231,10 @@ function withNarration(result, trace, fallbackGoal) {
228
231
  * own test suite pins composed answers with (see withGoalLine's own docblock,
229
232
  * just below, for why appended rather than led-with).
230
233
  *
231
- * `result.goal` is set ONLY by runAsk (see its own docblock at its return
232
- * statement) a plain count, a slash-command or a teach confirmation never
234
+ * `result.goal` is set by runAsk (see its own docblock at its return
235
+ * statement) AND by runCommand's own mk() (Bug F point 5 GOAL_BY_COMMAND,
236
+ * below — generalizes the SAME mechanism to slash-command dispatches like
237
+ * /search and /describe); a plain count or a teach confirmation never
233
238
  * carries the field, so this is a no-op for those turn types BY
234
239
  * CONSTRUCTION, not a special-cased suppression list here. Also a no-op when
235
240
  * `result.goal` is null/empty — deduceGoalFromParsed's own "nothing to
@@ -370,12 +375,23 @@ function looksLikePredicateFind(restTok) {
370
375
  export function asBareCommand(line) {
371
376
  const trimmed = String(line || "").trim();
372
377
  if (!trimmed || trimmed.startsWith("/")) return null;
373
- const [first, ...restTok] = trimmed.split(/\s+/);
378
+ const [first, ...restTokRaw] = trimmed.split(/\s+/);
374
379
  if (!COMMAND_WORDS.has(first.toLowerCase())) return null;
375
380
  const fl = first.toLowerCase();
381
+ // Bug F point 2 (operator follow-up request): "search for X" (bare, via this
382
+ // function) used to route with "for X" as the literal command argument — "for"
383
+ // is filler, not part of the search term, and burning one of the 3 allowed
384
+ // tokens on it could wrongly reject an otherwise-short query as "too long"
385
+ // ("search for the payment controller" is 4 tokens WITH "for", 3 without).
386
+ // Strip a leading "for " before it becomes the argument, and rebuild the
387
+ // returned command line from the STRIPPED remainder — the token-count check
388
+ // below already reads the stripped `restTok`/`rest`.
389
+ const stripped = (fl === "search" || fl === "find") && restTokRaw[0]?.toLowerCase() === "for";
390
+ const restTok = stripped ? restTokRaw.slice(1) : restTokRaw;
376
391
  const rest = restTok.join(" ");
392
+ const effectiveLine = stripped ? `${fl}${rest ? ` ${rest}` : ""}` : trimmed;
377
393
  // Zero-arg system commands are always the command; a bare command word is too.
378
- if (!rest || fl === "stats" || fl === "memory") return `/${trimmed}`;
394
+ if (!rest || fl === "stats" || fl === "memory") return `/${effectiveLine}`;
379
395
  // "find" (only — "search", its /find-tool alias, keeps its original behavior
380
396
  // unconditionally): the predicate-find grammar's own shape wins regardless of
381
397
  // word count, see the precedence note above.
@@ -398,7 +414,7 @@ export function asBareCommand(line) {
398
414
  if (COMMANDS[fl]?.arg == null && rest) return null;
399
415
  // Arg commands: route only a short, name-like argument (no query connectives),
400
416
  // so "describe Widget" / "members my class" route but a compositional query does not.
401
- if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${trimmed}`;
417
+ if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${effectiveLine}`;
402
418
  return null;
403
419
  }
404
420
 
@@ -525,7 +541,18 @@ export function answerCount(graph, query) {
525
541
  const cls = COUNT_NOUNS[noun];
526
542
  if (cls && RESTRICTOR_VERB_RE.test(String(query).slice(m.index + m[0].length))) return null;
527
543
  if (!cls) {
528
- return `I can't count "${noun}". I count: ${countableKinds(graph).join(", ")}. ` +
544
+ const kinds = countableKinds(graph);
545
+ // Bug C (operator manual-chat find, this session): when NO code graph is
546
+ // loaded, countableKinds(graph) is genuinely EMPTY (no class is present at
547
+ // all) — the old message rendered "I count: ." (a dangling empty list
548
+ // before the period) and then pointlessly suggested "how many classes are
549
+ // there", which would ALSO fail for the same reason. An honest, non-dangling
550
+ // message instead, pointing at how to actually get a graph loaded.
551
+ if (!kinds.length) {
552
+ return `I can't count "${noun}" — no code graph is loaded yet, so there's nothing to count ` +
553
+ `(point me at one with --repo, or run "npm run example:mini").`;
554
+ }
555
+ return `I can't count "${noun}". I count: ${kinds.join(", ")}. ` +
529
556
  `Try "how many classes are there".`;
530
557
  }
531
558
  const n = countClass(graph, cls);
@@ -673,6 +700,39 @@ const CAPABILITY_PHRASES = [
673
700
  // today (bin/tmct.mjs), dead once inside the chat loop; route to the same
674
701
  // capability answer a plain "help" gets.
675
702
  /^--help$/i, /^-h$/i, /^man( tmct)?\??$/i,
703
+ // Tier 6 playtest ("the messy real user", §3): the vague-opener family a
704
+ // stranger genuinely asks before knowing any query shapes — "what can you
705
+ // tell me about this repo", "tell me something interesting (about this
706
+ // codebase)", "so, what is going on in this codebase" (an optional leading
707
+ // "so," discourse connective, same species as LEADING_CONNECTIVE_RE
708
+ // elsewhere). All three used to fall straight to the raw grammar wall
709
+ // (isConversational's own ≤3-word/no-codeish catch-all never claims an
710
+ // 8-word sentence like these) even though orientationAnswer is EXACTLY the
711
+ // right answer — the same overview CAPABILITY_PHRASES' other entries already
712
+ // reach. The noun set mirrors META_ORIENT_RE's own closed list.
713
+ /^what can (?:you|u) tell me(?:\s+(?:more|anything))?\s+about (?:this|the)\s+(?:app|codebase|repo|repository|project|code|thing)\??$/i,
714
+ /^tell me something interesting(?:\s+about (?:this|the)\s+(?:app|codebase|repo|repository|project|code))?\??$/i,
715
+ /^(?:so,?\s+)?what(?:'s|s|\s+is)\s+(?:going on|happening)\s+(?:in|with)\s+(?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
716
+ // Tier 6 playtest cycle 2: three more vague-opener idioms found live, same
717
+ // family as the three just above — a stranger's orientation request has no
718
+ // fixed wording, so this closed set keeps growing additively as new natural
719
+ // phrasings surface, never a general "any long question is an orientation
720
+ // request" rule.
721
+ /^(?:can you\s+)?walk me through (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
722
+ /^what(?:'s|s|\s+is) the big picture(?:\s+here)?\??$/i,
723
+ /^(?:give me|what's) the lay of the land\??$/i,
724
+ // Tier 6 playtest cycle 3: "what have we got here"/"what've we got here" —
725
+ // a casual, self-answering opener (found after a leading "so" strips via
726
+ // LEADING_CONNECTIVE_RE, leaving this as the bare remainder).
727
+ /^what(?:'ve| have) (?:we|i) got here\??$/i,
728
+ // "what's in this repo/codebase" — arguably the MOST natural vague opener of
729
+ // this whole family, and genuinely ambiguous with the real "what's in <X>"
730
+ // members/containment grammar (ask.mjs) for any OTHER term — closed to
731
+ // exactly the same self-referential noun set META_ORIENT_RE already uses,
732
+ // so a real module/class named literally "repo"/"codebase" is never at risk
733
+ // (this repo's own fixture has none, and the noun list itself excludes
734
+ // ordinary code-ish names).
735
+ /^what(?:'s|s|\s+is) in (?:this|the)\s+(?:app|codebase|repo|repository|project|code)\??$/i,
676
736
  ];
677
737
  /** IDENTITY questions — "who/what are you", by name, in plain or ESL-ish phrasing.
678
738
  * Routed to a self-description (identity-self) that works regardless of graph
@@ -831,6 +891,11 @@ const THANKS = new Set([
831
891
  "thanks", "thank you", "thankyou", "thx", "ty", "ta", "cheers", "nice one",
832
892
  "much appreciated", "cool thanks", "many thanks", "much obliged", "ta very much",
833
893
  "cheers mate", "cheers for that", "tks", "sweet thanks", "nice",
894
+ // "ta for that" (Tier 6 playtest): "cheers for that" was already here, but
895
+ // its "ta" sibling (both dropped-word forms of the SAME "thanks for that"
896
+ // shape) was missing — fell to the generic orientation card via
897
+ // isConversational's ≤3-word catch-all instead of a thanks reply.
898
+ "ta for that",
834
899
  ]);
835
900
  /** Farewells → a goodbye AND a clean end of session (same path as /exit). */
836
901
  const BYE = new Set([
@@ -1034,7 +1099,16 @@ function conversationalTurn(line, ctx) {
1034
1099
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
1035
1100
  return mk(t(T_IDENTITY_SELF));
1036
1101
  }
1037
- if (q === "help" || q === "?" || CAPABILITY_PHRASES.some((re) => re.test(raw)) || ORIENT_OPENERS.has(q)) {
1102
+ // Tier 6 playtest cycle 2: CAPABILITY_PHRASES' vague-opener entries are
1103
+ // self-contained closed regexes, but a preamble ahead of one ("right, can
1104
+ // you walk me through this codebase" — an ACK_PREAMBLE_RE + MODAL_WRAPPER_RE
1105
+ // stack) is tested nowhere upstream of this check, unlike vagueTouchTermOf/
1106
+ // describeWrapperAnswer (both run applyPreambleFrames first). Trying the
1107
+ // SAME closed set again against the preamble-stripped text is purely
1108
+ // additive — it can only ever ADD a match CAPABILITY_PHRASES.test(raw)
1109
+ // alone would have missed, never take one away.
1110
+ if (q === "help" || q === "?" || CAPABILITY_PHRASES.some((re) => re.test(raw))
1111
+ || CAPABILITY_PHRASES.some((re) => re.test(applyPreambleFrames(raw))) || ORIENT_OPENERS.has(q)) {
1038
1112
  note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
1039
1113
  note(ctx.trace, "lane: conversational — help/orientation (CAPABILITY_PHRASES/ORIENT_OPENERS / bare help / ?)");
1040
1114
  return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
@@ -1251,7 +1325,13 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
1251
1325
  // silently swallowed);
1252
1326
  // - "<Name> owns/maintains <X>" (bare declarative or wrapped) → an
1253
1327
  // mgx:ownedBy fact, read back by "who owns <X>" (factReadBack).
1254
- const TEACH_RE = /^(?:please\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
1328
+ // Bug F point 1 (operator follow-up request, this session): "I want you to
1329
+ // remember X"/"I'd like you to remember X" teaches exactly like bare "remember
1330
+ // X" — a closed-set optional lead-in before the existing verb list, so it
1331
+ // automatically inherits the correct "teach/remember a new fact" goal line for
1332
+ // free (it flows through the SAME teach-lane goal revision, chat.mjs's runTurn
1333
+ // cascade — no extra wiring needed for this phrasing).
1334
+ const TEACH_RE = /^(?:please\s+)?(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
1255
1335
  const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
1256
1336
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1257
1337
  * ("what is a cache", "is a module a component"), never a teach declarative. */
@@ -1274,16 +1354,46 @@ const SUBCLASS_PREDICATE = "rdfs:subClassOf";
1274
1354
  const HAS_A_PREDICATE = "mgx:hasA";
1275
1355
 
1276
1356
  /** "<Name> owns/maintains <X>" — the ownership teach declarative. <Name> is one
1277
- * or two name tokens, <X> one code-ish token (a path, a file, a symbol). The
1278
- * BARE form additionally requires a Capitalized name (see teachLane), so
1279
- * ordinary lowercase prose never lands a fact without the explicit wrapper. */
1280
- const OWNS_TEACH_RE = /^([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)\s+(?:owns|maintains)\s+(\S+?)[.!?]*$/;
1357
+ * or two name tokens; <X> is a code-ish token (a path, a file, a symbol) OR a
1358
+ * short natural noun phrase ("the tasks handler") widened from a
1359
+ * single-token-only object (Tier-5 playtest fix, found live: "remember that
1360
+ * margo maintains the tasks handler" WALLED entirely, because the object
1361
+ * didn't fit ONE bare token and generalVerbTeach explicitly stands down for
1362
+ * "owns"/"maintains" anywhere in the sentence, deferring to this frame — so
1363
+ * neither recognizer ever stored the fact). The article-stripping needed so
1364
+ * "the tasks handler" reads back the same as a bare "tasks handler" is
1365
+ * handled once, centrally, by normFactTerm (memory/core.mjs) — teachFact
1366
+ * already normalizes both subject and object through it. The BARE form
1367
+ * additionally requires a Capitalized name (see teachLane), so ordinary
1368
+ * lowercase prose never lands a fact without the explicit wrapper. */
1369
+ const OWNS_TEACH_RE = /^([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)\s+(?:owns|maintains)\s+(.+?)[.!?]*$/;
1370
+ /** "<X> is owned by <Name>" — the PASSIVE ownership teach declarative
1371
+ * (Tier-5 playtest fix, cycle 2, found live): at least as natural a way to
1372
+ * state ownership as the active "<Name> owns <X>" above ("TaskController is
1373
+ * owned by sam" WALLED entirely — "is" put it in generalVerbTeach's own
1374
+ * GENERAL_VERB_ANYWHERE_EXCLUDE_RE stand-down territory, but no frame in
1375
+ * this lane actually recognized the passive shape, so nothing ever claimed
1376
+ * it). <X> (the owned thing) is a lazy multi-word capture, same discipline
1377
+ * OWNS_TEACH_RE's own object got widened to; <Name> (the owner) mirrors
1378
+ * OWNS_TEACH_RE's own 1-2-token name capture. Stores the SAME
1379
+ * OWNED_BY_PREDICATE shape (subject=thing, object=owner), so "who owns X" /
1380
+ * the yes/no readers below answer either phrasing identically. */
1381
+ const OWNS_PASSIVE_TEACH_RE = /^(.+?)\s+(?:is|are|was|were)\s+owned\s+by\s+([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)[.!?]*$/i;
1281
1382
 
1282
1383
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
1283
1384
  * subject and a single bare complement word. Never matches the "is a <noun>"
1284
1385
  * membership shape (that stays the ACE grammar's), so "remember that cache is
1285
- * a store" still lands as rdfs:subClassOf, not a property. */
1286
- const TEACH_PROPERTY_RE = /^(?:every\s+|each\s+|all\s+|the\s+)?(.+?)\s+(?:is|are)\s+(?!an?\b|the\b)([A-Za-z][\w-]*)$/i;
1386
+ * a store" still lands as rdfs:subClassOf, not a property. "was"/"were" join
1387
+ * "is"/"are" (Tier-5 playtest fix, cycle 3, sibling of Bug A's had->have
1388
+ * bridge for general-verb facts): "remember that the last commit was risky"
1389
+ * reads back as a present-tense property fact ("...is risky") the same way a
1390
+ * general-verb "had" fact already reads back as "has" — properties are
1391
+ * timeless facts in this store, not tensed events. Safe to widen here
1392
+ * (unlike the entry gates further up that decide whether `payload` even
1393
+ * reaches this match at all): this path only runs on an explicit
1394
+ * "remember/note/…"-WRAPPED sentence, never a bare one, so there's no real
1395
+ * question-shape ("was X Y?") this could ever misfire on. */
1396
+ const TEACH_PROPERTY_RE = /^(?:every\s+|each\s+|all\s+|the\s+)?(.+?)\s+(?:is|are|was|were)\s+(?!an?\b|the\b)([A-Za-z][\w-]*)$/i;
1287
1397
 
1288
1398
  /** The teach lane's provenance tag — mirrors grammar/assert.mjs's provenanceTag
1289
1399
  * shape under a distinct "teach:" family, so a taught fact is auditable apart
@@ -1351,10 +1461,124 @@ const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
1351
1461
  * apart from a singular/specific-entity "a"/bare reading (only "every" gets a
1352
1462
  * recorded quantifier here — this function's OWN caller passes it through to
1353
1463
  * teachFact; assertTurn, below, records the same "every" quantifier
1354
- * independently for the pre-existing ACE-success path). Single-token X and Y
1355
- * only the same fragment scope parseAce's own copula patterns cover, just
1356
- * with X's lexicon-membership requirement lifted. */
1357
- const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)$/i;
1464
+ * independently for the pre-existing ACE-success path). Y (the object) is a
1465
+ * single token, same as parseAce's own copula fragments; X (the subject) is
1466
+ * ONE OR TWO tokens (Tier-5 playtest fix: "vulcan gizmo is a tool"/"remember
1467
+ * vulcan gizmo is a tool" fell straight to a "teach me" nudge that offered
1468
+ * THIS EXACT phrasing as the fix, then itself failed when tried — a
1469
+ * single-token-only subject was too narrow for a natural 2-word noun phrase,
1470
+ * the same class of gap OWNS_TEACH_RE's own object had before its own
1471
+ * Tier-5 widening, above). The greedy quantifier tries the longer 2-word
1472
+ * subject first, backtracking to 1 word only if the tail doesn't then start
1473
+ * with is/are — the "is/are" anchor immediately after the subject removes
1474
+ * the ambiguity a fully free-form multi-word subject would otherwise have. */
1475
+ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)$/i;
1476
+
1477
+ /** ISA-family predicates (mirrors the private ISA_PREDICATES set defined near
1478
+ * memoryFacts, below, at module scope — both are simple top-level consts
1479
+ * evaluated once at load time, so referencing either from a function defined
1480
+ * earlier in this file is safe: no function here actually RUNS until well
1481
+ * after the whole module has finished loading). Named again here, right by
1482
+ * its one caller, so isGroundedByFact reads standalone. */
1483
+ const MINT_ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
1484
+
1485
+ /** Small CLOSED set of generic English root nouns that count as always-
1486
+ * grounded anchor terms for the mint-fallbacks below (operator refinement,
1487
+ * 2026-07-09) — deliberately NOT added to lexicon-core.json itself (that
1488
+ * file stays the curated ~180-word CODE vocabulary; these are ordinary-
1489
+ * English root nouns with no code meaning at all, confirmed absent from it
1490
+ * today). Their only job is to give a user who hits the "both sides
1491
+ * ungrounded" decline (groundingSuggestionMiss, below) an honest, guessable
1492
+ * way in: ground one brand-new term via one of THESE words first ("every
1493
+ * zorp is a thing"), then chain the other new term off the now-grounded one. */
1494
+ const GENERIC_ANCHOR_NOUNS = new Set(["thing", "concept", "object", "entity"]);
1495
+
1496
+ /** Shared fact-groundedness primitive (Feature A mint-extension, point 2):
1497
+ * true when `term` already appears as the SUBJECT or OBJECT of a previously
1498
+ * taught isa-family fact (rdfs:subClassOf/rdf:type) in memory. A term minted
1499
+ * by EITHER mint-fallback below (this session, or an earlier one — this
1500
+ * reads persisted memory, not session-scoped state) is exactly as legitimate
1501
+ * an anchor for a NEW fact as a static lexicon-core.json word — the whole
1502
+ * point of this extension is letting new vocabulary compound turn over turn
1503
+ * ("every cache is a store" mints "store"; "every store is a container" then
1504
+ * needs "store" to read as known even though it's not in the static lexicon
1505
+ * at all). Read-only, reuses memoryFacts' plain read path (existence only —
1506
+ * no trust-ranking needed here) and normFactTerm's own normalization, so a
1507
+ * fact-grounded term matches under the EXACT spelling teachFact itself stored
1508
+ * it under. Failure-tolerated: no memory dir / no match → false, never a
1509
+ * guessed "yes". */
1510
+ async function isGroundedByFact(term, memoryDir) {
1511
+ if (!memoryDir) return false;
1512
+ const raw = String(term ?? "").trim();
1513
+ if (!raw) return false;
1514
+ const { normFactTerm } = await import("./memory/core.mjs");
1515
+ const t = normFactTerm(raw);
1516
+ if (!t) return false;
1517
+ // TAUGHT-only (same discipline factReadBack's own cax-sco/scm-sco proof
1518
+ // chase already uses, above, for the identical reason: the bulk background
1519
+ // corpus band (ConceptNet, trust 0.7, seeded by the thousands on a fresh
1520
+ // repo) mentions ordinary English words like "store"/"container" constantly
1521
+ // — treating THOSE as "grounded" would silently reopen the general lexicon
1522
+ // bypass this whole feature is deliberately narrow to avoid. Only what the
1523
+ // OPERATOR actually taught (or a prior `tmct syllogise` entailment) anchors
1524
+ // a term here. factRows (not memoryFacts) is used specifically because it's
1525
+ // the one read path that carries sourceTypes for this filter.
1526
+ const rows = await factRows(memoryDir);
1527
+ const isTaught = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
1528
+ return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isTaught(f) && (f.subject === t || f.object === t));
1529
+ }
1530
+
1531
+ /** Shared "is this term grounded in ANY sense" aggregate (Feature A mint-
1532
+ * extension, point 2's named shared helper) — a static lexicon word (any
1533
+ * part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, OR a term
1534
+ * already anchored by a previously taught isa-family fact (isGroundedByFact,
1535
+ * above). Used by unknownObjectFallback's subject/object groundedness checks
1536
+ * below, where no part-of-speech branching follows — just "known or not".
1537
+ * (unknownSubjectFallback's own object-known check, above/below, stays
1538
+ * narrower and NOUN-specific — see its own comment — so an object that's
1539
+ * merely a known ADJECTIVE doesn't get misrouted into the class/subClassOf
1540
+ * branch instead of the property branch.) */
1541
+ async function isGroundedTerm(term, lex, memoryDir) {
1542
+ const raw = String(term ?? "").trim();
1543
+ if (!raw) return false;
1544
+ if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
1545
+ const { classify } = await import("./grammar/lexicon.mjs");
1546
+ if (classify(raw, lex)) return true;
1547
+ return isGroundedByFact(raw, memoryDir);
1548
+ }
1549
+
1550
+ /** The "both sides ungrounded" grounding NUDGE (operator refinement,
1551
+ * 2026-07-09): reuses teachSuggestion's own "compute a hint, APPEND it to
1552
+ * the existing honest-miss message, never replace/silently guess" pattern
1553
+ * (see its docblock, above, and the "did"/"why" append-style construction in
1554
+ * teachLane's own final decline, below) for a DIFFERENT decline case —
1555
+ * rather than mint a relationship between two brand-new terms (a real
1556
+ * fabrication risk, unknownObjectFallback's own explicit safety guard,
1557
+ * below), teachLane's final honest-miss text gets an EXTRA appended nudge
1558
+ * whenever the declined payload fit the "X is/are Y" shape
1559
+ * (UNKNOWN_SUBJECT_RE) but NEITHER side is grounded — an honest, actionable
1560
+ * way in: ground one side via a GENERIC_ANCHOR_NOUNS root first ("every zorp
1561
+ * is a thing"), then chain the other off the now-grounded term. Deliberately
1562
+ * APPENDED rather than a replacement/short-circuit: a "both sides
1563
+ * ungrounded" is/are sentence with a KNOWN subject on one side (e.g. "module
1564
+ * is banana") never reaches this at all (isGroundedTerm(subject) is true, so
1565
+ * the very first return below fires) — that stays unknownObjectFallback's
1566
+ * own mint territory, entirely unaffected here. Returns "" (message
1567
+ * unchanged) whenever the payload doesn't fit the shape, or at least one
1568
+ * side IS already grounded — a DIFFERENT, more specific reason it declined,
1569
+ * where this nudge would be actively unhelpful noise. */
1570
+ async function ungroundedPairHint(payload, lexicon, memoryDir) {
1571
+ if (!memoryDir) return "";
1572
+ const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
1573
+ if (!m) return "";
1574
+ const [, , subjectRaw, objectRaw] = m;
1575
+ const { loadLexicon } = await import("./grammar/lexicon.mjs");
1576
+ const lex = lexicon || loadLexicon();
1577
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
1578
+ if (await isGroundedTerm(objectRaw, lex, memoryDir)) return "";
1579
+ return ` I don't know "${subjectRaw}" or "${objectRaw}" yet. Try grounding one first, e.g. `
1580
+ + `"every ${subjectRaw} is a thing", then "every ${objectRaw} is a ${subjectRaw}".`;
1581
+ }
1358
1582
 
1359
1583
  /** The unknown-SUBJECT direct-write fallback (point 1 + point 2's bare-property
1360
1584
  * extension): tried ONLY after the real ACE grammar (assertTurn) has already
@@ -1367,18 +1591,25 @@ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+)\s+(?:
1367
1591
  * - X is actually a KNOWN lexicon word — then the ACE grammar's own miss was
1368
1592
  * a real structural/vocabulary problem elsewhere (e.g. Y itself unknown as
1369
1593
  * the WRONG part of speech), never silently reinterpreted through this
1370
- * narrow exception;
1371
- * - Y resolves as NEITHER a known noun NOR a known adjective — the OBJECT
1372
- * must still be a term tmct actually knows; an unknown Y stays an honest
1373
- * miss (never a guess), exactly like the pre-existing "monkey is an
1374
- * animal" case.
1375
- * Y resolving as a NOUN writes rdfs:subClassOf (mirrors the ACE grammar's own
1376
- * subClassOf/typeAssertion pattern); Y resolving as an ADJECTIVE (and not also
1377
- * a noun) writes mgx:hasProperty (mirrors the wrapped "remember that X is
1378
- * deprecated" property frame reused here for the bare/unwrapped form too,
1379
- * since the free pass is about the SUBJECT, not about the "remember that"
1380
- * wrapper). Only the "every" determiner records a quantifier (point 3: "a"/
1381
- * bare/"your" read as one specific entity, not a class-level generalization). */
1594
+ * narrow exception. (NOTE: this stays a STATIC-lexicon-only check,
1595
+ * deliberately not widened to isGroundedTerm a subject that's grounded
1596
+ * only via a PRIOR taught fact, not the static lexicon, is precisely the
1597
+ * case unknownObjectFallback, below, owns instead.)
1598
+ * - Y resolves as NEITHER a known noun NOR a known adjective NOR a term
1599
+ * already grounded by a prior taught fact / a GENERIC_ANCHOR_NOUNS root
1600
+ * (Feature A mint-extension, point 2 a term minted by
1601
+ * unknownObjectFallback, below, reads exactly as known here as any
1602
+ * lexicon word)the OBJECT must still be a term tmct actually knows;
1603
+ * an unknown Y stays an honest miss (never a guess), exactly like the
1604
+ * pre-existing "monkey is an animal" case.
1605
+ * Y resolving as a NOUN (or fact-/anchor-grounded) writes rdfs:subClassOf
1606
+ * (mirrors the ACE grammar's own subClassOf/typeAssertion pattern); Y
1607
+ * resolving as an ADJECTIVE (and not also a noun) writes mgx:hasProperty
1608
+ * (mirrors the wrapped "remember that X is deprecated" property frame —
1609
+ * reused here for the bare/unwrapped form too, since the free pass is about
1610
+ * the SUBJECT, not about the "remember that" wrapper). Only the "every"
1611
+ * determiner records a quantifier (point 3: "a"/bare/"your" read as one
1612
+ * specific entity, not a class-level generalization). */
1382
1613
  async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
1383
1614
  if (!memoryDir) return null;
1384
1615
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
@@ -1389,7 +1620,12 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1389
1620
  // A known X's own ACE miss is a real miss — never silently reinterpreted here.
1390
1621
  if (classify(subjectRaw, lex)) return null;
1391
1622
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
1392
- if (lookupNoun(lex, objectRaw)) {
1623
+ // Point 2 (mint-extension): a PRIOR turn's minted term, or a
1624
+ // GENERIC_ANCHOR_NOUNS root, grounds Y just as legitimately as a static
1625
+ // lexicon noun — both are always treated as class-level (never property),
1626
+ // consistent with unknownObjectFallback (below) always minting a CLASS.
1627
+ if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
1628
+ || (await isGroundedByFact(objectRaw, memoryDir))) {
1393
1629
  return teachFact(memoryDir, sessionId, {
1394
1630
  subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
1395
1631
  });
@@ -1404,6 +1640,67 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1404
1640
  return null; // Y unknown too — decline honestly, never guess
1405
1641
  }
1406
1642
 
1643
+ /** The unknown-OBJECT mint fallback (Feature A, 2026-07-09 operator-authorized
1644
+ * vocabulary-growth extension): the MIRROR of unknownSubjectFallback, above —
1645
+ * same "X is/are Y" payload shape (UNKNOWN_SUBJECT_RE, reused verbatim,
1646
+ * never a second regex for the identical shape), tried as a SIBLING call
1647
+ * right after unknownSubjectFallback in teachLane (below), but firing on the
1648
+ * OPPOSITE asymmetry: SUBJECT already grounded (a real lexicon-core.json
1649
+ * word of ANY part of speech, a GENERIC_ANCHOR_NOUNS root, OR a term a PRIOR
1650
+ * turn already minted via either fallback — isGroundedTerm, shared with this
1651
+ * check) and OBJECT completely ungrounded. Mints the object as a new
1652
+ * class-level concept (rdfs:subClassOf, same predicate/quantifier machinery
1653
+ * teachFact/unknownSubjectFallback already use) so ordinary conversation can
1654
+ * build up new vocabulary turn over turn: "every cache is a store" (subject
1655
+ * "cache" grounded via the static lexicon) mints "store"; a LATER "every
1656
+ * store is a container" then finds "store" grounded via the fact just
1657
+ * minted (not the static lexicon at all) and mints "container" the same way.
1658
+ *
1659
+ * GATED ON A GENUINE UNIVERSAL QUANTIFIER ("every"/"each"/"all" — never bare/
1660
+ * "a"/"an"/"your"): minting a NEW CLASS-LEVEL CONCEPT is inherently a general
1661
+ * claim about a class, the same "every"/bare distinction unknownSubjectFallback's
1662
+ * own docblock already draws (point 3) between a class generalization and a
1663
+ * claim about ONE specific entity. This is load-bearing, not cosmetic: a bare
1664
+ * "module is banana" (a KNOWN lexicon subject, an unrecognized bare object,
1665
+ * NO determiner at all) is a pinned regression — it must stay a plain honest
1666
+ * miss, never silently minted — and a WRAPPED "remember that X is <adjective>"
1667
+ * (also determiner-less at the subject) must keep falling through to
1668
+ * TEACH_PROPERTY_RE's own, more permissive arbitrary-adjective path
1669
+ * unimpeded. Requiring the determiner keeps this fallback's mint exactly as
1670
+ * narrow as the vocabulary-growth feature actually needs (every required
1671
+ * test case in this feature's own spec phrases the mint sentence with
1672
+ * "every"), without swallowing either of those pre-existing shapes.
1673
+ *
1674
+ * The critical safety guard (operator's own stated worry, mirrored from
1675
+ * unknownSubjectFallback's docblock): this must NEVER silently mint when
1676
+ * BOTH sides are ungrounded ("every zorp is a florp" — two brand-new,
1677
+ * never-seen terms with no relation to each other tmct actually knows) —
1678
+ * declines here (null), and teachLane's own final honest-miss text picks up
1679
+ * an appended grounding NUDGE for exactly this case (ungroundedPairHint,
1680
+ * above) — never a silent guess, never a silent hard-swallowed decline
1681
+ * either. Any OTHER decline (subject ungrounded + object grounded — not this
1682
+ * fallback's asymmetry; or both grounded — already known, nothing to mint;
1683
+ * or no genuine universal quantifier) falls through as a plain null,
1684
+ * letting the ordinary teachLane cascade (property teach, then the generic
1685
+ * honest-miss text) continue unaffected. */
1686
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
1687
+ if (!memoryDir) return null;
1688
+ const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
1689
+ if (!m) return null;
1690
+ const [, det, subjectRaw, objectRaw] = m;
1691
+ if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
1692
+ const { loadLexicon } = await import("./grammar/lexicon.mjs");
1693
+ const lex = lexicon || loadLexicon();
1694
+ const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
1695
+ if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
1696
+ const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
1697
+ if (objectGrounded) return null; // object already known — nothing to mint
1698
+ const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
1699
+ return teachFact(memoryDir, sessionId, {
1700
+ subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
1701
+ });
1702
+ }
1703
+
1407
1704
  // ---- BUG 3 (2026-07-09, operator-authorized generalizing — "I don't know
1408
1705
  // where that ban came from, overturn it. build it."): general verb-to-
1409
1706
  // predicate teaching. "remember tony has a hat" / "remember margo eats ribs"
@@ -1442,7 +1739,21 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1442
1739
  * multi-word subject simply doesn't match here and honestly declines
1443
1740
  * (point 6) rather than risk a wrong split — the is/are-specific frames
1444
1741
  * elsewhere in this lane already own that broader territory. */
1445
- const GENERAL_VERB_TEACH_RE = /^([\w'-]+)\s+([a-z]+)\s+(.+?)[.!?]*$/i;
1742
+ // Tier-5 playtest fix (this session): a frequency/degree ADVERB commonly sits
1743
+ // between a bare-name subject and the real verb in a natural teaching
1744
+ // sentence — the operator's own example, "remember that TaskController
1745
+ // usually needs review", mis-split subject="TaskController", VERB="usually"
1746
+ // (GENERAL_VERB_TEACH_RE had no way to see "usually" wasn't the verb), minting
1747
+ // a nonsense mgx:usually predicate and, worse, garbling the confirmation
1748
+ // itself: thirdPersonSingularSurface's naive fallback appended "-ies" to the
1749
+ // unrecognized lemma, surfacing "taskcontroller usuallies needs review". A
1750
+ // closed, non-capturing adverb-skip (never itself eligible to BE the verb)
1751
+ // fixes this at the source for both the teach shape and its query-side twins
1752
+ // below, without widening what counts as a recognized shape at all — the same
1753
+ // "recognition closed, mapping generalized" split this lane already uses.
1754
+ const TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|never|always|typically|generally|"
1755
+ + "occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually)\\s+)?";
1756
+ const GENERAL_VERB_TEACH_RE = new RegExp(`^([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[.!?]*$`, "i");
1446
1757
  /** Determiners/quantifiers that make the FIRST token an article, not a real
1447
1758
  * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
1448
1759
  * would otherwise happily bind them as a 1-token subject and misread the
@@ -1477,13 +1788,23 @@ const GENERAL_VERB_ANYWHERE_EXCLUDE_RE = /\b(?:is|are|am|owns|maintains)\b/i;
1477
1788
  * documented contract) and this falls back to the verb AS TYPED — still a
1478
1789
  * perfectly storable/retrievable predicate, just not cross-inflection
1479
1790
  * canonicalized. Never a hand-curated per-verb table entry required. */
1791
+ // Bug A (operator manual-chat find, this session): only the exact raw strings
1792
+ // "has"/"have" were special-cased onto HAS_A_PREDICATE above — past tense "had"
1793
+ // (or "having") fell through to the generic mgx:<lemma> path, where the lemma of
1794
+ // "had" IS "have", and predicatePhrase's thirdPersonSingularSurface fallback
1795
+ // naively appends "s" to any unrecognized lemma ending ("have"+"s" = "haves" —
1796
+ // wrong; the correct irregular is "has"). Fixed by checking the LEMMA (not just
1797
+ // the raw verb) for "have" — this catches had/having/has/have uniformly, so
1798
+ // "remember X had soup" reads back "...has soup", never "...haves soup".
1480
1799
  async function generalVerbPredicate(verb) {
1481
1800
  const v = String(verb || "").toLowerCase();
1482
1801
  if (v === "has" || v === "have") return HAS_A_PREDICATE;
1483
1802
  try {
1484
1803
  const { proseLemma } = await import("./prose-nlp.mjs");
1485
1804
  const lemma = proseLemma();
1486
- return `mgx:${lemma ? lemma(v) : v}`;
1805
+ const l = lemma ? lemma(v) : v;
1806
+ if (l === "have") return HAS_A_PREDICATE;
1807
+ return `mgx:${l}`;
1487
1808
  } catch {
1488
1809
  return `mgx:${v}`;
1489
1810
  }
@@ -1510,6 +1831,35 @@ async function generalVerbTeach(payload) {
1510
1831
  return { subject, predicate, object };
1511
1832
  }
1512
1833
 
1834
+ // ---- General verb-to-predicate DIRECT-QUESTION retrieval (item 5, this
1835
+ // session's follow-up to the teach mechanism above): "does margo eat ribs" /
1836
+ // "did margo eat ribs" / "what does margo eat" against a fact taught via
1837
+ // generalVerbTeach. "did" joins "does" so past-tense forms work too (also
1838
+ // GROUP 3 Bug B — "what did X have"/"did margo eat ribs"). Wired into
1839
+ // factReadBack (below), which only runs on an already-true `miss`, so these
1840
+ // never race ask.mjs's closed structural grammar for a real graph query
1841
+ // ("does TaskController call widget" resolves there first, this lane is never
1842
+ // reached). Both run the SAME GENERAL_VERB_EXCLUDE_RE/GENERAL_VERB_ANYWHERE_
1843
+ // EXCLUDE_RE decline guards generalVerbTeach uses, and route the verb through
1844
+ // the SAME generalVerbPredicate (not a re-implementation), so the has/have
1845
+ // bridge (and Bug A's had/having lemma fix) is automatic on the query side too. ----
1846
+ // Same adverb-skip as GENERAL_VERB_TEACH_RE above (TEACH_ADVERB_SKIP_SRC),
1847
+ // reused so "does TaskController usually need review"/"what does
1848
+ // TaskController usually need" read back a fact taught with the adverb
1849
+ // skipped the same way, never mis-splitting "usually" as the verb here either.
1850
+ const GENERAL_VERB_YESNO_RE = new RegExp(`^(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[?.!\\s]*$`, "i");
1851
+ const GENERAL_VERB_OPEN_RE = new RegExp(`^what\\s+(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)[?.!\\s]*$`, "i");
1852
+ /** GENERAL_VERB_EXCLUDE_RE was written for generalVerbTeach's fully-conjugated
1853
+ * declarative verb ("X OWNS Y", "X MAINTAINS Y") — but "does/did X <verb> Y"
1854
+ * captures the BARE INFINITIVE after do-support ("does X OWN Y", never "does X
1855
+ * owns Y"), so "owns"/"maintains" literally never appear in genYN/genOpen's
1856
+ * captured verb even when the sentence names exactly that relation. Found live
1857
+ * (a real false "no" against a genuinely-true taught ownership fact, since
1858
+ * generalVerbPredicate("own") mints a DIFFERENT predicate — mgx:own — than the
1859
+ * ownership frame's own OWNED_BY_PREDICATE): the query-side guard needs the
1860
+ * bare-infinitive counterpart too. */
1861
+ const GENERAL_VERB_QUERY_EXCLUDE_RE = /^(?:be|own|maintain)$/i;
1862
+
1513
1863
  /** Sentence forms to try asserting for a teach payload: the payload as-is, and
1514
1864
  * (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
1515
1865
  * grammar actually lands. */
@@ -1574,7 +1924,14 @@ function teachSuggestion(payload) {
1574
1924
  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+\S+/i;
1575
1925
 
1576
1926
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1577
- const rawInput = String(query).trim();
1927
+ // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
1928
+ // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
1929
+ // remember that TaskController is fragile") corrupted TEACH_RE's own match —
1930
+ // applyPreambleFrames is idempotent no-op on an already-clean teach sentence
1931
+ // (none of its frames' anchors — greeting/thanks/ack/modal/explain/show-give-me/
1932
+ // topic-switch/hedge — match ordinary teach phrasing, verified against this
1933
+ // lane's own test corpus), so this is purely additive.
1934
+ const rawInput = applyPreambleFrames(String(query).trim());
1578
1935
  const m = rawInput.match(TEACH_RE);
1579
1936
  const wrappedInput = m ? m[1].trim() : null;
1580
1937
  // "your X is a/an Y" (Feature A) — a plain casual synonym for "a/an X is a
@@ -1617,6 +1974,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1617
1974
  });
1618
1975
  if (stored) return stored;
1619
1976
  }
1977
+ // PASSIVE ownership — "<X> is owned by <Name>" (Tier-5 playtest, cycle 2).
1978
+ // Same bare-form gate as the active shape just above: a Capitalized owner
1979
+ // name AND no interrogative lead, so "is TaskController owned by anyone"
1980
+ // (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
1981
+ // a bogus fact here.
1982
+ const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
1983
+ if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
1984
+ const stored = await teachFact(memoryDir, sessionId, {
1985
+ subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
1986
+ });
1987
+ if (stored) return stored;
1988
+ }
1620
1989
 
1621
1990
  // "some Xs are Ys" / "a few Xs are Ys" (Feature A) — the plural class-
1622
1991
  // membership quantifier shape. ACE has no quantifier-phrase pattern at all
@@ -1641,6 +2010,33 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1641
2010
  subject, predicate: SUBCLASS_PREDICATE, object, quantifier,
1642
2011
  });
1643
2012
  if (stored) return stored;
2013
+ } else {
2014
+ // Tier-5 playtest fix (cycle 2), found live: "remember that some
2015
+ // functions are risky" — Y ("risky") is not a lexicon NOUN, so the
2016
+ // subclass path just above correctly declines it (SOME_A_FEW_RE is
2017
+ // subclass-only, by design — "risky" isn't even in the closed
2018
+ // lexicon at all, as either noun or adjective, so gating this decline
2019
+ // on "is Y a known adjective" missed the actual case entirely on the
2020
+ // first attempt at this fix). Without this guard, the sentence fell
2021
+ // through to unknownSubjectFallback/TEACH_PROPERTY_RE below, which DO
2022
+ // tolerate a multi-word subject with NO vocabulary check on the
2023
+ // complement at all — silently mis-teaching the LITERAL 2-word string
2024
+ // "some functions" as if it were one proper-noun subject ("noted —
2025
+ // remembered: some functions is risky", the quantifier word baked
2026
+ // wrongly into the subject and a subject/verb agreement error to
2027
+ // boot), a fact "how many functions are risky" could never sensibly
2028
+ // read back either (HOW_MANY_ARE_RE's own reader only ever looks for
2029
+ // the SUBCLASS_PREDICATE shape this path would have stored, not this
2030
+ // one). A quantified PROPERTY claim isn't a supported shape yet (only
2031
+ // a quantified SUBCLASS claim is) — decline honestly here instead of
2032
+ // silently mis-teaching, rather than let a later, less-specific frame
2033
+ // guess a wrong split.
2034
+ return {
2035
+ text: `I can only remember a quantified fact as "${quantifier} ${someMatch[2]} are <a kind of thing>" (like "${quantifier} bugs are issues") — `
2036
+ + `a quantified claim about a PROPERTY ("${quantifier} ${someMatch[2]} are ${object}") isn't a shape I can store yet. `
2037
+ + `I can remember "${someMatch[2]} are ${object}" for one specific ${subject}, though — try naming it directly.`,
2038
+ via: "teach-miss", miss: true,
2039
+ };
1644
2040
  }
1645
2041
  }
1646
2042
 
@@ -1665,7 +2061,28 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1665
2061
  let payload = null;
1666
2062
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
1667
2063
  else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
1668
- if (!payload) return null;
2064
+ if (!payload) {
2065
+ // Tier-5 playtest fix (cycle 3), found live: "remember that every
2066
+ // controller needs review" — a QUANTIFIED subject ("every X", declined
2067
+ // by generalVerbTeach's own GENERAL_VERB_DETERMINER_RE, by design — see
2068
+ // its docblock on the ambiguity risk of a free-form multi-word subject)
2069
+ // combined with a non-copula verb ("needs", not is/are/owns/maintains)
2070
+ // fits NONE of the recognizers above, so `payload` stays null and this
2071
+ // used to return null SILENTLY — the exact "wrong-context wall" bug
2072
+ // class Bug 3's generalVerbTeach mechanism was built to close for
2073
+ // "remember margo eats ribs", re-escaping here through a combination
2074
+ // that mechanism's own deliberate subject-shape restriction doesn't
2075
+ // cover. An explicit "remember/note/…"-wrapped sentence is an
2076
+ // UNAMBIGUOUS teach-intent signal — falling through to the ordinary
2077
+ // structural-query wall is a wrong-context reply even when nothing here
2078
+ // can actually STORE the fact; if `wrapped` stood, keep going with it as
2079
+ // the payload so the residue-detection/final-decline logic below still
2080
+ // runs (never a guess at storing it, just never silence either). A bare,
2081
+ // unwrapped sentence that also fits no shape has no such signal — return
2082
+ // null and let the ordinary cascade decide, unchanged.
2083
+ if (!wrapped) return null;
2084
+ payload = wrapped;
2085
+ }
1669
2086
  // Try to store it (a live session provides the write target). assertTurn returns
1670
2087
  // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
1671
2088
  if (memoryDir) {
@@ -1684,6 +2101,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1684
2101
  // the exact narrowing rules (object must still be known, etc.).
1685
2102
  const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
1686
2103
  if (fallback) return fallback;
2104
+ // MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
2105
+ // growth extension): the known-subject/unknown-object asymmetry — tried
2106
+ // right after the unknown-subject case declines, so a subject the STATIC
2107
+ // lexicon (or a prior taught fact) already grounds can mint a brand-new
2108
+ // object term. See unknownObjectFallback's own docblock for the exact
2109
+ // narrowing rules (the "both sides ungrounded" safety guard, etc.).
2110
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
2111
+ if (objectFallback) return objectFallback;
1687
2112
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
1688
2113
  // (a bare "X is deprecated" is never silently reified), and only after the
1689
2114
  // ACE grammar declined (unknown words / not the membership shape), so a
@@ -1742,9 +2167,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1742
2167
  + "I can only teach facts using tmct's own code-vocabulary nouns (like module, class, function…), "
1743
2168
  + "not arbitrary new terms."
1744
2169
  : "";
2170
+ // Grounding NUDGE (operator refinement, 2026-07-09): APPENDED, never a
2171
+ // replacement, exactly like "did" above — see ungroundedPairHint's own
2172
+ // docblock for why this is scoped to the "both sides ungrounded, fits the
2173
+ // X is/are Y shape" case only.
2174
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
1745
2175
  return {
1746
2176
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
1747
- + `words I know.${did} Type /memory to see what I already remember.`,
2177
+ + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
1748
2178
  via: "teach-miss", miss: true,
1749
2179
  };
1750
2180
  }
@@ -1754,11 +2184,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1754
2184
  // ONLY (the caller gates on a miss) and every pattern is a WHOLE-LINE self/session
1755
2185
  // reference with no graph entity or predicate, so real graph queries ("what does X
1756
2186
  // import", the meta "what does imports mean", "what did i ask before") never match.
1757
- const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
2187
+ // Bug D (operator manual-chat find, this session): "what is in your memory"/
2188
+ // "what's in your memory" is a plain synonym of the bare "what do you know" —
2189
+ // widened here rather than folded in as "what do you remember" (that phrase is
2190
+ // ALREADY WHOLE_RECALL_RE's own, more specific, territory — it lists every
2191
+ // remembered fact, a strictly better answer than this lane's short summary; see
2192
+ // WHOLE_RECALL_RE's docblock below and the pinned "'what do you remember' ...
2193
+ // STILL list facts" test — folding it in here would silently regress that).
2194
+ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what(?:'s|s|\s+is)\s+in\s+your\s+memory)$/;
1758
2195
  // 0.8.2 WS4 wall kindness (c): the most likely stranger openers — "what does this
1759
2196
  // app/codebase do", "what is this app (for)" — join the orientation lane, so a
1760
2197
  // first-touch question gets the live overview instead of the grammar wall.
1761
- const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+(?:this|the)\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin))$/;
2198
+ const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:app|codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+does\s+(?:this|the)\s+(?:app|code|codebase|project|repo)\s+do|what\s+is\s+(?:this|the)\s+app(?:\s+for)?|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin)|what\s+should\s+i\s+(?:read|look\s+at)\s+first(?:\s+to\s+understand\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+should\s+i\s+start\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?|where\s+do\s+i\s+begin\s+reading(?:\s+(?:this\s+)?(?:codebase|code|repo|repository|project))?)$/;
1762
2199
 
1763
2200
  /** A SHORT memory summary (never a fact dump) for the bare "what do you know".
1764
2201
  * This branch only fires when rows.length === 0 — i.e. precisely the case where
@@ -1795,15 +2232,57 @@ async function memorySummary(memoryDir, graph) {
1795
2232
  // case-sensitive, so this reads the ORIGINAL query text, never metaLane's
1796
2233
  // lowercased `q` (authorLane's same discipline, just above/below).
1797
2234
  const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
2235
+ /** The SUBJECT-FIRST word order of the SAME question ("what saveStore does" vs
2236
+ * "what does saveStore do") — Tier 6 playtest, §3b surface-variation axis: a
2237
+ * perfectly natural alternate phrasing of an ALREADY-recognized intent that
2238
+ * used to hit the raw grammar wall outright (MODULE_ORIENT_RE's own anchor
2239
+ * requires "does" BEFORE the term). Tried only when MODULE_ORIENT_RE/
2240
+ * MODULE_PURPOSE_RE both miss; the entity-resolution gate just below (a real,
2241
+ * UNIQUE graph entity or this lane declines) is what keeps this loose an
2242
+ * ending safe — a syntactic match against a term that isn't a real entity
2243
+ * simply falls through unchanged, same as every other lane in this file. */
2244
+ const MODULE_ORIENT_SVO_RE = /^what\s+(.+?)\s+does\??$/i;
2245
+ // Seonix Batch 3 (3a) — purpose/identity phrasing: "whats X for"/"what's X
2246
+ // about"/"what is X for", the sibling of "what does X do" that asks for the
2247
+ // SAME module-grain overview. Deliberately does NOT claim the literal noun
2248
+ // "app" ("what is this app for") — META_ORIENT_RE (above) already hardcodes
2249
+ // that exact phrasing and is checked BEFORE moduleOrientLane runs (metaLane's
2250
+ // own ordering), so this regex only ever gets a chance at OTHER resolvable
2251
+ // terms. "what(?:'s|s|\s+is)" mirrors PERSONAL_ASSISTANT_NUDGE_RE's own
2252
+ // tolerance for the bare "whats" contraction spelling, just below.
2253
+ const MODULE_PURPOSE_RE = /^what(?:'s|s|\s+is)\s+(.+?)\s+(?:for|about)\??$/i;
2254
+
2255
+ /** A leading politeness/formal-ESL wrapper this lane's own anchored regexes
2256
+ * otherwise miss entirely (Tier 6 playtest): "please explain what does X do"
2257
+ * starts with neither "what"/"whats" (MODULE_ORIENT_RE/MODULE_PURPOSE_RE's own
2258
+ * anchor) nor bare "explain" (normalize.mjs's own EXPLAIN_WRAPPER_RE, which
2259
+ * requires NOTHING before "explain" — a leading "please" defeats it too), so
2260
+ * it fell straight to the raw grammar wall. A repeated (please|kindly) plus an
2261
+ * optional "explain [to me]" — both closed, both optional, so a bare "what
2262
+ * does X do" is untouched (the whole prefix matches empty). */
2263
+ const MODULE_ORIENT_POLITENESS_RE = /^(?:(?:please|kindly)\s+)*(?:explain\s+(?:to\s+me\s+)?)?/i;
1798
2264
 
1799
2265
  /** authorLane's discipline, mirrored: a closed regex + an EXACT, UNIQUE
1800
2266
  * resolution via resolveEntity, else null — never a guess. Pronoun/self
1801
- * subjects ("what does it/this do") are META_ORIENT_RE's/isConversational's
1802
- * territory, not this lane's — declined here so they fall through unchanged. */
2267
+ * subjects ("what does it/this do", "what's it for") are META_ORIENT_RE's/
2268
+ * isConversational's territory, not this lane's — declined here so they
2269
+ * fall through unchanged. */
1803
2270
  async function moduleOrientLane(query, { graph }) {
1804
2271
  if (!graph) return null;
1805
- const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1806
- const m = q.match(MODULE_ORIENT_RE);
2272
+ let q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2273
+ // Tier 6 playtest: this lane reads the ORIGINAL (case-preserving) query text
2274
+ // and never ran any of the general-purpose normalization passes the rest of
2275
+ // the file uses for the SAME class of surface noise — correctMisspellings
2276
+ // for a typo'd anchor word ("waht dose the logger modul do"), applyPreambleFrames
2277
+ // for a topic-switch/self-interruption preamble ("scratch that, what does X
2278
+ // do") — plus a lane-local politeness strip for "please explain X" (applyPreambleFrames's
2279
+ // own EXPLAIN_WRAPPER_RE requires the string to literally START with "explain",
2280
+ // so a LEADING "please"/"kindly" ahead of it defeats that frame; see
2281
+ // MODULE_ORIENT_POLITENESS_RE's own docblock). All three are additive,
2282
+ // closed-set, and idempotent on an already-clean query, so applying them here
2283
+ // only ever WIDENS what resolves, never narrows it.
2284
+ q = applyPreambleFrames(correctMisspellings(q)).replace(MODULE_ORIENT_POLITENESS_RE, "");
2285
+ const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
1807
2286
  if (!m) return null;
1808
2287
  const term = m[1].trim();
1809
2288
  if (/^(?:it|this|that|they|them)$/i.test(term)) return null;
@@ -2179,7 +2658,11 @@ function configFor(repoPath) {
2179
2658
  /** Resolve a free-text term to a single graph entity via the ask engine's own
2180
2659
  * tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
2181
2660
  * Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
2182
- * records fewer ids / does not update the focus, never a crash or a wrong id. */
2661
+ * records fewer ids / does not update the focus, never a crash or a wrong id.
2662
+ * The leading-article-strip + trailing-grain-word disambiguation ("the logger
2663
+ * module" -> Module-only "logger") lives centrally in resolveObject itself
2664
+ * (ask.mjs) so every direct caller of resolveObject (ask()'s own WHERE/describe
2665
+ * grammar, traverse(), etc.) gets it too, not just this wrapper. */
2183
2666
  async function resolveEntity(graph, term) {
2184
2667
  if (!graph || !term) return null;
2185
2668
  try {
@@ -2398,6 +2881,13 @@ const FACT_PREDICATE_PHRASES = {
2398
2881
  * renders verbatim, unchanged from before this fix. */
2399
2882
  function thirdPersonSingularSurface(lemma) {
2400
2883
  const w = String(lemma || "");
2884
+ // Bug A safety net: "have" should never reach this naive fallback at all
2885
+ // (generalVerbPredicate special-cases it onto mgx:hasA before a predicate is
2886
+ // ever minted), but if some OTHER path ever reaches here with it as typed —
2887
+ // e.g. wink-nlp unavailable so lemma degrades to the raw verb — the naive
2888
+ // "+s" rule would produce the wrong-MEANING "haves" instead of the correct
2889
+ // irregular "has".
2890
+ if (/^have$/i.test(w)) return "has";
2401
2891
  if (/[a-z]y$/i.test(w) && !/[aeiou]y$/i.test(w)) return `${w.slice(0, -1)}ies`;
2402
2892
  if (/(?:s|x|z|ch|sh|o)$/i.test(w)) return `${w}es`;
2403
2893
  return `${w}s`;
@@ -2523,6 +3013,25 @@ function factTermVariants(normFactTerm, term) {
2523
3013
  return v;
2524
3014
  }
2525
3015
 
3016
+ /** GENERIC "kind" nouns a taught subject's head word is often built from
3017
+ * ("logger MODULE", "task CONTROLLER") — excluded from the head-word
3018
+ * overlap fallback both KNOW_ABOUT_RE's "what do you know about X" listing
3019
+ * and IS_ADJECTIVE_YESNO_RE's property yes/no reader use (below): a bare
3020
+ * length >= 4 floor alone isn't enough, since "module" (6 chars) is shared
3021
+ * by "logger module" AND "validate module" and any OTHER "X module" taught
3022
+ * subject — without this exclusion, "is the validate module deprecated"
3023
+ * confidently answered YES off a fact taught for "logger module" (found
3024
+ * live, Tier-5 playtest cycle 5 — a real false-positive fabrication, not a
3025
+ * routing gap, caught before shipping). Mirrors RECALL_STOPWORDS' own
3026
+ * path-noise exclusion (src/lib/mjs never counting as a real overlap
3027
+ * either) — same principle, a different word class. */
3028
+ const GENERIC_ENTITY_WORDS = new Set([
3029
+ "module", "modules", "class", "classes", "function", "functions",
3030
+ "method", "methods", "handler", "handlers", "controller", "controllers",
3031
+ "service", "services", "component", "components", "flow", "flows",
3032
+ "thing", "things", "item", "items", "object", "objects", "commit", "commits",
3033
+ ]);
3034
+
2526
3035
  // ---- PLAN_ontology-hierarchies.md §3 tracks (a)+(b): synonymsOf(term) —
2527
3036
  // QUERY-TIME term expansion wiring the two already-parsed-but-inert synonym
2528
3037
  // resources. §1's "two vocabulary gates" distinction: this widens what a
@@ -2605,8 +3114,14 @@ async function synonymsOf(term) {
2605
3114
  * doesn't parse; checked against the isa-family fact predicates only. */
2606
3115
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
2607
3116
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
2608
- /** "what do you know about caches" — the open recall-everything form. */
2609
- const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
3117
+ /** "what do you know about caches" — the open recall-everything form. Bug E
3118
+ * (operator manual-chat find, this session) widened this to also accept
3119
+ * "what is in your memory about X" / "what's in your memory about X" / "what
3120
+ * do you remember about X" as plain synonyms — none of these collide with an
3121
+ * existing more-specific lane (TOLD_ABOUT_RE only owns "what did i tell you
3122
+ * about X"; WHOLE_RECALL_RE's own "what do you remember" has no "about X"
3123
+ * tail, so it's a disjoint shape). */
3124
+ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+in\s+your\s+memory\s+about|what\s+do\s+you\s+remember\s+about)\s+(.+?)[?.!\s]*$/i;
2610
3125
  /** How many facts a single answer lists before the remainder is paged with "more". */
2611
3126
  const FACT_ANSWER_CAP = 32;
2612
3127
 
@@ -2631,7 +3146,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
2631
3146
  if (!metaTerm && miss && !envelope?.parsed) {
2632
3147
  const m = q.match(BARE_WHATIS_RE)
2633
3148
  || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
2634
- if (m) metaTerm = m[1];
3149
+ // Seonix Batch 2 Fix 3: strip a curated trailing scope clause ("… in this
3150
+ // graph"/"… in this codebase"/…) the same way grammar.mjs's T5 and
3151
+ // metaTermOf do — BARE_WHATIS_RE's capture is otherwise the literal glued
3152
+ // tail, verbatim.
3153
+ if (m) metaTerm = stripTrailingScopeFiller(m[1]);
2635
3154
  }
2636
3155
  if (metaTerm) {
2637
3156
  // BUG 1 fix: "what is a tree used for" parses (grammar.mjs T5) to the
@@ -2682,15 +3201,93 @@ async function factAnswer(memoryDir, query, envelope, miss) {
2682
3201
  const know = q.match(KNOW_ABOUT_RE);
2683
3202
  if (know) {
2684
3203
  const variants = factTermVariants(normFactTerm, know[1]);
2685
- const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject) || variants.has(f.object));
3204
+ const rows = await memoryFacts(memoryDir);
3205
+ // Bug E subtype walk (operator follow-up request, this session): a
3206
+ // cycle-safe BFS DOWNWARD over isa-family facts from the term's own
3207
+ // variants — every fact whose OBJECT is in the current frontier
3208
+ // contributes its SUBJECT as a known SUBTYPE (and the next hop's
3209
+ // frontier), so "every widget is a component" + "button is a widget" +
3210
+ // "button has a blue-color" lets "what do you know about component"
3211
+ // surface the button fact too, even though "button" never literally
3212
+ // mentions "component". Capped at 8 hops — this is a listing operation,
3213
+ // not findIsaChain's strict maxHops:2 proof-chase, but still bounded as a
3214
+ // safety net against pathological data.
3215
+ //
3216
+ // The chain itself is walked over TAUGHT isa facts only (same "isTaught"
3217
+ // discipline the live cax-sco/scm-sco proof chase already uses, below) —
3218
+ // the bulk background corpus (thousands of ConceptNet/seon "is a kind of"
3219
+ // rows) would otherwise chain almost ANY term into hundreds of coincidental
3220
+ // "subtypes" that have nothing to do with what the OPERATOR actually
3221
+ // taught, drowning the real answer and defeating the negative-case
3222
+ // discipline this feature exists to preserve. The literal-mention hits
3223
+ // (the ORIGINAL, non-subtype half of the filter below) still include
3224
+ // corpus facts exactly as before — only the SUBTYPE DISCOVERY chain is
3225
+ // taught-only.
3226
+ // `rows` here is memoryFacts()'s plain {subject,predicate,object,provenance}
3227
+ // shape (no `sourceTypes` — that's factRows()'s own trust-enriched shape),
3228
+ // so the taught/corpus distinction reads the SAME provenance-string
3229
+ // convention renderFactLine already keys its own corpus-vs-taught framing
3230
+ // on, just above.
3231
+ const isTaughtFact = (f) => !String(f.provenance || "").includes("corpus:") && !String(f.provenance || "").includes("web:");
3232
+ const isaRows = rows.filter((f) => ISA_PREDICATES.has(f.predicate) && isTaughtFact(f));
3233
+ const subtypeSubjects = new Set();
3234
+ let frontier = variants;
3235
+ for (let hop = 0; hop < 8 && frontier.size; hop += 1) {
3236
+ const nextSubjects = new Set();
3237
+ for (const f of isaRows) {
3238
+ if (frontier.has(f.object) && !subtypeSubjects.has(f.subject)) nextSubjects.add(f.subject);
3239
+ }
3240
+ if (!nextSubjects.size) break;
3241
+ for (const s of nextSubjects) subtypeSubjects.add(s);
3242
+ const nextFrontier = new Set();
3243
+ for (const s of nextSubjects) for (const v of factTermVariants(normFactTerm, s)) nextFrontier.add(v);
3244
+ frontier = nextFrontier;
3245
+ }
3246
+ let hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object) || subtypeSubjects.has(f.subject));
3247
+ // Tier-5 playtest fallback: a taught fact's subject is often a real NOUN
3248
+ // PHRASE ("logger module", "tasks handler"), but a natural follow-up
3249
+ // shortens it to one head word ("what do you know about the logger") —
3250
+ // an exact-variant miss above, since "logger" !== "logger module". Only
3251
+ // tried when the exact/subtype pass found NOTHING (never overrides a real
3252
+ // hit), and only on a whole WORD (length >= 4, the same floor
3253
+ // resolveObject's own tier-3/5 containment checks use to keep a short
3254
+ // staccato word from hijacking an unrelated fact) shared between the
3255
+ // query term and a fact's subject/object — a listing/discovery feature
3256
+ // (like the subtype walk above), not a yes/no claim, so a slightly wider
3257
+ // recall net is consistent with this lane's existing inclusiveness.
3258
+ if (!hits.length) {
3259
+ const queryWords = normFactTerm(know[1]).split(/\s+/).filter((w) => w.length >= 4 && !GENERIC_ENTITY_WORDS.has(w));
3260
+ if (queryWords.length) {
3261
+ const wordsOf = (s) => new Set(String(s || "").split(/\s+/));
3262
+ const overlaps = (term) => { const w = wordsOf(term); return queryWords.some((qw) => w.has(qw)); };
3263
+ hits = rows.filter((f) => overlaps(f.subject) || overlaps(f.object));
3264
+ }
3265
+ }
3266
+ // A genuinely empty result here is a real miss (Tier-5 playtest cycle 3:
3267
+ // "what do you know about the last commit" needs a TEACH-OFFER, not a
3268
+ // bare wall — added as a LATE runTurn-level addition, below, alongside
3269
+ // the sibling "what is X" offer, rather than returned from here: an
3270
+ // early return through this function's normal contract would pre-empt
3271
+ // runTurn's own wall-shortening pass (shortMissHint/lane 5), leaving the
3272
+ // FULL unshortened grammar cheat-sheet standing under the offer instead
3273
+ // of the nicer tailored one-liner — found live while adding this fix).
2686
3274
  if (!hits.length) return null;
2687
3275
  // echo the STORED spelling ("caches" asked → "cache" known), never a guess
2688
- const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
3276
+ const literalHit = hits.find((f) => variants.has(f.subject) || variants.has(f.object));
3277
+ const term = literalHit
3278
+ ? (variants.has(literalHit.subject) ? literalHit.subject : literalHit.object)
3279
+ : know[1].trim();
3280
+ // when a subtype-derived hit contributed something a plain literal-mention
3281
+ // match wouldn't have found, say so — lets the reader tell subtype-derived
3282
+ // facts apart from literal mentions.
3283
+ const viaSubtype = hits.some((f) => subtypeSubjects.has(f.subject) && !variants.has(f.subject) && !variants.has(f.object));
2689
3284
  const lines = hits.map((f) => ` ${renderFactLine(f)}`);
2690
3285
  const shown = lines.slice(0, FACT_ANSWER_CAP);
2691
3286
  const rest = lines.slice(FACT_ANSWER_CAP);
2692
3287
  const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
2693
- return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
3288
+ const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
3289
+ + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
3290
+ return { text: `${header}\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
2694
3291
  }
2695
3292
  return null;
2696
3293
  }
@@ -2816,6 +3413,45 @@ const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*
2816
3413
  /** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
2817
3414
  * the teach lane's mgx:ownedBy facts. */
2818
3415
  const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
3416
+ /** "does/did <Name> own/maintain <X>" — the yes/no ownership claim over the
3417
+ * SAME mgx:ownedBy facts WHO_OWNS_RE reads (Tier-5 playtest fix). The bare
3418
+ * infinitive after do-support ("does X own Y", never "does X owns Y") mirrors
3419
+ * GENERAL_VERB_YESNO_RE's own do-support convention. */
3420
+ const OWNS_YESNO_RE = /^(?:does|did)\s+([\w'-]+)\s+(?:owns?|maintains?)\s+(.+?)[?.!\s]*$/i;
3421
+ /** "is/are/was/were <X> owned by <Name>" — the PASSIVE yes/no ownership
3422
+ * claim, sibling of OWNS_YESNO_RE just above and OWNS_PASSIVE_TEACH_RE
3423
+ * (chat.mjs's teach lane) — same mgx:ownedBy facts, matched BEFORE
3424
+ * IS_ADJECTIVE_YESNO_RE below (which would otherwise also match this shape,
3425
+ * backtracking "owned by" into its own subject capture and "<Name>" into its
3426
+ * adjective slot, silently declining rather than answering). */
3427
+ const OWNS_PASSIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+owned\s+by\s+([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
3428
+ /** "is/are/was/were <X> <adjective>" — a yes/no claim over a taught
3429
+ * mgx:hasProperty fact (Tier-5 playtest fix). Deliberately has NO marker
3430
+ * between subject and complement — "a"/"an"/"a kind of"/"a type of" is
3431
+ * ISA_ASK_RE's own mandatory territory just below (matched and handled, or
3432
+ * matched-and-declined, BEFORE this code ever runs), so a genuine "is a
3433
+ * module a component" is never reachable here. Anaphoric "it"/"this"/"that"
3434
+ * resolves against the session's current FOCUS (threaded in as `focusLabel`)
3435
+ * — never a guess when there's no standing focus. Only ever answers "yes"
3436
+ * (a real fact found) or DECLINES (null) — never a fabricated closed-world
3437
+ * "no", unlike its ownership/general-verb siblings above: a bare copula
3438
+ * ("is this good", "is it done") is the single most common CASUAL English
3439
+ * shape, so a wrong-feeling "no — no remembered fact says X is Y" for
3440
+ * ordinary small talk would be worse than deferring to the ordinary
3441
+ * cascade/orientation nudge that already handles it. */
3442
+ const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
3443
+ const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
3444
+ /** The TEACH-OFFER for a subject IS_ADJECTIVE_YESNO_RE resolved but has no
3445
+ * fact about at all (Tier-5 playtest, cycle 2) — the offered "remember that
3446
+ * X is Y" phrasing is verified in-state: TEACH_PROPERTY_RE's own subject
3447
+ * capture is unbounded multi-word with no lexicon gate on the complement, so
3448
+ * this always actually stores, unlike the bare unwrapped form (which only
3449
+ * reaches TEACH_PROPERTY_RE via BARE_DECLARATIVE_RE's single-token-subject
3450
+ * restriction and would fail here). */
3451
+ const unknownAdjectiveOffer = (subject, adjective) => ({
3452
+ text: `I don't know anything about "${subject}" yet — teach me directly, e.g. "remember that ${subject.toLowerCase()} is ${adjective}".`,
3453
+ replace: true,
3454
+ });
2819
3455
  /** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
2820
3456
  * "what facts do you know", "what do you remember" — list EVERY remembered fact
2821
3457
  * (no subject/object term to filter on), cited, higher-trust first. The multi-turn
@@ -2881,13 +3517,80 @@ function inheritsChain(graph, startId) {
2881
3517
  * "what kind of thing is an X" reports X's own type (subject-side first).
2882
3518
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
2883
3519
  * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
2884
- async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
3520
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null) {
2885
3521
  if (!miss) return null;
2886
3522
  let normFactTerm;
2887
3523
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
2888
3524
  const q = String(query).trim();
3525
+ // Tier-5 playtest fix (cycle 4), found live: "actually is the store module
3526
+ // fragile" WALLED — a leading hedge adverb ("actually"/"really"/"honestly",
3527
+ // optionally comma'd) put the sentence one word out of alignment with
3528
+ // IS_ADJECTIVE_YESNO_RE/OWNS_YESNO_RE/OWNS_PASSIVE_YESNO_RE's own anchored
3529
+ // "is|are|was|were|does|did" openers — this session's own three new yes/no
3530
+ // readers, so scoped narrowly to just them (qHedge), not the older
3531
+ // ISA_ASK_RE/WHO_OWNS_RE paths above, which already work without it and
3532
+ // don't need the extra risk of a behavior change. Full leading-connective
3533
+ // tolerance (and/also/so/…) is STACCATO_LEAKED_CONNECTIVES' own separate,
3534
+ // broader territory elsewhere in this file — this is a narrower, adjacent
3535
+ // closed set (hedge adverbs, not coordinators).
3536
+ // "yeah nah" (Tier 6 playtest, §3b dialect axis): the same AU/NZ discourse
3537
+ // opener chat.mjs's own GREET closed set and GREETING_PREAMBLE_RE already
3538
+ // recognize elsewhere, added here too — "yeah nah, is TaskController
3539
+ // fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
3540
+ // above were fixed for, just a dialect opener instead of a hedge adverb.
3541
+ const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
2889
3542
  const rows = await factRows(memoryDir);
2890
- if (!rows.length) return null;
3543
+ if (!rows.length) {
3544
+ // Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
3545
+ // remembered yet (a fresh session, nothing taught at all), the early
3546
+ // bail-out below skipped even IS_ADJECTIVE_YESNO_RE's own "subject
3547
+ // completely unknown" TEACH-OFFER further down in this function — "is
3548
+ // the checkout flow deprecated" as someone's genuinely FIRST question
3549
+ // fell to the raw structural wall, unguided. Special-cased here (ahead
3550
+ // of the general empty-memory bail-out every other lane in this function
3551
+ // still relies on) rather than removing the bail-out outright.
3552
+ //
3553
+ // IS_ADJECTIVE_YESNO_RE's own backtracking (no vocabulary restriction on
3554
+ // either capture) means it ALSO syntactically matches shapes that are
3555
+ // NOT a property claim at all — "is a zebra a mammal" (ISA_ASK_RE's own
3556
+ // territory, tried first in the non-empty-rows path below, so never
3557
+ // reached here) and "is there anything bigger" (an existence/staccato-
3558
+ // comparative shape a LATER lane elsewhere in runTurn owns and answers
3559
+ // better than a teach-offer ever could) both regressed real, pinned
3560
+ // tests on first attempt at this fix — caught by running the full suite,
3561
+ // not just the live playtest transcript. Excluded explicitly: ISA_ASK_RE
3562
+ // matches take the SAME priority here they get in the non-empty-rows
3563
+ // path below, and a leading "there" is existential, never a real named
3564
+ // subject a property claim would name.
3565
+ if (!ISA_ASK_RE.test(qHedge)) {
3566
+ const emptyIsAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
3567
+ if (emptyIsAdj) {
3568
+ const rawSubject = emptyIsAdj[1].trim();
3569
+ const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
3570
+ // Tier 6 playtest: "is logger tested"/"is the store module tested" —
3571
+ // IS_ADJECTIVE_YESNO_RE's own unrestricted backtracking (already flagged
3572
+ // as a recurring risk, see this branch's own docblock above for the
3573
+ // ISA_ASK_RE/"is there" exclusions found the SAME way) ALSO matches
3574
+ // "tested" as if it were a free-form property adjective — but "tested"/
3575
+ // "covered"/"untested"/"uncovered" are REAL structural relation words
3576
+ // (PASSIVE_PARTICIPLE_TO_KIND/QUALIFIERS, ask-vocab.mjs) with an actual
3577
+ // graph-computable meaning ask()'s own grammar already resolved
3578
+ // (envelope.parsed stands — a genuine "tests" reverse-relation
3579
+ // traversal, hit or honest empty). Offering "I don't know that yet —
3580
+ // teach me" here would silently DISCARD a real, honest structural
3581
+ // answer in favor of an irrelevant memory teach-offer — the opposite
3582
+ // failure from every other exclusion in this function (a wrong
3583
+ // OVER-eager offer, not a missed one). Declines only when a real parse
3584
+ // already stood; "is the checkout flow deprecated" (this branch's own
3585
+ // ORIGINAL T8 target — "deprecated" has no structural meaning at all)
3586
+ // has no envelope.parsed to defer to, so it is untouched.
3587
+ if (subject && !/^there\b/i.test(subject) && !envelope?.parsed) {
3588
+ return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
3589
+ }
3590
+ }
3591
+ }
3592
+ return null;
3593
+ }
2891
3594
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
2892
3595
  const byTrust = (a, b) => b.trust - a.trust;
2893
3596
  const renderMany = (hits) => {
@@ -2993,6 +3696,181 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
2993
3696
  return renderMany(hits);
2994
3697
  }
2995
3698
 
3699
+ // (a2b) OWNERSHIP yes/no — "does/did <Name> own/maintain <X>": Tier-5
3700
+ // playtest fix, found live — WHO_OWNS_RE only ever answered the OPEN "who
3701
+ // owns X" form; a direct yes/no claim about a specific (owner, thing) pair
3702
+ // ("does margo maintain the tasks handler") fell all the way through to the
3703
+ // structural code-graph wall, even right after teaching exactly that fact,
3704
+ // because GENERAL_VERB_QUERY_EXCLUDE_RE deliberately stands the general-verb
3705
+ // reader down for "own"/"maintain" (they mint a DIFFERENT predicate,
3706
+ // mgx:own/mgx:maintain, than this frame's own OWNED_BY_PREDICATE) — this is
3707
+ // that missing specific reader. Same closed-world convention as (a3)'s
3708
+ // general-verb yes/no just below: a hit answers "yes", no stored fact
3709
+ // answers a definite "no" (never a guessed owner name, unlike the OPEN "who
3710
+ // owns" form above, which stays an honest miss rather than guess WHO).
3711
+ const ownsYN = qHedge.match(OWNS_YESNO_RE);
3712
+ if (ownsYN) {
3713
+ const [, ownerRaw, thingRaw] = ownsYN;
3714
+ const ownerVariants = factTermVariants(normFactTerm, ownerRaw.trim());
3715
+ const thingVariants = factTermVariants(normFactTerm, thingRaw.replace(/^an?\s+/i, "").trim());
3716
+ const hit = rows
3717
+ .filter((f) => f.predicate === OWNED_BY_PREDICATE && thingVariants.has(f.subject) && ownerVariants.has(f.object))
3718
+ .sort(byTrust)[0];
3719
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
3720
+ return {
3721
+ text: `no — no remembered fact says ${ownerRaw.trim().toLowerCase()} owns/maintains ${thingRaw.trim()}.`,
3722
+ replace: true,
3723
+ };
3724
+ }
3725
+
3726
+ // (a2b-ii) PASSIVE ownership yes/no — "is/are/was/were <X> owned by <Name>"
3727
+ // (Tier-5 playtest, cycle 2): same OWNED_BY_PREDICATE facts as (a2b) above,
3728
+ // just the passive phrasing — "is TaskController owned by sam" found live
3729
+ // to WALL entirely (no recognizer at all, teach OR read, for the passive
3730
+ // shape) even right after teaching that exact fact via OWNS_PASSIVE_TEACH_RE.
3731
+ // Checked BEFORE (a2c)'s adjective reader, which would otherwise also match
3732
+ // this shape (backtracking "owned by" into its subject and the owner name
3733
+ // into its adjective slot) and silently decline instead of answering.
3734
+ const ownsPassiveYN = qHedge.match(OWNS_PASSIVE_YESNO_RE);
3735
+ if (ownsPassiveYN) {
3736
+ const [, thingRaw, ownerRaw] = ownsPassiveYN;
3737
+ const thingVariants = factTermVariants(normFactTerm, thingRaw.replace(/^an?\s+/i, "").trim());
3738
+ const ownerVariants = factTermVariants(normFactTerm, ownerRaw.trim());
3739
+ const hit = rows
3740
+ .filter((f) => f.predicate === OWNED_BY_PREDICATE && thingVariants.has(f.subject) && ownerVariants.has(f.object))
3741
+ .sort(byTrust)[0];
3742
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
3743
+ return {
3744
+ text: `no — no remembered fact says ${thingRaw.trim().toLowerCase()} is owned by ${ownerRaw.trim()}.`,
3745
+ replace: true,
3746
+ };
3747
+ }
3748
+
3749
+ // (a2c) PROPERTY yes/no — "is/are/was/were <X> <adjective>": Tier-5 playtest
3750
+ // fix, found live — "remember that the logger module is deprecated" taught a
3751
+ // real mgx:hasProperty fact, but there was no direct-question reader for it
3752
+ // AT ALL (only presuppositionNudge's own narrow "why does X still Y" embeds
3753
+ // this same check) — "is the logger deprecated" fell straight to the
3754
+ // structural wall. Checks BOTH shapes a taught "<X> is <adjective>" can land
3755
+ // as (mirrors presuppositionNudge's own dual check, above): the teach lane's
3756
+ // mgx:hasProperty fact, or — when the adjective is a known ACE-OWL lexicon
3757
+ // data-property word — the ACE grammar's own tmct:<adjective> "true" triple.
3758
+ // "it"/"this"/"that" resolve against `focusLabel` — a bare pronoun with no
3759
+ // standing focus declines (null), same discipline STACCATO_PRONOUN_RE uses.
3760
+ const isAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
3761
+ if (isAdj) {
3762
+ const rawSubject = isAdj[1].trim();
3763
+ const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
3764
+ const adjective = isAdj[2].trim().toLowerCase();
3765
+ if (subject) {
3766
+ const subjVariants = factTermVariants(normFactTerm, subject);
3767
+ const propertyMatch = (f) => (f.predicate === HAS_PROPERTY_PREDICATE && normFactTerm(f.object) === adjective)
3768
+ || (f.predicate === `tmct:${adjective}` && f.object === "true");
3769
+ // Same head-word fallback as factAnswer's "(c) what do you know about"
3770
+ // lane: a taught subject is often a real noun PHRASE ("logger module"),
3771
+ // shortened in the natural follow-up ("is the logger deprecated") — an
3772
+ // exact-variant miss on its own, on a whole word (length >= 4, the same
3773
+ // floor used elsewhere) shared between the query subject and the fact's
3774
+ // own subject.
3775
+ const subjWords = normFactTerm(subject).split(/\s+/).filter((w) => w.length >= 4 && !GENERIC_ENTITY_WORDS.has(w));
3776
+ const wordOverlap = (f) => subjWords.some((w) => new Set(String(f.subject || "").split(/\s+/)).has(w));
3777
+ const subjectMatch = (f) => subjVariants.has(f.subject) || (subjWords.length && wordOverlap(f));
3778
+ const hit = rows.filter((f) => subjectMatch(f) && propertyMatch(f)).sort(byTrust)[0];
3779
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
3780
+ // no hit on THIS property — never a guessed "no" (see
3781
+ // IS_ADJECTIVE_YESNO_RE's own docblock for why this stays silent on a
3782
+ // truth claim, unlike its ownership/general-verb siblings above). But a
3783
+ // subject we DO know something else about ("the logger" has a
3784
+ // deprecated-fact, just not a fast-fact) still deserves an honest, named
3785
+ // receipt — never a bare wall — mirroring factAnswer's own established
3786
+ // convention for a known-subject/wrong-predicate miss ("I don't have
3787
+ // any 'X' facts about Y"). The SAME subjectMatch (exact-variant OR
3788
+ // head-word overlap) decides "known", so a shortened/article-led
3789
+ // subject that found its fact via the overlap fallback is recognized
3790
+ // as known too. A subject with NO known facts at all falls through
3791
+ // undecided — there's nothing honest to say beyond the ordinary
3792
+ // cascade's own miss/orientation nudge.
3793
+ //
3794
+ // Tier 6 playtest: gated on `!envelope?.parsed`, the SAME guard this
3795
+ // function's empty-memory branch above just added, for the identical
3796
+ // reason — "is the logger tested" (after teaching an UNRELATED
3797
+ // "logger... is deprecated" fact) used to return "I don't have a fact
3798
+ // saying the logger is tested" here, discarding a REAL structural
3799
+ // answer ("No tests cover logger") for a word ("tested") that already
3800
+ // has genuine graph-computable meaning. A subject with a KNOWN taught
3801
+ // fact under some OTHER, non-structural property (the common,
3802
+ // originally-intended case here) still gets this receipt exactly as
3803
+ // before, since envelope.parsed is null for those adjectives.
3804
+ if (rows.some(subjectMatch) && !envelope?.parsed) {
3805
+ return { text: `I don't have a fact saying ${subject.toLowerCase()} is ${adjective}.`, replace: true };
3806
+ }
3807
+ // Tier-5 playtest fix (cycle 2), found live: "is the checkout flow
3808
+ // deprecated" as a genuinely FIRST-EVER question about a subject tmct
3809
+ // has never heard of (no fact at all, not even under a different
3810
+ // property) fell through to the raw structural wall, unguided — the
3811
+ // exact "honest 'I don't know that yet' offers to learn" case
3812
+ // SKILL_CHAT_PLAYTEST.md's Tier 5 (§3) itself names. The offered
3813
+ // phrasing is the SAME verified "remember that X is Y" wrapped form
3814
+ // TEACH_PROPERTY_RE actually accepts (arbitrary-length subject, no
3815
+ // lexicon gate on the complement) — never the bare unwrapped form,
3816
+ // which TEACH_PROPERTY_RE only reaches via BARE_DECLARATIVE_RE's own
3817
+ // single-token-subject restriction and would fail for a multi-word
3818
+ // subject like this one. Same helper (unknownAdjectiveOffer) the
3819
+ // empty-memory special-case above this function's own rows.length
3820
+ // bail-out reuses, so the two paths can never disagree on wording.
3821
+ //
3822
+ // Tier 6 playtest: same `!envelope?.parsed` guard as just above — a
3823
+ // subject known only under an UNRELATED property (e.g. "deprecated")
3824
+ // must not offer to teach "tested" when ask()'s own grammar already
3825
+ // resolved it structurally.
3826
+ if (!envelope?.parsed) return unknownAdjectiveOffer(subject, adjective);
3827
+ }
3828
+ }
3829
+
3830
+ // (a3) GENERAL VERB-TO-PREDICATE direct-question retrieval (item 5, this
3831
+ // session): a taught general-verb fact ("margo eats ribs") answered back
3832
+ // directly. Yes/no form matches the taught triple EXACTLY (subject +
3833
+ // predicate + object, via the SAME factTermVariants/normFactTerm matching
3834
+ // WHO_OWNS_RE just used above) — no match is an honest, closed-world "no",
3835
+ // never a guess. Open form lists every stored fact row for {subject,
3836
+ // predicate} regardless of object.
3837
+ const genYN = q.match(GENERAL_VERB_YESNO_RE);
3838
+ if (genYN && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
3839
+ const [, subjectRaw, verbRaw, objectRaw] = genYN;
3840
+ const verb = verbRaw.toLowerCase();
3841
+ if (!GENERAL_VERB_EXCLUDE_RE.test(verb) && !GENERAL_VERB_QUERY_EXCLUDE_RE.test(verb)) {
3842
+ const subject = subjectRaw.trim();
3843
+ const object = objectRaw.replace(/^an?\s+/i, "").trim();
3844
+ if (subject && object) {
3845
+ const predicate = await generalVerbPredicate(verb);
3846
+ const subjVariants = factTermVariants(normFactTerm, subject);
3847
+ const objVariants = factTermVariants(normFactTerm, object);
3848
+ const hit = rows
3849
+ .filter((f) => f.predicate === predicate && subjVariants.has(f.subject) && objVariants.has(f.object))
3850
+ .sort(byTrust)[0];
3851
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true, generalVerbQuery: true };
3852
+ return {
3853
+ text: `no — no remembered fact says ${subject.toLowerCase()} ${predicatePhrase(predicate)} ${object}.`,
3854
+ replace: true, generalVerbQuery: true,
3855
+ };
3856
+ }
3857
+ }
3858
+ }
3859
+ const genOpen = q.match(GENERAL_VERB_OPEN_RE);
3860
+ if (genOpen && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
3861
+ const [, subjectRaw, verbRaw] = genOpen;
3862
+ const verb = verbRaw.toLowerCase();
3863
+ if (!GENERAL_VERB_EXCLUDE_RE.test(verb) && !GENERAL_VERB_QUERY_EXCLUDE_RE.test(verb)) {
3864
+ const subject = subjectRaw.trim();
3865
+ if (subject) {
3866
+ const predicate = await generalVerbPredicate(verb);
3867
+ const subjVariants = factTermVariants(normFactTerm, subject);
3868
+ const hits = rows.filter((f) => f.predicate === predicate && subjVariants.has(f.subject)).sort(byTrust);
3869
+ if (hits.length) return { ...renderMany(hits), generalVerbQuery: true };
3870
+ }
3871
+ }
3872
+ }
3873
+
2996
3874
  // (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
2997
3875
  const told = q.match(TOLD_ABOUT_RE);
2998
3876
  if (told) {
@@ -3326,14 +4204,42 @@ const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
3326
4204
  * question asks about — from the parse when present, else recognized directly
3327
4205
  * via BARE_WHATIS_RE (article optional — see its own docblock for why that's
3328
4206
  * safe here even though the grammar's own T5 keeps the article mandatory).
3329
- * Null when the line isn't such a form. */
4207
+ * Null when the line isn't such a form. Seonix Batch 2 Fix 3: a curated trailing
4208
+ * scope clause ("what is a Module in this graph") is stripped off the captured
4209
+ * term the same way grammar.mjs's T5 does (stripTrailingScopeFiller,
4210
+ * ask-vocab.mjs) — the envelope.parsed.object branch above already carries a
4211
+ * trimmed term when it came from that template, so the strip here only needs to
4212
+ * cover this function's own regex fallback. */
3330
4213
  function metaTermOf(query, envelope) {
3331
4214
  if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
3332
4215
  const q = String(query).trim();
3333
4216
  const m = q.match(BARE_WHATIS_RE)
3334
4217
  || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
3335
4218
  || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
3336
- return m ? m[1].trim() : null;
4219
+ return m ? stripTrailingScopeFiller(m[1].trim()) : null;
4220
+ }
4221
+
4222
+ /** The TEACH-OFFER line for a term that's genuinely unknown everywhere (Tier-5
4223
+ * playtest fix): "I don't know 'X' yet — teach me directly, e.g. …". The
4224
+ * concrete example is worded by WORD COUNT, verified in-state
4225
+ * (SKILL_CHAT_PLAYTEST.md §4's own rule) — an unwrapped bare declarative
4226
+ * only stores for a single-token subject (BARE_DECLARATIVE_RE's own scope);
4227
+ * the wrapped "remember X is a Y" form tolerates up to a two-token subject
4228
+ * (unknownSubjectFallback's UNKNOWN_SUBJECT_RE). A 3+-word term fits
4229
+ * neither shape — never offer a concrete example that would itself fail,
4230
+ * the plain nudge to teach it still guides, honestly. Shared by runTurn's
4231
+ * own "what is X" miss nudge and factAnswer's "what do you know about X"
4232
+ * miss nudge, below, so the two can never disagree on wording. */
4233
+ function unknownVocabTermOffer(term) {
4234
+ const article = /^[aeiou]/i.test(term) ? "an" : "a";
4235
+ const words = term.trim().split(/\s+/);
4236
+ const remember = `remember ${term} is ${article} <thing>`;
4237
+ const example = words.length === 1
4238
+ ? `"${term} is ${article} <thing>" or "${remember}"`
4239
+ : words.length === 2
4240
+ ? `"${remember}"`
4241
+ : null;
4242
+ return `I don't know "${term}" yet — teach me directly${example ? `, e.g. ${example}` : ` (e.g. "remember <name> is ${article} <thing>")`}.`;
3337
4243
  }
3338
4244
 
3339
4245
  /** The curated SEON definition to PREFER for a "what is a <lexicon term>", or null.
@@ -3387,6 +4293,18 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
3387
4293
  // SHORTHAND_CONTRACTIONS above: scoped locally to the lane that owns the word.
3388
4294
  // Word-boundary matched so "hotel"/"intel" are untouched.
3389
4295
  const VAGUE_TOUCH_TEL_RE = /\btel\b/i;
4296
+ // "abut" -> "about" (Tier 6 playtest, §3b typo axis): a one-letter-dropped
4297
+ // typo of THIS lane's own anchor word ("what abut imports" used to miss the
4298
+ // "what about …" regex entirely and search for a module literally named
4299
+ // "abut" instead). "about" is real English on its own (a genuine word) but is
4300
+ // not itself part of ask.mjs's code-graph grammar (VERB_TO_KIND/ENTITY_TO_TYPE/
4301
+ // anchor words) — same reasoning as VAGUE_TOUCH_TEL_RE just above, so this
4302
+ // stays a local, lane-scoped replace rather than a shared MISSPELLINGS entry
4303
+ // (test/ask-vocab.test.mjs enforces every correction TABLE value is grammar-
4304
+ // owned; a bare discourse word like "about" fails that gate on purpose).
4305
+ // Word-boundary matched so a real identifier merely containing "abut" (rare,
4306
+ // but e.g. "rebuttal") is untouched.
4307
+ const VAGUE_TOUCH_ABUT_RE = /\babut\b/i;
3390
4308
  /** "explain X" / "please explain X" / "kindly explain X" / "explain X to me" /
3391
4309
  * "explain X please" — a bare vague-touch shape, sibling of WHAT_ABOUT_RE
3392
4310
  * above. Named (not inlined) so both vagueTouchTermOf (term extraction) and
@@ -3407,6 +4325,7 @@ function vagueTouchTermOf(query) {
3407
4325
  // bridge frame), breaking this very regex.
3408
4326
  let q = correctMisspellings(String(query).trim());
3409
4327
  q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
4328
+ q = q.replace(VAGUE_TOUCH_ABUT_RE, "about");
3410
4329
  q = applyPreambleFrames(q);
3411
4330
  const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
3412
4331
  || q.match(/^(?:(?:and|so|but|ok|okay|now|then|kindly)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)(?:\s+then|\s+though)?[?.!\s]*$/i)
@@ -3518,8 +4437,22 @@ function relationTermOf(query, envelope) {
3518
4437
  * can never parse and always misses) — a drill-down chain that opens with
3519
4438
  * "describe X" (the README's own example) used to dead-end on the very next
3520
4439
  * "what about it"/"what about Y" turn. */
4440
+ // Bug F point 4 (operator follow-up request): "please tell me X" (no "about")
4441
+ // answers like "describe X"/"what is X" — the "tell me" branch's own "about"
4442
+ // is now OPTIONAL, so "please tell me Widget" reaches the same rescue "please
4443
+ // tell me about Widget" already did. "describe"/"what(?:'s|\s+is)? about" stay
4444
+ // unchanged (describe never took "about" at all; the "what about" branch
4445
+ // still requires it — a bare "what X" is BARE_WHATIS_RE's own territory, not
4446
+ // this lane's, and folding it in here would risk double-claiming that shape).
4447
+ // Note the trailing \s+ moved INSIDE each alternation branch (rather than one
4448
+ // shared \s+ after the whole group): making "about" optional inside the "tell
4449
+ // me" branch means that branch's own separator is sometimes owned by "me\s+"
4450
+ // and sometimes by "about\s+" — a single external \s+ double-counted the
4451
+ // separator when "about" fired (swallowing the one real space and then
4452
+ // requiring a second one that was never there, an always-null regex found
4453
+ // live while testing this fix).
3521
4454
  const DESCRIBE_WRAPPER_RE =
3522
- /^(?:(?: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;
4455
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?(?:about\s+)?|describe\s+|what(?:'s|\s+is)?\s+about\s+)(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
3523
4456
 
3524
4457
  /** Bare focus pronouns this lane resolves against the STANDING focus (0.9.13
3525
4458
  * Tier-1 playtest) — "describe that" / "tell me about it" after a prior turn
@@ -3542,14 +4475,77 @@ const DESCRIBE_PRONOUN_RE = /^(?:it|that|this|those|them)$/i;
3542
4475
  * unaffected either way. */
3543
4476
  const STACCATO_PRONOUN_RE = /^(?:and|also|so|then|now)\s+(it|that|this|those|them)(?:\s+ones?)?\s*\??$/i;
3544
4477
 
3545
- async function describeWrapperAnswer(query, { config, source, focus }) {
3546
- const q = String(query || "").trim();
4478
+ /** Tier 6 playtest: "describe the logger module"/"describe the Task class" —
4479
+ * dispatchTool("tmct_describe") resolves its `symbol` arg via codegraph.mjs's
4480
+ * resolveSymbol, a separate, simpler path/basename matcher with NO article- or
4481
+ * grain-word tolerance. A first attempt routed this whole free-text `term`
4482
+ * through resolveEntity/resolveObject instead (which DOES have that tolerance,
4483
+ * just added above) — reverted live, found via this same playtest cycle's own
4484
+ * regression run: resolveObject's tier-3 ANY-overlap fallback is tuned for
4485
+ * near-path/near-symbol terms, not arbitrary English sentences, and a genuine
4486
+ * English article ("a", "the") can itself be a real one-character path
4487
+ * component of some fixture module ("a.mjs") — "tell me A JOKE" tier-3-matched
4488
+ * that module by the shared bare "a" alone (test/sessions.test.mjs's own guard
4489
+ * test caught it: a turn meant to fall through as an honest grammar miss
4490
+ * instead silently "described" an unrelated module). Scoped down to ONLY ever
4491
+ * attempt a resolution when the term carries an EXPLICIT trailing grain word
4492
+ * (module/class/function/method/…, ENTITY_TO_TYPE's own closed table) — the
4493
+ * class-narrowed pool that then searches is both far smaller and still
4494
+ * requires the head noun to actually match a stem, so it stays safe; a bare
4495
+ * "the X"/"an X" or ordinary sentence (no grain word) gets NO rescue attempt
4496
+ * at all and falls through to the untouched, always-safe resolveSymbol path,
4497
+ * exactly as before this fix. */
4498
+ const DESCRIBE_GRAIN_WORD_RE = new RegExp(
4499
+ `^(?:(?:the|a|an)\\s+)?(.+?)\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i",
4500
+ );
4501
+ async function describeGrainRescue(graph, term) {
4502
+ if (!graph) return null;
4503
+ const m = String(term || "").trim().match(DESCRIBE_GRAIN_WORD_RE);
4504
+ if (!m) return null;
4505
+ const [, head, grainWord] = m;
4506
+ const expectedClass = ENTITY_TO_TYPE[grainWord.toLowerCase()];
4507
+ if (!head?.trim() || !expectedClass) return null;
4508
+ try {
4509
+ const { resolveObject } = await import("./ask.mjs");
4510
+ const r = resolveObject(graph, head.trim(), { expectedClass });
4511
+ if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
4512
+ } catch { /* tolerated */ }
4513
+ return null;
4514
+ }
4515
+
4516
+ async function describeWrapperAnswer(query, { config, source, focus, graph }) {
4517
+ // Tier 6 playtest: this lane is the LAST-RESORT rescue (4d, tried after every
4518
+ // earlier lane declines on the ORIGINAL query) — but it tested its own
4519
+ // DESCRIBE_WRAPPER_RE against the RAW, un-normalized text, so a preamble an
4520
+ // earlier lane (relationForceAnswer/vagueTouchTermOf) already knows how to
4521
+ // strip ("ok cool, what about the TaskController" — relationForceAnswer
4522
+ // correctly declines since "TaskController" isn't an enumerable RELATION_TERM,
4523
+ // but never hands its own stripped text forward) reappeared here, unstripped,
4524
+ // and broke DESCRIBE_WRAPPER_RE's own anchor. applyPreambleFrames is the same
4525
+ // general-purpose, closed, idempotent pass every other lane in this file
4526
+ // already runs first.
4527
+ const q = applyPreambleFrames(String(query || "").trim());
3547
4528
  const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
3548
4529
  let term = m?.[1]?.trim();
3549
4530
  if (!term) return null;
3550
4531
  if (DESCRIBE_PRONOUN_RE.test(term)) {
3551
4532
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
3552
4533
  term = focus.label;
4534
+ } else {
4535
+ const rescued = await describeGrainRescue(graph, term);
4536
+ if (rescued?.label) {
4537
+ term = rescued.label;
4538
+ } else {
4539
+ // Tier 6 playtest: "what about the TaskController" (no grain word, just a
4540
+ // bare article) — resolveSymbol (codegraph.mjs) has no component/overlap
4541
+ // tier at all, only exact/endsWith/basename/includes checks, so a leading
4542
+ // "the"/"a"/"an" is pure NOISE here (unlike resolveObject's looser tiers,
4543
+ // there is no accidental-match risk this could introduce — stripping it
4544
+ // only ever REMOVES characters no real label ever contains as a match
4545
+ // signal). "TaskController" resolves exactly where "the TaskController"
4546
+ // didn't.
4547
+ term = term.replace(/^(?:the|a|an)\s+/i, "");
4548
+ }
3553
4549
  }
3554
4550
  try {
3555
4551
  const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source });
@@ -4008,16 +5004,28 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4008
5004
  // guarantees a real answer before it defers, never stranding the turn on a
4009
5005
  // worse outcome — see isStaccatoPronounNoFocus's own docblock for the same
4010
5006
  // discipline).
5007
+ // Tier-5 playtest fix (this session): "is it deprecated" is EXACTLY the same
5008
+ // race BUG 2 (above) fixed for "what is john" — 3 words, no code-ish token,
5009
+ // so isConversationalCandidate would otherwise win unconditionally and a
5010
+ // just-taught property fact ("logger module is deprecated") becomes
5011
+ // unreachable the moment it's asked back about with a short pronoun/bare
5012
+ // form ("is it deprecated" / "is the logger deprecated"). Widened the SAME
5013
+ // divert-only-on-a-real-hit gate to also try IS_ADJECTIVE_YESNO_RE shapes —
5014
+ // factAnswer itself declines for this shape (no metaTerm), so the only
5015
+ // change in practice is that factReadBack's (a2c) property lane gets a
5016
+ // chance to run before the orientation card claims the turn.
5017
+ const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
5018
+ const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
4011
5019
  let bareMetaHit = null;
4012
- if (isConversationalCandidate && memoryDir && BARE_WHATIS_RE.test(String(query).trim())) {
5020
+ if (isConversationalCandidate && memoryDir && (bareWhatisShape || isAdjectiveShape)) {
4013
5021
  bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss))
4014
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
5022
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label));
4015
5023
  }
4016
5024
  if (bareMetaHit) {
4017
5025
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
4018
5026
  via = "fact"; recordMiss = false; handled = true;
4019
5027
  if (bareMetaHit.pending) factPending = bareMetaHit.pending;
4020
- note(trace, "lane: (2b) BARE META FACT — \"what is X\" (no article) resolved to a remembered fact before the conversational catch-all could claim it");
5028
+ note(trace, "lane: (2b) BARE META FACT — \"what is X\" (no article) / \"is X <adjective>\" resolved to a remembered fact before the conversational catch-all could claim it");
4021
5029
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
4022
5030
  } else if (isConversationalCandidate) {
4023
5031
  // A conversational miss (a greeting, "what can you do", a very short non-code
@@ -4053,7 +5061,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4053
5061
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
4054
5062
  // asserted "every X is a Y" answers "what is a Y" too.
4055
5063
  const fact = (await factAnswer(memoryDir, query, envelope, miss))
4056
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
5064
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label));
4057
5065
  if (fact) {
4058
5066
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
4059
5067
  via = "fact";
@@ -4061,6 +5069,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4061
5069
  if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
4062
5070
  note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
4063
5071
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
5072
+ // Goal-line fix (item 5 follow-up, this session): mirrors the TEACH lane's
5073
+ // own goal revision just below (Bug 3 point 4) — `deduced` was computed
5074
+ // WAY above off envelope.parsed alone, but a general-verb direct question
5075
+ // ("does margo eat ribs") never parses as a structural graph query at all,
5076
+ // so it either landed on an unrelated GOAL_BY_KIND guess or nothing.
5077
+ if (fact.generalVerbQuery) {
5078
+ deduced = "look up a taught fact about a subject/verb/object";
5079
+ note(trace, `goal: ${deduced} (revised — a general-verb direct-question fact lookup answered this turn)`);
5080
+ }
4064
5081
  } else if (miss) {
4065
5082
  // W2: after the honest miss is composed, consult the folded-session memory. A
4066
5083
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -4246,7 +5263,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4246
5263
  // for what would otherwise become the generic wall, never a competing route:
4247
5264
  // it only claims the turn if /describe actually resolves the captured term.
4248
5265
  if (miss && recordMiss && via === "composed") {
4249
- const described = await describeWrapperAnswer(query, { config, source, focus: newFocus });
5266
+ const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph });
4250
5267
  if (described) {
4251
5268
  answer = described.text; via = "describe"; recordMiss = false;
4252
5269
  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");
@@ -4273,6 +5290,47 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4273
5290
  answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
4274
5291
  note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
4275
5292
  }
5293
+ // TEACH-OFFER (Tier-5 playtest, this session) — SKILL_CHAT_PLAYTEST.md §0
5294
+ // names "'X' isn't a term in this graph's own vocabulary" as its own
5295
+ // dead-end example, and Tier 5 (§3) explicitly wants "the honest 'I don't
5296
+ // know that yet' that offers to learn" rather than a bare wall. A "what is
5297
+ // X" miss where X is genuinely unknown EVERYWHERE — not a real graph entity
5298
+ // (resolveEntity), not a schema/vocab term (that's what "still standing
5299
+ // miss" already means here), and not already in memory (checked directly,
5300
+ // not via factAnswer's OWN metaTerm branch, so this never duplicates its
5301
+ // more specific "no X facts about Y" miss) — gets a short offer appended
5302
+ // UNDER the existing miss text, never replacing it (every pinned assertion
5303
+ // on the miss wording elsewhere stays intact; this is purely additive, the
5304
+ // same discipline the corpus aside (W5) and empty-graph polish above use).
5305
+ if (recordMiss && (via === "composed" || via === "miss") && memoryDir) {
5306
+ // Tier-5 playtest fix (cycle 3): "what do you know about X" is its OWN
5307
+ // sibling shape — checked FIRST (and, unlike metaTermOf below, without a
5308
+ // resolveEntity(graph) gate): it's inherently a MEMORY question, not a
5309
+ // graph-structure one, so "nothing yet, teach me" is appropriate even
5310
+ // when X also happens to be a real graph entity — there's no genuinely
5311
+ // BETTER answer path to defer to the way "what is X" has (the concept
5312
+ // force, schema docs, …). Found live: "what do you know about the last
5313
+ // commit" (nothing in memory, and the whole sentence never fits ask.mjs's
5314
+ // grammar either) fell to the raw wall, unguided.
5315
+ const knowAboutTerm = String(query).trim().match(KNOW_ABOUT_RE)?.[1]?.trim();
5316
+ const offerTerm = knowAboutTerm || metaTermOf(query, envelope);
5317
+ if (offerTerm) {
5318
+ let normFactTerm;
5319
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
5320
+ if (normFactTerm) {
5321
+ const cleanTerm = normFactTerm(offerTerm);
5322
+ const ent = knowAboutTerm ? null : await resolveEntity(graph, offerTerm);
5323
+ if (!ent) {
5324
+ const variants = factTermVariants(normFactTerm, offerTerm);
5325
+ const known = (await memoryFacts(memoryDir)).some((f) => variants.has(f.subject) || variants.has(f.object));
5326
+ if (!known) {
5327
+ answer = `${answer}\n${unknownVocabTermOffer(cleanTerm)}`;
5328
+ note(trace, `intermediate: TEACH-OFFER — "${cleanTerm}" is unknown to both the graph and memory, so the miss got an offer to learn appended`);
5329
+ }
5330
+ }
5331
+ }
5332
+ }
5333
+ }
4276
5334
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
4277
5335
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
4278
5336
  // the honest miss (the miss itself stands; the aside is context, not an answer).
@@ -4363,6 +5421,38 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
4363
5421
  };
4364
5422
  }
4365
5423
 
5424
+ // Bug F point 5 (operator's explicit, most important ask this round): a
5425
+ // command name -> a short, honest one-line goal string, mirroring GOAL_BY_KIND's
5426
+ // own spirit (above) — but for COMMAND dispatches (find/search/describe/…)
5427
+ // instead of ask()-parsed relation queries, so "I want you to search for
5428
+ // Widget" (now reachable via a slash command per Bug F point 3) ALSO gets a
5429
+ // real "Goal (inferred): …" line instead of none at all, generalizing the
5430
+ // existing mechanism exactly as asked. Reuses GOAL_BY_KIND's EXISTING wording
5431
+ // verbatim wherever a command's intent overlaps one of those kinds (members/
5432
+ // subclasses reuse contains/inherits's own phrasing; callers/callees reuse
5433
+ // calls's; tests/untested reuse tests's; history reuses touches's; exports
5434
+ // reuses reexports's) — never invents new phrasing for the same concept. A
5435
+ // command not worth a bespoke entry (help/stats/memory/focus/narrate/unknown)
5436
+ // falls back to a short generic line in mk() itself, below.
5437
+ const GOAL_BY_COMMAND = {
5438
+ find: "locate a specific named entity",
5439
+ search: "locate a specific named entity",
5440
+ context: "gather the sized edit bundle for a symbol before changing code",
5441
+ snippet: "view a symbol's exact source",
5442
+ describe: "look up a symbol's definition and relations",
5443
+ signature: "view a symbol's signature",
5444
+ members: GOAL_BY_KIND.contains,
5445
+ subclasses: GOAL_BY_KIND.inherits,
5446
+ impact: "understand what a change to this module would reach (impact closure)",
5447
+ callers: GOAL_BY_KIND.calls,
5448
+ callees: GOAL_BY_KIND.calls,
5449
+ tests: GOAL_BY_KIND.tests,
5450
+ untested: GOAL_BY_KIND.tests,
5451
+ history: GOAL_BY_KIND.touches,
5452
+ exports: GOAL_BY_KIND.reexports,
5453
+ arch: "understand the overall architecture (package/module boundaries)",
5454
+ };
5455
+
4366
5456
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
4367
5457
  * cases). Returns the same { answer, logLines, record, focus } shape as
4368
5458
  * runAsk; the record carries the command name and the resolved entity id
@@ -4370,7 +5460,10 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
4370
5460
  * wherever it resolves an entity. `ctx.trace` (narrate mode, or undefined
4371
5461
  * when off) gets one "goal:"/"lane:" note per branch — a slash-command's
4372
5462
  * "decision" is simply which command+tool ran, so this is intentionally
4373
- * lighter than runAsk's miss-cascade instrumentation. */
5463
+ * lighter than runAsk's miss-cascade instrumentation. Also carries a `goal`
5464
+ * field now (Bug F point 5) — mirrors runAsk's own `goal` field so
5465
+ * withGoalLine's short "Goal (inferred): …" line fires for command
5466
+ * dispatches too, not just ask()-parsed queries. */
4374
5467
  async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false }) {
4375
5468
  const ts = new Date().toISOString();
4376
5469
  const sp = line.indexOf(" ");
@@ -4381,6 +5474,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
4381
5474
  logLines: [ts, `> ${line}`, answer, ""],
4382
5475
  record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
4383
5476
  focus: newFocus,
5477
+ goal: GOAL_BY_COMMAND[name] || "use a specific tool/command directly",
4384
5478
  ...(narrateNext !== undefined ? { narrate: narrateNext } : {}),
4385
5479
  });
4386
5480
 
@@ -4441,6 +5535,20 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
4441
5535
  value = focus?.label || "";
4442
5536
  if (value) note(trace, `intermediate: no/pronoun argument -> fell back to the standing focus "${value}"`);
4443
5537
  }
5538
+ // Tier 6 playtest: a bare English "describe the logger module"/"describe the
5539
+ // Task class" is short enough (≤3 tokens, no query connective) that
5540
+ // asBareCommand (above) already rewrote it into a literal slash command
5541
+ // BEFORE this function ever sees it — so describeGrainRescue's OWN lane
5542
+ // (describeWrapperAnswer) never runs for this exact shape; the rescue has to
5543
+ // happen HERE too, on the raw command argument, before it reaches
5544
+ // dispatchTool's resolveSymbol (which has no article/grain-word tolerance).
5545
+ if (entityArg && value) {
5546
+ const rescued = await describeGrainRescue(graph, value);
5547
+ if (rescued?.label) {
5548
+ note(trace, `intermediate: "${value}" carries a grain word -> resolved to ${rescued.label} before dispatch`);
5549
+ value = rescued.label;
5550
+ }
5551
+ }
4444
5552
  if (spec.arg && !spec.optional && !value) {
4445
5553
  const need = entityArg ? `${spec.arg} (none given and no focus set — /focus <x> or pass one)` : spec.arg;
4446
5554
  return mk(`/${name} needs a ${need}.`, { miss: true });
@@ -4570,8 +5678,30 @@ function morePage(query, { last, focus }) {
4570
5678
  return turn;
4571
5679
  }
4572
5680
 
5681
+ // Bug F point 3 (operator follow-up request): "I want you to search for
5682
+ // Widget" / "I'd like you to search for Widget" — a closed-set indirect-
5683
+ // request wrapper, checked VERY early (before asBareCommand/conversationalTurn/
5684
+ // the ask engine ever see the raw prefix). Found live: without this, "I want
5685
+ // you to search for Widget" was mis-swallowed by GENERAL_VERB_TEACH_RE as a
5686
+ // bare <subject> <verb> <object> teach triple (subject "I", verb "want") —
5687
+ // declined by the pronoun-subject guard with a confusing "pronouns aren't
5688
+ // things I can classify" message, instead of ever reaching /search at all.
5689
+ // Deliberately does NOT strip bare "please X" alone — that's already handled
5690
+ // ad hoc by many individual regexes throughout this file (TEACH_RE,
5691
+ // EXPLAIN_TOUCH_RE, describeWrapperAnswer's own regex, IMPERATIVE_NUDGE_RE) and
5692
+ // re-stripping it centrally here risks double-processing interactions across
5693
+ // the whole file — out of scope for this fix, higher risk than the concrete
5694
+ // gain.
5695
+ const INDIRECT_REQUEST_RE = /^(?:i\s+(?:want|wanted)\s+you\s+to\s+|i(?:'d|\s+would)\s+like\s+you\s+to\s+)\s*(.+)$/i;
5696
+
4573
5697
  export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null } = {}) {
4574
5698
  const line = String(input ?? "").trim();
5699
+ // The captured residue is used for RECOGNITION at every dispatch site below
5700
+ // (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
5701
+ // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
5702
+ // — restored centrally inside withLast (below), once, for every dispatch path.
5703
+ const indirectMatch = line.match(INDIRECT_REQUEST_RE);
5704
+ const workingLine = indirectMatch ? indirectMatch[1].trim() : line;
4575
5705
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
4576
5706
  // narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
4577
5707
  // matching every OTHER optional collaborator here — templates/memoryDir/lexicon
@@ -4598,6 +5728,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4598
5728
  // the PRE-narration finished result — see withNarration's docblock for why.
4599
5729
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
4600
5730
  const finished = finish(result, { graph });
5731
+ // Bug F point 3 fidelity: every dispatch path below built its own record off
5732
+ // `workingLine` (the indirect-request wrapper stripped, above) — restore the
5733
+ // ORIGINAL raw `line` into record.query and the logged "> …" transcript echo
5734
+ // here, once, centrally, for every path (they all funnel through withLast).
5735
+ if (indirectMatch) {
5736
+ if (finished.record) finished.record.query = line;
5737
+ if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
5738
+ }
4601
5739
  // runAsk's own effectiveQuery (set only when discourseRewrite substituted a
4602
5740
  // new subject AND the rewrite produced a genuine non-miss answer) takes
4603
5741
  // over as the continuation base for the NEXT turn's own discourseRewrite —
@@ -4617,32 +5755,32 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4617
5755
  // "memory", "describe X") is routed to its slash form BEFORE the conversational
4618
5756
  // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
4619
5757
  // of falling through to the generic orientation.
4620
- const bareCmd = asBareCommand(line);
5758
+ const bareCmd = asBareCommand(workingLine);
4621
5759
  if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
4622
5760
 
4623
5761
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
4624
5762
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
4625
5763
  // conversational turn is never finish()'d / never becomes a new `last`), so the
4626
5764
  // narrate block is applied directly here instead.
4627
- const convo = conversationalTurn(line, ctx);
5765
+ const convo = conversationalTurn(workingLine, ctx);
4628
5766
  if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
4629
5767
 
4630
5768
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
4631
5769
  // an actual pending remainder so a bare "more" with nothing to continue falls through
4632
5770
  // to the ordinary path (an honest miss), never a pretend page.
4633
- if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
5771
+ if (MORE_RE.test(workingLine) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
4634
5772
  note(trace, "goal: continue viewing a previous long listing (pagination)");
4635
5773
  note(trace, "lane: MORE_RE matched a held pending remainder from the previous turn's detail.pending");
4636
- return withLast(morePage(line, ctx), "continue viewing a previous long listing");
5774
+ return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
4637
5775
  }
4638
5776
 
4639
- if (line.startsWith("/")) return withLast(await runCommand(line, ctx), "use a specific tool/command directly");
5777
+ if (workingLine.startsWith("/")) return withLast(await runCommand(workingLine, ctx), "use a specific tool/command directly");
4640
5778
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
4641
5779
  // own memory and confirm — they are statements to remember, not graph queries.
4642
5780
  // Gated on memoryDir: only a session shell provides a write target, so a bare
4643
5781
  // runTurn (tests, library callers) stays pure and falls through to the engine.
4644
5782
  if (memoryDir) {
4645
- const asserted = await assertTurn(line, ctx);
5783
+ const asserted = await assertTurn(workingLine, ctx);
4646
5784
  if (asserted) {
4647
5785
  note(trace, "goal: teach/remember a new fact (declarative ACE sentence)");
4648
5786
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
@@ -4655,11 +5793,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4655
5793
  // otherwise say "I can't count facts"); it only speaks for a memory-class noun, so
4656
5794
  // structural counts (classes/functions/…) and sessions fall through unaffected.
4657
5795
  if (memoryDir) {
4658
- const memCount = await answerMemoryCount(memoryDir, line);
5796
+ const memCount = await answerMemoryCount(memoryDir, workingLine);
4659
5797
  if (memCount != null) {
4660
5798
  note(trace, "goal: get a count of a memory-store kind (facts/utterances)");
4661
5799
  note(trace, "lane: answerMemoryCount — matched a MEMORY_COUNT_NOUNS entry, answered off the .tmct/memory graph header");
4662
- return withLast(plainTurn(line, memCount, { via: "count", focus }), "get a count of a memory-store kind");
5800
+ return withLast(plainTurn(workingLine, memCount, { via: "count", focus }), "get a count of a memory-store kind");
4663
5801
  }
4664
5802
  }
4665
5803
  // Feature A point 4: "how many Xs are Ys" — a taught-quantifier RECALL, checked
@@ -4668,32 +5806,32 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4668
5806
  // authority gate declines (returns null) for anything answerCount should own,
4669
5807
  // so ordinary structural counts fall through completely unaffected.
4670
5808
  if (memoryDir) {
4671
- const quantifierRecall = await answerQuantifierRecall(memoryDir, line);
5809
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine);
4672
5810
  if (quantifierRecall != null) {
4673
5811
  note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
4674
5812
  note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
4675
- return withLast(plainTurn(line, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
5813
+ return withLast(plainTurn(workingLine, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
4676
5814
  }
4677
5815
  }
4678
5816
  // Aggregate/count questions are answered mechanically off the loaded graph header,
4679
5817
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
4680
- const count = answerCount(graph, line);
5818
+ const count = answerCount(graph, workingLine);
4681
5819
  if (count != null) {
4682
5820
  // An "I can't count <noun>" from a bare kind may still be answerable from an
4683
5821
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
4684
5822
  // class count). countFromFacts declines on a real graph kind, so ordinary
4685
5823
  // counts are unaffected; it only speaks for a remembered object noun.
4686
- const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
5824
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine) : null;
4687
5825
  if (viaFact != null) {
4688
5826
  note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
4689
5827
  note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
4690
- return withLast(plainTurn(line, viaFact, { via: "fact", focus }), "get a count");
5828
+ return withLast(plainTurn(workingLine, viaFact, { via: "fact", focus }), "get a count");
4691
5829
  }
4692
5830
  note(trace, "goal: get a count of a graph kind (classes/functions/modules/…)");
4693
5831
  note(trace, "lane: answerCount — a header-count aggregate question, answered mechanically off the graph header, never dispatched to the ask engine");
4694
- return withLast(plainTurn(line, count, { via: "count", focus }), "get a count of a graph kind");
5832
+ return withLast(plainTurn(workingLine, count, { via: "count", focus }), "get a count of a graph kind");
4695
5833
  }
4696
- return withLast(await runAsk(line, ctx), "unclear — no goal signal computed by the ask engine");
5834
+ return withLast(await runAsk(workingLine, ctx), "unclear — no goal signal computed by the ask engine");
4697
5835
  }
4698
5836
 
4699
5837
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----