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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,6 +43,20 @@
43
43
  "bytes": 3920,
44
44
  "sha256": "d089426833c393f6f1574fe75c4ac94a1483ee80f72f79bd7fd32aaaa24a6e44",
45
45
  "license": "MPL-2.0"
46
+ },
47
+ {
48
+ "id": "general",
49
+ "kind": "domain",
50
+ "description": "General-purpose everyday-knowledge concepts (animals, weather, the natural world, common objects) — a non-code-domain seed set, deliberately outside tmct's own code-domain bias.",
51
+ "source": {
52
+ "kind": "curated",
53
+ "tool": "corpus/tier2/generate.mjs"
54
+ },
55
+ "file": "general.jsonl",
56
+ "facts": 49,
57
+ "bytes": 5826,
58
+ "sha256": "01284f1350fa2ca9653b1c5f52a39fdcb657727c65e77693eae954cb95a14a2e",
59
+ "license": "MPL-2.0"
46
60
  }
47
61
  ]
48
62
  }
@@ -0,0 +1,98 @@
1
+ # data/templates/constructions/agent-noun-relations.toml — construction-grammar
2
+ # template bank (PLAN_ADVANCED_GRAMMAR.md track (d)): per-construction closed
3
+ # template families as DATA, loaded by src/interpret/strategies/constructions.mjs
4
+ # beside grammar.mjs's anchored T1-T10 grammar (this file's constructions are the
5
+ # next numbers, T11-T13 — see the `id` field on each [[construction]] below; do
6
+ # not renumber grammar.mjs's own T1-T10, this is an ADDITIVE, own-class strategy).
7
+ #
8
+ # The linguistic point (Construction Grammar, Goldberg 1995/2006): the SAME
9
+ # underlying relation ("things that import X") is realized by a developer through
10
+ # several distinct surface CONSTRUCTIONS — a prepositional NP ("importers of X"),
11
+ # a genitive-'s NP ("X's importers"), and a bare compound-juxtaposition NP ("X
12
+ # importers") — each its own grammatical FORM pairing with the same MEANING. This
13
+ # is a genuine gap, not a duplicate of grammar.mjs's T1-T10 or normalize.mjs's
14
+ # PHRASING_FRAMES: the "of"-NP form already reaches the graph correctly via the
15
+ # keyword-spot strategy's decomposition (interpret/strategies/keywords.mjs), but
16
+ # the genitive/compound forms do NOT — keyword-spot mis-parses "store.mjs's
17
+ # importers" / "store.mjs importers" as shape "forward" (reading store.mjs as the
18
+ # grammatical SUBJECT doing the importing) instead of "reverse" (store.mjs is the
19
+ # OBJECT being imported; the agent noun names WHO does the verb TO it) — confirmed
20
+ # live before this file existed. These three constructions fix that, and route
21
+ # the "of" form through the same anchored, closed table for consistency.
22
+ #
23
+ # Two [[relation]] tables define the closed AGENT-NOUN vocabulary (a plural
24
+ # nominalization of a relation verb -> {kind, entityType?}), validated at load
25
+ # time by the strategy against RELATIONS' own kind vocabulary (ask-vocab.mjs) and
26
+ # ENTITY_TO_TYPE's canonical class names — an unrecognized kind/entityType is
27
+ # REJECTED (the entry is dropped, never guessed into the nearest match), same
28
+ # "closed is deliberate" discipline as ask-vocab.mjs's RELATIONS table itself.
29
+ #
30
+ # Deliberately narrow, hand-curated set (not exhaustive): each noun is one a
31
+ # developer plausibly types for THIS codebase's relations, same judgment-call
32
+ # discipline ask-vocab.mjs's file header already states. "testers"/"containers"/
33
+ # "exporters"/"definers" were considered and left out — too easily confused with
34
+ # unrelated code-domain nouns (a Docker "container", a DI "container", a test
35
+ # "runner") or simply not natural developer phrasing; a false-positive match on
36
+ # an unrelated identifier is worse than an honest miss, so the set stays small.
37
+
38
+ [[relation]]
39
+ noun = "importers"
40
+ kind = "imports"
41
+ entityType = "Module" # the imports edge is module-grain only
42
+
43
+ [[relation]]
44
+ noun = "callers"
45
+ kind = "calls"
46
+ # no entityType: calls resolves at function/method OR module grain (ask.mjs's
47
+ # own fine/coarse call-edge handling) — leaving this unset means entityType null,
48
+ # "any grain", exactly like grammar.mjs's own bare "what calls X" (T3 forward).
49
+
50
+ [[relation]]
51
+ noun = "users"
52
+ kind = "uses"
53
+ # uses is itself a query-side union (imports + calls + callsSymbol, ask-vocab.mjs)
54
+ # — no single entityType is correct, so this stays grain-agnostic too.
55
+
56
+ [[relation]]
57
+ noun = "subclasses"
58
+ kind = "inherits"
59
+ entityType = "Class" # inherits is Class -> Class only
60
+
61
+ # ---- constructions: pattern -> AST skeleton -------------------------------
62
+ # Pattern DSL (src/interpret/strategies/constructions.mjs compiles this):
63
+ # <AGENT> one of the [[relation]] nouns above (closed alternation, longest
64
+ # noun first so e.g. a hypothetical multi-word noun would never be
65
+ # shadowed by a shorter one sharing a prefix)
66
+ # <TERM> a free-text object term, non-greedy — the SAME "capture the rest,
67
+ # let resolveObject do real matching" contract every anchored
68
+ # template in grammar.mjs already uses
69
+ # any other text is a literal (case-insensitive, whitespace-normalized)
70
+ # Every construction here compiles to shape="reverse": the AGENT noun names the
71
+ # relation's SUBJECT side, <TERM> is the OBJECT being related-to — precisely
72
+ # grammar.mjs's own T2 "reverse" shape (entityType/modifier/kind/object), just
73
+ # reached from a different surface form. A future construction needing a
74
+ # different `shape` is free to declare one; the strategy loader supports the
75
+ # full anchored-template shape vocabulary (ask/reverse/forward/where/when/
76
+ # meta/mentions), not "reverse" exclusively — this bank simply doesn't need the
77
+ # others yet.
78
+
79
+ [[construction]]
80
+ id = "T11"
81
+ name = "agent-noun-of"
82
+ comment = "\"importers of X\" -> reverse imports X. Also reached (correctly) by keyword-spot today; kept here for a single closed, anchored source of truth and byte-identical precedence with the other two surface forms below."
83
+ pattern = "<AGENT> of <TERM>"
84
+ shape = "reverse"
85
+
86
+ [[construction]]
87
+ id = "T12"
88
+ name = "agent-noun-genitive"
89
+ comment = "\"X's importers\" -> reverse imports X. The genitive-'s NP; keyword-spot currently mis-reads this as forward (X importing things) — this construction is the fix."
90
+ pattern = "<TERM>'s <AGENT>"
91
+ shape = "reverse"
92
+
93
+ [[construction]]
94
+ id = "T13"
95
+ name = "agent-noun-compound"
96
+ comment = "\"X importers\" (bare compound-noun juxtaposition, no possessive marker or preposition) -> reverse imports X. The same mis-parse as T12's genitive form, without the apostrophe; a developer drops the 's as often as they keep it."
97
+ pattern = "<TERM> <AGENT>"
98
+ shape = "reverse"
@@ -66,6 +66,7 @@
66
66
  {"id":"orientation-empty","class":"orientation","register":"friendly","template":"I'm tmct — a deterministic, offline chat assistant (no LLM). {vocabHint} /memory for what I remember.\nFor code structure (imports, calls, definitions) point me at a repo: `--repo <path>`, or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself.\n/help for commands."}
67
67
  {"id":"identity-self","class":"conversational","register":"friendly","template":"I'm tmct — a deterministic, offline chat assistant. No LLM: wink-nlp parsing over a seeded ontology/lexicon, plus a code graph when you point me at a repo with `--repo <path>`. /help for commands, /stats for an overview."}
68
68
  {"id":"identity-not-an-llm","class":"conversational","register":"friendly","template":"No — no LLM involved. tmct is deterministic: wink-nlp parsing over a graph/ontology, not a language model. /help for commands."}
69
+ {"id":"identity-no-feelings","class":"conversational","register":"friendly","template":"No — I don't have feelings, opinions, or consciousness. tmct is deterministic: wink-nlp parsing over a graph/ontology, not a mind. /help for commands."}
69
70
  {"id":"technical-density","class":"count","register":"technical","template":"{subject} carries {count} {noun} across {scope} — a concentration well above what a codebase of this size typically sustains ({provenance})."}
70
71
  {"id":"technical-comparison","class":"count","register":"technical","template":"At {count} {noun}, {subject} sits {comparison} the comparable-project baseline, a divergence that reflects deliberate structure rather than measurement noise ({provenance})."}
71
72
  {"id":"technical-superlative","class":"count","register":"technical","template":"No {noun} in {scope} is more {metric} than {subject}; it leads the next candidate by a clear margin of {count} ({provenance})."}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.4.0",
3
+ "version": "1.5.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -36,6 +36,9 @@
36
36
  "engines": {
37
37
  "node": ">=24"
38
38
  },
39
+ "workspaces": [
40
+ "packages/*"
41
+ ],
39
42
  "bin": {
40
43
  "tmct": "./bin/tmct.mjs"
41
44
  },
@@ -68,6 +71,7 @@
68
71
  "access": "public"
69
72
  },
70
73
  "dependencies": {
74
+ "@polycode-projects/ace-owl": "^0.1.0",
71
75
  "ink": "^7.1.0",
72
76
  "react": "^19.2.7",
73
77
  "smol-toml": "^1.7.0",
package/src/ask-vocab.mjs CHANGED
@@ -307,6 +307,40 @@ export function stripTrailingScopeFiller(text) {
307
307
  return text.replace(TRAILING_SCOPE_FILLER_RE, "").trim();
308
308
  }
309
309
 
310
+ /** Trailing bare discourse tags — the same curated "then"/"though" pair
311
+ * ask.mjs's PRED_LEAD_SKIP already recognizes as a trailing discourse tag on
312
+ * an otherwise-bare follow-up ("how many of those then"). A "what is X"
313
+ * meta-whatis question wasn't tolerant of this yet: "what is a component
314
+ * then" captured the literal unknown term "component then" instead of
315
+ * "component" (HANDOVER.md 2026-07-10, item 8). Stripped the same
316
+ * closed-list way as TRAILING_SCOPE_FILLER, just above. "too" (playtest
317
+ * sprint round 2): "is UserController a validator too then" — a STACKED
318
+ * pair of trailing tags — needed both "too" added to the set AND the strip
319
+ * applied twice (below), since a single pass only ever removes the
320
+ * outermost tag. */
321
+ export const TRAILING_DISCOURSE_TAG = Object.freeze(["then", "though", "too"]);
322
+
323
+ const TRAILING_DISCOURSE_TAG_RE = new RegExp(
324
+ `\\s+(?:${TRAILING_DISCOURSE_TAG.join("|")})\\s*[?.!]*$`, "i",
325
+ );
326
+
327
+ /** Strip trailing bare discourse tags (TRAILING_DISCOURSE_TAG, above) off the
328
+ * end of a captured meta-whatis term — "what is a component then" resolves
329
+ * the same term as "what is a component". Applied up to twice (a stacked
330
+ * "too then"/"then too" is the only worked case that ever needs a second
331
+ * pass; no phrasing seen so far stacks a third), mirroring
332
+ * stripTrailingScopeFiller's own single-clause discipline for the common
333
+ * single-tag case while still covering the rarer double-tag one. */
334
+ export function stripTrailingDiscourseTag(text) {
335
+ let out = text;
336
+ for (let pass = 0; pass < 2; pass += 1) {
337
+ const next = out.replace(TRAILING_DISCOURSE_TAG_RE, "").trim();
338
+ if (next === out) break;
339
+ out = next;
340
+ }
341
+ return out;
342
+ }
343
+
310
344
  /** relation token -> flat verb-phrase list, the shape ask.mjs's VERB_TO_KIND
311
345
  * table needs (phrase -> kind), derived once from RELATIONS. */
312
346
  export const VERB_TO_KIND = Object.freeze(
@@ -319,7 +353,11 @@ export const ENTITY_TO_TYPE = Object.freeze({
319
353
  function: "Function", functions: "Function",
320
354
  method: "Method", methods: "Method",
321
355
  class: "Class", classes: "Class",
322
- module: "Module", modules: "Module", file: "Module", files: "Module",
356
+ // "mod"/"mods" (HANDOVER.md 2026-07-10 item 10): a rushed-dev abbreviation
357
+ // prefix ("mod store.mjs imports") used to land in disambiguation instead of
358
+ // resolving cleanly, since nothing recognized "mod" as this same Module noun
359
+ // — same alias-of-Module trade "file"/"files" already make just above.
360
+ module: "Module", modules: "Module", mod: "Module", mods: "Module", file: "Module", files: "Module",
323
361
  attribute: "Attribute", attributes: "Attribute", field: "Attribute", fields: "Attribute",
324
362
  variable: "GlobalVariable", variables: "GlobalVariable", global: "GlobalVariable", globals: "GlobalVariable",
325
363
  // "changes" in a touch question ("which changes touch commit <sha>") means the
package/src/ask.mjs CHANGED
@@ -324,6 +324,7 @@ function parseComposite(text, nlp) {
324
324
  || parseNegation(text, nlp, 0)
325
325
  || parseForwardNegation(w, lc, nlp)
326
326
  || parseTemporal(w, lc, nlp, 0)
327
+ || parseCommitFilter(w, lc)
327
328
  || parseAnaphora(w, lc, nlp)
328
329
  || parseAggregate(w, lc, nlp)
329
330
  || parseSuperlative(w, lc, nlp)
@@ -533,6 +534,61 @@ function parseTemporal(w, lc, nlp, depth = 0) {
533
534
  return { node: "temporal", inner, entityType: (noun && noun.entityType) || null };
534
535
  }
535
536
 
537
+ // COMMIT FILTER (Track 1 temporal lever, PLAN_CHAT_FEEL item 6 remainder) — "what
538
+ // changed since/before/after/on <date-or-commit>": a date-qualified SURVEY of every
539
+ // recorded commit (distinct from the flat when-shape above, which dates ONE named
540
+ // entity's touch history). The pivot is either a literal ISO-8601 date (yyyy-mm-dd,
541
+ // mgx:commitDate's own format, so a lexical compare is a chronological one) or a
542
+ // named commit — resolved at EVAL time (graph-dependent), whose own recorded date
543
+ // becomes the pivot and who is excluded from its own before/after comparison (never
544
+ // "before/after itself"). "in"/"during" are deliberately NOT among the qualifiers:
545
+ // "what changed in <commit>" already means something else (that commit's own touch-
546
+ // set — the commit-as-subject flip elsewhere in this file), and this recognizer must
547
+ // never shadow it.
548
+ const COMMIT_FILTER_OPS = new Set(["since", "before", "after", "on"]);
549
+ function parseCommitFilter(w, lc) {
550
+ if (lc[0] !== "what" || lc[1] !== "changed") return null;
551
+ let i = 2;
552
+ if (lc[i] === "ever") i += 1;
553
+ if (!COMMIT_FILTER_OPS.has(lc[i])) return null;
554
+ const op = lc[i];
555
+ const pivotRaw = w.slice(i + 1).join(" ").trim();
556
+ if (!pivotRaw) return { node: "miss", reason: `"what changed ${op}" needs a date or commit afterward` };
557
+ return { node: "commitFilter", op, pivotRaw };
558
+ }
559
+
560
+ // Minimal code-identifier token shape — dotted paths ("app/lib/e.mjs"), Capitalized
561
+ // symbols ("Store"), or lowerCamelCase symbols ("fnAlpha"). Intentionally DUPLICATED
562
+ // from chat.mjs's own NAME_TOKEN_RE (chat.mjs ~line 4998, used there for exactly this
563
+ // kind of code-identifier detection in discourseRewrite) rather than imported:
564
+ // chat.mjs only ever imports ask.mjs LAZILY (dynamic `await import("./ask.mjs")`,
565
+ // per chat.mjs's own top-of-file comment), so a static ask.mjs -> chat.mjs import
566
+ // would invert that layering for the sake of one regex. Keep the two in sync by
567
+ // hand if either changes.
568
+ const ANAPHORA_NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
569
+
570
+ /** Distinct code-identifier-shaped tokens in `words` (original case preserved —
571
+ * the regex cares about case), in first-occurrence order, de-duplicated
572
+ * case-insensitively. Feeds parseAnaphora's in-sentence candidate-set fix
573
+ * (HANDOVER.md item 1, C2 pronoun-binding): "which of them <filter>" doesn't
574
+ * ALWAYS mean "the previous turn's answer set" — when the SAME sentence already
575
+ * named 2+ real candidates before the trigger ("app/lib/e.mjs ... app/lib/f.mjs
576
+ * ... which of them ...", a single turn, no prior turn at all), THAT'S the
577
+ * referent. Ordinary English words (even repeated nouns) never match this
578
+ * regex, so this never widens beyond genuine code identifiers. */
579
+ function inSentenceNameTokens(words) {
580
+ const seen = new Set();
581
+ const out = [];
582
+ for (const raw of words) {
583
+ if (!raw || !ANAPHORA_NAME_TOKEN_RE.test(raw)) continue;
584
+ const key = raw.toLowerCase();
585
+ if (seen.has(key)) continue;
586
+ seen.add(key);
587
+ out.push(raw);
588
+ }
589
+ return out;
590
+ }
591
+
536
592
  /** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
537
593
  * of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
538
594
  * fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
@@ -569,7 +625,19 @@ function parseAnaphora(w, lc, nlp) {
569
625
  const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
570
626
  const filter = parsePredicateFilter(w.slice(p + 1), nlp);
571
627
  if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
572
- return { node: "anaphora", mode, filter };
628
+ // in-sentence candidate set (HANDOVER.md item 1): if the SAME utterance already
629
+ // named 2+ real code identifiers BEFORE the "of them"/"of those" trigger — e.g.
630
+ // "app/lib/e.mjs ... because it imports app/lib/f.mjs — which of them imports
631
+ // app/lib/f.mjs" — those are the referent, not a previous turn's cached result
632
+ // set. evalAnaphora tries this FIRST and only falls back to opts.prev when it's
633
+ // absent or resolves to fewer than 2 real graph entities, so an ordinary
634
+ // multi-turn follow-up (no named entities in the current utterance at all) is
635
+ // completely unaffected.
636
+ const cutIdx = viaOf ? p - 1 : p;
637
+ const candidateTerms = inSentenceNameTokens(w.slice(0, cutIdx));
638
+ const ast = { node: "anaphora", mode, filter };
639
+ if (candidateTerms.length >= 2) ast.candidateTerms = candidateTerms;
640
+ return ast;
573
641
  }
574
642
 
575
643
  /** Parse a trailing filter (for anaphora, and any "of those that …" tail) into
@@ -1053,6 +1121,22 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1053
1121
  if (RECENT_COMMIT_LEAD.has(lc[i]) && nextNoun && nextNoun.entityType === "Commit" && i + 2 === lc.length) {
1054
1122
  return { node: "recentCommits" };
1055
1123
  }
1124
+ // Track 1 temporal lever (remainder) — the SAME bare lead, past an optional
1125
+ // copula + determiner ("what IS THE newest commit", "what WAS THE latest
1126
+ // commit"): FRAME_WORDS only strips "what"/"which"/…, so "is the"/"was the"
1127
+ // left `i` sitting on the copula, never reaching the check above at all. A
1128
+ // narrow lookahead (never mutating `i`, so every OTHER branch here is
1129
+ // byte-identical) that re-tries the exact same closed RECENT_COMMIT_LEAD
1130
+ // check past those two filler words only — an honest decline (falls through)
1131
+ // the instant either word doesn't match, never a guess.
1132
+ if (COPULA_WORDS.has(lc[i])) {
1133
+ let j = i + 1;
1134
+ if (j < lc.length && (lc[j] === "the" || lc[j] === "a" || lc[j] === "an")) j += 1;
1135
+ const leadNoun = j + 1 < lc.length ? entityNoun(lc[j + 1]) : null;
1136
+ if (RECENT_COMMIT_LEAD.has(lc[j]) && leadNoun && leadNoun.entityType === "Commit" && j + 2 === lc.length) {
1137
+ return { node: "recentCommits" };
1138
+ }
1139
+ }
1056
1140
  // CASCADE_NOISE_SET excluded alongside STOPWORDS (Tier-2 playtest, cycle 8):
1057
1141
  // "what about classes"/"how about the modules" used to reach here with
1058
1142
  // "about" sitting right where a real qualifying adjective would ("payment"
@@ -1343,6 +1427,42 @@ function moduleIdOf(graph, ind) {
1343
1427
  return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
1344
1428
  }
1345
1429
 
1430
+ /** META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted HANDOVER.md
1431
+ * 2026-07-10 item 6): "what is a Record" used to say "'Record' isn't a term in
1432
+ * this graph's own vocabulary" even when Record is a real code-graph entity —
1433
+ * after a SchemaClass/SchemaPredicate miss, an exact case-insensitive UNIQUE
1434
+ * label match against a small set of real code-entity classes (Class/Function/
1435
+ * Method/GlobalVariable/Attribute — not just Class; CHATBENCH g-a2-naming-6:
1436
+ * "what does fnAlpha mean", a Function, hit this same false vocabulary-miss
1437
+ * wall). Uniqueness is GLOBAL across all these classes together, not per-class:
1438
+ * a name colliding across two different classes stays an honest miss, never a
1439
+ * guess at which one was meant. Extracted (not just inlined in traverse()'s own
1440
+ * meta branch below) so chat.mjs's BARE "what is X" last-resort lane — no
1441
+ * article, so T5's structural parse never even produces a meta shape to reach
1442
+ * traverse() at all (CHATBENCH g-a2-naming-2: "what is Widget") — can reuse the
1443
+ * exact same lookup + wording, rather than risk the two silently drifting
1444
+ * apart. Returns null on anything less than a unique exact hit. */
1445
+ const META_FALLBACK_CLASSES = new Set(["Class", "Function", "Method", "GlobalVariable", "Attribute"]);
1446
+ export function metaFallbackEntityAnswer(graph, term) {
1447
+ const termLc = String(term || "").trim().toLowerCase();
1448
+ if (!termLc) return null;
1449
+ const hits = (graph?.individuals || []).filter((i) => META_FALLBACK_CLASSES.has(i.class) && String(i.label).toLowerCase() === termLc);
1450
+ if (hits.length !== 1) return null;
1451
+ const hit = hits[0];
1452
+ const mid = moduleIdOf(graph, hit);
1453
+ const modLabel = (mid && graph.byId.get(mid)?.label)
1454
+ || String((hit.attributes || []).find((a) => a.key === "site")?.value || "").split(":")[0]
1455
+ || null;
1456
+ const noun = nounFor(hit.class, 1);
1457
+ const article = noun === "attribute" ? "an" : "a";
1458
+ const definedIn = modLabel ? `, defined in ${modLabel}` : "";
1459
+ const followUp = hit.class === "Class" ? ` or "which classes inherit from ${hit.label}"` : "";
1460
+ return {
1461
+ text: `${hit.label} is ${article} ${noun} in this codebase${definedIn} — try "describe ${hit.label}"${followUp}.`,
1462
+ hit, modLabel,
1463
+ };
1464
+ }
1465
+
1346
1466
  // ---- MEMBERSHIP inheritance cascade (HANDOVER item 6) — "<kind> of <owner>" walks
1347
1467
  // UP `inherits` when the owner's own surface has nothing, exactly the way
1348
1468
  // computeFind's narrow-then-broaden pass does for predicate-find, below. ----
@@ -1704,12 +1824,36 @@ function evalBoolean(graph, ast, opts) {
1704
1824
  return acc;
1705
1825
  }
1706
1826
 
1707
- /** Anaphora over ask()'s `prev` id array filter/count the previous answer's ids.
1708
- * No prev supplied honest miss (never a guess), like an unresolved pronoun. */
1827
+ /** Resolve parseAnaphora's in-sentence candidateTerms (HANDOVER.md item 1) to real
1828
+ * graph entities, de-duplicated by resolved id. Returns null (not just []) when
1829
+ * fewer than 2 resolve, so the caller can tell "no in-sentence candidates" apart
1830
+ * from "named candidates that happen to fail resolution" and fall back to
1831
+ * opts.prev either way — never a guess, same discipline as everywhere else in
1832
+ * this function. */
1833
+ function resolveInSentenceCandidates(graph, terms) {
1834
+ if (!Array.isArray(terms) || terms.length < 2) return null;
1835
+ const seen = new Set();
1836
+ const resolved = [];
1837
+ for (const term of terms) {
1838
+ const r = resolveObject(graph, term);
1839
+ if (r && r.match && !seen.has(r.match.id)) { seen.add(r.match.id); resolved.push(r.match); }
1840
+ }
1841
+ return resolved.length >= 2 ? resolved : null;
1842
+ }
1843
+
1844
+ /** Anaphora over the candidate set: EITHER the current utterance's own in-sentence
1845
+ * named entities (parseAnaphora's candidateTerms — a single turn, no prior turn
1846
+ * needed at all, HANDOVER.md item 1), tried first, OR ask()'s `prev` id array (a
1847
+ * genuine previous-turn follow-up), filtered/counted the same way either way. No
1848
+ * candidate set at all → honest miss (never a guess), like an unresolved pronoun. */
1709
1849
  function evalAnaphora(graph, ast, opts) {
1710
- const prev = opts && opts.prev;
1711
- if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
1712
- const baseItems = prev.map((id) => graph.byId.get(id)).filter(Boolean);
1850
+ const inSentence = resolveInSentenceCandidates(graph, ast.candidateTerms);
1851
+ let baseItems = inSentence;
1852
+ if (!baseItems) {
1853
+ const prev = opts && opts.prev;
1854
+ if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
1855
+ baseItems = prev.map((id) => graph.byId.get(id)).filter(Boolean);
1856
+ }
1713
1857
  let items = baseItems;
1714
1858
  const f = ast.filter;
1715
1859
  if (f && f.type === "qual") {
@@ -1770,6 +1914,41 @@ function evalRecentCommits(graph) {
1770
1914
  return { compositeKind: "recentCommits", matches: commits };
1771
1915
  }
1772
1916
 
1917
+ const COMMIT_FILTER_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1918
+ /** COMMIT FILTER eval — resolves the pivot (a literal ISO date, or a NAMED commit
1919
+ * whose own recorded date becomes the pivot — resolved here, graph-dependent, per
1920
+ * the parser's own doc), then filters every Commit individual's date against it.
1921
+ * A pivot that IS itself a commit is excluded from its own comparison. An
1922
+ * unresolvable pivot (neither a date nor a known commit) declines honestly
1923
+ * (pivotResolved:false) rather than guessing an empty result. */
1924
+ function evalCommitFilter(graph, ast) {
1925
+ const { op, pivotRaw } = ast;
1926
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10);
1927
+ let pivotDate = null;
1928
+ let pivotId = null;
1929
+ if (COMMIT_FILTER_DATE_RE.test(pivotRaw)) {
1930
+ pivotDate = pivotRaw;
1931
+ } else {
1932
+ const { match, ambiguous } = resolveObject(graph, pivotRaw, { expectedClass: "Commit" });
1933
+ if (match && !ambiguous && match.class === "Commit" && dateOf(match)) {
1934
+ pivotId = match.id;
1935
+ pivotDate = dateOf(match);
1936
+ }
1937
+ }
1938
+ if (!pivotDate) return { compositeKind: "commitFilter", op, pivotRaw, pivotResolved: false, matches: [] };
1939
+ const matches = graph.individuals
1940
+ .filter((i) => i.class === "Commit" && i.id !== pivotId && dateOf(i))
1941
+ .filter((c) => {
1942
+ const d = dateOf(c);
1943
+ if (op === "since") return d >= pivotDate;
1944
+ if (op === "before") return d < pivotDate;
1945
+ if (op === "after") return d > pivotDate;
1946
+ return d === pivotDate; // "on"
1947
+ })
1948
+ .sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
1949
+ return { compositeKind: "commitFilter", op, pivotRaw, pivotDate, pivotResolved: true, matches };
1950
+ }
1951
+
1773
1952
  /** TEMPORAL over a nested set (lever 3) — the commits that touched ANY member of the
1774
1953
  * inner set, newest commit date first. Reuses the SAME touches→commit→date-sort the
1775
1954
  * flat when-shape runs (mgx:commitDate is ISO-8601, so a lexical sort IS a date sort;
@@ -1898,6 +2077,7 @@ export function evalComposite(graph, ast, opts = {}) {
1898
2077
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1899
2078
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
1900
2079
  if (ast.node === "recentCommits") return evalRecentCommits(graph);
2080
+ if (ast.node === "commitFilter") return evalCommitFilter(graph, ast);
1901
2081
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1902
2082
  // membership inheritance cascade (HANDOVER item 6), TOP-LEVEL: a bare "<kind> of
1903
2083
  // <owner>" node, or a qualifier wrapping one ("public methods of <owner>") — see
@@ -2069,6 +2249,33 @@ function renderComposite(parsed, result) {
2069
2249
  miss: false, ambiguous: false, matches: result.matches,
2070
2250
  };
2071
2251
  }
2252
+ // Track 1 temporal lever (remainder): "what changed since/before/after/on
2253
+ // <date-or-commit>" — same dated-list rendering convention as recentCommits just
2254
+ // above, scoped to the resolved pivot. An unresolvable pivot names itself and the
2255
+ // two supported pivot shapes (never a silent guess); a resolved pivot with no
2256
+ // qualifying commits is an honest empty (never the generic orientation blurb).
2257
+ if (result.compositeKind === "commitFilter") {
2258
+ if (!result.pivotResolved) {
2259
+ return {
2260
+ content: `"${result.pivotRaw}" isn't a recognized date (yyyy-mm-dd) or a known commit — try "what changed since 2026-06-01" or "what changed before <commit>".`,
2261
+ miss: true, ambiguous: false, matches: [],
2262
+ };
2263
+ }
2264
+ if (!result.matches.length) {
2265
+ return { content: `no commits recorded ${result.op} ${result.pivotRaw}.`, miss: true, ambiguous: false, matches: [] };
2266
+ }
2267
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
2268
+ const shown = result.matches.slice(0, HISTORY_CAP).map((c) => {
2269
+ const day = dateOf(c).slice(0, 10);
2270
+ const msg = (c.attributes || []).find((a) => a.key === "message")?.value || "";
2271
+ return `${c.label}${day ? ` (${day})` : ""}${msg ? ` — ${msg}` : ""}`;
2272
+ });
2273
+ const tail = result.matches.length > HISTORY_CAP ? ` …+${result.matches.length - HISTORY_CAP} more` : "";
2274
+ return {
2275
+ content: `${result.matches.length} commit(s) changed ${result.op} ${result.pivotRaw}: ${shown.join(", ")}${tail}.`,
2276
+ miss: false, ambiguous: false, matches: result.matches,
2277
+ };
2278
+ }
2072
2279
  if (result.compositeKind === "superlative") {
2073
2280
  if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
2074
2281
  const lead = result.extreme === "most" ? "the most" : "the fewest";
@@ -2733,24 +2940,17 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
2733
2940
  return token && String(token).toLowerCase() === termLc;
2734
2941
  });
2735
2942
  if (!match) {
2736
- // META FALLBACK TO REAL ENTITIES (0.8.2 WS1): "what is a Record" used to say
2737
- // "'Record' isn't a term in this graph's own vocabulary" even when Record is a
2738
- // code-graph Class individual. After the SchemaClass/SchemaPredicate miss, try
2739
- // an exact case-insensitive UNIQUE label match against class === "Class"
2740
- // individuals; a unique hit renders a describe-style one-liner (see render's
2741
- // metaCodeClass branch). Anything less than a unique exact hit keeps the
2742
- // honest vocabulary miss — never a guess.
2743
- const classHits = (graph.individuals || []).filter((i) => i.class === "Class" && String(i.label).toLowerCase() === termLc);
2744
- if (classHits.length === 1) {
2745
- const hit = classHits[0];
2746
- const mid = moduleIdOf(graph, hit);
2747
- const modLabel = (mid && graph.byId.get(mid)?.label)
2748
- || String((hit.attributes || []).find((a) => a.key === "site")?.value || "").split(":")[0]
2749
- || null;
2943
+ // META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted to
2944
+ // metaFallbackEntityAnswer, HANDOVER.md 2026-07-10 item 6) see that
2945
+ // function's own docblock for the full "what is a Record"/"what does
2946
+ // fnAlpha mean" history. A unique hit renders straight from its own text
2947
+ // (render()'s metaCodeClass branch just passes it through).
2948
+ const fallback = metaFallbackEntityAnswer(graph, term);
2949
+ if (fallback) {
2750
2950
  return {
2751
- matches: [hit], objMatch: hit, candidates: [], ambiguous: false,
2752
- metaCodeClass: true, metaModuleLabel: modLabel,
2753
- traversal: `schema lookup for "${term}" (miss), then unique Class individual by label`,
2951
+ matches: [fallback.hit], objMatch: fallback.hit, candidates: [], ambiguous: false,
2952
+ metaCodeClass: true, metaFallbackText: fallback.text,
2953
+ traversal: `schema lookup for "${term}" (miss), then unique code-entity individual by label`,
2754
2954
  };
2755
2955
  }
2756
2956
  return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
@@ -2862,6 +3062,30 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
2862
3062
  };
2863
3063
  }
2864
3064
 
3065
+ // who-last (HANDOVER.md 2026-07-10 item 5): "who last touched X" — the SAME
3066
+ // newest-commit-first resolution as "when" just above (single most-recent
3067
+ // toucher, not the full touch history), rendered as the commit's AUTHOR
3068
+ // instead of its date. A dedicated shape rather than reusing "when" outright:
3069
+ // render() needs to know to answer with "who", not "when", off the same
3070
+ // sorted commit list.
3071
+ if (shape === "whoLast") {
3072
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
3073
+ const edges = ["touches", "touchesSymbol"].flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
3074
+ const seen = new Set();
3075
+ const commits = [];
3076
+ for (const e of edges) {
3077
+ if (seen.has(e.subject)) continue;
3078
+ seen.add(e.subject);
3079
+ const c = graph.byId.get(e.subject);
3080
+ if (c && c.class === "Commit") commits.push(c);
3081
+ }
3082
+ commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
3083
+ return {
3084
+ matches: commits, objMatch, candidates, ambiguous, matchedVia, whoLastShape: true,
3085
+ traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit's author`,
3086
+ };
3087
+ }
3088
+
2865
3089
  // commit-as-subject flip: touches edges are stored commit -> entity, so when the
2866
3090
  // RESOLVED term of a touches question is itself a Commit — "which changes touch
2867
3091
  // commit ef74e44e25c8" (reverse), "what did commit abc1234 touch" (forward),
@@ -3171,16 +3395,14 @@ function renderCore(parsed, result) {
3171
3395
  miss: true, ambiguous: false,
3172
3396
  };
3173
3397
  }
3174
- // meta fallback hit (0.8.2 WS1, see traverse's meta branch): the term is not
3175
- // schema vocabulary but IS a unique code-graph Class a describe-style
3176
- // one-liner pointing at the real entity, instead of the false vocabulary miss.
3398
+ // meta fallback hit (0.8.2 WS1, widened + extracted to metaFallbackEntityAnswer,
3399
+ // HANDOVER.md 2026-07-10 item 6, see traverse's meta branch): the term is not
3400
+ // schema vocabulary but IS a unique code-graph entity (Class/Function/Method/
3401
+ // GlobalVariable/Attribute) — its pre-rendered describe-style one-liner is
3402
+ // passed straight through, so this stays byte-identical to whatever chat.mjs's
3403
+ // bare "what is X" last-resort lane produces by calling the SAME function.
3177
3404
  if (result.metaCodeClass) {
3178
- const label = result.objMatch.label;
3179
- const definedIn = result.metaModuleLabel ? `, defined in ${result.metaModuleLabel}` : "";
3180
- return {
3181
- content: `${label} is a class in this codebase${definedIn} — try "describe ${label}" or "which classes inherit from ${label}".`,
3182
- miss: false, ambiguous: false, matches: result.matches,
3183
- };
3405
+ return { content: result.metaFallbackText, miss: false, ambiguous: false, matches: result.matches };
3184
3406
  }
3185
3407
  const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
3186
3408
  const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
@@ -3285,6 +3507,30 @@ function renderCore(parsed, result) {
3285
3507
  miss: false, ambiguous: false, matches: result.matches,
3286
3508
  };
3287
3509
  }
3510
+ // who-last: newest touching commit's AUTHOR (HANDOVER.md 2026-07-10 item 5) — the
3511
+ // superlative "who" mirror of whenShape just above. "who last touched X" used to
3512
+ // fall into the ordinary reverse-list render below and name EVERY toucher; this
3513
+ // answers with the single most recent one instead. Unlike whenShape, no date is
3514
+ // needed to answer "who" — an undated-but-authored commit still resolves.
3515
+ if (result.whoLastShape) {
3516
+ const subject = result.objMatch.label;
3517
+ if (!result.matches.length) {
3518
+ return { content: `no recorded commit touches ${subject} in this index.`, miss: true, ambiguous: false };
3519
+ }
3520
+ const newest = result.matches[0];
3521
+ const author = (newest.attributes || []).find((a) => a.key === "author")?.value;
3522
+ if (!author) {
3523
+ return {
3524
+ content: `commit ${newest.label} last touched ${subject}, but this index records no commit author — regenerate the graph to attach mgx:commitAuthor.`,
3525
+ miss: true, ambiguous: false,
3526
+ };
3527
+ }
3528
+ const more = result.matches.length - 1;
3529
+ return {
3530
+ content: `${subject} was last touched by ${author} (commit ${newest.label})${more ? `; ${more} earlier commit${more === 1 ? "" : "s"} recorded` : ""}.`,
3531
+ miss: false, ambiguous: false, matches: result.matches,
3532
+ };
3533
+ }
3288
3534
  // commit-as-subject answers ("which changes touch commit X", "what did commit X
3289
3535
  // touch"): cite the commit, group the touched entities by CLASS — modules and
3290
3536
  // symbols are different grains of the same answer, and flattening them into one