@polycode-projects/the-mechanical-code-talker 2.11.0 → 2.11.1

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.
@@ -68,6 +68,7 @@
68
68
  {"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."}
69
69
  {"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."}
70
70
  {"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."}
71
+ {"id":"identity-honest-miss","class":"conversational","register":"friendly","template":"No — I never make up an answer. If a question doesn't ground to a taught fact or a real graph entity, I say so plainly instead of guessing. /help for commands."}
71
72
  {"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})."}
72
73
  {"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})."}
73
74
  {"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": "2.11.0",
3
+ "version": "2.11.1",
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.",
@@ -246,6 +246,23 @@ export const VERB_TO_KIND = Object.freeze(
246
246
  ),
247
247
  );
248
248
 
249
+ /** The bare possessive "has"/"have"/"holds"/"hold" is bucketed onto `defines`
250
+ * (RELATIONS.defines.verbs above) for genuine code shapes ("what modules
251
+ * does app.mjs have", "does createTask have tests") — but those reach the
252
+ * grammar/keyword-spot strategies pre-rewritten onto a different verb by
253
+ * normalize.mjs's PHRASING_FRAMES (has-tests -> "what tests X", members ->
254
+ * "what does X contain"), and the possession/property sense of "have" over a
255
+ * TAUGHT individual ("does whiskers have fur") is answered entirely by
256
+ * chat.mjs's own has-a/hasProperty readers, upstream of both strategies. So
257
+ * a bare have-family verb reaching either strategy's TWO-NAMED-ROLE "ask"
258
+ * shape ("does X have Y", free-text subject and object, no grain check) has
259
+ * no legitimate code question left to mean, and confidently framing it as
260
+ * "locate what a module/class defines" is worse than an honest miss — both
261
+ * grammar.mjs (T1/T2/T3) and keywords.mjs check this set to decline instead.
262
+ * Shared here (rather than declared per-file) so the two strategies' bundled
263
+ * builds never collide on the same top-level identifier. */
264
+ export const HAS_FAMILY_VERBS = Object.freeze(new Set(["has", "have", "holds", "hold"]));
265
+
249
266
  /** "what is a kind of X" / "what is a subclass of X" collision fix: some
250
267
  * inherits verbs are themselves phrased "is a <continuation>", which would
251
268
  * otherwise collide with grammar.mjs's literal meta-whatis reading and
@@ -1236,6 +1236,29 @@ function reverseOverSet(graph, kind, entityType, objectIds) {
1236
1236
  return [];
1237
1237
  }
1238
1238
 
1239
+ const GIT_PROV_REF_RE = /^git:(.+)$/i;
1240
+
1241
+ /** How many distinct commits are attested to have touched a SET of entities —
1242
+ * the same "touched by N commit(s)" convention renderDescribe's attestation
1243
+ * line already uses (codegraph.mjs's turnRefCount), not the narrower "touches
1244
+ * edge count" reverseOverSet(kind="touches") returns. A commit can be recorded
1245
+ * in an entity's own `derived_from` provenance with no full Commit individual
1246
+ * of its own (the ingester's touches-edge and provenance-ref writes can drift,
1247
+ * e.g. a truncated commit walk) — reverseOverSet alone then undercounts, or
1248
+ * misses entirely when NO touches edge survived. Deduped by short sha so a
1249
+ * provenance ref naming a commit that DOES have a touches-edge individual
1250
+ * isn't double-counted. */
1251
+ function commitTouchCount(graph, objectIds) {
1252
+ const shas = new Set(reverseOverSet(graph, "touches", "Commit", objectIds).map((c) => String(c.label || "").toLowerCase()));
1253
+ for (const id of objectIds) {
1254
+ for (const ref of graph.byId.get(id)?.derived_from || []) {
1255
+ const m = GIT_PROV_REF_RE.exec(String(ref || ""));
1256
+ if (m) shas.add(m[1].toLowerCase());
1257
+ }
1258
+ }
1259
+ return shas.size;
1260
+ }
1261
+
1239
1262
  /** Forward traversal over a SET of subject ids (the "things {X…} call/define" step). */
1240
1263
  function forwardOverSet(graph, kind, subjectIds) {
1241
1264
  const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => subjectIds.has(e.subject));
@@ -1934,6 +1957,24 @@ function evalUniversal(graph, ast, opts) {
1934
1957
  };
1935
1958
  }
1936
1959
 
1960
+ /** The object-id set a "how many commits touched <X>" count restricts to,
1961
+ * covering both AST shapes that phrasing compiles to: a flat single-object
1962
+ * reverse clause ("how many commits touched app/lib/a.mjs") or a composed
1963
+ * reverseSet over a nested inner set ("… touched the module that defines
1964
+ * fnAlpha"). Null for every other count shape (including an unresolved or
1965
+ * ambiguous object), so the caller falls back to its plain evalSet(base)
1966
+ * count unchanged. */
1967
+ function commitTouchObjectIds(graph, base, opts) {
1968
+ if (base.node === "reverseSet" && base.kind === "touches" && base.entityType === "Commit") {
1969
+ return new Set(evalSet(graph, base.inner, opts).map((i) => i.id));
1970
+ }
1971
+ if (base.node === "clause" && base.clause?.shape === "reverse" && base.clause.kind === "touches" && base.clause.entityType === "Commit") {
1972
+ const { objMatch, ambiguous } = traverse(graph, base.clause, opts);
1973
+ return objMatch && !ambiguous ? new Set([objMatch.id]) : null;
1974
+ }
1975
+ return null;
1976
+ }
1977
+
1937
1978
  /** Compile any compositional AST to a result object traverse() returns for the
1938
1979
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1939
1980
  function evalComposite(graph, ast, opts = {}) {
@@ -1941,7 +1982,16 @@ function evalComposite(graph, ast, opts = {}) {
1941
1982
  if (ast.node === "exists") return evalExists(graph, ast);
1942
1983
  if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
1943
1984
  if (ast.node === "universal") return evalUniversal(graph, ast, opts);
1944
- if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1985
+ if (ast.node === "count") {
1986
+ // "how many commits touched <X>" — count against provenance attestation
1987
+ // (commitTouchCount), not the bare touches-edge set evalSet(base) would
1988
+ // give: see commitTouchCount's own doc for why the two can disagree.
1989
+ const commitTouchIds = commitTouchObjectIds(graph, ast.base, opts);
1990
+ if (commitTouchIds) {
1991
+ return { compositeKind: "count", count: commitTouchCount(graph, commitTouchIds), entityType: ast.entityType, matches: [] };
1992
+ }
1993
+ return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1994
+ }
1945
1995
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1946
1996
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1947
1997
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
@@ -106,7 +106,12 @@ function resolveNP(lexicon, tokensIn, { allowCompound = false } = {}) {
106
106
  if (proper) return { term: `${ns}${proper}`, individual: true, extras: [], unknown: [] };
107
107
  if (CODE_REF.test(t)) return { term: `${ns}${t}`, individual: true, extras: [], unknown: [] };
108
108
  const noun = lookupNoun(lexicon, t, { singularOnly });
109
- if (noun) return { term: `${ns}${noun.lemma}`, individual: false, noun, extras: [], unknown: [] };
109
+ // `folded` marks a match that only exists because lookupNoun's own
110
+ // trailing-"-s" strip or irregular-plural table rewrote the surface word
111
+ // — never an exact lexicon hit. parseCopula (below) uses this to keep a
112
+ // proper name that happens to fold to an unrelated dictionary word
113
+ // ("whiskers" -> "whisker") from silently losing its spelling.
114
+ if (noun) return { term: `${ns}${noun.lemma}`, individual: false, noun, folded: noun.lemma.toLowerCase() !== t.toLowerCase(), extras: [], unknown: [] };
110
115
  return { term: null, individual: false, extras: [], unknown: [t] };
111
116
  }
112
117
  if (tokens.length === 2) {
@@ -161,6 +166,26 @@ function resolveNP(lexicon, tokensIn, { allowCompound = false } = {}) {
161
166
  return { term: null, individual: false, extras: [], unknown: tokens.filter((t) => !classify(t, lexicon)) };
162
167
  }
163
168
 
169
+ /** A bare single-token subject (no determiner of its own) that resolveNP only
170
+ * resolved via a fold, immediately followed by an indefinite-article object
171
+ * ("whiskers is A CAT", "every whiskers is A CAT"), is the canonical
172
+ * individual-naming shape ("john is a man") wearing a proper name that
173
+ * happens to fold to an unrelated dictionary word ("whiskers" ->
174
+ * "whisker"). Trusting the fold here silently rewrites the taught subject's
175
+ * spelling; declining it and reporting the token as unknown lets the
176
+ * caller's own novel-individual fallback store the literal typed word
177
+ * instead — the same honest-miss-over-guess call this file's
178
+ * exact/irregular-only folds already make everywhere else. Shared by
179
+ * parseCopula and parseEvery, the two patterns whose subject slot can hold
180
+ * a bare single token immediately followed by "a"/"an". */
181
+ function declineFoldedBareSubject(np, subjectToks, objectHead) {
182
+ if (np.term != null && !np.individual && np.folded
183
+ && subjectToks.length === 1 && /^an?$/i.test(objectHead)) {
184
+ return { term: null, individual: false, extras: [], unknown: [subjectToks[0]] };
185
+ }
186
+ return np;
187
+ }
188
+
164
189
  /** The shared miss result: a structural fit with undeclared words returns the
165
190
  * pattern + residue (triples empty); a fit with only declared-but-unusable
166
191
  * phrasing returns null — the honest fall-through either way. */
@@ -349,7 +374,16 @@ function parseEvery(lexicon, toks, lower) {
349
374
  if (isIdx <= 1 || isIdx === toks.length - 1) return null;
350
375
  const rest = toks.slice(isIdx + 1);
351
376
  const everyAdjOnly = rest.length === 1 ? lookupAdjective(lexicon, rest[0]) : null;
352
- const np1 = resolveNP(lexicon, toks.slice(1, isIdx), { allowCompound: !everyAdjOnly });
377
+ const subjectToks = toks.slice(1, isIdx);
378
+ // Same fold-vs-individual ambiguity parseCopula guards against — a
379
+ // determiner-less "every whiskers is a cat" reaches the identical
380
+ // single-token resolveNP fold ("whiskers" -> "whisker") assertCandidates
381
+ // manufactures as a candidate phrasing whenever the bare payload has no
382
+ // determiner of its own, so this needs the same declineFoldedBareSubject
383
+ // guard or that candidate alone re-opens the bug parseCopula just closed.
384
+ const np1 = declineFoldedBareSubject(
385
+ resolveNP(lexicon, subjectToks, { allowCompound: !everyAdjOnly }), subjectToks, rest[0],
386
+ );
353
387
  if (everyAdjOnly) return adjectiveCopula(lexicon, PATTERN_ADJECTIVE, np1, everyAdjOnly);
354
388
  const np2 = resolveNP(lexicon, rest, { allowCompound: true });
355
389
  if (np1.term == null || np2.term == null) return missOrNull(PATTERN_SUB_CLASS_OF, [np1, np2]);
@@ -414,7 +448,13 @@ function parseOfForm(lexicon, toks, lower) {
414
448
  function parseCopula(lexicon, toks, lower, isIdx) {
415
449
  const rest = toks.slice(isIdx + 1);
416
450
  if (!rest.length) return null;
417
- const np1 = resolveNP(lexicon, toks.slice(0, isIdx), { allowCompound: rest.length > 1 });
451
+ const subjectToks = toks.slice(0, isIdx);
452
+ // parseAce never reaches here for "are", only singular "is" — see
453
+ // declineFoldedBareSubject's own docblock for why a bare single-token
454
+ // subject that only resolves by folding needs this guard.
455
+ const np1 = declineFoldedBareSubject(
456
+ resolveNP(lexicon, subjectToks, { allowCompound: rest.length > 1 }), subjectToks, rest[0],
457
+ );
418
458
  if (rest.length === 1) {
419
459
  const adj = lookupAdjective(lexicon, rest[0]);
420
460
  if (adj) return adjectiveCopula(lexicon, PATTERN_ADJECTIVE, np1, adj);
@@ -128,8 +128,12 @@ const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy|g'?day|yeah\s+nah|g
128
128
  * the "thanks" word family: "thanks so much, <Q>" -> "<Q>". */
129
129
  const THANKS_PREAMBLE_RE = /^(?:thanks|thank\s+you|many\s+thanks|thx|ty|cheers)(?:\s+(?:so\s+much|a\s+lot|very\s+much|a\s+bunch))?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
130
130
  /** Acknowledgement lead-in with a delimiter ("ok cool, <Q>"), repeating (`+`)
131
- * so a stack of ack-words peels in one pass. */
132
- const ACK_PREAMBLE_RE = /^(?:(?:ok(?:ay)?|aight|cool|alright|sure|right|fine|great|nice|got it|gotcha|sounds good|no worries|no problem)[\s,]+)+(.+)$/i;
131
+ * so a stack of ack-words peels in one pass. "one more"/"another one"/"just
132
+ * one more" join the ack-word alternation as the same discourse move under a
133
+ * different wording — a throwaway counting aside before the real content,
134
+ * never part of the content itself ("ok, one more, teach me: no server is a
135
+ * client" must peel exactly as "ok cool, <Q>" already does). */
136
+ const ACK_PREAMBLE_RE = /^(?:(?:ok(?:ay)?|aight|cool|alright|sure|right|fine|great|nice|got it|gotcha|sounds good|no worries|no problem|(?:just\s+)?(?:one|another)\s+more|another\s+one)[\s,]+)+(.+)$/i;
133
137
  /** Self-orientation lead-in with a delimiter — "just poking around, <Q>",
134
138
  * "first time using this, <Q>". */
135
139
  const BROWSING_PREAMBLE_RE = /^(?:just\s+(?:poking\s+around|looking\s+around|browsing|exploring|checking\s+(?:this|it)\s+out)|first\s+time\s+(?:trying\s+this\s+out|using\s+this|here)|i'?m\s+new\s+(?:here|around\s+here|to\s+(?:this|all\s+this)(?:\s+(?:repo|codebase|project|app|tool|thing))?))\s*[,.—–-]\s*(.+)$/i;
@@ -8,7 +8,7 @@ import {
8
8
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
9
9
  META_MEANING_VERBS, WHERE_MARKERS, MENTION_MARKERS,
10
10
  INHERITS_REVERSE_VERBS, stripTrailingScopeFiller, stripTrailingDiscourseTag,
11
- ARTICLE_RELATION_CONTINUATIONS,
11
+ ARTICLE_RELATION_CONTINUATIONS, HAS_FAMILY_VERBS,
12
12
  } from "../../ask-vocab.mjs";
13
13
  import { escapeRegex } from "../normalize.mjs";
14
14
 
@@ -17,6 +17,11 @@ const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.lengt
17
17
  const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
18
18
  const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
19
19
 
20
+ // See ask-vocab.mjs's own HAS_FAMILY_VERBS for why a bare have-family verb
21
+ // never resolves to `defines` in this template's "ask"/"reverse"/"forward"
22
+ // shapes below.
23
+ const isHasFamilyDefines = (kind, verb) => kind === "defines" && HAS_FAMILY_VERBS.has(verb);
24
+
20
25
  const TEMPLATES = [
21
26
  // T1 ASK: "does X import Y" -> Yes/No. REVERSE VERB SWAP: a semantically-reverse
22
27
  // verb ("superclass of") means the opposite of its forward counterpart, so
@@ -27,6 +32,7 @@ const TEMPLATES = [
27
32
  build: (m) => {
28
33
  const verb = m[2].toLowerCase();
29
34
  const kind = VERB_TO_KIND[verb];
35
+ if (isHasFamilyDefines(kind, verb)) return null;
30
36
  let subject = m[1].trim();
31
37
  let object = m[3].trim();
32
38
  if (INHERITS_REVERSE_VERBS.includes(verb)) [subject, object] = [object, subject];
@@ -37,23 +43,30 @@ const TEMPLATES = [
37
43
  {
38
44
  name: "reverse",
39
45
  re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
40
- build: (m) => ({
41
- shape: "reverse",
42
- entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
43
- modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
44
- kind: VERB_TO_KIND[m[3].toLowerCase()],
45
- object: m[4].trim(),
46
- }),
46
+ build: (m) => {
47
+ const verb = m[3].toLowerCase();
48
+ const kind = VERB_TO_KIND[verb];
49
+ if (isHasFamilyDefines(kind, verb)) return null;
50
+ return {
51
+ shape: "reverse",
52
+ entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
53
+ modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
54
+ kind,
55
+ object: m[4].trim(),
56
+ };
57
+ },
47
58
  },
48
59
  // T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
49
60
  // "did" joins does/do for the past-tense commit forms ("what did commit <sha> touch").
50
61
  {
51
62
  name: "forward",
52
63
  re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
53
- build: (m) => ({
54
- shape: "forward", entityType: null, modifier: "direct",
55
- kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
56
- }),
64
+ build: (m) => {
65
+ const verb = m[2].toLowerCase();
66
+ const kind = VERB_TO_KIND[verb];
67
+ if (isHasFamilyDefines(kind, verb)) return null;
68
+ return { shape: "forward", entityType: null, modifier: "direct", kind, object: m[1].trim() };
69
+ },
57
70
  },
58
71
  // T4 meta: "what does <term> mean" — a question about the graph's own vocabulary,
59
72
  // not a graph traversal. VERB_ALT and META_ALT are disjoint tables, so this never
@@ -65,16 +78,33 @@ const TEMPLATES = [
65
78
  },
66
79
  // T5 meta: "what is a/an <term>" — the bare (no-article) form is restricted to
67
80
  // the closed ENTITY_TO_TYPE vocabulary (build() -> null otherwise, falling
68
- // through); the WITH-article form is unrestricted.
81
+ // through); the WITH-article form is unrestricted. A "the"-article form is
82
+ // ALSO accepted, but only for a single-token term ("the Task") — the same
83
+ // schema-then-code-entity lookup a bare "Task" would reach (traverse()'s
84
+ // shape:"meta" handling, via metaFallbackEntityAnswer). Multi-word "the …"
85
+ // phrases stay excluded on purpose: "what is the meaning of this codebase"/
86
+ // "the purpose of X" are existential framings with their own decline
87
+ // elsewhere, never a literal term to look up (see the out-of-grammar test
88
+ // this guards).
69
89
  {
70
90
  name: "meta-whatis",
71
- re: new RegExp(`^what\\s+(?:is|are)\\s+(?:(an?)\\s+)?(.+?)\\??$`, "i"),
91
+ re: new RegExp(`^what\\s+(?:is|are)\\s+(?:(an?|the)\\s+)?(.+?)\\??$`, "i"),
72
92
  build: (m) => {
93
+ const article = m[1] ? m[1].toLowerCase() : null;
73
94
  const object = stripTrailingDiscourseTag(m[2].trim());
74
- if (!m[1] && !ENTITY_TO_TYPE[object.toLowerCase()]) return null; // bare form: closed-set only
75
- // "what is a kind/subclass of X" is an inherits phrasing, not a term to define.
95
+ const isSingleToken = !/\s/.test(object);
96
+ if (article === "the" && !isSingleToken) return null;
97
+ if (!article && !ENTITY_TO_TYPE[object.toLowerCase()]) return null; // bare form: closed-set only
76
98
  const objLower = object.toLowerCase();
77
- if (m[1] && ARTICLE_RELATION_CONTINUATIONS.some(
99
+ // "what is a kind/subclass of X" is an inherits phrasing, not a term to
100
+ // define — ARTICLE_RELATION_CONTINUATIONS only ever derives from the
101
+ // "is a/an <continuation>" verb forms, so it's checked only for those;
102
+ // "the"-definite reverse-inherits forms ("is the superclass of") are a
103
+ // separate, deliberately unfolded set (ask-vocab.mjs's own comment on
104
+ // INHERITS_REVERSE_VERB_LIST) — moot here since those are always
105
+ // multi-word and already excluded by the single-token check above, but
106
+ // named for the same reason ARTICLE_RELATION_CONTINUATIONS is.
107
+ if (article && article !== "the" && ARTICLE_RELATION_CONTINUATIONS.some(
78
108
  (c) => objLower === c || objLower.startsWith(`${c} `),
79
109
  )) return null;
80
110
  return { shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: stripTrailingScopeFiller(object) };
@@ -8,7 +8,7 @@
8
8
  import {
9
9
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
10
10
  WHERE_MARKERS, MENTION_MARKERS, PLACEHOLDER_NOUNS, PASSIVE_PARTICIPLE_TO_KIND,
11
- INHERITS_REVERSE_VERBS,
11
+ INHERITS_REVERSE_VERBS, HAS_FAMILY_VERBS,
12
12
  } from "../../ask-vocab.mjs";
13
13
  import { STOPWORDS } from "../normalize.mjs";
14
14
  import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
@@ -19,6 +19,10 @@ import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
19
19
  const PASSIVE_AUX = new Set(["is", "are", "was", "were", "be", "been", "being", "get", "gets", "got"]);
20
20
  const WH_WORDS = new Set(["which", "what", "who", "whom", "whose"]);
21
21
  const PLACEHOLDER_SET = new Set(PLACEHOLDER_NOUNS.map((w) => w.toLowerCase()));
22
+ // See ask-vocab.mjs's own HAS_FAMILY_VERBS for why a bare have-family verb
23
+ // never resolves to `defines` in this strategy's two-named-role "ask" shape
24
+ // below (the forward/reverse branches keep their own tested grain-check
25
+ // decline, per that constant's own docblock).
22
26
 
23
27
  /** Find the longest phrase from `table`'s keys that appears as a contiguous
24
28
  * run of `words` (case already lowercased by the caller). Longest-match-first
@@ -107,6 +111,13 @@ export function parseKeywordSpot(text, nlp = null) {
107
111
  fuzzyVerb = { from: lcWords[at], to: fuzzyWords[at] };
108
112
  }
109
113
  }
114
+ // Tracks whether verbHit came from PASSIVE_PARTICIPLE_TO_KIND's fallback
115
+ // rather than an active VERB_TO_KIND entry — that table's own header says
116
+ // it's only meant to fire once a passive auxiliary AND an agent-marking
117
+ // "by" are confirmed. A direct complement with no "by" ("my cat is called
118
+ // whiskers") is the naming sense of "call", not the invoke-relation passive,
119
+ // so this flag gates the ask-shape SVO fallback below from misreading it.
120
+ let verbFromParticiple = false;
110
121
  if (!verbHit) {
111
122
  // A participle with no active verb entry still marks a passive when a passive
112
123
  // auxiliary precedes it — with or without an agent "by" phrase. "is http.mjs
@@ -125,10 +136,28 @@ export function parseKeywordSpot(text, nlp = null) {
125
136
  for (let i = 0; i < lcWords.length; i += 1) {
126
137
  const k = PASSIVE_PARTICIPLE_TO_KIND[lcWords[i]];
127
138
  if (k && lcWords[i] === "used" && lcWords[i + 1] === "for") continue;
128
- if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) { verbHit = { kind: k, start: i, end: i + 1 }; break; }
139
+ if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) {
140
+ verbHit = { kind: k, start: i, end: i + 1 };
141
+ verbFromParticiple = true;
142
+ break;
143
+ }
129
144
  }
130
145
  }
131
146
  if (!verbHit) return null;
147
+ // A single-word verbHit whose LITERAL surface text (not the lemma the tier-2
148
+ // pass may have rewritten it to) is itself a PASSIVE_PARTICIPLE_TO_KIND entry,
149
+ // preceded by a passive auxiliary, is the same "needs a confirmed 'by' agent"
150
+ // case the fallback loop above already flags — checked here too because the
151
+ // lemma tier reaches it independently: lemma("called") is "call", an ACTIVE
152
+ // verb-table entry in its own right, so tier 2 resolves verbHit before the
153
+ // fallback loop ever runs, silently losing the participle reading. "my cat is
154
+ // called whiskers" (the naming sense of "call") must not be read as though
155
+ // "cat" were the active subject invoking "whiskers".
156
+ if (!verbFromParticiple && verbHit.end - verbHit.start === 1
157
+ && PASSIVE_PARTICIPLE_TO_KIND[lcWords[verbHit.start]]
158
+ && lcWords.slice(0, verbHit.start).some((w) => PASSIVE_AUX.has(w))) {
159
+ verbFromParticiple = true;
160
+ }
132
161
  // A tier-3 verb is a REPAIR, not a reading — downstream consumers (the teach
133
162
  // lane's canonical receipt, and the chat surface's fuzzy-verb decline) need
134
163
  // to know the difference AND which word was rewritten, so {from, to} rides
@@ -221,15 +250,35 @@ export function parseKeywordSpot(text, nlp = null) {
221
250
  const [patient, agent] = agentIsFronted
222
251
  ? [roleText(passiveAuxIdx + 1, words.length), roleText(byIdx + 1, passiveAuxIdx)]
223
252
  : [roleText(0, byIdx), roleText(byIdx + 1, words.length)];
224
- if (patient && agent) return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject: agent, object: patient });
253
+ if (patient && agent) {
254
+ if (kind === "defines" && HAS_FAMILY_VERBS.has(canonWords.slice(verbHit.start, verbHit.end).join(" "))) return null;
255
+ return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject: agent, object: patient });
256
+ }
225
257
  if (agent) return stamp({ shape: "forward", entityType, modifier, kind, object: agent });
226
258
  if (patient) return stamp({ shape: "reverse", entityType, modifier, kind, object: patient });
227
259
  }
228
260
 
261
+ // "called" specifically (never the rest of the participle family — "was it
262
+ // touched recently"/"is X used anywhere" are genuine bare-passive code
263
+ // queries with a trailing adverb, not a competing sense, and must keep
264
+ // reaching their existing reading) carries a whole separate NAMING sense
265
+ // ("my cat is called whiskers") distinct from the invoke-relation passive
266
+ // this table exists for. A following complement with no "by" agent is that
267
+ // naming sense, not "whiskers calls cat" — falling through to the
268
+ // active-SVO/reverse branches below would read the participle as if it
269
+ // were an active verb and answer a code-graph question nobody asked, so
270
+ // this misses honestly instead.
271
+ if (verbFromParticiple && byIdx < 0 && afterText && lcWords[verbHit.start] === "called") return null;
272
+
229
273
  if (beforeText && afterText) {
274
+ const verbPhrase = canonWords.slice(verbHit.start, verbHit.end).join(" ");
275
+ // The bare have-family "ask" shape ("does X have Y") declines here, same
276
+ // reasoning as grammar.mjs's T1 (see HAS_FAMILY_VERBS above) — never for
277
+ // the entityType-driven forward/reverse branches below, which keep their
278
+ // own tested grain-check decline.
279
+ if (kind === "defines" && HAS_FAMILY_VERBS.has(verbPhrase)) return null;
230
280
  // A semantically-reverse verb ("superclass of") swaps subject/object, same as
231
281
  // grammar.mjs's T1.
232
- const verbPhrase = canonWords.slice(verbHit.start, verbHit.end).join(" ");
233
282
  let subject = beforeText;
234
283
  let object = afterText;
235
284
  if (INHERITS_REVERSE_VERBS.includes(verbPhrase)) [subject, object] = [object, subject];
@@ -242,11 +242,18 @@ export function registerCapability(cap) {
242
242
  }
243
243
 
244
244
  // ---- unregistered dispatch tools ---------------------------------------------
245
- // Dispatch tools not yet registered; each names the precondition work it needs first.
245
+ // Dispatch tools not yet registered; each names the work it needs first — a
246
+ // precondition/effect design, or resolver wiring, before it can join the registry.
246
247
  export const EXCLUDED_FROM_REGISTRY = Object.freeze({
247
248
  tmct_context: "unbounded edit-context bundle (multi-file); needs a size/budget precondition",
248
249
  tmct_context_more: "unbounded context continuation; same as tmct_context",
249
250
  tmct_snippet: "raw source-file read (reads the filesystem); needs a file-read + span precondition",
251
+ tmct_ask: "the plain-English question entry point itself — calls ask.mjs directly and bypasses the capability planner; not one of the planner's operators",
252
+ tmct_export: "reads the memory store's whole fact set with no discriminating param and no resolver goal frame; needs NL-reachability wiring before it can join",
253
+ tmct_ingest: "writes into the memory store (grounds facts); capability() only models read-only operators, so a write path needs its own precondition/effect design first",
254
+ tmct_file_history: "a module-grain cut of the same history edge tmct_history already models; no NL frame distinguishes the granularities yet",
255
+ tmct_method_history: "a method-grain cut of the same history edge tmct_history already models; no NL frame distinguishes the granularities yet",
256
+ tmct_class_history: "a class-grain cut of the same history edge tmct_history already models; no NL frame distinguishes the granularities yet",
250
257
  });
251
258
 
252
259
  /** The full registry as a plain frozen object (facts + index), for callers that