@mmnto/cli 1.114.0 → 1.115.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 (82) hide show
  1. package/dist/commands/doctor-seat-identity.d.ts +71 -0
  2. package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
  3. package/dist/commands/doctor-seat-identity.js +352 -0
  4. package/dist/commands/doctor-seat-identity.js.map +1 -0
  5. package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
  6. package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
  7. package/dist/commands/doctor-seat-identity.test.js +230 -0
  8. package/dist/commands/doctor-seat-identity.test.js.map +1 -0
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +3 -0
  11. package/dist/commands/doctor.js.map +1 -1
  12. package/dist/commands/doctor.test.js +1 -0
  13. package/dist/commands/doctor.test.js.map +1 -1
  14. package/dist/commands/ecl-gc.d.ts.map +1 -1
  15. package/dist/commands/ecl-gc.js +18 -7
  16. package/dist/commands/ecl-gc.js.map +1 -1
  17. package/dist/commands/ecl-gc.test.js +63 -1
  18. package/dist/commands/ecl-gc.test.js.map +1 -1
  19. package/dist/commands/eject.d.ts +62 -3
  20. package/dist/commands/eject.d.ts.map +1 -1
  21. package/dist/commands/eject.js +382 -47
  22. package/dist/commands/eject.js.map +1 -1
  23. package/dist/commands/eject.test.js +557 -7
  24. package/dist/commands/eject.test.js.map +1 -1
  25. package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
  26. package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
  27. package/dist/commands/gemini-beforetool-contract.test.js +106 -0
  28. package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
  29. package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
  30. package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
  31. package/dist/commands/gemini-sessionstart-contract.test.js +184 -0
  32. package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
  33. package/dist/commands/init-templates.d.ts +2 -2
  34. package/dist/commands/init-templates.d.ts.map +1 -1
  35. package/dist/commands/init-templates.js +267 -15
  36. package/dist/commands/init-templates.js.map +1 -1
  37. package/dist/commands/init.test.js +25 -7
  38. package/dist/commands/init.test.js.map +1 -1
  39. package/dist/commands/install-hooks-exit-contract.test.js +5 -0
  40. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  41. package/dist/commands/mail-degraded-e4.test.js +126 -0
  42. package/dist/commands/mail-degraded-e4.test.js.map +1 -1
  43. package/dist/commands/mail.d.ts +19 -1
  44. package/dist/commands/mail.d.ts.map +1 -1
  45. package/dist/commands/mail.js +195 -30
  46. package/dist/commands/mail.js.map +1 -1
  47. package/dist/commands/mail.test.js +291 -3
  48. package/dist/commands/mail.test.js.map +1 -1
  49. package/dist/commands/orient.d.ts.map +1 -1
  50. package/dist/commands/orient.js +82 -0
  51. package/dist/commands/orient.js.map +1 -1
  52. package/dist/commands/orient.test.js +144 -1
  53. package/dist/commands/orient.test.js.map +1 -1
  54. package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
  55. package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
  56. package/dist/commands/seat-cli-wiring.test.js +147 -0
  57. package/dist/commands/seat-cli-wiring.test.js.map +1 -0
  58. package/dist/commands/seat-poll-chain.test.d.ts +13 -0
  59. package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
  60. package/dist/commands/seat-poll-chain.test.js +103 -0
  61. package/dist/commands/seat-poll-chain.test.js.map +1 -0
  62. package/dist/commands/seat.d.ts +92 -0
  63. package/dist/commands/seat.d.ts.map +1 -0
  64. package/dist/commands/seat.js +649 -0
  65. package/dist/commands/seat.js.map +1 -0
  66. package/dist/commands/seat.test.d.ts +19 -0
  67. package/dist/commands/seat.test.d.ts.map +1 -0
  68. package/dist/commands/seat.test.js +532 -0
  69. package/dist/commands/seat.test.js.map +1 -0
  70. package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
  71. package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
  72. package/dist/commands/sessionstart-manifest-contract.test.js +176 -0
  73. package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
  74. package/dist/hooks/__tests__/auto-context.test.js +20 -0
  75. package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
  76. package/dist/hooks/auto-context.d.ts +19 -0
  77. package/dist/hooks/auto-context.d.ts.map +1 -1
  78. package/dist/hooks/auto-context.js +22 -4
  79. package/dist/hooks/auto-context.js.map +1 -1
  80. package/dist/index.js +71 -0
  81. package/dist/index.js.map +1 -1
  82. package/package.json +2 -2
@@ -1,5 +1,9 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ // Subpath import, NOT the core barrel: fs-atomic pulls only node builtins, so
4
+ // the command file's cold-start discipline holds without threading a lazy seam
5
+ // through the sync scrub helpers that need it (mmnto-ai/totem#2620).
6
+ import { writeFileAtomicSync } from '@mmnto/totem/fs-atomic';
3
7
  // ─── Constants ──────────────────────────────────────────
4
8
  const TAG = 'Eject';
5
9
  const TOTEM_HOOK_MARKER = '[totem] post-merge hook';
@@ -9,10 +13,10 @@ const TOTEM_CHECKOUT_END = '[totem] end post-checkout';
9
13
  // Comment-shape-agnostic: hooks open `// [totem] auto-generated …`, markdown
10
14
  // skills `<!-- [totem] auto-generated … -->` — the first-line gate matches both.
11
15
  const TOTEM_FILE_MARKER = '[totem] auto-generated';
12
- /** Files that may have AI reflex blocks appended by `totem init`. */
13
- const REFLEX_FILES = ['CLAUDE.md', '.cursorrules'];
14
- /** Files scaffolded by `totem init` that are fully owned by Totem. */
15
- const TOTEM_SCAFFOLDED_FILES = [
16
+ /** Files scaffolded by `totem init` that are fully owned by Totem.
17
+ * Exported for the sense-roster set-equality test (mmnto-ai/totem#2620) —
18
+ * a sampled roster assertion cannot detect a dropped member. */
19
+ export const TOTEM_SCAFFOLDED_FILES = [
16
20
  // Current (BeforeTool: mmnto-ai/totem#2481; SessionStart: mmnto-ai/totem#2488) +
17
21
  // the pre-migration `.js` each one renamed — eject removes both shapes so an
18
22
  // upgraded-then-ejected consumer leaves no fail-open artifact behind.
@@ -30,15 +34,24 @@ const TOTEM_SCAFFOLDED_FILES = [
30
34
  // PR-C action-gate wrapper (mmnto-ai/totem#2048, eject parity).
31
35
  '.claude/hooks/gate-wrapper.cjs',
32
36
  ];
37
+ /** Deletion/scrub targets shared by the mutation steps AND the rule-2 sense
38
+ * roster — one source, read by both sides and by the parity test (Tenet 20;
39
+ * round 2, F5: a literal re-declared in the sense was a mirror that could
40
+ * drift when `deleteArtifacts` gained a member). */
41
+ export const EJECT_ARTIFACT_DIRS = ['.lancedb', '.totem'];
42
+ export const EJECT_CONFIG_FILE = 'totem.config.ts';
43
+ export const CLAUDE_SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
44
+ export const CLAUDE_SETTINGS_FILE = '.claude/settings.json';
33
45
  /**
34
46
  * Resolve where eject should look for git hooks, reusing the SAME helpers every
35
47
  * `hook install` entry point uses (mmnto-ai/totem#2426, sibling of #2422): the
36
48
  * git root via `resolveGitRootForHookPath` (anchors from a subdirectory; maps an
37
49
  * unparseable pointer to a null root) and the hooks dir via `resolveHooksDir`
38
50
  * (git's own worktree/`commondir` walk — the rev-parse root resolution the
39
- * hardcode-`.git/hooks` lesson prescribes). Best-effort per Tenet 4's eject
40
- * cleanup carve-out a genuine git failure is reported as unresolvable, never a
41
- * crash of the whole eject.
51
+ * hardcode-`.git/hooks` lesson prescribes). Resolution is a READ, not the
52
+ * mutation boundary: a genuine git failure degrades to an unresolvable-hooks
53
+ * result that the caller reports as per-hook skip lines. No backstop rides
54
+ * this path — nothing has been mutated when it fires.
42
55
  */
43
56
  export async function resolveEjectHooksContext(cwd) {
44
57
  // Lazy-load the hook-path resolvers: they pull the core git barrel, so keeping
@@ -48,9 +61,9 @@ export async function resolveEjectHooksContext(cwd) {
48
61
  let gitRoot;
49
62
  try {
50
63
  ({ gitRoot } = resolveGitRootForHookPath(cwd));
51
- // Eject is best-effort per Tenet 4's cleanup carve-out: a genuine git failure
52
- // (NOT not-a-repo / unparseable pointer, which return a null root WITHOUT
53
- // throwing) degrades to "unresolvable" rather than crashing the whole eject.
64
+ // A genuine git failure (NOT not-a-repo / unparseable pointer, which
65
+ // return a null root WITHOUT throwing) degrades to "unresolvable" a
66
+ // read-path degrade the caller reports per hook, not a mutation swallow.
54
67
  // totem-context: intentional cleanup — best-effort git resolution for eject.
55
68
  }
56
69
  catch {
@@ -80,11 +93,40 @@ function scrubHook(hooksDir, summary, hookName, startMarker, endMarker) {
80
93
  summary.skipped.push(`${hookFileName} (not found)`);
81
94
  return;
82
95
  }
83
- const content = fs.readFileSync(hookPath, 'utf-8');
96
+ // Raw bytes first: the bak must be BYTE-exact (rule 3, "pre-mutation
97
+ // bytes"), and a hook that does not round-trip through UTF-8 cannot be
98
+ // line-scrubbed without silently substituting U+FFFD into the KEPT user
99
+ // content — that class degrades to a reported skip instead (2026-08-09
100
+ // falsification round 1, finding 3).
101
+ const rawContent = fs.readFileSync(hookPath);
102
+ const content = rawContent.toString('utf-8');
84
103
  if (!content.includes(startMarker)) {
85
104
  summary.skipped.push(`${hookFileName} (no Totem section)`);
86
105
  return;
87
106
  }
107
+ if (Buffer.compare(Buffer.from(content, 'utf-8'), rawContent) !== 0) {
108
+ summary.skipped.push(`${hookFileName} (non-UTF-8 content — not scrubbed; remove the Totem block manually)`);
109
+ return;
110
+ }
111
+ // Rule-3 recovery artifact (User-File Mutation Contract corollary,
112
+ // mmnto-ai/totem#2620): `.git/hooks/*` is unrecoverable through git BY
113
+ // SURFACE CLASS — no derivable-state escape, so the full-removal path baks
114
+ // too (author intent-read Q1: "these bytes are fully ours" is a belief
115
+ // produced by the same logic that would be the bug — the #2602 provenance).
116
+ // Written BEFORE mutating, carrying the source hook's mode; a bak failure
117
+ // skips the mutation entirely, because disclosure never substitutes for
118
+ // recovery (the strategy#1045 addendum).
119
+ const bakFileName = `${hookFileName}.totem-bak`;
120
+ try {
121
+ const sourceMode = fs.statSync(hookPath).mode & 0o7777;
122
+ writeFileAtomicSync(`${hookPath}.totem-bak`, rawContent, { mode: sourceMode });
123
+ // totem-context: intentional cleanup — bak failure degrades to a reported skip and WITHHOLDS the mutation
124
+ }
125
+ catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ summary.skipped.push(`${hookFileName} (recovery backup failed — hook left untouched: ${msg})`);
128
+ return;
129
+ }
88
130
  const endSentinel = `# ${endMarker}`;
89
131
  const hasEndMarker = content.includes(endSentinel);
90
132
  const lines = content.split('\n');
@@ -119,11 +161,11 @@ function scrubHook(hooksDir, summary, hookName, startMarker, endMarker) {
119
161
  try {
120
162
  if (!remaining || remaining === '#!/bin/sh') {
121
163
  fs.unlinkSync(hookPath);
122
- summary.removed.push(hookFileName);
164
+ summary.removed.push(`${hookFileName} (pre-removal bytes: ${bakFileName})`);
123
165
  }
124
166
  else {
125
- fs.writeFileSync(hookPath, remaining + '\n', 'utf-8');
126
- summary.scrubbed.push(hookFileName);
167
+ writeFileAtomicSync(hookPath, remaining + '\n');
168
+ summary.scrubbed.push(`${hookFileName} (pre-scrub bytes: ${bakFileName})`);
127
169
  }
128
170
  }
129
171
  catch (err) {
@@ -179,7 +221,7 @@ function removeScaffoldedFiles(cwd, summary) {
179
221
  * Remove the Totem PreToolUse hook entry from Claude's settings.local.json.
180
222
  */
181
223
  function scrubClaudeSettings(cwd, summary) {
182
- const filePath = path.join(cwd, '.claude', 'settings.local.json');
224
+ const filePath = path.join(cwd, CLAUDE_SETTINGS_LOCAL_FILE);
183
225
  if (!fs.existsSync(filePath)) {
184
226
  summary.skipped.push('.claude/settings.local.json (not found)');
185
227
  return;
@@ -228,7 +270,7 @@ function scrubClaudeSettings(cwd, summary) {
228
270
  summary.removed.push('.claude/settings.local.json');
229
271
  }
230
272
  else {
231
- fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
273
+ writeFileAtomicSync(filePath, JSON.stringify(parsed, null, 2) + '\n');
232
274
  summary.scrubbed.push('.claude/settings.local.json');
233
275
  }
234
276
  }
@@ -249,14 +291,14 @@ function scrubClaudeSettings(cwd, summary) {
249
291
  * filesystem state, matching the legacy scrubClaudeSettings cleanup chain.
250
292
  */
251
293
  function scrubCommittedClaudeSettings(cwd, summary) {
252
- const filePath = path.join(cwd, '.claude', 'settings.json');
294
+ const filePath = path.join(cwd, CLAUDE_SETTINGS_FILE);
253
295
  if (!fs.existsSync(filePath)) {
254
296
  summary.skipped.push('.claude/settings.json (not found)');
255
297
  return;
256
298
  }
257
299
  // Read separately from parse so permission errors fail loud (they are
258
- // a distinct failure mode from invalid JSON, which is recoverable as
259
- // a documented eject skip per Tenet 4's "best-effort cleanup" carve-out).
300
+ // a distinct failure mode from invalid JSON, which is recoverable as a
301
+ // documented eject skip a read-path degrade, before any mutation).
260
302
  const raw = fs.readFileSync(filePath, 'utf-8');
261
303
  let rawParsed;
262
304
  try {
@@ -338,7 +380,7 @@ function scrubCommittedClaudeSettings(cwd, summary) {
338
380
  summary.removed.push('.claude/settings.json');
339
381
  }
340
382
  else {
341
- fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
383
+ writeFileAtomicSync(filePath, JSON.stringify(parsed, null, 2) + '\n');
342
384
  summary.scrubbed.push('.claude/settings.json');
343
385
  }
344
386
  }
@@ -376,7 +418,7 @@ async function scrubClaudeSkills(cwd, summary) {
376
418
  try {
377
419
  fs.unlinkSync(filePath);
378
420
  summary.removed.push(rel);
379
- // totem-context: intentional cleanup — eject best-effort per Tenet 4 carve-out
421
+ // totem-context: intentional cleanup — per-item best-effort; the failure is a reported skip (no backstop rides this step)
380
422
  }
381
423
  catch (err) {
382
424
  const msg = err instanceof Error ? err.message : String(err);
@@ -406,42 +448,196 @@ async function scrubClaudeSkills(cwd, summary) {
406
448
  /* eject best-effort */
407
449
  }
408
450
  }
451
+ /** Reflex files that predate the marker era and may carry a v1 marker-less
452
+ * block. REFLEX_START shipped before GEMINI.md / .junie/guidelines.md /
453
+ * .github/copilot-instructions.md joined the tool table, so on those files a
454
+ * bare legacy heading can only be user-authored — never scrubbed.
455
+ * `.gemini/gemini.md` is the RETIRED pre-marker Gemini target (replaced by
456
+ * GEMINI.md when the tool table landed): init no longer injects there, but an
457
+ * old block can still be sitting in it, so eject still visits it. */
458
+ export const LEGACY_REFLEX_FILES = ['CLAUDE.md', '.gemini/gemini.md', '.cursorrules'];
459
+ /** The alt heading of the oldest legacy block shape (pre-LEGACY_SENTINEL). */
460
+ const LEGACY_ALT_HEADING = '## Totem Memory Reflexes';
409
461
  /**
410
462
  * Remove the AI Integration block appended by `totem init` to reflex files.
463
+ *
464
+ * Exported for the summary-contract tests (mmnto-ai/totem#2602).
411
465
  */
412
- function scrubReflexFiles(cwd, summary) {
413
- for (const rel of REFLEX_FILES) {
466
+ export async function scrubReflexFiles(cwd, summary) {
467
+ // Lazy-load both sources (the CLI command-file cold-start discipline
468
+ // scrubClaudeSkills documents). The roster is derived from the SAME tool
469
+ // table init injects through — the hand-mirrored pair this replaces silently
470
+ // missed GEMINI.md, .junie/guidelines.md, and .github/copilot-instructions.md
471
+ // (mmnto-ai/totem#2602).
472
+ const { AI_TOOLS } = await import('./init-detect.js');
473
+ const { LEGACY_SENTINEL, REFLEX_END, REFLEX_START } = await import('./init-templates.js');
474
+ const reflexFiles = AI_TOOLS.flatMap((t) => (t.reflexFile === null ? [] : [t.reflexFile]));
475
+ // Visit the union: the roster init injects through today, plus the retired
476
+ // pre-marker targets that can still carry an old block (scoped fold round).
477
+ const scanFiles = [...new Set([...reflexFiles, ...LEGACY_REFLEX_FILES])];
478
+ // Tenet 4 licenses per-item best-effort ONLY together with failure
479
+ // accounting and a loud backstop (the licensing gap this closes was
480
+ // mmnto-ai/totem#2620 pre-build leg finding 4 — before it, an eject where every
481
+ // scrub write failed still exited clean over a wall of skip lines).
482
+ let failed = 0;
483
+ let succeeded = 0;
484
+ for (const rel of scanFiles) {
414
485
  const filePath = path.join(cwd, rel);
415
- if (!fs.existsSync(filePath))
416
- continue;
417
- const content = fs.readFileSync(filePath, 'utf-8');
418
- // Match the block from the heading to end of file (init always appends at the end)
419
- const primaryMarker = /\n*## Totem AI Integration \(Auto-Generated\)[\s\S]*$/;
420
- const altMarker = /\n*## Totem Memory Reflexes[\s\S]*$/;
421
- const activeMarker = primaryMarker.test(content)
422
- ? primaryMarker
423
- : altMarker.test(content)
424
- ? altMarker
425
- : null;
426
- if (!activeMarker) {
427
- summary.skipped.push(`${rel} (no Totem block)`);
428
- continue;
486
+ // Per-file best-effort: one unreadable/locked file degrades to a reported
487
+ // skip, never an abort that strands the remaining eject steps.
488
+ try {
489
+ if (!fs.existsSync(filePath))
490
+ continue;
491
+ // Same guard as scrubHook's (round 2, F2 — the sibling gap): a reflex
492
+ // file that does not round-trip through UTF-8 cannot be scrubbed
493
+ // without substituting U+FFFD into the KEPT user content, and rule 4
494
+ // licenses no bak for tracked files — refuse with a reason instead.
495
+ // Ordering mirrors scrubHook (round 3, finding 3): block-presence FIRST
496
+ // (markers are ASCII and survive the lossy decode), so an untouched
497
+ // non-UTF-8 file keeps its honest "(no Totem block)" line instead of a
498
+ // false remove-the-block instruction.
499
+ const rawContent = fs.readFileSync(filePath);
500
+ const content = rawContent.toString('utf-8');
501
+ const hasMarkerBlock = content.includes(REFLEX_START) || content.includes(REFLEX_END);
502
+ const hasLegacyBlock = LEGACY_REFLEX_FILES.includes(rel) &&
503
+ (content.includes(LEGACY_SENTINEL) || content.includes(LEGACY_ALT_HEADING));
504
+ if ((hasMarkerBlock || hasLegacyBlock) &&
505
+ Buffer.compare(Buffer.from(content, 'utf-8'), rawContent) !== 0) {
506
+ summary.skipped.push(`${rel} (non-UTF-8 content — not scrubbed; remove the Totem block manually)`);
507
+ continue;
508
+ }
509
+ // Marker era (v2+): remove each complete REFLEX_START..REFLEX_END span.
510
+ // The span AFTER an end marker is contractually user content
511
+ // (mmnto-ai/totem#1890; regen preserves it byte-exactly since
512
+ // mmnto-ai/totem#2599) — the pre-fix heading-to-EOF scrub deleted it and
513
+ // left an orphan start+version pair that detectReflexStatus reads as
514
+ // `current` (mmnto-ai/totem#2602). Pairing is END-anchored (lastIndexOf)
515
+ // so an orphan START from that same pre-fix damage sitting ABOVE a
516
+ // complete block never widens the removal span; the loop clears
517
+ // double-injection damage instead of leaving a live block behind a
518
+ // "Scrubbed" line.
519
+ let out = content;
520
+ let removedBlocks = 0;
521
+ // A leading orphan END (no START anywhere before it) must not halt the
522
+ // scan — later complete pairs are still totem's to remove, and the
523
+ // changeset contract is "loops until no complete pair remains". Skip
524
+ // past the orphan (it stays residue) and keep scanning; reset after a
525
+ // removal since indices shifted (GCA round on this PR).
526
+ let searchStart = 0;
527
+ for (;;) {
528
+ const endIdx = out.indexOf(REFLEX_END, searchStart);
529
+ if (endIdx === -1)
530
+ break;
531
+ const startIdx = out.lastIndexOf(REFLEX_START, endIdx);
532
+ if (startIdx === -1) {
533
+ searchStart = endIdx + REFLEX_END.length;
534
+ continue;
535
+ }
536
+ // Single-owner seams, mirroring upgradeReflexes with the block replaced
537
+ // by nothing: the prefix keeps at most one trailing newline (the blank
538
+ // line above the block was AI_PROMPT_BLOCK's own leading `\n`), and the
539
+ // end marker's own line terminator leaves with the block. Seam
540
+ // whitespace normalizes exactly as regen would — a seam contract, not a
541
+ // byte-restore of the pre-init file.
542
+ let before = out.slice(0, startIdx).replace(/(?:\r?\n)*$/, '\n');
543
+ if (before === '\n')
544
+ before = '';
545
+ let after = out.slice(endIdx + REFLEX_END.length);
546
+ if (/^[ \t\r\n]*$/.test(after)) {
547
+ after = '';
548
+ }
549
+ else if (after.startsWith('\r\n')) {
550
+ after = after.slice(2);
551
+ }
552
+ else if (after.startsWith('\n')) {
553
+ after = after.slice(1);
554
+ }
555
+ out = before + after;
556
+ removedBlocks++;
557
+ searchStart = 0;
558
+ }
559
+ // Marker residue = an unpaired START or END (either shape of the pre-fix
560
+ // corruption, or an inverted pair). The content around it cannot be
561
+ // attributed, so it is never scrubbed — and while a version marker
562
+ // remains, detectReflexStatus keeps reading the file as `current`
563
+ // (the mmnto-ai/totem#2602 corrupt-but-green state): healing is manual.
564
+ const residue = out.includes(REFLEX_START) || out.includes(REFLEX_END);
565
+ if (removedBlocks > 0) {
566
+ writeFileAtomicSync(filePath, out);
567
+ succeeded++;
568
+ summary.scrubbed.push(residue ? `${rel} (marker residue remains — remove manually)` : rel);
569
+ continue;
570
+ }
571
+ if (residue) {
572
+ summary.skipped.push(`${rel} (reflex marker residue — not scrubbed; remove manually, else init keeps reading the file as current)`);
573
+ continue;
574
+ }
575
+ // Legacy era (pre-marker v1), LEGACY_REFLEX_FILES only. init's own legacy
576
+ // upgrade (upgradeReflexes Case 2) bounds a v1 block at the next
577
+ // non-Totem H2, NOT at EOF — mirror it, or user content below a v1 block
578
+ // takes the same mmnto-ai/totem#2602 loss the marker path just fixed.
579
+ if (!LEGACY_REFLEX_FILES.includes(rel)) {
580
+ summary.skipped.push(`${rel} (no Totem block)`);
581
+ continue;
582
+ }
583
+ const primaryIdx = content.indexOf(LEGACY_SENTINEL);
584
+ const altIdx = content.indexOf(LEGACY_ALT_HEADING);
585
+ const legacyIdx = primaryIdx === -1 ? altIdx : altIdx === -1 ? primaryIdx : Math.min(primaryIdx, altIdx);
586
+ if (legacyIdx === -1) {
587
+ summary.skipped.push(`${rel} (no Totem block)`);
588
+ continue;
589
+ }
590
+ const afterLegacy = content.slice(legacyIdx);
591
+ // init.ts upgradeReflexes Case 2's boundary, VERBATIM: the next H2 that
592
+ // is not a Totem AI Integration heading ends the block. No extra
593
+ // alternatives — both injector generations guarded on the absence of
594
+ // "Totem Memory Reflexes" before writing, so a second Totem-titled H2
595
+ // below the block can only be user-authored (scoped fold round).
596
+ const nextH2 = afterLegacy.match(/\r?\n## (?!Totem AI Integration)/);
597
+ const blockEnd = nextH2?.index !== undefined ? legacyIdx + nextH2.index : content.length;
598
+ let before = content.slice(0, legacyIdx).replace(/(?:\r?\n)*$/, '\n');
599
+ if (before === '\n')
600
+ before = '';
601
+ let tail = content.slice(blockEnd);
602
+ // The boundary newline is seam-owned: with a non-empty prefix it supplies
603
+ // the blank-line separation; at byte 0 it would mint a leading blank line
604
+ // the seam rules forbid.
605
+ if (before === '') {
606
+ if (tail.startsWith('\r\n'))
607
+ tail = tail.slice(2);
608
+ else if (tail.startsWith('\n'))
609
+ tail = tail.slice(1);
610
+ }
611
+ writeFileAtomicSync(filePath, before + tail);
612
+ succeeded++;
613
+ summary.scrubbed.push(rel);
614
+ // totem-context: intentional cleanup — per-file best-effort scrub; the failure is a reported skip the accounting below counts toward the loud backstop, never a silent drop.
429
615
  }
430
- fs.writeFileSync(filePath, content.replace(activeMarker, '\n'), 'utf-8');
431
- summary.scrubbed.push(rel);
616
+ catch (err) {
617
+ failed++;
618
+ const msg = err instanceof Error ? err.message : String(err);
619
+ summary.skipped.push(`${rel} (could not scrub: ${msg})`);
620
+ }
621
+ }
622
+ // The loud systemic backstop Tenet 4's licensed shape requires alongside
623
+ // the per-item accounting (mmnto-ai/totem#2620 pre-build leg finding 4): when every
624
+ // attempted scrub failed, eject must not exit clean.
625
+ if (failed > 0 && succeeded === 0) {
626
+ const { TotemError } = await import('@mmnto/totem');
627
+ throw new TotemError('EJECT_FAILED', `all ${failed} reflex-file scrub attempt(s) failed — nothing was scrubbed; the summary above carries the per-file skip reasons and any .totem-bak recovery paths from earlier steps`, 'Check permissions/locks on the listed reflex files, then re-run totem eject.');
432
628
  }
433
629
  }
434
630
  /**
435
631
  * Delete Totem directories and config file.
436
632
  */
437
633
  function deleteArtifacts(cwd, summary) {
438
- const artifacts = ['.lancedb', '.totem'];
439
- for (const dir of artifacts) {
634
+ for (const dir of EJECT_ARTIFACT_DIRS) {
440
635
  const dirPath = path.join(cwd, dir);
441
636
  if (fs.existsSync(dirPath)) {
442
637
  try {
443
638
  fs.rmSync(dirPath, { recursive: true, force: true });
444
639
  summary.removed.push(`${dir}/`);
640
+ // totem-context: intentional cleanup — per-item best-effort delete; the failure is a reported skip
445
641
  }
446
642
  catch (err) {
447
643
  const msg = err instanceof Error ? err.message : String(err);
@@ -452,26 +648,137 @@ function deleteArtifacts(cwd, summary) {
452
648
  summary.skipped.push(`${dir}/ (not found)`);
453
649
  }
454
650
  }
455
- const configPath = path.join(cwd, 'totem.config.ts');
651
+ const configPath = path.join(cwd, EJECT_CONFIG_FILE);
456
652
  if (fs.existsSync(configPath)) {
457
653
  try {
458
654
  fs.unlinkSync(configPath);
459
- summary.removed.push('totem.config.ts');
655
+ summary.removed.push(EJECT_CONFIG_FILE);
656
+ // totem-context: intentional cleanup — per-item best-effort delete; the failure is a reported skip
460
657
  }
461
658
  catch (err) {
462
659
  const msg = err instanceof Error ? err.message : String(err);
463
- summary.skipped.push(`totem.config.ts (could not delete: ${msg})`);
660
+ summary.skipped.push(`${EJECT_CONFIG_FILE} (could not delete: ${msg})`);
464
661
  }
465
662
  }
466
663
  else {
467
- summary.skipped.push('totem.config.ts (not found)');
664
+ summary.skipped.push(`${EJECT_CONFIG_FILE} (not found)`);
665
+ }
666
+ }
667
+ // ─── Dirty-tree sense (User-File Mutation Contract rule 2) ──────
668
+ /** Cap on per-path rows printed under each sense header — the exact COUNTS
669
+ * always ride the headers; unbounded rows can scroll the consent prompt off
670
+ * screen (CR round on the PR). */
671
+ const SENSE_DISPLAY_MAX_ROWS = 20;
672
+ function capSenseRows(rows) {
673
+ const shown = rows.slice(0, SENSE_DISPLAY_MAX_ROWS).map((l) => ` ${l}`);
674
+ if (rows.length > SENSE_DISPLAY_MAX_ROWS) {
675
+ shown.push(` … and ${rows.length - SENSE_DISPLAY_MAX_ROWS} more`);
676
+ }
677
+ return shown;
678
+ }
679
+ /**
680
+ * Rule-2 sense of the User-File Mutation Contract (mmnto-ai/totem#2620): the
681
+ * git-native form of backup-before-modify is knowing whether a commit exists
682
+ * to diff against, so eject SAYS which of its mutation targets carry
683
+ * uncommitted changes before asking consent. Sense-and-say only (Tenet 13):
684
+ * never a block, never an exit-code change; `--force` skips the prompt, not
685
+ * the sense. Deletions count — deleting a tracked-dirty file is the limiting
686
+ * case of mutation with no revert point (author intent-read Q3).
687
+ *
688
+ * The roster derives from the SAME constants the scrub/removal steps consume
689
+ * (Tenet 20 — a hand-mirrored sense list would be the prohibited mirror
690
+ * inside the very rule built to prevent silent loss). `.git/hooks/*` never
691
+ * appears here: git cannot see that surface — its recovery is scrubHook's
692
+ * rule-3 `.totem-bak` artifact.
693
+ *
694
+ * Exported for the sense-contract tests.
695
+ */
696
+ export async function deriveDirtyTreeSense(cwd) {
697
+ let porcelain;
698
+ let ignoredVisible = true;
699
+ try {
700
+ // The roster imports live INSIDE the try: a sensor that can throw before
701
+ // its own catch is a sensor that can abort the eject it senses for
702
+ // (round 1, finding 11).
703
+ const { AI_TOOLS } = await import('./init-detect.js');
704
+ const { DISTRIBUTED_CLAUDE_SKILLS } = await import('./init-templates.js');
705
+ const roster = [
706
+ ...new Set([
707
+ ...TOTEM_SCAFFOLDED_FILES,
708
+ CLAUDE_SETTINGS_LOCAL_FILE,
709
+ CLAUDE_SETTINGS_FILE,
710
+ ...DISTRIBUTED_CLAUDE_SKILLS.map((s) => `.claude/skills/${s.name}/SKILL.md`),
711
+ ...AI_TOOLS.flatMap((t) => (t.reflexFile === null ? [] : [t.reflexFile])),
712
+ ...LEGACY_REFLEX_FILES,
713
+ ...EJECT_ARTIFACT_DIRS,
714
+ EJECT_CONFIG_FILE,
715
+ ]),
716
+ ];
717
+ // Lazy barrel import — the established eject runtime pattern
718
+ // (resolveEjectHooksContext pulls the core git barrel the same way).
719
+ // `--ignored=matching`: plain porcelain omits ignored paths by design, so
720
+ // without it the sense reads "invisible to git" as "committed and clean"
721
+ // for exactly the paths with NO git copy at all (round 1, finding 2).
722
+ const { safeExec } = await import('@mmnto/totem');
723
+ try {
724
+ porcelain = safeExec('git', ['status', '--porcelain', '--ignored=matching', '--', ...roster], { cwd });
725
+ }
726
+ catch (flagErr) {
727
+ // `--ignored=<mode>` landed in git 2.16 (2018). On an older git the
728
+ // flag-bearing call fails whole — degrade to the DIRTY-ONLY sense
729
+ // rather than to no sense at all (round 2, F3). Not-a-repo rethrows to
730
+ // the outer honest-line arm; a second failure rethrows too.
731
+ if (!(flagErr instanceof Error) || flagErr.message.includes('not a git repository')) {
732
+ throw flagErr;
733
+ }
734
+ porcelain = safeExec('git', ['status', '--porcelain', '--', ...roster], { cwd });
735
+ ignoredVisible = false;
736
+ }
737
+ // totem-context: intentional sensor degradation — the failure is SAID as an honest sense line (Tenet 13), never swallowed
468
738
  }
739
+ catch (err) {
740
+ const msg = err instanceof Error ? err.message : String(err);
741
+ // The stderr tail names the actual cause (git's own words); the wrapper
742
+ // message leads with the full ~24-path pathspec wall (round 2, F3). The
743
+ // instanceof guard keeps a non-object throw from TypeError-ing INSIDE this
744
+ // catch — a sensor that can crash its own catch can abort the eject it
745
+ // senses for (GCA round on the PR).
746
+ const stderrRaw = err instanceof Error ? err.stderr : undefined;
747
+ const stderrLine = typeof stderrRaw === 'string' ? stderrRaw.trim().split('\n')[0] : undefined;
748
+ const cause = stderrLine && stderrLine.length > 0 ? stderrLine : msg.split('\n')[0];
749
+ const line = msg.includes('not a git repository')
750
+ ? 'Not a git repository — files eject modifies here have no VCS revert point.'
751
+ : `Could not derive VCS state for eject targets (${cause}) — proceeding without the dirty-tree sense.`;
752
+ return { dirty: [], ignored: [], lines: [line] };
753
+ }
754
+ const rows = porcelain.length === 0 ? [] : porcelain.split('\n').filter((l) => l.trim() !== '');
755
+ const ignored = rows.filter((l) => l.startsWith('!!'));
756
+ const dirty = rows.filter((l) => !l.startsWith('!!'));
757
+ const lines = [];
758
+ if (dirty.length > 0) {
759
+ lines.push(`Uncommitted changes in ${dirty.length} path(s) eject will modify — no revert point until committed:`, ...capSenseRows(dirty));
760
+ }
761
+ if (ignored.length > 0) {
762
+ lines.push(`${ignored.length} gitignored path(s) eject will remove — git holds no copy of these at all:`, ...capSenseRows(ignored));
763
+ }
764
+ if (!ignoredVisible) {
765
+ // States exactly what was measured (round 3, finding 4): the flagged call
766
+ // failed and the plain one succeeded — not a diagnosed git version.
767
+ lines.push('`git status --ignored=matching` failed here while plain status succeeded (git ≥ 2.16 is needed for ignored-path visibility) — ignored deletion targets are not sensed.');
768
+ }
769
+ return { dirty, ignored, lines };
469
770
  }
470
771
  export async function ejectCommand(options) {
471
772
  const { stdin: input, stdout: output } = await import('node:process');
472
773
  const readline = await import('node:readline/promises');
473
774
  const { log } = await import('../ui.js');
474
775
  const cwd = process.cwd();
776
+ // Rule-2 sense line(s) ride ahead of the consent prompt — and under
777
+ // --force too: the flag skips the prompt, never the sense (mmnto-ai/totem#2620).
778
+ const sense = await deriveDirtyTreeSense(cwd);
779
+ for (const line of sense.lines) {
780
+ log.warn(TAG, line);
781
+ }
475
782
  if (!options.force) {
476
783
  const rl = readline.createInterface({ input, output });
477
784
  try {
@@ -526,8 +833,26 @@ export async function ejectCommand(options) {
526
833
  scrubCommittedClaudeSettings(cwd, summary);
527
834
  // 5. Scrub distributed Claude session-utility skills (Phase C slice 3)
528
835
  await scrubClaudeSkills(cwd, summary);
529
- // 6. Scrub AI reflex blocks from markdown files
530
- scrubReflexFiles(cwd, summary);
836
+ // 6. Scrub AI reflex blocks from markdown files. An EJECT_FAILED throw here
837
+ // (the Tenet-4 backstop — and ONLY it, round 2 F1) is DEFERRED past step 7
838
+ // and the summary print, then rethrown: a backstop that fires before the summary
839
+ // destroys the very accounting surface — the skip reasons and the
840
+ // `.totem-bak` paths — that licenses it, and leaves the error text pointing
841
+ // at a summary that never printed (2026-08-09 falsification round 1, finding 1).
842
+ let deferredThrow;
843
+ try {
844
+ await scrubReflexFiles(cwd, summary);
845
+ }
846
+ catch (err) {
847
+ // Tenet 4 rethrow-unexpected: only the EJECT_FAILED backstop is
848
+ // type-discriminable as expected here. A programming/packaging defect
849
+ // (module resolution, TypeError) must fail loud NOW — deferring it would
850
+ // buy step 7's deletions before the failure surfaces (round 2, F1).
851
+ if (!(err instanceof Error) || err.code !== 'EJECT_FAILED') {
852
+ throw err;
853
+ }
854
+ deferredThrow = err;
855
+ }
531
856
  // 7. Delete artifacts
532
857
  deleteArtifacts(cwd, summary);
533
858
  // Print summary
@@ -552,6 +877,16 @@ export async function ejectCommand(options) {
552
877
  if (summary.removed.length === 0 && summary.scrubbed.length === 0) {
553
878
  log.info(TAG, 'Nothing to remove — project appears clean.');
554
879
  }
880
+ // The sense line rides the summary under --force (mmnto-ai/totem#2620 leg
881
+ // finding 5): the prompt was skipped, so the no-revert-point state is
882
+ // restated where the operator is actually looking.
883
+ const noRevertCount = sense.dirty.length + sense.ignored.length;
884
+ if (options.force && noRevertCount > 0) {
885
+ log.warn(TAG, `--force skipped the consent prompt with ${noRevertCount} touched path(s) lacking a revert point — see the sense lines above.`);
886
+ }
887
+ if (deferredThrow !== undefined) {
888
+ throw deferredThrow;
889
+ }
555
890
  log.success(TAG, 'Totem has been ejected from this project.');
556
891
  }
557
892
  //# sourceMappingURL=eject.js.map