@polycode-projects/the-mechanical-code-talker 1.5.4 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +123 -14
  2. package/ROADMAP.md +233 -1392
  3. package/bin/tmct.mjs +479 -98
  4. package/corpus/README.md +3 -0
  5. package/corpus/generated/README.md +43 -0
  6. package/corpus/generated/ace-surface-variants.jsonl +17 -0
  7. package/corpus/generated/manifest.json +9 -0
  8. package/corpus/tier2/generate.mjs +14668 -0
  9. package/corpus/tier2/human-examples-large.jsonl +1928 -0
  10. package/corpus/tier2/human-examples-medium.jsonl +356 -0
  11. package/corpus/tier2/human-examples.jsonl +120 -0
  12. package/corpus/tier2/human-large.jsonl +12001 -0
  13. package/corpus/tier2/human-medium.jsonl +944 -0
  14. package/corpus/tier2/human.jsonl +664 -0
  15. package/corpus/tier2/manifest.json +42 -0
  16. package/package.json +14 -8
  17. package/src/answer-variants.json +47 -0
  18. package/src/answer-variants.mjs +67 -0
  19. package/src/ask-browser-entry.mjs +34 -0
  20. package/src/ask-browser.bundle.js +5095 -0
  21. package/src/ask-vocab.mjs +93 -8
  22. package/src/ask.mjs +451 -49
  23. package/src/chat.mjs +1391 -141
  24. package/src/cli-args.mjs +164 -0
  25. package/src/codegraph.mjs +170 -32
  26. package/src/completions/graph-adapter.mjs +118 -0
  27. package/src/extensions.mjs +100 -19
  28. package/src/grammar/ace.mjs +85 -3
  29. package/src/grammar/lexicon-core.json +9531 -63
  30. package/src/grammar/lexicon.mjs +58 -8
  31. package/src/graph-merge.mjs +114 -0
  32. package/src/index.mjs +14 -0
  33. package/src/init.mjs +40 -14
  34. package/src/interpret/normalize.mjs +88 -3
  35. package/src/interpret/strategies/grammar.mjs +10 -0
  36. package/src/interpret/strategies/keywords.mjs +20 -0
  37. package/src/interpret/strategies/noise-strip.mjs +73 -4
  38. package/src/memory/core.mjs +466 -8
  39. package/src/router/goal-reasoner.mjs +41 -7
  40. package/src/router/guardrail.mjs +37 -7
  41. package/src/router/resolver.mjs +50 -4
  42. package/src/sessions.mjs +5 -1
  43. package/src/source.mjs +54 -1
  44. package/src/syllogise.mjs +398 -27
  45. package/src/toml-config.mjs +13 -4
  46. package/src/viz.mjs +541 -0
package/src/chat.mjs CHANGED
@@ -37,7 +37,7 @@
37
37
  // load-bearing — see its docblock), telemetry, and the close. runChat is the
38
38
  // readline shell over it; src/tui/app.mjs is the Ink shell over the same sink.
39
39
 
40
- import { join, dirname } from "node:path";
40
+ import { join, dirname, resolve } from "node:path";
41
41
  import { createWriteStream } from "node:fs";
42
42
  import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
43
43
  import { tmpdir } from "node:os";
@@ -45,21 +45,23 @@ import { createInterface } from "node:readline/promises";
45
45
  import { spawnSync } from "node:child_process";
46
46
  import { dispatchTool } from "./server.mjs";
47
47
  import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
48
+ import { resolveRuntimeConfig } from "./cli-args.mjs";
48
49
  import { parseEntities, edgesOfKind, renderAuthorCard, renderAuthorTouches, renderCommitAuthor } from "./codegraph.mjs";
49
50
  import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
50
51
  import { uuidv7 } from "./uuid.mjs";
51
52
  import { createTelemetry } from "./telemetry.mjs";
52
53
  import * as defaultSource from "./source.mjs";
53
54
  import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
54
- import { resolveExtensions, seedActiveCorpusEntries, mergedLexiconExtra } from "./extensions.mjs";
55
+ import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
55
56
  import { rankByBiasThenTrust } from "./memory/bias.mjs";
56
57
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
57
58
  import {
58
59
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
59
60
  stripTrailingScopeFiller, stripTrailingDiscourseTag,
60
61
  } from "./ask-vocab.mjs";
61
- import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, escapeRegex } from "./interpret/normalize.mjs";
62
+ import { COUNTERFACTUAL_RE, correctMisspellings, applyPreambleFrames, normalizeQuery, escapeRegex, kindNounAnaphoraHint } from "./interpret/normalize.mjs";
62
63
  import { fuzzyMatchInSet, fuzzyBound } from "./interpret/fuzzy.mjs";
64
+ import { pickPhrase } from "./answer-variants.mjs";
63
65
 
64
66
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
65
67
  // here because callers/tests still import it from chat.mjs.
@@ -785,6 +787,38 @@ const AI_IDENTITY_PHRASES = [
785
787
  /^do you use ai\??$/i, /^what language model are you( using)?\??$/i,
786
788
  /^am i (talking|speaking|chatting) (to|with) a (real )?(person|human|bot|ai)\??$/i,
787
789
  ];
790
+
791
+ /** Split raw turn text into candidate single-sentence clauses on sentence-
792
+ * ending punctuation ("?"/"!"/"."), trimmed, empties dropped. BENCHMARK_
793
+ * CONVERSATION_1.7.0.md routed backlog C4: AI_IDENTITY_PHRASES' own entries
794
+ * are anchored (^...$) against a SINGLE clause, so a two-sentence turn like
795
+ * "are you an AI? like chatgpt?" could never match the whole raw string even
796
+ * though its first clause alone is an exact "are you an AI" hit. Used ONLY
797
+ * by aiIdentityMatch below — every OTHER closed-set match in this file stays
798
+ * whole-string, on purpose (this is deliberately narrow to the one family
799
+ * that's shown up broken this way, not a general multi-clause rewrite of
800
+ * isConversational's whole match cascade). */
801
+ function splitClauses(text) {
802
+ return String(text).split(/[?!.]+\s*/).map((c) => c.trim()).filter(Boolean);
803
+ }
804
+
805
+ /** AI_IDENTITY_PHRASES matched against the whole raw turn OR, failing that,
806
+ * against any one of its sentence-split clauses (splitClauses, above) — so
807
+ * "are you an AI? like chatgpt?" matches on its first clause alone, the same
808
+ * way a single-sentence "are you an AI" already did. The whole-string check
809
+ * runs first (the common case, no split needed); the clause fallback only
810
+ * ever ADDS a match a single-clause turn already had no chance to win to,
811
+ * since every phrase is itself a complete anchored sentence — a genuinely
812
+ * unrelated longer sentence that merely CONTAINS identity-phrase-shaped
813
+ * words won't split into a clause that's ONLY those words, so this can't
814
+ * false-positive on it (e.g. "well are you an AI expert on this" has no
815
+ * clause boundary carving out "are you an AI" alone). */
816
+ function aiIdentityMatch(raw) {
817
+ const text = String(raw);
818
+ if (AI_IDENTITY_PHRASES.some((re) => re.test(text))) return true;
819
+ return splitClauses(text).some((clause) => AI_IDENTITY_PHRASES.some((re) => re.test(clause)));
820
+ }
821
+
788
822
  /** "Do you have feelings/emotions" — HANDOVER.md 2026-07-10 item 10 (small-talk
789
823
  * persona finding): with no closed-set match, this used to misfire into a
790
824
  * literal module-name lookup for the bare noun ("no module matching 'feelings'
@@ -830,7 +864,7 @@ export function isConversational(query) {
830
864
  if (GREET.has(q) || THANKS.has(q) || OK_ACK.has(q)) return true;
831
865
  if (CAPABILITY_PHRASES.some((re) => re.test(raw))) return true;
832
866
  if (IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
833
- if (AI_IDENTITY_PHRASES.some((re) => re.test(raw))) return true;
867
+ if (aiIdentityMatch(raw)) return true;
834
868
  const codeish = looksCodeish(raw, q);
835
869
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
836
870
  }
@@ -908,7 +942,7 @@ const GREET = new Set([
908
942
  // US
909
943
  "hey y'all", "howdy there", "hiya there",
910
944
  // formal
911
- "good day", "salutations", "good to meet you", "pleased to meet you",
945
+ "good day", "good day to you", "salutations", "good to meet you", "pleased to meet you",
912
946
  // slang
913
947
  "yo yo", "ayy", "wassup", "sup fam", "heya", "hiya!",
914
948
  // texting abbreviation
@@ -945,7 +979,19 @@ const THANKS = new Set([
945
979
  const BYE = new Set([
946
980
  "bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
947
981
  "peace", "peace out", "im off", "i'm off", "gtg", "gotta go", "catch you later",
948
- "good day to you", "farewell then",
982
+ "farewell then",
983
+ // "good day to you" deliberately does NOT live here (SKILL_BENCHMARK_
984
+ // CONVERSATION.md persona-sweep, 2026-07-11, Priority 2 — severe, killed
985
+ // the whole session): it's the formal-register GREETING §2.2 itself names
986
+ // ("good day" — down to slang), not a farewell. It used to sit in this set
987
+ // and won the race against GREET (foldedBye is checked first in
988
+ // conversationalTurn), so a plain formal "good day to you" silently ended
989
+ // the session — every turn piped after it was dropped with no log entry, a
990
+ // worse outcome than any wall. Moved to GREET (above) instead; a genuine
991
+ // dismissive sign-off ("farewell then", bare "farewell") stays here
992
+ // unchanged — this is a narrowing of an over-broad match, not a new
993
+ // farewell phrasing (§5 "farewells stay out of scope" governs ADDING
994
+ // coverage, not fixing a phrase that was on the wrong list).
949
995
  ]);
950
996
  /** Elaboration asks → RE-RENDER the last answer verbosely (traversal + matches). */
951
997
  const WHY = new Set([
@@ -1041,6 +1087,25 @@ function foldedBye(clause) {
1041
1087
  const folded = clause.match(REPEATED_WORD_RE);
1042
1088
  return !!(folded && closedOrCollapsed(folded[1], BYE, BYE_COLLAPSED));
1043
1089
  }
1090
+ /** Closing-filler clauses — the CONTENT half of a farewell/thanks sentence
1091
+ * ("thanks, that's everything for now") that isn't itself gratitude or bye
1092
+ * wording, but is unambiguous session-closing small talk, not a real
1093
+ * question. farewellOrThanksSignal's ≤3-word gate below exists to keep a
1094
+ * genuine question ("cheers, what does X do") out of this lane; these
1095
+ * clauses need their own exemption because they naturally run 4-5 words and
1096
+ * the gate would otherwise reject them. Found live (2026-07-11 playtest
1097
+ * sprint round 1): "thanks, that's everything for now" hit the raw grammar
1098
+ * wall as a session's LAST turn, even though a frozen single-turn regression
1099
+ * test for the same phrase already existed — that test only pinned "doesn't
1100
+ * match the wall text", which the isolated-turn fallthrough miss happened
1101
+ * not to, while the SAME routing gap produced the literal wall once real
1102
+ * session history was involved. The gap was the ≤3-word gate, not the
1103
+ * closed set — this clause list is the fix, not a new one-off phrase pin. */
1104
+ const CLOSING_FILLER_CLAUSES = new Set([
1105
+ "that's everything for now", "that's all for now",
1106
+ "that's everything i needed", "that's all i needed",
1107
+ "that's everything for today", "that's all for today",
1108
+ ]);
1044
1109
  function farewellOrThanksSignal(raw, q) {
1045
1110
  const words = q.split(/\s+/).filter(Boolean);
1046
1111
  if (words.length < 2 || words.length > 8 || looksCodeish(raw, q)) return null;
@@ -1055,7 +1120,8 @@ function farewellOrThanksSignal(raw, q) {
1055
1120
  // specific (genuine gratitude words rarely lead into an unrelated question),
1056
1121
  // but still gated below: a THANKS-hit only counts when every OTHER clause is
1057
1122
  // 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
1123
+ // isConversational's own catch-all uses — OR a curated closing-filler clause
1124
+ // (CLOSING_FILLER_CLAUSES, above) — so "cheers, what does X do" is still left
1059
1125
  // to the existing THANKS_PREAMBLE_RE lane, never grabbed here.
1060
1126
  let thanksClauseIdx = -1;
1061
1127
  let byeHit = false;
@@ -1069,6 +1135,7 @@ function farewellOrThanksSignal(raw, q) {
1069
1135
  }
1070
1136
  if (byeHit) return "bye";
1071
1137
  const thanksHit = thanksClauseIdx >= 0 && clauses.every((c, i) => i === thanksClauseIdx
1138
+ || CLOSING_FILLER_CLAUSES.has(c)
1072
1139
  || (c.split(/\s+/).filter(Boolean).length <= 3 && !looksCodeish(c, c.toLowerCase())));
1073
1140
  return thanksHit ? "thanks" : null;
1074
1141
  }
@@ -1227,7 +1294,7 @@ function conversationalTurn(line, ctx) {
1227
1294
  return mk(t(T_THANKS));
1228
1295
  }
1229
1296
  }
1230
- if (AI_IDENTITY_PHRASES.some((re) => re.test(raw))) {
1297
+ if (aiIdentityMatch(raw)) {
1231
1298
  note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
1232
1299
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
1233
1300
  return mk(t(T_IDENTITY_NOT_LLM));
@@ -1361,9 +1428,11 @@ function orientationText(graph, templates, vocabHint) {
1361
1428
  for (const [cls, sing, plur] of [["Module", "module", "modules"], ["Class", "class", "classes"], ["Function", "function", "functions"]]) {
1362
1429
  const n = by(cls); if (n) parts.push(`${n} ${n === 1 ? sing : plur}`);
1363
1430
  }
1364
- return `This is a tmct code graph — ${(graph.individuals || []).length} entities`
1431
+ const total = (graph.individuals || []).length;
1432
+ const lead = pickPhrase("ask-about-lead", `${total}:${parts.join(",")}`, "Ask about");
1433
+ return `This is a tmct code graph — ${total} entities`
1365
1434
  + `${parts.length ? ` (${parts.join(", ")})` : ""}. `
1366
- + 'Ask about imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". '
1435
+ + `${lead} imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". `
1367
1436
  + "/stats for the full overview, /help for commands.";
1368
1437
  }
1369
1438
 
@@ -1395,8 +1464,9 @@ function moduleOverviewText(graph, ind) {
1395
1464
  ? `covered by ${testedBy.length} test module${testedBy.length === 1 ? "" : "s"}`
1396
1465
  : "no recorded tests");
1397
1466
  const cls = (ind.class || "entity").toLowerCase();
1467
+ const pointer = pickPhrase("full-breakdown", ind.id, "for the full breakdown");
1398
1468
  return `${ind.label} is a ${cls} — ${parts.join("; ")}. `
1399
- + `/describe ${ind.label} for the full breakdown.`;
1469
+ + `/describe ${ind.label} ${pointer}.`;
1400
1470
  }
1401
1471
 
1402
1472
  // #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
@@ -1474,11 +1544,69 @@ const WALL_MISS_ANYWHERE_RE = /couldn't parse this as a graph question\. Try:/;
1474
1544
  // automatically inherits the correct "teach/remember a new fact" goal line for
1475
1545
  // free (it flows through the SAME teach-lane goal revision, chat.mjs's runTurn
1476
1546
  // cascade — no extra wiring needed for this phrasing).
1477
- 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;
1547
+ // BENCHMARK_CONVERSATION_1.7.0.md routed backlog C1 ("please learn this: John
1548
+ // is a man" / "please learn also: a man is having two legs"): "learn" joins
1549
+ // the verb list, and a new optional filler slot ("this"/"that"/"also")
1550
+ // tolerates a word between the verb and the colon/comma lead-in — the verb
1551
+ // list alone never covered that shape, so "remember this: X" (not just
1552
+ // "learn this: X") is now also recognized, matching the docblock above's own
1553
+ // worked "remember that X" case (the pre-existing `(?:that\s+)?` after the
1554
+ // lead-in punctuation still covers a lead-in-less "remember that X").
1555
+ 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|learn)\b(?:\s+(?:this|that|also))?[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
1478
1556
  const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
1479
1557
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1480
1558
  * ("what is a cache", "is a module a component"), never a teach declarative. */
1481
1559
  const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
1560
+ /** A bare wh-word token, tested one word at a time against `hasMidSentenceInterrogative`'s
1561
+ * own tokenization below — never re-anchored, so it matches at ANY word position. */
1562
+ const MID_SENTENCE_WH_RE = /^(?:which|who|what|where|when|why|how)$/i;
1563
+ /** PLAN_CONVERSATION.md Finding 4 fix: QUESTION_LEAD_RE (just above) is anchored
1564
+ * to the FIRST word, so a wh-word appearing LATER in the sentence ("it uses
1565
+ * WHICH controller as its base") slips past every teachLane guard that
1566
+ * reuses it — including TEACH_PRONOUN_RE's own check, which has no
1567
+ * interrogative guard at all. That let a mid-sentence question either mint a
1568
+ * GARBAGE fact (a bare sentence with a real subject: "TaskController uses
1569
+ * which controller as its base" got stored verbatim) or produce a confusing
1570
+ * pronoun-specific refusal that named the wrong problem (a pronoun subject:
1571
+ * "it uses which controller as its base" — "it" was never the real issue,
1572
+ * the mid-sentence question was).
1573
+ *
1574
+ * Detects a genuine mid-sentence INTERROGATIVE use of a wh-word — never the
1575
+ * first word, QUESTION_LEAD_RE's own anchored check already owns that case —
1576
+ * via wink's POS tagger (the SAME optional adapter subjectIsNounOrPropn/
1577
+ * objectReadsAsNonNoun below already use, ask-nlp.mjs's nlpAdapter):
1578
+ * whichever wh-word tokens appear after the first word, check whether the
1579
+ * token immediately BEFORE each is tagged VERB or AUX. A wh-in-situ
1580
+ * interrogative object/adjunct ("uses WHICH controller", "is used by WHICH
1581
+ * module") always immediately follows the verb it's an argument of; a
1582
+ * RELATIVE pronoun introducing a restrictive clause ("the handler WHICH
1583
+ * processes requests", "a grandparent WHO is male" — see
1584
+ * test/chat-taught-relations.test.mjs's own "a grandfather is a grandparent
1585
+ * who is male" teach, confirmed unaffected) always immediately follows the
1586
+ * NOUN it modifies instead. Checking the preceding tag is a real, if
1587
+ * imperfect, way to tell the two apart — not meant to be perfect (a first
1588
+ * increment), just enough to stop teachLane storing or refusing on the wrong
1589
+ * grounds. No wink installed, or any tagging surprise, degrades to false —
1590
+ * no signal, never a false positive from a missing adapter — matching
1591
+ * subjectIsNounOrPropn/objectReadsAsNonNoun's own discipline exactly. */
1592
+ async function hasMidSentenceInterrogative(text) {
1593
+ const words = String(text || "").trim().split(/\s+/).filter(Boolean);
1594
+ if (words.length < 2) return false;
1595
+ const whIdx = [];
1596
+ for (let i = 1; i < words.length; i += 1) {
1597
+ if (MID_SENTENCE_WH_RE.test(words[i].replace(/^[.,!?;:'"]+|[.,!?;:'"]+$/g, ""))) whIdx.push(i);
1598
+ }
1599
+ if (!whIdx.length) return false;
1600
+ try {
1601
+ const { nlpAdapter } = await import("./ask-nlp.mjs");
1602
+ const adapter = nlpAdapter();
1603
+ if (!adapter) return false; // no wink — no signal, never a false positive
1604
+ const tags = adapter.posTags(words);
1605
+ return whIdx.some((i) => tags[i - 1] === "VERB" || tags[i - 1] === "AUX");
1606
+ } catch {
1607
+ return false;
1608
+ }
1609
+ }
1482
1610
 
1483
1611
  // The teach lane's fact predicates (rendered via FACT_PREDICATE_PHRASES).
1484
1612
  const OWNED_BY_PREDICATE = "mgx:ownedBy";
@@ -1955,6 +2083,34 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1955
2083
  * or no genuine universal quantifier) falls through as a plain null,
1956
2084
  * letting the ordinary teachLane cascade (property teach, then the generic
1957
2085
  * honest-miss text) continue unaffected. */
2086
+ /** PLAN_CONVERSATION.md Finding 1 fix: before minting the object as a new
2087
+ * CLASS, ask wink's POS tagger (the SAME optional adapter subjectIsNounOrPropn,
2088
+ * above, already uses for this kind of disambiguation, via ask-nlp.mjs's
2089
+ * posTags) whether the word reads as anything OTHER than a NOUN/PROPN.
2090
+ * "every Record is persisted" tags "persisted" VERB (a past participle used
2091
+ * adjectivally); "every cache is bespoke" tags "bespoke" ADJ — both read as a
2092
+ * property claim about one word, not a brand-new class term, so this
2093
+ * fallback should decline and let the cascade fall through to
2094
+ * unknownAdjectiveFallback (below), which mints the SAME word correctly as a
2095
+ * property instead. A genuinely novel noun ("florble", "zorp") still tags
2096
+ * NOUN under wink's own out-of-vocabulary default (confirmed live), so this
2097
+ * never blocks the pre-existing mint-a-new-class behaviour the
2098
+ * vocabulary-growth feature needs. No wink installed, or any tagging
2099
+ * surprise, degrades to a null tag treated as "no signal" (never a decline)
2100
+ * — matching every other optional-adapter path in this file (ask-nlp.mjs's
2101
+ * own "null on any surprise, never a throw" discipline). */
2102
+ async function objectReadsAsNonNoun(word) {
2103
+ try {
2104
+ const { nlpAdapter } = await import("./ask-nlp.mjs");
2105
+ const adapter = nlpAdapter();
2106
+ if (!adapter) return false;
2107
+ const [tag] = adapter.posTags([String(word || "")]);
2108
+ if (!tag) return false; // no signal — never block the existing mint on a surprise
2109
+ return tag !== "NOUN" && tag !== "PROPN";
2110
+ } catch {
2111
+ return false;
2112
+ }
2113
+ }
1958
2114
  async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
1959
2115
  if (!memoryDir) return null;
1960
2116
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
@@ -1967,6 +2123,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
1967
2123
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
1968
2124
  const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
1969
2125
  if (objectGrounded) return null; // object already known — nothing to mint
2126
+ if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
1970
2127
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
1971
2128
  return teachFact(memoryDir, sessionId, {
1972
2129
  subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
@@ -2140,6 +2297,46 @@ const GENERAL_VERB_EXCLUDE_RE = /^(?:is|are|am|owns|maintains)$/i;
2140
2297
  * stands down entirely rather than risk a positional misread of a longer
2141
2298
  * copula/ownership sentence it was never meant to parse. */
2142
2299
  const GENERAL_VERB_ANYWHERE_EXCLUDE_RE = /\b(?:is|are|am|owns|maintains)\b/i;
2300
+ /** SKILL_BENCHMARK_CONVERSATION.md persona-sweep (2026-07-11), Priority 1 —
2301
+ * confirmed 4x independently across 2 personas: GENERAL_VERB_TEACH_RE's verb
2302
+ * slot is a bare `[a-z]+` with NO check that the captured word is a real
2303
+ * verb at all — a closed-class function word (a possessive/personal pronoun,
2304
+ * a preposition, a subordinating conjunction) sitting in that position reads
2305
+ * as an ordinary lemma just as happily as a genuine verb does, so
2306
+ * generalVerbPredicate mints a nonsense mgx:<word> predicate and
2307
+ * thirdPersonSingularSurface's naive -s/-es/-ies fold renders it as a
2308
+ * garbled "confirmation" that LOOKS like a successful teach (worse than a
2309
+ * wall — no error, no nudge). Four live repros, all misreading a closed-
2310
+ * class second token as the verb: "can you review my code for me" (after
2311
+ * MODAL_WRAPPER_RE's own preamble strip removes "can you", verb="my" ->
2312
+ * mgx:my -> "mies"), "impact if i change it??" (verb="if" -> mgx:if ->
2313
+ * "ifs"), "defs in model.mjs" (verb="in" -> mgx:in -> "ins"). A genuine verb
2314
+ * ("mentors", "eats", "owns", "needs", "maintains" — every existing teach
2315
+ * test's verb) is never one of these closed-class words, so this is a pure
2316
+ * narrowing: it can only turn an already-wrong absorb into an honest
2317
+ * decline, never break a real teach. Wink-nlp POS tagging was tried first
2318
+ * and rejected — out of sentence context it tags "my"/"if"/"in" correctly,
2319
+ * but IN context it also mistags the legit "mentors" (test/chat-teachlane-
2320
+ * general-verb.test.mjs's own pinned case) as NOUN, so a POS gate would have
2321
+ * regressed a real teach; a closed list (this project's own stated
2322
+ * preference for chat-layer fixes — templates over general grammar rules)
2323
+ * is both more reliable here and, being closed, can never widen recognition
2324
+ * the way a probabilistic POS heuristic could. */
2325
+ const GENERAL_VERB_NOT_A_VERB_RE = new RegExp(
2326
+ "^(?:"
2327
+ // personal/possessive/demonstrative pronouns + determiners (mirrors, and
2328
+ // extends, GENERAL_VERB_DETERMINER_RE's own closed set — that one gates the
2329
+ // SUBJECT slot, this gates the VERB slot)
2330
+ + "i|me|you|he|him|she|her|it|we|us|they|them|my|your|his|its|our|their|mine|yours|hers|ours|theirs"
2331
+ + "|this|that|these|those|a|an|the|every|each|all|some|any|no|both|either|neither"
2332
+ // prepositions
2333
+ + "|in|on|at|to|from|by|with|for|of|about|into|onto|over|under|near|before|after|during|through"
2334
+ + "|up|down|off|out|above|below|between|among|against|without|within|along|across|behind|beyond|upon|toward|towards|per"
2335
+ // conjunctions/subordinators
2336
+ + "|and|but|or|if|because|although|though|while|when|since|unless|until|whether|so|nor|than|as"
2337
+ + ")$",
2338
+ "i",
2339
+ );
2143
2340
 
2144
2341
  /** The predicate a general-verb teach payload's VERB maps to. "has"/"have"
2145
2342
  * special-cases onto the EXISTING mgx:hasA predicate (point 2) — the same
@@ -2182,12 +2379,19 @@ async function generalVerbPredicate(verb) {
2182
2379
  * the actual write via the shared teachFact. */
2183
2380
  async function generalVerbTeach(payload) {
2184
2381
  const p = String(payload || "").trim();
2382
+ // A genuine declarative assertion never ends in a question mark — "g day
2383
+ // mate, you alright?" (Priority 1, above) reaches this function with no
2384
+ // leading question-word signal left to catch it (it never matched a
2385
+ // wrapper, and QUESTION_LEAD_RE only checks the FIRST word), but the
2386
+ // trailing "?" is still an unambiguous "this is a question" marker.
2387
+ if (/\?\s*$/.test(p)) return null;
2185
2388
  if (GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(p)) return null; // another frame's territory — stand down
2186
2389
  const m = p.match(GENERAL_VERB_TEACH_RE);
2187
2390
  if (!m) return null;
2188
2391
  const [, subjectRaw, verbRaw, objectRaw] = m;
2189
2392
  const verb = verbRaw.toLowerCase();
2190
2393
  if (GENERAL_VERB_EXCLUDE_RE.test(verb)) return null; // owned by a more specific frame above
2394
+ if (GENERAL_VERB_NOT_A_VERB_RE.test(verb)) return null; // a closed-class word can never be the real verb
2191
2395
  if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) return null; // not a bare-name subject
2192
2396
  const subject = subjectRaw.trim();
2193
2397
  const object = objectRaw.replace(/^an?\s+/i, "").trim();
@@ -2196,6 +2400,31 @@ async function generalVerbTeach(payload) {
2196
2400
  return { subject, predicate, object };
2197
2401
  }
2198
2402
 
2403
+ /** HANDOVER.md 2026-07-10 item 2 — is `word` a genuine NOUN/PROPN, per wink-nlp's
2404
+ * optional POS tagger (ask-nlp.mjs's nlpAdapter, the SAME adapter the closed
2405
+ * structural grammar already leans on)? Used to let ONE narrow bare (unwrapped)
2406
+ * general-verb teach sentence through below: GENERAL_VERB_TEACH_RE's shape
2407
+ * ("<word> <word> <rest>") is too permissive to trust on a bare sentence with no
2408
+ * "remember"/"note" signal at all — "tell me a joke" and "explain the class
2409
+ * hierarchy to me" match the IDENTICAL shape (subject="tell"/"explain", the
2410
+ * imperative verb itself, mistaken for a subject) and must never be silently
2411
+ * reified as bogus mgx:me/mgx:the facts (confirmed live: both are tagged VERB).
2412
+ * A genuine declarative's first word is a NOUN/PROPN instead ("grace mentors
2413
+ * alan", "sam owns TaskController" — confirmed live: both tagged NOUN). No wink
2414
+ * installed degrades to false (never a guess), same as every other optional-
2415
+ * adapter path in this codebase. */
2416
+ async function subjectIsNounOrPropn(word) {
2417
+ try {
2418
+ const { nlpAdapter } = await import("./ask-nlp.mjs");
2419
+ const adapter = nlpAdapter();
2420
+ if (!adapter) return false;
2421
+ const [tag] = adapter.posTags([String(word || "")]);
2422
+ return tag === "NOUN" || tag === "PROPN";
2423
+ } catch {
2424
+ return false;
2425
+ }
2426
+ }
2427
+
2199
2428
  // ---- General verb-to-predicate DIRECT-QUESTION retrieval (item 5, this
2200
2429
  // session's follow-up to the teach mechanism above): "does margo eat ribs" /
2201
2430
  // "did margo eat ribs" / "what does margo eat" against a fact taught via
@@ -2321,8 +2550,29 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2321
2550
  // a parent", which unknownSubjectFallback already stores as
2322
2551
  // father ⊑ parent today (finding 2: "parent" is already a lexicon noun).
2323
2552
  const stripKindOf = (s) => (s == null ? s : s.replace(/\b(is|are|was|were)\s+(?:an?\s+)?(?:kind|type)\s+of\s+/i, "$1 a "));
2324
- const raw = stripKindOf(stripYour(rawInput));
2325
- const wrapped = stripKindOf(stripYour(wrappedInput));
2553
+ // "my <class-noun> <Name> is/are …" (SKILL_BENCHMARK_CONVERSATION.md persona-
2554
+ // sweep, 2026-07-11, Priority 3) — a THIRD natural phrasing of the exact same
2555
+ // "X is a Y" assertion this lane already teaches two other ways ("john is a
2556
+ // man", a bare name; "every cat is an animal", a universal quantifier) — a
2557
+ // possessive intro clause naming an instance by class + given name ("my cat
2558
+ // whiskers", "my dog rex") ahead of the real copula clause. grammar/ace.mjs's
2559
+ // resolveNP only ever fits a 1–2 token noun phrase (its own docblock: "0 or
2560
+ // 3+ tokens: not a fragment NP") — "my cat whiskers" is three content tokens,
2561
+ // so it never reached ANY existing recognizer (ACE, BARE_DECLARATIVE_RE,
2562
+ // TEACH_RE all declined) and hit the plain grammar wall instead of teaching.
2563
+ // Stripping the "my <noun> " lead-in down to the bare <Name> reduces it to
2564
+ // the EXACT shape "john is a man" already teaches correctly — no new storage
2565
+ // path, just one more surface recognized as equivalent to an existing one.
2566
+ // Only a LEADING "my <word> <word> is/are" run is stripped (mirrors
2567
+ // stripYour's own leading-only anchor just above), so this can't misfire on
2568
+ // "my" appearing mid-sentence, and requires a genuine THIRD word before the
2569
+ // copula (never "my cat is fluffy" — a bare possessive property claim, only
2570
+ // two words before "is" — nor "my TaskController is broken", one word),
2571
+ // keeping recognition exactly as closed as the shapes it's equivalent to.
2572
+ const stripPossessiveNamedInstance = (s) =>
2573
+ (s == null ? s : s.replace(/^my\s+[a-z][\w-]*\s+([\w'-]+\s+(?:is|are)\s+.+)$/i, "$1"));
2574
+ const raw = stripKindOf(stripYour(stripPossessiveNamedInstance(rawInput)));
2575
+ const wrapped = stripKindOf(stripYour(stripPossessiveNamedInstance(wrappedInput)));
2326
2576
 
2327
2577
  // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
2328
2578
  // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
@@ -2332,7 +2582,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2332
2582
  // docblock above for why.
2333
2583
  const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
2334
2584
  const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
2335
- if (pronounMatch) {
2585
+ // Finding 4 fix: a pronoun-led sentence that's ALSO a mid-sentence question
2586
+ // ("it uses which controller as its base") isn't a pronoun-classification
2587
+ // problem at all — "it" was never going to be storable either way, so
2588
+ // naming the pronoun as the reason is misleading. Stand down here (no
2589
+ // interrogative guard existed on this frame before) and let the rest of
2590
+ // this function's cascade run: none of the other frames' shapes fit a
2591
+ // pronoun subject with a non-copula verb, so this falls all the way
2592
+ // through to teachLane's own honest `return null` (no wrapper, no `is`/
2593
+ // `are` payload — see the payload-construction block below), which leaves
2594
+ // whatever the structural grammar's own honest miss already said standing,
2595
+ // rather than overwriting it with a wrong-reason refusal.
2596
+ if (pronounMatch && !(await hasMidSentenceInterrogative(pronounSrc))) {
2336
2597
  const pronoun = pronounMatch[1];
2337
2598
  return {
2338
2599
  text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
@@ -2343,11 +2604,24 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2343
2604
  }
2344
2605
 
2345
2606
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
2346
- // form is double-gated: a Capitalized name AND no interrogative lead, so the
2347
- // "who owns <X>" READ question and ordinary prose never land a fact here.
2607
+ // form is double-gated: no interrogative lead, PLUS either side spelling a
2608
+ // Capitalized token — so the "who owns <X>" READ question and ordinary
2609
+ // lowercase prose ("everybody owns a share") never land a fact here.
2610
+ // HANDOVER.md 2026-07-10 item 2 fix: the gate used to check ONLY the owner
2611
+ // name (own[1]) — "sam owns TaskController" WALLED entirely, because "sam"
2612
+ // isn't capitalized, even though "TaskController" (own[2], the owned thing)
2613
+ // is an obviously code-shaped proper name and just as strong a signal that
2614
+ // this isn't ordinary prose. Either side capitalized is now enough.
2348
2615
  const ownSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
2616
+ // Finding 4 fix: computed ONCE and reused by every ownSrc-gated frame below
2617
+ // (own/ownPassive/rel/hasMethod/compose2/filterRule/recursiveRule) — same
2618
+ // source string, so one wink pass suffices; see hasMidSentenceInterrogative's
2619
+ // own docblock (near QUESTION_LEAD_RE) for why this is additive alongside
2620
+ // each existing anchored QUESTION_LEAD_RE check, never a replacement for it.
2621
+ const ownSrcMidQuestion = await hasMidSentenceInterrogative(ownSrc);
2349
2622
  const own = ownSrc.match(OWNS_TEACH_RE);
2350
- if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && (wrapped || /^[A-Z]/.test(own[1]))) {
2623
+ if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
2624
+ && (wrapped || /^[A-Z]/.test(own[1]) || /^[A-Z]/.test(own[2]))) {
2351
2625
  const stored = await teachFact(memoryDir, sessionId, {
2352
2626
  subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1],
2353
2627
  });
@@ -2359,7 +2633,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2359
2633
  // (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
2360
2634
  // a bogus fact here.
2361
2635
  const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
2362
- if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
2636
+ if (ownPassive && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion && (wrapped || /^[A-Z]/.test(ownPassive[2]))) {
2363
2637
  const stored = await teachFact(memoryDir, sessionId, {
2364
2638
  subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
2365
2639
  });
@@ -2375,7 +2649,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2375
2649
  // to part of speech, so a role noun like "father" mints mgx:father the same
2376
2650
  // way a general verb would; an ordinary Fact, no new storage shape.
2377
2651
  const rel = ownSrc.match(RELATION_FACT_TEACH_RE);
2378
- if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2652
+ if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2379
2653
  const stored = await teachFact(memoryDir, sessionId, {
2380
2654
  subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3],
2381
2655
  });
@@ -2395,7 +2669,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2395
2669
  // a leading determiner — "every"/"a"/"an"/"the" — before a "has a … method"
2396
2670
  // claim).
2397
2671
  const hasMethod = ownSrc.match(TEACH_HAS_METHOD_RE);
2398
- if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2672
+ if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2399
2673
  const stored = await teachFact(memoryDir, sessionId, {
2400
2674
  subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`,
2401
2675
  });
@@ -2409,7 +2683,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2409
2683
  // COMPOSE2_RULE_TEACH_RE's own docblock). The query-side hop-counted chase
2410
2684
  // lives in factReadBack's relational-query dispatcher.
2411
2685
  const compose2 = ownSrc.match(COMPOSE2_RULE_TEACH_RE);
2412
- if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2686
+ if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2413
2687
  try {
2414
2688
  const { appendRule, RULE_KIND_COMPOSE2 } = await import("./memory/core.mjs");
2415
2689
  const { id } = await appendRule(memoryDir, {
@@ -2435,7 +2709,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2435
2709
  // The query-side generic base-then-property chase lives in factReadBack's
2436
2710
  // relational-query dispatcher (resolveRelation's own "filter" branch).
2437
2711
  const filterRule = ownSrc.match(FILTER_RULE_TEACH_RE);
2438
- if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2712
+ if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2439
2713
  try {
2440
2714
  const { appendRule, RULE_KIND_FILTER } = await import("./memory/core.mjs");
2441
2715
  const { id } = await appendRule(memoryDir, {
@@ -2464,7 +2738,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2464
2738
  // rule kinds' single-target search) lives in factReadBack's own
2465
2739
  // RECURSIVE_LIST_ASK_RE dispatch, below.
2466
2740
  const recursiveRule = ownSrc.match(RECURSIVE_RULE_TEACH_RE);
2467
- if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2741
+ if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2468
2742
  try {
2469
2743
  const { appendRule, RULE_KIND_RECURSIVE } = await import("./memory/core.mjs");
2470
2744
  const { id } = await appendRule(memoryDir, {
@@ -2493,7 +2767,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2493
2767
  // unknown object falls through to the generic honest-miss cascade at the
2494
2768
  // bottom of this function, same as every other unstorable teach.
2495
2769
  const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
2496
- const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? someSrc.match(SOME_A_FEW_RE) : null;
2770
+ // Finding 4 fix: additive alongside the existing anchored QUESTION_LEAD_RE
2771
+ // check — same discipline as ownSrcMidQuestion above (hasMidSentenceInterrogative's
2772
+ // own docblock, near QUESTION_LEAD_RE, has the full reasoning).
2773
+ const someSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? await hasMidSentenceInterrogative(someSrc) : false;
2774
+ const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) && !someSrcMidQuestion ? someSrc.match(SOME_A_FEW_RE) : null;
2497
2775
  if (someMatch) {
2498
2776
  const quantifier = someMatch[1].toLowerCase();
2499
2777
  const subject = singularizeSurface(someMatch[2]);
@@ -2545,17 +2823,45 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2545
2823
  // miss text at all (the ORIGINAL bug: "remember tony has a hat" never even
2546
2824
  // reached this lane's own honest-miss cascade, landing on the structural
2547
2825
  // grammar's wrong-context wall instead).
2548
- if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped)) {
2826
+ if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped) && !(await hasMidSentenceInterrogative(wrapped))) {
2549
2827
  const gv = await generalVerbTeach(wrapped);
2550
2828
  if (gv) {
2551
2829
  const stored = await teachFact(memoryDir, sessionId, gv);
2552
2830
  if (stored) return stored;
2553
2831
  }
2832
+ } else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))
2833
+ && !(await hasMidSentenceInterrogative(correctMisspellings(raw)))) {
2834
+ // BARE path (HANDOVER.md 2026-07-10 item 2 fix): "grace mentors alan" — no
2835
+ // "remember"/"note" wrapper at all — used to silently reach neither this
2836
+ // frame NOR an honest miss, landing on the raw structural wall instead
2837
+ // (or, at exactly <=3 words with no code-ish token, the UNRELATED
2838
+ // isConversational() orientation card — see subjectIsNounOrPropn's own
2839
+ // docblock for why a plain wrapper-required gate can't safely widen to
2840
+ // bare sentences on shape alone: "tell me a joke" fits the identical SVO
2841
+ // shape and must never be reified). Only a POS-confirmed NOUN/PROPN
2842
+ // subject earns a try here — the same distinction that separates a
2843
+ // genuine declarative from an imperative request. The QUESTION_LEAD_RE
2844
+ // check runs the SAME correctMisspellings() pass ask.mjs's own typo
2845
+ // tolerance already uses (not `raw` itself) — found live: "wich modules
2846
+ // touch model.mjs" (a typo'd "which…" structural question, MISSPELLINGS-
2847
+ // table-corrected everywhere ELSE in this file) POS-tags its uncorrected
2848
+ // "wich" as a bare NOUN (wink's honest fallback for any unrecognized
2849
+ // token, not a real signal), which would otherwise mis-store it as a
2850
+ // fact instead of leaving it for the structural grammar's own typo-
2851
+ // tolerant retry to answer for real.
2852
+ const subjectWord = raw.match(/^([\w'-]+)/)?.[1];
2853
+ if (subjectWord && (await subjectIsNounOrPropn(subjectWord))) {
2854
+ const gv = await generalVerbTeach(raw);
2855
+ if (gv) {
2856
+ const stored = await teachFact(memoryDir, sessionId, gv);
2857
+ if (stored) return stored;
2858
+ }
2859
+ }
2554
2860
  }
2555
2861
 
2556
2862
  let payload = null;
2557
2863
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
2558
- else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
2864
+ else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw) && !(await hasMidSentenceInterrogative(raw))) payload = raw;
2559
2865
  if (!payload) {
2560
2866
  // Tier-5 playtest fix (cycle 3), found live: "remember that every
2561
2867
  // controller needs review" — a QUANTIFIED subject ("every X", declined
@@ -2716,6 +3022,24 @@ const WHAT_KNOW_RE = /^(?:what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?|what
2716
3022
  // "what does the do" is not real input) — a natural stranger-opener that was one
2717
3023
  // token away from already working.
2718
3024
  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))?)$/;
3025
+ /** HANDOVER.md 2026-07-10 item 3 (part 2): a bare "what is in here"/"what's in
3026
+ * here"/"whats in here" — the SAME orientation intent as META_ORIENT_RE's own
3027
+ * "what's in this repo"-shaped members, just phrased with the CONTEXT_WORDS
3028
+ * pronoun "here" instead of a named noun (app/codebase/repo/…). ask.mjs's own
3029
+ * containment grammar parses "here" as a genuine pronoun object and, when a
3030
+ * focus IS standing, resolves it there exactly as intended — this regex is
3031
+ * ONLY ever tried when there is NO focus (see the call site's `!focus?.label`
3032
+ * gate), so that existing resolution path is completely untouched. With
3033
+ * nothing to resolve "here" against, ask.mjs's grammar instead renders the
3034
+ * honest but unhelpful "'here' needs a selected node…" miss — a poor answer
3035
+ * for a genuine first-time stranger who has never selected anything yet.
3036
+ * Tested against the NORMALIZED query (metaLane's call site runs
3037
+ * normalizeQuery first) rather than the raw text, so a preamble-wrapped
3038
+ * opener ("hey, first time trying this out - what is in here?") reaches this
3039
+ * exactly as the BARE "what is in here?" does — same preamble/filler-word
3040
+ * stripping ask.mjs's own grammar already applies before it ever sees the
3041
+ * pronoun. */
3042
+ const NO_FOCUS_WHATS_IN_HERE_RE = /^what(?:'s|s|\s+is)\s+in\s+here\??$/i;
2719
3043
 
2720
3044
  /** A SHORT memory summary (never a fact dump) for the bare "what do you know".
2721
3045
  * This branch only fires when rows.length === 0 — i.e. precisely the case where
@@ -2738,7 +3062,8 @@ async function memorySummary(memoryDir, graph) {
2738
3062
  }
2739
3063
  const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
2740
3064
  const n = rows.length;
2741
- return `I remember ${n} fact${n === 1 ? "" : "s"} across ${preds.size} relation `
3065
+ const span = pickPhrase("facts-across", `${n}:${preds.size}`, "across");
3066
+ return `I remember ${n} fact${n === 1 ? "" : "s"} ${span} ${preds.size} relation `
2742
3067
  + `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
2743
3068
  }
2744
3069
 
@@ -2813,7 +3138,7 @@ async function moduleOrientLane(query, { graph }) {
2813
3138
  return { text: moduleOverviewText(graph, ind), via: "meta" };
2814
3139
  }
2815
3140
 
2816
- async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null }) {
3141
+ async function metaLane(query, { graph, memoryDir, last = null, templates = null, vocabHint = null, focus = null }) {
2817
3142
  const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2818
3143
  if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
2819
3144
  return { text: await memorySummary(memoryDir, graph), via: "meta" };
@@ -2829,6 +3154,22 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
2829
3154
  const text = orientationText(graph, templates, vocabHint);
2830
3155
  return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
2831
3156
  }
3157
+ // HANDOVER.md 2026-07-10 item 3 (part 2): a bare "what is in here" with NO
3158
+ // standing focus — see NO_FOCUS_WHATS_IN_HERE_RE's own docblock. Tested
3159
+ // against normalizeQuery's output (the SAME normalization ask.mjs's own
3160
+ // grammar runs before it ever sees the "here" pronoun), not the raw `q`
3161
+ // above, so a preamble-wrapped opener reaches it identically to the bare
3162
+ // form. Gated on !focus?.label so a real standing focus (where ask.mjs
3163
+ // already resolves "here" against it) is completely unaffected — this only
3164
+ // ADDS a fallback for the true first-turn case, never changes resolution
3165
+ // when a focus exists.
3166
+ if (!focus?.label) {
3167
+ const stripped = normalizeQuery(String(query)).trim().replace(/[?.!]+$/, "").trim();
3168
+ if (NO_FOCUS_WHATS_IN_HERE_RE.test(stripped)) {
3169
+ const text = orientationText(graph, templates, vocabHint);
3170
+ return { text: last?.answer === text ? META_ORIENT_REPEAT_ONELINER : text, via: "meta" };
3171
+ }
3172
+ }
2832
3173
  // Bug E: an arbitrary "what does <term> do" that META_ORIENT_RE's closed noun
2833
3174
  // list didn't claim — try the module-grain overview before falling through to
2834
3175
  // the author-sha check below (disjoint triggers; order doesn't matter, but
@@ -2853,7 +3194,12 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
2853
3194
  // an unknown name renders null here and falls through to the ordinary honest miss
2854
3195
  // (never a guess, never a hijacked graph query).
2855
3196
  const AUTHOR_NAME_SRC = "([A-Za-z][\\w'.-]*(?:\\s+[A-Za-z][\\w'.-]*){0,3})";
2856
- const AUTHOR_WHO_IS_RE = new RegExp(`^who\\s+is\\s+${AUTHOR_NAME_SRC}$`, "i");
3197
+ // "was" joins "is" (2026-07-11 playtest find): "who was grace hopper" is the same
3198
+ // identity-card ask as "who is grace hopper", just past-tense phrasing — the way a
3199
+ // curious user actually asks about a person, code author or not. Previously only
3200
+ // present tense matched, so "who was <name>" fell all the way to the plain
3201
+ // grammar wall even for a name IN the author index.
3202
+ const AUTHOR_WHO_IS_RE = new RegExp(`^who\\s+(?:is|was)\\s+${AUTHOR_NAME_SRC}$`, "i");
2857
3203
  const AUTHOR_TOUCHED_RE = new RegExp(
2858
3204
  `^what\\s+(?:did|has)\\s+${AUTHOR_NAME_SRC}\\s+(?:touch(?:ed)?|chang(?:e|ed)|work(?:ed)?\\s+on|commit(?:ted)?)$`, "i");
2859
3205
  // The sha authorship forms — the interpret layer no longer rewrites these (WS2 guard).
@@ -2978,11 +3324,20 @@ function nudgeName(captured, focus) {
2978
3324
  * for the opinion gate: it must fire BEFORE the short-miss's "is a <thing> a
2979
3325
  * <kind>" membership hint would (the caller runs this whole step before the
2980
3326
  * short-miss rewrite). */
2981
- function nudgeAnswer(query, focus) {
3327
+ function nudgeAnswer(query, focus, vocabHint = null) {
2982
3328
  const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
2983
3329
  if (PERSONAL_ASSISTANT_NUDGE_RE.test(q)) {
3330
+ // "what is a dog" used to be hardcoded here regardless of session state — a lie
3331
+ // in any UNSEEDED session (no `tmct init`/corpus load ever ran), the exact
3332
+ // "offered example that itself fails" bug class the "vocab-hint is never a lie"
3333
+ // discipline (test/chat-ux.test.mjs) already fixed on the other 5 vocabulary-hint
3334
+ // surfaces (banner, greeting, capability orientation, meta/self, memory summary)
3335
+ // — this out-of-domain nudge was simply never brought into that same discipline.
3336
+ // vocabHint (threaded from runAsk/runTurn's own hasSeededVocabulary check) is
3337
+ // ALREADY the correct session-gated clause: "what is a dog" when seeded, `tmct
3338
+ // init` otherwise — reused verbatim instead of a second, ungated copy.
2984
3339
  return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
2985
- + 'Ask me about code structure ("which modules import <name>") or try "what is a cache".';
3340
+ + `Ask me about code structure ("which modules import <name>"). ${vocabHint || 'Run `tmct init` to seed a starter vocabulary.'}`;
2986
3341
  }
2987
3342
  if (OPINION_NUDGE_RE.test(q)) {
2988
3343
  const name = focus?.label || "<name>";
@@ -3019,7 +3374,12 @@ function nudgeAnswer(query, focus) {
3019
3374
  const name = focus?.label;
3020
3375
  return "I only name the single top (or bottom) match for a metric — no runner-up ranking, no comparing against a number. "
3021
3376
  + (name
3022
- ? `Ask about a specific module/class/function directly to compare it with ${name} (e.g. "how many imports does <name> have").`
3377
+ // "how many imports does <name> have" used to sit here but never parses — the
3378
+ // count grammar (ask.mjs's parseAggregate) requires a known entity-kind noun
3379
+ // ("modules", "classes", …) right after "how many", and "imports" isn't one; a
3380
+ // relation noun there is always an honest miss, for any <name>. "how many
3381
+ // modules does <name> import" is the real working per-entity count shape.
3382
+ ? `Ask about a specific module/class/function directly to compare it with ${name} (e.g. "how many modules does <name> import").`
3023
3383
  : `Ask a specific ranking directly, e.g. "which module has the most imports".`);
3024
3384
  }
3025
3385
  // A bare STACCATO PRONOUN continuation ("also that one?", "and it") with NO
@@ -3169,12 +3529,6 @@ export function gitToplevel(cwd = process.cwd()) {
3169
3529
  return null;
3170
3530
  }
3171
3531
 
3172
- /** Mirror bin/tmct.mjs's configFor: an explicit repo pins the artifact path; no
3173
- * repo falls back to the cwd/env-derived default. */
3174
- function configFor(repoPath) {
3175
- return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
3176
- }
3177
-
3178
3532
  /** Resolve a free-text term to a single graph entity via the ask engine's own
3179
3533
  * tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
3180
3534
  * Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
@@ -3212,9 +3566,11 @@ export async function helpText() {
3212
3566
  let shapes;
3213
3567
  try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
3214
3568
  catch {
3215
- shapes = '"which <functions|classes|modules> <import|call|use|test|touch> <name>", ' +
3569
+ // "touch" dropped from this cross-product for the same reason rephraseHint() drops it
3570
+ // (ask.mjs) — Module/Function/Class is never the subject of a touch edge, only Commit.
3571
+ shapes = '"which <functions|classes|modules> <import|call|use|test> <name>", ' +
3216
3572
  '"what does <name> <import|export>", "what uses <name>", "where is <name> defined", ' +
3217
- '"when did <name> change"';
3573
+ '"when did <name> change", "which commits touched <name>"';
3218
3574
  }
3219
3575
  return [
3220
3576
  "commands:", ...lines, "",
@@ -3549,6 +3905,21 @@ function factTermVariants(normFactTerm, term) {
3549
3905
  return v;
3550
3906
  }
3551
3907
 
3908
+ /** Try `prove(subj, obj)` over every (subject variant × object variant)
3909
+ * combination, returning the first truthy witness or null — the small
3910
+ * shared search the two cardinality readers below both need (a taught
3911
+ * restriction's subject/onClass are singular, but a queried term may be
3912
+ * spelled slightly differently, e.g. pluralized). */
3913
+ function findAcrossVariants(subjVariants, objVariants, prove) {
3914
+ for (const subj of subjVariants) {
3915
+ for (const obj of objVariants) {
3916
+ const w = prove(subj, obj);
3917
+ if (w) return w;
3918
+ }
3919
+ }
3920
+ return null;
3921
+ }
3922
+
3552
3923
  /** GENERIC "kind" nouns a taught subject's head word is often built from
3553
3924
  * ("logger MODULE", "task CONTROLLER") — excluded from the head-word
3554
3925
  * overlap fallback both KNOW_ABOUT_RE's "what do you know about X" listing
@@ -3742,6 +4113,21 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
3742
4113
  * doesn't parse; checked against the isa-family fact predicates only. */
3743
4114
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
3744
4115
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
4116
+
4117
+ // Live-caught 2026-07-11 (follow-up to the "what is a kind of X" ambiguousParse
4118
+ // fix, commit 5c858bf): RELATION_FACT_YESNO_RE/RELATION_WHO_ASK_RE both capture a
4119
+ // middle "role" word and treat it as an arbitrary user-taught relation/rule NAME
4120
+ // ("is X the father of Y", "who is the capital of Y") — but "kind"/"sort"/"type"/
4121
+ // "subclass"/"superclass" are this file's OWN vocabulary for the ISA/inherits
4122
+ // relation, never a name a user could have taught a relation under. Left
4123
+ // unexcluded, "what is a kind of animal" (once envelope/parse issues that used to
4124
+ // mask this were fixed) reached RELATION_WHO_ASK_RE first and produced a false "I
4125
+ // don't know a relation or rule called 'kind' yet" — inherits IS known, there's
4126
+ // just no fact making anything a kind of that particular object (a case (b5)
4127
+ // above already handles correctly, or ELSE whatever answer already stands —
4128
+ // a code-graph-specific miss, a relation-force glossary explanation — should be
4129
+ // left alone, never overridden by this generic reader).
4130
+ const ISA_IDIOM_ROLE_WORDS = new Set(["kind", "sort", "type", "subclass", "superclass"]);
3745
4131
  /** "so john is a man now right?" / "john is a man, right?" — a DECLARATIVE
3746
4132
  * statement wrapped in a confirmation-check tag ("now right?"/"right?"/
3747
4133
  * "correct?"), found live (playtest sprint round 1, 2026-07-10) after a
@@ -3768,6 +4154,55 @@ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+i
3768
4154
  /** How many facts a single answer lists before the remainder is paged with "more". */
3769
4155
  const FACT_ANSWER_CAP = 32;
3770
4156
 
4157
+ /** Finding 5 (PLAN_CONVERSATION.md) — four sibling readers closing the gap left
4158
+ * by ISA_ASK_RE's own family: forward yes/no and reverse-by-object shapes for
4159
+ * `mgx:capableOf`, `mgx:hasA`, and the ISA-family predicates. None of these
4160
+ * four leads ("can"/"could", "what can … do", "what has", "what inherit(s)")
4161
+ * overlaps KNOW_ABOUT_RE's fixed leads above, or RELATION_FACT_YESNO_RE/
4162
+ * RELATION_WHO_ASK_RE's required leading "is/are/was/were" (those two live in
4163
+ * the separate factReadBack, only ever reached via `factAnswer(...) ??
4164
+ * factReadBack(...)` — never both). */
4165
+ const CAN_ASK_RE = /^(?:can|could)\s+(?:an?\s+)?([\w'-]+(?:\s+[\w'-]+)*?)\s+([a-z]+)[?.!\s]*$/i;
4166
+ const WHAT_CAN_DO_RE = /^what\s+can\s+(?:an?\s+)?(.+?)\s+do[?.!\s]*$/i;
4167
+ const WHAT_HAS_RE = /^what\s+has\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4168
+ // Widened 2026-07-11 (live-caught follow-up to the ambiguousParse fix, commit
4169
+ // 5c858bf): on the FIRST turn of a graph-less session, dispatchTool's
4170
+ // loadGraph() throws its own documented "the graph is empty... this repo
4171
+ // starts with no graph" ToolError (src/server.mjs) — a pre-existing, by-design
4172
+ // bootstrap behavior (self-corrects from turn 2 on) — which leaves `envelope`
4173
+ // null for the rest of THIS turn's processing. The envelope.parsed branch just
4174
+ // below can't help on that turn, so this regex is the ONLY path available —
4175
+ // and it used to cover just "what inherits (from) X", never "what is a kind/
4176
+ // sort/type of X" or "what is a subclass of X", so those phrasings hit a wrong
4177
+ // "I don't know a relation or rule called 'kind'" answer (from a completely
4178
+ // different, unrelated reader downstream) specifically on a session's first
4179
+ // turn. Widened to match every phrasing ARTICLE_RELATION_CONTINUATIONS'
4180
+ // grammar-level fix already handles when envelope.parsed IS available.
4181
+ const WHAT_INHERITS_RE = /^what\s+(?:inherits?\s+(?:from\s+)?(?:an?\s+)?|is\s+(?:an?\s+)?(?:kind|sort|type)\s+of\s+|is\s+(?:an?\s+)?subclass\s+of\s+)(.+?)[?.!\s]*$/i;
4182
+ /** WHAT_HAS_RE guard: "what has changed (recently)" reads as a temporal/code
4183
+ * question, not a HasA lookup — checked against the captured phrase's FIRST
4184
+ * word only (a closed set, not a general heuristic). Verified live: nothing
4185
+ * today already answers this phrasing (it falls to an unrelated code-graph
4186
+ * miss), so this is a pure safety guard, not a behavior change. */
4187
+ const HAS_TEMPORAL_TAIL = new Set(["changed", "change", "changes", "updated", "modified", "happened", "occurred"]);
4188
+
4189
+ /** Local reproduction of ask.mjs's private `uniqueById` dedup idiom (not
4190
+ * exported, so not importable across modules): collapse exact-repeat
4191
+ * (subject,predicate,object) triples while keeping every DISTINCT subject —
4192
+ * more than one subject can share the same object (e.g. car/bicycle/train
4193
+ * all `mgx:hasA` wheel). */
4194
+ function uniqueFacts(rows) {
4195
+ const seen = new Set();
4196
+ const out = [];
4197
+ for (const f of rows) {
4198
+ const key = `${f.subject}|${f.predicate}|${f.object}`;
4199
+ if (seen.has(key)) continue;
4200
+ seen.add(key);
4201
+ out.push(f);
4202
+ }
4203
+ return out;
4204
+ }
4205
+
3771
4206
  /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
3772
4207
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
3773
4208
  * own (schema-docs) answer stands and the fact lines are appended under it —
@@ -3786,7 +4221,17 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
3786
4221
  // OPTIONAL — see that regex's docblock for why this is safe to loosen here
3787
4222
  // even though the structural grammar's T5 keeps the article mandatory).
3788
4223
  let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
3789
- if (!metaTerm && miss && !envelope?.parsed) {
4224
+ // Exclude "what is a kind/sort/type of X" / "what is a subclass of X" from this
4225
+ // bare catch-all: on the FIRST turn of a graph-less session, dispatchTool's
4226
+ // loadGraph() throws its own documented empty-graph ToolError (a pre-existing,
4227
+ // by-design bootstrap behavior — self-corrects from turn 2 on), which leaves
4228
+ // `envelope` null for the rest of the turn, arming this `!envelope?.parsed`
4229
+ // fallback. Without this guard it greedily swallows the WHOLE "kind of animal"
4230
+ // tail as a literal meta-term to define (mirroring grammar.mjs T5's OWN
4231
+ // ARTICLE_RELATION_CONTINUATIONS guard against the identical over-capture),
4232
+ // returning early and never letting (b5) below — which already handles this
4233
+ // exact shape via WHAT_INHERITS_RE, envelope or no envelope — get a chance.
4234
+ if (!metaTerm && miss && !envelope?.parsed && !WHAT_INHERITS_RE.test(q)) {
3790
4235
  const m = q.match(BARE_WHATIS_RE)
3791
4236
  || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
3792
4237
  // Seonix Batch 2 Fix 3: strip a curated trailing scope clause ("… in this
@@ -3846,6 +4291,105 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
3846
4291
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
3847
4292
  }
3848
4293
 
4294
+ // (b2) "can a dog bark" — yes iff a remembered mgx:capableOf fact says so.
4295
+ // Mirrors the ISA_ASK_RE block just above almost verbatim (same memoryFacts
4296
+ // single-hit lookup, same "never a guessed no" discipline).
4297
+ const can = q.match(CAN_ASK_RE);
4298
+ if (can) {
4299
+ const subj = factTermVariants(normFactTerm, can[1]);
4300
+ const obj = factTermVariants(normFactTerm, can[2]);
4301
+ const hit = (await memoryFacts(memoryDir)).find(
4302
+ (f) => f.predicate === "mgx:capableOf" && subj.has(f.subject) && obj.has(f.object),
4303
+ );
4304
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
4305
+ return null;
4306
+ }
4307
+
4308
+ // (b3) "what can a dog do" — every remembered mgx:capableOf fact for the
4309
+ // subject, open-list. Reuses the meta-lane's subject-hits/rank/render/
4310
+ // paginate recipe (lane (a) above) verbatim, with the predicate hardcoded.
4311
+ const canDo = q.match(WHAT_CAN_DO_RE);
4312
+ if (canDo) {
4313
+ const variants = factTermVariants(normFactTerm, canDo[1]);
4314
+ const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4315
+ if (!hits.length) return null;
4316
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4317
+ const lines = ranked.map(renderFactLine);
4318
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
4319
+ const rest = lines.slice(FACT_ANSWER_CAP);
4320
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4321
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4322
+ }
4323
+
4324
+ // (b4) "what has a wheel" — the REVERSE-by-OBJECT mirror of every other
4325
+ // reader in this cascade: filters factRows on mgx:hasA where the OBJECT
4326
+ // (not subject) matches, so every subject sharing that object surfaces
4327
+ // (e.g. car/bicycle/train all "have" a wheel). Guarded against shadowing
4328
+ // "what has changed(recently)"-shaped inputs, which read as a temporal/
4329
+ // code question, not a HasA lookup — see HAS_TEMPORAL_TAIL's own docblock.
4330
+ const hasQ = q.match(WHAT_HAS_RE);
4331
+ if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
4332
+ const variants = factTermVariants(normFactTerm, hasQ[1]);
4333
+ const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4334
+ if (!hits.length) return null;
4335
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4336
+ const lines = ranked.map(renderFactLine);
4337
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
4338
+ const rest = lines.slice(FACT_ANSWER_CAP);
4339
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4340
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4341
+ }
4342
+ // hasQ matched but shadowed a temporal-tail phrase ("what has changed…") —
4343
+ // deliberately falls through to the next reader below (never returns here),
4344
+ // leaving whatever already handles that phrasing today untouched.
4345
+
4346
+ // (b5) "what inherits from horse" — the reverse-by-object mirror of (b4),
4347
+ // over the ISA-family predicates instead of mgx:hasA. No temporal-style
4348
+ // guard needed: "inherits" has no competing common-English reading.
4349
+ //
4350
+ // "what is a kind of X" / "what is a subclass of X" (2026-07-11 follow-up,
4351
+ // live-repro: "boney is a dog" -> "what is a dog" -> "what is a kind of
4352
+ // animal" hit a wrong "I don't know a relation or rule called 'kind'"
4353
+ // answer). Fixing the parse-level {ambiguousParse} tie between this and a
4354
+ // spurious "meta" reading (grammar.mjs T5, ARTICLE_RELATION_CONTINUATIONS)
4355
+ // means `envelope.parsed` now cleanly carries {shape:"reverse",
4356
+ // kind:"inherits", object:"animal"} for this phrasing too — but WHAT_INHERITS_RE
4357
+ // is a FIXED regex ("what inherits (from) X") that never matched it, so this
4358
+ // block used to fall through to null and let factReadBack's RELATION_WHO_ASK_RE
4359
+ // misread "kind"/"subclass" as a relation NAME instead. Reading the ALREADY-
4360
+ // PARSED envelope directly (any phrasing the grammar recognizes as this exact
4361
+ // shape, not just WHAT_INHERITS_RE's one hardcoded surface form) fixes this
4362
+ // generally; the regex match is kept as a fallback for a parse the envelope
4363
+ // doesn't carry (e.g. no envelope at all).
4364
+ const inheritsQ = q.match(WHAT_INHERITS_RE);
4365
+ const inheritsObj = (envelope?.parsed?.shape === "reverse" && envelope.parsed.kind === "inherits")
4366
+ ? envelope.parsed.object
4367
+ : inheritsQ?.[1];
4368
+ if (inheritsObj) {
4369
+ const variants = factTermVariants(normFactTerm, inheritsObj);
4370
+ const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4371
+ // Only diverts on a REAL hit — same discipline every other reader in this
4372
+ // cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
4373
+ // null` on zero hits too). A zero-hit case here must NOT invent its own
4374
+ // override text: whatever answer already stands (a code-graph-specific miss
4375
+ // from ask.mjs's own traversal, a glossary/relation-force explanation, or the
4376
+ // generic wall) is left alone. The real fix for the "I don't know a relation
4377
+ // or rule called 'kind' yet" false claim (Live-caught 2026-07-11 follow-up to
4378
+ // the "what is a kind of X" ambiguousParse fix, commit 5c858bf) lives at
4379
+ // RELATION_WHO_ASK_RE's own handler in factReadBack, below — it excludes ISA-
4380
+ // idiom words ("kind"/"sort"/"type"/"subclass"/"superclass") from being
4381
+ // treated as arbitrary unknown relation NAMES, since they're not names a user
4382
+ // could have taught a relation under; they're this file's own vocabulary for
4383
+ // the inherits relation, always "known" by construction.
4384
+ if (!hits.length) return null;
4385
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4386
+ const lines = ranked.map(renderFactLine);
4387
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
4388
+ const rest = lines.slice(FACT_ANSWER_CAP);
4389
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4390
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4391
+ }
4392
+
3849
4393
  // (c) "what do you know about caches" — everything remembered that MENTIONS the
3850
4394
  // term (subject or object), capped.
3851
4395
  const know = q.match(KNOW_ABOUT_RE);
@@ -4031,8 +4575,9 @@ async function whatElseAnswer(memoryDir, query, last) {
4031
4575
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4032
4576
  const variants = factTermVariants(normFactTerm, term);
4033
4577
  const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
4578
+ const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
4034
4579
  const nothingMore = {
4035
- text: `That's everything I know about "${term}" — /memory to see the full picture.`,
4580
+ text: `That's everything I know about "${term}" — /memory to see ${picture}.`,
4036
4581
  replace: true,
4037
4582
  };
4038
4583
  if (!hits.length) return nothingMore;
@@ -4042,8 +4587,9 @@ async function whatElseAnswer(memoryDir, query, last) {
4042
4587
  const shown = lines.slice(0, FACT_ANSWER_CAP);
4043
4588
  const rest = lines.slice(FACT_ANSWER_CAP);
4044
4589
  const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4590
+ const lead = pickPhrase("beyond-that-lead", term.toLowerCase(), "Beyond that,");
4045
4591
  return {
4046
- text: `Beyond that, here's what else I know about "${term}":\n${shown.join("\n")}${extra}`,
4592
+ text: `${lead} here's what else I know about "${term}":\n${shown.join("\n")}${extra}`,
4047
4593
  replace: true,
4048
4594
  ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}),
4049
4595
  };
@@ -4090,6 +4636,26 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
4090
4636
  /** "what kind of thing is an X" — the subject-side membership phrasing the grammar
4091
4637
  * doesn't parse: reports X's OWN remembered type (falling back to X's members). */
4092
4638
  const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4639
+ /** "does every <N1> have at least <m> <N2>" — cardinality monotonicity
4640
+ * (PLAN_INFERENCE_TESTING.md INF-C1, this build): a class's OWN declared
4641
+ * exactly/min cardinality restriction proves "at least m" for any queried
4642
+ * m <= n (src/syllogise.mjs's proveCardinalityAtLeast). */
4643
+ const CARD_AT_LEAST_ASK_RE = /^does\s+every\s+(.+?)\s+have\s+at\s+least\s+(\d+)\s+(.+?)[?.!\s]*$/i;
4644
+ /** "does a/an <N1> have a/an <N2>" — cax-maxc0 (this build): a declared
4645
+ * max-cardinality-0 restriction proves the class-level "no" directly
4646
+ * (src/syllogise.mjs's proveMaxCardinalityZeroDenial). Both readers FALL
4647
+ * THROUGH ON A MISS (no unconditional decline, unlike isaAsk's own closing
4648
+ * `return null`): "does SUBJ have OBJ" is broad enough to otherwise collide
4649
+ * with GENERAL_VERB_YESNO_RE below and a pre-existing "3 unclear max0 cases"
4650
+ * quirk (HANDOVER.md) — a miss here simply lets the query continue to
4651
+ * whatever would have handled it before this build existed. */
4652
+ const CARD_EXISTENCE_ASK_RE = /^does\s+an?\s+(.+?)\s+have\s+an?\s+(.+?)[?.!\s]*$/i;
4653
+ /** The 4 pattern-5 cardinality-restriction predicates buildCardinalityRestrictions
4654
+ * reconstructs from — owl:onProperty (shared scaffolding with someValuesFrom
4655
+ * restrictions too) is added alongside this set by each reader below, not
4656
+ * folded into it here, mirroring infbench/grade.mjs's own identically-named
4657
+ * set + separate owl:onProperty handling. */
4658
+ const CARDINALITY_ROW_PREDICATES = new Set(["owl:cardinality", "owl:minCardinality", "owl:maxCardinality", "owl:onClass"]);
4093
4659
  /** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
4094
4660
  * the teach lane's mgx:ownedBy facts. */
4095
4661
  const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
@@ -4242,6 +4808,31 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4242
4808
  let normFactTerm;
4243
4809
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4244
4810
  const q = String(query).trim();
4811
+ // DIRECT STRUCTURAL CHECK (playtest sprint round 2, this session): "is X a Y"
4812
+ // naming a real code-graph inheritance edge needs NO taught fact at all — the
4813
+ // graph's own `inherits` relation already proves it. Checked here, BEFORE the
4814
+ // `rows.length` bail-out just below, because a pristine graph with ZERO taught
4815
+ // facts returns null from that bail-out and never reaches ISA_ASK_RE's own
4816
+ // taught-fact-only checks further down in this function at all. Found live:
4817
+ // "is TaskController a Controller" (a direct one-hop inherits edge) and "is
4818
+ // Task a Record" both hit the raw grammar wall on a freshly loaded graph with
4819
+ // nothing taught yet — even in the wall's OWN suggested phrasing ("is a
4820
+ // <thing> a <kind>"), and even though "what does Task inherit from" answers
4821
+ // "Record" via the exact same relation. Cheapest and most certain check
4822
+ // available: purely the graph's own relations, no memory/rows dependency,
4823
+ // never a guess.
4824
+ if (graph) {
4825
+ const directIsaAsk = q.match(ISA_ASK_RE);
4826
+ if (directIsaAsk) {
4827
+ const ent = await resolveEntity(graph, directIsaAsk[1]);
4828
+ if (ent) {
4829
+ const directObjVariants = factTermVariants(normFactTerm, stripTrailingDiscourseTag(directIsaAsk[2]));
4830
+ const directSup = inheritsChain(graph, ent.id)
4831
+ .find((sup) => [...factTermVariants(normFactTerm, sup.label)].some((v) => directObjVariants.has(v)));
4832
+ if (directSup) return { text: `yes — the code graph says ${ent.label} inherits ${directSup.label}.`, replace: true };
4833
+ }
4834
+ }
4835
+ }
4245
4836
  // Tier-5 playtest fix (cycle 4), found live: "actually is the store module
4246
4837
  // fragile" WALLED — a leading hedge adverb ("actually"/"really"/"honestly",
4247
4838
  // optionally comma'd) put the sentence one word out of alignment with
@@ -4379,7 +4970,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4379
4970
  const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
4380
4971
  const relationName = relAsk[2].trim().toLowerCase();
4381
4972
  const object = relAsk[3].trim();
4382
- if (subject) {
4973
+ if (subject && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
4383
4974
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4384
4975
  const aliasSubClassEdges = rows
4385
4976
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
@@ -4479,7 +5070,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4479
5070
  const relationName = whoAsk[1].trim().toLowerCase();
4480
5071
  const rawObject = whoAsk[2].trim();
4481
5072
  const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
4482
- if (object) {
5073
+ if (object && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
4483
5074
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4484
5075
  const aliasSubClassEdges = rows
4485
5076
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
@@ -4761,7 +5352,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4761
5352
  // (owl:onProperty/owl:someValuesFrom) and every property/type premise must
4762
5353
  // all be TAUGHT (never corpus-sourced), same as every other live chase in
4763
5354
  // this block.
4764
- const { deriveSomeValuesFromApplication, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE } = await import("./syllogise.mjs");
5355
+ const {
5356
+ deriveSomeValuesFromApplication, ON_PROPERTY_PREDICATE, SOME_VALUES_FROM_PREDICATE,
5357
+ deriveSomeValuesFromSubsumption, ENTAILED_SCM_SVF_PROVENANCE, SCM_SVF_RULE_CONFIDENCE, entailedTrustFrom,
5358
+ } = await import("./syllogise.mjs");
4765
5359
  const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
4766
5360
  const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
4767
5361
  if (onPropertyRows.length && someValuesFromRows.length) {
@@ -4788,10 +5382,178 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4788
5382
  replace: true,
4789
5383
  };
4790
5384
  }
5385
+ // LIVE scm-svf1 PROOF CHASE (PLAN_INFERENCE_TESTING.md INF-C1, this
5386
+ // build; W3C OWL 2 RL Table 9's scm-svf1 — confirmed distinct from
5387
+ // scm-svf2, which needs rdfs:subPropertyOf, which the ACE grammar can't
5388
+ // teach at all — see src/syllogise.mjs's own header comment): every
5389
+ // strategy above missed — two INDEPENDENTLY taught someValuesFrom
5390
+ // restrictions sharing the SAME property, whose filler classes are
5391
+ // themselves ⊑-related, license a restriction-to-restriction ⊑ fact
5392
+ // (deriveSomeValuesFromSubsumption). Reuses the SAME restrictionEdges
5393
+ // just built for cls-svf1 above — a SEPARATE findIsaChain call
5394
+ // (maxHops: 3, one hop of headroom over the A2 chase's maxHops: 2)
5395
+ // rather than folding into that earlier call, so INF-A2's pinned
5396
+ // behavior is untouched.
5397
+ const svfSubsumption = restrictionEdges.length > 1
5398
+ ? deriveSomeValuesFromSubsumption(restrictionEdges, chainSubClassEdges, { budget: 10 })
5399
+ : [];
5400
+ if (svfSubsumption.length) {
5401
+ const enlargedSubClassEdges = chainSubClassEdges.concat(svfSubsumption.map((d) => [d.subject, d.object]));
5402
+ // Trust-hook gap fix (this session): the SAME `min(premiseTrusts) x
5403
+ // ruleConfidence` discipline syllogise()'s own batch pass now applies
5404
+ // to scm-svf1 (src/syllogise.mjs), computed here for this LIVE,
5405
+ // read-only chase — each restriction's own onProperty/someValuesFrom
5406
+ // scaffolding trust plus the y1⊑y2 subClassOf premise that licensed
5407
+ // the comparison (always present, mirroring syllogise()'s own
5408
+ // scmSvfDerived mapping). `restrictionByRid` looks a restriction's
5409
+ // OWN (property, target) pair up by id — the same lookup
5410
+ // syllogise()'s batch pass uses.
5411
+ const restrictionByRid = new Map(restrictionEdges.map((r) => [r.restriction, r]));
5412
+ const svfTrustByTriple = new Map();
5413
+ for (const f of rows) svfTrustByTriple.set(`${f.subject}${f.predicate}${f.object}`, f.trust);
5414
+ const svfPremiseTrust = (s, p, o) => svfTrustByTriple.get(`${s}${p}${o}`);
5415
+ const svfTrustOf = new Map(); // "c1\0c2" -> computed trust, for the synthetic row below
5416
+ for (const d of svfSubsumption) {
5417
+ const r1 = restrictionByRid.get(d.subject);
5418
+ const r2 = restrictionByRid.get(d.object);
5419
+ const premiseTrusts = [
5420
+ r1 && svfPremiseTrust(d.subject, ON_PROPERTY_PREDICATE, r1.property),
5421
+ svfPremiseTrust(d.subject, SOME_VALUES_FROM_PREDICATE, d.viaY1),
5422
+ r2 && svfPremiseTrust(d.object, ON_PROPERTY_PREDICATE, r2.property),
5423
+ svfPremiseTrust(d.object, SOME_VALUES_FROM_PREDICATE, d.viaY2),
5424
+ svfPremiseTrust(d.viaY1, SC_PREDICATE, d.viaY2),
5425
+ ].filter((t) => typeof t === "number");
5426
+ const t = entailedTrustFrom(premiseTrusts, SCM_SVF_RULE_CONFIDENCE);
5427
+ if (t !== null) svfTrustOf.set(`${d.subject}${d.object}`, t);
5428
+ }
5429
+ // A derived restriction⊑restriction edge has no underlying stored
5430
+ // Fact row to cite (it's a schema-level conclusion, not a taught
5431
+ // sentence) — falls back to a SYNTHETIC row carrying scm-svf1's own
5432
+ // entailed provenance + its own computed trust, so renderIsaChain's
5433
+ // citation still names the real (low-trust, non-taught) source
5434
+ // honestly, same discipline as every "entailed:*" provenance tag
5435
+ // elsewhere in this file.
5436
+ const factForStepOrSvf = (step) => {
5437
+ if (step.predicate !== SC_PREDICATE) return chainTypeRows.find((f) => f.subject === step.subject && f.object === step.object);
5438
+ const stated = chainSubClassRows.find((f) => f.subject === step.subject && f.object === step.object);
5439
+ if (stated) return stated;
5440
+ const derived = svfSubsumption.find((d) => d.subject === step.subject && d.object === step.object);
5441
+ return derived
5442
+ ? {
5443
+ subject: derived.subject, predicate: SC_PREDICATE, object: derived.object, provenance: ENTAILED_SCM_SVF_PROVENANCE,
5444
+ trust: svfTrustOf.get(`${derived.subject}${derived.object}`),
5445
+ }
5446
+ : undefined;
5447
+ };
5448
+ for (const subj of subjCandidates) {
5449
+ const chain = findIsaChain(subj, objVariants, chainTypeEdges, enlargedSubClassEdges, { maxHops: 3 });
5450
+ if (!chain) continue;
5451
+ const premises = chain.map(factForStepOrSvf);
5452
+ if (premises.every(Boolean)) {
5453
+ // The WHOLE chain's own trust is the weakest link across every step
5454
+ // (each step's own trust, including the synthetic scm-svf1 step's
5455
+ // already-discounted figure computed above) — no further
5456
+ // ruleConfidence discount at this outer level; it is already
5457
+ // baked into whichever step was entailed rather than taught.
5458
+ const chainTrust = entailedTrustFrom(premises.map((p) => p.trust), 1);
5459
+ return { text: `yes — ${renderIsaChain(premises)}`, replace: true, ...(chainTrust !== null ? { trust: chainTrust } : {}) };
5460
+ }
5461
+ }
5462
+ }
4791
5463
  }
4792
5464
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
4793
5465
  }
4794
5466
 
5467
+ // (a1c-i) CARDINALITY MONOTONICITY — "does every X have at least N Y" over
5468
+ // a TAUGHT exactly/min cardinality restriction (PLAN_INFERENCE_TESTING.md
5469
+ // INF-C1, this build; pattern-5, src/grammar/ace.mjs's parseCardinality).
5470
+ // FALLS THROUGH ON A MISS (see CARD_AT_LEAST_ASK_RE's own doc comment) —
5471
+ // never an unconditional decline, unlike isaAsk's own closing `return null`.
5472
+ const cardAtLeast = q.match(CARD_AT_LEAST_ASK_RE);
5473
+ if (cardAtLeast) {
5474
+ const [, subjRaw, mRaw, objRaw] = cardAtLeast;
5475
+ const {
5476
+ SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
5477
+ buildCardinalityRestrictions, proveCardinalityAtLeast, CARDINALITY_RULE_CONFIDENCE, entailedTrustFrom,
5478
+ } = await import("./syllogise.mjs");
5479
+ const isTaughtCard = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
5480
+ const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
5481
+ const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
5482
+ const cardinalityRestrictionEdges = buildCardinalityRestrictions(cardRows);
5483
+ if (cardinalityRestrictionEdges.length) {
5484
+ const subjVariants = factTermVariants(normFactTerm, subjRaw.trim());
5485
+ const objVariants = factTermVariants(normFactTerm, objRaw.trim());
5486
+ const m = Number(mRaw);
5487
+ const witness = findAcrossVariants(subjVariants, objVariants, (s, o) => proveCardinalityAtLeast(cardSubClassEdges, cardinalityRestrictionEdges, s, o, m, {}));
5488
+ if (witness) {
5489
+ const restrictionFact = rows.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.viaClass && f.object === witness.viaRestriction);
5490
+ const cite = restrictionFact?.provenance ? ` (source: ${restrictionFact.provenance})` : "";
5491
+ const kindWord = witness.kind === "exactly" ? "exactly" : "at least";
5492
+ const plural = (w, n) => `${w}${n === 1 ? "" : "s"}`;
5493
+ // Trust-hook gap fix (this session): premise-derived trust for THIS
5494
+ // rule's answer (src/syllogise.mjs's CARDINALITY_RULE_CONFIDENCE doc
5495
+ // comment explains why there is no persisted Fact for it to attach
5496
+ // to) — the restriction's OWN scaffolding rows (onProperty/kind/
5497
+ // onClass, all keyed to witness.viaRestriction), the declaring
5498
+ // subClassOf edge, and (when this is a ⊑-lift) the one-hop premise
5499
+ // from the actually-queried subject up to viaClass.
5500
+ const cardPremiseTrusts = [
5501
+ restrictionFact?.trust,
5502
+ ...cardRows.filter((f) => f.subject === witness.viaRestriction).map((f) => f.trust),
5503
+ ...(witness.viaClass !== witness.subject
5504
+ ? [isa.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.subject && f.object === witness.viaClass)?.trust]
5505
+ : []),
5506
+ ].filter((t) => typeof t === "number");
5507
+ const trust = entailedTrustFrom(cardPremiseTrusts, CARDINALITY_RULE_CONFIDENCE);
5508
+ return {
5509
+ text: `yes — every ${witness.viaClass} has ${kindWord} ${witness.n} ${plural(witness.object, witness.n)}${cite}, so at least ${m} follows.`,
5510
+ replace: true,
5511
+ ...(trust !== null ? { trust } : {}),
5512
+ };
5513
+ }
5514
+ }
5515
+ // falls through — no witnessing restriction (or none declared at all)
5516
+ }
5517
+
5518
+ // (a1c-ii) cax-maxc0 — "does a/an X have a/an Y" over a TAUGHT
5519
+ // max-cardinality-0 restriction (PLAN_INFERENCE_TESTING.md INF-C1, this
5520
+ // build). NEVER infers "no" from absence, matching cax-dw's own discipline
5521
+ // above — a miss here FALLS THROUGH too (see CARD_EXISTENCE_ASK_RE's own
5522
+ // doc comment).
5523
+ const cardExistence = q.match(CARD_EXISTENCE_ASK_RE);
5524
+ if (cardExistence) {
5525
+ const [, subjRaw, objRaw] = cardExistence;
5526
+ const {
5527
+ SUBCLASS_PREDICATE: CARD_SC_PREDICATE, ON_PROPERTY_PREDICATE: CARD_ON_PROPERTY_PREDICATE,
5528
+ buildCardinalityRestrictions, proveMaxCardinalityZeroDenial, CAX_MAXC0_RULE_CONFIDENCE, entailedTrustFrom,
5529
+ } = await import("./syllogise.mjs");
5530
+ const isTaughtCard = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
5531
+ const cardSubClassEdges = isa.filter((f) => f.predicate === CARD_SC_PREDICATE && isTaughtCard(f)).map((f) => [f.subject, f.object]);
5532
+ const cardRows = rows.filter((f) => (f.predicate === CARD_ON_PROPERTY_PREDICATE || CARDINALITY_ROW_PREDICATES.has(f.predicate)) && isTaughtCard(f));
5533
+ const cardinalityRestrictionEdges = buildCardinalityRestrictions(cardRows);
5534
+ if (cardinalityRestrictionEdges.length) {
5535
+ const subjVariants = factTermVariants(normFactTerm, subjRaw.trim());
5536
+ const objVariants = factTermVariants(normFactTerm, objRaw.trim());
5537
+ const witness = findAcrossVariants(subjVariants, objVariants, (s, o) => proveMaxCardinalityZeroDenial(cardSubClassEdges, cardinalityRestrictionEdges, s, o, {}));
5538
+ if (witness) {
5539
+ const restrictionFact = rows.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.viaClass && f.object === witness.viaRestriction);
5540
+ const cite = restrictionFact?.provenance ? ` (source: ${restrictionFact.provenance})` : "";
5541
+ // Trust-hook gap fix (this session) — same discipline as the
5542
+ // cardinality-monotonicity reader just above (see its own comment).
5543
+ const cardPremiseTrusts = [
5544
+ restrictionFact?.trust,
5545
+ ...cardRows.filter((f) => f.subject === witness.viaRestriction).map((f) => f.trust),
5546
+ ...(witness.viaClass !== witness.subject
5547
+ ? [isa.find((f) => f.predicate === CARD_SC_PREDICATE && f.subject === witness.subject && f.object === witness.viaClass)?.trust]
5548
+ : []),
5549
+ ].filter((t) => typeof t === "number");
5550
+ const trust = entailedTrustFrom(cardPremiseTrusts, CAX_MAXC0_RULE_CONFIDENCE);
5551
+ return { text: `no — every ${witness.viaClass} has at most 0 ${witness.object}${cite}.`, replace: true, ...(trust !== null ? { trust } : {}) };
5552
+ }
5553
+ }
5554
+ // falls through — no witnessing restriction (or none declared at all)
5555
+ }
5556
+
4795
5557
  // (a2) OWNERSHIP read-back — "who owns/maintains <X>": the teach lane's
4796
5558
  // mgx:ownedBy facts about X, trust-ranked, each cited (the source receipt
4797
5559
  // stays in the render). No fact → null, the honest miss stands.
@@ -5070,12 +5832,28 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5070
5832
  // it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
5071
5833
  // thing is an X" reports X's own TYPE (subject-side first), so both directions
5072
5834
  // of a single remembered "X is a kind of Y" are queryable.
5835
+ //
5836
+ // Bug found live this session (PLAN_CONVERSATION.md verification, Finding 1):
5837
+ // this branch exists specifically to catch the bare, no-article "what is X"
5838
+ // shape the grammar's own T5 template DECLINES to parse for a non-ENTITY_TO_TYPE
5839
+ // term (grammar.mjs's own closed-set gate on the bare form) — envelope.parsed
5840
+ // stays null for exactly this case, which is this branch's own trigger
5841
+ // condition. But the regex required a MANDATORY article ("an?" with no "?"),
5842
+ // the opposite of BARE_WHATIS_RE's own already-established "article optional"
5843
+ // convention (chat.mjs:5777, used one function up in this same cascade) — so
5844
+ // this branch could never actually fire for the bare form it exists to catch.
5845
+ // "every cache is a florble" / "what is florble" (no article) and "cheese is
5846
+ // blue" / "what is blue" (no article) both silently fell through to the
5847
+ // generic orientation card as a result, even though "what is a florble"/
5848
+ // "what is a blue" (WITH the article) correctly found the reverse fact.
5849
+ // Matching BARE_WHATIS_RE's own optional-article group fixes this at the
5850
+ // root, for every term alike (not a term/lexicon-specific asymmetry at all).
5073
5851
  let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
5074
5852
  let kindOf = false;
5075
5853
  const mk = q.match(KIND_OF_RE);
5076
5854
  if (mk) { term = mk[1]; kindOf = true; }
5077
5855
  else if (!term && !envelope?.parsed) {
5078
- const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
5856
+ const m = q.match(/^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
5079
5857
  if (m) term = m[1];
5080
5858
  }
5081
5859
  if (!term) return null;
@@ -5299,6 +6077,29 @@ function superlativeRepeatRewrite(query, last) {
5299
6077
  return prevQ;
5300
6078
  }
5301
6079
 
6080
+ /** EXISTENTIAL "is there anything/something/anyone/anybody that/which/who
6081
+ * <verb-phrase>" -> "what <verb-phrase>". Round 2 playtest (2026-07-11):
6082
+ * parseExistence (ask.mjs) correctly DECLINES this shape — "anything" is a
6083
+ * placeholder, not a real entity-kind noun, so it rightly leaves a relative-
6084
+ * clause verb-phrase for the relation parsers below. But those parsers then
6085
+ * treat the ELIDED subject as an ANAPHORA continuation (reusing the standing
6086
+ * focus) instead of recognizing "anything that <verb> X" as the SAME open
6087
+ * reverse-lookup "is anything <verb-ing> X" already answers correctly
6088
+ * ("test/tasks.test.mjs."). Live finding: "is there anything that tests
6089
+ * Task", asked right after focus had landed on UserController, answered "No
6090
+ * — no tests edge found from UserController to Task" — a confidently WRONG
6091
+ * answer (worse than a miss), not the real answer. A closed textual rewrite
6092
+ * onto the ALREADY-CORRECT "what <verb> X" shape sidesteps the AST-shape
6093
+ * work entirely: no new capability, just aiming an existing one (the
6094
+ * reverse-relation lookup "what tests X"/"who calls X") at input that means
6095
+ * the same thing. Applied UNCONDITIONALLY (no `last` dependency, unlike
6096
+ * discourseRewrite) — this shape carries its own complete meaning. */
6097
+ const EXISTENTIAL_ANYTHING_RE = /^is\s+there\s+(?:anything|something|anyone|anybody)\s+(?:that|which|who)\s+(.+?)\s*\??$/i;
6098
+ function existentialAnythingRewrite(query) {
6099
+ const m = EXISTENTIAL_ANYTHING_RE.exec(String(query || "").trim());
6100
+ return m ? `what ${m[1].trim()}` : null;
6101
+ }
6102
+
5302
6103
  // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
5303
6104
  // A "what is a <term>" for a LEXICON term prefers the curated one-sentence
5304
6105
  // definition — the richer surface form of the same curated SEON knowledge that the
@@ -5705,7 +6506,16 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5705
6506
  // and broke DESCRIBE_WRAPPER_RE's own anchor. applyPreambleFrames is the same
5706
6507
  // general-purpose, closed, idempotent pass every other lane in this file
5707
6508
  // already runs first.
5708
- const q = applyPreambleFrames(String(query || "").trim());
6509
+ // BENCHMARK_CONVERSATION_1.7.0.md routed backlog C3 ("wat about store.mjs"):
6510
+ // this lane's own DESCRIBE_WRAPPER_RE anchors on a literal "what about"/
6511
+ // "describe"/"tell me about" — a curated typo of one of those anchor words
6512
+ // ("wat" for "what") never matched, so the whole lane silently declined even
6513
+ // though "wat" is already a curated MISSPELLINGS entry everywhere else.
6514
+ // correctMisspellings runs FIRST, same order chat.mjs's other normalization
6515
+ // call sites use (e.g. the module-orient lane above), so the anchor match
6516
+ // sees the corrected text; a genuinely uncurated typo still declines here,
6517
+ // same honest-miss behavior as before.
6518
+ const q = applyPreambleFrames(correctMisspellings(String(query || "").trim()));
5709
6519
  const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
5710
6520
  let term = m?.[1]?.trim();
5711
6521
  if (!term) return null;
@@ -5717,6 +6527,14 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5717
6527
  // branches never leave this residue, so this can only ever help the doubled-
5718
6528
  // verb case, never change a correctly-captured term.
5719
6529
  term = term.replace(/^about\s+/i, "");
6530
+ // Round 1 playtest fix (2026-07-11): a trailing bare discourse tag ("describe
6531
+ // Record then", "tell me about Record then") glued onto the captured term,
6532
+ // same class of bug HANDOVER.md 2026-07-10 item 8 already fixed for the
6533
+ // meta-whatis vocab lane (stripTrailingDiscourseTag, ask-vocab.mjs) — this
6534
+ // lane never got the same treatment, so "Record then" failed to resolve as
6535
+ // any real symbol even though "Record" alone (a real entity just discussed)
6536
+ // resolves cleanly.
6537
+ term = stripTrailingDiscourseTag(term);
5720
6538
  if (DESCRIBE_PRONOUN_RE.test(term)) {
5721
6539
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
5722
6540
  term = focus.label;
@@ -5738,7 +6556,18 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5738
6556
  }
5739
6557
  try {
5740
6558
  const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source, tel });
5741
- return text ? { text } : null;
6559
+ if (!text) return null;
6560
+ // Playtest sprint round 1 (2026-07-11): this rescue resolves and confidently
6561
+ // describes a real entity ("tell me more about Task"), but until now returned
6562
+ // only `text` — the resolved entity never reached the caller, so the session's
6563
+ // focus was never updated. The VERY NEXT natural follow-up ("what calls it",
6564
+ // "where's that defined") then dead-ended on "'it' needs a selected node to
6565
+ // refer to" right after the engine had just named one — the exact anaphora
6566
+ // this project's own playtest discipline requires to carry (SKILL_BENCHMARK_
6567
+ // CONVERSATION.md §1b). Mirrors the object-resolution/superlative-winner focus
6568
+ // updates already done for the ordinary ask() path just above this function.
6569
+ const ent = await resolveEntity(graph, term);
6570
+ return { text, ent };
5742
6571
  } catch {
5743
6572
  return null; // unresolvable term — decline, the ordinary wall stands unchanged
5744
6573
  }
@@ -5814,7 +6643,28 @@ async function completionsRescueAnswer(query, { memoryDir, graph }) {
5814
6643
  if (!term) return null;
5815
6644
  try {
5816
6645
  const { generateCompletion } = await import("./completions/complete.mjs");
5817
- const result = await generateCompletion(memoryDir, term, { query: term, graph });
6646
+ // HANDOVER.md item 1: broadSearch (src/completions/search.mjs) already accepts an
6647
+ // optional Repository-Interface `graphService` — its own docblock names
6648
+ // createGraphService(graph) (src/providers/graph-service.mjs) as the reference
6649
+ // shape — but until now nothing ever handed one through, so this lane could only
6650
+ // ever see memory BLOCKS saved via an explicit saveBlock() call. Ordinary chat
6651
+ // teaching/asking never calls saveBlock(), so a subject's first-ever mention in a
6652
+ // session always declined here, no matter how much the already-loaded graph (and
6653
+ // any taught Facts about it) actually knew. createCompletionsGraphAdapter
6654
+ // (src/completions/graph-adapter.mjs) wraps the SAME graph object this turn already
6655
+ // has in scope (runTurn's own `graph` param, loaded once per session by the chat
6656
+ // shell) plus this repo's already-loaded Fact store — no re-load, no new search
6657
+ // machinery, just handing broadSearch the adapter it was always built to accept.
6658
+ // Loading memory here (rather than letting generateCompletion load it itself at
6659
+ // Stage 3) lets the SAME loaded payload double as the adapter's Fact-search source;
6660
+ // passed straight through as opts.memory so Stage 3 doesn't re-read it a second
6661
+ // time. A null/empty graph (no code entities loaded yet) or empty memory (no Facts
6662
+ // taught yet) degrades to the pre-existing block-only search, exactly as before.
6663
+ const { createCompletionsGraphAdapter } = await import("./completions/graph-adapter.mjs");
6664
+ const { loadMemory } = await import("./memory/core.mjs");
6665
+ const memory = await loadMemory(memoryDir);
6666
+ const graphService = createCompletionsGraphAdapter(graph, memory);
6667
+ const result = await generateCompletion(memoryDir, term, { query: term, graph, memory, graphService });
5818
6668
  if (!result || result.declined || !result.text) return null; // honest decline — never fabricate
5819
6669
  return { text: result.text };
5820
6670
  } catch {
@@ -5911,6 +6761,41 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
5911
6761
  return { text, instances: composed.instances, allIds: composed.allInstanceIds, pending };
5912
6762
  }
5913
6763
 
6764
+ /** C2 rescue (BENCHMARK_CONVERSATION_1.7.0.md routed backlog): a matching-kind
6765
+ * individual explicitly NAMED in `answerText` — the SAME code-ish name tokens
6766
+ * discourseRewrite already trusts (NAME_TOKEN_RE: a path, a Capitalized
6767
+ * symbol, or lowerCamelCase), each tried in turn, resolved CLASS-FILTERED
6768
+ * (resolveObject's own `expectedClass` option — describeGrainRescue, above,
6769
+ * uses the same convention) so only a genuine same-kind hit counts, never a
6770
+ * same-text-different-kind coincidence. First unambiguous hit wins; null when
6771
+ * nothing of that class is named anywhere in the text (graph-less, empty
6772
+ * text, or no match all decline the same honest way). Used ONLY by runAsk's
6773
+ * pronoun-resolution kind-mismatch guard, below — never a general "search
6774
+ * the last answer" utility. */
6775
+ async function entityOfKindInText(graph, expectedClass, answerText) {
6776
+ if (!graph || !expectedClass || !answerText) return null;
6777
+ // "g" ONLY, never "gi" — NAME_TOKEN_RE's own case-SENSITIVITY is exactly
6778
+ // what makes it a safe code-ish-token signal (a Capitalized symbol / a
6779
+ // mid-word capital never occurs in plain English, per its own docblock
6780
+ // above); adding "i" here would let ordinary lowercase prose words
6781
+ // ("function", "is", "defined") spuriously match too (found live testing
6782
+ // this fix — a bare lowercase word matched the lowerCamelCase branch under
6783
+ // case-insensitivity, since [A-Z] there also accepts lowercase under /i).
6784
+ const tokens = String(answerText).match(new RegExp(NAME_TOKEN_RE.source, "g")) || [];
6785
+ const seen = new Set();
6786
+ for (const tok of tokens) {
6787
+ const key = tok.toLowerCase();
6788
+ if (seen.has(key)) continue;
6789
+ seen.add(key);
6790
+ try {
6791
+ const { resolveObject } = await import("./ask.mjs");
6792
+ const r = resolveObject(graph, tok, { expectedClass });
6793
+ if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
6794
+ } catch { /* tolerated — falls through to the next token */ }
6795
+ }
6796
+ return null;
6797
+ }
6798
+
5914
6799
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
5915
6800
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
5916
6801
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
@@ -5933,7 +6818,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5933
6818
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
5934
6819
  // prior shape with X swapped in; everything else parses verbatim. The record and
5935
6820
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
5936
- let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last) ?? query;
6821
+ let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last)
6822
+ ?? existentialAnythingRewrite(query) ?? query;
5937
6823
  // IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
5938
6824
  // "and how many are tested" drops the "of those/them" a fuller phrasing carries
5939
6825
  // — ask()'s own anaphora node (parseAnaphora) already understands "how many of
@@ -5967,6 +6853,54 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5967
6853
  via: "recall", miss: !summary, focus,
5968
6854
  });
5969
6855
  }
6856
+ // C2 fix (BENCHMARK_CONVERSATION_1.7.0.md routed backlog): an explicit
6857
+ // "this file"/"that module" kind-noun scope signal is collapsed to a bare
6858
+ // pronoun by normalize.mjs's KIND_NOUN_ANAPHORA_RE before ask() ever parses
6859
+ // askQuery — so ask()'s own contextId-based pronoun resolution (just below)
6860
+ // would otherwise silently bind "this"/"that" to the STANDING focus even
6861
+ // when that focus is a narrower, different kind of thing than what was
6862
+ // explicitly named. Repro: "where is it defined" resolves to a FILE and
6863
+ // names it in the answer text; if the standing focus is still a Method,
6864
+ // "what this file is importing" must mean the file just named, not the
6865
+ // Method. Detected here via kindNounAnaphoraHint (a read-only probe of the
6866
+ // SAME askQuery text — normalizeQuery's own collapse inside ask() is
6867
+ // completely untouched) and rescued by swapping the CONTEXTID itself to a
6868
+ // matching-kind individual the immediately PRECEDING turn's own answer
6869
+ // already named — so the traversal ask() computes (not just the focus
6870
+ // carried to the NEXT turn, below) reflects the explicit scope. Only
6871
+ // diverts when the hint actively DISAGREES with the standing focus's real
6872
+ // class; falls back to today's untouched behavior (the stale focus stands,
6873
+ // or an honest miss) when nothing of the expected kind is named in the
6874
+ // preceding answer, so an ordinary "it"/"this" with no kind noun at all is
6875
+ // byte-identical to before this fix.
6876
+ //
6877
+ // Scoped tightly to expectedClass === "Module" ("this file"/"that file"/
6878
+ // "this module"/"that module") ON PURPOSE, not every KIND_NOUN_ANAPHORA_RE
6879
+ // kind: test/chatflow-tier1-single-touch.test.mjs's own T3 case ("which
6880
+ // class contains Task.complete" -> "what else is in that class") pins the
6881
+ // OPPOSITE behavior for "that class" — the standing Method focus (Task.
6882
+ // complete) is deliberately reused there, by design, even though "class"
6883
+ // names a different kind than Method too. "class"/"method"/"function"/
6884
+ // "attribute"/"variable"/"commit" are all colloquially used to mean "the
6885
+ // thing we were just discussing", which may genuinely BE the narrower
6886
+ // standing focus (T3's own case). "file"/"module" is the one kind noun in
6887
+ // this set that's never plausibly the SAME individual as a Method/
6888
+ // Function/Class/Attribute/GlobalVariable focus — it's strictly a
6889
+ // CONTAINER of them — so it alone is safe to treat as an unambiguous
6890
+ // kind-mismatch signal without breaking that pinned case. Widening this
6891
+ // beyond Module would need a real redesign (disambiguating "reuse the
6892
+ // narrower focus" from "switch to the just-named container" in general);
6893
+ // out of scope here — see the routed-backlog report for this session.
6894
+ let effectiveContextId = focus?.id ?? null;
6895
+ let kindRescueEnt = null;
6896
+ if (graph && focus?.id) {
6897
+ const expectedClass = kindNounAnaphoraHint(askQuery);
6898
+ const focusClass = graph?.byId?.get(focus.id)?.class;
6899
+ if (expectedClass === "Module" && focusClass && focusClass !== expectedClass) {
6900
+ kindRescueEnt = await entityOfKindInText(graph, expectedClass, last?.answer);
6901
+ if (kindRescueEnt?.id) effectiveContextId = kindRescueEnt.id;
6902
+ }
6903
+ }
5970
6904
  let answer;
5971
6905
  let envelope = null;
5972
6906
  try {
@@ -5977,7 +6911,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
5977
6911
  // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
5978
6912
  // emits, so the parse below is identical either way.
5979
6913
  const { ask } = await import("./ask.mjs");
5980
- const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
6914
+ const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
5981
6915
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
5982
6916
  } else {
5983
6917
  text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
@@ -6057,7 +6991,21 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6057
6991
  // this case (contextId was null); silently adopting a bogus focus as a side
6058
6992
  // effect here would corrupt the NEXT turn's pronoun into a confidently WRONG
6059
6993
  // (not just empty) answer, exactly as the connective leak did.
6060
- const ent = isPronoun(obj) ? (focus?.id ? focus : null) : await resolveEntity(graph, obj);
6994
+ //
6995
+ // C2 fix (BENCHMARK_CONVERSATION_1.7.0.md routed backlog): the blind
6996
+ // focus-reuse above is exactly right when the pronoun carries no extra
6997
+ // scope signal ("what does it import") — but "this file"/"that module"
6998
+ // EXPLICITLY names a kind, and normalize.mjs's KIND_NOUN_ANAPHORA_RE
6999
+ // already collapses it to the bare pronoun before either parse strategy
7000
+ // ever sees it, discarding that signal. `kindRescueEnt` (computed ABOVE,
7001
+ // before ask() ran, off the SAME askQuery text — see its own docblock)
7002
+ // already carries the matching-kind individual the preceding turn's
7003
+ // answer named, when the hint disagreed with the standing focus's class;
7004
+ // reusing it here (rather than recomputing) keeps the focus this turn
7005
+ // hands to the NEXT turn consistent with the traversal ask() actually
7006
+ // ran. Null when there was no disagreement, or nothing rescuable was
7007
+ // named — today's untouched behavior (reuse the focus / honest miss).
7008
+ const ent = isPronoun(obj) ? (kindRescueEnt || (focus?.id ? focus : null)) : await resolveEntity(graph, obj);
6061
7009
  if (ent) {
6062
7010
  resolvedIds = [ent.id];
6063
7011
  // Class-gate the focus update: a Commit/Session/schema object never displaces a
@@ -6092,6 +7040,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6092
7040
  let via = "composed";
6093
7041
  let recordMiss = miss;
6094
7042
  let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
7043
+ // Trust-hook gap fix (this session): scm-svf1/cardinality-monotonicity/
7044
+ // cax-maxc0's LIVE proof chases (factReadBack) have no persisted Fact to
7045
+ // attach trust.mjs's entailed hook to (syllogise.mjs's own
7046
+ // CARDINALITY_RULE_CONFIDENCE/CAX_MAXC0_RULE_CONFIDENCE doc comments explain
7047
+ // why), so they compute `min(premiseTrusts) × ruleConfidence`
7048
+ // (`entailedTrustFrom`) themselves and hand it back on the answer object —
7049
+ // surfaced here onto the turn's own record (`record.entailedTrust` below)
7050
+ // so it is audit-observable from a real chat turn, not silently discarded.
7051
+ let entailedTrust = null;
6095
7052
  // GOAL DEDUCTION: from the parsed AST when one stood (deterministic, table-driven —
6096
7053
  // see deduceGoalFromParsed); a total grammar miss (no parse at all) gets the honest
6097
7054
  // "didn't resolve" goal line verbatim, matching the operator's own wording for that
@@ -6157,7 +7114,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6157
7114
  // codebase", "how do i start") → a summary / orientation, answered before the
6158
7115
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
6159
7116
  if (miss) {
6160
- const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint });
7117
+ const meta = await metaLane(query, { graph, memoryDir, last, templates, vocabHint, focus });
6161
7118
  if (meta) {
6162
7119
  answer = meta.text; via = meta.via; recordMiss = false; handled = true;
6163
7120
  note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
@@ -6322,6 +7279,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6322
7279
  }
6323
7280
  }
6324
7281
  }
7282
+ // (2c) BARE ENTITY NAME, NO VERB AT ALL (persona-sweep 2026-07-11, Priority
7283
+ // 4): "task" / "usercontroller" — a bare, unadorned word naming a REAL
7284
+ // class/function/method/global/attribute, with no "what is"/"describe"
7285
+ // wrapper for bareWhatisShape/isAdjectiveShape (just above) to catch —
7286
+ // isConversational()'s <=3-word catch-all claims it first, same race BUG 2
7287
+ // fixed for "what is john" above, just one layer short of even a bare
7288
+ // "what is". Reuses the SAME metaFallbackEntityAnswer lookup and the SAME
7289
+ // "divert only on a REAL, UNIQUE hit" discipline: it only ever returns
7290
+ // non-null for an EXACT case-insensitive Class/Function/Method/
7291
+ // GlobalVariable/Attribute label match, so an ordinary greeting/small-talk
7292
+ // word that doesn't happen to collide with a real graph entity name is
7293
+ // completely unaffected — this can only ever ADD a real describe-style
7294
+ // answer, never take one away or guess.
7295
+ if (!bareMetaHit && isConversationalCandidate && graph) {
7296
+ const { metaFallbackEntityAnswer } = await import("./ask.mjs");
7297
+ const fallback = metaFallbackEntityAnswer(graph, String(query).trim());
7298
+ if (fallback) bareMetaHit = { text: fallback.text, replace: true };
7299
+ }
6325
7300
  if (bareMetaHit) {
6326
7301
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
6327
7302
  via = "fact"; recordMiss = false; handled = true;
@@ -6368,6 +7343,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6368
7343
  via = "fact";
6369
7344
  recordMiss = false;
6370
7345
  if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
7346
+ if (typeof fact.trust === "number") entailedTrust = fact.trust; // scm-svf1/cardinality/cax-maxc0's live-chase trust (see the `entailedTrust` declaration above)
6371
7347
  note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
6372
7348
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
6373
7349
  // Goal-line fix (item 5 follow-up, this session): mirrors the TEACH lane's
@@ -6546,7 +7522,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6546
7522
  // must never become a recallable answer. The opinion gate fires HERE, before the
6547
7523
  // short-miss's "is a <thing> a <kind>" membership hint could claim the line.
6548
7524
  if (miss && recordMiss && via === "composed") {
6549
- const nudged = nudgeAnswer(query, newFocus);
7525
+ const nudged = nudgeAnswer(query, newFocus, vocabHint);
6550
7526
  if (nudged) {
6551
7527
  answer = nudged; via = "miss";
6552
7528
  note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
@@ -6569,6 +7545,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6569
7545
  answer = described.text; via = "describe"; recordMiss = false;
6570
7546
  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");
6571
7547
  note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
7548
+ // Round 1 playtest fix: carry the resolved entity forward as the new focus,
7549
+ // same class-gated nextFocus() every other resolution path here already uses
7550
+ // — otherwise "what calls it" right after this answer dead-ends on "'it'
7551
+ // needs a selected node to refer to" despite one having just been named.
7552
+ if (described.ent) {
7553
+ resolvedIds = [described.ent.id];
7554
+ newFocus = nextFocus(graph, newFocus, described.ent);
7555
+ note(trace, `result: describe-wrapper resolved "${query}" -> ${described.ent.label} (${described.ent.id}) — becomes the new focus`);
7556
+ }
6572
7557
  }
6573
7558
  }
6574
7559
  // (4e) COMPLETIONS RESCUE (HANDOVER.md 2026-07-10 item 7) — wires src/completions/'s
@@ -6683,7 +7668,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6683
7668
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
6684
7669
  // so record + expand them, not the schema match.
6685
7670
  const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
6686
- const record = { type: "turn", ts, query, via, resolvedIds, answeredIds: finalAnsweredIds, miss: recordMiss };
7671
+ const record = {
7672
+ type: "turn", ts, query, via, resolvedIds, answeredIds: finalAnsweredIds, miss: recordMiss,
7673
+ // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
7674
+ // restatement of what the request was understood to mean — English gloss +
7675
+ // machine-parsable notation — straight off ask.mjs's own `tmct_ask.canonical`
7676
+ // (canonicalOf(parsed), §1's same `parsed` this whole ask-lane already
7677
+ // carries). `null` only when nothing parsed at all (an honest grammar miss).
7678
+ canonical: envelope?.canonical ?? null,
7679
+ // premise-derived trust for a LIVE-CHASE-ONLY entailment answer (scm-svf1/
7680
+ // cardinality-monotonicity/cax-maxc0 — see the `entailedTrust` declaration
7681
+ // above); omitted entirely when this turn didn't answer via one of those,
7682
+ // so every other turn's record shape stays byte-identical.
7683
+ ...(entailedTrust !== null ? { entailedTrust } : {}),
7684
+ };
6687
7685
  const logLines = [ts, `> ${query}`, answer, ""];
6688
7686
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
6689
7687
  // matched entities the terse render trims (see renderVerbose). `pending` carries a
@@ -6732,12 +7730,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6732
7730
 
6733
7731
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
6734
7732
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
6735
- function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
7733
+ function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null } = {}) {
6736
7734
  const ts = new Date().toISOString();
6737
7735
  return {
6738
7736
  answer,
6739
7737
  logLines: [ts, `> ${query}`, answer, ""],
6740
- record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
7738
+ record: {
7739
+ type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss,
7740
+ // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive) — see runAsk's own
7741
+ // `record.canonical` for the full doc; `null` here is the honest default for
7742
+ // every non-ask/non-assert lane this shared helper serves (a bare command
7743
+ // confirmation, an orientation card, a count) that hasn't been given a real
7744
+ // structured form to restate yet.
7745
+ canonical,
7746
+ },
6741
7747
  focus,
6742
7748
  };
6743
7749
  }
@@ -6906,18 +7912,67 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
6906
7912
  return mk(answer);
6907
7913
  }
6908
7914
 
7915
+ /** Render an ambiguous assertTurn's response — Step 3 of
7916
+ * PLAN_DID_YOU_SEE_HER_DUCK.md's "handle ambiguity all the way to the
7917
+ * response": restate the operator's ask as canonical, disambiguated prose
7918
+ * FIRST, then present EVERY surviving reading's would-be triples, each
7919
+ * labeled by which token that reading reads as the verb — reusing the same
7920
+ * "${subject} ${predicate} ${object}" shape assertTurn's own confirmation
7921
+ * line already uses (below), and the same "this could mean more than one
7922
+ * thing" wording ask.mjs's OWN disambiguation surface uses for query-side
7923
+ * ambiguity (renderCore, src/ask.mjs), so the two never disagree in tone.
7924
+ * Nothing is written to memory here — an ambiguous sentence, unlike a
7925
+ * resolved one, has no single fact tmct can honestly commit to. */
7926
+ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
7927
+ const options = ambiguous.readings.map((r, idx) => {
7928
+ const shown = r.triples
7929
+ .map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
7930
+ .join("; ");
7931
+ return `${idx + 1}) reading "${r.verbLemma}" as the verb: ${shown}`;
7932
+ });
7933
+ return [
7934
+ `You asked: "${line}" — this could mean more than one thing:`,
7935
+ ...options,
7936
+ "Nothing was remembered yet — reply with the reading you meant (or rephrase) and I'll note it.",
7937
+ ].join("\n");
7938
+ }
7939
+
6909
7940
  /** A declarative ACE-grammar sentence → assert into memory + confirm; null on
6910
7941
  * any grammar miss / residue / import failure so the query engine keeps first
6911
7942
  * refusal on everything else. Lazy imports + catch-all: the grammar layer can
6912
- * never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
7943
+ * never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory.
7944
+ *
7945
+ * AMBIGUITY (Step 3, PLAN_DID_YOU_SEE_HER_DUCK.md): checked FIRST, via the
7946
+ * additive parseAceAmbiguous (grammar/ace.mjs) — a separate, breadth-first
7947
+ * scan that survives every verb-position split rather than committing to the
7948
+ * first, pruning only genuine dead ends. It returns null for the
7949
+ * overwhelming majority of sentences (anything not relation-shaped, or
7950
+ * relation-shaped with 0-1 surviving readings), so this adds exactly one
7951
+ * cheap check ahead of the EXISTING, unchanged parseAce path below — every
7952
+ * single-reading sentence renders byte-identically to before. */
6913
7953
  async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
6914
7954
  try {
6915
- const { parseAce } = await import("./grammar/ace.mjs");
7955
+ const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
6916
7956
  // A session handle carries its own loaded lexicon (createSession loads it once);
6917
7957
  // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
6918
7958
  // immutable, so sharing one reference across concurrent handles is re-entrant.
6919
7959
  let lex = lexicon;
6920
7960
  if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
7961
+ const ambiguous = parseAceAmbiguous(line, lex);
7962
+ if (ambiguous) {
7963
+ const { normFactTerm } = await import("./memory/core.mjs");
7964
+ const answer = renderAmbiguousAssert(line, ambiguous, normFactTerm);
7965
+ // Genuinely ambiguous — no single triple was committed, so the canonical
7966
+ // form is every surviving reading's own would-be triple set, same idiom
7967
+ // as ask.mjs's canonicalOf() for a parse-level tie.
7968
+ const canonical = {
7969
+ english: ambiguous.readings.map((r) => `reading "${r.verbLemma}" as the verb`).join(" — or — "),
7970
+ machine: ambiguous.readings.map((r) => r.triples
7971
+ .map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
7972
+ .join(", ")).join(" | "),
7973
+ };
7974
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
7975
+ }
6921
7976
  const parse = parseAce(line, lex);
6922
7977
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
6923
7978
  const { assertSentence } = await import("./grammar/assert.mjs");
@@ -6956,7 +8011,19 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
6956
8011
  .join("; ");
6957
8012
  const n = res.ids.length;
6958
8013
  const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
6959
- return plainTurn(line, answer, { command: "assert", via: "assert", focus });
8014
+ // PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
8015
+ // restatement of what was committed — `english` reuses the SAME confirmation
8016
+ // text just shown (already tmct's own preferred subject-predicate-object
8017
+ // phrasing, per normFactTerm), `machine` is the same fact(s) in the compact
8018
+ // notation ask.mjs's canonicalOf() uses for query-side parses, so both lanes
8019
+ // share one consistent syntax.
8020
+ const canonical = {
8021
+ english: shown,
8022
+ machine: res.triples
8023
+ .map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
8024
+ .join(", "),
8025
+ };
8026
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
6960
8027
  } catch {
6961
8028
  return null; // grammar unavailable / write failed — fall through to the engine
6962
8029
  }
@@ -7015,6 +8082,78 @@ function morePage(query, { last, focus }) {
7015
8082
  // gain.
7016
8083
  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;
7017
8084
 
8085
+ /** PLAN_CONVERSATION.md Finding 4's remaining gap: a DISCONTIGUOUS verb frame,
8086
+ * "SUBJECT uses OBJECT as its/a base(class)" — "uses" is split from its own
8087
+ * qualifier ("as its base") around the object, so no CONTIGUOUS phrase table
8088
+ * entry (VERB_TO_KIND/findPhrase, both ask-vocab.mjs/keywords.mjs, only ever
8089
+ * match a contiguous run of words) could ever register it. "uses" itself is
8090
+ * ALSO already claimed by the query-side "uses" UNION (imports+calls+
8091
+ * callsSymbol, KIND_UNIONS in ask.mjs) — a bare "X uses Y" must keep meaning
8092
+ * that; only THIS "...as its base"-qualified shape means the single stored
8093
+ * `inherits` relation (RELATIONS.inherits, ask-vocab.mjs — "Class -> Class:
8094
+ * subject's declared base resolves to object", the exact same subclassOf
8095
+ * semantics "is a kind of"/"inherits from" already carry).
8096
+ *
8097
+ * Fixed here by REWRITING the raw turn text, once, before any dispatch lane
8098
+ * sees it (same early-rewrite spot as INDIRECT_REQUEST_RE just above) — into
8099
+ * the equivalent ALREADY-WORKING "is a kind of" surface form, rather than
8100
+ * inventing a parallel teach/ask mechanism for a brand-new predicate
8101
+ * vocabulary entry. "is a kind of" is itself one of RELATIONS.inherits.verbs
8102
+ * (ask-vocab.mjs), and its teach (bare "X is a kind of Y" -> rdfs:subClassOf)
8103
+ * and ask readbacks (ISA_ASK_RE yes/no; BARE_WHATIS_RE + splitMetaPredicate's
8104
+ * "what is X a kind of" forward read) are existing, separately-tested
8105
+ * mechanisms — reusing them end to end means this fix needs no new predicate,
8106
+ * no new stored fact shape, and no changes to factAnswer's cascade at all.
8107
+ *
8108
+ * Four shapes recognized (checked in this order — see each RE's own
8109
+ * anchoring for why order matters: the WH-object and aux-fronted forms must
8110
+ * win before the bare-declarative TEACH form gets a chance to misread an
8111
+ * aux-fronted question's leading "does"/"what" as part of the subject):
8112
+ * 1. mid-sentence WH-object ask ("SUBJECT uses which controller as its
8113
+ * base" / "SUBJECT uses what as its base" — Finding 4's own repro
8114
+ * shape) -> "what is SUBJECT a kind of";
8115
+ * 2. WH-fronted forward ask ("what does SUBJECT use as its base") ->
8116
+ * "what is SUBJECT a kind of";
8117
+ * 3. aux-fronted yes/no ask ("does SUBJECT use OBJECT as its base") ->
8118
+ * "is SUBJECT a kind of OBJECT";
8119
+ * 4. bare declarative teach ("SUBJECT uses OBJECT as its base", never
8120
+ * ending in "?" — mirrors generalVerbTeach's own question-mark decline
8121
+ * guard) -> "SUBJECT is a kind of OBJECT".
8122
+ *
8123
+ * The "as ___" qualifier tolerates the reasonable surface variants named in
8124
+ * the operator's own worked examples: an optional "its"/"the"/"a"/"an"
8125
+ * determiner (or none at all), and "base"/"parent"/"base class"/"parent
8126
+ * class" as the qualifier noun. */
8127
+ const BASE_QUALIFIER_SRC = "as\\s+(?:its|the|an?)?\\s*(?:base\\s+class|parent\\s+class|base|parent)";
8128
+ const USES_AS_BASE_WH_ASK_RE = new RegExp(
8129
+ `^(.+?)\\s+uses?\\s+(?:which\\s+[\\w'-]+|what)\\s+${BASE_QUALIFIER_SRC}\\s*\\??$`, "i");
8130
+ const USES_AS_BASE_WHAT_FRONT_RE = new RegExp(
8131
+ `^what\\s+(?:does|do|did)\\s+(.+?)\\s+uses?\\s+${BASE_QUALIFIER_SRC}\\s*\\??$`, "i");
8132
+ const USES_AS_BASE_YESNO_RE = new RegExp(
8133
+ `^(?:does|do|did)\\s+(.+?)\\s+uses?\\s+(.+?)\\s+${BASE_QUALIFIER_SRC}\\s*\\??$`, "i");
8134
+ const USES_AS_BASE_TEACH_RE = new RegExp(
8135
+ `^(.+?)\\s+uses?\\s+(.+?)\\s+${BASE_QUALIFIER_SRC}\\s*[.!]*$`, "i");
8136
+
8137
+ /** Recognize + rewrite one of the four shapes above, or return null (no
8138
+ * match — the caller leaves the text untouched, same "honest decline, never
8139
+ * a guess" discipline as every other frame in this file). Pure text in, text
8140
+ * out — no grounding/lexicon lookups here, matching every other early-rewrite
8141
+ * step (INDIRECT_REQUEST_RE, normalize.mjs's own preamble frames). */
8142
+ function rewriteUsesAsBaseFrame(text) {
8143
+ const t = String(text || "").trim();
8144
+ if (!t) return null;
8145
+ let m = t.match(USES_AS_BASE_WH_ASK_RE);
8146
+ if (m) return `what is ${m[1].trim()} a kind of`;
8147
+ m = t.match(USES_AS_BASE_WHAT_FRONT_RE);
8148
+ if (m) return `what is ${m[1].trim()} a kind of`;
8149
+ m = t.match(USES_AS_BASE_YESNO_RE);
8150
+ if (m) return `is ${m[1].trim()} a kind of ${m[2].trim()}`;
8151
+ if (/\?\s*$/.test(t)) return null; // an unrecognized question shape — never guessed as a teach
8152
+ m = t.match(USES_AS_BASE_TEACH_RE);
8153
+ if (m) return `${m[1].trim()} is a kind of ${m[2].trim()}`;
8154
+ return null;
8155
+ }
8156
+
7018
8157
  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, tel = null, biasByBundle = {} } = {}) {
7019
8158
  const line = String(input ?? "").trim();
7020
8159
  // The captured residue is used for RECOGNITION at every dispatch site below
@@ -7022,7 +8161,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
7022
8161
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
7023
8162
  // — restored centrally inside withLast (below), once, for every dispatch path.
7024
8163
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
7025
- const workingLine = indirectMatch ? indirectMatch[1].trim() : line;
8164
+ const preRewriteLine = indirectMatch ? indirectMatch[1].trim() : line;
8165
+ // Finding 4's discontiguous-frame rewrite (rewriteUsesAsBaseFrame, above):
8166
+ // applied here, once, before ANY dispatch lane (bareCmd/conversationalTurn/
8167
+ // assertTurn/runAsk) sees the text — same reasoning as indirectMatch just
8168
+ // above it. `baseFrameRewrite` is null (no-op) for every turn that doesn't
8169
+ // match one of the four discontiguous shapes, so this can only ever ADD a
8170
+ // recognized shape, never change behavior for anything else.
8171
+ const baseFrameRewrite = rewriteUsesAsBaseFrame(preRewriteLine);
8172
+ const workingLine = baseFrameRewrite || preRewriteLine;
7026
8173
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
7027
8174
  // narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
7028
8175
  // matching every OTHER optional collaborator here — templates/memoryDir/lexicon
@@ -7050,10 +8197,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
7050
8197
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
7051
8198
  const finished = finish(result, { graph });
7052
8199
  // Bug F point 3 fidelity: every dispatch path below built its own record off
7053
- // `workingLine` (the indirect-request wrapper stripped, above) restore the
7054
- // ORIGINAL raw `line` into record.query and the logged "> …" transcript echo
7055
- // here, once, centrally, for every path (they all funnel through withLast).
7056
- if (indirectMatch) {
8200
+ // `workingLine` (the indirect-request wrapper stripped, and/or Finding 4's
8201
+ // discontiguous-frame rewrite applied, above) restore the ORIGINAL raw
8202
+ // `line` into record.query and the logged "> …" transcript echo here, once,
8203
+ // centrally, for every path (they all funnel through withLast).
8204
+ if (indirectMatch || baseFrameRewrite) {
7057
8205
  if (finished.record) finished.record.query = line;
7058
8206
  if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
7059
8207
  }
@@ -7177,60 +8325,65 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
7177
8325
  * corpus seed, so re-runs skip without even reading the slice. */
7178
8326
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
7179
8327
 
7180
- /** Seed the starter corpus into <repo>/.tmct/memory once a loop over every
7181
- * ACTIVE `corpus`-kind extension entry (src/extensions.mjs's resolveExtensions,
7182
- * the SAME seam `tmct init`'s seed step and `tmct init --corpus <id>` now
7183
- * share), in the resolver's FIXED order: seon first, then conceptnet, then any
7184
- * other active bundle sorted by name. seon runs first so its curated facts win
7185
- * the content-hash idempotency race a term the ConceptNet slice also carries
7186
- * keeps the seon provenance. Idempotent twice over (the marker short-circuits;
7187
- * seedMemory content-hashes fact ids) and failure-tolerated PER BUNDLE
7188
- * (seedActiveCorpusEntries): one bad third-party pack degrades to "not seeded"
7189
- * for that bundle alone (its error is recorded, not silently swallowed) while
7190
- * every other bundle still lands — never an error before the prompt. Returns
7191
- * { appended, skipped, total, seon, conceptnet, perBundle } on a fresh seed
7192
- * (the banner counts stay honest), null when skipped/failed outright. */
7193
- async function seedBootstrapMemory(repo) {
7194
- const marker = join(repo, SEED_MARKER_REL);
7195
- try {
7196
- await readFile(marker, "utf8");
7197
- return null; // already seeded the marker is authoritative
7198
- } catch { /* no marker first run */ }
8328
+ /** Bootstrap <repo> for tmct on a graph-less first run PLAN_SEED.md §2's
8329
+ * `createSession`→`initRepo` auto-init CONVERGENCE: this used to run its own
8330
+ * bespoke seed-only pair (resolveExtensions + seedActiveCorpusEntries)
8331
+ * directly, writing ONLY the in-memory seed marker a fresh `import {
8332
+ * runChat } from '...'; await runChat({repoPath})` on a bare directory got
8333
+ * seeded facts but no persisted, inspectable `tmct.toml`/`.tmct/init.json`,
8334
+ * unlike CLI `tmct init`. Now delegates to the FULL `initRepo(repo, {persona:
8335
+ * PERSONA_PRESETS.human, env})` the exact same function `tmct init` calls
8336
+ * so a library consumer gets the SAME "docker pull" first-run experience:
8337
+ * real `.tmct/` scaffold, a written `tmct.toml`, `.tmct/init.json`
8338
+ * provenance, not just a seed marker.
8339
+ *
8340
+ * Verified NOT to double-scaffold or double-seed: `initRepo` only writes
8341
+ * `tmct.toml` when absent (or `force`), only writes the seed marker/reseeds
8342
+ * when the marker is absent, and its own provenance write is a plain
8343
+ * idempotent overwrite (never destructive) — every one of its own guards
8344
+ * fires correctly whether IT was the first call ever, or a repeat call after
8345
+ * a prior CLI `tmct init` (or a prior `createSession` bootstrap) already ran.
8346
+ * `persona: PERSONA_PRESETS.human` only has any effect on a genuinely FRESH
8347
+ * write (no existing tmct.toml) — on an already-initialized repo `initRepo`
8348
+ * reads the EXISTING file back untouched, so this can never override an
8349
+ * operator's own `--with-persona code`/custom `[extensions]` choice.
8350
+ *
8351
+ * `entries`/`seedActiveCorpusEntries` are no longer called directly here —
8352
+ * `initRepo` calls them internally, in the SAME resolver's fixed order
8353
+ * (seon, conceptnet, then every other active bundle sorted by name).
8354
+ * Returns `initRepo`'s own `seedResult` ({ appended, skipped, total, seon,
8355
+ * conceptnet, perBundle }) on a fresh seed (the banner counts stay honest,
8356
+ * byte-identical shape to before), null when skipped/failed outright — the
8357
+ * CALLER's contract is unchanged even though the implementation now goes
8358
+ * through one shared code path instead of two. */
8359
+ async function seedBootstrapMemory(repo, env = process.env) {
7199
8360
  try {
7200
- const { entries } = await resolveExtensions(repo);
7201
- const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(repo, entries);
7202
- const res = {
7203
- appended, skipped, total, perBundle,
7204
- // seon/conceptnet stay named fields (not just perBundle lookups) so the
7205
- // banner's default two-bundle rendering (below) — and any external
7206
- // reader keyed on `.seon`/`.conceptnet` — stays byte-identical.
7207
- seon: perBundle.seon?.appended || 0,
7208
- conceptnet: perBundle.conceptnet?.appended || 0,
7209
- };
7210
- await mkdir(dirname(marker), { recursive: true });
7211
- await writeFile(marker, JSON.stringify({
7212
- seededAt: new Date().toISOString(),
7213
- appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
7214
- perBundle,
7215
- }) + "\n");
7216
- return res;
8361
+ const { initRepo, PERSONA_PRESETS } = await import("./init.mjs");
8362
+ const result = await initRepo(repo, { persona: PERSONA_PRESETS.human, env });
8363
+ return result.seeded ? result.seedResult : null;
7217
8364
  } catch {
7218
- return null; // corpus unavailable — bootstrap proceeds unseeded
8365
+ return null; // repo/corpus unavailable — bootstrap proceeds unseeded
7219
8366
  }
7220
8367
  }
7221
8368
 
7222
- /** The seed banner line — byte-identical to before for the default zero-config
7223
- * seon+conceptnet case; a THIRD (or more) active bundle appends its own
7224
- * "<n> <bundle-name>" clause rather than changing the base sentence, so a
7225
- * fresh `TMCT_NO_SEED`-unset run with no tmct.toml renders EXACTLY what
7226
- * test/wiring-seed.test.mjs's SEED_BANNER_RE already pins. */
8369
+ /** The seed banner line — BUNDLE-LIST-DRIVEN (PLAN_SEED.md §2 fix): renders
8370
+ * every `perBundle` entry that actually appended facts this run, in the
8371
+ * entries' own fixed order (src/extensions.mjs's resolveExtensions
8372
+ * seon, conceptnet, then the rest sorted by name), joined with " + ". No
8373
+ * bundle is privileged as one of "the first two" any more — with the
8374
+ * persona flip (seon/conceptnet now opt-in, `human` the new default) the
8375
+ * old hardcoded "N curated SEON + N ConceptNet" shape would render the
8376
+ * misleading "seeded 664 starter facts (0 curated SEON + 0 ConceptNet + 664
8377
+ * human)" for the new default. A single active bundle renders with no
8378
+ * " + " at all ("seeded 664 starter facts (664 human) — …"), matching the
8379
+ * common case cleanly. test/wiring-seed.test.mjs's SEED_BANNER_RE is
8380
+ * relaxed to match this generic form (still asserting the SHAPE, not a
8381
+ * brittle literal — see that test file's own header comment). */
7227
8382
  function seedBannerLine(seeded) {
7228
- const extra = Object.entries(seeded.perBundle || {})
7229
- .filter(([name]) => name !== "seon" && name !== "conceptnet")
8383
+ const clauses = Object.entries(seeded.perBundle || {})
7230
8384
  .filter(([, r]) => r && r.appended > 0)
7231
8385
  .map(([name, r]) => `${r.appended} ${name}`);
7232
- const extraClause = extra.length ? ` + ${extra.join(" + ")}` : "";
7233
- return `seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet${extraClause}) — /memory to inspect`;
8386
+ return `seeded ${seeded.appended} starter facts (${clauses.join(" + ")}) /memory to inspect`;
7234
8387
  }
7235
8388
 
7236
8389
  /** Whether THIS repo's memory actually carries the corpus seed — the marker is
@@ -7249,15 +8402,16 @@ async function hasSeededVocabulary(repo) {
7249
8402
  /** A "try this" vocabulary-example clause that's PROVABLY correct in the session
7250
8403
  * it's shown, mirroring the discipline orientationExamples() already applies to
7251
8404
  * structural examples (never offer an example that isn't confirmed to resolve).
7252
- * `cache` is confirmed live: present in corpus/seon/definitions.jsonl, backed by
7253
- * a corpus:seon concept fact, and a recognized lexicon noun — but only actually
7254
- * answerable once the seed has run. When it hasn't (TMCT_NO_SEED=1,
7255
- * seed.enabled=false, or corpus load failure), offering it would be a lie worse
7256
- * than no example swap to an unconditionally-true pointer instead (the teach
7257
- * lane and `tmct init` both work with zero preconditions). Computed ONCE per
7258
- * session (createSession), not per turn.
8405
+ * `dog` is confirmed live (PLAN_SEED.md's default human-world persona): present
8406
+ * in corpus/tier2/human.jsonl's human-nature clump, backed by a corpus:human
8407
+ * concept fact, and a recognized lexicon noun but only actually answerable
8408
+ * once the seed has run. When it hasn't (TMCT_NO_SEED=1, seed.enabled=false, or
8409
+ * corpus load failure), offering it would be a lie worse than no example —
8410
+ * swap to an unconditionally-true pointer instead (the teach lane and `tmct
8411
+ * init` both work with zero preconditions). Computed ONCE per session
8412
+ * (createSession), not per turn.
7259
8413
  * The unseeded branch's teach clause is a CONCRETE pair too, for the same
7260
- * reason `cache` is concrete in the seeded branch: playtest found that an
8414
+ * reason `dog` is concrete in the seeded branch: playtest found that an
7261
8415
  * abstract "every X is a Y" invites a curious user to fill X/Y with an
7262
8416
  * intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
7263
8417
  * the closed ACE lexicon) and hit the teach-miss dead-end right after being
@@ -7266,7 +8420,7 @@ async function hasSeededVocabulary(repo) {
7266
8420
  * test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
7267
8421
  function vocabExampleHint(seeded) {
7268
8422
  return seeded
7269
- ? 'Try "what is a cache" for general vocabulary.'
8423
+ ? 'Try "what is a dog" for general vocabulary.'
7270
8424
  : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
7271
8425
  }
7272
8426
 
@@ -7314,12 +8468,24 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
7314
8468
  */
7315
8469
  export async function createSession({
7316
8470
  repoPath,
8471
+ graphPaths,
8472
+ configPath,
7317
8473
  source = defaultSource,
7318
8474
  env = process.env,
7319
8475
  cwd = process.cwd(),
7320
8476
  gitRoot = gitToplevel,
7321
8477
  ephemeral = false,
7322
8478
  narrate = false,
8479
+ // PLAN_SEED.md §6's storage-backend seam: "file" (default, unchanged) keeps
8480
+ // memoryDir a plain repo-path string (Backend A, memory/core.mjs). "memory"
8481
+ // selects Backend B (createInMemoryStore — zero disk I/O, session-scoped, no
8482
+ // module-global state). "sqlite" selects Backend C (createSqliteMemoryStore
8483
+ // — a live node:sqlite connection kept open for the session's lifetime,
8484
+ // lazily imported only when this is actually chosen). TMCT_MEMORY_BACKEND
8485
+ // mirrors the TMCT_EPHEMERAL/TMCT_NARRATE on/off env convention. No CLI flag
8486
+ // wires this yet (bin/tmct.mjs's flag parsing is out of this change's
8487
+ // scope) — a library/test caller sets the option directly for now.
8488
+ memoryBackend = null,
7323
8489
  } = {}) {
7324
8490
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
7325
8491
  // write NOTHING back into it. The shipped examples run this way so a demo never
@@ -7335,27 +8501,59 @@ export async function createSession({
7335
8501
  // (see `turn()` below: a turn result's `narrate` field, when present,
7336
8502
  // updates this closure-private variable). Default OFF, as the operator asked.
7337
8503
  let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
7338
- // Graph resolution order for the chat surface (documented; --repo wins):
7339
- // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
7340
- // 2. TMCT_GRAPH_FILE env loads that graph anywhere (loadConfig reads it), so
7341
- // `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo the chat
7342
- // surface used to ignore it (only the `cli` tool path honoured it). The repo
7343
- // for logs/memory is still the git root / cwd; only the graph file is overridden.
7344
- // 3. git root → <root>/.tmct/graph.json (the default target).
7345
- // 4. cwd → <cwd>/.tmct/graph.json (not a git repo).
8504
+ // Graph resolution order for the chat surface (documented; --repo still
8505
+ // wins over TMCT_GRAPH_FILE env a deliberate, TESTED chat-specific
8506
+ // contract predating this batch: an explicit --repo means "use exactly
8507
+ // this repo's graph", never silently redirected by env. Every other tier
8508
+ // below delegates to the shared resolver, src/cli-args.mjs's
8509
+ // resolveRuntimeConfig:
8510
+ // 0. --graph <path> (repeatable, graphPaths) → the NEW top tier: an explicit
8511
+ // graph file (or files multi-graph, see src/graph-merge.mjs), wins
8512
+ // outright over everything below, including --repo.
8513
+ // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph);
8514
+ // tmct.toml's graph_file/graph_files at that repo is now ALSO consulted
8515
+ // (new — chat used to hardcode the default regardless of tmct.toml),
8516
+ // but TMCT_GRAPH_FILE env is deliberately excluded from this tier.
8517
+ // 2. TMCT_GRAPH_FILE env → loads that graph anywhere, so
8518
+ // `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo.
8519
+ // 3. tmct.toml's graph_file/graph_files at the resolved repo root (--config
8520
+ // <path>, `configPath`, can point this at an alternate location) — NEW.
8521
+ // 4. git root → <root>/.tmct/graph.json (the default target).
8522
+ // 5. cwd → <cwd>/.tmct/graph.json (not a git repo).
7346
8523
  // Default the target to the GIT ROOT, not raw cwd: running from a nested package
7347
8524
  // dir (npm sets cwd there) would otherwise index only that package's ~few modules
7348
8525
  // instead of the whole repo.
7349
8526
  let repo;
7350
8527
  let config;
7351
- if (repoPath) { repo = repoPath; config = configFor(repoPath); }
7352
- else {
8528
+ const explicitGraphs = (graphPaths || []).filter(Boolean);
8529
+ if (explicitGraphs.length) {
8530
+ repo = repoPath || gitRoot(cwd) || cwd;
8531
+ const resolvedGraphs = explicitGraphs.map((p) => resolve(cwd, p));
8532
+ config = resolvedGraphs.length > 1
8533
+ ? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
8534
+ : { graphFile: resolvedGraphs[0] };
8535
+ } else if (repoPath) {
8536
+ repo = repoPath;
8537
+ // env is deliberately withheld from resolveRuntimeConfig here (passed as
8538
+ // {}), so its own env-beats-repo-default tier can never fire — the ONLY
8539
+ // way this differs from the old hardcoded `{graphFile: join(repoPath,
8540
+ // DEFAULT_GRAPH_REL)}` default is that a repo's own tmct.toml
8541
+ // graph_file/graph_files (or an explicit --config override) is now
8542
+ // honored too.
8543
+ const argv = ["--repo", repoPath];
8544
+ if (configPath) argv.push("--config", configPath);
8545
+ ({ config } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
8546
+ } else {
7353
8547
  const root = gitRoot(cwd);
7354
8548
  repo = root || cwd;
7355
8549
  const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
7356
- // TMCT_GRAPH_FILE (via loadConfig) overrides the repo-derived default graph path;
7357
- // otherwise the repo's own .tmct/graph.json is the target.
7358
- config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
8550
+ if (envGraph) {
8551
+ config = loadConfig(env, cwd);
8552
+ } else {
8553
+ const argv = [];
8554
+ if (configPath) argv.push("--config", configPath);
8555
+ ({ config } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
8556
+ }
7359
8557
  }
7360
8558
 
7361
8559
  // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
@@ -7431,15 +8629,62 @@ export async function createSession({
7431
8629
  catch { /* best-effort — see above */ }
7432
8630
  };
7433
8631
 
8632
+ // PLAN_SEED.md §6's storage-backend seam: `memoryDir` is the opaque token
8633
+ // every memory/core.mjs call in this file threads through unchanged (it
8634
+ // never inspects `dir` itself — that's the whole point of the seam). Backend
8635
+ // A (default, unchanged) keeps it the plain repo string every earlier
8636
+ // version of this function used. Backend B/C swap in a handle instead;
8637
+ // `closeMemoryStore` is a no-op unless Backend C actually opened a
8638
+ // connection (Backend C's node:sqlite import is lazy — it only happens if
8639
+ // this branch is actually taken).
8640
+ const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || "").trim().toLowerCase();
8641
+ let memoryDir = repo;
8642
+ let closeMemoryStore = async () => {};
8643
+ if (backendChoice === "memory") {
8644
+ const { createInMemoryStore } = await import("./memory/core.mjs");
8645
+ memoryDir = createInMemoryStore();
8646
+ } else if (backendChoice === "sqlite") {
8647
+ const { createSqliteMemoryStore, closeSqliteMemoryStore } = await import("./memory/core.mjs");
8648
+ const dbPath = join(repo, ".tmct", "memory", "graph.sqlite");
8649
+ await mkdir(dirname(dbPath), { recursive: true });
8650
+ const handle = await createSqliteMemoryStore(dbPath);
8651
+ memoryDir = handle;
8652
+ closeMemoryStore = async () => closeSqliteMemoryStore(handle);
8653
+ }
8654
+
7434
8655
  const empty = graph.individuals.length === 0;
7435
8656
  // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
7436
8657
  // .tmct/memory so vocabulary questions ("what is a cache?") have something
7437
8658
  // honest to stand on from turn one. Guarded three ways: only the empty
7438
8659
  // bootstrap (a fixture/provider graph never seeds), only once (the marker),
7439
8660
  // and never when TMCT_NO_SEED=1 opts out.
8661
+ //
8662
+ // Backend B/C follow-ups (documented, not fixed here — out of this change's
8663
+ // scope):
8664
+ // - seedBootstrapMemory/seedActiveCorpusEntries/hasSeededVocabulary all
8665
+ // resolve their own marker file + corpus writes directly off the STRING
8666
+ // `repo` path (extensions.mjs territory, not touched by this seam), so
8667
+ // they'd seed the on-disk Backend-A file even for a Backend B/C session
8668
+ // rather than the handle actually in use. Skipping W3 seeding for a
8669
+ // non-default backend is the honest choice for now.
8670
+ // - sessions.mjs's OWN per-turn utterance mirror (appendSessionToGraph ->
8671
+ // recordSessionMemory -> appendUtterances) derives its OWN repoDir from
8672
+ // config.graphFile independently of this function's `memoryDir`, and
8673
+ // reads the session LOG/sidecar files by real path — it can't simply be
8674
+ // handed a Backend B/C handle (that path needs a real directory for the
8675
+ // log/sidecar reads, not just for the memory write). So a Backend B/C
8676
+ // session's Utterance/Session individuals (NEVER Facts/Rules — those
8677
+ // only ever go through THIS function's `memoryDir`, see runTurn's
8678
+ // options below) still land in an ordinary Backend-A .tmct/memory/
8679
+ // graph.json, independent of the chosen backend. Teaching that path to
8680
+ // thread a handle too (and fold.mjs's own direct writeMemoryGraph
8681
+ // alongside it) is future work for whoever finishes the seeding/persona
8682
+ // work (PLAN_SEED.md's own §2/§3) — out of this change's scope.
8683
+ // Taught FACTS themselves are unaffected by this gap: only the
8684
+ // conversational transcript mirror leaks onto disk, never the facts.
7440
8685
  let seeded = null;
7441
- if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
7442
- seeded = await seedBootstrapMemory(repo);
8686
+ if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
8687
+ seeded = await seedBootstrapMemory(repo, env);
7443
8688
  }
7444
8689
  // vocabHint: computed ONCE per session (not per-turn — see runTurn's own
7445
8690
  // per-call fallback for direct/library callers). `seeded` is only truthy when
@@ -7479,7 +8724,7 @@ export async function createSession({
7479
8724
  let closed = false;
7480
8725
 
7481
8726
  return {
7482
- repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
8727
+ repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
7483
8728
  logFile, sidecarFile, bannerLines, empty, biasByBundle,
7484
8729
  // Mutable between-turn state — read-only to the caller, so a shell can render the
7485
8730
  // prompt/expand-hint without reaching into runTurn's threading.
@@ -7498,7 +8743,7 @@ export async function createSession({
7498
8743
  async turn(line) {
7499
8744
  let result;
7500
8745
  try {
7501
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle });
8746
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, vocabHint, tel, biasByBundle });
7502
8747
  } catch (e) {
7503
8748
  const ts = new Date().toISOString();
7504
8749
  const message = e instanceof Error ? e.message : String(e);
@@ -7531,7 +8776,8 @@ export async function createSession({
7531
8776
  },
7532
8777
 
7533
8778
  /** End-of-session close: end lines in both artifacts, the final graph upsert
7534
- * (which also triggers the memory fold), stream flush. Idempotent. */
8779
+ * (which also triggers the memory fold), stream flush, the Backend C
8780
+ * connection close (a no-op for Backend A/B). Idempotent. */
7535
8781
  async close() {
7536
8782
  if (closed) return;
7537
8783
  closed = true;
@@ -7541,6 +8787,7 @@ export async function createSession({
7541
8787
  await upsertGraph(endIso);
7542
8788
  await new Promise((resolve) => stream.end(resolve));
7543
8789
  await new Promise((resolve) => sidecar.end(resolve));
8790
+ await closeMemoryStore();
7544
8791
  },
7545
8792
  };
7546
8793
  }
@@ -7555,6 +8802,8 @@ export async function createSession({
7555
8802
  */
7556
8803
  export async function runChat({
7557
8804
  repoPath,
8805
+ graphPaths,
8806
+ configPath,
7558
8807
  input = process.stdin,
7559
8808
  output = process.stdout,
7560
8809
  source = defaultSource,
@@ -7563,6 +8812,7 @@ export async function runChat({
7563
8812
  gitRoot = gitToplevel,
7564
8813
  ephemeral = false,
7565
8814
  narrate = false,
8815
+ memoryBackend = null,
7566
8816
  } = {}) {
7567
8817
  // createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
7568
8818
  // output until it fully resolves — found live: an operator reported `npm run chat`
@@ -7570,7 +8820,7 @@ export async function runChat({
7570
8820
  // fast subsequent run just flashes it briefly) and removes the "is this even
7571
8821
  // running" uncertainty during the one case that's genuinely slow.
7572
8822
  output.write("tmct — starting…\n");
7573
- const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral, narrate });
8823
+ const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, memoryBackend });
7574
8824
 
7575
8825
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
7576
8826
  for (const line of session.bannerLines) output.write(dim(line) + "\n");