@mmnto/cli 1.120.0 → 1.122.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 (95) hide show
  1. package/dist/artifact-vocabulary.d.ts +23 -0
  2. package/dist/artifact-vocabulary.d.ts.map +1 -0
  3. package/dist/artifact-vocabulary.js +23 -0
  4. package/dist/artifact-vocabulary.js.map +1 -0
  5. package/dist/commands/config-drift.test.js +26 -2
  6. package/dist/commands/config-drift.test.js.map +1 -1
  7. package/dist/commands/doctor-parity.d.ts.map +1 -1
  8. package/dist/commands/doctor-parity.js +21 -6
  9. package/dist/commands/doctor-parity.js.map +1 -1
  10. package/dist/commands/doctor-parity.test.js +14 -7
  11. package/dist/commands/doctor-parity.test.js.map +1 -1
  12. package/dist/commands/doctor.d.ts +21 -1
  13. package/dist/commands/doctor.d.ts.map +1 -1
  14. package/dist/commands/doctor.js +167 -9
  15. package/dist/commands/doctor.js.map +1 -1
  16. package/dist/commands/doctor.test.js +234 -6
  17. package/dist/commands/doctor.test.js.map +1 -1
  18. package/dist/commands/ecl-gc.d.ts.map +1 -1
  19. package/dist/commands/ecl-gc.js +38 -5
  20. package/dist/commands/ecl-gc.js.map +1 -1
  21. package/dist/commands/ecl-gc.test.js +71 -0
  22. package/dist/commands/ecl-gc.test.js.map +1 -1
  23. package/dist/commands/eject-totemdir.test.d.ts +8 -0
  24. package/dist/commands/eject-totemdir.test.d.ts.map +1 -0
  25. package/dist/commands/eject-totemdir.test.js +101 -0
  26. package/dist/commands/eject-totemdir.test.js.map +1 -0
  27. package/dist/commands/eject.d.ts +30 -5
  28. package/dist/commands/eject.d.ts.map +1 -1
  29. package/dist/commands/eject.js +145 -31
  30. package/dist/commands/eject.js.map +1 -1
  31. package/dist/commands/eject.test.js +3 -3
  32. package/dist/commands/eject.test.js.map +1 -1
  33. package/dist/commands/hook-totemdir-render.test.d.ts +23 -0
  34. package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
  35. package/dist/commands/hook-totemdir-render.test.js +288 -0
  36. package/dist/commands/hook-totemdir-render.test.js.map +1 -0
  37. package/dist/commands/init-templates.d.ts +4 -4
  38. package/dist/commands/init-templates.d.ts.map +1 -1
  39. package/dist/commands/init-templates.js +3 -3
  40. package/dist/commands/install-hooks-exit-contract.test.js +6 -6
  41. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  42. package/dist/commands/install-hooks.d.ts +162 -12
  43. package/dist/commands/install-hooks.d.ts.map +1 -1
  44. package/dist/commands/install-hooks.js +521 -88
  45. package/dist/commands/install-hooks.js.map +1 -1
  46. package/dist/commands/install-hooks.test.js +922 -147
  47. package/dist/commands/install-hooks.test.js.map +1 -1
  48. package/dist/commands/link.d.ts.map +1 -1
  49. package/dist/commands/link.js +71 -16
  50. package/dist/commands/link.js.map +1 -1
  51. package/dist/commands/link.test.d.ts +9 -0
  52. package/dist/commands/link.test.d.ts.map +1 -0
  53. package/dist/commands/link.test.js +92 -0
  54. package/dist/commands/link.test.js.map +1 -0
  55. package/dist/commands/mail.d.ts +48 -2
  56. package/dist/commands/mail.d.ts.map +1 -1
  57. package/dist/commands/mail.js +76 -2
  58. package/dist/commands/mail.js.map +1 -1
  59. package/dist/commands/mail.test.js +177 -0
  60. package/dist/commands/mail.test.js.map +1 -1
  61. package/dist/commands/pre-push-gate-matrix.test.js +9 -2
  62. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
  63. package/dist/commands/shield.js +1 -1
  64. package/dist/commands/shield.js.map +1 -1
  65. package/dist/commands/spec-cli-wiring.test.d.ts +19 -0
  66. package/dist/commands/spec-cli-wiring.test.d.ts.map +1 -0
  67. package/dist/commands/spec-cli-wiring.test.js +90 -0
  68. package/dist/commands/spec-cli-wiring.test.js.map +1 -0
  69. package/dist/commands/spec-templates.d.ts +18 -0
  70. package/dist/commands/spec-templates.d.ts.map +1 -1
  71. package/dist/commands/spec-templates.js +21 -0
  72. package/dist/commands/spec-templates.js.map +1 -1
  73. package/dist/commands/spec.d.ts +168 -1
  74. package/dist/commands/spec.d.ts.map +1 -1
  75. package/dist/commands/spec.js +448 -7
  76. package/dist/commands/spec.js.map +1 -1
  77. package/dist/commands/spec.test.js +903 -20
  78. package/dist/commands/spec.test.js.map +1 -1
  79. package/dist/commands/tools-hook-parity.test.js +7 -3
  80. package/dist/commands/tools-hook-parity.test.js.map +1 -1
  81. package/dist/index.js +16 -2
  82. package/dist/index.js.map +1 -1
  83. package/dist/services/run-artifacts.d.ts +12 -1
  84. package/dist/services/run-artifacts.d.ts.map +1 -1
  85. package/dist/services/run-artifacts.js +48 -3
  86. package/dist/services/run-artifacts.js.map +1 -1
  87. package/dist/services/run-artifacts.test.js +97 -1
  88. package/dist/services/run-artifacts.test.js.map +1 -1
  89. package/dist/utils.d.ts +22 -3
  90. package/dist/utils.d.ts.map +1 -1
  91. package/dist/utils.js +4 -0
  92. package/dist/utils.js.map +1 -1
  93. package/dist/utils.test.js +65 -1
  94. package/dist/utils.test.js.map +1 -1
  95. package/package.json +2 -2
@@ -1,10 +1,100 @@
1
1
  import { execSync, spawnSync } from 'node:child_process';
2
+ import * as crypto from 'node:crypto';
2
3
  import * as fs from 'node:fs';
3
4
  import * as os from 'node:os';
4
5
  import * as path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, RUN_ARTIFACT_SCHEMA_VERSION, RunArtifactSchema, } from '@mmnto/totem';
6
8
  import { cleanTmpDir } from '../test-utils.js';
7
9
  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';
10
+ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
11
+ // The default render options every pre-#2692 positional call implied
12
+ // (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
13
+ // each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
14
+ // override the one field a case actually varies.
15
+ const RENDER = {
16
+ tier: 'standard',
17
+ totemDir: '.totem',
18
+ fallbackCmd: 'pnpm dlx @mmnto/cli',
19
+ };
20
+ // ─── Anchored-evidence fixtures (mmnto-ai/totem#2700) ───
21
+ //
22
+ // Since #2700 an artifact is evidence only when it is ANCHORED and its SUBJECT
23
+ // carries a shape. These build the smallest artifact that satisfies each arm,
24
+ // so a test that means to exercise something else (age, newest-wins, torn
25
+ // files) does not accidentally exercise the shape check.
26
+ /** A draft that satisfies the TEMPLATE shape: every required heading with a body. */
27
+ const TEMPLATE_DRAFT = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
28
+ /** A draft that satisfies only the looser DOCUMENT shape — one heading with a body. */
29
+ const DOCUMENT_DRAFT = '## A hand-shaped section\n\nA body under it.\n';
30
+ /** The minimal ISSUE-anchored, template-shaped run artifact the gate accepts. */
31
+ function specEvidenceArtifact(overrides = {}) {
32
+ return {
33
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_BUILTIN } },
34
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' } },
35
+ output: { content: TEMPLATE_DRAFT },
36
+ createdAt: new Date().toISOString(),
37
+ ...overrides,
38
+ };
39
+ }
40
+ /**
41
+ * The fixture wrapped in the fields every run artifact must carry and the
42
+ * reader never touches — so the whole thing can be parsed by the REAL schema.
43
+ */
44
+ function asRunArtifact(fixture) {
45
+ const grounding = fixture['grounding'];
46
+ const output = fixture['output'];
47
+ return {
48
+ ...fixture,
49
+ schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION,
50
+ inputBundle: { maskedPrompt: 'the masked prompt' },
51
+ inputHash: 'a'.repeat(64),
52
+ grounding: { hash: 'b'.repeat(64), provenanceSummary: 'similarity-only:1', ...grounding },
53
+ backend: {
54
+ provider: 'gemini',
55
+ model: 'gemini-3-flash-preview',
56
+ qualifiedModel: 'gemini:gemini-3-flash-preview',
57
+ admissionClass: 'completion_only',
58
+ taskProfile: 'Spec',
59
+ },
60
+ output: { metrics: { durationMs: 500 }, ...output },
61
+ };
62
+ }
63
+ // The hook's reader is a rendered `node -e` string: it walks the artifact by
64
+ // HAND-SPELLED paths (`grounding.anchor`, `admission.runMetadata.promptSource`,
65
+ // `output.content`) that no type checker sees. These tests bind the PASS
66
+ // fixtures those paths are exercised against to the real core schema, so a
67
+ // rename or a shape change on the writer's side breaks a test here rather than
68
+ // silently leaving the hook reading a field that no longer exists.
69
+ describe('the anchored-evidence fixtures parse as real run artifacts (mmnto-ai/totem#2700)', () => {
70
+ it('the issue-anchored PASS fixture round-trips through RunArtifactSchema with its three read paths intact', () => {
71
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact()));
72
+ expect(parsed.grounding.anchor).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
73
+ expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_BUILTIN);
74
+ expect(parsed.output.content).toBe(TEMPLATE_DRAFT);
75
+ });
76
+ it('the record-anchored PASS fixture round-trips, sha256 and all', () => {
77
+ const sha256 = 'a'.repeat(64);
78
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
79
+ grounding: {
80
+ anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md', sha256 },
81
+ },
82
+ })));
83
+ expect(parsed.grounding.anchor).toEqual({
84
+ kind: GROUNDING_ANCHOR_RECORD,
85
+ ref: '.totem/specs/2700.md',
86
+ sha256,
87
+ });
88
+ });
89
+ it('the override-prompt PASS fixture round-trips with promptSource "override"', () => {
90
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
91
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
92
+ output: { content: DOCUMENT_DRAFT },
93
+ })));
94
+ expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_OVERRIDE);
95
+ expect(parsed.output.content).toBe(DOCUMENT_DRAFT);
96
+ });
97
+ });
8
98
  describe('detectTotemPrefix', () => {
9
99
  let tmpDir;
10
100
  beforeEach(() => {
@@ -152,76 +242,75 @@ describe('buildResolveBlock', () => {
152
242
  });
153
243
  describe('buildPreCommitHook', () => {
154
244
  it('contains the marker for idempotency', () => {
155
- const hook = buildPreCommitHook();
245
+ const hook = buildPreCommitHook(RENDER);
156
246
  expect(hook).toContain(TOTEM_PRECOMMIT_MARKER);
157
247
  });
158
248
  it('blocks main and master branches', () => {
159
- const hook = buildPreCommitHook();
249
+ const hook = buildPreCommitHook(RENDER);
160
250
  expect(hook).toContain('"main"');
161
251
  expect(hook).toContain('"master"');
162
252
  });
163
253
  it('prints a helpful error message with override instructions', () => {
164
- const hook = buildPreCommitHook();
254
+ const hook = buildPreCommitHook(RENDER);
165
255
  expect(hook).toContain('git checkout -b feat/my-feature');
166
256
  expect(hook).toContain('git commit --no-verify');
167
257
  });
168
258
  it('starts with a shebang', () => {
169
- const hook = buildPreCommitHook();
259
+ const hook = buildPreCommitHook(RENDER);
170
260
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
171
261
  });
172
262
  it('exits with code 1 when on protected branch', () => {
173
- const hook = buildPreCommitHook();
263
+ const hook = buildPreCommitHook(RENDER);
174
264
  expect(hook).toContain('exit 1');
175
265
  });
176
266
  });
177
267
  describe('buildPrePushHook', () => {
178
- const FALLBACK = 'pnpm dlx @mmnto/cli';
179
268
  it('contains the marker for idempotency', () => {
180
- const hook = buildPrePushHook(FALLBACK);
269
+ const hook = buildPrePushHook(RENDER);
181
270
  expect(hook).toContain(TOTEM_PREPUSH_MARKER);
182
271
  });
183
272
  it('starts with a shebang', () => {
184
- const hook = buildPrePushHook(FALLBACK);
273
+ const hook = buildPrePushHook(RENDER);
185
274
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
186
275
  });
187
276
  it('does not advertise --no-verify escape hatch', () => {
188
- const hook = buildPrePushHook(FALLBACK);
277
+ const hook = buildPrePushHook(RENDER);
189
278
  expect(hook).not.toContain('--no-verify');
190
279
  });
191
280
  it('contains verify-manifest check', () => {
192
- const hook = buildPrePushHook(FALLBACK);
281
+ const hook = buildPrePushHook(RENDER);
193
282
  expect(hook).toContain('verify-manifest');
194
283
  expect(hook).toContain('compile manifest is stale');
195
284
  });
196
285
  it('contains $TOTEM_CMD lint', () => {
197
- const hook = buildPrePushHook(FALLBACK);
286
+ const hook = buildPrePushHook(RENDER);
198
287
  expect(hook).toContain('$TOTEM_CMD lint');
199
288
  });
200
289
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
201
290
  it('contains $TOTEM_CMD verify-badges (mmnto-ai/totem#1926)', () => {
202
- const hook = buildPrePushHook(FALLBACK);
291
+ const hook = buildPrePushHook(RENDER);
203
292
  // totem-context: substring match on hook script content (not secret masking) — toContain is correct here
204
293
  expect(hook).toContain('$TOTEM_CMD verify-badges');
205
294
  });
206
295
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
207
296
  it('gates verify-badges on README.md existing', () => {
208
- const hook = buildPrePushHook(FALLBACK);
297
+ const hook = buildPrePushHook(RENDER);
209
298
  expect(hook).toMatch(/-f "README\.md".*verify-badges/s);
210
299
  });
211
300
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
212
301
  it('contains $TOTEM_CMD verify-lockfile-sync (mmnto-ai/totem#1961)', () => {
213
- const hook = buildPrePushHook(FALLBACK);
302
+ const hook = buildPrePushHook(RENDER);
214
303
  // totem-context: substring match on hook script content (not secret masking) — toContain is correct here
215
304
  expect(hook).toContain('$TOTEM_CMD verify-lockfile-sync');
216
305
  });
217
306
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
218
307
  it('gates verify-lockfile-sync on pnpm-lock.yaml existing', () => {
219
- const hook = buildPrePushHook(FALLBACK);
308
+ const hook = buildPrePushHook(RENDER);
220
309
  expect(hook).toMatch(/-f "pnpm-lock\.yaml".*verify-lockfile-sync/s);
221
310
  });
222
311
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
223
312
  it('slots verify-lockfile-sync before claim-discipline in the pre-push sequence', () => {
224
- const hook = buildPrePushHook(FALLBACK);
313
+ const hook = buildPrePushHook(RENDER);
225
314
  // Assert presence with toContain (precise matcher); index comparison is
226
315
  // safe only because both substrings are guaranteed present by the
227
316
  // toContain assertions above.
@@ -235,12 +324,12 @@ describe('buildPrePushHook', () => {
235
324
  });
236
325
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
237
326
  it('contains $TOTEM_CMD doctor --claim-discipline --strict (Proposal 279 Q3)', () => {
238
- const hook = buildPrePushHook(FALLBACK);
327
+ const hook = buildPrePushHook(RENDER);
239
328
  expect(hook).toContain('$TOTEM_CMD doctor --claim-discipline --strict');
240
329
  });
241
330
  // totem-context: hook-content assertion (mmnto-ai/totem#2002 — diff-scope narrowing)
242
331
  it('contains --scope-to-diff on the claim-discipline invocation (mmnto-ai/totem#2002)', () => {
243
- const hook = buildPrePushHook(FALLBACK);
332
+ const hook = buildPrePushHook(RENDER);
244
333
  // The flag must appear on the same `doctor --claim-discipline` line so the
245
334
  // hook narrows the WWND scan to diff-touched files. Standing-gate full scan
246
335
  // produced N=8 false-positive bypasses in <24hr on a pre-existing surface
@@ -249,7 +338,7 @@ describe('buildPrePushHook', () => {
249
338
  });
250
339
  // totem-context: hook-content assertion (mmnto-ai/totem#2002 — bootstrap defensive degrade)
251
340
  it('defensively degrades --scope-to-diff when CLI predates 1.47.0 (cohort bootstrap safety)', () => {
252
- const hook = buildPrePushHook(FALLBACK);
341
+ const hook = buildPrePushHook(RENDER);
253
342
  // The hook MUST detect --scope-to-diff support via --help and fall back to
254
343
  // standing-scan when the resolved CLI doesn't carry the flag. Required so a
255
344
  // cohort agent whose global @mmnto/cli predates 1.47.0 doesn't fail commander
@@ -270,13 +359,13 @@ describe('buildPrePushHook', () => {
270
359
  });
271
360
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
272
361
  it('gates claim-discipline on at-least-one in-scope public surface existing', () => {
273
- const hook = buildPrePushHook(FALLBACK);
362
+ const hook = buildPrePushHook(RENDER);
274
363
  // The gate fires when any of README.md, AGENTS.md, design-tenets.md, or docs/wiki/ exists
275
364
  expect(hook).toMatch(/-f "README\.md".*-f "AGENTS\.md".*-f "design-tenets\.md".*-d "docs\/wiki"/s);
276
365
  });
277
366
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
278
367
  it('slots claim-discipline after verify-badges in the pre-push sequence', () => {
279
- const hook = buildPrePushHook(FALLBACK);
368
+ const hook = buildPrePushHook(RENDER);
280
369
  const verifyBadgesIdx = hook.indexOf('$TOTEM_CMD verify-badges');
281
370
  const claimDisciplineIdx = hook.indexOf('$TOTEM_CMD doctor --claim-discipline');
282
371
  expect(verifyBadgesIdx).toBeGreaterThan(-1);
@@ -285,36 +374,36 @@ describe('buildPrePushHook', () => {
285
374
  });
286
375
  // totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
287
376
  it('mentions TOTEM_GATE_BYPASS_JUSTIFICATION as the bypass mechanism (Proposal 279 Q3 standardized convention)', () => {
288
- const hook = buildPrePushHook(FALLBACK);
377
+ const hook = buildPrePushHook(RENDER);
289
378
  expect(hook).toContain('TOTEM_GATE_BYPASS_JUSTIFICATION');
290
379
  });
291
380
  it('does NOT contain old flag-file references', () => {
292
- const hook = buildPrePushHook(FALLBACK);
381
+ const hook = buildPrePushHook(RENDER);
293
382
  expect(hook).not.toContain('.lint-passed');
294
383
  expect(hook).not.toContain('.shield-passed');
295
384
  expect(hook).not.toContain('.target-globs');
296
385
  });
297
386
  it('does NOT contain merge-base (no ancestry checks)', () => {
298
- const hook = buildPrePushHook(FALLBACK);
387
+ const hook = buildPrePushHook(RENDER);
299
388
  expect(hook).not.toContain('merge-base');
300
389
  });
301
390
  it('uses the resolve block', () => {
302
- const hook = buildPrePushHook(FALLBACK);
391
+ const hook = buildPrePushHook(RENDER);
303
392
  expect(hook).toContain('TOTEM_CMD=');
304
393
  expect(hook).toContain('command -v totem');
305
394
  });
306
395
  it('uses POSIX-compatible syntax only', () => {
307
- const hook = buildPrePushHook(FALLBACK);
396
+ const hook = buildPrePushHook(RENDER);
308
397
  // Must use [ ] not [[ ]]
309
398
  expect(hook).not.toContain('[[');
310
399
  expect(hook).not.toContain(']]');
311
400
  });
312
401
  it('embeds the provided fallback command', () => {
313
- const hook = buildPrePushHook('yarn dlx @mmnto/cli');
402
+ const hook = buildPrePushHook({ ...RENDER, fallbackCmd: 'yarn dlx @mmnto/cli' });
314
403
  expect(hook).toContain('yarn dlx @mmnto/cli');
315
404
  });
316
405
  it('includes format:check before push', () => {
317
- const hook = buildPrePushHook(FALLBACK);
406
+ const hook = buildPrePushHook(RENDER);
318
407
  expect(hook).toContain('format:check');
319
408
  expect(hook).toContain('Formatting check failed');
320
409
  });
@@ -330,7 +419,7 @@ describe('installGitHook', () => {
330
419
  cleanTmpDir(tmpDir);
331
420
  });
332
421
  it('creates a new hook file when none exists', () => {
333
- const content = buildPreCommitHook();
422
+ const content = buildPreCommitHook(RENDER);
334
423
  const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
335
424
  expect(result).toBe('installed');
336
425
  expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
@@ -339,11 +428,11 @@ describe('installGitHook', () => {
339
428
  });
340
429
  it('creates parent directories as needed', () => {
341
430
  expect(fs.existsSync(hooksDir)).toBe(false);
342
- installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
431
+ installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
343
432
  expect(fs.existsSync(hooksDir)).toBe(true);
344
433
  });
345
434
  it('returns exists when marker is already present (idempotent)', () => {
346
- const content = buildPreCommitHook();
435
+ const content = buildPreCommitHook(RENDER);
347
436
  installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
348
437
  const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
349
438
  expect(result).toBe('exists');
@@ -353,7 +442,7 @@ describe('installGitHook', () => {
353
442
  const hookPath = path.join(hooksDir, 'pre-commit');
354
443
  const userHook = '#!/bin/sh\necho "user hook"\n';
355
444
  fs.writeFileSync(hookPath, userHook);
356
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
445
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
357
446
  expect(result).toBe('appended');
358
447
  const written = fs.readFileSync(hookPath, 'utf-8');
359
448
  expect(written).toContain('echo "user hook"');
@@ -366,13 +455,13 @@ describe('installGitHook', () => {
366
455
  const hookPath = path.join(hooksDir, 'pre-push');
367
456
  const userHook = '#!/bin/sh\nrun_my_tests\n';
368
457
  fs.writeFileSync(hookPath, userHook);
369
- installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER);
458
+ installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
370
459
  const written = fs.readFileSync(hookPath, 'utf-8');
371
460
  expect(written).toContain('run_my_tests');
372
461
  expect(written).toContain(TOTEM_PREPUSH_MARKER);
373
462
  });
374
463
  it('is idempotent — double install does not duplicate', () => {
375
- const content = buildPrePushHook('pnpm dlx @mmnto/cli');
464
+ const content = buildPrePushHook(RENDER);
376
465
  installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
377
466
  installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
378
467
  const written = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
@@ -384,7 +473,7 @@ describe('installGitHook', () => {
384
473
  const hookPath = path.join(hooksDir, 'pre-commit');
385
474
  const nodeHook = '#!/usr/bin/env node\nconsole.log("lint");\n'; // totem-ignore — test fixture, not real logging
386
475
  fs.writeFileSync(hookPath, nodeHook);
387
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
476
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
388
477
  expect(result).toBe('skipped-non-shell');
389
478
  const written = fs.readFileSync(hookPath, 'utf-8');
390
479
  expect(written).toBe(nodeHook); // File untouched
@@ -394,7 +483,7 @@ describe('installGitHook', () => {
394
483
  const hookPath = path.join(hooksDir, 'pre-push');
395
484
  const pythonHook = '#!/usr/bin/env python3\nimport subprocess\n';
396
485
  fs.writeFileSync(hookPath, pythonHook);
397
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER);
486
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
398
487
  expect(result).toBe('skipped-non-shell');
399
488
  const written = fs.readFileSync(hookPath, 'utf-8');
400
489
  expect(written).toBe(pythonHook); // File untouched
@@ -403,33 +492,33 @@ describe('installGitHook', () => {
403
492
  fs.mkdirSync(hooksDir, { recursive: true });
404
493
  const hookPath = path.join(hooksDir, 'pre-commit');
405
494
  fs.writeFileSync(hookPath, '#!/bin/sh\necho "existing"\n');
406
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
495
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
407
496
  expect(result).toBe('appended');
408
497
  });
409
498
  it('appends normally to bash hooks', () => {
410
499
  fs.mkdirSync(hooksDir, { recursive: true });
411
500
  const hookPath = path.join(hooksDir, 'pre-commit');
412
501
  fs.writeFileSync(hookPath, '#!/bin/bash\necho "existing"\n');
413
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
502
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
414
503
  expect(result).toBe('appended');
415
504
  });
416
505
  it('appends normally to env bash hooks', () => {
417
506
  fs.mkdirSync(hooksDir, { recursive: true });
418
507
  const hookPath = path.join(hooksDir, 'pre-commit');
419
508
  fs.writeFileSync(hookPath, '#!/usr/bin/env bash\necho "existing"\n');
420
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
509
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
421
510
  expect(result).toBe('appended');
422
511
  });
423
512
  it('appends to hooks without a shebang (plain text)', () => {
424
513
  fs.mkdirSync(hooksDir, { recursive: true });
425
514
  const hookPath = path.join(hooksDir, 'pre-commit');
426
515
  fs.writeFileSync(hookPath, 'echo "no shebang"\n');
427
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
516
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
428
517
  expect(result).toBe('appended');
429
518
  });
430
519
  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);
520
+ installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
521
+ installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
433
522
  expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
434
523
  expect(fs.existsSync(path.join(hooksDir, 'pre-push'))).toBe(true);
435
524
  const preCommit = fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8');
@@ -444,7 +533,7 @@ describe('installGitHook', () => {
444
533
  const hookPath = path.join(hooksDir, 'pre-push');
445
534
  // Write an old-format hook with the marker
446
535
  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);
536
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, true);
448
537
  expect(result).toBe('overwritten');
449
538
  const content = fs.readFileSync(hookPath, 'utf-8');
450
539
  expect(content).toContain('verify-manifest'); // new format
@@ -453,7 +542,7 @@ describe('installGitHook', () => {
453
542
  it('returns exists when on-disk content already matches canonical (idempotent)', () => {
454
543
  fs.mkdirSync(hooksDir, { recursive: true });
455
544
  const hookPath = path.join(hooksDir, 'pre-push');
456
- const canonical = buildPrePushHook('pnpm dlx @mmnto/cli');
545
+ const canonical = buildPrePushHook(RENDER);
457
546
  fs.writeFileSync(hookPath, canonical);
458
547
  const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER);
459
548
  expect(result).toBe('exists');
@@ -464,7 +553,7 @@ describe('installGitHook', () => {
464
553
  // A totem-owned whole file frozen at an older generator's output — but it carries
465
554
  // the bounded end marker, so drift-repair may upgrade it in place without --force.
466
555
  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');
556
+ const canonical = buildPrePushHook(RENDER);
468
557
  const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
469
558
  expect(result).toBe('overwritten');
470
559
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
@@ -473,7 +562,7 @@ describe('installGitHook', () => {
473
562
  fs.mkdirSync(hooksDir, { recursive: true });
474
563
  const hookPath = path.join(hooksDir, 'pre-commit');
475
564
  fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\n`);
476
- const canonical = buildPreCommitHook();
565
+ const canonical = buildPreCommitHook(RENDER);
477
566
  const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
478
567
  expect(result).toBe('overwritten');
479
568
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
@@ -486,7 +575,7 @@ describe('installGitHook', () => {
486
575
  // marker → the region cannot be bounded → drift-repair declines (legacy path).
487
576
  const legacy = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale legacy body\n`;
488
577
  fs.writeFileSync(hookPath, legacy);
489
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
578
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
490
579
  expect(result).toBe('exists');
491
580
  // Left untouched — the legacy hook takes one `totem hook install --force`.
492
581
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(legacy);
@@ -498,7 +587,7 @@ describe('installGitHook', () => {
498
587
  // block is bounded by an end marker.
499
588
  const userThenTotem = `#!/bin/sh\nrun_my_tests\n# ${TOTEM_PREPUSH_MARKER}\nold content\n# ${TOTEM_PREPUSH_END}\n`;
500
589
  fs.writeFileSync(hookPath, userThenTotem);
501
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
590
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
502
591
  expect(result).toBe('exists');
503
592
  // Left untouched — the user's hook is preserved verbatim.
504
593
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(userThenTotem);
@@ -510,7 +599,7 @@ describe('installGitHook', () => {
510
599
  // whole-file overwrite would clobber it, so the end-marker guard must decline.
511
600
  const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\necho "my pre-commit notice"\n`;
512
601
  fs.writeFileSync(hookPath, staleWithTrailingUser);
513
- const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
602
+ const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
514
603
  expect(result).toBe('exists');
515
604
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
516
605
  });
@@ -519,7 +608,7 @@ describe('installGitHook', () => {
519
608
  const hookPath = path.join(hooksDir, 'pre-push');
520
609
  const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\nstale body\n# ${TOTEM_PREPUSH_END}\necho "my pre-push notice"\n`;
521
610
  fs.writeFileSync(hookPath, staleWithTrailingUser);
522
- const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook('pnpm dlx @mmnto/cli'), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
611
+ const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
523
612
  expect(result).toBe('exists');
524
613
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
525
614
  });
@@ -530,7 +619,7 @@ describe('installGitHook', () => {
530
619
  // whole-file overwrite would clobber it, so the end-marker guard must decline.
531
620
  const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_HOOK_MARKER}\nstale body\n# ${TOTEM_HOOK_END}\necho "my deploy notice"\n`;
532
621
  fs.writeFileSync(hookPath, staleWithTrailingUser);
533
- const result = installGitHook(hooksDir, 'post-merge', buildHookContent('pnpm dlx @mmnto/cli'), TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
622
+ const result = installGitHook(hooksDir, 'post-merge', buildHookContent(RENDER), TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
534
623
  expect(result).toBe('exists');
535
624
  expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
536
625
  });
@@ -545,7 +634,7 @@ describe('generateHookHelpers', () => {
545
634
  cleanTmpDir(tmpDir);
546
635
  });
547
636
  it('creates .totem/hooks/ directory and writes all 4 .sh files', () => {
548
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
637
+ generateHookHelpers(tmpDir, RENDER);
549
638
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
550
639
  expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
551
640
  expect(fs.existsSync(path.join(hooksDir, 'post-checkout.sh'))).toBe(true);
@@ -553,7 +642,7 @@ describe('generateHookHelpers', () => {
553
642
  expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
554
643
  });
555
644
  it('generated scripts contain expected content', () => {
556
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
645
+ generateHookHelpers(tmpDir, RENDER);
557
646
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
558
647
  const postMerge = fs.readFileSync(path.join(hooksDir, 'post-merge.sh'), 'utf-8');
559
648
  expect(postMerge).toContain('command -v totem');
@@ -563,8 +652,8 @@ describe('generateHookHelpers', () => {
563
652
  expect(prePush).toContain('verify-manifest');
564
653
  });
565
654
  it('is idempotent — calling twice does not error', () => {
566
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
567
- generateHookHelpers(tmpDir, 'pnpm dlx @mmnto/cli');
655
+ generateHookHelpers(tmpDir, RENDER);
656
+ generateHookHelpers(tmpDir, RENDER);
568
657
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
569
658
  expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
570
659
  });
@@ -578,14 +667,14 @@ describe('installHooksNonInteractive', () => {
578
667
  afterEach(() => {
579
668
  cleanTmpDir(tmpDir);
580
669
  });
581
- it('returns null when not a git repo', () => {
582
- const result = installHooksNonInteractive(tmpDir);
670
+ it('returns null when not a git repo', async () => {
671
+ const result = await installHooksNonInteractive(tmpDir);
583
672
  expect(result).toBeNull();
584
673
  });
585
- it('installs all four hooks in a git repo', () => {
674
+ it('installs all four hooks in a git repo', async () => {
586
675
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
587
676
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
588
- const result = installHooksNonInteractive(tmpDir);
677
+ const result = await installHooksNonInteractive(tmpDir);
589
678
  expect(result).not.toBeNull();
590
679
  expect(result.preCommit).toBe('installed');
591
680
  expect(result.prePush).toBe('installed');
@@ -598,33 +687,33 @@ describe('installHooksNonInteractive', () => {
598
687
  expect(fs.existsSync(path.join(hooksDir, 'post-merge'))).toBe(true);
599
688
  expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
600
689
  });
601
- it('is idempotent — second call returns exists for all hooks', () => {
690
+ it('is idempotent — second call returns exists for all hooks', async () => {
602
691
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
603
692
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
604
- installHooksNonInteractive(tmpDir);
605
- const result = installHooksNonInteractive(tmpDir);
693
+ await installHooksNonInteractive(tmpDir);
694
+ const result = await installHooksNonInteractive(tmpDir);
606
695
  expect(result).not.toBeNull();
607
696
  expect(result.preCommit).toBe('exists');
608
697
  expect(result.prePush).toBe('exists');
609
698
  expect(result.postMerge).toBe('exists');
610
699
  expect(result.postCheckout).toBe('exists');
611
700
  });
612
- it('returns null and generates helper scripts when hook manager is detected', () => {
701
+ it('returns null and generates helper scripts when hook manager is detected', async () => {
613
702
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
614
703
  fs.mkdirSync(path.join(tmpDir, '.husky'), { recursive: true });
615
- const result = installHooksNonInteractive(tmpDir);
704
+ const result = await installHooksNonInteractive(tmpDir);
616
705
  expect(result).toBeNull();
617
706
  // Verify helper scripts were generated
618
707
  const hooksDir = path.join(tmpDir, '.totem', 'hooks');
619
708
  expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
620
709
  expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
621
710
  });
622
- it('installs hooks at git root when run from a subdirectory', () => {
711
+ it('installs hooks at git root when run from a subdirectory', async () => {
623
712
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
624
713
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
625
714
  const subDir = path.join(tmpDir, 'packages', 'cli');
626
715
  fs.mkdirSync(subDir, { recursive: true });
627
- const result = installHooksNonInteractive(subDir);
716
+ const result = await installHooksNonInteractive(subDir);
628
717
  expect(result).not.toBeNull();
629
718
  expect(result.preCommit).toBe('installed');
630
719
  expect(result.prePush).toBe('installed');
@@ -638,31 +727,31 @@ describe('installHooksNonInteractive', () => {
638
727
  expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
639
728
  expect(fs.existsSync(path.join(subDir, '.git'))).toBe(false);
640
729
  });
641
- it('check passes from subdirectory after install', () => {
730
+ it('check passes from subdirectory after install', async () => {
642
731
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
643
732
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
644
733
  const subDir = path.join(tmpDir, 'packages', 'cli');
645
734
  fs.mkdirSync(subDir, { recursive: true });
646
- installHooksNonInteractive(subDir);
735
+ await installHooksNonInteractive(subDir);
647
736
  expect(checkHooksInstalled(subDir)).toBe(true);
648
737
  });
649
- it('appends to existing hooks without clobbering', () => {
738
+ it('appends to existing hooks without clobbering', async () => {
650
739
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
651
740
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
652
741
  fs.writeFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), '#!/bin/sh\nrun_my_tests\n');
653
- const result = installHooksNonInteractive(tmpDir);
742
+ const result = await installHooksNonInteractive(tmpDir);
654
743
  expect(result.prePush).toBe('appended');
655
744
  const content = fs.readFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), 'utf-8');
656
745
  expect(content).toContain('run_my_tests');
657
746
  expect(content).toContain(TOTEM_PREPUSH_MARKER);
658
747
  });
659
- it('force-overwrites all hooks when force is true', () => {
748
+ it('force-overwrites all hooks when force is true', async () => {
660
749
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
661
750
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
662
751
  // First install — creates hooks
663
- installHooksNonInteractive(tmpDir);
752
+ await installHooksNonInteractive(tmpDir);
664
753
  // Second install with force — overwrites all hooks
665
- const result = installHooksNonInteractive(tmpDir, true);
754
+ const result = await installHooksNonInteractive(tmpDir, true);
666
755
  expect(result).not.toBeNull();
667
756
  expect(result.preCommit).toBe('overwritten');
668
757
  expect(result.prePush).toBe('overwritten');
@@ -684,14 +773,14 @@ describe('checkHooksInstalled', () => {
684
773
  it('returns false when no hooks are installed', () => {
685
774
  expect(checkHooksInstalled(tmpDir)).toBe(false);
686
775
  });
687
- it('returns true when all hooks are installed', () => {
688
- installHooksNonInteractive(tmpDir);
776
+ it('returns true when all hooks are installed', async () => {
777
+ await installHooksNonInteractive(tmpDir);
689
778
  expect(checkHooksInstalled(tmpDir)).toBe(true);
690
779
  });
691
780
  it('returns false when only some hooks are installed', () => {
692
781
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
693
782
  fs.mkdirSync(hooksDir, { recursive: true });
694
- installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
783
+ installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
695
784
  // Missing pre-push and post-merge
696
785
  expect(checkHooksInstalled(tmpDir)).toBe(false);
697
786
  });
@@ -716,8 +805,8 @@ describe('post-merge hook content', () => {
716
805
  afterEach(() => {
717
806
  cleanTmpDir(tmpDir);
718
807
  });
719
- it('generates post-merge hook with git diff-tree lesson check', () => {
720
- installHooksNonInteractive(tmpDir);
808
+ it('generates post-merge hook with git diff-tree lesson check', async () => {
809
+ await installHooksNonInteractive(tmpDir);
721
810
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
722
811
  const content = fs.readFileSync(hookPath, 'utf-8');
723
812
  expect(content).toContain('ORIG_HEAD');
@@ -728,17 +817,17 @@ describe('post-merge hook content', () => {
728
817
  expect(content).toContain('[totem] post-merge hook');
729
818
  expect(content).toContain('[totem] end post-merge');
730
819
  });
731
- it('passes quiet flag to sync command in post-merge hook', () => {
732
- installHooksNonInteractive(tmpDir);
820
+ it('passes quiet flag to sync command in post-merge hook', async () => {
821
+ await installHooksNonInteractive(tmpDir);
733
822
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
734
823
  const content = fs.readFileSync(hookPath, 'utf-8');
735
824
  expect(content).toContain('--quiet');
736
825
  });
737
- it('preserves existing hooks when appending post-merge block', () => {
826
+ it('preserves existing hooks when appending post-merge block', async () => {
738
827
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
739
828
  fs.mkdirSync(hooksDir, { recursive: true });
740
829
  fs.writeFileSync(path.join(hooksDir, 'post-merge'), '#!/bin/sh\necho "my custom hook"\n');
741
- installHooksNonInteractive(tmpDir);
830
+ await installHooksNonInteractive(tmpDir);
742
831
  const content = fs.readFileSync(path.join(hooksDir, 'post-merge'), 'utf-8');
743
832
  expect(content).toContain('echo "my custom hook"');
744
833
  expect(content).toContain('[totem] post-merge hook');
@@ -757,8 +846,8 @@ describe('post-checkout hook content', () => {
757
846
  afterEach(() => {
758
847
  cleanTmpDir(tmpDir);
759
848
  });
760
- it('generates post-checkout hook with branch switch guard', () => {
761
- installHooksNonInteractive(tmpDir);
849
+ it('generates post-checkout hook with branch switch guard', async () => {
850
+ await installHooksNonInteractive(tmpDir);
762
851
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
763
852
  const content = fs.readFileSync(hookPath, 'utf-8');
764
853
  expect(content).toContain('$3');
@@ -767,18 +856,18 @@ describe('post-checkout hook content', () => {
767
856
  expect(content).toContain('[totem] end post-checkout');
768
857
  });
769
858
  it('handles null SHA for initial checkout', () => {
770
- const hook = buildPostCheckoutHookContent('pnpm dlx @mmnto/cli');
859
+ const hook = buildPostCheckoutHookContent(RENDER);
771
860
  expect(hook).toContain('0000000000000000000000000000000000000000');
772
861
  expect(hook).toContain('.totem');
773
862
  });
774
- it('uses quiet sync command', () => {
775
- installHooksNonInteractive(tmpDir);
863
+ it('uses quiet sync command', async () => {
864
+ await installHooksNonInteractive(tmpDir);
776
865
  const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
777
866
  const content = fs.readFileSync(hookPath, 'utf-8');
778
867
  expect(content).toContain('--quiet');
779
868
  });
780
- it('includes post-checkout in non-interactive install', () => {
781
- const result = installHooksNonInteractive(tmpDir);
869
+ it('includes post-checkout in non-interactive install', async () => {
870
+ const result = await installHooksNonInteractive(tmpDir);
782
871
  expect(result).not.toBeNull();
783
872
  expect(result.postCheckout).toBe('installed');
784
873
  });
@@ -787,7 +876,7 @@ describe('post-checkout hook content', () => {
787
876
  // ─── totem-status refresh-gh wiring (mmnto-ai/totem#2556) ─
788
877
  describe('post-merge hook fires totem-status refresh-gh', () => {
789
878
  it('invokes refresh-gh presence-gated and backgrounded (mmnto-ai/totem-status#127 C3)', () => {
790
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
879
+ const hook = buildHookContent(RENDER);
791
880
  // Presence gate (absent binary = zero noise) AND primary-checkout gate: in a
792
881
  // linked worktree .git is a pointer FILE and a backgrounded child inheriting
793
882
  // the worktree cwd holds a Windows directory lock that breaks worktree removal.
@@ -799,7 +888,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
799
888
  expect(hook.trimEnd().endsWith(`# ${TOTEM_HOOK_END}`)).toBe(true);
800
889
  });
801
890
  it('#2570: stamps each firing and hands the child the same log (observability leg)', () => {
802
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
891
+ const hook = buildHookContent(RENDER);
803
892
  // Repo-local log inside .git; the stamp carries the firing site, cwd, and
804
893
  // WHICH binary resolved (shell search order can be shadowed by a
805
894
  // checkout-local exe on Windows).
@@ -822,7 +911,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
822
911
  // (mmnto-ai/totem-status#127; sibling of mmnto-ai/totem#2556.) The durable
823
912
  // obligation store gets the same post-merge moment as the GH snapshot.
824
913
  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');
914
+ const hook = buildHookContent(RENDER);
826
915
  // One gate, not two: both verbs live inside the single presence +
827
916
  // primary-checkout `if`, so a worktree/non-adopter skips BOTH.
828
917
  const gateIdx = hook.indexOf('if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then');
@@ -905,7 +994,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
905
994
  const repo = path.join(tmpDir, 'repo');
906
995
  fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
907
996
  const hookPath = path.join(repo, 'post-merge');
908
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
997
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
909
998
  execSync('sh ./post-merge', {
910
999
  cwd: repo,
911
1000
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -935,7 +1024,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
935
1024
  // append; falsification round, MAJOR 1).
936
1025
  fs.mkdirSync(path.join(repo, '.git', 'totem-status-refresh-hook.log'), { recursive: true });
937
1026
  const hookPath = path.join(repo, 'post-merge');
938
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
1027
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
939
1028
  const result = spawnSync('sh', ['./post-merge'], {
940
1029
  cwd: repo,
941
1030
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -953,7 +1042,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
953
1042
  // Linked-worktree shape: .git is a pointer FILE, not a directory.
954
1043
  fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
955
1044
  const hookPath = path.join(repo, 'post-merge');
956
- fs.writeFileSync(hookPath, buildHookContent('pnpm dlx @mmnto/cli'), { mode: 0o755 });
1045
+ fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
957
1046
  execSync('sh ./post-merge', {
958
1047
  cwd: repo,
959
1048
  env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
@@ -967,14 +1056,14 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
967
1056
  });
968
1057
  describe('sync-log redirect resolves the git dir (worktree-safe)', () => {
969
1058
  it('post-merge hook derives the log path from git rev-parse --git-dir', () => {
970
- const hook = buildHookContent('pnpm dlx @mmnto/cli');
1059
+ const hook = buildHookContent(RENDER);
971
1060
  expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
972
1061
  expect(hook).toContain('> "$GIT_DIR_RESOLVED/totem-sync.log"');
973
1062
  // The hardcoded `.git/`-as-directory redirect must be gone (ENOTDIR in a worktree).
974
1063
  expect(hook).not.toContain('> .git/totem-sync.log');
975
1064
  });
976
1065
  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');
1066
+ const hook = buildPostCheckoutHookContent(RENDER);
978
1067
  expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
979
1068
  // Both the null-SHA and the branch-diff redirect use the resolved dir.
980
1069
  expect(hook.match(/> "\$GIT_DIR_RESOLVED\/totem-sync\.log"/g)).toHaveLength(2);
@@ -988,13 +1077,19 @@ describe('upgradePrePushHookIfNeeded', () => {
988
1077
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hooks-upgrade-'));
989
1078
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
990
1079
  fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
1080
+ // Pin the repo-local render options: since mmnto-ai/totem#2692 the upgrade
1081
+ // path resolves `hooks.tier` from config, and a config-less temp repo would
1082
+ // fall through to the developer's global `~/.totem/` profile — a global
1083
+ // `hooks.tier: strict` would then break the standard-tier expectation below
1084
+ // (CodeRabbit on mmnto-ai/totem#2701).
1085
+ 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
1086
  });
992
1087
  afterEach(() => {
993
1088
  cleanTmpDir(tmpDir);
994
1089
  });
995
1090
  /**
996
1091
  * 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).
1092
+ * the canonical output of buildPrePushHook(RENDER) (shebang stripped, trimmed).
998
1093
  * Catches stale shell fragments or splice boundary bugs that toContain would miss.
999
1094
  * The stateless format is now bounded by the pre-push end marker, so the block is the
1000
1095
  * span from the start-marker comment through the end-marker comment line inclusive —
@@ -1012,11 +1107,11 @@ describe('upgradePrePushHookIfNeeded', () => {
1012
1107
  }
1013
1108
  /** Canonical totem block: shebang stripped, trimmed — the expected upgrade output. */
1014
1109
  function expectedTotemBlock() {
1015
- return buildPrePushHook(getFallbackCommand(tmpDir))
1110
+ return buildPrePushHook({ ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) })
1016
1111
  .replace(/^#!\/bin\/sh\n/, '')
1017
1112
  .trim();
1018
1113
  }
1019
- it('upgrades old command-executing hook to stateless format', () => {
1114
+ it('upgrades old command-executing hook to stateless format', async () => {
1020
1115
  // Install an old-style hook that executes $TOTEM_CMD lint
1021
1116
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1022
1117
  fs.mkdirSync(hooksDir, { recursive: true });
@@ -1032,7 +1127,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1032
1127
  fi
1033
1128
  `;
1034
1129
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1035
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1130
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1036
1131
  expect(upgraded).toBe(true);
1037
1132
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1038
1133
  expect(content).toContain('verify-manifest');
@@ -1044,41 +1139,41 @@ fi
1044
1139
  const actual = extractTotemBlock(content);
1045
1140
  expect(actual).toBe(expectedTotemBlock());
1046
1141
  });
1047
- it('skips hook without totem marker', () => {
1142
+ it('skips hook without totem marker', async () => {
1048
1143
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1049
1144
  fs.mkdirSync(hooksDir, { recursive: true });
1050
1145
  const userHook = '#!/bin/sh\necho "user hook"\n';
1051
1146
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), userHook);
1052
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1147
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1053
1148
  expect(upgraded).toBe(false);
1054
1149
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1055
1150
  expect(content).toBe(userHook); // File untouched
1056
1151
  });
1057
- it('skips hook that already uses stateless format', () => {
1152
+ it('skips hook that already uses stateless format', async () => {
1058
1153
  // Install the current-version hook via non-interactive installer
1059
- installHooksNonInteractive(tmpDir);
1154
+ await installHooksNonInteractive(tmpDir);
1060
1155
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1061
1156
  const beforeContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1062
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1157
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1063
1158
  expect(upgraded).toBe(false);
1064
1159
  const afterContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1065
1160
  expect(afterContent).toBe(beforeContent); // File untouched
1066
1161
  });
1067
- it('returns false when no pre-push hook exists', () => {
1068
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1162
+ it('returns false when no pre-push hook exists', async () => {
1163
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1069
1164
  expect(upgraded).toBe(false);
1070
1165
  });
1071
- it('returns false when not a git repo', () => {
1166
+ it('returns false when not a git repo', async () => {
1072
1167
  const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-no-git-'));
1073
1168
  try {
1074
- const upgraded = upgradePrePushHookIfNeeded(nonGitDir);
1169
+ const upgraded = await upgradePrePushHookIfNeeded(nonGitDir);
1075
1170
  expect(upgraded).toBe(false);
1076
1171
  }
1077
1172
  finally {
1078
1173
  cleanTmpDir(nonGitDir);
1079
1174
  }
1080
1175
  });
1081
- it('preserves user-appended content when upgrading', () => {
1176
+ it('preserves user-appended content when upgrading', async () => {
1082
1177
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1083
1178
  fs.mkdirSync(hooksDir, { recursive: true });
1084
1179
  // Simulate an old totem hook with user content appended after it
@@ -1093,7 +1188,7 @@ fi
1093
1188
  `;
1094
1189
  const userAppended = '\n# My custom deploy notification\ncurl -X POST https://hooks.example.com/deploy\n';
1095
1190
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userAppended);
1096
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1191
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1097
1192
  expect(upgraded).toBe(true);
1098
1193
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1099
1194
  // New totem block should use stateless format
@@ -1107,7 +1202,7 @@ fi
1107
1202
  const actual = extractTotemBlock(content);
1108
1203
  expect(actual).toBe(expectedTotemBlock());
1109
1204
  });
1110
- it('preserves user-appended if/fi blocks without corrupting them', () => {
1205
+ it('preserves user-appended if/fi blocks without corrupting them', async () => {
1111
1206
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1112
1207
  fs.mkdirSync(hooksDir, { recursive: true });
1113
1208
  // Old totem block (needs upgrade) PLUS user content that contains its own if/fi structures
@@ -1138,7 +1233,7 @@ if [ -n "$SLACK_WEBHOOK" ]; then
1138
1233
  fi
1139
1234
  `;
1140
1235
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userIfFiBlock);
1141
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1236
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1142
1237
  expect(upgraded).toBe(true);
1143
1238
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1144
1239
  // Totem block must match canonical output exactly
@@ -1156,7 +1251,7 @@ fi
1156
1251
  const userBlockStart = content.indexOf('# Custom deploy guard');
1157
1252
  expect(userBlockStart).toBeGreaterThan(totemBlockEnd);
1158
1253
  });
1159
- it('leaves no stale fi or orphaned shell fragments after upgrade', () => {
1254
+ it('leaves no stale fi or orphaned shell fragments after upgrade', async () => {
1160
1255
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1161
1256
  fs.mkdirSync(hooksDir, { recursive: true });
1162
1257
  const oldHook = `#!/bin/sh
@@ -1171,7 +1266,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1171
1266
  fi
1172
1267
  `;
1173
1268
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1174
- upgradePrePushHookIfNeeded(tmpDir);
1269
+ await upgradePrePushHookIfNeeded(tmpDir);
1175
1270
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1176
1271
  // Count if/fi balance: every `if` must have a matching `fi`.
1177
1272
  // Inline `if ... fi` on a single line are self-balanced and excluded from both counts.
@@ -1187,7 +1282,7 @@ fi
1187
1282
  const actual = extractTotemBlock(content);
1188
1283
  expect(actual).toBe(expectedTotemBlock());
1189
1284
  });
1190
- it('upgrades old flag-checking hook to stateless format', () => {
1285
+ it('upgrades old flag-checking hook to stateless format', async () => {
1191
1286
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1192
1287
  fs.mkdirSync(hooksDir, { recursive: true });
1193
1288
  // Simulate the previous flag-checking format that used .lint-passed
@@ -1208,7 +1303,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1208
1303
  fi
1209
1304
  `;
1210
1305
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1211
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1306
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1212
1307
  expect(upgraded).toBe(true);
1213
1308
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1214
1309
  expect(content).toContain('verify-manifest');
@@ -1218,7 +1313,7 @@ fi
1218
1313
  const actual = extractTotemBlock(content);
1219
1314
  expect(actual).toBe(expectedTotemBlock());
1220
1315
  });
1221
- it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', () => {
1316
+ it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', async () => {
1222
1317
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
1223
1318
  fs.mkdirSync(hooksDir, { recursive: true });
1224
1319
  // Simulate a hook with the auto-refresh logic still using $TOTEM_CMD
@@ -1248,7 +1343,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
1248
1343
  fi
1249
1344
  `;
1250
1345
  fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
1251
- const upgraded = upgradePrePushHookIfNeeded(tmpDir);
1346
+ const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
1252
1347
  expect(upgraded).toBe(true);
1253
1348
  const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
1254
1349
  expect(content).toContain('verify-manifest');
@@ -1263,7 +1358,7 @@ fi
1263
1358
  // ─── Enforcement tier: agent detection & strict mode ──
1264
1359
  describe('buildPreCommitHook agent detection', () => {
1265
1360
  it('includes agent detection snippet', () => {
1266
- const hook = buildPreCommitHook();
1361
+ const hook = buildPreCommitHook(RENDER);
1267
1362
  expect(hook).toContain('is_agent=0');
1268
1363
  expect(hook).toContain('is_agent=1');
1269
1364
  expect(hook).toContain('CLAUDE_CODE_AGENT');
@@ -1272,52 +1367,730 @@ describe('buildPreCommitHook agent detection', () => {
1272
1367
  // GEMINI_API_KEY intentionally excluded — human devs export it for Totem's embedding provider
1273
1368
  });
1274
1369
  it('includes TOTEM_HOOK_TIER variable', () => {
1275
- const hook = buildPreCommitHook();
1370
+ const hook = buildPreCommitHook(RENDER);
1276
1371
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1277
1372
  });
1278
1373
  it('sets TOTEM_HOOK_TIER to strict when tier is strict', () => {
1279
- const hook = buildPreCommitHook('strict');
1374
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1280
1375
  expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
1281
1376
  });
1282
1377
  });
1283
1378
  describe('buildPreCommitHook with strict tier', () => {
1284
- it('includes spec-completed check', () => {
1285
- const hook = buildPreCommitHook('strict');
1286
- expect(hook).toContain('.spec-completed');
1379
+ it('gates on spec evidence and never on the retired hand-set marker', () => {
1380
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1381
+ // The former `.totem/cache/.spec-completed` sentinel (mmnto-ai/totem#2690)
1382
+ // may be NAMED in a comment that says it is not honored, but no test-able
1383
+ // path may read it.
1384
+ expect(hook).not.toMatch(/-f\s+"?\.totem\/cache\/\.spec-completed/);
1287
1385
  expect(hook).toContain("Run 'totem spec <issue>' before committing (strict mode)");
1288
1386
  });
1289
1387
  it('gates spec check on agent detection or strict tier', () => {
1290
- const hook = buildPreCommitHook('strict');
1388
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1291
1389
  expect(hook).toContain('$is_agent');
1292
1390
  expect(hook).toContain('$TOTEM_HOOK_TIER');
1293
1391
  });
1392
+ it('gates on the totem spec run artifact, JSON-aware — the only evidence', () => {
1393
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1394
+ expect(hook).toContain('.totem/artifacts/runs');
1395
+ expect(hook).toContain('admission.runMetadata.caller');
1396
+ expect(hook).toContain('spec evidence:');
1397
+ expect(hook).not.toContain('(legacy marker)');
1398
+ });
1399
+ // mmnto-ai/totem#2700 — the reader's vocabulary is RENDERED from the one
1400
+ // canonical constant, never re-spelled in the hook text, so a change to the
1401
+ // writer's spelling can never leave the gate reading a stale word.
1402
+ it('renders SPEC_REQUIRED_SECTIONS and the anchor vocabulary as JSON literals', () => {
1403
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1404
+ expect(hook).toContain(`const REQUIRED = ${JSON.stringify(SPEC_REQUIRED_SECTIONS)};`);
1405
+ expect(hook).toContain(`const KIND_ISSUE = ${JSON.stringify(GROUNDING_ANCHOR_ISSUE)};`);
1406
+ expect(hook).toContain(`const KIND_RECORD = ${JSON.stringify(GROUNDING_ANCHOR_RECORD)};`);
1407
+ expect(hook).toContain(`const PROMPT_OVERRIDE = ${JSON.stringify(PROMPT_SOURCE_OVERRIDE)};`);
1408
+ });
1409
+ it('carries the exit-3 arm distinctly from the exit-2 and reader-failure arms', () => {
1410
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1411
+ expect(hook).toContain('elif [ "$reader_status" = "3" ]; then');
1412
+ expect(hook).toContain(`echo "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"`);
1413
+ // The two pre-existing arms keep their exact text.
1414
+ expect(hook).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1415
+ expect(hook).toContain('the spec-evidence reader could not run (node exit status');
1416
+ });
1417
+ it('the reader body carries no single quote (it lives inside a single-quoted node -e word)', () => {
1418
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1419
+ const reader = hook.split("node -e '")[1]?.split("\n' 2>/dev/null)")[0] ?? '';
1420
+ expect(reader.length).toBeGreaterThan(0);
1421
+ expect(reader).not.toContain("'");
1422
+ });
1423
+ });
1424
+ // The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
1425
+ // above pin the text; these pin the behavior the gate exists for. `git init` +
1426
+ // a feature branch so the protected-branch block does not fire; the agent
1427
+ // env var arms the strict tier the way an agent commit does.
1428
+ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/totem#2690)', () => {
1429
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
1430
+ let tmpDir;
1431
+ beforeEach(() => {
1432
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-evidence-'));
1433
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
1434
+ execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
1435
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
1436
+ });
1437
+ afterEach(() => {
1438
+ cleanTmpDir(tmpDir);
1439
+ });
1440
+ function runHook() {
1441
+ const r = spawnSync('sh', ['./pre-commit'], {
1442
+ cwd: tmpDir,
1443
+ encoding: 'utf-8',
1444
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
1445
+ });
1446
+ return { status: r.status, stdout: r.stdout };
1447
+ }
1448
+ function writeRun(name, artifact) {
1449
+ const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
1450
+ fs.mkdirSync(dir, { recursive: true });
1451
+ fs.writeFileSync(path.join(dir, name), typeof artifact === 'string' ? artifact : JSON.stringify(artifact, null, 2));
1452
+ }
1453
+ // A `node` shim that fails: first on PATH, so the hook's reader cannot run.
1454
+ // Exercises the reader-failure arm without touching the real runtime.
1455
+ function shimNodeExiting(status) {
1456
+ const shimDir = path.join(tmpDir, 'node-shim');
1457
+ fs.mkdirSync(shimDir, { recursive: true });
1458
+ fs.writeFileSync(path.join(shimDir, 'node'), `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 });
1459
+ return {
1460
+ ...process.env,
1461
+ CLAUDE_CODE_AGENT: '1',
1462
+ PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`,
1463
+ };
1464
+ }
1465
+ function runHookWith(env) {
1466
+ const r = spawnSync('sh', ['./pre-commit'], { cwd: tmpDir, encoding: 'utf-8', env });
1467
+ return { status: r.status, stdout: r.stdout };
1468
+ }
1469
+ it.skipIf(!shellOk)('reports a reader that cannot run DISTINCTLY from missing evidence, and still fails closed', () => {
1470
+ const r = runHookWith(shimNodeExiting(5));
1471
+ expect(r.status).toBe(1);
1472
+ expect(r.stdout).toContain('[Totem] BLOCKED: the spec-evidence reader could not run (node exit status 5');
1473
+ expect(r.stdout).not.toContain('no totem spec run artifact');
1474
+ });
1475
+ it.skipIf(!shellOk)('a hand-set marker does not rescue a reader that cannot run — still the distinct failure, still closed', () => {
1476
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1477
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1478
+ const r = runHookWith(shimNodeExiting(127));
1479
+ expect(r.status).toBe(1);
1480
+ expect(r.stdout).toContain('the spec-evidence reader could not run (node exit status 127');
1481
+ expect(r.stdout).not.toContain('legacy');
1482
+ });
1483
+ it.skipIf(!shellOk)('blocks when this checkout carries no evidence at all', () => {
1484
+ const r = runHook();
1485
+ expect(r.status).toBe(1);
1486
+ expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
1487
+ expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1488
+ });
1489
+ it.skipIf(!shellOk)('passes on a spec run artifact and prints the evidence line with its age', () => {
1490
+ writeRun('a.json', specEvidenceArtifact());
1491
+ const r = runHook();
1492
+ expect(r.status).toBe(0);
1493
+ expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
1494
+ expect(r.stdout).toContain('0 days old');
1495
+ });
1496
+ it.skipIf(!shellOk)('does NOT pass on a review artifact that merely CARRIES a caller:spec object below the top level (the substring hazard)', () => {
1497
+ // A substring read of the run store would match this file: the key
1498
+ // spelling appears verbatim, as a carried OBJECT under inputBundle (the
1499
+ // shape a review over run metadata produces). Only the TOP-LEVEL
1500
+ // admission.runMetadata.caller is evidence. (Carried TEXT — a prompt that
1501
+ // quotes the key — is JSON-escaped on disk and never matches either read;
1502
+ // the carried-object form is the real hazard, so it is the control.)
1503
+ writeRun('r.json', {
1504
+ admission: { runMetadata: { caller: 'review' } },
1505
+ inputBundle: { runMetadata: { caller: 'spec' } },
1506
+ createdAt: new Date().toISOString(),
1507
+ });
1508
+ const onDisk = fs.readFileSync(path.join(tmpDir, '.totem/artifacts/runs/r.json'), 'utf-8');
1509
+ // Mutation check on the control itself: the naive pattern WOULD match.
1510
+ expect(/"caller": *"spec"/.test(onDisk)).toBe(true);
1511
+ const r = runHook();
1512
+ expect(r.status).toBe(1);
1513
+ expect(r.stdout).toContain('BLOCKED');
1514
+ });
1515
+ it.skipIf(!shellOk)('ignores the retired hand-set marker — a marker alone is BLOCKED', () => {
1516
+ // No CLI path ever wrote `.totem/cache/.spec-completed`; honoring it would be
1517
+ // compatibility with a hand hack, not with a user (operator ruling
1518
+ // 2026-08-29, mmnto-ai/totem#2690).
1519
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1520
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1521
+ const r = runHook();
1522
+ expect(r.status).toBe(1);
1523
+ expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1524
+ expect(r.stdout).not.toContain('spec evidence:');
1525
+ });
1526
+ it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
1527
+ writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
1528
+ writeRun('ok.json', specEvidenceArtifact());
1529
+ const r = runHook();
1530
+ expect(r.status).toBe(0);
1531
+ expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
1532
+ });
1533
+ it.skipIf(!shellOk)('blocks when the only artifact is torn', () => {
1534
+ writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
1535
+ const r = runHook();
1536
+ expect(r.status).toBe(1);
1537
+ });
1538
+ it.skipIf(!shellOk)('names the NEWEST spec artifact by its own createdAt', () => {
1539
+ writeRun('old.json', specEvidenceArtifact({ createdAt: '2026-01-01T00:00:00.000Z' }));
1540
+ writeRun('new.json', specEvidenceArtifact());
1541
+ const r = runHook();
1542
+ expect(r.status).toBe(0);
1543
+ expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
1544
+ expect(r.stdout).not.toContain('old.json');
1545
+ });
1546
+ });
1547
+ // The mmnto-ai/totem#2700 arm, EXECUTED: an artifact is evidence only when it
1548
+ // is ANCHORED (issue | record) and its SUBJECT carries a shape. Every negation
1549
+ // must BLOCK with its OWN named reason — never with the "no artifact" line and
1550
+ // never as a reader failure — because the reason is the whole cure.
1551
+ describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/totem#2700)', () => {
1552
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
1553
+ /**
1554
+ * Whether this platform (and this account) can make a symlink — a Windows
1555
+ * account without SeCreateSymbolicLinkPrivilege cannot. Probed ONCE, outside
1556
+ * any test, so the skip is a real capability check rather than a swallowed
1557
+ * failure inside the assertion.
1558
+ */
1559
+ const symlinkable = (() => {
1560
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-link-probe-'));
1561
+ const target = path.join(probeDir, 'target');
1562
+ fs.writeFileSync(target, 'probe');
1563
+ let ok = false;
1564
+ try {
1565
+ fs.symlinkSync(target, path.join(probeDir, 'sym'));
1566
+ ok = true;
1567
+ }
1568
+ catch (err) {
1569
+ void err;
1570
+ ok = false;
1571
+ }
1572
+ cleanTmpDir(probeDir);
1573
+ return ok;
1574
+ })();
1575
+ let tmpDir;
1576
+ /** A directory OUTSIDE the worktree — the target of the escaping-symlink case. */
1577
+ let outsideDir;
1578
+ beforeEach(() => {
1579
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-anchor-'));
1580
+ outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-outside-'));
1581
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
1582
+ execSync('git checkout -q -b feat/anchored', { cwd: tmpDir, stdio: 'ignore' });
1583
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
1584
+ });
1585
+ afterEach(() => {
1586
+ cleanTmpDir(tmpDir);
1587
+ cleanTmpDir(outsideDir);
1588
+ });
1589
+ function runHook() {
1590
+ const r = spawnSync('sh', ['./pre-commit'], {
1591
+ cwd: tmpDir,
1592
+ encoding: 'utf-8',
1593
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
1594
+ });
1595
+ return { status: r.status, stdout: r.stdout };
1596
+ }
1597
+ function writeRun(name, artifact) {
1598
+ const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
1599
+ fs.mkdirSync(dir, { recursive: true });
1600
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
1601
+ }
1602
+ /** Write a bound record at a repo-relative path and return its sha256. */
1603
+ function writeRecord(relPath, body) {
1604
+ const abs = path.join(tmpDir, ...relPath.split('/'));
1605
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1606
+ fs.writeFileSync(abs, body, 'utf-8');
1607
+ return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
1608
+ }
1609
+ /** Every BLOCKED arm must be distinguishable from the other two arms. */
1610
+ function expectDistinctBlock(stdout) {
1611
+ expect(stdout).not.toContain('no totem spec run artifact');
1612
+ expect(stdout).not.toContain('the spec-evidence reader could not run');
1613
+ }
1614
+ // ── The migration invariant ──
1615
+ it.skipIf(!shellOk)('a PRE-EXISTING artifact (no grounding.anchor) BLOCKS with the `predates` reason, not the no-artifact line', () => {
1616
+ writeRun('legacy.json', {
1617
+ admission: { runMetadata: { caller: 'spec' } },
1618
+ output: { content: TEMPLATE_DRAFT },
1619
+ createdAt: new Date().toISOString(),
1620
+ });
1621
+ const r = runHook();
1622
+ expect(r.status).toBe(1);
1623
+ expect(r.stdout).toContain('predates the anchored-evidence rule (no grounding.anchor)');
1624
+ expect(r.stdout).toContain('.totem/artifacts/runs/legacy.json');
1625
+ expectDistinctBlock(r.stdout);
1626
+ });
1627
+ // ── The unanchored kinds ──
1628
+ it.skipIf(!shellOk)('a `free-text` anchor BLOCKS, naming the kind and the topic', () => {
1629
+ writeRun('ft.json', specEvidenceArtifact({
1630
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'a loose slug' } },
1631
+ }));
1632
+ const r = runHook();
1633
+ expect(r.status).toBe(1);
1634
+ expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_FREE_TEXT} (a loose slug)`);
1635
+ expect(r.stdout).toContain('not gate evidence');
1636
+ expectDistinctBlock(r.stdout);
1637
+ });
1638
+ it.skipIf(!shellOk)('a `mixed` anchor BLOCKS, naming the kind and the ref', () => {
1639
+ writeRun('mx.json', specEvidenceArtifact({
1640
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_MIXED, ref: '#2700 | a loose slug' } },
1641
+ }));
1642
+ const r = runHook();
1643
+ expect(r.status).toBe(1);
1644
+ expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_MIXED} (#2700 | a loose slug)`);
1645
+ expectDistinctBlock(r.stdout);
1646
+ });
1647
+ it.skipIf(!shellOk)('both cures are named on every BLOCKED arm', () => {
1648
+ writeRun('ft.json', specEvidenceArtifact({
1649
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1650
+ }));
1651
+ const r = runHook();
1652
+ expect(r.stdout).toContain("run 'totem spec <issue>' or 'totem spec --from <record>'");
1653
+ });
1654
+ // ── The TEMPLATE shape (issue anchor, built-in prompt) ──
1655
+ it.skipIf(!shellOk)('the one-token draft (a lone newline) BLOCKS on the first missing heading', () => {
1656
+ writeRun('thin.json', specEvidenceArtifact({ output: { content: '\n' } }));
1657
+ const r = runHook();
1658
+ expect(r.status).toBe(1);
1659
+ expect(r.stdout).toContain('is missing heading ### Problem Statement');
1660
+ expectDistinctBlock(r.stdout);
1661
+ });
1662
+ it.skipIf(!shellOk)('both required headings present over EMPTY bodies BLOCK, naming the empty heading', () => {
1663
+ writeRun('empty.json', specEvidenceArtifact({
1664
+ output: { content: '### Problem Statement\n\n### Implementation Tasks\n' },
1665
+ }));
1666
+ const r = runHook();
1667
+ expect(r.status).toBe(1);
1668
+ expect(r.stdout).toContain('has an empty heading ### Problem Statement');
1669
+ expectDistinctBlock(r.stdout);
1670
+ });
1671
+ it.skipIf(!shellOk)('a second required heading left empty BLOCKS, naming THAT heading', () => {
1672
+ writeRun('half.json', specEvidenceArtifact({
1673
+ output: {
1674
+ content: '### Problem Statement\n\nA real body.\n\n### Implementation Tasks\n',
1675
+ },
1676
+ }));
1677
+ const r = runHook();
1678
+ expect(r.status).toBe(1);
1679
+ expect(r.stdout).toContain('has an empty heading ### Implementation Tasks');
1680
+ });
1681
+ it.skipIf(!shellOk)('both required headings with bodies PASS, and the evidence line carries the anchor and the shape', () => {
1682
+ writeRun('ok.json', specEvidenceArtifact());
1683
+ const r = runHook();
1684
+ expect(r.status).toBe(0);
1685
+ expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/ok.json (');
1686
+ expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_ISSUE} #2700`);
1687
+ expect(r.stdout).toContain('· shape TEMPLATE');
1688
+ });
1689
+ it.skipIf(!shellOk)('a non-string draft on an issue anchor BLOCKS as not-text', () => {
1690
+ writeRun('nt.json', specEvidenceArtifact({ output: { content: { nested: true } } }));
1691
+ const r = runHook();
1692
+ expect(r.status).toBe(1);
1693
+ expect(r.stdout).toContain('the draft is not text');
1694
+ expectDistinctBlock(r.stdout);
1695
+ });
1696
+ // ── The DOCUMENT shape (override prompt on an issue anchor) ──
1697
+ it.skipIf(!shellOk)('an OVERRIDE-prompt draft without the template headings PASSES on one heading with a body', () => {
1698
+ writeRun('ov.json', specEvidenceArtifact({
1699
+ admission: {
1700
+ runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE },
1701
+ },
1702
+ output: { content: DOCUMENT_DRAFT },
1703
+ }));
1704
+ const r = runHook();
1705
+ expect(r.status).toBe(0);
1706
+ expect(r.stdout).toContain('· shape DOCUMENT');
1707
+ });
1708
+ it.skipIf(!shellOk)('an OVERRIDE-prompt draft with no heading at all BLOCKS', () => {
1709
+ writeRun('ovbad.json', specEvidenceArtifact({
1710
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
1711
+ output: { content: 'just prose, no heading\n' },
1712
+ }));
1713
+ const r = runHook();
1714
+ expect(r.status).toBe(1);
1715
+ expect(r.stdout).toContain('has no heading with a body');
1716
+ expect(r.stdout).toContain('custom prompt');
1717
+ expectDistinctBlock(r.stdout);
1718
+ });
1719
+ // ── The record arm: the SUBJECT is the record's bytes ──
1720
+ function recordArtifact(ref, sha256, draft = TEMPLATE_DRAFT) {
1721
+ return specEvidenceArtifact({
1722
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref, sha256 } },
1723
+ output: { content: draft },
1724
+ });
1725
+ }
1726
+ it.skipIf(!shellOk)('a present record with a heading and a body PASSES, reporting `record sha256 matches`', () => {
1727
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1728
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1729
+ const r = runHook();
1730
+ expect(r.status).toBe(0);
1731
+ expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_RECORD} .totem/specs/2700.md`);
1732
+ expect(r.stdout).toContain('· shape DOCUMENT');
1733
+ expect(r.stdout).toContain('· record sha256 matches');
1734
+ });
1735
+ it.skipIf(!shellOk)('a REVISED record still PASSES — the digest is a SENSOR, never a gate', () => {
1736
+ const bound = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1737
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', bound));
1738
+ const now = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract, folded.\n');
1739
+ const r = runHook();
1740
+ expect(r.status).toBe(0);
1741
+ expect(r.stdout).toContain(`record revised since binding (bound ${bound.slice(0, 8)}, now ${now.slice(0, 8)})`);
1742
+ });
1743
+ it.skipIf(!shellOk)('a MISSING record BLOCKS, naming the path it was bound at', () => {
1744
+ writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'b'.repeat(64)));
1745
+ const r = runHook();
1746
+ expect(r.status).toBe(1);
1747
+ expect(r.stdout).toContain('the bound record is missing at .totem/specs/gone.md');
1748
+ expectDistinctBlock(r.stdout);
1749
+ });
1750
+ it.skipIf(!shellOk)('a record with NO heading-with-a-body BLOCKS', () => {
1751
+ const sha = writeRecord('.totem/specs/2700.md', 'prose with no heading\n');
1752
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1753
+ const r = runHook();
1754
+ expect(r.status).toBe(1);
1755
+ expect(r.stdout).toContain('the bound record at .totem/specs/2700.md has no heading with a body');
1756
+ expectDistinctBlock(r.stdout);
1757
+ });
1758
+ it.skipIf(!shellOk)('a record whose heading has no body BLOCKS (a heading alone is not a document)', () => {
1759
+ const sha = writeRecord('.totem/specs/2700.md', '# Heading only\n\n## Another heading\n');
1760
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1761
+ const r = runHook();
1762
+ expect(r.status).toBe(1);
1763
+ expect(r.stdout).toContain('has no heading with a body');
1764
+ });
1765
+ it.skipIf(!shellOk)('a THIN draft with a rich record PASSES — the reader never reads output.content on a record anchor', () => {
1766
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1767
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha, '\n'));
1768
+ const r = runHook();
1769
+ expect(r.status).toBe(0);
1770
+ expect(r.stdout).toContain('· record sha256 matches');
1771
+ });
1772
+ it.skipIf(!shellOk)('a RICH draft with a missing record BLOCKS — the record is the subject, not the draft', () => {
1773
+ writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'c'.repeat(64), TEMPLATE_DRAFT));
1774
+ const r = runHook();
1775
+ expect(r.status).toBe(1);
1776
+ expect(r.stdout).toContain('the bound record is missing at');
1777
+ });
1778
+ // ── The body-line boundary is a HEADING, not a leading "#" ──
1779
+ //
1780
+ // `#2700 is the issue.` is prose about an issue number, not a heading: no
1781
+ // space follows the hashes. Reading it as a boundary made a real body look
1782
+ // empty — the exact sentence a totem design record is most likely to open a
1783
+ // section with.
1784
+ it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the TEMPLATE arm', () => {
1785
+ const draft = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\n#2700 is the issue.\n`).join('\n');
1786
+ writeRun('hashbody.json', specEvidenceArtifact({ output: { content: draft } }));
1787
+ const r = runHook();
1788
+ expect(r.status).toBe(0);
1789
+ expect(r.stdout).toContain('· shape TEMPLATE');
1790
+ });
1791
+ it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the DOCUMENT arm', () => {
1792
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\n#2700 is the issue.\n');
1793
+ writeRun('hashrec.json', recordArtifact('.totem/specs/2700.md', sha));
1794
+ const r = runHook();
1795
+ expect(r.status).toBe(0);
1796
+ expect(r.stdout).toContain('· record sha256 matches');
1797
+ });
1798
+ // ── The DOCUMENT shape tolerates two ordinary authoring bytes ──
1799
+ it.skipIf(!shellOk)('a DRAFT opening with a UTF-8 BOM still reads as a template', () => {
1800
+ // The BOM strip runs on the SUBJECT, before the split — so it covers the
1801
+ // TEMPLATE arm too, where an unstripped BOM would make the first required
1802
+ // heading fail its exact-line match and BLOCK a well-formed draft.
1803
+ const bom = String.fromCharCode(0xfeff);
1804
+ writeRun('bomdraft.json', specEvidenceArtifact({ output: { content: bom + TEMPLATE_DRAFT } }));
1805
+ const r = runHook();
1806
+ expect(r.status).toBe(0);
1807
+ expect(r.stdout).toContain('· shape TEMPLATE');
1808
+ });
1809
+ it.skipIf(!shellOk)('a record opening with a UTF-8 BOM still reads as a document', () => {
1810
+ const bom = String.fromCharCode(0xfeff);
1811
+ const sha = writeRecord('.totem/specs/2700.md', `${bom}# Record\n\nThe ruled contract.\n`);
1812
+ writeRun('bom.json', recordArtifact('.totem/specs/2700.md', sha));
1813
+ const r = runHook();
1814
+ expect(r.status).toBe(0);
1815
+ expect(r.stdout).toContain('· shape DOCUMENT');
1816
+ });
1817
+ it.skipIf(!shellOk)('a heading separated from its text by a TAB is a heading', () => {
1818
+ const sha = writeRecord('.totem/specs/2700.md', '#\tTabbed heading\n\nThe body.\n');
1819
+ writeRun('tab.json', recordArtifact('.totem/specs/2700.md', sha));
1820
+ const r = runHook();
1821
+ expect(r.status).toBe(0);
1822
+ expect(r.stdout).toContain('· shape DOCUMENT');
1823
+ });
1824
+ // ── The artifact is hand-editable: nothing read out of it is trusted text ──
1825
+ it.skipIf(!shellOk)('a newline in anchor.ref cannot forge a second [Totem] line', () => {
1826
+ const forged = `slug${String.fromCharCode(0x0a)}[Totem] spec evidence: forged`;
1827
+ writeRun('inject.json', specEvidenceArtifact({
1828
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
1829
+ }));
1830
+ const r = runHook();
1831
+ expect(r.status).toBe(1);
1832
+ // Exactly one line of the hook's own output claims to be from Totem.
1833
+ expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
1834
+ // The control character is collapsed, not dropped — the ref stays legible.
1835
+ expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
1836
+ });
1837
+ it.skipIf(!shellOk)('a C1 control (U+0085 NEL) in anchor.ref is collapsed too', () => {
1838
+ // A predicate stopping at 0x7f let the whole C1 band through; NEL is a
1839
+ // LINE BREAK to some terminals and pagers, so it is a forged-line vector
1840
+ // of exactly the same kind as 0x0a.
1841
+ const forged = `slug${String.fromCharCode(0x85)}[Totem] spec evidence: forged`;
1842
+ writeRun('nel.json', specEvidenceArtifact({
1843
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
1844
+ }));
1845
+ const r = runHook();
1846
+ expect(r.status).toBe(1);
1847
+ expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
1848
+ expect(r.stdout).not.toContain(String.fromCharCode(0x85));
1849
+ });
1850
+ it.skipIf(!shellOk)('a newline in createdAt cannot forge a second [Totem] line on the PASS path', () => {
1851
+ // `createdAt` is read raw off the artifact and ECHOED in the evidence
1852
+ // line: unsanitized, a hand-edited stamp forges a second `[Totem] spec
1853
+ // evidence:` line inside a passing commit — the quietest place to hide
1854
+ // one.
1855
+ const forged = `2026-09-02T00:00:00.000Z${String.fromCharCode(0x0a)}[Totem] spec evidence: FORGED`;
1856
+ writeRun('stamp.json', specEvidenceArtifact({ createdAt: forged }));
1857
+ const r = runHook();
1858
+ expect(r.status).toBe(0);
1859
+ expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
1860
+ expect(r.stdout).toContain('.000Z?[Totem] spec evidence: FORGED');
1861
+ });
1862
+ it.skipIf(!shellOk)('a record ref that is ABSOLUTE is refused as outside the worktree', () => {
1863
+ writeRun('abs.json', recordArtifact('/etc/passwd', 'd'.repeat(64)));
1864
+ const r = runHook();
1865
+ expect(r.status).toBe(1);
1866
+ expect(r.stdout).toContain('the bound record ref is outside the worktree: /etc/passwd');
1867
+ expectDistinctBlock(r.stdout);
1868
+ });
1869
+ it.skipIf(!shellOk)('a record ref whose first segment is `..` is refused as outside the worktree', () => {
1870
+ writeRun('dotdot.json', recordArtifact('../outside.md', 'd'.repeat(64)));
1871
+ const r = runHook();
1872
+ expect(r.status).toBe(1);
1873
+ expect(r.stdout).toContain('the bound record ref is outside the worktree: ../outside.md');
1874
+ expectDistinctBlock(r.stdout);
1875
+ });
1876
+ // ── Containment is decided by RESOLUTION, not by segment 0 ──
1877
+ //
1878
+ // A first-segment test reads `sub/../../OUTSIDE.md` and `./../OUTSIDE.md` as
1879
+ // contained, so the reader would have READ and PASSED a file outside the
1880
+ // worktree. The reader now resolves the ref against `process.cwd()` — the
1881
+ // worktree top git runs hooks from — and refuses anything that lands outside
1882
+ // it, which is also the honest rule for the legitimate mid-path `..`.
1883
+ /** A backslash, built rather than escaped — the mmnto-ai/totem#2692 authoring trap. */
1884
+ const BACKSLASH = String.fromCharCode(0x5c);
1885
+ it.skipIf(!shellOk).each([
1886
+ ['sub/../../OUTSIDE.md', 'a `..` past segment 0'],
1887
+ ['./../OUTSIDE.md', 'a `.` first segment hiding a `..`'],
1888
+ [
1889
+ `a${BACKSLASH}..${BACKSLASH}..${BACKSLASH}OUTSIDE.md`,
1890
+ 'the same escape spelled with backslashes',
1891
+ ],
1892
+ ])('refuses %s as outside the worktree (%s)', (ref) => {
1893
+ writeRun('escape.json', recordArtifact(ref, 'd'.repeat(64)));
1894
+ const r = runHook();
1895
+ expect(r.status).toBe(1);
1896
+ expect(r.stdout).toContain(`the bound record ref is outside the worktree: ${ref}`);
1897
+ expectDistinctBlock(r.stdout);
1898
+ });
1899
+ it.skipIf(!shellOk)('a mid-path `..` that stays INSIDE the worktree still PASSES — containment, not a segment ban', () => {
1900
+ // `a/` must exist for POSIX path resolution to reach `a/../b.md`.
1901
+ fs.mkdirSync(path.join(tmpDir, 'a'), { recursive: true });
1902
+ const sha = writeRecord('b.md', '# Record\n\nThe ruled contract.\n');
1903
+ writeRun('inside.json', recordArtifact('a/../b.md', sha));
1904
+ const r = runHook();
1905
+ expect(r.status).toBe(0);
1906
+ expect(r.stdout).toContain('· record sha256 matches');
1907
+ });
1908
+ it.skipIf(!shellOk)('a record anchor with NO sha256 BLOCKS — a binding without bytes is not a binding', () => {
1909
+ writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1910
+ writeRun('nosha.json', specEvidenceArtifact({
1911
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md' } },
1912
+ }));
1913
+ const r = runHook();
1914
+ expect(r.status).toBe(1);
1915
+ expect(r.stdout).toContain('the record anchor carries no sha256 — not evidence');
1916
+ expectDistinctBlock(r.stdout);
1917
+ });
1918
+ it.skipIf(!shellOk)('a record anchor whose sha256 is MALFORMED BLOCKS with its OWN reason, not the absent one', () => {
1919
+ // "No digest" and "a digest that is not a digest" are different repairs:
1920
+ // the first says re-bind, the second says the artifact was edited. One
1921
+ // shared message would send both to the wrong cure.
1922
+ writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1923
+ writeRun('badsha.json', recordArtifact('.totem/specs/2700.md', 'not-a-digest'));
1924
+ const r = runHook();
1925
+ expect(r.status).toBe(1);
1926
+ expect(r.stdout).toContain('the record anchor sha256 is not a 64-hex digest (not-a-digest) — not evidence');
1927
+ expect(r.stdout).not.toContain('carries no sha256');
1928
+ expectDistinctBlock(r.stdout);
1929
+ });
1930
+ // ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
1931
+ //
1932
+ // A symlink inside the worktree is LEXICALLY contained, so the resolve-based
1933
+ // test above reads it as legal — and the reader would open, hash and judge a
1934
+ // file outside the tree. The realpath pair is compared once the ref is known
1935
+ // to exist and BEFORE its bytes are read.
1936
+ it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to OUTSIDE the worktree BLOCKS, naming both spellings', () => {
1937
+ const target = path.join(outsideDir, 'record.md');
1938
+ fs.writeFileSync(target, '# Outside\n\nThe ruled contract.\n', 'utf-8');
1939
+ const sha = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
1940
+ fs.symlinkSync(target, path.join(tmpDir, 'linked.md'));
1941
+ // A CORRECT digest, so the block cannot be the sha256 arm firing early.
1942
+ writeRun('symlink-out.json', recordArtifact('linked.md', sha));
1943
+ const r = runHook();
1944
+ expect(r.status).toBe(1);
1945
+ expect(r.stdout).toContain('the bound record resolves outside the worktree: linked.md -> ');
1946
+ expect(r.stdout).toContain(fs.realpathSync.native(target));
1947
+ expectDistinctBlock(r.stdout);
1948
+ });
1949
+ it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to an IN-worktree file PASSES — containment, not a link ban', () => {
1950
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1951
+ fs.symlinkSync(path.join(tmpDir, '.totem', 'specs', '2700.md'), path.join(tmpDir, 'linked.md'));
1952
+ writeRun('symlink-in.json', recordArtifact('linked.md', sha));
1953
+ const r = runHook();
1954
+ expect(r.status).toBe(0);
1955
+ expect(r.stdout).toContain('· record sha256 matches');
1956
+ });
1957
+ // ── The cure names the cache (mmnto-ai/totem#2700 m11) ──
1958
+ it.skipIf(!shellOk)('the BLOCKED cure names --fresh — a cached response mints no artifact', () => {
1959
+ writeRun('ft.json', specEvidenceArtifact({
1960
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1961
+ }));
1962
+ const r = runHook();
1963
+ expect(r.stdout).toContain('(add --fresh if the response is cached)');
1964
+ });
1965
+ // ── The other arms stay distinct ──
1966
+ it.skipIf(!shellOk)('exit 3 is NEVER reported as a reader failure', () => {
1967
+ writeRun('ft.json', specEvidenceArtifact({
1968
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1969
+ }));
1970
+ const r = runHook();
1971
+ expect(r.status).toBe(1);
1972
+ expect(r.stdout).not.toContain('node exit status');
1973
+ });
1974
+ it.skipIf(!shellOk)('the carried-object substring control still BLOCKS with the no-artifact line', () => {
1975
+ // Only the TOP-LEVEL caller counts: a review artifact that carries a
1976
+ // `caller: spec` object below the top level is not a spec artifact at all,
1977
+ // so the gate reports "none found", not "not evidence".
1978
+ writeRun('r.json', {
1979
+ admission: { runMetadata: { caller: 'review' } },
1980
+ inputBundle: { runMetadata: { caller: 'spec' } },
1981
+ createdAt: new Date().toISOString(),
1982
+ });
1983
+ const r = runHook();
1984
+ expect(r.status).toBe(1);
1985
+ expect(r.stdout).toContain('no totem spec run artifact');
1986
+ });
1987
+ it.skipIf(!shellOk)('the retired hand-set marker still rescues nothing', () => {
1988
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1989
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1990
+ writeRun('ft.json', specEvidenceArtifact({
1991
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1992
+ }));
1993
+ const r = runHook();
1994
+ expect(r.status).toBe(1);
1995
+ expect(r.stdout).not.toContain('spec evidence:');
1996
+ });
1997
+ });
1998
+ // The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
1999
+ // hook's reader and `totem spec`'s writer must name the SAME tree. The fixture
2000
+ // writes where the WRITER writes (`<totemDir>/artifacts/runs`, the
2001
+ // `path.join(configRoot, config.totemDir)/artifacts/runs` the run store uses) and
2002
+ // the hook is rendered at that same totemDir — so a pass here is the two halves
2003
+ // agreeing, and the control below is the pre-fix behavior (evidence under
2004
+ // `.totem/` while the hook reads `knowledge/`) still failing closed.
2005
+ describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — executed under sh (mmnto-ai/totem#2692)', () => {
2006
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
2007
+ const CUSTOM_TOTEM_DIR = 'knowledge';
2008
+ let tmpDir;
2009
+ beforeEach(() => {
2010
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-customdir-'));
2011
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
2012
+ execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
2013
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: CUSTOM_TOTEM_DIR }));
2014
+ });
2015
+ afterEach(() => {
2016
+ cleanTmpDir(tmpDir);
2017
+ });
2018
+ function runHook() {
2019
+ const r = spawnSync('sh', ['./pre-commit'], {
2020
+ cwd: tmpDir,
2021
+ encoding: 'utf-8',
2022
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
2023
+ });
2024
+ return { status: r.status, stdout: r.stdout };
2025
+ }
2026
+ /** Write a run artifact where the WRITER writes it, for an arbitrary totemDir. */
2027
+ function writeRunUnder(totemDir, name, artifact) {
2028
+ const dir = path.join(tmpDir, ...totemDir.split('/'), 'artifacts', 'runs');
2029
+ fs.mkdirSync(dir, { recursive: true });
2030
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
2031
+ }
2032
+ const specArtifact = () => specEvidenceArtifact();
2033
+ it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
2034
+ writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
2035
+ const r = runHook();
2036
+ expect(r.status).toBe(0);
2037
+ expect(r.stdout).toContain(`[Totem] spec evidence: ${CUSTOM_TOTEM_DIR}/artifacts/runs/a.json (`);
2038
+ });
2039
+ it.skipIf(!shellOk)('the pre-fix control: evidence under .totem/ does NOT satisfy a hook rendered for knowledge/', () => {
2040
+ // This is the mmnto-ai/totem#2692 defect, inverted: before the fix the hook
2041
+ // ALWAYS read `.totem/artifacts/runs` while the writer used the configured
2042
+ // dir, so the gate failed closed forever. With the hook rendered at the
2043
+ // configured dir, evidence in the WRONG tree is correctly not evidence.
2044
+ writeRunUnder('.totem', 'a.json', specArtifact());
2045
+ const r = runHook();
2046
+ expect(r.status).toBe(1);
2047
+ expect(r.stdout).toContain(`no totem spec run artifact under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
2048
+ });
2049
+ it.skipIf(!shellOk)('blocks with a message naming the configured directory', () => {
2050
+ const r = runHook();
2051
+ expect(r.status).toBe(1);
2052
+ expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
2053
+ expect(r.stdout).toContain(`under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
2054
+ expect(r.stdout).not.toContain('.totem');
2055
+ });
2056
+ it.skipIf(!shellOk)('a nested totemDir survives the same round trip', () => {
2057
+ const nested = 'var/totem-state';
2058
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: nested }));
2059
+ writeRunUnder(nested, 'a.json', specArtifact());
2060
+ const r = runHook();
2061
+ expect(r.status).toBe(0);
2062
+ expect(r.stdout).toContain(`[Totem] spec evidence: ${nested}/artifacts/runs/a.json (`);
2063
+ });
1294
2064
  });
1295
2065
  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');
2066
+ it('includes the spec-evidence check guarded by agent/tier condition', () => {
2067
+ const hook = buildPreCommitHook(RENDER);
2068
+ expect(hook).toContain('.totem/artifacts/runs');
1299
2069
  expect(hook).toContain('is_agent');
1300
2070
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1301
2071
  });
1302
- it('defaults to standard tier when no tier specified', () => {
1303
- const hook = buildPreCommitHook();
2072
+ // Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
2073
+ // specified". There is no default any more — the options object is required and
2074
+ // the RESOLVER supplies 'standard' — so the surviving intent is that the
2075
+ // standard-tier render still names the run store the resolver hands it.
2076
+ it('renders the run store at the options it was given', () => {
2077
+ const hook = buildPreCommitHook(RENDER);
1304
2078
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1305
- expect(hook).toContain('.spec-completed');
2079
+ expect(hook).toContain('.totem/artifacts/runs');
1306
2080
  });
1307
2081
  });
1308
2082
  describe('buildPrePushHook with strict tier', () => {
1309
- const FALLBACK = 'pnpm dlx @mmnto/cli';
1310
2083
  it('includes shield gate', () => {
1311
- const hook = buildPrePushHook(FALLBACK, 'strict');
2084
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1312
2085
  expect(hook).toContain('review');
1313
2086
  expect(hook).toContain('shield gate (strict mode)');
1314
2087
  });
1315
2088
  it('includes TOTEM_HOOK_TIER set to strict', () => {
1316
- const hook = buildPrePushHook(FALLBACK, 'strict');
2089
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1317
2090
  expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
1318
2091
  });
1319
2092
  it('includes agent detection snippet', () => {
1320
- const hook = buildPrePushHook(FALLBACK, 'strict');
2093
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1321
2094
  expect(hook).toContain('is_agent=0');
1322
2095
  expect(hook).toContain('CLAUDE_CODE_AGENT');
1323
2096
  });
@@ -1325,12 +2098,12 @@ describe('buildPrePushHook with strict tier', () => {
1325
2098
  // shieldBlock alongside the existing `totem review` gate. Repo-state
1326
2099
  // checks (like checkAgentsMdCanonical) now block push when they fail.
1327
2100
  it('includes $TOTEM_CMD doctor --strict in the shield block', () => {
1328
- const hook = buildPrePushHook(FALLBACK, 'strict');
2101
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1329
2102
  expect(hook).toContain('$TOTEM_CMD doctor --strict');
1330
2103
  expect(hook).toContain('doctor --strict (repo-state gate)');
1331
2104
  });
1332
2105
  it('gates doctor --strict on the agent/strict guard (does NOT fire unconditionally)', () => {
1333
- const hook = buildPrePushHook(FALLBACK, 'strict');
2106
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1334
2107
  // Find the position of the doctor --strict invocation and the surrounding
1335
2108
  // guard; assert the invocation lives INSIDE the agent-or-tier block.
1336
2109
  const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
@@ -1342,20 +2115,22 @@ describe('buildPrePushHook with strict tier', () => {
1342
2115
  });
1343
2116
  });
1344
2117
  describe('buildPrePushHook with standard tier', () => {
1345
- const FALLBACK = 'pnpm dlx @mmnto/cli';
1346
2118
  it('includes shield gate guarded by agent/tier condition', () => {
1347
- const hook = buildPrePushHook(FALLBACK, 'standard');
2119
+ const hook = buildPrePushHook(RENDER);
1348
2120
  expect(hook).toContain('shield gate');
1349
2121
  expect(hook).toContain('is_agent');
1350
2122
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1351
2123
  });
1352
- it('defaults to standard tier when no tier specified', () => {
1353
- const hook = buildPrePushHook(FALLBACK);
2124
+ // Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
2125
+ // specified" the builders take a REQUIRED options object now, so the
2126
+ // surviving intent is that a standard-tier render stamps the standard tier.
2127
+ it('stamps the standard tier it was given', () => {
2128
+ const hook = buildPrePushHook(RENDER);
1354
2129
  expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
1355
2130
  expect(hook).toContain('shield gate');
1356
2131
  });
1357
2132
  it('still includes agent detection', () => {
1358
- const hook = buildPrePushHook(FALLBACK, 'standard');
2133
+ const hook = buildPrePushHook(RENDER);
1359
2134
  expect(hook).toContain('is_agent=0');
1360
2135
  });
1361
2136
  // mmnto-ai/totem#1908 — even in standard tier, the doctor --strict line
@@ -1364,7 +2139,7 @@ describe('buildPrePushHook with standard tier', () => {
1364
2139
  // operators bypass it because their `is_agent=0` and `TOTEM_HOOK_TIER` is
1365
2140
  // standard, so the guard branch never enters.
1366
2141
  it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
1367
- const hook = buildPrePushHook(FALLBACK, 'standard');
2142
+ const hook = buildPrePushHook(RENDER);
1368
2143
  const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
1369
2144
  const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
1370
2145
  const fiCloseIdx = hook.indexOf('fi', guardIdx);
@@ -1375,7 +2150,7 @@ describe('buildPrePushHook with standard tier', () => {
1375
2150
  });
1376
2151
  describe('agent detection uses POSIX syntax', () => {
1377
2152
  it('pre-commit hook has no bashisms', () => {
1378
- const hook = buildPreCommitHook('strict');
2153
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1379
2154
  // Must use [ ] not [[ ]]
1380
2155
  expect(hook).not.toContain('[[');
1381
2156
  expect(hook).not.toContain(']]');
@@ -1385,7 +2160,7 @@ describe('agent detection uses POSIX syntax', () => {
1385
2160
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
1386
2161
  });
1387
2162
  it('pre-push hook has no bashisms', () => {
1388
- const hook = buildPrePushHook('pnpm dlx @mmnto/cli', 'strict');
2163
+ const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1389
2164
  // Must use [ ] not [[ ]]
1390
2165
  expect(hook).not.toContain('[[');
1391
2166
  expect(hook).not.toContain(']]');