@polycode-projects/the-mechanical-code-talker 1.0.4 → 1.0.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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));
@@ -312,6 +320,7 @@ function parseComposite(text, nlp) {
312
320
  const w = splitWords(text);
313
321
  const lc = w.map((x) => x.toLowerCase());
314
322
  return parseExistence(w, lc)
323
+ || parseQualifierCheck(w, lc)
315
324
  || parseNegation(text, nlp, 0)
316
325
  || parseForwardNegation(w, lc, nlp)
317
326
  || parseTemporal(w, lc, nlp, 0)
@@ -647,6 +656,43 @@ function parseExistence(w, lc) {
647
656
  // different (relationship) question; leave it for the parsers below.
648
657
  }
649
658
 
659
+ /** QUALIFIER-CHECK: "is <term> [a/an] <qualifier> [<kind>]?", "is <term> not
660
+ * <qualifier> …" — a single-ENTITY Yes/No property check ("is Task.title
661
+ * public", "is it exported", "is that class abstract"), reusing the SAME
662
+ * closed QUALIFIERS vocabulary and qualHolds() evaluator the attributive/
663
+ * predicative-survey filters already fold over a SET ("public methods",
664
+ * "which methods are public") — this is the missing single-entity sibling
665
+ * (0.9.15 Tier-1 single-touch playtest: "is it a public attribute?", a
666
+ * natural follow-up to a concept-force touch, had no recognizer at all and
667
+ * hit the bare grammar wall — even "is Task.title public", a concretely
668
+ * NAMED entity with no anaphora involved, walled the same way). Scoped
669
+ * tight: a leading "is"/"are", then TERM tokens up to the FIRST recognized
670
+ * qualifier word — a leading "the" and a trailing "a"/"an" article around
671
+ * the boundary are dropped, and a trailing decorative kind noun ("… public
672
+ * ATTRIBUTE") is simply never consumed, never required to agree with the
673
+ * resolved entity's real class. Guarded off "is/are THERE …" (parseExistence
674
+ * above owns that shape) and off any text with no qualifier word at all, so
675
+ * it can never swallow a genuine relationship/existence question. The term
676
+ * is resolved at EVAL time (a pronoun binds through the standing contextId,
677
+ * exactly like every other object term), never here. */
678
+ function parseQualifierCheck(w, lc) {
679
+ if (lc[0] !== "is" && lc[0] !== "are") return null;
680
+ if (lc[1] === "there") return null; // parseExistence's own shape
681
+ let qualIdx = -1;
682
+ let negated = false;
683
+ for (let i = 1; i < lc.length; i += 1) {
684
+ if (QUALIFIERS[lc[i]]) { qualIdx = i; negated = lc[i - 1] === "not"; break; }
685
+ }
686
+ if (qualIdx < 0) return null; // no qualifier word at all → not this shape
687
+ let termStart = 1;
688
+ if (lc[termStart] === "the") termStart += 1;
689
+ let termEnd = negated ? qualIdx - 1 : qualIdx;
690
+ if (termEnd > termStart && (lc[termEnd - 1] === "a" || lc[termEnd - 1] === "an")) termEnd -= 1;
691
+ const term = termEnd > termStart ? w.slice(termStart, termEnd).join(" ").trim() : "";
692
+ if (!term) return { node: "miss", reason: `"is/are <qualifier>" needs a named thing to check first` };
693
+ return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
694
+ }
695
+
650
696
  /** Trailing "and that's the whole question" filler an aggregate/list tail can carry
651
697
  * ("how many classes are there", "list functions in total", "which classes exist in
652
698
  * the index") — a count/list over a bare kind is frequently phrased with such a tail,
@@ -930,7 +976,8 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
930
976
  const bw = branches[b];
931
977
  const blc = bw.map((x) => x.toLowerCase());
932
978
  const op = b === 0 ? "intersection" : ops[b - 1];
933
- if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
979
+ const qc = dropLeadCopula(bw, blc);
980
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
934
981
  if (blc[0] === "of" || blc[0] === "in") {
935
982
  atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
936
983
  continue;
@@ -1005,9 +1052,20 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1005
1052
  if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
1006
1053
  const membershipLed = predLc[0] === "of" || predLc[0] === "in";
1007
1054
  const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
1055
+ // A boolean branch whose OWN content — past an optional leading copula ("and ARE
1056
+ // untested") — collapses to qualifier words alone is the compositional shape too
1057
+ // ("functions that call X and are untested": verb clause AND qualifier, same
1058
+ // subject). Probe with the same splitBoolean+QUALIFIERS fold the atoms loop below
1059
+ // uses, so a bare verb+verb boolean chain with NO qualifier signal anywhere
1060
+ // ("which classes extends Base and couples to logging") still has no marker here
1061
+ // and correctly stays on the legacy ambiguous-parse path, untouched.
1062
+ const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
1063
+ const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
1064
+ return blc.length && blc.every((x) => QUALIFIERS[x]);
1065
+ });
1008
1066
  // marker gate — the crux of backward-compat: without one of these, this is not a
1009
1067
  // compositional query and we must NOT hijack it from the existing parser.
1010
- if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
1068
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed)) return null;
1011
1069
 
1012
1070
  // empty predicate → a bare qualified class ("public methods")
1013
1071
  if (!predWords.length) {
@@ -1026,7 +1084,8 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1026
1084
  const bw = branches[b];
1027
1085
  const blc = bw.map((x) => x.toLowerCase());
1028
1086
  const op = b === 0 ? "seed" : ops[b - 1];
1029
- if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
1087
+ const qc = dropLeadCopula(bw, blc);
1088
+ if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
1030
1089
  if (blc[0] === "of" || blc[0] === "in") {
1031
1090
  atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
1032
1091
  continue;
@@ -1577,11 +1636,29 @@ function evalExists(graph, ast) {
1577
1636
  return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
1578
1637
  }
1579
1638
 
1639
+ /** QUALIFIER-CHECK eval — resolve the term (a context pronoun binds through
1640
+ * contextId, exactly like resolveTermOrContext's every other caller), then
1641
+ * read the SAME qualHolds() predicate the set-filter path already uses.
1642
+ * `holds` here means "the STATEMENT AS ASKED is true" (negation already
1643
+ * folded in), so the renderer can answer Yes/No directly off it without
1644
+ * re-deriving the negation. An unresolved term is an honest miss, never a
1645
+ * guess — no different from any other named-object lookup. */
1646
+ function evalQualCheck(graph, ast, opts) {
1647
+ const { term, qualifier, negated } = ast;
1648
+ const r = resolveTermOrContext(graph, term, opts.contextId);
1649
+ if (r.unresolvedPronoun) return { compositeKind: "qualCheck", qualCheckMiss: "pronoun", term, matches: [] };
1650
+ if (!r.match) return { compositeKind: "qualCheck", qualCheckMiss: "unresolved", term, matches: [] };
1651
+ const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
1652
+ const holds = negated ? !rawHolds : rawHolds;
1653
+ return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
1654
+ }
1655
+
1580
1656
  /** Compile any compositional AST to a result object traverse() returns for the
1581
1657
  * simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
1582
1658
  export function evalComposite(graph, ast, opts = {}) {
1583
1659
  if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
1584
1660
  if (ast.node === "exists") return evalExists(graph, ast);
1661
+ if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
1585
1662
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
1586
1663
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1587
1664
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
@@ -1655,6 +1732,27 @@ function renderComposite(parsed, result) {
1655
1732
  }
1656
1733
  return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
1657
1734
  }
1735
+ // qualCheck: "is <term> [a/an] <qualifier> […]" — the single-entity sibling of
1736
+ // "is there a/an <kind> …" above: a direct Yes/No over one already-named/-
1737
+ // focused individual, never a set listing. `result.holds` already has the
1738
+ // negation folded in (evalQualCheck), so the renderer states the actual truth
1739
+ // plainly — "No — X is tested." for "is X not tested" when X IS tested, never
1740
+ // an echo of the (now-false) question's own wording.
1741
+ if (result.compositeKind === "qualCheck") {
1742
+ if (result.qualCheckMiss === "pronoun") {
1743
+ return { content: `"${result.term}" needs a selected node to refer to — click a node first, or name it directly.`, miss: true, ambiguous: false };
1744
+ }
1745
+ if (result.qualCheckMiss === "unresolved") {
1746
+ return { content: `couldn't find "${result.term}" in the index to check.`, miss: true, ambiguous: false };
1747
+ }
1748
+ const label = result.subject.label;
1749
+ const truePhrase = result.negated ? `not ${result.qualifier}` : result.qualifier;
1750
+ const falsePhrase = result.negated ? result.qualifier : `not ${result.qualifier}`;
1751
+ return {
1752
+ content: `${result.holds ? "Yes" : "No"} — ${label} is ${result.holds ? truePhrase : falsePhrase}.`,
1753
+ miss: false, ambiguous: false, matches: result.matches,
1754
+ };
1755
+ }
1658
1756
  if (result.compositeKind === "count") {
1659
1757
  const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
1660
1758
  return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
@@ -1861,15 +1959,66 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1861
1959
  }
1862
1960
  }
1863
1961
  } else {
1864
- const termComps = componentSet(t);
1962
+ // Root-cause fix (Tier-2 playtest cycle 9, targeted substring-match sweep):
1963
+ // this raw containment check has no minimum-length floor, so a short
1964
+ // closed-vocabulary word is a near-certain ACCIDENTAL substring of SOME
1965
+ // real label — confirmed empirically against the shipped mini-webapp
1966
+ // fixture: "so"->sendJson, "or"->Store, "a"->Task, "is"->listTasks
1967
+ // (ambiguous), "in"->Logger.info, "on"->sendJson, "at"->createApp,
1968
+ // "to"->Store, all via this exact branch. Cycle 8 patched three of these
1969
+ // ("and"/"also"/"so"/"then"/"now" and bare "it") one word at a time at
1970
+ // individual chat.mjs CALL SITES (STACCATO_LEAKED_CONNECTIVES, the
1971
+ // pronoun-reuse guard) — necessary there because those bugs are about
1972
+ // FOCUS bookkeeping, not resolution per se, but leaving resolveObject
1973
+ // itself unguarded meant every OTHER caller (existence checks, expectedClass
1974
+ // lookups, etc.) stayed exposed to the same trap for every not-yet-hit
1975
+ // short word. Gating the containment check at the same floor tier 5's own
1976
+ // fuzzy pass already uses (`tLc.length >= 4` below) closes it at the
1977
+ // source. A whole-token component match (just below) is unaffected by this
1978
+ // floor — it requires an EXACT path/identifier segment equality, never a
1979
+ // raw substring, so a genuinely short real identifier ("db", "fs") is
1980
+ // still resolvable by literally matching a whole segment.
1981
+ const termComps = [...componentSet(t)];
1982
+ // A SLASHED term's final path segment, extension stripped ("src/nope.mjs"
1983
+ // -> "nope", "cover app/lib/b.mjs" -> "b") — the semantically load-bearing
1984
+ // FILENAME STEM, as opposed to a directory segment or a leaked verb noise
1985
+ // word. Isolated from the actual whitespace-delimited PATH TOKEN (not the
1986
+ // raw multi-word string as a whole) — "app/lib/f.mjs but untested" (a
1987
+ // trailing-noise leak, distinct from the leading-verb-noise shape above)
1988
+ // has no extension at the end of the whole string, so splitting the whole
1989
+ // string would strand "f.mjs but untested" as a bogus non-matching "stem";
1990
+ // finding the one token that itself contains "/" keeps the path term
1991
+ // intact regardless of what noise surrounds it on either side. Only
1992
+ // slash-shaped terms compute this; a bare identifier/multi-word query has
1993
+ // no path structure to anchor on, so it's null and the gate below is a
1994
+ // no-op for those (unaffected — original ANY-overlap behavior).
1995
+ const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
1996
+ const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
1865
1997
  for (const m of pool) {
1866
1998
  const label = String(m.label || "").toLowerCase();
1867
- if (label.includes(tLc)) {
1999
+ if (tLc.length >= 4 && label.includes(tLc)) {
1868
2000
  scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
1869
2001
  continue;
1870
2002
  }
1871
- const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
1872
- if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
2003
+ const labelComps = componentSet(m.label);
2004
+ const overlap = termComps.filter((c) => labelComps.has(c)).length;
2005
+ // For a slashed term, the stem MUST be among the label's own components
2006
+ // — an ANY-overlap match otherwise lets a NONEXISTENT path land on a
2007
+ // real module that merely shares its generic directory/extension
2008
+ // segments ("src", "mjs") with every other module in the pool (found
2009
+ // via the existence recognizer's "is there a class in src/nope.mjs"
2010
+ // scope clause, Tier-2 playtest cycle 9: it ambiguously "matched"
2011
+ // src/core/model.mjs even though no such module exists — the same
2012
+ // accidental-match disease as the short-word substring bug just above,
2013
+ // just triggered by GENERIC components instead of raw containment).
2014
+ // A leaked leading VERB ("cover app/lib/b.mjs", "touch app/lib/f.mjs" —
2015
+ // router-interface.test.mjs's own frozen contract) still resolves: the
2016
+ // stem ("b"/"f") is a real component of the target label even though
2017
+ // "cover"/"touch" themselves never overlap anything, so overlap>0 and
2018
+ // the stem gate both hold.
2019
+ if (overlap > 0 && (!slashStem || labelComps.has(slashStem))) {
2020
+ scored.push({ ind: m, score: overlap * 10 });
2021
+ }
1873
2022
  }
1874
2023
  }
1875
2024
  scored.sort((a, b) => b.score - a.score);
@@ -1890,12 +2039,24 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1890
2039
  // browser `lookupByProseTokens` is an undeclared identifier — without the guard,
1891
2040
  // ANY term reaching this tier threw a ReferenceError in the page instead of
1892
2041
  // rendering the honest miss (a real, previously-untested viewer bug).
1893
- // DOTTED terms never consult prose: "res.json" word-matches test/res.json.js's
1894
- // own path tokens, which is the tier-3 phantom-path bug reappearing through a
1895
- // side door — a dotted term names an identifier, and identifiers resolve by
1896
- // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
2042
+ // DOTTED or SLASHED terms never consult prose: "res.json" word-matches
2043
+ // test/res.json.js's own path tokens, which is the tier-3 phantom-path bug
2044
+ // reappearing through a side door — a dotted term names an identifier, and
2045
+ // identifiers resolve by label (tiers above) or the bounded fuzzy pass
2046
+ // below, or they honestly miss. The SAME side door was open for SLASHED
2047
+ // path terms too (Tier-2 playtest cycle 9, existence-recognizer follow-up):
2048
+ // "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
2049
+ // (the AND-across-components fix just above), but fell through to THIS
2050
+ // prose tier and ambiguously "matched" real modules anyway, because
2051
+ // lookupByProseTokens scores by ANY-token overlap (sum-scored, by design,
2052
+ // for genuine prose ranking) and "src"/"mjs" are near-universal path/
2053
+ // extension tokens shared by every module in the pool — the identical
2054
+ // accidental-match disease, just one tier further down. A slash-shaped term
2055
+ // names a literal path exactly like a dotted term names a literal symbol;
2056
+ // neither is prose, so neither should ever reach the prose fallback.
2057
+ const pathShaped = dotted || tLc.includes("/");
1897
2058
  let proseResult = null;
1898
- const proseHits = !dotted && typeof lookupByProseTokens === "function"
2059
+ const proseHits = !pathShaped && typeof lookupByProseTokens === "function"
1899
2060
  ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass)
1900
2061
  : [];
1901
2062
  if (proseHits.length) {
@@ -2910,10 +3071,22 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
2910
3071
  // own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
2911
3072
  // asked term and lets a bare marker slide into its place ("where is [X] defined" →
2912
3073
  // "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
2913
- const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
2914
- const lc = w.toLowerCase();
2915
- return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
2916
- });
3074
+ // A bare CONTEXT PRONOUN ("it"/"this"/"that"/"here"/…) is the one exception: it IS
3075
+ // the real, deliberate object here — it resolves through contextId, not through
3076
+ // vocabulary the grammar "already owns" as scaffolding — so it must count as a real
3077
+ // term rather than being mistaken for a dropped-into-place marker. Without this, a
3078
+ // relaxed candidate whose object survived layer 2 as a lone pronoun ("what else is
3079
+ // in that class" → drop "class"/"else" → "what does that contain") was rejected as
3080
+ // if it named nothing at all, even though the pronoun resolves to a real, answerable
3081
+ // focus (0.9.15 Tier-1 single-touch playtest).
3082
+ const hasRealTerm = (s) => {
3083
+ const whole = String(s || "").trim().toLowerCase();
3084
+ if (CONTEXT_PRONOUNS.includes(whole)) return true;
3085
+ return splitWords(whole).some((w) => {
3086
+ const lc = w.toLowerCase();
3087
+ return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
3088
+ });
3089
+ };
2917
3090
  // Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
2918
3091
  // AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
2919
3092
  // win only by turning a miss into an answer, never a differently-worded miss) — and
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
  }
@@ -44,6 +44,33 @@ const correctionRe = (table) => new RegExp(
44
44
  const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
45
  const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
46
 
47
+ /** "that class"/"this module"/"that function" (a context pronoun immediately
48
+ * followed by the SINGULAR kind noun it's already standing in for) -> the bare
49
+ * pronoun alone (0.9.15 Tier-1 single-touch playtest). "which class contains
50
+ * Task.complete" answers by NAMING the class ("… there is Task"); a curious
51
+ * user's very next turn naturally says "what is in that class", not the bare
52
+ * "what is in that" the grammar already understands. Left unstripped, the two
53
+ * parse strategies disagreed on the SPAN (grammar kept "that class" as one
54
+ * literal 2-word object term; keyword-spot split off "class" as an entityType
55
+ * keyword, leaving bare "that" as the object) — same PARSE, different shape,
56
+ * so merge.mjs's honest {ambiguousParse} tie fired even though a human reads
57
+ * this as one unambiguous sentence. Folding the kind noun away BEFORE either
58
+ * strategy runs leaves exactly one reading: CONTEXT_PRONOUNS' own existing
59
+ * focus-resolution (ask.mjs's resolveTermOrContext) then takes it from there,
60
+ * unchanged — this frame only removes the strategy disagreement, it does not
61
+ * touch how the pronoun itself resolves. Singular kind nouns only ("that
62
+ * classes" isn't grammatical, so plurals are never a real anaphora and are
63
+ * left alone); "one" is excluded ("that one" is already its own literal
64
+ * CONTEXT_PRONOUNS entry). */
65
+ const KIND_NOUN_ANAPHORA_RE = /\b(this|that)\s+(class|module|function|method|attribute|variable|file|commit)\b/gi;
66
+
67
+ // every relation verb phrase VERB_TO_KIND knows, as one alternation (longest-first
68
+ // so a multi-word verb like "inherit from" wins over its own leading word "inherit"
69
+ // appearing elsewhere) — feeds the DOES-X-VERB-ANYTHING-ELSE frame below, which needs
70
+ // to recognize "does <subject> <ANY closed relation verb> anything/something [else]"
71
+ // without hardcoding its own parallel verb list.
72
+ const VERB_ALTERNATION = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
73
+
47
74
  /** Just the curated MISSPELLINGS correction (ask-vocab.mjs), standalone — for a
48
75
  * caller that needs typo-tolerant ANCHOR-WORD matching (a closed regex shape
49
76
  * keyed on a literal "what"/"which"/"where" etc.) without running the rest of
@@ -158,6 +185,25 @@ const EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
158
185
  * module" -> "describe store module"). */
159
186
  const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
160
187
 
188
+ /** Leading STACCATO connective before an ALREADY well-formed question ("and
189
+ * what imports it", "so does it import anything else", "also where is X
190
+ * defined") -> the question alone (0.9.15 Tier-1 single-touch playtest). A
191
+ * rapid-fire drill-down naturally opens its next turn with a bare discourse
192
+ * connective (chat.mjs's own STACCATO_LEAKED_CONNECTIVES set: and/also/so/
193
+ * then/now, here joined by "but" for the same family); when what follows is
194
+ * ALREADY an interrogative lead or an auxiliary-question opener, the
195
+ * connective carries no query content of its own — conversational
196
+ * scaffolding, same species as the greeting/subordination preambles. Gated
197
+ * on the REMAINDER starting a real question, so a genuine mid-clause boolean
198
+ * composition ("classes that inherit from Base and are tested") is never
199
+ * touched — that "and" never sits at position 0 to begin with. Without this,
200
+ * "and what imports it" parsed as an "ask"-shape question with "and" itself
201
+ * MISREAD as the subject term — a miss the relaxation cascade can't rescue,
202
+ * because "and" is protected CONTENT_VOCAB (a boolean connective elsewhere)
203
+ * and the noise-strip layer never drops content vocab. */
204
+ const LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
205
+ const QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
206
+
161
207
  /** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
162
208
  * repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
163
209
  * please") peel fully. Pure and idempotent; unmatched text passes through. */
@@ -180,6 +226,11 @@ export function applyPreambleFrames(text) {
180
226
  q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
181
227
  }
182
228
  }
229
+ m = q.match(LEADING_CONNECTIVE_RE);
230
+ if (m) {
231
+ const rest = m[1].trim();
232
+ if (INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest)) q = rest;
233
+ }
183
234
  if (q === before) break;
184
235
  }
185
236
  return q;
@@ -343,6 +394,7 @@ export function normalizeQuery(text) {
343
394
  q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
344
395
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
345
396
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
397
+ q = q.replace(KIND_NOUN_ANAPHORA_RE, (_, pron) => pron);
346
398
  q = q.replace(G_DROP, "$1ing");
347
399
  // closed preamble frames (greeting lead-in, modal wrapper, show/give-me
348
400
  // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
@@ -414,6 +466,23 @@ export const PHRASING_FRAMES = Object.freeze([
414
466
  { re: /^(?:the\s+)?(?:members?|methods?|attributes?|contents)\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
415
467
  // "what's in X" / "what is in X" (contraction already expanded; sha handled above)
416
468
  { re: /^what\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
469
+ // "what else is in X" (0.9.15 Tier-1 single-touch playtest) — the natural
470
+ // "besides what I already know" drill-down after a members-of-class answer.
471
+ // Distinct from the "what else does X <verb>" family (which the compositional
472
+ // grammar already tolerates, dropping "else" as noise on its own): the "is
473
+ // in" idiom is NOT a compositional marker, so parseComposite never sees it and
474
+ // "what else is in X" fell through to the strategies with NO candidate at all
475
+ // (neither recognizes the bare "is in" idiom once "else" sits in front of it).
476
+ // The only rescue was the relaxation cascade's drop-unmatched layer — but that
477
+ // layer refuses to accept a relaxed reading that still renders an honest EMPTY
478
+ // (by design: relaxation must turn a miss into a real answer, never into
479
+ // another kind of miss), so a genuinely empty class ("what else is in
480
+ // Task.complete" — a method, no members) bottomed out at the bare grammar
481
+ // wall instead of the specific "no contains edges" receipt. Routing this
482
+ // frame onto the SAME direct "what does X contain" path the plain "what is
483
+ // in X" frame above already uses sidesteps the cascade's conservative gate
484
+ // entirely, so a real empty is reported honestly instead of walled.
485
+ { re: /^what\s+else\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
417
486
 
418
487
  // WHERE-DEFINED → "where is X defined". PAST TENSE ONLY ("what defined X", "what
419
488
  // declared X"): the PRESENT "what defines X" already parses as a reverse-defines
@@ -512,6 +581,26 @@ export const PHRASING_FRAMES = Object.freeze([
512
581
  // survey the bare "what is untested" frame lands on. Closed to the tests/coverage
513
582
  // object, so it can't swallow a general "what needs X".
514
583
  { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
584
+
585
+ // DOES-X-VERB-ANYTHING-ELSE → the plain forward "what does X <verb>" listing
586
+ // (0.9.15 Tier-1 single-touch playtest). A very natural drill-down follow-up
587
+ // after a relation answer — "does listTasks call anything else", "does
588
+ // src/handlers/tasks.mjs import something else" — used to dead-end: "anything"/
589
+ // "something" [else] is a placeholder standing in for "the rest of the list",
590
+ // not a real object term, but the two parse strategies disagreed on the SPAN
591
+ // (grammar kept "anything else" whole as the object, keyword-spot dropped
592
+ // "anything" and kept only "else"), landing on the {ambiguousParse} surface —
593
+ // two nonsense readings offered as if one might be right. "what does X <verb>"
594
+ // is the exact working canonical shape (see the MEMBERS-of-class frames above),
595
+ // so rewriting the whole closed pattern onto it sidesteps the disagreement
596
+ // instead of teaching either strategy's tokenizer to special-case "else".
597
+ // Anchored to the closed VERB_TO_KIND vocabulary so it can never swallow a
598
+ // genuine named object that happens to start with "any"/"some" (only the bare
599
+ // placeholder nouns "anything"/"something", optionally trailed by "else", match).
600
+ {
601
+ re: new RegExp(`^(?:do|does)\\s+(.+?)\\s+(${VERB_ALTERNATION})\\s+(?:anything|something)(?:\\s+else)?\\??$`, "i"),
602
+ to: (m) => `what does ${m[1]} ${m[2]}`,
603
+ },
515
604
  ]);
516
605
 
517
606
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins