@polycode-projects/the-mechanical-code-talker 2.3.1 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +131 -32
  2. package/bin/tmct.mjs +18 -91
  3. package/corpus/README.md +3 -3
  4. package/corpus/seon/README.md +1 -0
  5. package/corpus/tier2/generate.mjs +18 -18
  6. package/corpus/tier2/manifest.json +3 -3
  7. package/data/games/hanoi-3.txt +8 -2
  8. package/package.json +26 -8
  9. package/src/adapters/corpus-lanes.mjs +13 -0
  10. package/src/adapters/graph-build.mjs +5 -7
  11. package/src/adapters/import-closure.mjs +28 -0
  12. package/src/adapters/memory/blocks.mjs +5 -4
  13. package/src/adapters/memory/core.mjs +78 -5
  14. package/src/adapters/memory/shacl.mjs +12 -0
  15. package/src/adapters/providers/graph-service.mjs +12 -5
  16. package/src/adapters/tracked-files.mjs +17 -0
  17. package/src/domain/ask-vocab.mjs +2 -0
  18. package/src/domain/ask.mjs +225 -13
  19. package/src/domain/cli-verbs.mjs +201 -0
  20. package/src/domain/codegraph.mjs +142 -56
  21. package/src/domain/completions/graph-adapter.mjs +1 -1
  22. package/src/domain/completions/group.mjs +3 -17
  23. package/src/domain/completions/infer.mjs +4 -13
  24. package/src/domain/completions/rank.mjs +6 -19
  25. package/src/domain/grammar/lexicon-core.json +1 -1
  26. package/src/domain/hash.mjs +36 -13
  27. package/src/domain/interpret/fuzzy.mjs +7 -2
  28. package/src/domain/interpret/normalize.mjs +9 -0
  29. package/src/domain/interpret/strategies/keywords.mjs +19 -9
  30. package/src/domain/memory/capability.mjs +22 -3
  31. package/src/domain/memory/touched-facts.mjs +17 -0
  32. package/src/domain/module-paths.mjs +9 -0
  33. package/src/domain/persona/tiers.mjs +1 -1
  34. package/src/domain/planning.mjs +37 -0
  35. package/src/domain/prose.mjs +10 -2
  36. package/src/domain/relative-specifiers.mjs +12 -0
  37. package/src/domain/router/registry.mjs +3 -2
  38. package/src/domain/router/results.mjs +5 -18
  39. package/src/domain/seeded-random.mjs +33 -0
  40. package/src/domain/syllogise.mjs +10 -7
  41. package/src/domain/text-stats.mjs +31 -0
  42. package/src/services/chat.mjs +722 -184
  43. package/src/services/extract-facts.mjs +155 -0
  44. package/src/services/import-file.mjs +2 -2
  45. package/src/services/init.mjs +2 -2
  46. package/src/services/ledger-viz.mjs +6 -1
  47. package/src/services/sentences.mjs +26 -0
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
  49. package/src/tools/graph-load.mjs +7 -1
  50. package/src/tools/readme-docs.mjs +113 -0
  51. package/src/tools/schema-docs.mjs +2 -2
  52. package/ROADMAP.md +0 -129
  53. package/corpus/namenet/generate.mjs +0 -309
  54. package/corpus/wordnet/generate.mjs +0 -332
  55. package/src/adapters/prose-tokens.mjs +0 -98
  56. package/src/adapters/wordnet-source.mjs +0 -70
  57. package/src/domain/corpus-matrix.mjs +0 -87
  58. package/src/domain/inflect.mjs +0 -67
  59. package/src/domain/licences.mjs +0 -68
  60. package/src/domain/markdown-links.mjs +0 -55
  61. package/src/domain/persona/codegen.mjs +0 -123
  62. package/src/domain/publish-gate.mjs +0 -41
  63. package/src/domain/schemaorg/turtle.mjs +0 -25
  64. package/src/domain/semcor/parse.mjs +0 -87
  65. package/src/domain/version-stamp.mjs +0 -36
  66. package/src/domain/wordnet/yaml.mjs +0 -133
@@ -28,11 +28,12 @@ import { loadTemplates, render as renderTemplate } from "../adapters/corpus/temp
28
28
  import { rankByBiasThenTrust } from "../domain/memory/bias.mjs";
29
29
  import { HAS_A_PREDICATE, loadMemory as loadMemoryStore, normFactPredicate, normFactTerm as normFactTermStatic, readFactRows as readStoredFactRows, readRuleRows as readStoredRuleRows } from "../adapters/memory/core.mjs";
30
30
  import {
31
- CAPABILITY_REPORT_CAP, NEG_CAPABLE_OF_PREDICATE, capabilityBaseRate, capabilityExtension,
32
- isNegatedPredicate, negatedPredicate, positivePredicate, resolveCapabilityPolarity,
31
+ CAPABILITY_REPORT_CAP, NEG_CAPABLE_OF_PREDICATE, NEG_SUBCLASS_PREDICATE, capabilityBaseRate,
32
+ capabilityExtension, isNegatedPredicate, negatedPredicate, positivePredicate,
33
+ resolveCapabilityPolarity,
33
34
  } from "../domain/memory/capability.mjs";
34
35
  import { finish, beginsWithVowelSound, grammarRules } from "./finish.mjs";
35
- import { splitSentences } from "./sentences.mjs";
36
+ import { splitSentences, carriesASentenceBoundary } from "./sentences.mjs";
36
37
  import {
37
38
  VERB_TO_KIND, WHERE_MARKERS, MENTION_MARKERS, ENTITY_TO_TYPE, PASSIVE_PARTICIPLE_TO_KIND,
38
39
  stripTrailingScopeFiller, stripTrailingDiscourseTag, EDGE_NOUN_TO_METRIC, RELATIONS, LIST_TRIGGERS,
@@ -43,6 +44,7 @@ import { setConstructionBanks } from "../domain/interpret/strategies/constructio
43
44
  import { nlpAdapter } from "../adapters/ask-nlp.mjs";
44
45
  import { readConstructionFiles } from "../adapters/corpus/construction-banks.mjs";
45
46
  import { fuzzyMatchInSet, fuzzyBound } from "../domain/interpret/fuzzy.mjs";
47
+ import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
46
48
  import { pickPhrase } from "../domain/answer-variants.mjs";
47
49
 
48
50
  // Composition: the chat surface supplies the domain parser's default lemma/POS
@@ -453,6 +455,37 @@ const IMPLICIT_ANAPHORA_COUNT_RE = /^(?:(?:and|so|then|also)\s+)?how many (?:are
453
455
  * "defines" vs "contains" by the resolved subject's own class. */
454
456
  const AMBIGUOUS_HAVE_VERBS = new Set(["have", "has", "holds", "hold"]);
455
457
 
458
+ /** Words that can trail a bare count without restricting it: the copula/expletive
459
+ * of "are there", the "in total"/"in the graph" locatives, and the "do you know"
460
+ * politeness. Anything left after these is stripped is a real restrictor the
461
+ * header count cannot evaluate. Mirrors ask.mjs's AGG_TAIL_FILLER plus the
462
+ * interrogative scaffolding that only appears on the chat surface. */
463
+ const COUNT_TAIL_FILLER = new Set([
464
+ "are", "is", "were", "was", "there", "of",
465
+ "in", "total", "altogether", "overall",
466
+ "the", "a", "an", "do", "does", "did", "you", "we", "us", "me", "know",
467
+ "exist", "exists", "existing", "present", "here", "now", "currently",
468
+ "graph", "index", "codebase", "repo", "repository", "memory",
469
+ "that", "this", "known", "recorded", "listed", "stored",
470
+ // trailing discourse particles — "…are there then", "…anyway" — never restrict a count
471
+ "then", "so", "uh", "um", "er", "eh", "well", "though", "anyway", "anyhow",
472
+ "again", "really", "actually", "maybe", "perhaps", "just", "simply", "rather", "please",
473
+ ]);
474
+
475
+ /** A count tail past the kind noun carries a real restrictor — one the header
476
+ * count cannot evaluate — iff a content word survives the filler strip and the
477
+ * tail is not the have-family kept on the bare-count path (AMBIGUOUS_HAVE_VERBS).
478
+ * A topical tail ("about tasks", "with tasks", "related to tasks") names nothing
479
+ * RESTRICTOR_VERB_RE recognises, so without this it was silently discarded to the
480
+ * unqualified total; now it declines to the ask engine's honest miss instead. */
481
+ function countTailIsUnhandledRestrictor(tail) {
482
+ const words = String(tail).toLowerCase().replace(/[^a-z\s]+/g, " ").split(/\s+/).filter(Boolean);
483
+ const content = words.filter((w) => !COUNT_TAIL_FILLER.has(w));
484
+ if (!content.length) return false;
485
+ if (content.some((w) => AMBIGUOUS_HAVE_VERBS.has(w))) return false;
486
+ return true;
487
+ }
488
+
456
489
  /** A "how many <kind> …" tail carries a genuine RESTRICTOR clause — not filler — iff
457
490
  * it names a real relation verb (active, from VERB_TO_KIND, or passive-participle,
458
491
  * from PASSIVE_PARTICIPLE_TO_KIND — both ask-vocab.mjs's closed vocabulary, the
@@ -501,7 +534,11 @@ export function answerCount(graph, query) {
501
534
  if (!m) return null;
502
535
  const noun = m[1].toLowerCase();
503
536
  const cls = COUNT_NOUNS[noun];
504
- if (cls && RESTRICTOR_VERB_RE.test(String(query).slice(m.index + m[0].length))) return null;
537
+ if (cls) {
538
+ const tail = String(query).slice(m.index + m[0].length);
539
+ if (RESTRICTOR_VERB_RE.test(tail)) return null;
540
+ if (countTailIsUnhandledRestrictor(tail)) return null;
541
+ }
505
542
  if (!cls) {
506
543
  const kinds = countableKinds(graph);
507
544
  // When no code graph is loaded, countableKinds(graph) is genuinely
@@ -684,6 +721,29 @@ const MEMORY_COUNT_NOUNS = {
684
721
  };
685
722
  const MEMORY_CLASS_LABELS = { Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"] };
686
723
 
724
+ /** Everything a count question can trail after its counted noun and still mean
725
+ * the plain total: "how many facts do you know", "how many facts are there",
726
+ * "how many facts in total". A closed table — anything outside it restricts
727
+ * the count to something, and this lane says so rather than answering as if
728
+ * it were not there. */
729
+ const MEMORY_COUNT_FILLER_TAIL_RE =
730
+ /^(?:(?:do|d')\s+(?:you|u)\s+(?:know|have|remember)|are\s+there|(?:in\s+)?(?:total|all)|altogether)?[?.!\s]*$/i;
731
+
732
+ /** "how many facts about horses (are there)" — the one restriction this lane
733
+ * reads: facts naming a term on either side of the triple. */
734
+ const MEMORY_COUNT_ABOUT_TAIL_RE =
735
+ /^about\s+(?:the\s+|an?\s+)?([a-z][\w-]*)\s*(?:are\s+there|do\s+(?:you|u)\s+know)?[?.!\s]*$/i;
736
+
737
+ /** Count the stored Facts naming `term` as subject or object — the restriction
738
+ * "how many facts about horses" asks for. */
739
+ async function memoryFactsAboutCount(memoryDir, term) {
740
+ const { loadMemory, readFactRows, normFactTerm } = await import("../adapters/memory/core.mjs");
741
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
742
+ const wanted = normFactTerm(singularOf(term, loadLexicon(), lookupNoun));
743
+ const rows = readFactRows(await loadMemory(memoryDir));
744
+ return rows.filter((r) => normFactTerm(r.subject) === wanted || normFactTerm(r.object) === wanted).length;
745
+ }
746
+
687
747
  /** Recognise a memory-store count question and answer it by loading the memory
688
748
  * graph, or null (→ answerCount / the ask engine own it). Handles "how many facts",
689
749
  * "how many utterances", and the bare "how many do you know" (→ facts). Lazy +
@@ -693,20 +753,41 @@ async function answerMemoryCount(memoryDir, query) {
693
753
  if (!memoryDir) return null;
694
754
  const q = String(query).toLowerCase();
695
755
  let cls = null;
756
+ let tail = "";
696
757
  // the bare "how many do you know" (no explicit noun) defaults to remembered facts
697
758
  if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
698
759
  if (!cls) {
699
- const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/);
700
- if (m) cls = MEMORY_COUNT_NOUNS[m[1]] || null;
760
+ const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b(.*)$/);
761
+ if (m) {
762
+ cls = MEMORY_COUNT_NOUNS[m[1]] || null;
763
+ tail = m[2].trim();
764
+ }
701
765
  }
702
766
  if (!cls) return null;
767
+ const [sing, plur] = MEMORY_CLASS_LABELS[cls];
768
+ const said = (n) => `${n} ${n === 1 ? sing : plur}.`;
769
+ // A restriction this lane can read: count only the facts naming that term.
770
+ const about = cls === "Fact" ? tail.match(MEMORY_COUNT_ABOUT_TAIL_RE) : null;
771
+ if (about) {
772
+ try {
773
+ return `${said(await memoryFactsAboutCount(memoryDir, about[1]))} (about "${about[1]}")`;
774
+ } catch {
775
+ return null;
776
+ }
777
+ }
778
+ // A tail that restricts the question to something this lane cannot read. The
779
+ // total is not the answer to it — it is the answer to a shorter question
780
+ // nobody asked — so name what went unread instead of counting past it.
781
+ if (tail && !MEMORY_COUNT_FILLER_TAIL_RE.test(tail)) {
782
+ return `I can count the ${plur} I hold, but not the "${tail}" part of that question — `
783
+ + `so I won't answer with the plain total, which would be a count of something you didn't ask for. `
784
+ + `Ask "how many ${plur} do you know" for the total, or "how many ${plur} about <term>" to narrow it.`;
785
+ }
703
786
  let loadMemory;
704
787
  try { ({ loadMemory } = await import("../adapters/memory/core.mjs")); } catch { return null; }
705
788
  let mem;
706
789
  try { mem = await loadMemory(memoryDir); } catch { return null; }
707
- const n = (mem.individuals || []).filter((i) => (i.class || "") === cls).length;
708
- const [sing, plur] = MEMORY_CLASS_LABELS[cls];
709
- return `${n} ${n === 1 ? sing : plur}.`;
790
+ return said((mem.individuals || []).filter((i) => (i.class || "") === cls).length);
710
791
  }
711
792
 
712
793
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
@@ -1786,8 +1867,8 @@ const OWNS_PASSIVE_TEACH_RE = /^(.+?)\s+(?:is|are|was|were)\s+owned\s+by\s+([A-Z
1786
1867
  * a NAMED relationship between two entities ("ahab is the father of john"),
1787
1868
  * grouped here with the other relational/possessive teach shapes above
1788
1869
  * (ownership) since it's tried on the SAME ownSrc in teachLane, right after
1789
- * OWNS_PASSIVE_TEACH_RE and before SOME_A_FEW_RE — unconditionally ahead of
1790
- * generalVerbTeach's own call site. The literal "the" + bare role-noun +
1870
+ * OWNS_PASSIVE_TEACH_RE — unconditionally ahead of generalVerbTeach's own
1871
+ * call site. The literal "the" + bare role-noun +
1791
1872
  * "of" anchor is deliberate: a future composition-rule teach shape ("a
1792
1873
  * <rule> is a <relation> of a <relation>") uses an INDEFINITE "a"/"an" in
1793
1874
  * the same slot instead, so the two shapes structurally can never collide —
@@ -2051,9 +2132,32 @@ const GOAL_TEACH_VERBLESS_RE = new RegExp(
2051
2132
  // preposition), so this one reader answers either.
2052
2133
  const ACTION_SIGNATURE_ASK_RE = new RegExp(
2053
2134
  `^(?:can|could)\\s+you\\s+([a-z]+)\\s+an?\\s+([a-z][\\w-]*)\\s+(${PREP_SRC})\\s+an?\\s+([a-z][\\w-]*)[?.!]*$`, "i");
2054
- const PLAN_SOLVE_RE = /^(?:solve\s+it|plan\s+the\s+moves|how\s+do\s+i\s+get(?:\s+from\s+here)?\s+to\s+the\s+goal)[?.!\s]*$/i;
2135
+ const PLAN_SOLVE_RE = /^(?:solve\s+it|solve\s+(?:the\s+)?(?:towers?\s+of\s+hanoi|hanoi|puzzle|game|river\s+crossing|this)|plan\s+the\s+moves|how\s+do\s+i\s+get(?:\s+from\s+here)?\s+to\s+the\s+goal)[?.!\s]*$/i;
2055
2136
  const LEGAL_MOVES_RE = /^what\s+moves\s+are\s+legal(?:\s+now)?[?.!\s]*$/i;
2056
2137
  const PLAN_NEXT_RE = /^(?:next|next\s+move|go\s+on|continue)[.!?\s]*$/i;
2138
+ // The imperative voicing of a universal goal ("get all the disks onto peg-c"):
2139
+ // like the verbless frame it names no board verb, so planLaneAnswer reads that
2140
+ // off the taught locative facts. Captures a quantifier, a (possibly plural)
2141
+ // class term, the preposition and the target — the caller singularizes the
2142
+ // term and normalizes "onto"→"on" before the same verbless resolution runs.
2143
+ const GOAL_TEACH_IMPERATIVE_RE = new RegExp(
2144
+ `^(?:get|put|place)\\s+(?:(every|each|all|both)\\s+)?(?:the\\s+)?([\\w-]+?)\\s+(${PREP_SRC})\\s+([\\w-]+)[?.!]*$`, "i");
2145
+ // Plan follow-up questions, answered off the ACTIVE plan state (never invented
2146
+ // when no plan stands). "next move"/"continue" EXECUTE (PLAN_NEXT_RE above); these
2147
+ // three only REPORT.
2148
+ const PLAN_WHAT_NEXT_RE = /^(?:what(?:'s|\s+is)?|whats)\s+the\s+next\s+move[?.!\s]*$/i;
2149
+ const PLAN_MOVE_COUNT_RE = /^how\s+many\s+moves(?:\s+(?:are\s+(?:there|left)|remain(?:ing)?|left|to\s+go|in\s+the\s+plan|total))?[?.!\s]*$/i;
2150
+ const PLAN_WHY_MOVE_RE = /^why\s+(?:that|this|the\s+next|the)\s+move[?.!\s]*$/i;
2151
+ // Board-state read-backs, answered off the CURRENT board (the latest @stepK
2152
+ // snapshot, or the taught board before any step) so a read never contradicts
2153
+ // the plan's own board@stepK line. Clearness is derived, never stored: a piece
2154
+ // is clear iff nothing rests on it on the current board.
2155
+ const IS_CLEAR_RE = /^(?:is|are)\s+([\w-]+)\s+clear[?.!\s]*$/i;
2156
+ const BOARD_REVERSE_LOC_RE = new RegExp(
2157
+ `^(?:what|who)\\s+([a-z']+)\\s+(${PREP_SRC})\\s+(.+?)[?.!\\s]*$`, "i");
2158
+ const BOARD_FORWARD_LOC_RE = new RegExp(
2159
+ `^what\\s+(?:does|do|is)\\s+([\\w-]+)\\s+([a-z]+)(?:\\s+(${PREP_SRC}))?[?.!\\s]*$`, "i");
2160
+ const BOARD_WHERE_RE = /^(?:where\s+is|where's)\s+([\w-]+)(?:\s+now)?[?.!\s]*$/i;
2057
2161
 
2058
2162
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
2059
2163
  * subject and a single bare complement word. Never matches the "is a <noun>"
@@ -2115,11 +2219,6 @@ function singularizeSurface(word) {
2115
2219
  return w;
2116
2220
  }
2117
2221
 
2118
- /** "some Xs are Ys" / "a few Xs are Ys" — the plural class-membership
2119
- * quantifier shape. Captures the quantifier word itself (group 1) alongside
2120
- * the plural subject/object (groups 2/3); singularized before storage/lookup. */
2121
- const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
2122
-
2123
2222
  /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
2124
2223
  * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
2125
2224
  * names the determiner, so the caller can tell a genuine "every" universal
@@ -2295,9 +2394,7 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2295
2394
  if (classify(subjectRaw, lex)) return null;
2296
2395
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
2297
2396
  // Singularize the SUBJECT before storage, but ONLY on a genuinely PLURAL
2298
- // phrasing ("all men ARE mortal", verb "are") — mirrors SOME_A_FEW_RE's own
2299
- // singularizeSurface() call (above), which is safe unconditionally there
2300
- // only because that shape's own regex requires "are" by construction. This
2397
+ // phrasing ("all men ARE mortal", verb "are"). This
2301
2398
  // shape (UNKNOWN_SUBJECT_RE) also matches singular "is" sentences ("redis
2302
2399
  // is a cache"), where singularizing must NEVER run — "redis" naively folds
2303
2400
  // to "redi" under the same naive -s-strip. So "all men are mortal" stores
@@ -2418,7 +2515,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon },
2418
2515
  // unknownSubjectFallback itself already declines for it (its own
2419
2516
  // classify(subjectRaw) check reads "men" as the known noun "man") and hands
2420
2517
  // off to this mirror fallback instead. A naive suffix-strip
2421
- // (singularizeSurface — SOME_A_FEW_RE's own tool, reused as the fallback for
2518
+ // (singularizeSurface, the fallback for
2422
2519
  // a genuinely novel REGULAR plural not in the lexicon, e.g. "zorps") can't
2423
2520
  // undo an IRREGULAR plural like "men" -> "man" — only the lexicon's own
2424
2521
  // noun table (lookupNoun, already resolved by isGroundedTerm/classify to
@@ -3023,6 +3120,42 @@ function teachSuggestion(payload) {
3023
3120
  return `every ${subject} is ${article} ${object}`;
3024
3121
  }
3025
3122
 
3123
+ /** "some/a few/several/most/many Xs are Ys" — a claim about SOME members of a
3124
+ * class. Every teach frame in this lane stores a universal: a subClassOf says
3125
+ * each member of the subject class counts as the object, which is what makes
3126
+ * it a premise the syllogiser can chain through. Stored that way, "some men
3127
+ * are fathers" reads back as a proof that any given man is a father, citing
3128
+ * the sentence as its warrant.
3129
+ *
3130
+ * No existential shape exists in this store yet — owl:someValuesFrom is the
3131
+ * adjacent OWL construct, and reaching it means a rule of its own in
3132
+ * syllogise.mjs plus a fact shape that carries the restriction. Until one is
3133
+ * designed, these sentences refuse and name the universal that would work.
3134
+ * "every"/"each"/"all" ARE universals and teach unchanged. */
3135
+ const EXISTENTIAL_CLASS_TEACH_RE = /^(some|a few|several|most|many)\s+([\w-]+)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)[.!]*$/i;
3136
+
3137
+ /** The lexicon's own lemma for a plural, falling back to the naive suffix
3138
+ * strip — the only source that undoes an irregular plural ("men" -> "man",
3139
+ * which no suffix rule can reach). */
3140
+ const singularOf = (word, lex, lookupNoun) => lookupNoun(lex, word)?.lemma || singularizeSurface(word);
3141
+
3142
+ async function existentialTeachRefusal(payload, lexicon) {
3143
+ const sentence = String(payload || "").trim();
3144
+ const m = sentence.match(EXISTENTIAL_CLASS_TEACH_RE);
3145
+ if (!m) return null;
3146
+ const [, quantifier, subject, object] = m;
3147
+ const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3148
+ const lex = lexicon || loadLexicon();
3149
+ const singularSubject = singularOf(subject, lex, lookupNoun);
3150
+ const universal = teachSuggestion(`${singularSubject} is ${singularOf(object, lex, lookupNoun)}`);
3151
+ return {
3152
+ text: `I can't store "${sentence.replace(/[.!]+$/, "")}" — "${quantifier.toLowerCase()}" claims only some of them, `
3153
+ + "and I store universals, so that isn't a shape I can store yet."
3154
+ + (universal ? ` If you mean it of every ${singularSubject}, say "${universal}".` : ""),
3155
+ via: "teach-miss", miss: true,
3156
+ };
3157
+ }
3158
+
3026
3159
  /** The honest decline for a bare habitual teach ("penguins swim") whose
3027
3160
  * subject is grounded nowhere — neither the static lexicon nor a prior
3028
3161
  * taught fact. Mirrors ungroundedPairHint's "name the gap, hand over a
@@ -3079,21 +3212,21 @@ const TEACH_PRONOUN_RE = new RegExp(`^(?:every\\s+|each\\s+|all\\s+|some\\s+|a f
3079
3212
  * when a teach frame offers one. */
3080
3213
  const isTeachPronoun = (s) => TEACH_PRONOUNS.includes(String(s || "").trim().toLowerCase());
3081
3214
 
3082
- /** RETRACTION / NEGATION of an already-taught subClassOf fact: "X is not a Y"
3083
- * (tolerating the same "kind/type of" infix every other teach shape in this
3084
- * lane already tolerates — the "is/are" variant, plus the "isn't"/"aren't"
3085
- * contractions). Deliberately narrow — the SAFEST, most unambiguous negation
3086
- * phrasing only, matching a scoped 2-token subject (mirrors
3087
- * UNKNOWN_SUBJECT_RE's own subject width), never a general negation grammar.
3088
- * See the RETRACTION block's own comment in teachLane (below) for why a
3089
- * regex match here is only a TRIGGER, never itself proof a fact existed to
3090
- * retract retractSubClassOf (src/domain/syllogise.mjs) is the actual authority. */
3215
+ /** NEGATION of a subClassOf fact: "X is not a Y" (tolerating the same
3216
+ * "kind/type of" infix every other teach shape in this lane already tolerates
3217
+ * — the "is/are" variant, plus the "isn't"/"aren't" contractions).
3218
+ * Deliberately narrow — the SAFEST, most unambiguous negation phrasing only,
3219
+ * matching a scoped 2-token subject (mirrors UNKNOWN_SUBJECT_RE's own subject
3220
+ * width), never a general negation grammar. A match is only a TRIGGER: the
3221
+ * shape also fits a negated PROPERTY claim ("the logger is not deprecated"),
3222
+ * so the stored subject⊑object fact is what decides whether there is a
3223
+ * disagreement to record. */
3091
3224
  const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)\s+not|isn't|aren't)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
3092
- /** "forget (that) X is a Y" — the second closed retraction phrasing. Never
3093
- * wrapped by TEACH_RE ("forget" isn't one of its
3094
- * recognized lead verbs — remember/note/keep in mind/…), so this is matched
3095
- * against the RAW (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which
3096
- * is tried against the remember-wrapped surface too. */
3225
+ /** "forget (that) X is a Y" — the ONLY phrasing that retracts. Never wrapped
3226
+ * by TEACH_RE ("forget" isn't one of its recognized lead verbs —
3227
+ * remember/note/keep in mind/…), so this is matched against the RAW
3228
+ * (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which is tried against
3229
+ * the remember-wrapped surface too. */
3097
3230
  const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
3098
3231
 
3099
3232
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
@@ -3107,6 +3240,15 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3107
3240
  const rawInput = applyPreambleFrames(String(query).trim());
3108
3241
  const m = rawInput.match(TEACH_RE);
3109
3242
  const wrappedInput = m ? m[1].trim() : null;
3243
+ // Refuse an existential BEFORE any frame below can read it as a universal:
3244
+ // every one of them stores "some men are fathers" as a premise meaning every
3245
+ // man, whether it keeps the quantifier as an attribute the reasoner doesn't
3246
+ // consult (the subclass frame) or bakes the word into the subject itself
3247
+ // ("most men is a kind of fathers", the unknown-subject frame).
3248
+ if (memoryDir && !QUESTION_LEAD_RE.test(wrappedInput ?? rawInput)) {
3249
+ const refusal = await existentialTeachRefusal(wrappedInput ?? rawInput, lexicon);
3250
+ if (refusal) return refusal;
3251
+ }
3110
3252
  // "your X is a/an Y" — a plain casual synonym for "a/an X is a
3111
3253
  // Y": no special second-person semantics, so rewrite it to the ordinary
3112
3254
  // indefinite-article determiner UP FRONT, before any downstream regex/ACE
@@ -3265,31 +3407,13 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3265
3407
  };
3266
3408
  }
3267
3409
 
3268
- // RETRACTION "X is not a Y" / "forget that X is a Y": wires the
3269
- // data-layer retraction primitive (retractSubClassOf, src/domain/syllogise.mjs) up
3270
- // to chat-level phrasing. Tried here, right after the pronoun guard, so a
3271
- // pronoun subject ("it is not an animal") still falls to that
3272
- // guard's own decline first (TEACH_PRONOUN_RE matches ANY verb after the
3273
- // pronoun, including "is not"), never reaching this block.
3274
- //
3275
- // TRIGGER, never itself the authority: RETRACT_NOT_A_RE/RETRACT_FORGET_RE
3276
- // only recognize the SHAPE of a negation/retraction sentence — they say
3277
- // nothing about whether subject⊑object was ever actually taught.
3278
- // retractSubClassOf is asked for real and is the only thing that decides:
3279
- // - found:true → a real stored (or entailed) fact existed and was
3280
- // retracted (with its dependency-directed cascade) — confirmed here.
3281
- // - found:false → subject⊑object was never a stored fact. This is left
3282
- // to FALL THROUGH to the rest of teachLane's ordinary cascade below,
3283
- // deliberately NOT answered with a bespoke "nothing to forget" message
3284
- // — RETRACT_NOT_A_RE's shape also incidentally matches a NEGATED
3285
- // PROPERTY claim ("the logger is not deprecated" — never subClassOf-
3286
- // shaped at all, a pinned "genuine ceiling" case elsewhere in this
3287
- // codebase, test/chatflow-tier5.test.mjs, that must keep its own
3288
- // "I couldn't store that —" decline verbatim), so a bare "nothing
3289
- // found" here must never claim a specific, possibly-wrong reason —
3290
- // falling through preserves whatever honest response that OTHER shape
3291
- // already gets, byte-identical, while still fully closing the real gap
3292
- // (an already-taught fact's retraction, which now always succeeds).
3410
+ // NEGATION ("X is not a Y") and RETRACTION ("forget that X is a Y"). Two
3411
+ // sentences, two intents, and the split is the point: a negative is a source
3412
+ // DISAGREEING, never an instruction to destroy. Each branch documents itself
3413
+ // below; both are tried here, right after the pronoun guard, so a pronoun
3414
+ // subject ("it is not an animal") still falls to that guard's own decline
3415
+ // first (TEACH_PRONOUN_RE matches ANY verb after the pronoun, including
3416
+ // "is not"), never reaching this block.
3293
3417
  const retractSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
3294
3418
  const retractSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(retractSrc)
3295
3419
  ? await hasMidSentenceInterrogative(retractSrc) : false;
@@ -3302,22 +3426,76 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3302
3426
  if (retractMatch) {
3303
3427
  const retractSubject = retractMatch[1].trim();
3304
3428
  const retractObject = retractMatch[2].trim();
3305
- const { retractSubClassOf } = await import("../domain/syllogise.mjs");
3306
- const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts } = await import("../adapters/memory/core.mjs");
3307
- const result = await retractSubClassOf(memoryDir, retractSubject, retractObject, {
3308
- store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts },
3309
- });
3310
- if (result.found) {
3311
- const extra = result.count - 1; // beyond the target fact itself
3312
- return {
3313
- text: `noted forgotten: "${retractSubject} is a kind of ${retractObject}" is no longer stored`
3314
- + (extra > 0 ? ` (${extra} entailed fact${extra === 1 ? "" : "s"} that depended on it went too)` : "")
3315
- + (result.truncated ? " this cascade may not be complete (a lot depended on it); ask again if something still looks stale" : "")
3316
- + ".",
3317
- via: "retract", miss: false,
3318
- };
3429
+
3430
+ // A BARE NEGATIVE IS A CLAIM, NOT AN INSTRUCTION TO DELETE. "john is not a
3431
+ // man" disagrees with a stored fact; it does not ask for it to be
3432
+ // destroyed, and destroying it loses the very disagreement the user came
3433
+ // to record. Both polarities are stored under their own predicate
3434
+ // (memory/capability.mjs: a fact id hashes (subject, predicate, object), so
3435
+ // sharing one predicate would merge them and union their statedBy edges),
3436
+ // and the ask ladder reports the disagreement rather than picking a side.
3437
+ // Only the explicit "forget that X is a Y" verb retracts see below.
3438
+ //
3439
+ // GATED ON THE POSITIVE EXISTING, and that gate is load-bearing:
3440
+ // RETRACT_NOT_A_RE's shape also incidentally matches a negated PROPERTY
3441
+ // claim ("the logger is not deprecated"), which is never subClassOf-shaped
3442
+ // at all and keeps its own decline verbatim. With no stored subject⊑object
3443
+ // to disagree with, there is nothing here to record, so the sentence falls
3444
+ // through to the ordinary cascade exactly as it always has.
3445
+ if (retractNotMatch) {
3446
+ const { SUBCLASS_PREDICATE } = await import("../domain/syllogise.mjs");
3447
+ const { loadMemory: loadMemForNeg, normFactTerm: normTermForNeg, readFactRows: readRowsForNeg } = await import("../adapters/memory/core.mjs");
3448
+ const negSubject = normTermForNeg(retractSubject);
3449
+ const negObject = normTermForNeg(retractObject);
3450
+ const priorRows = readRowsForNeg(await loadMemForNeg(memoryDir));
3451
+ const positive = priorRows.find((r) => r.subject === negSubject && r.predicate === SUBCLASS_PREDICATE && r.object === negObject);
3452
+ if (positive) {
3453
+ const stored = await teachFact(memoryDir, sessionId, {
3454
+ subject: retractSubject, predicate: NEG_SUBCLASS_PREDICATE, object: retractObject,
3455
+ });
3456
+ if (stored) {
3457
+ return {
3458
+ ...stored,
3459
+ text: `${stored.text} — you told me earlier that ${negSubject} is a kind of ${negObject}, so both are now stored `
3460
+ + `and I'll report the disagreement rather than pick one. `
3461
+ + `To drop the earlier fact instead, say "forget that ${negSubject} is ${indefiniteArticleFor(negObject)} ${negObject}".`,
3462
+ };
3463
+ }
3464
+ }
3465
+ // nothing stored to disagree with — fall through (see the gate above).
3466
+ }
3467
+
3468
+ // RETRACTION — "forget that X is a Y": wires the data-layer retraction
3469
+ // primitive (retractSubClassOf, src/domain/syllogise.mjs) up to chat-level
3470
+ // phrasing.
3471
+ //
3472
+ // TRIGGER, never itself the authority: RETRACT_FORGET_RE only recognizes
3473
+ // the SHAPE of a retraction sentence — it says nothing about whether
3474
+ // subject⊑object was ever actually taught. retractSubClassOf is asked for
3475
+ // real and is the only thing that decides:
3476
+ // - found:true → a real stored (or entailed) fact existed and was
3477
+ // retracted (with its dependency-directed cascade) — confirmed here.
3478
+ // - found:false → subject⊑object was never a stored fact, and this falls
3479
+ // through to the rest of teachLane's ordinary cascade below rather than
3480
+ // claiming a specific, possibly-wrong reason.
3481
+ if (retractForgetMatch) {
3482
+ const { retractSubClassOf } = await import("../domain/syllogise.mjs");
3483
+ const { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts } = await import("../adapters/memory/core.mjs");
3484
+ const result = await retractSubClassOf(memoryDir, retractSubject, retractObject, {
3485
+ store: { loadMemory: loadMemForRetract, readFactRows: readRowsForRetract, removeFacts },
3486
+ });
3487
+ if (result.found) {
3488
+ const extra = result.count - 1; // beyond the target fact itself
3489
+ return {
3490
+ text: `noted — forgotten: "${retractSubject} is a kind of ${retractObject}" is no longer stored`
3491
+ + (extra > 0 ? ` (${extra} entailed fact${extra === 1 ? "" : "s"} that depended on it went too)` : "")
3492
+ + (result.truncated ? " — this cascade may not be complete (a lot depended on it); ask again if something still looks stale" : "")
3493
+ + ".",
3494
+ via: "retract", miss: false,
3495
+ };
3496
+ }
3497
+ // found:false — fall through to the rest of the cascade (see docblock above).
3319
3498
  }
3320
- // found:false — fall through to the rest of the cascade (see docblock above).
3321
3499
  }
3322
3500
 
3323
3501
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
@@ -3729,59 +3907,6 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
3729
3907
  if (stored) return stored;
3730
3908
  }
3731
3909
 
3732
- // "some Xs are Ys" / "a few Xs are Ys" — the plural class-
3733
- // membership quantifier shape. ACE has no quantifier-phrase pattern at all
3734
- // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
3735
- // never routed through assertTurn below. Wrapper-optional, like the
3736
- // "every X is a Y" baseline — a plural "some/a few" claim reads as an
3737
- // ordinary declarative teach the same way "every" always has. The OBJECT
3738
- // still has to be a known lexicon noun (the same "subject gets the free
3739
- // pass, object doesn't" discipline as unknownSubjectFallback below) — an
3740
- // unknown object falls through to the generic honest-miss cascade at the
3741
- // bottom of this function, same as every other unstorable teach.
3742
- const someSrc = wrapped ?? raw.replace(/[.!?]+\s*$/, "");
3743
- // Additive alongside the existing anchored QUESTION_LEAD_RE check — same
3744
- // discipline as ownSrcMidQuestion above (hasMidSentenceInterrogative's own
3745
- // docblock, near QUESTION_LEAD_RE, has the full reasoning).
3746
- const someSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(someSrc) ? await hasMidSentenceInterrogative(someSrc) : false;
3747
- const someMatch = memoryDir && !QUESTION_LEAD_RE.test(someSrc) && !someSrcMidQuestion ? someSrc.match(SOME_A_FEW_RE) : null;
3748
- if (someMatch) {
3749
- const quantifier = someMatch[1].toLowerCase();
3750
- const subject = singularizeSurface(someMatch[2]);
3751
- const object = singularizeSurface(someMatch[3]);
3752
- const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3753
- const lex = lexicon || loadLexicon();
3754
- if (lookupNoun(lex, object)) {
3755
- const stored = await teachFact(memoryDir, sessionId, {
3756
- subject, predicate: SUBCLASS_PREDICATE, object, quantifier,
3757
- });
3758
- if (stored) return stored;
3759
- } else {
3760
- // "remember that some functions are risky" — Y ("risky") is not a
3761
- // lexicon NOUN, so the subclass path just above correctly declines it
3762
- // (SOME_A_FEW_RE is subclass-only). Without this guard, the sentence
3763
- // would fall through to unknownSubjectFallback/TEACH_PROPERTY_RE below,
3764
- // which DO tolerate a multi-word subject with NO vocabulary check on
3765
- // the complement at all — silently mis-teaching the LITERAL 2-word
3766
- // string "some functions" as if it were one proper-noun subject
3767
- // ("noted — remembered: some functions is risky", the quantifier word
3768
- // baked wrongly into the subject and a subject/verb agreement error to
3769
- // boot), a fact "how many functions are risky" could never sensibly
3770
- // read back either (HOW_MANY_ARE_RE's own reader only ever looks for
3771
- // the SUBCLASS_PREDICATE shape this path would have stored, not this
3772
- // one). A quantified PROPERTY claim isn't a supported shape yet (only
3773
- // a quantified SUBCLASS claim is) — decline honestly here instead of
3774
- // silently mis-teaching, rather than let a later, less-specific frame
3775
- // guess a wrong split.
3776
- return {
3777
- text: `I can only remember a quantified fact as "${quantifier} ${someMatch[2]} are <a kind of thing>" (like "${quantifier} bugs are issues") — `
3778
- + `a quantified claim about a PROPERTY ("${quantifier} ${someMatch[2]} are ${object}") isn't a shape I can store yet. `
3779
- + `I can remember "${someMatch[2]} are ${object}" for one specific ${subject}, though — try naming it directly.`,
3780
- via: "teach-miss", miss: true,
3781
- };
3782
- }
3783
- }
3784
-
3785
3910
  // GENERAL VERB-TO-PREDICATE TEACH — "remember <Subject> <verb>
3786
3911
  // <Object>" where <verb> is neither is/are (handled below via the ACE/
3787
3912
  // unknown-subject/property paths) nor owns/maintains (handled above).
@@ -4099,6 +4224,18 @@ const MODULE_ORIENT_SVO_RE = new RegExp(`^what\\s+(.+?)\\s+does${TRAILING_ADVERB
4099
4224
  // tolerance for the bare "whats" contraction spelling, just below.
4100
4225
  const MODULE_PURPOSE_RE = /^what(?:'s|s|\s+is)\s+(.+?)\s+(?:for|about)\??$/i;
4101
4226
 
4227
+ /** A module PATH as a reader types it — "src/core/store.mjs", "app/lib/b.mjs",
4228
+ * or a bare "store.mjs". Requires a slash or a source-file extension, which is
4229
+ * what makes the two identity phrasings below safe to claim: no vocabulary
4230
+ * term can match this shape, so "what is a dog" is untouched. The lane's
4231
+ * exact-unique resolveEntity gate is still the authority — this only decides
4232
+ * what is worth ASKING it about. */
4233
+ const MODULE_PATH_RE = /^(?:[\w.@~-]+\/)+[\w.@~-]+$|^[\w.@~-]+\.(?:mjs|cjs|js|jsx|ts|tsx|py|java|rb|go|rs|php|cs|kt|swift)$/i;
4234
+ /** "what is src/core/store.mjs" — the identity phrasing of the same question
4235
+ * "what does X do" already answers. Kept distinct from MODULE_PURPOSE_RE
4236
+ * ("what is X for"), whose trailing "for"/"about" is what anchors it. */
4237
+ const MODULE_IDENTITY_RE = /^what(?:'s|s|\s+is)\s+(.+?)$/i;
4238
+
4102
4239
  /** A leading politeness/formal-ESL wrapper this lane's own anchored regexes
4103
4240
  * otherwise miss entirely: "please explain what does X do"
4104
4241
  * starts with neither "what"/"whats" (MODULE_ORIENT_RE/MODULE_PURPOSE_RE's own
@@ -4141,8 +4278,16 @@ async function moduleOrientLane(query, { graph }) {
4141
4278
  // regex only adds the "explain [to me]" wrapper on top).
4142
4279
  q = stripFillerWords(applyPreambleFrames(correctMisspellings(q))).replace(MODULE_ORIENT_POLITENESS_RE, "");
4143
4280
  const m = q.match(MODULE_ORIENT_RE) || q.match(MODULE_PURPOSE_RE) || q.match(MODULE_ORIENT_SVO_RE);
4144
- if (!m) return null;
4145
- const term = m[1].trim();
4281
+ // "what does src/core/store.mjs do" already reached the overview; the bare
4282
+ // path and "what is <path>" did not, so the same module answered one
4283
+ // phrasing and walled two. Both are claimed here rather than in ask.mjs,
4284
+ // whose Module fallback is absent BY DESIGN — adding it there replaces this
4285
+ // rich, module-grain overview with a thin one. Gated on the term looking
4286
+ // like a path, so this widens the lane by exactly the shape that was
4287
+ // missing and can never claim a vocabulary question.
4288
+ const identity = m ? null : (q.match(MODULE_IDENTITY_RE)?.[1]?.trim() ?? q);
4289
+ const term = m ? m[1].trim() : (identity && MODULE_PATH_RE.test(identity) ? identity : null);
4290
+ if (!term) return null;
4146
4291
  if (/^(?:it|this|that|they|them)$/i.test(term)) return null;
4147
4292
  const ent = await resolveEntity(graph, term);
4148
4293
  if (!ent) return null;
@@ -4693,6 +4838,7 @@ async function recallFromBlocks(memoryDir, query, graph) {
4693
4838
  * predicate renders verbatim rather than being guessed around. */
4694
4839
  const FACT_PREDICATE_PHRASES = {
4695
4840
  "rdfs:subClassOf": "is a kind of",
4841
+ "mgxneg:subClassOf": "is not a kind of",
4696
4842
  "rdf:type": "is a",
4697
4843
  "owl:disjointWith": "is not a",
4698
4844
  "mgx:partOf": "is part of",
@@ -4862,6 +5008,40 @@ function renderFactLine(f) {
4862
5008
  return `i learned: ${factPhrase(f)}${cite}`;
4863
5009
  }
4864
5010
 
5011
+ /** "a"/"an" for a term, through the SAME grammar-rules.toml "article" rule and
5012
+ * finish.mjs's beginsWithVowelSound every other agreement site in this file
5013
+ * uses — never a hardcoded "a", which is ungrammatical for a vowel-initial
5014
+ * term ("forget that task is a animal"). */
5015
+ function indefiniteArticleFor(term) {
5016
+ const articleRule = grammarRules().find((r) => r.kind === "article");
5017
+ return articleRule && beginsWithVowelSound(String(term || ""), articleRule) ? "an" : "a";
5018
+ }
5019
+
5020
+ /** The verdict on an is-a question, given the best stored fact of each
5021
+ * polarity. Null when neither is stored, so a caller's own honest miss stands
5022
+ * — an absent positive is never a "no".
5023
+ *
5024
+ * BOTH POLARITIES STORED names both sources and picks NOTHING, which is the
5025
+ * "both" verdict memory/capability.mjs already defines for the capability
5026
+ * family. Preferring either one would rank a tie-break the reader can't see
5027
+ * above what they actually said; recency in particular looks like a
5028
+ * correction and is just as often a second speaker.
5029
+ *
5030
+ * Shared by the two is-a readers (the memory-facts lane and the full ladder),
5031
+ * so a disagreement reads identically whichever one answers. */
5032
+ function isaPolarityReply(hit, negHit) {
5033
+ if (hit && negHit) {
5034
+ return {
5035
+ text: `you've told me both, and I won't pick between them — ${renderFactLine(hit)}; ${renderFactLine(negHit)}. `
5036
+ + `To settle it, say "forget that ${hit.subject} is ${indefiniteArticleFor(hit.object)} ${hit.object}".`,
5037
+ replace: true,
5038
+ };
5039
+ }
5040
+ if (negHit) return { text: `no — ${renderFactLine(negHit)}`, replace: true };
5041
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
5042
+ return null;
5043
+ }
5044
+
4865
5045
  /** PROOF-CHAIN RECEIPT — "renderable as a chain of thought in words": render
4866
5046
  * an ordered list of
4867
5047
  * premise Fact rows as one continuous argument — "cache is a kind of store;
@@ -4927,11 +5107,71 @@ async function factRows(memoryDir, cache = null) {
4927
5107
 
4928
5108
  /** Spelling variants a question term is matched under (normFactTerm + a naive
4929
5109
  * singular): "caches"/"a cache"/"/c/en/cache" all reach the stored "cache". */
5110
+ /** The lexicon's declared plural → lemma map ("men"→"man", "people"→"person"),
5111
+ * loaded once. It carries ONLY the irregulars — a regular plural is recovered
5112
+ * by the -s/-es fold below, so declaring one would be redundant — which is
5113
+ * exactly the set that fold cannot reach. Failure-tolerated: a broken lexicon
5114
+ * degrades to the fold alone, never a crash. */
5115
+ let declaredNounPlurals = null;
5116
+ function irregularSingularOf(word) {
5117
+ if (!declaredNounPlurals) {
5118
+ try { declaredNounPlurals = loadLexicon().nounPlurals; } catch { declaredNounPlurals = new Map(); }
5119
+ }
5120
+ return declaredNounPlurals.get(word) ?? null;
5121
+ }
5122
+
5123
+ /** A subject as it should be SPOKEN BACK in an offered teach sentence: the
5124
+ * lexicon's own lemma for a single known noun, else the reader's words
5125
+ * untouched.
5126
+ *
5127
+ * Without it a plural subject was echoed raw into a singular frame —
5128
+ * "remember that women is mortal" — offering a sentence that is both
5129
+ * ungrammatical and not the shape the teach path stores. lookupNoun is the
5130
+ * lemmatizer the teach path already uses, and it is the whole plural detector:
5131
+ * it folds "women"→woman and "dogs"→dog while leaving "bus" alone, which no
5132
+ * -s rule written here could do. A multi-word or unknown subject is left
5133
+ * exactly as typed — "every zibble is mortal" already reads correctly, and
5134
+ * guessing at a phrase's head would be worse than echoing it. */
5135
+ function teachableSubjectOf(subject) {
5136
+ const raw = String(subject || "").trim().toLowerCase();
5137
+ if (!raw || /\s/.test(raw)) return raw;
5138
+ try {
5139
+ return lookupNoun(loadLexicon(), raw)?.lemma || raw;
5140
+ } catch {
5141
+ return raw;
5142
+ }
5143
+ }
5144
+
5145
+ /** A leading universal quantifier, which is scaffolding rather than part of a
5146
+ * name. The teach frames strip exactly these before storing (UNKNOWN_SUBJECT_RE
5147
+ * above carries the same set), so no fact is ever stored under a subject that
5148
+ * begins with one — which is what makes stripping it here a lookup fix and not
5149
+ * a guess. "a"/"an" are deliberately absent: the readers' own regexes already
5150
+ * take the article. */
5151
+ const QUANTIFIER_LEAD_RE = /^(?:every|each|all|any)\s+/i;
5152
+
4930
5153
  function factTermVariants(normFactTerm, term) {
4931
5154
  const t = normFactTerm(term);
4932
- const v = new Set([t]);
4933
- if (t.endsWith("es")) v.add(t.slice(0, -2));
4934
- if (t.endsWith("s")) v.add(t.slice(0, -1));
5155
+ const v = new Set();
5156
+ // The ask frames glue a quantifier onto the subject and looked up "every
5157
+ // man", a name nothing is stored under, while "is a man mortal" answered.
5158
+ // Both spellings fold through the same plural rules below, so "are all men
5159
+ // mortal" reaches "man" the same way "are men mortal" does.
5160
+ const bases = new Set([t]);
5161
+ const unquantified = t.replace(QUANTIFIER_LEAD_RE, "").trim();
5162
+ if (unquantified && unquantified !== t) bases.add(unquantified);
5163
+ for (const base of bases) {
5164
+ v.add(base);
5165
+ if (base.endsWith("es")) v.add(base.slice(0, -2));
5166
+ if (base.endsWith("s")) v.add(base.slice(0, -1));
5167
+ // An IRREGULAR plural is invisible to the fold above: "men" keeps every
5168
+ // letter of "man" in a different order, so a reader asking "do men die"
5169
+ // looked up a subject no fact is stored under while "does a man die"
5170
+ // answered. The teach path stores the singular, so the ask path has to be
5171
+ // able to reach it.
5172
+ const irregular = irregularSingularOf(base);
5173
+ if (irregular) v.add(normFactTerm(irregular));
5174
+ }
4935
5175
  return v;
4936
5176
  }
4937
5177
 
@@ -5128,8 +5368,8 @@ function matchGenitiveWhoAsk(q) {
5128
5368
  * reachable from the named start entity through a taught `recursive` Rule,
5129
5369
  * not a single yes/no. `m[1]` = the rule's PLURAL name ("descendants",
5130
5370
  * singularized via singularizeSurface before the findRuleByName lookup —
5131
- * the same naive plural fold SOME_A_FEW_RE's own teach-side surface already
5132
- * uses elsewhere in this file), `m[2]` = the start entity ("ahab"). Dispatch
5371
+ * the same naive plural fold the teach-side surfaces use elsewhere in this
5372
+ * file), `m[2]` = the start entity ("ahab"). Dispatch
5133
5373
  * lives in factReadBack's own (a0.5) block, below — findRuleByName +
5134
5374
  * findReachableSet (src/domain/planning.mjs), never a yes/no answer. */
5135
5375
  const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
@@ -5757,13 +5997,23 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5757
5997
  // bare catch-all: on the FIRST turn of a graph-less session, dispatchTool's
5758
5998
  // loadGraph() throws its own documented empty-graph ToolError (a pre-existing,
5759
5999
  // by-design bootstrap behavior — self-corrects from turn 2 on), which leaves
5760
- // `envelope` null for the rest of the turn, arming this `!envelope?.parsed`
5761
- // fallback. Without this guard it greedily swallows the WHOLE "kind of animal"
5762
- // tail as a literal meta-term to define (mirroring grammar.mjs T5's OWN
5763
- // ARTICLE_RELATION_CONTINUATIONS guard against the identical over-capture),
5764
- // returning early and never letting (b5) below — which already handles this
5765
- // exact shape via WHAT_INHERITS_RE, envelope or no envelope — get a chance.
5766
- if (!metaTerm && miss && !envelope?.parsed && !WHAT_INHERITS_RE.test(q)) {
6000
+ // `envelope` null for the rest of the turn, arming this
6001
+ // no-parse-to-defer-to fallback. Without this guard it greedily swallows the
6002
+ // WHOLE "kind of animal" tail as a literal meta-term to define (mirroring
6003
+ // grammar.mjs T5's OWN ARTICLE_RELATION_CONTINUATIONS guard against the
6004
+ // identical over-capture), returning early and never letting (b5) below —
6005
+ // which already handles this exact shape via WHAT_INHERITS_RE, envelope or
6006
+ // no envelope get a chance.
6007
+ //
6008
+ // A parse that MISSED is not a parse to defer to. "what are dogs" is claimed
6009
+ // by the composite lane, which declines it ({node:"miss"}, "'dogs' isn't a
6010
+ // listable kind") and by existing merely blocked the vocabulary reader that
6011
+ // answers the singular. The plural is the whole difference: "what are dog"
6012
+ // always worked. A SUCCESSFUL parse still wins here exactly as before — the
6013
+ // over-capture this guard exists to stop parses fine, so it never reaches
6014
+ // this branch.
6015
+ const parsedOwnsIt = envelope?.parsed && envelope.parsed.node !== "miss";
6016
+ if (!metaTerm && miss && !parsedOwnsIt && !WHAT_INHERITS_RE.test(q)) {
5767
6017
  const m = q.match(BARE_WHATIS_RE)
5768
6018
  || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
5769
6019
  // Strip a curated trailing scope clause ("… in this
@@ -5899,11 +6149,15 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
5899
6149
  if (isa) {
5900
6150
  const subj = factTermVariants(normFactTerm, isa[1]);
5901
6151
  const obj = factTermVariants(normFactTerm, isa[2]);
5902
- const hit = (await memoryFacts(memoryDir)).find(
5903
- (f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject) && obj.has(f.object),
6152
+ const isaRows = await memoryFacts(memoryDir);
6153
+ const onTerms = (f) => subj.has(f.subject) && obj.has(f.object);
6154
+ // A remembered NEGATIVE is read on the same terms as the positive — it
6155
+ // carries its own predicate and so never reaches ISA_PREDICATES.
6156
+ const reply = isaPolarityReply(
6157
+ isaRows.find((f) => ISA_PREDICATES.has(f.predicate) && onTerms(f)),
6158
+ isaRows.find((f) => f.predicate === NEG_SUBCLASS_PREDICATE && onTerms(f)),
5904
6159
  );
5905
- if (hit) return { text: `yes${renderFactLine(hit)}`, replace: true };
5906
- return null; // no remembered fact — the honest miss stands (never a guessed "no")
6160
+ return reply; // no remembered factnull, so the honest miss stands (never a guessed "no")
5907
6161
  }
5908
6162
 
5909
6163
  // (b2) "can a dog bark" — the polarity of a capability, resolved through the
@@ -6298,6 +6552,13 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
6298
6552
  * vocabulary-term lookup for the literal term "in X". */
6299
6553
  const WHAT_ELSE_IS_RE = /^what\s+else\s+(?:is|are)\s+(?!in\b|inside\b)(?:an?\s+)?(.+?)[?.!\s]*$/i;
6300
6554
  const WHAT_ELSE_ABOUT_RE = /^what\s+else\s+(?:do\s+you\s+know\s+)?about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
6555
+ /** The same question with its subject left implicit — "what else", "anything
6556
+ * else", "what else do you know". A reader who has just been told about dogs
6557
+ * and asks "what else" means "what else about dogs"; the subject is carried by
6558
+ * the conversation, exactly as the pronoun in "can it bark" is. So the term
6559
+ * comes from the standing referent (vocabAntecedentFrom) and this shape is a
6560
+ * no-op when nothing is standing. */
6561
+ const WHAT_ELSE_BARE_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*(?:what\s+else(?:\s+do\s+you\s+know)?|anything\s+else(?:\s+you\s+know)?|got\s+anything\s+else)[?.!\s]*$/i;
6301
6562
 
6302
6563
  /** "what else is X" — surface remembered facts about X BEYOND the primary
6303
6564
  * curated (corpus/seon) prose definition, which is itself never a Facts row
@@ -6321,9 +6582,18 @@ async function whatElseAnswer(memoryDir, query, last) {
6321
6582
  if (!memoryDir) return null;
6322
6583
  const q = String(query).trim();
6323
6584
  const m = q.match(WHAT_ELSE_IS_RE) || q.match(WHAT_ELSE_ABOUT_RE);
6324
- if (!m) return null;
6325
- const term = m[1].trim();
6326
- if (!term) return null;
6585
+ // A bare "what else" takes its subject from the standing referent — the same
6586
+ // last-grounded-answer binding "can it bark" uses.
6587
+ const bare = !m && WHAT_ELSE_BARE_RE.test(q);
6588
+ const term = (m ? m[1] : (bare ? vocabAntecedentFrom(last) : null) || "").trim();
6589
+ // Asked cold, it names what it cannot resolve rather than introducing the
6590
+ // tool — the same courtesy a cold pronoun already gets. An identity blurb
6591
+ // answers a question nobody asked.
6592
+ if (!term) {
6593
+ return bare
6594
+ ? { text: "Nothing to add yet — there's no subject standing. Ask me about something first, then \"what else\".", replace: true, miss: true }
6595
+ : null;
6596
+ }
6327
6597
  let normFactTerm;
6328
6598
  try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
6329
6599
  const variants = factTermVariants(normFactTerm, term);
@@ -6510,7 +6780,7 @@ const IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE = /^(?:you|i|they|he|she|we)\b/i;
6510
6780
  * reaches TEACH_PROPERTY_RE via BARE_DECLARATIVE_RE's single-token-subject
6511
6781
  * restriction and would fail here). */
6512
6782
  const unknownAdjectiveOffer = (subject, adjective) => ({
6513
- text: `I don't know anything about "${subject}" yet — teach me directly, e.g. "remember that ${subject.toLowerCase()} is ${adjective}".`,
6783
+ text: `I don't know anything about "${subject}" yet — teach me directly, e.g. "remember that ${teachableSubjectOf(subject)} is ${adjective}".`,
6514
6784
  replace: true,
6515
6785
  });
6516
6786
  /** WHOLE-STORE recall: "what did i tell you [last time]",
@@ -7030,7 +7300,14 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7030
7300
  const hit = isa
7031
7301
  .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
7032
7302
  .sort(byTrust)[0];
7033
- if (hit) return { text: `yes ${renderFactLine(hit)}`, replace: true };
7303
+ // A STORED NEGATIVE ("john is not a man") is a source disagreeing, so it is
7304
+ // read on the same terms as the positive rather than losing to it by
7305
+ // default. It carries its own predicate and so never reaches `isa`.
7306
+ const negHit = rows
7307
+ .filter((f) => f.predicate === NEG_SUBCLASS_PREDICATE && subjCandidates.has(f.subject) && objVariants.has(f.object))
7308
+ .sort(byTrust)[0];
7309
+ const polarityReply = isaPolarityReply(hit, negHit);
7310
+ if (polarityReply) return polarityReply;
7034
7311
  // CLASS↔INSTANCE BRIDGE: when X resolves to a graph entity, its
7035
7312
  // inherits chain's superclass LABELS are subject candidates too — a taught
7036
7313
  // "controller ⊑ handler" composes with a graph "TaskController inherits
@@ -7184,7 +7461,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7184
7461
  // missed — check whether X, having taught-P'd something of a taught type
7185
7462
  // (lifted through that type's FULL ⊑-ancestor closure), satisfies a
7186
7463
  // TAUGHT someValuesFrom restriction declared over that SAME (property,
7187
- // type) pair — the restriction CLASS itself entailed (OWL 2 RL Table 8's
7464
+ // type) pair — the restriction CLASS itself entailed (OWL 2 RL Table 6's
7188
7465
  // cls-svf1), via syllogise.mjs's deriveSomeValuesFromApplication, LIVE and
7189
7466
  // READ-ONLY (same discipline as the cax-dw chase just above: nothing is
7190
7467
  // written; syllogise()'s materializing batch pass is the persisting
@@ -7668,7 +7945,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
7668
7945
  // originally-intended case here) still gets this receipt exactly as
7669
7946
  // before, since envelope.parsed is null for those adjectives.
7670
7947
  if (rows.some(subjectMatch) && !envelope?.parsed) {
7671
- return { text: `I don't have a fact saying ${subject.toLowerCase()} is ${adjective}.`, replace: true };
7948
+ return { text: `I don't have a fact saying ${teachableSubjectOf(subject)} is ${adjective}.`, replace: true };
7672
7949
  }
7673
7950
  // Without this, "is the checkout flow
7674
7951
  // deprecated" as a genuinely FIRST-EVER question about a subject tmct
@@ -8486,11 +8763,31 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
8486
8763
  // updates too — mirrors the focus updates the ordinary ask() path does.
8487
8764
  const ent = await resolveEntity(graph, term);
8488
8765
  return { text, ent };
8489
- } catch {
8766
+ } catch (e) {
8767
+ // tmct_describe answers a concept from memory/corpus facts whenever the
8768
+ // code map holds nothing for it — but dispatchTool loads the graph BEFORE
8769
+ // the handler runs, so on an empty one the handler never gets to reach its
8770
+ // own fall-through. Reach it here instead: "tell me about a dog" is a
8771
+ // question about a dog, and the code graph's emptiness is no answer to it.
8772
+ if (e?.emptyGraph) return describeFromMemoryFacts(term, config);
8490
8773
  return null; // unresolvable term — decline, the ordinary wall stands unchanged
8491
8774
  }
8492
8775
  }
8493
8776
 
8777
+ /** tmct_describe's OWN memory/corpus fall-through (tools/memory-fallthrough.mjs),
8778
+ * called directly for a session whose code graph is empty. Same rows, same
8779
+ * renderer, same provenance the handler itself would have cited. */
8780
+ async function describeFromMemoryFacts(term, config) {
8781
+ if (!config) return null;
8782
+ try {
8783
+ const { memoryFactRows, renderMemoryDefinition } = await import("../tools/memory-fallthrough.mjs");
8784
+ const text = renderMemoryDefinition(await memoryFactRows(config), term);
8785
+ return text ? { text, ent: null } : null;
8786
+ } catch {
8787
+ return null;
8788
+ }
8789
+ }
8790
+
8494
8791
  /** COMPARE — a scoped
8495
8792
  * v1: "how is X different from Y", "how does X differ from Y", "compare X and
8496
8793
  * Y"/"compare X with/to Y", "what's the difference between X and Y". Closed
@@ -8849,7 +9146,18 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
8849
9146
  // The verbless voicing carries every capture but the verb, so it folds into
8850
9147
  // the frame below once the store names the verb — same spec, same
8851
9148
  // confirmation, same fold as its verbed twin.
8852
- const verblessGoal = goalMatch ? null : q.match(GOAL_TEACH_VERBLESS_RE);
9149
+ let verblessGoal = goalMatch ? null : q.match(GOAL_TEACH_VERBLESS_RE);
9150
+ // The imperative voicing ("get all the disks onto peg-c") folds into the same
9151
+ // verbless resolution: singularize the class term ("disks"→"disk"), read the
9152
+ // universal off the quantifier, and normalize the motion preposition to the
9153
+ // static one a location fact is stored under ("onto"→"on").
9154
+ if (!goalMatch && !verblessGoal) {
9155
+ const imperative = q.match(GOAL_TEACH_IMPERATIVE_RE);
9156
+ if (imperative) {
9157
+ const prep = { onto: "on", into: "in", upon: "on" }[imperative[3].toLowerCase()] ?? imperative[3].toLowerCase();
9158
+ verblessGoal = [imperative[0], imperative[1] ? "every" : "", singularizeSurface(imperative[2]), prep, imperative[4]];
9159
+ }
9160
+ }
8853
9161
  if (verblessGoal) {
8854
9162
  const { normFactTerm } = await import("../adapters/memory/core.mjs");
8855
9163
  const { factRows, domain } = await loadPlanContext(memoryDir);
@@ -9073,6 +9381,90 @@ async function executePlanStep(planHolder, { memoryDir, sessionId = "" }) {
9073
9381
  };
9074
9382
  }
9075
9383
 
9384
+ /** Plan follow-up questions ("what is the next move", "how many moves", "why
9385
+ * that move") answered off the ACTIVE plan, and board-state questions ("is X
9386
+ * clear", "what rests on X", "where is X") answered off the CURRENT board (the
9387
+ * latest @stepK snapshot, or the taught board before any step). Returns
9388
+ * { text, deduced, note } or null — null when the query is neither shape, or
9389
+ * when no plan/board stands to answer from, so an honest miss stands cold.
9390
+ * This keeps a read from contradicting the plan's own board@stepK line: after
9391
+ * "next" moves a piece, "what rests on X" reflects the snapshot, not the stale
9392
+ * pre-plan facts. Clearness is derived, never stored — a piece is clear iff
9393
+ * nothing rests on it on the current board. */
9394
+ async function planFollowUpAnswer(query, { memoryDir, planHolder }) {
9395
+ const q = String(query).trim();
9396
+ const ps = planHolder?.state;
9397
+ const activePlan = ps && Array.isArray(ps.actions) && ps.actions.length;
9398
+
9399
+ if (PLAN_WHAT_NEXT_RE.test(q)) {
9400
+ if (!activePlan) return null;
9401
+ if (ps.done || ps.cursor >= ps.actions.length) {
9402
+ return { text: `the plan is complete — all ${ps.actions.length} moves are made.`, deduced: "name the next planned move (plan complete)", note: "PLAN FOLLOW-UP — next move: plan already complete" };
9403
+ }
9404
+ return { text: `the next move is move ${ps.cursor + 1} of ${ps.actions.length}: ${ps.actions[ps.cursor].label}. Say "next" to make it.`, deduced: "name the next planned move", note: "PLAN FOLLOW-UP — next move read from the active plan" };
9405
+ }
9406
+ if (PLAN_MOVE_COUNT_RE.test(q)) {
9407
+ if (!activePlan) return null;
9408
+ const total = ps.actions.length;
9409
+ const remaining = Math.max(0, total - ps.cursor);
9410
+ return { text: remaining === total ? `${total} move${total === 1 ? "" : "s"} in the plan.` : `${total} move${total === 1 ? "" : "s"} in the plan, ${remaining} still to make.`, deduced: "count the moves in the active plan", note: "PLAN FOLLOW-UP — move count from the active plan" };
9411
+ }
9412
+ if (PLAN_WHY_MOVE_RE.test(q)) {
9413
+ if (!activePlan) return null;
9414
+ const idx = ps.cursor < ps.actions.length ? ps.cursor : ps.actions.length - 1;
9415
+ const line = ps.stepGoals?.[idx] ?? `${ps.actions[idx].label} (step ${idx + 1} of ${ps.actions.length})`;
9416
+ return { text: `${line} — it is this step's move on the shortest path.`, deduced: "explain the next planned move", note: "PLAN FOLLOW-UP — why-move from the active plan's step goals" };
9417
+ }
9418
+
9419
+ const clear = q.match(IS_CLEAR_RE);
9420
+ const rev = clear ? null : q.match(BOARD_REVERSE_LOC_RE);
9421
+ const fwd = clear || rev ? null : q.match(BOARD_FORWARD_LOC_RE);
9422
+ const where = clear || rev || fwd ? null : q.match(BOARD_WHERE_RE);
9423
+ if (!clear && !rev && !fwd && !where) return null;
9424
+ if (!memoryDir) return null;
9425
+
9426
+ let ctx;
9427
+ try { ctx = await loadPlanContext(memoryDir); } catch { return null; }
9428
+ const { domain, state } = ctx;
9429
+ if (!domain.actions.length || !state.length) return null; // no board — the honest miss stands
9430
+ const { normFactTerm } = await import("../adapters/memory/core.mjs");
9431
+ const individuals = new Set(Object.values(domain.classMembers || {}).flat());
9432
+
9433
+ if (clear) {
9434
+ const x = normFactTerm(clear[1]);
9435
+ if (!individuals.has(x)) return null;
9436
+ const on = state.filter((r) => r.object === x);
9437
+ return on.length
9438
+ ? { text: `no — ${x} is not clear: ${on.map(factPhrase).join("; ")}.`, deduced: "check whether a board piece is clear", note: "BOARD — clearness derived from the current board (a piece rests on it)" }
9439
+ : { text: `yes — ${x} is clear: nothing rests on it on the current board.`, deduced: "check whether a board piece is clear", note: "BOARD — clearness derived from the current board (nothing rests on it)" };
9440
+ }
9441
+ if (where || fwd) {
9442
+ const x = normFactTerm((where ?? fwd)[1]);
9443
+ if (!individuals.has(x)) return null;
9444
+ const rows = state.filter((r) => r.subject === x);
9445
+ if (!rows.length) return where
9446
+ ? { text: `nothing on the current board says where ${x} is.`, deduced: "read the current board (where a piece is)", note: "BOARD — forward locative, no row for the piece" }
9447
+ : null; // a verb-specific forward miss falls to the ordinary reader
9448
+ return { text: rows.map(factPhrase).join("; "), deduced: "read the current board (where a piece is)", note: "BOARD — forward locative from the current board" };
9449
+ }
9450
+ // reverse: "what rests on X" / "what is on X"
9451
+ const verb = rev[1].toLowerCase();
9452
+ const prep = rev[2].toLowerCase();
9453
+ const x = normFactTerm(rev[3].replace(/^(?:an?|the)\s+/i, "").trim());
9454
+ if (!individuals.has(x)) return null;
9455
+ const copula = /^(?:is|are|'s)$/.test(verb);
9456
+ let predicate = null;
9457
+ if (!copula) {
9458
+ predicate = await generalVerbPredicate(verb);
9459
+ if (/^mgx:[a-z]+$/.test(predicate)) predicate = `${predicate}-${prep}`;
9460
+ }
9461
+ const hits = state.filter((r) => r.object === x && (copula || r.predicate === predicate));
9462
+ const emptyPhrase = predicate ? predicatePhrase(predicate) : "is on";
9463
+ return hits.length
9464
+ ? { text: hits.map(factPhrase).join("; "), deduced: "read the current board (what rests on a piece)", note: "BOARD — reverse locative from the current board" }
9465
+ : { text: `nothing ${emptyPhrase} ${x} on the current board.`, deduced: "read the current board (what rests on a piece)", note: "BOARD — reverse locative, nothing on the current board" };
9466
+ }
9467
+
9076
9468
  async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null, vocabAntecedent = null, planHolder = null }) {
9077
9469
  const ts = new Date().toISOString();
9078
9470
  // DISCOURSE ANAPHORA: a follow-up like "which of those are tested" / "count
@@ -9151,12 +9543,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9151
9543
  } catch (e) {
9152
9544
  const thrown = String(e?.message || e);
9153
9545
  // A graph-less session's ask dispatch fails reading the never-configured
9154
- // graph artifact an internal error string, not an answer. Swap in an
9155
- // honest wall; the teach/fact lanes below still get their turn and
9156
- // replace it whenever they can store or answer instead. A missing config
9157
- // gets the same wall: with no config at all, no dispatch could ever have
9158
- // loaded a graph, whatever the internal error spelled.
9159
- answer = !graph && (!config || /^cannot read graph artifact\b/.test(thrown))
9546
+ // graph artifact, or loads one holding nothing (e.emptyGraph the first
9547
+ // turn of a fresh session, before the conversation has folded anything in).
9548
+ // Either way it's an internal error string, not an answer. Swap in an
9549
+ // honest wall; the teach/fact/vocabulary lanes below still get their turn
9550
+ // and replace it whenever they can store or answer instead. A missing
9551
+ // config gets the same wall: with no config at all, no dispatch could ever
9552
+ // have loaded a graph, whatever the internal error spelled.
9553
+ //
9554
+ // The session hands this lane a KNOWN-EMPTY graph object rather than null
9555
+ // on that first turn, so the test is noCodeGraph, not `!graph`: an empty
9556
+ // graph is as unusable as an absent one, and reporting its emptiness to
9557
+ // someone asking about a dog answers a question they never asked.
9558
+ answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
9160
9559
  ? "I can't answer that as a code question — no code graph is loaded in this session. "
9161
9560
  + "I can still remember and answer taught facts (try \"every disk is a game piece\"), "
9162
9561
  + "or run `tmct init` in a repo to index one."
@@ -9295,7 +9694,9 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9295
9694
  if (memoryDir) {
9296
9695
  const whatElse = await whatElseAnswer(memoryDir, query, last);
9297
9696
  if (whatElse) {
9298
- answer = whatElse.text; via = "fact:what-else"; recordMiss = false; handled = true;
9697
+ // A cold "what else" carries miss:true it resolved no subject, so the
9698
+ // turn is a miss in better words, exactly like the isa ladder's closers.
9699
+ answer = whatElse.text; via = "fact:what-else"; recordMiss = whatElse.miss ?? false; handled = true;
9299
9700
  if (whatElse.pending) factPending = whatElse.pending;
9300
9701
  deduced = "surface additional remembered facts beyond the primary definition";
9301
9702
  note(trace, "lane: (0) WHAT ELSE — \"what else is/about X\" recognized off the raw query, before the relaxation cascade could quietly drop \"else\" and reduce it to a plain \"what is X\"");
@@ -10118,8 +10519,8 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
10118
10519
  return mk(`narrate mode ${next ? "on" : "off"}.`, { narrateNext: next });
10119
10520
  }
10120
10521
 
10121
- // /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
10122
- // inspection" surface; the same renderer serves the `tmct memory` CLI).
10522
+ // /memory [verbose] — what tmct remembers, as text (the same renderer
10523
+ // serves the `tmct memory` CLI).
10123
10524
  if (name === "memory") {
10124
10525
  note(trace, "goal: inspect tmct's memory store (facts/utterances/sessions)");
10125
10526
  if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
@@ -10350,6 +10751,57 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
10350
10751
  * committing to the first. Returns null for the overwhelming majority of
10351
10752
  * sentences, so every single-reading sentence renders byte-identically to
10352
10753
  * the unchanged parseAce path below. */
10754
+ /** Does this sentence match the general-verb teach frame on its own terms —
10755
+ * reusing generalVerbTeach's OWN closed guards, so the split gate and the lane
10756
+ * it feeds can never disagree about which sentences that lane accepts. */
10757
+ function matchesGeneralVerbTeachFrame(sentence) {
10758
+ if (GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(sentence)) return false;
10759
+ const m = sentence.match(GENERAL_VERB_TEACH_RE);
10760
+ if (!m) return false;
10761
+ const [, subject, verb] = m;
10762
+ return !GENERAL_VERB_EXCLUDE_RE.test(verb)
10763
+ && !GENERAL_VERB_NOT_A_VERB_RE.test(verb)
10764
+ && !GENERAL_VERB_DETERMINER_RE.test(subject)
10765
+ && !GENERAL_VERB_IMPERATIVE_SUBJECT_RE.test(subject);
10766
+ }
10767
+
10768
+ /** Does this sentence stand alone as a teach — a clean ACE triple, a taxonomy
10769
+ * declaration, the comparative frame, or the general-verb frame? A question
10770
+ * never counts.
10771
+ *
10772
+ * Each entry names a teach lane that accepts the sentence ALONE, so the set
10773
+ * here has to track that lane list or a line of real teach sentences goes to
10774
+ * the parser glued together. The comparative was missing, and the cost was on
10775
+ * a shipped surface: data/games/hanoi-3.txt's own board line
10776
+ * ("disk-1 is smaller than disk-2. disk-1 is smaller than disk-3.") stored
10777
+ * NOTHING, so disk-1 could never move and the recipe's promised solution did
10778
+ * not exist. The sibling lines on either side of it split correctly, which is
10779
+ * what made it invisible. */
10780
+ function sentenceTeachesAlone(sentence, parseAce, lex) {
10781
+ const s = String(sentence).trim();
10782
+ if (!s || s.includes("?")) return false;
10783
+ const parse = parseAce(s, lex);
10784
+ if (parse && parse.triples?.length && !parse.residue?.length) return true;
10785
+ return DECLARATIVE_KIND_OF_RE.test(s) || COMPARATIVE_TEACH_RE.test(s) || matchesGeneralVerbTeachFrame(s);
10786
+ }
10787
+
10788
+ /** Does every sentence of a multi-sentence line teach on its own? Then the line
10789
+ * is a teach line, and each sentence belongs in its own turn: handed over
10790
+ * glued, the first sentence's teach frame captures all the others as its
10791
+ * object ("disk-1 rests on disk-2. disk-2 rests on disk-3." stores an object of
10792
+ * "on disk-2. disk-2 rests on disk-3"). Any parse failure answers false and
10793
+ * leaves the unsplit line to the ordinary lanes. */
10794
+ async function everySentenceTeaches(sentences, lexicon) {
10795
+ try {
10796
+ const { parseAce } = await import("../domain/grammar/ace.mjs");
10797
+ let lex = lexicon;
10798
+ if (!lex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); lex = loadLexicon(); }
10799
+ return sentences.every((sentence) => sentenceTeachesAlone(sentence, parseAce, lex));
10800
+ } catch {
10801
+ return false;
10802
+ }
10803
+ }
10804
+
10353
10805
  async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
10354
10806
  try {
10355
10807
  const { parseAce, parseAceAmbiguous } = await import("../domain/grammar/ace.mjs");
@@ -10463,6 +10915,22 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
10463
10915
  // `pending`, so the remainder is naturally cleared — no stale continuation. ----
10464
10916
  const PAGE = 32;
10465
10917
  const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
10918
+
10919
+ /** "what would break if I change X" — the impact closure asked for in the
10920
+ * words people use, rather than as /impact. Sibling of normalize.mjs's
10921
+ * COUNTERFACTUAL_RE ("if X were deleted, what would break"), which states the
10922
+ * same counterfactual in the other clause order and compiles to the reverse
10923
+ * import closure; this shape names a CHANGE rather than a deletion, so it
10924
+ * answers with the impact closure /impact itself renders. The verbs are a
10925
+ * closed set on both sides — no general "any verb in a conditional" fit. */
10926
+ const IMPACT_PARAPHRASE_RE = new RegExp(
10927
+ "^what\\s+(?:would|will|might|could|does|do)?\\s*"
10928
+ + "(?:breaks?|fails?|is\\s+affected|are\\s+affected|gets?\\s+affected|be\\s+affected|is\\s+impacted|be\\s+impacted)"
10929
+ + "\\s+if\\s+(?:i|we|you|one|someone)\\s+"
10930
+ + "(?:changed?|modif(?:y|ied)|edits?|edited|touch(?:es|ed)?|updates?|updated|alters?|altered)"
10931
+ + "\\s+(?:the\\s+)?(.+?)[?.!\\s]*$",
10932
+ "i",
10933
+ );
10466
10934
  const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
10467
10935
 
10468
10936
  /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
@@ -10574,21 +11042,25 @@ function coldPronounDeclineText(query) {
10574
11042
  return `not sure what "${m[2].toLowerCase()}" refers to yet — name the subject directly, e.g. "what is a <name>".`;
10575
11043
  }
10576
11044
 
10577
- /** The subject of the LAST turn's first fact line, for vocabulary pronoun
10578
- * binding ("what is a dog" → "can it bark"). Fact answers render rigidly —
10579
- * "<subject> <phrase> <object> (source: …)", optionally behind a "yes — "/
10580
- * "no — "/"you told me: " prefix — so a 1–2 word leading subject followed
10581
- * by a phrase-table verb is extractable without any NLP. Anything else
10582
- * (code answers, walls, conversational text) returns null and no
11045
+ /** The subject of the last GROUNDED turn's first fact line, for vocabulary
11046
+ * pronoun binding ("what is a dog" → "can it bark"). Fact answers render
11047
+ * rigidly — "<subject> <phrase> <object> (source: …)", optionally behind a
11048
+ * "yes — "/"no — "/"you told me: " prefix — so a 1–2 word leading subject
11049
+ * followed by a phrase-table verb is extractable without any NLP. Anything
11050
+ * else (code answers, walls, conversational text) returns null and no
10583
11051
  * substitution happens.
10584
11052
  *
11053
+ * It reads `grounded`, not `answer`, so an intervening miss leaves the
11054
+ * standing referent alone rather than stranding every pronoun behind it.
11055
+ *
10585
11056
  * A pronoun never binds. An honest miss opens first-person ("I can't confirm
10586
11057
  * that — …"), which fits the subject+verb shape exactly, so without the
10587
- * isTeachPronoun check the miss lends "I" to the next turn and "is it an
10588
- * animal" is looked up as "is I an animal". A pronoun is no more a fact
10589
- * subject here than in the teach frames TEACH_PRONOUNS already guards. */
11058
+ * isTeachPronoun check a miss reaching here would lend "I" to the next turn
11059
+ * and "is it an animal" would be looked up as "is I an animal". A pronoun is
11060
+ * no more a fact subject here than in the teach frames TEACH_PRONOUNS already
11061
+ * guards. */
10590
11062
  function vocabAntecedentFrom(last) {
10591
- const first = String(last?.answer || "").split("\n")[0]
11063
+ const first = String(last?.grounded || "").split("\n")[0]
10592
11064
  .replace(/^(?:yes|no) — /i, "")
10593
11065
  .replace(/^you told me: /i, "");
10594
11066
  const m = first.match(/^([a-z][\w'-]*(?:\s+[a-z][\w'-]*)?)\s+(?:is|are|has|can|causes|wants|requires|involves|means|begins|ends)\b/i);
@@ -10666,7 +11138,21 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
10666
11138
  // runAsk's own effectiveQuery (set only when discourseRewrite substituted
10667
11139
  // a new subject and produced a genuine non-miss answer) takes over as the
10668
11140
  // continuation base for the NEXT turn's own discourseRewrite.
10669
- const nextLast = { query: finished.effectiveQuery ?? line, answer: finished.answer, detail: finished.detail ?? null };
11141
+ //
11142
+ // `grounded` is the last answer that actually ANSWERED, carried forward
11143
+ // across misses. It is what a pronoun's referent binds to
11144
+ // (vocabAntecedentFrom): a reader's misses come in clusters, and reading
11145
+ // the referent off a wall stranded every pronoun after one stray line.
11146
+ //
11147
+ // `answer` must keep recording the miss regardless — the repeat-shortening
11148
+ // walls compare consecutive answers through it, so a miss that declined to
11149
+ // record itself would make every wall look like a first offence.
11150
+ const nextLast = {
11151
+ query: finished.effectiveQuery ?? line,
11152
+ answer: finished.answer,
11153
+ detail: finished.detail ?? null,
11154
+ grounded: finished.record?.miss ? (last?.grounded ?? null) : finished.answer,
11155
+ };
10670
11156
  // Goal/canonical lines append onto the PRE-narration `finished` result
10671
11157
  // `nextLast` was captured from, so a narrated turn still gets both short
10672
11158
  // lines up top plus the full trace block after.
@@ -10702,6 +11188,23 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
10702
11188
  return rec;
10703
11189
  }
10704
11190
 
11191
+ // PLAN FOLLOW-UP + BOARD — "what is the next move", "how many moves", "why
11192
+ // that move" read the active plan; "is X clear", "what rests on X",
11193
+ // "where is X" read the CURRENT board. Placed before answerCount (which owns
11194
+ // "how many …") and the ask engine, so a plan/board answer never loses to a
11195
+ // code-graph miss. Returns null with no plan/board standing, so nothing
11196
+ // changes for a cold session — an honest miss still stands.
11197
+ if (memoryDir) {
11198
+ const follow = await planFollowUpAnswer(workingLine, { memoryDir, planHolder });
11199
+ if (follow) {
11200
+ note(trace, `goal: ${follow.deduced}`);
11201
+ note(trace, `lane: ${follow.note}`);
11202
+ const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus }), follow.deduced);
11203
+ rec.planState = planHolder.state;
11204
+ return rec;
11205
+ }
11206
+ }
11207
+
10705
11208
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
10706
11209
  // an actual pending remainder so a bare "more" with nothing to continue falls through
10707
11210
  // to the ordinary path (an honest miss), never a pretend page.
@@ -10711,16 +11214,37 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
10711
11214
  return withLast(morePage(workingLine, ctx), "continue viewing a previous long listing");
10712
11215
  }
10713
11216
 
10714
- // Multi-sentence PLAN pre-split one message carrying state sentences plus
10715
- // a goal/trigger ("disk-1 rests on disk-2. the goal is that …. solve it.")
10716
- // runs each sentence as its own nested turn, threading focus/last/planState
10717
- // through, and answers with the final turn's result behind brief receipts.
10718
- if (!_noSplit && memoryDir) {
11217
+ // "what would break if I change X" / "what breaks if I touch X" — the impact
11218
+ // closure, in the words people actually ask for it in. With no frame of its
11219
+ // own the line reached the grammar with "break"/"breaks if i" worn away as
11220
+ // filler, and the residue ("break I") read as a subject for the history
11221
+ // lane's `touches` answering who last touched a file to a question about
11222
+ // what a change to it would reach. /impact's own closure is the answer, and
11223
+ // its wording ("Impact of changing X") already says the change is
11224
+ // hypothetical.
11225
+ const impactParaphrase = workingLine.match(IMPACT_PARAPHRASE_RE);
11226
+ if (impactParaphrase) {
11227
+ const impactDeduced = "understand what a change to this module would reach (impact closure)";
11228
+ note(trace, `goal: ${impactDeduced}`);
11229
+ note(trace, `lane: IMPACT_PARAPHRASE_RE matched -> /impact ${impactParaphrase[1].trim()}`);
11230
+ return withLast(await runCommand(`/impact ${impactParaphrase[1].trim()}`, ctx), impactDeduced);
11231
+ }
11232
+
11233
+ // Multi-sentence pre-split — one message carrying several sentences
11234
+ // ("disk-1 rests on disk-2. … the goal is that …. solve it.") runs each
11235
+ // sentence as its own nested turn, threading focus/last/planState through,
11236
+ // and answers with the final turn's result behind brief receipts. Fires
11237
+ // when the line ends in a plan trigger, or when every sentence is a
11238
+ // self-contained teach: either way each sentence has a lane of its own, so
11239
+ // none of them reaches the parser glued to its neighbours.
11240
+ if (!_noSplit && memoryDir && carriesASentenceBoundary(workingLine)) {
10719
11241
  const sentences = splitSentences(workingLine);
10720
11242
  if (sentences.length > 1) {
10721
11243
  const lastSentence = sentences[sentences.length - 1];
10722
- if (PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence) || GOAL_TEACH_INFINITIVE_RE.test(lastSentence)
10723
- || GOAL_TEACH_VERBLESS_RE.test(lastSentence) || LEGAL_MOVES_RE.test(lastSentence)) {
11244
+ const endsInPlanTrigger = PLAN_SOLVE_RE.test(lastSentence) || GOAL_TEACH_RE.test(lastSentence)
11245
+ || GOAL_TEACH_INFINITIVE_RE.test(lastSentence) || GOAL_TEACH_VERBLESS_RE.test(lastSentence)
11246
+ || LEGAL_MOVES_RE.test(lastSentence);
11247
+ if (endsInPlanTrigger || await everySentenceTeaches(sentences, lexicon)) {
10724
11248
  let f = focus; let l = last; let ps = planHolder.state;
10725
11249
  const receipts = [];
10726
11250
  let finalRec = null;
@@ -10735,8 +11259,22 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
10735
11259
  finalRec = r;
10736
11260
  receipts.push(String(r.answer ?? "").split("\n")[0]);
10737
11261
  }
10738
- const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
10739
- const combined = { ...finalRec, answer: receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer };
11262
+ // When a plan trigger closes the line, the final sentence is the payload
11263
+ // (the plan) and the earlier teaches are brief bulleted receipts. When
11264
+ // every sentence teaches, the final one is a teach too, so it earns a
11265
+ // bullet like its siblings — otherwise it renders unbulleted and trails
11266
+ // a stray "Goal (inferred)" line the bulleted ones already dropped. Its
11267
+ // goal-line tail (everything after the receipt's first line) is kept once.
11268
+ let answer;
11269
+ if (endsInPlanTrigger) {
11270
+ const receiptLines = receipts.slice(0, -1).map((t) => `• ${t}`).join("\n");
11271
+ answer = receiptLines ? `${receiptLines}\n\n${finalRec.answer}` : finalRec.answer;
11272
+ } else {
11273
+ const bullets = receipts.map((t) => `• ${t}`).join("\n");
11274
+ const goalTail = String(finalRec.answer ?? "").split("\n").slice(1).join("\n").trim();
11275
+ answer = goalTail ? `${bullets}\n\n${goalTail}` : bullets;
11276
+ }
11277
+ const combined = { ...finalRec, answer };
10740
11278
  combined.planState = ps;
10741
11279
  combined.focus = f;
10742
11280
  combined.last = l;