@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
@@ -2,9 +2,11 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { safeExec } from '@mmnto/totem';
5
6
  import { cleanTmpDir } from '../test-utils.js';
6
- import { ejectCommand, resolveEjectHooksContext, scrubPostCheckoutHook, scrubPostMergeHook, } from './eject.js';
7
- import { SKILL_MARKER_END, SKILL_MARKER_START } from './init-templates.js';
7
+ import { CLAUDE_SETTINGS_FILE, CLAUDE_SETTINGS_LOCAL_FILE, deriveDirtyTreeSense, EJECT_ARTIFACT_DIRS, EJECT_CONFIG_FILE, ejectCommand, LEGACY_REFLEX_FILES, resolveEjectHooksContext, scrubPostCheckoutHook, scrubPostMergeHook, scrubReflexFiles, TOTEM_SCAFFOLDED_FILES, } from './eject.js';
8
+ import { AI_TOOLS } from './init-detect.js';
9
+ import { AI_PROMPT_BLOCK, DISTRIBUTED_CLAUDE_SKILLS, REFLEX_END, REFLEX_START, SKILL_MARKER_END, SKILL_MARKER_START, } from './init-templates.js';
8
10
  import { resolveGitRootForHookPath, resolveHooksDir } from './install-hooks.js';
9
11
  // Mock the git seam (mmnto-ai/totem#2426): eject now resolves the git root +
10
12
  // hooks dir via the SAME #2422 helpers `hook install` uses. Tests must NOT spawn
@@ -22,6 +24,33 @@ vi.mock('./install-hooks.js', async (importOriginal) => {
22
24
  resolveHooksDir: vi.fn(),
23
25
  };
24
26
  });
27
+ // Same git-seam rule for the rule-2 dirty-tree sense (mmnto-ai/totem#2620):
28
+ // `deriveDirtyTreeSense` shells `git status --porcelain` via the core barrel's
29
+ // safeExec — mocked so no test ever spawns real git from a temp cwd. Default
30
+ // (armed in the file-scope beforeEach): empty porcelain = clean tree.
31
+ vi.mock('@mmnto/totem', async (importOriginal) => {
32
+ const actual = await importOriginal();
33
+ return { ...actual, safeExec: vi.fn(() => '') };
34
+ });
35
+ // Failure-injection seam for the shared atomic writer (mmnto-ai/totem#2620):
36
+ // passthrough to the REAL helper unless a test arms a failure, so the
37
+ // byte-exact scrub fixtures keep exercising real fs writes.
38
+ const atomicControl = {};
39
+ vi.mock('@mmnto/totem/fs-atomic', async (importOriginal) => {
40
+ const actual = await importOriginal();
41
+ const writeFileAtomicSync = ((...args) => {
42
+ if (atomicControl.failAll)
43
+ throw atomicControl.failAll;
44
+ if (atomicControl.failPathIncludes && args[0].includes(atomicControl.failPathIncludes.needle)) {
45
+ throw atomicControl.failPathIncludes.err;
46
+ }
47
+ if (atomicControl.failPathEndsWith && args[0].endsWith(atomicControl.failPathEndsWith.suffix)) {
48
+ throw atomicControl.failPathEndsWith.err;
49
+ }
50
+ return actual.writeFileAtomicSync(...args);
51
+ });
52
+ return { ...actual, writeFileAtomicSync };
53
+ });
25
54
  // Default seam behavior for the plain-checkout fixtures every describe below
26
55
  // builds: git root = cwd, hooks dir = <root>/.git/hooks. Re-established before
27
56
  // EVERY test (file-scope hook runs before each describe-scope hook); the
@@ -32,6 +61,14 @@ beforeEach(() => {
32
61
  unparseablePointer: false,
33
62
  }));
34
63
  vi.mocked(resolveHooksDir).mockImplementation((root) => path.join(root, '.git', 'hooks'));
64
+ vi.mocked(safeExec).mockReturnValue('');
65
+ // Call HISTORY resets per test too: assertions index mock.calls[0]/[1] as
66
+ // test-local (the roster parity + retry-argv checks), and an accumulated
67
+ // cross-test history makes those indices land on a prior test's call.
68
+ vi.mocked(safeExec).mockClear();
69
+ atomicControl.failAll = undefined;
70
+ atomicControl.failPathIncludes = undefined;
71
+ atomicControl.failPathEndsWith = undefined;
35
72
  });
36
73
  function makeTmpDir() {
37
74
  return fs.mkdtempSync(path.join(os.tmpdir(), 'totem-eject-'));
@@ -137,6 +174,48 @@ describe('ejectCommand', () => {
137
174
  expect(content).not.toContain('Totem AI Integration');
138
175
  expect(content).not.toContain('Memory Reflexes');
139
176
  });
177
+ // ─── Marker-bounded reflex scrub (mmnto-ai/totem#2602) ─────────────
178
+ // The span AFTER the end marker is contractually user content (the same span
179
+ // regen preserves byte-exactly since the regen-idempotence fix). The
180
+ // heading-to-EOF scrub deleted it and left an orphan start+version pair.
181
+ it('preserves user content below the end marker and removes exactly the block (marker era)', async () => {
182
+ const claudePath = path.join(cwd, 'CLAUDE.md');
183
+ fs.writeFileSync(claudePath, '# CLAUDE.md\n' + AI_PROMPT_BLOCK + '\n## My Custom Section\n\nDo not delete this!\n');
184
+ await ejectCommand({ force: true });
185
+ expect(fs.readFileSync(claudePath, 'utf-8')).toBe('# CLAUDE.md\n\n## My Custom Section\n\nDo not delete this!\n');
186
+ });
187
+ it('scrubs a file that is nothing but the block down to empty (marker at byte 0)', async () => {
188
+ const claudePath = path.join(cwd, 'CLAUDE.md');
189
+ fs.writeFileSync(claudePath, AI_PROMPT_BLOCK);
190
+ await ejectCommand({ force: true });
191
+ expect(fs.readFileSync(claudePath, 'utf-8')).toBe('');
192
+ });
193
+ it('leaves a file with an ORPHAN start marker byte-untouched (never heading-to-EOF)', async () => {
194
+ // This is exactly the corrupt-but-green state the pre-fix eject left behind:
195
+ // start + version markers with no end marker, heading inside, user content
196
+ // below. A heading-to-EOF scrub here would re-enact the data loss.
197
+ const claudePath = path.join(cwd, 'CLAUDE.md');
198
+ const corrupt = '# CLAUDE.md\n\n' +
199
+ REFLEX_START +
200
+ '\n<!-- totem:reflexes:version:10 -->\n\n## Totem AI Integration (Auto-Generated)\nblock body\n\n## My Custom Section\n\nDo not delete this!\n';
201
+ fs.writeFileSync(claudePath, corrupt);
202
+ await ejectCommand({ force: true });
203
+ expect(fs.readFileSync(claudePath, 'utf-8')).toBe(corrupt);
204
+ });
205
+ it('scrubs every tool-table reflex file, not just CLAUDE.md/.cursorrules', async () => {
206
+ // The roster is derived from AI_TOOLS — the pre-fix hardcoded pair silently
207
+ // skipped GEMINI.md, .junie/guidelines.md, and .github/copilot-instructions.md.
208
+ const rels = ['GEMINI.md', '.junie/guidelines.md', '.github/copilot-instructions.md'];
209
+ for (const rel of rels) {
210
+ const p = path.join(cwd, ...rel.split('/'));
211
+ fs.mkdirSync(path.dirname(p), { recursive: true });
212
+ fs.writeFileSync(p, '# Head\n' + AI_PROMPT_BLOCK);
213
+ }
214
+ await ejectCommand({ force: true });
215
+ for (const rel of rels) {
216
+ expect(fs.readFileSync(path.join(cwd, ...rel.split('/')), 'utf-8'), rel).toBe('# Head\n');
217
+ }
218
+ });
140
219
  it('deletes .lancedb, .totem, and totem.config.ts', async () => {
141
220
  fs.mkdirSync(path.join(cwd, '.lancedb'), { recursive: true });
142
221
  fs.mkdirSync(path.join(cwd, '.totem'), { recursive: true });
@@ -367,7 +446,7 @@ fi
367
446
  const summary = { removed: [], scrubbed: [], skipped: [] };
368
447
  scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
369
448
  expect(fs.existsSync(hookPath)).toBe(false);
370
- expect(summary.removed).toContain('.git/hooks/post-merge');
449
+ expect(summary.removed).toContain('.git/hooks/post-merge (pre-removal bytes: .git/hooks/post-merge.totem-bak)');
371
450
  });
372
451
  it('scrubs old unconditional hook format', () => {
373
452
  const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
@@ -375,7 +454,7 @@ fi
375
454
  const summary = { removed: [], scrubbed: [], skipped: [] };
376
455
  scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
377
456
  expect(fs.existsSync(hookPath)).toBe(false);
378
- expect(summary.removed).toContain('.git/hooks/post-merge');
457
+ expect(summary.removed).toContain('.git/hooks/post-merge (pre-removal bytes: .git/hooks/post-merge.totem-bak)');
379
458
  });
380
459
  it('preserves non-totem content when scrubbing', () => {
381
460
  const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
@@ -395,7 +474,7 @@ fi
395
474
  const content = fs.readFileSync(hookPath, 'utf-8');
396
475
  expect(content).toContain('deploy notification');
397
476
  expect(content).not.toContain('[totem]');
398
- expect(summary.scrubbed).toContain('.git/hooks/post-merge');
477
+ expect(summary.scrubbed).toContain('.git/hooks/post-merge (pre-scrub bytes: .git/hooks/post-merge.totem-bak)');
399
478
  });
400
479
  });
401
480
  // ─── scrubPostCheckoutHook (direct unit tests) ──────
@@ -428,7 +507,7 @@ fi
428
507
  const summary = { removed: [], scrubbed: [], skipped: [] };
429
508
  scrubPostCheckoutHook(path.join(cwd, '.git', 'hooks'), summary);
430
509
  expect(fs.existsSync(hookPath)).toBe(false);
431
- expect(summary.removed).toContain('.git/hooks/post-checkout');
510
+ expect(summary.removed).toContain('.git/hooks/post-checkout (pre-removal bytes: .git/hooks/post-checkout.totem-bak)');
432
511
  });
433
512
  it('preserves non-Totem content in post-checkout hook', () => {
434
513
  const hookPath = path.join(cwd, '.git', 'hooks', 'post-checkout');
@@ -452,7 +531,7 @@ fi
452
531
  const content = fs.readFileSync(hookPath, 'utf-8');
453
532
  expect(content).toContain('deploy notification');
454
533
  expect(content).not.toContain('[totem]');
455
- expect(summary.scrubbed).toContain('.git/hooks/post-checkout');
534
+ expect(summary.scrubbed).toContain('.git/hooks/post-checkout (pre-scrub bytes: .git/hooks/post-checkout.totem-bak)');
456
535
  });
457
536
  });
458
537
  describe('eject — distributed Claude skills (mmnto-ai/totem#1890 Phase C slice 3)', () => {
@@ -643,4 +722,475 @@ describe('ejectCommand git-hook resolution (mmnto-ai/totem#2426)', () => {
643
722
  cleanTmpDir(dir);
644
723
  });
645
724
  });
725
+ // ─── scrubReflexFiles — pairing, residue, legacy boundary ─────────────
726
+ // Direct-drive summary-contract tests from the mmnto-ai/totem#2602
727
+ // falsification round: END-anchored pairing, the removal loop, residue
728
+ // reporting, and the legacy next-H2 boundary.
729
+ describe('scrubReflexFiles — marker pairing, residue, and legacy boundary', () => {
730
+ let cwd;
731
+ beforeEach(() => {
732
+ cwd = makeTmpDir();
733
+ });
734
+ afterEach(() => {
735
+ cleanTmpDir(cwd);
736
+ });
737
+ function freshSummary() {
738
+ return { removed: [], scrubbed: [], skipped: [] };
739
+ }
740
+ function write(rel, content) {
741
+ const p = path.join(cwd, ...rel.split('/'));
742
+ fs.mkdirSync(path.dirname(p), { recursive: true });
743
+ fs.writeFileSync(p, content, 'utf-8');
744
+ return p;
745
+ }
746
+ it('an orphan START above a complete pair never widens the span — content between them survives', async () => {
747
+ // The orphan head is exactly the corrupt state the pre-fix eject produced;
748
+ // a later re-init appended a fresh complete block below it (upgradeReflexes
749
+ // Case 3). First-occurrence pairing deleted everything between the orphan
750
+ // and the real block's END.
751
+ const orphanHead = '# Head\n\n' +
752
+ REFLEX_START +
753
+ '\n<!-- totem:reflexes:version:9 -->\n\n## My Custom Section\n\nDo not delete this!\n';
754
+ const p = write('CLAUDE.md', orphanHead + AI_PROMPT_BLOCK);
755
+ const summary = freshSummary();
756
+ await scrubReflexFiles(cwd, summary);
757
+ expect(fs.readFileSync(p, 'utf-8')).toBe(orphanHead);
758
+ expect(summary.scrubbed).toEqual(['CLAUDE.md (marker residue remains — remove manually)']);
759
+ });
760
+ it('double-injection damage clears — both complete blocks removed, none left behind a green line', async () => {
761
+ const p = write('CLAUDE.md', '# Head\n' + AI_PROMPT_BLOCK + AI_PROMPT_BLOCK);
762
+ const summary = freshSummary();
763
+ await scrubReflexFiles(cwd, summary);
764
+ expect(fs.readFileSync(p, 'utf-8')).toBe('# Head\n');
765
+ expect(summary.scrubbed).toEqual(['CLAUDE.md']);
766
+ });
767
+ it('inverted markers (END above START) are residue: file byte-untouched and reported', async () => {
768
+ const corrupt = '# Head\n\n' + REFLEX_END + '\nmiddle\n' + REFLEX_START + '\n';
769
+ const p = write('CLAUDE.md', corrupt);
770
+ const summary = freshSummary();
771
+ await scrubReflexFiles(cwd, summary);
772
+ expect(fs.readFileSync(p, 'utf-8')).toBe(corrupt);
773
+ expect(summary.skipped).toEqual([
774
+ 'CLAUDE.md (reflex marker residue — not scrubbed; remove manually, else init keeps reading the file as current)',
775
+ ]);
776
+ });
777
+ it('legacy block is bounded at the next non-Totem H2, not EOF — user content below survives', async () => {
778
+ const p = write('CLAUDE.md', '# My Project\n\nSome instructions.\n\n## Totem AI Integration (Auto-Generated)\nblock body\n\n## My Custom Section\n\nDo not delete this!\n');
779
+ const summary = freshSummary();
780
+ await scrubReflexFiles(cwd, summary);
781
+ expect(fs.readFileSync(p, 'utf-8')).toBe('# My Project\n\nSome instructions.\n\n## My Custom Section\n\nDo not delete this!\n');
782
+ expect(summary.scrubbed).toEqual(['CLAUDE.md']);
783
+ });
784
+ it('a bare legacy heading in a post-marker-era roster file is user-authored — byte-untouched', async () => {
785
+ // GEMINI.md joined the tool table after markers shipped, so a totem-written
786
+ // marker-less block there is impossible; heading-to-EOF on it could only
787
+ // ever eat a user's hand-authored section.
788
+ const userAuthored = '# Gemini notes\n\n## Totem AI Integration (Auto-Generated)\nI hand-copied this section.\n';
789
+ const p = write('GEMINI.md', userAuthored);
790
+ const summary = freshSummary();
791
+ await scrubReflexFiles(cwd, summary);
792
+ expect(fs.readFileSync(p, 'utf-8')).toBe(userAuthored);
793
+ expect(summary.skipped).toEqual(['GEMINI.md (no Totem block)']);
794
+ });
795
+ it("a user's own '## Totem Memory Reflexes…' H2 below a legacy block terminates the span (init Case 2 boundary, verbatim)", async () => {
796
+ // Both injector generations guarded on the absence of "Totem Memory
797
+ // Reflexes" before writing, so a second Totem-titled H2 below the block is
798
+ // always user-authored — it must bound the scrub, not be eaten by it.
799
+ const p = write('CLAUDE.md', '# P\n\n## Totem AI Integration (Auto-Generated)\nblock body\n\n## Totem Memory Reflexes tips\n\nmy notes\n');
800
+ const summary = freshSummary();
801
+ await scrubReflexFiles(cwd, summary);
802
+ expect(fs.readFileSync(p, 'utf-8')).toBe('# P\n\n## Totem Memory Reflexes tips\n\nmy notes\n');
803
+ });
804
+ it('the removal loop preserves user content BETWEEN two complete blocks', async () => {
805
+ const p = write('CLAUDE.md', '# Head\n' + AI_PROMPT_BLOCK + 'USER-MID\n' + AI_PROMPT_BLOCK);
806
+ const summary = freshSummary();
807
+ await scrubReflexFiles(cwd, summary);
808
+ expect(fs.readFileSync(p, 'utf-8')).toBe('# Head\nUSER-MID\n');
809
+ expect(summary.scrubbed).toEqual(['CLAUDE.md']);
810
+ });
811
+ it('a legacy block at byte 0 does not mint a leading blank line above the surviving user section', async () => {
812
+ const p = write('CLAUDE.md', '## Totem AI Integration (Auto-Generated)\nbody\n\n## User Section\n\nkeep me\n');
813
+ const summary = freshSummary();
814
+ await scrubReflexFiles(cwd, summary);
815
+ expect(fs.readFileSync(p, 'utf-8')).toBe('## User Section\n\nkeep me\n');
816
+ });
817
+ it('a leading orphan END does not halt the scan — the complete pair below it is still removed', async () => {
818
+ // "Loops until no complete pair remains" must hold even when an unpaired
819
+ // END sits above the real block (GCA round on this PR): the orphan stays
820
+ // residue, the well-formed block goes.
821
+ const residueHead = '# Head\n\n' + REFLEX_END + '\nuser between\n';
822
+ const p = write('CLAUDE.md', residueHead + AI_PROMPT_BLOCK);
823
+ const summary = freshSummary();
824
+ await scrubReflexFiles(cwd, summary);
825
+ expect(fs.readFileSync(p, 'utf-8')).toBe(residueHead);
826
+ expect(summary.scrubbed).toEqual(['CLAUDE.md (marker residue remains — remove manually)']);
827
+ });
828
+ it('an unreadable roster entry degrades to a could-not-scrub skip and later files still scrub', async () => {
829
+ // A DIRECTORY at CLAUDE.md makes readFileSync throw (EISDIR class) — the
830
+ // per-file best-effort contract: report the skip, keep going (CR round on
831
+ // this PR; live-verified for EISDIR and EPERM on the falsification round).
832
+ fs.mkdirSync(path.join(cwd, 'CLAUDE.md'));
833
+ const geminiPath = write('GEMINI.md', '# Head\n' + AI_PROMPT_BLOCK);
834
+ const summary = freshSummary();
835
+ await scrubReflexFiles(cwd, summary);
836
+ expect(summary.skipped).toHaveLength(1);
837
+ expect(summary.skipped[0]).toMatch(/^CLAUDE\.md \(could not scrub: /);
838
+ expect(fs.readFileSync(geminiPath, 'utf-8')).toBe('# Head\n');
839
+ expect(summary.scrubbed).toEqual(['GEMINI.md']);
840
+ });
841
+ it('the retired pre-marker target .gemini/gemini.md still gets its legacy block scrubbed', async () => {
842
+ const p = write('.gemini/gemini.md', '# Old gemini\n\n## Totem AI Integration (Auto-Generated)\nold body\n');
843
+ const summary = freshSummary();
844
+ await scrubReflexFiles(cwd, summary);
845
+ expect(fs.readFileSync(p, 'utf-8')).toBe('# Old gemini\n');
846
+ expect(summary.scrubbed).toEqual(['.gemini/gemini.md']);
847
+ });
848
+ });
849
+ // ─── Rule-3 recovery artifact + rule-2 sense + Tenet-4 backstop (mmnto-ai/totem#2620) ───
850
+ describe('scrubHook recovery artifact (User-File Mutation Contract rule 3)', () => {
851
+ let cwd;
852
+ beforeEach(() => {
853
+ cwd = makeTmpDir();
854
+ fs.mkdirSync(path.join(cwd, '.git', 'hooks'), { recursive: true });
855
+ });
856
+ afterEach(() => {
857
+ cleanTmpDir(cwd);
858
+ });
859
+ const MIXED_HOOK = `#!/bin/sh
860
+ echo "deploy notification"
861
+ # [totem] post-merge hook — background re-index after pull/merge.
862
+
863
+ if git diff-tree -r --name-only ORIG_HEAD HEAD 2>/dev/null | grep -q '\\.totem/lessons/'; then
864
+ (pnpm exec totem sync --incremental --quiet > .git/totem-sync.log 2>&1) &
865
+ fi
866
+ # [totem] end post-merge
867
+ `;
868
+ const TOTEM_ONLY_HOOK = `#!/bin/sh
869
+ # [totem] post-merge hook — background re-index after pull/merge.
870
+
871
+ echo "[totem] Triggering background re-index..."
872
+ (pnpm exec totem sync --incremental > .git/totem-sync.log 2>&1) &
873
+ `;
874
+ it('partial scrub writes the bak with exact pre-mutation bytes BEFORE mutating', () => {
875
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
876
+ fs.writeFileSync(hookPath, MIXED_HOOK);
877
+ const summary = { removed: [], scrubbed: [], skipped: [] };
878
+ scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
879
+ expect(fs.readFileSync(`${hookPath}.totem-bak`, 'utf-8')).toBe(MIXED_HOOK);
880
+ expect(fs.readFileSync(hookPath, 'utf-8')).not.toContain('[totem]');
881
+ expect(summary.scrubbed).toContain('.git/hooks/post-merge (pre-scrub bytes: .git/hooks/post-merge.totem-bak)');
882
+ });
883
+ it('full-removal path baks too — surface class, not content claim (intent-read Q1)', () => {
884
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
885
+ fs.writeFileSync(hookPath, TOTEM_ONLY_HOOK);
886
+ const summary = { removed: [], scrubbed: [], skipped: [] };
887
+ scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
888
+ expect(fs.existsSync(hookPath)).toBe(false);
889
+ expect(fs.readFileSync(`${hookPath}.totem-bak`, 'utf-8')).toBe(TOTEM_ONLY_HOOK);
890
+ });
891
+ it('bak failure skips the mutation entirely — recovery-before-mutation is load-bearing', () => {
892
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
893
+ fs.writeFileSync(hookPath, MIXED_HOOK);
894
+ atomicControl.failPathIncludes = {
895
+ needle: '.totem-bak',
896
+ err: Object.assign(new Error('ENOSPC: injected'), { code: 'ENOSPC' }),
897
+ };
898
+ const summary = { removed: [], scrubbed: [], skipped: [] };
899
+ scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
900
+ expect(fs.readFileSync(hookPath, 'utf-8')).toBe(MIXED_HOOK);
901
+ expect(fs.existsSync(`${hookPath}.totem-bak`)).toBe(false);
902
+ expect(summary.scrubbed).toEqual([]);
903
+ expect(summary.removed).toEqual([]);
904
+ expect(summary.skipped.some((s) => s.includes('recovery backup failed — hook left untouched'))).toBe(true);
905
+ });
906
+ it.skipIf(process.platform === 'win32')('bak carries the source hook mode (0755)', () => {
907
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
908
+ fs.writeFileSync(hookPath, MIXED_HOOK);
909
+ fs.chmodSync(hookPath, 0o755);
910
+ const summary = { removed: [], scrubbed: [], skipped: [] };
911
+ scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
912
+ expect(fs.statSync(`${hookPath}.totem-bak`).mode & 0o7777).toBe(0o755);
913
+ expect(fs.statSync(hookPath).mode & 0o7777).toBe(0o755);
914
+ });
915
+ it('ORDERING control: induced hook-write failure leaves the bak present and the hook byte-untouched (the strategy#1045 addendum, spec-declared invariant)', () => {
916
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
917
+ fs.writeFileSync(hookPath, MIXED_HOOK);
918
+ // Fails the write whose target IS the hook; the bak path ends '.totem-bak'
919
+ // and passes through — a bak written AFTER the mutation could not survive
920
+ // this arm with the hook intact.
921
+ atomicControl.failPathEndsWith = {
922
+ suffix: 'post-merge',
923
+ err: Object.assign(new Error('EACCES: injected'), { code: 'EACCES' }),
924
+ };
925
+ const summary = { removed: [], scrubbed: [], skipped: [] };
926
+ scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
927
+ expect(Buffer.compare(fs.readFileSync(`${hookPath}.totem-bak`), Buffer.from(MIXED_HOOK))).toBe(0);
928
+ expect(Buffer.compare(fs.readFileSync(hookPath), Buffer.from(MIXED_HOOK))).toBe(0);
929
+ expect(summary.scrubbed).toEqual([]);
930
+ expect(summary.removed).toEqual([]);
931
+ expect(summary.skipped.some((s) => s.includes('EACCES: injected'))).toBe(true);
932
+ });
933
+ it('non-UTF-8 hook bytes: reported skip, no mutation, no bak — scrubbing would substitute U+FFFD into kept user content', () => {
934
+ const hookPath = path.join(cwd, '.git', 'hooks', 'post-merge');
935
+ const raw = Buffer.concat([
936
+ Buffer.from('#!/bin/sh\necho "caf'),
937
+ Buffer.from([0xe9]),
938
+ Buffer.from('"\n# [totem] post-merge hook — x\n# [totem] end post-merge\n'),
939
+ ]);
940
+ fs.writeFileSync(hookPath, raw);
941
+ const summary = { removed: [], scrubbed: [], skipped: [] };
942
+ scrubPostMergeHook(path.join(cwd, '.git', 'hooks'), summary);
943
+ expect(Buffer.compare(fs.readFileSync(hookPath), raw)).toBe(0);
944
+ expect(fs.existsSync(`${hookPath}.totem-bak`)).toBe(false);
945
+ expect(summary.skipped.some((s) => s.includes('non-UTF-8 content'))).toBe(true);
946
+ expect(summary.scrubbed).toEqual([]);
947
+ });
948
+ });
949
+ describe('ejectCommand deferred backstop (falsification finding 1 — the summary is the accounting surface)', () => {
950
+ let cwd;
951
+ let originalCwd;
952
+ beforeEach(() => {
953
+ cwd = makeTmpDir();
954
+ originalCwd = process.cwd();
955
+ process.chdir(cwd);
956
+ fs.mkdirSync(path.join(cwd, '.git', 'hooks'), { recursive: true });
957
+ });
958
+ afterEach(() => {
959
+ process.chdir(originalCwd);
960
+ cleanTmpDir(cwd);
961
+ });
962
+ it('EJECT_FAILED rethrows only AFTER step 7 ran and the summary (skip reasons) printed', async () => {
963
+ fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), `# P\n\n${REFLEX_START}\nbody\n${REFLEX_END}\n`);
964
+ fs.writeFileSync(path.join(cwd, 'totem.config.ts'), 'export default {};\n');
965
+ atomicControl.failAll = Object.assign(new Error('EPERM: injected'), { code: 'EPERM' });
966
+ const { log } = await import('../ui.js');
967
+ const dimSpy = vi.spyOn(log, 'dim');
968
+ const successSpy = vi.spyOn(log, 'success');
969
+ await expect(ejectCommand({ force: true })).rejects.toThrow(/all 1 reflex-file scrub attempt/);
970
+ // Step 7 still ran: the artifact delete is not stranded by the backstop.
971
+ expect(fs.existsSync(path.join(cwd, 'totem.config.ts'))).toBe(false);
972
+ // The accounting surface printed BEFORE the throw: per-file skip reasons.
973
+ const dims = dimSpy.mock.calls.map((c) => String(c[1]));
974
+ expect(dims.some((m) => m.includes('could not scrub'))).toBe(true);
975
+ // And the success line did NOT print — the run still fails loud.
976
+ const successes = successSpy.mock.calls.map((c) => String(c[1]));
977
+ expect(successes.some((m) => m.includes('has been ejected'))).toBe(false);
978
+ dimSpy.mockRestore();
979
+ successSpy.mockRestore();
980
+ });
981
+ });
982
+ describe('deriveDirtyTreeSense (User-File Mutation Contract rule 2)', () => {
983
+ let cwd;
984
+ beforeEach(() => {
985
+ cwd = makeTmpDir();
986
+ });
987
+ afterEach(() => {
988
+ cleanTmpDir(cwd);
989
+ });
990
+ it('clean tree: no lines, no dirty paths', async () => {
991
+ vi.mocked(safeExec).mockReturnValue('');
992
+ const sense = await deriveDirtyTreeSense(cwd);
993
+ expect(sense.dirty).toEqual([]);
994
+ expect(sense.lines).toEqual([]);
995
+ });
996
+ it('dirty roster paths: says which files have no revert point, never blocks', async () => {
997
+ vi.mocked(safeExec).mockReturnValue(' M CLAUDE.md\n?? .totem/');
998
+ const sense = await deriveDirtyTreeSense(cwd);
999
+ expect(sense.dirty).toHaveLength(2);
1000
+ expect(sense.lines[0]).toContain('Uncommitted changes in 2 path(s)');
1001
+ expect(sense.lines.join('\n')).toContain('CLAUDE.md');
1002
+ expect(sense.lines.join('\n')).toContain('.totem/');
1003
+ });
1004
+ it('roster is SET-EQUAL to the constants the scrub/removal steps consume (Tenet 20; a sampled assertion cannot detect a dropped member)', async () => {
1005
+ vi.mocked(safeExec).mockReturnValue('');
1006
+ await deriveDirtyTreeSense(cwd);
1007
+ const [cmd, args] = vi.mocked(safeExec).mock.calls[0];
1008
+ expect(cmd).toBe('git');
1009
+ expect(args.slice(0, 4)).toEqual(['status', '--porcelain', '--ignored=matching', '--']);
1010
+ // Parity assertion: both sides read the same exported source constants
1011
+ // (round 2, F5 closed the six re-declared literals). The one narrated
1012
+ // literal left is the skills-path SHAPE, which mirrors scrubClaudeSkills'
1013
+ // path.join construction.
1014
+ const expected = [
1015
+ ...new Set([
1016
+ ...TOTEM_SCAFFOLDED_FILES,
1017
+ CLAUDE_SETTINGS_LOCAL_FILE,
1018
+ CLAUDE_SETTINGS_FILE,
1019
+ ...DISTRIBUTED_CLAUDE_SKILLS.map((s) => `.claude/skills/${s.name}/SKILL.md`),
1020
+ ...AI_TOOLS.flatMap((t) => (t.reflexFile === null ? [] : [t.reflexFile])),
1021
+ ...LEGACY_REFLEX_FILES,
1022
+ ...EJECT_ARTIFACT_DIRS,
1023
+ EJECT_CONFIG_FILE,
1024
+ ]),
1025
+ ];
1026
+ expect([...args.slice(4)].sort()).toEqual([...expected].sort());
1027
+ });
1028
+ it('older git rejecting --ignored=matching: degrades to the DIRTY-ONLY sense with an honest visibility line (round 2, F3)', async () => {
1029
+ vi.mocked(safeExec)
1030
+ .mockImplementationOnce(() => {
1031
+ throw new Error('Command failed: git status --porcelain --ignored=matching -- CLAUDE.md\nerror: unknown option `ignored=matching`');
1032
+ })
1033
+ .mockReturnValueOnce(' M CLAUDE.md');
1034
+ const sense = await deriveDirtyTreeSense(cwd);
1035
+ expect(sense.dirty).toHaveLength(1);
1036
+ expect(sense.lines.join('\n')).toContain('Uncommitted changes in 1 path(s)');
1037
+ expect(sense.lines.join('\n')).toContain('ignored deletion targets are not sensed');
1038
+ // The retry must actually DROP the flag — a copy/paste of the flagged args
1039
+ // would keep every other assertion green while the fallback no-ops against
1040
+ // a real pre-2.16 git (round 3, finding 1).
1041
+ expect(vi.mocked(safeExec).mock.calls[1][1].slice(0, 3)).toEqual([
1042
+ 'status',
1043
+ '--porcelain',
1044
+ '--',
1045
+ ]);
1046
+ });
1047
+ it('degrade line surfaces the stderr cause, not the pathspec wall (round 2, F3)', async () => {
1048
+ vi.mocked(safeExec).mockImplementation(() => {
1049
+ throw Object.assign(new Error('Command failed: git status --porcelain --ignored=matching -- <24 paths>'), { stderr: 'fatal: unable to read tree abc123\n' });
1050
+ });
1051
+ const sense = await deriveDirtyTreeSense(cwd);
1052
+ expect(sense.lines).toHaveLength(1);
1053
+ expect(sense.lines[0]).toContain('fatal: unable to read tree abc123');
1054
+ expect(sense.lines[0]).not.toContain('<24 paths>');
1055
+ });
1056
+ it('gitignored roster paths: says git holds no copy at all (the secrets-deletion class)', async () => {
1057
+ vi.mocked(safeExec).mockReturnValue('!! .lancedb/\n!! .totem/secrets.json');
1058
+ const sense = await deriveDirtyTreeSense(cwd);
1059
+ expect(sense.dirty).toEqual([]);
1060
+ expect(sense.ignored).toHaveLength(2);
1061
+ expect(sense.lines[0]).toContain('git holds no copy');
1062
+ expect(sense.lines.join('\n')).toContain('.totem/secrets.json');
1063
+ });
1064
+ it('per-path display rows cap at a named maximum; exact counts stay in the header (CR round)', async () => {
1065
+ const rows = Array.from({ length: 25 }, (_, i) => ` M .totem/file-${i}.md`).join('\n');
1066
+ vi.mocked(safeExec).mockReturnValue(rows);
1067
+ const sense = await deriveDirtyTreeSense(cwd);
1068
+ expect(sense.dirty).toHaveLength(25);
1069
+ expect(sense.lines[0]).toContain('Uncommitted changes in 25 path(s)');
1070
+ // Header + 20 shown rows + the "and N more" trailer.
1071
+ expect(sense.lines).toHaveLength(22);
1072
+ expect(sense.lines[21]).toContain('and 5 more');
1073
+ });
1074
+ it('not a git repository: honest no-revert-point line, still proceeds', async () => {
1075
+ vi.mocked(safeExec).mockImplementation(() => {
1076
+ throw new Error('Command failed: git status --porcelain\nfatal: not a git repository (or any of the parent directories): .git');
1077
+ });
1078
+ const sense = await deriveDirtyTreeSense(cwd);
1079
+ expect(sense.dirty).toEqual([]);
1080
+ expect(sense.lines).toEqual([
1081
+ 'Not a git repository — files eject modifies here have no VCS revert point.',
1082
+ ]);
1083
+ });
1084
+ it('underivable VCS state: honest could-not-derive line, still proceeds', async () => {
1085
+ vi.mocked(safeExec).mockImplementation(() => {
1086
+ throw new Error('Command failed: git status --porcelain\nfatal: unable to read tree');
1087
+ });
1088
+ const sense = await deriveDirtyTreeSense(cwd);
1089
+ expect(sense.dirty).toEqual([]);
1090
+ expect(sense.lines).toHaveLength(1);
1091
+ expect(sense.lines[0]).toContain('Could not derive VCS state');
1092
+ });
1093
+ });
1094
+ describe('scrubReflexFiles loud backstop (Tenet 4 licensed shape, #2620 pre-build leg finding 4)', () => {
1095
+ let cwd;
1096
+ beforeEach(() => {
1097
+ cwd = makeTmpDir();
1098
+ });
1099
+ afterEach(() => {
1100
+ cleanTmpDir(cwd);
1101
+ });
1102
+ const markerBlock = (body) => `# My project\n\n${REFLEX_START}\n${body}\n${REFLEX_END}\n`;
1103
+ it('every attempted scrub failing throws EJECT_FAILED instead of exiting clean', async () => {
1104
+ fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), markerBlock('claude body'));
1105
+ atomicControl.failAll = Object.assign(new Error('EPERM: injected'), { code: 'EPERM' });
1106
+ const summary = { removed: [], scrubbed: [], skipped: [] };
1107
+ await expect(scrubReflexFiles(cwd, summary)).rejects.toThrow(/all 1 reflex-file scrub attempt\(s\) failed/);
1108
+ // Per-item accounting still fired before the backstop threw.
1109
+ expect(summary.skipped.some((s) => s.includes('could not scrub'))).toBe(true);
1110
+ });
1111
+ it('non-UTF-8 reflex file: reported skip, bytes untouched, benign to the backstop (round 2, F2)', async () => {
1112
+ const raw = Buffer.concat([
1113
+ Buffer.from('# Proj caf'),
1114
+ Buffer.from([0xe9]),
1115
+ Buffer.from(` notes\n\n${REFLEX_START}\nbody\n${REFLEX_END}\n`),
1116
+ ]);
1117
+ fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), raw);
1118
+ const summary = { removed: [], scrubbed: [], skipped: [] };
1119
+ await scrubReflexFiles(cwd, summary);
1120
+ expect(Buffer.compare(fs.readFileSync(path.join(cwd, 'CLAUDE.md')), raw)).toBe(0);
1121
+ expect(summary.scrubbed).toEqual([]);
1122
+ expect(summary.skipped.some((s) => s.startsWith('CLAUDE.md (non-UTF-8 content'))).toBe(true);
1123
+ });
1124
+ it('non-UTF-8 file WITHOUT a Totem block keeps its honest no-block line — no false instruction (round 3, finding 3)', async () => {
1125
+ const raw = Buffer.concat([
1126
+ Buffer.from('# Proj caf'),
1127
+ Buffer.from([0xe9]),
1128
+ Buffer.from(' notes — never touched by totem\n'),
1129
+ ]);
1130
+ fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), raw);
1131
+ const summary = { removed: [], scrubbed: [], skipped: [] };
1132
+ await scrubReflexFiles(cwd, summary);
1133
+ expect(Buffer.compare(fs.readFileSync(path.join(cwd, 'CLAUDE.md')), raw)).toBe(0);
1134
+ expect(summary.skipped.some((s) => s.includes('non-UTF-8'))).toBe(false);
1135
+ expect(summary.skipped).toContain('CLAUDE.md (no Totem block)');
1136
+ });
1137
+ it('partial success does NOT trip the backstop — per-item accounting covers it', async () => {
1138
+ fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), markerBlock('claude body'));
1139
+ fs.writeFileSync(path.join(cwd, 'GEMINI.md'), markerBlock('gemini body'));
1140
+ atomicControl.failPathIncludes = {
1141
+ needle: 'GEMINI.md',
1142
+ err: Object.assign(new Error('EPERM: injected'), { code: 'EPERM' }),
1143
+ };
1144
+ const summary = { removed: [], scrubbed: [], skipped: [] };
1145
+ await scrubReflexFiles(cwd, summary);
1146
+ expect(summary.scrubbed).toContain('CLAUDE.md');
1147
+ expect(summary.skipped.some((s) => s.startsWith('GEMINI.md (could not scrub'))).toBe(true);
1148
+ });
1149
+ });
1150
+ describe('ejectCommand --force dirty-sense trailer (mmnto-ai/totem#2620 rule 2)', () => {
1151
+ let cwd;
1152
+ let originalCwd;
1153
+ beforeEach(() => {
1154
+ cwd = makeTmpDir();
1155
+ originalCwd = process.cwd();
1156
+ process.chdir(cwd);
1157
+ fs.mkdirSync(path.join(cwd, '.git', 'hooks'), { recursive: true });
1158
+ });
1159
+ afterEach(() => {
1160
+ process.chdir(originalCwd);
1161
+ cleanTmpDir(cwd);
1162
+ });
1163
+ it('says the no-revert-point state before the (skipped) prompt AND restates it after the summary', async () => {
1164
+ vi.mocked(safeExec).mockReturnValue(' M CLAUDE.md');
1165
+ const { log } = await import('../ui.js');
1166
+ const warnSpy = vi.spyOn(log, 'warn');
1167
+ await ejectCommand({ force: true });
1168
+ const messages = warnSpy.mock.calls.map((c) => String(c[1]));
1169
+ expect(messages.some((m) => m.includes('Uncommitted changes in 1 path(s)'))).toBe(true);
1170
+ // Full trailer wording pinned — a substring that also matched the pre-fold
1171
+ // string could not detect a wording regression (round 2, F6).
1172
+ expect(messages.some((m) => m.includes('--force skipped the consent prompt with 1 touched path(s) lacking a revert point'))).toBe(true);
1173
+ warnSpy.mockRestore();
1174
+ });
1175
+ it('gitignored-only dirty state still fires the --force trailer (round 2, F6 — the new class)', async () => {
1176
+ vi.mocked(safeExec).mockReturnValue('!! .totem/secrets.json');
1177
+ const { log } = await import('../ui.js');
1178
+ const warnSpy = vi.spyOn(log, 'warn');
1179
+ await ejectCommand({ force: true });
1180
+ const messages = warnSpy.mock.calls.map((c) => String(c[1]));
1181
+ expect(messages.some((m) => m.includes('git holds no copy'))).toBe(true);
1182
+ expect(messages.some((m) => m.includes('--force skipped the consent prompt with 1 touched path(s) lacking a revert point'))).toBe(true);
1183
+ warnSpy.mockRestore();
1184
+ });
1185
+ it('clean tree under --force: no sense lines, no trailer', async () => {
1186
+ vi.mocked(safeExec).mockReturnValue('');
1187
+ const { log } = await import('../ui.js');
1188
+ const warnSpy = vi.spyOn(log, 'warn');
1189
+ await ejectCommand({ force: true });
1190
+ const messages = warnSpy.mock.calls.map((c) => String(c[1]));
1191
+ expect(messages.some((m) => m.includes('Uncommitted changes'))).toBe(false);
1192
+ expect(messages.some((m) => m.includes('--force skipped the consent prompt'))).toBe(false);
1193
+ warnSpy.mockRestore();
1194
+ });
1195
+ });
646
1196
  //# sourceMappingURL=eject.test.js.map