@polycode-projects/the-mechanical-code-talker 0.8.1 → 0.8.2

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.
@@ -25,7 +25,18 @@ const DEFAULT_CONFIDENCE = 0.5;
25
25
  // commit-sha tier strips the noun — the anchored strategy captures the noun inside
26
26
  // its object span while keyword-spot consumes it as the entity keyword, so without
27
27
  // this the two strategies would "disagree" over a word that names no different thing.
28
- const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
28
+ // A leading DETERMINER is the same kind of non-difference (0.8.2 feel wave): the
29
+ // anchored grammar captures "the logger" while keyword-spot captures "logger", and
30
+ // the resulting "ambiguity" asked the user to choose between identical readings.
31
+ const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ")
32
+ .replace(/^(?:the|a|an)\s+/, "")
33
+ .replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
34
+
35
+ // Leading-determiner probe over a parse's term slots — the dedupe below keeps the
36
+ // det-LESS twin so downstream term resolution sees the bare object ("logger",
37
+ // never "the logger").
38
+ const LEADING_DET_RE = /^\s*(?:the|a|an)\s+/i;
39
+ const detCount = (p) => [p?.subject, p?.object].filter((t) => LEADING_DET_RE.test(String(t || ""))).length;
29
40
 
30
41
  /** Do two independently-produced parses mean the same graph query? Same
31
42
  * shape, same relation kind, and matching term(s) (both subject and object
@@ -99,6 +110,10 @@ export function mergeStrategyResults(results) {
99
110
  if (dup) {
100
111
  dup.agreed += 1;
101
112
  dup.confidence = Math.max(dup.confidence, c.confidence);
113
+ // determiner-insensitive collapse: when the agreeing parses differ only
114
+ // by a leading determiner, the det-less reading's parse survives (the
115
+ // representative's strategy/confidence standing is unchanged).
116
+ if (detCount(c.parsed) < detCount(dup.parsed)) dup.parsed = c.parsed;
102
117
  continue;
103
118
  }
104
119
  distinct.push({ ...c, agreed: 1 });
@@ -13,7 +13,7 @@
13
13
 
14
14
  import {
15
15
  CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
16
- NEGATION_FRAMES, COMMIT_CONTENT_FRAMES,
16
+ NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, VERB_TO_KIND,
17
17
  } from "../ask-vocab.mjs";
18
18
 
19
19
  export function escapeRegex(s) {
@@ -44,8 +44,92 @@ const correctionRe = (table) => new RegExp(
44
44
  const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
45
  const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
46
 
47
+ // ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
48
+ // conversational wrapping a developer puts AROUND a real question: a greeting
49
+ // lead-in with a delimiter ("hey there, quick question - …"), the modal
50
+ // politeness wrapper ("can you … please"), and the show/give-me presentation
51
+ // bridge. These are DELIMITER- and PHRASE-anchored, so they must run BEFORE the
52
+ // FILLER-strip pass below: FILLER_WORDS strips "hey"/"can you" as bare words, so
53
+ // after that pass the frames' anchors are gone while the punctuation debris
54
+ // ("there, quick question -") still poisons the parse (the playtest wall).
55
+ // Applied inside normalizeQuery — the one seam BOTH composition sites (ask.mjs
56
+ // parseQuery and interpret/pipeline.mjs normalizeInput) run first — AFTER the
57
+ // word-restoring correction tables (so "gimme"/"shwo me" are already "give me"/
58
+ // "show me") and BEFORE the filler strip. Closed patterns, applied in order to a
59
+ // small fixpoint; unmatched text passes through byte-unchanged. ----
60
+
61
+ /** Any relation verb phrase from the shared vocabulary — the show/give-me
62
+ * bridge's "this remainder is a real relation query" probe. */
63
+ const RELATION_VERB_RE = new RegExp(
64
+ "\\b(?:" + Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
65
+ "i",
66
+ );
67
+ /** A remainder that opens interrogatively is already a question — unwrap it. */
68
+ const INTERROGATIVE_LEAD_RE = /^(?:which|what|who|whose|where|when|why|how)\b/i;
69
+ /** A remainder that is a KIND listing ("show me [the] untested modules", "show
70
+ * me the tests") already belongs to the compositional list/qualifier grammar,
71
+ * whose LIST_TRIGGERS include "show me"/"give me" — leave the WHOLE text
72
+ * untouched so that working path keeps it. Two shapes: a plural kind noun in
73
+ * tail position, or a bare (det +) singular kind noun and nothing else. */
74
+ const LISTING_TAIL_KINDS = new Set([
75
+ "modules", "files", "functions", "methods", "classes", "attributes", "fields",
76
+ "properties", "variables", "globals", "commits", "changes", "tests", "members",
77
+ ]);
78
+ const BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
79
+ const isListingRemainder = (rest) => {
80
+ if (BARE_KIND_RE.test(rest)) return true;
81
+ const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
82
+ return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
83
+ };
84
+
85
+ /** Greeting lead-in with a delimiter (+ optional "quick question" bridge):
86
+ * "hey there, quick question - <Q>" -> "<Q>". The delimiter and the non-empty
87
+ * remainder are REQUIRED, so a bare "hey there" stays a greeting for chat's
88
+ * conversational lane, and "hey tmct, …" (a vocative, no delimiter after the
89
+ * greeting word) is left for the noise-strip tier that already owns it. */
90
+ const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
91
+ /** Modal politeness wrapper: "can/could/would/will you [please] <Q>[, please][?]"
92
+ * -> "<Q>". FILLER_WORDS already ate "can you"/"please" as words; this frame
93
+ * removes them as a WRAPPER so the ", please" comma never survives into the
94
+ * parsed object term. The unwrapped remainder flows on through the ordinary
95
+ * passes, so "can you tell me a joke" -> "tell me a joke" -> (FILLER) "a joke"
96
+ * — byte-identical to what the bare form normalizes to (the hm-joke wall). */
97
+ const MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
98
+ /** show/give-me presentation bridge: "show me [the] <thing>". Three-way:
99
+ * a KIND-listing remainder is left untouched (the compositional list grammar
100
+ * owns "show me untested modules"); a remainder carrying a relation verb or an
101
+ * interrogative lead is a real query merely presented — unwrap to it ("show me
102
+ * which modules import X" -> "which modules import X"); anything else is an
103
+ * entity presentation — bridge to the describe surface ("show me the store
104
+ * module" -> "describe store module"). */
105
+ const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
106
+
107
+ /** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
108
+ * repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
109
+ * please") peel fully. Pure and idempotent; unmatched text passes through. */
110
+ export function applyPreambleFrames(text) {
111
+ let q = String(text || "");
112
+ for (let pass = 0; pass < 3; pass++) {
113
+ const before = q;
114
+ let m = q.match(GREETING_PREAMBLE_RE);
115
+ if (m) q = m[1].trim();
116
+ m = q.match(MODAL_WRAPPER_RE);
117
+ if (m) q = m[1].trim();
118
+ m = q.match(SHOW_GIVE_ME_RE);
119
+ if (m) {
120
+ const rest = m[1].trim();
121
+ if (!isListingRemainder(rest)) {
122
+ q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
123
+ }
124
+ }
125
+ if (q === before) break;
126
+ }
127
+ return q;
128
+ }
129
+
47
130
  /** Free-text -> normalized free-text: contractions expanded, g-dropped words
48
- * restored, filler/politeness words stripped. Idempotent and pure — the same
131
+ * restored, closed preamble frames peeled, filler/politeness words stripped.
132
+ * Idempotent and pure — the same
49
133
  * input always normalizes the same way, so both parsing strategies see
50
134
  * identical text and their outputs are directly comparable. Deliberately
51
135
  * does NOT force lowercase: object/subject terms (module names like
@@ -59,6 +143,10 @@ export function normalizeQuery(text) {
59
143
  q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
60
144
  q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
61
145
  q = q.replace(G_DROP, "$1ing");
146
+ // closed preamble frames (greeting lead-in, modal wrapper, show/give-me
147
+ // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
148
+ // feeds the bridge) but BEFORE the filler strip erases their anchor words.
149
+ q = applyPreambleFrames(q);
62
150
  if (FILLER_WORDS.length) {
63
151
  const fillerRe = new RegExp(
64
152
  "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
@@ -173,8 +261,25 @@ export const PHRASING_FRAMES = Object.freeze([
173
261
  // separate authorship edge; "touched" IS the authorship signal (the churn commits
174
262
  // carry the author), so these are true synonyms of "who touched X", not a new
175
263
  // 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]}` },
264
+ // SHA GUARD (0.8.2 feel wave): a COMMIT object is NOT a synonym — "who is the
265
+ // author of abc1234" rewritten to "who touched abc1234" dumps the commit's
266
+ // touch-SET instead of naming its author. The negative lookahead refuses the
267
+ // rewrite when the object is a bare (optionally "commit "-prefixed) 7-40 char
268
+ // hex sha, leaving the un-rewritten form for the author lane to consume;
269
+ // file/symbol objects (anything non-sha, e.g. "deadbeef.mjs") keep the rewrite.
270
+ { re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
271
+ { re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
272
+
273
+ // HAS-TESTS → the coverage question the RELATIONS table answers. "does X have
274
+ // tests" parses "have" as a defines-verb (VERB_TO_KIND), producing the garbled
275
+ // "No — no defines edge found from X to <whatever resolves>" receipt; "is X
276
+ // tested" traverses tests edges from the WRONG side (subject = X). Both mean
277
+ // the coverage question "what tests X" — rewrite onto it. Closed to a
278
+ // tests/coverage object ("does X have methods/members" stays the members
279
+ // family) and refuses any "not" in the subject span, so the set-complement
280
+ // negations ("is X not tested") keep their own handler downstream.
281
+ { re: /^(?:does|do)\s+(?!.*\bnot\b)(.+?)\s+have\s+(?:any\s+)?(?:tests?|test\s+coverage|coverage)\??$/i, to: (m) => `what tests ${m[1]}` },
282
+ { re: /^(?:is|are)\s+(?!.*\bnot\b)(.+?)\s+tested\??$/i, to: (m) => `what tests ${m[1]}` },
178
283
 
179
284
  // NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
180
285
  // testing" is the plainest way to ask which modules are uncovered, and it hit the
@@ -78,7 +78,7 @@ const MEMORY_VOCABULARY = [
78
78
  { prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
79
79
  { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
80
80
  { prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
81
- { prop: "mgx:sourceType", note: "a Source's kind: operator | provider | corpus | web | entailed (the trust-prior key)" },
81
+ { prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | web | entailed (the trust-prior key)" },
82
82
  { prop: "mgx:sourceUrl", note: "a web Source's URL" },
83
83
  { prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
84
84
  { prop: TRUST_SCORE_PROP, note: "materialised trust cache in [0,1] — pure function of a fact's Sources + createdAt (memory/trust.mjs); invalidated when a statedBy edge is added" },
@@ -171,6 +171,7 @@ function setAttr(ind, prop, key, value) {
171
171
  function sourceIdFor(desc) {
172
172
  switch (desc?.kind) {
173
173
  case "operator": return { id: OPERATOR_SOURCE_ID, type: "operator" };
174
+ case "teach": return { id: "src:teach-chat", type: "teach" };
174
175
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
175
176
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
176
177
  case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
@@ -209,6 +210,7 @@ function upsertSource(payload, desc, createdAtCandidate) {
209
210
  * through. The tag formats are exactly what the writers produce:
210
211
  * corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
211
212
  * ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts> }
213
+ * teach:chat:<session>@<ts> → { kind:"teach", createdAt:<ts> }
212
214
  * web:<url> | url:<url> → { kind:"web", url:<url> }
213
215
  * entailed:<rule> → { kind:"entailed", rule:<rule> }
214
216
  * chat:/session: refs map to the operator; an unknown tag → null (no Source).
@@ -222,6 +224,11 @@ export function provenanceTagToSource(tag) {
222
224
  const at = head.indexOf("@");
223
225
  return { kind: "operator", createdAt: at >= 0 ? head.slice(at + 1) : "" };
224
226
  }
227
+ if (head.startsWith("teach:")) {
228
+ // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
229
+ const at = head.indexOf("@");
230
+ return { kind: "teach", createdAt: at >= 0 ? head.slice(at + 1) : "" };
231
+ }
225
232
  if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
226
233
  if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
227
234
  if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
Binary file
@@ -4,7 +4,7 @@
4
4
  // Trust is a COMPUTED attribute of a Fact — never hand-set — a pure function of
5
5
  // its Source edges, those Sources' types, and its mgx:createdAt. Three inputs
6
6
  // combine:
7
- // - a Source-TYPE PRIOR (operator > provider > corpus > web > entailed);
7
+ // - a Source-TYPE PRIOR (operator > teach > provider > corpus > web > entailed);
8
8
  // - CORROBORATION over the fact's distinct Sources by noisy-OR
9
9
  // (1 − Π(1 − wᵢ), capped at 1) — two independent web sources (0.4) reach
10
10
  // 0.64, a lone operator fact is already 1.0;
@@ -26,11 +26,15 @@
26
26
  export const TRUST_SCORE_PROP = "mgx:trustScore";
27
27
  export const TRUST_INPUTS_PROP = "mgx:trustInputs";
28
28
 
29
- /** Source-type priors — the ordering operator > provider-graph > curated-corpus
30
- * > web > unverified-entailment. The entailed value is a FLOOR before premise
31
- * adjustment (see the entailed hook below). */
29
+ /** Source-type priors — the ordering operator > teach > provider-graph >
30
+ * curated-corpus > web > unverified-entailment. `teach` is the chat teach
31
+ * lane's natural-frame writes ("remember that …", "<Name> owns <X>") — still
32
+ * operator speech, but through a looser recognizer than the ACE-parsed
33
+ * operator assert, so it sits just below the operator prior. The entailed
34
+ * value is a FLOOR before premise adjustment (see the entailed hook below). */
32
35
  export const SOURCE_PRIOR = Object.freeze({
33
36
  operator: 1.0,
37
+ teach: 0.95,
34
38
  provider: 0.9,
35
39
  corpus: 0.7,
36
40
  web: 0.4,
@@ -0,0 +1,45 @@
1
+ // src/router/call-validator.mjs — pure registry validators shared by the
2
+ // product router (resolver / guardrail / goal-reasoner) + the bench grader
3
+ // (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
4
+ // bench code — so the product←bench dependency stays inverted: the bench
5
+ // imports the product, never the other way round. No I/O, no Date.now, no LLM.
6
+
7
+ import { isCapability, argKeysOf, requiredArgsOf, preconditionsOf, PRECOND } from "./registry.mjs";
8
+
9
+ /** Every way a single produced call can be a HALLUCINATION — the one thing a
10
+ * deterministic router must never do. Returns [] for a clean call, else a list
11
+ * of { reason, detail }:
12
+ * - "undeclared" — name is not in the case's declared toolset
13
+ * - "unknown-tool"— name is not a registry capability at all
14
+ * - "unknown-arg" — an input key the capability does not accept
15
+ * - "missing-arg" — a required arg absent (and no any-present precond covers it)
16
+ * Any nonempty result = AUTOMATIC FAIL for the case. */
17
+ export function hallucinationsIn(call, declaredTools) {
18
+ const problems = [];
19
+ const name = call?.name;
20
+ if (typeof name !== "string" || !name) return [{ reason: "unknown-tool", detail: "no tool name" }];
21
+ if (!isCapability(name)) return [{ reason: "unknown-tool", detail: name }];
22
+ if (!declaredTools.includes(name)) problems.push({ reason: "undeclared", detail: name });
23
+ const input = call.input && typeof call.input === "object" ? call.input : {};
24
+ const accepted = argKeysOf(name);
25
+ for (const key of Object.keys(input)) {
26
+ if (!accepted.has(key)) problems.push({ reason: "unknown-arg", detail: `${name}.${key}` });
27
+ }
28
+ // required-arg presence, honoring an any-present disjunction (search: query|kind)
29
+ const anyGroups = preconditionsOf(name)
30
+ .filter((p) => p.pred === PRECOND.anyPresent)
31
+ .map((p) => p.params);
32
+ const present = (k) => input[k] !== undefined && input[k] !== null && String(input[k]).trim() !== "";
33
+ for (const req of requiredArgsOf(name)) {
34
+ if (!present(req)) problems.push({ reason: "missing-arg", detail: `${name}.${req}` });
35
+ }
36
+ for (const group of anyGroups) {
37
+ if (!group.some(present)) problems.push({ reason: "missing-arg", detail: `${name} needs one of ${group.join("|")}` });
38
+ }
39
+ return problems;
40
+ }
41
+
42
+ /** True iff a produced call is dispatchable-shaped (no hallucination). */
43
+ export function isCallWellFormed(call, declaredTools) {
44
+ return hallucinationsIn(call, declaredTools).length === 0;
45
+ }
@@ -43,8 +43,8 @@
43
43
 
44
44
  import { backwardChain, extractEntity } from "./resolver.mjs";
45
45
  import { capabilityByName, effectsOf } from "./registry.mjs";
46
- import { hallucinationsIn } from "../../agentbench/grade.mjs";
47
- import { intersect } from "../../agentbench/results.mjs";
46
+ import { hallucinationsIn } from "./call-validator.mjs";
47
+ import { intersect } from "./set-algebra.mjs";
48
48
 
49
49
  // Hard OUTER-tick budget — the meta-loop runs at most this many ticks, then
50
50
  // REFUSES (escalate). Independent of BDI convergence and of the monotone
@@ -55,8 +55,23 @@ export const MAX_TICKS = 16;
55
55
  // ---- the DECLARED goal model (data, mirroring registry.mjs's STRIPS operators)
56
56
  // A goal-rule is a maintenance INVARIANT over the graph, plus the epistemic
57
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
58
+ // that settles ties in first-step arbitration. Growing this set is the "long-chain
59
59
  // deduction library" the RFC flags — same discipline as syllogise's rule set.
60
+ //
61
+ // Each rule declares (pure frozen data, no code):
62
+ // focusClass — the entity class the scoped reading binds its focus to.
63
+ // modes — which deduced scopes the rule covers ("scoped" = a bound focus;
64
+ // "global" = whole-graph keystone arbitration). A rule whose
65
+ // sub-goals are all entity-scoped has no global reading.
66
+ // subGoals — the epistemic facts to gather, IN DECLARED ORDER (each
67
+ // backward-chains to a capability, exactly like an NL intent).
68
+ // compose — the declarative fold of the gathered facts into the scoped
69
+ // answer: intersect(a, b), each side naming a gathered topic,
70
+ // optionally bound to the focus (`of:"focus"`), optionally with
71
+ // the focus itself unioned in (`withFocus` — the change footprint).
72
+ // priorityTopic / coverageTopic — the global keystone arbitration keys
73
+ // (argmax |priority(m)| over the coverage-violating set).
74
+ // achieves — the meta-goal topic the composed answer achieves.
60
75
  export const GOAL_RULES = Object.freeze([
61
76
  Object.freeze({
62
77
  id: "coverage-invariant",
@@ -65,8 +80,8 @@ export const GOAL_RULES = Object.freeze([
65
80
  // closure) MUST have direct test coverage. A Module that is untested AND
66
81
  // impactful VIOLATES it — an active goal to close the coverage gap.
67
82
  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).
83
+ focusClass: "Module",
84
+ modes: Object.freeze(["scoped", "global"]),
70
85
  subGoals: Object.freeze(["impact", "untested"]),
71
86
  // the DECLARED priority key for first-step arbitration: a violation's
72
87
  // priority is its blast radius |impact(module)| — the wider the reach, the
@@ -74,9 +89,41 @@ export const GOAL_RULES = Object.freeze([
74
89
  priorityTopic: "impact",
75
90
  // the coverage predicate the invariant screens on.
76
91
  coverageTopic: "untested",
92
+ // scoped fold: untested ∩ ({focus} ∪ impact(focus)) — the change footprint.
93
+ compose: Object.freeze({
94
+ op: "intersect",
95
+ a: Object.freeze({ topic: "untested" }),
96
+ b: Object.freeze({ topic: "impact", of: "focus", withFocus: true }),
97
+ names: "the change's untested footprint",
98
+ empty: "no coverage gap",
99
+ }),
77
100
  // the meta-goal topic the composed answer achieves (backward-chained below).
78
101
  achieves: "coverage-gap",
79
102
  }),
103
+ Object.freeze({
104
+ id: "cochange-risk-invariant",
105
+ kind: "maintenance",
106
+ // INVARIANT: a module CHANGE-COUPLED with the focus (they historically land
107
+ // in the same commits) MUST have direct test coverage. A coupled module that
108
+ // is untested VIOLATES it — an active goal over the focus's coupling set.
109
+ invariant: "a module change-coupled with the focus must be tested",
110
+ focusClass: "Module",
111
+ // scoped ONLY: both sub-goals are read relative to a bound focus; there is
112
+ // no whole-graph keystone reading declared for change-coupling.
113
+ modes: Object.freeze(["scoped"]),
114
+ subGoals: Object.freeze(["cochanges", "untested"]),
115
+ priorityTopic: "cochanges",
116
+ coverageTopic: "untested",
117
+ // scoped fold: cochanges(focus) ∩ untested — the coupled-but-untested set.
118
+ compose: Object.freeze({
119
+ op: "intersect",
120
+ a: Object.freeze({ topic: "cochanges", of: "focus" }),
121
+ b: Object.freeze({ topic: "untested" }),
122
+ names: "the change-coupled untested set",
123
+ empty: "every change-coupled module is tested",
124
+ }),
125
+ achieves: "cochange-risk",
126
+ }),
80
127
  ]);
81
128
 
82
129
  /** Backward-chain a meta-goal topic to the declared goal-rule that achieves it —
@@ -85,6 +132,28 @@ export function backwardChainGoal(topic) {
85
132
  return GOAL_RULES.find((r) => r.achieves === topic) || null;
86
133
  }
87
134
 
135
+ /** THE RULE-SELECTION DEDUCTION (replaces the old single-rule hard-wiring): a
136
+ * declared goal-rule APPLIES to a request iff
137
+ * (1) every one of its epistemic sub-goal topics backward-chains to a
138
+ * capability IN the declared toolset (the closed-world groundability
139
+ * screen — the meta-level twin of "never select an out-of-set call"),
140
+ * (2) the deduced mode is one of the rule's declared modes, and
141
+ * (3) a scoped reading's bound focus is of the rule's declared focusClass.
142
+ * Pure over the goal model + registry: nothing here reads the request string.
143
+ * The caller REFUSES on zero matches (the open-world goal-generation seam) and
144
+ * on more than one (an ambiguous meta-goal — arbitration between meta-goals is
145
+ * undeclared, so guessing one would be an invented goal). */
146
+ export function applicableRules(declaredTools, focus, mode) {
147
+ const declared = Array.isArray(declaredTools) ? declaredTools : [];
148
+ return GOAL_RULES.filter((rule) =>
149
+ rule.modes.includes(mode)
150
+ && (mode !== "scoped" || (focus != null && focus.class === rule.focusClass))
151
+ && rule.subGoals.every((topic) => {
152
+ const cap = backwardChain(topic);
153
+ return Boolean(cap && declared.includes(cap.name));
154
+ }));
155
+ }
156
+
88
157
  const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], composed: null, driver, why });
89
158
 
90
159
  /** THREATS lifted to the meta-level: any pending intention whose needed condition
@@ -130,11 +199,12 @@ async function groundSubGoal(topic, entityLabel, tools, ctx) {
130
199
  }
131
200
 
132
201
  /** 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. Pureunit-testable in isolation. */
135
- export function dropCondition(intention, observed, mode, focus) {
202
+ * achieved / impossible / its goal lapses. `focusClass` is the SELECTED rule's
203
+ * declared focus class (never a literal here the rule is the authority).
204
+ * Returns the reason string, or null to KEEP committing to it. Pure. */
205
+ export function dropCondition(intention, observed, mode, focus, focusClass) {
136
206
  if (observed.has(intention.key)) return "achieved"; // fact now gathered
137
- if (mode === "scoped" && (!focus || focus.class !== "Module")) return "lapsed"; // focus moved
207
+ if (mode === "scoped" && (!focus || focus.class !== focusClass)) return "lapsed"; // focus moved
138
208
  return null; // else keep the commitment
139
209
  }
140
210
 
@@ -148,21 +218,32 @@ export function dropCondition(intention, observed, mode, focus) {
148
218
  * ctx: { dispatch(name,input)->{ok,result}, resolve(term)->{match,ambiguous} }. */
149
219
  export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" } = {}) {
150
220
  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
221
 
154
- // STEP 1 — deduce the goal scope from the DECLARED model + a bound focus.
222
+ // STEP 1 — deduce the goal scope from the DECLARED model + a bound focus,
223
+ // then SELECT the goal-rule by pure applicability (no request keyword ever):
224
+ // a bound focus reads scoped, no focus reads global (keystone arbitration).
155
225
  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)
226
+ const mode = focus ? "scoped" : "global";
160
227
 
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);
228
+ // The open-world goal-generation seam, named honestly: a resolved focus whose
229
+ // class NO declared goal-rule scopes is REFUSED, never given an invented goal.
230
+ if (focus && !GOAL_RULES.some((r) => r.focusClass === focus.class)) {
231
+ const covered = [...new Set(GOAL_RULES.map((r) => r.focusClass))].join("/");
232
+ return refuse(`open-world: no declared goal-rule covers a ${focus.class} focus (the declared goal-rules are ${covered}-scoped) — escalate`, driver);
233
+ }
234
+
235
+ // Rule selection is a DEDUCTION over the goal model + the declared toolset:
236
+ // 0 applicable rules => the same open-world seam (nothing declared grounds the
237
+ // request's scope in this toolset); >1 => an AMBIGUOUS meta-goal (arbitration
238
+ // between meta-goals is undeclared) — both are honest refusals, never a guess.
239
+ const applicable = applicableRules(declared, focus, mode);
240
+ if (!applicable.length) {
241
+ return refuse(`open-world: no declared goal-rule is applicable in ${mode} mode (each needs a sub-goal capability outside the declared toolset, or a scope it does not declare) — escalate`, driver);
242
+ }
243
+ if (applicable.length > 1) {
244
+ return refuse(`ambiguous meta-goal: ${applicable.length} declared goal-rules apply (${applicable.map((r) => r.id).join(", ")}) — meta-goal arbitration is undeclared, refuse rather than guess — escalate`, driver);
165
245
  }
246
+ const rule = applicable[0];
166
247
 
167
248
  // the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
168
249
  // twin of resolver.mjs's "backward-chain => <capability>" provenance).
@@ -175,15 +256,22 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
175
256
  const calls = [];
176
257
  const observed = new Map(); // intention.key -> gathered result set
177
258
 
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 }];
259
+ // STEP 2/3 — the pending INTENTIONS: the rule's epistemic sub-goals IN
260
+ // DECLARED ORDER (arbitration is least-commitment: min order first, the
261
+ // keystone selection over the gathered facts happens at compose). A topic
262
+ // binds the focus iff its capability declares a REQUIRED parameter (read from
263
+ // the registry, never special-cased by topic name); in global mode there is
264
+ // no focus to bind, so entity-scoped topics are deferred to the GDA expansion
265
+ // (gather the coverage scan first, then EXPAND to priority-of-each violator).
266
+ const bindsEntity = (topic) => {
267
+ const cap = backwardChain(topic);
268
+ return Boolean(cap && cap.parameters.some((p) => p.required));
269
+ };
270
+ const pending = rule.subGoals
271
+ .filter((topic) => mode === "scoped" || !bindsEntity(topic))
272
+ .map((topic, i) => (mode === "scoped" && bindsEntity(topic)
273
+ ? { topic, of: focus.label, key: `${topic}:${focus.label}`, order: i }
274
+ : { topic, of: null, key: topic, order: i }));
187
275
 
188
276
  let committed = null; // the persisted BDI intention (not re-derived each tick)
189
277
  let expanded = false; // one-shot guard: the single bounded GDA expansion
@@ -197,7 +285,7 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
197
285
  // (3b) PERSISTENCE — keep the committed intention unless a BDI drop condition
198
286
  // fires; only THEN re-arbitrate. This is the "commitment, not recomputed
199
287
  // preference" that stops the loop thrashing.
200
- if (committed && dropCondition(committed, observed, mode, focus)) committed = null;
288
+ if (committed && dropCondition(committed, observed, mode, focus, rule.focusClass)) committed = null;
201
289
  if (!committed || !pending.includes(committed)) {
202
290
  // (3a) FIRST-STEP ARBITRATION — least-commitment: the lowest declared order
203
291
  // among pending. Threat-aware: skip a step that would clobber another
@@ -220,17 +308,17 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
220
308
  pending.splice(pending.indexOf(committed), 1);
221
309
  committed = null;
222
310
 
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.
311
+ // GDA EXPANSION (monitor -> replan), ONCE: on observing the coverage set in
312
+ // global mode (guarded on the rule DECLARING a global reading), expand to
313
+ // the priority sub-goal for each violating module, so arbitration can rank
314
+ // them. Bounded by the finite coverage set and fired at most once (the
315
+ // `expanded` guard) => the pending set still converges.
228
316
  let expandedThisTick = false;
229
- if (mode === "global" && achievedTopic === rule.coverageTopic && !expanded) {
317
+ if (mode === "global" && rule.modes.includes("global") && achievedTopic === rule.coverageTopic && !expanded) {
230
318
  expanded = true;
231
319
  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 }));
320
+ const violating = observed.get(rule.coverageTopic) || [];
321
+ violating.forEach((m, i) => pending.push({ topic: rule.priorityTopic, of: m, key: `${rule.priorityTopic}:${m}`, order: 100 + i }));
234
322
  }
235
323
 
236
324
  // the invariant, enforced mechanically: the pending set shrank by one this
@@ -245,21 +333,31 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
245
333
  // facts (all INSIDE the driver's timeout guard; no unbounded post-work).
246
334
  let composed;
247
335
  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"})`);
336
+ // interpret the rule's DECLARATIVE compose spec: intersect two gathered
337
+ // sides, each a topic (optionally focus-bound, optionally with the focus
338
+ // itself unioned in — the change-footprint shape). is a real answer.
339
+ const sideSet = (side) => {
340
+ const key = side.of === "focus" ? `${side.topic}:${focus.label}` : side.topic;
341
+ const set = observed.get(key) || [];
342
+ return side.withFocus ? [focus.label, ...set] : set;
343
+ };
344
+ const sideDesc = (side) => (side.withFocus
345
+ ? `({${focus.label}} ∪ ${side.topic})`
346
+ : side.of === "focus" ? `${side.topic}(${focus.label})` : side.topic);
347
+ const spec = rule.compose;
348
+ composed = intersect(sideSet(spec.a), sideSet(spec.b));
349
+ why.push(`compose: ${sideDesc(spec.a)} ∩ ${sideDesc(spec.b)} = ${spec.names} (${composed.length ? composed.join(", ") : `∅ — ${spec.empty}`})`);
253
350
  } 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 }))
351
+ // KEYSTONE arbitration: among the coverage violations, pick the highest
352
+ // declared priority — the widest |priority(m)| set — tie broken by label
353
+ // order. The single most-worth-covering module. Only a rule declaring a
354
+ // global mode ever reaches here (applicability screened on rule.modes).
355
+ const violating = observed.get(rule.coverageTopic) || [];
356
+ const ranked = violating
357
+ .map((m) => ({ m, weight: (observed.get(`${rule.priorityTopic}:${m}`) || []).length }))
260
358
  .sort((a, b) => b.weight - a.weight || String(a.m).localeCompare(String(b.m)));
261
359
  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})` : "∅"}`);
360
+ why.push(`keystone: argmax |${rule.priorityTopic}| over ${violating.length} ${rule.coverageTopic} module(s) => ${composed.length ? `${composed[0]} (weight ${ranked[0].weight})` : "∅"}`);
263
361
  }
264
362
 
265
363
  return { calls, refused: false, terminated: true, proof, why, composed, driver, observed: `goal(${mode}): ${calls.map((c) => c.name).join(" -> ")}` };
@@ -28,7 +28,7 @@
28
28
  // Pure over its inputs + ctx.resolve (the binding oracle). No network, no Date.now.
29
29
 
30
30
  import { capabilityByName, preconditionsOf, PRECOND } from "./registry.mjs";
31
- import { hallucinationsIn } from "../../agentbench/grade.mjs";
31
+ import { hallucinationsIn } from "./call-validator.mjs";
32
32
 
33
33
  /** Validate a proposed tool_use. Returns a glass-box verdict:
34
34
  * { ok, tool, denied:[{reason,detail}], steps:[{pred,ok,...}], provenance }
@@ -74,7 +74,28 @@ export function decompose(request) {
74
74
  };
75
75
  }
76
76
 
77
- // METHOD 3 — SEQUENCING: split on the ordered connectives. Least commitment:
77
+ // METHOD 3 — the MEMBER-FILTER recipe: "which/what methods|members of X
78
+ // (end up|eventually)? calling/reaching Y". A C1 surface-syntax recipe like the
79
+ // conditional and relative-filter methods above (the C1 discipline: a closed,
80
+ // authored shape — NOT the C2 goal-reasoner's deduction). Decomposes to
81
+ // [enumerate members(X), filter by bounded transitive call-reach of Y]. The
82
+ // second segment is the filter TARGET, role "member-filter": the DRIVER owns
83
+ // the per-member callees hop + the reachability fold (driver-resolver.mjs) —
84
+ // segment 2 is not a resolvable leaf sub-goal on its own.
85
+ const mem = raw.match(
86
+ /^(?:which|what)\s+(?:methods?|members?)\s+of\s+(.+?)\s+(?:(?:end\s+up|eventually)\s+)?(?:calls?|calling|reach(?:es|ing)?|invokes?|invoking)\s+(.+?)\s*\??$/i,
87
+ );
88
+ if (mem) {
89
+ return {
90
+ method: "member-filter",
91
+ segments: [
92
+ { text: `members ${mem[1].trim()}`, role: "action", thread: false },
93
+ { text: mem[2].trim(), role: "member-filter", thread: true },
94
+ ],
95
+ };
96
+ }
97
+
98
+ // METHOD 4 — SEQUENCING: split on the ordered connectives. Least commitment:
78
99
  // we only split where a connective actually is.
79
100
  const parts = raw.split(/\s*(?:,\s*then\s+|,\s+and\s+then\s+|\s+and\s+then\s+|\s+then\s+|,\s+|\s+and\s+)\s*/i)
80
101
  .map((s) => s.replace(/^(?:then\s+|and\s+then\s+|and\s+|also\s+|check\s+|next\s+)/i, "").trim())