@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 +36 -29
- package/package.json +1 -1
- package/src/ask.mjs +29 -1
- package/src/chat.mjs +13 -0
- package/src/interpret/normalize.mjs +37 -0
- package/src/interpret/pipeline.mjs +18 -3
- package/src/interpret/strategies/ace.mjs +49 -0
- package/src/router/goal-reasoner.mjs +266 -0
- package/src/router/resolver.mjs +48 -10
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.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
42
|
-
*
|
|
43
|
-
|
|
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
|
+
}
|
package/src/router/resolver.mjs
CHANGED
|
@@ -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"
|
|
25
|
-
// chaining (topic -> capability), same
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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)
|
|
241
|
-
|
|
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)
|
|
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
|
|