@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.
- package/dist/commands/doctor.d.ts +16 -10
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +162 -54
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +444 -28
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/hook-totemdir-render.test.js +55 -1
- package/dist/commands/hook-totemdir-render.test.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +39 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +32 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +32 -1
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +143 -10
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +441 -69
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +1075 -7
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/spec-templates.d.ts +17 -4
- package/dist/commands/spec-templates.d.ts.map +1 -1
- package/dist/commands/spec-templates.js +23 -3
- package/dist/commands/spec-templates.js.map +1 -1
- package/dist/commands/spec.d.ts +57 -8
- package/dist/commands/spec.d.ts.map +1 -1
- package/dist/commands/spec.js +124 -28
- package/dist/commands/spec.js.map +1 -1
- package/dist/commands/spec.test.js +277 -44
- package/dist/commands/spec.test.js.map +1 -1
- package/dist/utils.d.ts +5 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +8 -3
- package/dist/utils.test.js.map +1 -1
- 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 '
|
|
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:
|
|
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
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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)
|
|
691
|
-
// (0x7f–0x9f), because U+0085 (NEL) breaks a line on some
|
|
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:
|
|
715
|
-
#
|
|
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
|
|
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
|
|
764
|
-
if ([" ", "\\t"].indexOf(line.charAt(n)) < 0) return
|
|
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
|
-
|
|
940
|
+
const tolerated = [];
|
|
941
|
+
function hasBodyAfter(start, level) {
|
|
818
942
|
for (let i = start + 1; i < lines.length; i++) {
|
|
819
|
-
|
|
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
|
-
|
|
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++) {
|
|
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
|
-
|
|
984
|
+
printf '%s\\n' "[Totem] spec evidence: $spec_evidence"
|
|
851
985
|
elif [ "$reader_status" = "3" ]; then
|
|
852
|
-
|
|
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
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
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
|
|
1757
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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 {
|