@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.
- package/dist/commands/doctor-seat-identity.d.ts +71 -0
- package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.js +352 -0
- package/dist/commands/doctor-seat-identity.js.map +1 -0
- package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
- package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.test.js +230 -0
- package/dist/commands/doctor-seat-identity.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +1 -0
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +18 -7
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +63 -1
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject.d.ts +62 -3
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +382 -47
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +557 -7
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
- package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-beforetool-contract.test.js +106 -0
- package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.js +184 -0
- package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +271 -19
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts +40 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +357 -32
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +546 -10
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +5 -0
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +26 -8
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +51 -6
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/mail-degraded-e4.test.js +126 -0
- package/dist/commands/mail-degraded-e4.test.js.map +1 -1
- package/dist/commands/mail.d.ts +19 -1
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +195 -30
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +291 -3
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +82 -0
- package/dist/commands/orient.js.map +1 -1
- package/dist/commands/orient.test.js +144 -1
- package/dist/commands/orient.test.js.map +1 -1
- package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
- package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/seat-cli-wiring.test.js +147 -0
- package/dist/commands/seat-cli-wiring.test.js.map +1 -0
- package/dist/commands/seat-poll-chain.test.d.ts +13 -0
- package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
- package/dist/commands/seat-poll-chain.test.js +103 -0
- package/dist/commands/seat-poll-chain.test.js.map +1 -0
- package/dist/commands/seat.d.ts +92 -0
- package/dist/commands/seat.d.ts.map +1 -0
- package/dist/commands/seat.js +649 -0
- package/dist/commands/seat.js.map +1 -0
- package/dist/commands/seat.test.d.ts +19 -0
- package/dist/commands/seat.test.d.ts.map +1 -0
- package/dist/commands/seat.test.js +532 -0
- package/dist/commands/seat.test.js.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.js +176 -0
- package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
- package/dist/hooks/__tests__/auto-context.test.js +20 -0
- package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
- package/dist/hooks/auto-context.d.ts +19 -0
- package/dist/hooks/auto-context.d.ts.map +1 -1
- package/dist/hooks/auto-context.js +22 -4
- package/dist/hooks/auto-context.js.map +1 -1
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -1
- 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
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
expect(GEMINI_SESSION_START).toContain(
|
|
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
|