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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -12,6 +12,37 @@ 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.0 — Phase 11 landed (Phases 0–10 shipped in 0.7.1).** All five Phase-11 tracks are
18
+ built, merged, and measured; `npm test` green (**869**). Push/publish held for the operator. Highlights:
19
+
20
+ - **Track 1 — chat levers (measured).** Pronoun/focus binding, discourse-count anaphora, and C1
21
+ temporal-over-relative composition all landed. `CHATBENCH_0.8.0`: tier-1 spine **331→333**, all three
22
+ lever families moved their cells (the B1-temporal control did not spill); judged fixed tag
23
+ `multi-turn-focus` **1.433→1.9** (the pronoun lever's echo). The judged pooled mean (1.44) is **not
24
+ case-comparable** to 0.7.1's 1.488 — the graded draw re-samples 10% of cells each run, so §1 compares
25
+ cell-level, not the scalar; the deterministic tier-1 spine is the load-bearing PASS.
26
+ - **Track 2 — the router (designed → DEMONSTRATED).** The Anthropic-compatible `/v1/messages` shim
27
+ (`serve` mode), the Stage-0 capability registry (STRIPS/PDDL operators, closed-world default-deny),
28
+ and the resolver (S1) + guardrail (S4) + planner (S3, pure-JS POP/HTN + Steel & Ho monitor). Measured:
29
+ `AGENTBENCH_0.8.0` (shim-transport floor, 46% completion) → `AGENTBENCH_0.8.0_001` (the real router):
30
+ **96% completion at 0% hallucination on every rung**, clearing the closed-world ladder to **C1**. The
31
+ honest gate the phase was staked on is **met on its hallucination axis (0%)**. **Caveat (stated in the
32
+ artifact):** AGENTBENCH grades the correct **call-plan + proof**, *not* the executed composed result,
33
+ and B1/B2/C1 are thin rungs (2–3 cases); the C2 case is honestly **refused** (Stage 5, unbuilt).
34
+ - **Track 3 — bedrock-meter surface.** `../bedrock-meter` gained a cost-ascending router ladder with the
35
+ **tmct `$0` rung at rank 0 below nova-micro**; an end-to-end test meters an in-envelope request at
36
+ **£0** and escalates out-of-envelope to nova-micro (that repo: 73 green, unpushed).
37
+ - **Track 4 — playtest.** Three frozen dialogue-flow transcripts (`test/chatflow-{coverage,history,
38
+ architecture}.test.mjs`) from routing dead-ends off the grammar wall.
39
+ - **Track 5 — research.** `docs/references/planning/` gained Stage-2 intent-frames + Stage-5
40
+ goal-reasoner design notes + a BDI/Goal-Driven-Autonomy reference entry.
41
+
42
+ **Next:** the C2 goal-reasoner (Stage 5) and Stage-2 imperative intent frames are the designed-but-
43
+ unbuilt edge; the tier-4/tier-5 items in Phase LATER remain the long tail. See Phase 11 below and
44
+ `HANDOVER.md` for the open follow-ups.
45
+
15
46
  ## The umbrella product definition (item 1)
16
47
 
17
48
  **A tolerant, ELIZA/PARRY-style chat, obsessed with software.** A best-efforts
@@ -279,11 +310,13 @@ and the block-index summary (blocks, tokens, top PageRank blocks). Same renderer
279
310
 
280
311
  > **STATUS: in progress — the two headline B1 levers shipped.** Negation as a bounded SET
281
312
  > 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.
313
+ > are live; the harness meta-fixes and the rest of the ranked lever board (levers 3–6 + the C2
314
+ > ceiling, below) continue. **This section is the durable home for the tuning arc** — the detailed
315
+ > cycle-4 plan was archived to `archive/PLAN_CYCLE_4.md` once its substance lived here.
283
316
 
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).
317
+ > The dependency audit that also fed this phase is archived at `archive/PLAN_DEPENDENCY_STRATEGY.md`
318
+ > (verdict: no dep changes now; a standing adoption register + avoid-list for phases 6-9; two
319
+ > near-term actions, both shipped see below).
287
320
 
288
321
  The immediate work: drive the graded benchmark up the CEFR ladder, one lever per cycle, per
289
322
  `SKILL_TUNING_CYCLE.md`. Cycle 3 (post-wiring-wave, CHATBENCH_003) gave the first full-spectrum
@@ -312,7 +345,7 @@ from meta-1's harness artifact), **C2 ceiling LAST**. Operator decision 2026-07-
312
345
  them**. Exit criterion in the plan (roughly: B1 grade mean ≥ ~1.5 with all cells dual-draw
313
346
  agreeing unlocks C-grade judging).
314
347
 
315
- ## Near-term actions (from the dependency audit, PLAN_DEPENDENCY_STRATEGY.md)
348
+ ## Near-term actions (from the dependency audit, archive/PLAN_DEPENDENCY_STRATEGY.md)
316
349
 
317
350
  > **STATUS: both shipped.** The wink browser-loader seam is added (shared model loader with a
318
351
  > browser registration path) and `fnv1a` is single-sourced into `src/hash.mjs`.
@@ -322,7 +355,7 @@ Two concrete, low-risk actions the audit surfaced — not features, not dep chan
322
355
  1. **Fix wink's `createRequire` browser-loader gap** — the wink model IS the browser build, but
323
356
  our adapters load it via `createRequire(import.meta.url)` (`ask-nlp.mjs:29`, `prose-nlp.mjs:31`),
324
357
  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
358
+ blocker** — budget it into `archive/PLAN_REPOSITORY_INTERFACE.md`; it is a wiring fix, not a dependency
326
359
  change (the model is already browser-capable).
327
360
  2. **Single-source `fnv1a`** — extract the content-address hash to one `src/hash.mjs` so the
328
361
  cross-version-stable fact-id contract has a single definition. Trivial refactor, do any time;
@@ -337,7 +370,7 @@ Two concrete, low-risk actions the audit surfaced — not features, not dep chan
337
370
  > `/memory` inspector surfaces contradictions with provenance. Legacy `mgx:factProvenance` kept
338
371
  > as a compat shim.
339
372
 
340
- > Detailed plan: **PLAN_PROVENANCE_TRUST.md**.
373
+ > Detailed plan: **archive/PLAN_PROVENANCE_TRUST.md**.
341
374
 
342
375
  *(Operator-specified 2026-07-05, from the observation that Phase-6 canonicalise-and-link,
343
376
  tier-4 learn-on-miss, and the ConceptNet slice all share one shape: raw source preserved,
@@ -370,7 +403,7 @@ fact enters memory:
370
403
  > **STATUS: shipped.** A technical (C1) register of templates and productive/performance
371
404
  > dual-banding (computed from the `via` provenance) are live in the benchmark.
372
405
 
373
- > Detailed plan: **`PLAN_FORMULAIC_COMPETENCE.md`**.
406
+ > Detailed plan: **`archive/PLAN_FORMULAIC_COMPETENCE.md`**.
374
407
 
375
408
  The operator's insight upgraded to the strategy: a consistently-failed C1/C2 graded cell whose
376
409
  answer EXISTS as a stable phrasing in technical prose is not a ceiling — it is a
@@ -403,7 +436,7 @@ answer EXISTS as a stable phrasing in technical prose is not a ceiling — it is
403
436
  > pass runs on prose spans only under a protected-span invariance guard. The a/an article fix is
404
437
  > active; broader voice/agreement rules are implemented-but-parked.
405
438
 
406
- > Detailed plan: **`PLAN_RESPONSE_FINISHING.md`**.
439
+ > Detailed plan: **`archive/PLAN_RESPONSE_FINISHING.md`**.
407
440
 
408
441
  *(Refined 2026-07-05; decisions settled with the operator. Fact invariance is achieved by
409
442
  CONSTRUCTION, not by hope. Finishing operates over a SEGMENTED answer, never a raw string.
@@ -440,7 +473,7 @@ ever emerge.)*
440
473
  > runnable conformance/compatibility suite; and `tmct init` (scaffold `.tmct/`, `tmct.toml`,
441
474
  > tier-1 seed, provenance).
442
475
 
443
- > Detailed plan: **`PLAN_REPOSITORY_INTERFACE.md`**.
476
+ > Detailed plan: **`archive/PLAN_REPOSITORY_INTERFACE.md`**.
444
477
 
445
478
  *(Operator-specified 2026-07-05; upgraded from research item to a build phase. tmct was spun OUT
446
479
  of seonix; this inverts the relationship: seonix reorients as a USER that imports the tmct
@@ -512,7 +545,7 @@ a passive payload loader into the product's primary integration surface.)*
512
545
  before it — PROACTIVELY extending memory with inferences that will be useful later, forward and
513
546
  backward chaining over the OWL base during idle/fold time rather than at query time.)*
514
547
 
515
- > Detailed plan: **`PLAN_SPECULATIVE_INFERENCE.md`**.
548
+ > Detailed plan: **`archive/PLAN_SPECULATIVE_INFERENCE.md`**.
516
549
 
517
550
  **A maintenance job, not a query-time cost.** Speculative inference runs as an explicit
518
551
  batch — `npx tmct syllogise --depth <N>` (default depth bounded, e.g. 32) — and **once
@@ -595,6 +628,75 @@ you toward precision" promise on the conversational surface.
595
628
  with new graded cells for the miss / empty-graph / concept-touch surfaces so these become
596
629
  regression-protected levers, not one-off polish.
597
630
 
631
+ ## Phase 11 — The capability router & the agentic bench (0.8.0 — LANDED)
632
+
633
+ *(Operator-directed 2026-07-06; built the same day across five concurrent tracks.)* tmct as a **deterministic, no-LLM
634
+ tool router** behind an Anthropic-compatible API — the workstream specified in
635
+ `PLAN_CAPABILITY_ROUTER.md`, grounded in `docs/references/planning/`. This is a **new capability on a
636
+ new axis** (driving a tool loop, not answering a chat turn), so it gets its **own benchmark**:
637
+ **`AGENTBENCH`**, a sibling to CHATBENCH — same versioned-naming + grading discipline
638
+ (`AGENTBENCH_<version>.md`, `_00N` for re-runs), but the levels are the **A0→C2 agentic rungs** and a
639
+ **hallucinated tool call is an automatic fail**.
640
+
641
+ **Status: DEMONSTRATED (with a stated scope caveat).** The router is built and measured:
642
+ `AGENTBENCH_0.8.0_001` = **96% completion at 0% hallucination on every rung**, closed-world ladder
643
+ cleared to **C1**. The gate the phase was staked on — a **0% hallucination rate on a real domain** (the
644
+ graph-query toolset over the fixture) — **is met.** The honest scope line, held from the start:
645
+ AGENTBENCH grades the correct **call-plan + causal-link proof, not the executed composed result**; the
646
+ B1/B2/C1 rungs are **thin (2–3 cases)**; and the one C2 case is **refused** (the Stage-5 goal-reasoner
647
+ is designed, not built). So "closed-world C1" means *the router provably selects and binds the right
648
+ tool sequence*, not *end-to-end multi-step reasoning* — the demonstrated-vs-designed boundary is the
649
+ real deliverable. The five tracks below are all built; the two research-agent stages (Track 4 below)
650
+ remain designed-not-built by intent.
651
+
652
+ ### Track 1 — chat-surface levers (next CHATBENCH; all three)
653
+ The three levers `CHATBENCH_0.7.1` measured + ranked — which **double as router prerequisites** (they
654
+ gate the A2→B1→C1 rungs, per Phase B of the router plan):
655
+ 1. **Pronoun / focus binding** — the "it → Commit" mis-bind (`B1 pron 1.24`); biggest movable mass.
656
+ 2. **Discourse-count anaphora** — "count them / how many of those" over a prior listing (clears the 2
657
+ `CHATBENCH_0.7.1` tier-1 misses).
658
+ 3. **C1 temporal-over-relative composition** — the two-hop ceiling (`C1 temp 0.31`).
659
+ Land all three (not just #1); they raise the chat floor *and* the router's floor at once.
660
+
661
+ ### Track 2 — the router build (the within-horizon slice, in order)
662
+ Buildable now with a frontier model as co-author (see PLAN §"solved vs unsolved"):
663
+ - **Phase A — the shim.** An Anthropic Messages API endpoint (`/v1/messages`, `tool_use`/`tool_result`
664
+ blocks). **Extended:** also present as a **`bedrock-meter`-compatible routing target** (see below).
665
+ - **Phase B — measure today → `AGENTBENCH_0.7.2.md`.** Shim + a small graph-query toolset up the
666
+ A0→C2 ladder; the honest baseline (expected A0 solid, A1–A2 partial, per the CHATBENCH_0.7.1
667
+ inherited assets).
668
+ - **Phase C — the grading ladder.** The AGENTBENCH benchmark itself (rungs as levels, comparable
669
+ local/hosted models as reference bands, zero-hallucination gate).
670
+ - **Stage 0 — capability registry** (`Capability`/`Parameter`/`Precondition`/`Effect` = STRIPS/PDDL
671
+ operators as facts).
672
+ - **Stage 1 — the resolver** (unification + backward chaining / a mini Datalog).
673
+ - **Stage 4 — the guardrail** (validate an LLM's proposed `tool_use` against declared preconditions —
674
+ the hybrid fast-path; cheap once 0–1 exist).
675
+ - **Stage 3 — the planner** (POP/HTN over operators + Steel & Ho monitor-and-replan → **closed-world
676
+ C1**; optionally defer search to an external PDDL solver).
677
+
678
+ ### Phase A extension — the `bedrock-meter` deployment surface
679
+ `../bedrock-meter` is pre-flight Bedrock cost metering + capping, with a **roadmap optimiser** that
680
+ "cheaply assesses a task's complexity … and routes to the lowest-cost capable model" (it already
681
+ meters Nova Lite + Nova Micro). tmct — **benchmarked against agent capabilities by AGENTBENCH** — slots
682
+ in as the **$0 floor *below* Nova-micro** in that routing ladder: for a request class AGENTBENCH proves
683
+ in-envelope, the optimiser routes to tmct (deterministic, ~$0, ms latency) instead of any metered
684
+ model. So Phase A's shim is built **bedrock-meter-pluggable**, and AGENTBENCH is what defines the
685
+ envelope the optimiser is allowed to trust. This is the concrete "near-free alternative" deployment.
686
+
687
+ ### Track 3 — playtest alongside the build (`git worktree`)
688
+ Run `SKILL_CHAT_PLAYTEST.md` **in a parallel `git worktree`** while the router is built — the
689
+ dialogue-flow dead-end hunt keeps running without blocking the build, and its fixes **merge back**.
690
+ (The worktree is auto-cleaned if unchanged; merge the frozen `test/chatflow-*` transcripts in.)
691
+
692
+ ### Track 4 — research agents (the "at the edge" stages)
693
+ Two stages need design judgment + exploration, so they run as **background research agents**, off the
694
+ critical build path:
695
+ - **Stage 2 — intent frames, controlled fragment** — imperative NL → structured intent for the
696
+ controlled command language (the front-end; the general case stays out-of-scope / escalate).
697
+ - **Stage 5 — goal-reasoner, closed-world C2** — BDI + Goal-Driven Autonomy: deduce-goals (long-chain
698
+ deduction) → plan-each (C1) → threat-aware, *persistent* first-step arbitration.
699
+
598
700
  ## Phase LATER — recognized, deferred, not now
599
701
 
600
702
  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.0",
3
+ "version": "0.8.0",
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: [] };
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}`;
@@ -1264,25 +1296,33 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
1264
1296
  * the graph parser reads as a count. Null when the line isn't such a touch. The
1265
1297
  * concept force is gated further downstream (a KNOWN, instance-bearing concept), so
1266
1298
  * this only has to recognize the SHAPE, not vet the term. */
1267
- function conceptTermOf(query, envelope) {
1268
- const base = metaTermOf(query, envelope);
1269
- if (base) return base;
1299
+ /** The VAGUE-TOUCH shapes ("tell me about X", "[and] what about X") — a concept
1300
+ * touch that is NOT the "what is a X" / "what does X mean" META shape. The meta shape
1301
+ * has its own established handling (a noun definition, a predicate definition, or the
1302
+ * honest ambiguity surround for a term that is BOTH a noun and a predicate — e.g.
1303
+ * "imports"), which the RELATION force must never preempt (frozen case
1304
+ * am-meta-imports). Gated downstream by CONCEPT_CLASS / RELATION_TERM, so a real
1305
+ * entity name declines here. */
1306
+ function vagueTouchTermOf(query) {
1270
1307
  const q = String(query).trim();
1271
1308
  const m = q.match(/^tell me about\s+(?:an?\s+)?(.+?)[?.!\s]*$/i)
1272
- // "[and/so/…] what about X" with no good discourse continuation — the concept
1273
- // KIND word is a concept touch, not a bare module lookup (DEAD-END 4). Gated
1274
- // downstream by CONCEPT_CLASS/RELATION_TERM, so a real entity name declines here.
1275
1309
  || q.match(/^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i);
1276
1310
  return m ? m[1].trim() : null;
1277
1311
  }
1278
1312
 
1279
- /** The RELATION term a vague touch names — reuses conceptTermOf's shapes ("what is
1280
- * X"/"what does X mean"/"tell me about X"/"what about X"), plus the relation-only
1281
- * openers the graph parser reads as something else: "what are the imports", "what
1282
- * calls are there", "what is calling". Null when the line isn't such a touch. Gated
1283
- * downstream by RELATION_TERM, so this only has to recognize the SHAPE. */
1313
+ function conceptTermOf(query, envelope) {
1314
+ return metaTermOf(query, envelope) || vagueTouchTermOf(query);
1315
+ }
1316
+
1317
+ /** The RELATION term a vague touch names the VAGUE-touch shapes only ("tell me
1318
+ * about X", "what about X"), NOT the "what is a X"/"what does X mean" meta shape (a
1319
+ * relation term that is also a vocabulary word, like "imports", keeps its established
1320
+ * ambiguity/predicate-definition answer — frozen case am-meta-imports). Plus the
1321
+ * relation-only openers the graph parser reads as something else: "what are the
1322
+ * imports", "what calls are there", "what is calling". Null when the line isn't such a
1323
+ * touch. Gated downstream by RELATION_TERM, so this only has to recognize the SHAPE. */
1284
1324
  function relationTermOf(query, envelope) {
1285
- const base = conceptTermOf(query, envelope);
1325
+ const base = vagueTouchTermOf(query);
1286
1326
  if (base) return base;
1287
1327
  const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1288
1328
  let m;
@@ -1374,7 +1414,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
1374
1414
  const pending = composed.remainder && composed.remainder.length
1375
1415
  ? { items: composed.remainder, noun: composed.noun }
1376
1416
  : null;
1377
- return { text, instances: composed.instances, pending };
1417
+ return { text, instances: composed.instances, allIds: composed.allInstanceIds, pending };
1378
1418
  }
1379
1419
 
1380
1420
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
@@ -1390,7 +1430,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1390
1430
  // answer's entity set. That set is the ids the last dispatched turn cited — carried
1391
1431
  // on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
1392
1432
  // node resolve instead of the "needs a previous answer" honest miss.
1393
- const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
1433
+ // Prefer the FULL id set (`allIds`) when the last turn carried one — a concept-force
1434
+ // listing caps its shown `matches` at MAX_EXAMPLES, so counting `matches` alone would
1435
+ // undercount "count them" over a truncated listing (CHATBENCH_0.7.1 discourse-count).
1436
+ const prev = (last?.detail?.allIds && last.detail.allIds.length)
1437
+ ? last.detail.allIds.filter(Boolean)
1438
+ : (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
1394
1439
  // The query the ENGINE parses: a "what about X" continuation is rewritten to the
1395
1440
  // prior shape with X swapped in; everything else parses verbatim. The record and
1396
1441
  // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
@@ -1430,8 +1475,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1430
1475
  let resolvedIds = [];
1431
1476
  let newFocus = focus;
1432
1477
  if (graph && envelope?.parsed?.object) {
1433
- const ent = await resolveEntity(graph, envelope.parsed.object);
1434
- if (ent) { resolvedIds = [ent.id]; newFocus = ent; }
1478
+ const obj = envelope.parsed.object;
1479
+ // A PRONOUN object ("it"/"this") was already resolved against the focus via
1480
+ // contextId — the resolved antecedent IS the focus. Re-resolving the literal
1481
+ // pronoun string is the CHATBENCH_0.7.1 B1-pron bug: "it" substring-matches the
1482
+ // "Commit" schema node (label contains "it"), so the focus jumped off the module
1483
+ // to a Commit and the NEXT "it" bound wrong. Reuse the focus directly instead.
1484
+ const ent = (isPronoun(obj) && focus?.id) ? focus : await resolveEntity(graph, obj);
1485
+ if (ent) {
1486
+ resolvedIds = [ent.id];
1487
+ // Class-gate the focus update: a Commit/Session/schema object never displaces a
1488
+ // standing code-entity focus (see nextFocus).
1489
+ newFocus = nextFocus(graph, focus, ent);
1490
+ }
1435
1491
  }
1436
1492
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
1437
1493
  const miss = envelope ? !!envelope.miss : true;
@@ -1503,12 +1559,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1503
1559
  // Runs after the corpus-fact/curated branches (via composed|corpus/seon) but not
1504
1560
  // over a "you told me" fact, a meta/self summary, or the conversational lanes.
1505
1561
  let conceptInstances = null;
1562
+ let conceptAllIds = null;
1506
1563
  let conceptPending = null;
1507
1564
  if (via === "composed" || via === "corpus/seon") {
1508
1565
  const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
1509
1566
  if (concept) {
1510
1567
  answer = concept.text; via = "corpus/seon"; recordMiss = false;
1511
1568
  conceptInstances = concept.instances;
1569
+ conceptAllIds = concept.allIds;
1512
1570
  conceptPending = concept.pending;
1513
1571
  } else {
1514
1572
  // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
@@ -1566,8 +1624,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1566
1624
  // holds it on conceptPending (the relation force resolves no instance ids, so it can
1567
1625
  // still page even with an empty matches set); a fact listing holds it on factPending.
1568
1626
  const pending = conceptPending ?? factPending;
1627
+ // `allIds` carries the FULL result-set ids (uncapped) so discourse-count anaphora
1628
+ // ("count them" / "how many of those") survives a truncated render: the concept
1629
+ // force shows only MAX_EXAMPLES instances on `matches`, but a follow-up count must
1630
+ // see them all. The ordinary ask-engine listing already carries its whole set on
1631
+ // `matches`, so only the concept force needs the extra field (a full id array).
1632
+ const allIds = conceptAllIds && conceptAllIds.length ? conceptAllIds : null;
1569
1633
  const detail = conceptInstances
1570
- ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(pending ? { pending } : {}) }
1634
+ ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(allIds ? { allIds } : {}), ...(pending ? { pending } : {}) }
1571
1635
  : (envelope
1572
1636
  ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
1573
1637
  : (pending ? { traversal: null, matches: [], pending } : null));
@@ -1645,7 +1709,10 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
1645
1709
  // follow-up ("what calls it", a no-arg /context) reuses it.
1646
1710
  if (entityArg) {
1647
1711
  const ent = await resolveEntity(graph, value);
1648
- if (ent) return mk(answer, { resolvedIds: [ent.id], newFocus: ent });
1712
+ // Same class-gate as the ask path (nextFocus): a command whose arg resolves to a
1713
+ // Commit/Session/schema node records the resolution but does not displace a
1714
+ // standing code-entity focus that "it" is meant to keep binding to.
1715
+ if (ent) return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
1649
1716
  }
1650
1717
  return mk(answer);
1651
1718
  }
package/src/concept.mjs CHANGED
@@ -213,6 +213,11 @@ export function composeConcept(graph, term, { definition = null, factRows = [] }
213
213
  instances: graphInstances.slice(0, MAX_EXAMPLES).map((i) => ({
214
214
  id: i.id, label: i.label, type: i.class,
215
215
  })),
216
+ // EVERY instance id (uncapped), for discourse-count anaphora ("count them" /
217
+ // "how many of those") over a truncated listing: `instances` is capped at
218
+ // MAX_EXAMPLES and the rest lives in `remainder` as prose labels, invisible to
219
+ // the counter — so a follow-up count would undercount without the full id set.
220
+ allInstanceIds: graphInstances.map((i) => i.id),
216
221
  // the un-shown instance labels + their plural noun, for the shell's "more"
217
222
  // pagination — empty when nothing was truncated.
218
223
  remainder: remainderLabels,
@@ -1,6 +1,6 @@
1
1
  // conformance.mjs — the Repository-Interface CONTRACT TEST SUITE as a reusable kit.
2
2
  //
3
- // PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
3
+ // archive/PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
4
4
  // passes `runConformance(name, makeProvider)`. tmct's own fixture + bootstrap providers
5
5
  // pass it in `npm test`; an EXTERNAL producer (seonix) imports this kit from the
6
6
  // published package and runs the SAME suite against its native provider to claim