@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
@@ -2,13 +2,13 @@ import { spawnSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
6
6
  import { AUTO_CLOSE_REGEX_SOURCE, LedgerEventSchema, resolveSelfAgents } from '@mmnto/totem';
7
7
  import { UNIVERSAL_BASELINE_LESSONS, UNIVERSAL_BASELINE_MARKDOWN, UNIVERSAL_BASELINE_MARKER, } from '../assets/universal-baseline.js';
8
8
  import { cleanTmpDir } from '../test-utils.js';
9
- import { buildNpxCommand, detectEmbeddingTier, detectReflexStatus, generateConfig, initCommand, installBaselineLessons, OLLAMA_FLOOR_DEFAULT_BASE_URL, probeOllamaFloor, REFLEX_VERSION, scaffoldClaudeHooks, scaffoldClaudeSessionStart, scaffoldClaudeSkill, scaffoldClaudeWriteShield, scaffoldFile, scaffoldMcpConfig, upgradeReflexes, } from './init.js';
9
+ import { buildNpxCommand, detectEmbeddingTier, detectReflexStatus, findUnownedHookSibling, generateConfig, initCommand, installBaselineLessons, installGeminiHooks, OLLAMA_FLOOR_DEFAULT_BASE_URL, probeOllamaFloor, REFLEX_VERSION, resolveToolSelection, scaffoldClaudeHooks, scaffoldClaudeSessionStart, scaffoldClaudeSkill, scaffoldClaudeWriteShield, scaffoldFile, scaffoldMcpConfig, upgradeReflexes, } from './init.js';
10
10
  import { detectProject } from './init-detect.js';
11
- import { BARE_REF_REGEX_SOURCE, CLAUDE_PREWRITESHIELD, CLAUDE_PREWRITESHIELD_ENTRY, CLAUDE_SESSION_START, CLAUDE_SESSION_START_ENTRY, DISTRIBUTED_CLAUDE_SKILLS, GEMINI_BEFORE_TOOL, GEMINI_SESSION_START, generateConfigForFormat, REVIEW_LOOP_SKILL_CONTENT, REVIEW_REPLY_SKILL_CONTENT, SIGNOFF_SKILL_CONTENT, SIGNON_SKILL_CONTENT, SKILL_MARKER_END, SKILL_MARKER_START, } from './init-templates.js';
11
+ import { AI_PROMPT_BLOCK, BARE_REF_REGEX_SOURCE, CLAUDE_PREWRITESHIELD, CLAUDE_PREWRITESHIELD_ENTRY, CLAUDE_SESSION_START, CLAUDE_SESSION_START_ENTRY, DISTRIBUTED_CLAUDE_SKILLS, GEMINI_BEFORE_TOOL, GEMINI_SESSION_START, generateConfigForFormat, REVIEW_LOOP_SKILL_CONTENT, REVIEW_REPLY_SKILL_CONTENT, SIGNOFF_SKILL_CONTENT, SIGNON_SKILL_CONTENT, SKILL_MARKER_END, SKILL_MARKER_START, TOTEM_FILE_END, TOTEM_FILE_MARKER, } from './init-templates.js';
12
12
  const SERVER_ENTRY = { type: 'stdio', command: 'npx', args: ['-y', '@mmnto/mcp'] };
13
13
  describe('buildNpxCommand', () => {
14
14
  it('returns cmd /c npx on Windows', () => {
@@ -100,6 +100,159 @@ describe('scaffoldMcpConfig', () => {
100
100
  expect(result.err).toContain('invalid JSON');
101
101
  expect(result.err).toContain('at position'); // includes original parse error detail
102
102
  });
103
+ // ─── Dedup keys on the registered package, not the entry name (#2601) ───
104
+ it('skips when @mmnto/mcp is already registered under a different name', () => {
105
+ const filePath = path.join(tmpDir, '.mcp.json');
106
+ const existing = {
107
+ mcpServers: {
108
+ 'totem-dev': { command: 'npx', args: ['-y', '@mmnto/mcp', '--cwd', '../totem'] },
109
+ },
110
+ };
111
+ const before = JSON.stringify(existing, null, 2);
112
+ fs.writeFileSync(filePath, before, 'utf-8');
113
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
114
+ expect(result).toEqual({ action: 'skipped', existingName: 'totem-dev' });
115
+ // The file is not rewritten at all — byte-identical, no reformat, no third entry.
116
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(before);
117
+ });
118
+ it('detects a registration carried on the command string', () => {
119
+ const filePath = path.join(tmpDir, '.mcp.json');
120
+ fs.writeFileSync(filePath, JSON.stringify({
121
+ mcpServers: {
122
+ 'totem-strategy': { command: 'node ./node_modules/@mmnto/mcp/dist/index.js' },
123
+ },
124
+ }, null, 2), 'utf-8');
125
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
126
+ expect(result).toEqual({ action: 'skipped', existingName: 'totem-strategy' });
127
+ });
128
+ it('detects a path-pinned registration that never spells the package name', () => {
129
+ // This repo's OWN .mcp.json shape: `node ./packages/mcp/dist/index.js`. No string
130
+ // in the entry contains `@mmnto/mcp` — only the file on disk knows what it is.
131
+ const distDir = path.join(tmpDir, 'packages', 'mcp', 'dist');
132
+ fs.mkdirSync(distDir, { recursive: true });
133
+ fs.writeFileSync(path.join(distDir, 'index.js'), '', 'utf-8');
134
+ fs.writeFileSync(path.join(tmpDir, 'packages', 'mcp', 'package.json'), JSON.stringify({ name: '@mmnto/mcp' }, null, 2), 'utf-8');
135
+ const filePath = path.join(tmpDir, '.mcp.json');
136
+ const before = JSON.stringify({
137
+ mcpServers: {
138
+ 'totem-dev': { command: 'node', args: ['./packages/mcp/dist/index.js', '--cwd', '.'] },
139
+ },
140
+ }, null, 2) + '\n';
141
+ fs.writeFileSync(filePath, before, 'utf-8');
142
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
143
+ expect(result).toEqual({ action: 'skipped', existingName: 'totem-dev' });
144
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(before);
145
+ });
146
+ it('detects a root-relative pin from a SUBDIRECTORY host config via projectRoot (leg D1)', () => {
147
+ // Three of the four host configs live in subdirectories (.gemini/, .cursor/,
148
+ // .junie/mcp/) while their pins are written relative to the PROJECT ROOT — the
149
+ // config's own directory is the wrong base for them.
150
+ const distDir = path.join(tmpDir, 'packages', 'mcp', 'dist');
151
+ fs.mkdirSync(distDir, { recursive: true });
152
+ fs.writeFileSync(path.join(distDir, 'index.js'), '', 'utf-8');
153
+ fs.writeFileSync(path.join(tmpDir, 'packages', 'mcp', 'package.json'), JSON.stringify({ name: '@mmnto/mcp' }, null, 2), 'utf-8');
154
+ const geminiDir = path.join(tmpDir, '.gemini');
155
+ fs.mkdirSync(geminiDir, { recursive: true });
156
+ const filePath = path.join(geminiDir, 'settings.json');
157
+ const before = JSON.stringify({
158
+ mcpServers: {
159
+ 'totem-dev': { command: 'node', args: ['./packages/mcp/dist/index.js'] },
160
+ },
161
+ }, null, 2) + '\n';
162
+ fs.writeFileSync(filePath, before, 'utf-8');
163
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY, { projectRoot: tmpDir });
164
+ expect(result).toEqual({ action: 'skipped', existingName: 'totem-dev' });
165
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(before);
166
+ });
167
+ it('a directory-shaped arg never starts an identity walk (leg D2)', () => {
168
+ // `--cwd ./` resolves to a DIRECTORY; walking from a directory's parent could
169
+ // escape the repo and attribute an unrelated server to an ancestor package.
170
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: '@mmnto/mcp' }, null, 2), 'utf-8');
171
+ const subDir = path.join(tmpDir, 'consumer');
172
+ fs.mkdirSync(subDir, { recursive: true });
173
+ const filePath = path.join(subDir, '.mcp.json');
174
+ fs.writeFileSync(filePath, JSON.stringify({ mcpServers: { unrelated: { command: 'some-server', args: ['--cwd', './'] } } }, null, 2), 'utf-8');
175
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY, { projectRoot: subDir });
176
+ expect(result).toEqual({ action: 'merged' });
177
+ });
178
+ it("a non-Node command's script argument never starts an identity walk (Greptile P2)", () => {
179
+ // An unrelated server consuming a totem-owned .js as a plugin/config ARG is not
180
+ // a registration — only a Node-family command's entrypoint is attributable.
181
+ const distDir = path.join(tmpDir, 'packages', 'mcp', 'dist');
182
+ fs.mkdirSync(distDir, { recursive: true });
183
+ fs.writeFileSync(path.join(distDir, 'index.js'), '', 'utf-8');
184
+ fs.writeFileSync(path.join(tmpDir, 'packages', 'mcp', 'package.json'), JSON.stringify({ name: '@mmnto/mcp' }, null, 2), 'utf-8');
185
+ const filePath = path.join(tmpDir, '.mcp.json');
186
+ fs.writeFileSync(filePath, JSON.stringify({
187
+ mcpServers: {
188
+ helper: { command: 'other-server', args: ['--plugin', './packages/mcp/dist/index.js'] },
189
+ },
190
+ }, null, 2), 'utf-8');
191
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
192
+ expect(result).toEqual({ action: 'merged' });
193
+ });
194
+ it('a dotted near-miss package name is not a registration (leg D6)', () => {
195
+ // npm scoped names may contain `.` — `@mmnto/mcp.js` is a different package.
196
+ const filePath = path.join(tmpDir, '.mcp.json');
197
+ fs.writeFileSync(filePath, JSON.stringify({ mcpServers: { other: { command: 'npx', args: ['-y', '@mmnto/mcp.js'] } } }, null, 2), 'utf-8');
198
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
199
+ expect(result).toEqual({ action: 'merged' });
200
+ });
201
+ it('merges when a path pin is stale and no name matches', () => {
202
+ // The pinned script does not exist, so there is no package identity to read and
203
+ // nothing to attribute the entry to — init registers its own.
204
+ const filePath = path.join(tmpDir, '.mcp.json');
205
+ fs.writeFileSync(filePath, JSON.stringify({
206
+ mcpServers: {
207
+ 'totem-dev': { command: 'node', args: ['./packages/mcp/dist/index.js'] },
208
+ },
209
+ }, null, 2), 'utf-8');
210
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
211
+ expect(result).toEqual({ action: 'merged' });
212
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
213
+ expect(content.mcpServers.totem).toEqual(SERVER_ENTRY);
214
+ });
215
+ it('merges when a mention sits outside command/args', () => {
216
+ // The probe is scoped to the fields that INVOKE something. A nested descriptor or
217
+ // an `env` doc-string that merely NAMES the package is not a registration — the
218
+ // old whole-entry serialization scan false-positived on exactly this.
219
+ const filePath = path.join(tmpDir, '.mcp.json');
220
+ fs.writeFileSync(filePath, JSON.stringify({
221
+ mcpServers: {
222
+ memory: { type: 'stdio', run: { pkg: '@mmnto/mcp', mode: 'local' } },
223
+ notes: {
224
+ command: 'notes-server',
225
+ env: { DOC: 'replaced by @mmnto/mcp in 2026' },
226
+ },
227
+ },
228
+ }, null, 2), 'utf-8');
229
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
230
+ expect(result).toEqual({ action: 'merged' });
231
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
232
+ expect(content.mcpServers.totem).toEqual(SERVER_ENTRY);
233
+ });
234
+ it('merges when only unrelated servers are registered', () => {
235
+ const filePath = path.join(tmpDir, '.mcp.json');
236
+ fs.writeFileSync(filePath, JSON.stringify({
237
+ mcpServers: {
238
+ github: { command: 'gh', args: ['mcp'] },
239
+ // Near-miss: a DIFFERENT package whose name extends the totem MCP's.
240
+ // A bare substring probe would call this a registration and skip.
241
+ other: { command: 'npx', args: ['-y', '@mmnto/mcp-experimental'] },
242
+ },
243
+ }, null, 2), 'utf-8');
244
+ const result = scaffoldMcpConfig(filePath, SERVER_ENTRY);
245
+ expect(result).toEqual({ action: 'merged' });
246
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
247
+ expect(content.mcpServers.totem).toEqual(SERVER_ENTRY);
248
+ expect(content.mcpServers.github).toEqual({ command: 'gh', args: ['mcp'] });
249
+ });
250
+ it('keeps the bare skip shape when the totem key itself is present', () => {
251
+ const filePath = path.join(tmpDir, '.mcp.json');
252
+ fs.writeFileSync(filePath, JSON.stringify({ mcpServers: { totem: SERVER_ENTRY } }, null, 2), 'utf-8');
253
+ // The name check runs first, so `existingName` marks ONLY the different-name case.
254
+ expect(scaffoldMcpConfig(filePath, SERVER_ENTRY)).toEqual({ action: 'skipped' });
255
+ });
103
256
  });
104
257
  describe('scaffoldFile', () => {
105
258
  let tmpDir;
@@ -330,6 +483,135 @@ describe('Gemini hook scaffolding', () => {
330
483
  expect(result).toEqual({ action: 'skipped' });
331
484
  });
332
485
  });
486
+ describe('vendor-hook managed-marker guard (mmnto-ai/totem#2601)', () => {
487
+ let tmpDir;
488
+ beforeEach(() => {
489
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-vendor-guard-'));
490
+ });
491
+ afterEach(() => {
492
+ cleanTmpDir(tmpDir);
493
+ });
494
+ const CUSTOM_HOOK = '// strategy#482 parity hook\nconsole.log("custom orientation");\n';
495
+ it('findUnownedHookSibling reports an unowned .js sibling', () => {
496
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
497
+ fs.mkdirSync(hooksDir, { recursive: true });
498
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), CUSTOM_HOOK, 'utf-8');
499
+ expect(findUnownedHookSibling(path.join(hooksDir, 'SessionStart.cjs'))).toBe('SessionStart.js');
500
+ });
501
+ it('findUnownedHookSibling ignores a marker-headed sibling and an absent one', () => {
502
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
503
+ fs.mkdirSync(hooksDir, { recursive: true });
504
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), GEMINI_SESSION_START, 'utf-8');
505
+ // Marker-headed → the legacy `.js`→`.cjs` migration case, not a custom hook.
506
+ expect(findUnownedHookSibling(path.join(hooksDir, 'SessionStart.cjs'))).toBeUndefined();
507
+ expect(findUnownedHookSibling(path.join(hooksDir, 'BeforeTool.cjs'))).toBeUndefined();
508
+ });
509
+ it('findUnownedHookSibling only answers for a .cjs target', () => {
510
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
511
+ fs.mkdirSync(hooksDir, { recursive: true });
512
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), CUSTOM_HOOK, 'utf-8');
513
+ // A non-`.cjs` target has no `<stem>.js` TWIN to guard against — it IS the `.js`.
514
+ // Dropping the extension check would make the probe compare the file to itself.
515
+ expect(findUnownedHookSibling(path.join(hooksDir, 'SessionStart.js'))).toBeUndefined();
516
+ });
517
+ it('withholds the managed .cjs when a custom same-stem .js is present', async () => {
518
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
519
+ fs.mkdirSync(hooksDir, { recursive: true });
520
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), CUSTOM_HOOK, 'utf-8');
521
+ const results = await installGeminiHooks(tmpDir);
522
+ // No generic twin dropped beside the custom hook, and the custom hook is untouched.
523
+ expect(fs.existsSync(path.join(hooksDir, 'SessionStart.cjs'))).toBe(false);
524
+ expect(fs.readFileSync(path.join(hooksDir, 'SessionStart.js'), 'utf-8')).toBe(CUSTOM_HOOK);
525
+ const disclosed = results.find((r) => r.file.endsWith('SessionStart.cjs'));
526
+ expect(disclosed?.action).toBe('skipped');
527
+ expect(disclosed?.summaryActionOverride).toContain('custom SessionStart.js present');
528
+ expect(disclosed?.summaryActionOverride).toContain('remove or rename it');
529
+ // The guard is per-target: the unguarded sibling hook still installs.
530
+ expect(fs.existsSync(path.join(hooksDir, 'BeforeTool.cjs'))).toBe(true);
531
+ });
532
+ it('leaves the legacy marker-headed .js migration path unchanged', async () => {
533
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
534
+ fs.mkdirSync(hooksDir, { recursive: true });
535
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), GEMINI_SESSION_START, 'utf-8');
536
+ const results = await installGeminiHooks(tmpDir);
537
+ // Migrated, not guarded: successor written, legacy removed.
538
+ expect(fs.existsSync(path.join(hooksDir, 'SessionStart.js'))).toBe(false);
539
+ expect(fs.readFileSync(path.join(hooksDir, 'SessionStart.cjs'), 'utf-8')).toBe(GEMINI_SESSION_START);
540
+ expect(results.some((r) => r.summaryActionOverride?.includes('Migrated legacy Gemini'))).toBe(true);
541
+ expect(results.some((r) => r.summaryActionOverride?.includes('custom SessionStart.js'))).toBe(false);
542
+ });
543
+ it('drift-repairs an existing managed .cjs and discloses the coexistence', async () => {
544
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
545
+ fs.mkdirSync(hooksDir, { recursive: true });
546
+ // The re-init shape: the twin is ALREADY live. Withholding here would suppress
547
+ // drift-repair of a file that fires anyway and report it as "not installed".
548
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), CUSTOM_HOOK, 'utf-8');
549
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.cjs'), `${TOTEM_FILE_MARKER}\nstale body\n${TOTEM_FILE_END}\n`, 'utf-8');
550
+ const results = await installGeminiHooks(tmpDir);
551
+ // Repaired to canonical, and the user's hook is still untouched.
552
+ expect(fs.readFileSync(path.join(hooksDir, 'SessionStart.cjs'), 'utf-8')).toBe(GEMINI_SESSION_START);
553
+ expect(fs.readFileSync(path.join(hooksDir, 'SessionStart.js'), 'utf-8')).toBe(CUSTOM_HOOK);
554
+ const disclosed = results.find((r) => r.file.endsWith('SessionStart.cjs'));
555
+ expect(disclosed?.action).toBe('merged');
556
+ expect(disclosed?.summaryActionOverride).toContain('Custom SessionStart.js coexists with the managed SessionStart.cjs');
557
+ expect(disclosed?.summaryActionOverride).toContain('both may fire');
558
+ // The false "not installed" claim is gone.
559
+ expect(disclosed?.summaryActionOverride).not.toContain('totem hook not installed');
560
+ });
561
+ it('a CANONICAL managed .cjs beside an unowned .js reports exists + coexistence (leg D4)', async () => {
562
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
563
+ fs.mkdirSync(hooksDir, { recursive: true });
564
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), CUSTOM_HOOK, 'utf-8');
565
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.cjs'), GEMINI_SESSION_START, 'utf-8');
566
+ const results = await installGeminiHooks(tmpDir);
567
+ const disclosed = results.find((r) => r.file.endsWith('SessionStart.cjs'));
568
+ // Nothing needed rewriting, but the coexistence hazard is unconditional — the
569
+ // `exists` action must still carry the disclosure or the summary swallows it.
570
+ expect(disclosed?.action).toBe('exists');
571
+ expect(disclosed?.summaryActionOverride).toContain('coexists with the managed');
572
+ });
573
+ it('a USER-owned .cjs beside an unowned .js gets the truthful not-installed line (leg D3)', async () => {
574
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
575
+ fs.mkdirSync(hooksDir, { recursive: true });
576
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), CUSTOM_HOOK, 'utf-8');
577
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.cjs'), '// MY OWN cjs\n', 'utf-8');
578
+ const results = await installGeminiHooks(tmpDir);
579
+ const disclosed = results.find((r) => r.file.endsWith('SessionStart.cjs'));
580
+ // Totem installed nothing: the line must not claim a "managed" .cjs exists or
581
+ // steer the user toward deleting their own file.
582
+ expect(disclosed?.summaryActionOverride).toContain("neither is totem's");
583
+ expect(disclosed?.summaryActionOverride).not.toContain('managed');
584
+ expect(fs.readFileSync(path.join(hooksDir, 'SessionStart.cjs'), 'utf-8')).toBe('// MY OWN cjs\n');
585
+ });
586
+ it('skips an unreadable SUCCESSOR rather than crashing the migration (CR round 1)', async () => {
587
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
588
+ fs.mkdirSync(hooksDir, { recursive: true });
589
+ // Marker-headed bounded legacy .js (migratable) + a DIRECTORY squatting the
590
+ // successor path: the successor read previously threw EISDIR mid-migration.
591
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), `${TOTEM_FILE_MARKER}\nlegacy body\n${TOTEM_FILE_END}\n`, 'utf-8');
592
+ fs.mkdirSync(path.join(hooksDir, 'SessionStart.cjs'));
593
+ const results = await installGeminiHooks(tmpDir);
594
+ const disclosed = results.find((r) => r.summaryActionOverride !== undefined &&
595
+ /successor.*could not be read/.test(r.summaryActionOverride));
596
+ expect(disclosed).toBeDefined();
597
+ // Both files exactly as found — no partial migration.
598
+ expect(fs.statSync(path.join(hooksDir, 'SessionStart.cjs')).isDirectory()).toBe(true);
599
+ expect(fs.existsSync(path.join(hooksDir, 'SessionStart.js'))).toBe(true);
600
+ });
601
+ it('skips an unreadable legacy hook rather than crashing the run', async () => {
602
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
603
+ fs.mkdirSync(hooksDir, { recursive: true });
604
+ // A DIRECTORY sharing the legacy name: `readFileSync` throws EISDIR. The ownership
605
+ // gate previously read it unguarded and took init down mid-run.
606
+ fs.mkdirSync(path.join(hooksDir, 'SessionStart.js'));
607
+ const results = await installGeminiHooks(tmpDir);
608
+ const disclosed = results.find((r) => r.file.endsWith('SessionStart.js') && r.action === 'skipped');
609
+ expect(disclosed?.summaryActionOverride).toContain('could not be read');
610
+ expect(disclosed?.summaryActionOverride).toContain('left in place');
611
+ // The path is left exactly as found — no clobber, no partial migration.
612
+ expect(fs.statSync(path.join(hooksDir, 'SessionStart.js')).isDirectory()).toBe(true);
613
+ });
614
+ });
333
615
  describe('detectEmbeddingTier', () => {
334
616
  let tmpDir;
335
617
  const SAVED_OPENAI = process.env['OPENAI_API_KEY'];
@@ -581,6 +863,37 @@ describe('installBaselineLessons', () => {
581
863
  const content = fs.readFileSync(lessonsPath, 'utf-8');
582
864
  expect(content).toContain(UNIVERSAL_BASELINE_MARKER);
583
865
  });
866
+ // ─── Threaded non-interactive predicate (mmnto-ai/totem#2601) ───
867
+ it('raises no prompt when the threaded interactive flag is false, even on a TTY', async () => {
868
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
869
+ // A prompt that would HANG a real `--yes` run: reaching it at all fails the test.
870
+ const explodingRl = {
871
+ question: async () => {
872
+ throw new Error('prompt raised in non-interactive mode');
873
+ },
874
+ };
875
+ const result = await installBaselineLessons(lessonsPath, explodingRl, undefined, false);
876
+ expect(result).toBe('installed');
877
+ expect(fs.readFileSync(lessonsPath, 'utf-8')).toContain(UNIVERSAL_BASELINE_MARKER);
878
+ });
879
+ it('honors the threaded interactive flag over the bare TTY probe', async () => {
880
+ // isTTY is false (beforeEach), but the caller says interactive — the prompt runs.
881
+ const result = await installBaselineLessons(lessonsPath, makeMockRl('n'), undefined, true);
882
+ expect(result).toBe('skipped');
883
+ expect(fs.existsSync(lessonsPath)).toBe(false);
884
+ });
885
+ });
886
+ describe('resolveToolSelection (mmnto-ai/totem#2601)', () => {
887
+ it('maps the explicit answers', () => {
888
+ expect(resolveToolSelection('none')).toBe('none');
889
+ expect(resolveToolSelection(' SELECT ')).toBe('select');
890
+ expect(resolveToolSelection('all')).toBe('all');
891
+ });
892
+ it('treats Enter and anything unrecognized as the "all" default', () => {
893
+ expect(resolveToolSelection('')).toBe('all');
894
+ expect(resolveToolSelection(' ')).toBe('all');
895
+ expect(resolveToolSelection('yes please')).toBe('all');
896
+ });
584
897
  });
585
898
  // ─── Reflex versioning ──────────────────────────────────────
586
899
  const LEGACY_BLOCK = `
@@ -700,6 +1013,54 @@ describe('upgradeReflexes', () => {
700
1013
  expect(updated).toContain('<!-- totem:reflexes:start -->');
701
1014
  expect(updated).toContain('<!-- totem:reflexes:end -->');
702
1015
  });
1016
+ it('regen is byte-idempotent — no blank-line accretion at the end-marker seam (#2599)', () => {
1017
+ const content = '# CLAUDE.md\n\nMy custom rules.\n\n<!-- totem:reflexes:start -->\n<!-- totem:reflexes:version:1 -->\nOld reflexes\n<!-- totem:reflexes:end -->\n\n## My Section\nUser content\n';
1018
+ const once = upgradeReflexes(content).content;
1019
+ const twice = upgradeReflexes(once).content;
1020
+ expect(twice).toBe(once);
1021
+ });
1022
+ it('a current-shape file with user content after the end marker regens byte-identically', () => {
1023
+ const content = '# CLAUDE.md\n' + AI_PROMPT_BLOCK + '\n## My Custom Section\n\nDo not delete this!\n';
1024
+ expect(upgradeReflexes(content).content).toBe(content);
1025
+ });
1026
+ it('heals an already-accreted pure-whitespace tail to the canonical single terminator', () => {
1027
+ const damaged = '# CLAUDE.md\n\n<!-- totem:reflexes:start -->\n<!-- totem:reflexes:version:9 -->\nOld\n<!-- totem:reflexes:end -->\n\n\n';
1028
+ const { content: updated } = upgradeReflexes(damaged);
1029
+ expect(updated.endsWith('<!-- totem:reflexes:end -->\n')).toBe(true);
1030
+ expect(updated.endsWith('<!-- totem:reflexes:end -->\n\n')).toBe(false);
1031
+ });
1032
+ it('a CRLF file regens without inserting a blank line at the seam (leg F4/M2)', () => {
1033
+ const content = '# CLAUDE.md\r\n\r\n<!-- totem:reflexes:start -->\r\n<!-- totem:reflexes:version:1 -->\r\nOld\r\n<!-- totem:reflexes:end -->\r\n\r\n## After\r\nUser content\r\n';
1034
+ const once = upgradeReflexes(content).content;
1035
+ const twice = upgradeReflexes(once).content;
1036
+ expect(twice).toBe(once);
1037
+ // The before-seam normalizes CRLF too (bot round 1): the trailing CRLF run
1038
+ // collapses to one canonical LF ahead of the block's own leading newline.
1039
+ expect(once).toContain('# CLAUDE.md\n\n<!-- totem:reflexes:start -->');
1040
+ // The old block's own CRLF terminator must not survive into the seam:
1041
+ // the canonical block ends `end -->\n`, so the seam is `\n` + user CRLF span.
1042
+ expect(once).toContain('<!-- totem:reflexes:end -->\n\r\n## After');
1043
+ expect(once).not.toContain('<!-- totem:reflexes:end -->\n\r\n\r\n## After');
1044
+ });
1045
+ it('user text glued to the end marker on its own line survives regen (leg F4/B2)', () => {
1046
+ const content = '# CLAUDE.md\n\n<!-- totem:reflexes:start -->\n<!-- totem:reflexes:version:1 -->\nOld\n<!-- totem:reflexes:end --> IMPORTANT same-line note\n';
1047
+ const once = upgradeReflexes(content).content;
1048
+ expect(once).toContain(' IMPORTANT same-line note');
1049
+ expect(upgradeReflexes(once).content).toBe(once);
1050
+ });
1051
+ it('marker at byte 0 and marker glued to a prefix are both byte-idempotent (leg F5)', () => {
1052
+ const atZero = '<!-- totem:reflexes:start -->\n<!-- totem:reflexes:version:1 -->\nOld\n<!-- totem:reflexes:end -->\n';
1053
+ const onceZero = upgradeReflexes(atZero).content;
1054
+ expect(upgradeReflexes(onceZero).content).toBe(onceZero);
1055
+ const glued = '# CLAUDE.md text<!-- totem:reflexes:start -->\n<!-- totem:reflexes:version:1 -->\nOld\n<!-- totem:reflexes:end -->\n';
1056
+ const onceGlued = upgradeReflexes(glued).content;
1057
+ expect(upgradeReflexes(onceGlued).content).toBe(onceGlued);
1058
+ expect(onceGlued).toContain('# CLAUDE.md text');
1059
+ });
1060
+ it('the guardrail names the exact envelope tag the MCP server emits (#2600 coupling lock)', () => {
1061
+ expect(AI_PROMPT_BLOCK).toContain('<size-disclosure ... />');
1062
+ expect(AI_PROMPT_BLOCK).toContain('totem_system_warning');
1063
+ });
703
1064
  });
704
1065
  describe('REFLEX_VERSION', () => {
705
1066
  it('is at least 2', () => {
@@ -869,6 +1230,163 @@ describe('initCommand --global', () => {
869
1230
  expect(configContent).toContain("glob: '.totem/lessons/*.md'");
870
1231
  });
871
1232
  });
1233
+ describe('initCommand non-interactive mode (mmnto-ai/totem#2601)', () => {
1234
+ let tmpDir;
1235
+ let originalCwd;
1236
+ const savedIsTTY = process.stdin.isTTY;
1237
+ const savedEnv = {
1238
+ OPENAI_API_KEY: process.env['OPENAI_API_KEY'],
1239
+ GEMINI_API_KEY: process.env['GEMINI_API_KEY'],
1240
+ GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'],
1241
+ };
1242
+ beforeEach(() => {
1243
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-init-noninteractive-'));
1244
+ originalCwd = process.cwd();
1245
+ // No key detected → the embedding-tier prompt site is on the path.
1246
+ delete process.env['OPENAI_API_KEY'];
1247
+ delete process.env['GEMINI_API_KEY'];
1248
+ delete process.env['GOOGLE_API_KEY'];
1249
+ process.chdir(tmpDir);
1250
+ });
1251
+ afterEach(() => {
1252
+ vi.restoreAllMocks();
1253
+ process.chdir(originalCwd);
1254
+ cleanTmpDir(tmpDir);
1255
+ Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true });
1256
+ for (const [key, val] of Object.entries(savedEnv)) {
1257
+ if (val === undefined)
1258
+ delete process.env[key];
1259
+ else
1260
+ process.env[key] = val;
1261
+ }
1262
+ });
1263
+ // Both cases would previously HANG on the first unguarded prompt (the run never
1264
+ // settles and the mutations already landed) — resolving at all is the assertion.
1265
+ it('runs to completion with a non-TTY stdin', async () => {
1266
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
1267
+ // A detected AI tool puts the tool-selection prompt — the site that killed the
1268
+ // real stdin-null run — on the path.
1269
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Project\n', 'utf-8');
1270
+ const stderr = [];
1271
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
1272
+ stderr.push(args.map(String).join(' '));
1273
+ });
1274
+ await initCommand({});
1275
+ expect(fs.existsSync(path.join(tmpDir, '.totem', 'lessons', 'baseline.md'))).toBe(true);
1276
+ expect(fs.existsSync(path.join(tmpDir, '.totem', 'compiled-rules.json'))).toBe(true);
1277
+ // The embedding-key prompt's non-interactive default is Lite — no .env is written.
1278
+ expect(fs.existsSync(path.join(tmpDir, '.env'))).toBe(false);
1279
+ // ...and the Lite tier LANDS in the generated config: no ecosystem marker in the
1280
+ // fixture → yaml, and the Lite tier is the one tier that emits no `embedding` key.
1281
+ // The `.env` absence alone would also hold for a tier that never writes one.
1282
+ const configPath = path.join(tmpDir, 'totem.yaml');
1283
+ expect(fs.existsSync(configPath)).toBe(true);
1284
+ expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('embedding');
1285
+ // Tool selection took the 'all' default and said so.
1286
+ const output = stderr.join('\n');
1287
+ expect(output).toContain('Non-interactive mode — configuring all detected tools');
1288
+ expect(output).toContain('Claude Code');
1289
+ expect(output).toContain('(Lite tier)');
1290
+ const mcp = JSON.parse(fs.readFileSync(path.join(tmpDir, '.mcp.json'), 'utf-8'));
1291
+ expect(mcp.mcpServers.totem).toBeDefined();
1292
+ }, 60000);
1293
+ it('discloses the package-level MCP dedup instead of appending a duplicate', async () => {
1294
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
1295
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Project\n', 'utf-8');
1296
+ // Already registered under a name the user chose — the live floor-refresh shape.
1297
+ fs.writeFileSync(path.join(tmpDir, '.mcp.json'), JSON.stringify({ mcpServers: { 'totem-dev': { command: 'npx', args: ['-y', '@mmnto/mcp'] } } }, null, 2) + '\n', 'utf-8');
1298
+ const stderr = [];
1299
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
1300
+ stderr.push(args.map(String).join(' '));
1301
+ });
1302
+ await initCommand({});
1303
+ const output = stderr.join('\n');
1304
+ expect(output).toContain('already registered as');
1305
+ expect(output).toContain('totem-dev');
1306
+ const mcp = JSON.parse(fs.readFileSync(path.join(tmpDir, '.mcp.json'), 'utf-8'));
1307
+ expect(Object.keys(mcp.mcpServers)).toEqual(['totem-dev']);
1308
+ }, 60000);
1309
+ it('takes both git-hook installer defaults non-interactively and discloses each', async () => {
1310
+ // Forced TTY + `--yes`: the ONLY way to reach the installers' non-interactive arms
1311
+ // through init, and a real git repo is what puts those arms on the path at all.
1312
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
1313
+ // Hermetic git: ambient `core.hooksPath` / `init.templateDir` would relocate the
1314
+ // hooks this test asserts on — and the installer's own git calls read the same
1315
+ // ambient config, so the isolation must be env-level for the WHOLE run, not a
1316
+ // `-c` on `git init` alone (CR round 1, adapted).
1317
+ const savedGlobal = process.env['GIT_CONFIG_GLOBAL'];
1318
+ const savedSystem = process.env['GIT_CONFIG_SYSTEM'];
1319
+ // An empty file, not os.devNull: git exits 128 on the Windows NUL-device path.
1320
+ const emptyGitConfig = path.join(tmpDir, 'empty-gitconfig');
1321
+ fs.writeFileSync(emptyGitConfig, '', 'utf-8');
1322
+ process.env['GIT_CONFIG_GLOBAL'] = emptyGitConfig;
1323
+ process.env['GIT_CONFIG_SYSTEM'] = emptyGitConfig;
1324
+ onTestFinished(() => {
1325
+ if (savedGlobal === undefined)
1326
+ delete process.env['GIT_CONFIG_GLOBAL'];
1327
+ else
1328
+ process.env['GIT_CONFIG_GLOBAL'] = savedGlobal;
1329
+ if (savedSystem === undefined)
1330
+ delete process.env['GIT_CONFIG_SYSTEM'];
1331
+ else
1332
+ process.env['GIT_CONFIG_SYSTEM'] = savedSystem;
1333
+ });
1334
+ const gitInit = spawnSync('git', ['init'], { cwd: tmpDir, encoding: 'utf-8' });
1335
+ expect(gitInit.status).toBe(0);
1336
+ const stderr = [];
1337
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
1338
+ stderr.push(args.map(String).join(' '));
1339
+ });
1340
+ await initCommand({ yes: true });
1341
+ const output = stderr.join('\n');
1342
+ // Enforcement hooks take the (Y) default...
1343
+ expect(output).toContain('[Totem] Non-interactive mode — installing git enforcement hooks.');
1344
+ // ...and the post-merge hook takes its (N) default, naming the verb that adds it.
1345
+ expect(output).toContain('[Totem] Non-interactive mode — post-merge auto-sync hook not installed. Run `totem install-hooks` to add it.');
1346
+ // The artifacts, not just the log lines (leg D5): the two enforcement hooks
1347
+ // actually landed and the declined post-merge hook actually did not.
1348
+ const gitHooksDir = path.join(tmpDir, '.git', 'hooks');
1349
+ expect(fs.existsSync(path.join(gitHooksDir, 'pre-commit'))).toBe(true);
1350
+ expect(fs.existsSync(path.join(gitHooksDir, 'pre-push'))).toBe(true);
1351
+ expect(fs.existsSync(path.join(gitHooksDir, 'post-merge'))).toBe(false);
1352
+ }, 60000);
1353
+ it('surfaces the coexistence disclosure through the summary on a canonical .cjs (leg D4)', async () => {
1354
+ // Kills the summary-filter mutant: an `exists`-action result carrying an override
1355
+ // must reach the rendered summary, not just the installer's return value.
1356
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
1357
+ const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
1358
+ fs.mkdirSync(hooksDir, { recursive: true });
1359
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.js'), '// my own hook\n', 'utf-8');
1360
+ fs.writeFileSync(path.join(hooksDir, 'SessionStart.cjs'), GEMINI_SESSION_START, 'utf-8');
1361
+ const stderr = [];
1362
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
1363
+ stderr.push(args.map(String).join(' '));
1364
+ });
1365
+ await initCommand({});
1366
+ expect(stderr.join('\n')).toContain('coexists with the managed SessionStart.cjs');
1367
+ }, 60000);
1368
+ it('runs to completion with --yes on a TTY', async () => {
1369
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
1370
+ await initCommand({ yes: true });
1371
+ expect(fs.existsSync(path.join(tmpDir, '.totem', 'lessons', 'baseline.md'))).toBe(true);
1372
+ expect(fs.existsSync(path.join(tmpDir, '.env'))).toBe(false);
1373
+ }, 60000);
1374
+ it('declines the cursor-rules compile rather than firing it headless', async () => {
1375
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
1376
+ fs.writeFileSync(path.join(tmpDir, '.cursorrules'), '# House rules\n\n- Never commit directly to main.\n', 'utf-8');
1377
+ const stderr = [];
1378
+ vi.spyOn(console, 'error').mockImplementation((...args) => {
1379
+ stderr.push(args.map(String).join(' '));
1380
+ });
1381
+ await initCommand({});
1382
+ const output = stderr.join('\n');
1383
+ // The decline is disclosed and names the manual verb.
1384
+ expect(output).toContain('totem compile --from-cursor');
1385
+ // Neither compile outcome line appears — the mutating path never ran.
1386
+ expect(output).not.toContain('cursor rule(s) into invariants');
1387
+ expect(output).not.toContain('Could not compile cursor rules');
1388
+ }, 60000);
1389
+ });
872
1390
  // ─── Write-time xrepo-qualify-refs hook (mmnto-ai/totem#1846) ────────────
873
1391
  describe('BARE_REF_REGEX_SOURCE', () => {
874
1392
  it('matches the compiled rule pattern shape', () => {
@@ -1187,13 +1705,31 @@ describe('GEMINI_SESSION_START template', () => {
1187
1705
  it('does NOT call `totem status` (the prior drifted shape)', () => {
1188
1706
  expect(GEMINI_SESSION_START).not.toContain("'totem status'");
1189
1707
  });
1190
- it('uses a 30-second timeout matching the Claude-side hook contract', () => {
1191
- expect(GEMINI_SESSION_START).toContain('timeout: 30000');
1192
- });
1193
- it('routes diagnostic stdio so the banner lands in the session prompt', () => {
1194
- // Gemini SessionStart hooks inherit stdio; the third entry must be
1195
- // 'inherit' for the orientation output to reach the agent.
1196
- expect(GEMINI_SESSION_START).toContain("stdio: ['ignore', 'inherit', 'inherit']");
1708
+ it('uses 20-second per-leg timeouts measured worst-case exit drops 60s to 40s', () => {
1709
+ // Two sequential legs at the old 30s each could consume Gemini's entire
1710
+ // 60s DEFAULT_HOOK_TIMEOUT on one pathological hang. Post-fix no
1711
+ // descendant inherits the hook's own streams, so the inner budgets are
1712
+ // honored even under a hung grandchild, and the vendor tree-kills at its
1713
+ // own 60s expiry regardless (mmnto-ai/totem#2613 falsification rounds).
1714
+ expect(GEMINI_SESSION_START).toContain('timeout: 20000');
1715
+ expect(GEMINI_SESSION_START).not.toContain('timeout: 30000');
1716
+ });
1717
+ it('captures BOTH streams of the briefing legs and emits envelope + systemMessage', () => {
1718
+ // Interactive Gemini ingests SessionStart output ONLY from
1719
+ // hookSpecificOutput.additionalContext — an 'inherit' stdout wraps as
1720
+ // systemMessage, which the interactive startup consumer never reads
1721
+ // (mmnto-ai/totem#2613; the pre-fix pin here asserted exactly that
1722
+ // dropped channel). The Totem CLI writes its banner to STDERR, so capture
1723
+ // must take both streams — a stdout-only capture silently drops the
1724
+ // describe leg. systemMessage rides alongside for the /clear and -p
1725
+ // surfaces, which read only that key. The runtime contract is pinned
1726
+ // end-to-end by gemini-sessionstart-contract.test.ts; this guards
1727
+ // template drift.
1728
+ expect(GEMINI_SESSION_START).toContain("stdio: ['ignore', 'pipe', 'pipe']");
1729
+ expect(GEMINI_SESSION_START).not.toContain("stdio: ['ignore', 'inherit', 'inherit']");
1730
+ expect(GEMINI_SESSION_START).toContain("hookEventName: 'SessionStart'");
1731
+ expect(GEMINI_SESSION_START).toContain('additionalContext: briefing');
1732
+ expect(GEMINI_SESSION_START).toContain('systemMessage: briefing');
1197
1733
  });
1198
1734
  it('emits a generic fallback breadcrumb when describe fails', () => {
1199
1735
  // No strategy-repo-specific pointers leak; matches the Claude-side