@polycode-projects/the-mechanical-code-talker 0.9.4 → 0.9.6

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
@@ -14,8 +14,9 @@ the file has been deleted.
14
14
 
15
15
  ## Where we are now (2026-07-07)
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 (**1042**). **v0.9.5, pushed** (0.8.2 0.9.0 for this session's main wave, then
18
+ one patch per shipped playtest-sprint fix — see `HANDOVER.md` for the exact release chain and the
19
+ "Playtest sprint" section there for what each patch fixed).
19
20
 
20
21
  ### Now: shipped this session
21
22
 
@@ -26,7 +27,7 @@ the file has been deleted.
26
27
  - **A new feature: predicate-based "find" queries.** "find me the payment class" now works:
27
28
  type-filtered, fuzzy property-surface matching, with a narrow-then-broaden inheritance-aware
28
29
  cascade and a boolean-fold generalization for compositional predicate queries. Design:
29
- `PLAN_PREDICATE_QUERIES.md`.
30
+ `archive/PLAN_PREDICATE_QUERIES.md`.
30
31
  - **Two research tracks landed as code.** The ontology plan's two inert synonym resources
31
32
  (ConceptNet synonym/similar-to rows, phrasebook synonym families) are now wired into query-time
32
33
  matching, and the disjointness premise set plus numeric vocabulary grew
@@ -41,7 +42,7 @@ the file has been deleted.
41
42
  end to end, and surfaced a real goal-reasoner honesty gap (Bug 8, below).
42
43
  - **A session-loop fix.** A throwing turn no longer aborts a piped/non-interactive session.
43
44
  - **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
45
+ infbench generation is mechanical, not hand-authored), `archive/PLAN_PREDICATE_QUERIES.md`, and
45
46
  `PLAN_CODE.md` (new, program synthesis over tmct's closed DSLs, gated on explicit operator
46
47
  sign-off per track, not built yet).
47
48
 
@@ -62,8 +63,12 @@ In priority order (full detail and measured targets in `HANDOVER.md`):
62
63
  5. **`PLAN_CODE.md`'s sign-off decision.** Track 1 (rule/frame synthesis) is the lowest-risk
63
64
  candidate; decide with the operator whether to greenlight it.
64
65
  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.
66
+ temporal, discourse-count, measured red sets), and `edgesOfKind` memoization for monorepo-scale
67
+ latency. (The version bump plus push is done — see "Now" above.)
68
+ 7. **`SKILL_PLAYTEST_SPRINT.md`, in progress.** A capped, delegated, chained playtest loop (each
69
+ round a background chat session against `examples/mini-webapp`, appraised and fixed+shipped
70
+ live). Rounds 1-3 shipped 3 real fixes (0.9.3-0.9.5); cap raised from 3 to 8 rounds mid-run;
71
+ continuing.
67
72
 
68
73
  ### Later: deferred by design, staged inside each plan
69
74
 
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.4",
3
+ "version": "0.9.6",
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/ask-vocab.mjs CHANGED
@@ -380,6 +380,7 @@ export const FILLER_WORDS = Object.freeze([
380
380
  "um", "uh", "erm", "so", "like", "yo", "hey", "bru", "bro", "fam", "mate",
381
381
  "please", "could you", "can you", "would you", "tell me", "i wonder",
382
382
  "just wondering", "quickly", "real quick", "kinda", "sorta",
383
+ "btw", "by the way",
383
384
  ]);
384
385
 
385
386
  /** Deictic/pronoun terms that refer to a context entity rather than naming one
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
  ];
@@ -2062,9 +2223,12 @@ function relationTermOf(query, envelope) {
2062
2223
  * (normalize.mjs). Deliberately used only as a LAST-RESORT lane (see its call
2063
2224
  * site below) — "tell me about X" is ALSO the relation/concept force's own
2064
2225
  * trigger phrase for enumerable concepts ("tell me about inheritance"), so
2065
- * this must never run before those have had their chance. */
2226
+ * this must never run before those have had their chance. Trails an optional
2227
+ * "please" as well as "for me" (playtest sprint round 3): this lane reads the
2228
+ * RAW turn text, not normalize.mjs's FILLER_WORDS-stripped one, so "could you
2229
+ * tell me more about Router please" needs its own trailing-politeness strip. */
2066
2230
  const DESCRIBE_WRAPPER_RE =
2067
- /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe)\s+(.+?)(?:\s+for\s+me)?\s*\??$/i;
2231
+ /^(?:(?:can|could|would)\s+you\s+(?:please\s+)?|please\s+)?(?:tell\s+me\s+(?:more\s+)?about|describe)\s+(.+?)(?:\s+for\s+me)?(?:\s+please)?\s*\??$/i;
2068
2232
 
2069
2233
  async function describeWrapperAnswer(query, { config, source }) {
2070
2234
  const m = DESCRIBE_WRAPPER_RE.exec(String(query || "").trim());
@@ -2166,7 +2330,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
2166
2330
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
2167
2331
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
2168
2332
  * normal answer, never a crash. */
2169
- 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 }) {
2170
2334
  const ts = new Date().toISOString();
2171
2335
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
2172
2336
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -2186,7 +2350,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2186
2350
  // W2: the explicit recall forms are answered from memory's folded blocks, never
2187
2351
  // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
2188
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");
2189
2355
  const summary = await recallSummary(memoryDir);
2356
+ note(trace, summary ? "source: memory/fold.mjs recallSummary" : "intermediate: no folded session blocks yet — nothing to recall");
2190
2357
  return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
2191
2358
  via: "recall", miss: !summary, focus,
2192
2359
  });
@@ -2211,7 +2378,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2211
2378
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
2212
2379
  } catch (e) {
2213
2380
  answer = String(e?.message || e);
2214
- }
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");
2215
2406
  // A grammar miss has parsed:null → stays []. An empty-RESULT query (object resolved,
2216
2407
  // no edges) still records the resolved subject — that IS the asksAbout signal, and
2217
2408
  // it becomes the new focus so a follow-up "what calls it" can reuse it.
@@ -2230,6 +2421,9 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2230
2421
  // Class-gate the focus update: a Commit/Session/schema object never displaces a
2231
2422
  // standing code-entity focus (see nextFocus).
2232
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`);
2233
2427
  }
2234
2428
  }
2235
2429
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
@@ -2239,6 +2433,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2239
2433
  let via = "composed";
2240
2434
  let recordMiss = miss;
2241
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
+ }
2242
2447
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
2243
2448
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
2244
2449
  // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
@@ -2251,7 +2456,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2251
2456
  // fact-dump readers so "what do you know" gets a summary, not raw facts.
2252
2457
  if (miss) {
2253
2458
  const meta = await metaLane(query, { graph, memoryDir });
2254
- 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
+ }
2255
2463
  }
2256
2464
  if (!handled && miss && isConversational(query)) {
2257
2465
  // A conversational miss (a greeting, "what can you do", a very short non-code
@@ -2261,8 +2469,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2261
2469
  // identical orientation-class turn used to repeat the full blurb verbatim —
2262
2470
  // collapse to a one-liner on that repeat, mirroring WALL_REPEAT_ONELINER.
2263
2471
  const orientation = orientationAnswer(templates, graph);
2264
- answer = (last?.answer === orientation) ? ORIENTATION_REPEAT_ONELINER : orientation;
2472
+ const repeat = last?.answer === orientation;
2473
+ answer = repeat ? ORIENTATION_REPEAT_ONELINER : orientation;
2265
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");
2266
2477
  } else if (!handled && memoryDir) {
2267
2478
  // W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
2268
2479
  // the schema-docs surface — a remembered fact answers a miss OR extends a (non-
@@ -2277,6 +2488,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2277
2488
  via = "fact";
2278
2489
  recordMiss = false;
2279
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)");
2280
2493
  } else if (miss) {
2281
2494
  // W2: after the honest miss is composed, consult the folded-session memory. A
2282
2495
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -2302,6 +2515,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2302
2515
  answer = `${recalled}\n\n${trailing}`;
2303
2516
  via = "recall";
2304
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)");
2305
2520
  }
2306
2521
  }
2307
2522
  }
@@ -2313,7 +2528,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2313
2528
  // conversational lanes (via:"meta"/"template"), which answer a different question.
2314
2529
  if (via === "composed" || via === "fact") {
2315
2530
  const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
2316
- 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
+ }
2317
2536
  }
2318
2537
  // THE CONCEPT FORCE (concept.mjs) — a vague "what is a X" / "tell me about X" that
2319
2538
  // names a KNOWN code concept WITH real instances composes the three-band answer
@@ -2333,6 +2552,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2333
2552
  conceptInstances = concept.instances;
2334
2553
  conceptAllIds = concept.allIds;
2335
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");
2336
2557
  } else {
2337
2558
  // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
2338
2559
  // touch ("what about imports", "what are the calls", "tell me about contains").
@@ -2346,6 +2567,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2346
2567
  if (relation) {
2347
2568
  answer = relation.text; via = "corpus/seon"; recordMiss = false;
2348
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)");
2349
2572
  }
2350
2573
  }
2351
2574
  }
@@ -2360,13 +2583,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2360
2583
  if (syn) {
2361
2584
  answer = syn.text; via = "fact"; recordMiss = false;
2362
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)");
2363
2588
  }
2364
2589
  }
2365
2590
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
2366
2591
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
2367
2592
  if (miss && recordMiss && via === "composed") {
2368
2593
  const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
2369
- 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
+ }
2370
2599
  }
2371
2600
  // (4b) #4 AUTHOR lane (0.8.2 WS4) — "who is <Name>", "what did <Name> touch",
2372
2601
  // "who authored <sha>": the Commit author ATTRIBUTE answered as a person, off
@@ -2375,7 +2604,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2375
2604
  // honest miss below (never a guess).
2376
2605
  if (miss && recordMiss && via === "composed") {
2377
2606
  const authored = authorLane(query, { graph });
2378
- 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
+ }
2379
2613
  }
2380
2614
  // (4b2) #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f) — "why
2381
2615
  // does X still/again import Y": names the presupposition being checked
@@ -2384,7 +2618,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2384
2618
  // one is still an honest, confident correction, not a miss.
2385
2619
  if (miss && recordMiss && via === "composed") {
2386
2620
  const presup = await presuppositionNudge(query, { graph, memoryDir });
2387
- 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
+ }
2388
2626
  }
2389
2627
  // (4c) CAPABILITY NUDGES (0.8.2 WS4) — risk scoring / code opinions / "write me
2390
2628
  // code" imperatives / motive-"why": an honest wall pointing at the nearest real
@@ -2393,7 +2631,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2393
2631
  // short-miss's "is a <thing> a <kind>" membership hint could claim the line.
2394
2632
  if (miss && recordMiss && via === "composed") {
2395
2633
  const nudged = nudgeAnswer(query, newFocus);
2396
- 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
+ }
2397
2639
  }
2398
2640
  // (4d) DESCRIBE-WRAPPER RESCUE (playtest sprint round 2, SKILL_PLAYTEST_SPRINT.md)
2399
2641
  // — "can you describe X for me" / "tell me more about X": a closed wrapper
@@ -2407,7 +2649,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2407
2649
  // it only claims the turn if /describe actually resolves the captured term.
2408
2650
  if (miss && recordMiss && via === "composed") {
2409
2651
  const described = await describeWrapperAnswer(query, { config, source });
2410
- 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
+ }
2411
2657
  }
2412
2658
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
2413
2659
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
@@ -2416,10 +2662,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2416
2662
  // to a one-liner whose text does NOT match WALL_MISS_RE — self-limiting, so a
2417
2663
  // third consecutive miss re-offers the tailored hint instead of droning.
2418
2664
  if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
2419
- answer = (last?.answer && WALL_MISS_RE.test(String(last.answer)))
2420
- ? WALL_REPEAT_ONELINER
2421
- : shortMissHint(query);
2665
+ const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
2666
+ answer = repeat ? WALL_REPEAT_ONELINER : shortMissHint(query);
2422
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"}`);
2423
2669
  }
2424
2670
  // #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
2425
2671
  // dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
@@ -2427,6 +2673,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2427
2673
  if (recordMiss && (via === "composed" || via === "miss")
2428
2674
  && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
2429
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");
2430
2677
  }
2431
2678
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
2432
2679
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
@@ -2436,6 +2683,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2436
2683
  if (aside) {
2437
2684
  answer = `${answer}\n${aside}`;
2438
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)");
2439
2688
  }
2440
2689
  }
2441
2690
  // ADVANCED_GRAMMAR track (a) — counterfactual marker (PLAN_ADVANCED_GRAMMAR.md
@@ -2451,6 +2700,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
2451
2700
  const counterfactualSubject = String(query).trim().match(COUNTERFACTUAL_RE);
2452
2701
  if (!recordMiss && via === "composed" && counterfactualSubject) {
2453
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)`);
2454
2704
  }
2455
2705
  // The concept force answers WITH real example instances — those are the entities the
2456
2706
  // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
@@ -2490,28 +2740,52 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
2490
2740
  };
2491
2741
  }
2492
2742
 
2493
- /** A slash-command → the mapped tool (or the /help, /focus, unknown cases). Returns
2494
- * the same { answer, logLines, record, focus } shape as runAsk; the record carries
2495
- * the command name and the resolved entity id (for entity commands) so a
2496
- * slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
2497
- 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 }) {
2498
2752
  const ts = new Date().toISOString();
2499
2753
  const sp = line.indexOf(" ");
2500
2754
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
2501
2755
  const argText = (sp === -1 ? "" : line.slice(sp + 1)).trim();
2502
- const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
2756
+ const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus, narrateNext } = {}) => ({
2503
2757
  answer,
2504
2758
  logLines: [ts, `> ${line}`, answer, ""],
2505
2759
  record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
2506
2760
  focus: newFocus,
2761
+ ...(narrateNext !== undefined ? { narrate: narrateNext } : {}),
2507
2762
  });
2508
2763
 
2509
- if (name === "help") return mk(await helpText());
2510
- 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
+ }
2511
2784
 
2512
2785
  // /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
2513
2786
  // inspection" surface; the same renderer serves the `tmct memory` CLI).
2514
2787
  if (name === "memory") {
2788
+ note(trace, "goal: inspect tmct's memory store (facts/utterances/sessions)");
2515
2789
  if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
2516
2790
  try {
2517
2791
  const { inspectMemory } = await import("./memory/inspect.mjs");
@@ -2522,18 +2796,28 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2522
2796
  }
2523
2797
 
2524
2798
  if (name === "focus") {
2799
+ note(trace, "goal: set the working focus entity for follow-up pronouns (it/this/that)");
2525
2800
  if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
2526
2801
  const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
2527
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})`);
2528
2804
  return mk(`focus set to ${ent.label}.`, { resolvedIds: [ent.id], newFocus: ent });
2529
2805
  }
2530
2806
 
2531
2807
  const spec = COMMANDS[name];
2532
- 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}}` : ""})`);
2533
2814
 
2534
2815
  const entityArg = ENTITY_ARGS.has(spec.arg);
2535
2816
  let value = argText;
2536
- 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
+ }
2537
2821
  if (spec.arg && !spec.optional && !value) {
2538
2822
  const need = entityArg ? `${spec.arg} (none given and no focus set — /focus <x> or pass one)` : spec.arg;
2539
2823
  return mk(`/${name} needs a ${need}.`, { miss: true });
@@ -2543,6 +2827,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2543
2827
  try {
2544
2828
  answer = await dispatchTool(spec.tool, spec.arg ? { [spec.arg]: value } : {}, { config, source });
2545
2829
  } catch (e) {
2830
+ note(trace, `intermediate: dispatchTool("${spec.tool}") threw — ${String(e?.message || e)}`);
2546
2831
  return mk(String(e?.message || e), { miss: true }); // the tool's own clean error, never a stack
2547
2832
  }
2548
2833
  // Entity commands resolve their subject for the sidecar/graph AND set the focus so a
@@ -2553,16 +2838,18 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
2553
2838
  // Commit/Session/schema node records the resolution but does not displace a
2554
2839
  // standing code-entity focus that "it" is meant to keep binding to.
2555
2840
  if (ent) {
2841
+ note(trace, `result: resolved "${value}" -> ${ent.label} (${ent.id}, class=${graph?.byId?.get?.(ent.id)?.class || "?"})`);
2556
2842
  // Bug B4 (0.8.2 follow-up): /describe's code-map render never sees memory,
2557
2843
  // so a taught fact about the resolved entity is invisible to it — append
2558
2844
  // matching taught facts (subject === the resolved entity, trust-ranked)
2559
2845
  // under the code-map answer, mirroring the ask-path's fact-append pattern.
2560
2846
  if (name === "describe" && memoryDir) {
2561
2847
  const facts = await describedFacts(memoryDir, ent.label);
2562
- 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"); }
2563
2849
  }
2564
2850
  return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
2565
2851
  }
2852
+ note(trace, `intermediate: "${value}" did not resolve to a single entity — the tool's own (unresolved) answer stands`);
2566
2853
  }
2567
2854
  return mk(answer);
2568
2855
  }
@@ -2637,10 +2924,17 @@ function morePage(query, { last, focus }) {
2637
2924
  return turn;
2638
2925
  }
2639
2926
 
2640
- 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 } = {}) {
2641
2928
  const line = String(input ?? "").trim();
2642
2929
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
2643
- 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 };
2644
2938
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
2645
2939
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
2646
2940
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -2648,10 +2942,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2648
2942
  // finished answer becomes the `last` we expand. finish() owns the prose-span
2649
2943
  // grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
2650
2944
  // protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
2651
- // so `last` and the transcript stay consistent with what the shell prints.
2652
- 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") => {
2653
2949
  const finished = finish(result, { graph });
2654
- 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 };
2655
2952
  };
2656
2953
 
2657
2954
  // Slash-optional system commands: a bare leading command word ("stats",
@@ -2659,28 +2956,36 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2659
2956
  // layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
2660
2957
  // of falling through to the generic orientation.
2661
2958
  const bareCmd = asBareCommand(line);
2662
- if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
2959
+ if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
2663
2960
 
2664
2961
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
2665
- // 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.
2666
2965
  const convo = conversationalTurn(line, ctx);
2667
- if (convo) return convo;
2966
+ if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
2668
2967
 
2669
2968
  // "more" — page the remainder of a previous long listing, if one is held. Gated on
2670
2969
  // an actual pending remainder so a bare "more" with nothing to continue falls through
2671
2970
  // to the ordinary path (an honest miss), never a pretend page.
2672
2971
  if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
2673
- 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");
2674
2975
  }
2675
2976
 
2676
- 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");
2677
2978
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
2678
2979
  // own memory and confirm — they are statements to remember, not graph queries.
2679
2980
  // Gated on memoryDir: only a session shell provides a write target, so a bare
2680
2981
  // runTurn (tests, library callers) stays pure and falls through to the engine.
2681
2982
  if (memoryDir) {
2682
2983
  const asserted = await assertTurn(line, ctx);
2683
- 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
+ }
2684
2989
  }
2685
2990
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
2686
2991
  // memory graph owns Facts + Utterances, so these are answerable and consistent
@@ -2689,7 +2994,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2689
2994
  // structural counts (classes/functions/…) and sessions fall through unaffected.
2690
2995
  if (memoryDir) {
2691
2996
  const memCount = await answerMemoryCount(memoryDir, line);
2692
- 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
+ }
2693
3002
  }
2694
3003
  // Aggregate/count questions are answered mechanically off the loaded graph header,
2695
3004
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
@@ -2700,10 +3009,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
2700
3009
  // class count). countFromFacts declines on a real graph kind, so ordinary
2701
3010
  // counts are unaffected; it only speaks for a remembered object noun.
2702
3011
  const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
2703
- if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
2704
- 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");
2705
3020
  }
2706
- return withLast(await runAsk(line, ctx));
3021
+ return withLast(await runAsk(line, ctx), "unclear — no goal signal computed by the ask engine");
2707
3022
  }
2708
3023
 
2709
3024
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
@@ -2821,6 +3136,7 @@ export async function createSession({
2821
3136
  cwd = process.cwd(),
2822
3137
  gitRoot = gitToplevel,
2823
3138
  ephemeral = false,
3139
+ narrate = false,
2824
3140
  } = {}) {
2825
3141
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
2826
3142
  // write NOTHING back into it. The shipped examples run this way so a demo never
@@ -2829,6 +3145,13 @@ export async function createSession({
2829
3145
  // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
2830
3146
  // temp dir and the read-time graph upsert is suppressed.
2831
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 || ""));
2832
3155
  // Graph resolution order for the chat surface (documented; --repo wins):
2833
3156
  // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
2834
3157
  // 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
@@ -2954,6 +3277,7 @@ export async function createSession({
2954
3277
  get focus() { return focus; },
2955
3278
  get lastAnswer() { return last; },
2956
3279
  get turns() { return turns; },
3280
+ get narrate() { return narrateOn; },
2957
3281
  promptFor: () => promptFor(focus),
2958
3282
 
2959
3283
  /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
@@ -2965,7 +3289,7 @@ export async function createSession({
2965
3289
  async turn(line) {
2966
3290
  let result;
2967
3291
  try {
2968
- 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 });
2969
3293
  } catch (e) {
2970
3294
  const ts = new Date().toISOString();
2971
3295
  const message = e instanceof Error ? e.message : String(e);
@@ -2976,9 +3300,12 @@ export async function createSession({
2976
3300
  turns += 1;
2977
3301
  return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
2978
3302
  }
2979
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = result;
3303
+ const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
2980
3304
  focus = nextFocus;
2981
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;
2982
3309
  await writeLog(logLines.join("\n") + "\n");
2983
3310
  await writeSidecar(record);
2984
3311
  turnRecords.push(record);
@@ -3026,6 +3353,7 @@ export async function runChat({
3026
3353
  cwd = process.cwd(),
3027
3354
  gitRoot = gitToplevel,
3028
3355
  ephemeral = false,
3356
+ narrate = false,
3029
3357
  } = {}) {
3030
3358
  // createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
3031
3359
  // output until it fully resolves — found live: an operator reported `npm run chat`
@@ -3033,7 +3361,7 @@ export async function runChat({
3033
3361
  // fast subsequent run just flashes it briefly) and removes the "is this even
3034
3362
  // running" uncertainty during the one case that's genuinely slow.
3035
3363
  output.write("tmct — starting…\n");
3036
- const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
3364
+ const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral, narrate });
3037
3365
 
3038
3366
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
3039
3367
  for (const line of session.bannerLines) output.write(dim(line) + "\n");