@polycode-projects/the-mechanical-code-talker 1.0.7 → 1.0.9

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,18 @@ 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;
1358
1476
 
1359
1477
  /** The unknown-SUBJECT direct-write fallback (point 1 + point 2's bare-property
1360
1478
  * extension): tried ONLY after the real ACE grammar (assertTurn) has already
@@ -1442,7 +1560,21 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1442
1560
  * multi-word subject simply doesn't match here and honestly declines
1443
1561
  * (point 6) rather than risk a wrong split — the is/are-specific frames
1444
1562
  * elsewhere in this lane already own that broader territory. */
1445
- const GENERAL_VERB_TEACH_RE = /^([\w'-]+)\s+([a-z]+)\s+(.+?)[.!?]*$/i;
1563
+ // Tier-5 playtest fix (this session): a frequency/degree ADVERB commonly sits
1564
+ // between a bare-name subject and the real verb in a natural teaching
1565
+ // sentence — the operator's own example, "remember that TaskController
1566
+ // usually needs review", mis-split subject="TaskController", VERB="usually"
1567
+ // (GENERAL_VERB_TEACH_RE had no way to see "usually" wasn't the verb), minting
1568
+ // a nonsense mgx:usually predicate and, worse, garbling the confirmation
1569
+ // itself: thirdPersonSingularSurface's naive fallback appended "-ies" to the
1570
+ // unrecognized lemma, surfacing "taskcontroller usuallies needs review". A
1571
+ // closed, non-capturing adverb-skip (never itself eligible to BE the verb)
1572
+ // fixes this at the source for both the teach shape and its query-side twins
1573
+ // below, without widening what counts as a recognized shape at all — the same
1574
+ // "recognition closed, mapping generalized" split this lane already uses.
1575
+ const TEACH_ADVERB_SKIP_SRC = "(?:(?:usually|often|sometimes|rarely|never|always|typically|generally|"
1576
+ + "occasionally|frequently|normally|regularly|commonly|mostly|currently|still|also|really|actually)\\s+)?";
1577
+ const GENERAL_VERB_TEACH_RE = new RegExp(`^([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[.!?]*$`, "i");
1446
1578
  /** Determiners/quantifiers that make the FIRST token an article, not a real
1447
1579
  * bare-name subject ("every controller…", "the cache…") — GENERAL_VERB_TEACH_RE
1448
1580
  * would otherwise happily bind them as a 1-token subject and misread the
@@ -1477,13 +1609,23 @@ const GENERAL_VERB_ANYWHERE_EXCLUDE_RE = /\b(?:is|are|am|owns|maintains)\b/i;
1477
1609
  * documented contract) and this falls back to the verb AS TYPED — still a
1478
1610
  * perfectly storable/retrievable predicate, just not cross-inflection
1479
1611
  * canonicalized. Never a hand-curated per-verb table entry required. */
1612
+ // Bug A (operator manual-chat find, this session): only the exact raw strings
1613
+ // "has"/"have" were special-cased onto HAS_A_PREDICATE above — past tense "had"
1614
+ // (or "having") fell through to the generic mgx:<lemma> path, where the lemma of
1615
+ // "had" IS "have", and predicatePhrase's thirdPersonSingularSurface fallback
1616
+ // naively appends "s" to any unrecognized lemma ending ("have"+"s" = "haves" —
1617
+ // wrong; the correct irregular is "has"). Fixed by checking the LEMMA (not just
1618
+ // the raw verb) for "have" — this catches had/having/has/have uniformly, so
1619
+ // "remember X had soup" reads back "...has soup", never "...haves soup".
1480
1620
  async function generalVerbPredicate(verb) {
1481
1621
  const v = String(verb || "").toLowerCase();
1482
1622
  if (v === "has" || v === "have") return HAS_A_PREDICATE;
1483
1623
  try {
1484
1624
  const { proseLemma } = await import("./prose-nlp.mjs");
1485
1625
  const lemma = proseLemma();
1486
- return `mgx:${lemma ? lemma(v) : v}`;
1626
+ const l = lemma ? lemma(v) : v;
1627
+ if (l === "have") return HAS_A_PREDICATE;
1628
+ return `mgx:${l}`;
1487
1629
  } catch {
1488
1630
  return `mgx:${v}`;
1489
1631
  }
@@ -1510,6 +1652,35 @@ async function generalVerbTeach(payload) {
1510
1652
  return { subject, predicate, object };
1511
1653
  }
1512
1654
 
1655
+ // ---- General verb-to-predicate DIRECT-QUESTION retrieval (item 5, this
1656
+ // session's follow-up to the teach mechanism above): "does margo eat ribs" /
1657
+ // "did margo eat ribs" / "what does margo eat" against a fact taught via
1658
+ // generalVerbTeach. "did" joins "does" so past-tense forms work too (also
1659
+ // GROUP 3 Bug B — "what did X have"/"did margo eat ribs"). Wired into
1660
+ // factReadBack (below), which only runs on an already-true `miss`, so these
1661
+ // never race ask.mjs's closed structural grammar for a real graph query
1662
+ // ("does TaskController call widget" resolves there first, this lane is never
1663
+ // reached). Both run the SAME GENERAL_VERB_EXCLUDE_RE/GENERAL_VERB_ANYWHERE_
1664
+ // EXCLUDE_RE decline guards generalVerbTeach uses, and route the verb through
1665
+ // the SAME generalVerbPredicate (not a re-implementation), so the has/have
1666
+ // bridge (and Bug A's had/having lemma fix) is automatic on the query side too. ----
1667
+ // Same adverb-skip as GENERAL_VERB_TEACH_RE above (TEACH_ADVERB_SKIP_SRC),
1668
+ // reused so "does TaskController usually need review"/"what does
1669
+ // TaskController usually need" read back a fact taught with the adverb
1670
+ // skipped the same way, never mis-splitting "usually" as the verb here either.
1671
+ const GENERAL_VERB_YESNO_RE = new RegExp(`^(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)\\s+(.+?)[?.!\\s]*$`, "i");
1672
+ const GENERAL_VERB_OPEN_RE = new RegExp(`^what\\s+(?:does|did)\\s+([\\w'-]+)\\s+${TEACH_ADVERB_SKIP_SRC}([a-z]+)[?.!\\s]*$`, "i");
1673
+ /** GENERAL_VERB_EXCLUDE_RE was written for generalVerbTeach's fully-conjugated
1674
+ * declarative verb ("X OWNS Y", "X MAINTAINS Y") — but "does/did X <verb> Y"
1675
+ * captures the BARE INFINITIVE after do-support ("does X OWN Y", never "does X
1676
+ * owns Y"), so "owns"/"maintains" literally never appear in genYN/genOpen's
1677
+ * captured verb even when the sentence names exactly that relation. Found live
1678
+ * (a real false "no" against a genuinely-true taught ownership fact, since
1679
+ * generalVerbPredicate("own") mints a DIFFERENT predicate — mgx:own — than the
1680
+ * ownership frame's own OWNED_BY_PREDICATE): the query-side guard needs the
1681
+ * bare-infinitive counterpart too. */
1682
+ const GENERAL_VERB_QUERY_EXCLUDE_RE = /^(?:be|own|maintain)$/i;
1683
+
1513
1684
  /** Sentence forms to try asserting for a teach payload: the payload as-is, and
1514
1685
  * (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
1515
1686
  * grammar actually lands. */
@@ -1574,7 +1745,14 @@ function teachSuggestion(payload) {
1574
1745
  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
1746
 
1576
1747
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1577
- const rawInput = String(query).trim();
1748
+ // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
1749
+ // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
1750
+ // remember that TaskController is fragile") corrupted TEACH_RE's own match —
1751
+ // applyPreambleFrames is idempotent no-op on an already-clean teach sentence
1752
+ // (none of its frames' anchors — greeting/thanks/ack/modal/explain/show-give-me/
1753
+ // topic-switch/hedge — match ordinary teach phrasing, verified against this
1754
+ // lane's own test corpus), so this is purely additive.
1755
+ const rawInput = applyPreambleFrames(String(query).trim());
1578
1756
  const m = rawInput.match(TEACH_RE);
1579
1757
  const wrappedInput = m ? m[1].trim() : null;
1580
1758
  // "your X is a/an Y" (Feature A) — a plain casual synonym for "a/an X is a
@@ -1617,6 +1795,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1617
1795
  });
1618
1796
  if (stored) return stored;
1619
1797
  }
1798
+ // PASSIVE ownership — "<X> is owned by <Name>" (Tier-5 playtest, cycle 2).
1799
+ // Same bare-form gate as the active shape just above: a Capitalized owner
1800
+ // name AND no interrogative lead, so "is TaskController owned by anyone"
1801
+ // (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
1802
+ // a bogus fact here.
1803
+ const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
1804
+ if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
1805
+ const stored = await teachFact(memoryDir, sessionId, {
1806
+ subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
1807
+ });
1808
+ if (stored) return stored;
1809
+ }
1620
1810
 
1621
1811
  // "some Xs are Ys" / "a few Xs are Ys" (Feature A) — the plural class-
1622
1812
  // membership quantifier shape. ACE has no quantifier-phrase pattern at all
@@ -1641,6 +1831,33 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1641
1831
  subject, predicate: SUBCLASS_PREDICATE, object, quantifier,
1642
1832
  });
1643
1833
  if (stored) return stored;
1834
+ } else {
1835
+ // Tier-5 playtest fix (cycle 2), found live: "remember that some
1836
+ // functions are risky" — Y ("risky") is not a lexicon NOUN, so the
1837
+ // subclass path just above correctly declines it (SOME_A_FEW_RE is
1838
+ // subclass-only, by design — "risky" isn't even in the closed
1839
+ // lexicon at all, as either noun or adjective, so gating this decline
1840
+ // on "is Y a known adjective" missed the actual case entirely on the
1841
+ // first attempt at this fix). Without this guard, the sentence fell
1842
+ // through to unknownSubjectFallback/TEACH_PROPERTY_RE below, which DO
1843
+ // tolerate a multi-word subject with NO vocabulary check on the
1844
+ // complement at all — silently mis-teaching the LITERAL 2-word string
1845
+ // "some functions" as if it were one proper-noun subject ("noted —
1846
+ // remembered: some functions is risky", the quantifier word baked
1847
+ // wrongly into the subject and a subject/verb agreement error to
1848
+ // boot), a fact "how many functions are risky" could never sensibly
1849
+ // read back either (HOW_MANY_ARE_RE's own reader only ever looks for
1850
+ // the SUBCLASS_PREDICATE shape this path would have stored, not this
1851
+ // one). A quantified PROPERTY claim isn't a supported shape yet (only
1852
+ // a quantified SUBCLASS claim is) — decline honestly here instead of
1853
+ // silently mis-teaching, rather than let a later, less-specific frame
1854
+ // guess a wrong split.
1855
+ return {
1856
+ text: `I can only remember a quantified fact as "${quantifier} ${someMatch[2]} are <a kind of thing>" (like "${quantifier} bugs are issues") — `
1857
+ + `a quantified claim about a PROPERTY ("${quantifier} ${someMatch[2]} are ${object}") isn't a shape I can store yet. `
1858
+ + `I can remember "${someMatch[2]} are ${object}" for one specific ${subject}, though — try naming it directly.`,
1859
+ via: "teach-miss", miss: true,
1860
+ };
1644
1861
  }
1645
1862
  }
1646
1863
 
@@ -1665,7 +1882,28 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1665
1882
  let payload = null;
1666
1883
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
1667
1884
  else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
1668
- if (!payload) return null;
1885
+ if (!payload) {
1886
+ // Tier-5 playtest fix (cycle 3), found live: "remember that every
1887
+ // controller needs review" — a QUANTIFIED subject ("every X", declined
1888
+ // by generalVerbTeach's own GENERAL_VERB_DETERMINER_RE, by design — see
1889
+ // its docblock on the ambiguity risk of a free-form multi-word subject)
1890
+ // combined with a non-copula verb ("needs", not is/are/owns/maintains)
1891
+ // fits NONE of the recognizers above, so `payload` stays null and this
1892
+ // used to return null SILENTLY — the exact "wrong-context wall" bug
1893
+ // class Bug 3's generalVerbTeach mechanism was built to close for
1894
+ // "remember margo eats ribs", re-escaping here through a combination
1895
+ // that mechanism's own deliberate subject-shape restriction doesn't
1896
+ // cover. An explicit "remember/note/…"-wrapped sentence is an
1897
+ // UNAMBIGUOUS teach-intent signal — falling through to the ordinary
1898
+ // structural-query wall is a wrong-context reply even when nothing here
1899
+ // can actually STORE the fact; if `wrapped` stood, keep going with it as
1900
+ // the payload so the residue-detection/final-decline logic below still
1901
+ // runs (never a guess at storing it, just never silence either). A bare,
1902
+ // unwrapped sentence that also fits no shape has no such signal — return
1903
+ // null and let the ordinary cascade decide, unchanged.
1904
+ if (!wrapped) return null;
1905
+ payload = wrapped;
1906
+ }
1669
1907
  // Try to store it (a live session provides the write target). assertTurn returns
1670
1908
  // the "noted — remembered …" confirmation or null (grammar miss / unknown words).
1671
1909
  if (memoryDir) {
@@ -1754,11 +1992,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1754
1992
  // ONLY (the caller gates on a miss) and every pattern is a WHOLE-LINE self/session
1755
1993
  // reference with no graph entity or predicate, so real graph queries ("what does X
1756
1994
  // 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)?$/;
1995
+ // Bug D (operator manual-chat find, this session): "what is in your memory"/
1996
+ // "what's in your memory" is a plain synonym of the bare "what do you know" —
1997
+ // widened here rather than folded in as "what do you remember" (that phrase is
1998
+ // ALREADY WHOLE_RECALL_RE's own, more specific, territory — it lists every
1999
+ // remembered fact, a strictly better answer than this lane's short summary; see
2000
+ // WHOLE_RECALL_RE's docblock below and the pinned "'what do you remember' ...
2001
+ // STILL list facts" test — folding it in here would silently regress that).
2002
+ 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
2003
  // 0.8.2 WS4 wall kindness (c): the most likely stranger openers — "what does this
1759
2004
  // app/codebase do", "what is this app (for)" — join the orientation lane, so a
1760
2005
  // 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))$/;
2006
+ 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
2007
 
1763
2008
  /** A SHORT memory summary (never a fact dump) for the bare "what do you know".
1764
2009
  * This branch only fires when rows.length === 0 — i.e. precisely the case where
@@ -1795,15 +2040,57 @@ async function memorySummary(memoryDir, graph) {
1795
2040
  // case-sensitive, so this reads the ORIGINAL query text, never metaLane's
1796
2041
  // lowercased `q` (authorLane's same discipline, just above/below).
1797
2042
  const MODULE_ORIENT_RE = /^what\s+does\s+(.+?)\s+do\??$/i;
2043
+ /** The SUBJECT-FIRST word order of the SAME question ("what saveStore does" vs
2044
+ * "what does saveStore do") — Tier 6 playtest, §3b surface-variation axis: a
2045
+ * perfectly natural alternate phrasing of an ALREADY-recognized intent that
2046
+ * used to hit the raw grammar wall outright (MODULE_ORIENT_RE's own anchor
2047
+ * requires "does" BEFORE the term). Tried only when MODULE_ORIENT_RE/
2048
+ * MODULE_PURPOSE_RE both miss; the entity-resolution gate just below (a real,
2049
+ * UNIQUE graph entity or this lane declines) is what keeps this loose an
2050
+ * ending safe — a syntactic match against a term that isn't a real entity
2051
+ * simply falls through unchanged, same as every other lane in this file. */
2052
+ const MODULE_ORIENT_SVO_RE = /^what\s+(.+?)\s+does\??$/i;
2053
+ // Seonix Batch 3 (3a) — purpose/identity phrasing: "whats X for"/"what's X
2054
+ // about"/"what is X for", the sibling of "what does X do" that asks for the
2055
+ // SAME module-grain overview. Deliberately does NOT claim the literal noun
2056
+ // "app" ("what is this app for") — META_ORIENT_RE (above) already hardcodes
2057
+ // that exact phrasing and is checked BEFORE moduleOrientLane runs (metaLane's
2058
+ // own ordering), so this regex only ever gets a chance at OTHER resolvable
2059
+ // terms. "what(?:'s|s|\s+is)" mirrors PERSONAL_ASSISTANT_NUDGE_RE's own
2060
+ // tolerance for the bare "whats" contraction spelling, just below.
2061
+ const MODULE_PURPOSE_RE = /^what(?:'s|s|\s+is)\s+(.+?)\s+(?:for|about)\??$/i;
2062
+
2063
+ /** A leading politeness/formal-ESL wrapper this lane's own anchored regexes
2064
+ * otherwise miss entirely (Tier 6 playtest): "please explain what does X do"
2065
+ * starts with neither "what"/"whats" (MODULE_ORIENT_RE/MODULE_PURPOSE_RE's own
2066
+ * anchor) nor bare "explain" (normalize.mjs's own EXPLAIN_WRAPPER_RE, which
2067
+ * requires NOTHING before "explain" — a leading "please" defeats it too), so
2068
+ * it fell straight to the raw grammar wall. A repeated (please|kindly) plus an
2069
+ * optional "explain [to me]" — both closed, both optional, so a bare "what
2070
+ * does X do" is untouched (the whole prefix matches empty). */
2071
+ const MODULE_ORIENT_POLITENESS_RE = /^(?:(?:please|kindly)\s+)*(?:explain\s+(?:to\s+me\s+)?)?/i;
1798
2072
 
1799
2073
  /** authorLane's discipline, mirrored: a closed regex + an EXACT, UNIQUE
1800
2074
  * 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. */
2075
+ * subjects ("what does it/this do", "what's it for") are META_ORIENT_RE's/
2076
+ * isConversational's territory, not this lane's — declined here so they
2077
+ * fall through unchanged. */
1803
2078
  async function moduleOrientLane(query, { graph }) {
1804
2079
  if (!graph) return null;
1805
- const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1806
- const m = q.match(MODULE_ORIENT_RE);
2080
+ let q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2081
+ // Tier 6 playtest: this lane reads the ORIGINAL (case-preserving) query text
2082
+ // and never ran any of the general-purpose normalization passes the rest of
2083
+ // the file uses for the SAME class of surface noise — correctMisspellings
2084
+ // for a typo'd anchor word ("waht dose the logger modul do"), applyPreambleFrames
2085
+ // for a topic-switch/self-interruption preamble ("scratch that, what does X
2086
+ // do") — plus a lane-local politeness strip for "please explain X" (applyPreambleFrames's
2087
+ // own EXPLAIN_WRAPPER_RE requires the string to literally START with "explain",
2088
+ // so a LEADING "please"/"kindly" ahead of it defeats that frame; see
2089
+ // MODULE_ORIENT_POLITENESS_RE's own docblock). All three are additive,
2090
+ // closed-set, and idempotent on an already-clean query, so applying them here
2091
+ // only ever WIDENS what resolves, never narrows it.
2092
+ q = applyPreambleFrames(correctMisspellings(q)).replace(MODULE_ORIENT_POLITENESS_RE, "");
2093
+ const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
1807
2094
  if (!m) return null;
1808
2095
  const term = m[1].trim();
1809
2096
  if (/^(?:it|this|that|they|them)$/i.test(term)) return null;
@@ -2179,7 +2466,11 @@ function configFor(repoPath) {
2179
2466
  /** Resolve a free-text term to a single graph entity via the ask engine's own
2180
2467
  * tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
2181
2468
  * 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. */
2469
+ * records fewer ids / does not update the focus, never a crash or a wrong id.
2470
+ * The leading-article-strip + trailing-grain-word disambiguation ("the logger
2471
+ * module" -> Module-only "logger") lives centrally in resolveObject itself
2472
+ * (ask.mjs) so every direct caller of resolveObject (ask()'s own WHERE/describe
2473
+ * grammar, traverse(), etc.) gets it too, not just this wrapper. */
2183
2474
  async function resolveEntity(graph, term) {
2184
2475
  if (!graph || !term) return null;
2185
2476
  try {
@@ -2398,6 +2689,13 @@ const FACT_PREDICATE_PHRASES = {
2398
2689
  * renders verbatim, unchanged from before this fix. */
2399
2690
  function thirdPersonSingularSurface(lemma) {
2400
2691
  const w = String(lemma || "");
2692
+ // Bug A safety net: "have" should never reach this naive fallback at all
2693
+ // (generalVerbPredicate special-cases it onto mgx:hasA before a predicate is
2694
+ // ever minted), but if some OTHER path ever reaches here with it as typed —
2695
+ // e.g. wink-nlp unavailable so lemma degrades to the raw verb — the naive
2696
+ // "+s" rule would produce the wrong-MEANING "haves" instead of the correct
2697
+ // irregular "has".
2698
+ if (/^have$/i.test(w)) return "has";
2401
2699
  if (/[a-z]y$/i.test(w) && !/[aeiou]y$/i.test(w)) return `${w.slice(0, -1)}ies`;
2402
2700
  if (/(?:s|x|z|ch|sh|o)$/i.test(w)) return `${w}es`;
2403
2701
  return `${w}s`;
@@ -2523,6 +2821,25 @@ function factTermVariants(normFactTerm, term) {
2523
2821
  return v;
2524
2822
  }
2525
2823
 
2824
+ /** GENERIC "kind" nouns a taught subject's head word is often built from
2825
+ * ("logger MODULE", "task CONTROLLER") — excluded from the head-word
2826
+ * overlap fallback both KNOW_ABOUT_RE's "what do you know about X" listing
2827
+ * and IS_ADJECTIVE_YESNO_RE's property yes/no reader use (below): a bare
2828
+ * length >= 4 floor alone isn't enough, since "module" (6 chars) is shared
2829
+ * by "logger module" AND "validate module" and any OTHER "X module" taught
2830
+ * subject — without this exclusion, "is the validate module deprecated"
2831
+ * confidently answered YES off a fact taught for "logger module" (found
2832
+ * live, Tier-5 playtest cycle 5 — a real false-positive fabrication, not a
2833
+ * routing gap, caught before shipping). Mirrors RECALL_STOPWORDS' own
2834
+ * path-noise exclusion (src/lib/mjs never counting as a real overlap
2835
+ * either) — same principle, a different word class. */
2836
+ const GENERIC_ENTITY_WORDS = new Set([
2837
+ "module", "modules", "class", "classes", "function", "functions",
2838
+ "method", "methods", "handler", "handlers", "controller", "controllers",
2839
+ "service", "services", "component", "components", "flow", "flows",
2840
+ "thing", "things", "item", "items", "object", "objects", "commit", "commits",
2841
+ ]);
2842
+
2526
2843
  // ---- PLAN_ontology-hierarchies.md §3 tracks (a)+(b): synonymsOf(term) —
2527
2844
  // QUERY-TIME term expansion wiring the two already-parsed-but-inert synonym
2528
2845
  // resources. §1's "two vocabulary gates" distinction: this widens what a
@@ -2605,8 +2922,14 @@ async function synonymsOf(term) {
2605
2922
  * doesn't parse; checked against the isa-family fact predicates only. */
2606
2923
  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
2924
  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;
2925
+ /** "what do you know about caches" — the open recall-everything form. Bug E
2926
+ * (operator manual-chat find, this session) widened this to also accept
2927
+ * "what is in your memory about X" / "what's in your memory about X" / "what
2928
+ * do you remember about X" as plain synonyms — none of these collide with an
2929
+ * existing more-specific lane (TOLD_ABOUT_RE only owns "what did i tell you
2930
+ * about X"; WHOLE_RECALL_RE's own "what do you remember" has no "about X"
2931
+ * tail, so it's a disjoint shape). */
2932
+ 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
2933
  /** How many facts a single answer lists before the remainder is paged with "more". */
2611
2934
  const FACT_ANSWER_CAP = 32;
2612
2935
 
@@ -2631,7 +2954,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
2631
2954
  if (!metaTerm && miss && !envelope?.parsed) {
2632
2955
  const m = q.match(BARE_WHATIS_RE)
2633
2956
  || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
2634
- if (m) metaTerm = m[1];
2957
+ // Seonix Batch 2 Fix 3: strip a curated trailing scope clause ("… in this
2958
+ // graph"/"… in this codebase"/…) the same way grammar.mjs's T5 and
2959
+ // metaTermOf do — BARE_WHATIS_RE's capture is otherwise the literal glued
2960
+ // tail, verbatim.
2961
+ if (m) metaTerm = stripTrailingScopeFiller(m[1]);
2635
2962
  }
2636
2963
  if (metaTerm) {
2637
2964
  // BUG 1 fix: "what is a tree used for" parses (grammar.mjs T5) to the
@@ -2682,15 +3009,93 @@ async function factAnswer(memoryDir, query, envelope, miss) {
2682
3009
  const know = q.match(KNOW_ABOUT_RE);
2683
3010
  if (know) {
2684
3011
  const variants = factTermVariants(normFactTerm, know[1]);
2685
- const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject) || variants.has(f.object));
3012
+ const rows = await memoryFacts(memoryDir);
3013
+ // Bug E subtype walk (operator follow-up request, this session): a
3014
+ // cycle-safe BFS DOWNWARD over isa-family facts from the term's own
3015
+ // variants — every fact whose OBJECT is in the current frontier
3016
+ // contributes its SUBJECT as a known SUBTYPE (and the next hop's
3017
+ // frontier), so "every widget is a component" + "button is a widget" +
3018
+ // "button has a blue-color" lets "what do you know about component"
3019
+ // surface the button fact too, even though "button" never literally
3020
+ // mentions "component". Capped at 8 hops — this is a listing operation,
3021
+ // not findIsaChain's strict maxHops:2 proof-chase, but still bounded as a
3022
+ // safety net against pathological data.
3023
+ //
3024
+ // The chain itself is walked over TAUGHT isa facts only (same "isTaught"
3025
+ // discipline the live cax-sco/scm-sco proof chase already uses, below) —
3026
+ // the bulk background corpus (thousands of ConceptNet/seon "is a kind of"
3027
+ // rows) would otherwise chain almost ANY term into hundreds of coincidental
3028
+ // "subtypes" that have nothing to do with what the OPERATOR actually
3029
+ // taught, drowning the real answer and defeating the negative-case
3030
+ // discipline this feature exists to preserve. The literal-mention hits
3031
+ // (the ORIGINAL, non-subtype half of the filter below) still include
3032
+ // corpus facts exactly as before — only the SUBTYPE DISCOVERY chain is
3033
+ // taught-only.
3034
+ // `rows` here is memoryFacts()'s plain {subject,predicate,object,provenance}
3035
+ // shape (no `sourceTypes` — that's factRows()'s own trust-enriched shape),
3036
+ // so the taught/corpus distinction reads the SAME provenance-string
3037
+ // convention renderFactLine already keys its own corpus-vs-taught framing
3038
+ // on, just above.
3039
+ const isTaughtFact = (f) => !String(f.provenance || "").includes("corpus:") && !String(f.provenance || "").includes("web:");
3040
+ const isaRows = rows.filter((f) => ISA_PREDICATES.has(f.predicate) && isTaughtFact(f));
3041
+ const subtypeSubjects = new Set();
3042
+ let frontier = variants;
3043
+ for (let hop = 0; hop < 8 && frontier.size; hop += 1) {
3044
+ const nextSubjects = new Set();
3045
+ for (const f of isaRows) {
3046
+ if (frontier.has(f.object) && !subtypeSubjects.has(f.subject)) nextSubjects.add(f.subject);
3047
+ }
3048
+ if (!nextSubjects.size) break;
3049
+ for (const s of nextSubjects) subtypeSubjects.add(s);
3050
+ const nextFrontier = new Set();
3051
+ for (const s of nextSubjects) for (const v of factTermVariants(normFactTerm, s)) nextFrontier.add(v);
3052
+ frontier = nextFrontier;
3053
+ }
3054
+ let hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object) || subtypeSubjects.has(f.subject));
3055
+ // Tier-5 playtest fallback: a taught fact's subject is often a real NOUN
3056
+ // PHRASE ("logger module", "tasks handler"), but a natural follow-up
3057
+ // shortens it to one head word ("what do you know about the logger") —
3058
+ // an exact-variant miss above, since "logger" !== "logger module". Only
3059
+ // tried when the exact/subtype pass found NOTHING (never overrides a real
3060
+ // hit), and only on a whole WORD (length >= 4, the same floor
3061
+ // resolveObject's own tier-3/5 containment checks use to keep a short
3062
+ // staccato word from hijacking an unrelated fact) shared between the
3063
+ // query term and a fact's subject/object — a listing/discovery feature
3064
+ // (like the subtype walk above), not a yes/no claim, so a slightly wider
3065
+ // recall net is consistent with this lane's existing inclusiveness.
3066
+ if (!hits.length) {
3067
+ const queryWords = normFactTerm(know[1]).split(/\s+/).filter((w) => w.length >= 4 && !GENERIC_ENTITY_WORDS.has(w));
3068
+ if (queryWords.length) {
3069
+ const wordsOf = (s) => new Set(String(s || "").split(/\s+/));
3070
+ const overlaps = (term) => { const w = wordsOf(term); return queryWords.some((qw) => w.has(qw)); };
3071
+ hits = rows.filter((f) => overlaps(f.subject) || overlaps(f.object));
3072
+ }
3073
+ }
3074
+ // A genuinely empty result here is a real miss (Tier-5 playtest cycle 3:
3075
+ // "what do you know about the last commit" needs a TEACH-OFFER, not a
3076
+ // bare wall — added as a LATE runTurn-level addition, below, alongside
3077
+ // the sibling "what is X" offer, rather than returned from here: an
3078
+ // early return through this function's normal contract would pre-empt
3079
+ // runTurn's own wall-shortening pass (shortMissHint/lane 5), leaving the
3080
+ // FULL unshortened grammar cheat-sheet standing under the offer instead
3081
+ // of the nicer tailored one-liner — found live while adding this fix).
2686
3082
  if (!hits.length) return null;
2687
3083
  // 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;
3084
+ const literalHit = hits.find((f) => variants.has(f.subject) || variants.has(f.object));
3085
+ const term = literalHit
3086
+ ? (variants.has(literalHit.subject) ? literalHit.subject : literalHit.object)
3087
+ : know[1].trim();
3088
+ // when a subtype-derived hit contributed something a plain literal-mention
3089
+ // match wouldn't have found, say so — lets the reader tell subtype-derived
3090
+ // facts apart from literal mentions.
3091
+ const viaSubtype = hits.some((f) => subtypeSubjects.has(f.subject) && !variants.has(f.subject) && !variants.has(f.object));
2689
3092
  const lines = hits.map((f) => ` ${renderFactLine(f)}`);
2690
3093
  const shown = lines.slice(0, FACT_ANSWER_CAP);
2691
3094
  const rest = lines.slice(FACT_ANSWER_CAP);
2692
3095
  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" } } : {}) };
3096
+ const header = `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}`
3097
+ + `${viaSubtype ? " (including its known subtypes)" : ""}:`;
3098
+ return { text: `${header}\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
2694
3099
  }
2695
3100
  return null;
2696
3101
  }
@@ -2816,6 +3221,45 @@ const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*
2816
3221
  /** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
2817
3222
  * the teach lane's mgx:ownedBy facts. */
2818
3223
  const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
3224
+ /** "does/did <Name> own/maintain <X>" — the yes/no ownership claim over the
3225
+ * SAME mgx:ownedBy facts WHO_OWNS_RE reads (Tier-5 playtest fix). The bare
3226
+ * infinitive after do-support ("does X own Y", never "does X owns Y") mirrors
3227
+ * GENERAL_VERB_YESNO_RE's own do-support convention. */
3228
+ const OWNS_YESNO_RE = /^(?:does|did)\s+([\w'-]+)\s+(?:owns?|maintains?)\s+(.+?)[?.!\s]*$/i;
3229
+ /** "is/are/was/were <X> owned by <Name>" — the PASSIVE yes/no ownership
3230
+ * claim, sibling of OWNS_YESNO_RE just above and OWNS_PASSIVE_TEACH_RE
3231
+ * (chat.mjs's teach lane) — same mgx:ownedBy facts, matched BEFORE
3232
+ * IS_ADJECTIVE_YESNO_RE below (which would otherwise also match this shape,
3233
+ * backtracking "owned by" into its own subject capture and "<Name>" into its
3234
+ * adjective slot, silently declining rather than answering). */
3235
+ 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;
3236
+ /** "is/are/was/were <X> <adjective>" — a yes/no claim over a taught
3237
+ * mgx:hasProperty fact (Tier-5 playtest fix). Deliberately has NO marker
3238
+ * between subject and complement — "a"/"an"/"a kind of"/"a type of" is
3239
+ * ISA_ASK_RE's own mandatory territory just below (matched and handled, or
3240
+ * matched-and-declined, BEFORE this code ever runs), so a genuine "is a
3241
+ * module a component" is never reachable here. Anaphoric "it"/"this"/"that"
3242
+ * resolves against the session's current FOCUS (threaded in as `focusLabel`)
3243
+ * — never a guess when there's no standing focus. Only ever answers "yes"
3244
+ * (a real fact found) or DECLINES (null) — never a fabricated closed-world
3245
+ * "no", unlike its ownership/general-verb siblings above: a bare copula
3246
+ * ("is this good", "is it done") is the single most common CASUAL English
3247
+ * shape, so a wrong-feeling "no — no remembered fact says X is Y" for
3248
+ * ordinary small talk would be worse than deferring to the ordinary
3249
+ * cascade/orientation nudge that already handles it. */
3250
+ const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
3251
+ const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
3252
+ /** The TEACH-OFFER for a subject IS_ADJECTIVE_YESNO_RE resolved but has no
3253
+ * fact about at all (Tier-5 playtest, cycle 2) — the offered "remember that
3254
+ * X is Y" phrasing is verified in-state: TEACH_PROPERTY_RE's own subject
3255
+ * capture is unbounded multi-word with no lexicon gate on the complement, so
3256
+ * this always actually stores, unlike the bare unwrapped form (which only
3257
+ * reaches TEACH_PROPERTY_RE via BARE_DECLARATIVE_RE's single-token-subject
3258
+ * restriction and would fail here). */
3259
+ const unknownAdjectiveOffer = (subject, adjective) => ({
3260
+ text: `I don't know anything about "${subject}" yet — teach me directly, e.g. "remember that ${subject.toLowerCase()} is ${adjective}".`,
3261
+ replace: true,
3262
+ });
2819
3263
  /** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
2820
3264
  * "what facts do you know", "what do you remember" — list EVERY remembered fact
2821
3265
  * (no subject/object term to filter on), cited, higher-trust first. The multi-turn
@@ -2881,13 +3325,80 @@ function inheritsChain(graph, startId) {
2881
3325
  * "what kind of thing is an X" reports X's own type (subject-side first).
2882
3326
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
2883
3327
  * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
2884
- async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
3328
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null) {
2885
3329
  if (!miss) return null;
2886
3330
  let normFactTerm;
2887
3331
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
2888
3332
  const q = String(query).trim();
3333
+ // Tier-5 playtest fix (cycle 4), found live: "actually is the store module
3334
+ // fragile" WALLED — a leading hedge adverb ("actually"/"really"/"honestly",
3335
+ // optionally comma'd) put the sentence one word out of alignment with
3336
+ // IS_ADJECTIVE_YESNO_RE/OWNS_YESNO_RE/OWNS_PASSIVE_YESNO_RE's own anchored
3337
+ // "is|are|was|were|does|did" openers — this session's own three new yes/no
3338
+ // readers, so scoped narrowly to just them (qHedge), not the older
3339
+ // ISA_ASK_RE/WHO_OWNS_RE paths above, which already work without it and
3340
+ // don't need the extra risk of a behavior change. Full leading-connective
3341
+ // tolerance (and/also/so/…) is STACCATO_LEAKED_CONNECTIVES' own separate,
3342
+ // broader territory elsewhere in this file — this is a narrower, adjacent
3343
+ // closed set (hedge adverbs, not coordinators).
3344
+ // "yeah nah" (Tier 6 playtest, §3b dialect axis): the same AU/NZ discourse
3345
+ // opener chat.mjs's own GREET closed set and GREETING_PREAMBLE_RE already
3346
+ // recognize elsewhere, added here too — "yeah nah, is TaskController
3347
+ // fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
3348
+ // above were fixed for, just a dialect opener instead of a hedge adverb.
3349
+ const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
2889
3350
  const rows = await factRows(memoryDir);
2890
- if (!rows.length) return null;
3351
+ if (!rows.length) {
3352
+ // Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
3353
+ // remembered yet (a fresh session, nothing taught at all), the early
3354
+ // bail-out below skipped even IS_ADJECTIVE_YESNO_RE's own "subject
3355
+ // completely unknown" TEACH-OFFER further down in this function — "is
3356
+ // the checkout flow deprecated" as someone's genuinely FIRST question
3357
+ // fell to the raw structural wall, unguided. Special-cased here (ahead
3358
+ // of the general empty-memory bail-out every other lane in this function
3359
+ // still relies on) rather than removing the bail-out outright.
3360
+ //
3361
+ // IS_ADJECTIVE_YESNO_RE's own backtracking (no vocabulary restriction on
3362
+ // either capture) means it ALSO syntactically matches shapes that are
3363
+ // NOT a property claim at all — "is a zebra a mammal" (ISA_ASK_RE's own
3364
+ // territory, tried first in the non-empty-rows path below, so never
3365
+ // reached here) and "is there anything bigger" (an existence/staccato-
3366
+ // comparative shape a LATER lane elsewhere in runTurn owns and answers
3367
+ // better than a teach-offer ever could) both regressed real, pinned
3368
+ // tests on first attempt at this fix — caught by running the full suite,
3369
+ // not just the live playtest transcript. Excluded explicitly: ISA_ASK_RE
3370
+ // matches take the SAME priority here they get in the non-empty-rows
3371
+ // path below, and a leading "there" is existential, never a real named
3372
+ // subject a property claim would name.
3373
+ if (!ISA_ASK_RE.test(qHedge)) {
3374
+ const emptyIsAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
3375
+ if (emptyIsAdj) {
3376
+ const rawSubject = emptyIsAdj[1].trim();
3377
+ const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
3378
+ // Tier 6 playtest: "is logger tested"/"is the store module tested" —
3379
+ // IS_ADJECTIVE_YESNO_RE's own unrestricted backtracking (already flagged
3380
+ // as a recurring risk, see this branch's own docblock above for the
3381
+ // ISA_ASK_RE/"is there" exclusions found the SAME way) ALSO matches
3382
+ // "tested" as if it were a free-form property adjective — but "tested"/
3383
+ // "covered"/"untested"/"uncovered" are REAL structural relation words
3384
+ // (PASSIVE_PARTICIPLE_TO_KIND/QUALIFIERS, ask-vocab.mjs) with an actual
3385
+ // graph-computable meaning ask()'s own grammar already resolved
3386
+ // (envelope.parsed stands — a genuine "tests" reverse-relation
3387
+ // traversal, hit or honest empty). Offering "I don't know that yet —
3388
+ // teach me" here would silently DISCARD a real, honest structural
3389
+ // answer in favor of an irrelevant memory teach-offer — the opposite
3390
+ // failure from every other exclusion in this function (a wrong
3391
+ // OVER-eager offer, not a missed one). Declines only when a real parse
3392
+ // already stood; "is the checkout flow deprecated" (this branch's own
3393
+ // ORIGINAL T8 target — "deprecated" has no structural meaning at all)
3394
+ // has no envelope.parsed to defer to, so it is untouched.
3395
+ if (subject && !/^there\b/i.test(subject) && !envelope?.parsed) {
3396
+ return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
3397
+ }
3398
+ }
3399
+ }
3400
+ return null;
3401
+ }
2891
3402
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
2892
3403
  const byTrust = (a, b) => b.trust - a.trust;
2893
3404
  const renderMany = (hits) => {
@@ -2993,6 +3504,181 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
2993
3504
  return renderMany(hits);
2994
3505
  }
2995
3506
 
3507
+ // (a2b) OWNERSHIP yes/no — "does/did <Name> own/maintain <X>": Tier-5
3508
+ // playtest fix, found live — WHO_OWNS_RE only ever answered the OPEN "who
3509
+ // owns X" form; a direct yes/no claim about a specific (owner, thing) pair
3510
+ // ("does margo maintain the tasks handler") fell all the way through to the
3511
+ // structural code-graph wall, even right after teaching exactly that fact,
3512
+ // because GENERAL_VERB_QUERY_EXCLUDE_RE deliberately stands the general-verb
3513
+ // reader down for "own"/"maintain" (they mint a DIFFERENT predicate,
3514
+ // mgx:own/mgx:maintain, than this frame's own OWNED_BY_PREDICATE) — this is
3515
+ // that missing specific reader. Same closed-world convention as (a3)'s
3516
+ // general-verb yes/no just below: a hit answers "yes", no stored fact
3517
+ // answers a definite "no" (never a guessed owner name, unlike the OPEN "who
3518
+ // owns" form above, which stays an honest miss rather than guess WHO).
3519
+ const ownsYN = qHedge.match(OWNS_YESNO_RE);
3520
+ if (ownsYN) {
3521
+ const [, ownerRaw, thingRaw] = ownsYN;
3522
+ const ownerVariants = factTermVariants(normFactTerm, ownerRaw.trim());
3523
+ const thingVariants = factTermVariants(normFactTerm, thingRaw.replace(/^an?\s+/i, "").trim());
3524
+ const hit = rows
3525
+ .filter((f) => f.predicate === OWNED_BY_PREDICATE && thingVariants.has(f.subject) && ownerVariants.has(f.object))
3526
+ .sort(byTrust)[0];
3527
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
3528
+ return {
3529
+ text: `no — no remembered fact says ${ownerRaw.trim().toLowerCase()} owns/maintains ${thingRaw.trim()}.`,
3530
+ replace: true,
3531
+ };
3532
+ }
3533
+
3534
+ // (a2b-ii) PASSIVE ownership yes/no — "is/are/was/were <X> owned by <Name>"
3535
+ // (Tier-5 playtest, cycle 2): same OWNED_BY_PREDICATE facts as (a2b) above,
3536
+ // just the passive phrasing — "is TaskController owned by sam" found live
3537
+ // to WALL entirely (no recognizer at all, teach OR read, for the passive
3538
+ // shape) even right after teaching that exact fact via OWNS_PASSIVE_TEACH_RE.
3539
+ // Checked BEFORE (a2c)'s adjective reader, which would otherwise also match
3540
+ // this shape (backtracking "owned by" into its subject and the owner name
3541
+ // into its adjective slot) and silently decline instead of answering.
3542
+ const ownsPassiveYN = qHedge.match(OWNS_PASSIVE_YESNO_RE);
3543
+ if (ownsPassiveYN) {
3544
+ const [, thingRaw, ownerRaw] = ownsPassiveYN;
3545
+ const thingVariants = factTermVariants(normFactTerm, thingRaw.replace(/^an?\s+/i, "").trim());
3546
+ const ownerVariants = factTermVariants(normFactTerm, ownerRaw.trim());
3547
+ const hit = rows
3548
+ .filter((f) => f.predicate === OWNED_BY_PREDICATE && thingVariants.has(f.subject) && ownerVariants.has(f.object))
3549
+ .sort(byTrust)[0];
3550
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
3551
+ return {
3552
+ text: `no — no remembered fact says ${thingRaw.trim().toLowerCase()} is owned by ${ownerRaw.trim()}.`,
3553
+ replace: true,
3554
+ };
3555
+ }
3556
+
3557
+ // (a2c) PROPERTY yes/no — "is/are/was/were <X> <adjective>": Tier-5 playtest
3558
+ // fix, found live — "remember that the logger module is deprecated" taught a
3559
+ // real mgx:hasProperty fact, but there was no direct-question reader for it
3560
+ // AT ALL (only presuppositionNudge's own narrow "why does X still Y" embeds
3561
+ // this same check) — "is the logger deprecated" fell straight to the
3562
+ // structural wall. Checks BOTH shapes a taught "<X> is <adjective>" can land
3563
+ // as (mirrors presuppositionNudge's own dual check, above): the teach lane's
3564
+ // mgx:hasProperty fact, or — when the adjective is a known ACE-OWL lexicon
3565
+ // data-property word — the ACE grammar's own tmct:<adjective> "true" triple.
3566
+ // "it"/"this"/"that" resolve against `focusLabel` — a bare pronoun with no
3567
+ // standing focus declines (null), same discipline STACCATO_PRONOUN_RE uses.
3568
+ const isAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
3569
+ if (isAdj) {
3570
+ const rawSubject = isAdj[1].trim();
3571
+ const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
3572
+ const adjective = isAdj[2].trim().toLowerCase();
3573
+ if (subject) {
3574
+ const subjVariants = factTermVariants(normFactTerm, subject);
3575
+ const propertyMatch = (f) => (f.predicate === HAS_PROPERTY_PREDICATE && normFactTerm(f.object) === adjective)
3576
+ || (f.predicate === `tmct:${adjective}` && f.object === "true");
3577
+ // Same head-word fallback as factAnswer's "(c) what do you know about"
3578
+ // lane: a taught subject is often a real noun PHRASE ("logger module"),
3579
+ // shortened in the natural follow-up ("is the logger deprecated") — an
3580
+ // exact-variant miss on its own, on a whole word (length >= 4, the same
3581
+ // floor used elsewhere) shared between the query subject and the fact's
3582
+ // own subject.
3583
+ const subjWords = normFactTerm(subject).split(/\s+/).filter((w) => w.length >= 4 && !GENERIC_ENTITY_WORDS.has(w));
3584
+ const wordOverlap = (f) => subjWords.some((w) => new Set(String(f.subject || "").split(/\s+/)).has(w));
3585
+ const subjectMatch = (f) => subjVariants.has(f.subject) || (subjWords.length && wordOverlap(f));
3586
+ const hit = rows.filter((f) => subjectMatch(f) && propertyMatch(f)).sort(byTrust)[0];
3587
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
3588
+ // no hit on THIS property — never a guessed "no" (see
3589
+ // IS_ADJECTIVE_YESNO_RE's own docblock for why this stays silent on a
3590
+ // truth claim, unlike its ownership/general-verb siblings above). But a
3591
+ // subject we DO know something else about ("the logger" has a
3592
+ // deprecated-fact, just not a fast-fact) still deserves an honest, named
3593
+ // receipt — never a bare wall — mirroring factAnswer's own established
3594
+ // convention for a known-subject/wrong-predicate miss ("I don't have
3595
+ // any 'X' facts about Y"). The SAME subjectMatch (exact-variant OR
3596
+ // head-word overlap) decides "known", so a shortened/article-led
3597
+ // subject that found its fact via the overlap fallback is recognized
3598
+ // as known too. A subject with NO known facts at all falls through
3599
+ // undecided — there's nothing honest to say beyond the ordinary
3600
+ // cascade's own miss/orientation nudge.
3601
+ //
3602
+ // Tier 6 playtest: gated on `!envelope?.parsed`, the SAME guard this
3603
+ // function's empty-memory branch above just added, for the identical
3604
+ // reason — "is the logger tested" (after teaching an UNRELATED
3605
+ // "logger... is deprecated" fact) used to return "I don't have a fact
3606
+ // saying the logger is tested" here, discarding a REAL structural
3607
+ // answer ("No tests cover logger") for a word ("tested") that already
3608
+ // has genuine graph-computable meaning. A subject with a KNOWN taught
3609
+ // fact under some OTHER, non-structural property (the common,
3610
+ // originally-intended case here) still gets this receipt exactly as
3611
+ // before, since envelope.parsed is null for those adjectives.
3612
+ if (rows.some(subjectMatch) && !envelope?.parsed) {
3613
+ return { text: `I don't have a fact saying ${subject.toLowerCase()} is ${adjective}.`, replace: true };
3614
+ }
3615
+ // Tier-5 playtest fix (cycle 2), found live: "is the checkout flow
3616
+ // deprecated" as a genuinely FIRST-EVER question about a subject tmct
3617
+ // has never heard of (no fact at all, not even under a different
3618
+ // property) fell through to the raw structural wall, unguided — the
3619
+ // exact "honest 'I don't know that yet' offers to learn" case
3620
+ // SKILL_CHAT_PLAYTEST.md's Tier 5 (§3) itself names. The offered
3621
+ // phrasing is the SAME verified "remember that X is Y" wrapped form
3622
+ // TEACH_PROPERTY_RE actually accepts (arbitrary-length subject, no
3623
+ // lexicon gate on the complement) — never the bare unwrapped form,
3624
+ // which TEACH_PROPERTY_RE only reaches via BARE_DECLARATIVE_RE's own
3625
+ // single-token-subject restriction and would fail for a multi-word
3626
+ // subject like this one. Same helper (unknownAdjectiveOffer) the
3627
+ // empty-memory special-case above this function's own rows.length
3628
+ // bail-out reuses, so the two paths can never disagree on wording.
3629
+ //
3630
+ // Tier 6 playtest: same `!envelope?.parsed` guard as just above — a
3631
+ // subject known only under an UNRELATED property (e.g. "deprecated")
3632
+ // must not offer to teach "tested" when ask()'s own grammar already
3633
+ // resolved it structurally.
3634
+ if (!envelope?.parsed) return unknownAdjectiveOffer(subject, adjective);
3635
+ }
3636
+ }
3637
+
3638
+ // (a3) GENERAL VERB-TO-PREDICATE direct-question retrieval (item 5, this
3639
+ // session): a taught general-verb fact ("margo eats ribs") answered back
3640
+ // directly. Yes/no form matches the taught triple EXACTLY (subject +
3641
+ // predicate + object, via the SAME factTermVariants/normFactTerm matching
3642
+ // WHO_OWNS_RE just used above) — no match is an honest, closed-world "no",
3643
+ // never a guess. Open form lists every stored fact row for {subject,
3644
+ // predicate} regardless of object.
3645
+ const genYN = q.match(GENERAL_VERB_YESNO_RE);
3646
+ if (genYN && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
3647
+ const [, subjectRaw, verbRaw, objectRaw] = genYN;
3648
+ const verb = verbRaw.toLowerCase();
3649
+ if (!GENERAL_VERB_EXCLUDE_RE.test(verb) && !GENERAL_VERB_QUERY_EXCLUDE_RE.test(verb)) {
3650
+ const subject = subjectRaw.trim();
3651
+ const object = objectRaw.replace(/^an?\s+/i, "").trim();
3652
+ if (subject && object) {
3653
+ const predicate = await generalVerbPredicate(verb);
3654
+ const subjVariants = factTermVariants(normFactTerm, subject);
3655
+ const objVariants = factTermVariants(normFactTerm, object);
3656
+ const hit = rows
3657
+ .filter((f) => f.predicate === predicate && subjVariants.has(f.subject) && objVariants.has(f.object))
3658
+ .sort(byTrust)[0];
3659
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true, generalVerbQuery: true };
3660
+ return {
3661
+ text: `no — no remembered fact says ${subject.toLowerCase()} ${predicatePhrase(predicate)} ${object}.`,
3662
+ replace: true, generalVerbQuery: true,
3663
+ };
3664
+ }
3665
+ }
3666
+ }
3667
+ const genOpen = q.match(GENERAL_VERB_OPEN_RE);
3668
+ if (genOpen && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
3669
+ const [, subjectRaw, verbRaw] = genOpen;
3670
+ const verb = verbRaw.toLowerCase();
3671
+ if (!GENERAL_VERB_EXCLUDE_RE.test(verb) && !GENERAL_VERB_QUERY_EXCLUDE_RE.test(verb)) {
3672
+ const subject = subjectRaw.trim();
3673
+ if (subject) {
3674
+ const predicate = await generalVerbPredicate(verb);
3675
+ const subjVariants = factTermVariants(normFactTerm, subject);
3676
+ const hits = rows.filter((f) => f.predicate === predicate && subjVariants.has(f.subject)).sort(byTrust);
3677
+ if (hits.length) return { ...renderMany(hits), generalVerbQuery: true };
3678
+ }
3679
+ }
3680
+ }
3681
+
2996
3682
  // (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
2997
3683
  const told = q.match(TOLD_ABOUT_RE);
2998
3684
  if (told) {
@@ -3326,14 +4012,42 @@ const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
3326
4012
  * question asks about — from the parse when present, else recognized directly
3327
4013
  * via BARE_WHATIS_RE (article optional — see its own docblock for why that's
3328
4014
  * safe here even though the grammar's own T5 keeps the article mandatory).
3329
- * Null when the line isn't such a form. */
4015
+ * Null when the line isn't such a form. Seonix Batch 2 Fix 3: a curated trailing
4016
+ * scope clause ("what is a Module in this graph") is stripped off the captured
4017
+ * term the same way grammar.mjs's T5 does (stripTrailingScopeFiller,
4018
+ * ask-vocab.mjs) — the envelope.parsed.object branch above already carries a
4019
+ * trimmed term when it came from that template, so the strip here only needs to
4020
+ * cover this function's own regex fallback. */
3330
4021
  function metaTermOf(query, envelope) {
3331
4022
  if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
3332
4023
  const q = String(query).trim();
3333
4024
  const m = q.match(BARE_WHATIS_RE)
3334
4025
  || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
3335
4026
  || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
3336
- return m ? m[1].trim() : null;
4027
+ return m ? stripTrailingScopeFiller(m[1].trim()) : null;
4028
+ }
4029
+
4030
+ /** The TEACH-OFFER line for a term that's genuinely unknown everywhere (Tier-5
4031
+ * playtest fix): "I don't know 'X' yet — teach me directly, e.g. …". The
4032
+ * concrete example is worded by WORD COUNT, verified in-state
4033
+ * (SKILL_CHAT_PLAYTEST.md §4's own rule) — an unwrapped bare declarative
4034
+ * only stores for a single-token subject (BARE_DECLARATIVE_RE's own scope);
4035
+ * the wrapped "remember X is a Y" form tolerates up to a two-token subject
4036
+ * (unknownSubjectFallback's UNKNOWN_SUBJECT_RE). A 3+-word term fits
4037
+ * neither shape — never offer a concrete example that would itself fail,
4038
+ * the plain nudge to teach it still guides, honestly. Shared by runTurn's
4039
+ * own "what is X" miss nudge and factAnswer's "what do you know about X"
4040
+ * miss nudge, below, so the two can never disagree on wording. */
4041
+ function unknownVocabTermOffer(term) {
4042
+ const article = /^[aeiou]/i.test(term) ? "an" : "a";
4043
+ const words = term.trim().split(/\s+/);
4044
+ const remember = `remember ${term} is ${article} <thing>`;
4045
+ const example = words.length === 1
4046
+ ? `"${term} is ${article} <thing>" or "${remember}"`
4047
+ : words.length === 2
4048
+ ? `"${remember}"`
4049
+ : null;
4050
+ return `I don't know "${term}" yet — teach me directly${example ? `, e.g. ${example}` : ` (e.g. "remember <name> is ${article} <thing>")`}.`;
3337
4051
  }
3338
4052
 
3339
4053
  /** The curated SEON definition to PREFER for a "what is a <lexicon term>", or null.
@@ -3387,6 +4101,18 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
3387
4101
  // SHORTHAND_CONTRACTIONS above: scoped locally to the lane that owns the word.
3388
4102
  // Word-boundary matched so "hotel"/"intel" are untouched.
3389
4103
  const VAGUE_TOUCH_TEL_RE = /\btel\b/i;
4104
+ // "abut" -> "about" (Tier 6 playtest, §3b typo axis): a one-letter-dropped
4105
+ // typo of THIS lane's own anchor word ("what abut imports" used to miss the
4106
+ // "what about …" regex entirely and search for a module literally named
4107
+ // "abut" instead). "about" is real English on its own (a genuine word) but is
4108
+ // not itself part of ask.mjs's code-graph grammar (VERB_TO_KIND/ENTITY_TO_TYPE/
4109
+ // anchor words) — same reasoning as VAGUE_TOUCH_TEL_RE just above, so this
4110
+ // stays a local, lane-scoped replace rather than a shared MISSPELLINGS entry
4111
+ // (test/ask-vocab.test.mjs enforces every correction TABLE value is grammar-
4112
+ // owned; a bare discourse word like "about" fails that gate on purpose).
4113
+ // Word-boundary matched so a real identifier merely containing "abut" (rare,
4114
+ // but e.g. "rebuttal") is untouched.
4115
+ const VAGUE_TOUCH_ABUT_RE = /\babut\b/i;
3390
4116
  /** "explain X" / "please explain X" / "kindly explain X" / "explain X to me" /
3391
4117
  * "explain X please" — a bare vague-touch shape, sibling of WHAT_ABOUT_RE
3392
4118
  * above. Named (not inlined) so both vagueTouchTermOf (term extraction) and
@@ -3407,6 +4133,7 @@ function vagueTouchTermOf(query) {
3407
4133
  // bridge frame), breaking this very regex.
3408
4134
  let q = correctMisspellings(String(query).trim());
3409
4135
  q = q.replace(VAGUE_TOUCH_TEL_RE, "tell");
4136
+ q = q.replace(VAGUE_TOUCH_ABUT_RE, "about");
3410
4137
  q = applyPreambleFrames(q);
3411
4138
  const m = q.match(/^(?:kindly\s+)?tell me about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i)
3412
4139
  || 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 +4245,22 @@ function relationTermOf(query, envelope) {
3518
4245
  * can never parse and always misses) — a drill-down chain that opens with
3519
4246
  * "describe X" (the README's own example) used to dead-end on the very next
3520
4247
  * "what about it"/"what about Y" turn. */
4248
+ // Bug F point 4 (operator follow-up request): "please tell me X" (no "about")
4249
+ // answers like "describe X"/"what is X" — the "tell me" branch's own "about"
4250
+ // is now OPTIONAL, so "please tell me Widget" reaches the same rescue "please
4251
+ // tell me about Widget" already did. "describe"/"what(?:'s|\s+is)? about" stay
4252
+ // unchanged (describe never took "about" at all; the "what about" branch
4253
+ // still requires it — a bare "what X" is BARE_WHATIS_RE's own territory, not
4254
+ // this lane's, and folding it in here would risk double-claiming that shape).
4255
+ // Note the trailing \s+ moved INSIDE each alternation branch (rather than one
4256
+ // shared \s+ after the whole group): making "about" optional inside the "tell
4257
+ // me" branch means that branch's own separator is sometimes owned by "me\s+"
4258
+ // and sometimes by "about\s+" — a single external \s+ double-counted the
4259
+ // separator when "about" fired (swallowing the one real space and then
4260
+ // requiring a second one that was never there, an always-null regex found
4261
+ // live while testing this fix).
3521
4262
  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;
4263
+ /^(?:(?: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
4264
 
3524
4265
  /** Bare focus pronouns this lane resolves against the STANDING focus (0.9.13
3525
4266
  * Tier-1 playtest) — "describe that" / "tell me about it" after a prior turn
@@ -3542,14 +4283,77 @@ const DESCRIBE_PRONOUN_RE = /^(?:it|that|this|those|them)$/i;
3542
4283
  * unaffected either way. */
3543
4284
  const STACCATO_PRONOUN_RE = /^(?:and|also|so|then|now)\s+(it|that|this|those|them)(?:\s+ones?)?\s*\??$/i;
3544
4285
 
3545
- async function describeWrapperAnswer(query, { config, source, focus }) {
3546
- const q = String(query || "").trim();
4286
+ /** Tier 6 playtest: "describe the logger module"/"describe the Task class" —
4287
+ * dispatchTool("tmct_describe") resolves its `symbol` arg via codegraph.mjs's
4288
+ * resolveSymbol, a separate, simpler path/basename matcher with NO article- or
4289
+ * grain-word tolerance. A first attempt routed this whole free-text `term`
4290
+ * through resolveEntity/resolveObject instead (which DOES have that tolerance,
4291
+ * just added above) — reverted live, found via this same playtest cycle's own
4292
+ * regression run: resolveObject's tier-3 ANY-overlap fallback is tuned for
4293
+ * near-path/near-symbol terms, not arbitrary English sentences, and a genuine
4294
+ * English article ("a", "the") can itself be a real one-character path
4295
+ * component of some fixture module ("a.mjs") — "tell me A JOKE" tier-3-matched
4296
+ * that module by the shared bare "a" alone (test/sessions.test.mjs's own guard
4297
+ * test caught it: a turn meant to fall through as an honest grammar miss
4298
+ * instead silently "described" an unrelated module). Scoped down to ONLY ever
4299
+ * attempt a resolution when the term carries an EXPLICIT trailing grain word
4300
+ * (module/class/function/method/…, ENTITY_TO_TYPE's own closed table) — the
4301
+ * class-narrowed pool that then searches is both far smaller and still
4302
+ * requires the head noun to actually match a stem, so it stays safe; a bare
4303
+ * "the X"/"an X" or ordinary sentence (no grain word) gets NO rescue attempt
4304
+ * at all and falls through to the untouched, always-safe resolveSymbol path,
4305
+ * exactly as before this fix. */
4306
+ const DESCRIBE_GRAIN_WORD_RE = new RegExp(
4307
+ `^(?:(?:the|a|an)\\s+)?(.+?)\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i",
4308
+ );
4309
+ async function describeGrainRescue(graph, term) {
4310
+ if (!graph) return null;
4311
+ const m = String(term || "").trim().match(DESCRIBE_GRAIN_WORD_RE);
4312
+ if (!m) return null;
4313
+ const [, head, grainWord] = m;
4314
+ const expectedClass = ENTITY_TO_TYPE[grainWord.toLowerCase()];
4315
+ if (!head?.trim() || !expectedClass) return null;
4316
+ try {
4317
+ const { resolveObject } = await import("./ask.mjs");
4318
+ const r = resolveObject(graph, head.trim(), { expectedClass });
4319
+ if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
4320
+ } catch { /* tolerated */ }
4321
+ return null;
4322
+ }
4323
+
4324
+ async function describeWrapperAnswer(query, { config, source, focus, graph }) {
4325
+ // Tier 6 playtest: this lane is the LAST-RESORT rescue (4d, tried after every
4326
+ // earlier lane declines on the ORIGINAL query) — but it tested its own
4327
+ // DESCRIBE_WRAPPER_RE against the RAW, un-normalized text, so a preamble an
4328
+ // earlier lane (relationForceAnswer/vagueTouchTermOf) already knows how to
4329
+ // strip ("ok cool, what about the TaskController" — relationForceAnswer
4330
+ // correctly declines since "TaskController" isn't an enumerable RELATION_TERM,
4331
+ // but never hands its own stripped text forward) reappeared here, unstripped,
4332
+ // and broke DESCRIBE_WRAPPER_RE's own anchor. applyPreambleFrames is the same
4333
+ // general-purpose, closed, idempotent pass every other lane in this file
4334
+ // already runs first.
4335
+ const q = applyPreambleFrames(String(query || "").trim());
3547
4336
  const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
3548
4337
  let term = m?.[1]?.trim();
3549
4338
  if (!term) return null;
3550
4339
  if (DESCRIBE_PRONOUN_RE.test(term)) {
3551
4340
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
3552
4341
  term = focus.label;
4342
+ } else {
4343
+ const rescued = await describeGrainRescue(graph, term);
4344
+ if (rescued?.label) {
4345
+ term = rescued.label;
4346
+ } else {
4347
+ // Tier 6 playtest: "what about the TaskController" (no grain word, just a
4348
+ // bare article) — resolveSymbol (codegraph.mjs) has no component/overlap
4349
+ // tier at all, only exact/endsWith/basename/includes checks, so a leading
4350
+ // "the"/"a"/"an" is pure NOISE here (unlike resolveObject's looser tiers,
4351
+ // there is no accidental-match risk this could introduce — stripping it
4352
+ // only ever REMOVES characters no real label ever contains as a match
4353
+ // signal). "TaskController" resolves exactly where "the TaskController"
4354
+ // didn't.
4355
+ term = term.replace(/^(?:the|a|an)\s+/i, "");
4356
+ }
3553
4357
  }
3554
4358
  try {
3555
4359
  const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source });
@@ -4008,16 +4812,28 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4008
4812
  // guarantees a real answer before it defers, never stranding the turn on a
4009
4813
  // worse outcome — see isStaccatoPronounNoFocus's own docblock for the same
4010
4814
  // discipline).
4815
+ // Tier-5 playtest fix (this session): "is it deprecated" is EXACTLY the same
4816
+ // race BUG 2 (above) fixed for "what is john" — 3 words, no code-ish token,
4817
+ // so isConversationalCandidate would otherwise win unconditionally and a
4818
+ // just-taught property fact ("logger module is deprecated") becomes
4819
+ // unreachable the moment it's asked back about with a short pronoun/bare
4820
+ // form ("is it deprecated" / "is the logger deprecated"). Widened the SAME
4821
+ // divert-only-on-a-real-hit gate to also try IS_ADJECTIVE_YESNO_RE shapes —
4822
+ // factAnswer itself declines for this shape (no metaTerm), so the only
4823
+ // change in practice is that factReadBack's (a2c) property lane gets a
4824
+ // chance to run before the orientation card claims the turn.
4825
+ const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
4826
+ const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
4011
4827
  let bareMetaHit = null;
4012
- if (isConversationalCandidate && memoryDir && BARE_WHATIS_RE.test(String(query).trim())) {
4828
+ if (isConversationalCandidate && memoryDir && (bareWhatisShape || isAdjectiveShape)) {
4013
4829
  bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss))
4014
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
4830
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label));
4015
4831
  }
4016
4832
  if (bareMetaHit) {
4017
4833
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
4018
4834
  via = "fact"; recordMiss = false; handled = true;
4019
4835
  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");
4836
+ 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
4837
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
4022
4838
  } else if (isConversationalCandidate) {
4023
4839
  // A conversational miss (a greeting, "what can you do", a very short non-code
@@ -4053,7 +4869,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4053
4869
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
4054
4870
  // asserted "every X is a Y" answers "what is a Y" too.
4055
4871
  const fact = (await factAnswer(memoryDir, query, envelope, miss))
4056
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
4872
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label));
4057
4873
  if (fact) {
4058
4874
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
4059
4875
  via = "fact";
@@ -4061,6 +4877,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4061
4877
  if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
4062
4878
  note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
4063
4879
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
4880
+ // Goal-line fix (item 5 follow-up, this session): mirrors the TEACH lane's
4881
+ // own goal revision just below (Bug 3 point 4) — `deduced` was computed
4882
+ // WAY above off envelope.parsed alone, but a general-verb direct question
4883
+ // ("does margo eat ribs") never parses as a structural graph query at all,
4884
+ // so it either landed on an unrelated GOAL_BY_KIND guess or nothing.
4885
+ if (fact.generalVerbQuery) {
4886
+ deduced = "look up a taught fact about a subject/verb/object";
4887
+ note(trace, `goal: ${deduced} (revised — a general-verb direct-question fact lookup answered this turn)`);
4888
+ }
4064
4889
  } else if (miss) {
4065
4890
  // W2: after the honest miss is composed, consult the folded-session memory. A
4066
4891
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -4246,7 +5071,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4246
5071
  // for what would otherwise become the generic wall, never a competing route:
4247
5072
  // it only claims the turn if /describe actually resolves the captured term.
4248
5073
  if (miss && recordMiss && via === "composed") {
4249
- const described = await describeWrapperAnswer(query, { config, source, focus: newFocus });
5074
+ const described = await describeWrapperAnswer(query, { config, source, focus: newFocus, graph });
4250
5075
  if (described) {
4251
5076
  answer = described.text; via = "describe"; recordMiss = false;
4252
5077
  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 +5098,47 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
4273
5098
  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
5099
  note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
4275
5100
  }
5101
+ // TEACH-OFFER (Tier-5 playtest, this session) — SKILL_CHAT_PLAYTEST.md §0
5102
+ // names "'X' isn't a term in this graph's own vocabulary" as its own
5103
+ // dead-end example, and Tier 5 (§3) explicitly wants "the honest 'I don't
5104
+ // know that yet' that offers to learn" rather than a bare wall. A "what is
5105
+ // X" miss where X is genuinely unknown EVERYWHERE — not a real graph entity
5106
+ // (resolveEntity), not a schema/vocab term (that's what "still standing
5107
+ // miss" already means here), and not already in memory (checked directly,
5108
+ // not via factAnswer's OWN metaTerm branch, so this never duplicates its
5109
+ // more specific "no X facts about Y" miss) — gets a short offer appended
5110
+ // UNDER the existing miss text, never replacing it (every pinned assertion
5111
+ // on the miss wording elsewhere stays intact; this is purely additive, the
5112
+ // same discipline the corpus aside (W5) and empty-graph polish above use).
5113
+ if (recordMiss && (via === "composed" || via === "miss") && memoryDir) {
5114
+ // Tier-5 playtest fix (cycle 3): "what do you know about X" is its OWN
5115
+ // sibling shape — checked FIRST (and, unlike metaTermOf below, without a
5116
+ // resolveEntity(graph) gate): it's inherently a MEMORY question, not a
5117
+ // graph-structure one, so "nothing yet, teach me" is appropriate even
5118
+ // when X also happens to be a real graph entity — there's no genuinely
5119
+ // BETTER answer path to defer to the way "what is X" has (the concept
5120
+ // force, schema docs, …). Found live: "what do you know about the last
5121
+ // commit" (nothing in memory, and the whole sentence never fits ask.mjs's
5122
+ // grammar either) fell to the raw wall, unguided.
5123
+ const knowAboutTerm = String(query).trim().match(KNOW_ABOUT_RE)?.[1]?.trim();
5124
+ const offerTerm = knowAboutTerm || metaTermOf(query, envelope);
5125
+ if (offerTerm) {
5126
+ let normFactTerm;
5127
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { normFactTerm = null; }
5128
+ if (normFactTerm) {
5129
+ const cleanTerm = normFactTerm(offerTerm);
5130
+ const ent = knowAboutTerm ? null : await resolveEntity(graph, offerTerm);
5131
+ if (!ent) {
5132
+ const variants = factTermVariants(normFactTerm, offerTerm);
5133
+ const known = (await memoryFacts(memoryDir)).some((f) => variants.has(f.subject) || variants.has(f.object));
5134
+ if (!known) {
5135
+ answer = `${answer}\n${unknownVocabTermOffer(cleanTerm)}`;
5136
+ note(trace, `intermediate: TEACH-OFFER — "${cleanTerm}" is unknown to both the graph and memory, so the miss got an offer to learn appended`);
5137
+ }
5138
+ }
5139
+ }
5140
+ }
5141
+ }
4276
5142
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
4277
5143
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
4278
5144
  // the honest miss (the miss itself stands; the aside is context, not an answer).
@@ -4363,6 +5229,38 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
4363
5229
  };
4364
5230
  }
4365
5231
 
5232
+ // Bug F point 5 (operator's explicit, most important ask this round): a
5233
+ // command name -> a short, honest one-line goal string, mirroring GOAL_BY_KIND's
5234
+ // own spirit (above) — but for COMMAND dispatches (find/search/describe/…)
5235
+ // instead of ask()-parsed relation queries, so "I want you to search for
5236
+ // Widget" (now reachable via a slash command per Bug F point 3) ALSO gets a
5237
+ // real "Goal (inferred): …" line instead of none at all, generalizing the
5238
+ // existing mechanism exactly as asked. Reuses GOAL_BY_KIND's EXISTING wording
5239
+ // verbatim wherever a command's intent overlaps one of those kinds (members/
5240
+ // subclasses reuse contains/inherits's own phrasing; callers/callees reuse
5241
+ // calls's; tests/untested reuse tests's; history reuses touches's; exports
5242
+ // reuses reexports's) — never invents new phrasing for the same concept. A
5243
+ // command not worth a bespoke entry (help/stats/memory/focus/narrate/unknown)
5244
+ // falls back to a short generic line in mk() itself, below.
5245
+ const GOAL_BY_COMMAND = {
5246
+ find: "locate a specific named entity",
5247
+ search: "locate a specific named entity",
5248
+ context: "gather the sized edit bundle for a symbol before changing code",
5249
+ snippet: "view a symbol's exact source",
5250
+ describe: "look up a symbol's definition and relations",
5251
+ signature: "view a symbol's signature",
5252
+ members: GOAL_BY_KIND.contains,
5253
+ subclasses: GOAL_BY_KIND.inherits,
5254
+ impact: "understand what a change to this module would reach (impact closure)",
5255
+ callers: GOAL_BY_KIND.calls,
5256
+ callees: GOAL_BY_KIND.calls,
5257
+ tests: GOAL_BY_KIND.tests,
5258
+ untested: GOAL_BY_KIND.tests,
5259
+ history: GOAL_BY_KIND.touches,
5260
+ exports: GOAL_BY_KIND.reexports,
5261
+ arch: "understand the overall architecture (package/module boundaries)",
5262
+ };
5263
+
4366
5264
  /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
4367
5265
  * cases). Returns the same { answer, logLines, record, focus } shape as
4368
5266
  * runAsk; the record carries the command name and the resolved entity id
@@ -4370,7 +5268,10 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
4370
5268
  * wherever it resolves an entity. `ctx.trace` (narrate mode, or undefined
4371
5269
  * when off) gets one "goal:"/"lane:" note per branch — a slash-command's
4372
5270
  * "decision" is simply which command+tool ran, so this is intentionally
4373
- * lighter than runAsk's miss-cascade instrumentation. */
5271
+ * lighter than runAsk's miss-cascade instrumentation. Also carries a `goal`
5272
+ * field now (Bug F point 5) — mirrors runAsk's own `goal` field so
5273
+ * withGoalLine's short "Goal (inferred): …" line fires for command
5274
+ * dispatches too, not just ask()-parsed queries. */
4374
5275
  async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false }) {
4375
5276
  const ts = new Date().toISOString();
4376
5277
  const sp = line.indexOf(" ");
@@ -4381,6 +5282,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
4381
5282
  logLines: [ts, `> ${line}`, answer, ""],
4382
5283
  record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
4383
5284
  focus: newFocus,
5285
+ goal: GOAL_BY_COMMAND[name] || "use a specific tool/command directly",
4384
5286
  ...(narrateNext !== undefined ? { narrate: narrateNext } : {}),
4385
5287
  });
4386
5288
 
@@ -4441,6 +5343,20 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
4441
5343
  value = focus?.label || "";
4442
5344
  if (value) note(trace, `intermediate: no/pronoun argument -> fell back to the standing focus "${value}"`);
4443
5345
  }
5346
+ // Tier 6 playtest: a bare English "describe the logger module"/"describe the
5347
+ // Task class" is short enough (≤3 tokens, no query connective) that
5348
+ // asBareCommand (above) already rewrote it into a literal slash command
5349
+ // BEFORE this function ever sees it — so describeGrainRescue's OWN lane
5350
+ // (describeWrapperAnswer) never runs for this exact shape; the rescue has to
5351
+ // happen HERE too, on the raw command argument, before it reaches
5352
+ // dispatchTool's resolveSymbol (which has no article/grain-word tolerance).
5353
+ if (entityArg && value) {
5354
+ const rescued = await describeGrainRescue(graph, value);
5355
+ if (rescued?.label) {
5356
+ note(trace, `intermediate: "${value}" carries a grain word -> resolved to ${rescued.label} before dispatch`);
5357
+ value = rescued.label;
5358
+ }
5359
+ }
4444
5360
  if (spec.arg && !spec.optional && !value) {
4445
5361
  const need = entityArg ? `${spec.arg} (none given and no focus set — /focus <x> or pass one)` : spec.arg;
4446
5362
  return mk(`/${name} needs a ${need}.`, { miss: true });
@@ -4570,8 +5486,30 @@ function morePage(query, { last, focus }) {
4570
5486
  return turn;
4571
5487
  }
4572
5488
 
5489
+ // Bug F point 3 (operator follow-up request): "I want you to search for
5490
+ // Widget" / "I'd like you to search for Widget" — a closed-set indirect-
5491
+ // request wrapper, checked VERY early (before asBareCommand/conversationalTurn/
5492
+ // the ask engine ever see the raw prefix). Found live: without this, "I want
5493
+ // you to search for Widget" was mis-swallowed by GENERAL_VERB_TEACH_RE as a
5494
+ // bare <subject> <verb> <object> teach triple (subject "I", verb "want") —
5495
+ // declined by the pronoun-subject guard with a confusing "pronouns aren't
5496
+ // things I can classify" message, instead of ever reaching /search at all.
5497
+ // Deliberately does NOT strip bare "please X" alone — that's already handled
5498
+ // ad hoc by many individual regexes throughout this file (TEACH_RE,
5499
+ // EXPLAIN_TOUCH_RE, describeWrapperAnswer's own regex, IMPERATIVE_NUDGE_RE) and
5500
+ // re-stripping it centrally here risks double-processing interactions across
5501
+ // the whole file — out of scope for this fix, higher risk than the concrete
5502
+ // gain.
5503
+ 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;
5504
+
4573
5505
  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
5506
  const line = String(input ?? "").trim();
5507
+ // The captured residue is used for RECOGNITION at every dispatch site below
5508
+ // (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
5509
+ // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
5510
+ // — restored centrally inside withLast (below), once, for every dispatch path.
5511
+ const indirectMatch = line.match(INDIRECT_REQUEST_RE);
5512
+ const workingLine = indirectMatch ? indirectMatch[1].trim() : line;
4575
5513
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
4576
5514
  // narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
4577
5515
  // matching every OTHER optional collaborator here — templates/memoryDir/lexicon
@@ -4598,6 +5536,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4598
5536
  // the PRE-narration finished result — see withNarration's docblock for why.
4599
5537
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
4600
5538
  const finished = finish(result, { graph });
5539
+ // Bug F point 3 fidelity: every dispatch path below built its own record off
5540
+ // `workingLine` (the indirect-request wrapper stripped, above) — restore the
5541
+ // ORIGINAL raw `line` into record.query and the logged "> …" transcript echo
5542
+ // here, once, centrally, for every path (they all funnel through withLast).
5543
+ if (indirectMatch) {
5544
+ if (finished.record) finished.record.query = line;
5545
+ if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
5546
+ }
4601
5547
  // runAsk's own effectiveQuery (set only when discourseRewrite substituted a
4602
5548
  // new subject AND the rewrite produced a genuine non-miss answer) takes
4603
5549
  // over as the continuation base for the NEXT turn's own discourseRewrite —
@@ -4617,32 +5563,32 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4617
5563
  // "memory", "describe X") is routed to its slash form BEFORE the conversational
4618
5564
  // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
4619
5565
  // of falling through to the generic orientation.
4620
- const bareCmd = asBareCommand(line);
5566
+ const bareCmd = asBareCommand(workingLine);
4621
5567
  if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
4622
5568
 
4623
5569
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
4624
5570
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
4625
5571
  // conversational turn is never finish()'d / never becomes a new `last`), so the
4626
5572
  // narrate block is applied directly here instead.
4627
- const convo = conversationalTurn(line, ctx);
5573
+ const convo = conversationalTurn(workingLine, ctx);
4628
5574
  if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
4629
5575
 
4630
5576
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
4631
5577
  // an actual pending remainder so a bare "more" with nothing to continue falls through
4632
5578
  // 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) {
5579
+ if (MORE_RE.test(workingLine) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
4634
5580
  note(trace, "goal: continue viewing a previous long listing (pagination)");
4635
5581
  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");
5582
+ return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
4637
5583
  }
4638
5584
 
4639
- if (line.startsWith("/")) return withLast(await runCommand(line, ctx), "use a specific tool/command directly");
5585
+ if (workingLine.startsWith("/")) return withLast(await runCommand(workingLine, ctx), "use a specific tool/command directly");
4640
5586
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
4641
5587
  // own memory and confirm — they are statements to remember, not graph queries.
4642
5588
  // Gated on memoryDir: only a session shell provides a write target, so a bare
4643
5589
  // runTurn (tests, library callers) stays pure and falls through to the engine.
4644
5590
  if (memoryDir) {
4645
- const asserted = await assertTurn(line, ctx);
5591
+ const asserted = await assertTurn(workingLine, ctx);
4646
5592
  if (asserted) {
4647
5593
  note(trace, "goal: teach/remember a new fact (declarative ACE sentence)");
4648
5594
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
@@ -4655,11 +5601,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4655
5601
  // otherwise say "I can't count facts"); it only speaks for a memory-class noun, so
4656
5602
  // structural counts (classes/functions/…) and sessions fall through unaffected.
4657
5603
  if (memoryDir) {
4658
- const memCount = await answerMemoryCount(memoryDir, line);
5604
+ const memCount = await answerMemoryCount(memoryDir, workingLine);
4659
5605
  if (memCount != null) {
4660
5606
  note(trace, "goal: get a count of a memory-store kind (facts/utterances)");
4661
5607
  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");
5608
+ return withLast(plainTurn(workingLine, memCount, { via: "count", focus }), "get a count of a memory-store kind");
4663
5609
  }
4664
5610
  }
4665
5611
  // Feature A point 4: "how many Xs are Ys" — a taught-quantifier RECALL, checked
@@ -4668,32 +5614,32 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
4668
5614
  // authority gate declines (returns null) for anything answerCount should own,
4669
5615
  // so ordinary structural counts fall through completely unaffected.
4670
5616
  if (memoryDir) {
4671
- const quantifierRecall = await answerQuantifierRecall(memoryDir, line);
5617
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine);
4672
5618
  if (quantifierRecall != null) {
4673
5619
  note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
4674
5620
  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");
5621
+ return withLast(plainTurn(workingLine, quantifierRecall, { via: "fact", focus }), "recall a taught quantifier");
4676
5622
  }
4677
5623
  }
4678
5624
  // Aggregate/count questions are answered mechanically off the loaded graph header,
4679
5625
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
4680
- const count = answerCount(graph, line);
5626
+ const count = answerCount(graph, workingLine);
4681
5627
  if (count != null) {
4682
5628
  // An "I can't count <noun>" from a bare kind may still be answerable from an
4683
5629
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
4684
5630
  // class count). countFromFacts declines on a real graph kind, so ordinary
4685
5631
  // counts are unaffected; it only speaks for a remembered object noun.
4686
- const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
5632
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine) : null;
4687
5633
  if (viaFact != null) {
4688
5634
  note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
4689
5635
  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");
5636
+ return withLast(plainTurn(workingLine, viaFact, { via: "fact", focus }), "get a count");
4691
5637
  }
4692
5638
  note(trace, "goal: get a count of a graph kind (classes/functions/modules/…)");
4693
5639
  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");
5640
+ return withLast(plainTurn(workingLine, count, { via: "count", focus }), "get a count of a graph kind");
4695
5641
  }
4696
- return withLast(await runAsk(line, ctx), "unclear — no goal signal computed by the ask engine");
5642
+ return withLast(await runAsk(workingLine, ctx), "unclear — no goal signal computed by the ask engine");
4697
5643
  }
4698
5644
 
4699
5645
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----