@polycode-projects/the-mechanical-code-talker 0.8.0 → 0.8.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.
package/ROADMAP.md CHANGED
@@ -14,34 +14,41 @@ the file has been deleted.
14
14
 
15
15
  ## Where we are now (2026-07-06)
16
16
 
17
- **Built: v0.8.0 — Phase 11 landed (Phases 0–10 shipped in 0.7.1).** All five Phase-11 tracks are
18
- built, merged, and measured; `npm test` green (**869**). Push/publish held for the operator. Highlights:
19
-
20
- - **Track 1 — chat levers (measured).** Pronoun/focus binding, discourse-count anaphora, and C1
21
- temporal-over-relative composition all landed. `CHATBENCH_0.8.0`: tier-1 spine **331→333**, all three
22
- lever families moved their cells (the B1-temporal control did not spill); judged fixed tag
23
- `multi-turn-focus` **1.433→1.9** (the pronoun lever's echo). The judged pooled mean (1.44) is **not
24
- case-comparable** to 0.7.1's 1.488 — the graded draw re-samples 10% of cells each run, so §1 compares
25
- cell-level, not the scalar; the deterministic tier-1 spine is the load-bearing PASS.
26
- - **Track 2 the router (designed DEMONSTRATED).** The Anthropic-compatible `/v1/messages` shim
27
- (`serve` mode), the Stage-0 capability registry (STRIPS/PDDL operators, closed-world default-deny),
28
- and the resolver (S1) + guardrail (S4) + planner (S3, pure-JS POP/HTN + Steel & Ho monitor). Measured:
29
- `AGENTBENCH_0.8.0` (shim-transport floor, 46% completion) `AGENTBENCH_0.8.0_001` (the real router):
30
- **96% completion at 0% hallucination on every rung**, clearing the closed-world ladder to **C1**. The
31
- honest gate the phase was staked on is **met on its hallucination axis (0%)**. **Caveat (stated in the
32
- artifact):** AGENTBENCH grades the correct **call-plan + proof**, *not* the executed composed result,
33
- and B1/B2/C1 are thin rungs (2–3 cases); the C2 case is honestly **refused** (Stage 5, unbuilt).
34
- - **Track 3 bedrock-meter surface.** `../bedrock-meter` gained a cost-ascending router ladder with the
35
- **tmct `$0` rung at rank 0 below nova-micro**; an end-to-end test meters an in-envelope request at
36
- **£0** and escalates out-of-envelope to nova-micro (that repo: 73 green, unpushed).
37
- - **Track 4 — playtest.** Three frozen dialogue-flow transcripts (`test/chatflow-{coverage,history,
38
- architecture}.test.mjs`) from routing dead-ends off the grammar wall.
39
- - **Track 5 research.** `docs/references/planning/` gained Stage-2 intent-frames + Stage-5
40
- goal-reasoner design notes + a BDI/Goal-Driven-Autonomy reference entry.
41
-
42
- **Next:** the C2 goal-reasoner (Stage 5) and Stage-2 imperative intent frames are the designed-but-
43
- unbuilt edge; the tier-4/tier-5 items in Phase LATER remain the long tail. See Phase 11 below and
44
- `HANDOVER.md` for the open follow-ups.
17
+ **Built: v0.8.1 — Phase 11 deepened (0.8.0 shipped + published; 0.8.1 push held for the operator).**
18
+ `npm test` green (**916**). 0.8.1 hardened the router with real *reasoning* measurement and closed the
19
+ frontier stages, all under the same demonstrated-vs-designed honesty discipline:
20
+
21
+ - **AGENTBENCH now grades the executed composed RESULT, not just the call-plan** (retires the headline
22
+ 0.8.0 caveat). Under result-grading the resolver is **97% plan / 91% result / 0% hallucination**; the
23
+ honest gap lands exactly where reasoning is hard (C1, C2).
24
+ - **Stage 5 — the C2 goal-reasoner** (BDI + Goal-Driven Autonomy) genuinely lifts result-completion.
25
+ The honest, **like-for-like** figure is the *driver swap on the identical 39-case ladder*
26
+ (`AGENTBENCH_0.8.1_001`): resolver **85% goal-reasoner 95% result** (+10pp, purely the driver, 0%
27
+ hallucination). C2 is **cleared for one declared coverage-invariant goal-rule** — real and
28
+ phrasing-robust (validated by held-out phrasings graded blind, no request-string literals), but
29
+ **thinly sampled, not rule-general**; the open-world case is honestly **refused**. *(NB: the
30
+ cross-release "0→83% C2" reads on a grown 6-case C2 ladder whose 4 new cases exercise the
31
+ goal-reasoner not a like-for-like rate; the same-basket win is one case fixed, 0→50% original C2.)*
32
+ - **Stage 2 imperative intent frames + ACE reach** (`AGENTBENCH_0.8.1_002`, goal driver): **100% plan
33
+ / 95% result / 0% hallucination**. `tmct_calls` is now **genuinely NL-reachable** via a distinct
34
+ edge-dump frame (not by un-tagging bidirectional conformance enforces it; `NOT_NL_REACHABLE` is now
35
+ `{}`). ACE is wired **async** into the interpret pipeline so the synchronous CHATBENCH parse spine is
36
+ **byte-identical** ACE is inert in the shipped product path (`interpret()` is called nowhere in
37
+ chat/ask/server/bin), reach without regression.
38
+ - **Chat surface (CHATBENCH_0.8.1):** quick wins (singular "what is a test", friendly commit-author
39
+ refs, "No tests cover X" honest-empty, the chatbench discourse-count flake root-caused + scrubbed) +
40
+ two playtests (coverage-survey + authorship routing; **6 frozen `chatflow-*` transcripts** total).
41
+ Joint deterministic CHATBENCH (both new frame tables together): **no tier-1 regression vs 0.7.1.**
42
+ Judged tags touched by the text changes were re-judged (not blanket-reused) see `CHATBENCH_0.8.1`.
43
+
44
+ **Shipped earlier in v0.8.0 (published):** all five Phase-11 tracks — the `/v1/messages` shim + Stage-0
45
+ registry + resolver/guardrail/planner (`AGENTBENCH_0.8.0_001`: 96% *plan* completion, 0% hallucination,
46
+ closed-world C1); the three chat levers (`CHATBENCH_0.8.0`: tier-1 331→333); the `../bedrock-meter` `$0`
47
+ rung (rank 0 below nova-micro, £0 metered e2e); the playtest; and the Stage-2/Stage-5 research notes.
48
+
49
+ **Next:** grow the thin C2/coverage sampling to a second goal-rule; author→commit querying (a dead-end
50
+ the friendly-commit-ref quick win opened); AGENTBENCH ladder depth; the tier-4/tier-5 Phase LATER long
51
+ tail. See Phase 11 below and `HANDOVER.md` for the full open-follow-up list.
45
52
 
46
53
  ## The umbrella product definition (item 1)
47
54
 
@@ -628,7 +635,7 @@ you toward precision" promise on the conversational surface.
628
635
  with new graded cells for the miss / empty-graph / concept-touch surfaces so these become
629
636
  regression-protected levers, not one-off polish.
630
637
 
631
- ## Phase 11 — The capability router & the agentic bench (0.8.0 LANDED)
638
+ ## Phase 11 — The capability router & the agentic bench (0.8.0 shipped · 0.8.1 deepened)
632
639
 
633
640
  *(Operator-directed 2026-07-06; built the same day across five concurrent tracks.)* tmct as a **deterministic, no-LLM
634
641
  tool router** behind an Anthropic-compatible API — the workstream specified in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.8.0",
3
+ "version": "0.8.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.",
package/src/ask.mjs CHANGED
@@ -1646,6 +1646,17 @@ function symbolLabelOf(ind) {
1646
1646
  return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
1647
1647
  }
1648
1648
 
1649
+ /** A FRIENDLY commit reference for a "who touched X" list — the raw sha alone reads as
1650
+ * noise, so when the Commit individual carries an author (mgx:commitAuthor → key
1651
+ * "author") name them beside it. The label is already the graph's short ref (the
1652
+ * builder stores sha.slice(0,12)), so it is used verbatim. Degrades gracefully: a
1653
+ * commit with no recorded author renders the sha alone, exactly as before. */
1654
+ function commitRefOf(ind) {
1655
+ const sha = String(ind.label || ind.id || "");
1656
+ const author = (ind.attributes || []).find((a) => a.key === "author")?.value;
1657
+ return author ? `${sha} (${author})` : sha;
1658
+ }
1659
+
1649
1660
  function listJoin(syms) {
1650
1661
  return syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
1651
1662
  }
@@ -1834,6 +1845,20 @@ function renderCore(parsed, result) {
1834
1845
  miss: true, ambiguous: false,
1835
1846
  };
1836
1847
  }
1848
+ // "what tests cover X" / "what tests X" — the tests themselves are the search
1849
+ // target (no explicit entity keyword → entityType null), and "tests" reads as a
1850
+ // verb phrase, so the generic "No <modules> found whose module directly tests <obj>"
1851
+ // template garbles: it mislabels the searched kind as "modules" and lets the leaked
1852
+ // "cover " verb ride into the object ("…directly tests cover X"). Render the honest
1853
+ // empty as the natural "No tests cover X." The frozen entity-keyword form ("which
1854
+ // modules test X", entityType="Module") keeps its pinned wording below.
1855
+ if (parsed.kind === "tests" && !parsed.entityType) {
1856
+ const obj = String(parsed.object || "").replace(/^cover(?:s|ing)?\s+/i, "").trim();
1857
+ return {
1858
+ content: `No tests cover ${obj}. (traversal: ${result.traversal || "no traversal resolved"})`,
1859
+ miss: true, ambiguous: false,
1860
+ };
1861
+ }
1837
1862
  // NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
1838
1863
  // the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
1839
1864
  // (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
@@ -1853,7 +1878,10 @@ function renderCore(parsed, result) {
1853
1878
  // Commit list ("which commits touched X") has no containing module to group by, so
1854
1879
  // anything that is not a fine entity takes the flat join.
1855
1880
  if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
1856
- const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
1881
+ // A reverse "who touched X" resolves to Commit individuals — render friendly refs
1882
+ // (short sha + author) instead of the raw stored sha; every other flat list (module
1883
+ // labels, etc.) keeps its own label verbatim.
1884
+ const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.class === "Commit" ? commitRefOf(m) : m.label);
1857
1885
  const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
1858
1886
  return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
1859
1887
  }
package/src/chat.mjs CHANGED
@@ -1332,6 +1332,19 @@ function relationTermOf(query, envelope) {
1332
1332
  if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
1333
1333
  // "what is calling", "what is importing" (bare gerund, no object)
1334
1334
  if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
1335
+ // THE SINGULAR META FORM — "what is a test" / "what is an import". The whole meta
1336
+ // shape used to be excluded here to keep the frozen am-meta-imports ambiguity case
1337
+ // ("what does imports mean") out; but that case is a DIFFERENT shape (ambiguousParse
1338
+ // → envelope.parsed is null), and a relation word whose SINGULAR reads as a real
1339
+ // graph-schema class/predicate ("what is a contains"/"cochange") answers non-miss
1340
+ // from the ordinary meta path. So admit the article meta form ONLY when the ordinary
1341
+ // path MISSED on a plain definitional parse (envelope.miss on a shape:"meta" object):
1342
+ // an unambiguous relation word like "test" — no schema reading, no ambiguity — then
1343
+ // reaches the relation-concept force exactly as its plural "what are the tests" does.
1344
+ // RELATION_TERM still gates the term downstream, so a non-relation miss is untouched.
1345
+ if (envelope?.miss === true && envelope?.parsed?.shape === "meta" && envelope.parsed.object) {
1346
+ return envelope.parsed.object;
1347
+ }
1335
1348
  return null;
1336
1349
  }
1337
1350
 
@@ -136,6 +136,26 @@ export const PHRASING_FRAMES = Object.freeze([
136
136
  to: (m) => `${m[2].toLowerCase()} ${m[1].toLowerCase()}`,
137
137
  },
138
138
 
139
+ // BARE COVERAGE SURVEY (no entity kind) → the attributive "<qualifier> modules"
140
+ // the grammar already answers. Once "what is a test" opens the topic, a developer
141
+ // asks the survey the plainest way — "what is untested", "what's not tested",
142
+ // "what isn't covered", "what is covered" — with NO entity noun at all, so the
143
+ // predicative-qualifier frame above (which needs a KIND between what/which and
144
+ // are/is) can't catch it, and it fell through to a soft wall ("no module matching
145
+ // 'not'…" / the "I answer questions…" orientation). Default the surveyed kind to
146
+ // modules (the same set "which modules are not tested" / "untested modules" return)
147
+ // and fold the negation into the qualifier (not tested → untested, not covered →
148
+ // uncovered). Anchored with no object, so "what tests cover X" / "what is a test"
149
+ // never match here.
150
+ {
151
+ re: /^what\s+(?:is|are)\s+(not\s+)?(tested|untested|covered|uncovered)\??$/i,
152
+ to: (m) => {
153
+ const q = m[2].toLowerCase();
154
+ const flipped = m[1] ? (q === "tested" ? "untested" : q === "covered" ? "uncovered" : q) : q;
155
+ return `${flipped} modules`;
156
+ },
157
+ },
158
+
139
159
  // CO-CHANGE → the "co-changes with" canonical the RELATIONS table answers. The
140
160
  // cochange verb synonyms (ask-vocab.mjs) include "co-changes with" / "moves
141
161
  // together with" / "tends to change together with", but NOT the plainest form a
@@ -145,6 +165,23 @@ export const PHRASING_FRAMES = Object.freeze([
145
165
  // onto "what co-changes with X" routes them to the working change-coupling query.
146
166
  { re: /^what\s+does\s+(.+?)\s+changes?\s+together\s+with\??$/i, to: (m) => `what co-changes with ${m[1]}` },
147
167
  { re: /^what\s+changes?\s+together\s+with\s+(.+?)\??$/i, to: (m) => `what co-changes with ${m[1]}` },
168
+
169
+ // AUTHORSHIP → the "who touched X" churn query. "who touched X" now names the
170
+ // commit author beside the sha (the 0.8.1 commit-ref quick-win), which invites the
171
+ // synonyms a developer reaches for next — "who wrote X", "who authored X", "who is
172
+ // the author of X" — and every one of them hit the grammar wall. tmct has no
173
+ // separate authorship edge; "touched" IS the authorship signal (the churn commits
174
+ // carry the author), so these are true synonyms of "who touched X", not a new
175
+ // capability. Anaphora rides through untouched ("who wrote it" → "who touched it").
176
+ { re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
177
+ { re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
178
+
179
+ // NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
180
+ // testing" is the plainest way to ask which modules are uncovered, and it hit the
181
+ // grammar wall ("no module matching 'needs'…"). Route it onto the same attributive
182
+ // survey the bare "what is untested" frame lands on. Closed to the tests/coverage
183
+ // object, so it can't swallow a general "what needs X".
184
+ { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
148
185
  ]);
149
186
 
150
187
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins
@@ -27,6 +27,13 @@ import { normalizeQuery, applyNegationFrames, applyPhrasingFrames } from "./norm
27
27
  import { grammarStrategy } from "./strategies/grammar.mjs";
28
28
  import { keywordSpotStrategy } from "./strategies/keywords.mjs";
29
29
  import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
30
+ // Optional Node-flavored ACE strategy — same viewer-bundle boundary as the
31
+ // ask-nlp adapter below: the ACE grammar reaches grammar/ace.mjs -> lexicon.mjs,
32
+ // which reads its committed JSON via Node fs, so an inlining viewer bundle strips
33
+ // this import; the `typeof` guard where STRATEGIES is built then degrades to an
34
+ // ace-less registry instead of throwing over an undeclared identifier. (ACE is
35
+ // async-only anyway, so the sync parseQuery path the viewer uses never ran it.)
36
+ import { aceStrategy } from "./strategies/ace.mjs";
30
37
  import { mergeStrategyResults } from "./merge.mjs";
31
38
  // Optional Node-only wink adapter — same viewer-bundle boundary as ask.mjs: an
32
39
  // inlining bundle strips this import and the `typeof` read below degrades to
@@ -38,9 +45,17 @@ import { nlpAdapter } from "../ask-nlp.mjs";
38
45
  * byte-identical to the original two-way agree/disagree behavior); noise-strip
39
46
  * is the item-10 tolerant fallback (its own class; it only fires when the
40
47
  * anchored grammar missed the text as-given, so it can never displace an
41
- * existing template parse). interpret/strategies/ace.mjs (Phase 2) will
42
- * register here the same way. */
43
- export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy];
48
+ * existing template parse). interpret/strategies/ace.mjs (Phase 2 / Stage 2) is
49
+ * the ACE-OWL controlled-fragment grammar, registered here as an ADDITIVE, own-
50
+ * class ("ace-fact") strategy. It is ASYNC on purpose: runStrategiesSync (the
51
+ * parseQuery / CHATBENCH-facing path) SKIPS Promise-returning strategies, so ACE
52
+ * adds declarative-fragment reach to interpret() while leaving the sync spine
53
+ * byte-stable (see strategies/ace.mjs for the full rationale). The `typeof` guard
54
+ * mirrors the nlpAdapter degradation: a stripped ACE import (viewer bundle) leaves
55
+ * the identifier undeclared, so the registry is ace-less there instead of a crash. */
56
+ // eslint-disable-next-line no-undef
57
+ const OPTIONAL_STRATEGIES = typeof aceStrategy !== "undefined" ? [aceStrategy] : [];
58
+ export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy, ...OPTIONAL_STRATEGIES];
44
59
 
45
60
  /** The documented normalization pre-pass: whitespace-collapse + the §3.5
46
61
  * normalization pipeline + the closed rhetorical-frame rewrites, applied ONCE
@@ -0,0 +1,49 @@
1
+ // interpret/strategies/ace.mjs — the ACE-OWL controlled-fragment grammar wired
2
+ // into the interpretation pipeline as an ADDITIVE strategy (Stage 2, "ACE reach").
3
+ //
4
+ // The ACE engine (src/grammar/ace.mjs) has existed since Phase 2, but its pipeline
5
+ // ADAPTER was the "real and empty" seam the pipeline header names (interpret/
6
+ // pipeline.mjs). This file fills it. The contract is strictly ADD-ONLY:
7
+ //
8
+ // · Its own class, "ace-fact" — DISJOINT from the graph-query strategies, so a
9
+ // clean ACE parse is a distinct-class ALTERNATE ("if you mean X then …"), never
10
+ // a same-class competitor that could displace a graph-query winner.
11
+ // · It emits a candidate ONLY on a CLEAN parse (parseAce returns triples). A
12
+ // structural-fit-with-residue (empty triples) or a total miss returns null, so
13
+ // a query sentence that merely LOOKS relation-shaped ("which modules import X",
14
+ // whose ACE residue is the "which") contributes nothing — fitting the grammar
15
+ // is a strong signal; missing it is a FEATURE and the tolerant strategies win.
16
+ //
17
+ // WHY ASYNC — the byte-stability guarantee. ask.mjs's parseQuery (the CHATBENCH
18
+ // chat-facing path) runs strategies through runStrategiesSync, which — by the
19
+ // pipeline's documented contract — SKIPS any Promise-returning strategy ("an async
20
+ // strategy can only participate via interpret()"). Registering ACE async therefore
21
+ // makes the sync parseQuery path PROVABLY untouched (CHATBENCH neutral, byte-for-
22
+ // byte) while interpret() — the async pipeline — gains the declarative-fragment
23
+ // reach. The work parseAce does is synchronous; the async wrapper is deliberate,
24
+ // the mechanical seam that keeps the chat spine frozen. (grammar/ace.mjs itself is
25
+ // imported UNCHANGED — no chat-facing edit.)
26
+
27
+ import { parseAce } from "../../grammar/ace.mjs";
28
+
29
+ /** Adapter: a clean ACE parse -> one candidate in its own class; anything else
30
+ * (residue-only structural fit, or a hard miss) -> null. `via:"exact"` — a
31
+ * controlled-grammar fit is exact evidence, never an approximate rewrite. */
32
+ export function runAce(text) {
33
+ let parsed = null;
34
+ try { parsed = parseAce(text); } catch { return null; }
35
+ if (!parsed || !Array.isArray(parsed.triples) || parsed.triples.length === 0) return null;
36
+ return { strategyId: "ace", class: "ace-fact", candidates: [{ parsed, confidence: 0.85, via: "exact", note: `ACE ${parsed.pattern}` }] };
37
+ }
38
+
39
+ /** Pipeline registration (interpret/pipeline.mjs). ASYNC on purpose (see file
40
+ * header): it participates in interpret() but is SKIPPED by runStrategiesSync,
41
+ * so parseQuery — and the CHATBENCH spine it feeds — is byte-stable. */
42
+ export const aceStrategy = {
43
+ id: "ace",
44
+ class: "ace-fact",
45
+ // eslint-disable-next-line require-await
46
+ async run(text) {
47
+ return runAce(text);
48
+ },
49
+ };
@@ -0,0 +1,266 @@
1
+ // src/router/goal-reasoner.mjs — Stage 5 of the capability router
2
+ // (PLAN_CAPABILITY_ROUTER.md / STAGE_5_GOAL_REASONER.md): THE CLOSED-WORLD C2
3
+ // GOAL-REASONER. "Self-directed" is not magic — it is a canned, HARD-BOUNDED
4
+ // meta-loop (Rao & Georgeff BDI × Aha/Molineaux/Cox GDA × continual planning):
5
+ //
6
+ // deduce current goals (step 1 — the only genuinely new part)
7
+ // → plan for each goal (step 2 — C1: the Stage-3 planner/resolver)
8
+ // → arbitrate the first steps (step 3a — keystone, threat-aware)
9
+ // → PERSIST the committed intention (step 3b — BDI drop conditions)
10
+ // → execute ONE, observe, repeat (step 5 — Steel & Ho monitor / GDA replan)
11
+ //
12
+ // The elegance (RFC): C2 collapses into C1 + a goal-deduction step + an
13
+ // action-selection rule. Everything except goal-deduction is solved machinery.
14
+ // This module supplies the goal-deduction as a DEDUCTION over a DECLARED goal
15
+ // model (never a judgement over the request string) and REFUSES at the
16
+ // open-world goal-generation seam rather than inventing a goal — the C2 analogue
17
+ // of the resolver's "never emit a call it cannot prove".
18
+ //
19
+ // DEDUCTION, NOT KEYWORD-MATCH. The current goals fall out of the KB via a
20
+ // declared goal model (GOAL_RULES), exactly as syllogise chains a declared rule
21
+ // over the graph under mechanical guards. The ONLY thing this reads off the
22
+ // request is a FOCUS entity (delegated to the resolver's extractEntity + the
23
+ // binding oracle — entity resolution, never intent keywords). Whether a goal is
24
+ // active is then deduced from the graph (is the focus module untested? what does
25
+ // its change reach?), so no request-string literal steers the routing.
26
+ //
27
+ // MECHANICAL TERMINATION (not a convergence argument). Two independent bounds:
28
+ // (1) a hard OUTER-tick budget MAX_TICKS (mirrors the planner's MAX_STEPS), and
29
+ // (2) a MONOTONE-PROGRESS invariant — every tick ACHIEVES exactly one intention
30
+ // (removes it from the pending set); the only growth is a SINGLE, bounded
31
+ // GDA expansion (impact-of-each over the finite untested set), gated by a
32
+ // one-shot flag. So the pending set strictly shrinks to the empty set in
33
+ // <= (initial + |untested|) ticks, and MAX_TICKS caps it absolutely. A tick
34
+ // that makes no progress HALTS (honest refuse). Termination is proven
35
+ // mechanically, not argued from BDI convergence.
36
+ //
37
+ // THREAT-AWARENESS (POP threats lifted to the meta-level). A first step that
38
+ // clobbers another live goal's precondition is a threat. Here it is PROVABLY
39
+ // absent: every registry capability is read-only with an EMPTY delete-list
40
+ // (queries mutate nothing — the STRIPS closed world), so no step can delete a
41
+ // condition another goal depends on. We compute this from the registry rather
42
+ // than assume it (threatsAmong), so the guarantee is grounded, not asserted.
43
+
44
+ import { backwardChain, extractEntity } from "./resolver.mjs";
45
+ import { capabilityByName, effectsOf } from "./registry.mjs";
46
+ import { hallucinationsIn } from "../../agentbench/grade.mjs";
47
+ import { intersect } from "../../agentbench/results.mjs";
48
+
49
+ // Hard OUTER-tick budget — the meta-loop runs at most this many ticks, then
50
+ // REFUSES (escalate). Independent of BDI convergence and of the monotone
51
+ // invariant: a belt-and-braces mechanical stop, the meta-level twin of the
52
+ // planner's MAX_STEPS. A deduce->plan->observe cycle can never wedge the caller.
53
+ export const MAX_TICKS = 16;
54
+
55
+ // ---- the DECLARED goal model (data, mirroring registry.mjs's STRIPS operators)
56
+ // A goal-rule is a maintenance INVARIANT over the graph, plus the epistemic
57
+ // sub-goals whose facts decide whether it is violated and the DECLARED priority
58
+ // that breaks ties in first-step arbitration. Growing this set is the "long-chain
59
+ // deduction library" the RFC flags — same discipline as syllogise's rule set.
60
+ export const GOAL_RULES = Object.freeze([
61
+ Object.freeze({
62
+ id: "coverage-invariant",
63
+ kind: "maintenance",
64
+ // INVARIANT: a Module whose change reaches other modules (non-empty impact
65
+ // closure) MUST have direct test coverage. A Module that is untested AND
66
+ // impactful VIOLATES it — an active goal to close the coverage gap.
67
+ invariant: "an impactful module must be tested",
68
+ // the epistemic facts a plan must gather to evaluate the invariant (each
69
+ // backward-chains to a capability, exactly like the resolver's NL intents).
70
+ subGoals: Object.freeze(["impact", "untested"]),
71
+ // the DECLARED priority key for first-step arbitration: a violation's
72
+ // priority is its blast radius |impact(module)| — the wider the reach, the
73
+ // higher the goal (keystone = the widest-reach untested module).
74
+ priorityTopic: "impact",
75
+ // the coverage predicate the invariant screens on.
76
+ coverageTopic: "untested",
77
+ // the meta-goal topic the composed answer achieves (backward-chained below).
78
+ achieves: "coverage-gap",
79
+ }),
80
+ ]);
81
+
82
+ /** Backward-chain a meta-goal topic to the declared goal-rule that achieves it —
83
+ * the goal-level twin of resolver.backwardChain (capability selection). Pure. */
84
+ export function backwardChainGoal(topic) {
85
+ return GOAL_RULES.find((r) => r.achieves === topic) || null;
86
+ }
87
+
88
+ const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], composed: null, driver, why });
89
+
90
+ /** THREATS lifted to the meta-level: any pending intention whose needed condition
91
+ * a candidate step's DELETE-effects would clobber (POP threats over the
92
+ * conjunction of active goals). Computed from the registry's delete-lists. In
93
+ * this read-only registry every capability's delete-list is empty, so this is
94
+ * provably [] — but we DERIVE it rather than assume it, so the guarantee holds
95
+ * the day a mutating capability is ever registered. Pure over the registry. */
96
+ export function threatsAmong(candidateName, _pending) {
97
+ const cap = capabilityByName(candidateName);
98
+ const del = cap ? effectsOf(cap.name).del : [];
99
+ return del.length ? [{ name: candidateName, deletes: del }] : [];
100
+ }
101
+
102
+ /** Resolve the FOCUS the request scopes the goal model to — an entity binding
103
+ * (extractEntity + the graph oracle), NOT an intent keyword. Returns the bound
104
+ * individual or null (no bindable focus => a whole-graph / global goal). */
105
+ function focusOf(request, ctx) {
106
+ const term = extractEntity(String(request || ""));
107
+ if (!term || !ctx || !ctx.resolve) return null;
108
+ const r = ctx.resolve(term);
109
+ return r && r.match && !r.ambiguous ? r.match : null;
110
+ }
111
+
112
+ /** Ground ONE epistemic sub-goal (a topic + optional bound entity) into a
113
+ * grounded, EXECUTED call, or null when it is not groundable in the declared
114
+ * toolset (=> the meta-loop escalates). Backward-chains topic->capability, binds
115
+ * the entity, self-checks the same zero-hallucination gate the grader enforces,
116
+ * then dispatches. Mirrors the resolver/planner's honest-miss discipline. */
117
+ async function groundSubGoal(topic, entityLabel, tools, ctx) {
118
+ const cap = backwardChain(topic);
119
+ if (!cap || !tools.includes(cap.name)) return null; // no declared capability => escalate
120
+ // the arg grain: a no-arg coverage scan (untested) binds nothing; an entity
121
+ // topic binds the focus label to the capability's single slot.
122
+ const param = cap.parameters.find((p) => p.required);
123
+ if (param && !entityLabel) return null; // an entity topic with nothing to bind
124
+ const input = param && entityLabel ? { [param.arg]: entityLabel } : {};
125
+ const call = { name: cap.name, input };
126
+ if (hallucinationsIn(call, tools).length) return null; // never emit an unprovable call
127
+ const res = await ctx.dispatch(cap.name, input);
128
+ if (!res || !res.ok) return null; // honest miss at dispatch => escalate
129
+ return { call, result: Array.isArray(res.result) ? res.result : [] };
130
+ }
131
+
132
+ /** BDI DROP CONDITIONS (Rao & Georgeff): an intention persists until it is
133
+ * achieved / impossible / its goal lapses. Returns the reason string, or null
134
+ * to KEEP committing to it. Pure — unit-testable in isolation. */
135
+ export function dropCondition(intention, observed, mode, focus) {
136
+ if (observed.has(intention.key)) return "achieved"; // fact now gathered
137
+ if (mode === "scoped" && (!focus || focus.class !== "Module")) return "lapsed"; // focus moved
138
+ return null; // else keep the commitment
139
+ }
140
+
141
+ /** THE META-LOOP. deduce current goals -> plan-each (C1) -> threat-aware
142
+ * persistent first-step arbitration -> execute one -> observe -> repeat,
143
+ * HARD-BOUNDED. Returns a loopResult { calls, refused, terminated, proof, why,
144
+ * composed, driver } — a composed answer (the coverage-gap set for a focus, or
145
+ * the keystone module globally) or an HONEST REFUSE at the open-world
146
+ * goal-generation seam.
147
+ *
148
+ * ctx: { dispatch(name,input)->{ok,result}, resolve(term)->{match,ambiguous} }. */
149
+ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" } = {}) {
150
+ const declared = Array.isArray(tools) ? tools : [];
151
+ const rule = backwardChainGoal("coverage-gap");
152
+ if (!rule) return refuse("no declared goal-rule achieves the meta-goal — escalate", driver);
153
+
154
+ // STEP 1 — deduce the goal scope from the DECLARED model + a bound focus.
155
+ const focus = focusOf(request, ctx);
156
+ let mode;
157
+ if (focus && focus.class === "Module") mode = "scoped"; // assess the focus module's change footprint
158
+ else if (focus) mode = "escalate"; // a non-Module focus: no declared rule covers it
159
+ else mode = "global"; // no focus => rank the whole codebase (keystone)
160
+
161
+ // The open-world goal-generation seam, named honestly: a resolved focus the
162
+ // declared goal model does not cover is REFUSED, never given an invented goal.
163
+ if (mode === "escalate") {
164
+ return refuse(`open-world: no declared goal-rule covers a ${focus.class} focus (the coverage-invariant is Module-scoped) — escalate`, driver);
165
+ }
166
+
167
+ // the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
168
+ // twin of resolver.mjs's "backward-chain => <capability>" provenance).
169
+ const why = [
170
+ `goal-deduction: backward-chain (achieves ${rule.achieves}) => goal-rule "${rule.id}" (${rule.invariant})`,
171
+ `mode: ${mode}${focus ? ` (focus ${focus.label} [${focus.class}])` : " (whole-graph / keystone arbitration)"}`,
172
+ "threat-check: read-only registry => every capability delete-list empty => meta-level POP threats provably none",
173
+ ];
174
+ const proof = [{ step: "goal-rule", rule: rule.id, achieves: rule.achieves, ok: true }];
175
+ const calls = [];
176
+ const observed = new Map(); // intention.key -> gathered result set
177
+
178
+ // STEP 2/3 — the pending INTENTIONS (epistemic sub-goals), each carrying its
179
+ // declared execution order (arbitration is least-commitment: min order first,
180
+ // the keystone selection over the gathered facts happens at compose).
181
+ // scoped: gather impact(focus) then the untested coverage-scan.
182
+ // global: gather untested first, then EXPAND to impact-of-each (GDA replan).
183
+ const pending = mode === "scoped"
184
+ ? [{ topic: "impact", of: focus.label, key: `impact:${focus.label}`, order: 0 },
185
+ { topic: "untested", of: null, key: "untested", order: 1 }]
186
+ : [{ topic: "untested", of: null, key: "untested", order: 0 }];
187
+
188
+ let committed = null; // the persisted BDI intention (not re-derived each tick)
189
+ let expanded = false; // one-shot guard: the single bounded GDA expansion
190
+ let ticks = 0;
191
+
192
+ while (pending.length) {
193
+ // (1) HARD OUTER BOUND — mechanical, independent of the monotone invariant.
194
+ if (ticks >= MAX_TICKS) return refuse(`meta-loop tick budget exhausted (${MAX_TICKS}) — escalate`, driver);
195
+ ticks += 1;
196
+
197
+ // (3b) PERSISTENCE — keep the committed intention unless a BDI drop condition
198
+ // fires; only THEN re-arbitrate. This is the "commitment, not recomputed
199
+ // preference" that stops the loop thrashing.
200
+ if (committed && dropCondition(committed, observed, mode, focus)) committed = null;
201
+ if (!committed || !pending.includes(committed)) {
202
+ // (3a) FIRST-STEP ARBITRATION — least-commitment: the lowest declared order
203
+ // among pending. Threat-aware: skip a step that would clobber another
204
+ // live goal (provably never, read-only) before committing.
205
+ const admissible = pending.filter((i) => threatsAmong(backwardChain(i.topic)?.name, pending).length === 0);
206
+ if (!admissible.length) return refuse("all first steps are threatened (would clobber a live goal) — escalate", driver);
207
+ committed = admissible.slice().sort((a, b) => a.order - b.order)[0];
208
+ }
209
+
210
+ // (5) EXECUTE ONE, then OBSERVE (Steel & Ho monitor).
211
+ const grounded = await groundSubGoal(committed.topic, committed.of, declared, ctx);
212
+ if (!grounded) return refuse(`sub-goal (knows ${committed.topic}${committed.of ? ` ${committed.of}` : ""}) not groundable in the declared toolset — escalate`, driver);
213
+ calls.push(grounded.call);
214
+ observed.set(committed.key, grounded.result);
215
+ proof.push({ step: "causal-link", producer: "graph", condition: committed.of ?? committed.topic, consumer: `${committed.topic}:${grounded.call.name}`, ok: true });
216
+
217
+ // MONOTONE PROGRESS — this tick ACHIEVED exactly one intention: drop it.
218
+ const before = pending.length;
219
+ const achievedTopic = committed.topic;
220
+ pending.splice(pending.indexOf(committed), 1);
221
+ committed = null;
222
+
223
+ // GDA EXPANSION (monitor -> replan), ONCE: on observing the untested set in
224
+ // global mode, expand to the priority sub-goal (impact) for each violating
225
+ // module, so arbitration can rank them. Bounded by |untested| (finite) and
226
+ // fired at most once (the `expanded` guard) => the pending set still
227
+ // converges.
228
+ let expandedThisTick = false;
229
+ if (mode === "global" && achievedTopic === rule.coverageTopic && !expanded) {
230
+ expanded = true;
231
+ expandedThisTick = true;
232
+ const untested = observed.get("untested") || [];
233
+ untested.forEach((m, i) => pending.push({ topic: rule.priorityTopic, of: m, key: `impact:${m}`, order: 100 + i }));
234
+ }
235
+
236
+ // the invariant, enforced mechanically: the pending set shrank by one this
237
+ // tick (progress) OR grew ONLY by the one-shot bounded expansion. Anything
238
+ // else is non-progress => HALT honestly rather than risk a livelock.
239
+ if (pending.length > before - 1 && !expandedThisTick) {
240
+ return refuse("meta-loop made no monotone progress — halting", driver);
241
+ }
242
+ }
243
+
244
+ // STEP 3a (the answer) — COMPOSE + arbitrate the keystone from the gathered
245
+ // facts (all INSIDE the driver's timeout guard; no unbounded post-work).
246
+ let composed;
247
+ if (mode === "scoped") {
248
+ // the coverage-gap of the focus: the untested modules in its change
249
+ // FOOTPRINT ({focus} ∪ its impact closure) — a real composed set (∅ = no gap).
250
+ const footprint = [focus.label, ...(observed.get(`impact:${focus.label}`) || [])];
251
+ composed = intersect(observed.get("untested") || [], footprint);
252
+ why.push(`compose: untested ∩ ({${focus.label}} ∪ impact) = the change's untested footprint (${composed.length ? composed.join(", ") : "∅ — no coverage gap"})`);
253
+ } else {
254
+ // KEYSTONE arbitration: among the coverage violations (untested modules), pick
255
+ // the highest declared priority — the widest blast radius |impact(m)| — tie
256
+ // broken by label order. The single most-worth-covering module.
257
+ const untested = observed.get("untested") || [];
258
+ const ranked = untested
259
+ .map((m) => ({ m, weight: (observed.get(`impact:${m}`) || []).length }))
260
+ .sort((a, b) => b.weight - a.weight || String(a.m).localeCompare(String(b.m)));
261
+ composed = ranked.length ? [ranked[0].m] : [];
262
+ why.push(`keystone: argmax |impact| over ${untested.length} untested module(s) => ${composed.length ? `${composed[0]} (weight ${ranked[0].weight})` : "∅"}`);
263
+ }
264
+
265
+ return { calls, refused: false, terminated: true, proof, why, composed, driver, observed: `goal(${mode}): ${calls.map((c) => c.name).join(" -> ")}` };
266
+ }
@@ -21,8 +21,13 @@
21
21
  // the topic to the capability. This is the Stage-1 deliverable proper.
22
22
  // 3. IMPERATIVE INTENT FRAMES (Stage 2, this module's FRAMES table) — curated
23
23
  // phrasings the relational grammar does not carry ("blast radius of X",
24
- // "who calls X", "search for X"): a regex -> {topic, arg}. Same backward
25
- // chaining (topic -> capability), same resolveObject binding.
24
+ // "who calls X", "search for X", "the call edges of X", "explain X"): a
25
+ // regex -> {topic, arg}. Same backward chaining (topic -> capability), same
26
+ // resolveObject binding. This is the surface that lifts NL reach above the
27
+ // command register: it reaches tmct_calls (the raw call-edge dump — a grain
28
+ // the relational "call" verb collides with) via an EXPLICIT edge-dump frame,
29
+ // and it rescues a request whose NL parse selected an OUT-OF-SET capability
30
+ // by re-selecting a DECLARED one (resolveOne falls through to the frame).
26
31
  //
27
32
  // ENTITY BINDING is DELEGATED to `resolveObject` (ask.mjs — the tiered lemma/
28
33
  // fuzzy binding oracle with honest ambiguity). This module NEVER re-implements
@@ -77,9 +82,17 @@ export const UNMAPPED_KINDS = Object.freeze({
77
82
  // conformance test FAILS on an untagged gap; a genuinely-unreachable cap must be
78
83
  // tagged HERE with the Stage it needs, so the ceiling is honest rather than a
79
84
  // silent low-completion refuse. (Coordinator reinforcement 2.)
80
- export const NOT_NL_REACHABLE = Object.freeze({
81
- tmct_calls: "the raw call-edge dump collides with tmct_callees on every NL phrasing (\"what does X call\" -> callees) and has no command verb; distinguishing it needs a Stage-2 intent frame we have not authored",
82
- });
85
+ //
86
+ // EMPTY as of Stage 2. tmct_calls the raw call-edge dump that USED to sit here
87
+ // is now reached by a DEDICATED imperative frame keyed on the "call edges / call
88
+ // graph / outgoing calls of X" phrasings the relational grammar does NOT carry
89
+ // (see FRAMES below). The collision the old tag named is real, so the frame does
90
+ // NOT touch the relational "call" verb (that still routes callers/callees); it
91
+ // opens a SECOND, distinct surface that names the edge-dump grain explicitly. With
92
+ // it every declared capability is NL/command/frame-reachable — the ceiling is
93
+ // genuinely empty, not a silenced gap. (The conformance test enforces both
94
+ // directions: an over-claimed tag would now fail, since tmct_calls IS reachable.)
95
+ export const NOT_NL_REACHABLE = Object.freeze({});
83
96
 
84
97
  // ---- imperative intent FRAMES (Stage 2 — fills what the relational grammar and
85
98
  // the command register both miss). regex -> { topic, arg | noArg }. `arg` names
@@ -90,7 +103,14 @@ export const NOT_NL_REACHABLE = Object.freeze({
90
103
  export const FRAMES = Object.freeze([
91
104
  { re: /\buntested\b|\bwithout\s+(?:a\s+)?tests?\b|\bhas\s+no\s+tests?\b|\bneeds?\s+(?:a\s+)?tests?\b/i, topic: "untested", noArg: true },
92
105
  { re: /\bblast\s*radius\b|\bimpacts?\b|\bimpacted\b|what\s+(?:a\s+)?change.*(?:reach|affect|touch)|what\s+(?:depends?\s+on|dependents?)\b/i, topic: "impact", arg: "module" },
93
- { re: /\bcallees?\b|what\s+does\s+\S+\s+call\b/i, topic: "callees", arg: "symbol" },
106
+ // tmct_calls (Stage 2 the reachability win): the RAW call-edge dump, a grain
107
+ // the relational "call" verb collides with (which routes callers/callees). This
108
+ // frame does NOT use the bare verb — it keys on the EXPLICIT edge-dump nouns
109
+ // ("call edges", "call graph", "outgoing calls of X") the relational grammar
110
+ // never emits, so it opens a distinct surface without touching callers/callees.
111
+ // FIRST so its explicit phrasing wins before the callees/callers verb frames.
112
+ { re: /\bcall[\s-]*edges?\b|\bcall[\s-]*graph\b|\boutgoing\s+calls?\b|\bcall[\s-]*sites?\s+(?:of|in|out|from)\b/i, topic: "calls", arg: "symbol" },
113
+ { re: /\bcallees?\b|wh(?:at|o)\s+does\s+\S+\s+call\b/i, topic: "callees", arg: "symbol" },
94
114
  { re: /\bcallers?\b|who\s+calls\b|what\s+calls\b/i, topic: "callers", arg: "symbol" },
95
115
  { re: /\btests?\b.*\b(?:for|cover|covering|of)\b|which\s+tests?\b|covers?\b|covered\b|test\s+coverage\b/i, topic: "tests", arg: "symbol" },
96
116
  { re: /\bcochang|change[- ]coupl/i, topic: "cochanges", arg: "symbol" },
@@ -99,7 +119,7 @@ export const FRAMES = Object.freeze([
99
119
  { re: /\bmembers?\b|\bmethods?\s+of\b|\battributes?\s+of\b/i, topic: "members", arg: "class" },
100
120
  { re: /\bhistory\b|who\s+changed\b|commits?\s+(?:that\s+)?touch/i, topic: "history", arg: "symbol" },
101
121
  { re: /\bsignature\b/i, topic: "signature", arg: "symbol" },
102
- { re: /\bdescribe\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
122
+ { re: /\bdescribe\b|\bexplain\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
103
123
  { re: /\bsearch\b|\bfind\b|look\s+for\b/i, topic: "matches", arg: "query" },
104
124
  ]);
105
125
 
@@ -129,6 +149,9 @@ const STOP = new Set([
129
149
  "export", "exports", "history", "commit", "commits", "signature", "search", "find", "look",
130
150
  "module", "modules", "class", "classes", "function", "functions", "symbol", "symbols",
131
151
  "untested", "blast", "radius", "change", "changes", "changing", "reach", "reaches", "affect", "affects",
152
+ // Stage-2 edge-dump + imperative-verb tokens (tmct_calls frame + explain/outgoing
153
+ // phrasings): none names an entity, so keep them out of the slot-filler's pool.
154
+ "explain", "edge", "edges", "graph", "outgoing", "site", "sites", "invoke", "invokes", "run", "runs", "execute", "executes",
132
155
  ]);
133
156
 
134
157
  /** Pull one entity token from a request (imperative-frame slot-filling). Prefer a
@@ -235,13 +258,28 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
235
258
  // so we fall through and only surface the NL reason if the frame misses too.
236
259
  let pick = commandCapability(request, declared);
237
260
  let nlRefuse = null;
261
+ let nlUndeclared = null;
238
262
  if (!pick) {
239
263
  const mapped = mapParse(parseQuery(request));
240
- if (mapped && !mapped.refuse) pick = mapped;
241
- else if (mapped && mapped.refuse) nlRefuse = mapped.reason;
264
+ if (mapped && !mapped.refuse) {
265
+ // An NL parse that selects a DECLARED capability is the Stage-1 answer. One
266
+ // that selects an OUT-OF-SET capability is NOT terminal (Stage-2 widening):
267
+ // an imperative FRAME may still reach a DECLARED capability for the SAME
268
+ // request (e.g. keyword-spot mis-routes "outgoing calls of X" toward an
269
+ // out-of-set callers/callees, but the calls-frame reaches the declared
270
+ // tmct_calls). Hold the out-of-set name and fall through; surface it only if
271
+ // the frame misses too. This can only turn a refuse into a grounded DECLARED
272
+ // call — the declared/hallucination gates below still apply, never a guess.
273
+ if (declared.has(mapped.name)) pick = mapped;
274
+ else nlUndeclared = mapped.name;
275
+ } else if (mapped && mapped.refuse) nlRefuse = mapped.reason;
242
276
  }
243
277
  if (!pick) pick = mapFrame(request);
244
- if (!pick) return REFUSE(nlRefuse || "no command, NL parse, or imperative frame selects a capability");
278
+ if (!pick) {
279
+ if (nlRefuse) return REFUSE(nlRefuse);
280
+ if (nlUndeclared) return REFUSE(`selected ${nlUndeclared} but it is not in the declared toolset`);
281
+ return REFUSE("no command, NL parse, or imperative frame selects a capability");
282
+ }
245
283
  let why = pick.why ?? [];
246
284
  if (!declared.has(pick.name)) return REFUSE(`selected ${pick.name} but it is not in the declared toolset`);
247
285