@mmnto/cli 1.124.0 → 2.0.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 (37) hide show
  1. package/dist/commands/doctor.d.ts +16 -10
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +162 -54
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +444 -28
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/hook-totemdir-render.test.js +55 -1
  8. package/dist/commands/hook-totemdir-render.test.js.map +1 -1
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +39 -0
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +32 -0
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/install-hooks-exit-contract.test.js +32 -1
  15. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  16. package/dist/commands/install-hooks.d.ts +143 -10
  17. package/dist/commands/install-hooks.d.ts.map +1 -1
  18. package/dist/commands/install-hooks.js +441 -69
  19. package/dist/commands/install-hooks.js.map +1 -1
  20. package/dist/commands/install-hooks.test.js +1075 -7
  21. package/dist/commands/install-hooks.test.js.map +1 -1
  22. package/dist/commands/spec-templates.d.ts +17 -4
  23. package/dist/commands/spec-templates.d.ts.map +1 -1
  24. package/dist/commands/spec-templates.js +23 -3
  25. package/dist/commands/spec-templates.js.map +1 -1
  26. package/dist/commands/spec.d.ts +57 -8
  27. package/dist/commands/spec.d.ts.map +1 -1
  28. package/dist/commands/spec.js +124 -28
  29. package/dist/commands/spec.js.map +1 -1
  30. package/dist/commands/spec.test.js +277 -44
  31. package/dist/commands/spec.test.js.map +1 -1
  32. package/dist/utils.d.ts +5 -3
  33. package/dist/utils.d.ts.map +1 -1
  34. package/dist/utils.js.map +1 -1
  35. package/dist/utils.test.js +8 -3
  36. package/dist/utils.test.js.map +1 -1
  37. package/package.json +2 -2
@@ -3,6 +3,8 @@ 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
+ // totem-context: mmnto-ai/totem#2753 — the rule's startup-cost premise does not apply to THIS module, because `install-hooks.js` is reached only through `await import` (index.ts, index-lite.ts, doctor.ts, doctor-parity.ts, eject.ts, init.ts, shield.ts), so it is never on the `--help` graph, and the core barrel is already in its static graph via `../git.js` (`import { safeExec } from '@mmnto/totem'`) and `../artifact-vocabulary.js`. The dynamic form is also unavailable: `isAttestedTrailer` is a SYNCHRONOUS exported predicate by contract and `installGitHook` is synchronous, so the only alternative would be duplicating core's `parseForkMarker` regex in the CLI — the divergence the shared parser exists to prevent.
7
+ import { parseForkMarker, writeFileAtomicSync } from '@mmnto/totem';
6
8
  import { GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_RECORD, PROMPT_SOURCE_OVERRIDE, } from '../artifact-vocabulary.js';
7
9
  import { resolveGitRoot } from '../git.js';
8
10
  import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
@@ -270,8 +272,16 @@ function escapeBre(value) {
270
272
  */
271
273
  export async function resolveHookRenderOptions(cwd, flags) {
272
274
  const fallbackCmd = getFallbackCommand(cwd);
275
+ // `tierPinned` belongs on the DEFAULTS, not only on the fully-resolved return:
276
+ // both early exits below (no config anywhere, config present but unloadable)
277
+ // hand `defaults` straight back, and a flag is pinned in those states exactly as
278
+ // it is in the resolved one. Without it `tierForHook` would let an installed
279
+ // hook's own declaration override an explicit `--strict` / `--standard` in every
280
+ // config-less repo — `totem hook install --strict` a no-op, and `--force` writing
281
+ // the tier the user just asked to change (mmnto-ai/totem#2753 fold 3 F1).
273
282
  const defaults = {
274
283
  tier: flags?.tier ?? 'standard',
284
+ ...(flags?.tier === undefined ? {} : { tierPinned: true }),
275
285
  totemDir: DEFAULT_TOTEM_DIR,
276
286
  fallbackCmd,
277
287
  };
@@ -292,7 +302,7 @@ export async function resolveHookRenderOptions(cwd, flags) {
292
302
  }
293
303
  catch (err) {
294
304
  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\`.`);
305
+ console.error(`[Totem] Could not load ${configPath} (${reason.split('\n')[0]}) — the git hooks are rendered at the defaults (totemDir '${DEFAULT_TOTEM_DIR}'); the tier follows an explicit flag, else the tier each installed hook declares, else 'standard'; fix the config and re-run \`totem hook install --force\`.`);
296
306
  return { ...defaults, configError: reason };
297
307
  }
298
308
  const totemDir = isGlobalConfigPath(configPath)
@@ -305,13 +315,68 @@ export async function resolveHookRenderOptions(cwd, flags) {
305
315
  const { TotemError } = await import('@mmnto/totem');
306
316
  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
317
  }
318
+ const pinned = flags?.tier ?? config.hooks?.tier;
308
319
  return {
309
- tier: flags?.tier ?? config.hooks?.tier ?? 'standard',
320
+ tier: pinned ?? 'standard',
321
+ ...(pinned === undefined ? {} : { tierPinned: true }),
310
322
  totemDir,
311
323
  fallbackCmd,
312
324
  configPath,
313
325
  };
314
326
  }
327
+ /**
328
+ * The enforcement tier an INSTALLED hook declares (`TOTEM_HOOK_TIER="…"`), read from
329
+ * the TOTEM-OWNED BLOCK only — never from the whole file, so a user's own line
330
+ * carrying that assignment above an appended block cannot steer the render (the
331
+ * mmnto-ai/totem#2692 pass-2 F3 lesson, applied on the install side).
332
+ *
333
+ * `undefined` when the hook is absent, carries no marker, or predates the tier line.
334
+ *
335
+ * Only an ASSIGNMENT at the start of a line counts — the templates emit
336
+ * `TOTEM_HOOK_TIER="…"` unindented — so a comment inside the block that quotes the
337
+ * assignment (`# TOTEM_HOOK_TIER="strict" …`) cannot steer the render (Gemini,
338
+ * mmnto-ai/totem#2760 round 1).
339
+ *
340
+ * A hook with a start marker but NO end marker is read from the marker to EOF. That
341
+ * is a POLICY, not an observation about such files: everything below an unbounded
342
+ * start marker is TREATED as ours, because that file's one cure is `--force`, which
343
+ * discards the tail anyway. So a user line below it can only steer the render toward
344
+ * the tier it names — fail-closed toward strict, never a silent downgrade.
345
+ */
346
+ export function declaredHookTier(content, marker, endMarker) {
347
+ const start = content.indexOf(marker);
348
+ if (start === -1)
349
+ return undefined;
350
+ const end = content.indexOf(endMarker, start + marker.length);
351
+ const block = end === -1 ? content.slice(start) : content.slice(start, end + endMarker.length);
352
+ return /^TOTEM_HOOK_TIER="(strict|standard)"/m.exec(block)?.[1];
353
+ }
354
+ /**
355
+ * The tier to RENDER one hook at: an explicit flag or a configured `hooks.tier`
356
+ * (both carried as `render.tierPinned`) wins; otherwise the tier the hook already
357
+ * on disk declares; otherwise `render.tier` (the `'standard'` default).
358
+ *
359
+ * Without this last-but-one rung a bare `totem hook install` or `totem init` on a
360
+ * repo that pins no tier re-renders a `--strict` hook at standard — a SILENT
361
+ * enforcement downgrade performed by a command the user ran to stay current
362
+ * (mmnto-ai/totem#2753 fold F4). doctor already refuses to call a tier difference
363
+ * drift for exactly this reason (mmnto-ai/totem#2692 amendment A10); this is the
364
+ * writer-side half of that ruling.
365
+ */
366
+ function tierForHook(hooksDir, hookName, marker, endMarker, render) {
367
+ if (render.tierPinned === true)
368
+ return render.tier;
369
+ const hookPath = path.join(hooksDir, hookName);
370
+ let existing;
371
+ try {
372
+ existing = fs.readFileSync(hookPath, 'utf-8');
373
+ // totem-context: an unreadable/absent hook simply has no declared tier to honor — the caller falls back to the resolved default, which is the pre-#2753 behavior, never a crash of the install.
374
+ }
375
+ catch {
376
+ return render.tier;
377
+ }
378
+ return declaredHookTier(existing, marker, endMarker) ?? render.tier;
379
+ }
315
380
  /**
316
381
  * Build a POSIX shell block that resolves the totem command at runtime.
317
382
  *
@@ -455,10 +520,12 @@ fi
455
520
  * integration. These scripts contain the full guard logic (diff checks, null-SHA
456
521
  * guards) that bare inline commands would skip.
457
522
  *
458
- * Takes the RESOLVED {@link HookRenderOptions} rather than resolving config
523
+ * Takes the RESOLVED {@link ResolvedHookRenderOptions} rather than resolving config
459
524
  * itself: both callers already hold the one resolution for this invocation, and
460
525
  * a required parameter is the same compiler-enforced thread the builders use
461
- * (mmnto-ai/totem#2692 C1/C2).
526
+ * (mmnto-ai/totem#2692 C1/C2). `tierPinned` rides along so this path applies the
527
+ * SAME tier rule the git-hook writers do — a hook-manager repo is not a repo whose
528
+ * enforcement tier may be silently reset (mmnto-ai/totem#2753 fold 3 F2).
462
529
  */
463
530
  export function generateHookHelpers(gitRoot, render) {
464
531
  // Refuse BEFORE the mkdir: the helper dir is joined from the value, and a
@@ -469,12 +536,22 @@ export function generateHookHelpers(gitRoot, render) {
469
536
  fs.mkdirSync(hooksDir, { recursive: true });
470
537
  const postMerge = buildHookContent(render);
471
538
  const postCheckout = buildPostCheckoutHookContent(render);
472
- const preCommit = buildPreCommitHook(render);
473
- const prePush = buildPrePushHook(render);
474
- fs.writeFileSync(path.join(hooksDir, 'post-merge.sh'), postMerge, { mode: 0o755 });
475
- fs.writeFileSync(path.join(hooksDir, 'post-checkout.sh'), postCheckout, { mode: 0o755 });
476
- fs.writeFileSync(path.join(hooksDir, 'pre-commit.sh'), preCommit, { mode: 0o755 });
477
- fs.writeFileSync(path.join(hooksDir, 'pre-push.sh'), prePush, { mode: 0o755 });
539
+ // Only these two carry `TOTEM_HOOK_TIER`, so only these two can be downgraded.
540
+ // The declaration is read from the helper ALREADY on disk, exactly as the git-hook
541
+ // path reads it from the installed hook.
542
+ const preCommit = buildPreCommitHook({
543
+ ...render,
544
+ tier: tierForHook(hooksDir, 'pre-commit.sh', TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END, render),
545
+ });
546
+ const prePush = buildPrePushHook({
547
+ ...render,
548
+ tier: tierForHook(hooksDir, 'pre-push.sh', TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END, render),
549
+ });
550
+ // Atomic like every other git-hook write (mmnto-ai/totem#2760 round 1, leg F2).
551
+ writeExecutableHook(path.join(hooksDir, 'post-merge.sh'), postMerge);
552
+ writeExecutableHook(path.join(hooksDir, 'post-checkout.sh'), postCheckout);
553
+ writeExecutableHook(path.join(hooksDir, 'pre-commit.sh'), preCommit);
554
+ writeExecutableHook(path.join(hooksDir, 'pre-push.sh'), prePush);
478
555
  }
479
556
  function detectHookManager(cwd) {
480
557
  if (fs.existsSync(path.join(cwd, '.husky'))) {
@@ -599,31 +676,30 @@ export async function installPostMergeHook(cwd, rl, options) {
599
676
  const hookPath = path.join(hooksDir, 'post-merge');
600
677
  // Idempotency: check if already installed
601
678
  if (fs.existsSync(hookPath)) {
602
- const existing = fs.readFileSync(hookPath, 'utf-8');
679
+ // Raw bytes are the user's file; the decoded text serves the probes only.
680
+ const raw = fs.readFileSync(hookPath);
681
+ const existing = raw.toString('utf-8');
603
682
  if (existing.includes(TOTEM_HOOK_MARKER)) {
604
683
  console.log('[Totem] Post-merge hook already installed.');
605
684
  return;
606
685
  }
607
- // Append to existing hook — reuse buildHookContent, strip shebang
686
+ // Append to existing hook — reuse buildHookContent, strip shebang. Written as
687
+ // one atomic replacement of the whole file (the user's RAW bytes + ours) rather
688
+ // than an append: an interrupted append leaves a hook truncated mid-block,
689
+ // which git still runs (mmnto-ai/totem#2760 round 1, leg F2). The helper
690
+ // keeps the user's file mode.
608
691
  const separator = existing.endsWith('\n') ? '' : '\n';
609
692
  const appendBlock = buildHookContent(render)
610
693
  .replace(/^#!\/bin\/sh\n/, '')
611
694
  .trimStart();
612
- fs.appendFileSync(hookPath, separator + '\n' + appendBlock);
695
+ writeFileAtomicSync(hookPath, Buffer.concat([raw, Buffer.from(separator + '\n' + appendBlock, 'utf-8')]));
613
696
  console.log('[Totem] Appended post-merge hook to existing hook file.');
614
697
  return;
615
698
  }
616
- // Create new hook
699
+ // Create new hook — atomic, executable on POSIX, mode skipped on Windows by the
700
+ // helper's own boundary (git bash owns the bit there).
617
701
  fs.mkdirSync(hooksDir, { recursive: true });
618
- fs.writeFileSync(hookPath, buildHookContent(render));
619
- // Make executable (no-op on Windows, git bash handles it)
620
- try {
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.
623
- }
624
- catch {
625
- // chmod may fail on Windows — hooks still work via git bash
626
- }
702
+ writeExecutableHook(hookPath, buildHookContent(render));
627
703
  console.log('[Totem] Installed post-merge hook.');
628
704
  }
629
705
  // ─── Agent detection snippet (POSIX-compliant) ─────────
@@ -668,6 +744,22 @@ export function buildPreCommitHook(options) {
668
744
  // nothing. The record's sha256 is compared and REPORTED (matches / revised
669
745
  // since binding) but never blocks: blocking on revision would price every
670
746
  // fold of a design record at one LLM call, the friction this slice retires.
747
+ //
748
+ // #2737 fixes what that shape check MEASURED, on both halves. A body now ends
749
+ // only at a heading of the SAME OR SHALLOWER level: a deeper heading neither
750
+ // ends the body nor counts as one, so a section that opens with a `####`
751
+ // sub-heading is no longer read as empty (it was, in 3 of the 7 recorded R3
752
+ // drafts, on the longest section each of them wrote). And a promised heading
753
+ // is matched EXACTLY first, then — only if nothing matched — tolerantly, with
754
+ // ONE trailing parenthetical group stripped from BOTH sides: symmetric, so a
755
+ // dropped `(structural constraint)` and a differing `(required)` both match,
756
+ // and LEVEL-EXACT, because the `###` marker is part of the compared string
757
+ // (`## Problem Statement` never satisfies `### Problem Statement`). A
758
+ // tolerant match is never silent: the pass line carries `· tolerated
759
+ // <promised> ~ <found>` for each one, so the drift is disclosed on the commit
760
+ // that relied on it rather than absorbed. Trailing whitespace is not drift —
761
+ // `trimEnd()` settles it on the exact pass, and nothing is named.
762
+ //
671
763
  // Exit vocabulary: 0 evidence · 2 no spec artifact · 3 the newest spec
672
764
  // artifact is NOT evidence (reason on stdout) · anything else = the reader
673
765
  // itself could not run. The evidence line makes a stale pass VISIBLE (age
@@ -685,10 +777,32 @@ export function buildPreCommitHook(options) {
685
777
  // The artifact is a plain JSON file a seat can hand-edit, and its NAME comes
686
778
  // off the filesystem, so nothing echoed is trusted as text: EVERY value that
687
779
  // reaches stdout — the artifact's path, its `createdAt`, `anchor.kind`,
688
- // `anchor.ref` and each required heading passes through `safe()` first. A
780
+ // `anchor.ref`, `anchor.sha256`, the resolved realpath of a bound record,
781
+ // each required heading, and, on a tolerant match, the draft line it matched
782
+ // — passes through `safe()` first, except the two sha256 prefixes in
783
+ // `recordStatus`, which the preceding `/^[0-9a-f]{64}$/` block proves hex. A
689
784
  // 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.
785
+ // hook's own output; `safe()` collapses C0 (0x00–0x1f), the DEL/C1 band
786
+ // (0x7f–0x9f), and U+2028/U+2029, because U+0085 (NEL) breaks a line on some
787
+ // terminals and U+2028/U+2029 are line separators for the same purpose.
788
+ //
789
+ // `safe()` is necessary but NOT sufficient, because it cannot see the attack
790
+ // that lives in PRINTABLE bytes (mmnto-ai/totem#2737 fold 3). A literal
791
+ // backslash followed by `n` is two printable characters, so it passes
792
+ // `safe()` untouched — and the hole was open wherever `/bin/sh` EXPANDS
793
+ // backslash escapes in `echo`: `dash`, which is `/bin/sh` on Debian and
794
+ // Ubuntu, and macOS's own `/bin/sh`, a bash built with `xpg_echo` on. On
795
+ // those the pair becomes a real newline at the shell and forges the second
796
+ // `[Totem]` line (`\\c` truncates the line instead, swallowing the cure text
797
+ // that follows). Only Git Bash and a plain bash leave it inert, so the hole
798
+ // was invisible in exactly the shells a seat develops in. The two
799
+ // sinks that echo an untrusted value — the evidence line and the BLOCKED
800
+ // reason, both carrying `$spec_evidence` — therefore print through
801
+ // `printf '%s\\n'`, which is defined to treat its ARGUMENT as literal text on
802
+ // every POSIX shell. The remaining echoes in this block carry only
803
+ // `$reader_status` (an integer from `$?`) and the render-time `runsDir`
804
+ // (validated by `assertRenderableTotemDir`, which refuses a backslash), so
805
+ // neither can carry the payload.
692
806
  // Containment is decided by RESOLUTION, not by inspecting one segment, and
693
807
  // it is decided TWICE. Lexically first: a `record` ref that is absolute
694
808
  // (either path flavor) or whose `path.resolve` against `process.cwd()` — the
@@ -711,8 +825,10 @@ export function buildPreCommitHook(options) {
711
825
  # top-level admission.runMetadata.caller of "spec"), read JSON-aware — a
712
826
  # substring match would accept a review artifact that merely quotes the key —
713
827
  # 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
828
+ # carries a real shape: every promised heading (level-exact; a trailing
829
+ # parenthetical may differ or be dropped, and the evidence line names it) each
830
+ # with a non-blank body before the next heading of the same or shallower level
831
+ # (an issue run drafted by the built-in prompt), or at least one heading with a body (a
716
832
  # record run, or an issue run drafted under a custom prompt). A record run is
717
833
  # judged on the bytes of the record at grounding.anchor.ref, re-read here from
718
834
  # the worktree top; its sha256 is REPORTED, never enforced.
@@ -750,19 +866,26 @@ function safe(text) {
750
866
  let out = "";
751
867
  for (let i = 0; i < text.length; i++) {
752
868
  const code = text.charCodeAt(i);
753
- const control = code < 32 || (code >= 127 && code <= 159);
869
+ const control = code < 32 || (code >= 127 && code <= 159) || [8232, 8233].indexOf(code) > -1;
754
870
  out = out + (control ? "?" : text.charAt(i));
755
871
  }
756
872
  return out;
757
873
  }
758
874
  const shownFile = safe(file);
759
875
  const shownAt = safe(best.at);
760
- function isHeading(line) {
876
+ function headingLevel(line) {
761
877
  let n = 0;
762
878
  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;
879
+ if (n < 1 || n > 6) return 0;
880
+ if ([" ", "\\t"].indexOf(line.charAt(n)) < 0) return 0;
881
+ return line.slice(n + 1).trim().length > 0 ? n : 0;
882
+ }
883
+ function stripParen(s) {
884
+ const t = s.trimEnd();
885
+ if (t.charAt(t.length - 1) !== ")") return t;
886
+ const open = t.lastIndexOf("(");
887
+ if (open < 1) return t;
888
+ return t.slice(0, open).trimEnd();
766
889
  }
767
890
  function escapesTop(rel) {
768
891
  const norm = rel.split("\\\\").join("/");
@@ -814,9 +937,12 @@ if (kind !== KIND_RECORD) {
814
937
  }
815
938
  if ([65279].indexOf(subject.charCodeAt(0)) > -1) subject = subject.slice(1);
816
939
  const lines = subject.split("\\n");
817
- function hasBodyAfter(start) {
940
+ const tolerated = [];
941
+ function hasBodyAfter(start, level) {
818
942
  for (let i = start + 1; i < lines.length; i++) {
819
- if (isHeading(lines[i])) return false;
943
+ const n = headingLevel(lines[i]);
944
+ if (n > 0 && n <= level) return false;
945
+ if (n > 0) continue;
820
946
  if (lines[i].trim().length > 0) return true;
821
947
  }
822
948
  return false;
@@ -825,12 +951,19 @@ if (shape !== "DOCUMENT") {
825
951
  for (const heading of REQUIRED) {
826
952
  let at = -1;
827
953
  for (let i = 0; i < lines.length; i++) { if ([heading].indexOf(lines[i].trimEnd()) > -1) { at = i; break; } }
954
+ let matchedAs = "";
955
+ if (at < 0) {
956
+ const want = stripParen(heading);
957
+ for (let i = 0; i < lines.length; i++) { if ([want].indexOf(stripParen(lines[i].trimEnd())) > -1) { at = i; break; } }
958
+ if (at > -1) { matchedAs = safe(lines[at].trimEnd()); tolerated.push(safe(heading) + " ~ " + matchedAs); }
959
+ }
828
960
  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));
961
+ const shownHeading = safe(heading) + (matchedAs.length > 0 ? " (matched as " + matchedAs + ")" : "");
962
+ if (!hasBodyAfter(at, headingLevel(lines[at]))) block("the draft in " + shownFile + " has an empty heading " + shownHeading);
830
963
  }
831
964
  } else {
832
965
  let bodied = false;
833
- for (let i = 0; i < lines.length; i++) { if (isHeading(lines[i]) && hasBodyAfter(i)) { bodied = true; break; } }
966
+ for (let i = 0; i < lines.length; i++) { const n = headingLevel(lines[i]); if (n > 0 && hasBodyAfter(i, n)) { bodied = true; break; } }
834
967
  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
968
  if (!bodied) block("the bound record at " + shownRef + " has no heading with a body");
836
969
  }
@@ -838,6 +971,7 @@ const stamp = best.at ? Date.parse(best.at) : NaN;
838
971
  const days = Number.isNaN(stamp) ? -1 : Math.floor((Date.now() - stamp) / 86400000);
839
972
  let out = shownFile + " (" + (shownAt || "undated") + (days >= 0 ? ", " + days + " days old" : "") + ")";
840
973
  out = out + " · anchor " + shownKind + " " + shownRef + " · shape " + shape;
974
+ if (tolerated.length > 0) out = out + " · tolerated " + tolerated.join("; ");
841
975
  if (recordStatus.length > 0) out = out + " · " + recordStatus;
842
976
  emit(out);
843
977
  ' 2>/dev/null)
@@ -847,9 +981,9 @@ emit(out);
847
981
  # each reported distinctly, never as "no evidence", and all fail-closed.
848
982
  reader_status=$?
849
983
  if [ "$reader_status" = "0" ] && [ -n "$spec_evidence" ]; then
850
- echo "[Totem] spec evidence: $spec_evidence"
984
+ printf '%s\\n' "[Totem] spec evidence: $spec_evidence"
851
985
  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)"
986
+ printf '%s\\n' "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"
853
987
  exit 1
854
988
  elif [ "$reader_status" != "2" ]; then
855
989
  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)"
@@ -1073,16 +1207,25 @@ const OWNED_WHOLE_FILE_PREAMBLE_RE = /^#![^\n]*\n#[ \t]*$/;
1073
1207
  /** POSIX executable mode for git hooks (rwxr-xr-x). */
1074
1208
  const HOOK_EXECUTABLE_MODE = 0o755;
1075
1209
  /**
1076
- * Write a hook file and mark it executable. On POSIX the chmod failure propagates
1077
- * (Tenet 4 a hook git cannot execute must fail loud, never silently report
1078
- * `installed`). On Windows the exec bit is skipped explicitly: git-bash owns the
1079
- * executable bit there, and NTFS has no POSIX mode to set.
1210
+ * Write a hook file and mark it executable ATOMICALLY (core's
1211
+ * `writeFileAtomicSync`, the Tenet 4 user-file mutation helper, mmnto-ai/totem#2620):
1212
+ * the bytes land in a same-directory temp, the mode is applied to the temp, and
1213
+ * the rename comes last, so an interrupted install leaves the old hook or the new
1214
+ * one and never a truncated file. That matters most on the attested-extension
1215
+ * rewrite (mmnto-ai/totem#2753): the trailer is the consumer's own lines, which no
1216
+ * template can regenerate (Greptile P1, mmnto-ai/totem#2760 round 1).
1217
+ *
1218
+ * On POSIX a mode failure propagates from the helper (a hook git cannot execute
1219
+ * must fail loud, never silently report `installed`). On Windows the exec bit is
1220
+ * skipped by the helper's own boundary: git-bash owns the executable bit there,
1221
+ * and NTFS has no POSIX mode to set. Symlinked hooks keep their link identity
1222
+ * (the helper writes through to the real path). A DANGLING symlinked hook is the
1223
+ * one case the old in-place write handled differently: `fs.writeFileSync` followed
1224
+ * the link and created its target, the helper throws ENOENT and leaves the link
1225
+ * untouched — remove or re-point the link first. Declared, not defended.
1080
1226
  */
1081
1227
  function writeExecutableHook(hookPath, content) {
1082
- fs.writeFileSync(hookPath, content);
1083
- if (process.platform !== 'win32') {
1084
- fs.chmodSync(hookPath, HOOK_EXECUTABLE_MODE);
1085
- }
1228
+ writeFileAtomicSync(hookPath, content, { mode: HOOK_EXECUTABLE_MODE });
1086
1229
  }
1087
1230
  /**
1088
1231
  * Whether an existing hook is a totem-OWNED whole file (generated verbatim by a
@@ -1104,21 +1247,153 @@ function writeExecutableHook(hookPath, content) {
1104
1247
  * whitespace may follow the end marker).
1105
1248
  */
1106
1249
  export function isTotemOwnedWholeFile(content, marker, endMarker) {
1250
+ const trailerStart = ownedTrailerStart(content, marker, endMarker);
1251
+ if (trailerStart === undefined)
1252
+ return false;
1253
+ return content.slice(trailerStart).trim().length === 0;
1254
+ }
1255
+ /**
1256
+ * The offset just past the totem end marker — where a trailer would begin — for a
1257
+ * hook whose managed region OPENS the file and is BOUNDED. `undefined` when either
1258
+ * rule fails: no start marker, user content before it (beyond a shebang + the start
1259
+ * of the marker comment), or no end marker after it (the legacy-hook path).
1260
+ *
1261
+ * The one shared prefix/bound rule behind {@link isTotemOwnedWholeFile} and
1262
+ * {@link isTotemOwnedWithAttestedTrailer} — the two differ ONLY in what they
1263
+ * accept after this offset (mmnto-ai/totem#2753).
1264
+ */
1265
+ function ownedTrailerStart(content, marker, endMarker) {
1107
1266
  const idx = content.indexOf(marker);
1108
1267
  if (idx === -1)
1109
- return false;
1268
+ return undefined;
1110
1269
  const before = content.slice(0, idx);
1111
1270
  if (before.trim().length !== 0 && !OWNED_WHOLE_FILE_PREAMBLE_RE.test(before)) {
1112
- return false;
1271
+ return undefined;
1113
1272
  }
1114
1273
  const end = content.indexOf(endMarker, idx + marker.length);
1115
1274
  // Start marker present but end marker missing → region cannot be bounded →
1116
- // not safe to whole-file overwrite without --force (also the legacy-hook path).
1275
+ // not safe to rewrite without --force (also the legacy-hook path).
1117
1276
  if (end === -1)
1277
+ return undefined;
1278
+ return end + endMarker.length;
1279
+ }
1280
+ /**
1281
+ * {@link ownedTrailerStart} as a BYTE offset into the raw file — the offset the
1282
+ * block-rewrite arm slices the trailer at — or `undefined` when the managed region
1283
+ * (start of file through the end marker) does not decode as UTF-8 losslessly.
1284
+ *
1285
+ * The string offset converts to a byte offset only if the region's re-encoded text
1286
+ * equals its raw bytes; totem wrote the region, so it does, and the equality check
1287
+ * PROVES it rather than assuming it. A region that fails it is not totem's text any
1288
+ * more — an ANSI-editor save that turned the template's em dash into one `0x97`
1289
+ * byte, say — so the installer reports that shape (`skipped-non-utf8`) and doctor
1290
+ * classifies it (`non-utf8`) instead of either guessing an offset or prescribing a
1291
+ * bare install that would decline (mmnto-ai/totem#2760 legs F9 and F13). The
1292
+ * trailer's own bytes are never decoded by anything that writes them back.
1293
+ */
1294
+ export function ownedTrailerByteStart(raw, marker, endMarker) {
1295
+ const existing = raw.toString('utf-8');
1296
+ const trailerStart = ownedTrailerStart(existing, marker, endMarker);
1297
+ if (trailerStart === undefined)
1298
+ return undefined;
1299
+ const prefixBytes = Buffer.from(existing.slice(0, trailerStart), 'utf-8');
1300
+ return raw.subarray(0, prefixBytes.length).equals(prefixBytes) ? prefixBytes.length : undefined;
1301
+ }
1302
+ /**
1303
+ * A trailer (the text after a managed hook's end marker) is ATTESTED when its
1304
+ * LEADING COMMENT RUN carries a full fork attestation — reason, owner and attested
1305
+ * all present and non-empty AFTER TRIMMING; a whitespace-only value does not attest
1306
+ * (mmnto-ai/totem#2753; the trim from mmnto-ai/totem#2760 round 1).
1307
+ *
1308
+ * The leading comment run is every line up to the first line that is neither blank
1309
+ * nor a shell comment — i.e. up to the extension's first COMMAND. Blank lines inside
1310
+ * the run are skipped. The attestation is core's `<!-- totem:fork … -->` marker
1311
+ * (`parseForkMarker`), the same shape the parity detector reads, on a comment line:
1312
+ *
1313
+ * (blank)
1314
+ * # [lc] docs-inject extension
1315
+ * # <!-- totem:fork reason="…" owner="satur8d" attested="2026-06-07" -->
1316
+ * sh "tools/git-hooks/pre-commit-docs-inject.sh"
1317
+ *
1318
+ * The run, not the first line: a real consumer labels its block before it signs it.
1319
+ * That is the measured liquid-city shape — `tools/git-hooks/install.cjs` emits a
1320
+ * `# [lc] <name> extension` line FIRST and the fork marker SECOND — and a
1321
+ * first-line-only rule declined the very datum this slice was built from
1322
+ * (mmnto-ai/liquid-city#1174).
1323
+ *
1324
+ * Two things do NOT attest, and both matter:
1325
+ * - A marker below the first command. An attestation buried under code vouches
1326
+ * for nothing above it, so the run ends at that command.
1327
+ * - A marker on a NON-comment line. `rm -rf / # <!-- totem:fork … -->` is a
1328
+ * command, not a signature; only a line whose trimmed text STARTS with `#` can
1329
+ * carry one.
1330
+ *
1331
+ * The marker must also sit on ONE line: `parseForkMarker` is applied per line here,
1332
+ * so core's multi-line (dotAll) form of the marker is deliberately not in play.
1333
+ *
1334
+ * A BARE `totem:fork` marker — or one missing any of the three fields — is not
1335
+ * attested either. That asymmetry with the parity detector (where a bare marker is
1336
+ * enough to CLAIM a fork) is deliberate: carrying a consumer's lines through a
1337
+ * managed-block rewrite is a maintenance promise, and a promise needs a name, a
1338
+ * reason and a date.
1339
+ */
1340
+ export function isAttestedTrailer(trailer) {
1341
+ for (const line of trailer.split('\n')) {
1342
+ const trimmed = line.trim();
1343
+ // Blank lines sit inside the run — the measured shape opens with one.
1344
+ if (trimmed.length === 0)
1345
+ continue;
1346
+ // The first command ends the run: nothing below it can vouch for it.
1347
+ if (!trimmed.startsWith('#'))
1348
+ return false;
1349
+ const fork = parseForkMarker(line);
1350
+ // Trimmed: core's parser captures the quoted value raw, so `reason=" "` would
1351
+ // otherwise pass a length check — a promise with no name is not a promise
1352
+ // (Greptile P2, mmnto-ai/totem#2760 round 1).
1353
+ if (fork !== undefined &&
1354
+ typeof fork.reason === 'string' &&
1355
+ fork.reason.trim().length > 0 &&
1356
+ typeof fork.owner === 'string' &&
1357
+ fork.owner.trim().length > 0 &&
1358
+ typeof fork.attested === 'string' &&
1359
+ fork.attested.trim().length > 0) {
1360
+ return true;
1361
+ }
1362
+ }
1363
+ // Blank/whitespace-only, or a comment run with no full marker in it.
1364
+ return false;
1365
+ }
1366
+ /**
1367
+ * The mmnto-ai/totem#2406 owned-whole-file shape with ONE relaxation: the trailer may
1368
+ * be non-blank if it is attested ({@link isAttestedTrailer}).
1369
+ *
1370
+ * The precondition for the in-place managed-block rewrite: totem still owns
1371
+ * everything from the top of the file through the end marker, and what follows it is
1372
+ * a consumer extension that named itself. Everything before the end marker is
1373
+ * regenerated; everything after it is carried through byte-for-byte.
1374
+ */
1375
+ export function isTotemOwnedWithAttestedTrailer(content, marker, endMarker) {
1376
+ const trailerStart = ownedTrailerStart(content, marker, endMarker);
1377
+ if (trailerStart === undefined)
1118
1378
  return false;
1119
- if (content.slice(end + endMarker.length).trim().length !== 0)
1120
- return false;
1121
- return true;
1379
+ return isAttestedTrailer(content.slice(trailerStart));
1380
+ }
1381
+ /**
1382
+ * The trailer as it must be re-attached after a regenerated managed block: the BYTES
1383
+ * after `endMarker` with exactly ONE leading line terminator (`\r\n` or `\n`)
1384
+ * removed. The canonical hook text already ends with the end marker's own
1385
+ * terminator, so re-attaching the raw slice would duplicate it (the `upgradeReflexes`
1386
+ * seam precedent in init.ts). Everything past that one terminator is untouched —
1387
+ * and never decoded: the trailer is the consumer's own file, and a byte that does
1388
+ * not round-trip UTF-8 must come back as itself (mmnto-ai/totem#2760 leg F9).
1389
+ */
1390
+ function trailerTailAfterEndMarker(raw, trailerStart) {
1391
+ const trailer = raw.subarray(trailerStart);
1392
+ if (trailer[0] === 0x0d && trailer[1] === 0x0a)
1393
+ return trailer.subarray(2);
1394
+ if (trailer[0] === 0x0a)
1395
+ return trailer.subarray(1);
1396
+ return trailer;
1122
1397
  }
1123
1398
  /**
1124
1399
  * Install a single git hook with idempotency and chain preservation.
@@ -1134,11 +1409,28 @@ export function isTotemOwnedWholeFile(content, marker, endMarker) {
1134
1409
  * templates now emit one. Drift-repair fires only when the caller threads the end
1135
1410
  * marker AND the on-disk hook carries it — a legacy pre-end-marker hook declines to
1136
1411
  * `exists` and takes one `totem hook install --force`.
1412
+ *
1413
+ * Since mmnto-ai/totem#2753 a THIRD arm sits between drift-repair and the decline: a
1414
+ * file totem owns through its end marker whose trailer is an ATTESTED `totem:fork`
1415
+ * extension ({@link isTotemOwnedWithAttestedTrailer}) has its managed block rewritten
1416
+ * IN PLACE (`block-rewritten`) — the canonical text plus the existing trailer,
1417
+ * byte-identical past the seam. That is the liquid-city shape: a consumer appending
1418
+ * its own blocks after totem's end marker never received a managed-hook upgrade
1419
+ * through bare `totem init` (measured at `@mmnto/cli` 1.123.0,
1420
+ * mmnto-ai/liquid-city#1174). An UNATTESTED trailer still declines to `exists`,
1421
+ * unchanged. `--force` is untouched by all of this: it overwrites the WHOLE file,
1422
+ * trailer included.
1137
1423
  */
1138
1424
  export function installGitHook(hooksDir, hookName, hookContent, marker, force, endMarker) {
1139
1425
  const hookPath = path.join(hooksDir, hookName);
1140
1426
  if (fs.existsSync(hookPath)) {
1141
- const existing = fs.readFileSync(hookPath, 'utf-8');
1427
+ // Raw bytes are the user's file; the decoded text serves the PROBES only
1428
+ // (markers, shebang, terminator). Every write below that carries the user's
1429
+ // content carries it as BYTES — a hook that does not round-trip UTF-8 must
1430
+ // never come back with U+FFFD where its bytes were (the mmnto-ai/totem#2620
1431
+ // eject ruling, re-learned on mmnto-ai/totem#2760 leg F8).
1432
+ const raw = fs.readFileSync(hookPath);
1433
+ const existing = raw.toString('utf-8');
1142
1434
  if (existing.includes(marker)) {
1143
1435
  if (force) {
1144
1436
  // Force overwrite — replace the entire hook with the new content
@@ -1157,6 +1449,34 @@ export function installGitHook(hooksDir, hookName, hookContent, marker, force, e
1157
1449
  writeExecutableHook(hookPath, hookContent);
1158
1450
  return 'overwritten';
1159
1451
  }
1452
+ // In-place managed-block rewrite (mmnto-ai/totem#2753): totem owns the file
1453
+ // through its end marker and what follows is an ATTESTED `totem:fork`
1454
+ // extension. Regenerate the block, carry the trailer through byte-for-byte.
1455
+ // The currency compare here is the RECOMPOSED file, not the whole existing
1456
+ // one — an attested-trailer hook whose block already equals the canonical is
1457
+ // current (`exists`, no write), which is what makes a second bare run a no-op.
1458
+ if (endMarker !== undefined && isTotemOwnedWithAttestedTrailer(existing, marker, endMarker)) {
1459
+ // "Byte-for-byte" is literal: the trailer is sliced from the RAW file at the
1460
+ // byte offset `ownedTrailerByteStart` PROVES — the region above the
1461
+ // extension (shebang line and managed block) re-encodes to its own bytes.
1462
+ // That is the ONE marker scan, shared with doctor (fold F11's rule, one
1463
+ // implementation); the predicate above ran it too, and a second run on the
1464
+ // same string is cheaper than a second implementation. A region that does
1465
+ // not round-trip is not ours to rewrite — and not something to stay silent
1466
+ // about: the skip is REPORTED, and doctor senses the same shape with the
1467
+ // same predicate (legs F13, F16). The trailer's bytes are never decoded.
1468
+ const trailerByteStart = ownedTrailerByteStart(raw, marker, endMarker);
1469
+ if (trailerByteStart === undefined)
1470
+ return 'skipped-non-utf8';
1471
+ const rewritten = Buffer.concat([
1472
+ Buffer.from(hookContent, 'utf-8'),
1473
+ trailerTailAfterEndMarker(raw, trailerByteStart),
1474
+ ]);
1475
+ if (rewritten.equals(raw))
1476
+ return 'exists';
1477
+ writeExecutableHook(hookPath, rewritten);
1478
+ return 'block-rewritten';
1479
+ }
1160
1480
  return 'exists';
1161
1481
  }
1162
1482
  // Guard: do not append bash syntax to non-shell hooks (Node, Python, etc.)
@@ -1164,12 +1484,16 @@ export function installGitHook(hooksDir, hookName, hookContent, marker, force, e
1164
1484
  if (firstLine.startsWith('#!') && !SHELL_SHEBANG_RE.test(firstLine)) {
1165
1485
  return 'skipped-non-shell';
1166
1486
  }
1167
- // Append to existing hook — preserve user's existing hooks
1487
+ // Append to existing hook — preserve user's existing hooks. One atomic
1488
+ // replacement of the whole file (their RAW bytes + ours), not an append: an
1489
+ // interrupted append leaves a hook truncated mid-block, which git still
1490
+ // runs (mmnto-ai/totem#2760 round 1, leg F2). The helper keeps the user's
1491
+ // file mode and writes through a symlink to its real path.
1168
1492
  const separator = existing.endsWith('\n') ? '\n' : '\n\n';
1169
1493
  const appendBlock = hookContent
1170
1494
  .replace(/^#!\/bin\/sh\n/, '') // Strip shebang when appending
1171
1495
  .trimStart();
1172
- fs.appendFileSync(hookPath, separator + appendBlock);
1496
+ writeFileAtomicSync(hookPath, Buffer.concat([raw, Buffer.from(separator + appendBlock, 'utf-8')]));
1173
1497
  return 'appended';
1174
1498
  }
1175
1499
  // Create new hook
@@ -1223,8 +1547,17 @@ export async function installEnforcementHooks(cwd, rl, options) {
1223
1547
  // while every hook writer resolves at the git root: a pre-existing split this
1224
1548
  // slice names and does not close.
1225
1549
  const render = await resolveHookRenderOptions(gitRoot, { tier: options?.tier });
1226
- const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(render), TOTEM_PRECOMMIT_MARKER, undefined, TOTEM_PRECOMMIT_END);
1227
- const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(render), TOTEM_PREPUSH_MARKER, undefined, TOTEM_PREPUSH_END);
1550
+ // Render each hook at the tier it is entitled to keep (mmnto-ai/totem#2753 fold
1551
+ // F4): nothing pinned + an installed `--strict` hook → strict, not a silent
1552
+ // downgrade to standard.
1553
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook({
1554
+ ...render,
1555
+ tier: tierForHook(hooksDir, 'pre-commit', TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END, render),
1556
+ }), TOTEM_PRECOMMIT_MARKER, undefined, TOTEM_PRECOMMIT_END);
1557
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook({
1558
+ ...render,
1559
+ tier: tierForHook(hooksDir, 'pre-push', TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END, render),
1560
+ }), TOTEM_PREPUSH_MARKER, undefined, TOTEM_PREPUSH_END);
1228
1561
  // Warn about non-shell hooks that Totem cannot safely append to
1229
1562
  if (preCommit === 'skipped-non-shell') {
1230
1563
  console.error('[Totem] Warning: pre-commit hook uses a non-shell interpreter. Manually integrate branch protection into your existing hook.');
@@ -1232,6 +1565,15 @@ export async function installEnforcementHooks(cwd, rl, options) {
1232
1565
  if (prePush === 'skipped-non-shell') {
1233
1566
  console.error('[Totem] Warning: pre-push hook uses a non-shell interpreter. Manually add: totem lint');
1234
1567
  }
1568
+ // A skip with its reason, never a silent "already installed" (mmnto-ai/totem#2760 leg F13).
1569
+ for (const [name, action] of [
1570
+ ['pre-commit', preCommit],
1571
+ ['pre-push', prePush],
1572
+ ]) {
1573
+ if (action === 'skipped-non-utf8') {
1574
+ console.error(`[Totem] Skipped ${name} hook: the region above its extension (shebang line and managed block) does not decode as UTF-8, so it was left byte-identical. Re-save the hook as UTF-8 and re-run, or take \`totem hook install --force\` (rewrites the whole file and drops your extension).`);
1575
+ }
1576
+ }
1235
1577
  return { preCommit, prePush };
1236
1578
  }
1237
1579
  export async function installHooksCommand() {
@@ -1293,8 +1635,17 @@ export async function installHooksNonInteractive(cwd, force, options) {
1293
1635
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
1294
1636
  return null;
1295
1637
  }
1296
- const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(render), TOTEM_PRECOMMIT_MARKER, force, TOTEM_PRECOMMIT_END);
1297
- const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook(render), TOTEM_PREPUSH_MARKER, force, TOTEM_PREPUSH_END);
1638
+ // Same entitlement rule as the init path (mmnto-ai/totem#2753 fold F4) and it
1639
+ // has to hold HERE above all, because `totem hook install` is the bare command
1640
+ // the doctor's own stale-block remedy sends people to.
1641
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook({
1642
+ ...render,
1643
+ tier: tierForHook(hooksDir, 'pre-commit', TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END, render),
1644
+ }), TOTEM_PRECOMMIT_MARKER, force, TOTEM_PRECOMMIT_END);
1645
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook({
1646
+ ...render,
1647
+ tier: tierForHook(hooksDir, 'pre-push', TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END, render),
1648
+ }), TOTEM_PREPUSH_MARKER, force, TOTEM_PREPUSH_END);
1298
1649
  const postMergeContent = buildHookContent(render);
1299
1650
  const postMerge = installGitHook(hooksDir, 'post-merge', postMergeContent, TOTEM_HOOK_MARKER, force, TOTEM_HOOK_END);
1300
1651
  const postCheckoutContent = buildPostCheckoutHookContent(render);
@@ -1411,9 +1762,21 @@ export async function hooksCommand(opts) {
1411
1762
  ? `[Totem] Force-overwritten ${name} hook.`
1412
1763
  : `[Totem] Drift-repaired ${name} hook (totem-owned bounded region).`);
1413
1764
  break;
1765
+ case 'block-rewritten':
1766
+ // Distinct from the whole-file line above: this write REGENERATED the
1767
+ // managed block and left everything after the end marker alone. Saying so
1768
+ // is the point — a consumer that extends its hooks needs to read, from the
1769
+ // summary, that its extension survived (mmnto-ai/totem#2753).
1770
+ console.error(`[Totem] Drift-repaired ${name} hook (managed block rewritten in place; the attested extension after its end marker carried through unchanged).`);
1771
+ break;
1414
1772
  case 'skipped-non-shell':
1415
1773
  console.error(`[Totem] Warning: ${name} hook uses a non-shell interpreter. Integrate manually.`);
1416
1774
  break;
1775
+ case 'skipped-non-utf8':
1776
+ // The eject precedent (mmnto-ai/totem#2620): a skip is reported with its
1777
+ // reason and the file is left byte-identical — never "already installed".
1778
+ console.error(`[Totem] Skipped ${name} hook: the region above its extension (shebang line and managed block) does not decode as UTF-8, so it was left byte-identical. Re-save the hook as UTF-8 and re-run, or take --force (rewrites the whole file and drops your extension).`);
1779
+ break;
1417
1780
  }
1418
1781
  }
1419
1782
  }
@@ -1753,10 +2116,21 @@ export async function upgradePrePushHookIfNeeded(cwd) {
1753
2116
  const hookPath = path.join(hooksDir, 'pre-push');
1754
2117
  if (!fs.existsSync(hookPath))
1755
2118
  return false;
1756
- const content = fs.readFileSync(hookPath, 'utf-8');
1757
- // Only upgrade hooks that Totem owns (have our marker)
2119
+ const rawContent = fs.readFileSync(hookPath);
2120
+ const content = rawContent.toString('utf-8');
2121
+ // Only upgrade hooks that Totem owns (have our marker) — block presence FIRST,
2122
+ // the order eject.ts ruled, so the two sites read alike even though this one
2123
+ // returns a bare `false` either way.
1758
2124
  if (!content.includes(TOTEM_PREPUSH_MARKER))
1759
2125
  return false;
2126
+ // The splice below is text on both sides of the block, so it is byte-exact
2127
+ // only when the whole file decoded losslessly. A hook that does not
2128
+ // round-trip UTF-8 is declined here — this upgrader's ruled posture is a
2129
+ // silent `false` (mmnto-ai/totem#2692 N4), and declining beats writing U+FFFD
2130
+ // over a user's bytes (mmnto-ai/totem#2620's eject ruling, mmnto-ai/totem#2760
2131
+ // leg F9). Such a hook keeps its old block and takes `totem hook install --force`.
2132
+ if (!Buffer.from(content, 'utf-8').equals(rawContent))
2133
+ return false;
1760
2134
  // Already on the new stateless format — no upgrade needed.
1761
2135
  // SAFETY INVARIANT: old hooks (pre-verify-manifest) have a single top-level
1762
2136
  // if/fi block and no agent detection. The parser below relies on this — it
@@ -1799,6 +2173,7 @@ export async function upgradePrePushHookIfNeeded(cwd) {
1799
2173
  if (endOffset === -1)
1800
2174
  return false;
1801
2175
  const blockEnd = markerIdx + endOffset;
2176
+ // totem-context: mmnto-ai/totem#2753 — this upgrader is unreachable for any hook carrying TOTEM_HOOK_TIER (the verify-manifest guard above skips every current template), so it renders from config alone; if that guard ever changes, route through tierForHook.
1802
2177
  const render = await resolveHookRenderOptions(gitRoot);
1803
2178
  // Build the replacement block (strip shebang — we're splicing into existing file)
1804
2179
  const newBlock = buildPrePushHook(render)
@@ -1808,13 +2183,10 @@ export async function upgradePrePushHookIfNeeded(cwd) {
1808
2183
  const before = content.slice(0, markerIdx);
1809
2184
  const after = content.slice(blockEnd);
1810
2185
  const upgraded = before + newBlock.trimEnd() + after;
1811
- fs.writeFileSync(hookPath, upgraded);
1812
- try {
1813
- fs.chmodSync(hookPath, 0o755);
1814
- }
1815
- catch {
1816
- // chmod may fail on Windows — hooks still work via git bash
1817
- }
2186
+ // The splice keeps the user's lines on BOTH sides of the block — the exact
2187
+ // shape Greptile P1 named on the attested-extension arm — so it takes the same
2188
+ // atomic, executable write (mmnto-ai/totem#2760 round 1, leg F4).
2189
+ writeExecutableHook(hookPath, upgraded);
1818
2190
  return true;
1819
2191
  }
1820
2192
  catch {