@polycode-projects/the-mechanical-code-talker 0.7.1 → 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/src/concept.mjs CHANGED
@@ -213,6 +213,11 @@ export function composeConcept(graph, term, { definition = null, factRows = [] }
213
213
  instances: graphInstances.slice(0, MAX_EXAMPLES).map((i) => ({
214
214
  id: i.id, label: i.label, type: i.class,
215
215
  })),
216
+ // EVERY instance id (uncapped), for discourse-count anaphora ("count them" /
217
+ // "how many of those") over a truncated listing: `instances` is capped at
218
+ // MAX_EXAMPLES and the rest lives in `remainder` as prose labels, invisible to
219
+ // the counter — so a follow-up count would undercount without the full id set.
220
+ allInstanceIds: graphInstances.map((i) => i.id),
216
221
  // the un-shown instance labels + their plural noun, for the shell's "more"
217
222
  // pagination — empty when nothing was truncated.
218
223
  remainder: remainderLabels,
@@ -1,6 +1,6 @@
1
1
  // conformance.mjs — the Repository-Interface CONTRACT TEST SUITE as a reusable kit.
2
2
  //
3
- // PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
3
+ // archive/PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
4
4
  // passes `runConformance(name, makeProvider)`. tmct's own fixture + bootstrap providers
5
5
  // pass it in `npm test`; an EXTERNAL producer (seonix) imports this kit from the
6
6
  // published package and runs the SAME suite against its native provider to claim
@@ -19,7 +19,7 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
19
  export const TEMPLATES_FILE = join(PKG_ROOT, "data", "templates", "responses.jsonl");
20
20
  export const PHRASEBOOK_FILE = join(PKG_ROOT, "data", "phrasebook", "software-phrases.txt");
21
21
 
22
- // Registers (Phase 6, PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
22
+ // Registers (Phase 6, archive/PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
23
23
  // conversational bands; `technical` is the C1 / technical-paper band whose
24
24
  // templates render item-5 mechanical conclusions (count / comparison /
25
25
  // superlative + the provenance we already compute) as advanced prose. A
package/src/finish.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // finish.mjs — Phase 7 response finishing: the segmentation IR seam.
2
- // (PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
2
+ // (archive/PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
3
3
  //
4
4
  // The governing principle is fact-invariance BY CONSTRUCTION. An answer is a
5
5
  // list of typed spans, [{ type, text }, …], carried alongside the flat string
package/src/hash.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // hash.mjs — the single home for tmct's content-address hash.
2
2
  //
3
- // FNV-1a 32-bit is deliberately home-grown (see PLAN_DEPENDENCY_STRATEGY.md): it
3
+ // FNV-1a 32-bit is deliberately home-grown (see archive/PLAN_DEPENDENCY_STRATEGY.md): it
4
4
  // must be synchronous, browser-safe, dependency-free, and — critically —
5
5
  // CROSS-VERSION STABLE, because fact ids are content-addressed by it and a fact's
6
6
  // id is its identity across the whole memory graph. Every library candidate fails
@@ -118,6 +118,70 @@ export const PHRASING_FRAMES = Object.freeze([
118
118
  { re: /^what\s+(?:defined|declared)\s+(?:the\s+)?(?:function\s+|method\s+|class\s+|module\s+|variable\s+|constant\s+)?(.+?)\??$/i, to: (m) => `where is ${m[1]} defined` },
119
119
  // "where's X defined" (the "where's" contraction is not in the contraction table)
120
120
  { re: /^where'?s\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
121
+
122
+ // PREDICATIVE QUALIFIER → the ATTRIBUTIVE form the grammar already answers. The
123
+ // adjective-qualifier post-filters (ask-vocab.mjs QUALIFIERS: tested/untested,
124
+ // public/private, exported, static/abstract/constant, …) parse in the ATTRIBUTIVE
125
+ // slot — "untested modules", "public methods" — but a developer just as naturally
126
+ // asks the PREDICATIVE "which modules are untested" / "what functions are tested",
127
+ // which hit the grammar wall (and, worse, the wall's own hint SUGGESTED "which
128
+ // functions are tested" — a shape it could not then answer). Rewriting the
129
+ // predicative "<which|what> <kind> are <QUALIFIER>" to "<QUALIFIER> <kind>" routes
130
+ // it onto the working attributive filter. Closed to the known qualifier adjectives
131
+ // (not a general "… are X" catch), and the QUALIFIER must sit immediately after
132
+ // are/is, so "which modules are NOT tested" never matches here — that keeps its own
133
+ // set-complement handler (matchNegationSet, downstream in ask.mjs's parseNegation).
134
+ {
135
+ re: /^(?:which|what)\s+(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+(?:are|is)\s+(public|private|protected|static|abstract|constant|exported|re-?exported|tested|covered|untested|uncovered)\??$/i,
136
+ to: (m) => `${m[2].toLowerCase()} ${m[1].toLowerCase()}`,
137
+ },
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
+
159
+ // CO-CHANGE → the "co-changes with" canonical the RELATIONS table answers. The
160
+ // cochange verb synonyms (ask-vocab.mjs) include "co-changes with" / "moves
161
+ // together with" / "tends to change together with", but NOT the plainest form a
162
+ // developer types — the one the README itself prints and the relation renders as:
163
+ // "what does X change together with" / "what changes together with X". Both hit a
164
+ // dead-end ("couldn't resolve one of the terms" / the grammar wall); rewriting them
165
+ // onto "what co-changes with X" routes them to the working change-coupling query.
166
+ { re: /^what\s+does\s+(.+?)\s+changes?\s+together\s+with\??$/i, to: (m) => `what co-changes with ${m[1]}` },
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" },
121
185
  ]);
122
186
 
123
187
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins
@@ -132,7 +196,7 @@ export function applyPhrasingFrames(text) {
132
196
  return text;
133
197
  }
134
198
 
135
- // ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, PLAN_CYCLE_4.md). Recognizes
199
+ // ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, archive/PLAN_CYCLE_4.md). Recognizes
136
200
  // a BARE set-negation query — "which X do not <verb> Y", "X that don't <verb> Y",
137
201
  // "modules not importing Y", "which X are not <qualifier>" — and returns a descriptor
138
202
  // {entWord, predicate} that ask.mjs's compositional grammar turns into a bounded
@@ -27,6 +27,13 @@ import { normalizeQuery, applyNegationFrames, applyPhrasingFrames } from "./norm
27
27
  import { grammarStrategy } from "./strategies/grammar.mjs";
28
28
  import { keywordSpotStrategy } from "./strategies/keywords.mjs";
29
29
  import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
30
+ // Optional Node-flavored ACE strategy — same viewer-bundle boundary as the
31
+ // ask-nlp adapter below: the ACE grammar reaches grammar/ace.mjs -> lexicon.mjs,
32
+ // which reads its committed JSON via Node fs, so an inlining viewer bundle strips
33
+ // this import; the `typeof` guard where STRATEGIES is built then degrades to an
34
+ // ace-less registry instead of throwing over an undeclared identifier. (ACE is
35
+ // async-only anyway, so the sync parseQuery path the viewer uses never ran it.)
36
+ import { aceStrategy } from "./strategies/ace.mjs";
30
37
  import { mergeStrategyResults } from "./merge.mjs";
31
38
  // Optional Node-only wink adapter — same viewer-bundle boundary as ask.mjs: an
32
39
  // inlining bundle strips this import and the `typeof` read below degrades to
@@ -38,9 +45,17 @@ import { nlpAdapter } from "../ask-nlp.mjs";
38
45
  * byte-identical to the original two-way agree/disagree behavior); noise-strip
39
46
  * is the item-10 tolerant fallback (its own class; it only fires when the
40
47
  * anchored grammar missed the text as-given, so it can never displace an
41
- * existing template parse). interpret/strategies/ace.mjs (Phase 2) will
42
- * register here the same way. */
43
- export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy];
48
+ * existing template parse). interpret/strategies/ace.mjs (Phase 2 / Stage 2) is
49
+ * the ACE-OWL controlled-fragment grammar, registered here as an ADDITIVE, own-
50
+ * class ("ace-fact") strategy. It is ASYNC on purpose: runStrategiesSync (the
51
+ * parseQuery / CHATBENCH-facing path) SKIPS Promise-returning strategies, so ACE
52
+ * adds declarative-fragment reach to interpret() while leaving the sync spine
53
+ * byte-stable (see strategies/ace.mjs for the full rationale). The `typeof` guard
54
+ * mirrors the nlpAdapter degradation: a stripped ACE import (viewer bundle) leaves
55
+ * the identifier undeclared, so the registry is ace-less there instead of a crash. */
56
+ // eslint-disable-next-line no-undef
57
+ const OPTIONAL_STRATEGIES = typeof aceStrategy !== "undefined" ? [aceStrategy] : [];
58
+ export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy, ...OPTIONAL_STRATEGIES];
44
59
 
45
60
  /** The documented normalization pre-pass: whitespace-collapse + the §3.5
46
61
  * normalization pipeline + the closed rhetorical-frame rewrites, applied ONCE
@@ -0,0 +1,49 @@
1
+ // interpret/strategies/ace.mjs — the ACE-OWL controlled-fragment grammar wired
2
+ // into the interpretation pipeline as an ADDITIVE strategy (Stage 2, "ACE reach").
3
+ //
4
+ // The ACE engine (src/grammar/ace.mjs) has existed since Phase 2, but its pipeline
5
+ // ADAPTER was the "real and empty" seam the pipeline header names (interpret/
6
+ // pipeline.mjs). This file fills it. The contract is strictly ADD-ONLY:
7
+ //
8
+ // · Its own class, "ace-fact" — DISJOINT from the graph-query strategies, so a
9
+ // clean ACE parse is a distinct-class ALTERNATE ("if you mean X then …"), never
10
+ // a same-class competitor that could displace a graph-query winner.
11
+ // · It emits a candidate ONLY on a CLEAN parse (parseAce returns triples). A
12
+ // structural-fit-with-residue (empty triples) or a total miss returns null, so
13
+ // a query sentence that merely LOOKS relation-shaped ("which modules import X",
14
+ // whose ACE residue is the "which") contributes nothing — fitting the grammar
15
+ // is a strong signal; missing it is a FEATURE and the tolerant strategies win.
16
+ //
17
+ // WHY ASYNC — the byte-stability guarantee. ask.mjs's parseQuery (the CHATBENCH
18
+ // chat-facing path) runs strategies through runStrategiesSync, which — by the
19
+ // pipeline's documented contract — SKIPS any Promise-returning strategy ("an async
20
+ // strategy can only participate via interpret()"). Registering ACE async therefore
21
+ // makes the sync parseQuery path PROVABLY untouched (CHATBENCH neutral, byte-for-
22
+ // byte) while interpret() — the async pipeline — gains the declarative-fragment
23
+ // reach. The work parseAce does is synchronous; the async wrapper is deliberate,
24
+ // the mechanical seam that keeps the chat spine frozen. (grammar/ace.mjs itself is
25
+ // imported UNCHANGED — no chat-facing edit.)
26
+
27
+ import { parseAce } from "../../grammar/ace.mjs";
28
+
29
+ /** Adapter: a clean ACE parse -> one candidate in its own class; anything else
30
+ * (residue-only structural fit, or a hard miss) -> null. `via:"exact"` — a
31
+ * controlled-grammar fit is exact evidence, never an approximate rewrite. */
32
+ export function runAce(text) {
33
+ let parsed = null;
34
+ try { parsed = parseAce(text); } catch { return null; }
35
+ if (!parsed || !Array.isArray(parsed.triples) || parsed.triples.length === 0) return null;
36
+ return { strategyId: "ace", class: "ace-fact", candidates: [{ parsed, confidence: 0.85, via: "exact", note: `ACE ${parsed.pattern}` }] };
37
+ }
38
+
39
+ /** Pipeline registration (interpret/pipeline.mjs). ASYNC on purpose (see file
40
+ * header): it participates in interpret() but is SKIPPED by runStrategiesSync,
41
+ * so parseQuery — and the CHATBENCH spine it feeds — is byte-stable. */
42
+ export const aceStrategy = {
43
+ id: "ace",
44
+ class: "ace-fact",
45
+ // eslint-disable-next-line require-await
46
+ async run(text) {
47
+ return runAce(text);
48
+ },
49
+ };
@@ -12,7 +12,7 @@ import {
12
12
  import { STOPWORDS } from "../normalize.mjs";
13
13
  import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
14
14
 
15
- // Reversible-passive detection (Cycle 6, PLAN_CYCLE_4.md): the passive auxiliaries that,
15
+ // Reversible-passive detection (Cycle 6, archive/PLAN_CYCLE_4.md): the passive auxiliaries that,
16
16
  // together with an agent-marking "by", flip the active reading, and the wh-words that
17
17
  // mark a QUESTIONED agent ("by which classes" / stranded "who is X tested by"). Bare
18
18
  // "do/does/did" are deliberately EXCLUDED — "which X do not <verb> Y" is a NEGATION, not
@@ -176,7 +176,7 @@ export function parseKeywordSpot(text, nlp = null) {
176
176
  if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
177
177
  }
178
178
 
179
- // reversible passive (Cycle 6, PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
179
+ // reversible passive (Cycle 6, archive/PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
180
180
  // agent-marking "by" plus a passive auxiliary flips the active reading, so the AGENT
181
181
  // (after "by") is the edge SUBJECT and the PATIENT the edge OBJECT. Object-first
182
182
  // phrasing is otherwise read subject-first and the edge traversed backwards. Fires
@@ -1,5 +1,5 @@
1
1
  // The BOOTSTRAP reference provider — the empty/degenerate graph a fresh repo
2
- // "contains" before anything is indexed. PLAN_REPOSITORY_INTERFACE.md deliverable
2
+ // "contains" before anything is indexed. archive/PLAN_REPOSITORY_INTERFACE.md deliverable
3
3
  // 2: "bootstrap returns honest empties".
4
4
  //
5
5
  // It implements every Repository-Interface service over the empty bootstrap
@@ -1,5 +1,5 @@
1
1
  // The FIXTURE reference provider — a small, real, self-contained code graph that
2
- // implements every Repository-Interface service. PLAN_REPOSITORY_INTERFACE.md
2
+ // implements every Repository-Interface service. archive/PLAN_REPOSITORY_INTERFACE.md
3
3
  // deliverable 2: "the executable specification an external producer reads first".
4
4
  //
5
5
  // It is a degenerate provider in the sense that its graph is tiny and its source
@@ -1,5 +1,5 @@
1
1
  // The reference Repository-Interface service over a parsed code graph.
2
- // PLAN_REPOSITORY_INTERFACE.md — "the executable specification".
2
+ // archive/PLAN_REPOSITORY_INTERFACE.md — "the executable specification".
3
3
  //
4
4
  // createGraphService(graph) returns a typed service object implementing EVERY
5
5
  // service in src/repository-interface.mjs over the `{ individuals, byId,
@@ -1,6 +1,6 @@
1
1
  // The Repository Interface — tmct's OWNED, versioned contract between "interpret
2
2
  // the query" (tmct, the brittle side) and "ask the graph for truth" (a provider,
3
- // the stable side). PLAN_REPOSITORY_INTERFACE.md.
3
+ // the stable side). archive/PLAN_REPOSITORY_INTERFACE.md.
4
4
  //
5
5
  // tmct defines and versions this shape; a provider (seonix, a fixture, a browser
6
6
  // page) IMPLEMENTS it over its native graph. Both sides already agree on the
@@ -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
+ }