@polycode-projects/the-mechanical-code-talker 0.7.0 → 0.8.0

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.
@@ -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,33 @@ 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
+ // CO-CHANGE → the "co-changes with" canonical the RELATIONS table answers. The
140
+ // cochange verb synonyms (ask-vocab.mjs) include "co-changes with" / "moves
141
+ // together with" / "tends to change together with", but NOT the plainest form a
142
+ // developer types — the one the README itself prints and the relation renders as:
143
+ // "what does X change together with" / "what changes together with X". Both hit a
144
+ // dead-end ("couldn't resolve one of the terms" / the grammar wall); rewriting them
145
+ // onto "what co-changes with X" routes them to the working change-coupling query.
146
+ { re: /^what\s+does\s+(.+?)\s+changes?\s+together\s+with\??$/i, to: (m) => `what co-changes with ${m[1]}` },
147
+ { re: /^what\s+changes?\s+together\s+with\s+(.+?)\??$/i, to: (m) => `what co-changes with ${m[1]}` },
121
148
  ]);
122
149
 
123
150
  /** Apply the phrasing frames (members-of-class + where-defined) — first match wins
@@ -132,7 +159,7 @@ export function applyPhrasingFrames(text) {
132
159
  return text;
133
160
  }
134
161
 
135
- // ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, PLAN_CYCLE_4.md). Recognizes
162
+ // ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, archive/PLAN_CYCLE_4.md). Recognizes
136
163
  // a BARE set-negation query — "which X do not <verb> Y", "X that don't <verb> Y",
137
164
  // "modules not importing Y", "which X are not <qualifier>" — and returns a descriptor
138
165
  // {entWord, predicate} that ask.mjs's compositional grammar turns into a bounded
@@ -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,120 @@
1
+ // src/router/guardrail.mjs — Stage 4 of the capability router
2
+ // (PLAN_CAPABILITY_ROUTER.md): THE GUARDRAIL. Validate an EXTERNALLY-proposed
3
+ // `tool_use` (e.g. an LLM's chosen call in the hybrid fast-path) against the
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.
7
+ //
8
+ // WHAT IT PROVES — and what it deliberately does NOT.
9
+ // The guardrail proves RESOLVABILITY: the tool exists in the registry, it was
10
+ // declared, every arg-key is one the operator accepts, every required arg is
11
+ // present, and every `resolves(param, as)` precondition binds to a real graph
12
+ // entity (delegated to resolveObject — the same oracle Stage 1 uses).
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.
27
+ //
28
+ // Pure over its inputs + ctx.resolve (the binding oracle). No network, no Date.now.
29
+
30
+ import { capabilityByName, preconditionsOf, PRECOND } from "./registry.mjs";
31
+ import { hallucinationsIn } from "../../agentbench/grade.mjs";
32
+
33
+ /** Validate a proposed tool_use. Returns a glass-box verdict:
34
+ * { ok, tool, denied:[{reason,detail}], steps:[{pred,ok,...}], provenance }
35
+ * - ok=false with a `default-deny`/`undeclared`/`unknown-arg`/`missing-arg`
36
+ * denial is a STRUCTURAL rejection (no graph needed).
37
+ * - ok=false with an `unresolved` step is a BINDING rejection (a `resolves`
38
+ * precondition whose term matched no entity, or matched ambiguously).
39
+ * - ok=true means the call is RESOLVABLE + well-formed (NOT proven antecedent-
40
+ * correct — see the file header).
41
+ * `declaredNames` may be null to skip the declared-set check (validate against
42
+ * the registry alone); pass it to also enforce the case/session toolset.
43
+ * `ctx.resolve(term)` is the resolveObject oracle; omit it to skip binding proof
44
+ * (structural-only validation). */
45
+ export function guard(toolUse, declaredNames = null, ctx = {}) {
46
+ const name = toolUse?.name;
47
+ const input = toolUse && typeof toolUse.input === "object" && toolUse.input ? toolUse.input : {};
48
+ const denied = [];
49
+ const steps = [];
50
+
51
+ // 1. DEFAULT-DENY — unknown/unregistered tool is an automatic reject. Reuse the
52
+ // grader's hallucination check as the single source of truth for structural
53
+ // well-formedness (unknown-tool / undeclared / unknown-arg / missing-arg).
54
+ const declaredList = declaredNames ? [...declaredNames] : null;
55
+ const cap = capabilityByName(name);
56
+ if (!cap) {
57
+ denied.push({ reason: "default-deny", detail: `"${name ?? "(none)"}" is not a registered capability` });
58
+ return { ok: false, tool: name ?? null, denied, steps, provenance: "registry default-deny" };
59
+ }
60
+ // structural well-formedness against the registry (and the declared set when
61
+ // given — an undeclared-but-registered tool is still a policy denial).
62
+ const structural = hallucinationsIn({ name, input }, declaredList ?? [name]);
63
+ for (const p of structural) {
64
+ // when no declared set is supplied, an "undeclared" finding is not a real
65
+ // denial (we synthesised [name] as the set) — filter it out.
66
+ if (!declaredList && p.reason === "undeclared") continue;
67
+ denied.push(p);
68
+ }
69
+
70
+ // 2. PRECONDITION CHECK — the STRIPS safety gate, step by step (the proof).
71
+ for (const pre of preconditionsOf(name)) {
72
+ if (pre.pred === PRECOND.graphLoaded) {
73
+ // graph presence is the harness's responsibility; if a resolver is wired we
74
+ // treat graph-loaded as satisfied (resolveObject would throw without one).
75
+ steps.push({ step: "precondition", pred: pre.pred, ok: true });
76
+ } else if (pre.pred === PRECOND.anyPresent) {
77
+ const ok = pre.params.some((k) => input[k] !== undefined && input[k] !== null && String(input[k]).trim() !== "");
78
+ steps.push({ step: "precondition", pred: pre.pred, params: pre.params, ok });
79
+ if (!ok) denied.push({ reason: "missing-arg", detail: `${name} needs one of ${pre.params.join("|")}` });
80
+ } else if (pre.pred === PRECOND.resolves) {
81
+ const term = input[pre.param];
82
+ const present = term !== undefined && term !== null && String(term).trim() !== "";
83
+ if (!present) {
84
+ // a missing required arg is already flagged structurally; record the step.
85
+ steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: null, ok: false });
86
+ continue;
87
+ }
88
+ // DELEGATE to resolveObject (the binding oracle). No oracle wired → we can
89
+ // only assert the arg is PRESENT, not that it binds (structural mode).
90
+ if (!ctx.resolve) {
91
+ steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: term, ok: true, note: "structural-only (no resolver wired)" });
92
+ continue;
93
+ }
94
+ const r = ctx.resolve(String(term));
95
+ const resolvedOk = Boolean(r && r.match && !r.ambiguous);
96
+ steps.push({
97
+ step: "precondition", pred: pre.pred, param: pre.param, value: term,
98
+ ok: resolvedOk,
99
+ ...(r && r.match ? { boundTo: r.match.label, boundClass: r.match.class ?? null, tier: r.tier ?? null } : {}),
100
+ ...(r && r.ambiguous ? { ambiguous: true } : {}),
101
+ });
102
+ if (!resolvedOk) {
103
+ denied.push({
104
+ reason: "unresolved",
105
+ detail: r && r.ambiguous
106
+ ? `${name}.${pre.param}="${term}" is ambiguous (narrow it)`
107
+ : `${name}.${pre.param}="${term}" resolves to no graph entity`,
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ const ok = denied.length === 0;
114
+ return { ok, tool: name, denied, steps, provenance: ok ? "resolvable (NOT proven antecedent-correct)" : "denied" };
115
+ }
116
+
117
+ /** Convenience boolean: does a proposed tool_use PASS the guardrail? */
118
+ export function admits(toolUse, declaredNames = null, ctx = {}) {
119
+ return guard(toolUse, declaredNames, ctx).ok;
120
+ }
@@ -0,0 +1,168 @@
1
+ // src/router/planner.mjs — Stage 3 of the capability router
2
+ // (PLAN_CAPABILITY_ROUTER.md): THE PLANNER. Compose a bounded, ordered plan of
3
+ // tool calls for a multi-step request, over the SAME operators Stage 1 resolves
4
+ // single-shot. Pure-JS POP/HTN + a Steel & Ho monitor-and-replan loop under a
5
+ // HARD budget — sound/complete INSIDE the declared operator model, honest-refuse
6
+ // (escalate) for novelty outside it. Deterministic, no-LLM, glass-box.
7
+ //
8
+ // THE MODEL, mapped to the literature:
9
+ // - HTN decomposition (NONLIN/SHOP2): a compound request is decomposed into an
10
+ // ORDERED list of sub-goals by declared METHODS — the sequencing connectives
11
+ // ("... then ...", "... and then ...") and the two closed recipes we author:
12
+ // the CONDITIONAL method ("if <check>, <action> [instead]") and the
13
+ // RELATIVE-FILTER method ("of the <set> <rel> X, which are <Y>"). Each leaf
14
+ // sub-goal is resolved by Stage 1 (resolveOne) — the primitive operator.
15
+ // - POP causal links (partial-order planning): each step's proof records the
16
+ // PRODUCER -> CONDITION -> CONSUMER link. An independent step's producer is
17
+ // the grounded graph (graph-loaded); a THREADED step (one whose entity came
18
+ // from a prior step via anaphora — "its subclasses", "describe it") records
19
+ // the prior STEP as its producer. That link IS the proof chain (grade.mjs's
20
+ // connectedness check reads it), never a flat ok-list. Least commitment: we
21
+ // only order what the connectives actually order.
22
+ // - Steel & Ho monitor-and-replan: after each call we read the tool_result;
23
+ // a failed sub-goal (an unresolvable entity / an operator that errors) forces
24
+ // an honest STOP (refuse/escalate) rather than pressing on with a broken
25
+ // chain. Bounded depth + a hard step counter GUARANTEE termination — no
26
+ // unbounded search can ever wedge the caller (the harness also caps us).
27
+ //
28
+ // THE OPEN-WORLD BOUNDARY, named honestly: novelty the declared methods + operators
29
+ // do not cover (a sub-goal that resolves to nothing, a connective we do not model)
30
+ // is REFUSED/ESCALATED, not guessed. Sound/complete is claimed only INSIDE the
31
+ // declared world.
32
+
33
+ import { resolveOne, extractEntity } from "./resolver.mjs";
34
+
35
+ // Hard budget — the planner may emit at most this many steps; a request that
36
+ // decomposes to more is REFUSED (escalate) rather than searched. Guarantees
37
+ // termination independent of the harness backstop.
38
+ export const MAX_STEPS = 8;
39
+
40
+ const PRONOUN_RE = /\b(?:it|its|them|those|these|that|their)\b/i;
41
+
42
+ /** HTN decomposition — turn a request into an ORDERED list of leaf sub-goals.
43
+ * Returns { method, segments:[{ text, role, thread }] }:
44
+ * - role "check" — a conditional antecedent (a test whose call still emits)
45
+ * - role "action" — a plain sub-goal
46
+ * - thread:true — the segment carries an anaphor to bind from a prior step
47
+ * A single segment (no connective) means "not multi-step" (the driver hands
48
+ * those to resolveOne directly). Pure. */
49
+ export function decompose(request) {
50
+ const raw = String(request || "").trim();
51
+
52
+ // METHOD 1 — the CONDITIONAL recipe: "if <check>, <action> [instead]".
53
+ const cond = raw.match(/^if\s+(.+?),\s*(.+?)(?:\s+instead)?$/i);
54
+ if (cond) {
55
+ return {
56
+ method: "conditional",
57
+ segments: [
58
+ { text: cond[1].trim(), role: "check", thread: PRONOUN_RE.test(cond[1]) },
59
+ { text: cond[2].trim(), role: "action", thread: PRONOUN_RE.test(cond[2]) },
60
+ ],
61
+ };
62
+ }
63
+
64
+ // METHOD 2 — the RELATIVE-FILTER recipe: "of the <set> <rel> X, which are <Y>".
65
+ // Decomposes to [produce the <set> (the <rel> over X), filter it by <Y>].
66
+ const rel = raw.match(/^of\s+the\s+(.+?),\s*which\s+(?:are\s+)?(.+?)$/i);
67
+ if (rel) {
68
+ return {
69
+ method: "relative-filter",
70
+ segments: [
71
+ { text: rel[1].trim(), role: "action", thread: false },
72
+ { text: `which are ${rel[2].trim()}`, role: "action", thread: true },
73
+ ],
74
+ };
75
+ }
76
+
77
+ // METHOD 3 — SEQUENCING: split on the ordered connectives. Least commitment:
78
+ // we only split where a connective actually is.
79
+ 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
+ .map((s) => s.replace(/^(?:then\s+|and\s+then\s+|and\s+|also\s+|check\s+|next\s+)/i, "").trim())
81
+ .filter(Boolean);
82
+ const segments = parts.map((text) => ({ text, role: "action", thread: PRONOUN_RE.test(text) }));
83
+ return { method: segments.length > 1 ? "sequence" : "single", segments };
84
+ }
85
+
86
+ /** True iff the request is multi-step (the planner owns it); else the driver
87
+ * routes it to the single-shot resolver. */
88
+ export function isMultiStep(request) {
89
+ return decompose(request).segments.length > 1;
90
+ }
91
+
92
+ /** Substitute a bound anaphor: replace a bare pronoun with the prior step's
93
+ * entity label so the leaf resolver can bind it ("its subclasses" + Widget ->
94
+ * "Widget subclasses"; "describe it" + fnAlpha -> "describe fnAlpha"). */
95
+ function bindAnaphor(text, lastEntity) {
96
+ if (!lastEntity) return text;
97
+ return text.replace(PRONOUN_RE, lastEntity);
98
+ }
99
+
100
+ /** A conditional ANTECEDENT that tests a SPECIFIC entity's coverage ("X has no
101
+ * tests", "fnAlpha is untested") is the CHECK operator tmct_tests_for over that
102
+ * entity — NOT the no-arg tmct_untested (which lists the whole codebase). Rewrite
103
+ * it to the terse "tests <entity>" command form so the leaf resolver binds the
104
+ * entity. A check with no coverage predicate is left untouched. */
105
+ function rewriteCheck(text, lastEntity) {
106
+ if (!/\b(?:untested|tested|no\s+tests?|has\s+no\s+tests?|tests?|coverage|covered)\b/i.test(text)) return text;
107
+ const entity = extractEntity(text) || lastEntity;
108
+ return entity ? `tests ${entity}` : text;
109
+ }
110
+
111
+ const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], driver, why });
112
+
113
+ /** Plan + execute a multi-step request. Returns a loopResult
114
+ * { calls, refused, terminated, proof, why, driver, observed }
115
+ * with a POP causal-link proof chain. Steel & Ho: each step is monitored; a
116
+ * failed sub-goal STOPS the plan honestly (refuse/escalate). Bounded by
117
+ * MAX_STEPS + a hard step counter. `driver` labels the row.
118
+ *
119
+ * ctx: { dispatch(name,input)->{ok,text,resolved?}, resolve(term)->resolveObject } */
120
+ export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8.0" } = {}) {
121
+ const { method, segments } = decompose(request);
122
+ if (segments.length > MAX_STEPS) {
123
+ return refuse(`plan would need ${segments.length} steps (> budget ${MAX_STEPS}) — escalate`, driver);
124
+ }
125
+
126
+ const calls = [];
127
+ const proof = [];
128
+ const why = [`HTN method: ${method} — ${segments.length} sub-goal(s)`];
129
+ let lastEntity = null; // the most-recent bound entity label (for anaphora threading)
130
+ let steps = 0;
131
+
132
+ for (let i = 0; i < segments.length; i += 1) {
133
+ if (steps >= MAX_STEPS) return refuse("step budget exhausted mid-plan — escalate", driver);
134
+ steps += 1;
135
+ const seg = segments[i];
136
+ let text = seg.thread ? bindAnaphor(seg.text, lastEntity) : seg.text;
137
+ if (seg.role === "check") text = rewriteCheck(text, lastEntity);
138
+
139
+ const r = await resolveOne(text, declaredNames, ctx, { execute: true });
140
+ if (r.refused) {
141
+ // Steel & Ho: an unresolvable sub-goal breaks the causal chain — STOP
142
+ // honestly (escalate), never emit a partial/guessed plan.
143
+ return refuse(`sub-goal ${i + 1} ("${text}") did not resolve: ${r.reason}`, driver);
144
+ }
145
+
146
+ calls.push(r.selected);
147
+ // POP causal link: the producer is the prior step when this step THREADED an
148
+ // anaphor from it; otherwise the grounded graph. Its condition is the arg the
149
+ // step needed. This is the "why step i" edge, not a flat ok.
150
+ const producer = seg.thread && i > 0 ? `step-${i}` : "graph";
151
+ const boundLabel = r.resolved?.label ?? Object.values(r.selected.input || {})[0] ?? null;
152
+ proof.push({ step: "causal-link", producer, condition: boundLabel, consumer: `step-${i + 1}:${r.selected.name}`, role: seg.role, ok: true });
153
+ for (const s of r.proof) proof.push({ ...s, ofStep: i + 1 });
154
+
155
+ if (r.resolved?.label) lastEntity = r.resolved.label;
156
+ why.push(...(r.why || []).map((w) => `[${i + 1}] ${w}`));
157
+ }
158
+
159
+ return {
160
+ calls,
161
+ refused: false,
162
+ terminated: true,
163
+ proof,
164
+ driver,
165
+ why,
166
+ observed: `plan(${method}): ${calls.map((c) => c.name).join(" -> ")}`,
167
+ };
168
+ }