@mmnto/cli 1.111.0 → 1.112.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 (44) hide show
  1. package/dist/commands/doctor-estate.d.ts +38 -0
  2. package/dist/commands/doctor-estate.d.ts.map +1 -0
  3. package/dist/commands/doctor-estate.js +234 -0
  4. package/dist/commands/doctor-estate.js.map +1 -0
  5. package/dist/commands/doctor-estate.test.d.ts +10 -0
  6. package/dist/commands/doctor-estate.test.d.ts.map +1 -0
  7. package/dist/commands/doctor-estate.test.js +459 -0
  8. package/dist/commands/doctor-estate.test.js.map +1 -0
  9. package/dist/commands/doctor-parity.d.ts +1 -1
  10. package/dist/commands/doctor-parity.d.ts.map +1 -1
  11. package/dist/commands/doctor-parity.js +9 -3
  12. package/dist/commands/doctor-parity.js.map +1 -1
  13. package/dist/commands/doctor-parity.test.js +21 -2
  14. package/dist/commands/doctor-parity.test.js.map +1 -1
  15. package/dist/commands/doctor.d.ts +41 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +103 -1
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/doctor.test.js +216 -11
  20. package/dist/commands/doctor.test.js.map +1 -1
  21. package/dist/commands/eject.d.ts.map +1 -1
  22. package/dist/commands/eject.js +18 -5
  23. package/dist/commands/eject.js.map +1 -1
  24. package/dist/commands/eject.test.js +48 -0
  25. package/dist/commands/eject.test.js.map +1 -1
  26. package/dist/commands/init-templates.d.ts +3 -1
  27. package/dist/commands/init-templates.d.ts.map +1 -1
  28. package/dist/commands/init-templates.js +26 -4
  29. package/dist/commands/init-templates.js.map +1 -1
  30. package/dist/commands/init.d.ts +1 -1
  31. package/dist/commands/init.d.ts.map +1 -1
  32. package/dist/commands/init.js +16 -10
  33. package/dist/commands/init.js.map +1 -1
  34. package/dist/commands/init.test.js +75 -5
  35. package/dist/commands/init.test.js.map +1 -1
  36. package/dist/commands/install-hooks-exit-contract.test.js +134 -6
  37. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  38. package/dist/commands/install-hooks.d.ts +20 -0
  39. package/dist/commands/install-hooks.d.ts.map +1 -1
  40. package/dist/commands/install-hooks.js +38 -5
  41. package/dist/commands/install-hooks.js.map +1 -1
  42. package/dist/index.js +21 -3
  43. package/dist/index.js.map +1 -1
  44. package/package.json +2 -2
@@ -4,7 +4,7 @@ import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { cleanTmpDir } from '../test-utils.js';
7
- import { AGENTS_MD_REDIRECT_PATTERN, BYPASS_THRESHOLD, checkAgentsMdCanonical, checkCompiledRules, checkConfig, checkEmbeddingConfig, checkFreezes, checkGitHooks, checkGrandfatheredRules, checkIndex, checkLinkedIndexes, checkOllama, checkSecretLeaks, checkSecretsFileTracked, checkStaleRules, checkStrategyRoot, checkUpgradeCandidates, CLAUDE_MD_REDIRECT_MAX_BYTES, doctorCommand, doctorGateFailed, findLegacyGrandfatheredRules, findStaleRules, MIN_CONTEXT_EVENTS, MIN_EVENTS, NON_CODE_THRESHOLD, resolveStrictTier, runSelfHealing, V_1_13_0_SHIP_DATE_ISO, } from './doctor.js';
7
+ import { AGENTS_MD_REDIRECT_PATTERN, BYPASS_THRESHOLD, checkAgentsMdCanonical, checkCompiledRules, checkConfig, checkEmbeddingConfig, checkEstate, checkFreezes, checkGitHooks, checkGrandfatheredRules, checkIndex, checkLinkedIndexes, checkOllama, checkSecretLeaks, checkSecretsFileTracked, checkStaleRules, checkStrategyRoot, checkUpgradeCandidates, CLAUDE_MD_REDIRECT_MAX_BYTES, doctorCommand, doctorGateFailed, findLegacyGrandfatheredRules, findStaleRules, MIN_CONTEXT_EVENTS, MIN_EVENTS, NON_CODE_THRESHOLD, resolveStrictTier, runSelfHealing, V_1_13_0_SHIP_DATE_ISO, } from './doctor.js';
8
8
  // ─── Helpers ────────────────────────────────────────────
9
9
  function makeTmpDir() {
10
10
  return fs.mkdtempSync(path.join(os.tmpdir(), 'totem-doctor-'));
@@ -314,7 +314,15 @@ const EXPECTED_DIAGNOSTIC_NAMES = [
314
314
  'Stale Rules',
315
315
  'Grandfathered Rules',
316
316
  'Freeze state',
317
+ 'Estate',
317
318
  ];
319
+ /**
320
+ * The ambient `Estate` row reads the real user-level registry and shells git at
321
+ * every repo listed there. Every `doctorCommand` call in this suite passes an
322
+ * EMPTY registry through the seam so the suite stays hermetic — same reason the
323
+ * Ollama probe's `fetch` is mocked above.
324
+ */
325
+ const HERMETIC = { estateSeamsForTest: { registry: {} } };
318
326
  describe('doctorCommand', () => {
319
327
  let tmpDir;
320
328
  let originalCwd;
@@ -339,12 +347,12 @@ describe('doctorCommand', () => {
339
347
  vi.restoreAllMocks();
340
348
  });
341
349
  it('runs without throwing', async () => {
342
- const results = await doctorCommand();
350
+ const results = await doctorCommand(HERMETIC);
343
351
  expect(results).toBeDefined();
344
352
  expect(results).toHaveLength(EXPECTED_DIAGNOSTIC_NAMES.length);
345
353
  });
346
354
  it('returns correct check names', async () => {
347
- const results = await doctorCommand();
355
+ const results = await doctorCommand(HERMETIC);
348
356
  const names = results.map((r) => r.name);
349
357
  expect(names).toEqual(expect.arrayContaining([...EXPECTED_DIAGNOSTIC_NAMES]));
350
358
  });
@@ -372,7 +380,7 @@ describe('doctorCommand output', () => {
372
380
  vi.restoreAllMocks();
373
381
  });
374
382
  it('outputs all check names in console output', async () => {
375
- await doctorCommand();
383
+ await doctorCommand(HERMETIC);
376
384
  const output = stderrSpy.mock.calls.map((args) => String(args[0])).join('\n');
377
385
  expect(output).toContain('Config');
378
386
  expect(output).toContain('Compiled Rules');
@@ -387,7 +395,7 @@ describe('doctorCommand output', () => {
387
395
  expect(output).toContain('Stale Rules');
388
396
  });
389
397
  it('outputs summary line with pass/warn/fail counts', async () => {
390
- await doctorCommand();
398
+ await doctorCommand(HERMETIC);
391
399
  const output = stderrSpy.mock.calls.map((args) => String(args[0])).join('\n');
392
400
  expect(output).toMatch(/\d+ passed/);
393
401
  expect(output).toMatch(/\d+ warnings/);
@@ -429,7 +437,7 @@ describe('doctorCommand strict mode contract', () => {
429
437
  // Seed a guaranteed failure: no config means checkConfig returns `fail`.
430
438
  fs.unlinkSync(path.join(tmpDir, 'totem.config.ts'));
431
439
  process.exitCode = undefined;
432
- const results = await doctorCommand({ strict: true });
440
+ const results = await doctorCommand({ ...HERMETIC, strict: true });
433
441
  expect(results.some((r) => r.status === 'fail')).toBe(true);
434
442
  expect(process.exitCode).toBeUndefined();
435
443
  });
@@ -437,14 +445,14 @@ describe('doctorCommand strict mode contract', () => {
437
445
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((_) => {
438
446
  throw new Error('process.exit should not be called by doctorCommand');
439
447
  }));
440
- await doctorCommand({ strict: true });
441
- await doctorCommand({ strict: false });
442
- await doctorCommand();
448
+ await doctorCommand({ ...HERMETIC, strict: true });
449
+ await doctorCommand({ ...HERMETIC, strict: false });
450
+ await doctorCommand(HERMETIC);
443
451
  expect(exitSpy).not.toHaveBeenCalled();
444
452
  });
445
453
  it('returns the same DiagnosticResult[] shape regardless of strict flag', async () => {
446
- const resultsStrict = await doctorCommand({ strict: true });
447
- const resultsLoose = await doctorCommand({ strict: false });
454
+ const resultsStrict = await doctorCommand({ ...HERMETIC, strict: true });
455
+ const resultsLoose = await doctorCommand({ ...HERMETIC, strict: false });
448
456
  expect(resultsStrict.map((r) => r.name)).toEqual(resultsLoose.map((r) => r.name));
449
457
  });
450
458
  });
@@ -492,6 +500,203 @@ describe('doctorGateFailed', () => {
492
500
  expect(doctorGateFailed(onlySkip, 'fail')).toBe(false);
493
501
  expect(doctorGateFailed(onlySkip, 'warn')).toBe(false);
494
502
  });
503
+ // Sensor-class rows report but never gate (mmnto-ai/totem#2580 ruled scope).
504
+ // The exemption rides the ROW, so widening the tier later cannot give a
505
+ // sensor teeth by accident.
506
+ it('a gateExempt warn row does not gate even under the warn tier', () => {
507
+ const estateWarn = [
508
+ ...clean,
509
+ { name: 'Estate', status: 'warn', message: '', gateExempt: true },
510
+ ];
511
+ expect(doctorGateFailed(estateWarn, 'warn')).toBe(false);
512
+ expect(doctorGateFailed(estateWarn, 'fail')).toBe(false);
513
+ });
514
+ it('a non-exempt warn row alongside an exempt one still gates under the warn tier', () => {
515
+ const mixed = [
516
+ { name: 'Estate', status: 'warn', message: '', gateExempt: true },
517
+ { name: 'C', status: 'warn', message: '' },
518
+ ];
519
+ expect(doctorGateFailed(mixed, 'warn')).toBe(true);
520
+ expect(doctorGateFailed(mixed, 'fail')).toBe(false);
521
+ });
522
+ // The exemption is scoped to ADVISORY statuses. A fail is a wiring failure
523
+ // and no row may hide one — sensor rows never emit `fail`, so the narrowing
524
+ // costs them nothing and closes the mislabelled-row hole.
525
+ it('a gateExempt FAIL row still gates in both tiers', () => {
526
+ const exemptFail = [
527
+ ...clean,
528
+ { name: 'Sensor', status: 'fail', message: '', gateExempt: true },
529
+ ];
530
+ expect(doctorGateFailed(exemptFail, 'fail')).toBe(true);
531
+ expect(doctorGateFailed(exemptFail, 'warn')).toBe(true);
532
+ });
533
+ });
534
+ // ─── Ambient estate row (mmnto-ai/totem#2580) ───────────
535
+ describe('checkEstate', () => {
536
+ const registryOf = (...paths) => Object.fromEntries(paths.map((p) => [
537
+ p,
538
+ { path: p, chunkCount: 1, lastSync: '2026-08-01T00:00:00.000Z', embedder: 'test' },
539
+ ]));
540
+ let estateDir;
541
+ beforeEach(() => {
542
+ estateDir = fs.realpathSync(makeTmpDir());
543
+ });
544
+ afterEach(() => {
545
+ cleanTmpDir(estateDir);
546
+ });
547
+ /** Canned git for `repo`; every other path answers as its own toplevel. */
548
+ function seams(repo, opts = {}) {
549
+ const listing = [
550
+ `worktree ${repo.split(path.sep).join('/')}`,
551
+ `HEAD ${'a'.repeat(40)}`,
552
+ 'branch refs/heads/main',
553
+ '',
554
+ ].join('\n');
555
+ const fold = (p) => process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
556
+ const toplevels = new Map(Object.entries(opts.toplevels ?? {}).map(([k, v]) => [fold(k), v]));
557
+ const failToplevel = new Set((opts.failToplevel ?? []).map(fold));
558
+ return {
559
+ registry: registryOf(repo),
560
+ now: Date.parse('2026-08-05T12:00:00.000Z'),
561
+ safeExec: ((_command, args = []) => {
562
+ if (opts.throws === true)
563
+ throw new Error('git exploded');
564
+ const cwd = args[2] ?? '';
565
+ const verb = args.slice(3);
566
+ if (verb[0] === 'rev-parse' && verb[1] === '--show-toplevel') {
567
+ if (failToplevel.has(fold(cwd)))
568
+ throw new Error('not a git repository');
569
+ return (toplevels.get(fold(cwd)) ?? path.resolve(cwd)).split(path.sep).join('/');
570
+ }
571
+ if (verb[0] === 'worktree') {
572
+ if (fold(cwd) !== fold(repo))
573
+ throw new Error('not a git repository');
574
+ return listing;
575
+ }
576
+ if (verb[0] === 'rev-parse')
577
+ return 'origin/main';
578
+ return '';
579
+ }),
580
+ };
581
+ }
582
+ it('skips when nothing is registered', async () => {
583
+ const result = await checkEstate({ registry: {} });
584
+ expect(result.status).toBe('skip');
585
+ expect(result.message).toContain('No registered repos');
586
+ });
587
+ // A corrupt registry must not collapse into the clean "nothing registered"
588
+ // skip — the ambient row is the surface operators actually see, and it is
589
+ // the one that would hide the failure. Drives the REAL readRegistry (no
590
+ // registry seam) against a temp home holding an unparseable registry.json.
591
+ it('warns (not skip) when the registry exists but cannot be read', async () => {
592
+ const home = fs.realpathSync(makeTmpDir());
593
+ fs.mkdirSync(path.join(home, '.totem'), { recursive: true });
594
+ fs.writeFileSync(path.join(home, '.totem', 'registry.json'), '{ not json', 'utf-8');
595
+ const prevHome = process.env['HOME'];
596
+ const prevProfile = process.env['USERPROFILE'];
597
+ process.env['HOME'] = home;
598
+ process.env['USERPROFILE'] = home;
599
+ try {
600
+ const result = await checkEstate();
601
+ expect(result.status).toBe('warn');
602
+ expect(result.message).toContain('Registry unreadable');
603
+ expect(result.gateExempt).toBe(true);
604
+ }
605
+ finally {
606
+ if (prevHome === undefined)
607
+ delete process.env['HOME'];
608
+ else
609
+ process.env['HOME'] = prevHome;
610
+ if (prevProfile === undefined)
611
+ delete process.env['USERPROFILE'];
612
+ else
613
+ process.env['USERPROFILE'] = prevProfile;
614
+ cleanTmpDir(home);
615
+ }
616
+ });
617
+ it('passes quietly on a clean estate, naming missing and unscannable counts', async () => {
618
+ const repo = path.join(estateDir, 'repo');
619
+ fs.mkdirSync(repo, { recursive: true });
620
+ const gone = path.join(estateDir, 'vanished');
621
+ const base = seams(repo);
622
+ const result = await checkEstate({
623
+ ...base,
624
+ registry: registryOf(repo, gone),
625
+ });
626
+ expect(result.status).toBe('pass');
627
+ // The denominator is what was ENUMERATED — an entry that was missing, not a
628
+ // git root, or unprobeable was never looked inside and must not inflate it.
629
+ expect(result.message).toContain('1 enumerated repo(s)');
630
+ expect(result.message).toContain('(1 missing)');
631
+ expect(result.message).toContain('0 linked worktree(s)');
632
+ expect(result.gateExempt).toBe(true);
633
+ });
634
+ it('names the not-git-root and unprobeable counts too', async () => {
635
+ const repo = path.join(estateDir, 'repo');
636
+ fs.mkdirSync(repo, { recursive: true });
637
+ const inside = path.join(repo, 'packages');
638
+ fs.mkdirSync(inside, { recursive: true });
639
+ const broken = path.join(estateDir, 'broken');
640
+ fs.mkdirSync(broken, { recursive: true });
641
+ const result = await checkEstate({
642
+ ...seams(repo, { toplevels: { [inside]: repo }, failToplevel: [broken] }),
643
+ registry: registryOf(repo, inside, broken),
644
+ });
645
+ expect(result.status).toBe('pass');
646
+ expect(result.message).toContain('1 enumerated repo(s)');
647
+ expect(result.message).toContain('1 not-git-root');
648
+ expect(result.message).toContain('1 unprobeable');
649
+ });
650
+ it('warns with counts and the --estate remediation when husks exist', async () => {
651
+ const repo = path.join(estateDir, 'repo');
652
+ fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
653
+ fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-b'), { recursive: true });
654
+ const result = await checkEstate(seams(repo));
655
+ expect(result.status).toBe('warn');
656
+ expect(result.message).toContain('2 husk candidate(s)');
657
+ expect(result.remediation).toContain('totem doctor --estate');
658
+ expect(result.gateExempt).toBe(true);
659
+ });
660
+ // A git that fails on every invocation is NOT a scan crash: the scan is
661
+ // fail-soft per probe, so this lands as unscannable rows and the row still
662
+ // reports. Asserted so the two failure classes stay distinguishable.
663
+ it('keeps reporting when every git probe fails, naming the unscannable count', async () => {
664
+ const repo = path.join(estateDir, 'repo');
665
+ fs.mkdirSync(repo, { recursive: true });
666
+ const result = await checkEstate(seams(repo, { throws: true }));
667
+ expect(result.status).toBe('pass');
668
+ expect(result.message).toContain('1 probe(s) unscannable');
669
+ });
670
+ it('warns (never throws) on a crash-class scan failure', async () => {
671
+ const repo = path.join(estateDir, 'repo');
672
+ fs.mkdirSync(repo, { recursive: true });
673
+ // A malformed registry is the reachable crash class: EVERY exec call site
674
+ // inside the scan is wrapped, so even a broken git seam degrades to
675
+ // unscannable rows (asserted above) rather than throwing. What the catch
676
+ // exists for is a crash BEFORE or AROUND the probes — a bad registry
677
+ // shape, or the dynamic core import failing.
678
+ const result = await checkEstate({ ...seams(repo), registry: { bad: null } });
679
+ expect(result.status).toBe('warn');
680
+ expect(result.message).toContain('Estate scan failed');
681
+ });
682
+ it('marks every row gateExempt so the sensor can never gate', async () => {
683
+ const repo = path.join(estateDir, 'repo');
684
+ fs.mkdirSync(repo, { recursive: true });
685
+ const huskRepo = path.join(estateDir, 'husk-repo');
686
+ fs.mkdirSync(path.join(huskRepo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
687
+ // All FOUR real paths: skip / pass / husk-warn / crash-warn.
688
+ const rows = [
689
+ await checkEstate({ registry: {} }),
690
+ await checkEstate(seams(repo)),
691
+ await checkEstate(seams(huskRepo)),
692
+ await checkEstate({ ...seams(repo), registry: { bad: null } }),
693
+ ];
694
+ expect(rows.map((r) => r.status)).toEqual(['skip', 'pass', 'warn', 'warn']);
695
+ expect(rows[2].message).toContain('husk candidate(s)');
696
+ for (const row of rows)
697
+ expect(row.gateExempt).toBe(true);
698
+ expect(doctorGateFailed(rows, 'warn')).toBe(false);
699
+ });
495
700
  });
496
701
  // ─── Secrets file tracking check ────────────────────────
497
702
  describe('checkSecretsFileTracked', () => {