@polycode-projects/the-mechanical-code-talker 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -12,10 +12,28 @@ mapped into them (item numbers retained for traceability); the seven sketches
12
12
  formerly held in `code-talker-ideas.txt` are folded into items 8–11 below and
13
13
  the file has been deleted.
14
14
 
15
- ## Where we are now (2026-07-07)
15
+ ## Where we are now (2026-07-08)
16
16
 
17
- `npm test` green (**1033**). A version bump to reflect this session's wave is imminent; see
18
- `HANDOVER.md` for the exact release status.
17
+ `npm test` green (**1055**). **v0.9.6, pushed** (0.8.2 0.9.6 across this session see
18
+ `HANDOVER.md` for the exact release chain).
19
+
20
+ ### Doing right now (5 background clusters, all worktree-isolated, merged sequentially as each lands)
21
+
22
+ - **Cluster A** — Bug 6 (scoped-listing false-empty) + Bug 7 (modal-in-fuzzy-cascade) + the
23
+ chat-feel residual pair (items 1-2 of the trio's siblings) + `edgesOfKind` memoization +
24
+ ontology numeric-vocabulary declaration.
25
+ - **Cluster B** — Bug 8 (goal-reasoner confident-wrong on off-domain requests) + refreshing
26
+ `PLAN_CAPABILITY_ROUTER.md`'s stale stage-5 status text.
27
+ - **Cluster C** — `PLAN_CHAT_FEEL.md` item 6, the pronoun/temporal/discourse-count trio
28
+ (measured red sets from an earlier advisor tick; re-measured against the current graded pool
29
+ before any fix).
30
+ - **Cluster D** — `PLAN_CODE.md` Track 1 (GOAL_RULE/PHRASING_FRAMES synthesis) — operator
31
+ sign-off given this session; Tracks 2-4 remain unsigned-off and untouched.
32
+ - **Cluster E** — `PLAN_INFERENCE_TESTING.md` Stage 1 (`cax-sco` rule) + Stage 2 (proof-chain
33
+ receipts) in `src/syllogise.mjs`, targeting the measured chat-A2 50% ceiling.
34
+
35
+ Also in flight: playtest sprint rounds 4-8 (3/3 rounds so far shipped a real fix), the strategy
36
+ advisor (restarted, 5-min re-arm).
19
37
 
20
38
  ### Now: shipped this session
21
39
 
@@ -26,7 +44,7 @@ the file has been deleted.
26
44
  - **A new feature: predicate-based "find" queries.** "find me the payment class" now works:
27
45
  type-filtered, fuzzy property-surface matching, with a narrow-then-broaden inheritance-aware
28
46
  cascade and a boolean-fold generalization for compositional predicate queries. Design:
29
- `PLAN_PREDICATE_QUERIES.md`.
47
+ `archive/PLAN_PREDICATE_QUERIES.md`.
30
48
  - **Two research tracks landed as code.** The ontology plan's two inert synonym resources
31
49
  (ConceptNet synonym/similar-to rows, phrasebook synonym families) are now wired into query-time
32
50
  matching, and the disjointness premise set plus numeric vocabulary grew
@@ -41,18 +59,25 @@ the file has been deleted.
41
59
  end to end, and surfaced a real goal-reasoner honesty gap (Bug 8, below).
42
60
  - **A session-loop fix.** A throwing turn no longer aborts a piped/non-interactive session.
43
61
  - **4 new plan docs.** `PLAN_ontology-hierarchies.md`, `PLAN_INFERENCE_TESTING.md` (revised so
44
- infbench generation is mechanical, not hand-authored), `PLAN_PREDICATE_QUERIES.md`, and
62
+ infbench generation is mechanical, not hand-authored), `archive/PLAN_PREDICATE_QUERIES.md`, and
45
63
  `PLAN_CODE.md` (new, program synthesis over tmct's closed DSLs, gated on explicit operator
46
- sign-off per track, not built yet).
64
+ sign-off per track — Track 1 signed off, see "Doing" above).
65
+ - **`PLAN_TMCT_ECOSYSTEM_INTEGRATION.md`**, landed — a code-grounded tmct/bedrock-meter/marginalia
66
+ integration plan. Corrects a standing wrong assumption (the `/v1/messages` shim was believed
67
+ unmerged; it's been on `main` since 0.8.0) and confirms seonix has already fully migrated onto
68
+ tmct as a real dependency.
69
+ - **A `/narrate` debug mode.** Opt-in (`/narrate on`, `--narrate`, `TMCT_NARRATE=1`) per-turn
70
+ decision narrative — which lane/pattern matched, results + sources, a deterministic goal
71
+ deduction — built for playtest-sprint debugging; zero cost when off (byte-identical regression
72
+ guard).
47
73
 
48
74
  ### Next: the open follow-ups
49
75
 
50
76
  In priority order (full detail and measured targets in `HANDOVER.md`):
51
77
 
52
- 1. **Bug 8.** The goal-reasoner's global-mode deduction answers requests unrelated to any deduced
53
- goal with false confidence, once no focus entity binds (`src/router/goal-reasoner.mjs:226`).
54
- The most serious open issue: a genuine confident-wrong failure in the flagship
55
- zero-hallucination capability. Needs a real semantic gate.
78
+ 1. **Bug 8 — FIXED.** A global-mode domain gate now requires the request itself (not just the
79
+ caller's declared toolset) to parse as a query about the deduced goal's domain, reusing
80
+ `ask.mjs`'s own grammar. Zero-hallucination baseline unchanged; independently re-verified.
56
81
  2. **Bug 6.** Scoped listing false-empty: "list modules in `<pkg>`" returns empty though the
57
82
  unscoped lister shows matches. Found dogfooding a 191k-entity monorepo graph.
58
83
  3. **Bug 7.** A modal auxiliary ("should") survives the fuzzy-correction cascade and misreads as
@@ -62,8 +87,12 @@ In priority order (full detail and measured targets in `HANDOVER.md`):
62
87
  5. **`PLAN_CODE.md`'s sign-off decision.** Track 1 (rule/frame synthesis) is the lowest-risk
63
88
  candidate; decide with the operator whether to greenlight it.
64
89
  6. Smaller chat-feel residuals from the 0.8.2 confirmation playtest, the Track-1 trio (pronoun,
65
- temporal, discourse-count, measured red sets), `edgesOfKind` memoization for monorepo-scale
66
- latency, and the version bump plus push.
90
+ temporal, discourse-count, measured red sets), and `edgesOfKind` memoization for monorepo-scale
91
+ latency. (The version bump plus push is done — see "Now" above.)
92
+ 7. **`SKILL_PLAYTEST_SPRINT.md`, in progress.** A capped, delegated, chained playtest loop (each
93
+ round a background chat session against `examples/mini-webapp`, appraised and fixed+shipped
94
+ live). Rounds 1-3 shipped 3 real fixes (0.9.3-0.9.5); cap raised from 3 to 8 rounds mid-run;
95
+ continuing.
67
96
 
68
97
  ### Later: deferred by design, staged inside each plan
69
98
 
package/bin/tmct.mjs CHANGED
@@ -35,6 +35,10 @@ Usage:
35
35
  tmct interactive chat (the headline surface)
36
36
  tmct chat [--repo <abs>] chat over a specific repo's graph
37
37
  [--ephemeral] read the graph but write nothing back (demo/read-only)
38
+ [--narrate] start with narrate mode on — a verbose, developer-facing
39
+ trace of decision points/matched pattern/results/goal per
40
+ turn, appended under a "--- narrate ---" marker (also
41
+ TMCT_NARRATE=1; toggle mid-session with /narrate on|off)
38
42
  [--plain] force the plain readline shell (the default when
39
43
  stdin/stdout is not a terminal)
40
44
  tmct memory [--repo <abs>] what tmct remembers: facts, utterances, sessions,
@@ -277,16 +281,21 @@ async function main() {
277
281
  // code graph, no .tmct/memory dropped under it. A demo you can run repeatedly
278
282
  // on a checked-in example without ever dirtying it.
279
283
  const ephemeral = rest.includes("--ephemeral");
284
+ // `--narrate` (or TMCT_NARRATE=1, read directly by createSession from
285
+ // process.env — no extra wiring needed for the env-var form): start the
286
+ // session with the verbose developer/debug narrate mode already on. Default
287
+ // OFF; `/narrate on`/`/narrate off` also toggles it mid-session.
288
+ const narrate = rest.includes("--narrate");
280
289
  // The shell gate: a real terminal gets the full-screen Ink TUI; `--plain` or a
281
290
  // non-TTY stream (pipes, scripts, the test suite) gets the readline shell. Both
282
291
  // drive the same createSession sink — only the drawing differs.
283
292
  const plain = rest.includes("--plain") || !process.stdin.isTTY || !process.stdout.isTTY;
284
293
  if (plain) {
285
294
  const { runChat } = await import("../src/chat.mjs");
286
- await runChat({ repoPath, ephemeral });
295
+ await runChat({ repoPath, ephemeral, narrate });
287
296
  } else {
288
297
  const { runTui } = await import("../src/tui/app.mjs");
289
- await runTui({ repoPath, ephemeral });
298
+ await runTui({ repoPath, ephemeral, narrate });
290
299
  }
291
300
  return;
292
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
package/src/chat.mjs CHANGED
@@ -79,6 +79,144 @@ const ASK_ENVELOPE_DELIM = "\n\n---tmct_ask---\n";
79
79
  const CONTEXT_WORDS = new Set(["it", "this", "that", "here"]);
80
80
  const isPronoun = (s) => CONTEXT_WORDS.has(String(s || "").trim().toLowerCase());
81
81
 
82
+ // ---- narrate mode (opt-in, developer/debug-facing) -------------------------
83
+ // "in the tmct interface let's be a lot more verbose, you and I are the only
84
+ // users" — a narrative of decision points, the matched pattern, the results +
85
+ // sources, and a deduced per-turn goal, appended to the answer. OFF by default
86
+ // (a `/narrate on`/`/narrate off` toggle, or a `--narrate`/TMCT_NARRATE=1
87
+ // session start): the DEFAULT (narrate:false) path must stay byte-identical
88
+ // to before this feature existed, so every site below is a cheap `trace?.push`
89
+ // no-op when tracing is off — runTurn only allocates the `trace` array at all
90
+ // when narrate is true. Design: a single mutable `trace` array threaded
91
+ // through runTurn -> runAsk/runCommand/conversationalTurn (via `ctx.trace`);
92
+ // each stage pushes plain, already-formatted lines tagged with their own
93
+ // category prefix ("goal:", "lane:", "pattern:", "result:", "source:",
94
+ // "intermediate:") — the trace array IS the narrative, in decision order; no
95
+ // separate structured side-channel to keep in sync. renderNarration (below,
96
+ // next to runTurn) buckets by that prefix into the sections the operator
97
+ // asked for and appends the block under NARRATE_MARKER, AFTER finish() and
98
+ // OUTSIDE of `last.answer` — so a narrated turn's repeat-detection / why-
99
+ // re-render logic (which compares `last.answer` bytes) is unaffected by
100
+ // whether narrate happens to be on.
101
+ export const NARRATE_MARKER = "--- narrate ---";
102
+
103
+ /** Push one narrative line, only when tracing is on (`trace` is the mutable
104
+ * array runTurn allocates for a narrate:true turn, else null/undefined). */
105
+ function note(trace, text) { if (trace) trace.push(text); }
106
+
107
+ /** relation `kind` (ask-vocab.mjs RELATIONS) -> a short, deterministic
108
+ * statement of what a person asking that KIND of question is probably after.
109
+ * Deliberately a small, honest bucket lookup over the query SHAPE the engine
110
+ * already computed — tmct is no-LLM, so goal deduction is table-driven, never
111
+ * free-text generation. A kind/shape this table doesn't recognise falls
112
+ * through to a generic line in deduceGoalFromParsed, never a fabricated guess. */
113
+ const GOAL_BY_KIND = {
114
+ imports: "understand a dependency/import relationship",
115
+ uses: "understand a dependency/usage relationship (imports and/or calls)",
116
+ calls: "understand a call relationship",
117
+ callsSymbol: "understand a call relationship",
118
+ defines: "locate what a module/class defines",
119
+ contains: "understand class membership (methods/attributes)",
120
+ tests: "assess test coverage",
121
+ inherits: "understand a class hierarchy/inheritance relationship",
122
+ touches: "understand commit/change history",
123
+ touchesSymbol: "understand commit/change history",
124
+ cochange: "understand change-coupling between modules",
125
+ reexports: "understand a module's public exports/API surface",
126
+ };
127
+ const goalNoun = (entityType) => (entityType ? `${String(entityType).toLowerCase()}(s)` : "entities");
128
+
129
+ /** Deduce a one-line goal statement from the ask engine's parsed AST — either
130
+ * the plain-clause form ({shape,kind,entityType,object[,subject]}) or the
131
+ * compositional form ({node:...}, ask.mjs's §compositional grammar). Returns
132
+ * null when there's nothing to bucket on (no parse stood at all); the caller
133
+ * supplies its own honest "didn't resolve" wording in that case. */
134
+ function deduceGoalFromParsed(parsed) {
135
+ if (!parsed) return null;
136
+ const { node, shape, kind } = parsed;
137
+ if (node === "find") return `locate a specific named entity ("${parsed.term}")`;
138
+ if (node === "count") return `get a count of ${goalNoun(parsed.entityType)}`;
139
+ if (node === "list") return `list/enumerate ${goalNoun(parsed.entityType)} matching a condition`;
140
+ if (node === "superlative") return `rank/compare ${goalNoun(parsed.entityType)} by ${parsed.metricNoun || parsed.metric || "a metric"}`;
141
+ if (node === "anaphora") return "follow up on the previous answer's result set (discourse anaphora)";
142
+ if (node === "membership") return `understand "${parsed.term || "an entity"}"'s membership/relationship`;
143
+ if (node === "clause") return deduceGoalFromParsed(parsed.clause);
144
+ if (node === "miss") return null;
145
+ if (node === "boolean" || node === "qualifier" || node === "reverseSet" || node === "forwardSet" || node === "allOfClass" || node === "temporal") {
146
+ const k = kind || parsed.inner?.kind;
147
+ return k && GOAL_BY_KIND[k] ? GOAL_BY_KIND[k] : `filter/traverse ${goalNoun(parsed.entityType)} by a relationship`;
148
+ }
149
+ // plain (non-compositional) clause
150
+ if (shape === "meta") return `understand a vocabulary/definition term ("${parsed.object}")`;
151
+ if (shape === "where") return `locate where something is defined ("${parsed.object}")`;
152
+ if (shape === "when") return "understand when something last changed (history)";
153
+ if (shape === "mentions") return `find where something is mentioned in prose ("${parsed.object}")`;
154
+ if (shape === "ask") return (kind && GOAL_BY_KIND[kind]) || "check a specific subject/object relationship";
155
+ if ((shape === "reverse" || shape === "forward") && kind) return GOAL_BY_KIND[kind] || `understand a "${kind}" relationship`;
156
+ return "understand a graph relationship";
157
+ }
158
+
159
+ /** Split the collected trace into buckets by its own leading category tag, so
160
+ * renderNarration can group like with like while the trace array itself stays
161
+ * a flat, chronological narrative — no structured side-channel to keep in
162
+ * sync with the notes pushed at each call site. */
163
+ function bucketTrace(trace) {
164
+ const buckets = { goal: [], lane: [], pattern: [], result: [], source: [], intermediate: [] };
165
+ const other = [];
166
+ for (const line of trace) {
167
+ const m = /^([a-z]+):\s/.exec(String(line));
168
+ if (m && buckets[m[1]]) buckets[m[1]].push(line); else other.push(line);
169
+ }
170
+ return { ...buckets, other };
171
+ }
172
+
173
+ /** Render the collected trace into the human-readable block appended to a
174
+ * narrated turn's answer (see runTurn's withLast — this runs AFTER finish()
175
+ * and never touches `last.answer`). `fallbackGoal` covers turn types that
176
+ * push no "goal:" note of their own (a bare slash-command, a count, an
177
+ * assert) with a generic via-derived line — every narrated turn gets a goal
178
+ * line, never a silent gap. */
179
+ function renderNarration(trace, { record, detail, fallbackGoal }) {
180
+ const b = bucketTrace(trace);
181
+ const lines = [NARRATE_MARKER];
182
+ lines.push(...(b.goal.length ? b.goal : [`goal: ${fallbackGoal}`]));
183
+ lines.push(`decision: via=${record.via || "?"}${record.command ? ` command=/${record.command}` : ""}${record.miss ? " (miss)" : ""}`);
184
+ lines.push(...b.lane, ...b.pattern);
185
+ if (detail?.traversal) lines.push(`result: traversal — ${detail.traversal}`);
186
+ if (Array.isArray(detail?.matches) && detail.matches.length) {
187
+ const shown = detail.matches.slice(0, 5).map((m) => `${m.label}${m.type ? ` [${m.type}]` : ""}`);
188
+ lines.push(`result: ${detail.matches.length} match(es) — ${shown.join(", ")}${detail.matches.length > shown.length ? ", …" : ""}`);
189
+ }
190
+ if (Array.isArray(record.resolvedIds) && record.resolvedIds.length) {
191
+ lines.push(`result: resolved entity id(s) — ${record.resolvedIds.join(", ")}`);
192
+ }
193
+ if (Array.isArray(record.answeredIds) && record.answeredIds.length && record.answeredIds.length !== (detail?.matches?.length || 0)) {
194
+ lines.push(`result: answered entity id(s) — ${record.answeredIds.join(", ")}`);
195
+ }
196
+ lines.push(...b.result, ...b.source, ...b.intermediate, ...b.other);
197
+ return lines.join("\n");
198
+ }
199
+
200
+ /** Append the narrate-mode block to a turn's OUTWARD-FACING answer/logLines —
201
+ * used at every runTurn return site, AFTER finish() (or, for a conversational
202
+ * turn, after its own render). Deliberately never touches `last.answer` /
203
+ * `last.detail` (the caller builds `last` from the PRE-narration `result`) so
204
+ * a narrated turn's own repeat-detection and why/say-more re-render (both of
205
+ * which compare `last.answer` bytes — see ORIENTATION_REPEAT_ONELINER and
206
+ * renderVerbose) see the exact same text a narrate:false run would have
207
+ * produced; narrate is purely additive to what's PRINTED, never to what's
208
+ * REMEMBERED. No-op (returns `result` unchanged, by reference) when `trace`
209
+ * is null (narrate off) or empty (nothing was traced). */
210
+ function withNarration(result, trace, fallbackGoal) {
211
+ if (!trace || !trace.length) return result;
212
+ const narrative = renderNarration(trace, { record: result.record, detail: result.detail, fallbackGoal });
213
+ const answer = `${result.answer}\n\n${narrative}`;
214
+ const logLines = Array.isArray(result.logLines)
215
+ ? result.logLines.map((l) => (l === result.answer ? answer : l))
216
+ : result.logLines;
217
+ return { ...result, answer, logLines };
218
+ }
219
+
82
220
  /** Slash-command → (dispatchTool name, arg key). Arg keys are the EXACT ones the
83
221
  * server.mjs dispatchTool switch reads (members/subclasses take `class`;
84
222
  * impact/exports take `module`; architecture takes `package`; search takes
@@ -517,26 +655,48 @@ function conversationalTurn(line, ctx) {
517
655
  ...(end ? { end: true } : {}),
518
656
  };
519
657
  };
520
- if (BYE.has(q)) return mk(t(T_FAREWELL), { end: true });
658
+ if (BYE.has(q)) {
659
+ note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
660
+ note(ctx.trace, "lane: conversational — farewell (BYE closed set)");
661
+ return mk(t(T_FAREWELL), { end: true });
662
+ }
521
663
  if (WHY.has(q)) {
664
+ note(ctx.trace, "goal: elaborate on the previous answer (why/say-more)");
665
+ note(ctx.trace, "lane: conversational — why/say-more (WHY closed set)");
522
666
  const v = renderVerbose(ctx.last);
523
667
  // The empty-state hint is template wording (via:"template", the data row wins;
524
668
  // renderVerbose's own string is the degraded fallback for direct library callers).
525
669
  // A real expansion re-renders the LAST ANSWER — its wording is the prior answer's,
526
670
  // not a template's, so it carries via:"conversational".
527
- if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
671
+ if (v.empty) {
672
+ note(ctx.trace, "intermediate: no previous answer held on ctx.last — nothing to expand");
673
+ return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
674
+ }
675
+ note(ctx.trace, `result: re-rendering the previous answer to "${ctx.last?.query ?? "?"}" verbosely`);
528
676
  return mk(v.text, { via: "conversational" });
529
677
  }
530
678
  if (GREET.has(q)) {
679
+ note(ctx.trace, "goal: casual/social — greeting, no graph intent");
680
+ note(ctx.trace, "lane: conversational — greeting (GREET closed set)");
531
681
  // #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
532
682
  // modules orients toward --repo/tmct init instead of over-promising "ask me
533
683
  // about this codebase". Phrase-specific variants (good morning, hello there)
534
684
  // keep their wording; only the default greeting swaps.
535
685
  const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
686
+ note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
536
687
  return mk(t(id));
537
688
  }
538
- if (THANKS.has(q)) return mk(t(T_THANKS));
539
- if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(orientationAnswer(ctx.templates, ctx.graph));
689
+ if (THANKS.has(q)) {
690
+ note(ctx.trace, "goal: casual/social acknowledgement, no graph intent");
691
+ note(ctx.trace, "lane: conversational — thanks/acknowledgement (THANKS closed set)");
692
+ note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
693
+ return mk(t(T_THANKS));
694
+ }
695
+ if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) {
696
+ note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
697
+ note(ctx.trace, "lane: conversational — help/orientation (HELP_PHRASES / bare help / ?)");
698
+ return mk(orientationAnswer(ctx.templates, ctx.graph));
699
+ }
540
700
  return null;
541
701
  }
542
702
 
@@ -1154,6 +1314,7 @@ export async function helpText() {
1154
1314
  ["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
1155
1315
  ["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
1156
1316
  ["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
1317
+ ["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
1157
1318
  ["/help", "this list"],
1158
1319
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
1159
1320
  ];
@@ -2169,7 +2330,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
2169
2330
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
2170
2331
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
2171
2332
  * normal answer, never a crash. */
2172
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
2333
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace }) {
2173
2334
  const ts = new Date().toISOString();
2174
2335
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
2175
2336
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -2189,7 +2350,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2189
2350
  // W2: the explicit recall forms are answered from memory's folded blocks, never
2190
2351
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
2191
2352
  if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
2353
+ note(trace, "goal: recall what was discussed earlier (explicit recall phrasing)");
2354
+ note(trace, "lane: RECALL_ASK_RE matched — answered from folded-session memory, never the graph");
2192
2355
  const summary = await recallSummary(memoryDir);
2356
+ note(trace, summary ? "source: memory/fold.mjs recallSummary" : "intermediate: no folded session blocks yet — nothing to recall");
2193
2357
  return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
2194
2358
  via: "recall", miss: !summary, focus,
2195
2359
  });
@@ -2214,7 +2378,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2214
2378
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
2215
2379
  } catch (e) {
2216
2380
  answer = String(e?.message || e);
2217
- }
2381
+ note(trace, `intermediate: the ask engine threw — ${answer}`);
2382
+ }
2383
+ // NARRATE: the direct parse/traversal receipt, straight off ask()'s own envelope
2384
+ // (§6.2) — this alone covers most of "the version of prompt that matched" and
2385
+ // "intermediate information" with ZERO extra instrumentation of ask.mjs: `parsed`
2386
+ // is the compiled AST (shape/kind/entityType or a compositional {node:...}),
2387
+ // `relaxed` is the FULL relaxation-cascade trace (what noise/unmatched tokens the
2388
+ // engine stripped/corrected before it found an answerable parse — exactly the
2389
+ // "almost resolved but failed" near-miss detail a playtest debugging session
2390
+ // wants), and `matchedVia` names the confidence tier (prose/fuzzy) a resolution
2391
+ // fell through to.
2392
+ if (envelope?.parsed) {
2393
+ const p = envelope.parsed;
2394
+ const shape = p.node ? `node=${p.node}` : `shape=${p.shape}`;
2395
+ note(trace, `pattern: parsed AST — ${shape}${p.kind ? ` kind=${p.kind}` : ""}${p.entityType ? ` entityType=${p.entityType}` : ""}${p.object != null ? ` object="${p.object}"` : ""}${p.term != null ? ` term="${p.term}"` : ""}`);
2396
+ } else {
2397
+ note(trace, "pattern: no parse stood (direct grammar miss — every registered strategy declined)");
2398
+ }
2399
+ if (envelope?.relaxed) {
2400
+ const r = envelope.relaxed;
2401
+ note(trace, `intermediate: the direct parse missed — the relaxation cascade rescued it: "${r.from}" -> "${r.to}"${r.dropped?.length ? ` (dropped: ${r.dropped.join(", ")})` : ""}`);
2402
+ if (r.steps?.length) note(trace, `intermediate: relaxation steps — ${r.steps.join(" | ")}`);
2403
+ }
2404
+ if (envelope?.matchedVia) note(trace, `source: term resolved via the "${envelope.matchedVia}" confidence tier (not a literal identifier match)`);
2405
+ if (envelope?.ambiguous) note(trace, "intermediate: the resolved term was AMBIGUOUS — multiple candidates matched, see the answer's disambiguation prompt");
2218
2406
  // A grammar miss has parsed:null → stays []. An empty-RESULT query (object resolved,
2219
2407
  // no edges) still records the resolved subject — that IS the asksAbout signal, and
2220
2408
  // it becomes the new focus so a follow-up "what calls it" can reuse it.
@@ -2233,6 +2421,9 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2233
2421
  // Class-gate the focus update: a Commit/Session/schema object never displaces a
2234
2422
  // standing code-entity focus (see nextFocus).
2235
2423
  newFocus = nextFocus(graph, focus, ent);
2424
+ note(trace, `result: resolved object "${obj}" -> ${ent.label} (${ent.id}) — becomes the new focus`);
2425
+ } else if (!isPronoun(obj)) {
2426
+ note(trace, `intermediate: object "${obj}" did NOT resolve to a graph entity — this is why an otherwise-parsed query still misses`);
2236
2427
  }
2237
2428
  }
2238
2429
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
@@ -2242,6 +2433,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2242
2433
  let via = "composed";
2243
2434
  let recordMiss = miss;
2244
2435
  let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
2436
+ // GOAL DEDUCTION: from the parsed AST when one stood (deterministic, table-driven —
2437
+ // see deduceGoalFromParsed); a total grammar miss (no parse at all) gets the honest
2438
+ // "didn't resolve" goal line verbatim, matching the operator's own wording for that
2439
+ // case. Pushed once, EARLY (before the miss cascade below may go on to answer via a
2440
+ // completely different lane — an intent lane's own goal note, when it pushes one,
2441
+ // stays the more specific of the two since bucketTrace keeps every "goal:" line and
2442
+ // renderNarration shows them all, most-specific-last-written).
2443
+ {
2444
+ const deduced = deduceGoalFromParsed(envelope?.parsed);
2445
+ note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
2446
+ }
2245
2447
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
2246
2448
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
2247
2449
  // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
@@ -2254,7 +2456,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2254
2456
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
2255
2457
  if (miss) {
2256
2458
  const meta = await metaLane(query, { graph, memoryDir });
2257
- if (meta) { answer = meta.text; via = meta.via; recordMiss = false; handled = true; }
2459
+ if (meta) {
2460
+ answer = meta.text; via = meta.via; recordMiss = false; handled = true;
2461
+ note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
2462
+ }
2258
2463
  }
2259
2464
  if (!handled && miss && isConversational(query)) {
2260
2465
  // A conversational miss (a greeting, "what can you do", a very short non-code
@@ -2264,8 +2469,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2264
2469
  // identical orientation-class turn used to repeat the full blurb verbatim —
2265
2470
  // collapse to a one-liner on that repeat, mirroring WALL_REPEAT_ONELINER.
2266
2471
  const orientation = orientationAnswer(templates, graph);
2267
- answer = (last?.answer === orientation) ? ORIENTATION_REPEAT_ONELINER : orientation;
2472
+ const repeat = last?.answer === orientation;
2473
+ answer = repeat ? ORIENTATION_REPEAT_ONELINER : orientation;
2268
2474
  via = "template"; handled = true;
2475
+ note(trace, `lane: (2) conversational orientation — isConversational() matched a would-miss; ${repeat ? "REPEAT collapsed to one-liner" : "full orientation card"}`);
2476
+ note(trace, "goal: casual/social or too-short-to-be-structural — no graph intent");
2269
2477
  } else if (!handled && memoryDir) {
2270
2478
  // W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
2271
2479
  // the schema-docs surface — a remembered fact answers a miss OR extends a (non-
@@ -2280,6 +2488,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2280
2488
  via = "fact";
2281
2489
  recordMiss = false;
2282
2490
  if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
2491
+ note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
2492
+ note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
2283
2493
  } else if (miss) {
2284
2494
  // W2: after the honest miss is composed, consult the folded-session memory. A
2285
2495
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -2305,6 +2515,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2305
2515
  answer = `${recalled}\n\n${trailing}`;
2306
2516
  via = "recall";
2307
2517
  recordMiss = false; // memory answered it, cited — no longer a blank
2518
+ note(trace, "lane: (3) memory recall — recallFromBlocks matched a folded-session Q/A above the relevance floor");
2519
+ note(trace, "source: .tmct/memory folded session blocks (fold.mjs)");
2308
2520
  }
2309
2521
  }
2310
2522
  }
@@ -2316,7 +2528,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2316
2528
  // conversational lanes (via:"meta"/"template"), which answer a different question.
2317
2529
  if (via === "composed" || via === "fact") {
2318
2530
  const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
2319
- if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
2531
+ if (def) {
2532
+ answer = def.text; via = "corpus/seon"; recordMiss = false;
2533
+ note(trace, "lane: CURATED SEON DEFINITION — curatedDefinitionAnswer matched a lexicon term");
2534
+ note(trace, "source: corpus/seon (curated prose definition, licensed per data/corpus/seon)");
2535
+ }
2320
2536
  }
2321
2537
  // THE CONCEPT FORCE (concept.mjs) — a vague "what is a X" / "tell me about X" that
2322
2538
  // names a KNOWN code concept WITH real instances composes the three-band answer
@@ -2336,6 +2552,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2336
2552
  conceptInstances = concept.instances;
2337
2553
  conceptAllIds = concept.allIds;
2338
2554
  conceptPending = concept.pending;
2555
+ note(trace, `lane: THE CONCEPT FORCE — a known code concept with ${concept.instances?.length ?? 0} real instance(s) composed the 3-band answer`);
2556
+ note(trace, "source: concept.mjs composeConcept — graph instances + corpus/seon definition");
2339
2557
  } else {
2340
2558
  // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
2341
2559
  // touch ("what about imports", "what are the calls", "tell me about contains").
@@ -2349,6 +2567,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2349
2567
  if (relation) {
2350
2568
  answer = relation.text; via = "corpus/seon"; recordMiss = false;
2351
2569
  conceptPending = relation.pending;
2570
+ note(trace, "lane: THE RELATION CONCEPT FORCE — the touched word named a known, edge-bearing relation kind");
2571
+ note(trace, "source: relationForceAnswer over the loaded graph's own edges (not corpus)");
2352
2572
  }
2353
2573
  }
2354
2574
  }
@@ -2363,13 +2583,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2363
2583
  if (syn) {
2364
2584
  answer = syn.text; via = "fact"; recordMiss = false;
2365
2585
  if (syn.pending) factPending = syn.pending;
2586
+ note(trace, "lane: (3b) ONTOLOGY SYNONYM EXPANSION — a last-resort synonym of the term had direct facts");
2587
+ note(trace, "source: .tmct/memory Facts, reached via a known synonym (cited in the answer itself)");
2366
2588
  }
2367
2589
  }
2368
2590
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
2369
2591
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
2370
2592
  if (miss && recordMiss && via === "composed") {
2371
2593
  const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
2372
- if (taught) { answer = taught.text; via = taught.via; recordMiss = taught.miss; }
2594
+ if (taught) {
2595
+ answer = taught.text; via = taught.via; recordMiss = taught.miss;
2596
+ note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
2597
+ note(trace, "goal: teach/remember a new fact");
2598
+ }
2373
2599
  }
2374
2600
  // (4b) #4 AUTHOR lane (0.8.2 WS4) — "who is <Name>", "what did <Name> touch",
2375
2601
  // "who authored <sha>": the Commit author ATTRIBUTE answered as a person, off
@@ -2378,7 +2604,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2378
2604
  // honest miss below (never a guess).
2379
2605
  if (miss && recordMiss && via === "composed") {
2380
2606
  const authored = authorLane(query, { graph });
2381
- if (authored) { answer = authored.text; via = authored.via; recordMiss = false; }
2607
+ if (authored) {
2608
+ answer = authored.text; via = authored.via; recordMiss = false;
2609
+ note(trace, "lane: (4b) AUTHOR — a who-is/what-did-<Name>-touch/who-authored-<sha> pattern matched a commit author");
2610
+ note(trace, "source: codegraph.mjs authorIndex (derived from Commit individuals)");
2611
+ note(trace, "goal: identify a person and/or what they touched (authorship/history)");
2612
+ }
2382
2613
  }
2383
2614
  // (4b2) #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f) — "why
2384
2615
  // does X still/again import Y": names the presupposition being checked
@@ -2387,7 +2618,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2387
2618
  // one is still an honest, confident correction, not a miss.
2388
2619
  if (miss && recordMiss && via === "composed") {
2389
2620
  const presup = await presuppositionNudge(query, { graph, memoryDir });
2390
- if (presup) { answer = presup.text; via = "presupposition"; recordMiss = false; }
2621
+ if (presup) {
2622
+ answer = presup.text; via = "presupposition"; recordMiss = false;
2623
+ note(trace, "lane: (4b2) PRESUPPOSITION HONEST-NUDGE — a still/again-marked question's presupposition was checked against the graph");
2624
+ note(trace, "goal: verify an assumption baked into the question, then answer what survives");
2625
+ }
2391
2626
  }
2392
2627
  // (4c) CAPABILITY NUDGES (0.8.2 WS4) — risk scoring / code opinions / "write me
2393
2628
  // code" imperatives / motive-"why": an honest wall pointing at the nearest real
@@ -2396,7 +2631,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2396
2631
  // short-miss's "is a <thing> a <kind>" membership hint could claim the line.
2397
2632
  if (miss && recordMiss && via === "composed") {
2398
2633
  const nudged = nudgeAnswer(query, newFocus);
2399
- if (nudged) { answer = nudged; via = "miss"; }
2634
+ if (nudged) {
2635
+ answer = nudged; via = "miss";
2636
+ note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
2637
+ note(trace, "goal: out of scope for a no-LLM graph reader — pointed at the nearest real query shapes");
2638
+ }
2400
2639
  }
2401
2640
  // (4d) DESCRIBE-WRAPPER RESCUE (playtest sprint round 2, SKILL_PLAYTEST_SPRINT.md)
2402
2641
  // — "can you describe X for me" / "tell me more about X": a closed wrapper
@@ -2410,7 +2649,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2410
2649
  // it only claims the turn if /describe actually resolves the captured term.
2411
2650
  if (miss && recordMiss && via === "composed") {
2412
2651
  const described = await describeWrapperAnswer(query, { config, source });
2413
- if (described) { answer = described.text; via = "describe"; recordMiss = false; }
2652
+ if (described) {
2653
+ answer = described.text; via = "describe"; recordMiss = false;
2654
+ note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
2655
+ note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
2656
+ }
2414
2657
  }
2415
2658
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
2416
2659
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
@@ -2419,10 +2662,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2419
2662
  // to a one-liner whose text does NOT match WALL_MISS_RE — self-limiting, so a
2420
2663
  // third consecutive miss re-offers the tailored hint instead of droning.
2421
2664
  if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
2422
- answer = (last?.answer && WALL_MISS_RE.test(String(last.answer)))
2423
- ? WALL_REPEAT_ONELINER
2424
- : shortMissHint(query);
2665
+ const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
2666
+ answer = repeat ? WALL_REPEAT_ONELINER : shortMissHint(query);
2425
2667
  via = "miss";
2668
+ note(trace, `lane: (5) SHORT TAILORED MISS — every lane above declined; ${repeat ? "REPEAT collapsed to one-liner (wall kindness)" : "the full grammar wall was shortened + tailored to the query's keywords"}`);
2426
2669
  }
2427
2670
  // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
2428
2671
  // dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
@@ -2430,6 +2673,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2430
2673
  if (recordMiss && (via === "composed" || via === "miss")
2431
2674
  && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
2432
2675
  answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
2676
+ note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
2433
2677
  }
2434
2678
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
2435
2679
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
@@ -2439,6 +2683,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2439
2683
  if (aside) {
2440
2684
  answer = `${answer}\n${aside}`;
2441
2685
  via = "corpus";
2686
+ note(trace, "lane: W5 corpus aside — an unknown term matched the local committed corpus slice (TMCT_CORPUS_LOOKUP=1)");
2687
+ note(trace, "source: local committed corpus slice (licence-cited in the aside itself)");
2442
2688
  }
2443
2689
  }
2444
2690
  // ADVANCED_GRAMMAR track (a) — counterfactual marker (PLAN_ADVANCED_GRAMMAR.md
@@ -2454,6 +2700,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2454
2700
  const counterfactualSubject = String(query).trim().match(COUNTERFACTUAL_RE);
2455
2701
  if (!recordMiss && via === "composed" && counterfactualSubject) {
2456
2702
  answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
2703
+ note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
2457
2704
  }
2458
2705
  // The concept force answers WITH real example instances — those are the entities the
2459
2706
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
@@ -2493,28 +2740,52 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
2493
2740
  };
2494
2741
  }
2495
2742
 
2496
- /** A slash-command → the mapped tool (or the /help, /focus, unknown cases). Returns
2497
- * the same { answer, logLines, record, focus } shape as runAsk; the record carries
2498
- * the command name and the resolved entity id (for entity commands) so a
2499
- * slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
2500
- async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2743
+ /** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
2744
+ * cases). Returns the same { answer, logLines, record, focus } shape as
2745
+ * runAsk; the record carries the command name and the resolved entity id
2746
+ * (for entity commands) so a slash-command turn becomes asksAbout graph data
2747
+ * wherever it resolves an entity. `ctx.trace` (narrate mode, or undefined
2748
+ * when off) gets one "goal:"/"lane:" note per branch — a slash-command's
2749
+ * "decision" is simply which command+tool ran, so this is intentionally
2750
+ * lighter than runAsk's miss-cascade instrumentation. */
2751
+ async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false }) {
2501
2752
  const ts = new Date().toISOString();
2502
2753
  const sp = line.indexOf(" ");
2503
2754
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
2504
2755
  const argText = (sp === -1 ? "" : line.slice(sp + 1)).trim();
2505
- const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
2756
+ const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus, narrateNext } = {}) => ({
2506
2757
  answer,
2507
2758
  logLines: [ts, `> ${line}`, answer, ""],
2508
2759
  record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
2509
2760
  focus: newFocus,
2761
+ ...(narrateNext !== undefined ? { narrate: narrateNext } : {}),
2510
2762
  });
2511
2763
 
2512
- if (name === "help") return mk(await helpText());
2513
- if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
2764
+ if (name === "help") { note(trace, "goal: get oriented / learn available commands"); return mk(await helpText()); }
2765
+ if (name === "stats") {
2766
+ note(trace, "goal: get a one-screen overview of the loaded graph");
2767
+ return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
2768
+ }
2769
+
2770
+ // /narrate on|off — the debug-mode toggle itself (session-scoped, mirrors the
2771
+ // /focus pattern: the new state rides the turn RESULT as `narrate`, and
2772
+ // createSession's turn() applies it to its own mutable state; a bare
2773
+ // runTurn caller threads it the same way it threads `focus`/`last`). A
2774
+ // status-only "/narrate" (no on/off) reports the CURRENT state and changes
2775
+ // nothing — never silently flips it.
2776
+ if (name === "narrate") {
2777
+ const arg = argText.toLowerCase();
2778
+ if (arg !== "on" && arg !== "off") {
2779
+ return mk(`narrate mode is ${narrate ? "on" : "off"} — /narrate on or /narrate off to change it.`);
2780
+ }
2781
+ const next = arg === "on";
2782
+ return mk(`narrate mode ${next ? "on" : "off"}.`, { narrateNext: next });
2783
+ }
2514
2784
 
2515
2785
  // /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
2516
2786
  // inspection" surface; the same renderer serves the `tmct memory` CLI).
2517
2787
  if (name === "memory") {
2788
+ note(trace, "goal: inspect tmct's memory store (facts/utterances/sessions)");
2518
2789
  if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
2519
2790
  try {
2520
2791
  const { inspectMemory } = await import("./memory/inspect.mjs");
@@ -2525,18 +2796,28 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2525
2796
  }
2526
2797
 
2527
2798
  if (name === "focus") {
2799
+ note(trace, "goal: set the working focus entity for follow-up pronouns (it/this/that)");
2528
2800
  if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
2529
2801
  const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
2530
2802
  if (!ent) return mk(`could not resolve "${argText}" to a single entity — focus unchanged${focus ? ` (still ${focus.label})` : ""}.`, { miss: true });
2803
+ note(trace, `result: resolved "${argText}" -> ${ent.label} (${ent.id})`);
2531
2804
  return mk(`focus set to ${ent.label}.`, { resolvedIds: [ent.id], newFocus: ent });
2532
2805
  }
2533
2806
 
2534
2807
  const spec = COMMANDS[name];
2535
- if (!spec) return mk(`unknown command /${name} — type /help for the list of commands.`, { miss: true });
2808
+ if (!spec) {
2809
+ note(trace, `pattern: /${name} is not a registered command (see COMMANDS in src/chat.mjs)`);
2810
+ return mk(`unknown command /${name} — type /help for the list of commands.`, { miss: true });
2811
+ }
2812
+ note(trace, `goal: ${spec.help}`);
2813
+ note(trace, `lane: slash-command /${name} -> dispatchTool("${spec.tool}"${spec.arg ? `, {${spec.arg}}` : ""})`);
2536
2814
 
2537
2815
  const entityArg = ENTITY_ARGS.has(spec.arg);
2538
2816
  let value = argText;
2539
- if (entityArg && (!value || isPronoun(value))) value = focus?.label || "";
2817
+ if (entityArg && (!value || isPronoun(value))) {
2818
+ value = focus?.label || "";
2819
+ if (value) note(trace, `intermediate: no/pronoun argument -> fell back to the standing focus "${value}"`);
2820
+ }
2540
2821
  if (spec.arg && !spec.optional && !value) {
2541
2822
  const need = entityArg ? `${spec.arg} (none given and no focus set — /focus <x> or pass one)` : spec.arg;
2542
2823
  return mk(`/${name} needs a ${need}.`, { miss: true });
@@ -2546,6 +2827,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2546
2827
  try {
2547
2828
  answer = await dispatchTool(spec.tool, spec.arg ? { [spec.arg]: value } : {}, { config, source });
2548
2829
  } catch (e) {
2830
+ note(trace, `intermediate: dispatchTool("${spec.tool}") threw — ${String(e?.message || e)}`);
2549
2831
  return mk(String(e?.message || e), { miss: true }); // the tool's own clean error, never a stack
2550
2832
  }
2551
2833
  // Entity commands resolve their subject for the sidecar/graph AND set the focus so a
@@ -2556,16 +2838,18 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2556
2838
  // Commit/Session/schema node records the resolution but does not displace a
2557
2839
  // standing code-entity focus that "it" is meant to keep binding to.
2558
2840
  if (ent) {
2841
+ note(trace, `result: resolved "${value}" -> ${ent.label} (${ent.id}, class=${graph?.byId?.get?.(ent.id)?.class || "?"})`);
2559
2842
  // Bug B4 (0.8.2 follow-up): /describe's code-map render never sees memory,
2560
2843
  // so a taught fact about the resolved entity is invisible to it — append
2561
2844
  // matching taught facts (subject === the resolved entity, trust-ranked)
2562
2845
  // under the code-map answer, mirroring the ask-path's fact-append pattern.
2563
2846
  if (name === "describe" && memoryDir) {
2564
2847
  const facts = await describedFacts(memoryDir, ent.label);
2565
- if (facts) answer = `${answer}\n${facts}`;
2848
+ if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
2566
2849
  }
2567
2850
  return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
2568
2851
  }
2852
+ note(trace, `intermediate: "${value}" did not resolve to a single entity — the tool's own (unresolved) answer stands`);
2569
2853
  }
2570
2854
  return mk(answer);
2571
2855
  }
@@ -2640,10 +2924,17 @@ function morePage(query, { last, focus }) {
2640
2924
  return turn;
2641
2925
  }
2642
2926
 
2643
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
2927
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false } = {}) {
2644
2928
  const line = String(input ?? "").trim();
2645
2929
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
2646
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon };
2930
+ // narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
2931
+ // matching every OTHER optional collaborator here — templates/memoryDir/lexicon
2932
+ // all null-degrade the same way). Every note()/withNarration() call below is a
2933
+ // cheap `if (trace)`/`if (!trace || !trace.length)` no-op when this is null, so
2934
+ // the narrate:false path allocates nothing extra and renders byte-identically to
2935
+ // before this feature existed — see the "---- narrate mode ----" section above.
2936
+ const trace = narrate ? [] : null;
2937
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate };
2647
2938
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
2648
2939
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
2649
2940
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -2651,10 +2942,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2651
2942
  // finished answer becomes the `last` we expand. finish() owns the prose-span
2652
2943
  // grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
2653
2944
  // protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
2654
- // so `last` and the transcript stay consistent with what the shell prints.
2655
- const withLast = (result) => {
2945
+ // so `last` and the transcript stay consistent with what the shell prints. The
2946
+ // narrate block (withNarration, above) is applied AFTER `last` is captured from
2947
+ // the PRE-narration finished result — see withNarration's docblock for why.
2948
+ const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
2656
2949
  const finished = finish(result, { graph });
2657
- return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
2950
+ const nextLast = { query: line, answer: finished.answer, detail: finished.detail ?? null };
2951
+ return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
2658
2952
  };
2659
2953
 
2660
2954
  // Slash-optional system commands: a bare leading command word ("stats",
@@ -2662,28 +2956,36 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2662
2956
  // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
2663
2957
  // of falling through to the generic orientation.
2664
2958
  const bareCmd = asBareCommand(line);
2665
- if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
2959
+ if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
2666
2960
 
2667
2961
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
2668
- // resolve no entity and carry their own preserved `last`.
2962
+ // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
2963
+ // conversational turn is never finish()'d / never becomes a new `last`), so the
2964
+ // narrate block is applied directly here instead.
2669
2965
  const convo = conversationalTurn(line, ctx);
2670
- if (convo) return convo;
2966
+ if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
2671
2967
 
2672
2968
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
2673
2969
  // an actual pending remainder so a bare "more" with nothing to continue falls through
2674
2970
  // to the ordinary path (an honest miss), never a pretend page.
2675
2971
  if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
2676
- return withLast(morePage(line, ctx));
2972
+ note(trace, "goal: continue viewing a previous long listing (pagination)");
2973
+ note(trace, "lane: MORE_RE matched a held pending remainder from the previous turn's detail.pending");
2974
+ return withLast(morePage(line, ctx), "continue viewing a previous long listing");
2677
2975
  }
2678
2976
 
2679
- if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
2977
+ if (line.startsWith("/")) return withLast(await runCommand(line, ctx), "use a specific tool/command directly");
2680
2978
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
2681
2979
  // own memory and confirm — they are statements to remember, not graph queries.
2682
2980
  // Gated on memoryDir: only a session shell provides a write target, so a bare
2683
2981
  // runTurn (tests, library callers) stays pure and falls through to the engine.
2684
2982
  if (memoryDir) {
2685
2983
  const asserted = await assertTurn(line, ctx);
2686
- if (asserted) return withLast(asserted);
2984
+ if (asserted) {
2985
+ note(trace, "goal: teach/remember a new fact (declarative ACE sentence)");
2986
+ note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
2987
+ return withLast(asserted, "teach/remember a new fact");
2988
+ }
2687
2989
  }
2688
2990
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
2689
2991
  // memory graph owns Facts + Utterances, so these are answerable and consistent
@@ -2692,7 +2994,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2692
2994
  // structural counts (classes/functions/…) and sessions fall through unaffected.
2693
2995
  if (memoryDir) {
2694
2996
  const memCount = await answerMemoryCount(memoryDir, line);
2695
- if (memCount != null) return withLast(plainTurn(line, memCount, { via: "count", focus }));
2997
+ if (memCount != null) {
2998
+ note(trace, "goal: get a count of a memory-store kind (facts/utterances)");
2999
+ note(trace, "lane: answerMemoryCount — matched a MEMORY_COUNT_NOUNS entry, answered off the .tmct/memory graph header");
3000
+ return withLast(plainTurn(line, memCount, { via: "count", focus }), "get a count of a memory-store kind");
3001
+ }
2696
3002
  }
2697
3003
  // Aggregate/count questions are answered mechanically off the loaded graph header,
2698
3004
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
@@ -2703,10 +3009,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2703
3009
  // class count). countFromFacts declines on a real graph kind, so ordinary
2704
3010
  // counts are unaffected; it only speaks for a remembered object noun.
2705
3011
  const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
2706
- if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
2707
- return withLast(plainTurn(line, count, { via: "count", focus }));
3012
+ if (viaFact != null) {
3013
+ note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
3014
+ note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
3015
+ return withLast(plainTurn(line, viaFact, { via: "fact", focus }), "get a count");
3016
+ }
3017
+ note(trace, "goal: get a count of a graph kind (classes/functions/modules/…)");
3018
+ note(trace, "lane: answerCount — a header-count aggregate question, answered mechanically off the graph header, never dispatched to the ask engine");
3019
+ return withLast(plainTurn(line, count, { via: "count", focus }), "get a count of a graph kind");
2708
3020
  }
2709
- return withLast(await runAsk(line, ctx));
3021
+ return withLast(await runAsk(line, ctx), "unclear — no goal signal computed by the ask engine");
2710
3022
  }
2711
3023
 
2712
3024
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
@@ -2824,6 +3136,7 @@ export async function createSession({
2824
3136
  cwd = process.cwd(),
2825
3137
  gitRoot = gitToplevel,
2826
3138
  ephemeral = false,
3139
+ narrate = false,
2827
3140
  } = {}) {
2828
3141
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
2829
3142
  // write NOTHING back into it. The shipped examples run this way so a demo never
@@ -2832,6 +3145,13 @@ export async function createSession({
2832
3145
  // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
2833
3146
  // temp dir and the read-time graph upsert is suppressed.
2834
3147
  ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
3148
+ // NARRATE mode (--narrate, or TMCT_NARRATE=1 — same on/off convention as
3149
+ // TMCT_EPHEMERAL/TMCT_NO_SEED): start the session with narrate mode already
3150
+ // on. Session-scoped and mutable from here — `/narrate on`/`/narrate off`
3151
+ // flips it turn-to-turn the same way `/focus` mutates the session's focus
3152
+ // (see `turn()` below: a turn result's `narrate` field, when present,
3153
+ // updates this closure-private variable). Default OFF, as the operator asked.
3154
+ let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
2835
3155
  // Graph resolution order for the chat surface (documented; --repo wins):
2836
3156
  // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
2837
3157
  // 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
@@ -2957,6 +3277,7 @@ export async function createSession({
2957
3277
  get focus() { return focus; },
2958
3278
  get lastAnswer() { return last; },
2959
3279
  get turns() { return turns; },
3280
+ get narrate() { return narrateOn; },
2960
3281
  promptFor: () => promptFor(focus),
2961
3282
 
2962
3283
  /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
@@ -2968,7 +3289,7 @@ export async function createSession({
2968
3289
  async turn(line) {
2969
3290
  let result;
2970
3291
  try {
2971
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
3292
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn });
2972
3293
  } catch (e) {
2973
3294
  const ts = new Date().toISOString();
2974
3295
  const message = e instanceof Error ? e.message : String(e);
@@ -2979,9 +3300,12 @@ export async function createSession({
2979
3300
  turns += 1;
2980
3301
  return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
2981
3302
  }
2982
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = result;
3303
+ const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
2983
3304
  focus = nextFocus;
2984
3305
  last = nextLast;
3306
+ // /narrate on|off (runCommand) rides the turn RESULT the same way a focus
3307
+ // update does — apply it to this handle's session-scoped state.
3308
+ if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
2985
3309
  await writeLog(logLines.join("\n") + "\n");
2986
3310
  await writeSidecar(record);
2987
3311
  turnRecords.push(record);
@@ -3029,6 +3353,7 @@ export async function runChat({
3029
3353
  cwd = process.cwd(),
3030
3354
  gitRoot = gitToplevel,
3031
3355
  ephemeral = false,
3356
+ narrate = false,
3032
3357
  } = {}) {
3033
3358
  // createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
3034
3359
  // output until it fully resolves — found live: an operator reported `npm run chat`
@@ -3036,7 +3361,7 @@ export async function runChat({
3036
3361
  // fast subsequent run just flashes it briefly) and removes the "is this even
3037
3362
  // running" uncertainty during the one case that's genuinely slow.
3038
3363
  output.write("tmct — starting…\n");
3039
- const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
3364
+ const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral, narrate });
3040
3365
 
3041
3366
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
3042
3367
  for (const line of session.bannerLines) output.write(dim(line) + "\n");
@@ -40,11 +40,32 @@
40
40
  // (queries mutate nothing — the STRIPS closed world), so no step can delete a
41
41
  // condition another goal depends on. We compute this from the registry rather
42
42
  // than assume it (threatsAmong), so the guarantee is grounded, not asserted.
43
+ //
44
+ // THE GLOBAL-MODE DOMAIN GATE (Bug 8 fix). In SCOPED mode, relevance is already
45
+ // proven structurally: the focus is a REAL bound graph entity (resolveObject
46
+ // found it), so the request is provably about something in the graph. In GLOBAL
47
+ // mode there is no focus to bind, and `applicableRules` alone only screens the
48
+ // CALLER'S DECLARED TOOLSET — a caller-constant fact that says nothing about
49
+ // whether THIS request has any connection to the deduced goal (a caller who
50
+ // declares tmct_untested/tmct_impact once per session would ground
51
+ // coverage-invariant for every off-topic turn). The fix reuses ask.mjs's OWN
52
+ // compositional NL grammar (parseQuery — the SAME primitive the C1 resolver
53
+ // already parses every request with, see resolver.mjs mapParse) as a structural
54
+ // relevance check, never a new keyword table: does the request even COMPILE to a
55
+ // recognized graph-query shape naming a known entity kind, and does that kind
56
+ // match the rule's declared focusClass? A request parseQuery cannot place at all
57
+ // (null — "write a haiku about pizza") or places without landing on any
58
+ // recognized entity kind (a miss with no entity kind — "how many pizzas are
59
+ // there") is an honest "not about this graph" signal; a request parseQuery
60
+ // resolves to a real AST naming the rule's focus class ("which module is the
61
+ // biggest testing risk" -> {node:"superlative", entityType:"Module", ...})
62
+ // stays exactly as reachable as before. Zero request keywords added.
43
63
 
44
64
  import { backwardChain, extractEntity } from "./resolver.mjs";
45
65
  import { capabilityByName, effectsOf } from "./registry.mjs";
46
66
  import { hallucinationsIn } from "./call-validator.mjs";
47
67
  import { intersect } from "./set-algebra.mjs";
68
+ import { parseQuery } from "../ask.mjs";
48
69
 
49
70
  // Hard OUTER-tick budget — the meta-loop runs at most this many ticks, then
50
71
  // REFUSES (escalate). Independent of BDI convergence and of the monotone
@@ -178,6 +199,25 @@ function focusOf(request, ctx) {
178
199
  return r && r.match && !r.ambiguous ? r.match : null;
179
200
  }
180
201
 
202
+ /** The GLOBAL-MODE DOMAIN GATE's primitive: what entity CLASS (if any) did
203
+ * ask.mjs's own compositional NL grammar recognize in the request? Walks
204
+ * parseQuery's AST (the same shapes resolver.mjs's mapParse/mapFrame already
205
+ * consume) for its declared `entityType` field, unwrapping the wrapper nodes
206
+ * (`clause`, `inner`, `base`) that carry no entityType of their own. Returns the
207
+ * class name, or null when the grammar placed nothing (an outright non-parse) or
208
+ * placed a MISS with no recognized entity kind at all ("how many pizzas are
209
+ * there" -> {node:"miss", reason:"count needs a known entity kind..."} carries no
210
+ * entityType, same as a flat null). Pure; no request-string keyword table — it
211
+ * reads a field ask.mjs's grammar already computes for every request. */
212
+ function parsedEntityType(node) {
213
+ if (!node || typeof node !== "object") return null;
214
+ if (typeof node.entityType === "string") return node.entityType;
215
+ if (node.clause) return parsedEntityType(node.clause);
216
+ if (node.inner) return parsedEntityType(node.inner);
217
+ if (node.base) return parsedEntityType(node.base);
218
+ return null;
219
+ }
220
+
181
221
  /** Ground ONE epistemic sub-goal (a topic + optional bound entity) into a
182
222
  * grounded, EXECUTED call, or null when it is not groundable in the declared
183
223
  * toolset (=> the meta-loop escalates). Backward-chains topic->capability, binds
@@ -237,13 +277,31 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
237
277
  // request's scope in this toolset); >1 => an AMBIGUOUS meta-goal (arbitration
238
278
  // between meta-goals is undeclared) — both are honest refusals, never a guess.
239
279
  const applicable = applicableRules(declared, focus, mode);
240
- if (!applicable.length) {
280
+
281
+ // THE GLOBAL-MODE DOMAIN GATE (Bug 8 fix, see the module header). SCOPED mode
282
+ // already proved relevance via a bound graph entity; GLOBAL mode has not, so
283
+ // `applicable` alone (a pure function of the caller's DECLARED TOOLSET) is not
284
+ // enough — it says nothing about whether THIS request is even about the graph.
285
+ // Screen it against ask.mjs's own NL grammar: the request must parse to a shape
286
+ // naming the candidate rule's declared focusClass, or it is refused as honestly
287
+ // off-domain rather than answered with someone else's goal.
288
+ let domainRelevant = applicable;
289
+ if (mode === "global" && applicable.length) {
290
+ const requestClass = parsedEntityType(parseQuery(request));
291
+ domainRelevant = applicable.filter((r) => requestClass === r.focusClass);
292
+ if (!domainRelevant.length) {
293
+ const classes = [...new Set(applicable.map((r) => r.focusClass))].join("/");
294
+ return refuse(`open-world: the request does not parse as a query about ${classes} (ask.mjs's own NL grammar names ${requestClass || "no recognized entity kind"} in it) — global goal deduction needs the REQUEST ITSELF to be about the deduced goal's domain, not just a declared toolset that happens to ground it — escalate`, driver);
295
+ }
296
+ }
297
+
298
+ if (!domainRelevant.length) {
241
299
  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
300
  }
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);
301
+ if (domainRelevant.length > 1) {
302
+ return refuse(`ambiguous meta-goal: ${domainRelevant.length} declared goal-rules apply (${domainRelevant.map((r) => r.id).join(", ")}) — meta-goal arbitration is undeclared, refuse rather than guess — escalate`, driver);
245
303
  }
246
- const rule = applicable[0];
304
+ const rule = domainRelevant[0];
247
305
 
248
306
  // the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
249
307
  // twin of resolver.mjs's "backward-chain => <capability>" provenance).