@mmnto/cli 1.119.0 → 1.121.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 (72) hide show
  1. package/dist/commands/config-drift.test.js +10 -2
  2. package/dist/commands/config-drift.test.js.map +1 -1
  3. package/dist/commands/doctor-parity.d.ts.map +1 -1
  4. package/dist/commands/doctor-parity.js +21 -6
  5. package/dist/commands/doctor-parity.js.map +1 -1
  6. package/dist/commands/doctor-parity.test.js +14 -7
  7. package/dist/commands/doctor-parity.test.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +21 -1
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +167 -9
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/doctor.test.js +234 -6
  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 +38 -5
  16. package/dist/commands/ecl-gc.js.map +1 -1
  17. package/dist/commands/ecl-gc.test.js +71 -0
  18. package/dist/commands/ecl-gc.test.js.map +1 -1
  19. package/dist/commands/eject-totemdir.test.d.ts +8 -0
  20. package/dist/commands/eject-totemdir.test.d.ts.map +1 -0
  21. package/dist/commands/eject-totemdir.test.js +101 -0
  22. package/dist/commands/eject-totemdir.test.js.map +1 -0
  23. package/dist/commands/eject.d.ts +30 -5
  24. package/dist/commands/eject.d.ts.map +1 -1
  25. package/dist/commands/eject.js +145 -31
  26. package/dist/commands/eject.js.map +1 -1
  27. package/dist/commands/eject.test.js +3 -3
  28. package/dist/commands/eject.test.js.map +1 -1
  29. package/dist/commands/gemini-sessionstart-contract.test.js +50 -0
  30. package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -1
  31. package/dist/commands/hook-totemdir-render.test.d.ts +23 -0
  32. package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
  33. package/dist/commands/hook-totemdir-render.test.js +288 -0
  34. package/dist/commands/hook-totemdir-render.test.js.map +1 -0
  35. package/dist/commands/init-templates.d.ts +6 -6
  36. package/dist/commands/init-templates.d.ts.map +1 -1
  37. package/dist/commands/init-templates.js +105 -19
  38. package/dist/commands/init-templates.js.map +1 -1
  39. package/dist/commands/init.test.js +136 -12
  40. package/dist/commands/init.test.js.map +1 -1
  41. package/dist/commands/install-hooks-exit-contract.test.js +6 -6
  42. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  43. package/dist/commands/install-hooks.d.ts +162 -12
  44. package/dist/commands/install-hooks.d.ts.map +1 -1
  45. package/dist/commands/install-hooks.js +381 -92
  46. package/dist/commands/install-hooks.js.map +1 -1
  47. package/dist/commands/install-hooks.test.js +446 -158
  48. package/dist/commands/install-hooks.test.js.map +1 -1
  49. package/dist/commands/link.d.ts.map +1 -1
  50. package/dist/commands/link.js +71 -16
  51. package/dist/commands/link.js.map +1 -1
  52. package/dist/commands/link.test.d.ts +9 -0
  53. package/dist/commands/link.test.d.ts.map +1 -0
  54. package/dist/commands/link.test.js +92 -0
  55. package/dist/commands/link.test.js.map +1 -0
  56. package/dist/commands/mail.d.ts +48 -2
  57. package/dist/commands/mail.d.ts.map +1 -1
  58. package/dist/commands/mail.js +76 -2
  59. package/dist/commands/mail.js.map +1 -1
  60. package/dist/commands/mail.test.js +177 -0
  61. package/dist/commands/mail.test.js.map +1 -1
  62. package/dist/commands/pre-push-gate-matrix.test.js +9 -2
  63. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
  64. package/dist/commands/rule.test.js +30 -0
  65. package/dist/commands/rule.test.js.map +1 -1
  66. package/dist/commands/shield.js +1 -1
  67. package/dist/commands/shield.js.map +1 -1
  68. package/dist/commands/tools-hook-parity.test.js +7 -3
  69. package/dist/commands/tools-hook-parity.test.js.map +1 -1
  70. package/dist/index.js +9 -0
  71. package/dist/index.js.map +1 -1
  72. package/package.json +2 -2
@@ -5,6 +5,15 @@ import * as path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import { cleanTmpDir } from '../test-utils.js';
7
7
  import { buildHookContent, buildPostCheckoutHookContent, buildPreCommitHook, buildPrePushHook, buildResolveBlock, checkHooksInstalled, detectTotemPrefix, generateHookHelpers, getFallbackCommand, installGitHook, installHooksNonInteractive, TOTEM_HOOK_END, TOTEM_HOOK_MARKER, TOTEM_PRECOMMIT_END, TOTEM_PRECOMMIT_MARKER, TOTEM_PREPUSH_END, TOTEM_PREPUSH_MARKER, upgradePrePushHookIfNeeded, } from './install-hooks.js';
8
+ // The default render options every pre-#2692 positional call implied
9
+ // (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
10
+ // each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
11
+ // override the one field a case actually varies.
12
+ const RENDER = {
13
+ tier: 'standard',
14
+ totemDir: '.totem',
15
+ fallbackCmd: 'pnpm dlx @mmnto/cli',
16
+ };
8
17
  describe('detectTotemPrefix', () => {
9
18
  let tmpDir;
10
19
  beforeEach(() => {
@@ -152,76 +161,75 @@ describe('buildResolveBlock', () => {
152
161
  });
153
162
  describe('buildPreCommitHook', () => {
154
163
  it('contains the marker for idempotency', () => {
155
- const hook = buildPreCommitHook();
164
+ const hook = buildPreCommitHook(RENDER);
156
165
  expect(hook).toContain(TOTEM_PRECOMMIT_MARKER);
157
166
  });
158
167
  it('blocks main and master branches', () => {
159
- const hook = buildPreCommitHook();
168
+ const hook = buildPreCommitHook(RENDER);
160
169
  expect(hook).toContain('"main"');
161
170
  expect(hook).toContain('"master"');
162
171
  });
163
172
  it('prints a helpful error message with override instructions', () => {
164
- const hook = buildPreCommitHook();
173
+ const hook = buildPreCommitHook(RENDER);
165
174
  expect(hook).toContain('git checkout -b feat/my-feature');
166
175
  expect(hook).toContain('git commit --no-verify');
167
176
  });
168
177
  it('starts with a shebang', () => {
169
- const hook = buildPreCommitHook();
178
+ const hook = buildPreCommitHook(RENDER);
170
179
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
171
180
  });
172
181
  it('exits with code 1 when on protected branch', () => {
173
- const hook = buildPreCommitHook();
182
+ const hook = buildPreCommitHook(RENDER);
174
183
  expect(hook).toContain('exit 1');
175
184
  });
176
185
  });
177
186
  describe('buildPrePushHook', () => {
178
- const FALLBACK = 'pnpm dlx @mmnto/cli';
179
187
  it('contains the marker for idempotency', () => {
180
- const hook = buildPrePushHook(FALLBACK);
188
+ const hook = buildPrePushHook(RENDER);
181
189
  expect(hook).toContain(TOTEM_PREPUSH_MARKER);
182
190
  });
183
191
  it('starts with a shebang', () => {
184
- const hook = buildPrePushHook(FALLBACK);
192
+ const hook = buildPrePushHook(RENDER);
185
193
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
186
194
  });
187
195
  it('does not advertise --no-verify escape hatch', () => {
188
- const hook = buildPrePushHook(FALLBACK);
196
+ const hook = buildPrePushHook(RENDER);
189
197
  expect(hook).not.toContain('--no-verify');
190
198
  });
191
199
  it('contains verify-manifest check', () => {
192
- const hook = buildPrePushHook(FALLBACK);
200
+ const hook = buildPrePushHook(RENDER);
193
201
  expect(hook).toContain('verify-manifest');
194
202
  expect(hook).toContain('compile manifest is stale');
195
203
  });
196
204
  it('contains $TOTEM_CMD lint', () => {
197
- const hook = buildPrePushHook(FALLBACK);
205
+ const hook = buildPrePushHook(RENDER);
198
206
  expect(hook).toContain('$TOTEM_CMD lint');
199
207
  });
200
208
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
201
209
  it('contains $TOTEM_CMD verify-badges (mmnto-ai/totem#1926)', () => {
202
- const hook = buildPrePushHook(FALLBACK);
210
+ const hook = buildPrePushHook(RENDER);
203
211
  // totem-context: substring match on hook script content (not secret masking) — toContain is correct here
204
212
  expect(hook).toContain('$TOTEM_CMD verify-badges');
205
213
  });
206
214
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
207
215
  it('gates verify-badges on README.md existing', () => {
208
- const hook = buildPrePushHook(FALLBACK);
216
+ const hook = buildPrePushHook(RENDER);
209
217
  expect(hook).toMatch(/-f "README\.md".*verify-badges/s);
210
218
  });
211
219
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
212
220
  it('contains $TOTEM_CMD verify-lockfile-sync (mmnto-ai/totem#1961)', () => {
213
- const hook = buildPrePushHook(FALLBACK);
221
+ const hook = buildPrePushHook(RENDER);
214
222
  // totem-context: substring match on hook script content (not secret masking) — toContain is correct here
215
223
  expect(hook).toContain('$TOTEM_CMD verify-lockfile-sync');
216
224
  });
217
225
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
218
226
  it('gates verify-lockfile-sync on pnpm-lock.yaml existing', () => {
219
- const hook = buildPrePushHook(FALLBACK);
227
+ const hook = buildPrePushHook(RENDER);
220
228
  expect(hook).toMatch(/-f "pnpm-lock\.yaml".*verify-lockfile-sync/s);
221
229
  });
222
230
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
223
231
  it('slots verify-lockfile-sync before claim-discipline in the pre-push sequence', () => {
224
- const hook = buildPrePushHook(FALLBACK);
232
+ const hook = buildPrePushHook(RENDER);
225
233
  // Assert presence with toContain (precise matcher); index comparison is
226
234
  // safe only because both substrings are guaranteed present by the
227
235
  // toContain assertions above.
@@ -235,12 +243,12 @@ describe('buildPrePushHook', () => {
235
243
  });
236
244
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
237
245
  it('contains $TOTEM_CMD doctor --claim-discipline --strict (Proposal 279 Q3)', () => {
238
- const hook = buildPrePushHook(FALLBACK);
246
+ const hook = buildPrePushHook(RENDER);
239
247
  expect(hook).toContain('$TOTEM_CMD doctor --claim-discipline --strict');
240
248
  });
241
249
  // totem-context: hook-content assertion (mmnto-ai/totem#2002 — diff-scope narrowing)
242
250
  it('contains --scope-to-diff on the claim-discipline invocation (mmnto-ai/totem#2002)', () => {
243
- const hook = buildPrePushHook(FALLBACK);
251
+ const hook = buildPrePushHook(RENDER);
244
252
  // The flag must appear on the same `doctor --claim-discipline` line so the
245
253
  // hook narrows the WWND scan to diff-touched files. Standing-gate full scan
246
254
  // produced N=8 false-positive bypasses in <24hr on a pre-existing surface
@@ -249,7 +257,7 @@ describe('buildPrePushHook', () => {
249
257
  });
250
258
  // totem-context: hook-content assertion (mmnto-ai/totem#2002 — bootstrap defensive degrade)
251
259
  it('defensively degrades --scope-to-diff when CLI predates 1.47.0 (cohort bootstrap safety)', () => {
252
- const hook = buildPrePushHook(FALLBACK);
260
+ const hook = buildPrePushHook(RENDER);
253
261
  // The hook MUST detect --scope-to-diff support via --help and fall back to
254
262
  // standing-scan when the resolved CLI doesn't carry the flag. Required so a
255
263
  // cohort agent whose global @mmnto/cli predates 1.47.0 doesn't fail commander
@@ -270,13 +278,13 @@ describe('buildPrePushHook', () => {
270
278
  });
271
279
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
272
280
  it('gates claim-discipline on at-least-one in-scope public surface existing', () => {
273
- const hook = buildPrePushHook(FALLBACK);
281
+ const hook = buildPrePushHook(RENDER);
274
282
  // The gate fires when any of README.md, AGENTS.md, design-tenets.md, or docs/wiki/ exists
275
283
  expect(hook).toMatch(/-f "README\.md".*-f "AGENTS\.md".*-f "design-tenets\.md".*-d "docs\/wiki"/s);
276
284
  });
277
285
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
278
286
  it('slots claim-discipline after verify-badges in the pre-push sequence', () => {
279
- const hook = buildPrePushHook(FALLBACK);
287
+ const hook = buildPrePushHook(RENDER);
280
288
  const verifyBadgesIdx = hook.indexOf('$TOTEM_CMD verify-badges');
281
289
  const claimDisciplineIdx = hook.indexOf('$TOTEM_CMD doctor --claim-discipline');
282
290
  expect(verifyBadgesIdx).toBeGreaterThan(-1);
@@ -285,36 +293,36 @@ describe('buildPrePushHook', () => {
285
293
  });
286
294
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
287
295
  it('mentions TOTEM_GATE_BYPASS_JUSTIFICATION as the bypass mechanism (Proposal 279 Q3 standardized convention)', () => {
288
- const hook = buildPrePushHook(FALLBACK);
296
+ const hook = buildPrePushHook(RENDER);
289
297
  expect(hook).toContain('TOTEM_GATE_BYPASS_JUSTIFICATION');
290
298
  });
291
299
  it('does NOT contain old flag-file references', () => {
292
- const hook = buildPrePushHook(FALLBACK);
300
+ const hook = buildPrePushHook(RENDER);
293
301
  expect(hook).not.toContain('.lint-passed');
294
302
  expect(hook).not.toContain('.shield-passed');
295
303
  expect(hook).not.toContain('.target-globs');
296
304
  });
297
305
  it('does NOT contain merge-base (no ancestry checks)', () => {
298
- const hook = buildPrePushHook(FALLBACK);
306
+ const hook = buildPrePushHook(RENDER);
299
307
  expect(hook).not.toContain('merge-base');
300
308
  });
301
309
  it('uses the resolve block', () => {
302
- const hook = buildPrePushHook(FALLBACK);
310
+ const hook = buildPrePushHook(RENDER);
303
311
  expect(hook).toContain('TOTEM_CMD=');
304
312
  expect(hook).toContain('command -v totem');
305
313
  });
306
314
  it('uses POSIX-compatible syntax only', () => {
307
- const hook = buildPrePushHook(FALLBACK);
315
+ const hook = buildPrePushHook(RENDER);
308
316
  // Must use [ ] not [[ ]]
309
317
  expect(hook).not.toContain('[[');
310
318
  expect(hook).not.toContain(']]');
311
319
  });
312
320
  it('embeds the provided fallback command', () => {
313
- const hook = buildPrePushHook('yarn dlx @mmnto/cli');
321
+ const hook = buildPrePushHook({ ...RENDER, fallbackCmd: 'yarn dlx @mmnto/cli' });
314
322
  expect(hook).toContain('yarn dlx @mmnto/cli');
315
323
  });
316
324
  it('includes format:check before push', () => {
317
- const hook = buildPrePushHook(FALLBACK);
325
+ const hook = buildPrePushHook(RENDER);
318
326
  expect(hook).toContain('format:check');
319
327
  expect(hook).toContain('Formatting check failed');
320
328
  });
@@ -330,7 +338,7 @@ describe('installGitHook', () => {
330
338
  cleanTmpDir(tmpDir);
331
339
  });
332
340
  it('creates a new hook file when none exists', () => {
333
- const content = buildPreCommitHook();
341
+ const content = buildPreCommitHook(RENDER);
334
342
  const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
335
343
  expect(result).toBe('installed');
336
344
  expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
@@ -339,11 +347,11 @@ describe('installGitHook', () => {
339
347
  });
340
348
  it('creates parent directories as needed', () => {
341
349
  expect(fs.existsSync(hooksDir)).toBe(false);
342
- installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
350
+ installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
343
351
  expect(fs.existsSync(hooksDir)).toBe(true);
344
352
  });
345
353
  it('returns exists when marker is already present (idempotent)', () => {
346
- const content = buildPreCommitHook();
354
+ const content = buildPreCommitHook(RENDER);
347
355
  installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
348
356
  const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
349
357
  expect(result).toBe('exists');
@@ -353,7 +361,7 @@ describe('installGitHook', () => {
353
361
  const hookPath = path.join(hooksDir, 'pre-commit');
354
362
  const userHook = '#!/bin/sh\necho "user hook"\n';
355
363
  fs.writeFileSync(hookPath, userHook);
356
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
364
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
357
365
  expect(result).toBe('appended');
358
366
  const written = fs.readFileSync(hookPath, 'utf-8');
359
367
  expect(written).toContain('echo "user hook"');
@@ -366,13 +374,13 @@ describe('installGitHook', () => {
366
374
  const hookPath = path.join(hooksDir, 'pre-push');
367
375
  const userHook = '#!/bin/sh\nrun_my_tests\n';
368
376
  fs.writeFileSync(hookPath, userHook);
369
- installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER);
377
+ installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
370
378
  const written = fs.readFileSync(hookPath, 'utf-8');
371
379
  expect(written).toContain('run_my_tests');
372
380
  expect(written).toContain(TOTEM_PREPUSH_MARKER);
373
381
  });
374
382
  it('is idempotent — double install does not duplicate', () => {
375
- const content = buildPrePushHook('pnpm dlx @mmnto/cli');
383
+ const content = buildPrePushHook(RENDER);
376
384
  installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
377
385
  installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
378
386
  const written = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
@@ -384,7 +392,7 @@ describe('installGitHook', () => {
384
392
  const hookPath = path.join(hooksDir, 'pre-commit');
385
393
  const nodeHook = '#!/usr/bin/env node\nconsole.log("lint");\n'; // totem-ignore — test fixture, not real logging
386
394
  fs.writeFileSync(hookPath, nodeHook);
387
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
395
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
388
396
  expect(result).toBe('skipped-non-shell');
389
397
  const written = fs.readFileSync(hookPath, 'utf-8');
390
398
  expect(written).toBe(nodeHook); // File untouched
@@ -394,7 +402,7 @@ describe('installGitHook', () => {
394
402
  const hookPath = path.join(hooksDir, 'pre-push');
395
403
  const pythonHook = '#!/usr/bin/env python3\nimport subprocess\n';
396
404
  fs.writeFileSync(hookPath, pythonHook);
397
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER);
405
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
398
406
  expect(result).toBe('skipped-non-shell');
399
407
  const written = fs.readFileSync(hookPath, 'utf-8');
400
408
  expect(written).toBe(pythonHook); // File untouched
@@ -403,33 +411,33 @@ describe('installGitHook', () => {
403
411
  fs.mkdirSync(hooksDir, { recursive: true });
404
412
  const hookPath = path.join(hooksDir, 'pre-commit');
405
413
  fs.writeFileSync(hookPath, '#!/bin/sh\necho "existing"\n');
406
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
414
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
407
415
  expect(result).toBe('appended');
408
416
  });
409
417
  it('appends normally to bash hooks', () => {
410
418
  fs.mkdirSync(hooksDir, { recursive: true });
411
419
  const hookPath = path.join(hooksDir, 'pre-commit');
412
420
  fs.writeFileSync(hookPath, '#!/bin/bash\necho "existing"\n');
413
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
421
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
414
422
  expect(result).toBe('appended');
415
423
  });
416
424
  it('appends normally to env bash hooks', () => {
417
425
  fs.mkdirSync(hooksDir, { recursive: true });
418
426
  const hookPath = path.join(hooksDir, 'pre-commit');
419
427
  fs.writeFileSync(hookPath, '#!/usr/bin/env bash\necho "existing"\n');
420
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
428
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
421
429
  expect(result).toBe('appended');
422
430
  });
423
431
  it('appends to hooks without a shebang (plain text)', () => {
424
432
  fs.mkdirSync(hooksDir, { recursive: true });
425
433
  const hookPath = path.join(hooksDir, 'pre-commit');
426
434
  fs.writeFileSync(hookPath, 'echo "no shebang"\n');
427
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
435
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
428
436
  expect(result).toBe('appended');
429
437
  });
430
438
  it('handles pre-commit and pre-push independently', () => {
431
- installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
432
- installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER);
439
+ installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
440
+ installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
433
441
  expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
434
442
  expect(fs.existsSync(path.join(hooksDir, 'pre-push'))).toBe(true);
435
443
  const preCommit = fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8');
@@ -444,7 +452,7 @@ describe('installGitHook', () => {
444
452
  const hookPath = path.join(hooksDir, 'pre-push');
445
453
  // Write an old-format hook with the marker
446
454
  fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\n$TOTEM_CMD lint\n`);
447
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER, true);
455
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, true);
448
456
  expect(result).toBe('overwritten');
449
457
  const content = fs.readFileSync(hookPath, 'utf-8');
450
458
  expect(content).toContain('verify-manifest'); // new format
@@ -453,7 +461,7 @@ describe('installGitHook', () => {
453
461
  it('returns exists when on-disk content already matches canonical (idempotent)', () => {
454
462
  fs.mkdirSync(hooksDir, { recursive: true });
455
463
  const hookPath = path.join(hooksDir, 'pre-push');
456
- const canonical = buildPrePushHook('pnpm dlx @mmnto/cli');
464
+ const canonical = buildPrePushHook(RENDER);
457
465
  fs.writeFileSync(hookPath, canonical);
458
466
  const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER);
459
467
  expect(result).toBe('exists');
@@ -464,7 +472,7 @@ describe('installGitHook', () => {
464
472
  // A totem-owned whole file frozen at an older generator's output — but it carries
465
473
  // the bounded end marker, so drift-repair may upgrade it in place without --force.
466
474
  fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\n$TOTEM_CMD lint\n# ${TOTEM_PREPUSH_END}\n`);
467
- const canonical = buildPrePushHook('pnpm dlx @mmnto/cli');
475
+ const canonical = buildPrePushHook(RENDER);
468
476
  const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
469
477
  expect(result).toBe('overwritten');
470
478
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
@@ -473,7 +481,7 @@ describe('installGitHook', () => {
473
481
  fs.mkdirSync(hooksDir, { recursive: true });
474
482
  const hookPath = path.join(hooksDir, 'pre-commit');
475
483
  fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\n`);
476
- const canonical = buildPreCommitHook();
484
+ const canonical = buildPreCommitHook(RENDER);
477
485
  const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
478
486
  expect(result).toBe('overwritten');
479
487
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
@@ -486,7 +494,7 @@ describe('installGitHook', () => {
486
494
  // marker → the region cannot be bounded → drift-repair declines (legacy path).
487
495
  const legacy = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale legacy body\n`;
488
496
  fs.writeFileSync(hookPath, legacy);
489
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
497
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
490
498
  expect(result).toBe('exists');
491
499
  // Left untouched — the legacy hook takes one `totem hook install --force`.
492
500
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(legacy);
@@ -498,7 +506,7 @@ describe('installGitHook', () => {
498
506
  // block is bounded by an end marker.
499
507
  const userThenTotem = `#!/bin/sh\nrun_my_tests\n# ${TOTEM_PREPUSH_MARKER}\nold content\n# ${TOTEM_PREPUSH_END}\n`;
500
508
  fs.writeFileSync(hookPath, userThenTotem);
501
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
509
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
502
510
  expect(result).toBe('exists');
503
511
  // Left untouched — the user's hook is preserved verbatim.
504
512
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(userThenTotem);
@@ -510,7 +518,7 @@ describe('installGitHook', () => {
510
518
  // whole-file overwrite would clobber it, so the end-marker guard must decline.
511
519
  const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\necho "my pre-commit notice"\n`;
512
520
  fs.writeFileSync(hookPath, staleWithTrailingUser);
513
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
521
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
514
522
  expect(result).toBe('exists');
515
523
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
516
524
  });
@@ -519,7 +527,7 @@ describe('installGitHook', () => {
519
527
  const hookPath = path.join(hooksDir, 'pre-push');
520
528
  const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\nstale body\n# ${TOTEM_PREPUSH_END}\necho "my pre-push notice"\n`;
521
529
  fs.writeFileSync(hookPath, staleWithTrailingUser);
522
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
530
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
523
531
  expect(result).toBe('exists');
524
532
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
525
533
  });
@@ -530,7 +538,7 @@ describe('installGitHook', () => {
530
538
  // whole-file overwrite would clobber it, so the end-marker guard must decline.
531
539
  const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_HOOK_MARKER}\nstale body\n# ${TOTEM_HOOK_END}\necho "my deploy notice"\n`;
532
540
  fs.writeFileSync(hookPath, staleWithTrailingUser);
533
- const result = installGitHook(hooksDir, 'post-merge', buildHookContent('pnpm dlx @mmnto/cli'), TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
541
+ const result = installGitHook(hooksDir, 'post-merge', buildHookContent(RENDER), TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
534
542
  expect(result).toBe('exists');
535
543
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
536
544
  });
@@ -545,7 +553,7 @@ describe('generateHookHelpers', () => {
545
553
  cleanTmpDir(tmpDir);
546
554
  });
547
555
  it('creates .totem/hooks/ directory and writes all 4 .sh files', () => {
548
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
556
+ generateHookHelpers(tmpDir, RENDER);
549
557
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
550
558
  expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
551
559
  expect(fs.existsSync(path.join(hooksDir, 'post-checkout.sh'))).toBe(true);
@@ -553,7 +561,7 @@ describe('generateHookHelpers', () => {
553
561
  expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
554
562
  });
555
563
  it('generated scripts contain expected content', () => {
556
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
564
+ generateHookHelpers(tmpDir, RENDER);
557
565
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
558
566
  const postMerge = fs.readFileSync(path.join(hooksDir, 'post-merge.sh'), 'utf-8');
559
567
  expect(postMerge).toContain('command -v totem');
@@ -563,8 +571,8 @@ describe('generateHookHelpers', () => {
563
571
  expect(prePush).toContain('verify-manifest');
564
572
  });
565
573
  it('is idempotent — calling twice does not error', () => {
566
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
567
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
574
+ generateHookHelpers(tmpDir, RENDER);
575
+ generateHookHelpers(tmpDir, RENDER);
568
576
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
569
577
  expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
570
578
  });
@@ -578,14 +586,14 @@ describe('installHooksNonInteractive', () => {
578
586
  afterEach(() => {
579
587
  cleanTmpDir(tmpDir);
580
588
  });
581
- it('returns null when not a git repo', () => {
582
- const result = installHooksNonInteractive(tmpDir);
589
+ it('returns null when not a git repo', async () => {
590
+ const result = await installHooksNonInteractive(tmpDir);
583
591
  expect(result).toBeNull();
584
592
  });
585
- it('installs all four hooks in a git repo', () => {
593
+ it('installs all four hooks in a git repo', async () => {
586
594
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
587
595
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
588
- const result = installHooksNonInteractive(tmpDir);
596
+ const result = await installHooksNonInteractive(tmpDir);
589
597
  expect(result).not.toBeNull();
590
598
  expect(result.preCommit).toBe('installed');
591
599
  expect(result.prePush).toBe('installed');
@@ -598,33 +606,33 @@ describe('installHooksNonInteractive', () => {
598
606
  expect(fs.existsSync(path.join(hooksDir, 'post-merge'))).toBe(true);
599
607
  expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
600
608
  });
601
- it('is idempotent — second call returns exists for all hooks', () => {
609
+ it('is idempotent — second call returns exists for all hooks', async () => {
602
610
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
603
611
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
604
- installHooksNonInteractive(tmpDir);
605
- const result = installHooksNonInteractive(tmpDir);
612
+ await installHooksNonInteractive(tmpDir);
613
+ const result = await installHooksNonInteractive(tmpDir);
606
614
  expect(result).not.toBeNull();
607
615
  expect(result.preCommit).toBe('exists');
608
616
  expect(result.prePush).toBe('exists');
609
617
  expect(result.postMerge).toBe('exists');
610
618
  expect(result.postCheckout).toBe('exists');
611
619
  });
612
- it('returns null and generates helper scripts when hook manager is detected', () => {
620
+ it('returns null and generates helper scripts when hook manager is detected', async () => {
613
621
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
614
622
  fs.mkdirSync(path.join(tmpDir, '.husky'), { recursive: true });
615
- const result = installHooksNonInteractive(tmpDir);
623
+ const result = await installHooksNonInteractive(tmpDir);
616
624
  expect(result).toBeNull();
617
625
  // Verify helper scripts were generated
618
626
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
619
627
  expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
620
628
  expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
621
629
  });
622
- it('installs hooks at git root when run from a subdirectory', () => {
630
+ it('installs hooks at git root when run from a subdirectory', async () => {
623
631
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
624
632
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
625
633
  const subDir = path.join(tmpDir, 'packages', 'cli');
626
634
  fs.mkdirSync(subDir, { recursive: true });
627
- const result = installHooksNonInteractive(subDir);
635
+ const result = await installHooksNonInteractive(subDir);
628
636
  expect(result).not.toBeNull();
629
637
  expect(result.preCommit).toBe('installed');
630
638
  expect(result.prePush).toBe('installed');
@@ -638,31 +646,31 @@ describe('installHooksNonInteractive', () => {
638
646
  expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
639
647
  expect(fs.existsSync(path.join(subDir, '.git'))).toBe(false);
640
648
  });
641
- it('check passes from subdirectory after install', () => {
649
+ it('check passes from subdirectory after install', async () => {
642
650
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
643
651
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
644
652
  const subDir = path.join(tmpDir, 'packages', 'cli');
645
653
  fs.mkdirSync(subDir, { recursive: true });
646
- installHooksNonInteractive(subDir);
654
+ await installHooksNonInteractive(subDir);
647
655
  expect(checkHooksInstalled(subDir)).toBe(true);
648
656
  });
649
- it('appends to existing hooks without clobbering', () => {
657
+ it('appends to existing hooks without clobbering', async () => {
650
658
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
651
659
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
652
660
  fs.writeFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), '#!/bin/sh\nrun_my_tests\n');
653
- const result = installHooksNonInteractive(tmpDir);
661
+ const result = await installHooksNonInteractive(tmpDir);
654
662
  expect(result.prePush).toBe('appended');
655
663
  const content = fs.readFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), 'utf-8');
656
664
  expect(content).toContain('run_my_tests');
657
665
  expect(content).toContain(TOTEM_PREPUSH_MARKER);
658
666
  });
659
- it('force-overwrites all hooks when force is true', () => {
667
+ it('force-overwrites all hooks when force is true', async () => {
660
668
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
661
669
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
662
670
  // First install — creates hooks
663
- installHooksNonInteractive(tmpDir);
671
+ await installHooksNonInteractive(tmpDir);
664
672
  // Second install with force — overwrites all hooks
665
- const result = installHooksNonInteractive(tmpDir, true);
673
+ const result = await installHooksNonInteractive(tmpDir, true);
666
674
  expect(result).not.toBeNull();
667
675
  expect(result.preCommit).toBe('overwritten');
668
676
  expect(result.prePush).toBe('overwritten');
@@ -684,14 +692,14 @@ describe('checkHooksInstalled', () => {
684
692
  it('returns false when no hooks are installed', () => {
685
693
  expect(checkHooksInstalled(tmpDir)).toBe(false);
686
694
  });
687
- it('returns true when all hooks are installed', () => {
688
- installHooksNonInteractive(tmpDir);
695
+ it('returns true when all hooks are installed', async () => {
696
+ await installHooksNonInteractive(tmpDir);
689
697
  expect(checkHooksInstalled(tmpDir)).toBe(true);
690
698
  });
691
699
  it('returns false when only some hooks are installed', () => {
692
700
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
693
701
  fs.mkdirSync(hooksDir, { recursive: true });
694
- installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
702
+ installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
695
703
  // Missing pre-push and post-merge
696
704
  expect(checkHooksInstalled(tmpDir)).toBe(false);
697
705
  });
@@ -716,8 +724,8 @@ describe('post-merge hook content', () => {
716
724
  afterEach(() => {
717
725
  cleanTmpDir(tmpDir);
718
726
  });
719
- it('generates post-merge hook with git diff-tree lesson check', () => {
720
- installHooksNonInteractive(tmpDir);
727
+ it('generates post-merge hook with git diff-tree lesson check', async () => {
728
+ await installHooksNonInteractive(tmpDir);
721
729
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
722
730
  const content = fs.readFileSync(hookPath, 'utf-8');
723
731
  expect(content).toContain('ORIG_HEAD');
@@ -728,17 +736,17 @@ describe('post-merge hook content', () => {
728
736
  expect(content).toContain('[totem] post-merge hook');
729
737
  expect(content).toContain('[totem] end post-merge');
730
738
  });
731
- it('passes quiet flag to sync command in post-merge hook', () => {
732
- installHooksNonInteractive(tmpDir);
739
+ it('passes quiet flag to sync command in post-merge hook', async () => {
740
+ await installHooksNonInteractive(tmpDir);
733
741
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
734
742
  const content = fs.readFileSync(hookPath, 'utf-8');
735
743
  expect(content).toContain('--quiet');
736
744
  });
737
- it('preserves existing hooks when appending post-merge block', () => {
745
+ it('preserves existing hooks when appending post-merge block', async () => {
738
746
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
739
747
  fs.mkdirSync(hooksDir, { recursive: true });
740
748
  fs.writeFileSync(path.join(hooksDir, 'post-merge'), '#!/bin/sh\necho "my custom hook"\n');
741
- installHooksNonInteractive(tmpDir);
749
+ await installHooksNonInteractive(tmpDir);
742
750
  const content = fs.readFileSync(path.join(hooksDir, 'post-merge'), 'utf-8');
743
751
  expect(content).toContain('echo "my custom hook"');
744
752
  expect(content).toContain('[totem] post-merge hook');
@@ -757,8 +765,8 @@ describe('post-checkout hook content', () => {
757
765
  afterEach(() => {
758
766
  cleanTmpDir(tmpDir);
759
767
  });
760
- it('generates post-checkout hook with branch switch guard', () => {
761
- installHooksNonInteractive(tmpDir);
768
+ it('generates post-checkout hook with branch switch guard', async () => {
769
+ await installHooksNonInteractive(tmpDir);
762
770
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
763
771
  const content = fs.readFileSync(hookPath, 'utf-8');
764
772
  expect(content).toContain('$3');
@@ -767,18 +775,18 @@ describe('post-checkout hook content', () => {
767
775
  expect(content).toContain('[totem] end post-checkout');
768
776
  });
769
777
  it('handles null SHA for initial checkout', () => {
770
- const hook = buildPostCheckoutHookContent('pnpm dlx @mmnto/cli');
778
+ const hook = buildPostCheckoutHookContent(RENDER);
771
779
  expect(hook).toContain('0000000000000000000000000000000000000000');
772
780
  expect(hook).toContain('.totem');
773
781
  });
774
- it('uses quiet sync command', () => {
775
- installHooksNonInteractive(tmpDir);
782
+ it('uses quiet sync command', async () => {
783
+ await installHooksNonInteractive(tmpDir);
776
784
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
777
785
  const content = fs.readFileSync(hookPath, 'utf-8');
778
786
  expect(content).toContain('--quiet');
779
787
  });
780
- it('includes post-checkout in non-interactive install', () => {
781
- const result = installHooksNonInteractive(tmpDir);
788
+ it('includes post-checkout in non-interactive install', async () => {
789
+ const result = await installHooksNonInteractive(tmpDir);
782
790
  expect(result).not.toBeNull();
783
791
  expect(result.postCheckout).toBe('installed');
784
792
  });
@@ -787,7 +795,7 @@ describe('post-checkout hook content', () => {
787
795
  // ─── totem-status refresh-gh wiring (mmnto-ai/totem#2556) ─
788
796
  describe('post-merge hook fires totem-status refresh-gh', () => {
789
797
  it('invokes refresh-gh presence-gated and backgrounded (mmnto-ai/totem-status#127 C3)', () => {
790
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
798
+ const hook = buildHookContent(RENDER);
791
799
  // Presence gate (absent binary = zero noise) AND primary-checkout gate: in a
792
800
  // linked worktree .git is a pointer FILE and a backgrounded child inheriting
793
801
  // the worktree cwd holds a Windows directory lock that breaks worktree removal.
@@ -799,7 +807,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
799
807
  expect(hook.trimEnd().endsWith(`# ${TOTEM_HOOK_END}`)).toBe(true);
800
808
  });
801
809
  it('#2570: stamps each firing and hands the child the same log (observability leg)', () => {
802
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
810
+ const hook = buildHookContent(RENDER);
803
811
  // Repo-local log inside .git; the stamp carries the firing site, cwd, and
804
812
  // WHICH binary resolved (shell search order can be shadowed by a
805
813
  // checkout-local exe on Windows).
@@ -812,10 +820,40 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
812
820
  // so the open failure of an unwritable log is itself silent (falsification
813
821
  // round, MAJOR 1).
814
822
  expect(hook).toContain('2>/dev/null >> "$TS_REFRESH_LOG"');
815
- // The child appends to the SAME log so its success line lands after the
816
- // stamp; a stamp with nothing after it = the child never finished.
823
+ // The children append to the SAME log so their output lands after the
824
+ // stamps. Narrowed once a second verb joined the block: child output is
825
+ // unlabelled and interleaves, so a silent tail attributes only to the LAST
826
+ // verb stamped — not per child.
817
827
  expect(hook).toContain('(totem-status refresh-gh >> "$TS_REFRESH_LOG" 2>&1 &)');
818
828
  });
829
+ // ── slice-two residual: refresh-obligation-store beside refresh-gh ──
830
+ // (mmnto-ai/totem-status#127; sibling of mmnto-ai/totem#2556.) The durable
831
+ // obligation store gets the same post-merge moment as the GH snapshot.
832
+ it('also fires refresh-obligation-store under the SAME gate and log (mmnto-ai/totem-status#127)', () => {
833
+ const hook = buildHookContent(RENDER);
834
+ // One gate, not two: both verbs live inside the single presence +
835
+ // primary-checkout `if`, so a worktree/non-adopter skips BOTH.
836
+ const gateIdx = hook.indexOf('if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then');
837
+ expect(gateIdx).toBeGreaterThanOrEqual(0);
838
+ // Anchor on the EXECUTABLE form, not the banner prose above the gate: a
839
+ // mutant that relocates the invocation outside the gate but leaves the
840
+ // comment in place must fail here.
841
+ expect(hook.indexOf('(totem-status refresh-obligation-store')).toBeGreaterThan(gateIdx);
842
+ // …and it stays inside the block, ahead of the gate's closing `fi`.
843
+ expect(hook.indexOf('(totem-status refresh-obligation-store')).toBeLessThan(hook.indexOf('# Only sync when lessons changed'));
844
+ // Both arms of the log-writability branch mirror the refresh-gh pair: the
845
+ // logged form, and the blind fallback when the log cannot be opened.
846
+ expect(hook).toContain('(totem-status refresh-obligation-store >> "$TS_REFRESH_LOG" 2>&1 &)');
847
+ expect(hook).toContain('(totem-status refresh-obligation-store >/dev/null 2>&1 &)');
848
+ // The stamp NAMES the verb, so the log records which verbs fired and in
849
+ // what order (it does not restore per-child reap attribution — child
850
+ // output is unlabelled).
851
+ expect(hook).toContain('post-merge spawn cwd=%s bin=%s verb=%s');
852
+ expect(hook).toContain('"$(command -v totem-status | tr -d \'[:cntrl:]\')" refresh-gh');
853
+ expect(hook).toContain('"$(command -v totem-status | tr -d \'[:cntrl:]\')" refresh-obligation-store');
854
+ // The bounded owned region stays intact after the addition.
855
+ expect(hook.trimEnd().endsWith(`# ${TOTEM_HOOK_END}`)).toBe(true);
856
+ });
819
857
  });
820
858
  // Behavioral coverage of BOTH gate branches (falsification-leg round 1: the string
821
859
  // assertions above are satisfiable without the behavior). POSIX-only: the stub
@@ -830,23 +868,38 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
830
868
  binDir = path.join(tmpDir, 'stub-bin');
831
869
  fs.mkdirSync(binDir);
832
870
  markerPath = path.join(tmpDir, 'refresh-fired.marker');
833
- fs.writeFileSync(path.join(binDir, 'totem-status'), `#!/bin/sh\necho "$1" > "${markerPath}"\n`, { mode: 0o755 });
871
+ // APPEND, not truncate: the gate now fires TWO verbs from one block and the
872
+ // backgrounded children land in whatever order they finish — a `>` stub
873
+ // would race them into a last-writer-wins single line.
874
+ fs.writeFileSync(path.join(binDir, 'totem-status'), `#!/bin/sh\necho "$1" >> "${markerPath}"\n`, { mode: 0o755 });
834
875
  });
835
876
  afterEach(() => {
836
877
  cleanTmpDir(tmpDir);
837
878
  });
838
- /** Wait for the marker to exist AND carry content existence alone races
839
- * the stub's open-truncate-then-write window (observed as a CI flake:
840
- * `expected '' to be 'refresh-gh'`). */
841
- function markerReady() {
879
+ /** The verbs the stub sidecar has been invoked with so far, sorted. Reading
880
+ * CONTENT (not existence) still guards the stub's open-then-write window
881
+ * (observed as a CI flake: `expected '' to be 'refresh-gh'`). */
882
+ function firedVerbs() {
842
883
  try {
843
- return fs.existsSync(markerPath) && fs.readFileSync(markerPath, 'utf-8').trim() !== '';
844
- // totem-context: intentional false on a read race (marker mid-write) — the poll loop retries
884
+ if (!fs.existsSync(markerPath))
885
+ return [];
886
+ return fs
887
+ .readFileSync(markerPath, 'utf-8')
888
+ .split('\n')
889
+ .map((line) => line.trim())
890
+ .filter((line) => line.length > 0)
891
+ .sort();
892
+ // totem-context: intentional [] on a read race (marker mid-write) — the poll loop retries
845
893
  }
846
894
  catch {
847
- return false;
895
+ return [];
848
896
  }
849
897
  }
898
+ /** Both verbs must land: refresh-gh AND the slice-two refresh-obligation-store. */
899
+ const BOTH_VERBS = ['refresh-gh', 'refresh-obligation-store'];
900
+ function markerReady() {
901
+ return firedVerbs().length === BOTH_VERBS.length;
902
+ }
850
903
  async function markerAppears(timeoutMs) {
851
904
  const deadline = Date.now() + timeoutMs;
852
905
  while (Date.now() < deadline) {
@@ -860,14 +913,15 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
860
913
  const repo = path.join(tmpDir, 'repo');
861
914
  fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
862
915
  const hookPath = path.join(repo, 'post-merge');
863
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
916
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
864
917
  execSync('sh ./post-merge', {
865
918
  cwd: repo,
866
919
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
867
920
  stdio: 'ignore',
868
921
  });
869
922
  expect(await markerAppears(5000)).toBe(true);
870
- expect(fs.readFileSync(markerPath, 'utf-8').trim()).toBe('refresh-gh');
923
+ // BOTH verbs fired from the one gate, and nothing else did.
924
+ expect(firedVerbs()).toEqual(BOTH_VERBS);
871
925
  // #2570 observability leg: the firing left a stamp in the repo-local
872
926
  // .git log before the child ran, and bin= carries the RESOLVED path.
873
927
  const logPath = path.join(repo, '.git', 'totem-status-refresh-hook.log');
@@ -875,6 +929,10 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
875
929
  const logText = fs.readFileSync(logPath, 'utf-8');
876
930
  expect(logText).toContain('post-merge spawn cwd=');
877
931
  expect(logText).toMatch(/bin=\S*totem-status/);
932
+ // Each stamp NAMES its verb, so the log records which verbs fired and in
933
+ // what order — the attribution the unlabelled child output cannot give.
934
+ expect(logText).toContain('verb=refresh-gh');
935
+ expect(logText).toContain('verb=refresh-obligation-store');
878
936
  });
879
937
  it('#2570: an unwritable log falls back to blind firing with zero stderr noise', { timeout: 15000 }, async () => {
880
938
  const repo = path.join(tmpDir, 'repo');
@@ -885,7 +943,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
885
943
  // append; falsification round, MAJOR 1).
886
944
  fs.mkdirSync(path.join(repo, '.git', 'totem-status-refresh-hook.log'), { recursive: true });
887
945
  const hookPath = path.join(repo, 'post-merge');
888
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
946
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
889
947
  const result = spawnSync('sh', ['./post-merge'], {
890
948
  cwd: repo,
891
949
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -894,7 +952,8 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
894
952
  expect(result.status).toBe(0);
895
953
  expect(result.stderr ?? '').not.toContain('totem-status-refresh-hook.log');
896
954
  expect(await markerAppears(5000)).toBe(true);
897
- expect(fs.readFileSync(markerPath, 'utf-8').trim()).toBe('refresh-gh');
955
+ // The blind fallback carries BOTH verbs, not just the first.
956
+ expect(firedVerbs()).toEqual(BOTH_VERBS);
898
957
  });
899
958
  it('skips the sidecar when .git is not a directory (worktree/non-git guard)', { timeout: 15000 }, async () => {
900
959
  const repo = path.join(tmpDir, 'repo');
@@ -902,25 +961,28 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
902
961
  // Linked-worktree shape: .git is a pointer FILE, not a directory.
903
962
  fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
904
963
  const hookPath = path.join(repo, 'post-merge');
905
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
964
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
906
965
  execSync('sh ./post-merge', {
907
966
  cwd: repo,
908
967
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
909
968
  stdio: 'ignore',
910
969
  });
911
970
  expect(await markerAppears(500)).toBe(false);
971
+ // The gate covers BOTH verbs — a partial leak (either one firing) is a
972
+ // failure here, not a pass by way of "fewer than two".
973
+ expect(firedVerbs()).toEqual([]);
912
974
  });
913
975
  });
914
976
  describe('sync-log redirect resolves the git dir (worktree-safe)', () => {
915
977
  it('post-merge hook derives the log path from git rev-parse --git-dir', () => {
916
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
978
+ const hook = buildHookContent(RENDER);
917
979
  expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
918
980
  expect(hook).toContain('> "$GIT_DIR_RESOLVED/totem-sync.log"');
919
981
  // The hardcoded `.git/`-as-directory redirect must be gone (ENOTDIR in a worktree).
920
982
  expect(hook).not.toContain('> .git/totem-sync.log');
921
983
  });
922
984
  it('post-checkout hook derives the log path from git rev-parse --git-dir on both branches', () => {
923
- const hook = buildPostCheckoutHookContent('pnpm dlx @mmnto/cli');
985
+ const hook = buildPostCheckoutHookContent(RENDER);
924
986
  expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
925
987
  // Both the null-SHA and the branch-diff redirect use the resolved dir.
926
988
  expect(hook.match(/> "\$GIT_DIR_RESOLVED\/totem-sync\.log"/g)).toHaveLength(2);
@@ -934,13 +996,19 @@ describe('upgradePrePushHookIfNeeded', () => {
934
996
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hooks-upgrade-'));
935
997
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
936
998
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
999
+ // Pin the repo-local render options: since mmnto-ai/totem#2692 the upgrade
1000
+ // path resolves `hooks.tier` from config, and a config-less temp repo would
1001
+ // fall through to the developer's global `~/.totem/` profile — a global
1002
+ // `hooks.tier: strict` would then break the standard-tier expectation below
1003
+ // (CodeRabbit on mmnto-ai/totem#2701).
1004
+ fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets:\n - glob: "docs/*.md"\n type: lesson\n strategy: markdown-heading\nhooks:\n tier: standard\n', 'utf-8');
937
1005
  });
938
1006
  afterEach(() => {
939
1007
  cleanTmpDir(tmpDir);
940
1008
  });
941
1009
  /**
942
1010
  * Helper: extract the totem block from a hook file and compare it against
943
- * the canonical output of buildPrePushHook('pnpm dlx @mmnto/cli') (shebang stripped, trimmed).
1011
+ * the canonical output of buildPrePushHook(RENDER) (shebang stripped, trimmed).
944
1012
  * Catches stale shell fragments or splice boundary bugs that toContain would miss.
945
1013
  * The stateless format is now bounded by the pre-push end marker, so the block is the
946
1014
  * span from the start-marker comment through the end-marker comment line inclusive —
@@ -958,11 +1026,11 @@ describe('upgradePrePushHookIfNeeded', () => {
958
1026
  }
959
1027
  /** Canonical totem block: shebang stripped, trimmed — the expected upgrade output. */
960
1028
  function expectedTotemBlock() {
961
- return buildPrePushHook(getFallbackCommand(tmpDir))
1029
+ return buildPrePushHook({ ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) })
962
1030
  .replace(/^#!\/bin\/sh\n/, '')
963
1031
  .trim();
964
1032
  }
965
- it('upgrades old command-executing hook to stateless format', () => {
1033
+ it('upgrades old command-executing hook to stateless format', async () => {
966
1034
  // Install an old-style hook that executes $TOTEM_CMD lint
967
1035
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
968
1036
  fs.mkdirSync(hooksDir, { recursive: true });
@@ -978,7 +1046,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
978
1046
  fi
979
1047
  `;
980
1048
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
981
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1049
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
982
1050
  expect(upgraded).toBe(true);
983
1051
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
984
1052
  expect(content).toContain('verify-manifest');
@@ -990,41 +1058,41 @@ fi
990
1058
  const actual = extractTotemBlock(content);
991
1059
  expect(actual).toBe(expectedTotemBlock());
992
1060
  });
993
- it('skips hook without totem marker', () => {
1061
+ it('skips hook without totem marker', async () => {
994
1062
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
995
1063
  fs.mkdirSync(hooksDir, { recursive: true });
996
1064
  const userHook = '#!/bin/sh\necho "user hook"\n';
997
1065
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), userHook);
998
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1066
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
999
1067
  expect(upgraded).toBe(false);
1000
1068
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1001
1069
  expect(content).toBe(userHook); // File untouched
1002
1070
  });
1003
- it('skips hook that already uses stateless format', () => {
1071
+ it('skips hook that already uses stateless format', async () => {
1004
1072
  // Install the current-version hook via non-interactive installer
1005
- installHooksNonInteractive(tmpDir);
1073
+ await installHooksNonInteractive(tmpDir);
1006
1074
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1007
1075
  const beforeContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1008
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1076
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1009
1077
  expect(upgraded).toBe(false);
1010
1078
  const afterContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1011
1079
  expect(afterContent).toBe(beforeContent); // File untouched
1012
1080
  });
1013
- it('returns false when no pre-push hook exists', () => {
1014
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1081
+ it('returns false when no pre-push hook exists', async () => {
1082
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1015
1083
  expect(upgraded).toBe(false);
1016
1084
  });
1017
- it('returns false when not a git repo', () => {
1085
+ it('returns false when not a git repo', async () => {
1018
1086
  const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-no-git-'));
1019
1087
  try {
1020
- const upgraded = upgradePrePushHookIfNeeded(nonGitDir);
1088
+ const upgraded = await upgradePrePushHookIfNeeded(nonGitDir);
1021
1089
  expect(upgraded).toBe(false);
1022
1090
  }
1023
1091
  finally {
1024
1092
  cleanTmpDir(nonGitDir);
1025
1093
  }
1026
1094
  });
1027
- it('preserves user-appended content when upgrading', () => {
1095
+ it('preserves user-appended content when upgrading', async () => {
1028
1096
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1029
1097
  fs.mkdirSync(hooksDir, { recursive: true });
1030
1098
  // Simulate an old totem hook with user content appended after it
@@ -1039,7 +1107,7 @@ fi
1039
1107
  `;
1040
1108
  const userAppended = '\n# My custom deploy notification\ncurl -X POST https://hooks.example.com/deploy\n';
1041
1109
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userAppended);
1042
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1110
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1043
1111
  expect(upgraded).toBe(true);
1044
1112
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1045
1113
  // New totem block should use stateless format
@@ -1053,7 +1121,7 @@ fi
1053
1121
  const actual = extractTotemBlock(content);
1054
1122
  expect(actual).toBe(expectedTotemBlock());
1055
1123
  });
1056
- it('preserves user-appended if/fi blocks without corrupting them', () => {
1124
+ it('preserves user-appended if/fi blocks without corrupting them', async () => {
1057
1125
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1058
1126
  fs.mkdirSync(hooksDir, { recursive: true });
1059
1127
  // Old totem block (needs upgrade) PLUS user content that contains its own if/fi structures
@@ -1084,7 +1152,7 @@ if [ -n "$SLACK_WEBHOOK" ]; then
1084
1152
  fi
1085
1153
  `;
1086
1154
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userIfFiBlock);
1087
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1155
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1088
1156
  expect(upgraded).toBe(true);
1089
1157
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1090
1158
  // Totem block must match canonical output exactly
@@ -1102,7 +1170,7 @@ fi
1102
1170
  const userBlockStart = content.indexOf('# Custom deploy guard');
1103
1171
  expect(userBlockStart).toBeGreaterThan(totemBlockEnd);
1104
1172
  });
1105
- it('leaves no stale fi or orphaned shell fragments after upgrade', () => {
1173
+ it('leaves no stale fi or orphaned shell fragments after upgrade', async () => {
1106
1174
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1107
1175
  fs.mkdirSync(hooksDir, { recursive: true });
1108
1176
  const oldHook = `#!/bin/sh
@@ -1117,7 +1185,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1117
1185
  fi
1118
1186
  `;
1119
1187
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1120
- upgradePrePushHookIfNeeded(tmpDir);
1188
+ await upgradePrePushHookIfNeeded(tmpDir);
1121
1189
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1122
1190
  // Count if/fi balance: every `if` must have a matching `fi`.
1123
1191
  // Inline `if ... fi` on a single line are self-balanced and excluded from both counts.
@@ -1133,7 +1201,7 @@ fi
1133
1201
  const actual = extractTotemBlock(content);
1134
1202
  expect(actual).toBe(expectedTotemBlock());
1135
1203
  });
1136
- it('upgrades old flag-checking hook to stateless format', () => {
1204
+ it('upgrades old flag-checking hook to stateless format', async () => {
1137
1205
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1138
1206
  fs.mkdirSync(hooksDir, { recursive: true });
1139
1207
  // Simulate the previous flag-checking format that used .lint-passed
@@ -1154,7 +1222,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1154
1222
  fi
1155
1223
  `;
1156
1224
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1157
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1225
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1158
1226
  expect(upgraded).toBe(true);
1159
1227
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1160
1228
  expect(content).toContain('verify-manifest');
@@ -1164,7 +1232,7 @@ fi
1164
1232
  const actual = extractTotemBlock(content);
1165
1233
  expect(actual).toBe(expectedTotemBlock());
1166
1234
  });
1167
- it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', () => {
1235
+ it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', async () => {
1168
1236
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1169
1237
  fs.mkdirSync(hooksDir, { recursive: true });
1170
1238
  // Simulate a hook with the auto-refresh logic still using $TOTEM_CMD
@@ -1194,7 +1262,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1194
1262
  fi
1195
1263
  `;
1196
1264
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1197
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1265
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1198
1266
  expect(upgraded).toBe(true);
1199
1267
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1200
1268
  expect(content).toContain('verify-manifest');
@@ -1209,7 +1277,7 @@ fi
1209
1277
  // ─── Enforcement tier: agent detection & strict mode ──
1210
1278
  describe('buildPreCommitHook agent detection', () => {
1211
1279
  it('includes agent detection snippet', () => {
1212
- const hook = buildPreCommitHook();
1280
+ const hook = buildPreCommitHook(RENDER);
1213
1281
  expect(hook).toContain('is_agent=0');
1214
1282
  expect(hook).toContain('is_agent=1');
1215
1283
  expect(hook).toContain('CLAUDE_CODE_AGENT');
@@ -1218,52 +1286,270 @@ describe('buildPreCommitHook agent detection', () => {
1218
1286
  // GEMINI_API_KEY intentionally excluded — human devs export it for Totem's embedding provider
1219
1287
  });
1220
1288
  it('includes TOTEM_HOOK_TIER variable', () => {
1221
- const hook = buildPreCommitHook();
1289
+ const hook = buildPreCommitHook(RENDER);
1222
1290
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1223
1291
  });
1224
1292
  it('sets TOTEM_HOOK_TIER to strict when tier is strict', () => {
1225
- const hook = buildPreCommitHook('strict');
1293
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1226
1294
  expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
1227
1295
  });
1228
1296
  });
1229
1297
  describe('buildPreCommitHook with strict tier', () => {
1230
- it('includes spec-completed check', () => {
1231
- const hook = buildPreCommitHook('strict');
1232
- expect(hook).toContain('.spec-completed');
1298
+ it('gates on spec evidence and never on the retired hand-set marker', () => {
1299
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1300
+ // The former `.totem/cache/.spec-completed` sentinel (mmnto-ai/totem#2690)
1301
+ // may be NAMED in a comment that says it is not honored, but no test-able
1302
+ // path may read it.
1303
+ expect(hook).not.toMatch(/-f\s+"?\.totem\/cache\/\.spec-completed/);
1233
1304
  expect(hook).toContain("Run 'totem spec <issue>' before committing (strict mode)");
1234
1305
  });
1235
1306
  it('gates spec check on agent detection or strict tier', () => {
1236
- const hook = buildPreCommitHook('strict');
1307
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1237
1308
  expect(hook).toContain('$is_agent');
1238
1309
  expect(hook).toContain('$TOTEM_HOOK_TIER');
1239
1310
  });
1311
+ it('gates on the totem spec run artifact, JSON-aware — the only evidence', () => {
1312
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1313
+ expect(hook).toContain('.totem/artifacts/runs');
1314
+ expect(hook).toContain('admission.runMetadata.caller');
1315
+ expect(hook).toContain('spec evidence:');
1316
+ expect(hook).not.toContain('(legacy marker)');
1317
+ });
1318
+ });
1319
+ // The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
1320
+ // above pin the text; these pin the behavior the gate exists for. `git init` +
1321
+ // a feature branch so the protected-branch block does not fire; the agent
1322
+ // env var arms the strict tier the way an agent commit does.
1323
+ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/totem#2690)', () => {
1324
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
1325
+ let tmpDir;
1326
+ beforeEach(() => {
1327
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-evidence-'));
1328
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
1329
+ execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
1330
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
1331
+ });
1332
+ afterEach(() => {
1333
+ cleanTmpDir(tmpDir);
1334
+ });
1335
+ function runHook() {
1336
+ const r = spawnSync('sh', ['./pre-commit'], {
1337
+ cwd: tmpDir,
1338
+ encoding: 'utf-8',
1339
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
1340
+ });
1341
+ return { status: r.status, stdout: r.stdout };
1342
+ }
1343
+ function writeRun(name, artifact) {
1344
+ const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
1345
+ fs.mkdirSync(dir, { recursive: true });
1346
+ fs.writeFileSync(path.join(dir, name), typeof artifact === 'string' ? artifact : JSON.stringify(artifact, null, 2));
1347
+ }
1348
+ // A `node` shim that fails: first on PATH, so the hook's reader cannot run.
1349
+ // Exercises the reader-failure arm without touching the real runtime.
1350
+ function shimNodeExiting(status) {
1351
+ const shimDir = path.join(tmpDir, 'node-shim');
1352
+ fs.mkdirSync(shimDir, { recursive: true });
1353
+ fs.writeFileSync(path.join(shimDir, 'node'), `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 });
1354
+ return {
1355
+ ...process.env,
1356
+ CLAUDE_CODE_AGENT: '1',
1357
+ PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`,
1358
+ };
1359
+ }
1360
+ function runHookWith(env) {
1361
+ const r = spawnSync('sh', ['./pre-commit'], { cwd: tmpDir, encoding: 'utf-8', env });
1362
+ return { status: r.status, stdout: r.stdout };
1363
+ }
1364
+ it.skipIf(!shellOk)('reports a reader that cannot run DISTINCTLY from missing evidence, and still fails closed', () => {
1365
+ const r = runHookWith(shimNodeExiting(5));
1366
+ expect(r.status).toBe(1);
1367
+ expect(r.stdout).toContain('[Totem] BLOCKED: the spec-evidence reader could not run (node exit status 5');
1368
+ expect(r.stdout).not.toContain('no totem spec run artifact');
1369
+ });
1370
+ it.skipIf(!shellOk)('a hand-set marker does not rescue a reader that cannot run — still the distinct failure, still closed', () => {
1371
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1372
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1373
+ const r = runHookWith(shimNodeExiting(127));
1374
+ expect(r.status).toBe(1);
1375
+ expect(r.stdout).toContain('the spec-evidence reader could not run (node exit status 127');
1376
+ expect(r.stdout).not.toContain('legacy');
1377
+ });
1378
+ it.skipIf(!shellOk)('blocks when this checkout carries no evidence at all', () => {
1379
+ const r = runHook();
1380
+ expect(r.status).toBe(1);
1381
+ expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
1382
+ expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1383
+ });
1384
+ it.skipIf(!shellOk)('passes on a spec run artifact and prints the evidence line with its age', () => {
1385
+ writeRun('a.json', {
1386
+ admission: { runMetadata: { caller: 'spec' } },
1387
+ createdAt: new Date().toISOString(),
1388
+ });
1389
+ const r = runHook();
1390
+ expect(r.status).toBe(0);
1391
+ expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
1392
+ expect(r.stdout).toContain('0 days old');
1393
+ });
1394
+ it.skipIf(!shellOk)('does NOT pass on a review artifact that merely CARRIES a caller:spec object below the top level (the substring hazard)', () => {
1395
+ // A substring read of the run store would match this file: the key
1396
+ // spelling appears verbatim, as a carried OBJECT under inputBundle (the
1397
+ // shape a review over run metadata produces). Only the TOP-LEVEL
1398
+ // admission.runMetadata.caller is evidence. (Carried TEXT — a prompt that
1399
+ // quotes the key — is JSON-escaped on disk and never matches either read;
1400
+ // the carried-object form is the real hazard, so it is the control.)
1401
+ writeRun('r.json', {
1402
+ admission: { runMetadata: { caller: 'review' } },
1403
+ inputBundle: { runMetadata: { caller: 'spec' } },
1404
+ createdAt: new Date().toISOString(),
1405
+ });
1406
+ const onDisk = fs.readFileSync(path.join(tmpDir, '.totem/artifacts/runs/r.json'), 'utf-8');
1407
+ // Mutation check on the control itself: the naive pattern WOULD match.
1408
+ expect(/"caller": *"spec"/.test(onDisk)).toBe(true);
1409
+ const r = runHook();
1410
+ expect(r.status).toBe(1);
1411
+ expect(r.stdout).toContain('BLOCKED');
1412
+ });
1413
+ it.skipIf(!shellOk)('ignores the retired hand-set marker — a marker alone is BLOCKED', () => {
1414
+ // No CLI path ever wrote `.totem/cache/.spec-completed`; honoring it would be
1415
+ // compatibility with a hand hack, not with a user (operator ruling
1416
+ // 2026-08-29, mmnto-ai/totem#2690).
1417
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1418
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1419
+ const r = runHook();
1420
+ expect(r.status).toBe(1);
1421
+ expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1422
+ expect(r.stdout).not.toContain('spec evidence:');
1423
+ });
1424
+ it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
1425
+ writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
1426
+ writeRun('ok.json', {
1427
+ admission: { runMetadata: { caller: 'spec' } },
1428
+ createdAt: new Date().toISOString(),
1429
+ });
1430
+ const r = runHook();
1431
+ expect(r.status).toBe(0);
1432
+ expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
1433
+ });
1434
+ it.skipIf(!shellOk)('blocks when the only artifact is torn', () => {
1435
+ writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
1436
+ const r = runHook();
1437
+ expect(r.status).toBe(1);
1438
+ });
1439
+ it.skipIf(!shellOk)('names the NEWEST spec artifact by its own createdAt', () => {
1440
+ writeRun('old.json', {
1441
+ admission: { runMetadata: { caller: 'spec' } },
1442
+ createdAt: '2026-01-01T00:00:00.000Z',
1443
+ });
1444
+ writeRun('new.json', {
1445
+ admission: { runMetadata: { caller: 'spec' } },
1446
+ createdAt: new Date().toISOString(),
1447
+ });
1448
+ const r = runHook();
1449
+ expect(r.status).toBe(0);
1450
+ expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
1451
+ expect(r.stdout).not.toContain('old.json');
1452
+ });
1453
+ });
1454
+ // The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
1455
+ // hook's reader and `totem spec`'s writer must name the SAME tree. The fixture
1456
+ // writes where the WRITER writes (`<totemDir>/artifacts/runs`, the
1457
+ // `path.join(configRoot, config.totemDir)/artifacts/runs` the run store uses) and
1458
+ // the hook is rendered at that same totemDir — so a pass here is the two halves
1459
+ // agreeing, and the control below is the pre-fix behavior (evidence under
1460
+ // `.totem/` while the hook reads `knowledge/`) still failing closed.
1461
+ describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — executed under sh (mmnto-ai/totem#2692)', () => {
1462
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
1463
+ const CUSTOM_TOTEM_DIR = 'knowledge';
1464
+ let tmpDir;
1465
+ beforeEach(() => {
1466
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-customdir-'));
1467
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
1468
+ execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
1469
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: CUSTOM_TOTEM_DIR }));
1470
+ });
1471
+ afterEach(() => {
1472
+ cleanTmpDir(tmpDir);
1473
+ });
1474
+ function runHook() {
1475
+ const r = spawnSync('sh', ['./pre-commit'], {
1476
+ cwd: tmpDir,
1477
+ encoding: 'utf-8',
1478
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
1479
+ });
1480
+ return { status: r.status, stdout: r.stdout };
1481
+ }
1482
+ /** Write a run artifact where the WRITER writes it, for an arbitrary totemDir. */
1483
+ function writeRunUnder(totemDir, name, artifact) {
1484
+ const dir = path.join(tmpDir, ...totemDir.split('/'), 'artifacts', 'runs');
1485
+ fs.mkdirSync(dir, { recursive: true });
1486
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
1487
+ }
1488
+ const specArtifact = () => ({
1489
+ admission: { runMetadata: { caller: 'spec' } },
1490
+ createdAt: new Date().toISOString(),
1491
+ });
1492
+ it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
1493
+ writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
1494
+ const r = runHook();
1495
+ expect(r.status).toBe(0);
1496
+ expect(r.stdout).toContain(`[Totem] spec evidence: ${CUSTOM_TOTEM_DIR}/artifacts/runs/a.json (`);
1497
+ });
1498
+ it.skipIf(!shellOk)('the pre-fix control: evidence under .totem/ does NOT satisfy a hook rendered for knowledge/', () => {
1499
+ // This is the mmnto-ai/totem#2692 defect, inverted: before the fix the hook
1500
+ // ALWAYS read `.totem/artifacts/runs` while the writer used the configured
1501
+ // dir, so the gate failed closed forever. With the hook rendered at the
1502
+ // configured dir, evidence in the WRONG tree is correctly not evidence.
1503
+ writeRunUnder('.totem', 'a.json', specArtifact());
1504
+ const r = runHook();
1505
+ expect(r.status).toBe(1);
1506
+ expect(r.stdout).toContain(`no totem spec run artifact under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
1507
+ });
1508
+ it.skipIf(!shellOk)('blocks with a message naming the configured directory', () => {
1509
+ const r = runHook();
1510
+ expect(r.status).toBe(1);
1511
+ expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
1512
+ expect(r.stdout).toContain(`under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
1513
+ expect(r.stdout).not.toContain('.totem');
1514
+ });
1515
+ it.skipIf(!shellOk)('a nested totemDir survives the same round trip', () => {
1516
+ const nested = 'var/totem-state';
1517
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: nested }));
1518
+ writeRunUnder(nested, 'a.json', specArtifact());
1519
+ const r = runHook();
1520
+ expect(r.status).toBe(0);
1521
+ expect(r.stdout).toContain(`[Totem] spec evidence: ${nested}/artifacts/runs/a.json (`);
1522
+ });
1240
1523
  });
1241
1524
  describe('buildPreCommitHook with standard tier', () => {
1242
- it('includes spec-completed check guarded by agent/tier condition', () => {
1243
- const hook = buildPreCommitHook('standard');
1244
- expect(hook).toContain('.spec-completed');
1525
+ it('includes the spec-evidence check guarded by agent/tier condition', () => {
1526
+ const hook = buildPreCommitHook(RENDER);
1527
+ expect(hook).toContain('.totem/artifacts/runs');
1245
1528
  expect(hook).toContain('is_agent');
1246
1529
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1247
1530
  });
1248
- it('defaults to standard tier when no tier specified', () => {
1249
- const hook = buildPreCommitHook();
1531
+ // Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
1532
+ // specified". There is no default any more — the options object is required and
1533
+ // the RESOLVER supplies 'standard' — so the surviving intent is that the
1534
+ // standard-tier render still names the run store the resolver hands it.
1535
+ it('renders the run store at the options it was given', () => {
1536
+ const hook = buildPreCommitHook(RENDER);
1250
1537
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1251
- expect(hook).toContain('.spec-completed');
1538
+ expect(hook).toContain('.totem/artifacts/runs');
1252
1539
  });
1253
1540
  });
1254
1541
  describe('buildPrePushHook with strict tier', () => {
1255
- const FALLBACK = 'pnpm dlx @mmnto/cli';
1256
1542
  it('includes shield gate', () => {
1257
- const hook = buildPrePushHook(FALLBACK, 'strict');
1543
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1258
1544
  expect(hook).toContain('review');
1259
1545
  expect(hook).toContain('shield gate (strict mode)');
1260
1546
  });
1261
1547
  it('includes TOTEM_HOOK_TIER set to strict', () => {
1262
- const hook = buildPrePushHook(FALLBACK, 'strict');
1548
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1263
1549
  expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
1264
1550
  });
1265
1551
  it('includes agent detection snippet', () => {
1266
- const hook = buildPrePushHook(FALLBACK, 'strict');
1552
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1267
1553
  expect(hook).toContain('is_agent=0');
1268
1554
  expect(hook).toContain('CLAUDE_CODE_AGENT');
1269
1555
  });
@@ -1271,12 +1557,12 @@ describe('buildPrePushHook with strict tier', () => {
1271
1557
  // shieldBlock alongside the existing `totem review` gate. Repo-state
1272
1558
  // checks (like checkAgentsMdCanonical) now block push when they fail.
1273
1559
  it('includes $TOTEM_CMD doctor --strict in the shield block', () => {
1274
- const hook = buildPrePushHook(FALLBACK, 'strict');
1560
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1275
1561
  expect(hook).toContain('$TOTEM_CMD doctor --strict');
1276
1562
  expect(hook).toContain('doctor --strict (repo-state gate)');
1277
1563
  });
1278
1564
  it('gates doctor --strict on the agent/strict guard (does NOT fire unconditionally)', () => {
1279
- const hook = buildPrePushHook(FALLBACK, 'strict');
1565
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1280
1566
  // Find the position of the doctor --strict invocation and the surrounding
1281
1567
  // guard; assert the invocation lives INSIDE the agent-or-tier block.
1282
1568
  const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
@@ -1288,20 +1574,22 @@ describe('buildPrePushHook with strict tier', () => {
1288
1574
  });
1289
1575
  });
1290
1576
  describe('buildPrePushHook with standard tier', () => {
1291
- const FALLBACK = 'pnpm dlx @mmnto/cli';
1292
1577
  it('includes shield gate guarded by agent/tier condition', () => {
1293
- const hook = buildPrePushHook(FALLBACK, 'standard');
1578
+ const hook = buildPrePushHook(RENDER);
1294
1579
  expect(hook).toContain('shield gate');
1295
1580
  expect(hook).toContain('is_agent');
1296
1581
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1297
1582
  });
1298
- it('defaults to standard tier when no tier specified', () => {
1299
- const hook = buildPrePushHook(FALLBACK);
1583
+ // Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
1584
+ // specified" the builders take a REQUIRED options object now, so the
1585
+ // surviving intent is that a standard-tier render stamps the standard tier.
1586
+ it('stamps the standard tier it was given', () => {
1587
+ const hook = buildPrePushHook(RENDER);
1300
1588
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1301
1589
  expect(hook).toContain('shield gate');
1302
1590
  });
1303
1591
  it('still includes agent detection', () => {
1304
- const hook = buildPrePushHook(FALLBACK, 'standard');
1592
+ const hook = buildPrePushHook(RENDER);
1305
1593
  expect(hook).toContain('is_agent=0');
1306
1594
  });
1307
1595
  // mmnto-ai/totem#1908 — even in standard tier, the doctor --strict line
@@ -1310,7 +1598,7 @@ describe('buildPrePushHook with standard tier', () => {
1310
1598
  // operators bypass it because their `is_agent=0` and `TOTEM_HOOK_TIER` is
1311
1599
  // standard, so the guard branch never enters.
1312
1600
  it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
1313
- const hook = buildPrePushHook(FALLBACK, 'standard');
1601
+ const hook = buildPrePushHook(RENDER);
1314
1602
  const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
1315
1603
  const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
1316
1604
  const fiCloseIdx = hook.indexOf('fi', guardIdx);
@@ -1321,7 +1609,7 @@ describe('buildPrePushHook with standard tier', () => {
1321
1609
  });
1322
1610
  describe('agent detection uses POSIX syntax', () => {
1323
1611
  it('pre-commit hook has no bashisms', () => {
1324
- const hook = buildPreCommitHook('strict');
1612
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1325
1613
  // Must use [ ] not [[ ]]
1326
1614
  expect(hook).not.toContain('[[');
1327
1615
  expect(hook).not.toContain(']]');
@@ -1331,7 +1619,7 @@ describe('agent detection uses POSIX syntax', () => {
1331
1619
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
1332
1620
  });
1333
1621
  it('pre-push hook has no bashisms', () => {
1334
- const hook = buildPrePushHook('pnpm dlx @mmnto/cli', 'strict');
1622
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1335
1623
  // Must use [ ] not [[ ]]
1336
1624
  expect(hook).not.toContain('[[');
1337
1625
  expect(hook).not.toContain(']]');