@mmnto/cli 1.113.1 → 1.115.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.
Files changed (90) hide show
  1. package/dist/commands/doctor-seat-identity.d.ts +71 -0
  2. package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
  3. package/dist/commands/doctor-seat-identity.js +352 -0
  4. package/dist/commands/doctor-seat-identity.js.map +1 -0
  5. package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
  6. package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
  7. package/dist/commands/doctor-seat-identity.test.js +230 -0
  8. package/dist/commands/doctor-seat-identity.test.js.map +1 -0
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +3 -0
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/doctor.test.js +1 -0
  13. package/dist/commands/doctor.test.js.map +1 -1
  14. package/dist/commands/ecl-gc.d.ts.map +1 -1
  15. package/dist/commands/ecl-gc.js +18 -7
  16. package/dist/commands/ecl-gc.js.map +1 -1
  17. package/dist/commands/ecl-gc.test.js +63 -1
  18. package/dist/commands/ecl-gc.test.js.map +1 -1
  19. package/dist/commands/eject.d.ts +62 -3
  20. package/dist/commands/eject.d.ts.map +1 -1
  21. package/dist/commands/eject.js +382 -47
  22. package/dist/commands/eject.js.map +1 -1
  23. package/dist/commands/eject.test.js +557 -7
  24. package/dist/commands/eject.test.js.map +1 -1
  25. package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
  26. package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
  27. package/dist/commands/gemini-beforetool-contract.test.js +106 -0
  28. package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
  29. package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
  30. package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
  31. package/dist/commands/gemini-sessionstart-contract.test.js +184 -0
  32. package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
  33. package/dist/commands/init-templates.d.ts +6 -6
  34. package/dist/commands/init-templates.d.ts.map +1 -1
  35. package/dist/commands/init-templates.js +271 -19
  36. package/dist/commands/init-templates.js.map +1 -1
  37. package/dist/commands/init.d.ts +40 -4
  38. package/dist/commands/init.d.ts.map +1 -1
  39. package/dist/commands/init.js +357 -32
  40. package/dist/commands/init.js.map +1 -1
  41. package/dist/commands/init.test.js +546 -10
  42. package/dist/commands/init.test.js.map +1 -1
  43. package/dist/commands/install-hooks-exit-contract.test.js +5 -0
  44. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  45. package/dist/commands/install-hooks.d.ts +26 -8
  46. package/dist/commands/install-hooks.d.ts.map +1 -1
  47. package/dist/commands/install-hooks.js +51 -6
  48. package/dist/commands/install-hooks.js.map +1 -1
  49. package/dist/commands/mail-degraded-e4.test.js +126 -0
  50. package/dist/commands/mail-degraded-e4.test.js.map +1 -1
  51. package/dist/commands/mail.d.ts +19 -1
  52. package/dist/commands/mail.d.ts.map +1 -1
  53. package/dist/commands/mail.js +195 -30
  54. package/dist/commands/mail.js.map +1 -1
  55. package/dist/commands/mail.test.js +291 -3
  56. package/dist/commands/mail.test.js.map +1 -1
  57. package/dist/commands/orient.d.ts.map +1 -1
  58. package/dist/commands/orient.js +82 -0
  59. package/dist/commands/orient.js.map +1 -1
  60. package/dist/commands/orient.test.js +144 -1
  61. package/dist/commands/orient.test.js.map +1 -1
  62. package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
  63. package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
  64. package/dist/commands/seat-cli-wiring.test.js +147 -0
  65. package/dist/commands/seat-cli-wiring.test.js.map +1 -0
  66. package/dist/commands/seat-poll-chain.test.d.ts +13 -0
  67. package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
  68. package/dist/commands/seat-poll-chain.test.js +103 -0
  69. package/dist/commands/seat-poll-chain.test.js.map +1 -0
  70. package/dist/commands/seat.d.ts +92 -0
  71. package/dist/commands/seat.d.ts.map +1 -0
  72. package/dist/commands/seat.js +649 -0
  73. package/dist/commands/seat.js.map +1 -0
  74. package/dist/commands/seat.test.d.ts +19 -0
  75. package/dist/commands/seat.test.d.ts.map +1 -0
  76. package/dist/commands/seat.test.js +532 -0
  77. package/dist/commands/seat.test.js.map +1 -0
  78. package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
  79. package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
  80. package/dist/commands/sessionstart-manifest-contract.test.js +176 -0
  81. package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
  82. package/dist/hooks/__tests__/auto-context.test.js +20 -0
  83. package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
  84. package/dist/hooks/auto-context.d.ts +19 -0
  85. package/dist/hooks/auto-context.d.ts.map +1 -1
  86. package/dist/hooks/auto-context.js +22 -4
  87. package/dist/hooks/auto-context.js.map +1 -1
  88. package/dist/index.js +73 -0
  89. package/dist/index.js.map +1 -1
  90. package/package.json +2 -2
@@ -188,7 +188,39 @@ export function wirePreparePackageJson(pkgPath) {
188
188
  return { action: 'wired' };
189
189
  }
190
190
  // --- Gemini CLI hook installer ---
191
- async function installGeminiHooks(cwd) {
191
+ /**
192
+ * Whether a consumer owns a custom `<stem>.js` sibling of a managed `<stem>.cjs` vendor
193
+ * hook (mmnto-ai/totem#2601 — the strategy#482 incident: the generic template dropped
194
+ * beside a repo's own SessionStart.js, double-firing or regressing orientation depending
195
+ * on Gemini's hook discovery). Ownership is the same positional gate `scaffoldFile` and
196
+ * `migrateLegacyGeminiHooks` use, so a marker-headed sibling — the legacy `.js`→`.cjs`
197
+ * migration case — never reports here; only an UNOWNED sibling does. A sibling that
198
+ * cannot be read is not provably totem-owned and reports (non-destructive posture,
199
+ * never a crash). Returns the sibling's basename when one is present.
200
+ *
201
+ * This is a PREDICATE, not the policy: whether a report withholds the scaffold or only
202
+ * discloses a coexistence depends on whether the managed `.cjs` already exists — see
203
+ * `installGeminiHooks`, the sole caller.
204
+ */
205
+ export function findUnownedHookSibling(cjsPath) {
206
+ if (!cjsPath.endsWith('.cjs'))
207
+ return undefined;
208
+ const siblingPath = `${cjsPath.slice(0, -'.cjs'.length)}.js`;
209
+ if (!fs.existsSync(siblingPath))
210
+ return undefined;
211
+ // totem-context: intentional cleanup — an unreadable sibling cannot be proven totem-owned, so it withholds the scaffold (non-destructive) rather than aborting init.
212
+ try {
213
+ const existing = fs.readFileSync(siblingPath, 'utf-8');
214
+ if (markerOpensFile(existing, TOTEM_FILE_MARKER))
215
+ return undefined;
216
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
217
+ }
218
+ catch {
219
+ // fall through to withhold
220
+ }
221
+ return path.basename(siblingPath);
222
+ }
223
+ export async function installGeminiHooks(cwd) {
192
224
  const results = [];
193
225
  // The two whole-file hooks carry the managed end marker (mmnto-ai/totem#2410) so a
194
226
  // drifted-but-bounded artifact self-repairs on re-init; the skill is marker-block
@@ -218,13 +250,43 @@ async function installGeminiHooks(cwd) {
218
250
  ];
219
251
  for (const { rel, content, marker, endMarker } of files) {
220
252
  const filePath = path.join(cwd, rel);
253
+ // Vendor-hook managed-marker guard (mmnto-ai/totem#2601): a custom same-stem `.js`
254
+ // the consumer owns withholds the managed `.cjs` — the drop is disclosed, never
255
+ // silent, because a withheld hook otherwise reads as an installed one.
256
+ //
257
+ // The withhold is scoped to the FRESH path (no `.cjs` on disk yet), where it is what
258
+ // prevents the twin from being introduced. Once the `.cjs` EXISTS the twin is already
259
+ // live: withholding there would suppress drift-repair of a file that fires anyway and
260
+ // would report "not installed" about an installed hook. That case scaffolds normally
261
+ // and discloses the coexistence plus the two ways out.
262
+ const unownedSibling = findUnownedHookSibling(filePath);
263
+ if (unownedSibling !== undefined && !fs.existsSync(filePath)) {
264
+ results.push({
265
+ file: rel,
266
+ action: 'skipped',
267
+ summaryActionOverride: `Skipped — custom ${unownedSibling} present — totem hook not installed; remove or rename it to adopt the managed hook`,
268
+ });
269
+ continue;
270
+ }
221
271
  const result = scaffoldFile(filePath, content, marker, endMarker);
222
272
  // Map the scaffold action onto HookInstallerResult: a bounded drift-repair
223
273
  // (`refreshed`) surfaces as `merged` (file mutated) for installer summary parity
224
274
  // with scaffoldClaudeSkill's mapping.
275
+ const action = result.action === 'refreshed' ? 'merged' : result.action;
276
+ // The coexistence line is true only when the `.cjs` really is totem's — `exists`
277
+ // (canonical) or `merged` (drift-repaired). A USER-owned `.cjs` (no marker) takes
278
+ // scaffoldFile's preserve path: totem installed nothing, so claiming a "managed
279
+ // .cjs" and steering the user to delete their own file would be false both ways
280
+ // (scoped leg round 2, D3) — that state gets the truthful not-installed line.
281
+ const coexistenceOverride = unownedSibling === undefined
282
+ ? undefined
283
+ : action === 'exists' || action === 'merged'
284
+ ? `Custom ${unownedSibling} coexists with the managed ${path.basename(filePath)} — both may fire; remove one (delete the managed .cjs to keep yours, or your .js to keep totem's)`
285
+ : `Custom ${unownedSibling} and ${path.basename(filePath)} are both present and neither is totem's — totem hook not installed`;
225
286
  results.push({
226
287
  file: rel,
227
- action: result.action === 'refreshed' ? 'merged' : result.action,
288
+ action,
289
+ ...(coexistenceOverride !== undefined ? { summaryActionOverride: coexistenceOverride } : {}),
228
290
  ...(result.err ? { err: result.err } : {}),
229
291
  });
230
292
  }
@@ -236,7 +298,7 @@ async function installGeminiHooks(cwd) {
236
298
  // Dynamic-imported to keep the migration co-located with the upgrade-path
237
299
  // drift-repair machinery.
238
300
  const { migrateGeminiHookRegistration, migrateLegacyGeminiHooks } = await import('./install-hooks.js');
239
- for (const { file, action } of await migrateLegacyGeminiHooks(cwd)) {
301
+ for (const { file, action, reason } of await migrateLegacyGeminiHooks(cwd)) {
240
302
  if (action === 'migrated') {
241
303
  results.push({
242
304
  file,
@@ -246,6 +308,23 @@ async function installGeminiHooks(cwd) {
246
308
  summaryActionOverride: `Migrated legacy Gemini ${path.basename(file)} → .cjs`,
247
309
  });
248
310
  }
311
+ else if (action === 'skipped' && reason === 'unreadable-legacy') {
312
+ // Unreadable outcomes are the non-migrated ones init discloses: the path
313
+ // exists and could not be read at all, so the user is the only one who can
314
+ // resolve it — and "user-owned" must never render for them (#2601).
315
+ results.push({
316
+ file,
317
+ action: 'skipped',
318
+ summaryActionOverride: `Skipped — legacy ${path.basename(file)} could not be read; left in place (remove or rename it to complete the .cjs migration)`,
319
+ });
320
+ }
321
+ else if (action === 'skipped' && reason === 'unreadable-successor') {
322
+ results.push({
323
+ file,
324
+ action: 'skipped',
325
+ summaryActionOverride: `Skipped — the .cjs successor of ${path.basename(file)} could not be read; left both files untouched (remove or rename the successor to complete the migration)`,
326
+ });
327
+ }
249
328
  }
250
329
  const registration = migrateGeminiHookRegistration(cwd);
251
330
  if (registration.err) {
@@ -487,7 +566,152 @@ if (claudeTool)
487
566
  const geminiTool = AI_TOOLS.find((t) => t.name === 'Gemini CLI');
488
567
  if (geminiTool)
489
568
  geminiTool.hookInstaller = installGeminiHooks;
490
- export function scaffoldMcpConfig(filePath, serverEntry) {
569
+ /** The npm package every Totem MCP registration invokes, under whatever entry name. */
570
+ const MCP_PACKAGE = '@mmnto/mcp';
571
+ /**
572
+ * Boundary-anchored name probe. A bare `includes('@mmnto/mcp')` also fires on
573
+ * `@mmnto/mcp-experimental` — a DIFFERENT package whose presence must not suppress the
574
+ * real registration. The lookahead rejects a following word char, hyphen, or dot
575
+ * (`@mmnto/mcp.js` is a different package name too); a `/`, an `@`, a quote, a space,
576
+ * or end-of-string all still match (`@mmnto/mcp/dist/index.js`, `@mmnto/mcp@1.2.3`).
577
+ * Non-global, so it carries no `lastIndex` state across calls.
578
+ */
579
+ const MCP_PACKAGE_RE = /@mmnto\/mcp(?![\w.-])/;
580
+ /**
581
+ * Whether a `command`/`args` string names a JS-family SCRIPT FILE worth an identity
582
+ * walk. A suffix gate, not a separator gate: a separator-shaped non-script arg
583
+ * (`--cwd ./`) resolves to a DIRECTORY, and `path.dirname` on a directory would start
584
+ * the walk one level above it — outside the config, potentially outside the repo
585
+ * (scoped leg round 2, D2). Non-global, so `.test()` carries no `lastIndex` state.
586
+ */
587
+ const SCRIPT_FILE_RE = /\.(?:cjs|mjs|js)$/i;
588
+ function looksLikeScriptPath(value) {
589
+ return SCRIPT_FILE_RE.test(value);
590
+ }
591
+ /**
592
+ * Whether a `command` is a Node-family runtime — the only commands whose FIRST
593
+ * script-suffixed argument is, by the runtime's own semantics, the ENTRYPOINT.
594
+ * The path probe is scoped to this shape: probing every `.js` argument of every
595
+ * command would let an unrelated server's plugin/config argument (a file totem
596
+ * happens to own) masquerade as a totem registration and silently suppress the
597
+ * real install (Greptile P2 on #2606). Package-manager launchers (`npx`,
598
+ * `pnpm dlx`, `cmd /c npx …`) name the package instead — the name probe's job.
599
+ */
600
+ function isNodeCommand(command) {
601
+ const base = path.basename(command).toLowerCase();
602
+ return base === 'node' || base === 'node.exe';
603
+ }
604
+ /** No-throw file probe: only an existing regular FILE qualifies for the identity walk. */
605
+ function isExistingFile(p) {
606
+ // totem-context: intentional cleanup — an unstat-able path is simply not an attributable registration; dedup must never abort the init scaffold.
607
+ try {
608
+ return fs.statSync(p).isFile();
609
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
610
+ }
611
+ catch {
612
+ return false;
613
+ }
614
+ }
615
+ /**
616
+ * The package name owning a resolved script: walk from the script's directory to the
617
+ * filesystem root and read the nearest `package.json`'s `name`. A `package.json` that
618
+ * names nothing (the `{"type":"module"}` dual-package shim inside a `dist/`) is not an
619
+ * identity, so the walk continues past it.
620
+ */
621
+ function packageNameForScript(scriptPath) {
622
+ let dir = path.dirname(scriptPath);
623
+ for (;;) {
624
+ const pkgPath = path.join(dir, 'package.json');
625
+ if (fs.existsSync(pkgPath)) {
626
+ // totem-context: intentional cleanup — an unreadable/invalid package.json is not evidence of a DIFFERENT package, so the walk continues to the next parent; dedup must never abort the init scaffold.
627
+ try {
628
+ // `JSON.parse` also yields `null`, arrays, and scalars — checked explicitly
629
+ // (the wirePreparePackageJson posture) rather than left to throw into the catch.
630
+ const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
631
+ if (parsed !== null && typeof parsed === 'object') {
632
+ const { name } = parsed;
633
+ if (typeof name === 'string')
634
+ return name;
635
+ }
636
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
637
+ }
638
+ catch {
639
+ // fall through to the next parent
640
+ }
641
+ }
642
+ // `path.dirname` is a fixpoint at the filesystem root — that fixpoint ends the walk.
643
+ const parent = path.dirname(dir);
644
+ if (parent === dir)
645
+ return undefined;
646
+ dir = parent;
647
+ }
648
+ }
649
+ /**
650
+ * Find an existing registration of the Totem MCP package under ANY entry name
651
+ * (mmnto-ai/totem#2601). Dedup keys on the registered PACKAGE, not the entry name: a
652
+ * consumer that registered `@mmnto/mcp` as `totem-dev` / `totem-strategy` (a `--cwd`
653
+ * pin, a local path pin) must not collect a third duplicate `totem` entry on re-init.
654
+ *
655
+ * Two probes, both scoped to `command` and the string members of `args` — the only
656
+ * fields that can INVOKE anything. There is deliberately no whole-entry serialization
657
+ * scan: it false-positives on a prose mention in `env`, a field that cannot invoke
658
+ * anything.
659
+ *
660
+ * 1. Name probe — {@link MCP_PACKAGE_RE} on each candidate string.
661
+ * 2. Path probe — a registration need not spell the package at all. This repo's own
662
+ * configs run `node ./packages/mcp/dist/index.js`; for a Node-family command
663
+ * ({@link isNodeCommand}), the FIRST script-suffixed arg — the runtime's own
664
+ * entrypoint — is resolved against every candidate base and, when it lands on a
665
+ * real file, the owning package is read from the nearest `package.json` on the
666
+ * written path (lexical — an aliased/symlinked script attributes to its host
667
+ * package) via {@link packageNameForScript}.
668
+ *
669
+ * `baseDirs` carries the config's own directory AND the project root: hosts launch
670
+ * the server from the project root, and three of the four host configs live in
671
+ * subdirectories (`.gemini/`, `.cursor/`, `.junie/mcp/`) while their pins are written
672
+ * root-relative — the config's directory alone is the right base only for the
673
+ * root-level `.mcp.json` (scoped leg round 2, D1).
674
+ *
675
+ * A path that does not resolve to an existing file under any base is a stale pin, not
676
+ * a registration we can attribute — it falls through and init appends its own entry.
677
+ */
678
+ function findMcpPackageRegistration(servers, baseDirs) {
679
+ for (const [name, entry] of Object.entries(servers)) {
680
+ if (entry === null || typeof entry !== 'object')
681
+ continue;
682
+ const { command, args } = entry;
683
+ const candidates = [];
684
+ if (typeof command === 'string')
685
+ candidates.push(command);
686
+ if (Array.isArray(args)) {
687
+ for (const arg of args)
688
+ if (typeof arg === 'string')
689
+ candidates.push(arg);
690
+ }
691
+ if (candidates.some((candidate) => MCP_PACKAGE_RE.test(candidate)))
692
+ return name;
693
+ // Path probe: only a Node-family command has a script ENTRYPOINT to attribute,
694
+ // and only its FIRST script-suffixed arg is that entrypoint (node's own CLI
695
+ // semantics). Later `.js` args are plugin/config inputs — attributing those
696
+ // would suppress a real registration over a file the server merely consumes.
697
+ if (typeof command !== 'string' || !isNodeCommand(command))
698
+ continue;
699
+ const entrypoint = Array.isArray(args)
700
+ ? args.find((arg) => typeof arg === 'string' && looksLikeScriptPath(arg))
701
+ : undefined;
702
+ if (entrypoint === undefined)
703
+ continue;
704
+ for (const baseDir of baseDirs) {
705
+ const resolved = path.resolve(baseDir, entrypoint);
706
+ if (!isExistingFile(resolved))
707
+ continue;
708
+ if (packageNameForScript(resolved) === MCP_PACKAGE)
709
+ return name;
710
+ }
711
+ }
712
+ return undefined;
713
+ }
714
+ export function scaffoldMcpConfig(filePath, serverEntry, opts) {
491
715
  try {
492
716
  if (!fs.existsSync(filePath)) {
493
717
  const dir = path.dirname(filePath);
@@ -522,6 +746,15 @@ export function scaffoldMcpConfig(filePath, serverEntry) {
522
746
  if ('totem' in servers) {
523
747
  return { action: 'skipped' };
524
748
  }
749
+ // Package-level dedup runs after the name check so the `totem`-key skip keeps its
750
+ // bare shape: `existingName` marks the different-name case the caller discloses.
751
+ const baseDirs = [path.dirname(filePath)];
752
+ if (opts?.projectRoot !== undefined)
753
+ baseDirs.push(opts.projectRoot);
754
+ const existingName = findMcpPackageRegistration(servers, baseDirs);
755
+ if (existingName !== undefined) {
756
+ return { action: 'skipped', existingName };
757
+ }
525
758
  servers.totem = serverEntry;
526
759
  parsed.mcpServers = servers;
527
760
  fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
@@ -535,9 +768,11 @@ export function scaffoldMcpConfig(filePath, serverEntry) {
535
768
  /**
536
769
  * Install the Universal AI Developer Baseline lessons into the lessons file.
537
770
  * Returns 'installed', 'exists' (already present), or 'skipped' (user declined).
538
- * In non-TTY mode (CI), defaults to installing without prompting.
771
+ * In non-interactive mode (CI, `--yes`, piped input), defaults to installing without
772
+ * prompting. `interactive` is threaded from init's single non-interactive predicate
773
+ * (mmnto-ai/totem#2601) and falls back to the bare TTY probe for standalone callers.
539
774
  */
540
- export async function installBaselineLessons(baselinePath, rl, ecosystems) {
775
+ export async function installBaselineLessons(baselinePath, rl, ecosystems, interactive = process.stdin.isTTY === true) {
541
776
  const { UNIVERSAL_BASELINE_LESSONS, UNIVERSAL_BASELINE_MARKER } = await import('../assets/universal-baseline.js');
542
777
  const { log } = await import('../ui.js');
543
778
  try {
@@ -547,12 +782,15 @@ export async function installBaselineLessons(baselinePath, rl, ecosystems) {
547
782
  existing.includes('<!-- totem:baseline -->'))
548
783
  return 'exists';
549
784
  }
550
- // In non-TTY mode (CI, piped input), default to installing
785
+ // Non-interactive (CI, `--yes`, piped input): take the (Y) default install.
551
786
  let declined = false;
552
- if (process.stdin.isTTY) {
787
+ if (interactive) {
553
788
  const answer = await rl.question('Install baseline lessons? (Y/n): ');
554
789
  declined = answer.trim().toLowerCase() === 'n' || answer.trim().toLowerCase() === 'no';
555
790
  }
791
+ else {
792
+ log.info('Totem', 'Non-interactive mode — installing baseline lessons.');
793
+ }
556
794
  if (declined)
557
795
  return 'skipped';
558
796
  const dir = path.dirname(baselinePath);
@@ -621,8 +859,31 @@ export function upgradeReflexes(content) {
621
859
  const startIdx = content.indexOf(REFLEX_START);
622
860
  const endIdx = content.indexOf(REFLEX_END);
623
861
  if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
624
- const before = content.slice(0, startIdx).replace(/\n+$/, '\n');
625
- const after = content.slice(endIdx + REFLEX_END.length);
862
+ // Both seams must be owned by exactly one party for regen to be a
863
+ // byte-fixpoint. Before-seam: normalize the prefix to end with exactly one
864
+ // `\n` (AI_PROMPT_BLOCK's own leading `\n` supplies the separating blank
865
+ // line); a prefix that is nothing but that added newline drops to '' so a
866
+ // marker-at-byte-0 file cannot grow a leading blank line per regen.
867
+ let before = content.slice(0, startIdx).replace(/(?:\r?\n)*$/, '\n');
868
+ if (before === '\n')
869
+ before = '';
870
+ let after = content.slice(endIdx + REFLEX_END.length);
871
+ // After-seam: AI_PROMPT_BLOCK already ends with the end marker's own line
872
+ // terminator, so the OLD block's terminator must not re-enter through
873
+ // `after` — that duplication accreted one blank line per regen into the
874
+ // after-end span that is contractually user content (#1890; byte-verified
875
+ // v7→v9, #2599). A tail of nothing but ASCII whitespace is totem's own
876
+ // accretion residue, not user content: normalize it away entirely so
877
+ // already-damaged files heal instead of freezing their residue.
878
+ if (/^[ \t\r\n]*$/.test(after)) {
879
+ after = '';
880
+ }
881
+ else if (after.startsWith('\r\n')) {
882
+ after = after.slice(2);
883
+ }
884
+ else if (after.startsWith('\n')) {
885
+ after = after.slice(1);
886
+ }
626
887
  return { content: before + AI_PROMPT_BLOCK + after, clean: true };
627
888
  }
628
889
  // Case 2: Legacy block (v1) — no boundaries, appended at end of file
@@ -666,6 +927,19 @@ function applyReflexUpgrade(filePath) {
666
927
  fs.writeFileSync(filePath, updated, 'utf-8');
667
928
  return clean;
668
929
  }
930
+ /**
931
+ * Resolve the AI-tool selection prompt's answer. Anything that is not an explicit
932
+ * `none` / `select` is the Enter default, `all` — which is also the answer the
933
+ * non-interactive path takes without raising the prompt (mmnto-ai/totem#2601).
934
+ */
935
+ export function resolveToolSelection(answer) {
936
+ const trimmed = answer.trim().toLowerCase();
937
+ if (trimmed === 'none')
938
+ return 'none';
939
+ if (trimmed === 'select')
940
+ return 'select';
941
+ return 'all';
942
+ }
669
943
  export async function initCommand(options) {
670
944
  // ─── Doctrine pin wiring (mmnto-ai/totem#2088, Proposal 292 S1) ───
671
945
  // Self-contained non-interactive path: point orient.parityManifest at the
@@ -773,6 +1047,11 @@ export default {
773
1047
  const existingConfig = CONFIG_FILES.map((f) => path.join(cwd, f)).find((p) => fs.existsSync(p));
774
1048
  const configExists = !!existingConfig;
775
1049
  const rl = readline.createInterface({ input, output });
1050
+ // The ONE non-interactive predicate (mmnto-ai/totem#2601). Every `rl.question` site
1051
+ // below is guarded by it: a prompt raised without a TTY never settles, and init's
1052
+ // mutations land BEFORE the prompts, so a half-run reads as a silent no-op. Each
1053
+ // guarded site logs the default it took.
1054
+ const interactive = process.stdin.isTTY === true && options?.yes !== true;
776
1055
  const summary = [];
777
1056
  try {
778
1057
  printBanner();
@@ -823,6 +1102,11 @@ export default {
823
1102
  else if (embeddingTier === 'gemini') {
824
1103
  log.info('Totem', `Detected ${bold('GEMINI_API_KEY')} in environment. Using Gemini embeddings (single-key DX).`);
825
1104
  }
1105
+ else if (!interactive) {
1106
+ // Non-interactive: take the Enter default (Lite tier). The other two answers
1107
+ // MUTATE (an .env write, an Ollama pin) and must not fire unattended.
1108
+ log.info('Totem', 'Non-interactive mode — no embedding key entered; starting in Lite tier.');
1109
+ }
826
1110
  else {
827
1111
  // No key detected — prompt the user
828
1112
  const answer = await rl.question('Enter your OpenAI API key, type "ollama" for a local model, or press Enter for Lite tier: ');
@@ -929,7 +1213,7 @@ export default {
929
1213
  // --- Baseline lessons (core + detected ecosystem packs) ---
930
1214
  const baselinePath = path.join(lessonsDir, 'baseline.md');
931
1215
  const detectedEcosystems = detectProject(cwd).ecosystems;
932
- const baselineResult = await installBaselineLessons(baselinePath, rl, detectedEcosystems);
1216
+ const baselineResult = await installBaselineLessons(baselinePath, rl, detectedEcosystems, interactive);
933
1217
  if (baselineResult === 'installed') {
934
1218
  const extraPacks = detectedEcosystems.filter((e) => e !== 'javascript');
935
1219
  const packLabel = extraPacks.length > 0 ? ` + ${extraPacks.join(', ')}` : '';
@@ -994,31 +1278,38 @@ export default {
994
1278
  if (detectedTools.length > 0) {
995
1279
  const toolNames = detectedTools.map((t) => t.name).join(', ');
996
1280
  log.info('Totem', `Detected AI tools: ${bold(toolNames)}`);
997
- const toolAnswer = await rl.question('Which tools should Totem configure? [all/none/select] (default: all): ');
998
1281
  let selectedTools;
999
- const trimmed = toolAnswer.trim().toLowerCase();
1000
- if (trimmed === 'none') {
1001
- selectedTools = [];
1282
+ if (!interactive) {
1283
+ // Non-interactive: the Enter default is 'all'. The per-tool `select` loop is
1284
+ // unreachable from here — it is entered only by an interactive 'select'.
1285
+ selectedTools = detectedTools;
1286
+ log.info('Totem', `Non-interactive mode — configuring all detected tools (${toolNames}).`);
1002
1287
  }
1003
- else if (trimmed === 'select') {
1004
- selectedTools = [];
1005
- for (const tool of detectedTools) {
1006
- const pick = await rl.question(` Configure ${tool.name}? (Y/n): `);
1007
- if (pick.trim().toLowerCase() !== 'n' && pick.trim().toLowerCase() !== 'no') {
1008
- selectedTools.push(tool);
1288
+ else {
1289
+ const toolAnswer = await rl.question('Which tools should Totem configure? [all/none/select] (default: all): ');
1290
+ const selection = resolveToolSelection(toolAnswer);
1291
+ if (selection === 'none') {
1292
+ selectedTools = [];
1293
+ }
1294
+ else if (selection === 'select') {
1295
+ selectedTools = [];
1296
+ for (const tool of detectedTools) {
1297
+ const pick = await rl.question(` Configure ${tool.name}? (Y/n): `);
1298
+ if (pick.trim().toLowerCase() !== 'n' && pick.trim().toLowerCase() !== 'no') {
1299
+ selectedTools.push(tool);
1300
+ }
1009
1301
  }
1010
1302
  }
1011
- }
1012
- else {
1013
- // 'all' or Enter (default)
1014
- selectedTools = detectedTools;
1303
+ else {
1304
+ selectedTools = detectedTools;
1305
+ }
1015
1306
  }
1016
1307
  // --- MCP scaffolding for selected tools ---
1017
1308
  for (const tool of selectedTools) {
1018
1309
  if (!tool.mcpPath || !tool.serverEntry)
1019
1310
  continue;
1020
1311
  const filePath = path.join(cwd, tool.mcpPath);
1021
- const result = scaffoldMcpConfig(filePath, tool.serverEntry);
1312
+ const result = scaffoldMcpConfig(filePath, tool.serverEntry, { projectRoot: cwd });
1022
1313
  if (result.err) {
1023
1314
  log.error('Totem Error', result.err); // totem-ignore — result.err is internal scaffolding error, not LLM output
1024
1315
  console.error(`To fix this, add the following manually to your ${tool.mcpPath} under "mcpServers":\n`);
@@ -1030,6 +1321,15 @@ export default {
1030
1321
  else if (result.action === 'merged') {
1031
1322
  summary.push({ file: tool.mcpPath, action: `Added totem to mcpServers` });
1032
1323
  }
1324
+ else if (result.existingName !== undefined) {
1325
+ // Package-level dedup hit (mmnto-ai/totem#2601): no append, but the skip is
1326
+ // disclosed rather than silent — the user's registration is under a name
1327
+ // they chose, and a silent no-op reads as init having done nothing.
1328
+ summary.push({
1329
+ file: tool.mcpPath,
1330
+ action: `Totem MCP already registered as \`${result.existingName}\` — no duplicate added`,
1331
+ });
1332
+ }
1033
1333
  }
1034
1334
  // --- Reflex injection & upgrade for selected tools ---
1035
1335
  const outdatedFiles = [];
@@ -1056,13 +1356,14 @@ export default {
1056
1356
  const fileList = outdatedFiles.map((f) => f.tool.reflexFile).join(', ');
1057
1357
  log.warn('Totem', `Outdated reflexes found in: ${bold(fileList)}`);
1058
1358
  let shouldUpgrade = false;
1059
- if (process.stdin.isTTY) {
1359
+ if (interactive) {
1060
1360
  const answer = await rl.question(`Upgrade reflexes to v${REFLEX_VERSION}? (Y/n): `);
1061
1361
  shouldUpgrade =
1062
1362
  answer.trim().toLowerCase() !== 'n' && answer.trim().toLowerCase() !== 'no';
1063
1363
  }
1064
1364
  else {
1065
- // Non-TTY (CI/scripted): auto-upgrade to match baseline lessons behavior
1365
+ // Non-interactive (CI/scripted/`--yes`): take the (Y) default, matching
1366
+ // baseline lessons behavior.
1066
1367
  shouldUpgrade = true;
1067
1368
  log.info('Totem', 'Non-interactive mode — auto-upgrading reflexes.');
1068
1369
  }
@@ -1122,6 +1423,16 @@ export default {
1122
1423
  action: result.summaryActionOverride ?? `Merged ${tool.name} hook into existing config`,
1123
1424
  });
1124
1425
  }
1426
+ else if ((result.action === 'skipped' || result.action === 'exists') &&
1427
+ result.summaryActionOverride) {
1428
+ // A no-op an installer chose to DISCLOSE (mmnto-ai/totem#2601 vendor-hook
1429
+ // guard). Bare `skipped`/`exists` stay silent; an override means the user
1430
+ // must know why the file was left alone. `exists` carries one too: a
1431
+ // custom `.js` coexisting with an already-canonical managed `.cjs` is the
1432
+ // same live double-fire hazard as the drift-repaired case, and the fact
1433
+ // that nothing needed rewriting must not silence it.
1434
+ summary.push({ file: result.file, action: result.summaryActionOverride });
1435
+ }
1125
1436
  }
1126
1437
  }
1127
1438
  }
@@ -1164,7 +1475,10 @@ export default {
1164
1475
  // 'exists' (already canonical) and 'missing' (no package.json) are silent no-ops.
1165
1476
  // --- Always run: enforcement hooks (pre-commit + pre-push) ---
1166
1477
  const hookTier = options?.strict ? 'strict' : undefined;
1167
- const enforcement = await installEnforcementHooks(cwd, rl, { tier: hookTier });
1478
+ const enforcement = await installEnforcementHooks(cwd, rl, {
1479
+ tier: hookTier,
1480
+ interactive,
1481
+ });
1168
1482
  if (enforcement.preCommit === 'installed' || enforcement.preCommit === 'appended') {
1169
1483
  summary.push({
1170
1484
  file: '.git/hooks/pre-commit',
@@ -1190,7 +1504,7 @@ export default {
1190
1504
  });
1191
1505
  }
1192
1506
  // --- Always run: post-merge git hook ---
1193
- await installPostMergeHook(cwd, rl);
1507
+ await installPostMergeHook(cwd, rl, { interactive });
1194
1508
  // --- Always run: .gitignore ---
1195
1509
  const gitignorePath = path.join(cwd, '.gitignore');
1196
1510
  if (fs.existsSync(gitignorePath)) {
@@ -1220,8 +1534,19 @@ export default {
1220
1534
  const { scanCursorInstructions } = await import('@mmnto/totem');
1221
1535
  const cursorInstructions = scanCursorInstructions(cwd);
1222
1536
  if (cursorInstructions.length > 0) {
1223
- const answer = await rl.question(`\nFound ${cursorInstructions.length} existing AI rule(s) (.cursorrules / .mdc). Compile into deterministic invariants? (Y/n): `);
1224
- if (answer.trim().toLowerCase() !== 'n' && answer.trim().toLowerCase() !== 'no') {
1537
+ // The one non-interactive default that is NOT the Enter default: compiling is
1538
+ // an LLM-driven rewrite of compiled-rules.json, so it stays opt-in and the
1539
+ // manual command is disclosed instead (mmnto-ai/totem#2601).
1540
+ let shouldCompile = false;
1541
+ if (interactive) {
1542
+ const answer = await rl.question(`\nFound ${cursorInstructions.length} existing AI rule(s) (.cursorrules / .mdc). Compile into deterministic invariants? (Y/n): `);
1543
+ shouldCompile =
1544
+ answer.trim().toLowerCase() !== 'n' && answer.trim().toLowerCase() !== 'no';
1545
+ }
1546
+ else {
1547
+ log.info('Totem', `Non-interactive mode — found ${cursorInstructions.length} existing AI rule(s) (.cursorrules / .mdc); not compiling. Run \`totem compile --from-cursor\` to compile them.`);
1548
+ }
1549
+ if (shouldCompile) {
1225
1550
  try {
1226
1551
  const { compileCommand } = await import('./compile.js');
1227
1552
  await compileCommand({ fromCursor: true });