@mmnto/cli 1.120.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 (65) 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/hook-totemdir-render.test.d.ts +23 -0
  30. package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
  31. package/dist/commands/hook-totemdir-render.test.js +288 -0
  32. package/dist/commands/hook-totemdir-render.test.js.map +1 -0
  33. package/dist/commands/init-templates.d.ts +4 -4
  34. package/dist/commands/init-templates.d.ts.map +1 -1
  35. package/dist/commands/init-templates.js +3 -3
  36. package/dist/commands/install-hooks-exit-contract.test.js +6 -6
  37. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  38. package/dist/commands/install-hooks.d.ts +162 -12
  39. package/dist/commands/install-hooks.d.ts.map +1 -1
  40. package/dist/commands/install-hooks.js +355 -88
  41. package/dist/commands/install-hooks.js.map +1 -1
  42. package/dist/commands/install-hooks.test.js +381 -147
  43. package/dist/commands/install-hooks.test.js.map +1 -1
  44. package/dist/commands/link.d.ts.map +1 -1
  45. package/dist/commands/link.js +71 -16
  46. package/dist/commands/link.js.map +1 -1
  47. package/dist/commands/link.test.d.ts +9 -0
  48. package/dist/commands/link.test.d.ts.map +1 -0
  49. package/dist/commands/link.test.js +92 -0
  50. package/dist/commands/link.test.js.map +1 -0
  51. package/dist/commands/mail.d.ts +48 -2
  52. package/dist/commands/mail.d.ts.map +1 -1
  53. package/dist/commands/mail.js +76 -2
  54. package/dist/commands/mail.js.map +1 -1
  55. package/dist/commands/mail.test.js +177 -0
  56. package/dist/commands/mail.test.js.map +1 -1
  57. package/dist/commands/pre-push-gate-matrix.test.js +9 -2
  58. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
  59. package/dist/commands/shield.js +1 -1
  60. package/dist/commands/shield.js.map +1 -1
  61. package/dist/commands/tools-hook-parity.test.js +7 -3
  62. package/dist/commands/tools-hook-parity.test.js.map +1 -1
  63. package/dist/index.js +9 -0
  64. package/dist/index.js.map +1 -1
  65. 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).
@@ -822,7 +830,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
822
830
  // (mmnto-ai/totem-status#127; sibling of mmnto-ai/totem#2556.) The durable
823
831
  // obligation store gets the same post-merge moment as the GH snapshot.
824
832
  it('also fires refresh-obligation-store under the SAME gate and log (mmnto-ai/totem-status#127)', () => {
825
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
833
+ const hook = buildHookContent(RENDER);
826
834
  // One gate, not two: both verbs live inside the single presence +
827
835
  // primary-checkout `if`, so a worktree/non-adopter skips BOTH.
828
836
  const gateIdx = hook.indexOf('if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then');
@@ -905,7 +913,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
905
913
  const repo = path.join(tmpDir, 'repo');
906
914
  fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
907
915
  const hookPath = path.join(repo, 'post-merge');
908
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
916
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
909
917
  execSync('sh ./post-merge', {
910
918
  cwd: repo,
911
919
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -935,7 +943,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
935
943
  // append; falsification round, MAJOR 1).
936
944
  fs.mkdirSync(path.join(repo, '.git', 'totem-status-refresh-hook.log'), { recursive: true });
937
945
  const hookPath = path.join(repo, 'post-merge');
938
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
946
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
939
947
  const result = spawnSync('sh', ['./post-merge'], {
940
948
  cwd: repo,
941
949
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -953,7 +961,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
953
961
  // Linked-worktree shape: .git is a pointer FILE, not a directory.
954
962
  fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
955
963
  const hookPath = path.join(repo, 'post-merge');
956
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
964
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
957
965
  execSync('sh ./post-merge', {
958
966
  cwd: repo,
959
967
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -967,14 +975,14 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
967
975
  });
968
976
  describe('sync-log redirect resolves the git dir (worktree-safe)', () => {
969
977
  it('post-merge hook derives the log path from git rev-parse --git-dir', () => {
970
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
978
+ const hook = buildHookContent(RENDER);
971
979
  expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
972
980
  expect(hook).toContain('> "$GIT_DIR_RESOLVED/totem-sync.log"');
973
981
  // The hardcoded `.git/`-as-directory redirect must be gone (ENOTDIR in a worktree).
974
982
  expect(hook).not.toContain('> .git/totem-sync.log');
975
983
  });
976
984
  it('post-checkout hook derives the log path from git rev-parse --git-dir on both branches', () => {
977
- const hook = buildPostCheckoutHookContent('pnpm dlx @mmnto/cli');
985
+ const hook = buildPostCheckoutHookContent(RENDER);
978
986
  expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
979
987
  // Both the null-SHA and the branch-diff redirect use the resolved dir.
980
988
  expect(hook.match(/> "\$GIT_DIR_RESOLVED\/totem-sync\.log"/g)).toHaveLength(2);
@@ -988,13 +996,19 @@ describe('upgradePrePushHookIfNeeded', () => {
988
996
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hooks-upgrade-'));
989
997
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
990
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');
991
1005
  });
992
1006
  afterEach(() => {
993
1007
  cleanTmpDir(tmpDir);
994
1008
  });
995
1009
  /**
996
1010
  * Helper: extract the totem block from a hook file and compare it against
997
- * the canonical output of buildPrePushHook('pnpm dlx @mmnto/cli') (shebang stripped, trimmed).
1011
+ * the canonical output of buildPrePushHook(RENDER) (shebang stripped, trimmed).
998
1012
  * Catches stale shell fragments or splice boundary bugs that toContain would miss.
999
1013
  * The stateless format is now bounded by the pre-push end marker, so the block is the
1000
1014
  * span from the start-marker comment through the end-marker comment line inclusive —
@@ -1012,11 +1026,11 @@ describe('upgradePrePushHookIfNeeded', () => {
1012
1026
  }
1013
1027
  /** Canonical totem block: shebang stripped, trimmed — the expected upgrade output. */
1014
1028
  function expectedTotemBlock() {
1015
- return buildPrePushHook(getFallbackCommand(tmpDir))
1029
+ return buildPrePushHook({ ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) })
1016
1030
  .replace(/^#!\/bin\/sh\n/, '')
1017
1031
  .trim();
1018
1032
  }
1019
- it('upgrades old command-executing hook to stateless format', () => {
1033
+ it('upgrades old command-executing hook to stateless format', async () => {
1020
1034
  // Install an old-style hook that executes $TOTEM_CMD lint
1021
1035
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1022
1036
  fs.mkdirSync(hooksDir, { recursive: true });
@@ -1032,7 +1046,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1032
1046
  fi
1033
1047
  `;
1034
1048
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1035
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1049
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1036
1050
  expect(upgraded).toBe(true);
1037
1051
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1038
1052
  expect(content).toContain('verify-manifest');
@@ -1044,41 +1058,41 @@ fi
1044
1058
  const actual = extractTotemBlock(content);
1045
1059
  expect(actual).toBe(expectedTotemBlock());
1046
1060
  });
1047
- it('skips hook without totem marker', () => {
1061
+ it('skips hook without totem marker', async () => {
1048
1062
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1049
1063
  fs.mkdirSync(hooksDir, { recursive: true });
1050
1064
  const userHook = '#!/bin/sh\necho "user hook"\n';
1051
1065
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), userHook);
1052
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1066
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1053
1067
  expect(upgraded).toBe(false);
1054
1068
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1055
1069
  expect(content).toBe(userHook); // File untouched
1056
1070
  });
1057
- it('skips hook that already uses stateless format', () => {
1071
+ it('skips hook that already uses stateless format', async () => {
1058
1072
  // Install the current-version hook via non-interactive installer
1059
- installHooksNonInteractive(tmpDir);
1073
+ await installHooksNonInteractive(tmpDir);
1060
1074
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1061
1075
  const beforeContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1062
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1076
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1063
1077
  expect(upgraded).toBe(false);
1064
1078
  const afterContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1065
1079
  expect(afterContent).toBe(beforeContent); // File untouched
1066
1080
  });
1067
- it('returns false when no pre-push hook exists', () => {
1068
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1081
+ it('returns false when no pre-push hook exists', async () => {
1082
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1069
1083
  expect(upgraded).toBe(false);
1070
1084
  });
1071
- it('returns false when not a git repo', () => {
1085
+ it('returns false when not a git repo', async () => {
1072
1086
  const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-no-git-'));
1073
1087
  try {
1074
- const upgraded = upgradePrePushHookIfNeeded(nonGitDir);
1088
+ const upgraded = await upgradePrePushHookIfNeeded(nonGitDir);
1075
1089
  expect(upgraded).toBe(false);
1076
1090
  }
1077
1091
  finally {
1078
1092
  cleanTmpDir(nonGitDir);
1079
1093
  }
1080
1094
  });
1081
- it('preserves user-appended content when upgrading', () => {
1095
+ it('preserves user-appended content when upgrading', async () => {
1082
1096
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1083
1097
  fs.mkdirSync(hooksDir, { recursive: true });
1084
1098
  // Simulate an old totem hook with user content appended after it
@@ -1093,7 +1107,7 @@ fi
1093
1107
  `;
1094
1108
  const userAppended = '\n# My custom deploy notification\ncurl -X POST https://hooks.example.com/deploy\n';
1095
1109
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userAppended);
1096
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1110
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1097
1111
  expect(upgraded).toBe(true);
1098
1112
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1099
1113
  // New totem block should use stateless format
@@ -1107,7 +1121,7 @@ fi
1107
1121
  const actual = extractTotemBlock(content);
1108
1122
  expect(actual).toBe(expectedTotemBlock());
1109
1123
  });
1110
- it('preserves user-appended if/fi blocks without corrupting them', () => {
1124
+ it('preserves user-appended if/fi blocks without corrupting them', async () => {
1111
1125
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1112
1126
  fs.mkdirSync(hooksDir, { recursive: true });
1113
1127
  // Old totem block (needs upgrade) PLUS user content that contains its own if/fi structures
@@ -1138,7 +1152,7 @@ if [ -n "$SLACK_WEBHOOK" ]; then
1138
1152
  fi
1139
1153
  `;
1140
1154
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userIfFiBlock);
1141
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1155
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1142
1156
  expect(upgraded).toBe(true);
1143
1157
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1144
1158
  // Totem block must match canonical output exactly
@@ -1156,7 +1170,7 @@ fi
1156
1170
  const userBlockStart = content.indexOf('# Custom deploy guard');
1157
1171
  expect(userBlockStart).toBeGreaterThan(totemBlockEnd);
1158
1172
  });
1159
- it('leaves no stale fi or orphaned shell fragments after upgrade', () => {
1173
+ it('leaves no stale fi or orphaned shell fragments after upgrade', async () => {
1160
1174
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1161
1175
  fs.mkdirSync(hooksDir, { recursive: true });
1162
1176
  const oldHook = `#!/bin/sh
@@ -1171,7 +1185,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1171
1185
  fi
1172
1186
  `;
1173
1187
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1174
- upgradePrePushHookIfNeeded(tmpDir);
1188
+ await upgradePrePushHookIfNeeded(tmpDir);
1175
1189
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1176
1190
  // Count if/fi balance: every `if` must have a matching `fi`.
1177
1191
  // Inline `if ... fi` on a single line are self-balanced and excluded from both counts.
@@ -1187,7 +1201,7 @@ fi
1187
1201
  const actual = extractTotemBlock(content);
1188
1202
  expect(actual).toBe(expectedTotemBlock());
1189
1203
  });
1190
- it('upgrades old flag-checking hook to stateless format', () => {
1204
+ it('upgrades old flag-checking hook to stateless format', async () => {
1191
1205
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1192
1206
  fs.mkdirSync(hooksDir, { recursive: true });
1193
1207
  // Simulate the previous flag-checking format that used .lint-passed
@@ -1208,7 +1222,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1208
1222
  fi
1209
1223
  `;
1210
1224
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1211
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1225
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1212
1226
  expect(upgraded).toBe(true);
1213
1227
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1214
1228
  expect(content).toContain('verify-manifest');
@@ -1218,7 +1232,7 @@ fi
1218
1232
  const actual = extractTotemBlock(content);
1219
1233
  expect(actual).toBe(expectedTotemBlock());
1220
1234
  });
1221
- 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 () => {
1222
1236
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1223
1237
  fs.mkdirSync(hooksDir, { recursive: true });
1224
1238
  // Simulate a hook with the auto-refresh logic still using $TOTEM_CMD
@@ -1248,7 +1262,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1248
1262
  fi
1249
1263
  `;
1250
1264
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1251
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1265
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1252
1266
  expect(upgraded).toBe(true);
1253
1267
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1254
1268
  expect(content).toContain('verify-manifest');
@@ -1263,7 +1277,7 @@ fi
1263
1277
  // ─── Enforcement tier: agent detection & strict mode ──
1264
1278
  describe('buildPreCommitHook agent detection', () => {
1265
1279
  it('includes agent detection snippet', () => {
1266
- const hook = buildPreCommitHook();
1280
+ const hook = buildPreCommitHook(RENDER);
1267
1281
  expect(hook).toContain('is_agent=0');
1268
1282
  expect(hook).toContain('is_agent=1');
1269
1283
  expect(hook).toContain('CLAUDE_CODE_AGENT');
@@ -1272,52 +1286,270 @@ describe('buildPreCommitHook agent detection', () => {
1272
1286
  // GEMINI_API_KEY intentionally excluded — human devs export it for Totem's embedding provider
1273
1287
  });
1274
1288
  it('includes TOTEM_HOOK_TIER variable', () => {
1275
- const hook = buildPreCommitHook();
1289
+ const hook = buildPreCommitHook(RENDER);
1276
1290
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1277
1291
  });
1278
1292
  it('sets TOTEM_HOOK_TIER to strict when tier is strict', () => {
1279
- const hook = buildPreCommitHook('strict');
1293
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1280
1294
  expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
1281
1295
  });
1282
1296
  });
1283
1297
  describe('buildPreCommitHook with strict tier', () => {
1284
- it('includes spec-completed check', () => {
1285
- const hook = buildPreCommitHook('strict');
1286
- 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/);
1287
1304
  expect(hook).toContain("Run 'totem spec <issue>' before committing (strict mode)");
1288
1305
  });
1289
1306
  it('gates spec check on agent detection or strict tier', () => {
1290
- const hook = buildPreCommitHook('strict');
1307
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1291
1308
  expect(hook).toContain('$is_agent');
1292
1309
  expect(hook).toContain('$TOTEM_HOOK_TIER');
1293
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
+ });
1294
1523
  });
1295
1524
  describe('buildPreCommitHook with standard tier', () => {
1296
- it('includes spec-completed check guarded by agent/tier condition', () => {
1297
- const hook = buildPreCommitHook('standard');
1298
- 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');
1299
1528
  expect(hook).toContain('is_agent');
1300
1529
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1301
1530
  });
1302
- it('defaults to standard tier when no tier specified', () => {
1303
- 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);
1304
1537
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1305
- expect(hook).toContain('.spec-completed');
1538
+ expect(hook).toContain('.totem/artifacts/runs');
1306
1539
  });
1307
1540
  });
1308
1541
  describe('buildPrePushHook with strict tier', () => {
1309
- const FALLBACK = 'pnpm dlx @mmnto/cli';
1310
1542
  it('includes shield gate', () => {
1311
- const hook = buildPrePushHook(FALLBACK, 'strict');
1543
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1312
1544
  expect(hook).toContain('review');
1313
1545
  expect(hook).toContain('shield gate (strict mode)');
1314
1546
  });
1315
1547
  it('includes TOTEM_HOOK_TIER set to strict', () => {
1316
- const hook = buildPrePushHook(FALLBACK, 'strict');
1548
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1317
1549
  expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
1318
1550
  });
1319
1551
  it('includes agent detection snippet', () => {
1320
- const hook = buildPrePushHook(FALLBACK, 'strict');
1552
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1321
1553
  expect(hook).toContain('is_agent=0');
1322
1554
  expect(hook).toContain('CLAUDE_CODE_AGENT');
1323
1555
  });
@@ -1325,12 +1557,12 @@ describe('buildPrePushHook with strict tier', () => {
1325
1557
  // shieldBlock alongside the existing `totem review` gate. Repo-state
1326
1558
  // checks (like checkAgentsMdCanonical) now block push when they fail.
1327
1559
  it('includes $TOTEM_CMD doctor --strict in the shield block', () => {
1328
- const hook = buildPrePushHook(FALLBACK, 'strict');
1560
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1329
1561
  expect(hook).toContain('$TOTEM_CMD doctor --strict');
1330
1562
  expect(hook).toContain('doctor --strict (repo-state gate)');
1331
1563
  });
1332
1564
  it('gates doctor --strict on the agent/strict guard (does NOT fire unconditionally)', () => {
1333
- const hook = buildPrePushHook(FALLBACK, 'strict');
1565
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1334
1566
  // Find the position of the doctor --strict invocation and the surrounding
1335
1567
  // guard; assert the invocation lives INSIDE the agent-or-tier block.
1336
1568
  const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
@@ -1342,20 +1574,22 @@ describe('buildPrePushHook with strict tier', () => {
1342
1574
  });
1343
1575
  });
1344
1576
  describe('buildPrePushHook with standard tier', () => {
1345
- const FALLBACK = 'pnpm dlx @mmnto/cli';
1346
1577
  it('includes shield gate guarded by agent/tier condition', () => {
1347
- const hook = buildPrePushHook(FALLBACK, 'standard');
1578
+ const hook = buildPrePushHook(RENDER);
1348
1579
  expect(hook).toContain('shield gate');
1349
1580
  expect(hook).toContain('is_agent');
1350
1581
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1351
1582
  });
1352
- it('defaults to standard tier when no tier specified', () => {
1353
- 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);
1354
1588
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1355
1589
  expect(hook).toContain('shield gate');
1356
1590
  });
1357
1591
  it('still includes agent detection', () => {
1358
- const hook = buildPrePushHook(FALLBACK, 'standard');
1592
+ const hook = buildPrePushHook(RENDER);
1359
1593
  expect(hook).toContain('is_agent=0');
1360
1594
  });
1361
1595
  // mmnto-ai/totem#1908 — even in standard tier, the doctor --strict line
@@ -1364,7 +1598,7 @@ describe('buildPrePushHook with standard tier', () => {
1364
1598
  // operators bypass it because their `is_agent=0` and `TOTEM_HOOK_TIER` is
1365
1599
  // standard, so the guard branch never enters.
1366
1600
  it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
1367
- const hook = buildPrePushHook(FALLBACK, 'standard');
1601
+ const hook = buildPrePushHook(RENDER);
1368
1602
  const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
1369
1603
  const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
1370
1604
  const fiCloseIdx = hook.indexOf('fi', guardIdx);
@@ -1375,7 +1609,7 @@ describe('buildPrePushHook with standard tier', () => {
1375
1609
  });
1376
1610
  describe('agent detection uses POSIX syntax', () => {
1377
1611
  it('pre-commit hook has no bashisms', () => {
1378
- const hook = buildPreCommitHook('strict');
1612
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1379
1613
  // Must use [ ] not [[ ]]
1380
1614
  expect(hook).not.toContain('[[');
1381
1615
  expect(hook).not.toContain(']]');
@@ -1385,7 +1619,7 @@ describe('agent detection uses POSIX syntax', () => {
1385
1619
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
1386
1620
  });
1387
1621
  it('pre-push hook has no bashisms', () => {
1388
- const hook = buildPrePushHook('pnpm dlx @mmnto/cli', 'strict');
1622
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1389
1623
  // Must use [ ] not [[ ]]
1390
1624
  expect(hook).not.toContain('[[');
1391
1625
  expect(hook).not.toContain(']]');