@polycode-projects/the-mechanical-code-talker 1.4.0 → 1.5.2

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
@@ -56,9 +56,9 @@ import { rankByBiasThenTrust } from "./memory/bias.mjs";
56
56
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
57
57
  import {
58
58
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
59
- stripTrailingScopeFiller,
59
+ stripTrailingScopeFiller, stripTrailingDiscourseTag,
60
60
  } from "./ask-vocab.mjs";
61
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames } from "./interpret/normalize.mjs";
61
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, escapeRegex } from "./interpret/normalize.mjs";
62
62
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
63
63
 
64
64
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
@@ -156,6 +156,7 @@ function deduceGoalFromParsed(parsed) {
156
156
  if (shape === "meta") return `understand a vocabulary/definition term ("${parsed.object}")`;
157
157
  if (shape === "where") return `locate where something is defined ("${parsed.object}")`;
158
158
  if (shape === "when") return "understand when something last changed (history)";
159
+ if (shape === "whoLast") return "find who most recently touched something (history)";
159
160
  if (shape === "mentions") return `find where something is mentioned in prose ("${parsed.object}")`;
160
161
  if (shape === "ask") return (kind && GOAL_BY_KIND[kind]) || "check a specific subject/object relationship";
161
162
  if ((shape === "reverse" || shape === "forward") && kind) return GOAL_BY_KIND[kind] || `understand a "${kind}" relationship`;
@@ -698,7 +699,12 @@ export function renderStats(graph) {
698
699
  * which meant "who are you" always got the "here's what I can query" blurb and
699
700
  * never a self-description — split so each gets the answer it actually asked for. */
700
701
  const CAPABILITY_PHRASES = [
701
- /^what can (you|u) do\??$/i, /^what do you do\??$/i, /^help( me)?\??$/i, /^\?+$/,
702
+ // HANDOVER.md 2026-07-10 item 10: "what can you actually do" (an intensifier
703
+ // adverb inserted before the verb) used to miss this exact-match regex entirely
704
+ // and fall to the raw grammar wall instead of orientationAnswer — the same
705
+ // question in every way that matters, just phrased with emphasis.
706
+ /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? do\??$/i, /^(?:so,?\s+)?what do you(?:\s+(?:actually|really))? do\??$/i,
707
+ /^help( me)?\??$/i, /^\?+$/,
702
708
  /^how do (i|you) work\??$/i, /^how does (this|it) work\??$/i,
703
709
  // unix-habit openers typed inside the REPL out of muscle memory — argv-only
704
710
  // today (bin/tmct.mjs), dead once inside the chat loop; route to the same
@@ -770,11 +776,28 @@ const IDENTITY_PHRASES = [
770
776
  * a genuinely different, more specific answer than the generic self-description,
771
777
  * and this is a very likely first question given how most chat tools work today. */
772
778
  const AI_IDENTITY_PHRASES = [
773
- /^(are you|r u) (an? )?(ai|a bot|chatgpt|gpt|an? llm|a language model|a robot)\??$/i,
779
+ // HANDOVER.md 2026-07-10 item 10: "are you secretly GPT" an adverb ("secretly"/
780
+ // "really"/"actually") wedged between "are you" and the noun (a deliberate-breaker
781
+ // persona's own phrasing) used to mis-segment the subject as "you secretly" and
782
+ // fall through to the ordinary graph-query grammar instead of this lane.
783
+ /^(are you|r u)\s+(?:secretly|really|actually)?\s*(an? )?(ai|a bot|chatgpt|gpt|an? llm|a language model|a robot)\??$/i,
774
784
  /^is this (chatgpt|gpt|claude|an? ai|an? llm)\??$/i,
775
785
  /^do you use ai\??$/i, /^what language model are you( using)?\??$/i,
776
786
  /^am i (talking|speaking|chatting) (to|with) a (real )?(person|human|bot|ai)\??$/i,
777
787
  ];
788
+ /** "Do you have feelings/emotions" — HANDOVER.md 2026-07-10 item 10 (small-talk
789
+ * persona finding): with no closed-set match, this used to misfire into a
790
+ * literal module-name lookup for the bare noun ("no module matching 'feelings'
791
+ * found in the index") — a wrong-flavor wall, not an honest personality decline.
792
+ * Same family/placement as AI_IDENTITY_PHRASES just above (a self-awareness
793
+ * question about tmct, not a code-graph query), checked in conversationalTurn
794
+ * BEFORE any graph query is attempted. */
795
+ const FEELINGS_PHRASES = [
796
+ /^do you have (?:feelings|emotions|opinions|thoughts)\??$/i,
797
+ /^are you (?:sentient|conscious|self[- ]aware)\??$/i,
798
+ /^can you feel(?:\s+(?:things|emotions|anything))?\??$/i,
799
+ /^do you (?:feel|think|dream)\??$/i,
800
+ ];
778
801
  /** The structural verbs/nouns that mark a near-miss code question (→ keep the
779
802
  * precise grammar hint, not the friendly nudge). */
780
803
  const STRUCT_WORDS = new Set([
@@ -843,6 +866,7 @@ const T_ORIENTATION_EMPTY = "orientation-empty";
843
866
  * depend on whether a repo is loaded. */
844
867
  const T_IDENTITY_SELF = "identity-self";
845
868
  const T_IDENTITY_NOT_LLM = "identity-not-an-llm";
869
+ const T_IDENTITY_NO_FEELINGS = "identity-no-feelings";
846
870
  /** THE CONCEPT FORCE (concept.mjs): the three-band answer to a vague "what is a X"
847
871
  * that names a known concept WITH instances — {definition}/{examples}/{followups}. */
848
872
  const T_CONCEPT = "concept-force";
@@ -895,11 +919,27 @@ const THANKS = new Set([
895
919
  "thanks", "thank you", "thankyou", "thx", "ty", "ta", "cheers", "nice one",
896
920
  "much appreciated", "cool thanks", "many thanks", "much obliged", "ta very much",
897
921
  "cheers mate", "cheers for that", "tks", "sweet thanks", "nice",
922
+ // "brilliant" (playtest sprint round 3, 2026-07-10): a UK-English enthusiasm
923
+ // interjection functioning as a bare acknowledgement, the same shape as
924
+ // "nice"/"cheers" just above — "brilliant, that's all I needed" hit the raw
925
+ // grammar wall via item 2's own multi-clause scan (which deliberately checks
926
+ // THANKS only, not OK_ACK — see farewellOrThanksSignal's own docblock for why
927
+ // "ok"/"cool"/"right" stay excluded there) because "brilliant" wasn't in
928
+ // EITHER closed set yet.
929
+ "brilliant",
898
930
  // "ta for that" (Tier 6 playtest): "cheers for that" was already here, but
899
931
  // its "ta" sibling (both dropped-word forms of the SAME "thanks for that"
900
932
  // shape) was missing — fell to the generic orientation card via
901
933
  // isConversational's ≤3-word catch-all instead of a thanks reply.
902
934
  "ta for that",
935
+ // Playtest sprint round 3 (2026-07-10): a natural session-closing remark
936
+ // hit the raw grammar wall instead of a warm sign-off — the LAST turn of a
937
+ // session is a bad place to end on a wall. Same discipline as "ta for
938
+ // that": add the SPECIFIC found phrasing, not a general "closing remark"
939
+ // grammar.
940
+ "cheers, that's everything for now, thanks",
941
+ "that's everything for now, thanks",
942
+ "that's all for now, thanks",
903
943
  ]);
904
944
  /** Farewells → a goodbye AND a clean end of session (same path as /exit). */
905
945
  const BYE = new Set([
@@ -957,6 +997,82 @@ function closedOrCollapsed(q, set, idx) {
957
997
  return idx.get(collapseRuns(q)) ?? null;
958
998
  }
959
999
 
1000
+ /** HANDOVER.md 2026-07-10 item 2: THANKS/BYE were exact-match-the-WHOLE-line
1001
+ * closed sets, grown one literal phrase at a time across sessions — and kept
1002
+ * failing on the very next unlisted phrasing tried (3 independently-run
1003
+ * personas each hit this in one persona-sweep: "thanks, that was fun",
1004
+ * "ok thank you very much, bye bye", "thanks, bye"). The generalization is
1005
+ * over PHRASE SHAPE (a thanks/bye clause tacked onto a larger sentence),
1006
+ * not another one-off literal string:
1007
+ * - split on comma/semicolon/a standalone "and" into clauses
1008
+ * - strip a leading bare OK_ACK lead-in off each clause ("ok thank you…")
1009
+ * - strip a trailing intensifier ("very much"/"so much"/"a lot"/"a bunch" —
1010
+ * the SAME curated set THANKS_PREAMBLE_RE, interpret/normalize.mjs,
1011
+ * already recognizes) before matching THANKS
1012
+ * - fold an exact word-repeated clause ("bye bye") to one instance before
1013
+ * matching BYE — informal reduplication for emphasis, not a new phrase
1014
+ * Still the SAME closed THANKS/BYE sets underneath (same closedOrCollapsed
1015
+ * matcher) — only the SEGMENTATION generalizes. Bounded to short, non-codeish
1016
+ * lines (same discipline as isConversational/fuzzyConversationalMatch) so a
1017
+ * genuine structural question is never grabbed. A single-clause line (no
1018
+ * comma/semicolon/"and") is left to the exact whole-line checks above/below —
1019
+ * this only handles the MULTI-clause case those can't. Returns "bye"/"thanks"/
1020
+ * null; bye wins when a line carries both (a farewell should end the session
1021
+ * even alongside a thanks — the small-talk persona's "thanks, bye" finding:
1022
+ * the README implies "bye" phrasing should end the session, full stop). */
1023
+ const ACK_LEAD_RE = new RegExp(`^(?:${[...OK_ACK].map(escapeRegex).join("|")})\\s+(.+)$`, "i");
1024
+ const TRAILING_INTENSIFIER_RE = /\s+(?:very\s+much|so\s+much|a\s+lot|a\s+bunch)\s*$/i;
1025
+ const REPEATED_WORD_RE = /^(\S+)\s+\1$/i;
1026
+ // Comma/semicolon (optionally swallowing a following "and") OR a standalone
1027
+ // "and" — a single combined pattern so "X, and Y" splits into ["X", "Y"], not
1028
+ // ["X", "and Y"] (a naive comma-only split leaves "and" glued to the second
1029
+ // clause, which then fails every closed-set match downstream).
1030
+ const CLAUSE_SPLIT_RE = /\s*[,;]\s*(?:and\s+)?|\s+and\s+/;
1031
+ function conversationalClauses(q) {
1032
+ return q.split(CLAUSE_SPLIT_RE).map((c) => c.trim()).filter(Boolean);
1033
+ }
1034
+ /** BYE match tolerant of informal reduplication ("bye bye", "no no" — general,
1035
+ * not specific to any one word): a clause consisting of the SAME word twice
1036
+ * folds to one instance before the ordinary closed/collapsed BYE lookup.
1037
+ * Shared by the single-clause whole-line check and the multi-clause scan
1038
+ * below, so "bye bye" resolves the same way whether or not a comma follows it. */
1039
+ function foldedBye(clause) {
1040
+ if (closedOrCollapsed(clause, BYE, BYE_COLLAPSED)) return true;
1041
+ const folded = clause.match(REPEATED_WORD_RE);
1042
+ return !!(folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED));
1043
+ }
1044
+ function farewellOrThanksSignal(raw, q) {
1045
+ const words = q.split(/\s+/).filter(Boolean);
1046
+ if (words.length < 2 || words.length > 8 || looksCodeish(raw, q)) return null;
1047
+ const clauses = conversationalClauses(q);
1048
+ if (clauses.length < 2) return null; // single-clause lines: the exact whole-line checks own this
1049
+ // OK_ACK is deliberately NOT a signal here (unlike the exact whole-line check
1050
+ // above/below): "ok"/"cool"/"right"/"sure" are constitutionally ACK-PREAMBLE
1051
+ // words in this codebase (ACK_PREAMBLE_RE, interpret/normalize.mjs) — "right,
1052
+ // can you walk me through this codebase" is an ack-preamble before a REAL
1053
+ // question, not a closing acknowledgement, and treating a bare OK_ACK clause
1054
+ // as a thanks-signal regressed exactly that live case. THANKS itself is more
1055
+ // specific (genuine gratitude words rarely lead into an unrelated question),
1056
+ // but still gated below: a THANKS-hit only counts when every OTHER clause is
1057
+ // itself small-talk-shaped (≤3 words, non-codeish) — the SAME bound
1058
+ // isConversational's own catch-all uses — so "cheers, what does X do" is left
1059
+ // to the existing THANKS_PREAMBLE_RE lane, never grabbed here.
1060
+ let thanksClauseIdx = -1;
1061
+ let byeHit = false;
1062
+ for (let i = 0; i < clauses.length; i += 1) {
1063
+ const rawClause = clauses[i];
1064
+ const ackMatch = rawClause.match(ACK_LEAD_RE);
1065
+ const clause = ackMatch ? ackMatch[1].trim() : rawClause;
1066
+ if (foldedBye(clause)) { byeHit = true; break; }
1067
+ const deIntensified = clause.replace(TRAILING_INTENSIFIER_RE, "").trim();
1068
+ if (thanksClauseIdx < 0 && closedOrCollapsed(deIntensified, THANKS, THANKS_COLLAPSED)) thanksClauseIdx = i;
1069
+ }
1070
+ if (byeHit) return "bye";
1071
+ const thanksHit = thanksClauseIdx >= 0 && clauses.every((c, i) => i === thanksClauseIdx
1072
+ || (c.split(/\s+/).filter(Boolean).length <= 3 && !looksCodeish(c, c.toLowerCase())));
1073
+ return thanksHit ? "thanks" : null;
1074
+ }
1075
+
960
1076
  /** The fuzzy-typo fallback's candidate pool: every canonical phrase across the
961
1077
  * closed conversational sets, flattened once. Consulted only after every exact/
962
1078
  * collapsed lookup misses (see fuzzyConversationalMatch). */
@@ -1050,11 +1166,29 @@ function conversationalTurn(line, ctx) {
1050
1166
  ...(end ? { end: true } : {}),
1051
1167
  };
1052
1168
  };
1053
- if (BYE.has(q)) {
1169
+ if (foldedBye(q)) {
1054
1170
  note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
1055
- note(ctx.trace, "lane: conversational — farewell (BYE closed set)");
1171
+ note(ctx.trace, "lane: conversational — farewell (BYE closed set, incl. bare reduplication e.g. \"bye bye\")");
1056
1172
  return mk(t(T_FAREWELL), { end: true });
1057
1173
  }
1174
+ {
1175
+ // HANDOVER.md 2026-07-10 item 2: a multi-clause line carrying a bye/thanks
1176
+ // clause tacked onto a larger sentence ("thanks, that was fun", "ok thank
1177
+ // you very much, bye bye", "thanks, bye") — see farewellOrThanksSignal's
1178
+ // own docblock. Never fires on a single-clause line (those are the exact
1179
+ // checks just above/below), so this only ADDS coverage, never shadows it.
1180
+ const signal = farewellOrThanksSignal(raw, q);
1181
+ if (signal === "bye") {
1182
+ note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
1183
+ note(ctx.trace, "lane: conversational — farewell (multi-clause phrase-shape match)");
1184
+ return mk(t(T_FAREWELL), { end: true });
1185
+ }
1186
+ if (signal === "thanks") {
1187
+ note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
1188
+ note(ctx.trace, "lane: conversational — thanks (multi-clause phrase-shape match)");
1189
+ return mk(t(T_THANKS));
1190
+ }
1191
+ }
1058
1192
  if (WHY.has(q)) {
1059
1193
  note(ctx.trace, "goal: elaborate on the previous answer (why/say-more)");
1060
1194
  note(ctx.trace, "lane: conversational — why/say-more (WHY closed set)");
@@ -1098,6 +1232,11 @@ function conversationalTurn(line, ctx) {
1098
1232
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
1099
1233
  return mk(t(T_IDENTITY_NOT_LLM));
1100
1234
  }
1235
+ if (FEELINGS_PHRASES.some((re) => re.test(raw))) {
1236
+ note(ctx.trace, "goal: identity — does tmct have feelings/consciousness (small-talk persona finding)");
1237
+ note(ctx.trace, "lane: conversational — identity/feelings (FEELINGS_PHRASES closed set)");
1238
+ return mk(t(T_IDENTITY_NO_FEELINGS));
1239
+ }
1101
1240
  if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
1102
1241
  note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
1103
1242
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
@@ -1402,6 +1541,53 @@ const OWNS_PASSIVE_TEACH_RE = /^(.+?)\s+(?:is|are|was|were)\s+owned\s+by\s+([A-Z
1402
1541
  const RELATION_FACT_TEACH_RE =
1403
1542
  /^([\w'-]+(?:\s+[A-Z][\w'-]*)?)\s+(?:is|are|was|were)\s+the\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[.!?]*$/i;
1404
1543
 
1544
+ /** "every/a/an/the <N1> has a/an <N2> method" — the HAS-A-METHOD teach
1545
+ * declarative (HANDOVER.md 2026-07-10 item 9, a new pattern the operator
1546
+ * explicitly authorized this session — NOT one of PLAN_TAUGHT_RELATIONS.md's
1547
+ * own six items): a possession-of-capability claim about a class/entity's
1548
+ * method ("every Component has a render method", "a Widget has a render
1549
+ * method"). Before this pattern existed, this exact phrasing reached NO teach
1550
+ * recognizer at all: RELATION_FACT_TEACH_RE (above) requires a literal "is/
1551
+ * are the ROLE of", never "has a ROLE method"; GENERAL_VERB_TEACH_RE (below)
1552
+ * maps "has"/"have" onto the same HAS_A_PREDICATE this pattern uses, but only
1553
+ * for a BARE, wrapper-required, single-token subject with NO leading
1554
+ * determiner (GENERAL_VERB_DETERMINER_RE explicitly declines "every"/"a"/
1555
+ * "the" as a subject, by design — see its own docblock) — so a determiner-led
1556
+ * subject (the operator's own canonical example, "every Component…") fell
1557
+ * all the way through teachLane, landing on ask.mjs's own structural
1558
+ * "defines" grammar instead (VERB_TO_KIND maps "has"/"have" to the code-graph
1559
+ * "defines" relation), which can't resolve "Component"/"render" as real
1560
+ * code-graph entities and reports the vague, non-actionable
1561
+ * `"couldn't resolve one of the terms in this question."` wall — exactly the
1562
+ * symptom HANDOVER.md item 9 names.
1563
+ *
1564
+ * Deliberately a NARROW, EXPLICIT new pattern, not a widening of
1565
+ * GENERAL_VERB_TEACH_RE's own bare-subject shape (this project's own
1566
+ * discipline: small curated closed-set patterns, each independently tested,
1567
+ * never one generalized catch-all) — the literal trailing word "method" is
1568
+ * the anchor that keeps this pattern structurally DISJOINT from
1569
+ * generalVerbTeach's broader "X has a Y" territory (an ordinary "TaskController
1570
+ * has a hat" still never matches here, and falls through to generalVerbTeach
1571
+ * unaffected). Tried on the SAME ownSrc the other relational/possessive teach
1572
+ * shapes above already use, ahead of generalVerbTeach's own call site, so a
1573
+ * wrapped sentence with NO determiner ("remember that Component has a render
1574
+ * method" — already handled by generalVerbTeach today) is claimed here first
1575
+ * instead, producing the byte-identical stored fact and confirmation text —
1576
+ * a widening of COVERAGE (the determiner-led/bare-unwrapped case), never a
1577
+ * behavior change to the case that already worked.
1578
+ *
1579
+ * Predicate minting reuses the EXISTING HAS_A_PREDICATE (mgx:hasA) —
1580
+ * generalVerbTeach's own has/have special case already mints this, so a fact
1581
+ * taught via either recognizer reads back interoperably. m[1] = the subject
1582
+ * (N1, "Component"); m[2] = the capability word (N2, "render") — stored as
1583
+ * the object `"<N2> method"` ("render method"), so the query-side readers
1584
+ * below (HAS_METHOD_YESNO_RE/HAS_METHOD_OPEN_RE) can match on the whole
1585
+ * "<capability> method" phrase, never just the bare capability word (which
1586
+ * would risk colliding with an unrelated mgx:hasA fact about the same
1587
+ * capability noun taught some other way). */
1588
+ const TEACH_HAS_METHOD_RE =
1589
+ /^(?:every\s+|each\s+|all\s+|a\s+|an\s+|the\s+)?([A-Za-z][\w'-]*)\s+has\s+an?\s+([a-z][\w-]*)\s+method[.!?]*$/i;
1590
+
1405
1591
  /** "a <name> is a <base1> of a <base2>" — the fixed-hop COMPOSITION-RULE teach
1406
1592
  * declarative (PLAN_TAUGHT_RELATIONS.md Item 3, Phase 4): "a grandparent is a
1407
1593
  * parent of a parent" teaches a RULE (mgx:ruleKind "compose2"), never a Fact —
@@ -1653,8 +1839,17 @@ async function ungroundedPairHint(payload, lexicon, memoryDir) {
1653
1839
  const lex = lexicon || loadLexicon();
1654
1840
  if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
1655
1841
  if (await isGroundedTerm(objectRaw, lex, memoryDir)) return "";
1656
- return ` I don't know "${subjectRaw}" or "${objectRaw}" yet. Try grounding one first, e.g. `
1657
- + `"every ${subjectRaw} is a thing", then "every ${objectRaw} is a ${subjectRaw}".`;
1842
+ // 2026-07-10 (found live via SKILL_BENCHMARK_CONVERSATION.md playtest, a
1843
+ // classic first-thing-a-user-tries example: "john is a man"): the original
1844
+ // suggestion chained the second term UNDER the first's now-grounded proper
1845
+ // name ("every man is a john") — technically accepted by the grammar (once
1846
+ // "john" is grounded, ANY term can be taught as a kind of it), but reads as
1847
+ // nonsense to a human, since a proper name is never a category. Ground both
1848
+ // sides independently instead — two clear, parallel, semantically sane
1849
+ // suggestions, not a confusing chain through an arbitrary first term.
1850
+ return ` I don't know "${subjectRaw}" or "${objectRaw}" yet. Try grounding each one first, e.g. `
1851
+ + `"every ${subjectRaw} is a thing" and "every ${objectRaw} is a thing", then re-teach the`
1852
+ + ` original fact.`;
1658
1853
  }
1659
1854
 
1660
1855
  /** The unknown-SUBJECT direct-write fallback (point 1 + point 2's bare-property
@@ -2187,6 +2382,26 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2187
2382
  if (stored) return stored;
2188
2383
  }
2189
2384
 
2385
+ // HAS-A-METHOD TEACH — "every/a/an/the <N1> has a/an <N2> method"
2386
+ // (HANDOVER.md 2026-07-10 item 9): a possession-of-capability claim, stored
2387
+ // as an ordinary Fact via the SAME HAS_A_PREDICATE generalVerbTeach's own
2388
+ // has/have special case already uses (see TEACH_HAS_METHOD_RE's own
2389
+ // docblock above for the full design). Grouped with the other relational/
2390
+ // possessive teach shapes above, tried on the SAME ownSrc, unconditionally
2391
+ // ahead of generalVerbTeach's own call site below — disjoint from
2392
+ // RELATION_FACT_TEACH_RE just above (that shape requires a literal "the
2393
+ // ROLE of", never "has a … method") and from generalVerbTeach's own bare-
2394
+ // subject shape (this one is the ONLY recognizer in this lane that accepts
2395
+ // a leading determiner — "every"/"a"/"an"/"the" — before a "has a … method"
2396
+ // claim).
2397
+ const hasMethod = ownSrc.match(TEACH_HAS_METHOD_RE);
2398
+ if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2399
+ const stored = await teachFact(memoryDir, sessionId, {
2400
+ subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`,
2401
+ });
2402
+ if (stored) return stored;
2403
+ }
2404
+
2190
2405
  // COMPOSE2 RULE TEACH — "a <name> is a <base1> of a <base2>"
2191
2406
  // (PLAN_TAUGHT_RELATIONS.md Item 3, Phase 4): stores a RULE (appendRule,
2192
2407
  // kind "compose2"), never a Fact — tried right after item 1's relational
@@ -2451,10 +2666,17 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2451
2666
  }
2452
2667
  } catch { /* lexicon unavailable — fall through to the generic message */ }
2453
2668
  }
2669
+ // HANDOVER.md 2026-07-10 item 3: this used to claim "I can only teach facts
2670
+ // using tmct's own code-vocabulary nouns" — false (general vocabulary teaching
2671
+ // is fully supported elsewhere, e.g. "Paris is the capital of France" stores
2672
+ // directly, and unknownSubjectFallback/ungroundedPairHint above both accept
2673
+ // ANY new vocabulary once one side is grounded). The real constraint named
2674
+ // here now: at least one side of a fact must already be grounded (or the
2675
+ // sentence must fit a specific relation shape) — not a vocabulary restriction.
2454
2676
  const why = unknown.length
2455
2677
  ? ` I don't recognize ${joinList(unknown.map((w) => `"${w}"`))} as ${unknown.length === 1 ? "a word" : "words"} I know — `
2456
- + "I can only teach facts using tmct's own code-vocabulary nouns (like module, class, function…), "
2457
- + "not arbitrary new terms."
2678
+ + "any vocabulary works, but at least one side of a fact needs to already be grounded to something I "
2679
+ + "know (or fit one of my specific relation shapes), not two brand-new terms at once."
2458
2680
  : "";
2459
2681
  // Grounding NUDGE (operator refinement, 2026-07-09): APPENDED, never a
2460
2682
  // replacement, exactly like "did" above — see ungroundedPairHint's own
@@ -2484,7 +2706,16 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
2484
2706
  // 0.8.2 WS4 wall kindness (c): the most likely stranger openers — "what does this
2485
2707
  // app/codebase do", "what is this app (for)" — join the orientation lane, so a
2486
2708
  // first-touch question gets the live overview instead of the grammar wall.
2487
- 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))?)$/;
2709
+ // Playtest sprint round 1 (2026-07-10): "what does this do" — the bare pronoun,
2710
+ // no explicit noun — used to fall through this lane entirely (the "do" branch
2711
+ // required an explicit app/code/codebase/project/repo noun after this/the) into
2712
+ // MODULE_ORIENT_RE, which tried to resolve "this" as a graph entity, failed, and
2713
+ // hit the raw grammar wall — even though the identical-intent "what can you tell
2714
+ // me about this project" already answers cleanly via this same lane. The noun is
2715
+ // now OPTIONAL after "this" specifically (kept REQUIRED after "the", since bare
2716
+ // "what does the do" is not real input) — a natural stranger-opener that was one
2717
+ // token away from already working.
2718
+ 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(?:\s+(?:app|code|codebase|project|repo))?\s+do|what\s+does\s+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))?)$/;
2488
2719
 
2489
2720
  /** A SHORT memory summary (never a fact dump) for the bare "what do you know".
2490
2721
  * This branch only fires when rows.length === 0 — i.e. precisely the case where
@@ -3364,8 +3595,30 @@ const GENERIC_ENTITY_WORDS = new Set([
3364
3595
  * index admits only SINGLE-WORD, purely-alphabetic ConceptNet endpoints on
3365
3596
  * BOTH sides of a row — a first-cut heuristic filter, not a full manual
3366
3597
  * review of all 1,228 rows (a natural follow-up, not claimed as done here).
3367
- * Lazy + failure-tolerated: a missing/broken corpus file degrades to an
3368
- * empty (or phrasebook-only) index, never a throw. */
3598
+ * A FOLLOW-UP spot check (later dispatch, same plan) sampled the 903 rows
3599
+ * this heuristic admits and confirmed the risk note is real even after the
3600
+ * single-word filter: generic-English collisions ("battalion"~"heap",
3601
+ * "bash"~"sock") and, more dangerously, IN-DOMAIN false synonyms — pairs
3602
+ * where both endpoints are real software terms but are NOT interchangeable
3603
+ * ("interpreter"~"compiler", "string"~"thread") — the exact "confidently
3604
+ * wrong within the domain" failure this codebase's ground rules treat as
3605
+ * worse than an honest miss. SYNONYM_DENYLIST below removes the specific
3606
+ * false pairs found by that spot check (a manually-reviewed blocklist, the
3607
+ * same shape as `conceptnet-map.toml`'s own reviewed relation-gate — not a
3608
+ * general noise heuristic); a full manual review of the remaining ~900
3609
+ * rows is still the honest follow-up, not claimed as done here either. */
3610
+ const SYNONYM_DENYLIST = new Set([
3611
+ ["interpreter", "compiler"], // different execution strategies, not synonyms
3612
+ ["string", "thread"], // unrelated CS concepts (text data vs. execution thread)
3613
+ ["heart", "kernel"], // generic-English collision on "kernel"
3614
+ ["battalion", "heap"], // generic-English collision on "heap" (data structure)
3615
+ ["bash", "sock"], // generic-English collision ("bash"/"sock" = to hit)
3616
+ ["command", "skill"], // too loose to be a safe query-time substitution
3617
+ ["docker", "longshoreman"], // proper-noun/tool name vs. unrelated profession
3618
+ ["name", "list"], // generic-English collision, not a domain synonym
3619
+ ["list", "number"], // generic-English collision, not a domain synonym
3620
+ ].map(([a, b]) => [a, b].sort().join("|")));
3621
+
3369
3622
  let synonymIndexCache = null;
3370
3623
  async function synonymIndex() {
3371
3624
  if (synonymIndexCache) return synonymIndexCache;
@@ -3374,6 +3627,7 @@ async function synonymIndex() {
3374
3627
  const ta = String(a || "").trim().toLowerCase();
3375
3628
  const tb = String(b || "").trim().toLowerCase();
3376
3629
  if (!ta || !tb || ta === tb) return;
3630
+ if (SYNONYM_DENYLIST.has([ta, tb].sort().join("|"))) return;
3377
3631
  if (!index.has(ta)) index.set(ta, []);
3378
3632
  if (!index.get(ta).some((e) => e.variant === tb)) index.get(ta).push({ variant: tb, source });
3379
3633
  if (!index.has(tb)) index.set(tb, []);
@@ -3460,9 +3714,17 @@ const RELATION_FACT_YESNO_RE =
3460
3714
  * below — a `resolveRelationChaseReverse` closure re-deriving the SAME
3461
3715
  * resolution logic as (a0)'s `resolveRelationChase` (direct fact, alias via
3462
3716
  * findIsaChain, compose2 via a reverse hop-counted chase, filter via a
3463
- * recursive base-then-property chase), walked backward from the object. */
3717
+ * recursive base-then-property chase), walked backward from the object.
3718
+ * HANDOVER.md 2026-07-10 item 3 (teach-then-recall gap): "who" also accepts
3719
+ * "what" — a taught relation whose role isn't a person ("paris is the capital
3720
+ * of france") reads naturally as "what is the capital of france", and the
3721
+ * resolution below is identical either way (it just returns the satisfying
3722
+ * subject(s)); the two words never compete for a query built from a DIFFERENT
3723
+ * shape, since T5's bare meta-whatis grammar shape ("what is X") only wins the
3724
+ * turn when factAnswer/factReadBack's own more specific readers upstream (this
3725
+ * one included) have already declined. */
3464
3726
  const RELATION_WHO_ASK_RE =
3465
- /^who\s+(?:is|are)\s+(?:the|an?)\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
3727
+ /^(?:who|what)\s+(?:is|are)\s+(?:the|an?)\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
3466
3728
 
3467
3729
  /** "list the descendants of ahab" — the REACHABILITY-SET list query
3468
3730
  * (PLAN_TAUGHT_RELATIONS.md Item 6, Phase 6's wiring half): a genuine
@@ -3480,6 +3742,21 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
3480
3742
  * doesn't parse; checked against the isa-family fact predicates only. */
3481
3743
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
3482
3744
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
3745
+ /** "so john is a man now right?" / "john is a man, right?" — a DECLARATIVE
3746
+ * statement wrapped in a confirmation-check tag ("now right?"/"right?"/
3747
+ * "correct?"), found live (playtest sprint round 1, 2026-07-10) after a
3748
+ * just-declined teach attempt: the user reasonably assumes it worked and
3749
+ * asks to confirm — but this shape doesn't match ISA_ASK_RE at all (no
3750
+ * leading "is/are"), so it fell to the fully GENERIC grammar wall instead of
3751
+ * the same (already ISA-tailored) honest miss/hint the plain "is X a Y" form
3752
+ * gets. Deliberately narrow (requires "right?"/"correct?"/"yeah?" as the
3753
+ * VERY LAST word, optionally preceded by "now" and/or a comma) so it can
3754
+ * only ever REDIRECT a would-be-wall to the isaAsk block's own answer —
3755
+ * never a fabricated confirmation, and never touches phrasings that already
3756
+ * have their own home (e.g. OPINION_NUDGE_RE's own "is the code good"
3757
+ * ordering is unaffected — that starts with "is", leaving no room for this
3758
+ * regex's required leading subject clause). */
3759
+ const CONFIRM_TAG_RE = /^(?:so\s+)?(.+?)\s+(?:is|are)\s+(?:an?\s+)?(.+?)\s*,?\s*(?:now\s+)?(?:right|correct|yeah)\??$/i;
3483
3760
  /** "what do you know about caches" — the open recall-everything form. Bug E
3484
3761
  * (operator manual-chat find, this session) widened this to also accept
3485
3762
  * "what is in your memory about X" / "what's in your memory about X" / "what
@@ -3645,6 +3922,32 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
3645
3922
  // FULL unshortened grammar cheat-sheet standing under the offer instead
3646
3923
  // of the nicer tailored one-liner — found live while adding this fix).
3647
3924
  if (!hits.length) return null;
3925
+ // LIVE CONSISTENCY CHECK (PLAN_INFERENCE_TESTING.md INF-C2, §4 stage 5):
3926
+ // before answering from this subject's memory, check whether its OWN
3927
+ // taught/entailed types contradict each other (x rdf:type C1, x rdf:type
3928
+ // C2, C1 owl:disjointWith C2, lifted through both types' ⊑-ancestor
3929
+ // closures) via syllogise.mjs's findConsistencyViolations, LIVE and
3930
+ // READ-ONLY — same discipline as the cax-dw chase in the isaAsk block
3931
+ // above. A hit REFUSES the whole answer (every belief about a
3932
+ // contradictory subject is suspect, not just the clashing pair) rather
3933
+ // than silently answering from a memory that's already inconsistent.
3934
+ const { findConsistencyViolations, TYPE_PREDICATE: CONS_TYPE_PREDICATE, SUBCLASS_PREDICATE: CONS_SC_PREDICATE, DISJOINT_PREDICATE: CONS_DISJOINT_PREDICATE } = await import("./syllogise.mjs");
3935
+ const consIsTaught = (f) => !f.provenance?.includes("corpus:") && !f.provenance?.includes("web:");
3936
+ const consTypeEdges = rows.filter((f) => f.predicate === CONS_TYPE_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
3937
+ const consSubClassEdges = rows.filter((f) => f.predicate === CONS_SC_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
3938
+ const consDisjointEdges = rows.filter((f) => f.predicate === CONS_DISJOINT_PREDICATE && consIsTaught(f)).map((f) => [f.subject, f.object]);
3939
+ if (consDisjointEdges.length) {
3940
+ const clashes = findConsistencyViolations(consTypeEdges, consSubClassEdges, consDisjointEdges, { focus: variants, budget: 5 });
3941
+ const clash = clashes.find((c) => variants.has(c.subject));
3942
+ if (clash) {
3943
+ return {
3944
+ text: `I can't answer that — what I've been told about ${clash.subject} is inconsistent: it's taught to be both `
3945
+ + `${clash.classA} and ${clash.classB}, but ${clash.viaA} and ${clash.viaB} are disjoint (${clash.viaA} owl:disjointWith `
3946
+ + `${clash.viaB}). I'd need one of those retracted before I can answer honestly.`,
3947
+ replace: true,
3948
+ };
3949
+ }
3950
+ }
3648
3951
  // echo the STORED spelling ("caches" asked → "cache" known), never a guess
3649
3952
  const literalHit = hits.find((f) => variants.has(f.subject) || variants.has(f.object));
3650
3953
  const term = literalHit
@@ -3802,6 +4105,46 @@ const OWNS_YESNO_RE = /^(?:does|did)\s+([\w'-]+)\s+(?:owns?|maintains?)\s+(.+?)[
3802
4105
  * backtracking "owned by" into its own subject capture and "<Name>" into its
3803
4106
  * adjective slot, silently declining rather than answering). */
3804
4107
  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;
4108
+ /** "does/did <N1> have a/an <N2> method" — the HAS-A-METHOD yes/no reader
4109
+ * (HANDOVER.md 2026-07-10 item 9), sibling of OWNS_YESNO_RE above: mirrors
4110
+ * TEACH_HAS_METHOD_RE's own subject/capability shape, answering a direct
4111
+ * yes/no claim against a fact taught via that pattern (mgx:hasA, object
4112
+ * `"<capability> method"`).
4113
+ *
4114
+ * NOTE — a real, pre-existing structural collision, confirmed live before
4115
+ * wiring this: ask.mjs's OWN structural grammar already maps "has"/"have"
4116
+ * onto the code-graph "defines" relation (ask-vocab.mjs's VERB_TO_KIND), so
4117
+ * when a real code graph is loaded this EXACT phrasing is parsed there
4118
+ * FIRST — and because "a <word> method" is separately ambiguous with a
4119
+ * QUALIFIER reading there ("a public method"), ask.mjs resolves with its own
4120
+ * (possibly confusing) disambiguation choice, `miss: false`, before this
4121
+ * reader (factReadBack, gated on `miss` already being true) ever gets a
4122
+ * turn. Verified live: with a populated code graph, "does Component have a
4123
+ * render method" always lands on ask.mjs's disambiguation prompt, regardless
4124
+ * of subject/object identity or any taught fact; with NO code graph loaded
4125
+ * (this project's other supported mode — a purely conceptual teach-and-
4126
+ * recall session, see PLAN_TAUGHT_RELATIONS.md's own CONFIG={} test
4127
+ * convention) ask.mjs's structural attempt declines outright and this reader
4128
+ * answers correctly. Changing ask.mjs's own qualifier-disambiguation
4129
+ * behavior is a pre-existing, unrelated structural-grammar concern — out of
4130
+ * scope for this item.
4131
+ *
4132
+ * Same "never a guessed no" discipline as IS_ADJECTIVE_YESNO_RE/
4133
+ * GENERAL_VERB_YESNO_RE below (not OWNS_YESNO_RE's closed-world "no" text):
4134
+ * a hit answers "yes"; no matching fact DECLINES (null), since "nothing
4135
+ * taught yet" is not proof the class genuinely lacks the method. */
4136
+ const HAS_METHOD_YESNO_RE = /^(?:does|did)\s+([\w'-]+)\s+(?:has|have)\s+an?\s+([a-z][\w-]*)\s+method[?.!\s]*$/i;
4137
+ /** "what methods does <N1> have" — the HAS-A-METHOD open-list reader
4138
+ * (HANDOVER.md 2026-07-10 item 9): the read-back companion to
4139
+ * HAS_METHOD_YESNO_RE just above — lists every taught mgx:hasA fact for
4140
+ * <N1> whose object is a "<word> method" phrase. A distinct query shape
4141
+ * (object noun right after "what", not after the subject), so it does NOT
4142
+ * share HAS_METHOD_YESNO_RE's own ask.mjs collision: "what methods does X
4143
+ * have" already reaches an honest `miss: true` from ask.mjs even against a
4144
+ * populated code graph (confirmed live — "no module matching X found in the
4145
+ * index" when X isn't a real graph entity), so this reader is reachable in
4146
+ * both configurations. */
4147
+ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i;
3805
4148
  /** "is/are/was/were <X> <adjective>" — a yes/no claim over a taught
3806
4149
  * mgx:hasProperty fact (Tier-5 playtest fix). Deliberately has NO marker
3807
4150
  * between subject and complement — "a"/"an"/"a kind of"/"a type of" is
@@ -4080,91 +4423,17 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4080
4423
  // guard is needed at THIS dispatch level (the search kernels
4081
4424
  // underneath — findActionPath — carry their own `seen`-set safety
4082
4425
  // regardless).
4083
- const resolveRelationChase = async (name, subjectTerm, objectTerm) => {
4084
- const target = String(name || "").trim().toLowerCase();
4085
- // (i)+(ii): direct hit or alias-chased hit for this exact (subject,
4086
- // object) pair under the queried relation name.
4087
- const sv = factTermVariants(normFactTerm, subjectTerm);
4088
- const ov = factTermVariants(normFactTerm, objectTerm);
4089
- const pairHits = relationFactsFor(target).filter((e) => sv.has(e.fact.subject) && ov.has(e.fact.object));
4090
- if (pairHits.length) {
4091
- const hit = pairHits.slice().sort((a, b) => byTrust(a.fact, b.fact))[0];
4092
- return { citation: [renderFactLine(hit.fact), ...hit.aliasFacts.map(
4093
- (af) => `${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`,
4094
- )] };
4095
- }
4096
- // The queried name may itself be a taught RULE. findRuleByName is
4097
- // the SAME lookup §2/§3's own genericity design uses ("what kind of
4098
- // thing is X") — no per-rule-name branch, just a class/kind check.
4099
- const {
4100
- loadMemory, findRuleByName, RULE_KIND_PROP: ruleKindProp,
4101
- RULE_KIND_COMPOSE2: composeKind, RULE_KIND_FILTER: filterKind,
4102
- } = await import("./memory/core.mjs");
4103
- const memory = await loadMemory(memoryDir);
4104
- const rule = findRuleByName(memory, target);
4105
- const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindProp)?.value;
4106
- // (iii) COMPOSE2 RULE CHASE (Phase 4 item 3) — a hop-counted
4107
- // findActionPath search over { entity, hopsTaken } states,
4108
- // dispatching base1's edges at hop 0 and base2's edges at hop 1,
4109
- // requiring EXACTLY hopsTaken === 2 at the goal — never just
4110
- // entity === target at any depth.
4111
- if (rule && ruleKind === composeKind) {
4112
- const base1 = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4113
- const base2 = rule.attributes.find((a) => a.prop === "mgx:ruleBase2")?.value;
4114
- const startEntity = normFactTerm(subjectTerm);
4115
- const targetEntity = normFactTerm(objectTerm);
4116
- if (!base1 || !base2 || !startEntity || !targetEntity) return null;
4117
- const { findActionPath } = await import("./planning.mjs");
4118
- const applyActions = (state) => {
4119
- if (state.hopsTaken >= 2) return [];
4120
- const relName = state.hopsTaken === 0 ? base1 : base2;
4121
- return relationFactsFor(relName)
4122
- .filter((e) => e.fact.subject === state.entity)
4123
- .map((e) => ({ action: e, nextState: { entity: e.fact.object, hopsTaken: state.hopsTaken + 1 } }));
4124
- };
4125
- const isGoal = (state) => state.hopsTaken === 2 && state.entity === targetEntity;
4126
- const stateKey = (state) => `${state.entity}#${state.hopsTaken}`;
4127
- const found = findActionPath({ entity: startEntity, hopsTaken: 0 }, isGoal, applyActions, { maxDepth: 2, stateKey });
4128
- if (!found) return null;
4129
- const seenAlias = new Set();
4130
- const parts = [];
4131
- for (const e of found.actions) {
4132
- parts.push(renderFactLine(e.fact));
4133
- for (const af of e.aliasFacts) {
4134
- const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
4135
- if (seenAlias.has(key)) continue;
4136
- seenAlias.add(key);
4137
- parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
4138
- }
4139
- }
4140
- return { citation: parts };
4141
- }
4142
- // (iv) FILTER RULE CHASE (Phase 5 item 4) — recursively resolve the
4143
- // base (a plain relation OR another rule — this SAME function,
4144
- // generic over which one it turns out to be), then filter by
4145
- // whether the SUBJECT carries the property literal
4146
- // (mgx:hasProperty, a plain Fact lookup over the already-loaded
4147
- // `rows`). A base chase that fails declines here too (never a
4148
- // guess); a base chase that succeeds but whose subject lacks the
4149
- // taught property declines as well — the filter correctly EXCLUDES
4150
- // that candidate rather than silently ignoring the property clause.
4151
- if (rule && ruleKind === filterKind) {
4152
- const base = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4153
- const property = rule.attributes.find((a) => a.prop === "mgx:ruleFilterProperty")?.value;
4154
- if (!base || !property) return null;
4155
- const baseHit = await resolveRelationChase(base, subjectTerm, objectTerm);
4156
- if (!baseHit) return null;
4157
- const subjectEntity = normFactTerm(subjectTerm);
4158
- const propertyNorm = normFactTerm(property);
4159
- const propHit = rows.find(
4160
- (f) => f.predicate === HAS_PROPERTY_PREDICATE && f.subject === subjectEntity && normFactTerm(f.object) === propertyNorm,
4161
- );
4162
- if (!propHit) return null; // base relation holds, but the property filter excludes this candidate
4163
- return { citation: [...baseHit.citation, renderFactLine(propHit)] };
4164
- }
4165
- return null; // no remembered fact, alias, or rule (of any kind) reaches this
4166
- };
4167
- const hit = await resolveRelationChase(relationName, subject, object);
4426
+ // Extracted to memory/core.mjs (PLAN_COMPLETIONS.md Stage 1
4427
+ // prerequisite: cross-group inference reuses this SAME resolution
4428
+ // logic outside chat.mjs's dispatch context) findRuleByName's own
4429
+ // natural sibling there. `relationFactsFor`/`renderFactLine`/
4430
+ // `factPhrase`/`factTermVariants`/`byTrust`/`rows`/
4431
+ // `HAS_PROPERTY_PREDICATE` are this block's own local closures/
4432
+ // constants, threaded through explicitly rather than re-derived.
4433
+ const { loadMemory, findRuleByName, resolveRelationChase } = await import("./memory/core.mjs");
4434
+ const memory = await loadMemory(memoryDir);
4435
+ const relationChaseHelpers = { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE };
4436
+ const hit = await resolveRelationChase(memory, relationName, subject, object, relationChaseHelpers);
4168
4437
  if (hit) return { text: `yes — ${hit.citation.join("; ")}`, replace: true };
4169
4438
  // Gap 1 fix (live-tested 2026-07-09, PLAN_TAUGHT_RELATIONS.md follow-up):
4170
4439
  // this used to `return null` unconditionally on any miss here — the
@@ -4180,10 +4449,8 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4180
4449
  // object) pair's chase came up short (e.g. a 2-hop rule with only 1
4181
4450
  // hop of facts taught, or an unrelated pair) — an honest, specific
4182
4451
  // decline that NAMES the relation, never a guessed "no".
4183
- const { loadMemory: loadMemForMiss, findRuleByName: findRuleByNameForMiss } = await import("./memory/core.mjs");
4184
- const memoryForMiss = await loadMemForMiss(memoryDir);
4185
4452
  const nameKnown = relationFactsFor(relationName).length > 0
4186
- || !!findRuleByNameForMiss(memoryForMiss, relationName);
4453
+ || !!findRuleByName(memory, relationName);
4187
4454
  if (!nameKnown) {
4188
4455
  return { text: `I don't know a relation or rule called '${relationName}' yet.`, replace: true };
4189
4456
  }
@@ -4237,10 +4504,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4237
4504
  }
4238
4505
  return out;
4239
4506
  };
4240
- const {
4241
- loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, RULE_KIND_PROP: ruleKindPropWho,
4242
- RULE_KIND_COMPOSE2: composeKindWho, RULE_KIND_FILTER: filterKindWho,
4243
- } = await import("./memory/core.mjs");
4507
+ const { loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, resolveRelationChaseReverse } = await import("./memory/core.mjs");
4244
4508
  const memoryWho = await loadMemWho(memoryDir);
4245
4509
  // Generic REVERSE relation-NAME resolver — the mirror image of (a0)'s
4246
4510
  // resolveRelationChase: given a relation/rule name and a FIXED OBJECT,
@@ -4249,102 +4513,13 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4249
4513
  // bounded the SAME way (a0)'s own chase is (§3.3): a filter rule's base
4250
4514
  // is always either a plain relation (terminal) or another rule (one
4251
4515
  // level deeper), never itself.
4252
- const resolveRelationChaseReverse = async (name, objectTerm) => {
4253
- const target = String(name || "").trim().toLowerCase();
4254
- const ov = factTermVariants(normFactTerm, objectTerm);
4255
- // (i)+(ii): every direct/alias-chased fact under this name whose
4256
- // object matches the target one result per distinct subject (the
4257
- // highest-trust fact when more than one reaches the same subject).
4258
- const directHits = relationFactsForWho(target).filter((e) => ov.has(e.fact.object));
4259
- if (directHits.length) {
4260
- const bySubject = new Map();
4261
- for (const e of directHits) {
4262
- if (!bySubject.has(e.fact.subject)) bySubject.set(e.fact.subject, []);
4263
- bySubject.get(e.fact.subject).push(e);
4264
- }
4265
- return [...bySubject.entries()].map(([subj, hits]) => {
4266
- const hit = hits.slice().sort((a, b) => byTrust(a.fact, b.fact))[0];
4267
- return {
4268
- subject: subj,
4269
- citation: [renderFactLine(hit.fact), ...hit.aliasFacts.map(
4270
- (af) => `${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`,
4271
- )],
4272
- };
4273
- });
4274
- }
4275
- const rule = findRuleByNameWho(memoryWho, target);
4276
- const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindPropWho)?.value;
4277
- // (iii) COMPOSE2 REVERSE CHASE — the same hop-counted search (a0)'s
4278
- // forward chase uses, walked BACKWARD: seed from the TARGET object,
4279
- // reverse-hop via base2's edges first (the SECOND forward hop,
4280
- // closest to the object), then base1's edges (the FIRST forward
4281
- // hop) — swapping which side of each fact is queried (object instead
4282
- // of subject) rather than building a new search kernel. Enumerates
4283
- // every subject reachable at EXACTLY 2 reverse hops (never just
4284
- // "reachable within budget" — the same exact-hop-count discipline
4285
- // (a0)'s own isGoal uses), via findReachableSet (already proven
4286
- // cycle-safe by item 6's own reachability-list wiring, (a0.5) below).
4287
- if (rule && ruleKind === composeKindWho) {
4288
- const base1 = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4289
- const base2 = rule.attributes.find((a) => a.prop === "mgx:ruleBase2")?.value;
4290
- const targetEntity = normFactTerm(objectTerm);
4291
- if (!base1 || !base2 || !targetEntity) return [];
4292
- const { findReachableSet: findReachableSetWho } = await import("./planning.mjs");
4293
- const applyActionsRev = (state) => {
4294
- if (state.hopsTaken >= 2) return [];
4295
- const relName = state.hopsTaken === 0 ? base2 : base1;
4296
- return relationFactsForWho(relName)
4297
- .filter((e) => e.fact.object === state.entity)
4298
- .map((e) => ({ action: e, nextState: { entity: e.fact.subject, hopsTaken: state.hopsTaken + 1 } }));
4299
- };
4300
- const stateKeyRev = (state) => `${state.entity}#${state.hopsTaken}`;
4301
- const reached = findReachableSetWho(
4302
- { entity: targetEntity, hopsTaken: 0 }, applyActionsRev, { maxDepth: 2, stateKey: stateKeyRev },
4303
- );
4304
- return reached.filter((r) => r.node.hopsTaken === 2).map(({ node, path }) => {
4305
- const seenAlias = new Set();
4306
- const parts = [];
4307
- // path.actions was accumulated walking BACKWARD from the object
4308
- // (base2's edge first, base1's edge second) — reversed here so
4309
- // the citation reads in the natural subject-to-object order
4310
- // ("ahab fathers john; …; john fathers ishmael"), matching (a0)'s
4311
- // own forward-chase citation order rather than exposing the
4312
- // reverse-walk's internal accumulation order to the user.
4313
- for (const e of path.actions.slice().reverse()) {
4314
- parts.push(renderFactLine(e.fact));
4315
- for (const af of e.aliasFacts) {
4316
- const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
4317
- if (seenAlias.has(key)) continue;
4318
- seenAlias.add(key);
4319
- parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
4320
- }
4321
- }
4322
- return { subject: node.entity, citation: parts };
4323
- });
4324
- }
4325
- // (iv) FILTER REVERSE CHASE — reverse-chase the base (recursively,
4326
- // same as the forward filter chase — this SAME function calls
4327
- // itself), then filter the resulting subjects by whether EACH
4328
- // carries the taught property.
4329
- if (rule && ruleKind === filterKindWho) {
4330
- const base = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4331
- const property = rule.attributes.find((a) => a.prop === "mgx:ruleFilterProperty")?.value;
4332
- if (!base || !property) return [];
4333
- const baseHits = await resolveRelationChaseReverse(base, objectTerm);
4334
- const propertyNorm = normFactTerm(property);
4335
- const out = [];
4336
- for (const bh of baseHits) {
4337
- const subjectEntity = normFactTerm(bh.subject);
4338
- const propHit = rows.find(
4339
- (f) => f.predicate === HAS_PROPERTY_PREDICATE && f.subject === subjectEntity && normFactTerm(f.object) === propertyNorm,
4340
- );
4341
- if (propHit) out.push({ subject: bh.subject, citation: [...bh.citation, renderFactLine(propHit)] });
4342
- }
4343
- return out;
4344
- }
4345
- return []; // no remembered fact, alias, or rule (of any kind) reaches this
4346
- };
4347
- const hits = await resolveRelationChaseReverse(relationName, object);
4516
+ // Extracted to memory/core.mjs alongside (a0)'s own resolveRelationChase
4517
+ // (PLAN_COMPLETIONS.md Stage 1 prerequisite — see (a0)'s own comment for
4518
+ // why); `relationFactsForWho`/`renderFactLine`/`factPhrase`/
4519
+ // `factTermVariants`/`byTrust`/`rows`/`HAS_PROPERTY_PREDICATE` are this
4520
+ // block's own local closures/constants, threaded through explicitly.
4521
+ const relationChaseHelpersWho = { relationFactsFor: relationFactsForWho, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE };
4522
+ const hits = await resolveRelationChaseReverse(memoryWho, relationName, object, relationChaseHelpersWho);
4348
4523
  if (hits.length) {
4349
4524
  const lines = hits.map((h) => `${h.subject} — ${h.citation.join("; ")}`);
4350
4525
  return { text: lines.join("\n"), replace: true };
@@ -4357,8 +4532,15 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4357
4532
  if (!nameKnownWho) {
4358
4533
  return { text: `I don't know a relation or rule called '${relationName}' yet.`, replace: true };
4359
4534
  }
4535
+ // HANDOVER.md 2026-07-10 item 3: "what is the capital of france" reads
4536
+ // oddly as "I don't know ANYONE who is the capital…" — the neutral
4537
+ // "nothing/anyone" split below matches whichever interrogative word the
4538
+ // query actually used.
4539
+ const isWhatAsk = /^what\b/i.test(qHedge);
4360
4540
  return {
4361
- text: `I don't know anyone who is the ${relationName} of ${object} from what you've told me.`,
4541
+ text: isWhatAsk
4542
+ ? `I don't know what the ${relationName} of ${object} is from what you've told me.`
4543
+ : `I don't know anyone who is the ${relationName} of ${object} from what you've told me.`,
4362
4544
  replace: true,
4363
4545
  };
4364
4546
  }
@@ -4462,9 +4644,20 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4462
4644
  // (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
4463
4645
  // term itself (a class word) AND, when it resolves in the graph, its class-noun
4464
4646
  // (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
4465
- const isaAsk = q.match(ISA_ASK_RE);
4647
+ // A confirmation-check wrapper ("so X is a Y now right?") is rewritten to the
4648
+ // plain "is X a Y" form and re-tried when the raw query itself doesn't match —
4649
+ // see CONFIRM_TAG_RE's own docblock.
4650
+ const confirmTag = q.match(CONFIRM_TAG_RE);
4651
+ const isaAsk = q.match(ISA_ASK_RE) || (confirmTag && `is ${confirmTag[1].trim()} a ${confirmTag[2].trim()}`.match(ISA_ASK_RE));
4466
4652
  if (isaAsk) {
4467
- const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
4653
+ // Playtest sprint round 1 (2026-07-10): "is TaskController a validator then"
4654
+ // — the same trailing bare discourse tag item 8 fixed for metaTermOf's bare
4655
+ // "what is X" shape also glues onto ISA_ASK_RE's captured kind term (its own
4656
+ // trailing anchor only allows punctuation/whitespace, not a stray word), so
4657
+ // "validator then" never matched any taught fact even though the CLASS↔
4658
+ // INSTANCE BRIDGE below would otherwise answer yes. Same stripTrailingDiscourseTag
4659
+ // fix, applied here too.
4660
+ const objVariants = factTermVariants(normFactTerm, stripTrailingDiscourseTag(isaAsk[2]));
4468
4661
  const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
4469
4662
  const noun = await entityClassNoun(graph, isaAsk[1]);
4470
4663
  if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
@@ -4529,6 +4722,73 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4529
4722
  const premises = chain.map(factForStep);
4530
4723
  if (premises.every(Boolean)) return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
4531
4724
  }
4725
+ // LIVE cax-dw PROOF CHASE (PLAN_INFERENCE_TESTING.md INF-B1, §4 stage 3):
4726
+ // every "yes" strategy above missed — check whether X's taught type
4727
+ // (lifted through its FULL ⊑-ancestor closure) is disjointWith the
4728
+ // queried class, via syllogise.mjs's deriveDisjointViolations, LIVE and
4729
+ // READ-ONLY (same discipline as the findIsaChain chase just above:
4730
+ // nothing is written; syllogise()'s materializing batch pass is the
4731
+ // persisting counterpart of this same rule, never on the chat hot path).
4732
+ // A hit here is a PROVABLE "no" — the one shape on this ladder allowed to
4733
+ // answer "no" from absence-of-membership rather than decline; anything
4734
+ // this chase can't connect through a stated disjointness falls through
4735
+ // to the honest miss below, never a guessed "no".
4736
+ const { deriveDisjointViolations, DISJOINT_PREDICATE } = await import("./syllogise.mjs");
4737
+ const disjointRows = rows.filter((f) => f.predicate === DISJOINT_PREDICATE && isTaught(f));
4738
+ if (disjointRows.length) {
4739
+ const disjointEdges = disjointRows.map((f) => [f.subject, f.object]);
4740
+ const violations = deriveDisjointViolations(chainTypeEdges, chainSubClassEdges, disjointEdges, { budget: 10 });
4741
+ for (const subj of subjCandidates) {
4742
+ const v = violations.find((vv) => vv.subject === subj && objVariants.has(vv.object));
4743
+ if (!v) continue;
4744
+ const typeFact = chainTypeRows.find((f) => f.subject === v.subject && f.object === v.viaType);
4745
+ const disjointFact = disjointRows.find((f) => (f.subject === v.viaClass && f.object === v.object)
4746
+ || (f.subject === v.object && f.object === v.viaClass));
4747
+ const parts = [typeFact, disjointFact].filter(Boolean).map(renderFactLine);
4748
+ return { text: `no — ${parts.length ? parts.join("; ") : `${v.viaClass} and ${v.object} are disjoint.`}`, replace: true };
4749
+ }
4750
+ }
4751
+ // LIVE cls-svf1 PROOF CHASE (HANDOVER.md 2026-07-10 item 4,
4752
+ // PLAN_INFERENCE_TESTING.md INF-B2, §4 stage 4): every strategy above
4753
+ // missed — check whether X, having taught-P'd something of a taught type
4754
+ // (lifted through that type's FULL ⊑-ancestor closure), satisfies a
4755
+ // TAUGHT someValuesFrom restriction declared over that SAME (property,
4756
+ // type) pair — the restriction CLASS itself entailed (OWL 2 RL Table 8's
4757
+ // cls-svf1), via syllogise.mjs's deriveSomeValuesFromApplication, LIVE and
4758
+ // READ-ONLY (same discipline as the cax-dw chase just above: nothing is
4759
+ // written; syllogise()'s materializing batch pass is the persisting
4760
+ // counterpart of this same rule). The restriction's own scaffolding
4761
+ // (owl:onProperty/owl:someValuesFrom) and every property/type premise must
4762
+ // all be TAUGHT (never corpus-sourced), same as every other live chase in
4763
+ // this block.
4764
+ const { deriveSomeValuesFromApplication, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE } = await import("./syllogise.mjs");
4765
+ const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
4766
+ const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
4767
+ if (onPropertyRows.length && someValuesFromRows.length) {
4768
+ const someValuesFromOf = new Map(someValuesFromRows.map((f) => [f.subject, f.object]));
4769
+ const restrictionEdges = onPropertyRows
4770
+ .map((f) => ({ restriction: f.subject, property: f.object, target: someValuesFromOf.get(f.subject) }))
4771
+ .filter((r) => r.target);
4772
+ // Every OTHER taught object-property assertion is a candidate premise —
4773
+ // never hard-coded to one verb, mirroring syllogise()'s own generic
4774
+ // propertyEdges scan (RESERVED_PREDICATES, syllogise.mjs) minus the
4775
+ // predicates the other rules on this ladder already own.
4776
+ const svf1Reserved = new Set([SC_PREDICATE, RDF_TYPE_PREDICATE, DISJOINT_PREDICATE, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE, "owl:intersectionOf"]);
4777
+ const propertyRows = rows.filter((f) => isTaught(f) && !svf1Reserved.has(f.predicate));
4778
+ const propertyEdges = propertyRows.map((f) => [f.subject, f.predicate, f.object]);
4779
+ const svf1Derived = deriveSomeValuesFromApplication(propertyEdges, chainTypeEdges, chainSubClassEdges, restrictionEdges, { budget: 10 });
4780
+ for (const subj of subjCandidates) {
4781
+ const hit = svf1Derived.find((d) => d.subject === subj && objVariants.has(d.object));
4782
+ if (!hit) continue;
4783
+ const propFact = propertyRows.find((f) => f.subject === hit.subject && f.predicate === hit.viaProperty && f.object === hit.viaValue);
4784
+ const typeFact = chainTypeRows.find((f) => f.subject === hit.viaValue && f.object === hit.viaType);
4785
+ const parts = [propFact, typeFact].filter(Boolean).map(renderFactLine);
4786
+ return {
4787
+ text: `yes — ${parts.length ? parts.join("; ") : `${hit.subject} ${hit.viaProperty} ${hit.viaValue}, and ${hit.viaValue} is a ${hit.viaType}.`}`,
4788
+ replace: true,
4789
+ };
4790
+ }
4791
+ }
4532
4792
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
4533
4793
  }
4534
4794
 
@@ -4595,6 +4855,39 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4595
4855
  };
4596
4856
  }
4597
4857
 
4858
+ // (a2b-iii) HAS-A-METHOD yes/no — "does/did <N1> have a/an <N2> method"
4859
+ // (HANDOVER.md 2026-07-10 item 9): see HAS_METHOD_YESNO_RE's own docblock
4860
+ // for the full design, including the confirmed pre-existing ask.mjs
4861
+ // structural collision when a real code graph is loaded. A hit answers
4862
+ // "yes"; no matching fact DECLINES (null) — never a guessed "no" (this
4863
+ // reader follows IS_ADJECTIVE_YESNO_RE/GENERAL_VERB_YESNO_RE's OWA
4864
+ // discipline below, not OWNS_YESNO_RE's closed-world "no" just above).
4865
+ const hasMethodYN = qHedge.match(HAS_METHOD_YESNO_RE);
4866
+ if (hasMethodYN) {
4867
+ const [, subjRaw, capRaw] = hasMethodYN;
4868
+ const subjVariants = factTermVariants(normFactTerm, subjRaw.trim());
4869
+ const objVariants = factTermVariants(normFactTerm, `${capRaw.trim()} method`);
4870
+ const hit = rows
4871
+ .filter((f) => f.predicate === HAS_A_PREDICATE && subjVariants.has(f.subject) && objVariants.has(f.object))
4872
+ .sort(byTrust)[0];
4873
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
4874
+ return null; // no remembered fact — honest decline, never a guessed "no"
4875
+ }
4876
+
4877
+ // (a2b-iv) HAS-A-METHOD open list — "what methods does <N1> have"
4878
+ // (HANDOVER.md 2026-07-10 item 9): every taught mgx:hasA fact for <N1>
4879
+ // whose object is a "<word> method" phrase. An honest empty (null, never a
4880
+ // guessed method name) when nothing was taught for this subject.
4881
+ const hasMethodOpen = qHedge.match(HAS_METHOD_OPEN_RE);
4882
+ if (hasMethodOpen) {
4883
+ const subjVariants = factTermVariants(normFactTerm, hasMethodOpen[1].trim());
4884
+ const hits = rows
4885
+ .filter((f) => f.predicate === HAS_A_PREDICATE && subjVariants.has(f.subject) && / method$/.test(f.object))
4886
+ .sort(byTrust);
4887
+ if (!hits.length) return null;
4888
+ return renderMany(hits);
4889
+ }
4890
+
4598
4891
  // (a2c) PROPERTY yes/no — "is/are/was/were <X> <adjective>": Tier-5 playtest
4599
4892
  // fix, found live — "remember that the logger module is deprecated" taught a
4600
4893
  // real mgx:hasProperty fact, but there was no direct-question reader for it
@@ -4613,6 +4906,30 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4613
4906
  const adjective = isAdj[2].trim().toLowerCase();
4614
4907
  if (subject) {
4615
4908
  const subjVariants = factTermVariants(normFactTerm, subject);
4909
+ // CLASS↔INSTANCE BRIDGE (playtest sprint round 2, 2026-07-10): "is Task
4910
+ // auditable" used to say "I don't know anything about Task yet" even
4911
+ // with "every Record is auditable" taught and Task inheriting Record in
4912
+ // the code graph — this property-yes/no reader had no inheritance
4913
+ // bridging at all, unlike isaAsk's own CLASS↔INSTANCE BRIDGE just above
4914
+ // (chat.mjs's `inheritsChain`). Same bridge, same discipline: when the
4915
+ // subject resolves to a real graph entity, its superclass LABELS are
4916
+ // ADDITIONAL subject candidates, so a taught property on an ancestor
4917
+ // class is found too — never a guess, still just a direct fact lookup,
4918
+ // now over a wider (but still fact-backed) candidate set.
4919
+ const bridgeSubjects = new Map(); // fact-term variant → superclass label, as spelled in the graph
4920
+ let bridgeEnt = null;
4921
+ if (graph) {
4922
+ const ent = await resolveEntity(graph, subject);
4923
+ bridgeEnt = ent;
4924
+ if (ent) {
4925
+ for (const sup of inheritsChain(graph, ent.id)) {
4926
+ for (const v of factTermVariants(normFactTerm, sup.label)) {
4927
+ if (!subjVariants.has(v) && !bridgeSubjects.has(v)) bridgeSubjects.set(v, sup.label);
4928
+ subjVariants.add(v);
4929
+ }
4930
+ }
4931
+ }
4932
+ }
4616
4933
  const propertyMatch = (f) => (f.predicate === HAS_PROPERTY_PREDICATE && normFactTerm(f.object) === adjective)
4617
4934
  || (f.predicate === `tmct:${adjective}` && f.object === "true");
4618
4935
  // Same head-word fallback as factAnswer's "(c) what do you know about"
@@ -4625,7 +4942,17 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4625
4942
  const wordOverlap = (f) => subjWords.some((w) => new Set(String(f.subject || "").split(/\s+/)).has(w));
4626
4943
  const subjectMatch = (f) => subjVariants.has(f.subject) || (subjWords.length && wordOverlap(f));
4627
4944
  const hit = rows.filter((f) => subjectMatch(f) && propertyMatch(f)).sort(byTrust)[0];
4628
- if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
4945
+ if (hit) {
4946
+ const viaSuper = bridgeSubjects.get(hit.subject);
4947
+ // Named explicitly (never a silent subject swap) — same honesty
4948
+ // discipline isaAsk's own class↔instance bridge follows just above.
4949
+ return {
4950
+ text: viaSuper
4951
+ ? `yes — the code graph says ${bridgeEnt.label} inherits ${viaSuper}, and ${renderFactLine(hit)}`
4952
+ : `yes — ${renderFactLine(hit)}`,
4953
+ replace: true,
4954
+ };
4955
+ }
4629
4956
  // no hit on THIS property — never a guessed "no" (see
4630
4957
  // IS_ADJECTIVE_YESNO_RE's own docblock for why this stays silent on a
4631
4958
  // truth claim, unlike its ownership/general-verb siblings above). But a
@@ -5061,14 +5388,16 @@ const BARE_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
5061
5388
  * term the same way grammar.mjs's T5 does (stripTrailingScopeFiller,
5062
5389
  * ask-vocab.mjs) — the envelope.parsed.object branch above already carries a
5063
5390
  * trimmed term when it came from that template, so the strip here only needs to
5064
- * cover this function's own regex fallback. */
5391
+ * cover this function's own regex fallback. HANDOVER.md 2026-07-10 item 8: a
5392
+ * trailing bare discourse tag ("what is a component THEN") is stripped the same
5393
+ * way (stripTrailingDiscourseTag) before the scope-filler strip. */
5065
5394
  function metaTermOf(query, envelope) {
5066
5395
  if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
5067
5396
  const q = String(query).trim();
5068
5397
  const m = q.match(BARE_WHATIS_RE)
5069
5398
  || q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
5070
5399
  || q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
5071
- return m ? stripTrailingScopeFiller(m[1].trim()) : null;
5400
+ return m ? stripTrailingScopeFiller(stripTrailingDiscourseTag(m[1].trim())) : null;
5072
5401
  }
5073
5402
 
5074
5403
  /** The TEACH-OFFER line for a term that's genuinely unknown everywhere (Tier-5
@@ -5380,6 +5709,14 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5380
5709
  const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
5381
5710
  let term = m?.[1]?.trim();
5382
5711
  if (!term) return null;
5712
+ // HANDOVER.md 2026-07-10 item 10: "describe about X" (a doubled verb — the
5713
+ // "describe" branch of DESCRIBE_WRAPPER_RE never expects a following "about",
5714
+ // unlike its own "tell me about"/"what about" branches, which already consume
5715
+ // theirs inside the regex) leaves a redundant leading "about " glued to the
5716
+ // captured term. Stripped once, here, before any resolution — the other two
5717
+ // branches never leave this residue, so this can only ever help the doubled-
5718
+ // verb case, never change a correctly-captured term.
5719
+ term = term.replace(/^about\s+/i, "");
5383
5720
  if (DESCRIBE_PRONOUN_RE.test(term)) {
5384
5721
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
5385
5722
  term = focus.label;
@@ -5407,6 +5744,84 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5407
5744
  }
5408
5745
  }
5409
5746
 
5747
+ /** DETAILED-SUMMARY / EXPLAIN-IN-DETAIL closed phrasings (HANDOVER.md 2026-07-10 item
5748
+ * 7) — "give me a detailed summary of how the task system works" / "explain in detail
5749
+ * how X works" / "give me a detailed overview of X". PLAYTESTBENCH_1.4.1.md round 3
5750
+ * caught this EXACT phrasing hitting the plain grammar wall with NO inferred goal at
5751
+ * all, even though src/completions/'s extractive multi-sentence pipeline (Stages 0-3,
5752
+ * built and unit-tested the same session) already existed and could answer it when
5753
+ * called directly — it was simply unreachable from any real chat turn.
5754
+ *
5755
+ * Two closed shapes, deliberately narrow (this project's own discipline: curated
5756
+ * closed patterns, never a general "any long question" catch-all):
5757
+ * 1. DETAILED_HOW_WORKS_RE — "...detailed (summary|overview|explanation) of how X
5758
+ * works" / "explain ... in detail how X works" — the "how X works" shape
5759
+ * PLAYTESTBENCH's own probe used. Tried FIRST (its "works" anchor is strictly
5760
+ * more specific, so it must win over #2 whenever both could parse).
5761
+ * 2. DETAILED_OVERVIEW_RE — "...detailed (overview|summary|explanation) of X" — the
5762
+ * bare-subject sibling, no "how...works" wrapper.
5763
+ * Distinct from DESCRIBE_WRAPPER_RE (a single-answer "one definition" lane, anchored
5764
+ * on "tell me about"/"describe"/"what about") — neither of these two anchors on
5765
+ * "give me"/"explain ... in detail", so there is no overlap to shadow. */
5766
+ const DETAILED_HOW_WORKS_RE =
5767
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:give\s+me\s+a\s+detailed\s+(?:summary|overview|explanation)\s+of\s+how|explain\s+(?:to\s+me\s+)?in\s+detail\s+how)\s+(.+?)\s+works\s*\??$/i;
5768
+
5769
+ const DETAILED_OVERVIEW_RE =
5770
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?give\s+me\s+a\s+detailed\s+(?:overview|summary|explanation)\s+of\s+(.+?)\s*\??$/i;
5771
+
5772
+ /** THE COMPLETIONS RESCUE (HANDOVER.md 2026-07-10 item 7) — wires src/completions/'s
5773
+ * extractive, cited, groundedness-checked multi-sentence pipeline (generateCompletion(),
5774
+ * src/completions/complete.mjs) into live chat dispatch. Tried in runAsk ONLY after
5775
+ * (4d) DESCRIBE-WRAPPER RESCUE (and everything above it) has already declined — the
5776
+ * same "last-resort lane" discipline synonymFactAnswer's and describeWrapperAnswer's
5777
+ * own docblocks each spell out: "describe X" / "tell me about X" phrasings must keep
5778
+ * reaching describeWrapperAnswer's (or the relation force's) single-answer rescue
5779
+ * unmolested; this lane only ever claims a turn shaped as an EXPLICIT request for a
5780
+ * detailed/multi-sentence account (DETAILED_HOW_WORKS_RE / DETAILED_OVERVIEW_RE,
5781
+ * above), a shape neither DESCRIBE_WRAPPER_RE nor vagueTouchTermOf recognizes.
5782
+ *
5783
+ * Honest by construction: generateCompletion() itself declines (returns
5784
+ * `declined:true`, empty text) whenever nothing in the corpus/graph clears its own
5785
+ * pruning bar for the term (PLAN_COMPLETIONS.md §3's honest ceiling) — this lane
5786
+ * passes that decline straight through as null, falling through to the ordinary miss
5787
+ * below. NEVER fabricates. Lazy + failure-tolerated (dynamic import, try/catch) like
5788
+ * every other lane in this file. src/completions/ itself is untouched by this change —
5789
+ * this is the call site only. */
5790
+ async function completionsRescueAnswer(query, { memoryDir, graph }) {
5791
+ if (!memoryDir) return null; // no repo/memory to search — honest decline
5792
+ // Deliberately NOT applyPreambleFrames here (unlike describeWrapperAnswer just
5793
+ // above) — found live while wiring this lane: its own SHOW_GIVE_ME_RE frame turns
5794
+ // ANY "give me (the)? X" into "describe X" before this lane would ever see it,
5795
+ // which is exactly right for describeWrapperAnswer (DESCRIBE_WRAPPER_RE has its own
5796
+ // "describe " branch to catch that) but SILENTLY DESTROYS this lane's own
5797
+ // "give me a detailed summary/overview of ..." anchor — "give me a detailed summary
5798
+ // of how the Widget works" became "describe a detailed summary of how the Widget
5799
+ // works" and neither DETAILED_HOW_WORKS_RE nor DETAILED_OVERVIEW_RE could match it
5800
+ // anymore, so the turn silently fell through to the plain grammar wall exactly like
5801
+ // before this lane existed. Matching the RAW trimmed query instead is safe: this
5802
+ // lane's own two regexes already carry their own optional "can/could/would you
5803
+ // (please)?"/"please" politeness prefix (mirroring DESCRIBE_WRAPPER_RE's own), so no
5804
+ // separate normalization pass is needed for the phrasings this lane targets.
5805
+ const q = String(query || "").trim();
5806
+ const m = DETAILED_HOW_WORKS_RE.exec(q) || DETAILED_OVERVIEW_RE.exec(q);
5807
+ let term = m?.[1]?.trim();
5808
+ if (!term) return null;
5809
+ // same bare-article strip describeWrapperAnswer's resolveSymbol-facing branch
5810
+ // already uses just above — pure retrieval/ranking noise, never a real content
5811
+ // signal, and stripping it only ever REMOVES characters, never changes a
5812
+ // correctly-captured term.
5813
+ term = term.replace(/^(?:the|a|an)\s+/i, "").trim();
5814
+ if (!term) return null;
5815
+ try {
5816
+ const { generateCompletion } = await import("./completions/complete.mjs");
5817
+ const result = await generateCompletion(memoryDir, term, { query: term, graph });
5818
+ if (!result || result.declined || !result.text) return null; // honest decline — never fabricate
5819
+ return { text: result.text };
5820
+ } catch {
5821
+ return null; // unresolvable/errored — decline, the ordinary wall stands unchanged
5822
+ }
5823
+ }
5824
+
5410
5825
  /** THE RELATION CONCEPT FORCE — compose the three-band answer (curated relation
5411
5826
  * definition + real example EDGES + pre-validated follow-ups) for a vague touch on a
5412
5827
  * relation/edge kind ("what about imports", "what are the calls", "tell me about
@@ -5869,9 +6284,43 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5869
6284
  const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
5870
6285
  const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
5871
6286
  let bareMetaHit = null;
5872
- if (isConversationalCandidate && memoryDir && (bareWhatisShape || isAdjectiveShape)) {
5873
- bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
5874
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
6287
+ if (isConversationalCandidate && (bareWhatisShape || isAdjectiveShape)) {
6288
+ if (memoryDir) {
6289
+ bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
6290
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
6291
+ // HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
6292
+ // with NO taught fact but a KNOWN curated corpus term ("what is cache", no
6293
+ // article) used to lose this exact same isConversationalCandidate race —
6294
+ // curatedDefinitionAnswer was only ever reached once the article made T5's
6295
+ // structural parse succeed (envelope.parsed non-null), never on the bare
6296
+ // form. Same "only diverts on a REAL hit" discipline as the rest of this
6297
+ // lane — an unknown bare term still falls through to the ordinary
6298
+ // orientation card, exactly as before.
6299
+ if (!bareMetaHit) {
6300
+ const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
6301
+ if (def) bareMetaHit = { text: def.text, replace: true };
6302
+ }
6303
+ }
6304
+ // HANDOVER.md 2026-07-10 item 6 (CHATBENCH g-a2-naming-2: "what is Widget",
6305
+ // no article): a bare "what is X" naming a REAL code-graph entity (Class/
6306
+ // Function/Method/GlobalVariable/Attribute — not a taught fact, not a
6307
+ // curated corpus term) lost this SAME isConversationalCandidate race too.
6308
+ // metaFallbackEntityAnswer (ask.mjs) is the exact fallback the ARTICLED
6309
+ // form's structural parse already reaches once T5 succeeds; tried here so
6310
+ // the bare form gets the byte-identical answer, never a worse one just
6311
+ // because it dropped the article. Deliberately OUTSIDE the `memoryDir`
6312
+ // check above — this is a pure graph lookup, no memory/Facts access
6313
+ // needed, and CHATBENCH's own "turns" replay mode drives runTurn with a
6314
+ // graph but no memoryDir at all, so gating this on memoryDir too would
6315
+ // silently never fire in the one harness this fix specifically targets.
6316
+ if (!bareMetaHit && graph) {
6317
+ const term = metaTermOf(query, envelope);
6318
+ if (term) {
6319
+ const { metaFallbackEntityAnswer } = await import("./ask.mjs");
6320
+ const fallback = metaFallbackEntityAnswer(graph, term);
6321
+ if (fallback) bareMetaHit = { text: fallback.text, replace: true };
6322
+ }
6323
+ }
5875
6324
  }
5876
6325
  if (bareMetaHit) {
5877
6326
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
@@ -6122,6 +6571,26 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6122
6571
  note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
6123
6572
  }
6124
6573
  }
6574
+ // (4e) COMPLETIONS RESCUE (HANDOVER.md 2026-07-10 item 7) — wires src/completions/'s
6575
+ // extractive multi-sentence pipeline in as a genuine last-resort lane, tried ONLY
6576
+ // here, after EVERY lane above (including (4d) DESCRIBE-WRAPPER RESCUE) has already
6577
+ // declined — "describe X"/"tell me about X" must keep reaching describeWrapperAnswer's
6578
+ // (or the relation force's) single-answer rescue unmolested; this lane only fires for
6579
+ // an EXPLICIT "detailed summary/overview of how X works" phrasing
6580
+ // (DETAILED_HOW_WORKS_RE/DETAILED_OVERVIEW_RE), a shape neither DESCRIBE_WRAPPER_RE
6581
+ // nor vagueTouchTermOf recognizes. PLAYTESTBENCH_1.4.1.md round 3's own
6582
+ // architecturally-confirmed gap: this exact phrasing hit the plain grammar wall with
6583
+ // no inferred goal at all, even though the pipeline that could answer it already
6584
+ // existed — it was simply unreachable from any real chat turn.
6585
+ if (miss && recordMiss && via === "composed") {
6586
+ const completed = await completionsRescueAnswer(query, { memoryDir, graph });
6587
+ if (completed) {
6588
+ answer = completed.text; via = "completion"; recordMiss = false;
6589
+ note(trace, "lane: (4e) COMPLETIONS RESCUE — a \"detailed summary/overview of how X works\" phrasing matched, answered via src/completions/'s extractive multi-sentence pipeline (generateCompletion())");
6590
+ note(trace, "source: src/completions/complete.mjs generateCompletion() (broadSearch + groupHits + rankSentences + inferRelations + pruneCompletion + finish())");
6591
+ note(trace, "goal: produce a grounded, cited, multi-sentence account of the subject (not a single fact/definition)");
6592
+ }
6593
+ }
6125
6594
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
6126
6595
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
6127
6596
  // WALL KINDNESS (0.8.2 WS4 (a)): when the PREVIOUS turn's answer was already a