@polycode-projects/the-mechanical-code-talker 1.5.5 → 1.8.3

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.
Files changed (45) hide show
  1. package/README.md +123 -14
  2. package/ROADMAP.md +233 -1392
  3. package/bin/tmct.mjs +479 -98
  4. package/corpus/README.md +3 -0
  5. package/corpus/generated/README.md +43 -0
  6. package/corpus/generated/ace-surface-variants.jsonl +17 -0
  7. package/corpus/generated/manifest.json +9 -0
  8. package/corpus/tier2/generate.mjs +14668 -0
  9. package/corpus/tier2/human-examples-large.jsonl +1928 -0
  10. package/corpus/tier2/human-examples-medium.jsonl +356 -0
  11. package/corpus/tier2/human-examples.jsonl +120 -0
  12. package/corpus/tier2/human-large.jsonl +12001 -0
  13. package/corpus/tier2/human-medium.jsonl +944 -0
  14. package/corpus/tier2/human.jsonl +664 -0
  15. package/corpus/tier2/manifest.json +42 -0
  16. package/package.json +14 -8
  17. package/src/answer-variants.json +47 -0
  18. package/src/answer-variants.mjs +67 -0
  19. package/src/ask-browser-entry.mjs +34 -0
  20. package/src/ask-browser.bundle.js +5095 -0
  21. package/src/ask-vocab.mjs +93 -8
  22. package/src/ask.mjs +451 -49
  23. package/src/chat.mjs +1273 -137
  24. package/src/cli-args.mjs +164 -0
  25. package/src/codegraph.mjs +170 -32
  26. package/src/extensions.mjs +100 -19
  27. package/src/grammar/ace.mjs +85 -3
  28. package/src/grammar/lexicon-core.json +9531 -63
  29. package/src/grammar/lexicon.mjs +58 -8
  30. package/src/graph-merge.mjs +114 -0
  31. package/src/index.mjs +14 -0
  32. package/src/init.mjs +40 -14
  33. package/src/interpret/normalize.mjs +75 -1
  34. package/src/interpret/strategies/grammar.mjs +10 -0
  35. package/src/interpret/strategies/keywords.mjs +20 -0
  36. package/src/interpret/strategies/noise-strip.mjs +73 -4
  37. package/src/memory/core.mjs +466 -8
  38. package/src/router/goal-reasoner.mjs +41 -7
  39. package/src/router/guardrail.mjs +37 -7
  40. package/src/router/resolver.mjs +50 -4
  41. package/src/sessions.mjs +5 -1
  42. package/src/source.mjs +54 -1
  43. package/src/syllogise.mjs +398 -27
  44. package/src/toml-config.mjs +13 -4
  45. package/src/viz.mjs +541 -0
package/bin/tmct.mjs CHANGED
@@ -34,6 +34,9 @@ software repository. No model calls; no codebase index of its own.
34
34
  Usage:
35
35
  tmct interactive chat (the headline surface)
36
36
  tmct chat [--repo <abs>] chat over a specific repo's graph
37
+ [--graph <path>] explicit graph file (repeatable — multiple graphs merge;
38
+ see src/graph-merge.mjs); wins over --repo/TMCT_GRAPH_FILE/tmct.toml
39
+ [--config <path>] an alternate tmct.toml location (a file or a directory)
37
40
  [--ephemeral] read the graph but write nothing back (demo/read-only)
38
41
  [--narrate] start with narrate mode on — a verbose, developer-facing
39
42
  trace of decision points/matched pattern/results/goal per
@@ -42,31 +45,57 @@ Usage:
42
45
  [--plain] force the plain readline shell (the default when
43
46
  stdin/stdout is not a terminal)
44
47
  tmct memory [--repo <abs>] what tmct remembers: facts, utterances, sessions,
45
- [--verbose] folded blocks (the /memory chat command, from the shell)
46
- tmct init [--force] initialize the current directory for tmct: .tmct/,
47
- tmct.toml, tier-1 corpus seed, provenance record
48
- [--corpus <id>] also seed a tier-2 corpus (aws|python|java) opt-in,
49
- offline, $0; init is tier-1-only unless asked
48
+ [--config <path>] folded blocks (the /memory chat command, from the shell)
49
+ [--verbose]
50
+ tmct init [--repo <abs>] initialize a repo for tmct (default: cwd): .tmct/,
51
+ [--force] tmct.toml, tier-1 corpus seed, provenance record
52
+ [--corpus <id|path>] also seed a corpus — a tier-2 manifest id (aws|python|java|
53
+ general) or a jsonl file path — opt-in, offline, $0
54
+ [--ontology <name|path>] activate+seed an ontology bundle (a recognized name or a path)
55
+ [--lexicon <name|path>] activate a lexicon bundle (recognized name or a path;
56
+ merged read-time, never seeded — see mergedLexiconExtra)
57
+ [--graph <path>] set graph_file/graph_files in tmct.toml (repeatable)
58
+ [--config <path>] write to an alternate tmct.toml location
50
59
  [--detect] suggest a tier-2 corpus from the repo's manifests
51
60
  (pyproject.toml → python, pom.xml → java); never seeds unasked
52
61
  [--with-persona <name>] write an explicit [extensions]/[bias] preset into tmct.toml
53
62
  ("code" — today's implicit default, made explicit)
63
+ [--persona-size <medium|large>] grow the default "human" persona's fact count
64
+ beyond Small (the default): "medium" activates
65
+ human-medium.jsonl (~1,608 facts total), "large" also
66
+ activates human-large.jsonl (~13,600 facts total,
67
+ with genuine multi-hop hypernym chains) — additive
68
+ size tiers of the SAME bundle, not separate personas
69
+ tmct import [--repo <abs>] activate+seed into an ALREADY-initialized repo (any
70
+ [--corpus <id|path>] combination of these flags in one call). --graph is a
71
+ [--ontology <name|path>] DIFFERENT operation from the others: it APPENDS to
72
+ [--lexicon <name|path>] tmct.toml's graph_files array (multi-graph growth),
73
+ [--graph <path>] never an extensions-bundle activation.
74
+ [--config <path>]
54
75
  tmct extend --validate <dir> validate a third-party extension pack's declared
55
- resources (corpus/lexicon/templates) before activating
76
+ [--config <path>] resources (corpus/lexicon/templates) before activating
56
77
  it in any repo's tmct.toml; exits non-zero on failure
57
78
  tmct syllogise [--repo <abs>] speculative inference (offline maintenance job): forward-
58
79
  [--depth <n>] [--budget <n>] chain the memory's rdfs:subClassOf closure, materialising
59
- bounded, low-trust, retractable entailed facts (never on the chat path)
80
+ [--config <path>] bounded, low-trust, retractable entailed facts (never on the chat path)
81
+ tmct viz [--repo <abs>] write one self-contained, navigable HTML file rendering the
82
+ [--focus <id>] memory graph: pan/zoom, click a node for its label/class/
83
+ [--output <path>] timestamps. Seeds from the most recently created individual
84
+ [--config <path>] by default; --output defaults to graph.html in the cwd.
60
85
  tmct serve [--repo <abs>] run the Anthropic Messages API-compatible endpoint
61
86
  [--host <h>] [--port <n>] (POST /v1/messages) over the graph — a deterministic,
62
- no-LLM "model" a tool-loop client can call; $0 usage.
63
- Defaults: host 127.0.0.1, port 8787. Ctrl+C to stop.
87
+ [--graph <path>] no-LLM "model" a tool-loop client can call; $0 usage.
88
+ [--config <path>] Defaults: host 127.0.0.1, port 8787. Ctrl+C to stop.
64
89
  tmct cli <tool> '{…}' invoke a graph tool directly (carry-over, de-emphasized)
65
90
  tmct cli digest '{…}' architecture map + per-module context bundles
66
91
  tmct --help show this help
67
92
 
68
93
  On a terminal, chat opens the full-screen TUI; piped input gets the plain shell.
69
94
  In chat: /help lists slash-commands; /exit leaves. Session log → <repo>/.tmct/session-<id>.log.
95
+
96
+ Shared graph-path precedence (chat/serve; see src/cli-args.mjs): --graph flag(s) >
97
+ TMCT_GRAPH_FILE env > tmct.toml graph_file/graph_files > --repo-derived
98
+ <repo>/.tmct/graph.json > git-root/cwd default.
70
99
  `;
71
100
 
72
101
  const argv = process.argv.slice(2);
@@ -274,6 +303,207 @@ async function runCliMode() {
274
303
  process.exit(2);
275
304
  }
276
305
 
306
+ // ---- shared `init`/`import` pluggable-input helpers ---------------------------
307
+ //
308
+ // Both `tmct init --corpus/--ontology/--lexicon` and the new `tmct import` verb
309
+ // funnel through the SAME two-step seam: resolvePluggableInput (name/id/path →
310
+ // a resolved descriptor) then activatePluggableInput (write tmct.toml + seed via
311
+ // src/extensions.mjs's unified loop) — one seam instead of three near-duplicate
312
+ // hand-rolled call sites.
313
+
314
+ /** Read `repoRoot`'s current tmct.toml (if any) into the shape init.mjs's
315
+ * renderTomlConfig/defaultConfig expect, so a caller can add ONE key and
316
+ * write it straight back without losing every other already-written key.
317
+ * Mirrors `tmct init --corpus`'s original inline config-merge exactly. */
318
+ async function readConfigForRewrite(repoRoot) {
319
+ const { defaultConfig } = await import("../src/init.mjs");
320
+ const { loadTomlConfig } = await import("../src/toml-config.mjs");
321
+ const raw = await loadTomlConfig(repoRoot);
322
+ const cfg = { ...defaultConfig() };
323
+ if (raw?.graph_file !== undefined) cfg.graphFile = String(raw.graph_file);
324
+ if (Array.isArray(raw?.graph_files)) cfg.graphFiles = raw.graph_files.slice();
325
+ if (raw?.corpus?.tier !== undefined) cfg.corpus = { tier: raw.corpus.tier };
326
+ if (raw?.seed) {
327
+ cfg.seed = { ...cfg.seed };
328
+ if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
329
+ if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
330
+ }
331
+ if (raw?.extensions !== undefined) cfg.extensions = raw.extensions;
332
+ if (raw?.bias !== undefined) cfg.bias = raw.bias;
333
+ return { raw, cfg };
334
+ }
335
+
336
+ async function writeConfig(repoRoot, cfg) {
337
+ const { renderTomlConfig, CONFIG_FILE } = await import("../src/init.mjs");
338
+ const { writeFile } = await import("node:fs/promises");
339
+ const { join: joinPath } = await import("node:path");
340
+ await writeFile(joinPath(repoRoot, CONFIG_FILE), renderTomlConfig(cfg));
341
+ }
342
+
343
+ /** realpathSync `absPath`, tolerating that it (or some trailing segment of
344
+ * it) may not exist yet: walks up to the longest EXISTING ancestor,
345
+ * realpath's THAT, then rejoins the non-existent remainder untouched.
346
+ * Equivalent to `fs.realpathSync(absPath)` when `absPath` already exists.
347
+ * Needed because `--graph <path>` etc. deliberately name not-yet-written
348
+ * files (a fresh graph a future indexer will create) — so we can't just
349
+ * realpathSync() the candidate path outright. */
350
+ function realpathTolerant(absPath, { realpathSync, dirname, basename, join: joinPath }) {
351
+ let dir = absPath;
352
+ const remainder = [];
353
+ while (true) {
354
+ try {
355
+ const real = realpathSync(dir);
356
+ return remainder.length ? joinPath(real, ...remainder) : real;
357
+ } catch {
358
+ const parent = dirname(dir);
359
+ if (parent === dir) return absPath; // hit the filesystem root; nothing on this path exists — give up as-is
360
+ remainder.unshift(basename(dir));
361
+ dir = parent;
362
+ }
363
+ }
364
+ }
365
+
366
+ /** Render `p` (resolved against `repoRoot` if not already absolute) as a
367
+ * path relative to `repoRoot` for tmct.toml — whose own header documents
368
+ * paths as "relative to this file" — falling back to the absolute form
369
+ * when `p` lands outside `repoRoot`. Both sides are realpath'd (tolerantly
370
+ * — `p` need not exist yet) before computing relative(), so the
371
+ * inside/outside-repoRoot decision doesn't depend on whether the OS
372
+ * happens to route a path through a symlink: macOS's os.tmpdir() resolves
373
+ * under /var/folders/..., and /var is itself a symlink to /private/var, so
374
+ * a spawned child's process.cwd() (kernel-realpath'd) and a test's raw
375
+ * mkdtemp() string (unresolved) can name the same real directory while
376
+ * disagreeing textually — Linux's /tmp has no such symlink, which is why
377
+ * this only showed up on CI. Comparing realpath'd forms on both sides
378
+ * makes the decision symlink-invariant on every platform. */
379
+ async function repoRelative(repoRoot, p) {
380
+ const { resolve: resolvePath, relative, dirname, basename, join: joinPath, isAbsolute } = await import("node:path");
381
+ const { realpathSync } = await import("node:fs");
382
+ const abs = isAbsolute(p) ? p : resolvePath(repoRoot, p);
383
+ const helpers = { realpathSync, dirname, basename, join: joinPath };
384
+ const repoReal = realpathTolerant(repoRoot, helpers);
385
+ const absReal = realpathTolerant(abs, helpers);
386
+ const rel = relative(repoReal, absReal);
387
+ return rel && !rel.startsWith("..") ? rel : abs;
388
+ }
389
+
390
+ /** Resolve a `--corpus`/`--ontology`/`--lexicon` value to something
391
+ * activatePluggableInput can act on: either a RECOGNIZED name (a
392
+ * BUILTIN_EXTENSIONS entry of the matching kind; for `--corpus` specifically,
393
+ * also a tier-2 manifest id like "aws" — today's `--corpus <id>` contract,
394
+ * preserved byte-for-byte) or a filesystem PATH, to be declared as a brand
395
+ * new `[extensions.<slug>]` entry. Throws the same clear "unknown --corpus"
396
+ * error the original tier-2-only implementation gave (naming the available
397
+ * ids), generalized to name a checked file path too. Validates before any
398
+ * disk write — callers resolve every pluggable input BEFORE calling
399
+ * initRepo, so a bad name/path touches nothing. */
400
+ async function resolvePluggableInput(kind, nameOrPath, { repoRoot }) {
401
+ const { BUILTIN_EXTENSIONS } = await import("../src/extensions.mjs");
402
+ if (Object.prototype.hasOwnProperty.call(BUILTIN_EXTENSIONS, nameOrPath) && BUILTIN_EXTENSIONS[nameOrPath].kind === kind) {
403
+ return { known: true, name: nameOrPath };
404
+ }
405
+ let manifestIds = null;
406
+ if (kind === "corpus") {
407
+ const { readFile } = await import("node:fs/promises");
408
+ const { TIER2_MANIFEST_FILE } = await import("../src/corpus/conceptnet.mjs");
409
+ try {
410
+ const manifest = JSON.parse(await readFile(TIER2_MANIFEST_FILE, "utf8"));
411
+ const corpuses = manifest.corpuses || [];
412
+ manifestIds = corpuses.map((c) => c.id);
413
+ const manifestEntry = corpuses.find((c) => c.id === nameOrPath);
414
+ if (manifestEntry) return { known: true, name: `tier2-${manifestEntry.id}`, manifestEntry };
415
+ } catch { /* manifest unreadable — fall through to path resolution */ }
416
+ }
417
+ const { access } = await import("node:fs/promises");
418
+ const { resolve: resolvePath, basename, isAbsolute } = await import("node:path");
419
+ const abs = isAbsolute(nameOrPath) ? nameOrPath : resolvePath(repoRoot, nameOrPath);
420
+ try {
421
+ await access(abs);
422
+ } catch {
423
+ if (manifestIds) {
424
+ throw new Error(`unknown --corpus "${nameOrPath}". Available tier-2 corpuses: ${manifestIds.join(", ")}.`);
425
+ }
426
+ throw new Error(`unknown --${kind} "${nameOrPath}" — not a recognized name, and no file found at ${abs}`);
427
+ }
428
+ const slug = basename(abs).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]+/g, "-").toLowerCase() || kind;
429
+ return { known: false, name: slug, kind, path: abs };
430
+ }
431
+
432
+ /** Activate one resolvePluggableInput() result in `repoRoot`'s tmct.toml and —
433
+ * for corpus/ontology-kind entries (or a pack-kind entry with a corpusPath) —
434
+ * seed it via the SAME unified loop every other bundle uses
435
+ * (src/extensions.mjs's seedActiveCorpusEntries). A lexicon/templates-kind
436
+ * entry only activates (merged read-time via mergedLexiconExtra, never
437
+ * seeded — matching today's behavior for those kinds). Returns a
438
+ * human-readable status line for stdout. */
439
+ async function activatePluggableInput(repoRoot, resolved) {
440
+ const { raw, cfg } = await readConfigForRewrite(repoRoot);
441
+ const name = resolved.name;
442
+ const existingEntry = raw?.extensions?.[name] || {};
443
+ const newEntry = { ...existingEntry, active: true };
444
+ if (!resolved.known) {
445
+ newEntry.kind = resolved.kind;
446
+ const pathKey = resolved.kind === "ontology" ? "ontology_path"
447
+ : resolved.kind === "lexicon" ? "lexicon_path"
448
+ : resolved.kind === "templates" ? "templates_path"
449
+ : "corpus_path";
450
+ newEntry[pathKey] = await repoRelative(repoRoot, resolved.path);
451
+ }
452
+ cfg.extensions = { ...(cfg.extensions || {}), [name]: newEntry };
453
+ await writeConfig(repoRoot, cfg);
454
+
455
+ const { resolveExtensions, seedActiveCorpusEntries } = await import("../src/extensions.mjs");
456
+ const { entries } = await resolveExtensions(repoRoot);
457
+ const entry = entries.get(name);
458
+ const seedable = entry.kind === "corpus" || entry.kind === "ontology" || (entry.kind === "pack" && entry.corpusPath);
459
+ if (!seedable) {
460
+ return `activated "${name}" (${entry.kind}) in tmct.toml — no corpus facts to seed for this kind.\n`;
461
+ }
462
+ const { perBundle } = await seedActiveCorpusEntries(repoRoot, new Map([[name, entry]]));
463
+ const seeded = perBundle[name];
464
+ if (seeded.error) {
465
+ throw new Error(`could not seed "${name}" — ${seeded.error}`);
466
+ }
467
+ if (resolved.manifestEntry) {
468
+ // Preserve the ORIGINAL `--corpus <tier2-id>` wording byte-for-byte
469
+ // (test/init-cli.test.mjs asserts on this exact shape).
470
+ return `seeded tier-2 corpus "${resolved.manifestEntry.id}" (${resolved.manifestEntry.kind}) — ${seeded.appended} fact(s) added`
471
+ + `${seeded.skipped ? `, ${seeded.skipped} already present` : ""}. Source: corpus/tier2/${resolved.manifestEntry.file} (${resolved.manifestEntry.license}). `
472
+ + `Activated in tmct.toml — future \`tmct init\`/chat sessions seed it automatically.\n`;
473
+ }
474
+ return `seeded "${name}" (${entry.kind}) — ${seeded.appended} fact(s) added`
475
+ + `${seeded.skipped ? `, ${seeded.skipped} already present` : ""}. `
476
+ + `Activated in tmct.toml — future \`tmct init\`/chat sessions seed it automatically.\n`;
477
+ }
478
+
479
+ /** `--graph <path>` (repeatable) APPENDS to `repoRoot`'s tmct.toml
480
+ * `graph_files` array — a DIFFERENT, purely additive operation from
481
+ * activatePluggableInput (no extensions-bundle activation). Each path is
482
+ * sanity-checked (readable, valid JSON, has an "individuals" array) before
483
+ * being recorded — `tmct import` targets an ALREADY-initialized repo, so the
484
+ * named graph is expected to genuinely exist. */
485
+ async function appendGraphFiles(repoRoot, graphPaths) {
486
+ const { readFile } = await import("node:fs/promises");
487
+ const { resolve: resolvePath } = await import("node:path");
488
+ for (const p of graphPaths) {
489
+ const abs = resolvePath(repoRoot, p);
490
+ let text;
491
+ try { text = await readFile(abs, "utf8"); }
492
+ catch (e) { throw new Error(`--graph ${p}: cannot read ${abs} (${e?.message || e})`); }
493
+ let payload;
494
+ try { payload = JSON.parse(text); }
495
+ catch (e) { throw new Error(`--graph ${p}: ${abs} is not valid JSON (${e?.message || e})`); }
496
+ if (typeof payload !== "object" || payload === null || !Array.isArray(payload.individuals)) {
497
+ throw new Error(`--graph ${p}: ${abs} doesn't look like a graph entities payload (missing an "individuals" array)`);
498
+ }
499
+ }
500
+ const { cfg } = await readConfigForRewrite(repoRoot);
501
+ const additions = [];
502
+ for (const p of graphPaths) additions.push(await repoRelative(repoRoot, p));
503
+ cfg.graphFiles = [...(cfg.graphFiles || []), ...additions];
504
+ await writeConfig(repoRoot, cfg);
505
+ }
506
+
277
507
  async function main() {
278
508
  const mode = process.argv[2];
279
509
 
@@ -291,30 +521,42 @@ async function main() {
291
521
  // session with the verbose developer/debug narrate mode already on. Default
292
522
  // OFF; `/narrate on`/`/narrate off` also toggles it mid-session.
293
523
  const narrate = rest.includes("--narrate");
524
+ // `--graph <path>` (repeatable) / `--config <path>` (src/cli-args.mjs): threaded
525
+ // through as the new top tier of createSession's graph-resolution order (above
526
+ // TMCT_GRAPH_FILE — see chat.mjs's own docblock at createSession). Omitted from
527
+ // the call entirely when absent, so a plain `tmct chat [--repo]` invocation is
528
+ // byte-identical to before these flags existed.
529
+ const { repeatedFlag, strFlag } = await import("../src/cli-args.mjs");
530
+ const graphPaths = repeatedFlag(rest, ["--graph"]);
531
+ const configPath = strFlag(rest, ["--config"]);
532
+ const extra = {};
533
+ if (graphPaths.length) extra.graphPaths = graphPaths;
534
+ if (configPath) extra.configPath = configPath;
294
535
  // The shell gate: a real terminal gets the full-screen Ink TUI; `--plain` or a
295
536
  // non-TTY stream (pipes, scripts, the test suite) gets the readline shell. Both
296
537
  // drive the same createSession sink — only the drawing differs.
297
538
  const plain = rest.includes("--plain") || !process.stdin.isTTY || !process.stdout.isTTY;
298
539
  if (plain) {
299
540
  const { runChat } = await import("../src/chat.mjs");
300
- await runChat({ repoPath, ephemeral, narrate });
541
+ await runChat({ repoPath, ephemeral, narrate, ...extra });
301
542
  } else {
302
543
  const { runTui } = await import("../src/tui/app.mjs");
303
- await runTui({ repoPath, ephemeral, narrate });
544
+ await runTui({ repoPath, ephemeral, narrate, ...extra });
304
545
  }
305
546
  return;
306
547
  }
307
548
 
308
549
  if (mode === "memory") {
309
550
  // `tmct memory` — the /memory chat command from the shell: same renderer
310
- // (src/memory/inspect.mjs), same repo resolution as chat (git root default).
551
+ // (src/memory/inspect.mjs). Repo resolution now goes through the shared
552
+ // resolveRuntimeConfig (src/cli-args.mjs) — --repo > git root > cwd, same
553
+ // as before, plus a (currently inert but accepted) `--config` for symmetry
554
+ // with every other subcommand. No `--graph`: memory reads no code graph.
311
555
  const rest = process.argv.slice(3);
312
- const i = rest.indexOf("--repo");
313
- const repoPath = i !== -1 ? rest[i + 1] : undefined;
314
556
  const verbose = rest.includes("--verbose") || rest.includes("-v");
315
- const { gitToplevel } = await import("../src/chat.mjs");
557
+ const { resolveRuntimeConfig } = await import("../src/cli-args.mjs");
316
558
  const { inspectMemory } = await import("../src/memory/inspect.mjs");
317
- const repo = repoPath || gitToplevel(process.cwd()) || process.cwd();
559
+ const { repo } = await resolveRuntimeConfig({ argv: rest });
318
560
  process.stdout.write(await inspectMemory(repo, { verbose }) + "\n");
319
561
  return;
320
562
  }
@@ -330,34 +572,40 @@ async function main() {
330
572
  // explicitly asked via `--corpus <id>`. The `--detect` auto-detect is a
331
573
  // documented STUB: it inspects the repo's manifests (pyproject.toml → python,
332
574
  // pom.xml → java) and SUGGESTS the matching corpus, but never seeds it unasked.
575
+ //
576
+ // `--repo <abs>` (NEW): init used to always hardcode process.cwd() — the only
577
+ // subcommand without a --repo flag. It now takes one like every other
578
+ // subcommand, defaulting to cwd exactly as before when absent.
333
579
  const rest = process.argv.slice(3);
334
- const { initRepo, defaultConfig, renderTomlConfig, CONFIG_FILE, PERSONA_PRESETS } = await import("../src/init.mjs");
335
- const { loadTomlConfig } = await import("../src/toml-config.mjs");
336
-
337
- const ci = rest.indexOf("--corpus");
338
- const corpusId = ci !== -1 ? rest[ci + 1] : undefined;
339
- let manifest = null;
340
- let manifestEntry = null;
341
- if (corpusId) {
342
- // Validate the id against the tier-2 manifest (same source of truth the
343
- // old ad hoc path used) BEFORE touching anything on disk.
344
- const { readFile } = await import("node:fs/promises");
345
- const { TIER2_MANIFEST_FILE } = await import("../src/corpus/conceptnet.mjs");
346
- try { manifest = JSON.parse(await readFile(TIER2_MANIFEST_FILE, "utf8")); }
347
- catch (e) { process.stderr.write(`tmct init: cannot read the tier-2 manifest — ${e?.message || e}\n`); process.exit(1); }
348
- manifestEntry = (manifest.corpuses || []).find((c) => c.id === corpusId);
349
- if (!manifestEntry) {
350
- const ids = (manifest.corpuses || []).map((c) => c.id).join(", ");
351
- process.stderr.write(`tmct init: unknown --corpus "${corpusId}". Available tier-2 corpuses: ${ids}.\n`);
352
- process.exit(2);
353
- }
580
+ const { strFlag, repeatedFlag } = await import("../src/cli-args.mjs");
581
+ const { resolve: resolvePath } = await import("node:path");
582
+ const { initRepo, PERSONA_PRESETS } = await import("../src/init.mjs");
583
+
584
+ const repoFlag = strFlag(rest, ["--repo"]);
585
+ const repoRoot = repoFlag ? resolvePath(process.cwd(), repoFlag) : process.cwd();
586
+
587
+ const corpusVal = strFlag(rest, ["--corpus"]);
588
+ const ontologyVal = strFlag(rest, ["--ontology"]);
589
+ const lexiconVal = strFlag(rest, ["--lexicon"]);
590
+ const graphFlags = repeatedFlag(rest, ["--graph"]);
591
+
592
+ // Resolve + validate EVERY pluggable input BEFORE touching disk — mirrors
593
+ // `--with-persona`'s own "validate before scaffolding" discipline, and the
594
+ // original `--corpus`'s "unknown id touches nothing" contract.
595
+ let corpusResolved = null, ontologyResolved = null, lexiconResolved = null;
596
+ try {
597
+ if (corpusVal) corpusResolved = await resolvePluggableInput("corpus", corpusVal, { repoRoot });
598
+ if (ontologyVal) ontologyResolved = await resolvePluggableInput("ontology", ontologyVal, { repoRoot });
599
+ if (lexiconVal) lexiconResolved = await resolvePluggableInput("lexicon", lexiconVal, { repoRoot });
600
+ } catch (e) {
601
+ process.stderr.write(`tmct init: ${e?.message || e}\n`);
602
+ process.exit(2);
354
603
  }
355
604
 
356
605
  // `--with-persona <name>` (Part 7): resolve + validate BEFORE touching
357
606
  // disk, mirroring `--corpus`'s own unknown-id error handling — a bad
358
607
  // persona name never scaffolds anything.
359
- const pi = rest.indexOf("--with-persona");
360
- const personaName = pi !== -1 ? rest[pi + 1] : undefined;
608
+ const personaName = strFlag(rest, ["--with-persona"]);
361
609
  let personaPreset = null;
362
610
  if (personaName) {
363
611
  if (!Object.prototype.hasOwnProperty.call(PERSONA_PRESETS, personaName)) {
@@ -368,60 +616,87 @@ async function main() {
368
616
  personaPreset = PERSONA_PRESETS[personaName];
369
617
  }
370
618
 
371
- const res = await initRepo(process.cwd(), { force: rest.includes("--force"), persona: personaPreset });
619
+ // `--persona-size <medium|large>` (PLAN_SEED.md §3): Small/Medium/Large are
620
+ // SIZES of the one `human` bundle, not separate corpus ids — human.jsonl
621
+ // (Small, the default) stays exactly as-is; human-medium.jsonl/
622
+ // human-large.jsonl hold ONLY the facts each size adds beyond the previous
623
+ // one (built by scripts/build-persona-tiers.mjs). "medium" activates just
624
+ // human-medium; "large" activates BOTH human-medium AND human-large (Large
625
+ // is Medium's facts plus its own — both incremental bundles must be active
626
+ // to reach the full ~13,600-fact total). Reuses the EXACT SAME
627
+ // resolvePluggableInput/activatePluggableInput seam `--corpus <id>` already
628
+ // uses (human-medium/human-large are ordinary `kind: "corpus"`
629
+ // BUILTIN_EXTENSIONS entries, recognized directly, no manifest needed) —
630
+ // this is sugar over "activate these specific corpus ids", not a new
631
+ // activation mechanism. Works whether the repo is fresh (this same
632
+ // `tmct init` call) or already initialized (a later re-run), exactly like
633
+ // `--corpus` does.
634
+ const PERSONA_SIZE_BUNDLES = { medium: ["human-medium"], large: ["human-medium", "human-large"] };
635
+ const personaSizeVal = strFlag(rest, ["--persona-size"]);
636
+ let personaSizeResolved = [];
637
+ if (personaSizeVal) {
638
+ if (!Object.prototype.hasOwnProperty.call(PERSONA_SIZE_BUNDLES, personaSizeVal)) {
639
+ const sizes = Object.keys(PERSONA_SIZE_BUNDLES).join(", ");
640
+ process.stderr.write(`tmct init: unknown --persona-size "${personaSizeVal}". Available sizes: ${sizes}.\n`);
641
+ process.exit(2);
642
+ }
643
+ try {
644
+ for (const id of PERSONA_SIZE_BUNDLES[personaSizeVal]) {
645
+ personaSizeResolved.push(await resolvePluggableInput("corpus", id, { repoRoot }));
646
+ }
647
+ } catch (e) {
648
+ process.stderr.write(`tmct init: ${e?.message || e}\n`);
649
+ process.exit(2);
650
+ }
651
+ }
652
+
653
+ const res = await initRepo(repoRoot, { force: rest.includes("--force"), persona: personaPreset });
372
654
  process.stdout.write(res.message + "\n");
373
655
 
374
- if (manifestEntry) {
375
- // `--corpus <id>` now means "activate extensions.tier2-<id> and PERSIST
376
- // that into tmct.toml" so a second `tmct init` (or the next chat
377
- // bootstrap) remembers the choice, unlike the old ad hoc path, which had
378
- // to be repeated every time. This changes the tier-2 provenance tag from
379
- // the old colon-separated "corpus:tier2:<id>" to the hyphenated
380
- // "corpus:tier2-<id>" (matching the TOML-legal extension name) a
381
- // deliberate, low-risk rename; nothing in chat.mjs's runtime logic keys
382
- // on the old colon-separated string (verified via grep).
383
- const extName = `tier2-${manifestEntry.id}`;
384
- const raw = await loadTomlConfig(process.cwd()); // just-written by initRepo above
385
- const cfg = { ...defaultConfig() };
386
- if (raw?.graph_file !== undefined) cfg.graphFile = String(raw.graph_file);
387
- if (raw?.corpus?.tier !== undefined) cfg.corpus = { tier: raw.corpus.tier };
388
- if (raw?.seed) {
389
- cfg.seed = { ...cfg.seed };
390
- if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
391
- if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
392
- }
393
- cfg.extensions = { ...(raw?.extensions || {}), [extName]: { ...(raw?.extensions?.[extName] || {}), active: true } };
394
- if (raw?.bias !== undefined) cfg.bias = raw.bias;
395
- const { writeFile } = await import("node:fs/promises");
396
- const { join } = await import("node:path");
397
- await writeFile(join(process.cwd(), CONFIG_FILE), renderTomlConfig(cfg));
398
-
399
- // Seed it now too — through the SAME unified corpus loader every other
400
- // bundle goes through (src/extensions.mjs), not a bespoke seedMemory call.
401
- const { resolveExtensions, seedActiveCorpusEntries } = await import("../src/extensions.mjs");
402
- const { entries } = await resolveExtensions(process.cwd());
403
- const entry = entries.get(extName);
404
- const { perBundle } = await seedActiveCorpusEntries(process.cwd(), new Map([[extName, entry]]));
405
- const seeded = perBundle[extName];
406
- if (seeded.error) {
407
- process.stderr.write(`tmct init: could not seed tier-2 corpus "${manifestEntry.id}" — ${seeded.error}\n`);
656
+ // `--corpus`/`--ontology`/`--lexicon` now mean "activate this bundle and
657
+ // PERSIST that into tmct.toml" so a second `tmct init` (or the next chat
658
+ // bootstrap) remembers the choice, unlike the old ad hoc path, which had
659
+ // to be repeated every time. (For a tier-2 --corpus id this changes the
660
+ // provenance tag from the old colon-separated "corpus:tier2:<id>" to the
661
+ // hyphenated "corpus:tier2-<id>" a deliberate, low-risk rename predating
662
+ // this batch; nothing in chat.mjs's runtime logic keys on the old string.)
663
+ let anyActivation = false;
664
+ for (const resolved of [corpusResolved, ontologyResolved, lexiconResolved, ...personaSizeResolved]) {
665
+ if (!resolved) continue;
666
+ anyActivation = true;
667
+ try {
668
+ process.stdout.write(await activatePluggableInput(repoRoot, resolved));
669
+ } catch (e) {
670
+ process.stderr.write(`tmct init: ${e?.message || e}\n`);
408
671
  process.exit(1);
409
672
  }
410
- process.stdout.write(
411
- `seeded tier-2 corpus "${manifestEntry.id}" (${manifestEntry.kind}) — ${seeded.appended} fact(s) added`
412
- + `${seeded.skipped ? `, ${seeded.skipped} already present` : ""}. Source: corpus/tier2/${manifestEntry.file} (${manifestEntry.license}). `
413
- + `Activated in tmct.toml — future \`tmct init\`/chat sessions seed it automatically.\n`,
414
- );
415
- return;
416
673
  }
417
674
 
675
+ // `--graph <path>` (repeatable): SETS (not appends — that's `tmct import`'s
676
+ // job) the fresh scaffold's graph pointer(s). No existence check: like
677
+ // init's own default graph_file, this just names where a future indexer
678
+ // will write, and a brand new repo's graph typically doesn't exist yet.
679
+ if (graphFlags.length) {
680
+ const { cfg } = await readConfigForRewrite(repoRoot);
681
+ if (graphFlags.length === 1) {
682
+ cfg.graphFile = await repoRelative(repoRoot, graphFlags[0]);
683
+ delete cfg.graphFiles;
684
+ } else {
685
+ cfg.graphFiles = await Promise.all(graphFlags.map((p) => repoRelative(repoRoot, p)));
686
+ }
687
+ await writeConfig(repoRoot, cfg);
688
+ process.stdout.write(`graph path${graphFlags.length > 1 ? "s" : ""} set in tmct.toml: ${graphFlags.join(", ")}\n`);
689
+ anyActivation = true;
690
+ }
691
+
692
+ if (anyActivation) return;
693
+
418
694
  if (rest.includes("--detect")) {
419
695
  // AUTO-DETECT STUB (documented, non-seeding): map a build manifest to the
420
696
  // tier-2 corpus that fits, and tell the operator how to add it. Kept a stub on
421
697
  // purpose — the $0/offline default never expands the corpus without an ask.
422
698
  const { access } = await import("node:fs/promises");
423
- const { join } = await import("node:path");
424
- const has = (f) => access(join(process.cwd(), f)).then(() => true, () => false);
699
+ const has = (f) => access(resolvePath(repoRoot, f)).then(() => true, () => false);
425
700
  const DETECT = [["pyproject.toml", "python"], ["pom.xml", "java"]];
426
701
  const found = [];
427
702
  for (const [file, id] of DETECT) if (await has(file)) found.push([file, id]);
@@ -437,6 +712,61 @@ async function main() {
437
712
  return;
438
713
  }
439
714
 
715
+ if (mode === "import") {
716
+ // `tmct import` — activate+seed into an ALREADY-initialized repo, reusing
717
+ // the SAME resolvePluggableInput/activatePluggableInput seam `init`'s own
718
+ // --corpus/--ontology/--lexicon flags use. `--graph` is a DIFFERENT, purely
719
+ // additive operation (appendGraphFiles) — it grows tmct.toml's graph_files
720
+ // array, never activates an extensions bundle.
721
+ const rest = process.argv.slice(3);
722
+ const { strFlag, repeatedFlag } = await import("../src/cli-args.mjs");
723
+ const { resolve: resolvePath } = await import("node:path");
724
+
725
+ const repoFlag = strFlag(rest, ["--repo"]);
726
+ const repoRoot = repoFlag ? resolvePath(process.cwd(), repoFlag) : process.cwd();
727
+
728
+ const corpusVal = strFlag(rest, ["--corpus"]);
729
+ const ontologyVal = strFlag(rest, ["--ontology"]);
730
+ const lexiconVal = strFlag(rest, ["--lexicon"]);
731
+ const graphFlags = repeatedFlag(rest, ["--graph"]);
732
+
733
+ if (!corpusVal && !ontologyVal && !lexiconVal && !graphFlags.length) {
734
+ process.stderr.write("tmct import: needs at least one of --corpus/--ontology/--lexicon/--graph\n");
735
+ process.exit(2);
736
+ }
737
+
738
+ let corpusResolved = null, ontologyResolved = null, lexiconResolved = null;
739
+ try {
740
+ if (corpusVal) corpusResolved = await resolvePluggableInput("corpus", corpusVal, { repoRoot });
741
+ if (ontologyVal) ontologyResolved = await resolvePluggableInput("ontology", ontologyVal, { repoRoot });
742
+ if (lexiconVal) lexiconResolved = await resolvePluggableInput("lexicon", lexiconVal, { repoRoot });
743
+ } catch (e) {
744
+ process.stderr.write(`tmct import: ${e?.message || e}\n`);
745
+ process.exit(2);
746
+ }
747
+
748
+ for (const resolved of [corpusResolved, ontologyResolved, lexiconResolved]) {
749
+ if (!resolved) continue;
750
+ try {
751
+ process.stdout.write(await activatePluggableInput(repoRoot, resolved));
752
+ } catch (e) {
753
+ process.stderr.write(`tmct import: ${e?.message || e}\n`);
754
+ process.exit(1);
755
+ }
756
+ }
757
+
758
+ if (graphFlags.length) {
759
+ try {
760
+ await appendGraphFiles(repoRoot, graphFlags);
761
+ process.stdout.write(`added ${graphFlags.length} graph file(s) to tmct.toml's graph_files: ${graphFlags.join(", ")}\n`);
762
+ } catch (e) {
763
+ process.stderr.write(`tmct import: ${e?.message || e}\n`);
764
+ process.exit(1);
765
+ }
766
+ }
767
+ return;
768
+ }
769
+
440
770
  if (mode === "extend") {
441
771
  // `tmct extend --validate <dir>` — validate a THIRD-PARTY extension pack
442
772
  // (the shape a package like seonix/marginalia ships) BEFORE it's activated
@@ -456,11 +786,26 @@ async function main() {
456
786
  process.exit(2);
457
787
  }
458
788
  const { resolve: resolvePath } = await import("node:path");
789
+ const { strFlag } = await import("../src/cli-args.mjs");
459
790
  const target = resolvePath(process.cwd(), dirArg);
791
+ // `--config <path>` (optional): an alternate tmct.toml to validate against,
792
+ // INSTEAD of `<target>/tmct.toml` — `target` still anchors every resource
793
+ // path (a pure validator stays that way; this never mutates anything).
794
+ // Accepts either a FILE (the tmct.toml itself) or a DIRECTORY (look for
795
+ // tmct.toml under it), same as every other subcommand's --config.
796
+ const configFlag = strFlag(rest, ["--config"]);
797
+ let configFile;
798
+ if (configFlag) {
799
+ const { stat } = await import("node:fs/promises");
800
+ const abs = resolvePath(process.cwd(), configFlag);
801
+ let isDir = false;
802
+ try { isDir = (await stat(abs)).isDirectory(); } catch { /* missing path — treat as a file target */ }
803
+ configFile = isDir ? resolvePath(abs, "tmct.toml") : abs;
804
+ }
460
805
  const { resolveExtensions, BUILTIN_EXTENSIONS, validateExtensionPack } = await import("../src/extensions.mjs");
461
806
  let entries;
462
807
  try {
463
- ({ entries } = await resolveExtensions(target));
808
+ ({ entries } = await resolveExtensions(target, configFile ? { configFile } : {}));
464
809
  } catch (e) {
465
810
  process.stderr.write(`tmct extend --validate: ${e?.message || e}\n`);
466
811
  process.exit(1);
@@ -490,18 +835,18 @@ async function main() {
490
835
  if (mode === "syllogise") {
491
836
  // `tmct syllogise` — the explicit speculative-inference batch (never on the chat
492
837
  // hot path): forward-chain the memory's rdfs:subClassOf closure into bounded,
493
- // low-trust, retractable entailed facts. Same repo resolution as `memory`.
838
+ // low-trust, retractable entailed facts. Same repo resolution as `memory` —
839
+ // resolveRuntimeConfig (src/cli-args.mjs): --repo > git root > cwd. Also
840
+ // accepts `--config` for symmetry (syllogise reads no code graph either).
494
841
  const rest = process.argv.slice(3);
495
- const i = rest.indexOf("--repo");
496
- const repoPath = i !== -1 ? rest[i + 1] : undefined;
497
842
  const numFlag = (name, dflt) => {
498
843
  const j = rest.indexOf(name);
499
844
  const v = j !== -1 ? Number(rest[j + 1]) : NaN;
500
845
  return Number.isFinite(v) ? v : dflt;
501
846
  };
502
- const { gitToplevel } = await import("../src/chat.mjs");
847
+ const { resolveRuntimeConfig } = await import("../src/cli-args.mjs");
503
848
  const { syllogise } = await import("../src/syllogise.mjs");
504
- const repo = repoPath || gitToplevel(process.cwd()) || process.cwd();
849
+ const { repo } = await resolveRuntimeConfig({ argv: rest });
505
850
  const res = await syllogise(repo, { depth: numFlag("--depth", 32), budget: numFlag("--budget", 50) });
506
851
  process.stdout.write(
507
852
  `tmct syllogise — derived ${res.count} entailed fact(s) (subClassOf closure, depth ${res.depth}, budget ${res.budget})`
@@ -510,6 +855,36 @@ async function main() {
510
855
  return;
511
856
  }
512
857
 
858
+ if (mode === "viz") {
859
+ // `tmct viz` — one self-contained, navigable HTML file rendering the
860
+ // memory graph (PLAN_BREADTH_FIRST_NLU.md §5, PLAN_VIZ.md's design):
861
+ // pan/zoom, click-a-node, a concentric ring layout keyed on hop with a
862
+ // depth/age falloff. Same repo resolution as `memory`/`syllogise` —
863
+ // resolveRuntimeConfig: --repo > git root > cwd.
864
+ const rest = process.argv.slice(3);
865
+ const { strFlag, resolveRuntimeConfig } = await import("../src/cli-args.mjs");
866
+ const { computeVizGraph, renderVizHtml, readAskBundle } = await import("../src/viz.mjs");
867
+ const { writeFile } = await import("node:fs/promises");
868
+ const { resolve } = await import("node:path");
869
+ const focus = strFlag(rest, ["--focus"]);
870
+ const outPath = resolve(process.cwd(), strFlag(rest, ["--output", "--out"], "graph.html"));
871
+ const { repo } = await resolveRuntimeConfig({ argv: rest });
872
+ const vizGraph = await computeVizGraph(repo, focus ? { focus } : {});
873
+ // The embedded "Ask the graph" chat panel — the real ask.mjs engine,
874
+ // bundled for the browser (scripts/build-ask-bundle.mjs's checked-in
875
+ // output). readAskBundle() never throws; an empty string renders a
876
+ // graph-only page with an honest "chat unavailable" note instead of a
877
+ // broken one (e.g. a fresh checkout before the bundle's first build).
878
+ const askBundle = await readAskBundle();
879
+ const html = renderVizHtml({ ...vizGraph, askBundle });
880
+ await writeFile(outPath, html, "utf8");
881
+ process.stdout.write(
882
+ `tmct viz — wrote ${vizGraph.nodes.length} node(s), ${vizGraph.edges.length} edge(s) to ${outPath}`
883
+ + `${askBundle ? " (with the embedded ask-the-graph chat panel)" : " (no chat panel — run `npm run build:ask-bundle` first)"}\n`,
884
+ );
885
+ return;
886
+ }
887
+
513
888
  if (mode === "serve") {
514
889
  // `tmct serve` — the Phase-A capability-router interface: an Anthropic
515
890
  // Messages API-compatible HTTP endpoint (POST /v1/messages) over the graph.
@@ -521,8 +896,11 @@ async function main() {
521
896
  process.stdout.write(
522
897
  "tmct serve — Anthropic Messages API-compatible endpoint (POST /v1/messages)\n\n" +
523
898
  "Usage:\n" +
524
- " tmct serve [--repo <abs>] [--host <h>] [--port <n>]\n\n" +
525
- " --repo <abs> target a repo's graph (<abs>/.tmct/graph.json); default: cwd/TMCT_GRAPH_FILE\n" +
899
+ " tmct serve [--repo <abs>] [--graph <path>] [--config <path>] [--host <h>] [--port <n>]\n\n" +
900
+ " --repo <abs> target a repo's graph (<abs>/.tmct/graph.json); default: git root/cwd\n" +
901
+ " --graph <path> explicit graph file (repeatable — multiple graphs merge; wins\n" +
902
+ " over --repo/TMCT_GRAPH_FILE/tmct.toml)\n" +
903
+ " --config <path> an alternate tmct.toml location (a file or a directory)\n" +
526
904
  " --host <h> bind address (default 127.0.0.1)\n" +
527
905
  " --port <n> TCP port (default 8787; 0 picks an ephemeral port)\n\n" +
528
906
  "Request: { model, messages:[...], tools:[...], max_tokens, system? }\n" +
@@ -531,16 +909,19 @@ async function main() {
531
909
  );
532
910
  return;
533
911
  }
534
- const strFlag = (name, dflt) => { const j = rest.indexOf(name); return j !== -1 ? rest[j + 1] : dflt; };
535
- const repoPath = strFlag("--repo", undefined);
536
- const host = strFlag("--host", "127.0.0.1");
537
- const portRaw = strFlag("--port", undefined);
912
+ const { strFlag, resolveRuntimeConfig } = await import("../src/cli-args.mjs");
913
+ const host = strFlag(rest, ["--host"], "127.0.0.1");
914
+ const portRaw = strFlag(rest, ["--port"]);
538
915
  const port = portRaw !== undefined && Number.isFinite(Number(portRaw)) ? Number(portRaw) : 8787;
539
- const { join } = await import("node:path");
540
916
  const { startServer } = await import("../src/server-http.mjs");
541
- const { loadConfig, DEFAULT_GRAPH_REL } = await import("../src/config.mjs");
542
- const configFor = (rp) => rp ? { graphFile: join(rp, DEFAULT_GRAPH_REL) } : loadConfig();
543
- const srv = await startServer({ config: configFor(repoPath), host, port });
917
+ // Graph-path precedence (src/cli-args.mjs, shared with `chat`): --graph
918
+ // flag(s) > TMCT_GRAPH_FILE env > tmct.toml graph_file/graph_files >
919
+ // --repo-derived <repo>/.tmct/graph.json > git-root/cwd default. This
920
+ // REPLACES serve's old cwd-only default (loadConfig had no git-root
921
+ // fallback) with the same git-root-aware default every other subcommand
922
+ // now shares — a deliberate, documented unification, not a regression.
923
+ const { config } = await resolveRuntimeConfig({ argv: rest });
924
+ const srv = await startServer({ config, host, port });
544
925
  process.stdout.write(
545
926
  `tmct serve — Anthropic Messages API at ${srv.url}/v1/messages (POST) — ` +
546
927
  `graph ${srv.config.graphFile} — usage billed $0 — Ctrl+C to stop\n`,
@@ -559,7 +940,7 @@ async function main() {
559
940
  // An unknown mode gets the instructive usage line and exit 2. (A bare invocation
560
941
  // never lands here — the argv splice above rewrote it to `chat`.)
561
942
  process.stderr.write(`tmct: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
562
- "Use `cli digest …`, `cli <tool> …`, `memory`, or `chat`.\n");
943
+ "Use `chat`, `memory`, `init`, `import`, `extend --validate`, `syllogise`, `serve`, `cli digest …`, or `cli <tool> …`.\n");
563
944
  process.exit(2);
564
945
  }
565
946