@polycode-projects/the-mechanical-code-talker 1.9.2 → 1.10.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/README.md +441 -202
- package/bin/tmct.mjs +126 -1
- package/package.json +4 -2
- package/src/answer-variants.mjs +8 -36
- package/src/ask-browser-entry.mjs +5 -23
- package/src/ask-browser.bundle.js +1 -2
- package/src/ask-nlp.mjs +9 -23
- package/src/ask-vocab.mjs +139 -589
- package/src/ask.mjs +627 -1729
- package/src/chat.mjs +1684 -2872
- package/src/cli-args.mjs +14 -28
- package/src/codegraph.mjs +236 -644
- package/src/completions/complete.mjs +18 -62
- package/src/completions/graph-adapter.mjs +14 -60
- package/src/completions/group.mjs +12 -68
- package/src/completions/infer.mjs +38 -126
- package/src/completions/prune.mjs +17 -70
- package/src/completions/rank.mjs +16 -69
- package/src/completions/search.mjs +8 -31
- package/src/concept.mjs +32 -88
- package/src/conformance.mjs +11 -15
- package/src/corpus/conceptnet.mjs +31 -89
- package/src/corpus/templates.mjs +19 -45
- package/src/corpus/unknown-ingest.mjs +31 -92
- package/src/embed.mjs +10 -22
- package/src/extensions.mjs +50 -154
- package/src/finish.mjs +35 -91
- package/src/grammar/ace.mjs +16 -40
- package/src/grammar/assert.mjs +1 -1
- package/src/grammar/lexicon-core.json +1 -1
- package/src/grammar/lexicon.mjs +9 -27
- package/src/graph-merge.mjs +2 -3
- package/src/hash.mjs +6 -14
- package/src/index.mjs +6 -10
- package/src/init.mjs +38 -125
- package/src/interpret/fuzzy.mjs +10 -29
- package/src/interpret/merge.mjs +9 -27
- package/src/interpret/normalize.mjs +137 -585
- package/src/interpret/pipeline.mjs +23 -71
- package/src/interpret/strategies/ace.mjs +7 -31
- package/src/interpret/strategies/constructions.mjs +14 -41
- package/src/interpret/strategies/grammar.mjs +21 -60
- package/src/interpret/strategies/keywords.mjs +42 -131
- package/src/interpret/strategies/noise-strip.mjs +18 -89
- package/src/memory/bias.mjs +11 -54
- package/src/memory/blocks.mjs +18 -69
- package/src/memory/core.mjs +171 -591
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +7 -25
- package/src/memory/shacl.mjs +10 -39
- package/src/memory/trust.mjs +26 -127
- package/src/memory-ask-browser-entry.mjs +7 -30
- package/src/memory-ask-browser.bundle.js +1 -1
- package/src/paraphrase.mjs +20 -53
- package/src/planning.mjs +15 -157
- package/src/prose-nlp.mjs +4 -17
- package/src/prose.mjs +19 -67
- package/src/providers/bootstrap.mjs +1 -2
- package/src/providers/fixture.mjs +1 -2
- package/src/providers/graph-service.mjs +28 -59
- package/src/repository-interface.mjs +6 -8
- package/src/router/drive.mjs +183 -0
- package/src/router/goal-reasoner.mjs +66 -231
- package/src/router/guardrail.mjs +20 -58
- package/src/router/planner.mjs +15 -46
- package/src/router/registry.mjs +13 -43
- package/src/router/resolver.mjs +46 -131
- package/src/router/results.mjs +231 -0
- package/src/schema-docs.mjs +10 -27
- package/src/server-http.mjs +10 -19
- package/src/server.mjs +22 -28
- package/src/sessions.mjs +15 -30
- package/src/source-slice.mjs +5 -7
- package/src/source.mjs +10 -20
- package/src/syllogise.mjs +187 -575
- package/src/telemetry.mjs +3 -3
- package/src/toml-config.mjs +4 -4
- package/src/tui/app.mjs +9 -19
- package/src/viz.mjs +66 -123
- package/src/wink-model.mjs +10 -24
|
@@ -1,65 +1,21 @@
|
|
|
1
|
-
// src/router/goal-reasoner.mjs —
|
|
2
|
-
//
|
|
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):
|
|
1
|
+
// src/router/goal-reasoner.mjs — the closed-world goal-reasoner: a canned,
|
|
2
|
+
// hard-bounded meta-loop (BDI x goal-driven autonomy):
|
|
5
3
|
//
|
|
6
|
-
// deduce current goals
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// → PERSIST the committed intention (step 3b — BDI drop conditions)
|
|
10
|
-
// → execute ONE, observe, repeat (step 5 — Steel & Ho monitor / GDA replan)
|
|
4
|
+
// deduce current goals -> plan each goal (resolver.mjs's backward chain)
|
|
5
|
+
// -> arbitrate the first steps, threat-aware -> persist the committed intention
|
|
6
|
+
// -> execute ONE, observe, repeat
|
|
11
7
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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".
|
|
8
|
+
// Goal-deduction is a DEDUCTION over a DECLARED goal model (GOAL_RULES), never a judgement
|
|
9
|
+
// over the request string: the only thing read off the request is a FOCUS entity (via the
|
|
10
|
+
// resolver's extractEntity + binding oracle). REFUSES at the open-world goal-generation seam
|
|
11
|
+
// rather than inventing a goal.
|
|
18
12
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
// THE GLOBAL-MODE DOMAIN GATE (Bug 8 fix). In SCOPED mode, relevance is already
|
|
45
|
-
// proven structurally: the focus is a REAL bound graph entity (resolveObject
|
|
46
|
-
// found it), so the request is provably about something in the graph. In GLOBAL
|
|
47
|
-
// mode there is no focus to bind, and `applicableRules` alone only screens the
|
|
48
|
-
// CALLER'S DECLARED TOOLSET — a caller-constant fact that says nothing about
|
|
49
|
-
// whether THIS request has any connection to the deduced goal (a caller who
|
|
50
|
-
// declares tmct_untested/tmct_impact once per session would ground
|
|
51
|
-
// coverage-invariant for every off-topic turn). The fix reuses ask.mjs's OWN
|
|
52
|
-
// compositional NL grammar (parseQuery — the SAME primitive the C1 resolver
|
|
53
|
-
// already parses every request with, see resolver.mjs mapParse) as a structural
|
|
54
|
-
// relevance check, never a new keyword table: does the request even COMPILE to a
|
|
55
|
-
// recognized graph-query shape naming a known entity kind, and does that kind
|
|
56
|
-
// match the rule's declared focusClass? A request parseQuery cannot place at all
|
|
57
|
-
// (null — "write a haiku about pizza") or places without landing on any
|
|
58
|
-
// recognized entity kind (a miss with no entity kind — "how many pizzas are
|
|
59
|
-
// there") is an honest "not about this graph" signal; a request parseQuery
|
|
60
|
-
// resolves to a real AST naming the rule's focus class ("which module is the
|
|
61
|
-
// biggest testing risk" -> {node:"superlative", entityType:"Module", ...})
|
|
62
|
-
// stays exactly as reachable as before. Zero request keywords added.
|
|
13
|
+
// Termination: MAX_TICKS caps the loop; each tick achieves exactly one intention (monotone
|
|
14
|
+
// progress), with one bounded GDA expansion allowed. Threat safety: every registry capability
|
|
15
|
+
// is read-only (empty delete-list), so no step can clobber another goal's precondition —
|
|
16
|
+
// derived from the registry, not assumed (threatsAmong). In GLOBAL mode (no bound focus),
|
|
17
|
+
// relevance is additionally screened by parsing the request through ask.mjs's own NL grammar,
|
|
18
|
+
// since the declared toolset alone doesn't prove the request is about this graph.
|
|
63
19
|
|
|
64
20
|
import { backwardChain, extractEntity } from "./resolver.mjs";
|
|
65
21
|
import { capabilityByName, effectsOf } from "./registry.mjs";
|
|
@@ -67,50 +23,27 @@ import { hallucinationsIn } from "./call-validator.mjs";
|
|
|
67
23
|
import { intersect } from "./set-algebra.mjs";
|
|
68
24
|
import { parseQuery } from "../ask.mjs";
|
|
69
25
|
|
|
70
|
-
// Hard
|
|
71
|
-
// REFUSES (escalate). Independent of BDI convergence and of the monotone
|
|
72
|
-
// invariant: a belt-and-braces mechanical stop, the meta-level twin of the
|
|
73
|
-
// planner's MAX_STEPS. A deduce->plan->observe cycle can never wedge the caller.
|
|
26
|
+
// Hard outer-tick budget — the meta-loop runs at most this many ticks, then refuses.
|
|
74
27
|
export const MAX_TICKS = 16;
|
|
75
28
|
|
|
76
|
-
// ---- the DECLARED goal model (data, mirroring registry.mjs's STRIPS operators)
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// modes — which deduced scopes the rule covers ("scoped" = a bound focus;
|
|
85
|
-
// "global" = whole-graph keystone arbitration). A rule whose
|
|
86
|
-
// sub-goals are all entity-scoped has no global reading.
|
|
87
|
-
// subGoals — the epistemic facts to gather, IN DECLARED ORDER (each
|
|
88
|
-
// backward-chains to a capability, exactly like an NL intent).
|
|
89
|
-
// compose — the declarative fold of the gathered facts into the scoped
|
|
90
|
-
// answer: intersect(a, b), each side naming a gathered topic,
|
|
91
|
-
// optionally bound to the focus (`of:"focus"`), optionally with
|
|
92
|
-
// the focus itself unioned in (`withFocus` — the change footprint).
|
|
93
|
-
// priorityTopic / coverageTopic — the global keystone arbitration keys
|
|
94
|
-
// (argmax |priority(m)| over the coverage-violating set).
|
|
95
|
-
// achieves — the meta-goal topic the composed answer achieves.
|
|
29
|
+
// ---- the DECLARED goal model (data, mirroring registry.mjs's STRIPS operators). Each rule:
|
|
30
|
+
// focusClass — the entity class a scoped reading binds its focus to.
|
|
31
|
+
// modes — "scoped" (bound focus) and/or "global" (whole-graph keystone arbitration).
|
|
32
|
+
// subGoals — epistemic facts to gather, in declared order (each backward-chains to a
|
|
33
|
+
// capability).
|
|
34
|
+
// compose — intersect(a, b) over two gathered topics, optionally focus-bound/unioned.
|
|
35
|
+
// priorityTopic/coverageTopic — the global keystone arbitration keys.
|
|
36
|
+
// achieves — the meta-goal topic the composed answer achieves.
|
|
96
37
|
export const GOAL_RULES = Object.freeze([
|
|
97
38
|
Object.freeze({
|
|
98
39
|
id: "coverage-invariant",
|
|
99
40
|
kind: "maintenance",
|
|
100
|
-
// INVARIANT: a Module whose change reaches other modules (non-empty impact
|
|
101
|
-
// closure) MUST have direct test coverage. A Module that is untested AND
|
|
102
|
-
// impactful VIOLATES it — an active goal to close the coverage gap.
|
|
103
41
|
invariant: "an impactful module must be tested",
|
|
104
42
|
focusClass: "Module",
|
|
105
43
|
modes: Object.freeze(["scoped", "global"]),
|
|
106
44
|
subGoals: Object.freeze(["impact", "untested"]),
|
|
107
|
-
// the DECLARED priority key for first-step arbitration: a violation's
|
|
108
|
-
// priority is its blast radius |impact(module)| — the wider the reach, the
|
|
109
|
-
// higher the goal (keystone = the widest-reach untested module).
|
|
110
45
|
priorityTopic: "impact",
|
|
111
|
-
// the coverage predicate the invariant screens on.
|
|
112
46
|
coverageTopic: "untested",
|
|
113
|
-
// scoped fold: untested ∩ ({focus} ∪ impact(focus)) — the change footprint.
|
|
114
47
|
compose: Object.freeze({
|
|
115
48
|
op: "intersect",
|
|
116
49
|
a: Object.freeze({ topic: "untested" }),
|
|
@@ -118,24 +51,17 @@ export const GOAL_RULES = Object.freeze([
|
|
|
118
51
|
names: "the change's untested footprint",
|
|
119
52
|
empty: "no coverage gap",
|
|
120
53
|
}),
|
|
121
|
-
// the meta-goal topic the composed answer achieves (backward-chained below).
|
|
122
54
|
achieves: "coverage-gap",
|
|
123
55
|
}),
|
|
124
56
|
Object.freeze({
|
|
125
57
|
id: "cochange-risk-invariant",
|
|
126
58
|
kind: "maintenance",
|
|
127
|
-
// INVARIANT: a module CHANGE-COUPLED with the focus (they historically land
|
|
128
|
-
// in the same commits) MUST have direct test coverage. A coupled module that
|
|
129
|
-
// is untested VIOLATES it — an active goal over the focus's coupling set.
|
|
130
59
|
invariant: "a module change-coupled with the focus must be tested",
|
|
131
60
|
focusClass: "Module",
|
|
132
|
-
// scoped ONLY: both sub-goals are read relative to a bound focus; there is
|
|
133
|
-
// no whole-graph keystone reading declared for change-coupling.
|
|
134
61
|
modes: Object.freeze(["scoped"]),
|
|
135
62
|
subGoals: Object.freeze(["cochanges", "untested"]),
|
|
136
63
|
priorityTopic: "cochanges",
|
|
137
64
|
coverageTopic: "untested",
|
|
138
|
-
// scoped fold: cochanges(focus) ∩ untested — the coupled-but-untested set.
|
|
139
65
|
compose: Object.freeze({
|
|
140
66
|
op: "intersect",
|
|
141
67
|
a: Object.freeze({ topic: "cochanges", of: "focus" }),
|
|
@@ -147,30 +73,16 @@ export const GOAL_RULES = Object.freeze([
|
|
|
147
73
|
}),
|
|
148
74
|
]);
|
|
149
75
|
|
|
150
|
-
/** Backward-chain a meta-goal topic to the declared goal-rule that achieves it
|
|
151
|
-
* the goal-level twin of resolver.backwardChain (capability selection). Pure. */
|
|
76
|
+
/** Backward-chain a meta-goal topic to the declared goal-rule that achieves it. Pure. */
|
|
152
77
|
export function backwardChainGoal(topic) {
|
|
153
78
|
return GOAL_RULES.find((r) => r.achieves === topic) || null;
|
|
154
79
|
}
|
|
155
80
|
|
|
156
|
-
/**
|
|
157
|
-
* declared
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* (2) the deduced mode is one of the rule's declared modes, and
|
|
162
|
-
* (3) a scoped reading's bound focus is of the rule's declared focusClass.
|
|
163
|
-
* Pure over the goal model + registry: nothing here reads the request string.
|
|
164
|
-
* The caller REFUSES on zero matches (the open-world goal-generation seam) and
|
|
165
|
-
* on more than one (an ambiguous meta-goal — arbitration between meta-goals is
|
|
166
|
-
* undeclared, so guessing one would be an invented goal). */
|
|
167
|
-
// `ruleSet` defaults to the real, shipped GOAL_RULES — every existing caller
|
|
168
|
-
// (driver-goal.mjs, this module's own goalReason, every test) is unaffected.
|
|
169
|
-
// The override exists ONLY for PLAN_CODE.md Track 1 (§1.4): the synthesis
|
|
170
|
-
// oracle clones a CANDIDATE rule into its own array and runs it through this
|
|
171
|
-
// SAME trusted function — never a re-derivation — to check it decides
|
|
172
|
-
// applicability exactly like a hand-written rule would. synthbench/ is the
|
|
173
|
-
// only caller that ever passes a non-default ruleSet.
|
|
81
|
+
/** A declared goal-rule APPLIES to a request iff (1) every sub-goal topic backward-chains
|
|
82
|
+
* to a capability in the declared toolset, (2) the deduced mode is one of the rule's
|
|
83
|
+
* declared modes, and (3) a scoped focus is of the rule's declared focusClass. The caller
|
|
84
|
+
* refuses on zero matches or more than one (ambiguous meta-goal). `ruleSet` defaults to
|
|
85
|
+
* GOAL_RULES; the synthesis-oracle test harness is the only caller that overrides it. */
|
|
174
86
|
export function applicableRules(declaredTools, focus, mode, ruleSet = GOAL_RULES) {
|
|
175
87
|
const declared = Array.isArray(declaredTools) ? declaredTools : [];
|
|
176
88
|
return ruleSet.filter((rule) =>
|
|
@@ -184,26 +96,19 @@ export function applicableRules(declaredTools, focus, mode, ruleSet = GOAL_RULES
|
|
|
184
96
|
|
|
185
97
|
const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], composed: null, driver, why });
|
|
186
98
|
|
|
187
|
-
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* this read-only registry every capability's delete-list is empty, so this is
|
|
191
|
-
* provably [] — but we DERIVE it rather than assume it, so the guarantee holds
|
|
192
|
-
* the day a mutating capability is ever registered. Pure over the registry. */
|
|
99
|
+
/** Threats: whether a candidate step's DELETE-effects would clobber another pending
|
|
100
|
+
* intention. Computed from the registry's delete-lists (always [] today, since every
|
|
101
|
+
* capability is read-only) rather than assumed, so the guarantee holds if that changes. */
|
|
193
102
|
export function threatsAmong(candidateName, _pending) {
|
|
194
103
|
const cap = capabilityByName(candidateName);
|
|
195
104
|
const del = cap ? effectsOf(cap.name).del : [];
|
|
196
105
|
return del.length ? [{ name: candidateName, deletes: del }] : [];
|
|
197
106
|
}
|
|
198
107
|
|
|
199
|
-
/** Resolve the FOCUS the request scopes the goal model to
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* the caller distinguish "genuinely no focus" from "a focus term that TIED"
|
|
204
|
-
* (PLAN_BREADTH_FIRST_NLU.md §4 — the latter must never silently fall back to
|
|
205
|
-
* a global answer, since that would silently answer a DIFFERENT goal than the
|
|
206
|
-
* one the user actually named). */
|
|
108
|
+
/** Resolve the FOCUS the request scopes the goal model to (entity binding, not a keyword).
|
|
109
|
+
* `match` is the bound individual or null (no focus => a global goal); `ambiguous` lets the
|
|
110
|
+
* caller distinguish "no focus" from "a focus term that tied" (never silently falls back
|
|
111
|
+
* to global on a tie). */
|
|
207
112
|
function focusOf(request, ctx) {
|
|
208
113
|
const term = extractEntity(String(request || ""));
|
|
209
114
|
if (!term || !ctx || !ctx.resolve) return { match: null, ambiguous: false, candidates: [] };
|
|
@@ -213,16 +118,9 @@ function focusOf(request, ctx) {
|
|
|
213
118
|
return { match: null, ambiguous: false, candidates: [] };
|
|
214
119
|
}
|
|
215
120
|
|
|
216
|
-
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* consume) for its declared `entityType` field, unwrapping the wrapper nodes
|
|
220
|
-
* (`clause`, `inner`, `base`) that carry no entityType of their own. Returns the
|
|
221
|
-
* class name, or null when the grammar placed nothing (an outright non-parse) or
|
|
222
|
-
* placed a MISS with no recognized entity kind at all ("how many pizzas are
|
|
223
|
-
* there" -> {node:"miss", reason:"count needs a known entity kind..."} carries no
|
|
224
|
-
* entityType, same as a flat null). Pure; no request-string keyword table — it
|
|
225
|
-
* reads a field ask.mjs's grammar already computes for every request. */
|
|
121
|
+
/** What entity CLASS (if any) did ask.mjs's NL grammar recognize in the request? Walks
|
|
122
|
+
* parseQuery's AST for its `entityType` field, unwrapping wrapper nodes (`clause`, `inner`,
|
|
123
|
+
* `base`). Returns null on a non-parse or an entity-less miss. */
|
|
226
124
|
function parsedEntityType(node) {
|
|
227
125
|
if (!node || typeof node !== "object") return null;
|
|
228
126
|
if (typeof node.entityType === "string") return node.entityType;
|
|
@@ -232,16 +130,12 @@ function parsedEntityType(node) {
|
|
|
232
130
|
return null;
|
|
233
131
|
}
|
|
234
132
|
|
|
235
|
-
/** Ground ONE epistemic sub-goal
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* the entity, self-checks the same zero-hallucination gate the grader enforces,
|
|
239
|
-
* then dispatches. Mirrors the resolver/planner's honest-miss discipline. */
|
|
133
|
+
/** Ground ONE epistemic sub-goal into an executed call, or null when not groundable in the
|
|
134
|
+
* declared toolset (=> the meta-loop escalates). Backward-chains topic->capability, binds
|
|
135
|
+
* the entity, self-checks the zero-hallucination gate, then dispatches. */
|
|
240
136
|
async function groundSubGoal(topic, entityLabel, tools, ctx) {
|
|
241
137
|
const cap = backwardChain(topic);
|
|
242
138
|
if (!cap || !tools.includes(cap.name)) return null; // no declared capability => escalate
|
|
243
|
-
// the arg grain: a no-arg coverage scan (untested) binds nothing; an entity
|
|
244
|
-
// topic binds the focus label to the capability's single slot.
|
|
245
139
|
const param = cap.parameters.find((p) => p.required);
|
|
246
140
|
if (param && !entityLabel) return null; // an entity topic with nothing to bind
|
|
247
141
|
const input = param && entityLabel ? { [param.arg]: entityLabel } : {};
|
|
@@ -252,54 +146,30 @@ async function groundSubGoal(topic, entityLabel, tools, ctx) {
|
|
|
252
146
|
return { call, result: Array.isArray(res.result) ? res.result : [] };
|
|
253
147
|
}
|
|
254
148
|
|
|
255
|
-
/**
|
|
256
|
-
*
|
|
257
|
-
* declared focus class (never a literal here — the rule is the authority).
|
|
258
|
-
* Returns the reason string, or null to KEEP committing to it. Pure. */
|
|
149
|
+
/** An intention persists until achieved or its goal lapses. Returns the reason string, or
|
|
150
|
+
* null to keep committing to it. Pure. */
|
|
259
151
|
export function dropCondition(intention, observed, mode, focus, focusClass) {
|
|
260
152
|
if (observed.has(intention.key)) return "achieved"; // fact now gathered
|
|
261
153
|
if (mode === "scoped" && (!focus || focus.class !== focusClass)) return "lapsed"; // focus moved
|
|
262
154
|
return null; // else keep the commitment
|
|
263
155
|
}
|
|
264
156
|
|
|
265
|
-
/** THE META-LOOP
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* composed, driver } — a composed answer (the coverage-gap set for a focus, or
|
|
269
|
-
* the keystone module globally) or an HONEST REFUSE at the open-world
|
|
270
|
-
* goal-generation seam.
|
|
157
|
+
/** THE META-LOOP: deduce -> plan (C1) -> threat-aware arbitration -> execute -> observe ->
|
|
158
|
+
* repeat, hard-bounded. Returns a loopResult { calls, refused, terminated, proof, why,
|
|
159
|
+
* composed, driver } or an honest refuse.
|
|
271
160
|
*
|
|
272
161
|
* ctx: { dispatch(name,input)->{ok,result}, resolve(term)->{match,ambiguous} }.
|
|
273
|
-
*
|
|
274
|
-
* `
|
|
275
|
-
*
|
|
276
|
-
* oracle.mjs) is the one caller that overrides it, with a CLONED array
|
|
277
|
-
* holding a candidate rule — the candidate then runs through this exact same
|
|
278
|
-
* meta-loop a hand-written rule does, never a parallel re-implementation.
|
|
279
|
-
*
|
|
280
|
-
* `pinnedFocus` (internal — PLAN_BREADTH_FIRST_NLU.md §4's breadth-first
|
|
281
|
-
* ambiguity fix) skips `focusOf` entirely and scopes straight to the given
|
|
282
|
-
* individual: the mechanism the ambiguous-focus branch below uses to run this
|
|
283
|
-
* SAME meta-loop once per tied candidate, "pin, don't re-resolve" (the same
|
|
284
|
-
* idiom PLAN_BREADTH_FIRST_NLU.md §1 uses for ask.mjs's entity ties). No
|
|
285
|
-
* product call site ever passes it. */
|
|
162
|
+
* `ruleSet` defaults to GOAL_RULES; only the synthesis-oracle test harness overrides it.
|
|
163
|
+
* `pinnedFocus` (internal) skips `focusOf` and scopes straight to the given individual —
|
|
164
|
+
* used to run this loop once per tied candidate on an ambiguous focus. */
|
|
286
165
|
export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", ruleSet = GOAL_RULES, pinnedFocus = null } = {}) {
|
|
287
166
|
const declared = Array.isArray(tools) ? tools : [];
|
|
288
167
|
|
|
289
|
-
// STEP 1 — deduce the goal scope from the DECLARED model + a bound focus,
|
|
290
|
-
// then SELECT the goal-rule by pure applicability (no request keyword ever):
|
|
291
|
-
// a bound focus reads scoped, no focus reads global (keystone arbitration).
|
|
292
168
|
const focusRes = pinnedFocus ? { match: pinnedFocus, ambiguous: false, candidates: [] } : focusOf(request, ctx);
|
|
293
169
|
const focus = focusRes.match;
|
|
294
170
|
|
|
295
|
-
// An
|
|
296
|
-
//
|
|
297
|
-
// than the one the user actually named. Refuse honestly, the same "never a
|
|
298
|
-
// guess" discipline resolver.mjs/guardrail.mjs apply to an ambiguous resolved
|
|
299
|
-
// term — and, when the tied candidates share ONE class a declared goal-rule
|
|
300
|
-
// scopes and a dispatcher is wired, ADDITIONALLY run this SAME meta-loop once
|
|
301
|
-
// per (pinned) candidate, so a machine caller gets both the honest "still
|
|
302
|
-
// ambiguous" signal and every candidate's real composed answer.
|
|
171
|
+
// An ambiguous focus must never silently collapse to "global". When the tied candidates
|
|
172
|
+
// share one class a goal-rule scopes, additionally run this loop once per candidate.
|
|
303
173
|
if (!pinnedFocus && focusRes.ambiguous) {
|
|
304
174
|
const term = extractEntity(String(request || ""));
|
|
305
175
|
const pool = focusRes.candidates.slice(0, 4);
|
|
@@ -312,26 +182,16 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
312
182
|
}
|
|
313
183
|
const mode = focus ? "scoped" : "global";
|
|
314
184
|
|
|
315
|
-
//
|
|
316
|
-
// class NO declared goal-rule scopes is REFUSED, never given an invented goal.
|
|
185
|
+
// A resolved focus whose class no declared goal-rule scopes is refused, never invented.
|
|
317
186
|
if (focus && !ruleSet.some((r) => r.focusClass === focus.class)) {
|
|
318
187
|
const covered = [...new Set(ruleSet.map((r) => r.focusClass))].join("/");
|
|
319
188
|
return refuse(`open-world: no declared goal-rule covers a ${focus.class} focus (the declared goal-rules are ${covered}-scoped) — escalate`, driver);
|
|
320
189
|
}
|
|
321
190
|
|
|
322
|
-
// Rule selection is a DEDUCTION over the goal model + the declared toolset:
|
|
323
|
-
// 0 applicable rules => the same open-world seam (nothing declared grounds the
|
|
324
|
-
// request's scope in this toolset); >1 => an AMBIGUOUS meta-goal (arbitration
|
|
325
|
-
// between meta-goals is undeclared) — both are honest refusals, never a guess.
|
|
326
191
|
const applicable = applicableRules(declared, focus, mode, ruleSet);
|
|
327
192
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// `applicable` alone (a pure function of the caller's DECLARED TOOLSET) is not
|
|
331
|
-
// enough — it says nothing about whether THIS request is even about the graph.
|
|
332
|
-
// Screen it against ask.mjs's own NL grammar: the request must parse to a shape
|
|
333
|
-
// naming the candidate rule's declared focusClass, or it is refused as honestly
|
|
334
|
-
// off-domain rather than answered with someone else's goal.
|
|
193
|
+
// GLOBAL mode has no bound focus to prove relevance, so also screen the request against
|
|
194
|
+
// ask.mjs's NL grammar: it must parse to a shape naming the candidate rule's focusClass.
|
|
335
195
|
let domainRelevant = applicable;
|
|
336
196
|
if (mode === "global" && applicable.length) {
|
|
337
197
|
const requestClass = parsedEntityType(parseQuery(request));
|
|
@@ -350,8 +210,6 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
350
210
|
}
|
|
351
211
|
const rule = domainRelevant[0];
|
|
352
212
|
|
|
353
|
-
// the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
|
|
354
|
-
// twin of resolver.mjs's "backward-chain => <capability>" provenance).
|
|
355
213
|
const why = [
|
|
356
214
|
`goal-deduction: backward-chain (achieves ${rule.achieves}) => goal-rule "${rule.id}" (${rule.invariant})`,
|
|
357
215
|
`mode: ${mode}${focus ? ` (focus ${focus.label} [${focus.class}])` : " (whole-graph / keystone arbitration)"}`,
|
|
@@ -361,13 +219,8 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
361
219
|
const calls = [];
|
|
362
220
|
const observed = new Map(); // intention.key -> gathered result set
|
|
363
221
|
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// keystone selection over the gathered facts happens at compose). A topic
|
|
367
|
-
// binds the focus iff its capability declares a REQUIRED parameter (read from
|
|
368
|
-
// the registry, never special-cased by topic name); in global mode there is
|
|
369
|
-
// no focus to bind, so entity-scoped topics are deferred to the GDA expansion
|
|
370
|
-
// (gather the coverage scan first, then EXPAND to priority-of-each violator).
|
|
222
|
+
// The pending intentions: the rule's sub-goals in declared order. In global mode,
|
|
223
|
+
// entity-scoped topics are deferred to the GDA expansion (no focus to bind yet).
|
|
371
224
|
const bindsEntity = (topic) => {
|
|
372
225
|
const cap = backwardChain(topic);
|
|
373
226
|
return Boolean(cap && cap.parameters.some((p) => p.required));
|
|
@@ -383,41 +236,32 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
383
236
|
let ticks = 0;
|
|
384
237
|
|
|
385
238
|
while (pending.length) {
|
|
386
|
-
// (1) HARD OUTER BOUND — mechanical, independent of the monotone invariant.
|
|
387
239
|
if (ticks >= MAX_TICKS) return refuse(`meta-loop tick budget exhausted (${MAX_TICKS}) — escalate`, driver);
|
|
388
240
|
ticks += 1;
|
|
389
241
|
|
|
390
|
-
//
|
|
391
|
-
// fires; only THEN re-arbitrate. This is the "commitment, not recomputed
|
|
392
|
-
// preference" that stops the loop thrashing.
|
|
242
|
+
// Keep the committed intention unless a drop condition fires; only then re-arbitrate.
|
|
393
243
|
if (committed && dropCondition(committed, observed, mode, focus, rule.focusClass)) committed = null;
|
|
394
244
|
if (!committed || !pending.includes(committed)) {
|
|
395
|
-
//
|
|
396
|
-
// among pending. Threat-aware: skip a step that would clobber another
|
|
397
|
-
// live goal (provably never, read-only) before committing.
|
|
245
|
+
// Least-commitment: lowest declared order among pending, skipping threatened steps.
|
|
398
246
|
const admissible = pending.filter((i) => threatsAmong(backwardChain(i.topic)?.name, pending).length === 0);
|
|
399
247
|
if (!admissible.length) return refuse("all first steps are threatened (would clobber a live goal) — escalate", driver);
|
|
400
248
|
committed = admissible.slice().sort((a, b) => a.order - b.order)[0];
|
|
401
249
|
}
|
|
402
250
|
|
|
403
|
-
// (5) EXECUTE ONE, then OBSERVE (Steel & Ho monitor).
|
|
404
251
|
const grounded = await groundSubGoal(committed.topic, committed.of, declared, ctx);
|
|
405
252
|
if (!grounded) return refuse(`sub-goal (knows ${committed.topic}${committed.of ? ` ${committed.of}` : ""}) not groundable in the declared toolset — escalate`, driver);
|
|
406
253
|
calls.push(grounded.call);
|
|
407
254
|
observed.set(committed.key, grounded.result);
|
|
408
255
|
proof.push({ step: "causal-link", producer: "graph", condition: committed.of ?? committed.topic, consumer: `${committed.topic}:${grounded.call.name}`, ok: true });
|
|
409
256
|
|
|
410
|
-
//
|
|
257
|
+
// This tick achieved exactly one intention: drop it (monotone progress).
|
|
411
258
|
const before = pending.length;
|
|
412
259
|
const achievedTopic = committed.topic;
|
|
413
260
|
pending.splice(pending.indexOf(committed), 1);
|
|
414
261
|
committed = null;
|
|
415
262
|
|
|
416
|
-
// GDA
|
|
417
|
-
//
|
|
418
|
-
// the priority sub-goal for each violating module, so arbitration can rank
|
|
419
|
-
// them. Bounded by the finite coverage set and fired at most once (the
|
|
420
|
-
// `expanded` guard) => the pending set still converges.
|
|
263
|
+
// GDA expansion, once: on observing the coverage set in global mode, expand to the
|
|
264
|
+
// priority sub-goal for each violating module so arbitration can rank them.
|
|
421
265
|
let expandedThisTick = false;
|
|
422
266
|
if (mode === "global" && rule.modes.includes("global") && achievedTopic === rule.coverageTopic && !expanded) {
|
|
423
267
|
expanded = true;
|
|
@@ -426,21 +270,15 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
426
270
|
violating.forEach((m, i) => pending.push({ topic: rule.priorityTopic, of: m, key: `${rule.priorityTopic}:${m}`, order: 100 + i }));
|
|
427
271
|
}
|
|
428
272
|
|
|
429
|
-
//
|
|
430
|
-
// tick (progress) OR grew ONLY by the one-shot bounded expansion. Anything
|
|
431
|
-
// else is non-progress => HALT honestly rather than risk a livelock.
|
|
273
|
+
// Anything other than shrink-by-one or the one-shot expansion is non-progress: halt.
|
|
432
274
|
if (pending.length > before - 1 && !expandedThisTick) {
|
|
433
275
|
return refuse("meta-loop made no monotone progress — halting", driver);
|
|
434
276
|
}
|
|
435
277
|
}
|
|
436
278
|
|
|
437
|
-
//
|
|
438
|
-
// facts (all INSIDE the driver's timeout guard; no unbounded post-work).
|
|
279
|
+
// Compose the answer: intersect two gathered sides (scoped), or keystone-arbitrate (global).
|
|
439
280
|
let composed;
|
|
440
281
|
if (mode === "scoped") {
|
|
441
|
-
// interpret the rule's DECLARATIVE compose spec: intersect two gathered
|
|
442
|
-
// sides, each a topic (optionally focus-bound, optionally with the focus
|
|
443
|
-
// itself unioned in — the change-footprint shape). ∅ is a real answer.
|
|
444
282
|
const sideSet = (side) => {
|
|
445
283
|
const key = side.of === "focus" ? `${side.topic}:${focus.label}` : side.topic;
|
|
446
284
|
const set = observed.get(key) || [];
|
|
@@ -453,10 +291,7 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
453
291
|
composed = intersect(sideSet(spec.a), sideSet(spec.b));
|
|
454
292
|
why.push(`compose: ${sideDesc(spec.a)} ∩ ${sideDesc(spec.b)} = ${spec.names} (${composed.length ? composed.join(", ") : `∅ — ${spec.empty}`})`);
|
|
455
293
|
} else {
|
|
456
|
-
//
|
|
457
|
-
// declared priority — the widest |priority(m)| set — tie broken by label
|
|
458
|
-
// order. The single most-worth-covering module. Only a rule declaring a
|
|
459
|
-
// global mode ever reaches here (applicability screened on rule.modes).
|
|
294
|
+
// Keystone: among the coverage violations, pick the highest priority, tie by label.
|
|
460
295
|
const violating = observed.get(rule.coverageTopic) || [];
|
|
461
296
|
const ranked = violating
|
|
462
297
|
.map((m) => ({ m, weight: (observed.get(`${rule.priorityTopic}:${m}`) || []).length }))
|
package/src/router/guardrail.mjs
CHANGED
|
@@ -1,39 +1,21 @@
|
|
|
1
|
-
// src/router/guardrail.mjs —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// registry's declared preconditions, and DEFAULT-DENY anything outside the
|
|
5
|
-
// declared, registered envelope. This is the precondition-checking half of the
|
|
6
|
-
// STRIPS model: a call fires only when its preconditions are provably satisfied.
|
|
1
|
+
// src/router/guardrail.mjs — the guardrail. Validate an
|
|
2
|
+
// EXTERNALLY-proposed `tool_use` against the registry's declared preconditions, and
|
|
3
|
+
// DEFAULT-DENY anything outside the declared, registered envelope.
|
|
7
4
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// It does NOT prove ANTECEDENT-CORRECTNESS. A cross-turn mis-binding — "it" ->
|
|
15
|
-
// the wrong Commit, say — produces a call whose symbol STILL resolves to a real
|
|
16
|
-
// entity, so it PASSES the guardrail. That is by design: binding-confidence
|
|
17
|
-
// across turns is the CHAT LEVER's job (pronoun/focus binding), not the
|
|
18
|
-
// guardrail's. A gate that leaned on Stage 4 for antecedent correctness would be
|
|
19
|
-
// trusting the wrong layer. The guardrail's contract is narrow and honest:
|
|
20
|
-
// "this symbol denotes SOMETHING real and the call is well-formed", never "this
|
|
21
|
-
// is the RIGHT something".
|
|
22
|
-
//
|
|
23
|
-
// DEFAULT-DENY: a tool name that is not a registered capability is denied outright
|
|
24
|
-
// (identical to a hallucinated/invented tool). The intentionally-unregistered
|
|
25
|
-
// unbounded tools (tmct_snippet/tmct_context*) are therefore denied here too — the
|
|
26
|
-
// registry is the whole trust boundary.
|
|
5
|
+
// Proves RESOLVABILITY (the tool is registered/declared, args are well-formed, every
|
|
6
|
+
// `resolves(param, as)` precondition binds to a real graph entity) — NOT antecedent
|
|
7
|
+
// correctness: a cross-turn mis-binding ("it" -> the wrong Commit) still resolves to a
|
|
8
|
+
// real entity and passes. "This symbol denotes something real and the call is
|
|
9
|
+
// well-formed", never "this is the right something".
|
|
27
10
|
//
|
|
28
11
|
// Pure over its inputs + ctx.resolve (the binding oracle). No network, no Date.now.
|
|
29
12
|
|
|
30
13
|
import { capabilityByName, preconditionsOf, PRECOND } from "./registry.mjs";
|
|
31
14
|
import { hallucinationsIn } from "./call-validator.mjs";
|
|
32
15
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* per tied candidate is safe. Returns `[{candidate, result}, ...]`, or
|
|
16
|
+
/** The same read-only breadth-first enrichment as resolver.mjs's `dispatchEachCandidate`:
|
|
17
|
+
* every registered capability is `readOnly:true` with an empty delete-list, so dispatching
|
|
18
|
+
* the SAME tool once per tied candidate is safe. Returns `[{candidate, result}, ...]`, or
|
|
37
19
|
* undefined when there is no dispatcher to run it with. */
|
|
38
20
|
async function dispatchEachCandidate(pool, capName, arg, ctx) {
|
|
39
21
|
if (!ctx.dispatch) return undefined;
|
|
@@ -47,53 +29,35 @@ async function dispatchEachCandidate(pool, capName, arg, ctx) {
|
|
|
47
29
|
|
|
48
30
|
/** Validate a proposed tool_use. Returns a glass-box verdict:
|
|
49
31
|
* { ok, tool, denied:[{reason,detail}], steps:[{pred,ok,...}], provenance, candidateResults? }
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* is "the" one) but, when `ctx.dispatch` is wired, ADDITIONALLY carries a
|
|
56
|
-
* top-level `candidateResults`: the SAME tool dispatched once per tied
|
|
57
|
-
* candidate (PLAN_BREADTH_FIRST_NLU.md §4 — mirrors resolver.mjs's
|
|
58
|
-
* resolveOne on the exact same ambiguity shape).
|
|
59
|
-
* - ok=true means the call is RESOLVABLE + well-formed (NOT proven antecedent-
|
|
60
|
-
* correct — see the file header).
|
|
61
|
-
* `declaredNames` may be null to skip the declared-set check (validate against
|
|
62
|
-
* the registry alone); pass it to also enforce the case/session toolset.
|
|
63
|
-
* `ctx.resolve(term)` is the resolveObject oracle; omit it to skip binding proof
|
|
64
|
-
* (structural-only validation). Async only because an ambiguous `resolves` term
|
|
65
|
-
* may dispatch each candidate via `ctx.dispatch`. */
|
|
32
|
+
* ok=false with a default-deny/undeclared/unknown-arg/missing-arg denial is structural (no
|
|
33
|
+
* graph needed); an `unresolved` step is a binding rejection, and an ambiguous `resolves`
|
|
34
|
+
* term additionally carries `candidateResults` (the tool dispatched once per tied candidate)
|
|
35
|
+
* when `ctx.dispatch` is wired. `declaredNames=null` skips the declared-set check.
|
|
36
|
+
* `ctx.resolve(term)` is the resolveObject oracle; omit it for structural-only validation. */
|
|
66
37
|
export async function guard(toolUse, declaredNames = null, ctx = {}) {
|
|
67
38
|
const name = toolUse?.name;
|
|
68
39
|
const input = toolUse && typeof toolUse.input === "object" && toolUse.input ? toolUse.input : {};
|
|
69
40
|
const denied = [];
|
|
70
41
|
const steps = [];
|
|
71
42
|
|
|
72
|
-
//
|
|
73
|
-
// grader's hallucination check as the single source of truth for structural
|
|
74
|
-
// well-formedness (unknown-tool / undeclared / unknown-arg / missing-arg).
|
|
43
|
+
// Default-deny: unknown/unregistered tool is an automatic reject.
|
|
75
44
|
const declaredList = declaredNames ? [...declaredNames] : null;
|
|
76
45
|
const cap = capabilityByName(name);
|
|
77
46
|
if (!cap) {
|
|
78
47
|
denied.push({ reason: "default-deny", detail: `"${name ?? "(none)"}" is not a registered capability` });
|
|
79
48
|
return { ok: false, tool: name ?? null, denied, steps, provenance: "registry default-deny" };
|
|
80
49
|
}
|
|
81
|
-
// structural well-formedness against the registry (and the declared set when
|
|
82
|
-
// given — an undeclared-but-registered tool is still a policy denial).
|
|
83
50
|
const structural = hallucinationsIn({ name, input }, declaredList ?? [name]);
|
|
84
51
|
for (const p of structural) {
|
|
85
|
-
//
|
|
86
|
-
// denial (we synthesised [name] as the set) — filter it out.
|
|
52
|
+
// no declared set supplied => "undeclared" isn't a real denial (we synthesised [name]).
|
|
87
53
|
if (!declaredList && p.reason === "undeclared") continue;
|
|
88
54
|
denied.push(p);
|
|
89
55
|
}
|
|
90
56
|
|
|
91
|
-
//
|
|
57
|
+
// Precondition check — the STRIPS safety gate, step by step.
|
|
92
58
|
let candidateResults;
|
|
93
59
|
for (const pre of preconditionsOf(name)) {
|
|
94
60
|
if (pre.pred === PRECOND.graphLoaded) {
|
|
95
|
-
// graph presence is the harness's responsibility; if a resolver is wired we
|
|
96
|
-
// treat graph-loaded as satisfied (resolveObject would throw without one).
|
|
97
61
|
steps.push({ step: "precondition", pred: pre.pred, ok: true });
|
|
98
62
|
} else if (pre.pred === PRECOND.anyPresent) {
|
|
99
63
|
const ok = pre.params.some((k) => input[k] !== undefined && input[k] !== null && String(input[k]).trim() !== "");
|
|
@@ -103,12 +67,10 @@ export async function guard(toolUse, declaredNames = null, ctx = {}) {
|
|
|
103
67
|
const term = input[pre.param];
|
|
104
68
|
const present = term !== undefined && term !== null && String(term).trim() !== "";
|
|
105
69
|
if (!present) {
|
|
106
|
-
// a missing required arg is already flagged structurally; record the step.
|
|
107
70
|
steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: null, ok: false });
|
|
108
71
|
continue;
|
|
109
72
|
}
|
|
110
|
-
//
|
|
111
|
-
// only assert the arg is PRESENT, not that it binds (structural mode).
|
|
73
|
+
// No oracle wired: assert the arg is PRESENT only, not that it binds.
|
|
112
74
|
if (!ctx.resolve) {
|
|
113
75
|
steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: term, ok: true, note: "structural-only (no resolver wired)" });
|
|
114
76
|
continue;
|