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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -12,6 +12,44 @@ 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-06)
16
+
17
+ **Built: v0.8.1 — Phase 11 deepened (0.8.0 shipped + published; 0.8.1 push held for the operator).**
18
+ `npm test` green (**916**). 0.8.1 hardened the router with real *reasoning* measurement and closed the
19
+ frontier stages, all under the same demonstrated-vs-designed honesty discipline:
20
+
21
+ - **AGENTBENCH now grades the executed composed RESULT, not just the call-plan** (retires the headline
22
+ 0.8.0 caveat). Under result-grading the resolver is **97% plan / 91% result / 0% hallucination**; the
23
+ honest gap lands exactly where reasoning is hard (C1, C2).
24
+ - **Stage 5 — the C2 goal-reasoner** (BDI + Goal-Driven Autonomy) genuinely lifts result-completion.
25
+ The honest, **like-for-like** figure is the *driver swap on the identical 39-case ladder*
26
+ (`AGENTBENCH_0.8.1_001`): resolver **85% → goal-reasoner 95% result** (+10pp, purely the driver, 0%
27
+ hallucination). C2 is **cleared for one declared coverage-invariant goal-rule** — real and
28
+ phrasing-robust (validated by held-out phrasings graded blind, no request-string literals), but
29
+ **thinly sampled, not rule-general**; the open-world case is honestly **refused**. *(NB: the
30
+ cross-release "0→83% C2" reads on a grown 6-case C2 ladder whose 4 new cases exercise the
31
+ goal-reasoner — not a like-for-like rate; the same-basket win is one case fixed, 0→50% original C2.)*
32
+ - **Stage 2 — imperative intent frames + ACE reach** (`AGENTBENCH_0.8.1_002`, goal driver): **100% plan
33
+ / 95% result / 0% hallucination**. `tmct_calls` is now **genuinely NL-reachable** via a distinct
34
+ edge-dump frame (not by un-tagging — bidirectional conformance enforces it; `NOT_NL_REACHABLE` is now
35
+ `{}`). ACE is wired **async** into the interpret pipeline so the synchronous CHATBENCH parse spine is
36
+ **byte-identical** — ACE is inert in the shipped product path (`interpret()` is called nowhere in
37
+ chat/ask/server/bin), reach without regression.
38
+ - **Chat surface (CHATBENCH_0.8.1):** quick wins (singular "what is a test", friendly commit-author
39
+ refs, "No tests cover X" honest-empty, the chatbench discourse-count flake root-caused + scrubbed) +
40
+ two playtests (coverage-survey + authorship routing; **6 frozen `chatflow-*` transcripts** total).
41
+ Joint deterministic CHATBENCH (both new frame tables together): **no tier-1 regression vs 0.7.1.**
42
+ Judged tags touched by the text changes were re-judged (not blanket-reused) — see `CHATBENCH_0.8.1`.
43
+
44
+ **Shipped earlier in v0.8.0 (published):** all five Phase-11 tracks — the `/v1/messages` shim + Stage-0
45
+ registry + resolver/guardrail/planner (`AGENTBENCH_0.8.0_001`: 96% *plan* completion, 0% hallucination,
46
+ closed-world C1); the three chat levers (`CHATBENCH_0.8.0`: tier-1 331→333); the `../bedrock-meter` `$0`
47
+ rung (rank 0 below nova-micro, £0 metered e2e); the playtest; and the Stage-2/Stage-5 research notes.
48
+
49
+ **Next:** grow the thin C2/coverage sampling to a second goal-rule; author→commit querying (a dead-end
50
+ the friendly-commit-ref quick win opened); AGENTBENCH ladder depth; the tier-4/tier-5 Phase LATER long
51
+ tail. See Phase 11 below and `HANDOVER.md` for the full open-follow-up list.
52
+
15
53
  ## The umbrella product definition (item 1)
16
54
 
17
55
  **A tolerant, ELIZA/PARRY-style chat, obsessed with software.** A best-efforts
@@ -279,11 +317,13 @@ and the block-index summary (blocks, tokens, top PageRank blocks). Same renderer
279
317
 
280
318
  > **STATUS: in progress — the two headline B1 levers shipped.** Negation as a bounded SET
281
319
  > COMPLEMENT ("which X do not <verb> Y") and reversible-passive traversal ("X is imported by Y")
282
- > are live; the harness meta-fixes and the rest of the ranked lever board continue.
320
+ > are live; the harness meta-fixes and the rest of the ranked lever board (levers 3–6 + the C2
321
+ > ceiling, below) continue. **This section is the durable home for the tuning arc** — the detailed
322
+ > cycle-4 plan was archived to `archive/PLAN_CYCLE_4.md` once its substance lived here.
283
323
 
284
- > Detailed plans: **PLAN_CYCLE_4.md** (the tuning arc) and **PLAN_DEPENDENCY_STRATEGY.md**
285
- > (the pre-build dependency audit — verdict: no dep changes now; a standing adoption
286
- > register + avoid-list for phases 6-9; two near-term actions).
324
+ > The dependency audit that also fed this phase is archived at `archive/PLAN_DEPENDENCY_STRATEGY.md`
325
+ > (verdict: no dep changes now; a standing adoption register + avoid-list for phases 6-9; two
326
+ > near-term actions, both shipped see below).
287
327
 
288
328
  The immediate work: drive the graded benchmark up the CEFR ladder, one lever per cycle, per
289
329
  `SKILL_TUNING_CYCLE.md`. Cycle 3 (post-wiring-wave, CHATBENCH_003) gave the first full-spectrum
@@ -312,7 +352,7 @@ from meta-1's harness artifact), **C2 ceiling LAST**. Operator decision 2026-07-
312
352
  them**. Exit criterion in the plan (roughly: B1 grade mean ≥ ~1.5 with all cells dual-draw
313
353
  agreeing unlocks C-grade judging).
314
354
 
315
- ## Near-term actions (from the dependency audit, PLAN_DEPENDENCY_STRATEGY.md)
355
+ ## Near-term actions (from the dependency audit, archive/PLAN_DEPENDENCY_STRATEGY.md)
316
356
 
317
357
  > **STATUS: both shipped.** The wink browser-loader seam is added (shared model loader with a
318
358
  > browser registration path) and `fnv1a` is single-sourced into `src/hash.mjs`.
@@ -322,7 +362,7 @@ Two concrete, low-risk actions the audit surfaced — not features, not dep chan
322
362
  1. **Fix wink's `createRequire` browser-loader gap** — the wink model IS the browser build, but
323
363
  our adapters load it via `createRequire(import.meta.url)` (`ask-nlp.mjs:29`, `prose-nlp.mjs:31`),
324
364
  which is Node-only. Browser mode needs a bundler `import` path. **A Phase 8 (browser-mode)
325
- blocker** — budget it into `PLAN_REPOSITORY_INTERFACE.md`; it is a wiring fix, not a dependency
365
+ blocker** — budget it into `archive/PLAN_REPOSITORY_INTERFACE.md`; it is a wiring fix, not a dependency
326
366
  change (the model is already browser-capable).
327
367
  2. **Single-source `fnv1a`** — extract the content-address hash to one `src/hash.mjs` so the
328
368
  cross-version-stable fact-id contract has a single definition. Trivial refactor, do any time;
@@ -337,7 +377,7 @@ Two concrete, low-risk actions the audit surfaced — not features, not dep chan
337
377
  > `/memory` inspector surfaces contradictions with provenance. Legacy `mgx:factProvenance` kept
338
378
  > as a compat shim.
339
379
 
340
- > Detailed plan: **PLAN_PROVENANCE_TRUST.md**.
380
+ > Detailed plan: **archive/PLAN_PROVENANCE_TRUST.md**.
341
381
 
342
382
  *(Operator-specified 2026-07-05, from the observation that Phase-6 canonicalise-and-link,
343
383
  tier-4 learn-on-miss, and the ConceptNet slice all share one shape: raw source preserved,
@@ -370,7 +410,7 @@ fact enters memory:
370
410
  > **STATUS: shipped.** A technical (C1) register of templates and productive/performance
371
411
  > dual-banding (computed from the `via` provenance) are live in the benchmark.
372
412
 
373
- > Detailed plan: **`PLAN_FORMULAIC_COMPETENCE.md`**.
413
+ > Detailed plan: **`archive/PLAN_FORMULAIC_COMPETENCE.md`**.
374
414
 
375
415
  The operator's insight upgraded to the strategy: a consistently-failed C1/C2 graded cell whose
376
416
  answer EXISTS as a stable phrasing in technical prose is not a ceiling — it is a
@@ -403,7 +443,7 @@ answer EXISTS as a stable phrasing in technical prose is not a ceiling — it is
403
443
  > pass runs on prose spans only under a protected-span invariance guard. The a/an article fix is
404
444
  > active; broader voice/agreement rules are implemented-but-parked.
405
445
 
406
- > Detailed plan: **`PLAN_RESPONSE_FINISHING.md`**.
446
+ > Detailed plan: **`archive/PLAN_RESPONSE_FINISHING.md`**.
407
447
 
408
448
  *(Refined 2026-07-05; decisions settled with the operator. Fact invariance is achieved by
409
449
  CONSTRUCTION, not by hope. Finishing operates over a SEGMENTED answer, never a raw string.
@@ -440,7 +480,7 @@ ever emerge.)*
440
480
  > runnable conformance/compatibility suite; and `tmct init` (scaffold `.tmct/`, `tmct.toml`,
441
481
  > tier-1 seed, provenance).
442
482
 
443
- > Detailed plan: **`PLAN_REPOSITORY_INTERFACE.md`**.
483
+ > Detailed plan: **`archive/PLAN_REPOSITORY_INTERFACE.md`**.
444
484
 
445
485
  *(Operator-specified 2026-07-05; upgraded from research item to a build phase. tmct was spun OUT
446
486
  of seonix; this inverts the relationship: seonix reorients as a USER that imports the tmct
@@ -512,7 +552,7 @@ a passive payload loader into the product's primary integration surface.)*
512
552
  before it — PROACTIVELY extending memory with inferences that will be useful later, forward and
513
553
  backward chaining over the OWL base during idle/fold time rather than at query time.)*
514
554
 
515
- > Detailed plan: **`PLAN_SPECULATIVE_INFERENCE.md`**.
555
+ > Detailed plan: **`archive/PLAN_SPECULATIVE_INFERENCE.md`**.
516
556
 
517
557
  **A maintenance job, not a query-time cost.** Speculative inference runs as an explicit
518
558
  batch — `npx tmct syllogise --depth <N>` (default depth bounded, e.g. 32) — and **once
@@ -595,6 +635,75 @@ you toward precision" promise on the conversational surface.
595
635
  with new graded cells for the miss / empty-graph / concept-touch surfaces so these become
596
636
  regression-protected levers, not one-off polish.
597
637
 
638
+ ## Phase 11 — The capability router & the agentic bench (0.8.0 shipped · 0.8.1 deepened)
639
+
640
+ *(Operator-directed 2026-07-06; built the same day across five concurrent tracks.)* tmct as a **deterministic, no-LLM
641
+ tool router** behind an Anthropic-compatible API — the workstream specified in
642
+ `PLAN_CAPABILITY_ROUTER.md`, grounded in `docs/references/planning/`. This is a **new capability on a
643
+ new axis** (driving a tool loop, not answering a chat turn), so it gets its **own benchmark**:
644
+ **`AGENTBENCH`**, a sibling to CHATBENCH — same versioned-naming + grading discipline
645
+ (`AGENTBENCH_<version>.md`, `_00N` for re-runs), but the levels are the **A0→C2 agentic rungs** and a
646
+ **hallucinated tool call is an automatic fail**.
647
+
648
+ **Status: DEMONSTRATED (with a stated scope caveat).** The router is built and measured:
649
+ `AGENTBENCH_0.8.0_001` = **96% completion at 0% hallucination on every rung**, closed-world ladder
650
+ cleared to **C1**. The gate the phase was staked on — a **0% hallucination rate on a real domain** (the
651
+ graph-query toolset over the fixture) — **is met.** The honest scope line, held from the start:
652
+ AGENTBENCH grades the correct **call-plan + causal-link proof, not the executed composed result**; the
653
+ B1/B2/C1 rungs are **thin (2–3 cases)**; and the one C2 case is **refused** (the Stage-5 goal-reasoner
654
+ is designed, not built). So "closed-world C1" means *the router provably selects and binds the right
655
+ tool sequence*, not *end-to-end multi-step reasoning* — the demonstrated-vs-designed boundary is the
656
+ real deliverable. The five tracks below are all built; the two research-agent stages (Track 4 below)
657
+ remain designed-not-built by intent.
658
+
659
+ ### Track 1 — chat-surface levers (next CHATBENCH; all three)
660
+ The three levers `CHATBENCH_0.7.1` measured + ranked — which **double as router prerequisites** (they
661
+ gate the A2→B1→C1 rungs, per Phase B of the router plan):
662
+ 1. **Pronoun / focus binding** — the "it → Commit" mis-bind (`B1 pron 1.24`); biggest movable mass.
663
+ 2. **Discourse-count anaphora** — "count them / how many of those" over a prior listing (clears the 2
664
+ `CHATBENCH_0.7.1` tier-1 misses).
665
+ 3. **C1 temporal-over-relative composition** — the two-hop ceiling (`C1 temp 0.31`).
666
+ Land all three (not just #1); they raise the chat floor *and* the router's floor at once.
667
+
668
+ ### Track 2 — the router build (the within-horizon slice, in order)
669
+ Buildable now with a frontier model as co-author (see PLAN §"solved vs unsolved"):
670
+ - **Phase A — the shim.** An Anthropic Messages API endpoint (`/v1/messages`, `tool_use`/`tool_result`
671
+ blocks). **Extended:** also present as a **`bedrock-meter`-compatible routing target** (see below).
672
+ - **Phase B — measure today → `AGENTBENCH_0.7.2.md`.** Shim + a small graph-query toolset up the
673
+ A0→C2 ladder; the honest baseline (expected A0 solid, A1–A2 partial, per the CHATBENCH_0.7.1
674
+ inherited assets).
675
+ - **Phase C — the grading ladder.** The AGENTBENCH benchmark itself (rungs as levels, comparable
676
+ local/hosted models as reference bands, zero-hallucination gate).
677
+ - **Stage 0 — capability registry** (`Capability`/`Parameter`/`Precondition`/`Effect` = STRIPS/PDDL
678
+ operators as facts).
679
+ - **Stage 1 — the resolver** (unification + backward chaining / a mini Datalog).
680
+ - **Stage 4 — the guardrail** (validate an LLM's proposed `tool_use` against declared preconditions —
681
+ the hybrid fast-path; cheap once 0–1 exist).
682
+ - **Stage 3 — the planner** (POP/HTN over operators + Steel & Ho monitor-and-replan → **closed-world
683
+ C1**; optionally defer search to an external PDDL solver).
684
+
685
+ ### Phase A extension — the `bedrock-meter` deployment surface
686
+ `../bedrock-meter` is pre-flight Bedrock cost metering + capping, with a **roadmap optimiser** that
687
+ "cheaply assesses a task's complexity … and routes to the lowest-cost capable model" (it already
688
+ meters Nova Lite + Nova Micro). tmct — **benchmarked against agent capabilities by AGENTBENCH** — slots
689
+ in as the **$0 floor *below* Nova-micro** in that routing ladder: for a request class AGENTBENCH proves
690
+ in-envelope, the optimiser routes to tmct (deterministic, ~$0, ms latency) instead of any metered
691
+ model. So Phase A's shim is built **bedrock-meter-pluggable**, and AGENTBENCH is what defines the
692
+ envelope the optimiser is allowed to trust. This is the concrete "near-free alternative" deployment.
693
+
694
+ ### Track 3 — playtest alongside the build (`git worktree`)
695
+ Run `SKILL_CHAT_PLAYTEST.md` **in a parallel `git worktree`** while the router is built — the
696
+ dialogue-flow dead-end hunt keeps running without blocking the build, and its fixes **merge back**.
697
+ (The worktree is auto-cleaned if unchanged; merge the frozen `test/chatflow-*` transcripts in.)
698
+
699
+ ### Track 4 — research agents (the "at the edge" stages)
700
+ Two stages need design judgment + exploration, so they run as **background research agents**, off the
701
+ critical build path:
702
+ - **Stage 2 — intent frames, controlled fragment** — imperative NL → structured intent for the
703
+ controlled command language (the front-end; the general case stays out-of-scope / escalate).
704
+ - **Stage 5 — goal-reasoner, closed-world C2** — BDI + Goal-Driven Autonomy: deduce-goals (long-chain
705
+ deduction) → plan-each (C1) → threat-aware, *persistent* first-step arbitration.
706
+
598
707
  ## Phase LATER — recognized, deferred, not now
599
708
 
600
709
  Features we have deliberately shaped seams for but will not build until the phases above have
package/bin/tmct.mjs CHANGED
@@ -48,6 +48,10 @@ Usage:
48
48
  tmct syllogise [--repo <abs>] speculative inference (offline maintenance job): forward-
49
49
  [--depth <n>] [--budget <n>] chain the memory's rdfs:subClassOf closure, materialising
50
50
  bounded, low-trust, retractable entailed facts (never on the chat path)
51
+ tmct serve [--repo <abs>] run the Anthropic Messages API-compatible endpoint
52
+ [--host <h>] [--port <n>] (POST /v1/messages) over the graph — a deterministic,
53
+ no-LLM "model" a tool-loop client can call; $0 usage.
54
+ Defaults: host 127.0.0.1, port 8787. Ctrl+C to stop.
51
55
  tmct cli <tool> '{…}' invoke a graph tool directly (carry-over, de-emphasized)
52
56
  tmct cli digest '{…}' architecture map + per-module context bundles
53
57
  tmct --help show this help
@@ -387,6 +391,47 @@ async function main() {
387
391
  return;
388
392
  }
389
393
 
394
+ if (mode === "serve") {
395
+ // `tmct serve` — the Phase-A capability-router interface: an Anthropic
396
+ // Messages API-compatible HTTP endpoint (POST /v1/messages) over the graph.
397
+ // A deterministic, no-LLM "model" a tool-loop client (Claude Code) can point
398
+ // at; every response reports $0 usage. Read-only: no session artifacts, no
399
+ // writes back to the graph. See src/server-http.mjs.
400
+ const rest = process.argv.slice(3);
401
+ if (rest.includes("--help") || rest.includes("-h")) {
402
+ process.stdout.write(
403
+ "tmct serve — Anthropic Messages API-compatible endpoint (POST /v1/messages)\n\n" +
404
+ "Usage:\n" +
405
+ " tmct serve [--repo <abs>] [--host <h>] [--port <n>]\n\n" +
406
+ " --repo <abs> target a repo's graph (<abs>/.tmct/graph.json); default: cwd/TMCT_GRAPH_FILE\n" +
407
+ " --host <h> bind address (default 127.0.0.1)\n" +
408
+ " --port <n> TCP port (default 8787; 0 picks an ephemeral port)\n\n" +
409
+ "Request: { model, messages:[...], tools:[...], max_tokens, system? }\n" +
410
+ "Response: { id, type:\"message\", role:\"assistant\", content:[...blocks], stop_reason, usage }\n" +
411
+ " usage is always { input_tokens: 0, output_tokens: 0 } — tmct is the $0 floor.\n",
412
+ );
413
+ return;
414
+ }
415
+ const strFlag = (name, dflt) => { const j = rest.indexOf(name); return j !== -1 ? rest[j + 1] : dflt; };
416
+ const repoPath = strFlag("--repo", undefined);
417
+ const host = strFlag("--host", "127.0.0.1");
418
+ const portRaw = strFlag("--port", undefined);
419
+ const port = portRaw !== undefined && Number.isFinite(Number(portRaw)) ? Number(portRaw) : 8787;
420
+ const { join } = await import("node:path");
421
+ const { startServer } = await import("../src/server-http.mjs");
422
+ const { loadConfig, DEFAULT_GRAPH_REL } = await import("../src/config.mjs");
423
+ const configFor = (rp) => rp ? { graphFile: join(rp, DEFAULT_GRAPH_REL) } : loadConfig();
424
+ const srv = await startServer({ config: configFor(repoPath), host, port });
425
+ process.stdout.write(
426
+ `tmct serve — Anthropic Messages API at ${srv.url}/v1/messages (POST) — ` +
427
+ `graph ${srv.config.graphFile} — usage billed $0 — Ctrl+C to stop\n`,
428
+ );
429
+ const shutdown = async () => { await srv.close(); process.exit(0); };
430
+ process.on("SIGINT", shutdown);
431
+ process.on("SIGTERM", shutdown);
432
+ return; // the listening server keeps the event loop alive
433
+ }
434
+
390
435
  if (mode === "cli") {
391
436
  await runCliMode();
392
437
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
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.",
@@ -85,6 +85,8 @@
85
85
  "example:polyglot": "node bin/tmct.mjs chat --repo examples/polyglot --ephemeral",
86
86
  "chatbench:run": "node chatbench/run.mjs",
87
87
  "chatbench:judge": "node chatbench/judge.mjs",
88
+ "serve": "node bin/tmct.mjs serve",
89
+ "agentbench:run": "node agentbench/run.mjs",
88
90
  "audit": "npm audit --audit-level=high",
89
91
  "audit:fix": "npm audit fix"
90
92
  },
package/src/ask-vocab.mjs CHANGED
@@ -220,7 +220,7 @@ export const MODIFIER_TO_KIND = Object.freeze({
220
220
  transitively: "transitive", indirectly: "transitive",
221
221
  });
222
222
 
223
- // ---- reversible-passive participles (Cycle 6, PLAN_CYCLE_4.md) — past participles ->
223
+ // ---- reversible-passive participles (Cycle 6, archive/PLAN_CYCLE_4.md) — past participles ->
224
224
  // relation kind, for the agent-marked passive "X is <participle> by Y". Kept SEPARATE
225
225
  // from VERB_TO_KIND on purpose: these forms are NOT standalone active verbs in this
226
226
  // grammar ("defined" belongs to the multi-word "is defined in" and to the WHERE_MARKERS
package/src/ask.mjs CHANGED
@@ -246,6 +246,7 @@ function parseComposite(text, nlp) {
246
246
  const lc = w.map((x) => x.toLowerCase());
247
247
  return parseNegation(text, nlp, 0)
248
248
  || parseForwardNegation(w, lc, nlp)
249
+ || parseTemporal(w, lc, nlp, 0)
249
250
  || parseAnaphora(w, lc, nlp)
250
251
  || parseAggregate(w, lc, nlp)
251
252
  || parseSuperlative(w, lc, nlp)
@@ -254,7 +255,7 @@ function parseComposite(text, nlp) {
254
255
  || parseRelationalOrQualified(w, lc, nlp, 0);
255
256
  }
256
257
 
257
- // B1 NEGATION (Cycle 5, PLAN_CYCLE_4.md) — the SET COMPLEMENT. "which X do not <verb>
258
+ // B1 NEGATION (Cycle 5, archive/PLAN_CYCLE_4.md) — the SET COMPLEMENT. "which X do not <verb>
258
259
  // Y" / "X that don't <verb> Y" / "modules not importing Y" / "which X are not
259
260
  // <qualifier>" compiles to allOfClass(kind) DIFFERENCE (the positive result set),
260
261
  // reusing the EXISTING machinery: evalBoolean already folds a "difference" atom, and
@@ -416,6 +417,44 @@ function parseNested(w, lc, nlp, depth) {
416
417
  return null;
417
418
  }
418
419
 
420
+ // TEMPORAL-OVER-RELATIVE (Phase 11 Track 1, lever 3) — "when did <relative set> [last]
421
+ // change". The flat when-shape (traverse) dates the commits touching ONE resolved term;
422
+ // this composes that same touches→commit→date-sort machinery as an OUTER operator over a
423
+ // NESTED inner set ("when did the modules that import X last change", "when were the
424
+ // functions that call Y last touched"). Fires only for a RELATIVE subject (a "that/which"
425
+ // marker) so the single-entity "when did X change" stays on the flat path untouched; a
426
+ // marker present but uncompilable inner is an honest miss, never a guess.
427
+ const TEMPORAL_AUX = new Set(["did", "was", "were", "do", "does", "has", "have", "had"]);
428
+ const TEMPORAL_TAIL = new Set([
429
+ "change", "changed", "changes", "update", "updated", "updates",
430
+ "modify", "modified", "modifies", "touch", "touched", "touches", "edit", "edited", "revise", "revised",
431
+ ]);
432
+ const TEMPORAL_TRAIL_FILLER = new Set(["last", "recently", "ever", "get", "got", "been", "then", "now", "already"]);
433
+ const TEMPORAL_DET = new Set(["the", "a", "an", "all", "those", "these", "any"]);
434
+
435
+ function parseTemporal(w, lc, nlp, depth = 0) {
436
+ if (lc[0] !== "when") return null; // temporal questions lead with "when"
437
+ let i = 1;
438
+ if (!TEMPORAL_AUX.has(lc[i])) return null; // need an auxiliary ("when did …")
439
+ i += 1;
440
+ // the change-verb tail — take the LAST occurrence so "…that import X last change" works.
441
+ let t = -1;
442
+ for (let k = lc.length - 1; k >= i; k -= 1) { if (TEMPORAL_TAIL.has(lc[k])) { t = k; break; } }
443
+ if (t < 0) return null; // no change verb → not a temporal question
444
+ let subjWords = w.slice(i, t);
445
+ let subjLc = lc.slice(i, t);
446
+ while (subjLc.length && TEMPORAL_TRAIL_FILLER.has(subjLc[subjLc.length - 1])) { subjWords = subjWords.slice(0, -1); subjLc = subjLc.slice(0, -1); }
447
+ while (subjLc.length && TEMPORAL_DET.has(subjLc[0])) { subjWords = subjWords.slice(1); subjLc = subjLc.slice(1); }
448
+ if (!subjWords.length) return null;
449
+ // ONLY a relative/nested subject composes here; a bare named entity is the flat path's.
450
+ if (!subjLc.some((x) => RELATIVE_PRONOUNS.includes(x))) return null;
451
+ const framed = FRAME_WORDS.has(subjLc[0]) ? subjWords.join(" ") : `which ${subjWords.join(" ")}`;
452
+ const inner = parseSetPhrase(framed, nlp, depth + 1);
453
+ if (!inner || inner.node === "miss") return inner ? { node: "miss", reason: inner.reason || "the inner set of the temporal query didn't parse" } : { node: "miss", reason: "the inner set of the temporal query didn't parse" };
454
+ const noun = entityNoun(subjLc[0]);
455
+ return { node: "temporal", inner, entityType: (noun && noun.entityType) || null };
456
+ }
457
+
419
458
  /** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
420
459
  * of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
421
460
  * fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
@@ -745,7 +784,15 @@ function reverseOverSet(graph, kind, entityType, objectIds) {
745
784
  const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
746
785
  return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
747
786
  }
748
- const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => objectIds.has(e.object));
787
+ // GRAIN-AWARE OBJECT SET (Phase 11 Track 1, lever 3): when the inner set resolved to
788
+ // FINE symbols (functions/methods/…), also scan the symbol-grain sibling — the coarse
789
+ // edge (touches Commit→Module, calls Module→Module) can never point AT a symbol, so a
790
+ // two-hop whose inner clause produced symbols ("which commits touched the functions
791
+ // that call X") would falsely miss without it. Mirrors traverse()'s objIsFineSymbol
792
+ // branch for the flat path; the module-coarse case is byte-unchanged (no fine ids).
793
+ const objHasFine = !!symbolKind && [...objectIds].some((id) => FINE_ENTITY_TYPES.has(graph.byId.get(id)?.class));
794
+ const scanKinds = objHasFine ? [...kindsFor(kind), symbolKind] : kindsFor(kind);
795
+ const edges = scanKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => objectIds.has(e.object));
749
796
  const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
750
797
  if (!entityType || entityType === "Change") return subjects;
751
798
  const direct = subjects.filter((s) => s.class === entityType);
@@ -933,6 +980,24 @@ function degreeMetric(graph, ind, metric) {
933
980
  }
934
981
  return n;
935
982
  }
983
+ /** TEMPORAL over a nested set (lever 3) — the commits that touched ANY member of the
984
+ * inner set, newest commit date first. Reuses the SAME touches→commit→date-sort the
985
+ * flat when-shape runs (mgx:commitDate is ISO-8601, so a lexical sort IS a date sort;
986
+ * undated commits sort last and render says so). `entityType` is the inner noun, only
987
+ * for phrasing. An empty inner set (nothing resolved) or no touching commit is an
988
+ * honest empty — never a guess. */
989
+ function evalTemporal(graph, ast, opts) {
990
+ const inner = evalSet(graph, ast.inner, opts);
991
+ const ids = new Set(inner.map((i) => i.id));
992
+ if (!ids.size) return { compositeKind: "temporal", matches: [], entityType: ast.entityType, innerCount: 0 };
993
+ // reverseOverSet(touches) collects the touching commits across BOTH grains (its
994
+ // grain-aware object-set branch reads touchesSymbol when the inner set is symbols).
995
+ const commits = reverseOverSet(graph, "touches", "Commit", ids);
996
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
997
+ commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
998
+ return { compositeKind: "temporal", matches: commits, entityType: ast.entityType, innerCount: inner.length };
999
+ }
1000
+
936
1001
  function evalSuperlative(graph, ast) {
937
1002
  const pool = graph.individuals.filter((i) => i.class === ast.entityType);
938
1003
  const scored = pool.map((ind) => ({ ind, score: degreeMetric(graph, ind, ast.metric) }))
@@ -950,6 +1015,7 @@ export function evalComposite(graph, ast, opts = {}) {
950
1015
  if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
951
1016
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
952
1017
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1018
+ if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
953
1019
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
954
1020
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
955
1021
  }
@@ -1000,6 +1066,32 @@ function renderComposite(parsed, result) {
1000
1066
  miss: false, ambiguous: false, matches: result.matches,
1001
1067
  };
1002
1068
  }
1069
+ // temporal (lever 3): the newest touching commit + its date over the inner set;
1070
+ // honest empty when nothing in the set was touched, undated commits said out loud —
1071
+ // the same discipline as the flat when-shape, now over a composed set.
1072
+ if (result.compositeKind === "temporal") {
1073
+ const n = result.innerCount || 0;
1074
+ const setNoun = result.entityType ? nounFor(result.entityType, n || 2) : (n === 1 ? "entity" : "entities");
1075
+ const wasWere = n === 1 ? "was" : "were";
1076
+ if (!n) {
1077
+ return { content: `nothing in the index matches the inner set, so there is no change history to date.`, miss: true, ambiguous: false, matches: [] };
1078
+ }
1079
+ if (!result.matches.length) {
1080
+ return { content: `no recorded commit touched the ${n} ${setNoun} in that set in this index.`, miss: true, ambiguous: false, matches: [] };
1081
+ }
1082
+ const newest = result.matches[0];
1083
+ const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
1084
+ if (!date) {
1085
+ return { content: `the ${setNoun} in that set ${wasWere} last touched by commit ${newest.label}, but this index records no commit dates — regenerate the graph to attach mgx:commitDate.`, miss: true, ambiguous: false, matches: result.matches };
1086
+ }
1087
+ const msg = (newest.attributes || []).find((a) => a.key === "message")?.value || "";
1088
+ const day = String(date).slice(0, 10);
1089
+ const more = result.matches.length - 1;
1090
+ return {
1091
+ content: `the ${setNoun} in that set ${wasWere} last touched by commit ${newest.label} on ${day}${msg ? ` ("${msg}")` : ""}${more ? `; ${more} earlier commit${more === 1 ? "" : "s"} recorded` : ""}.`,
1092
+ miss: false, ambiguous: false, matches: result.matches,
1093
+ };
1094
+ }
1003
1095
  // set-producing
1004
1096
  if (!result.matches.length) {
1005
1097
  return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
@@ -1554,6 +1646,17 @@ function symbolLabelOf(ind) {
1554
1646
  return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
1555
1647
  }
1556
1648
 
1649
+ /** A FRIENDLY commit reference for a "who touched X" list — the raw sha alone reads as
1650
+ * noise, so when the Commit individual carries an author (mgx:commitAuthor → key
1651
+ * "author") name them beside it. The label is already the graph's short ref (the
1652
+ * builder stores sha.slice(0,12)), so it is used verbatim. Degrades gracefully: a
1653
+ * commit with no recorded author renders the sha alone, exactly as before. */
1654
+ function commitRefOf(ind) {
1655
+ const sha = String(ind.label || ind.id || "");
1656
+ const author = (ind.attributes || []).find((a) => a.key === "author")?.value;
1657
+ return author ? `${sha} (${author})` : sha;
1658
+ }
1659
+
1557
1660
  function listJoin(syms) {
1558
1661
  return syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
1559
1662
  }
@@ -1742,6 +1845,20 @@ function renderCore(parsed, result) {
1742
1845
  miss: true, ambiguous: false,
1743
1846
  };
1744
1847
  }
1848
+ // "what tests cover X" / "what tests X" — the tests themselves are the search
1849
+ // target (no explicit entity keyword → entityType null), and "tests" reads as a
1850
+ // verb phrase, so the generic "No <modules> found whose module directly tests <obj>"
1851
+ // template garbles: it mislabels the searched kind as "modules" and lets the leaked
1852
+ // "cover " verb ride into the object ("…directly tests cover X"). Render the honest
1853
+ // empty as the natural "No tests cover X." The frozen entity-keyword form ("which
1854
+ // modules test X", entityType="Module") keeps its pinned wording below.
1855
+ if (parsed.kind === "tests" && !parsed.entityType) {
1856
+ const obj = String(parsed.object || "").replace(/^cover(?:s|ing)?\s+/i, "").trim();
1857
+ return {
1858
+ content: `No tests cover ${obj}. (traversal: ${result.traversal || "no traversal resolved"})`,
1859
+ miss: true, ambiguous: false,
1860
+ };
1861
+ }
1745
1862
  // NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
1746
1863
  // the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
1747
1864
  // (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
@@ -1761,7 +1878,10 @@ function renderCore(parsed, result) {
1761
1878
  // Commit list ("which commits touched X") has no containing module to group by, so
1762
1879
  // anything that is not a fine entity takes the flat join.
1763
1880
  if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
1764
- const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
1881
+ // A reverse "who touched X" resolves to Commit individuals — render friendly refs
1882
+ // (short sha + author) instead of the raw stored sha; every other flat list (module
1883
+ // labels, etc.) keeps its own label verbatim.
1884
+ const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.class === "Commit" ? commitRefOf(m) : m.label);
1765
1885
  const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
1766
1886
  return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
1767
1887
  }
package/src/chat.mjs CHANGED
@@ -105,6 +105,29 @@ export const COMMANDS = {
105
105
  * back to the focus for, and that update the focus on a successful resolve. */
106
106
  const ENTITY_ARGS = new Set(["symbol", "module", "class"]);
107
107
 
108
+ /** The individual classes that are FOCUS-WORTHY — the code entities the ENTITY_ARGS
109
+ * commands (symbol/module/class) name and that "it"/"this" should bind to. A
110
+ * `Commit`/`Session` (history/provenance) or a `SchemaClass`/`SchemaPredicate`
111
+ * (vocabulary meta-node) is a real graph individual but NOT a standing antecedent:
112
+ * it must not silently DISPLACE a code-entity focus, or the next turn's pronoun
113
+ * binds to the wrong thing (CHATBENCH_0.7.1 B1-pron: "it" → Commit). */
114
+ const FOCUS_WORTHY_CLASSES = new Set(["Module", "Function", "Class", "Method", "Attribute", "GlobalVariable"]);
115
+
116
+ /** Is the individual with this id a focus-worthy code entity? Looks the class up on
117
+ * the loaded graph (focus objects stay `{id,label}` — the class is never stored on
118
+ * them, so a caller that deepEquals the focus shape is unaffected). An unknown id
119
+ * (ext: endpoint, missing) is treated as not-worthy: conservative, so it never
120
+ * displaces a standing code focus but is freely adopted when there is none. */
121
+ const isFocusWorthy = (graph, id) => FOCUS_WORTHY_CLASSES.has(graph?.byId?.get(id)?.class);
122
+
123
+ /** The focus to carry forward after a turn resolved `ent`. A newly-resolved entity
124
+ * becomes the focus UNLESS it is not focus-worthy (a Commit/Session/schema node)
125
+ * AND there is already a standing focus-worthy (code) focus — in which case the
126
+ * standing code focus holds. So a query whose object resolves to a Commit never
127
+ * hijacks the "it" antecedent from the module/function the user was working on. */
128
+ const nextFocus = (graph, focus, ent) =>
129
+ (!focus?.id || isFocusWorthy(graph, ent.id) || !isFocusWorthy(graph, focus.id)) ? ent : focus;
130
+
108
131
  /** System-command words that a forgiving shell accepts WITHOUT the leading "/":
109
132
  * `stats`, `memory`, `describe X`, `members X`, … all work bare. "help" is left
110
133
  * out on purpose — bare "help" stays the friendly orientation; "/help" is the
@@ -127,9 +150,18 @@ export function asBareCommand(line) {
127
150
  if (!trimmed || trimmed.startsWith("/")) return null;
128
151
  const [first, ...restTok] = trimmed.split(/\s+/);
129
152
  if (!COMMAND_WORDS.has(first.toLowerCase())) return null;
153
+ const fl = first.toLowerCase();
130
154
  const rest = restTok.join(" ");
131
155
  // Zero-arg system commands are always the command; a bare command word is too.
132
- if (!rest || first.toLowerCase() === "stats" || first.toLowerCase() === "memory") return `/${trimmed}`;
156
+ if (!rest || fl === "stats" || fl === "memory") return `/${trimmed}`;
157
+ // A NO-ARGUMENT command word ("untested") with trailing words is NOT a command
158
+ // call — the /untested tool takes no argument and would silently drop the qualifier,
159
+ // listing MODULES for "untested classes". "untested classes" / "untested modules"
160
+ // is a kind-FILTERED query the ask engine answers correctly (Base, Button for
161
+ // classes) AND, as a listing, seeds discourse-count anaphora ("count them",
162
+ // "how many of those are tested") with its match set — the CHATBENCH_0.7.1
163
+ // discourse-count tier-1 misses (g-b1-disc-count-22/-3). Fall through to the engine.
164
+ if (COMMANDS[fl]?.arg == null && rest) return null;
133
165
  // Arg commands: route only a short, name-like argument (no query connectives),
134
166
  // so "describe Widget" / "members my class" route but a compositional query does not.
135
167
  if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${trimmed}`;
@@ -1300,6 +1332,19 @@ function relationTermOf(query, envelope) {
1300
1332
  if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
1301
1333
  // "what is calling", "what is importing" (bare gerund, no object)
1302
1334
  if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
1335
+ // THE SINGULAR META FORM — "what is a test" / "what is an import". The whole meta
1336
+ // shape used to be excluded here to keep the frozen am-meta-imports ambiguity case
1337
+ // ("what does imports mean") out; but that case is a DIFFERENT shape (ambiguousParse
1338
+ // → envelope.parsed is null), and a relation word whose SINGULAR reads as a real
1339
+ // graph-schema class/predicate ("what is a contains"/"cochange") answers non-miss
1340
+ // from the ordinary meta path. So admit the article meta form ONLY when the ordinary
1341
+ // path MISSED on a plain definitional parse (envelope.miss on a shape:"meta" object):
1342
+ // an unambiguous relation word like "test" — no schema reading, no ambiguity — then
1343
+ // reaches the relation-concept force exactly as its plural "what are the tests" does.
1344
+ // RELATION_TERM still gates the term downstream, so a non-relation miss is untouched.
1345
+ if (envelope?.miss === true && envelope?.parsed?.shape === "meta" && envelope.parsed.object) {
1346
+ return envelope.parsed.object;
1347
+ }
1303
1348
  return null;
1304
1349
  }
1305
1350
 
@@ -1382,7 +1427,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
1382
1427
  const pending = composed.remainder && composed.remainder.length
1383
1428
  ? { items: composed.remainder, noun: composed.noun }
1384
1429
  : null;
1385
- return { text, instances: composed.instances, pending };
1430
+ return { text, instances: composed.instances, allIds: composed.allInstanceIds, pending };
1386
1431
  }
1387
1432
 
1388
1433
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
@@ -1398,7 +1443,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1398
1443
  // answer's entity set. That set is the ids the last dispatched turn cited — carried
1399
1444
  // on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
1400
1445
  // node resolve instead of the "needs a previous answer" honest miss.
1401
- const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
1446
+ // Prefer the FULL id set (`allIds`) when the last turn carried one — a concept-force
1447
+ // listing caps its shown `matches` at MAX_EXAMPLES, so counting `matches` alone would
1448
+ // undercount "count them" over a truncated listing (CHATBENCH_0.7.1 discourse-count).
1449
+ const prev = (last?.detail?.allIds && last.detail.allIds.length)
1450
+ ? last.detail.allIds.filter(Boolean)
1451
+ : (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
1402
1452
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
1403
1453
  // prior shape with X swapped in; everything else parses verbatim. The record and
1404
1454
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
@@ -1438,8 +1488,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1438
1488
  let resolvedIds = [];
1439
1489
  let newFocus = focus;
1440
1490
  if (graph && envelope?.parsed?.object) {
1441
- const ent = await resolveEntity(graph, envelope.parsed.object);
1442
- if (ent) { resolvedIds = [ent.id]; newFocus = ent; }
1491
+ const obj = envelope.parsed.object;
1492
+ // A PRONOUN object ("it"/"this") was already resolved against the focus via
1493
+ // contextId — the resolved antecedent IS the focus. Re-resolving the literal
1494
+ // pronoun string is the CHATBENCH_0.7.1 B1-pron bug: "it" substring-matches the
1495
+ // "Commit" schema node (label contains "it"), so the focus jumped off the module
1496
+ // to a Commit and the NEXT "it" bound wrong. Reuse the focus directly instead.
1497
+ const ent = (isPronoun(obj) && focus?.id) ? focus : await resolveEntity(graph, obj);
1498
+ if (ent) {
1499
+ resolvedIds = [ent.id];
1500
+ // Class-gate the focus update: a Commit/Session/schema object never displaces a
1501
+ // standing code-entity focus (see nextFocus).
1502
+ newFocus = nextFocus(graph, focus, ent);
1503
+ }
1443
1504
  }
1444
1505
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
1445
1506
  const miss = envelope ? !!envelope.miss : true;
@@ -1511,12 +1572,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1511
1572
  // Runs after the corpus-fact/curated branches (via composed|corpus/seon) but not
1512
1573
  // over a "you told me" fact, a meta/self summary, or the conversational lanes.
1513
1574
  let conceptInstances = null;
1575
+ let conceptAllIds = null;
1514
1576
  let conceptPending = null;
1515
1577
  if (via === "composed" || via === "corpus/seon") {
1516
1578
  const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
1517
1579
  if (concept) {
1518
1580
  answer = concept.text; via = "corpus/seon"; recordMiss = false;
1519
1581
  conceptInstances = concept.instances;
1582
+ conceptAllIds = concept.allIds;
1520
1583
  conceptPending = concept.pending;
1521
1584
  } else {
1522
1585
  // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
@@ -1574,8 +1637,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1574
1637
  // holds it on conceptPending (the relation force resolves no instance ids, so it can
1575
1638
  // still page even with an empty matches set); a fact listing holds it on factPending.
1576
1639
  const pending = conceptPending ?? factPending;
1640
+ // `allIds` carries the FULL result-set ids (uncapped) so discourse-count anaphora
1641
+ // ("count them" / "how many of those") survives a truncated render: the concept
1642
+ // force shows only MAX_EXAMPLES instances on `matches`, but a follow-up count must
1643
+ // see them all. The ordinary ask-engine listing already carries its whole set on
1644
+ // `matches`, so only the concept force needs the extra field (a full id array).
1645
+ const allIds = conceptAllIds && conceptAllIds.length ? conceptAllIds : null;
1577
1646
  const detail = conceptInstances
1578
- ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(pending ? { pending } : {}) }
1647
+ ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(allIds ? { allIds } : {}), ...(pending ? { pending } : {}) }
1579
1648
  : (envelope
1580
1649
  ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
1581
1650
  : (pending ? { traversal: null, matches: [], pending } : null));
@@ -1653,7 +1722,10 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
1653
1722
  // follow-up ("what calls it", a no-arg /context) reuses it.
1654
1723
  if (entityArg) {
1655
1724
  const ent = await resolveEntity(graph, value);
1656
- if (ent) return mk(answer, { resolvedIds: [ent.id], newFocus: ent });
1725
+ // Same class-gate as the ask path (nextFocus): a command whose arg resolves to a
1726
+ // Commit/Session/schema node records the resolution but does not displace a
1727
+ // standing code-entity focus that "it" is meant to keep binding to.
1728
+ if (ent) return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
1657
1729
  }
1658
1730
  return mk(answer);
1659
1731
  }