@mmnto/cli 1.119.0 → 1.121.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/config-drift.test.js +10 -2
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +21 -6
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +14 -7
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/doctor.d.ts +21 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +167 -9
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +234 -6
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +38 -5
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +71 -0
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject-totemdir.test.d.ts +8 -0
- package/dist/commands/eject-totemdir.test.d.ts.map +1 -0
- package/dist/commands/eject-totemdir.test.js +101 -0
- package/dist/commands/eject-totemdir.test.js.map +1 -0
- package/dist/commands/eject.d.ts +30 -5
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +145 -31
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +3 -3
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gemini-sessionstart-contract.test.js +50 -0
- package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -1
- package/dist/commands/hook-totemdir-render.test.d.ts +23 -0
- package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
- package/dist/commands/hook-totemdir-render.test.js +288 -0
- package/dist/commands/hook-totemdir-render.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +105 -19
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +136 -12
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +6 -6
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +162 -12
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +381 -92
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +446 -158
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +71 -16
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/link.test.d.ts +9 -0
- package/dist/commands/link.test.d.ts.map +1 -0
- package/dist/commands/link.test.js +92 -0
- package/dist/commands/link.test.js.map +1 -0
- package/dist/commands/mail.d.ts +48 -2
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +76 -2
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +177 -0
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/pre-push-gate-matrix.test.js +9 -2
- package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
- package/dist/commands/rule.test.js +30 -0
- package/dist/commands/rule.test.js.map +1 -1
- package/dist/commands/shield.js +1 -1
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/tools-hook-parity.test.js +7 -3
- package/dist/commands/tools-hook-parity.test.js.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- 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(
|
|
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
|
|
|
@@ -165,6 +347,17 @@ ${buildResolveBlock(fallbackCmd)}
|
|
|
165
347
|
# exit-0-or-nothing contract (single-flight, atomic rename, no-clobber when gh is
|
|
166
348
|
# missing) makes blind firing safe, and the merge must never wait on it. An absent
|
|
167
349
|
# binary means the sidecar is not adopted here (non-cohort consumer) — skip silently.
|
|
350
|
+
# A SECOND verb rides the same gate: \`totem-status refresh-obligation-store\`
|
|
351
|
+
# (mmnto-ai/totem-status#127 slice-two residual, sibling of mmnto-ai/totem#2556)
|
|
352
|
+
# writes the durable obligation store beside the GH snapshot, so it gets the same
|
|
353
|
+
# post-merge moment. Same presence + primary-checkout gate, same backgrounded
|
|
354
|
+
# subshell, same log — and each firing stamps its own verb= field, so the log
|
|
355
|
+
# records WHICH verbs fired and in what order. That does NOT restore the #2570
|
|
356
|
+
# per-child reap discriminator: child output carries no verb tag and the two
|
|
357
|
+
# backgrounded children interleave nondeterministically, so a silent tail
|
|
358
|
+
# attributes only to the LAST verb stamped. Reopen when the sidecar tags its own
|
|
359
|
+
# output. Blind firing stays safe there too: the verb is in-process single-flight
|
|
360
|
+
# only, so it races the daemon exactly the way its manual invocation already does.
|
|
168
361
|
# PRIMARY checkout only ([ -d .git ]): in a linked worktree .git is a FILE, and a
|
|
169
362
|
# detached child inheriting the worktree cwd holds a Windows directory lock that
|
|
170
363
|
# breaks worktree removal; the primary's hooks + the daemon cover the workspace-level
|
|
@@ -172,9 +365,12 @@ ${buildResolveBlock(fallbackCmd)}
|
|
|
172
365
|
if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then
|
|
173
366
|
# Observability leg (mmnto-ai/totem#2570): stamp each firing (time, cwd, and
|
|
174
367
|
# WHICH binary resolved — the shell search order includes cwd on Windows, so
|
|
175
|
-
# a stale checkout-local exe can shadow the installed one) and hand the
|
|
176
|
-
# the same log, so
|
|
177
|
-
#
|
|
368
|
+
# a stale checkout-local exe can shadow the installed one) and hand the children
|
|
369
|
+
# the same log, so their output lands after the stamps. Measured caveat now that
|
|
370
|
+
# TWO verbs share one log: child output is unlabelled and the two children
|
|
371
|
+
# interleave nondeterministically, so a silent tail no longer discriminates per
|
|
372
|
+
# child — it attributes only to the LAST verb stamped. The stamps still record
|
|
373
|
+
# which verbs fired, and in what order. Repo-local inside
|
|
178
374
|
# .git (never tracked, per-repo, writable wherever git itself writes); 1 MiB
|
|
179
375
|
# self-cap. If the log is not writable, fall back to the previous blind
|
|
180
376
|
# firing — the 2>/dev/null PRECEDES the append so the open failure itself
|
|
@@ -185,17 +381,25 @@ if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then
|
|
|
185
381
|
# fields pass through tr -d '[:cntrl:]' so a crafted checkout path cannot
|
|
186
382
|
# forge stamp lines or inject terminal controls into the log.
|
|
187
383
|
if [ -f "$TS_REFRESH_LOG" ] && [ "$(( $(wc -c < "$TS_REFRESH_LOG" 2>/dev/null || echo 0) ))" -gt 1048576 ]; then : > "$TS_REFRESH_LOG"; fi
|
|
188
|
-
if printf '[%s] post-merge spawn cwd=%s bin=%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(pwd | tr -d '[:cntrl:]')" "$(command -v totem-status | tr -d '[:cntrl:]')" 2>/dev/null >> "$TS_REFRESH_LOG"; then
|
|
384
|
+
if printf '[%s] post-merge spawn cwd=%s bin=%s verb=%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(pwd | tr -d '[:cntrl:]')" "$(command -v totem-status | tr -d '[:cntrl:]')" refresh-gh 2>/dev/null >> "$TS_REFRESH_LOG"; then
|
|
189
385
|
(totem-status refresh-gh >> "$TS_REFRESH_LOG" 2>&1 &)
|
|
190
386
|
else
|
|
191
387
|
(totem-status refresh-gh >/dev/null 2>&1 &)
|
|
192
388
|
fi
|
|
389
|
+
# Second verb, same gate and same log. Written out rather than looped so the
|
|
390
|
+
# stamp and the backgrounded invocation each carry a literal verb — a reader of
|
|
391
|
+
# the hook (or of the log) never has to resolve a variable to know which fired.
|
|
392
|
+
if printf '[%s] post-merge spawn cwd=%s bin=%s verb=%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(pwd | tr -d '[:cntrl:]')" "$(command -v totem-status | tr -d '[:cntrl:]')" refresh-obligation-store 2>/dev/null >> "$TS_REFRESH_LOG"; then
|
|
393
|
+
(totem-status refresh-obligation-store >> "$TS_REFRESH_LOG" 2>&1 &)
|
|
394
|
+
else
|
|
395
|
+
(totem-status refresh-obligation-store >/dev/null 2>&1 &)
|
|
396
|
+
fi
|
|
193
397
|
fi
|
|
194
398
|
|
|
195
399
|
# Only sync when lessons changed (suppress errors if ORIG_HEAD is missing).
|
|
196
400
|
# The trailing -- terminates the revision list so a ref/path ambiguity can never
|
|
197
401
|
# reinterpret ORIG_HEAD/HEAD as pathspecs.
|
|
198
|
-
if [ -n "$TOTEM_CMD" ] && git diff-tree -r --name-only ORIG_HEAD HEAD -- 2>/dev/null | grep -q '
|
|
402
|
+
if [ -n "$TOTEM_CMD" ] && git diff-tree -r --name-only ORIG_HEAD HEAD -- 2>/dev/null | grep -q '${escapeBre(totemDir)}/lessons/'; then
|
|
199
403
|
# Resolve the real git dir so the sync-log redirect works in a linked worktree,
|
|
200
404
|
# where .git is a FILE (gitdir: pointer), not a directory (mmnto-ai/totem#2376).
|
|
201
405
|
GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)
|
|
@@ -204,7 +408,9 @@ fi
|
|
|
204
408
|
# ${TOTEM_HOOK_END}
|
|
205
409
|
`;
|
|
206
410
|
}
|
|
207
|
-
export function buildPostCheckoutHookContent(
|
|
411
|
+
export function buildPostCheckoutHookContent(options) {
|
|
412
|
+
const { fallbackCmd, totemDir } = options;
|
|
413
|
+
assertRenderableTotemDir(totemDir);
|
|
208
414
|
return `#!/bin/sh
|
|
209
415
|
# ${TOTEM_CHECKOUT_MARKER} — background re-index on branch switch.
|
|
210
416
|
|
|
@@ -220,34 +426,43 @@ ${buildResolveBlock(fallbackCmd)}
|
|
|
220
426
|
# where .git is a FILE (gitdir: pointer), not a directory (mmnto-ai/totem#2376).
|
|
221
427
|
GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)
|
|
222
428
|
|
|
223
|
-
# Handle initial checkout (null SHA) — sync if
|
|
429
|
+
# Handle initial checkout (null SHA) — sync if ${totemDir}/ exists
|
|
224
430
|
if [ "$1" = "0000000000000000000000000000000000000000" ]; then
|
|
225
|
-
if [ -n "$TOTEM_CMD" ] && [ -d "
|
|
431
|
+
if [ -n "$TOTEM_CMD" ] && [ -d "${totemDir}" ]; then
|
|
226
432
|
($TOTEM_CMD sync --incremental --quiet > "$GIT_DIR_RESOLVED/totem-sync.log" 2>&1) &
|
|
227
433
|
fi
|
|
228
434
|
exit 0
|
|
229
435
|
fi
|
|
230
436
|
|
|
231
|
-
# Only sync when
|
|
437
|
+
# Only sync when ${totemDir}/ files differ between branches. The trailing -- terminates
|
|
232
438
|
# the revision list so the "$1"/"$2" SHAs can never be reinterpreted as pathspecs.
|
|
233
|
-
if [ -n "$TOTEM_CMD" ] && git diff --name-only "$1" "$2" -- 2>/dev/null | grep -q '
|
|
439
|
+
if [ -n "$TOTEM_CMD" ] && git diff --name-only "$1" "$2" -- 2>/dev/null | grep -q '${escapeBre(totemDir)}/'; then
|
|
234
440
|
($TOTEM_CMD sync --incremental --quiet > "$GIT_DIR_RESOLVED/totem-sync.log" 2>&1) &
|
|
235
441
|
fi
|
|
236
442
|
# ${TOTEM_CHECKOUT_END}
|
|
237
443
|
`;
|
|
238
444
|
}
|
|
239
445
|
/**
|
|
240
|
-
* Generate helper shell scripts under
|
|
241
|
-
* These scripts contain the full guard logic (diff checks, null-SHA
|
|
242
|
-
* 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).
|
|
243
454
|
*/
|
|
244
|
-
export function generateHookHelpers(gitRoot,
|
|
245
|
-
|
|
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');
|
|
246
461
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
247
|
-
const postMerge = buildHookContent(
|
|
248
|
-
const postCheckout = buildPostCheckoutHookContent(
|
|
249
|
-
const preCommit = buildPreCommitHook(
|
|
250
|
-
const prePush = buildPrePushHook(
|
|
462
|
+
const postMerge = buildHookContent(render);
|
|
463
|
+
const postCheckout = buildPostCheckoutHookContent(render);
|
|
464
|
+
const preCommit = buildPreCommitHook(render);
|
|
465
|
+
const prePush = buildPrePushHook(render);
|
|
251
466
|
fs.writeFileSync(path.join(hooksDir, 'post-merge.sh'), postMerge, { mode: 0o755 });
|
|
252
467
|
fs.writeFileSync(path.join(hooksDir, 'post-checkout.sh'), postCheckout, { mode: 0o755 });
|
|
253
468
|
fs.writeFileSync(path.join(hooksDir, 'pre-commit.sh'), preCommit, { mode: 0o755 });
|
|
@@ -275,49 +490,63 @@ function detectHookManager(cwd) {
|
|
|
275
490
|
}
|
|
276
491
|
return null;
|
|
277
492
|
}
|
|
278
|
-
|
|
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;
|
|
279
508
|
switch (manager) {
|
|
280
509
|
case 'husky':
|
|
281
510
|
console.error('[Totem] Detected husky. Add the following to your hook files:');
|
|
282
511
|
console.error('');
|
|
283
512
|
console.error(' # .husky/pre-commit');
|
|
284
|
-
console.error(
|
|
513
|
+
console.error(` sh ${sh}/hooks/pre-commit.sh`);
|
|
285
514
|
console.error('');
|
|
286
515
|
console.error(' # .husky/pre-push');
|
|
287
|
-
console.error(
|
|
516
|
+
console.error(` sh ${sh}/hooks/pre-push.sh`);
|
|
288
517
|
console.error('');
|
|
289
518
|
console.error(' # .husky/post-merge');
|
|
290
|
-
console.error(
|
|
519
|
+
console.error(` sh ${sh}/hooks/post-merge.sh`);
|
|
291
520
|
console.error('');
|
|
292
521
|
console.error(' # .husky/post-checkout');
|
|
293
|
-
console.error(
|
|
522
|
+
console.error(` sh ${sh}/hooks/post-checkout.sh`);
|
|
294
523
|
break;
|
|
295
524
|
case 'lefthook':
|
|
296
525
|
console.error('[Totem] Detected lefthook. Add to your lefthook.yml:');
|
|
297
526
|
console.error(' pre-commit:');
|
|
298
527
|
console.error(' commands:');
|
|
299
528
|
console.error(' totem-block-main:');
|
|
300
|
-
console.error(
|
|
529
|
+
console.error(` run: sh ${sh}/hooks/pre-commit.sh`);
|
|
301
530
|
console.error(' pre-push:');
|
|
302
531
|
console.error(' commands:');
|
|
303
532
|
console.error(' totem-review:');
|
|
304
|
-
console.error(
|
|
533
|
+
console.error(` run: sh ${sh}/hooks/pre-push.sh`);
|
|
305
534
|
console.error(' post-merge:');
|
|
306
535
|
console.error(' commands:');
|
|
307
536
|
console.error(' totem-sync:');
|
|
308
|
-
console.error(
|
|
537
|
+
console.error(` run: sh ${sh}/hooks/post-merge.sh`);
|
|
309
538
|
console.error(' post-checkout:');
|
|
310
539
|
console.error(' commands:');
|
|
311
540
|
console.error(' totem-sync-checkout:');
|
|
312
|
-
console.error(
|
|
541
|
+
console.error(` run: sh ${sh}/hooks/post-checkout.sh`);
|
|
313
542
|
break;
|
|
314
543
|
case 'simple-git-hooks':
|
|
315
544
|
console.error('[Totem] Detected simple-git-hooks. Add to your package.json:');
|
|
316
545
|
console.error(' "simple-git-hooks": {');
|
|
317
|
-
console.error(
|
|
318
|
-
console.error(
|
|
319
|
-
console.error(
|
|
320
|
-
console.error(
|
|
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"`);
|
|
321
550
|
console.error(' }');
|
|
322
551
|
break;
|
|
323
552
|
}
|
|
@@ -333,11 +562,13 @@ export async function installPostMergeHook(cwd, rl, options) {
|
|
|
333
562
|
: '[Totem] Not a git repository — skipping hook installation.');
|
|
334
563
|
return;
|
|
335
564
|
}
|
|
336
|
-
|
|
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 });
|
|
337
568
|
const manager = detectHookManager(gitRoot);
|
|
338
569
|
if (manager) {
|
|
339
|
-
generateHookHelpers(gitRoot,
|
|
340
|
-
printHookManagerGuidance(manager);
|
|
570
|
+
generateHookHelpers(gitRoot, render);
|
|
571
|
+
printHookManagerGuidance(manager, render.totemDir);
|
|
341
572
|
return;
|
|
342
573
|
}
|
|
343
574
|
const interactive = options?.interactive ?? process.stdin.isTTY === true;
|
|
@@ -367,7 +598,7 @@ export async function installPostMergeHook(cwd, rl, options) {
|
|
|
367
598
|
}
|
|
368
599
|
// Append to existing hook — reuse buildHookContent, strip shebang
|
|
369
600
|
const separator = existing.endsWith('\n') ? '' : '\n';
|
|
370
|
-
const appendBlock = buildHookContent(
|
|
601
|
+
const appendBlock = buildHookContent(render)
|
|
371
602
|
.replace(/^#!\/bin\/sh\n/, '')
|
|
372
603
|
.trimStart();
|
|
373
604
|
fs.appendFileSync(hookPath, separator + '\n' + appendBlock);
|
|
@@ -376,10 +607,11 @@ export async function installPostMergeHook(cwd, rl, options) {
|
|
|
376
607
|
}
|
|
377
608
|
// Create new hook
|
|
378
609
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
379
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
610
|
+
fs.writeFileSync(hookPath, buildHookContent(render));
|
|
380
611
|
// Make executable (no-op on Windows, git bash handles it)
|
|
381
612
|
try {
|
|
382
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.
|
|
383
615
|
}
|
|
384
616
|
catch {
|
|
385
617
|
// chmod may fail on Windows — hooks still work via git bash
|
|
@@ -395,13 +627,69 @@ if [ -n "$CLAUDE_CODE_AGENT" ] || [ -n "$CLAUDE_VERSION" ] || [ -n "$CURSOR_TRAC
|
|
|
395
627
|
fi`;
|
|
396
628
|
}
|
|
397
629
|
// ─── Enforcement hooks (pre-commit + pre-push) ──────────
|
|
398
|
-
export function buildPreCommitHook(
|
|
399
|
-
const effectiveTier = tier
|
|
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.
|
|
400
655
|
const strictBlock = `
|
|
401
|
-
# 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).
|
|
402
661
|
if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]; then
|
|
403
|
-
|
|
404
|
-
|
|
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"
|
|
405
693
|
exit 1
|
|
406
694
|
fi
|
|
407
695
|
fi`;
|
|
@@ -423,8 +711,10 @@ ${strictBlock}
|
|
|
423
711
|
# ${TOTEM_PRECOMMIT_END}
|
|
424
712
|
`;
|
|
425
713
|
}
|
|
426
|
-
export function buildPrePushHook(
|
|
427
|
-
const
|
|
714
|
+
export function buildPrePushHook(options) {
|
|
715
|
+
const { fallbackCmd, totemDir } = options;
|
|
716
|
+
const effectiveTier = options.tier;
|
|
717
|
+
assertRenderableTotemDir(totemDir);
|
|
428
718
|
// Strict-tier gate per Proposal 273 § 6 Q2 (mmnto-ai/totem#1908): operator-invoked
|
|
429
719
|
// is the default for new checks while behavior calibrates. Doctor's `--strict`
|
|
430
720
|
// mode gates on repo-state `fail` results; unconditional firing would break
|
|
@@ -459,7 +749,7 @@ ${buildResolveBlock(fallbackCmd)}
|
|
|
459
749
|
|
|
460
750
|
if [ -n "$TOTEM_CMD" ]; then
|
|
461
751
|
# Verify compile manifest is current
|
|
462
|
-
if [ -f "
|
|
752
|
+
if [ -f "${totemDir}/compile-manifest.json" ]; then
|
|
463
753
|
if ! $TOTEM_CMD verify-manifest > /dev/null 2>&1; then
|
|
464
754
|
echo "[totem] Push blocked: compile manifest is stale. Run 'totem lesson compile'." >&2
|
|
465
755
|
exit 1
|
|
@@ -467,14 +757,14 @@ if [ -n "$TOTEM_CMD" ]; then
|
|
|
467
757
|
fi
|
|
468
758
|
|
|
469
759
|
# Run deterministic lint
|
|
470
|
-
if [ -f "
|
|
760
|
+
if [ -f "${totemDir}/compiled-rules.json" ]; then
|
|
471
761
|
if ! $TOTEM_CMD lint; then
|
|
472
762
|
exit 1
|
|
473
763
|
fi
|
|
474
764
|
fi
|
|
475
765
|
|
|
476
766
|
# Verify shields.io badges in README.md (mmnto-ai/totem#1926 — deterministic claim-discipline)
|
|
477
|
-
if [ -f "README.md" ] && [ -f "
|
|
767
|
+
if [ -f "README.md" ] && [ -f "${totemDir}/compiled-rules.json" ]; then
|
|
478
768
|
if ! $TOTEM_CMD verify-badges; then
|
|
479
769
|
exit 1
|
|
480
770
|
fi
|
|
@@ -497,7 +787,7 @@ if [ -n "$TOTEM_CMD" ]; then
|
|
|
497
787
|
# missing-Goal-prefix, covenant-without-backing). Fires only when at
|
|
498
788
|
# least one in-scope surface exists. Bypass with mandatory justification:
|
|
499
789
|
# TOTEM_GATE_BYPASS_JUSTIFICATION="<reason>" git push
|
|
500
|
-
if [ -f "
|
|
790
|
+
if [ -f "${totemDir}/compiled-rules.json" ] && { [ -f "README.md" ] || [ -f "AGENTS.md" ] || [ -f "design-tenets.md" ] || [ -d "docs/wiki" ]; }; then
|
|
501
791
|
# --scope-to-diff (mmnto-ai/totem#2002): narrow the WWND scan to files
|
|
502
792
|
# touched in the current push diff. Eliminates the standing-gate
|
|
503
793
|
# false-positive class where pre-existing warnings on in-scope surfaces
|
|
@@ -589,7 +879,7 @@ function writeExecutableHook(hookPath, content) {
|
|
|
589
879
|
* overwrite would clobber it, so such a file is NOT owned (only trailing
|
|
590
880
|
* whitespace may follow the end marker).
|
|
591
881
|
*/
|
|
592
|
-
function isTotemOwnedWholeFile(content, marker, endMarker) {
|
|
882
|
+
export function isTotemOwnedWholeFile(content, marker, endMarker) {
|
|
593
883
|
const idx = content.indexOf(marker);
|
|
594
884
|
if (idx === -1)
|
|
595
885
|
return false;
|
|
@@ -702,9 +992,15 @@ export async function installEnforcementHooks(cwd, rl, options) {
|
|
|
702
992
|
console.error(HOOKS_DIR_UNRESOLVED_MSG);
|
|
703
993
|
return skip;
|
|
704
994
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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);
|
|
708
1004
|
// Warn about non-shell hooks that Totem cannot safely append to
|
|
709
1005
|
if (preCommit === 'skipped-non-shell') {
|
|
710
1006
|
console.error('[Totem] Warning: pre-commit hook uses a non-shell interpreter. Manually integrate branch protection into your existing hook.');
|
|
@@ -729,8 +1025,8 @@ export async function installHooksCommand() {
|
|
|
729
1025
|
const postMerge = path.join(hooksDir, 'post-merge');
|
|
730
1026
|
const hasPostMerge = fs.existsSync(postMerge) && fs.readFileSync(postMerge, 'utf-8').includes(TOTEM_HOOK_MARKER);
|
|
731
1027
|
if (hasPostMerge) {
|
|
732
|
-
const
|
|
733
|
-
installGitHook(hooksDir, 'post-checkout', buildPostCheckoutHookContent(
|
|
1028
|
+
const render = await resolveHookRenderOptions(gitRoot);
|
|
1029
|
+
installGitHook(hooksDir, 'post-checkout', buildPostCheckoutHookContent(render), TOTEM_CHECKOUT_MARKER, undefined, TOTEM_CHECKOUT_END);
|
|
734
1030
|
}
|
|
735
1031
|
}
|
|
736
1032
|
}
|
|
@@ -741,8 +1037,12 @@ export async function installHooksCommand() {
|
|
|
741
1037
|
/**
|
|
742
1038
|
* Non-interactive hook installer for `totem hooks` and `prepare` scripts.
|
|
743
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.
|
|
744
1044
|
*/
|
|
745
|
-
export function installHooksNonInteractive(cwd, force, options) {
|
|
1045
|
+
export async function installHooksNonInteractive(cwd, force, options) {
|
|
746
1046
|
// Guard: must be a git repo — resolve root from any subdirectory. Not-a-repo
|
|
747
1047
|
// stays a silent null (the documented contract — callers print); the malformed
|
|
748
1048
|
// pointer prints its declared-skip line here so a direct API caller honors the
|
|
@@ -753,12 +1053,12 @@ export function installHooksNonInteractive(cwd, force, options) {
|
|
|
753
1053
|
console.error(HOOKS_DIR_UNRESOLVED_MSG);
|
|
754
1054
|
return null;
|
|
755
1055
|
}
|
|
756
|
-
const
|
|
1056
|
+
const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
|
|
757
1057
|
// Hook managers handle their own installation — generate helper scripts + print guidance
|
|
758
1058
|
const manager = detectHookManager(gitRoot);
|
|
759
1059
|
if (manager) {
|
|
760
|
-
generateHookHelpers(gitRoot,
|
|
761
|
-
printHookManagerGuidance(manager);
|
|
1060
|
+
generateHookHelpers(gitRoot, render);
|
|
1061
|
+
printHookManagerGuidance(manager, render.totemDir);
|
|
762
1062
|
return null;
|
|
763
1063
|
}
|
|
764
1064
|
const hooksDir = resolveHooksDir(gitRoot);
|
|
@@ -769,11 +1069,11 @@ export function installHooksNonInteractive(cwd, force, options) {
|
|
|
769
1069
|
console.error(HOOKS_DIR_UNRESOLVED_MSG);
|
|
770
1070
|
return null;
|
|
771
1071
|
}
|
|
772
|
-
const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(
|
|
773
|
-
const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
774
|
-
const postMergeContent = buildHookContent(
|
|
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);
|
|
775
1075
|
const postMerge = installGitHook(hooksDir, 'post-merge', postMergeContent, TOTEM_HOOK_MARKER, force, TOTEM_HOOK_END);
|
|
776
|
-
const postCheckoutContent = buildPostCheckoutHookContent(
|
|
1076
|
+
const postCheckoutContent = buildPostCheckoutHookContent(render);
|
|
777
1077
|
const postCheckout = installGitHook(hooksDir, 'post-checkout', postCheckoutContent, TOTEM_CHECKOUT_MARKER, force, TOTEM_CHECKOUT_END);
|
|
778
1078
|
return { preCommit, prePush, postMerge, postCheckout };
|
|
779
1079
|
}
|
|
@@ -842,32 +1142,17 @@ export async function hooksCommand(opts) {
|
|
|
842
1142
|
}
|
|
843
1143
|
return;
|
|
844
1144
|
}
|
|
845
|
-
//
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
catch (err) {
|
|
860
|
-
if (process.env.TOTEM_DEBUG) {
|
|
861
|
-
console.error('[Totem] Could not load config for tier resolution:', err);
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
if (opts.strict) {
|
|
865
|
-
tier = 'strict';
|
|
866
|
-
}
|
|
867
|
-
else if (opts.standard) {
|
|
868
|
-
tier = 'standard';
|
|
869
|
-
}
|
|
870
|
-
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 });
|
|
871
1156
|
// The git-hook summary prints ONLY when git hooks were actually written. A null
|
|
872
1157
|
// result means a hook manager (husky/lefthook) was detected and
|
|
873
1158
|
// installHooksNonInteractive already printed its guidance — but this MUST NOT
|
|
@@ -1228,8 +1513,12 @@ async function printGeminiHookMigrationSummary(cwd, force) {
|
|
|
1228
1513
|
* stateless format that runs verify-manifest + lint directly.
|
|
1229
1514
|
*
|
|
1230
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.
|
|
1231
1520
|
*/
|
|
1232
|
-
export function upgradePrePushHookIfNeeded(cwd) {
|
|
1521
|
+
export async function upgradePrePushHookIfNeeded(cwd) {
|
|
1233
1522
|
try {
|
|
1234
1523
|
const gitRoot = resolveGitRoot(cwd);
|
|
1235
1524
|
if (!gitRoot)
|
|
@@ -1286,9 +1575,9 @@ export function upgradePrePushHookIfNeeded(cwd) {
|
|
|
1286
1575
|
if (endOffset === -1)
|
|
1287
1576
|
return false;
|
|
1288
1577
|
const blockEnd = markerIdx + endOffset;
|
|
1289
|
-
const
|
|
1578
|
+
const render = await resolveHookRenderOptions(gitRoot);
|
|
1290
1579
|
// Build the replacement block (strip shebang — we're splicing into existing file)
|
|
1291
|
-
const newBlock = buildPrePushHook(
|
|
1580
|
+
const newBlock = buildPrePushHook(render)
|
|
1292
1581
|
.replace(/^#!\/bin\/sh\n/, '')
|
|
1293
1582
|
.trimStart();
|
|
1294
1583
|
// Splice: preserve everything before and after the totem block
|