@mmnto/cli 1.124.0 → 2.1.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 (58) hide show
  1. package/dist/commands/describe.d.ts +13 -1
  2. package/dist/commands/describe.d.ts.map +1 -1
  3. package/dist/commands/describe.js +29 -1
  4. package/dist/commands/describe.js.map +1 -1
  5. package/dist/commands/describe.test.js +86 -3
  6. package/dist/commands/describe.test.js.map +1 -1
  7. package/dist/commands/doctor.d.ts +16 -10
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +162 -54
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/doctor.test.js +444 -28
  12. package/dist/commands/doctor.test.js.map +1 -1
  13. package/dist/commands/hook-totemdir-render.test.js +55 -1
  14. package/dist/commands/hook-totemdir-render.test.js.map +1 -1
  15. package/dist/commands/init-templates.d.ts +2 -2
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +2 -2
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +39 -0
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/init.test.js +32 -0
  22. package/dist/commands/init.test.js.map +1 -1
  23. package/dist/commands/install-hooks-exit-contract.test.js +32 -1
  24. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  25. package/dist/commands/install-hooks.d.ts +143 -10
  26. package/dist/commands/install-hooks.d.ts.map +1 -1
  27. package/dist/commands/install-hooks.js +596 -92
  28. package/dist/commands/install-hooks.js.map +1 -1
  29. package/dist/commands/install-hooks.test.js +1535 -19
  30. package/dist/commands/install-hooks.test.js.map +1 -1
  31. package/dist/commands/legs.d.ts +35 -7
  32. package/dist/commands/legs.d.ts.map +1 -1
  33. package/dist/commands/legs.js +26 -7
  34. package/dist/commands/legs.js.map +1 -1
  35. package/dist/commands/legs.test.js +87 -0
  36. package/dist/commands/legs.test.js.map +1 -1
  37. package/dist/commands/release-train-shape.test.d.ts +2 -0
  38. package/dist/commands/release-train-shape.test.d.ts.map +1 -0
  39. package/dist/commands/release-train-shape.test.js +226 -0
  40. package/dist/commands/release-train-shape.test.js.map +1 -0
  41. package/dist/commands/spec-templates.d.ts +17 -4
  42. package/dist/commands/spec-templates.d.ts.map +1 -1
  43. package/dist/commands/spec-templates.js +23 -3
  44. package/dist/commands/spec-templates.js.map +1 -1
  45. package/dist/commands/spec.d.ts +57 -8
  46. package/dist/commands/spec.d.ts.map +1 -1
  47. package/dist/commands/spec.js +124 -28
  48. package/dist/commands/spec.js.map +1 -1
  49. package/dist/commands/spec.test.js +277 -44
  50. package/dist/commands/spec.test.js.map +1 -1
  51. package/dist/commands/tools-hook-parity.test.js +200 -0
  52. package/dist/commands/tools-hook-parity.test.js.map +1 -1
  53. package/dist/utils.d.ts +5 -3
  54. package/dist/utils.d.ts.map +1 -1
  55. package/dist/utils.js.map +1 -1
  56. package/dist/utils.test.js +8 -3
  57. package/dist/utils.test.js.map +1 -1
  58. 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,23 +937,144 @@ 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
+ // Fences (mmnto-ai/totem#2769), by the CommonMark rule: a line indented at most
942
+ // three spaces whose first run is three or more backticks or tildes OPENS a
943
+ // fence — a backtick run only when the rest of the line carries no backtick —
944
+ // and a CLOSER is the same character, at least as long, with nothing else on
945
+ // the line; an unclosed fence runs to the end of the draft. A delimiter line
946
+ // is neither a heading, a match candidate, nor body. A line inside a fence is
947
+ // neither a heading nor a match candidate — a promised heading quoted there
948
+ // (the skeleton pasted as an example) is not the section it names, and is not
949
+ // body for the section above it either — but any other non-blank fenced line
950
+ // IS body: a section whose content is one code block is a written section.
951
+ const TICK = String.fromCharCode(96);
952
+ const fenceLine = [];
953
+ const inFence = [];
954
+ const fenceOpenedAt = [];
955
+ let fenceChar = "";
956
+ let fenceLen = 0;
957
+ let openedAt = 0;
958
+ for (let i = 0; i < lines.length; i++) {
959
+ const line = lines[i];
960
+ let indent = 0;
961
+ while (indent < line.length && [" "].indexOf(line.charAt(indent)) > -1) indent = indent + 1;
962
+ const ch = line.charAt(indent);
963
+ let run = 0;
964
+ if (indent < 4 && [TICK, "~"].indexOf(ch) > -1) {
965
+ while (indent + run < line.length && [ch].indexOf(line.charAt(indent + run)) > -1) run = run + 1;
966
+ }
967
+ const rest = line.slice(indent + run);
968
+ if (fenceChar.length < 1) {
969
+ const opens = run >= 3 && ([TICK].indexOf(ch) < 0 || rest.indexOf(TICK) < 0);
970
+ if (opens) { fenceChar = ch; fenceLen = run; openedAt = i + 1; fenceLine.push(true); inFence.push(false); fenceOpenedAt.push(0); continue; }
971
+ fenceLine.push(false); inFence.push(false); fenceOpenedAt.push(0);
972
+ continue;
973
+ }
974
+ // Only spaces and tabs may follow a closer (CommonMark); JS trim() would also
975
+ // strip Unicode spaces such as U+00A0, so a closer that Markdown keeps open
976
+ // would read as closed here and a fenced skeleton after it would reach the
977
+ // gate (Greptile P1 on mmnto-ai/totem#2769).
978
+ const closes = run >= fenceLen && [fenceChar].indexOf(ch) > -1 && rest.split(" ").join("").split(" ").join("").length < 1;
979
+ if (closes) { fenceChar = ""; fenceLen = 0; fenceLine.push(true); inFence.push(false); fenceOpenedAt.push(0); continue; }
980
+ fenceLine.push(false); inFence.push(true); fenceOpenedAt.push(openedAt);
981
+ }
982
+ // A line of only # characters and whitespace (mmnto-ai/totem#2769): not a
983
+ // heading (no text) and not body either — it is the skeleton of one. Inside a
984
+ // fence it is code, and code is body.
985
+ function bareHashes(line) {
986
+ const t = line.trim();
987
+ return t.length > 0 && t.split("#").join("").length < 1;
988
+ }
989
+ function levelAt(i) {
990
+ if (fenceLine[i] || inFence[i]) return 0;
991
+ return headingLevel(lines[i]);
992
+ }
993
+ // A promised heading, spelled exactly or with one trailing parenthetical
994
+ // dropped (the same tolerance the matcher extends), whatever its indentation.
995
+ function promisedHeading(text) {
996
+ const t = text.trim();
997
+ if (REQUIRED.indexOf(t) > -1) return true;
998
+ const w = stripParen(t);
999
+ for (const h of REQUIRED) { if ([w].indexOf(stripParen(h)) > -1) return true; }
1000
+ return false;
1001
+ }
1002
+ function isBody(i) {
1003
+ if (fenceLine[i]) return false;
1004
+ if (lines[i].trim().length < 1) return false;
1005
+ if (inFence[i]) return !promisedHeading(lines[i]);
1006
+ return !bareHashes(lines[i]);
1007
+ }
1008
+ // Why a section read as empty when it was not blank: the reader says which
1009
+ // rule emptied it, so the cure is never a line the seat can see and not explain.
1010
+ function emptyReason(start, level) {
1011
+ let quoted = false;
1012
+ let hashes = false;
1013
+ let fences = false;
1014
+ for (let i = start + 1; i < lines.length; i++) {
1015
+ const n = levelAt(i);
1016
+ if (n > 0 && n <= level) break;
1017
+ if (n > 0) continue;
1018
+ if (fenceLine[i]) fences = true;
1019
+ else if (inFence[i] && lines[i].trim().length > 0) quoted = true;
1020
+ else if (bareHashes(lines[i])) hashes = true;
1021
+ }
1022
+ const found = [];
1023
+ if (quoted) found.push("a promised heading quoted inside a fenced code block");
1024
+ if (hashes) found.push("a line of # characters");
1025
+ if (fences && !quoted) found.push("an empty fenced code block");
1026
+ if (found.length < 1) return "";
1027
+ return " — nothing under it counts as body: " + found.join("; ");
1028
+ }
1029
+ function hasBodyAfter(start, level) {
818
1030
  for (let i = start + 1; i < lines.length; i++) {
819
- if (isHeading(lines[i])) return false;
820
- if (lines[i].trim().length > 0) return true;
1031
+ const n = levelAt(i);
1032
+ if (n > 0 && n <= level) return false;
1033
+ if (n > 0) continue;
1034
+ if (isBody(i)) return true;
821
1035
  }
822
1036
  return false;
823
1037
  }
1038
+ function candidate(i) {
1039
+ return !fenceLine[i] && !inFence[i];
1040
+ }
1041
+ function fencedCopyOf(heading) {
1042
+ const want = stripParen(heading);
1043
+ for (let i = 0; i < lines.length; i++) {
1044
+ if (!inFence[i]) continue;
1045
+ const seen = lines[i].trim();
1046
+ if ([heading].indexOf(seen) > -1 || [want].indexOf(stripParen(seen)) > -1) return i;
1047
+ }
1048
+ return -1;
1049
+ }
1050
+ // The fence still open when the scan ended, if any — its copy of a heading
1051
+ // gets the cure that fits (close it) rather than the one that does not.
1052
+ const unclosedAt = fenceChar.length > 0 ? openedAt : 0;
1053
+ function fenceCure(openLine) {
1054
+ if (unclosedAt > 0 && [unclosedAt].indexOf(openLine) > -1) return " (that fence is never closed — close it with a bare closing fence: the same character, at least as long, nothing else on the line — or write the section outside it)";
1055
+ return " (write the section outside the fence)";
1056
+ }
824
1057
  if (shape !== "DOCUMENT") {
825
1058
  for (const heading of REQUIRED) {
826
1059
  let at = -1;
827
- for (let i = 0; i < lines.length; i++) { if ([heading].indexOf(lines[i].trimEnd()) > -1) { at = i; break; } }
828
- if (at < 0) block("the draft in " + shownFile + " is missing heading " + safe(heading));
829
- if (!hasBodyAfter(at)) block("the draft in " + shownFile + " has an empty heading " + safe(heading));
1060
+ for (let i = 0; i < lines.length; i++) { if (candidate(i) && [heading].indexOf(lines[i].trimEnd()) > -1) { at = i; break; } }
1061
+ let matchedAs = "";
1062
+ if (at < 0) {
1063
+ const want = stripParen(heading);
1064
+ for (let i = 0; i < lines.length; i++) { if (candidate(i) && [want].indexOf(stripParen(lines[i].trimEnd())) > -1) { at = i; break; } }
1065
+ if (at > -1) { matchedAs = safe(lines[at].trimEnd()); tolerated.push(safe(heading) + " ~ " + matchedAs); }
1066
+ }
1067
+ if (at < 0) {
1068
+ const fencedAt = fencedCopyOf(heading);
1069
+ if (fencedAt > -1) block("the draft in " + shownFile + " is missing heading " + safe(heading) + " — it appears only inside a fenced code block opened at line " + fenceOpenedAt[fencedAt] + fenceCure(fenceOpenedAt[fencedAt]));
1070
+ block("the draft in " + shownFile + " is missing heading " + safe(heading));
1071
+ }
1072
+ const shownHeading = safe(heading) + (matchedAs.length > 0 ? " (matched as " + matchedAs + ")" : "");
1073
+ if (!hasBodyAfter(at, levelAt(at))) block("the draft in " + shownFile + " has an empty heading " + shownHeading + emptyReason(at, levelAt(at)));
830
1074
  }
831
1075
  } else {
832
1076
  let bodied = false;
833
- for (let i = 0; i < lines.length; i++) { if (isHeading(lines[i]) && hasBodyAfter(i)) { bodied = true; break; } }
1077
+ for (let i = 0; i < lines.length; i++) { const n = levelAt(i); if (n > 0 && hasBodyAfter(i, n)) { bodied = true; break; } }
834
1078
  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
1079
  if (!bodied) block("the bound record at " + shownRef + " has no heading with a body");
836
1080
  }
@@ -838,6 +1082,7 @@ const stamp = best.at ? Date.parse(best.at) : NaN;
838
1082
  const days = Number.isNaN(stamp) ? -1 : Math.floor((Date.now() - stamp) / 86400000);
839
1083
  let out = shownFile + " (" + (shownAt || "undated") + (days >= 0 ? ", " + days + " days old" : "") + ")";
840
1084
  out = out + " · anchor " + shownKind + " " + shownRef + " · shape " + shape;
1085
+ if (tolerated.length > 0) out = out + " · tolerated " + tolerated.join("; ");
841
1086
  if (recordStatus.length > 0) out = out + " · " + recordStatus;
842
1087
  emit(out);
843
1088
  ' 2>/dev/null)
@@ -847,9 +1092,9 @@ emit(out);
847
1092
  # each reported distinctly, never as "no evidence", and all fail-closed.
848
1093
  reader_status=$?
849
1094
  if [ "$reader_status" = "0" ] && [ -n "$spec_evidence" ]; then
850
- echo "[Totem] spec evidence: $spec_evidence"
1095
+ printf '%s\\n' "[Totem] spec evidence: $spec_evidence"
851
1096
  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)"
1097
+ 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
1098
  exit 1
854
1099
  elif [ "$reader_status" != "2" ]; then
855
1100
  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)"
@@ -881,14 +1126,24 @@ export function buildPrePushHook(options) {
881
1126
  const { fallbackCmd, totemDir } = options;
882
1127
  const effectiveTier = options.tier;
883
1128
  assertRenderableTotemDir(totemDir);
884
- // The strict-tier review-leg floor (mmnto-ai/totem#2698; doctrine
885
- // `model-tiering.md` § Review legs — a self-authored judgment-dense diff owes
886
- // one falsification leg before it is presented). The gate itself derives
887
- // everything: whether the push is legs-owed (the changed-file set against
888
- // `hooks.legsOwed.globs`, read at RUN time so a glob edit needs no hook
889
- // re-install) and whether a deposit ancestor-or-equal of HEAD answers for it.
890
- // The hook maps that derivation onto exit codes and NOTHING else — the tier
891
- // changes only which code blocks, never a line of the text.
1129
+ // The review-leg floor (mmnto-ai/totem#2698; doctrine `model-tiering.md`
1130
+ // § Review legs — a self-authored judgment-dense diff owes one falsification
1131
+ // leg before it is presented). The gate itself derives everything: whether
1132
+ // the push is legs-owed (the changed-file set against `hooks.legsOwed.globs`,
1133
+ // read at RUN time so a glob edit needs no hook re-install) and whether a
1134
+ // deposit ancestor-or-equal of HEAD answers for it. The hook maps that
1135
+ // derivation onto exit codes and NOTHING else — the tier changes only which
1136
+ // code blocks, never a line of the text.
1137
+ //
1138
+ // Since mmnto-ai/totem#2771 the repo's `hooks.legsOwed.enforce` (read at run
1139
+ // time by the gate, like the globs) can arm this ONE gate at any tier
1140
+ // (`'block'`) or soften it at every tier (`'advisory'`) without touching the
1141
+ // spec-evidence and shield arms. The hook never reads the knob: it reads the
1142
+ // EXIT CODE the gate has already mapped through it. So 3 and 2 block on
1143
+ // every tier — under the advisory flag they only ever come back when the
1144
+ // knob says block — and only a failure before the derivation (an unloadable
1145
+ // config, which is also how the knob would go unread) stays strict-only,
1146
+ // which keeps a knob-less install byte-for-byte on its old behaviour.
892
1147
  //
893
1148
  // Slotted BEFORE the shield block deliberately: on strict this is a sub-second
894
1149
  // local read, and paying for the slow review gate before discovering the push
@@ -910,29 +1165,40 @@ export function buildPrePushHook(options) {
910
1165
  // `legs gate` at all. `--advisory` is specific to this verb, so its presence
911
1166
  // in that output means the verb itself answered.
912
1167
  const legsBlock = `
913
- # Strict mode: require a fresh falsification-leg deposit for legs-owed pushes
914
- # (mmnto-ai/totem#2698, doctrine/model-tiering.md § Review legs).
1168
+ # The review-leg floor: require a fresh falsification-leg deposit for
1169
+ # legs-owed pushes (mmnto-ai/totem#2698, doctrine/model-tiering.md § Review legs).
915
1170
  # Exit vocabulary of \`totem legs gate\`: 0 = not owed, or a deposit answers for
916
1171
  # this head · 3 = owed with no fresh deposit · 2 = the gate could not derive
917
- # (not a git repo, HEAD or the branch diff unresolvable). Advisory tiers print
918
- # the SAME lines and exit 0.
1172
+ # (not a git repo, HEAD or the branch diff unresolvable). The strict tier and
1173
+ # agent seats run the bare gate; the other tiers pass --advisory, under which
1174
+ # the gate prints the SAME lines and exits 0 — unless the repo's
1175
+ # hooks.legsOwed.enforce says 'block', in which case the gate exits 3 or 2 at
1176
+ # any tier and this hook blocks on it (mmnto-ai/totem#2771); 'advisory' makes
1177
+ # it exit 0 at any tier. A status other than 0, 2 or 3 is a failure before
1178
+ # the derivation (an unloadable config) and blocks only on the strict arm.
919
1179
  if $TOTEM_CMD legs gate --help 2>/dev/null | grep -q -- '--advisory'; then
1180
+ legs_strict=0
920
1181
  if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]; then
1182
+ legs_strict=1
921
1183
  $TOTEM_CMD legs gate
922
1184
  legs_status=$?
923
- if [ "$legs_status" = "3" ]; then
924
- echo "[Totem] BLOCKED: this push is legs-owed and carries no fresh falsification-leg deposit — run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>' (mmnto-ai/totem#2698, strict mode)"
925
- exit 1
926
- elif [ "$legs_status" != "0" ]; then
927
- echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status $legs_status) — fix the checkout and retry (strict mode)"
928
- exit 1
929
- fi
930
1185
  else
931
1186
  $TOTEM_CMD legs gate --advisory
1187
+ legs_status=$?
1188
+ fi
1189
+ if [ "$legs_status" = "3" ]; then
1190
+ echo "[Totem] BLOCKED: this push is legs-owed and carries no fresh falsification-leg deposit — run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>' (mmnto-ai/totem#2698; strict mode, an agent seat, or hooks.legsOwed.enforce: block)"
1191
+ exit 1
1192
+ elif [ "$legs_status" = "2" ]; then
1193
+ echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status 2) — fix the checkout and retry (strict mode, an agent seat, or hooks.legsOwed.enforce: block)"
1194
+ exit 1
1195
+ elif [ "$legs_status" != "0" ] && [ "$legs_strict" = "1" ]; then
1196
+ echo "[Totem] BLOCKED: the legs gate failed before deriving (totem legs gate exit status $legs_status) — fix the config or the CLI and retry (strict mode or an agent seat)"
1197
+ exit 1
932
1198
  fi
933
1199
  else
934
1200
  if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]; then
935
- echo "[Totem] BLOCKED: this hook expects 'totem legs gate' (mmnto-ai/totem#2698) but the resolved CLI lacks it — 'npm i -g @mmnto/cli@latest' (strict mode)" >&2
1201
+ echo "[Totem] BLOCKED: this hook expects 'totem legs gate' (mmnto-ai/totem#2698) but the resolved CLI lacks it — 'npm i -g @mmnto/cli@latest' (strict mode or an agent seat)" >&2
936
1202
  exit 1
937
1203
  else
938
1204
  echo "[totem] Hook running without the legs gate (CLI predates 'totem legs'); 'npm i -g @mmnto/cli@latest' enables it." >&2
@@ -1073,16 +1339,25 @@ const OWNED_WHOLE_FILE_PREAMBLE_RE = /^#![^\n]*\n#[ \t]*$/;
1073
1339
  /** POSIX executable mode for git hooks (rwxr-xr-x). */
1074
1340
  const HOOK_EXECUTABLE_MODE = 0o755;
1075
1341
  /**
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.
1342
+ * Write a hook file and mark it executable ATOMICALLY (core's
1343
+ * `writeFileAtomicSync`, the Tenet 4 user-file mutation helper, mmnto-ai/totem#2620):
1344
+ * the bytes land in a same-directory temp, the mode is applied to the temp, and
1345
+ * the rename comes last, so an interrupted install leaves the old hook or the new
1346
+ * one and never a truncated file. That matters most on the attested-extension
1347
+ * rewrite (mmnto-ai/totem#2753): the trailer is the consumer's own lines, which no
1348
+ * template can regenerate (Greptile P1, mmnto-ai/totem#2760 round 1).
1349
+ *
1350
+ * On POSIX a mode failure propagates from the helper (a hook git cannot execute
1351
+ * must fail loud, never silently report `installed`). On Windows the exec bit is
1352
+ * skipped by the helper's own boundary: git-bash owns the executable bit there,
1353
+ * and NTFS has no POSIX mode to set. Symlinked hooks keep their link identity
1354
+ * (the helper writes through to the real path). A DANGLING symlinked hook is the
1355
+ * one case the old in-place write handled differently: `fs.writeFileSync` followed
1356
+ * the link and created its target, the helper throws ENOENT and leaves the link
1357
+ * untouched — remove or re-point the link first. Declared, not defended.
1080
1358
  */
1081
1359
  function writeExecutableHook(hookPath, content) {
1082
- fs.writeFileSync(hookPath, content);
1083
- if (process.platform !== 'win32') {
1084
- fs.chmodSync(hookPath, HOOK_EXECUTABLE_MODE);
1085
- }
1360
+ writeFileAtomicSync(hookPath, content, { mode: HOOK_EXECUTABLE_MODE });
1086
1361
  }
1087
1362
  /**
1088
1363
  * Whether an existing hook is a totem-OWNED whole file (generated verbatim by a
@@ -1104,21 +1379,153 @@ function writeExecutableHook(hookPath, content) {
1104
1379
  * whitespace may follow the end marker).
1105
1380
  */
1106
1381
  export function isTotemOwnedWholeFile(content, marker, endMarker) {
1382
+ const trailerStart = ownedTrailerStart(content, marker, endMarker);
1383
+ if (trailerStart === undefined)
1384
+ return false;
1385
+ return content.slice(trailerStart).trim().length === 0;
1386
+ }
1387
+ /**
1388
+ * The offset just past the totem end marker — where a trailer would begin — for a
1389
+ * hook whose managed region OPENS the file and is BOUNDED. `undefined` when either
1390
+ * rule fails: no start marker, user content before it (beyond a shebang + the start
1391
+ * of the marker comment), or no end marker after it (the legacy-hook path).
1392
+ *
1393
+ * The one shared prefix/bound rule behind {@link isTotemOwnedWholeFile} and
1394
+ * {@link isTotemOwnedWithAttestedTrailer} — the two differ ONLY in what they
1395
+ * accept after this offset (mmnto-ai/totem#2753).
1396
+ */
1397
+ function ownedTrailerStart(content, marker, endMarker) {
1107
1398
  const idx = content.indexOf(marker);
1108
1399
  if (idx === -1)
1109
- return false;
1400
+ return undefined;
1110
1401
  const before = content.slice(0, idx);
1111
1402
  if (before.trim().length !== 0 && !OWNED_WHOLE_FILE_PREAMBLE_RE.test(before)) {
1112
- return false;
1403
+ return undefined;
1113
1404
  }
1114
1405
  const end = content.indexOf(endMarker, idx + marker.length);
1115
1406
  // 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).
1407
+ // not safe to rewrite without --force (also the legacy-hook path).
1117
1408
  if (end === -1)
1409
+ return undefined;
1410
+ return end + endMarker.length;
1411
+ }
1412
+ /**
1413
+ * {@link ownedTrailerStart} as a BYTE offset into the raw file — the offset the
1414
+ * block-rewrite arm slices the trailer at — or `undefined` when the managed region
1415
+ * (start of file through the end marker) does not decode as UTF-8 losslessly.
1416
+ *
1417
+ * The string offset converts to a byte offset only if the region's re-encoded text
1418
+ * equals its raw bytes; totem wrote the region, so it does, and the equality check
1419
+ * PROVES it rather than assuming it. A region that fails it is not totem's text any
1420
+ * more — an ANSI-editor save that turned the template's em dash into one `0x97`
1421
+ * byte, say — so the installer reports that shape (`skipped-non-utf8`) and doctor
1422
+ * classifies it (`non-utf8`) instead of either guessing an offset or prescribing a
1423
+ * bare install that would decline (mmnto-ai/totem#2760 legs F9 and F13). The
1424
+ * trailer's own bytes are never decoded by anything that writes them back.
1425
+ */
1426
+ export function ownedTrailerByteStart(raw, marker, endMarker) {
1427
+ const existing = raw.toString('utf-8');
1428
+ const trailerStart = ownedTrailerStart(existing, marker, endMarker);
1429
+ if (trailerStart === undefined)
1430
+ return undefined;
1431
+ const prefixBytes = Buffer.from(existing.slice(0, trailerStart), 'utf-8');
1432
+ return raw.subarray(0, prefixBytes.length).equals(prefixBytes) ? prefixBytes.length : undefined;
1433
+ }
1434
+ /**
1435
+ * A trailer (the text after a managed hook's end marker) is ATTESTED when its
1436
+ * LEADING COMMENT RUN carries a full fork attestation — reason, owner and attested
1437
+ * all present and non-empty AFTER TRIMMING; a whitespace-only value does not attest
1438
+ * (mmnto-ai/totem#2753; the trim from mmnto-ai/totem#2760 round 1).
1439
+ *
1440
+ * The leading comment run is every line up to the first line that is neither blank
1441
+ * nor a shell comment — i.e. up to the extension's first COMMAND. Blank lines inside
1442
+ * the run are skipped. The attestation is core's `<!-- totem:fork … -->` marker
1443
+ * (`parseForkMarker`), the same shape the parity detector reads, on a comment line:
1444
+ *
1445
+ * (blank)
1446
+ * # [lc] docs-inject extension
1447
+ * # <!-- totem:fork reason="…" owner="satur8d" attested="2026-06-07" -->
1448
+ * sh "tools/git-hooks/pre-commit-docs-inject.sh"
1449
+ *
1450
+ * The run, not the first line: a real consumer labels its block before it signs it.
1451
+ * That is the measured liquid-city shape — `tools/git-hooks/install.cjs` emits a
1452
+ * `# [lc] <name> extension` line FIRST and the fork marker SECOND — and a
1453
+ * first-line-only rule declined the very datum this slice was built from
1454
+ * (mmnto-ai/liquid-city#1174).
1455
+ *
1456
+ * Two things do NOT attest, and both matter:
1457
+ * - A marker below the first command. An attestation buried under code vouches
1458
+ * for nothing above it, so the run ends at that command.
1459
+ * - A marker on a NON-comment line. `rm -rf / # <!-- totem:fork … -->` is a
1460
+ * command, not a signature; only a line whose trimmed text STARTS with `#` can
1461
+ * carry one.
1462
+ *
1463
+ * The marker must also sit on ONE line: `parseForkMarker` is applied per line here,
1464
+ * so core's multi-line (dotAll) form of the marker is deliberately not in play.
1465
+ *
1466
+ * A BARE `totem:fork` marker — or one missing any of the three fields — is not
1467
+ * attested either. That asymmetry with the parity detector (where a bare marker is
1468
+ * enough to CLAIM a fork) is deliberate: carrying a consumer's lines through a
1469
+ * managed-block rewrite is a maintenance promise, and a promise needs a name, a
1470
+ * reason and a date.
1471
+ */
1472
+ export function isAttestedTrailer(trailer) {
1473
+ for (const line of trailer.split('\n')) {
1474
+ const trimmed = line.trim();
1475
+ // Blank lines sit inside the run — the measured shape opens with one.
1476
+ if (trimmed.length === 0)
1477
+ continue;
1478
+ // The first command ends the run: nothing below it can vouch for it.
1479
+ if (!trimmed.startsWith('#'))
1480
+ return false;
1481
+ const fork = parseForkMarker(line);
1482
+ // Trimmed: core's parser captures the quoted value raw, so `reason=" "` would
1483
+ // otherwise pass a length check — a promise with no name is not a promise
1484
+ // (Greptile P2, mmnto-ai/totem#2760 round 1).
1485
+ if (fork !== undefined &&
1486
+ typeof fork.reason === 'string' &&
1487
+ fork.reason.trim().length > 0 &&
1488
+ typeof fork.owner === 'string' &&
1489
+ fork.owner.trim().length > 0 &&
1490
+ typeof fork.attested === 'string' &&
1491
+ fork.attested.trim().length > 0) {
1492
+ return true;
1493
+ }
1494
+ }
1495
+ // Blank/whitespace-only, or a comment run with no full marker in it.
1496
+ return false;
1497
+ }
1498
+ /**
1499
+ * The mmnto-ai/totem#2406 owned-whole-file shape with ONE relaxation: the trailer may
1500
+ * be non-blank if it is attested ({@link isAttestedTrailer}).
1501
+ *
1502
+ * The precondition for the in-place managed-block rewrite: totem still owns
1503
+ * everything from the top of the file through the end marker, and what follows it is
1504
+ * a consumer extension that named itself. Everything before the end marker is
1505
+ * regenerated; everything after it is carried through byte-for-byte.
1506
+ */
1507
+ export function isTotemOwnedWithAttestedTrailer(content, marker, endMarker) {
1508
+ const trailerStart = ownedTrailerStart(content, marker, endMarker);
1509
+ if (trailerStart === undefined)
1118
1510
  return false;
1119
- if (content.slice(end + endMarker.length).trim().length !== 0)
1120
- return false;
1121
- return true;
1511
+ return isAttestedTrailer(content.slice(trailerStart));
1512
+ }
1513
+ /**
1514
+ * The trailer as it must be re-attached after a regenerated managed block: the BYTES
1515
+ * after `endMarker` with exactly ONE leading line terminator (`\r\n` or `\n`)
1516
+ * removed. The canonical hook text already ends with the end marker's own
1517
+ * terminator, so re-attaching the raw slice would duplicate it (the `upgradeReflexes`
1518
+ * seam precedent in init.ts). Everything past that one terminator is untouched —
1519
+ * and never decoded: the trailer is the consumer's own file, and a byte that does
1520
+ * not round-trip UTF-8 must come back as itself (mmnto-ai/totem#2760 leg F9).
1521
+ */
1522
+ function trailerTailAfterEndMarker(raw, trailerStart) {
1523
+ const trailer = raw.subarray(trailerStart);
1524
+ if (trailer[0] === 0x0d && trailer[1] === 0x0a)
1525
+ return trailer.subarray(2);
1526
+ if (trailer[0] === 0x0a)
1527
+ return trailer.subarray(1);
1528
+ return trailer;
1122
1529
  }
1123
1530
  /**
1124
1531
  * Install a single git hook with idempotency and chain preservation.
@@ -1134,11 +1541,28 @@ export function isTotemOwnedWholeFile(content, marker, endMarker) {
1134
1541
  * templates now emit one. Drift-repair fires only when the caller threads the end
1135
1542
  * marker AND the on-disk hook carries it — a legacy pre-end-marker hook declines to
1136
1543
  * `exists` and takes one `totem hook install --force`.
1544
+ *
1545
+ * Since mmnto-ai/totem#2753 a THIRD arm sits between drift-repair and the decline: a
1546
+ * file totem owns through its end marker whose trailer is an ATTESTED `totem:fork`
1547
+ * extension ({@link isTotemOwnedWithAttestedTrailer}) has its managed block rewritten
1548
+ * IN PLACE (`block-rewritten`) — the canonical text plus the existing trailer,
1549
+ * byte-identical past the seam. That is the liquid-city shape: a consumer appending
1550
+ * its own blocks after totem's end marker never received a managed-hook upgrade
1551
+ * through bare `totem init` (measured at `@mmnto/cli` 1.123.0,
1552
+ * mmnto-ai/liquid-city#1174). An UNATTESTED trailer still declines to `exists`,
1553
+ * unchanged. `--force` is untouched by all of this: it overwrites the WHOLE file,
1554
+ * trailer included.
1137
1555
  */
1138
1556
  export function installGitHook(hooksDir, hookName, hookContent, marker, force, endMarker) {
1139
1557
  const hookPath = path.join(hooksDir, hookName);
1140
1558
  if (fs.existsSync(hookPath)) {
1141
- const existing = fs.readFileSync(hookPath, 'utf-8');
1559
+ // Raw bytes are the user's file; the decoded text serves the PROBES only
1560
+ // (markers, shebang, terminator). Every write below that carries the user's
1561
+ // content carries it as BYTES — a hook that does not round-trip UTF-8 must
1562
+ // never come back with U+FFFD where its bytes were (the mmnto-ai/totem#2620
1563
+ // eject ruling, re-learned on mmnto-ai/totem#2760 leg F8).
1564
+ const raw = fs.readFileSync(hookPath);
1565
+ const existing = raw.toString('utf-8');
1142
1566
  if (existing.includes(marker)) {
1143
1567
  if (force) {
1144
1568
  // Force overwrite — replace the entire hook with the new content
@@ -1157,6 +1581,34 @@ export function installGitHook(hooksDir, hookName, hookContent, marker, force, e
1157
1581
  writeExecutableHook(hookPath, hookContent);
1158
1582
  return 'overwritten';
1159
1583
  }
1584
+ // In-place managed-block rewrite (mmnto-ai/totem#2753): totem owns the file
1585
+ // through its end marker and what follows is an ATTESTED `totem:fork`
1586
+ // extension. Regenerate the block, carry the trailer through byte-for-byte.
1587
+ // The currency compare here is the RECOMPOSED file, not the whole existing
1588
+ // one — an attested-trailer hook whose block already equals the canonical is
1589
+ // current (`exists`, no write), which is what makes a second bare run a no-op.
1590
+ if (endMarker !== undefined && isTotemOwnedWithAttestedTrailer(existing, marker, endMarker)) {
1591
+ // "Byte-for-byte" is literal: the trailer is sliced from the RAW file at the
1592
+ // byte offset `ownedTrailerByteStart` PROVES — the region above the
1593
+ // extension (shebang line and managed block) re-encodes to its own bytes.
1594
+ // That is the ONE marker scan, shared with doctor (fold F11's rule, one
1595
+ // implementation); the predicate above ran it too, and a second run on the
1596
+ // same string is cheaper than a second implementation. A region that does
1597
+ // not round-trip is not ours to rewrite — and not something to stay silent
1598
+ // about: the skip is REPORTED, and doctor senses the same shape with the
1599
+ // same predicate (legs F13, F16). The trailer's bytes are never decoded.
1600
+ const trailerByteStart = ownedTrailerByteStart(raw, marker, endMarker);
1601
+ if (trailerByteStart === undefined)
1602
+ return 'skipped-non-utf8';
1603
+ const rewritten = Buffer.concat([
1604
+ Buffer.from(hookContent, 'utf-8'),
1605
+ trailerTailAfterEndMarker(raw, trailerByteStart),
1606
+ ]);
1607
+ if (rewritten.equals(raw))
1608
+ return 'exists';
1609
+ writeExecutableHook(hookPath, rewritten);
1610
+ return 'block-rewritten';
1611
+ }
1160
1612
  return 'exists';
1161
1613
  }
1162
1614
  // Guard: do not append bash syntax to non-shell hooks (Node, Python, etc.)
@@ -1164,12 +1616,16 @@ export function installGitHook(hooksDir, hookName, hookContent, marker, force, e
1164
1616
  if (firstLine.startsWith('#!') && !SHELL_SHEBANG_RE.test(firstLine)) {
1165
1617
  return 'skipped-non-shell';
1166
1618
  }
1167
- // Append to existing hook — preserve user's existing hooks
1619
+ // Append to existing hook — preserve user's existing hooks. One atomic
1620
+ // replacement of the whole file (their RAW bytes + ours), not an append: an
1621
+ // interrupted append leaves a hook truncated mid-block, which git still
1622
+ // runs (mmnto-ai/totem#2760 round 1, leg F2). The helper keeps the user's
1623
+ // file mode and writes through a symlink to its real path.
1168
1624
  const separator = existing.endsWith('\n') ? '\n' : '\n\n';
1169
1625
  const appendBlock = hookContent
1170
1626
  .replace(/^#!\/bin\/sh\n/, '') // Strip shebang when appending
1171
1627
  .trimStart();
1172
- fs.appendFileSync(hookPath, separator + appendBlock);
1628
+ writeFileAtomicSync(hookPath, Buffer.concat([raw, Buffer.from(separator + appendBlock, 'utf-8')]));
1173
1629
  return 'appended';
1174
1630
  }
1175
1631
  // Create new hook
@@ -1223,8 +1679,17 @@ export async function installEnforcementHooks(cwd, rl, options) {
1223
1679
  // while every hook writer resolves at the git root: a pre-existing split this
1224
1680
  // slice names and does not close.
1225
1681
  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);
1682
+ // Render each hook at the tier it is entitled to keep (mmnto-ai/totem#2753 fold
1683
+ // F4): nothing pinned + an installed `--strict` hook → strict, not a silent
1684
+ // downgrade to standard.
1685
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook({
1686
+ ...render,
1687
+ tier: tierForHook(hooksDir, 'pre-commit', TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END, render),
1688
+ }), TOTEM_PRECOMMIT_MARKER, undefined, TOTEM_PRECOMMIT_END);
1689
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook({
1690
+ ...render,
1691
+ tier: tierForHook(hooksDir, 'pre-push', TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END, render),
1692
+ }), TOTEM_PREPUSH_MARKER, undefined, TOTEM_PREPUSH_END);
1228
1693
  // Warn about non-shell hooks that Totem cannot safely append to
1229
1694
  if (preCommit === 'skipped-non-shell') {
1230
1695
  console.error('[Totem] Warning: pre-commit hook uses a non-shell interpreter. Manually integrate branch protection into your existing hook.');
@@ -1232,6 +1697,15 @@ export async function installEnforcementHooks(cwd, rl, options) {
1232
1697
  if (prePush === 'skipped-non-shell') {
1233
1698
  console.error('[Totem] Warning: pre-push hook uses a non-shell interpreter. Manually add: totem lint');
1234
1699
  }
1700
+ // A skip with its reason, never a silent "already installed" (mmnto-ai/totem#2760 leg F13).
1701
+ for (const [name, action] of [
1702
+ ['pre-commit', preCommit],
1703
+ ['pre-push', prePush],
1704
+ ]) {
1705
+ if (action === 'skipped-non-utf8') {
1706
+ 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).`);
1707
+ }
1708
+ }
1235
1709
  return { preCommit, prePush };
1236
1710
  }
1237
1711
  export async function installHooksCommand() {
@@ -1293,8 +1767,17 @@ export async function installHooksNonInteractive(cwd, force, options) {
1293
1767
  console.error(HOOKS_DIR_UNRESOLVED_MSG);
1294
1768
  return null;
1295
1769
  }
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);
1770
+ // Same entitlement rule as the init path (mmnto-ai/totem#2753 fold F4) and it
1771
+ // has to hold HERE above all, because `totem hook install` is the bare command
1772
+ // the doctor's own stale-block remedy sends people to.
1773
+ const preCommit = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook({
1774
+ ...render,
1775
+ tier: tierForHook(hooksDir, 'pre-commit', TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END, render),
1776
+ }), TOTEM_PRECOMMIT_MARKER, force, TOTEM_PRECOMMIT_END);
1777
+ const prePush = installGitHook(hooksDir, 'pre-push', buildPrePushHook({
1778
+ ...render,
1779
+ tier: tierForHook(hooksDir, 'pre-push', TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END, render),
1780
+ }), TOTEM_PREPUSH_MARKER, force, TOTEM_PREPUSH_END);
1298
1781
  const postMergeContent = buildHookContent(render);
1299
1782
  const postMerge = installGitHook(hooksDir, 'post-merge', postMergeContent, TOTEM_HOOK_MARKER, force, TOTEM_HOOK_END);
1300
1783
  const postCheckoutContent = buildPostCheckoutHookContent(render);
@@ -1411,9 +1894,21 @@ export async function hooksCommand(opts) {
1411
1894
  ? `[Totem] Force-overwritten ${name} hook.`
1412
1895
  : `[Totem] Drift-repaired ${name} hook (totem-owned bounded region).`);
1413
1896
  break;
1897
+ case 'block-rewritten':
1898
+ // Distinct from the whole-file line above: this write REGENERATED the
1899
+ // managed block and left everything after the end marker alone. Saying so
1900
+ // is the point — a consumer that extends its hooks needs to read, from the
1901
+ // summary, that its extension survived (mmnto-ai/totem#2753).
1902
+ console.error(`[Totem] Drift-repaired ${name} hook (managed block rewritten in place; the attested extension after its end marker carried through unchanged).`);
1903
+ break;
1414
1904
  case 'skipped-non-shell':
1415
1905
  console.error(`[Totem] Warning: ${name} hook uses a non-shell interpreter. Integrate manually.`);
1416
1906
  break;
1907
+ case 'skipped-non-utf8':
1908
+ // The eject precedent (mmnto-ai/totem#2620): a skip is reported with its
1909
+ // reason and the file is left byte-identical — never "already installed".
1910
+ 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).`);
1911
+ break;
1417
1912
  }
1418
1913
  }
1419
1914
  }
@@ -1753,10 +2248,21 @@ export async function upgradePrePushHookIfNeeded(cwd) {
1753
2248
  const hookPath = path.join(hooksDir, 'pre-push');
1754
2249
  if (!fs.existsSync(hookPath))
1755
2250
  return false;
1756
- const content = fs.readFileSync(hookPath, 'utf-8');
1757
- // Only upgrade hooks that Totem owns (have our marker)
2251
+ const rawContent = fs.readFileSync(hookPath);
2252
+ const content = rawContent.toString('utf-8');
2253
+ // Only upgrade hooks that Totem owns (have our marker) — block presence FIRST,
2254
+ // the order eject.ts ruled, so the two sites read alike even though this one
2255
+ // returns a bare `false` either way.
1758
2256
  if (!content.includes(TOTEM_PREPUSH_MARKER))
1759
2257
  return false;
2258
+ // The splice below is text on both sides of the block, so it is byte-exact
2259
+ // only when the whole file decoded losslessly. A hook that does not
2260
+ // round-trip UTF-8 is declined here — this upgrader's ruled posture is a
2261
+ // silent `false` (mmnto-ai/totem#2692 N4), and declining beats writing U+FFFD
2262
+ // over a user's bytes (mmnto-ai/totem#2620's eject ruling, mmnto-ai/totem#2760
2263
+ // leg F9). Such a hook keeps its old block and takes `totem hook install --force`.
2264
+ if (!Buffer.from(content, 'utf-8').equals(rawContent))
2265
+ return false;
1760
2266
  // Already on the new stateless format — no upgrade needed.
1761
2267
  // SAFETY INVARIANT: old hooks (pre-verify-manifest) have a single top-level
1762
2268
  // if/fi block and no agent detection. The parser below relies on this — it
@@ -1799,6 +2305,7 @@ export async function upgradePrePushHookIfNeeded(cwd) {
1799
2305
  if (endOffset === -1)
1800
2306
  return false;
1801
2307
  const blockEnd = markerIdx + endOffset;
2308
+ // 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
2309
  const render = await resolveHookRenderOptions(gitRoot);
1803
2310
  // Build the replacement block (strip shebang — we're splicing into existing file)
1804
2311
  const newBlock = buildPrePushHook(render)
@@ -1808,13 +2315,10 @@ export async function upgradePrePushHookIfNeeded(cwd) {
1808
2315
  const before = content.slice(0, markerIdx);
1809
2316
  const after = content.slice(blockEnd);
1810
2317
  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
- }
2318
+ // The splice keeps the user's lines on BOTH sides of the block — the exact
2319
+ // shape Greptile P1 named on the attested-extension arm — so it takes the same
2320
+ // atomic, executable write (mmnto-ai/totem#2760 round 1, leg F4).
2321
+ writeExecutableHook(hookPath, upgraded);
1818
2322
  return true;
1819
2323
  }
1820
2324
  catch {