@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
@@ -3,7 +3,9 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { stdin as input, stdout as output } from 'node:process';
5
5
  import * as readline from 'node:readline/promises';
6
+ import { GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_RECORD, PROMPT_SOURCE_OVERRIDE, } from '../artifact-vocabulary.js';
6
7
  import { resolveGitRoot } from '../git.js';
8
+ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
7
9
  export const TOTEM_HOOK_MARKER = '[totem] post-merge hook';
8
10
  export const TOTEM_HOOK_END = '[totem] end post-merge';
9
11
  export const TOTEM_CHECKOUT_MARKER = '[totem] post-checkout hook';
@@ -12,6 +14,12 @@ export const TOTEM_PRECOMMIT_MARKER = '[totem] pre-commit hook';
12
14
  export const TOTEM_PRECOMMIT_END = '[totem] end pre-commit';
13
15
  export const TOTEM_PREPUSH_MARKER = '[totem] pre-push hook';
14
16
  export const TOTEM_PREPUSH_END = '[totem] end pre-push';
17
+ /**
18
+ * Hex characters of each sha256 the strict reader's record SENSOR shows when
19
+ * a bound record has been revised (mmnto-ai/totem#2700). Identity at a glance,
20
+ * not a full digest — the comparison itself is over the whole hash.
21
+ */
22
+ const RECORD_HASH_DISPLAY_PREFIX = 8;
15
23
  // ─── Hooks-directory resolution (mmnto-ai/totem#2418) ─────────
16
24
  /**
17
25
  * Resolve the git hooks directory for `gitRoot`. In a plain checkout this is
@@ -124,6 +132,186 @@ export function detectTotemPrefix(cwd) {
124
132
  return 'bunx totem';
125
133
  return 'npx totem';
126
134
  }
135
+ // ─── Hook render options (mmnto-ai/totem#2692) ────────────────
136
+ /** The `totemDir` every hook renders when the repo configures none. */
137
+ export const DEFAULT_TOTEM_DIR = '.totem';
138
+ /**
139
+ * Whether `value` carries a character that cannot be rendered SAFELY into the
140
+ * managed hooks: a single quote (breaks the `sh` single-quoted word AND the
141
+ * single-quoted `node -e '…'` reader), a double quote or a backslash (breaks the
142
+ * JS string literal inside that reader), a dollar sign or a backtick (the only
143
+ * characters that stay ACTIVE inside the double-quoted `sh` words every guard
144
+ * uses — refusing them is what lets those sites keep the one plain
145
+ * double-quoted form `tools/*` ships; mmnto-ai/totem#2692 amendment A2), or a
146
+ * control character / newline (breaks both, and can forge lines in the hook
147
+ * body).
148
+ *
149
+ * Written as a code-point walk rather than a regex with escape literals so the
150
+ * predicate carries no escape sequence of its own to mis-author.
151
+ */
152
+ export function hasUnrenderableTotemDirChar(value) {
153
+ for (const ch of value) {
154
+ if (ch === "'" || ch === '"' || ch === '\\' || ch === '$' || ch === '`')
155
+ return true;
156
+ const code = ch.codePointAt(0) ?? 0;
157
+ // Control characters, DEL, and everything non-ASCII: git C-quotes any path
158
+ // byte above 0x7e in the `diff --name-only` output the two `grep -q` diff
159
+ // filters read (`core.quotePath`, on by default), so a directory name
160
+ // carrying one could never match — the silent-skip class this closes.
161
+ if (code < 0x20 || code > 0x7e)
162
+ return true;
163
+ }
164
+ return false;
165
+ }
166
+ /**
167
+ * Why `totemDir` cannot be rendered into the managed hooks, or `null` when it
168
+ * can (mmnto-ai/totem#2692 C4 + amendment A7). Two classes:
169
+ *
170
+ * - CHARACTERS the quoting regimes cannot carry (see
171
+ * {@link hasUnrenderableTotemDirChar}) — the `@mmnto/totem` schema refuses
172
+ * the same set, so a validated config never reaches this arm.
173
+ * - SHAPES the hooks could never govern, which the schema deliberately still
174
+ * accepts because other verbs can use them (`.` is the global profile's own
175
+ * spelling): empty, a trailing slash, `.`, a `.` or empty segment, a `..`
176
+ * segment, a leading `-`. Each of these renders a hook whose post-merge /
177
+ * post-checkout diff filter (`grep -q '<dir>/…'` over the repo-relative
178
+ * paths git prints) can never match, or — for the empty value — an ABSOLUTE
179
+ * run-store path in the strict pre-commit reader. The schema normalises a
180
+ * trailing slash away; a raw value reaching a builder directly is refused,
181
+ * never normalised here (a builder is a pure function of its options).
182
+ */
183
+ export function hookTotemDirProblem(totemDir) {
184
+ if (hasUnrenderableTotemDirChar(totemDir)) {
185
+ 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)';
186
+ }
187
+ if (totemDir.length === 0) {
188
+ 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";
189
+ }
190
+ if (totemDir.endsWith('/')) {
191
+ return "a trailing slash renders 'dir//…' into the post-merge / post-checkout diff filters, which then never match — spell it without the slash";
192
+ }
193
+ if (totemDir === '.') {
194
+ return "'.' names the config directory itself; the hooks' diff filters ('grep -q <dir>/…') could never match the repo-relative paths git prints";
195
+ }
196
+ const segments = totemDir.split('/');
197
+ if (segments.includes('.') || segments.includes('')) {
198
+ return "a '.' segment (or '//') never appears in the repo-relative paths git prints, so the diff filters would never match";
199
+ }
200
+ if (segments.includes('..')) {
201
+ return "a '..' segment points outside the worktree the hooks run in; git prints repo-relative paths, so the diff filters could never match";
202
+ }
203
+ if (totemDir.startsWith('-')) {
204
+ return "a leading '-' is read as an option by grep in the diff filters";
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * Refuse — loudly, naming the value and the reason — a `totemDir` the hook
210
+ * templates cannot render (mmnto-ai/totem#2692 C4/A7). Called by the resolver
211
+ * on the configured value and by every builder as the render-path backstop for
212
+ * direct-API and hand-threaded call sites.
213
+ *
214
+ * Throws rather than degrades: a hook rendered from a value we could not quote
215
+ * is a shell-injection surface, and silently falling back to `.totem` would
216
+ * re-create the very writer/reader split this slice closes (Tenet 4).
217
+ */
218
+ export function assertRenderableTotemDir(totemDir) {
219
+ const problem = hookTotemDirProblem(totemDir);
220
+ if (problem === null)
221
+ return;
222
+ // A plain Error, unprefixed: this backstop sits on the SYNC render path (the
223
+ // builders), where `@mmnto/totem`'s TotemError cannot be lazy-imported; the
224
+ // resolver — the CLI's actual entry — raises the TotemError form of the same
225
+ // refusal. `handleError` adds the `[Totem Error]` tag, so the message carries
226
+ // none of its own (Gemini on mmnto-ai/totem#2701).
227
+ throw new Error(`Refusing to render git hooks for totemDir ${JSON.stringify(totemDir)}: ${problem}. ` +
228
+ 'Set `totemDir` to a plain relative directory inside the repo and re-run `totem hook install --force`.');
229
+ }
230
+ /**
231
+ * Escape a validated `totemDir` for a POSIX Basic Regular Expression — the two
232
+ * `grep -q '…'` diff filters. BRE specials are `\ ^ $ . * [ ]`; `^` and `$` are
233
+ * only special positionally, but escaping them unconditionally is still a
234
+ * literal match and keeps the rule one line.
235
+ */
236
+ function escapeBre(value) {
237
+ return value.replace(/[\\^$.*[\]]/g, '\\$&');
238
+ }
239
+ /**
240
+ * THE resolver: config → the options every hook writer renders from
241
+ * (mmnto-ai/totem#2692 C1).
242
+ *
243
+ * `tier` = explicit flag > `hooks.tier` from config > `'standard'` (the
244
+ * precedence `hooksCommand` already implemented, moved here so `totem init`,
245
+ * `installHooksNonInteractive` and the silent pre-push upgrade honor it too) —
246
+ * from whichever config resolves, global profile included, exactly as before.
247
+ *
248
+ * `totemDir` = the REPO-LOCAL config's `totemDir` > `.totem`. Repo-local only,
249
+ * and deliberately asymmetric with `tier`: the value is a path rendered into a
250
+ * hook that runs at the worktree top, so only this project's config can name it.
251
+ * The global `~/.totem/` profile `totem init --global` writes declares
252
+ * `totemDir: '.'` — describing that profile directory itself — and honoring it
253
+ * here would silently re-render every config-less repo's hooks against the
254
+ * checkout root on any machine that has a profile (the mmnto-ai/totem#2692 C3
255
+ * "no consumer's hooks drift on upgrade" invariant, and the same
256
+ * machine-dependence `doctor --parity` guards with `isGlobalConfigPath`).
257
+ *
258
+ * `fallbackCmd` = the lockfile probe anchored at `cwd` — pass the GIT ROOT, the
259
+ * anchor the installer has always used, so a hook installed from a subdirectory
260
+ * still names the repo's package manager.
261
+ *
262
+ * No config at all → the defaults, silently: a config-less repo installing hooks
263
+ * is a supported path, not an error. A config that RESOLVES but will not LOAD
264
+ * (a syntax error, a `totemDir` the schema refines out) → the defaults, LOUDLY:
265
+ * one line names the file and the failure, so a repo whose config says
266
+ * `knowledge/` never gets `.totem/` hooks without a word (mmnto-ai/totem#2692
267
+ * amendment A8 — the silent→loud shape of mmnto-ai/totem#2685). A config that
268
+ * loads but names a `totemDir` the hooks cannot govern (`.`, a `..` segment, a
269
+ * leading `-`) REFUSES — {@link assertRenderableTotemDir}.
270
+ */
271
+ export async function resolveHookRenderOptions(cwd, flags) {
272
+ const fallbackCmd = getFallbackCommand(cwd);
273
+ const defaults = {
274
+ tier: flags?.tier ?? 'standard',
275
+ totemDir: DEFAULT_TOTEM_DIR,
276
+ fallbackCmd,
277
+ };
278
+ const { loadConfig, loadEnv, resolveConfigPath, isGlobalConfigPath } = await import('../utils.js');
279
+ loadEnv(cwd);
280
+ let configPath;
281
+ try {
282
+ configPath = resolveConfigPath(cwd);
283
+ // totem-context: no config anywhere (resolveConfigPath throws CONFIG_MISSING) is the honest-default path — hooks install in config-less repos by design.
284
+ }
285
+ catch {
286
+ return defaults;
287
+ }
288
+ let config;
289
+ try {
290
+ config = await loadConfig(configPath);
291
+ // 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).
292
+ }
293
+ catch (err) {
294
+ const reason = err instanceof Error ? err.message : String(err);
295
+ 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\`.`);
296
+ return { ...defaults, configError: reason };
297
+ }
298
+ const totemDir = isGlobalConfigPath(configPath)
299
+ ? DEFAULT_TOTEM_DIR
300
+ : (config.totemDir ?? DEFAULT_TOTEM_DIR);
301
+ // The CLI-layer form of the refusal: a TotemError with a recovery hint (the
302
+ // sync builders keep the plain-Error backstop, `assertRenderableTotemDir`).
303
+ const problem = hookTotemDirProblem(totemDir);
304
+ if (problem !== null) {
305
+ const { TotemError } = await import('@mmnto/totem');
306
+ 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`.');
307
+ }
308
+ return {
309
+ tier: flags?.tier ?? config.hooks?.tier ?? 'standard',
310
+ totemDir,
311
+ fallbackCmd,
312
+ configPath,
313
+ };
314
+ }
127
315
  /**
128
316
  * Build a POSIX shell block that resolves the totem command at runtime.
129
317
  *
@@ -154,7 +342,9 @@ else
154
342
  TOTEM_CMD=""
155
343
  fi`;
156
344
  }
157
- export function buildHookContent(fallbackCmd) {
345
+ export function buildHookContent(options) {
346
+ const { fallbackCmd, totemDir } = options;
347
+ assertRenderableTotemDir(totemDir);
158
348
  return `#!/bin/sh
159
349
  # ${TOTEM_HOOK_MARKER} — background re-index after pull/merge.
160
350
 
@@ -217,7 +407,7 @@ fi
217
407
  # Only sync when lessons changed (suppress errors if ORIG_HEAD is missing).
218
408
  # The trailing -- terminates the revision list so a ref/path ambiguity can never
219
409
  # 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
410
+ if [ -n "$TOTEM_CMD" ] && git diff-tree -r --name-only ORIG_HEAD HEAD -- 2>/dev/null | grep -q '${escapeBre(totemDir)}/lessons/'; then
221
411
  # Resolve the real git dir so the sync-log redirect works in a linked worktree,
222
412
  # where .git is a FILE (gitdir: pointer), not a directory (mmnto-ai/totem#2376).
223
413
  GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)
@@ -226,7 +416,9 @@ fi
226
416
  # ${TOTEM_HOOK_END}
227
417
  `;
228
418
  }
229
- export function buildPostCheckoutHookContent(fallbackCmd) {
419
+ export function buildPostCheckoutHookContent(options) {
420
+ const { fallbackCmd, totemDir } = options;
421
+ assertRenderableTotemDir(totemDir);
230
422
  return `#!/bin/sh
231
423
  # ${TOTEM_CHECKOUT_MARKER} — background re-index on branch switch.
232
424
 
@@ -242,34 +434,43 @@ ${buildResolveBlock(fallbackCmd)}
242
434
  # where .git is a FILE (gitdir: pointer), not a directory (mmnto-ai/totem#2376).
243
435
  GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)
244
436
 
245
- # Handle initial checkout (null SHA) — sync if .totem/ exists
437
+ # Handle initial checkout (null SHA) — sync if ${totemDir}/ exists
246
438
  if [ "$1" = "0000000000000000000000000000000000000000" ]; then
247
- if [ -n "$TOTEM_CMD" ] && [ -d ".totem" ]; then
439
+ if [ -n "$TOTEM_CMD" ] && [ -d "${totemDir}" ]; then
248
440
  ($TOTEM_CMD sync --incremental --quiet > "$GIT_DIR_RESOLVED/totem-sync.log" 2>&1) &
249
441
  fi
250
442
  exit 0
251
443
  fi
252
444
 
253
- # Only sync when .totem/ files differ between branches. The trailing -- terminates
445
+ # Only sync when ${totemDir}/ files differ between branches. The trailing -- terminates
254
446
  # 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
447
+ if [ -n "$TOTEM_CMD" ] && git diff --name-only "$1" "$2" -- 2>/dev/null | grep -q '${escapeBre(totemDir)}/'; then
256
448
  ($TOTEM_CMD sync --incremental --quiet > "$GIT_DIR_RESOLVED/totem-sync.log" 2>&1) &
257
449
  fi
258
450
  # ${TOTEM_CHECKOUT_END}
259
451
  `;
260
452
  }
261
453
  /**
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.
454
+ * Generate helper shell scripts under `<totemDir>/hooks/` for hook manager
455
+ * integration. These scripts contain the full guard logic (diff checks, null-SHA
456
+ * guards) that bare inline commands would skip.
457
+ *
458
+ * Takes the RESOLVED {@link HookRenderOptions} rather than resolving config
459
+ * itself: both callers already hold the one resolution for this invocation, and
460
+ * a required parameter is the same compiler-enforced thread the builders use
461
+ * (mmnto-ai/totem#2692 C1/C2).
265
462
  */
266
- export function generateHookHelpers(gitRoot, fallbackCmd, options) {
267
- const hooksDir = path.join(gitRoot, '.totem', 'hooks');
463
+ export function generateHookHelpers(gitRoot, render) {
464
+ // Refuse BEFORE the mkdir: the helper dir is joined from the value, and a
465
+ // `..` segment would create a directory outside the checkout before any
466
+ // builder got the chance to refuse it (mmnto-ai/totem#2692 amendment A7).
467
+ assertRenderableTotemDir(render.totemDir);
468
+ const hooksDir = path.join(gitRoot, render.totemDir, 'hooks');
268
469
  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);
470
+ const postMerge = buildHookContent(render);
471
+ const postCheckout = buildPostCheckoutHookContent(render);
472
+ const preCommit = buildPreCommitHook(render);
473
+ const prePush = buildPrePushHook(render);
273
474
  fs.writeFileSync(path.join(hooksDir, 'post-merge.sh'), postMerge, { mode: 0o755 });
274
475
  fs.writeFileSync(path.join(hooksDir, 'post-checkout.sh'), postCheckout, { mode: 0o755 });
275
476
  fs.writeFileSync(path.join(hooksDir, 'pre-commit.sh'), preCommit, { mode: 0o755 });
@@ -297,49 +498,63 @@ function detectHookManager(cwd) {
297
498
  }
298
499
  return null;
299
500
  }
300
- function printHookManagerGuidance(manager) {
501
+ /**
502
+ * Print the manual wiring a detected hook manager needs. `totemDir` is the
503
+ * RESOLVED value the helper scripts were just written under — guidance that
504
+ * names `.totem/` in a repo that configured something else points the consumer
505
+ * at files that do not exist (mmnto-ai/totem#2692 C5).
506
+ */
507
+ function printHookManagerGuidance(manager, totemDir) {
508
+ // The validator accepts whitespace in a totemDir; an unquoted word would split
509
+ // into two arguments in every consumer's shell (CodeRabbit on
510
+ // mmnto-ai/totem#2701). Quote only when needed so the default guidance stays
511
+ // the familiar `sh .totem/hooks/…`. `$` and a backtick are refused upstream, so
512
+ // double quotes are inert; the JSON form escapes them for package.json.
513
+ const needsQuotes = /\s/.test(totemDir);
514
+ const sh = needsQuotes ? `"${totemDir}"` : totemDir;
515
+ const json = needsQuotes ? `\\"${totemDir}\\"` : totemDir;
301
516
  switch (manager) {
302
517
  case 'husky':
303
518
  console.error('[Totem] Detected husky. Add the following to your hook files:');
304
519
  console.error('');
305
520
  console.error(' # .husky/pre-commit');
306
- console.error(' sh .totem/hooks/pre-commit.sh');
521
+ console.error(` sh ${sh}/hooks/pre-commit.sh`);
307
522
  console.error('');
308
523
  console.error(' # .husky/pre-push');
309
- console.error(' sh .totem/hooks/pre-push.sh');
524
+ console.error(` sh ${sh}/hooks/pre-push.sh`);
310
525
  console.error('');
311
526
  console.error(' # .husky/post-merge');
312
- console.error(' sh .totem/hooks/post-merge.sh');
527
+ console.error(` sh ${sh}/hooks/post-merge.sh`);
313
528
  console.error('');
314
529
  console.error(' # .husky/post-checkout');
315
- console.error(' sh .totem/hooks/post-checkout.sh');
530
+ console.error(` sh ${sh}/hooks/post-checkout.sh`);
316
531
  break;
317
532
  case 'lefthook':
318
533
  console.error('[Totem] Detected lefthook. Add to your lefthook.yml:');
319
534
  console.error(' pre-commit:');
320
535
  console.error(' commands:');
321
536
  console.error(' totem-block-main:');
322
- console.error(' run: sh .totem/hooks/pre-commit.sh');
537
+ console.error(` run: sh ${sh}/hooks/pre-commit.sh`);
323
538
  console.error(' pre-push:');
324
539
  console.error(' commands:');
325
540
  console.error(' totem-review:');
326
- console.error(' run: sh .totem/hooks/pre-push.sh');
541
+ console.error(` run: sh ${sh}/hooks/pre-push.sh`);
327
542
  console.error(' post-merge:');
328
543
  console.error(' commands:');
329
544
  console.error(' totem-sync:');
330
- console.error(' run: sh .totem/hooks/post-merge.sh');
545
+ console.error(` run: sh ${sh}/hooks/post-merge.sh`);
331
546
  console.error(' post-checkout:');
332
547
  console.error(' commands:');
333
548
  console.error(' totem-sync-checkout:');
334
- console.error(' run: sh .totem/hooks/post-checkout.sh');
549
+ console.error(` run: sh ${sh}/hooks/post-checkout.sh`);
335
550
  break;
336
551
  case 'simple-git-hooks':
337
552
  console.error('[Totem] Detected simple-git-hooks. Add to your package.json:');
338
553
  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"');
554
+ console.error(` "pre-commit": "sh ${json}/hooks/pre-commit.sh",`);
555
+ console.error(` "pre-push": "sh ${json}/hooks/pre-push.sh",`);
556
+ console.error(` "post-merge": "sh ${json}/hooks/post-merge.sh",`);
557
+ console.error(` "post-checkout": "sh ${json}/hooks/post-checkout.sh"`);
343
558
  console.error(' }');
344
559
  break;
345
560
  }
@@ -355,11 +570,13 @@ export async function installPostMergeHook(cwd, rl, options) {
355
570
  : '[Totem] Not a git repository — skipping hook installation.');
356
571
  return;
357
572
  }
358
- const fallbackCmd = getFallbackCommand(gitRoot);
573
+ // One config read per invocation, anchored at the git root — the same anchor
574
+ // getFallbackCommand has always used (mmnto-ai/totem#2692 C1).
575
+ const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
359
576
  const manager = detectHookManager(gitRoot);
360
577
  if (manager) {
361
- generateHookHelpers(gitRoot, fallbackCmd, options);
362
- printHookManagerGuidance(manager);
578
+ generateHookHelpers(gitRoot, render);
579
+ printHookManagerGuidance(manager, render.totemDir);
363
580
  return;
364
581
  }
365
582
  const interactive = options?.interactive ?? process.stdin.isTTY === true;
@@ -389,7 +606,7 @@ export async function installPostMergeHook(cwd, rl, options) {
389
606
  }
390
607
  // Append to existing hook — reuse buildHookContent, strip shebang
391
608
  const separator = existing.endsWith('\n') ? '' : '\n';
392
- const appendBlock = buildHookContent(fallbackCmd)
609
+ const appendBlock = buildHookContent(render)
393
610
  .replace(/^#!\/bin\/sh\n/, '')
394
611
  .trimStart();
395
612
  fs.appendFileSync(hookPath, separator + '\n' + appendBlock);
@@ -398,10 +615,11 @@ export async function installPostMergeHook(cwd, rl, options) {
398
615
  }
399
616
  // Create new hook
400
617
  fs.mkdirSync(hooksDir, { recursive: true });
401
- fs.writeFileSync(hookPath, buildHookContent(fallbackCmd));
618
+ fs.writeFileSync(hookPath, buildHookContent(render));
402
619
  // Make executable (no-op on Windows, git bash handles it)
403
620
  try {
404
621
  fs.chmodSync(hookPath, 0o755);
622
+ // 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
623
  }
406
624
  catch {
407
625
  // chmod may fail on Windows — hooks still work via git bash
@@ -417,13 +635,227 @@ if [ -n "$CLAUDE_CODE_AGENT" ] || [ -n "$CLAUDE_VERSION" ] || [ -n "$CURSOR_TRAC
417
635
  fi`;
418
636
  }
419
637
  // ─── Enforcement hooks (pre-commit + pre-push) ──────────
420
- export function buildPreCommitHook(tier) {
421
- const effectiveTier = tier ?? 'standard';
638
+ export function buildPreCommitHook(options) {
639
+ const effectiveTier = options.tier;
640
+ const totemDir = options.totemDir;
641
+ assertRenderableTotemDir(totemDir);
642
+ // The run store the strict arm reads, rendered from the CONFIGURED totemDir so
643
+ // the reader names the tree `totem spec` actually writes (mmnto-ai/totem#2692).
644
+ const runsDir = `${totemDir}/artifacts/runs`;
645
+ // Strict-tier evidence (mmnto-ai/totem#2690, tightened by
646
+ // mmnto-ai/totem#2700): the gate names `totem spec`, so it must pass on what
647
+ // `totem spec` actually writes — the grounded run artifact under
648
+ // <totemDir>/artifacts/runs/ (mmnto-ai/totem#2100; written on every
649
+ // successful run, --fresh included) whose TOP-LEVEL
650
+ // admission.runMetadata.caller is "spec". The read is JSON-aware on purpose:
651
+ // the run store is written by every orchestrator caller, and a `review`
652
+ // artifact's inputBundle embeds the reviewed diff — a substring grep would
653
+ // pass the gate on a review of any text that merely QUOTES the key (this
654
+ // very test fixture). node is already assumed by the pre-push template's
655
+ // format-check block; ~50 ms, no CLI boot, nothing written (Tenet 13). The
656
+ // former <totemDir>/cache/.spec-completed marker is NOT honored: no CLI path
657
+ // ever wrote it, so "compatibility" with it would be compatibility with a
658
+ // hand hack (operator ruling 2026-08-29 — no legacy shims while there is no
659
+ // hard consumer, Tenet 19).
660
+ //
661
+ // #2700 adds the second half of the rule: an artifact is EVIDENCE only when
662
+ // it is ANCHORED (`grounding.anchor.kind` of "issue" or "record" — a
663
+ // "free-text" or "mixed" run is the confabulation surface the rule exists
664
+ // for) and its SUBJECT carries a real shape. The SUBJECT depends on the
665
+ // anchor: for an `issue` run it is the draft (`output.content`); for a
666
+ // `record` run it is the RECORD'S OWN BYTES, re-read from disk at commit
667
+ // time — the draft is discarded on that path, so checking it would check
668
+ // nothing. The record's sha256 is compared and REPORTED (matches / revised
669
+ // since binding) but never blocks: blocking on revision would price every
670
+ // fold of a design record at one LLM call, the friction this slice retires.
671
+ // Exit vocabulary: 0 evidence · 2 no spec artifact · 3 the newest spec
672
+ // artifact is NOT evidence (reason on stdout) · anything else = the reader
673
+ // itself could not run. The evidence line makes a stale pass VISIBLE (age
674
+ // from the artifact's own createdAt); a freshness rule is a separate policy,
675
+ // deliberately not here. This is the ONLY reader of the rule — the repo's
676
+ // pre-managed-era `.gemini/hooks/BeforeTool.js` (unregistered, inert) was
677
+ // deleted with the marker rather than kept in step.
678
+ //
679
+ // Every value the reader compares against is RENDERED from the one canonical
680
+ // constant via JSON.stringify (the runsDir precedent): the required section
681
+ // headings from `SPEC_REQUIRED_SECTIONS`, the anchor kinds and the
682
+ // prompt-source spelling from the core schema's exported constants. The hook
683
+ // text can never drift from the writer's vocabulary by re-spelling it.
684
+ //
685
+ // The artifact is a plain JSON file a seat can hand-edit, and its NAME comes
686
+ // off the filesystem, so nothing echoed is trusted as text: EVERY value that
687
+ // reaches stdout — the artifact's path, its `createdAt`, `anchor.kind`,
688
+ // `anchor.ref` and each required heading — passes through `safe()` first. A
689
+ // newline in any of them would otherwise forge a second `[Totem]` line in the
690
+ // hook's own output; `safe()` collapses C0 (0x00–0x1f) AND the DEL/C1 band
691
+ // (0x7f–0x9f), because U+0085 (NEL) breaks a line on some terminals.
692
+ // Containment is decided by RESOLUTION, not by inspecting one segment, and
693
+ // it is decided TWICE. Lexically first: a `record` ref that is absolute
694
+ // (either path flavor) or whose `path.resolve` against `process.cwd()` — the
695
+ // worktree top git runs hooks from — lands outside it is refused, so
696
+ // `sub/../../x.md` and `./../x.md` are caught where a first-segment test let
697
+ // them through, while a mid-path `..` that stays inside stays legal. Then by
698
+ // REALPATH, once the ref is known to exist and before its bytes are read: an
699
+ // in-repo SYMLINK whose target lives outside the tree is lexically contained
700
+ // and would otherwise be read and judged, so the resolved pair is compared
701
+ // too and the block names both spellings. A `record` anchor whose `sha256` is
702
+ // missing and one whose `sha256` is not a 64-hex digest are refused with
703
+ // their OWN reasons — "no sha256" and "not a 64-hex digest" are different
704
+ // repairs — rather than as one malformed sensor line. Every reason and the
705
+ // pass line go out through `fs.writeSync(1, …)`: `process.stdout.write` is
706
+ // asynchronous on a pipe (macOS), so `process.exit` could truncate the text
707
+ // the `sh` arm is about to echo.
422
708
  const strictBlock = `
423
- # Strict mode: require spec before commit
709
+ # Strict mode: require spec EVIDENCE before commit (mmnto-ai/totem#2690, mmnto-ai/totem#2700).
710
+ # Evidence = a totem spec run artifact (${runsDir}/*.json with a
711
+ # top-level admission.runMetadata.caller of "spec"), read JSON-aware — a
712
+ # substring match would accept a review artifact that merely quotes the key —
713
+ # that is ANCHORED on an issue or a bound design record, and whose subject
714
+ # carries a real shape: the required headings each with a body (an issue run
715
+ # drafted by the built-in prompt), or at least one heading with a body (a
716
+ # record run, or an issue run drafted under a custom prompt). A record run is
717
+ # judged on the bytes of the record at grounding.anchor.ref, re-read here from
718
+ # the worktree top; its sha256 is REPORTED, never enforced.
719
+ # The former ${totemDir}/cache/.spec-completed marker is not honored (no CLI wrote it).
424
720
  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)"
721
+ spec_evidence=$(node -e '
722
+ const fs = require("fs");
723
+ const crypto = require("crypto");
724
+ const nodePath = require("path");
725
+ const dir = ${JSON.stringify(runsDir)};
726
+ const REQUIRED = ${JSON.stringify(SPEC_REQUIRED_SECTIONS)};
727
+ const KIND_ISSUE = ${JSON.stringify(GROUNDING_ANCHOR_ISSUE)};
728
+ const KIND_RECORD = ${JSON.stringify(GROUNDING_ANCHOR_RECORD)};
729
+ const PROMPT_OVERRIDE = ${JSON.stringify(PROMPT_SOURCE_OVERRIDE)};
730
+ let names = [];
731
+ try { names = fs.readdirSync(dir); } catch (err) { names = []; }
732
+ let best = null;
733
+ for (const name of names) {
734
+ if (!name.endsWith(".json")) continue;
735
+ let a = null;
736
+ try { a = JSON.parse(fs.readFileSync(dir + "/" + name, "utf8")); } catch (err) { continue; }
737
+ const caller = a && a.admission && a.admission.runMetadata && a.admission.runMetadata.caller;
738
+ if (["spec"].indexOf(caller) < 0) continue;
739
+ const at = ["string"].indexOf(typeof a.createdAt) < 0 ? "" : a.createdAt;
740
+ if (!best || at > best.at) best = { name: name, at: at, art: a };
741
+ }
742
+ if (!best) process.exit(2);
743
+ const file = dir + "/" + best.name;
744
+ // fs.writeSync, not process.stdout.write: stdout is a PIPE here (the sh arm
745
+ // captures it) and a piped write is asynchronous on macOS, so process.exit
746
+ // below could truncate the very text the sh arm is about to echo.
747
+ function emit(text) { fs.writeSync(1, text); }
748
+ function block(reason) { emit(reason); process.exit(3); }
749
+ function safe(text) {
750
+ let out = "";
751
+ for (let i = 0; i < text.length; i++) {
752
+ const code = text.charCodeAt(i);
753
+ const control = code < 32 || (code >= 127 && code <= 159);
754
+ out = out + (control ? "?" : text.charAt(i));
755
+ }
756
+ return out;
757
+ }
758
+ const shownFile = safe(file);
759
+ const shownAt = safe(best.at);
760
+ function isHeading(line) {
761
+ let n = 0;
762
+ while (n < line.length && ["#"].indexOf(line.charAt(n)) > -1) n = n + 1;
763
+ if (n < 1 || n > 6) return false;
764
+ if ([" ", "\\t"].indexOf(line.charAt(n)) < 0) return false;
765
+ return line.slice(n + 1).trim().length > 0;
766
+ }
767
+ function escapesTop(rel) {
768
+ const norm = rel.split("\\\\").join("/");
769
+ if (nodePath.win32.isAbsolute(norm) || nodePath.posix.isAbsolute(norm)) return true;
770
+ return [".."].indexOf(norm) > -1 || ["../"].indexOf(norm.slice(0, 3)) > -1;
771
+ }
772
+ function outsideWorktree(r) {
773
+ const norm = r.split("\\\\").join("/");
774
+ if (nodePath.win32.isAbsolute(norm) || nodePath.posix.isAbsolute(norm)) return true;
775
+ const top = process.cwd();
776
+ return escapesTop(nodePath.relative(top, nodePath.resolve(top, norm)));
777
+ }
778
+ const art = best.art;
779
+ const grounding = art.grounding;
780
+ const anchor = grounding && grounding.anchor;
781
+ if (!anchor || ["string"].indexOf(typeof anchor.kind) < 0) block("the newest spec run artifact (" + shownFile + ") predates the anchored-evidence rule (no grounding.anchor)");
782
+ const kind = anchor.kind;
783
+ const ref = ["string"].indexOf(typeof anchor.ref) < 0 ? "(no ref)" : anchor.ref;
784
+ const shownKind = safe(kind);
785
+ const shownRef = safe(ref);
786
+ if (kind !== KIND_ISSUE && kind !== KIND_RECORD) block("the newest spec run artifact (" + shownFile + ") is anchored " + shownKind + " (" + shownRef + "), which is not gate evidence");
787
+ let subject = "";
788
+ let shape = "";
789
+ let recordStatus = "";
790
+ if (kind !== KIND_RECORD) {
791
+ const content = art.output && art.output.content;
792
+ if (["string"].indexOf(typeof content) < 0) block("the newest spec run artifact (" + shownFile + ") is not evidence: the draft is not text");
793
+ subject = content;
794
+ const meta = art.admission && art.admission.runMetadata;
795
+ const source = meta && meta.promptSource;
796
+ shape = source !== PROMPT_OVERRIDE ? "TEMPLATE" : "DOCUMENT";
797
+ } else {
798
+ if (outsideWorktree(ref)) block("the bound record ref is outside the worktree: " + shownRef);
799
+ const bound = ["string"].indexOf(typeof anchor.sha256) < 0 ? "" : anchor.sha256;
800
+ if (bound.length < 1) block("the record anchor carries no sha256 — not evidence (" + shownFile + ")");
801
+ if (!/^[0-9a-f]{64}$/.test(bound)) block("the record anchor sha256 is not a 64-hex digest (" + safe(bound) + ") — not evidence (" + shownFile + ")");
802
+ const missingRecord = "the bound record is missing at " + shownRef + " (bound by " + shownFile + ")";
803
+ if (!fs.existsSync(ref)) block(missingRecord);
804
+ const realRef = fs.realpathSync.native(ref);
805
+ if (escapesTop(nodePath.relative(fs.realpathSync.native(process.cwd()), realRef))) block("the bound record resolves outside the worktree: " + shownRef + " -> " + safe(realRef));
806
+ let bytes = null;
807
+ let read = false;
808
+ try { bytes = fs.readFileSync(ref); read = true; } catch (err) { read = false; }
809
+ if (!read) block(missingRecord);
810
+ subject = bytes.toString("utf8");
811
+ shape = "DOCUMENT";
812
+ const now = crypto.createHash("sha256").update(bytes).digest("hex");
813
+ recordStatus = now !== bound ? "record revised since binding (bound " + bound.slice(0, ${RECORD_HASH_DISPLAY_PREFIX}) + ", now " + now.slice(0, ${RECORD_HASH_DISPLAY_PREFIX}) + ")" : "record sha256 matches";
814
+ }
815
+ if ([65279].indexOf(subject.charCodeAt(0)) > -1) subject = subject.slice(1);
816
+ const lines = subject.split("\\n");
817
+ function hasBodyAfter(start) {
818
+ for (let i = start + 1; i < lines.length; i++) {
819
+ if (isHeading(lines[i])) return false;
820
+ if (lines[i].trim().length > 0) return true;
821
+ }
822
+ return false;
823
+ }
824
+ if (shape !== "DOCUMENT") {
825
+ for (const heading of REQUIRED) {
826
+ let at = -1;
827
+ for (let i = 0; i < lines.length; i++) { if ([heading].indexOf(lines[i].trimEnd()) > -1) { at = i; break; } }
828
+ if (at < 0) block("the draft in " + shownFile + " is missing heading " + safe(heading));
829
+ if (!hasBodyAfter(at)) block("the draft in " + shownFile + " has an empty heading " + safe(heading));
830
+ }
831
+ } else {
832
+ let bodied = false;
833
+ for (let i = 0; i < lines.length; i++) { if (isHeading(lines[i]) && hasBodyAfter(i)) { bodied = true; break; } }
834
+ if (!bodied && kind !== KIND_RECORD) block("the draft in " + shownFile + " has no heading with a body (custom prompt: the built-in template skeleton is not required)");
835
+ if (!bodied) block("the bound record at " + shownRef + " has no heading with a body");
836
+ }
837
+ const stamp = best.at ? Date.parse(best.at) : NaN;
838
+ const days = Number.isNaN(stamp) ? -1 : Math.floor((Date.now() - stamp) / 86400000);
839
+ let out = shownFile + " (" + (shownAt || "undated") + (days >= 0 ? ", " + days + " days old" : "") + ")";
840
+ out = out + " · anchor " + shownKind + " " + shownRef + " · shape " + shape;
841
+ if (recordStatus.length > 0) out = out + " · " + recordStatus;
842
+ emit(out);
843
+ ' 2>/dev/null)
844
+ # Reader status: 0 = evidence found · 2 = no spec artifact at all · 3 = the
845
+ # newest spec artifact is NOT evidence (the reason is on stdout) · anything
846
+ # else = the reader itself could not run (node missing from PATH, a crash) —
847
+ # each reported distinctly, never as "no evidence", and all fail-closed.
848
+ reader_status=$?
849
+ if [ "$reader_status" = "0" ] && [ -n "$spec_evidence" ]; then
850
+ echo "[Totem] spec evidence: $spec_evidence"
851
+ elif [ "$reader_status" = "3" ]; then
852
+ echo "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"
853
+ exit 1
854
+ elif [ "$reader_status" != "2" ]; then
855
+ 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)"
856
+ exit 1
857
+ else
858
+ echo "[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode) — no totem spec run artifact under ${runsDir}/ in this checkout"
427
859
  exit 1
428
860
  fi
429
861
  fi`;
@@ -445,8 +877,10 @@ ${strictBlock}
445
877
  # ${TOTEM_PRECOMMIT_END}
446
878
  `;
447
879
  }
448
- export function buildPrePushHook(fallbackCmd, tier) {
449
- const effectiveTier = tier ?? 'standard';
880
+ export function buildPrePushHook(options) {
881
+ const { fallbackCmd, totemDir } = options;
882
+ const effectiveTier = options.tier;
883
+ assertRenderableTotemDir(totemDir);
450
884
  // Strict-tier gate per Proposal 273 § 6 Q2 (mmnto-ai/totem#1908): operator-invoked
451
885
  // is the default for new checks while behavior calibrates. Doctor's `--strict`
452
886
  // mode gates on repo-state `fail` results; unconditional firing would break
@@ -481,7 +915,7 @@ ${buildResolveBlock(fallbackCmd)}
481
915
 
482
916
  if [ -n "$TOTEM_CMD" ]; then
483
917
  # Verify compile manifest is current
484
- if [ -f ".totem/compile-manifest.json" ]; then
918
+ if [ -f "${totemDir}/compile-manifest.json" ]; then
485
919
  if ! $TOTEM_CMD verify-manifest > /dev/null 2>&1; then
486
920
  echo "[totem] Push blocked: compile manifest is stale. Run 'totem lesson compile'." >&2
487
921
  exit 1
@@ -489,14 +923,14 @@ if [ -n "$TOTEM_CMD" ]; then
489
923
  fi
490
924
 
491
925
  # Run deterministic lint
492
- if [ -f ".totem/compiled-rules.json" ]; then
926
+ if [ -f "${totemDir}/compiled-rules.json" ]; then
493
927
  if ! $TOTEM_CMD lint; then
494
928
  exit 1
495
929
  fi
496
930
  fi
497
931
 
498
932
  # 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
933
+ if [ -f "README.md" ] && [ -f "${totemDir}/compiled-rules.json" ]; then
500
934
  if ! $TOTEM_CMD verify-badges; then
501
935
  exit 1
502
936
  fi
@@ -519,7 +953,7 @@ if [ -n "$TOTEM_CMD" ]; then
519
953
  # missing-Goal-prefix, covenant-without-backing). Fires only when at
520
954
  # least one in-scope surface exists. Bypass with mandatory justification:
521
955
  # 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
956
+ if [ -f "${totemDir}/compiled-rules.json" ] && { [ -f "README.md" ] || [ -f "AGENTS.md" ] || [ -f "design-tenets.md" ] || [ -d "docs/wiki" ]; }; then
523
957
  # --scope-to-diff (mmnto-ai/totem#2002): narrow the WWND scan to files
524
958
  # touched in the current push diff. Eliminates the standing-gate
525
959
  # false-positive class where pre-existing warnings on in-scope surfaces
@@ -611,7 +1045,7 @@ function writeExecutableHook(hookPath, content) {
611
1045
  * overwrite would clobber it, so such a file is NOT owned (only trailing
612
1046
  * whitespace may follow the end marker).
613
1047
  */
614
- function isTotemOwnedWholeFile(content, marker, endMarker) {
1048
+ export function isTotemOwnedWholeFile(content, marker, endMarker) {
615
1049
  const idx = content.indexOf(marker);
616
1050
  if (idx === -1)
617
1051
  return false;
@@ -724,9 +1158,15 @@ export async function installEnforcementHooks(cwd, rl, options) {
724
1158
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
725
1159
  return skip;
726
1160
  }
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);
1161
+ // `totem init` writes the config BEFORE this runs, so — when init runs at the
1162
+ // git root, the supported layout — the resolved options are the ones the repo
1163
+ // just declared, and init and `totem hook install` render identically
1164
+ // (mmnto-ai/totem#2692 C1/C7). Off the root, init writes its config at cwd
1165
+ // while every hook writer resolves at the git root: a pre-existing split this
1166
+ // slice names and does not close.
1167
+ const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
1168
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(render), TOTEM_PRECOMMIT_MARKER, undefined, TOTEM_PRECOMMIT_END);
1169
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(render), TOTEM_PREPUSH_MARKER, undefined, TOTEM_PREPUSH_END);
730
1170
  // Warn about non-shell hooks that Totem cannot safely append to
731
1171
  if (preCommit === 'skipped-non-shell') {
732
1172
  console.error('[Totem] Warning: pre-commit hook uses a non-shell interpreter. Manually integrate branch protection into your existing hook.');
@@ -751,8 +1191,8 @@ export async function installHooksCommand() {
751
1191
  const postMerge = path.join(hooksDir, 'post-merge');
752
1192
  const hasPostMerge = fs.existsSync(postMerge) && fs.readFileSync(postMerge, 'utf-8').includes(TOTEM_HOOK_MARKER);
753
1193
  if (hasPostMerge) {
754
- const fallbackCmd = getFallbackCommand(gitRoot);
755
- installGitHook(hooksDir, 'post-checkout', buildPostCheckoutHookContent(fallbackCmd), TOTEM_CHECKOUT_MARKER, undefined, TOTEM_CHECKOUT_END);
1194
+ const render = await resolveHookRenderOptions(gitRoot);
1195
+ installGitHook(hooksDir, 'post-checkout', buildPostCheckoutHookContent(render), TOTEM_CHECKOUT_MARKER, undefined, TOTEM_CHECKOUT_END);
756
1196
  }
757
1197
  }
758
1198
  }
@@ -763,8 +1203,12 @@ export async function installHooksCommand() {
763
1203
  /**
764
1204
  * Non-interactive hook installer for `totem hooks` and `prepare` scripts.
765
1205
  * Installs pre-commit, pre-push, and post-merge hooks without prompting.
1206
+ *
1207
+ * Async since mmnto-ai/totem#2692: the hook text is rendered from the repo's
1208
+ * CONFIGURED `totemDir` (and `hooks.tier`), which means one config read —
1209
+ * {@link resolveHookRenderOptions} — before anything is written.
766
1210
  */
767
- export function installHooksNonInteractive(cwd, force, options) {
1211
+ export async function installHooksNonInteractive(cwd, force, options) {
768
1212
  // Guard: must be a git repo — resolve root from any subdirectory. Not-a-repo
769
1213
  // stays a silent null (the documented contract — callers print); the malformed
770
1214
  // pointer prints its declared-skip line here so a direct API caller honors the
@@ -775,12 +1219,12 @@ export function installHooksNonInteractive(cwd, force, options) {
775
1219
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
776
1220
  return null;
777
1221
  }
778
- const fallbackCmd = getFallbackCommand(gitRoot);
1222
+ const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
779
1223
  // Hook managers handle their own installation — generate helper scripts + print guidance
780
1224
  const manager = detectHookManager(gitRoot);
781
1225
  if (manager) {
782
- generateHookHelpers(gitRoot, fallbackCmd, options);
783
- printHookManagerGuidance(manager);
1226
+ generateHookHelpers(gitRoot, render);
1227
+ printHookManagerGuidance(manager, render.totemDir);
784
1228
  return null;
785
1229
  }
786
1230
  const hooksDir = resolveHooksDir(gitRoot);
@@ -791,11 +1235,11 @@ export function installHooksNonInteractive(cwd, force, options) {
791
1235
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
792
1236
  return null;
793
1237
  }
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);
1238
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(render), TOTEM_PRECOMMIT_MARKER, force, TOTEM_PRECOMMIT_END);
1239
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(render), TOTEM_PREPUSH_MARKER, force, TOTEM_PREPUSH_END);
1240
+ const postMergeContent = buildHookContent(render);
797
1241
  const postMerge = installGitHook(hooksDir, 'post-merge', postMergeContent, TOTEM_HOOK_MARKER, force, TOTEM_HOOK_END);
798
- const postCheckoutContent = buildPostCheckoutHookContent(fallbackCmd);
1242
+ const postCheckoutContent = buildPostCheckoutHookContent(render);
799
1243
  const postCheckout = installGitHook(hooksDir, 'post-checkout', postCheckoutContent, TOTEM_CHECKOUT_MARKER, force, TOTEM_CHECKOUT_END);
800
1244
  return { preCommit, prePush, postMerge, postCheckout };
801
1245
  }
@@ -864,32 +1308,17 @@ export async function hooksCommand(opts) {
864
1308
  }
865
1309
  return;
866
1310
  }
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 });
1311
+ // Tier precedence (CLI flag > config `hooks.tier` > 'standard') now lives in
1312
+ // `resolveHookRenderOptions`, the ONE config→hook-render seam
1313
+ // (mmnto-ai/totem#2692 C1) which `installHooksNonInteractive` calls with the
1314
+ // flag below, so the config is read exactly once per invocation and at the
1315
+ // git-root anchor the installer writes from.
1316
+ const tier = opts.strict
1317
+ ? 'strict'
1318
+ : opts.standard
1319
+ ? 'standard'
1320
+ : undefined;
1321
+ const result = await installHooksNonInteractive(cwd, opts.force, { tier });
893
1322
  // The git-hook summary prints ONLY when git hooks were actually written. A null
894
1323
  // result means a hook manager (husky/lefthook) was detected and
895
1324
  // installHooksNonInteractive already printed its guidance — but this MUST NOT
@@ -1250,8 +1679,12 @@ async function printGeminiHookMigrationSummary(cwd, force) {
1250
1679
  * stateless format that runs verify-manifest + lint directly.
1251
1680
  *
1252
1681
  * Returns true if the hook was upgraded, false otherwise.
1682
+ *
1683
+ * Async since mmnto-ai/totem#2692: the spliced block is rendered from the repo's
1684
+ * configured `totemDir` and `hooks.tier` like every other writer, so it no
1685
+ * longer silently downgrades a strict hook to standard on the upgrade path.
1253
1686
  */
1254
- export function upgradePrePushHookIfNeeded(cwd) {
1687
+ export async function upgradePrePushHookIfNeeded(cwd) {
1255
1688
  try {
1256
1689
  const gitRoot = resolveGitRoot(cwd);
1257
1690
  if (!gitRoot)
@@ -1308,9 +1741,9 @@ export function upgradePrePushHookIfNeeded(cwd) {
1308
1741
  if (endOffset === -1)
1309
1742
  return false;
1310
1743
  const blockEnd = markerIdx + endOffset;
1311
- const fallbackCmd = getFallbackCommand(gitRoot);
1744
+ const render = await resolveHookRenderOptions(gitRoot);
1312
1745
  // Build the replacement block (strip shebang — we're splicing into existing file)
1313
- const newBlock = buildPrePushHook(fallbackCmd)
1746
+ const newBlock = buildPrePushHook(render)
1314
1747
  .replace(/^#!\/bin\/sh\n/, '')
1315
1748
  .trimStart();
1316
1749
  // Splice: preserve everything before and after the totem block