@polycode-projects/the-mechanical-code-talker 1.5.5 → 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 (45) 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 +1273 -137
  24. package/src/cli-args.mjs +164 -0
  25. package/src/codegraph.mjs +170 -32
  26. package/src/extensions.mjs +100 -19
  27. package/src/grammar/ace.mjs +85 -3
  28. package/src/grammar/lexicon-core.json +9531 -63
  29. package/src/grammar/lexicon.mjs +58 -8
  30. package/src/graph-merge.mjs +114 -0
  31. package/src/index.mjs +14 -0
  32. package/src/init.mjs +40 -14
  33. package/src/interpret/normalize.mjs +75 -1
  34. package/src/interpret/strategies/grammar.mjs +10 -0
  35. package/src/interpret/strategies/keywords.mjs +20 -0
  36. package/src/interpret/strategies/noise-strip.mjs +73 -4
  37. package/src/memory/core.mjs +466 -8
  38. package/src/router/goal-reasoner.mjs +41 -7
  39. package/src/router/guardrail.mjs +37 -7
  40. package/src/router/resolver.mjs +50 -4
  41. package/src/sessions.mjs +5 -1
  42. package/src/source.mjs +54 -1
  43. package/src/syllogise.mjs +398 -27
  44. package/src/toml-config.mjs +13 -4
  45. 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, normalizeQuery, 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();
@@ -2346,8 +2550,29 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2346
2550
  // a parent", which unknownSubjectFallback already stores as
2347
2551
  // father ⊑ parent today (finding 2: "parent" is already a lexicon noun).
2348
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 "));
2349
- const raw = stripKindOf(stripYour(rawInput));
2350
- 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)));
2351
2576
 
2352
2577
  // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
2353
2578
  // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
@@ -2357,7 +2582,18 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2357
2582
  // docblock above for why.
2358
2583
  const pronounSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
2359
2584
  const pronounMatch = pronounSrc.match(TEACH_PRONOUN_RE);
2360
- 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))) {
2361
2597
  const pronoun = pronounMatch[1];
2362
2598
  return {
2363
2599
  text: `I can't store a fact about "${pronoun}" as a class — pronouns aren't things I can classify. `
@@ -2377,8 +2613,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2377
2613
  // is an obviously code-shaped proper name and just as strong a signal that
2378
2614
  // this isn't ordinary prose. Either side capitalized is now enough.
2379
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);
2380
2622
  const own = ownSrc.match(OWNS_TEACH_RE);
2381
- if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)
2623
+ if (own && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion
2382
2624
  && (wrapped || /^[A-Z]/.test(own[1]) || /^[A-Z]/.test(own[2]))) {
2383
2625
  const stored = await teachFact(memoryDir, sessionId, {
2384
2626
  subject: own[2], predicate: OWNED_BY_PREDICATE, object: own[1],
@@ -2391,7 +2633,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2391
2633
  // (a genuine yes/no QUESTION, handled by factReadBack instead) never lands
2392
2634
  // a bogus fact here.
2393
2635
  const ownPassive = ownSrc.match(OWNS_PASSIVE_TEACH_RE);
2394
- 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]))) {
2395
2637
  const stored = await teachFact(memoryDir, sessionId, {
2396
2638
  subject: ownPassive[1], predicate: OWNED_BY_PREDICATE, object: ownPassive[2],
2397
2639
  });
@@ -2407,7 +2649,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2407
2649
  // to part of speech, so a role noun like "father" mints mgx:father the same
2408
2650
  // way a general verb would; an ordinary Fact, no new storage shape.
2409
2651
  const rel = ownSrc.match(RELATION_FACT_TEACH_RE);
2410
- if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2652
+ if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2411
2653
  const stored = await teachFact(memoryDir, sessionId, {
2412
2654
  subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3],
2413
2655
  });
@@ -2427,7 +2669,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2427
2669
  // a leading determiner — "every"/"a"/"an"/"the" — before a "has a … method"
2428
2670
  // claim).
2429
2671
  const hasMethod = ownSrc.match(TEACH_HAS_METHOD_RE);
2430
- if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2672
+ if (hasMethod && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2431
2673
  const stored = await teachFact(memoryDir, sessionId, {
2432
2674
  subject: hasMethod[1], predicate: HAS_A_PREDICATE, object: `${hasMethod[2]} method`,
2433
2675
  });
@@ -2441,7 +2683,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2441
2683
  // COMPOSE2_RULE_TEACH_RE's own docblock). The query-side hop-counted chase
2442
2684
  // lives in factReadBack's relational-query dispatcher.
2443
2685
  const compose2 = ownSrc.match(COMPOSE2_RULE_TEACH_RE);
2444
- if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2686
+ if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2445
2687
  try {
2446
2688
  const { appendRule, RULE_KIND_COMPOSE2 } = await import("./memory/core.mjs");
2447
2689
  const { id } = await appendRule(memoryDir, {
@@ -2467,7 +2709,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2467
2709
  // The query-side generic base-then-property chase lives in factReadBack's
2468
2710
  // relational-query dispatcher (resolveRelation's own "filter" branch).
2469
2711
  const filterRule = ownSrc.match(FILTER_RULE_TEACH_RE);
2470
- if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2712
+ if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2471
2713
  try {
2472
2714
  const { appendRule, RULE_KIND_FILTER } = await import("./memory/core.mjs");
2473
2715
  const { id } = await appendRule(memoryDir, {
@@ -2496,7 +2738,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2496
2738
  // rule kinds' single-target search) lives in factReadBack's own
2497
2739
  // RECURSIVE_LIST_ASK_RE dispatch, below.
2498
2740
  const recursiveRule = ownSrc.match(RECURSIVE_RULE_TEACH_RE);
2499
- if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2741
+ if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc) && !ownSrcMidQuestion) {
2500
2742
  try {
2501
2743
  const { appendRule, RULE_KIND_RECURSIVE } = await import("./memory/core.mjs");
2502
2744
  const { id } = await appendRule(memoryDir, {
@@ -2525,7 +2767,11 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2525
2767
  // unknown object falls through to the generic honest-miss cascade at the
2526
2768
  // bottom of this function, same as every other unstorable teach.
2527
2769
  const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
2528
- 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;
2529
2775
  if (someMatch) {
2530
2776
  const quantifier = someMatch[1].toLowerCase();
2531
2777
  const subject = singularizeSurface(someMatch[2]);
@@ -2577,13 +2823,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2577
2823
  // miss text at all (the ORIGINAL bug: "remember tony has a hat" never even
2578
2824
  // reached this lane's own honest-miss cascade, landing on the structural
2579
2825
  // grammar's wrong-context wall instead).
2580
- if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped)) {
2826
+ if (wrapped && memoryDir && !QUESTION_LEAD_RE.test(wrapped) && !(await hasMidSentenceInterrogative(wrapped))) {
2581
2827
  const gv = await generalVerbTeach(wrapped);
2582
2828
  if (gv) {
2583
2829
  const stored = await teachFact(memoryDir, sessionId, gv);
2584
2830
  if (stored) return stored;
2585
2831
  }
2586
- } else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))) {
2832
+ } else if (!wrapped && memoryDir && !QUESTION_LEAD_RE.test(correctMisspellings(raw))
2833
+ && !(await hasMidSentenceInterrogative(correctMisspellings(raw)))) {
2587
2834
  // BARE path (HANDOVER.md 2026-07-10 item 2 fix): "grace mentors alan" — no
2588
2835
  // "remember"/"note" wrapper at all — used to silently reach neither this
2589
2836
  // frame NOR an honest miss, landing on the raw structural wall instead
@@ -2614,7 +2861,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2614
2861
 
2615
2862
  let payload = null;
2616
2863
  if (wrapped && /\b(?:is|are)\b/i.test(wrapped)) payload = wrapped;
2617
- 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;
2618
2865
  if (!payload) {
2619
2866
  // Tier-5 playtest fix (cycle 3), found live: "remember that every
2620
2867
  // controller needs review" — a QUANTIFIED subject ("every X", declined
@@ -2815,7 +3062,8 @@ async function memorySummary(memoryDir, graph) {
2815
3062
  }
2816
3063
  const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
2817
3064
  const n = rows.length;
2818
- 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 `
2819
3067
  + `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
2820
3068
  }
2821
3069
 
@@ -2946,7 +3194,12 @@ async function metaLane(query, { graph, memoryDir, last = null, templates = null
2946
3194
  // an unknown name renders null here and falls through to the ordinary honest miss
2947
3195
  // (never a guess, never a hijacked graph query).
2948
3196
  const AUTHOR_NAME_SRC = "([A-Za-z][\\w'.-]*(?:\\s+[A-Za-z][\\w'.-]*){0,3})";
2949
- 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");
2950
3203
  const AUTHOR_TOUCHED_RE = new RegExp(
2951
3204
  `^what\\s+(?:did|has)\\s+${AUTHOR_NAME_SRC}\\s+(?:touch(?:ed)?|chang(?:e|ed)|work(?:ed)?\\s+on|commit(?:ted)?)$`, "i");
2952
3205
  // The sha authorship forms — the interpret layer no longer rewrites these (WS2 guard).
@@ -3071,11 +3324,20 @@ function nudgeName(captured, focus) {
3071
3324
  * for the opinion gate: it must fire BEFORE the short-miss's "is a <thing> a
3072
3325
  * <kind>" membership hint would (the caller runs this whole step before the
3073
3326
  * short-miss rewrite). */
3074
- function nudgeAnswer(query, focus) {
3327
+ function nudgeAnswer(query, focus, vocabHint = null) {
3075
3328
  const q = String(query).trim().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
3076
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.
3077
3339
  return "I don't have access to that — I'm a deterministic code/vocabulary assistant, not a general assistant. "
3078
- + '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.'}`;
3079
3341
  }
3080
3342
  if (OPINION_NUDGE_RE.test(q)) {
3081
3343
  const name = focus?.label || "<name>";
@@ -3112,7 +3374,12 @@ function nudgeAnswer(query, focus) {
3112
3374
  const name = focus?.label;
3113
3375
  return "I only name the single top (or bottom) match for a metric — no runner-up ranking, no comparing against a number. "
3114
3376
  + (name
3115
- ? `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").`
3116
3383
  : `Ask a specific ranking directly, e.g. "which module has the most imports".`);
3117
3384
  }
3118
3385
  // A bare STACCATO PRONOUN continuation ("also that one?", "and it") with NO
@@ -3262,12 +3529,6 @@ export function gitToplevel(cwd = process.cwd()) {
3262
3529
  return null;
3263
3530
  }
3264
3531
 
3265
- /** Mirror bin/tmct.mjs's configFor: an explicit repo pins the artifact path; no
3266
- * repo falls back to the cwd/env-derived default. */
3267
- function configFor(repoPath) {
3268
- return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
3269
- }
3270
-
3271
3532
  /** Resolve a free-text term to a single graph entity via the ask engine's own
3272
3533
  * tiered resolver — {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
3273
3534
  * Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
@@ -3305,9 +3566,11 @@ export async function helpText() {
3305
3566
  let shapes;
3306
3567
  try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
3307
3568
  catch {
3308
- 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>", ' +
3309
3572
  '"what does <name> <import|export>", "what uses <name>", "where is <name> defined", ' +
3310
- '"when did <name> change"';
3573
+ '"when did <name> change", "which commits touched <name>"';
3311
3574
  }
3312
3575
  return [
3313
3576
  "commands:", ...lines, "",
@@ -3642,6 +3905,21 @@ function factTermVariants(normFactTerm, term) {
3642
3905
  return v;
3643
3906
  }
3644
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
+
3645
3923
  /** GENERIC "kind" nouns a taught subject's head word is often built from
3646
3924
  * ("logger MODULE", "task CONTROLLER") — excluded from the head-word
3647
3925
  * overlap fallback both KNOW_ABOUT_RE's "what do you know about X" listing
@@ -3835,6 +4113,21 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
3835
4113
  * doesn't parse; checked against the isa-family fact predicates only. */
3836
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;
3837
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"]);
3838
4131
  /** "so john is a man now right?" / "john is a man, right?" — a DECLARATIVE
3839
4132
  * statement wrapped in a confirmation-check tag ("now right?"/"right?"/
3840
4133
  * "correct?"), found live (playtest sprint round 1, 2026-07-10) after a
@@ -3861,6 +4154,55 @@ const KNOW_ABOUT_RE = /^(?:what\s+do\s+you\s+know\s+about|what(?:'s|s|\s+is)\s+i
3861
4154
  /** How many facts a single answer lists before the remainder is paged with "more". */
3862
4155
  const FACT_ANSWER_CAP = 32;
3863
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
+
3864
4206
  /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
3865
4207
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
3866
4208
  * own (schema-docs) answer stands and the fact lines are appended under it —
@@ -3879,7 +4221,17 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
3879
4221
  // OPTIONAL — see that regex's docblock for why this is safe to loosen here
3880
4222
  // even though the structural grammar's T5 keeps the article mandatory).
3881
4223
  let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
3882
- 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)) {
3883
4235
  const m = q.match(BARE_WHATIS_RE)
3884
4236
  || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
3885
4237
  // Seonix Batch 2 Fix 3: strip a curated trailing scope clause ("… in this
@@ -3939,6 +4291,105 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
3939
4291
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
3940
4292
  }
3941
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
+
3942
4393
  // (c) "what do you know about caches" — everything remembered that MENTIONS the
3943
4394
  // term (subject or object), capped.
3944
4395
  const know = q.match(KNOW_ABOUT_RE);
@@ -4124,8 +4575,9 @@ async function whatElseAnswer(memoryDir, query, last) {
4124
4575
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4125
4576
  const variants = factTermVariants(normFactTerm, term);
4126
4577
  const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
4578
+ const picture = pickPhrase("full-picture", term.toLowerCase(), "the full picture");
4127
4579
  const nothingMore = {
4128
- 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}.`,
4129
4581
  replace: true,
4130
4582
  };
4131
4583
  if (!hits.length) return nothingMore;
@@ -4135,8 +4587,9 @@ async function whatElseAnswer(memoryDir, query, last) {
4135
4587
  const shown = lines.slice(0, FACT_ANSWER_CAP);
4136
4588
  const rest = lines.slice(FACT_ANSWER_CAP);
4137
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,");
4138
4591
  return {
4139
- 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}`,
4140
4593
  replace: true,
4141
4594
  ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}),
4142
4595
  };
@@ -4183,6 +4636,26 @@ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|s
4183
4636
  /** "what kind of thing is an X" — the subject-side membership phrasing the grammar
4184
4637
  * doesn't parse: reports X's OWN remembered type (falling back to X's members). */
4185
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"]);
4186
4659
  /** "who owns <X>" / "who maintains <X>" — the closed ownership read-back over
4187
4660
  * the teach lane's mgx:ownedBy facts. */
4188
4661
  const WHO_OWNS_RE = /^who\s+(?:owns|maintains)\s+(.+?)[?.!\s]*$/i;
@@ -4335,6 +4808,31 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4335
4808
  let normFactTerm;
4336
4809
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4337
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
+ }
4338
4836
  // Tier-5 playtest fix (cycle 4), found live: "actually is the store module
4339
4837
  // fragile" WALLED — a leading hedge adverb ("actually"/"really"/"honestly",
4340
4838
  // optionally comma'd) put the sentence one word out of alignment with
@@ -4472,7 +4970,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4472
4970
  const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
4473
4971
  const relationName = relAsk[2].trim().toLowerCase();
4474
4972
  const object = relAsk[3].trim();
4475
- if (subject) {
4973
+ if (subject && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
4476
4974
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4477
4975
  const aliasSubClassEdges = rows
4478
4976
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
@@ -4572,7 +5070,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4572
5070
  const relationName = whoAsk[1].trim().toLowerCase();
4573
5071
  const rawObject = whoAsk[2].trim();
4574
5072
  const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
4575
- if (object) {
5073
+ if (object && !ISA_IDIOM_ROLE_WORDS.has(relationName)) {
4576
5074
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4577
5075
  const aliasSubClassEdges = rows
4578
5076
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
@@ -4854,7 +5352,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4854
5352
  // (owl:onProperty/owl:someValuesFrom) and every property/type premise must
4855
5353
  // all be TAUGHT (never corpus-sourced), same as every other live chase in
4856
5354
  // this block.
4857
- 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");
4858
5359
  const onPropertyRows = rows.filter((f) => f.predicate === ON_PROPERTY_PREDICATE && isTaught(f));
4859
5360
  const someValuesFromRows = rows.filter((f) => f.predicate === SOME_VALUES_FROM_PREDICATE && isTaught(f));
4860
5361
  if (onPropertyRows.length && someValuesFromRows.length) {
@@ -4881,10 +5382,178 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
4881
5382
  replace: true,
4882
5383
  };
4883
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
+ }
4884
5463
  }
4885
5464
  return null; // no remembered fact — the honest miss stands (never a guessed "no")
4886
5465
  }
4887
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
+
4888
5557
  // (a2) OWNERSHIP read-back — "who owns/maintains <X>": the teach lane's
4889
5558
  // mgx:ownedBy facts about X, trust-ranked, each cited (the source receipt
4890
5559
  // stays in the render). No fact → null, the honest miss stands.
@@ -5163,12 +5832,28 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5163
5832
  // it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
5164
5833
  // thing is an X" reports X's own TYPE (subject-side first), so both directions
5165
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).
5166
5851
  let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
5167
5852
  let kindOf = false;
5168
5853
  const mk = q.match(KIND_OF_RE);
5169
5854
  if (mk) { term = mk[1]; kindOf = true; }
5170
5855
  else if (!term && !envelope?.parsed) {
5171
- 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);
5172
5857
  if (m) term = m[1];
5173
5858
  }
5174
5859
  if (!term) return null;
@@ -5392,6 +6077,29 @@ function superlativeRepeatRewrite(query, last) {
5392
6077
  return prevQ;
5393
6078
  }
5394
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
+
5395
6103
  // ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
5396
6104
  // A "what is a <term>" for a LEXICON term prefers the curated one-sentence
5397
6105
  // definition — the richer surface form of the same curated SEON knowledge that the
@@ -5798,7 +6506,16 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5798
6506
  // and broke DESCRIBE_WRAPPER_RE's own anchor. applyPreambleFrames is the same
5799
6507
  // general-purpose, closed, idempotent pass every other lane in this file
5800
6508
  // already runs first.
5801
- 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()));
5802
6519
  const m = DESCRIBE_WRAPPER_RE.exec(q) || STACCATO_PRONOUN_RE.exec(q);
5803
6520
  let term = m?.[1]?.trim();
5804
6521
  if (!term) return null;
@@ -5810,6 +6527,14 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5810
6527
  // branches never leave this residue, so this can only ever help the doubled-
5811
6528
  // verb case, never change a correctly-captured term.
5812
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);
5813
6538
  if (DESCRIBE_PRONOUN_RE.test(term)) {
5814
6539
  if (!focus?.label) return null; // no standing focus to resolve against — honest decline
5815
6540
  term = focus.label;
@@ -5831,7 +6556,18 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
5831
6556
  }
5832
6557
  try {
5833
6558
  const text = await dispatchTool("tmct_describe", { symbol: term }, { config, source, tel });
5834
- 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 };
5835
6571
  } catch {
5836
6572
  return null; // unresolvable term — decline, the ordinary wall stands unchanged
5837
6573
  }
@@ -6025,6 +6761,41 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
6025
6761
  return { text, instances: composed.instances, allIds: composed.allInstanceIds, pending };
6026
6762
  }
6027
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
+
6028
6799
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
6029
6800
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
6030
6801
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
@@ -6047,7 +6818,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6047
6818
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
6048
6819
  // prior shape with X swapped in; everything else parses verbatim. The record and
6049
6820
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
6050
- let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last) ?? query;
6821
+ let askQuery = superlativeRepeatRewrite(query, last) ?? discourseRewrite(query, last)
6822
+ ?? existentialAnythingRewrite(query) ?? query;
6051
6823
  // IMPLICIT ANAPHORIC COUNT (Tier-2 playtest, 5th pass): "how many are tested" /
6052
6824
  // "and how many are tested" drops the "of those/them" a fuller phrasing carries
6053
6825
  // — ask()'s own anaphora node (parseAnaphora) already understands "how many of
@@ -6081,6 +6853,54 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6081
6853
  via: "recall", miss: !summary, focus,
6082
6854
  });
6083
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
+ }
6084
6904
  let answer;
6085
6905
  let envelope = null;
6086
6906
  try {
@@ -6091,7 +6911,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6091
6911
  // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
6092
6912
  // emits, so the parse below is identical either way.
6093
6913
  const { ask } = await import("./ask.mjs");
6094
- const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
6914
+ const r = ask(graph, askQuery, { contextId: effectiveContextId, prev });
6095
6915
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
6096
6916
  } else {
6097
6917
  text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source, tel });
@@ -6171,7 +6991,21 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6171
6991
  // this case (contextId was null); silently adopting a bogus focus as a side
6172
6992
  // effect here would corrupt the NEXT turn's pronoun into a confidently WRONG
6173
6993
  // (not just empty) answer, exactly as the connective leak did.
6174
- 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);
6175
7009
  if (ent) {
6176
7010
  resolvedIds = [ent.id];
6177
7011
  // Class-gate the focus update: a Commit/Session/schema object never displaces a
@@ -6206,6 +7040,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6206
7040
  let via = "composed";
6207
7041
  let recordMiss = miss;
6208
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;
6209
7052
  // GOAL DEDUCTION: from the parsed AST when one stood (deterministic, table-driven —
6210
7053
  // see deduceGoalFromParsed); a total grammar miss (no parse at all) gets the honest
6211
7054
  // "didn't resolve" goal line verbatim, matching the operator's own wording for that
@@ -6436,6 +7279,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6436
7279
  }
6437
7280
  }
6438
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
+ }
6439
7300
  if (bareMetaHit) {
6440
7301
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
6441
7302
  via = "fact"; recordMiss = false; handled = true;
@@ -6482,6 +7343,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6482
7343
  via = "fact";
6483
7344
  recordMiss = false;
6484
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)
6485
7347
  note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
6486
7348
  note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
6487
7349
  // Goal-line fix (item 5 follow-up, this session): mirrors the TEACH lane's
@@ -6660,7 +7522,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6660
7522
  // must never become a recallable answer. The opinion gate fires HERE, before the
6661
7523
  // short-miss's "is a <thing> a <kind>" membership hint could claim the line.
6662
7524
  if (miss && recordMiss && via === "composed") {
6663
- const nudged = nudgeAnswer(query, newFocus);
7525
+ const nudged = nudgeAnswer(query, newFocus, vocabHint);
6664
7526
  if (nudged) {
6665
7527
  answer = nudged; via = "miss";
6666
7528
  note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
@@ -6683,6 +7545,15 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6683
7545
  answer = described.text; via = "describe"; recordMiss = false;
6684
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");
6685
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
+ }
6686
7557
  }
6687
7558
  }
6688
7559
  // (4e) COMPLETIONS RESCUE (HANDOVER.md 2026-07-10 item 7) — wires src/completions/'s
@@ -6797,7 +7668,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6797
7668
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
6798
7669
  // so record + expand them, not the schema match.
6799
7670
  const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
6800
- 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
+ };
6801
7685
  const logLines = [ts, `> ${query}`, answer, ""];
6802
7686
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
6803
7687
  // matched entities the terse render trims (see renderVerbose). `pending` carries a
@@ -6846,12 +7730,20 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
6846
7730
 
6847
7731
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
6848
7732
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
6849
- 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 } = {}) {
6850
7734
  const ts = new Date().toISOString();
6851
7735
  return {
6852
7736
  answer,
6853
7737
  logLines: [ts, `> ${query}`, answer, ""],
6854
- 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
+ },
6855
7747
  focus,
6856
7748
  };
6857
7749
  }
@@ -7020,18 +7912,67 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
7020
7912
  return mk(answer);
7021
7913
  }
7022
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
+
7023
7940
  /** A declarative ACE-grammar sentence → assert into memory + confirm; null on
7024
7941
  * any grammar miss / residue / import failure so the query engine keeps first
7025
7942
  * refusal on everything else. Lazy imports + catch-all: the grammar layer can
7026
- * 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. */
7027
7953
  async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
7028
7954
  try {
7029
- const { parseAce } = await import("./grammar/ace.mjs");
7955
+ const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
7030
7956
  // A session handle carries its own loaded lexicon (createSession loads it once);
7031
7957
  // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
7032
7958
  // immutable, so sharing one reference across concurrent handles is re-entrant.
7033
7959
  let lex = lexicon;
7034
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
+ }
7035
7976
  const parse = parseAce(line, lex);
7036
7977
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
7037
7978
  const { assertSentence } = await import("./grammar/assert.mjs");
@@ -7070,7 +8011,19 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
7070
8011
  .join("; ");
7071
8012
  const n = res.ids.length;
7072
8013
  const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
7073
- 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 });
7074
8027
  } catch {
7075
8028
  return null; // grammar unavailable / write failed — fall through to the engine
7076
8029
  }
@@ -7129,6 +8082,78 @@ function morePage(query, { last, focus }) {
7129
8082
  // gain.
7130
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;
7131
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
+
7132
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 = {} } = {}) {
7133
8158
  const line = String(input ?? "").trim();
7134
8159
  // The captured residue is used for RECOGNITION at every dispatch site below
@@ -7136,7 +8161,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
7136
8161
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
7137
8162
  // — restored centrally inside withLast (below), once, for every dispatch path.
7138
8163
  const indirectMatch = line.match(INDIRECT_REQUEST_RE);
7139
- 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;
7140
8173
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
7141
8174
  // narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
7142
8175
  // matching every OTHER optional collaborator here — templates/memoryDir/lexicon
@@ -7164,10 +8197,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
7164
8197
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
7165
8198
  const finished = finish(result, { graph });
7166
8199
  // Bug F point 3 fidelity: every dispatch path below built its own record off
7167
- // `workingLine` (the indirect-request wrapper stripped, above) restore the
7168
- // ORIGINAL raw `line` into record.query and the logged "> …" transcript echo
7169
- // here, once, centrally, for every path (they all funnel through withLast).
7170
- 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) {
7171
8205
  if (finished.record) finished.record.query = line;
7172
8206
  if (Array.isArray(finished.logLines) && finished.logLines.length > 1) finished.logLines[1] = `> ${line}`;
7173
8207
  }
@@ -7291,60 +8325,65 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
7291
8325
  * corpus seed, so re-runs skip without even reading the slice. */
7292
8326
  export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
7293
8327
 
7294
- /** Seed the starter corpus into <repo>/.tmct/memory once a loop over every
7295
- * ACTIVE `corpus`-kind extension entry (src/extensions.mjs's resolveExtensions,
7296
- * the SAME seam `tmct init`'s seed step and `tmct init --corpus <id>` now
7297
- * share), in the resolver's FIXED order: seon first, then conceptnet, then any
7298
- * other active bundle sorted by name. seon runs first so its curated facts win
7299
- * the content-hash idempotency race a term the ConceptNet slice also carries
7300
- * keeps the seon provenance. Idempotent twice over (the marker short-circuits;
7301
- * seedMemory content-hashes fact ids) and failure-tolerated PER BUNDLE
7302
- * (seedActiveCorpusEntries): one bad third-party pack degrades to "not seeded"
7303
- * for that bundle alone (its error is recorded, not silently swallowed) while
7304
- * every other bundle still lands — never an error before the prompt. Returns
7305
- * { appended, skipped, total, seon, conceptnet, perBundle } on a fresh seed
7306
- * (the banner counts stay honest), null when skipped/failed outright. */
7307
- async function seedBootstrapMemory(repo) {
7308
- const marker = join(repo, SEED_MARKER_REL);
7309
- try {
7310
- await readFile(marker, "utf8");
7311
- return null; // already seeded the marker is authoritative
7312
- } 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) {
7313
8360
  try {
7314
- const { entries } = await resolveExtensions(repo);
7315
- const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(repo, entries);
7316
- const res = {
7317
- appended, skipped, total, perBundle,
7318
- // seon/conceptnet stay named fields (not just perBundle lookups) so the
7319
- // banner's default two-bundle rendering (below) — and any external
7320
- // reader keyed on `.seon`/`.conceptnet` — stays byte-identical.
7321
- seon: perBundle.seon?.appended || 0,
7322
- conceptnet: perBundle.conceptnet?.appended || 0,
7323
- };
7324
- await mkdir(dirname(marker), { recursive: true });
7325
- await writeFile(marker, JSON.stringify({
7326
- seededAt: new Date().toISOString(),
7327
- appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
7328
- perBundle,
7329
- }) + "\n");
7330
- 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;
7331
8364
  } catch {
7332
- return null; // corpus unavailable — bootstrap proceeds unseeded
8365
+ return null; // repo/corpus unavailable — bootstrap proceeds unseeded
7333
8366
  }
7334
8367
  }
7335
8368
 
7336
- /** The seed banner line — byte-identical to before for the default zero-config
7337
- * seon+conceptnet case; a THIRD (or more) active bundle appends its own
7338
- * "<n> <bundle-name>" clause rather than changing the base sentence, so a
7339
- * fresh `TMCT_NO_SEED`-unset run with no tmct.toml renders EXACTLY what
7340
- * 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). */
7341
8382
  function seedBannerLine(seeded) {
7342
- const extra = Object.entries(seeded.perBundle || {})
7343
- .filter(([name]) => name !== "seon" && name !== "conceptnet")
8383
+ const clauses = Object.entries(seeded.perBundle || {})
7344
8384
  .filter(([, r]) => r && r.appended > 0)
7345
8385
  .map(([name, r]) => `${r.appended} ${name}`);
7346
- const extraClause = extra.length ? ` + ${extra.join(" + ")}` : "";
7347
- 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`;
7348
8387
  }
7349
8388
 
7350
8389
  /** Whether THIS repo's memory actually carries the corpus seed — the marker is
@@ -7363,15 +8402,16 @@ async function hasSeededVocabulary(repo) {
7363
8402
  /** A "try this" vocabulary-example clause that's PROVABLY correct in the session
7364
8403
  * it's shown, mirroring the discipline orientationExamples() already applies to
7365
8404
  * structural examples (never offer an example that isn't confirmed to resolve).
7366
- * `cache` is confirmed live: present in corpus/seon/definitions.jsonl, backed by
7367
- * a corpus:seon concept fact, and a recognized lexicon noun — but only actually
7368
- * answerable once the seed has run. When it hasn't (TMCT_NO_SEED=1,
7369
- * seed.enabled=false, or corpus load failure), offering it would be a lie worse
7370
- * than no example swap to an unconditionally-true pointer instead (the teach
7371
- * lane and `tmct init` both work with zero preconditions). Computed ONCE per
7372
- * 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.
7373
8413
  * The unseeded branch's teach clause is a CONCRETE pair too, for the same
7374
- * 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
7375
8415
  * abstract "every X is a Y" invites a curious user to fill X/Y with an
7376
8416
  * intuitive-but-unknown word ("every cache is a thing" — "thing" isn't in
7377
8417
  * the closed ACE lexicon) and hit the teach-miss dead-end right after being
@@ -7380,7 +8420,7 @@ async function hasSeededVocabulary(repo) {
7380
8420
  * test/chatflow-tier0.test.mjs), so the offer resolves if copied verbatim. */
7381
8421
  function vocabExampleHint(seeded) {
7382
8422
  return seeded
7383
- ? 'Try "what is a cache" for general vocabulary.'
8423
+ ? 'Try "what is a dog" for general vocabulary.'
7384
8424
  : 'Run `tmct init` to seed a starter vocabulary, or teach me directly, e.g. "every bug is an issue".';
7385
8425
  }
7386
8426
 
@@ -7428,12 +8468,24 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
7428
8468
  */
7429
8469
  export async function createSession({
7430
8470
  repoPath,
8471
+ graphPaths,
8472
+ configPath,
7431
8473
  source = defaultSource,
7432
8474
  env = process.env,
7433
8475
  cwd = process.cwd(),
7434
8476
  gitRoot = gitToplevel,
7435
8477
  ephemeral = false,
7436
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,
7437
8489
  } = {}) {
7438
8490
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
7439
8491
  // write NOTHING back into it. The shipped examples run this way so a demo never
@@ -7449,27 +8501,59 @@ export async function createSession({
7449
8501
  // (see `turn()` below: a turn result's `narrate` field, when present,
7450
8502
  // updates this closure-private variable). Default OFF, as the operator asked.
7451
8503
  let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
7452
- // Graph resolution order for the chat surface (documented; --repo wins):
7453
- // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
7454
- // 2. TMCT_GRAPH_FILE env loads that graph anywhere (loadConfig reads it), so
7455
- // `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo the chat
7456
- // surface used to ignore it (only the `cli` tool path honoured it). The repo
7457
- // for logs/memory is still the git root / cwd; only the graph file is overridden.
7458
- // 3. git root → <root>/.tmct/graph.json (the default target).
7459
- // 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).
7460
8523
  // Default the target to the GIT ROOT, not raw cwd: running from a nested package
7461
8524
  // dir (npm sets cwd there) would otherwise index only that package's ~few modules
7462
8525
  // instead of the whole repo.
7463
8526
  let repo;
7464
8527
  let config;
7465
- if (repoPath) { repo = repoPath; config = configFor(repoPath); }
7466
- 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 {
7467
8547
  const root = gitRoot(cwd);
7468
8548
  repo = root || cwd;
7469
8549
  const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
7470
- // TMCT_GRAPH_FILE (via loadConfig) overrides the repo-derived default graph path;
7471
- // otherwise the repo's own .tmct/graph.json is the target.
7472
- 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
+ }
7473
8557
  }
7474
8558
 
7475
8559
  // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
@@ -7545,15 +8629,62 @@ export async function createSession({
7545
8629
  catch { /* best-effort — see above */ }
7546
8630
  };
7547
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
+
7548
8655
  const empty = graph.individuals.length === 0;
7549
8656
  // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
7550
8657
  // .tmct/memory so vocabulary questions ("what is a cache?") have something
7551
8658
  // honest to stand on from turn one. Guarded three ways: only the empty
7552
8659
  // bootstrap (a fixture/provider graph never seeds), only once (the marker),
7553
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.
7554
8685
  let seeded = null;
7555
- if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
7556
- seeded = await seedBootstrapMemory(repo);
8686
+ if (empty && backendChoice === "" && String(env.TMCT_NO_SEED || "") !== "1") {
8687
+ seeded = await seedBootstrapMemory(repo, env);
7557
8688
  }
7558
8689
  // vocabHint: computed ONCE per session (not per-turn — see runTurn's own
7559
8690
  // per-call fallback for direct/library callers). `seeded` is only truthy when
@@ -7593,7 +8724,7 @@ export async function createSession({
7593
8724
  let closed = false;
7594
8725
 
7595
8726
  return {
7596
- repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
8727
+ repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
7597
8728
  logFile, sidecarFile, bannerLines, empty, biasByBundle,
7598
8729
  // Mutable between-turn state — read-only to the caller, so a shell can render the
7599
8730
  // prompt/expand-hint without reaching into runTurn's threading.
@@ -7612,7 +8743,7 @@ export async function createSession({
7612
8743
  async turn(line) {
7613
8744
  let result;
7614
8745
  try {
7615
- 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 });
7616
8747
  } catch (e) {
7617
8748
  const ts = new Date().toISOString();
7618
8749
  const message = e instanceof Error ? e.message : String(e);
@@ -7645,7 +8776,8 @@ export async function createSession({
7645
8776
  },
7646
8777
 
7647
8778
  /** End-of-session close: end lines in both artifacts, the final graph upsert
7648
- * (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. */
7649
8781
  async close() {
7650
8782
  if (closed) return;
7651
8783
  closed = true;
@@ -7655,6 +8787,7 @@ export async function createSession({
7655
8787
  await upsertGraph(endIso);
7656
8788
  await new Promise((resolve) => stream.end(resolve));
7657
8789
  await new Promise((resolve) => sidecar.end(resolve));
8790
+ await closeMemoryStore();
7658
8791
  },
7659
8792
  };
7660
8793
  }
@@ -7669,6 +8802,8 @@ export async function createSession({
7669
8802
  */
7670
8803
  export async function runChat({
7671
8804
  repoPath,
8805
+ graphPaths,
8806
+ configPath,
7672
8807
  input = process.stdin,
7673
8808
  output = process.stdout,
7674
8809
  source = defaultSource,
@@ -7677,6 +8812,7 @@ export async function runChat({
7677
8812
  gitRoot = gitToplevel,
7678
8813
  ephemeral = false,
7679
8814
  narrate = false,
8815
+ memoryBackend = null,
7680
8816
  } = {}) {
7681
8817
  // createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
7682
8818
  // output until it fully resolves — found live: an operator reported `npm run chat`
@@ -7684,7 +8820,7 @@ export async function runChat({
7684
8820
  // fast subsequent run just flashes it briefly) and removes the "is this even
7685
8821
  // running" uncertainty during the one case that's genuinely slow.
7686
8822
  output.write("tmct — starting…\n");
7687
- 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 });
7688
8824
 
7689
8825
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
7690
8826
  for (const line of session.bannerLines) output.write(dim(line) + "\n");