@polycode-projects/the-mechanical-code-talker 1.8.18 → 1.9.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/src/chat.mjs CHANGED
@@ -4044,6 +4044,11 @@ const FACT_PREDICATE_PHRASES = {
4044
4044
  "mgx:hasLastSubevent": "ends with",
4045
4045
  "mgx:hasPrerequisite": "requires",
4046
4046
  "mgx:ownedBy": "is owned by", // the teach lane's ownership frame ("Priya owns tasks.mjs")
4047
+ "mgx:synonym": "means the same as",
4048
+ "mgx:antonym": "is the opposite of",
4049
+ "mgx:similarTo": "is similar to",
4050
+ "mgx:relatedTo": "is related to",
4051
+ "mgx:symbolOf": "is a symbol of",
4047
4052
  };
4048
4053
 
4049
4054
  /** Bug 3 (2026-07-09) point 3b: the MECHANICAL fallback for a predicate this
@@ -4138,16 +4143,26 @@ function splitMetaPredicate(term) {
4138
4143
 
4139
4144
  /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
4140
4145
  * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
4141
- * source cited NEVER "i learned: …", which over-claims and anthropomorphises
4142
- * (especially when the corpus row is noise); the relation and its provenance speak
4143
- * for themselves. Provenance stays VERBATIM either way. */
4146
+ * source cited, not "i learned: …" that phrase over-claims and anthropomorphises
4147
+ * a first-person experience the bot never had; the relation and its provenance
4148
+ * speak for themselves. A WEAK-corpus fact (memory/trust.mjs SOURCE_PRIOR.corpusWeak
4149
+ * — real data, low-precision relation, e.g. ConceptNet's undirected /r/RelatedTo)
4150
+ * still isn't "i learned" (same anthropomorphism problem), but reads identically to
4151
+ * a solid corpus fact loses the only reader-visible signal that it's lower-
4152
+ * confidence — re-examined 2026-07-12 (TOO_HARD_AUDIT.md): the prior blanket "corpus
4153
+ * never hedges" rule predates corpus data having any confidence spread at all, so a
4154
+ * distinct, honest hedge ("possibly: …") applies here instead of either extreme.
4155
+ * Provenance stays VERBATIM in every case. */
4144
4156
  function renderFactLine(f) {
4145
4157
  const cite = f.provenance ? ` (source: ${f.provenance})` : "";
4146
4158
  // ace:chat = the ACE-parsed operator assert; teach:chat = the teach lane's
4147
4159
  // natural frames — both are things the operator SAID, so both read first-person.
4148
4160
  if (f.provenance.includes("ace:chat") || f.provenance.includes("teach:chat")) return `you told me: ${factPhrase(f)}${cite}`;
4149
- // CORPUS facts are background DATApresent the relation plainly, cited to its
4150
- // source, NEVER "i learned: …" (the footgun: a first-person claim over corpus noise).
4161
+ // WEAK corpus facts (lower trust, e.g. RelatedTo) real, cited, but hedged as
4162
+ // uncertain rather than either flatly stated or falsely claimed as "learned".
4163
+ if (f.provenance.includes("corpus-weak:")) return `possibly: ${factPhrase(f)}${cite}`;
4164
+ // SOLID corpus facts are background DATA — present the relation plainly, cited
4165
+ // to its source, never "i learned: …" (a first-person claim over corpus data).
4151
4166
  if (f.provenance.includes("corpus:")) return `${factPhrase(f)}${cite}`;
4152
4167
  return `i learned: ${factPhrase(f)}${cite}`;
4153
4168
  }
@@ -4501,6 +4516,49 @@ const FACT_ANSWER_CAP = 32;
4501
4516
  const CAN_ASK_RE = /^(?:can|could)\s+(?:an?\s+)?([\w'-]+(?:\s+[\w'-]+)*?)\s+([a-z]+)[?.!\s]*$/i;
4502
4517
  const WHAT_CAN_DO_RE = /^what\s+can\s+(?:an?\s+)?(.+?)\s+do[?.!\s]*$/i;
4503
4518
  const WHAT_HAS_RE = /^what\s+has\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4519
+ // "what is used for riding" / "what can be used for riding" / "what is for
4520
+ // riding" — the reverse-by-object mirror of BUG 1's forward reader ("what is
4521
+ // a tree used for"), missing until now: BUG 1 only ever filtered a KNOWN
4522
+ // subject's facts down to mgx:usedFor; nothing answered the reverse question
4523
+ // (object known, subject unknown), so it fell all the way through to the
4524
+ // code-graph miss cascade — actively misleading for a pure vocabulary query.
4525
+ const WHAT_USED_FOR_RE = /^what\s+(?:(?:can\s+be|is)\s+used\s+for|is\s+for)\s+(.+?)[?.!\s]*$/i;
4526
+
4527
+ // Live-caught 2026-07-12 follow-up: the SAME gap as mgx:usedFor above turned
4528
+ // out to be systemic, not one-off — "what causes fire", "what is made of
4529
+ // wood", "what is found in a kitchen", "what wants food" all fell through to
4530
+ // the same misleading code-graph miss, for the same reason (no reverse-by-
4531
+ // object reader existed for these predicates either). Rather than hand-roll
4532
+ // one more one-off regex per predicate, this DERIVES a reverse-by-object
4533
+ // regex for every FACT_PREDICATE_PHRASES entry that's safe to reverse — the
4534
+ // same "derivation, not a curated subset" philosophy TRAILING_PREDICATE_MARKERS
4535
+ // already uses for the forward direction (see that const's own docblock).
4536
+ // Excluded, each for a specific reason:
4537
+ // - rdfs:subClassOf, mgx:hasA, mgx:capableOf, mgx:usedFor — already have
4538
+ // their own dedicated, richer reverse readers (WHAT_INHERITS_RE/
4539
+ // WHAT_HAS_RE/WHAT_CAN_DO_RE/WHAT_USED_FOR_RE above).
4540
+ // - mgx:ownedBy — already has its own dedicated "who owns X" reader
4541
+ // (WHO_OWNS_RE) — a WHO question, not a WHAT question, so it would never
4542
+ // collide, but is excluded anyway to keep exactly one reader per relation.
4543
+ // - rdf:type ("is a") and mgx:hasProperty ("is") — too short/generic to
4544
+ // safely anchor a reverse question: "what is X" already belongs to the
4545
+ // meta lane's own vocabulary lookup, and reversing it here would mean
4546
+ // guessing whether the user meant "define X" or "what has property X"
4547
+ // from word order alone.
4548
+ // - owl:disjointWith ("is not a") and mgx:receivesAction ("can be") — both
4549
+ // broad enough that "what is not a X" / "what can be X" read as much more
4550
+ // likely to be a different question shape than a genuine reverse lookup.
4551
+ const REVERSE_PREDICATE_EXCLUDE = new Set([
4552
+ "rdfs:subClassOf", "rdf:type", "mgx:hasA", "mgx:capableOf", "mgx:usedFor",
4553
+ "mgx:ownedBy", "owl:disjointWith", "mgx:hasProperty", "mgx:receivesAction",
4554
+ ]);
4555
+ const REVERSE_PREDICATE_MARKERS = Object.entries(FACT_PREDICATE_PHRASES)
4556
+ .filter(([predicate]) => !REVERSE_PREDICATE_EXCLUDE.has(predicate))
4557
+ .map(([predicate, phrase]) => ({
4558
+ predicate,
4559
+ re: new RegExp(`^what\\s+${escapeRegex(phrase)}\\s+(.+?)[?.!\\s]*$`, "i"),
4560
+ }))
4561
+ .sort((a, b) => b.re.source.length - a.re.source.length); // longest phrase first
4504
4562
  // Widened 2026-07-11 (live-caught follow-up to the ambiguousParse fix, commit
4505
4563
  // 5c858bf): on the FIRST turn of a graph-less session, dispatchTool's
4506
4564
  // loadGraph() throws its own documented "the graph is empty... this repo
@@ -4542,12 +4600,69 @@ function uniqueFacts(rows) {
4542
4600
  /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
4543
4601
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
4544
4602
  * own (schema-docs) answer stands and the fact lines are appended under it —
4545
- * or null when memory holds nothing relevant (misses stay unchanged). */
4546
- async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4603
+ * or null when memory holds nothing relevant (misses stay unchanged).
4604
+ * Exported (PLAN_VIZ_MEMORY.md Bug 1 fix) so src/memory-ask-browser-entry.mjs
4605
+ * can re-export it for `tmct viz`'s embedded "Ask the graph" panel — the ONLY
4606
+ * reason this is `export` rather than module-private; the function's own
4607
+ * behavior is unchanged (same signature, same logic, answers identically in
4608
+ * the CLI and the browser bundle). `memoryDir` may be memory/core.mjs's
4609
+ * Backend-B in-memory handle (`createInMemoryStore()`) as well as a real repo
4610
+ * path — every I/O this function does routes through `loadMemory(memoryDir)`
4611
+ * (via factRows/memoryFacts below), and loadMemory's own Backend-B branch
4612
+ * returns the handle's `payload` directly with ZERO fs calls — so a caller
4613
+ * that hands this a handle already carrying the embedded page's full graph
4614
+ * gets a pure, disk-free traversal, no bundle-time module shimming needed. */
4615
+ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4547
4616
  let normFactTerm;
4548
4617
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4549
4618
  const q = String(query).trim();
4550
4619
 
4620
+ // (a-pre) "what is used for riding" / "what can be used for riding" / "what
4621
+ // is for riding" — the reverse-by-OBJECT mirror of BUG 1's forward reader
4622
+ // ("what is a tree used for", the (a) block just below). Checked BEFORE (a)
4623
+ // deliberately: this phrasing's leading "what is …" ALSO matches (a)'s own
4624
+ // BARE_WHATIS_RE, which would otherwise greedily treat "used for riding" as
4625
+ // one literal term to define — a guaranteed miss, since no vocabulary term
4626
+ // is ever named "used for riding" — and (a) always returns (hit, honest
4627
+ // per-relation "no", or null), never falling through to a later reader. Only
4628
+ // takes over when it finds a REAL hit; a non-match or zero-hit case falls
4629
+ // through unchanged to (a) and beyond, so ordinary miss messaging is
4630
+ // untouched. mgx:usedFor instead of mgx:hasA; same filter/rank/render/
4631
+ // paginate recipe as (b4)'s WHAT_HAS_RE below.
4632
+ const usedForQ = q.match(WHAT_USED_FOR_RE);
4633
+ if (usedForQ) {
4634
+ const variants = factTermVariants(normFactTerm, usedForQ[1]);
4635
+ const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4636
+ if (hits.length) {
4637
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4638
+ const lines = ranked.map(renderFactLine);
4639
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
4640
+ const rest = lines.slice(FACT_ANSWER_CAP);
4641
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4642
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4643
+ }
4644
+ }
4645
+
4646
+ // (a-pre2) The generic derived cascade for every other reversible predicate
4647
+ // (REVERSE_PREDICATE_MARKERS, see its own docblock for the exclusion list
4648
+ // and why). Same checked-before-the-meta-lane placement and same
4649
+ // only-take-over-on-a-real-hit discipline as (a-pre) just above — a phrase
4650
+ // like "is found in"/"is made of" also starts with "what is …", so it must
4651
+ // run before (a) can greedily claim the whole tail as a literal term.
4652
+ for (const { predicate, re } of REVERSE_PREDICATE_MARKERS) {
4653
+ const m = q.match(re);
4654
+ if (!m) continue;
4655
+ const variants = factTermVariants(normFactTerm, m[1]);
4656
+ const hits = (await factRows(memoryDir)).filter((f) => f.predicate === predicate && variants.has(f.object));
4657
+ if (!hits.length) continue; // try the next candidate marker, don't give up yet
4658
+ const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4659
+ const lines = ranked.map(renderFactLine);
4660
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
4661
+ const rest = lines.slice(FACT_ANSWER_CAP);
4662
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
4663
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
4664
+ }
4665
+
4551
4666
  // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
4552
4667
  // parsed object term, matched against fact SUBJECTS; consulted for hits (append
4553
4668
  // alongside the schema-docs answer) and misses (facts answer alone) alike.
@@ -7817,8 +7932,22 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7817
7932
  // further down is UNCHANGED, so a CamelCase term with no real hit still falls
7818
7933
  // through to its existing miss handling, never the generic orientation card.
7819
7934
  const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(gateQuery);
7935
+ // 2026-07-12 follow-up to the used-for/reverse-predicate fix (factAnswer's
7936
+ // WHAT_USED_FOR_RE/REVERSE_PREDICATE_MARKERS, above): the SAME race BUG 2/
7937
+ // Tier-5/CamelCase already fixed above hits these too, and for the shortest
7938
+ // members of the family it's actually MORE likely to fire — "what wants
7939
+ // happiness" is exactly 3 words, none of them in STRUCT_WORDS, so
7940
+ // isConversational() claims it before factAnswer ever gets a turn, even
7941
+ // though a real mgx:desires fact answers it correctly once reached (proven:
7942
+ // the longer "what can be used for riding" already worked, since 5 words
7943
+ // clears isConversational's <=3-word gate outright — only the short
7944
+ // members of this family were ever actually broken). Same discipline as
7945
+ // every sibling exemption on this gate: matching the shape alone changes
7946
+ // nothing by itself, factAnswer below still only diverts on a REAL hit.
7947
+ const reversePredicateShape = WHAT_USED_FOR_RE.test(gateQuery)
7948
+ || REVERSE_PREDICATE_MARKERS.some(({ re }) => re.test(gateQuery));
7820
7949
  let bareMetaHit = null;
7821
- if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape)) {
7950
+ if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
7822
7951
  if (memoryDir) {
7823
7952
  bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
7824
7953
  ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
@@ -9142,9 +9271,11 @@ export async function createSession({
9142
9271
  // module-global state). "sqlite" selects Backend C (createSqliteMemoryStore
9143
9272
  // — a live node:sqlite connection kept open for the session's lifetime,
9144
9273
  // lazily imported only when this is actually chosen). TMCT_MEMORY_BACKEND
9145
- // mirrors the TMCT_EPHEMERAL/TMCT_NARRATE on/off env convention. No CLI flag
9146
- // wires this yet (bin/tmct.mjs's flag parsing is out of this change's
9147
- // scope) — a library/test caller sets the option directly for now.
9274
+ // mirrors the TMCT_EPHEMERAL/TMCT_NARRATE on/off env convention. This
9275
+ // parameter IS `bin/tmct.mjs`'s `tmct chat --memory-backend <...>` CLI flag
9276
+ // (a library/test caller can still set it directly) the full precedence
9277
+ // (this param > TMCT_MEMORY_BACKEND env > tmct.toml's `[memory] backend` >
9278
+ // "default") is resolved below, once `toml` is known.
9148
9279
  memoryBackend = null,
9149
9280
  } = {}) {
9150
9281
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
@@ -9185,6 +9316,13 @@ export async function createSession({
9185
9316
  // instead of the whole repo.
9186
9317
  let repo;
9187
9318
  let config;
9319
+ // tmct.toml's normalized knobs (src/toml-config.mjs), captured alongside
9320
+ // `config` in whichever branch below resolves the graph path — used further
9321
+ // down for the memory-backend precedence (`toml.memory.backend`), so that
9322
+ // knob is honoured the same way regardless of which graph-resolution tier
9323
+ // fired. `null` when no branch could read a tmct.toml (never fatal — the
9324
+ // backend precedence below just skips this tier).
9325
+ let toml = null;
9188
9326
  const explicitGraphs = (graphPaths || []).filter(Boolean);
9189
9327
  if (explicitGraphs.length) {
9190
9328
  repo = repoPath || gitRoot(cwd) || cwd;
@@ -9192,6 +9330,11 @@ export async function createSession({
9192
9330
  config = resolvedGraphs.length > 1
9193
9331
  ? { graphFile: resolvedGraphs[0], graphFiles: resolvedGraphs }
9194
9332
  : { graphFile: resolvedGraphs[0] };
9333
+ try {
9334
+ const argv = ["--repo", repo];
9335
+ if (configPath) argv.push("--config", configPath);
9336
+ ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9337
+ } catch { toml = null; }
9195
9338
  } else if (repoPath) {
9196
9339
  repo = repoPath;
9197
9340
  // env is deliberately withheld from resolveRuntimeConfig here (passed as
@@ -9202,17 +9345,22 @@ export async function createSession({
9202
9345
  // honored too.
9203
9346
  const argv = ["--repo", repoPath];
9204
9347
  if (configPath) argv.push("--config", configPath);
9205
- ({ config } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9348
+ ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9206
9349
  } else {
9207
9350
  const root = gitRoot(cwd);
9208
9351
  repo = root || cwd;
9209
9352
  const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
9210
9353
  if (envGraph) {
9211
9354
  config = loadConfig(env, cwd);
9355
+ try {
9356
+ const argv = [];
9357
+ if (configPath) argv.push("--config", configPath);
9358
+ ({ toml } = await resolveRuntimeConfig({ argv, cwd, env: {}, gitRoot }));
9359
+ } catch { toml = null; }
9212
9360
  } else {
9213
9361
  const argv = [];
9214
9362
  if (configPath) argv.push("--config", configPath);
9215
- ({ config } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
9363
+ ({ config, toml } = await resolveRuntimeConfig({ argv, cwd, env, gitRoot }));
9216
9364
  }
9217
9365
  }
9218
9366
 
@@ -9297,20 +9445,22 @@ export async function createSession({
9297
9445
  // `closeMemoryStore` is a no-op unless Backend C actually opened a
9298
9446
  // connection (Backend C's node:sqlite import is lazy — it only happens if
9299
9447
  // this branch is actually taken).
9300
- const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || "").trim().toLowerCase();
9301
- let memoryDir = repo;
9302
- let closeMemoryStore = async () => {};
9303
- if (backendChoice === "memory") {
9304
- const { createInMemoryStore } = await import("./memory/core.mjs");
9305
- memoryDir = createInMemoryStore();
9306
- } else if (backendChoice === "sqlite") {
9307
- const { createSqliteMemoryStore, closeSqliteMemoryStore } = await import("./memory/core.mjs");
9308
- const dbPath = join(repo, ".tmct", "memory", "graph.sqlite");
9309
- await mkdir(dirname(dbPath), { recursive: true });
9310
- const handle = await createSqliteMemoryStore(dbPath);
9311
- memoryDir = handle;
9312
- closeMemoryStore = async () => closeSqliteMemoryStore(handle);
9313
- }
9448
+ //
9449
+ // Precedence CLI flag > env > tmct.toml > default — matches the graph-path
9450
+ // precedence documented above: `memoryBackend` here is `tmct chat
9451
+ // --memory-backend <...>`'s already-resolved value; TMCT_MEMORY_BACKEND is
9452
+ // the env tier; `toml.memory.backend` is tmct.toml's `[memory] backend`
9453
+ // (src/toml-config.mjs). A toml value of "default" (or anything unrecognized)
9454
+ // falls through to Backend A below, same as an absent value always has.
9455
+ const backendChoice = String(memoryBackend || env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
9456
+ // openMemoryBackend (memory/core.mjs) is the ONE shared resolver for this
9457
+ // seam src/init.mjs's corpus seed and bin/tmct.mjs's --corpus/--ontology/
9458
+ // --lexicon activation now call the exact same function, so a repo's
9459
+ // seeded facts and its chat-taught facts always land in the same backend
9460
+ // (a split-brain bug found in review: init used to always seed Backend A
9461
+ // regardless of the configured backend).
9462
+ const { openMemoryBackend } = await import("./memory/core.mjs");
9463
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
9314
9464
 
9315
9465
  const empty = graph.individuals.length === 0;
9316
9466
  // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
package/src/cli-args.mjs CHANGED
@@ -5,10 +5,14 @@
5
5
  // precedence chain — built on top of toml-config.mjs's already-tested
6
6
  // mergeEffective/normalizeConfig (arg > toml > default), not a rebuild of it.
7
7
  //
8
- // Three tiny flag helpers (pure, no I/O) plus the one async resolver:
8
+ // Four tiny flag helpers (pure, no I/O) plus the one async resolver:
9
9
  // strFlag(rest, names, dflt) → single value, last flag occurrence wins
10
10
  // repeatedFlag(rest, names) → every value for a repeatable flag (e.g. --graph)
11
11
  // boolFlag(rest, names) → true if any of `names` appears at all
12
+ // enumFlag(rest, names, choices) → strFlag, validated against a closed set
13
+ // (throws a clear error naming the flag + the choices — the shared shape
14
+ // for a closed-choice option like `--memory-backend default|memory|sqlite`,
15
+ // matching `--with-persona`'s own "unknown name" error style)
12
16
  // resolveRuntimeConfig({argv, cwd, env, gitRoot}) → the resolved repo/config
13
17
  //
14
18
  // Graph-path precedence (documented once, here — every subcommand shares it):
@@ -69,6 +73,21 @@ export function boolFlag(rest, names) {
69
73
  return rest.some((r) => list.includes(r));
70
74
  }
71
75
 
76
+ /** Closed-choice single-value flag: `strFlag` plus validation against
77
+ * `choices`. Returns `undefined` when absent (never a default — the caller
78
+ * decides what "absent" means, same as an omitted `strFlag` call). Throws a
79
+ * clear, user-facing error naming the flag and the valid choices when a
80
+ * value IS given but isn't one of them — validate-before-any-disk-write,
81
+ * the same discipline `tmct init --with-persona <unknown>` already uses. */
82
+ export function enumFlag(rest, names, choices) {
83
+ const val = strFlag(rest, names, undefined);
84
+ if (val !== undefined && !choices.includes(val)) {
85
+ const flagName = asList(names)[0];
86
+ throw new Error(`invalid ${flagName} "${val}". Choices: ${choices.join(", ")}.`);
87
+ }
88
+ return val;
89
+ }
90
+
72
91
  /**
73
92
  * Resolve one subcommand invocation's repo root, tmct.toml, and graph
74
93
  * path(s) — the shared precedence chain every subcommand (chat/memory/init/