@polycode-projects/the-mechanical-code-talker 1.0.3 → 1.0.5

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/ask.mjs +220 -13
  3. package/src/chat.mjs +41 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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.",
package/src/ask.mjs CHANGED
@@ -291,6 +291,14 @@ const NEST_SENTINEL = "zzinnerset";
291
291
  // then"/"what about X though".
292
292
  const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
293
293
  const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
294
+ // A bare copula leading a boolean branch ("...and ARE untested") is discourse glue,
295
+ // not part of the qualifier — dropped before a branch is tested/used as a
296
+ // qualifier-only atom (both the marker-gate probe below and the two atom-building
297
+ // folds — buildPredicateAtoms, parseRelationalOrQualified's own fold — apply it).
298
+ const COPULA_WORDS = new Set(["are", "is", "was", "were"]);
299
+ function dropLeadCopula(bw, blc) {
300
+ return blc.length && COPULA_WORDS.has(blc[0]) ? { bw: bw.slice(1), blc: blc.slice(1) } : { bw, blc };
301
+ }
294
302
 
295
303
  const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
296
304
  : (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
@@ -311,7 +319,8 @@ function parseSimpleClause(text, nlp) {
311
319
  function parseComposite(text, nlp) {
312
320
  const w = splitWords(text);
313
321
  const lc = w.map((x) => x.toLowerCase());
314
- return parseNegation(text, nlp, 0)
322
+ return parseExistence(w, lc)
323
+ || parseNegation(text, nlp, 0)
315
324
  || parseForwardNegation(w, lc, nlp)
316
325
  || parseTemporal(w, lc, nlp, 0)
317
326
  || parseAnaphora(w, lc, nlp)
@@ -580,6 +589,72 @@ function parsePredicateFilter(words, nlp) {
580
589
  return undefined;
581
590
  }
582
591
 
592
+ /** EXISTENCE: "is there a/an <kind> [called|named <term>] [in <module>] [anywhere]"
593
+ * and "are there any <kind>(s) [called|named <term>] [in <module>]" — a genuine
594
+ * existence question ("does this kind/name exist at all", optionally scoped to a
595
+ * module), answered directly against class/kind membership rather than routed
596
+ * through the relation-verb machinery. Triage bug (2026-07-09, seonix dogfooding):
597
+ * with no dedicated recognizer, "is there a class called Store anywhere" fell
598
+ * through to the legacy keyword-spot strategy, whose lemma tier canonicalizes
599
+ * "called" -> "call" (a `calls` verb — ask-vocab.mjs) and silently answered a
600
+ * DIFFERENT question ("which classes call Store") with a confidently-wrong-shaped
601
+ * negative, even though a class named Store genuinely exists. "is there a class in
602
+ * <module>" walled out the same way — no marker in this grammar recognized it at
603
+ * all. Scoped to a tight closed shape: a leading "is there a/an" or "are there any"
604
+ * immediately followed by a recognized entity-kind noun, then ONLY "called"/"named
605
+ * <term>", "in <module>", the two combined, or an empty/"anywhere"/"at all" tail —
606
+ * anything else (a relative clause, a verb phrase: "is there a class THAT CALLS
607
+ * Store") is a genuine relationship question and is left untouched for the
608
+ * relation parsers below, never swallowed here. */
609
+ function parseExistence(w, lc) {
610
+ let i;
611
+ if (lc[0] === "is" && lc[1] === "there") i = 2;
612
+ else if (lc[0] === "are" && lc[1] === "there") i = 2;
613
+ else return null;
614
+ const article = lc[i];
615
+ if (article === "a" || article === "an" || article === "any") i += 1;
616
+ else return null;
617
+ const noun = i < lc.length ? entityNoun(lc[i]) : null;
618
+ if (!noun || noun.placeholder || !noun.entityType) return null;
619
+ const entityType = noun.entityType;
620
+ i += 1;
621
+
622
+ let rest = lc.slice(i);
623
+ let restW = w.slice(i);
624
+ // trailing filler — "anywhere" / "at all" — stripped so it never gets misread as
625
+ // a (nonexistent) module/name term below.
626
+ if (rest.length && rest[rest.length - 1] === "anywhere") {
627
+ rest = rest.slice(0, -1); restW = restW.slice(0, -1);
628
+ } else if (rest.length >= 2 && rest[rest.length - 2] === "at" && rest[rest.length - 1] === "all") {
629
+ rest = rest.slice(0, -2); restW = restW.slice(0, -2);
630
+ }
631
+
632
+ if (!rest.length) return { node: "exists", entityType, term: null, scopeModule: null };
633
+
634
+ if (rest[0] === "called" || rest[0] === "named") {
635
+ if (rest.length < 2) return { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
636
+ const inIdx = rest.indexOf("in", 1);
637
+ if (inIdx > 0) {
638
+ const term = restW.slice(1, inIdx).join(" ").trim();
639
+ const scopeModule = restW.slice(inIdx + 1).join(" ").trim();
640
+ if (!term || !scopeModule) return { node: "miss", reason: `a named existence check needs both a name and a module after "in"` };
641
+ return { node: "exists", entityType, term, scopeModule };
642
+ }
643
+ const term = restW.slice(1).join(" ").trim();
644
+ return term ? { node: "exists", entityType, term, scopeModule: null }
645
+ : { node: "miss", reason: `"${rest[0]}" needs a name afterward` };
646
+ }
647
+
648
+ if (rest[0] === "in") {
649
+ const scopeModule = restW.slice(1).join(" ").trim();
650
+ return scopeModule ? { node: "exists", entityType, term: null, scopeModule }
651
+ : { node: "miss", reason: `"in" needs a module afterward` };
652
+ }
653
+
654
+ return null; // a relative clause / verb phrase / anything else — genuinely a
655
+ // different (relationship) question; leave it for the parsers below.
656
+ }
657
+
583
658
  /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
584
659
  * ("how many classes are there", "list functions in total", "which classes exist in
585
660
  * the index") — a count/list over a bare kind is frequently phrased with such a tail,
@@ -863,7 +938,8 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
863
938
  const bw = branches[b];
864
939
  const blc = bw.map((x) => x.toLowerCase());
865
940
  const op = b === 0 ? "intersection" : ops[b - 1];
866
- if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
941
+ const qc = dropLeadCopula(bw, blc);
942
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
867
943
  if (blc[0] === "of" || blc[0] === "in") {
868
944
  atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
869
945
  continue;
@@ -938,9 +1014,20 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
938
1014
  if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
939
1015
  const membershipLed = predLc[0] === "of" || predLc[0] === "in";
940
1016
  const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
1017
+ // A boolean branch whose OWN content — past an optional leading copula ("and ARE
1018
+ // untested") — collapses to qualifier words alone is the compositional shape too
1019
+ // ("functions that call X and are untested": verb clause AND qualifier, same
1020
+ // subject). Probe with the same splitBoolean+QUALIFIERS fold the atoms loop below
1021
+ // uses, so a bare verb+verb boolean chain with NO qualifier signal anywhere
1022
+ // ("which classes extends Base and couples to logging") still has no marker here
1023
+ // and correctly stays on the legacy ambiguous-parse path, untouched.
1024
+ const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
1025
+ const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
1026
+ return blc.length && blc.every((x) => QUALIFIERS[x]);
1027
+ });
941
1028
  // marker gate — the crux of backward-compat: without one of these, this is not a
942
1029
  // compositional query and we must NOT hijack it from the existing parser.
943
- if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
1030
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed)) return null;
944
1031
 
945
1032
  // empty predicate → a bare qualified class ("public methods")
946
1033
  if (!predWords.length) {
@@ -959,7 +1046,8 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
959
1046
  const bw = branches[b];
960
1047
  const blc = bw.map((x) => x.toLowerCase());
961
1048
  const op = b === 0 ? "seed" : ops[b - 1];
962
- if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
1049
+ const qc = dropLeadCopula(bw, blc);
1050
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
963
1051
  if (blc[0] === "of" || blc[0] === "in") {
964
1052
  atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
965
1053
  continue;
@@ -1479,10 +1567,42 @@ function evalSuperlative(graph, ast) {
1479
1567
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1480
1568
  }
1481
1569
 
1570
+ /** EXISTENCE eval — "is there a/an <kind> [called/named <term>] [in <module>]": a
1571
+ * direct membership/name check against the graph, never routed through the
1572
+ * relation-verb machinery. A named check resolves the term against the SAME
1573
+ * tiered resolveObject() every other named-lookup shape uses (expectedClass pins
1574
+ * the pool to the asked kind, so "is there a class called Store" can never
1575
+ * resolve to a same-named function/module); a scope clause resolves the module
1576
+ * the same way and narrows the check to that module's own `defines` edges
1577
+ * (refineToEntities — the same primitive members-of-a-module questions use). */
1578
+ function evalExists(graph, ast) {
1579
+ const { entityType, term, scopeModule } = ast;
1580
+ let scopeMatch = null;
1581
+ if (scopeModule) {
1582
+ const r = resolveObject(graph, scopeModule, { expectedClass: "Module" });
1583
+ if (!r.match) return { compositeKind: "exists", entityType, term, scopeModule, scopeMiss: true, matches: [] };
1584
+ scopeMatch = r.match;
1585
+ }
1586
+ if (term) {
1587
+ const r = resolveObject(graph, term, { expectedClass: entityType });
1588
+ const inScope = !scopeMatch || (r.match && moduleIdOf(graph, r.match) === scopeMatch.id);
1589
+ const hit = r.match && inScope;
1590
+ return {
1591
+ compositeKind: "exists", entityType, term, scopeModule, scopeMatch,
1592
+ matches: hit ? [r.match] : [],
1593
+ };
1594
+ }
1595
+ const pool = scopeMatch
1596
+ ? refineToEntities(graph, new Set([scopeMatch.id]), entityType)
1597
+ : graph.individuals.filter((i) => i.class === entityType);
1598
+ return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1599
+ }
1600
+
1482
1601
  /** Compile any compositional AST to a result object traverse() returns for the
1483
1602
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1484
1603
  export function evalComposite(graph, ast, opts = {}) {
1485
1604
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1605
+ if (ast.node === "exists") return evalExists(graph, ast);
1486
1606
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1487
1607
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1488
1608
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1532,6 +1652,30 @@ function renderComposite(parsed, result) {
1532
1652
  }
1533
1653
  return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
1534
1654
  }
1655
+ // exists: "is there a/an <kind> [called/named <term>] [in <module>]" — an
1656
+ // honest Yes/No membership check, never routed through the relation-verb
1657
+ // machinery (see parseExistence's own doc for the bug this fixes).
1658
+ if (result.compositeKind === "exists") {
1659
+ if (result.scopeMiss) {
1660
+ return { content: `no module matching "${result.scopeModule}" found in the index.`, miss: true, ambiguous: false };
1661
+ }
1662
+ const kindSingular = nounFor(result.entityType, 1);
1663
+ const kindPlural = nounFor(result.entityType, 2);
1664
+ const scopeSuffix = result.scopeMatch ? ` in ${result.scopeMatch.label}` : "";
1665
+ if (result.term) {
1666
+ if (!result.matches.length) {
1667
+ return { content: `No — no ${kindSingular} named "${result.term}" found${scopeSuffix}.`, miss: true, ambiguous: false };
1668
+ }
1669
+ const hit = result.matches[0];
1670
+ const modLabel = moduleLabelOf(hit);
1671
+ const definedIn = hit.class === "Module" ? "" : (modLabel && modLabel !== "(unknown module)" ? `, defined in ${modLabel}` : "");
1672
+ return { content: `Yes — ${hit.label} is a ${kindSingular}${definedIn}.`, miss: false, ambiguous: false, matches: result.matches };
1673
+ }
1674
+ if (!result.matches.length) {
1675
+ return { content: `No — no ${kindPlural} found${scopeSuffix}.`, miss: true, ambiguous: false };
1676
+ }
1677
+ return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
1678
+ }
1535
1679
  if (result.compositeKind === "count") {
1536
1680
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1537
1681
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -1738,15 +1882,66 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1738
1882
  }
1739
1883
  }
1740
1884
  } else {
1741
- const termComps = componentSet(t);
1885
+ // Root-cause fix (Tier-2 playtest cycle 9, targeted substring-match sweep):
1886
+ // this raw containment check has no minimum-length floor, so a short
1887
+ // closed-vocabulary word is a near-certain ACCIDENTAL substring of SOME
1888
+ // real label — confirmed empirically against the shipped mini-webapp
1889
+ // fixture: "so"->sendJson, "or"->Store, "a"->Task, "is"->listTasks
1890
+ // (ambiguous), "in"->Logger.info, "on"->sendJson, "at"->createApp,
1891
+ // "to"->Store, all via this exact branch. Cycle 8 patched three of these
1892
+ // ("and"/"also"/"so"/"then"/"now" and bare "it") one word at a time at
1893
+ // individual chat.mjs CALL SITES (STACCATO_LEAKED_CONNECTIVES, the
1894
+ // pronoun-reuse guard) — necessary there because those bugs are about
1895
+ // FOCUS bookkeeping, not resolution per se, but leaving resolveObject
1896
+ // itself unguarded meant every OTHER caller (existence checks, expectedClass
1897
+ // lookups, etc.) stayed exposed to the same trap for every not-yet-hit
1898
+ // short word. Gating the containment check at the same floor tier 5's own
1899
+ // fuzzy pass already uses (`tLc.length >= 4` below) closes it at the
1900
+ // source. A whole-token component match (just below) is unaffected by this
1901
+ // floor — it requires an EXACT path/identifier segment equality, never a
1902
+ // raw substring, so a genuinely short real identifier ("db", "fs") is
1903
+ // still resolvable by literally matching a whole segment.
1904
+ const termComps = [...componentSet(t)];
1905
+ // A SLASHED term's final path segment, extension stripped ("src/nope.mjs"
1906
+ // -> "nope", "cover app/lib/b.mjs" -> "b") — the semantically load-bearing
1907
+ // FILENAME STEM, as opposed to a directory segment or a leaked verb noise
1908
+ // word. Isolated from the actual whitespace-delimited PATH TOKEN (not the
1909
+ // raw multi-word string as a whole) — "app/lib/f.mjs but untested" (a
1910
+ // trailing-noise leak, distinct from the leading-verb-noise shape above)
1911
+ // has no extension at the end of the whole string, so splitting the whole
1912
+ // string would strand "f.mjs but untested" as a bogus non-matching "stem";
1913
+ // finding the one token that itself contains "/" keeps the path term
1914
+ // intact regardless of what noise surrounds it on either side. Only
1915
+ // slash-shaped terms compute this; a bare identifier/multi-word query has
1916
+ // no path structure to anchor on, so it's null and the gate below is a
1917
+ // no-op for those (unaffected — original ANY-overlap behavior).
1918
+ const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
1919
+ const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
1742
1920
  for (const m of pool) {
1743
1921
  const label = String(m.label || "").toLowerCase();
1744
- if (label.includes(tLc)) {
1922
+ if (tLc.length >= 4 && label.includes(tLc)) {
1745
1923
  scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
1746
1924
  continue;
1747
1925
  }
1748
- const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
1749
- if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
1926
+ const labelComps = componentSet(m.label);
1927
+ const overlap = termComps.filter((c) => labelComps.has(c)).length;
1928
+ // For a slashed term, the stem MUST be among the label's own components
1929
+ // — an ANY-overlap match otherwise lets a NONEXISTENT path land on a
1930
+ // real module that merely shares its generic directory/extension
1931
+ // segments ("src", "mjs") with every other module in the pool (found
1932
+ // via the existence recognizer's "is there a class in src/nope.mjs"
1933
+ // scope clause, Tier-2 playtest cycle 9: it ambiguously "matched"
1934
+ // src/core/model.mjs even though no such module exists — the same
1935
+ // accidental-match disease as the short-word substring bug just above,
1936
+ // just triggered by GENERIC components instead of raw containment).
1937
+ // A leaked leading VERB ("cover app/lib/b.mjs", "touch app/lib/f.mjs" —
1938
+ // router-interface.test.mjs's own frozen contract) still resolves: the
1939
+ // stem ("b"/"f") is a real component of the target label even though
1940
+ // "cover"/"touch" themselves never overlap anything, so overlap>0 and
1941
+ // the stem gate both hold.
1942
+ if (overlap > 0 && (!slashStem || labelComps.has(slashStem))) {
1943
+ scored.push({ ind: m, score: overlap * 10 });
1944
+ }
1750
1945
  }
1751
1946
  }
1752
1947
  scored.sort((a, b) => b.score - a.score);
@@ -1767,12 +1962,24 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1767
1962
  // browser `lookupByProseTokens` is an undeclared identifier — without the guard,
1768
1963
  // ANY term reaching this tier threw a ReferenceError in the page instead of
1769
1964
  // rendering the honest miss (a real, previously-untested viewer bug).
1770
- // DOTTED terms never consult prose: "res.json" word-matches test/res.json.js's
1771
- // own path tokens, which is the tier-3 phantom-path bug reappearing through a
1772
- // side door — a dotted term names an identifier, and identifiers resolve by
1773
- // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
1965
+ // DOTTED or SLASHED terms never consult prose: "res.json" word-matches
1966
+ // test/res.json.js's own path tokens, which is the tier-3 phantom-path bug
1967
+ // reappearing through a side door — a dotted term names an identifier, and
1968
+ // identifiers resolve by label (tiers above) or the bounded fuzzy pass
1969
+ // below, or they honestly miss. The SAME side door was open for SLASHED
1970
+ // path terms too (Tier-2 playtest cycle 9, existence-recognizer follow-up):
1971
+ // "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
1972
+ // (the AND-across-components fix just above), but fell through to THIS
1973
+ // prose tier and ambiguously "matched" real modules anyway, because
1974
+ // lookupByProseTokens scores by ANY-token overlap (sum-scored, by design,
1975
+ // for genuine prose ranking) and "src"/"mjs" are near-universal path/
1976
+ // extension tokens shared by every module in the pool — the identical
1977
+ // accidental-match disease, just one tier further down. A slash-shaped term
1978
+ // names a literal path exactly like a dotted term names a literal symbol;
1979
+ // neither is prose, so neither should ever reach the prose fallback.
1980
+ const pathShaped = dotted || tLc.includes("/");
1774
1981
  let proseResult = null;
1775
- const proseHits = !dotted && typeof lookupByProseTokens === "function"
1982
+ const proseHits = !pathShaped && typeof lookupByProseTokens === "function"
1776
1983
  ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass)
1777
1984
  : [];
1778
1985
  if (proseHits.length) {
package/src/chat.mjs CHANGED
@@ -3300,7 +3300,13 @@ async function describeWrapperAnswer(query, { config, source, focus }) {
3300
3300
  * known edge concept (RELATION_TERM), no curated definition, or the graph has NO
3301
3301
  * edges of that kind (composeRelation's own honest-miss gate). Loads the definition
3302
3302
  * from the shipped corpus/seon/relations.jsonl, so it works without per-repo memory
3303
- * seeding. Lazy + failure-tolerated throughout. Returns { text, pending }. */
3303
+ * seeding. Lazy + failure-tolerated throughout. Returns { text, pending, kind }
3304
+ * `kind` is the resolved RELATION_TERM canonical kind (imports/calls/…), the SAME
3305
+ * vocabulary GOAL_BY_KIND keys on, so a caller whose own envelope.parsed never
3306
+ * stood (this force's whole reason to exist — see relationTermOf/
3307
+ * isVagueRelationTouch's own docs) can still deduce the correct "Goal (inferred):
3308
+ * …" line instead of silently carrying forward a null goal from earlier in the
3309
+ * turn. */
3304
3310
  async function relationForceAnswer(query, envelope, { graph, config, source, templates }) {
3305
3311
  const rawTerm = relationTermOf(query, envelope);
3306
3312
  if (!rawTerm) return null;
@@ -3308,8 +3314,9 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
3308
3314
  try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
3309
3315
  catch { return null; }
3310
3316
  const term = String(rawTerm).toLowerCase();
3311
- if (!RELATION_TERM[term]) return null; // not an enumerable edge concept — ordinary path owns it
3312
- const definition = (await relationDefinitions()).get(RELATION_TERM[term]) ?? null;
3317
+ const kind = RELATION_TERM[term];
3318
+ if (!kind) return null; // not an enumerable edge concept — ordinary path owns it
3319
+ const definition = (await relationDefinitions()).get(kind) ?? null;
3313
3320
  if (!definition) return null;
3314
3321
  // Same graph-load fallback as conceptForceAnswer: the shell hands the loaded graph
3315
3322
  // straight in; the pure runTurn(config) path loads it the way dispatchTool does.
@@ -3329,7 +3336,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
3329
3336
  const pending = composed.remainder && composed.remainder.length
3330
3337
  ? { items: composed.remainder, noun: composed.noun }
3331
3338
  : null;
3332
- return { text, pending };
3339
+ return { text, pending, kind };
3333
3340
  }
3334
3341
 
3335
3342
  /** THE CONCEPT FORCE — compose the three-band answer (corpus/seon definition + real
@@ -3571,7 +3578,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3571
3578
  // SAME value the debug trace's own "goal:" line uses (one deduction, two
3572
3579
  // presentations) — null here (no parse stood at all) means withGoalLine
3573
3580
  // shows nothing, never a "Goal (inferred): unclear" line.
3574
- const deduced = deduceGoalFromParsed(envelope?.parsed);
3581
+ // `let`, not `const`: the RELATION CONCEPT FORCE (relationForceAnswer, below)
3582
+ // can answer a turn CORRECTLY with no envelope.parsed at all to deduce from —
3583
+ // a staccato relation-chain continuation whose leading connective never
3584
+ // itself parses ("and inherits?": ask()'s raw grammar has no production for
3585
+ // a bare relation word with no verb, exactly like the "cochange" vague-touch
3586
+ // gap composeRelation's own degrade fix addressed) still reaches the SAME
3587
+ // relation force a normally-parsed "what about inherits" would. Tier-2
3588
+ // playtest cycle 9, the Goal-line gap cycle 8 flagged: the answer content
3589
+ // was always correct here — only the cosmetic trailing "Goal (inferred): …"
3590
+ // line went missing, because it was computed once, this early, straight off
3591
+ // envelope.parsed and never revisited even when a LATER lane went on to
3592
+ // answer the turn through a completely different path. Reassigned at the
3593
+ // relation-force call site below (never overwritten with something worse:
3594
+ // only filled in from the SAME GOAL_BY_KIND table deduceGoalFromParsed
3595
+ // itself already uses for a normally-parsed relation query, so the two
3596
+ // never disagree on the cases where both would fire).
3597
+ let deduced = deduceGoalFromParsed(envelope?.parsed);
3575
3598
  note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
3576
3599
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
3577
3600
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
@@ -3800,6 +3823,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
3800
3823
  conceptPending = relation.pending;
3801
3824
  note(trace, "lane: THE RELATION CONCEPT FORCE — the touched word named a known, edge-bearing relation kind");
3802
3825
  note(trace, "source: relationForceAnswer over the loaded graph's own edges (not corpus)");
3826
+ // Goal-line gap fix (Tier-2 playtest cycle 9): this force just answered
3827
+ // the turn CORRECTLY over a query shape ask()'s own grammar may never
3828
+ // have parsed at all (a staccato relation-chain continuation whose
3829
+ // leading connective never itself parses, "and inherits?") — `deduced`
3830
+ // was computed way above, off envelope.parsed alone, and would
3831
+ // otherwise stay null forever here even though the answer is real.
3832
+ // relation.kind is the SAME GOAL_BY_KIND vocabulary a normally-parsed
3833
+ // relation query already deduces its goal line from, so this can never
3834
+ // disagree with the ordinary path on a case where both would fire.
3835
+ if (relation.kind && GOAL_BY_KIND[relation.kind]) {
3836
+ deduced = GOAL_BY_KIND[relation.kind];
3837
+ note(trace, `goal: ${deduced} (revised — the relation concept force answered where the raw parse never stood)`);
3838
+ }
3803
3839
  }
3804
3840
  }
3805
3841
  }