@mmnto/cli 1.120.0 → 1.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/commands/config-drift.test.js +10 -2
  2. package/dist/commands/config-drift.test.js.map +1 -1
  3. package/dist/commands/doctor-parity.d.ts.map +1 -1
  4. package/dist/commands/doctor-parity.js +21 -6
  5. package/dist/commands/doctor-parity.js.map +1 -1
  6. package/dist/commands/doctor-parity.test.js +14 -7
  7. package/dist/commands/doctor-parity.test.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +21 -1
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +167 -9
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/doctor.test.js +234 -6
  13. package/dist/commands/doctor.test.js.map +1 -1
  14. package/dist/commands/ecl-gc.d.ts.map +1 -1
  15. package/dist/commands/ecl-gc.js +38 -5
  16. package/dist/commands/ecl-gc.js.map +1 -1
  17. package/dist/commands/ecl-gc.test.js +71 -0
  18. package/dist/commands/ecl-gc.test.js.map +1 -1
  19. package/dist/commands/eject-totemdir.test.d.ts +8 -0
  20. package/dist/commands/eject-totemdir.test.d.ts.map +1 -0
  21. package/dist/commands/eject-totemdir.test.js +101 -0
  22. package/dist/commands/eject-totemdir.test.js.map +1 -0
  23. package/dist/commands/eject.d.ts +30 -5
  24. package/dist/commands/eject.d.ts.map +1 -1
  25. package/dist/commands/eject.js +145 -31
  26. package/dist/commands/eject.js.map +1 -1
  27. package/dist/commands/eject.test.js +3 -3
  28. package/dist/commands/eject.test.js.map +1 -1
  29. package/dist/commands/hook-totemdir-render.test.d.ts +23 -0
  30. package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
  31. package/dist/commands/hook-totemdir-render.test.js +288 -0
  32. package/dist/commands/hook-totemdir-render.test.js.map +1 -0
  33. package/dist/commands/init-templates.d.ts +4 -4
  34. package/dist/commands/init-templates.d.ts.map +1 -1
  35. package/dist/commands/init-templates.js +3 -3
  36. package/dist/commands/install-hooks-exit-contract.test.js +6 -6
  37. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  38. package/dist/commands/install-hooks.d.ts +162 -12
  39. package/dist/commands/install-hooks.d.ts.map +1 -1
  40. package/dist/commands/install-hooks.js +355 -88
  41. package/dist/commands/install-hooks.js.map +1 -1
  42. package/dist/commands/install-hooks.test.js +381 -147
  43. package/dist/commands/install-hooks.test.js.map +1 -1
  44. package/dist/commands/link.d.ts.map +1 -1
  45. package/dist/commands/link.js +71 -16
  46. package/dist/commands/link.js.map +1 -1
  47. package/dist/commands/link.test.d.ts +9 -0
  48. package/dist/commands/link.test.d.ts.map +1 -0
  49. package/dist/commands/link.test.js +92 -0
  50. package/dist/commands/link.test.js.map +1 -0
  51. package/dist/commands/mail.d.ts +48 -2
  52. package/dist/commands/mail.d.ts.map +1 -1
  53. package/dist/commands/mail.js +76 -2
  54. package/dist/commands/mail.js.map +1 -1
  55. package/dist/commands/mail.test.js +177 -0
  56. package/dist/commands/mail.test.js.map +1 -1
  57. package/dist/commands/pre-push-gate-matrix.test.js +9 -2
  58. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
  59. package/dist/commands/shield.js +1 -1
  60. package/dist/commands/shield.js.map +1 -1
  61. package/dist/commands/tools-hook-parity.test.js +7 -3
  62. package/dist/commands/tools-hook-parity.test.js.map +1 -1
  63. package/dist/index.js +9 -0
  64. package/dist/index.js.map +1 -1
  65. package/package.json +2 -2
@@ -124,6 +124,186 @@ export function detectTotemPrefix(cwd) {
124
124
  return 'bunx totem';
125
125
  return 'npx totem';
126
126
  }
127
+ // ─── Hook render options (mmnto-ai/totem#2692) ────────────────
128
+ /** The `totemDir` every hook renders when the repo configures none. */
129
+ export const DEFAULT_TOTEM_DIR = '.totem';
130
+ /**
131
+ * Whether `value` carries a character that cannot be rendered SAFELY into the
132
+ * managed hooks: a single quote (breaks the `sh` single-quoted word AND the
133
+ * single-quoted `node -e '…'` reader), a double quote or a backslash (breaks the
134
+ * JS string literal inside that reader), a dollar sign or a backtick (the only
135
+ * characters that stay ACTIVE inside the double-quoted `sh` words every guard
136
+ * uses — refusing them is what lets those sites keep the one plain
137
+ * double-quoted form `tools/*` ships; mmnto-ai/totem#2692 amendment A2), or a
138
+ * control character / newline (breaks both, and can forge lines in the hook
139
+ * body).
140
+ *
141
+ * Written as a code-point walk rather than a regex with escape literals so the
142
+ * predicate carries no escape sequence of its own to mis-author.
143
+ */
144
+ export function hasUnrenderableTotemDirChar(value) {
145
+ for (const ch of value) {
146
+ if (ch === "'" || ch === '"' || ch === '\\' || ch === '$' || ch === '`')
147
+ return true;
148
+ const code = ch.codePointAt(0) ?? 0;
149
+ // Control characters, DEL, and everything non-ASCII: git C-quotes any path
150
+ // byte above 0x7e in the `diff --name-only` output the two `grep -q` diff
151
+ // filters read (`core.quotePath`, on by default), so a directory name
152
+ // carrying one could never match — the silent-skip class this closes.
153
+ if (code < 0x20 || code > 0x7e)
154
+ return true;
155
+ }
156
+ return false;
157
+ }
158
+ /**
159
+ * Why `totemDir` cannot be rendered into the managed hooks, or `null` when it
160
+ * can (mmnto-ai/totem#2692 C4 + amendment A7). Two classes:
161
+ *
162
+ * - CHARACTERS the quoting regimes cannot carry (see
163
+ * {@link hasUnrenderableTotemDirChar}) — the `@mmnto/totem` schema refuses
164
+ * the same set, so a validated config never reaches this arm.
165
+ * - SHAPES the hooks could never govern, which the schema deliberately still
166
+ * accepts because other verbs can use them (`.` is the global profile's own
167
+ * spelling): empty, a trailing slash, `.`, a `.` or empty segment, a `..`
168
+ * segment, a leading `-`. Each of these renders a hook whose post-merge /
169
+ * post-checkout diff filter (`grep -q '<dir>/…'` over the repo-relative
170
+ * paths git prints) can never match, or — for the empty value — an ABSOLUTE
171
+ * run-store path in the strict pre-commit reader. The schema normalises a
172
+ * trailing slash away; a raw value reaching a builder directly is refused,
173
+ * never normalised here (a builder is a pure function of its options).
174
+ */
175
+ export function hookTotemDirProblem(totemDir) {
176
+ if (hasUnrenderableTotemDirChar(totemDir)) {
177
+ return 'a single quote, double quote, backslash, dollar sign, backtick, non-ASCII character, newline or control character cannot be safely rendered into the managed hooks (git C-quotes non-ASCII paths, so a diff filter naming one could never match)';
178
+ }
179
+ if (totemDir.length === 0) {
180
+ return "an empty totemDir renders an ABSOLUTE run-store path ('/artifacts/runs') into the strict pre-commit reader and a diff filter that matches every path";
181
+ }
182
+ if (totemDir.endsWith('/')) {
183
+ return "a trailing slash renders 'dir//…' into the post-merge / post-checkout diff filters, which then never match — spell it without the slash";
184
+ }
185
+ if (totemDir === '.') {
186
+ return "'.' names the config directory itself; the hooks' diff filters ('grep -q <dir>/…') could never match the repo-relative paths git prints";
187
+ }
188
+ const segments = totemDir.split('/');
189
+ if (segments.includes('.') || segments.includes('')) {
190
+ return "a '.' segment (or '//') never appears in the repo-relative paths git prints, so the diff filters would never match";
191
+ }
192
+ if (segments.includes('..')) {
193
+ return "a '..' segment points outside the worktree the hooks run in; git prints repo-relative paths, so the diff filters could never match";
194
+ }
195
+ if (totemDir.startsWith('-')) {
196
+ return "a leading '-' is read as an option by grep in the diff filters";
197
+ }
198
+ return null;
199
+ }
200
+ /**
201
+ * Refuse — loudly, naming the value and the reason — a `totemDir` the hook
202
+ * templates cannot render (mmnto-ai/totem#2692 C4/A7). Called by the resolver
203
+ * on the configured value and by every builder as the render-path backstop for
204
+ * direct-API and hand-threaded call sites.
205
+ *
206
+ * Throws rather than degrades: a hook rendered from a value we could not quote
207
+ * is a shell-injection surface, and silently falling back to `.totem` would
208
+ * re-create the very writer/reader split this slice closes (Tenet 4).
209
+ */
210
+ export function assertRenderableTotemDir(totemDir) {
211
+ const problem = hookTotemDirProblem(totemDir);
212
+ if (problem === null)
213
+ return;
214
+ // A plain Error, unprefixed: this backstop sits on the SYNC render path (the
215
+ // builders), where `@mmnto/totem`'s TotemError cannot be lazy-imported; the
216
+ // resolver — the CLI's actual entry — raises the TotemError form of the same
217
+ // refusal. `handleError` adds the `[Totem Error]` tag, so the message carries
218
+ // none of its own (Gemini on mmnto-ai/totem#2701).
219
+ throw new Error(`Refusing to render git hooks for totemDir ${JSON.stringify(totemDir)}: ${problem}. ` +
220
+ 'Set `totemDir` to a plain relative directory inside the repo and re-run `totem hook install --force`.');
221
+ }
222
+ /**
223
+ * Escape a validated `totemDir` for a POSIX Basic Regular Expression — the two
224
+ * `grep -q '…'` diff filters. BRE specials are `\ ^ $ . * [ ]`; `^` and `$` are
225
+ * only special positionally, but escaping them unconditionally is still a
226
+ * literal match and keeps the rule one line.
227
+ */
228
+ function escapeBre(value) {
229
+ return value.replace(/[\\^$.*[\]]/g, '\\$&');
230
+ }
231
+ /**
232
+ * THE resolver: config → the options every hook writer renders from
233
+ * (mmnto-ai/totem#2692 C1).
234
+ *
235
+ * `tier` = explicit flag > `hooks.tier` from config > `'standard'` (the
236
+ * precedence `hooksCommand` already implemented, moved here so `totem init`,
237
+ * `installHooksNonInteractive` and the silent pre-push upgrade honor it too) —
238
+ * from whichever config resolves, global profile included, exactly as before.
239
+ *
240
+ * `totemDir` = the REPO-LOCAL config's `totemDir` > `.totem`. Repo-local only,
241
+ * and deliberately asymmetric with `tier`: the value is a path rendered into a
242
+ * hook that runs at the worktree top, so only this project's config can name it.
243
+ * The global `~/.totem/` profile `totem init --global` writes declares
244
+ * `totemDir: '.'` — describing that profile directory itself — and honoring it
245
+ * here would silently re-render every config-less repo's hooks against the
246
+ * checkout root on any machine that has a profile (the mmnto-ai/totem#2692 C3
247
+ * "no consumer's hooks drift on upgrade" invariant, and the same
248
+ * machine-dependence `doctor --parity` guards with `isGlobalConfigPath`).
249
+ *
250
+ * `fallbackCmd` = the lockfile probe anchored at `cwd` — pass the GIT ROOT, the
251
+ * anchor the installer has always used, so a hook installed from a subdirectory
252
+ * still names the repo's package manager.
253
+ *
254
+ * No config at all → the defaults, silently: a config-less repo installing hooks
255
+ * is a supported path, not an error. A config that RESOLVES but will not LOAD
256
+ * (a syntax error, a `totemDir` the schema refines out) → the defaults, LOUDLY:
257
+ * one line names the file and the failure, so a repo whose config says
258
+ * `knowledge/` never gets `.totem/` hooks without a word (mmnto-ai/totem#2692
259
+ * amendment A8 — the silent→loud shape of mmnto-ai/totem#2685). A config that
260
+ * loads but names a `totemDir` the hooks cannot govern (`.`, a `..` segment, a
261
+ * leading `-`) REFUSES — {@link assertRenderableTotemDir}.
262
+ */
263
+ export async function resolveHookRenderOptions(cwd, flags) {
264
+ const fallbackCmd = getFallbackCommand(cwd);
265
+ const defaults = {
266
+ tier: flags?.tier ?? 'standard',
267
+ totemDir: DEFAULT_TOTEM_DIR,
268
+ fallbackCmd,
269
+ };
270
+ const { loadConfig, loadEnv, resolveConfigPath, isGlobalConfigPath } = await import('../utils.js');
271
+ loadEnv(cwd);
272
+ let configPath;
273
+ try {
274
+ configPath = resolveConfigPath(cwd);
275
+ // totem-context: no config anywhere (resolveConfigPath throws CONFIG_MISSING) is the honest-default path — hooks install in config-less repos by design.
276
+ }
277
+ catch {
278
+ return defaults;
279
+ }
280
+ let config;
281
+ try {
282
+ config = await loadConfig(configPath);
283
+ // totem-context: LOUD default, not a swallow — the failure is printed on the line below and surfaced as `configError`; a repo whose config will not load still gets default hooks rather than an aborted install (mmnto-ai/totem#2692 A8, the silent→loud shape of mmnto-ai/totem#2685).
284
+ }
285
+ catch (err) {
286
+ const reason = err instanceof Error ? err.message : String(err);
287
+ console.error(`[Totem] Could not load ${configPath} (${reason.split('\n')[0]}) — the git hooks are rendered at the defaults (totemDir '${DEFAULT_TOTEM_DIR}', tier '${defaults.tier}'); fix the config and re-run \`totem hook install --force\`.`);
288
+ return { ...defaults, configError: reason };
289
+ }
290
+ const totemDir = isGlobalConfigPath(configPath)
291
+ ? DEFAULT_TOTEM_DIR
292
+ : (config.totemDir ?? DEFAULT_TOTEM_DIR);
293
+ // The CLI-layer form of the refusal: a TotemError with a recovery hint (the
294
+ // sync builders keep the plain-Error backstop, `assertRenderableTotemDir`).
295
+ const problem = hookTotemDirProblem(totemDir);
296
+ if (problem !== null) {
297
+ const { TotemError } = await import('@mmnto/totem');
298
+ throw new TotemError('CONFIG_INVALID', `Refusing to render git hooks for totemDir ${JSON.stringify(totemDir)}: ${problem}`, 'Set `totemDir` to a plain relative directory inside the repo and re-run `totem hook install --force`.');
299
+ }
300
+ return {
301
+ tier: flags?.tier ?? config.hooks?.tier ?? 'standard',
302
+ totemDir,
303
+ fallbackCmd,
304
+ configPath,
305
+ };
306
+ }
127
307
  /**
128
308
  * Build a POSIX shell block that resolves the totem command at runtime.
129
309
  *
@@ -154,7 +334,9 @@ else
154
334
  TOTEM_CMD=""
155
335
  fi`;
156
336
  }
157
- export function buildHookContent(fallbackCmd) {
337
+ export function buildHookContent(options) {
338
+ const { fallbackCmd, totemDir } = options;
339
+ assertRenderableTotemDir(totemDir);
158
340
  return `#!/bin/sh
159
341
  # ${TOTEM_HOOK_MARKER} — background re-index after pull/merge.
160
342
 
@@ -217,7 +399,7 @@ fi
217
399
  # Only sync when lessons changed (suppress errors if ORIG_HEAD is missing).
218
400
  # The trailing -- terminates the revision list so a ref/path ambiguity can never
219
401
  # reinterpret ORIG_HEAD/HEAD as pathspecs.
220
- if [ -n "$TOTEM_CMD" ] && git diff-tree -r --name-only ORIG_HEAD HEAD -- 2>/dev/null | grep -q '\\.totem/lessons/'; then
402
+ if [ -n "$TOTEM_CMD" ] && git diff-tree -r --name-only ORIG_HEAD HEAD -- 2>/dev/null | grep -q '${escapeBre(totemDir)}/lessons/'; then
221
403
  # Resolve the real git dir so the sync-log redirect works in a linked worktree,
222
404
  # where .git is a FILE (gitdir: pointer), not a directory (mmnto-ai/totem#2376).
223
405
  GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)
@@ -226,7 +408,9 @@ fi
226
408
  # ${TOTEM_HOOK_END}
227
409
  `;
228
410
  }
229
- export function buildPostCheckoutHookContent(fallbackCmd) {
411
+ export function buildPostCheckoutHookContent(options) {
412
+ const { fallbackCmd, totemDir } = options;
413
+ assertRenderableTotemDir(totemDir);
230
414
  return `#!/bin/sh
231
415
  # ${TOTEM_CHECKOUT_MARKER} — background re-index on branch switch.
232
416
 
@@ -242,34 +426,43 @@ ${buildResolveBlock(fallbackCmd)}
242
426
  # where .git is a FILE (gitdir: pointer), not a directory (mmnto-ai/totem#2376).
243
427
  GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)
244
428
 
245
- # Handle initial checkout (null SHA) — sync if .totem/ exists
429
+ # Handle initial checkout (null SHA) — sync if ${totemDir}/ exists
246
430
  if [ "$1" = "0000000000000000000000000000000000000000" ]; then
247
- if [ -n "$TOTEM_CMD" ] && [ -d ".totem" ]; then
431
+ if [ -n "$TOTEM_CMD" ] && [ -d "${totemDir}" ]; then
248
432
  ($TOTEM_CMD sync --incremental --quiet > "$GIT_DIR_RESOLVED/totem-sync.log" 2>&1) &
249
433
  fi
250
434
  exit 0
251
435
  fi
252
436
 
253
- # Only sync when .totem/ files differ between branches. The trailing -- terminates
437
+ # Only sync when ${totemDir}/ files differ between branches. The trailing -- terminates
254
438
  # the revision list so the "$1"/"$2" SHAs can never be reinterpreted as pathspecs.
255
- if [ -n "$TOTEM_CMD" ] && git diff --name-only "$1" "$2" -- 2>/dev/null | grep -q '\\.totem/'; then
439
+ if [ -n "$TOTEM_CMD" ] && git diff --name-only "$1" "$2" -- 2>/dev/null | grep -q '${escapeBre(totemDir)}/'; then
256
440
  ($TOTEM_CMD sync --incremental --quiet > "$GIT_DIR_RESOLVED/totem-sync.log" 2>&1) &
257
441
  fi
258
442
  # ${TOTEM_CHECKOUT_END}
259
443
  `;
260
444
  }
261
445
  /**
262
- * Generate helper shell scripts under `.totem/hooks/` for hook manager integration.
263
- * These scripts contain the full guard logic (diff checks, null-SHA guards) that
264
- * bare inline commands would skip.
446
+ * Generate helper shell scripts under `<totemDir>/hooks/` for hook manager
447
+ * integration. These scripts contain the full guard logic (diff checks, null-SHA
448
+ * guards) that bare inline commands would skip.
449
+ *
450
+ * Takes the RESOLVED {@link HookRenderOptions} rather than resolving config
451
+ * itself: both callers already hold the one resolution for this invocation, and
452
+ * a required parameter is the same compiler-enforced thread the builders use
453
+ * (mmnto-ai/totem#2692 C1/C2).
265
454
  */
266
- export function generateHookHelpers(gitRoot, fallbackCmd, options) {
267
- const hooksDir = path.join(gitRoot, '.totem', 'hooks');
455
+ export function generateHookHelpers(gitRoot, render) {
456
+ // Refuse BEFORE the mkdir: the helper dir is joined from the value, and a
457
+ // `..` segment would create a directory outside the checkout before any
458
+ // builder got the chance to refuse it (mmnto-ai/totem#2692 amendment A7).
459
+ assertRenderableTotemDir(render.totemDir);
460
+ const hooksDir = path.join(gitRoot, render.totemDir, 'hooks');
268
461
  fs.mkdirSync(hooksDir, { recursive: true });
269
- const postMerge = buildHookContent(fallbackCmd);
270
- const postCheckout = buildPostCheckoutHookContent(fallbackCmd);
271
- const preCommit = buildPreCommitHook(options?.tier);
272
- const prePush = buildPrePushHook(fallbackCmd, options?.tier);
462
+ const postMerge = buildHookContent(render);
463
+ const postCheckout = buildPostCheckoutHookContent(render);
464
+ const preCommit = buildPreCommitHook(render);
465
+ const prePush = buildPrePushHook(render);
273
466
  fs.writeFileSync(path.join(hooksDir, 'post-merge.sh'), postMerge, { mode: 0o755 });
274
467
  fs.writeFileSync(path.join(hooksDir, 'post-checkout.sh'), postCheckout, { mode: 0o755 });
275
468
  fs.writeFileSync(path.join(hooksDir, 'pre-commit.sh'), preCommit, { mode: 0o755 });
@@ -297,49 +490,63 @@ function detectHookManager(cwd) {
297
490
  }
298
491
  return null;
299
492
  }
300
- function printHookManagerGuidance(manager) {
493
+ /**
494
+ * Print the manual wiring a detected hook manager needs. `totemDir` is the
495
+ * RESOLVED value the helper scripts were just written under — guidance that
496
+ * names `.totem/` in a repo that configured something else points the consumer
497
+ * at files that do not exist (mmnto-ai/totem#2692 C5).
498
+ */
499
+ function printHookManagerGuidance(manager, totemDir) {
500
+ // The validator accepts whitespace in a totemDir; an unquoted word would split
501
+ // into two arguments in every consumer's shell (CodeRabbit on
502
+ // mmnto-ai/totem#2701). Quote only when needed so the default guidance stays
503
+ // the familiar `sh .totem/hooks/…`. `$` and a backtick are refused upstream, so
504
+ // double quotes are inert; the JSON form escapes them for package.json.
505
+ const needsQuotes = /\s/.test(totemDir);
506
+ const sh = needsQuotes ? `"${totemDir}"` : totemDir;
507
+ const json = needsQuotes ? `\\"${totemDir}\\"` : totemDir;
301
508
  switch (manager) {
302
509
  case 'husky':
303
510
  console.error('[Totem] Detected husky. Add the following to your hook files:');
304
511
  console.error('');
305
512
  console.error(' # .husky/pre-commit');
306
- console.error(' sh .totem/hooks/pre-commit.sh');
513
+ console.error(` sh ${sh}/hooks/pre-commit.sh`);
307
514
  console.error('');
308
515
  console.error(' # .husky/pre-push');
309
- console.error(' sh .totem/hooks/pre-push.sh');
516
+ console.error(` sh ${sh}/hooks/pre-push.sh`);
310
517
  console.error('');
311
518
  console.error(' # .husky/post-merge');
312
- console.error(' sh .totem/hooks/post-merge.sh');
519
+ console.error(` sh ${sh}/hooks/post-merge.sh`);
313
520
  console.error('');
314
521
  console.error(' # .husky/post-checkout');
315
- console.error(' sh .totem/hooks/post-checkout.sh');
522
+ console.error(` sh ${sh}/hooks/post-checkout.sh`);
316
523
  break;
317
524
  case 'lefthook':
318
525
  console.error('[Totem] Detected lefthook. Add to your lefthook.yml:');
319
526
  console.error(' pre-commit:');
320
527
  console.error(' commands:');
321
528
  console.error(' totem-block-main:');
322
- console.error(' run: sh .totem/hooks/pre-commit.sh');
529
+ console.error(` run: sh ${sh}/hooks/pre-commit.sh`);
323
530
  console.error(' pre-push:');
324
531
  console.error(' commands:');
325
532
  console.error(' totem-review:');
326
- console.error(' run: sh .totem/hooks/pre-push.sh');
533
+ console.error(` run: sh ${sh}/hooks/pre-push.sh`);
327
534
  console.error(' post-merge:');
328
535
  console.error(' commands:');
329
536
  console.error(' totem-sync:');
330
- console.error(' run: sh .totem/hooks/post-merge.sh');
537
+ console.error(` run: sh ${sh}/hooks/post-merge.sh`);
331
538
  console.error(' post-checkout:');
332
539
  console.error(' commands:');
333
540
  console.error(' totem-sync-checkout:');
334
- console.error(' run: sh .totem/hooks/post-checkout.sh');
541
+ console.error(` run: sh ${sh}/hooks/post-checkout.sh`);
335
542
  break;
336
543
  case 'simple-git-hooks':
337
544
  console.error('[Totem] Detected simple-git-hooks. Add to your package.json:');
338
545
  console.error(' "simple-git-hooks": {');
339
- console.error(' "pre-commit": "sh .totem/hooks/pre-commit.sh",');
340
- console.error(' "pre-push": "sh .totem/hooks/pre-push.sh",');
341
- console.error(' "post-merge": "sh .totem/hooks/post-merge.sh",');
342
- console.error(' "post-checkout": "sh .totem/hooks/post-checkout.sh"');
546
+ console.error(` "pre-commit": "sh ${json}/hooks/pre-commit.sh",`);
547
+ console.error(` "pre-push": "sh ${json}/hooks/pre-push.sh",`);
548
+ console.error(` "post-merge": "sh ${json}/hooks/post-merge.sh",`);
549
+ console.error(` "post-checkout": "sh ${json}/hooks/post-checkout.sh"`);
343
550
  console.error(' }');
344
551
  break;
345
552
  }
@@ -355,11 +562,13 @@ export async function installPostMergeHook(cwd, rl, options) {
355
562
  : '[Totem] Not a git repository — skipping hook installation.');
356
563
  return;
357
564
  }
358
- const fallbackCmd = getFallbackCommand(gitRoot);
565
+ // One config read per invocation, anchored at the git root — the same anchor
566
+ // getFallbackCommand has always used (mmnto-ai/totem#2692 C1).
567
+ const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
359
568
  const manager = detectHookManager(gitRoot);
360
569
  if (manager) {
361
- generateHookHelpers(gitRoot, fallbackCmd, options);
362
- printHookManagerGuidance(manager);
570
+ generateHookHelpers(gitRoot, render);
571
+ printHookManagerGuidance(manager, render.totemDir);
363
572
  return;
364
573
  }
365
574
  const interactive = options?.interactive ?? process.stdin.isTTY === true;
@@ -389,7 +598,7 @@ export async function installPostMergeHook(cwd, rl, options) {
389
598
  }
390
599
  // Append to existing hook — reuse buildHookContent, strip shebang
391
600
  const separator = existing.endsWith('\n') ? '' : '\n';
392
- const appendBlock = buildHookContent(fallbackCmd)
601
+ const appendBlock = buildHookContent(render)
393
602
  .replace(/^#!\/bin\/sh\n/, '')
394
603
  .trimStart();
395
604
  fs.appendFileSync(hookPath, separator + '\n' + appendBlock);
@@ -398,10 +607,11 @@ export async function installPostMergeHook(cwd, rl, options) {
398
607
  }
399
608
  // Create new hook
400
609
  fs.mkdirSync(hooksDir, { recursive: true });
401
- fs.writeFileSync(hookPath, buildHookContent(fallbackCmd));
610
+ fs.writeFileSync(hookPath, buildHookContent(render));
402
611
  // Make executable (no-op on Windows, git bash handles it)
403
612
  try {
404
613
  fs.chmodSync(hookPath, 0o755);
614
+ // totem-context: intentional cleanup — chmod may fail on Windows; the hook still runs via git bash, so a failed mode bit is not a failed install.
405
615
  }
406
616
  catch {
407
617
  // chmod may fail on Windows — hooks still work via git bash
@@ -417,13 +627,69 @@ if [ -n "$CLAUDE_CODE_AGENT" ] || [ -n "$CLAUDE_VERSION" ] || [ -n "$CURSOR_TRAC
417
627
  fi`;
418
628
  }
419
629
  // ─── Enforcement hooks (pre-commit + pre-push) ──────────
420
- export function buildPreCommitHook(tier) {
421
- const effectiveTier = tier ?? 'standard';
630
+ export function buildPreCommitHook(options) {
631
+ const effectiveTier = options.tier;
632
+ const totemDir = options.totemDir;
633
+ assertRenderableTotemDir(totemDir);
634
+ // The run store the strict arm reads, rendered from the CONFIGURED totemDir so
635
+ // the reader names the tree `totem spec` actually writes (mmnto-ai/totem#2692).
636
+ const runsDir = `${totemDir}/artifacts/runs`;
637
+ // Strict-tier evidence (mmnto-ai/totem#2690): the gate names `totem spec`,
638
+ // so it must pass on what `totem spec` actually writes — the grounded run
639
+ // artifact under .totem/artifacts/runs/ (mmnto-ai/totem#2100; written on
640
+ // every successful run, --fresh included) whose TOP-LEVEL
641
+ // admission.runMetadata.caller is "spec". The read is JSON-aware on purpose:
642
+ // the run store is written by every orchestrator caller, and a `review`
643
+ // artifact's inputBundle embeds the reviewed diff — a substring grep would
644
+ // pass the gate on a review of any text that merely QUOTES the key (this
645
+ // very test fixture). node is already assumed by the pre-push template's
646
+ // format-check block; ~50 ms, no CLI boot, nothing written (Tenet 13). The
647
+ // former .totem/cache/.spec-completed marker is NOT honored: no CLI path ever
648
+ // wrote it, so "compatibility" with it would be compatibility with a hand
649
+ // hack (operator ruling 2026-08-29 — no legacy shims while there is no hard
650
+ // consumer, Tenet 19). The evidence line makes a stale pass VISIBLE (age
651
+ // from the artifact's own createdAt); a freshness rule is a separate policy,
652
+ // deliberately not here. This is the ONLY reader of the rule — the repo's
653
+ // pre-managed-era `.gemini/hooks/BeforeTool.js` (unregistered, inert) was
654
+ // deleted with the marker rather than kept in step.
422
655
  const strictBlock = `
423
- # Strict mode: require spec before commit
656
+ # Strict mode: require spec EVIDENCE before commit (mmnto-ai/totem#2690).
657
+ # Evidence = a totem spec run artifact (${runsDir}/*.json with a
658
+ # top-level admission.runMetadata.caller of "spec"), read JSON-aware — a
659
+ # substring match would accept a review artifact that merely quotes the key.
660
+ # The former ${totemDir}/cache/.spec-completed marker is not honored (no CLI wrote it).
424
661
  if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]; then
425
- if [ ! -f ".totem/cache/.spec-completed" ]; then
426
- echo "[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)"
662
+ spec_evidence=$(node -e '
663
+ const fs = require("fs");
664
+ const dir = ${JSON.stringify(runsDir)};
665
+ let names = [];
666
+ try { names = fs.readdirSync(dir); } catch (err) { names = []; }
667
+ let best = null;
668
+ for (const name of names) {
669
+ if (!name.endsWith(".json")) continue;
670
+ let a = null;
671
+ try { a = JSON.parse(fs.readFileSync(dir + "/" + name, "utf8")); } catch (err) { continue; }
672
+ const caller = a && a.admission && a.admission.runMetadata && a.admission.runMetadata.caller;
673
+ if (["spec"].indexOf(caller) < 0) continue;
674
+ const at = ["string"].indexOf(typeof a.createdAt) < 0 ? "" : a.createdAt;
675
+ if (!best || at > best.at) best = { name: name, at: at };
676
+ }
677
+ if (!best) process.exit(2);
678
+ const parsed = best.at ? Date.parse(best.at) : NaN;
679
+ const days = Number.isNaN(parsed) ? -1 : Math.floor((Date.now() - parsed) / 86400000);
680
+ process.stdout.write(dir + "/" + best.name + " (" + (best.at || "undated") + (days >= 0 ? ", " + days + " days old" : "") + ")");
681
+ ' 2>/dev/null)
682
+ # Reader status: 0 = evidence found · 2 = none found · anything else = the
683
+ # reader itself could not run (node missing from PATH, a crash) — reported
684
+ # distinctly, never as "no evidence", and still fail-closed.
685
+ reader_status=$?
686
+ if [ "$reader_status" = "0" ] && [ -n "$spec_evidence" ]; then
687
+ echo "[Totem] spec evidence: $spec_evidence"
688
+ elif [ "$reader_status" != "2" ]; then
689
+ echo "[Totem] BLOCKED: the spec-evidence reader could not run (node exit status $reader_status — node missing from PATH, or ${runsDir}/ unreadable); fix the runtime and retry (strict mode)"
690
+ exit 1
691
+ else
692
+ echo "[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode) — no totem spec run artifact under ${runsDir}/ in this checkout"
427
693
  exit 1
428
694
  fi
429
695
  fi`;
@@ -445,8 +711,10 @@ ${strictBlock}
445
711
  # ${TOTEM_PRECOMMIT_END}
446
712
  `;
447
713
  }
448
- export function buildPrePushHook(fallbackCmd, tier) {
449
- const effectiveTier = tier ?? 'standard';
714
+ export function buildPrePushHook(options) {
715
+ const { fallbackCmd, totemDir } = options;
716
+ const effectiveTier = options.tier;
717
+ assertRenderableTotemDir(totemDir);
450
718
  // Strict-tier gate per Proposal 273 § 6 Q2 (mmnto-ai/totem#1908): operator-invoked
451
719
  // is the default for new checks while behavior calibrates. Doctor's `--strict`
452
720
  // mode gates on repo-state `fail` results; unconditional firing would break
@@ -481,7 +749,7 @@ ${buildResolveBlock(fallbackCmd)}
481
749
 
482
750
  if [ -n "$TOTEM_CMD" ]; then
483
751
  # Verify compile manifest is current
484
- if [ -f ".totem/compile-manifest.json" ]; then
752
+ if [ -f "${totemDir}/compile-manifest.json" ]; then
485
753
  if ! $TOTEM_CMD verify-manifest > /dev/null 2>&1; then
486
754
  echo "[totem] Push blocked: compile manifest is stale. Run 'totem lesson compile'." >&2
487
755
  exit 1
@@ -489,14 +757,14 @@ if [ -n "$TOTEM_CMD" ]; then
489
757
  fi
490
758
 
491
759
  # Run deterministic lint
492
- if [ -f ".totem/compiled-rules.json" ]; then
760
+ if [ -f "${totemDir}/compiled-rules.json" ]; then
493
761
  if ! $TOTEM_CMD lint; then
494
762
  exit 1
495
763
  fi
496
764
  fi
497
765
 
498
766
  # Verify shields.io badges in README.md (mmnto-ai/totem#1926 — deterministic claim-discipline)
499
- if [ -f "README.md" ] && [ -f ".totem/compiled-rules.json" ]; then
767
+ if [ -f "README.md" ] && [ -f "${totemDir}/compiled-rules.json" ]; then
500
768
  if ! $TOTEM_CMD verify-badges; then
501
769
  exit 1
502
770
  fi
@@ -519,7 +787,7 @@ if [ -n "$TOTEM_CMD" ]; then
519
787
  # missing-Goal-prefix, covenant-without-backing). Fires only when at
520
788
  # least one in-scope surface exists. Bypass with mandatory justification:
521
789
  # TOTEM_GATE_BYPASS_JUSTIFICATION="<reason>" git push
522
- if [ -f ".totem/compiled-rules.json" ] && { [ -f "README.md" ] || [ -f "AGENTS.md" ] || [ -f "design-tenets.md" ] || [ -d "docs/wiki" ]; }; then
790
+ if [ -f "${totemDir}/compiled-rules.json" ] && { [ -f "README.md" ] || [ -f "AGENTS.md" ] || [ -f "design-tenets.md" ] || [ -d "docs/wiki" ]; }; then
523
791
  # --scope-to-diff (mmnto-ai/totem#2002): narrow the WWND scan to files
524
792
  # touched in the current push diff. Eliminates the standing-gate
525
793
  # false-positive class where pre-existing warnings on in-scope surfaces
@@ -611,7 +879,7 @@ function writeExecutableHook(hookPath, content) {
611
879
  * overwrite would clobber it, so such a file is NOT owned (only trailing
612
880
  * whitespace may follow the end marker).
613
881
  */
614
- function isTotemOwnedWholeFile(content, marker, endMarker) {
882
+ export function isTotemOwnedWholeFile(content, marker, endMarker) {
615
883
  const idx = content.indexOf(marker);
616
884
  if (idx === -1)
617
885
  return false;
@@ -724,9 +992,15 @@ export async function installEnforcementHooks(cwd, rl, options) {
724
992
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
725
993
  return skip;
726
994
  }
727
- const fallbackCmd = getFallbackCommand(gitRoot);
728
- const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(options?.tier), TOTEM_PRECOMMIT_MARKER, undefined, TOTEM_PRECOMMIT_END);
729
- const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(fallbackCmd, options?.tier), TOTEM_PREPUSH_MARKER, undefined, TOTEM_PREPUSH_END);
995
+ // `totem init` writes the config BEFORE this runs, so — when init runs at the
996
+ // git root, the supported layout — the resolved options are the ones the repo
997
+ // just declared, and init and `totem hook install` render identically
998
+ // (mmnto-ai/totem#2692 C1/C7). Off the root, init writes its config at cwd
999
+ // while every hook writer resolves at the git root: a pre-existing split this
1000
+ // slice names and does not close.
1001
+ const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
1002
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(render), TOTEM_PRECOMMIT_MARKER, undefined, TOTEM_PRECOMMIT_END);
1003
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(render), TOTEM_PREPUSH_MARKER, undefined, TOTEM_PREPUSH_END);
730
1004
  // Warn about non-shell hooks that Totem cannot safely append to
731
1005
  if (preCommit === 'skipped-non-shell') {
732
1006
  console.error('[Totem] Warning: pre-commit hook uses a non-shell interpreter. Manually integrate branch protection into your existing hook.');
@@ -751,8 +1025,8 @@ export async function installHooksCommand() {
751
1025
  const postMerge = path.join(hooksDir, 'post-merge');
752
1026
  const hasPostMerge = fs.existsSync(postMerge) && fs.readFileSync(postMerge, 'utf-8').includes(TOTEM_HOOK_MARKER);
753
1027
  if (hasPostMerge) {
754
- const fallbackCmd = getFallbackCommand(gitRoot);
755
- installGitHook(hooksDir, 'post-checkout', buildPostCheckoutHookContent(fallbackCmd), TOTEM_CHECKOUT_MARKER, undefined, TOTEM_CHECKOUT_END);
1028
+ const render = await resolveHookRenderOptions(gitRoot);
1029
+ installGitHook(hooksDir, 'post-checkout', buildPostCheckoutHookContent(render), TOTEM_CHECKOUT_MARKER, undefined, TOTEM_CHECKOUT_END);
756
1030
  }
757
1031
  }
758
1032
  }
@@ -763,8 +1037,12 @@ export async function installHooksCommand() {
763
1037
  /**
764
1038
  * Non-interactive hook installer for `totem hooks` and `prepare` scripts.
765
1039
  * Installs pre-commit, pre-push, and post-merge hooks without prompting.
1040
+ *
1041
+ * Async since mmnto-ai/totem#2692: the hook text is rendered from the repo's
1042
+ * CONFIGURED `totemDir` (and `hooks.tier`), which means one config read —
1043
+ * {@link resolveHookRenderOptions} — before anything is written.
766
1044
  */
767
- export function installHooksNonInteractive(cwd, force, options) {
1045
+ export async function installHooksNonInteractive(cwd, force, options) {
768
1046
  // Guard: must be a git repo — resolve root from any subdirectory. Not-a-repo
769
1047
  // stays a silent null (the documented contract — callers print); the malformed
770
1048
  // pointer prints its declared-skip line here so a direct API caller honors the
@@ -775,12 +1053,12 @@ export function installHooksNonInteractive(cwd, force, options) {
775
1053
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
776
1054
  return null;
777
1055
  }
778
- const fallbackCmd = getFallbackCommand(gitRoot);
1056
+ const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
779
1057
  // Hook managers handle their own installation — generate helper scripts + print guidance
780
1058
  const manager = detectHookManager(gitRoot);
781
1059
  if (manager) {
782
- generateHookHelpers(gitRoot, fallbackCmd, options);
783
- printHookManagerGuidance(manager);
1060
+ generateHookHelpers(gitRoot, render);
1061
+ printHookManagerGuidance(manager, render.totemDir);
784
1062
  return null;
785
1063
  }
786
1064
  const hooksDir = resolveHooksDir(gitRoot);
@@ -791,11 +1069,11 @@ export function installHooksNonInteractive(cwd, force, options) {
791
1069
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
792
1070
  return null;
793
1071
  }
794
- const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(options?.tier), TOTEM_PRECOMMIT_MARKER, force, TOTEM_PRECOMMIT_END);
795
- const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(fallbackCmd, options?.tier), TOTEM_PREPUSH_MARKER, force, TOTEM_PREPUSH_END);
796
- const postMergeContent = buildHookContent(fallbackCmd);
1072
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(render), TOTEM_PRECOMMIT_MARKER, force, TOTEM_PRECOMMIT_END);
1073
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(render), TOTEM_PREPUSH_MARKER, force, TOTEM_PREPUSH_END);
1074
+ const postMergeContent = buildHookContent(render);
797
1075
  const postMerge = installGitHook(hooksDir, 'post-merge', postMergeContent, TOTEM_HOOK_MARKER, force, TOTEM_HOOK_END);
798
- const postCheckoutContent = buildPostCheckoutHookContent(fallbackCmd);
1076
+ const postCheckoutContent = buildPostCheckoutHookContent(render);
799
1077
  const postCheckout = installGitHook(hooksDir, 'post-checkout', postCheckoutContent, TOTEM_CHECKOUT_MARKER, force, TOTEM_CHECKOUT_END);
800
1078
  return { preCommit, prePush, postMerge, postCheckout };
801
1079
  }
@@ -864,32 +1142,17 @@ export async function hooksCommand(opts) {
864
1142
  }
865
1143
  return;
866
1144
  }
867
- // Resolve tier + pilot: CLI flag > config file > default ('standard')
868
- let tier;
869
- // Resolve tier: CLI flag > config file > default ('standard')
870
- try {
871
- const { loadConfig, loadEnv, resolveConfigPath } = await import('../utils.js');
872
- loadEnv(cwd);
873
- const configPath = resolveConfigPath(cwd);
874
- if (configPath) {
875
- const config = await loadConfig(configPath);
876
- if (!opts.strict && !opts.standard) {
877
- tier = config.hooks?.tier;
878
- }
879
- }
880
- }
881
- catch (err) {
882
- if (process.env.TOTEM_DEBUG) {
883
- console.error('[Totem] Could not load config for tier resolution:', err);
884
- }
885
- }
886
- if (opts.strict) {
887
- tier = 'strict';
888
- }
889
- else if (opts.standard) {
890
- tier = 'standard';
891
- }
892
- const result = installHooksNonInteractive(cwd, opts.force, { tier });
1145
+ // Tier precedence (CLI flag > config `hooks.tier` > 'standard') now lives in
1146
+ // `resolveHookRenderOptions`, the ONE config→hook-render seam
1147
+ // (mmnto-ai/totem#2692 C1) which `installHooksNonInteractive` calls with the
1148
+ // flag below, so the config is read exactly once per invocation and at the
1149
+ // git-root anchor the installer writes from.
1150
+ const tier = opts.strict
1151
+ ? 'strict'
1152
+ : opts.standard
1153
+ ? 'standard'
1154
+ : undefined;
1155
+ const result = await installHooksNonInteractive(cwd, opts.force, { tier });
893
1156
  // The git-hook summary prints ONLY when git hooks were actually written. A null
894
1157
  // result means a hook manager (husky/lefthook) was detected and
895
1158
  // installHooksNonInteractive already printed its guidance — but this MUST NOT
@@ -1250,8 +1513,12 @@ async function printGeminiHookMigrationSummary(cwd, force) {
1250
1513
  * stateless format that runs verify-manifest + lint directly.
1251
1514
  *
1252
1515
  * Returns true if the hook was upgraded, false otherwise.
1516
+ *
1517
+ * Async since mmnto-ai/totem#2692: the spliced block is rendered from the repo's
1518
+ * configured `totemDir` and `hooks.tier` like every other writer, so it no
1519
+ * longer silently downgrades a strict hook to standard on the upgrade path.
1253
1520
  */
1254
- export function upgradePrePushHookIfNeeded(cwd) {
1521
+ export async function upgradePrePushHookIfNeeded(cwd) {
1255
1522
  try {
1256
1523
  const gitRoot = resolveGitRoot(cwd);
1257
1524
  if (!gitRoot)
@@ -1308,9 +1575,9 @@ export function upgradePrePushHookIfNeeded(cwd) {
1308
1575
  if (endOffset === -1)
1309
1576
  return false;
1310
1577
  const blockEnd = markerIdx + endOffset;
1311
- const fallbackCmd = getFallbackCommand(gitRoot);
1578
+ const render = await resolveHookRenderOptions(gitRoot);
1312
1579
  // Build the replacement block (strip shebang — we're splicing into existing file)
1313
- const newBlock = buildPrePushHook(fallbackCmd)
1580
+ const newBlock = buildPrePushHook(render)
1314
1581
  .replace(/^#!\/bin\/sh\n/, '')
1315
1582
  .trimStart();
1316
1583
  // Splice: preserve everything before and after the totem block