@mmnto/cli 1.111.1 → 1.113.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.
@@ -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: {}, wtRoots: [] } };
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,206 @@ 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
+ // Pinned so a machine that has actually run `totem wt create` cannot
562
+ // drag its real recorded roots into these assertions (#2580 slice 2).
563
+ wtRoots: [],
564
+ safeExec: ((_command, args = []) => {
565
+ if (opts.throws === true)
566
+ throw new Error('git exploded');
567
+ const cwd = args[2] ?? '';
568
+ const verb = args.slice(3);
569
+ if (verb[0] === 'rev-parse' && verb[1] === '--show-toplevel') {
570
+ if (failToplevel.has(fold(cwd)))
571
+ throw new Error('not a git repository');
572
+ return (toplevels.get(fold(cwd)) ?? path.resolve(cwd)).split(path.sep).join('/');
573
+ }
574
+ if (verb[0] === 'worktree') {
575
+ if (fold(cwd) !== fold(repo))
576
+ throw new Error('not a git repository');
577
+ return listing;
578
+ }
579
+ if (verb[0] === 'rev-parse')
580
+ return 'origin/main';
581
+ return '';
582
+ }),
583
+ };
584
+ }
585
+ it('skips when nothing is registered', async () => {
586
+ const result = await checkEstate({ registry: {}, wtRoots: [] });
587
+ expect(result.status).toBe('skip');
588
+ expect(result.message).toContain('No registered repos');
589
+ });
590
+ // A corrupt registry must not collapse into the clean "nothing registered"
591
+ // skip — the ambient row is the surface operators actually see, and it is
592
+ // the one that would hide the failure. Drives the REAL readRegistry (no
593
+ // registry seam) against a temp home holding an unparseable registry.json.
594
+ it('warns (not skip) when the registry exists but cannot be read', async () => {
595
+ const home = fs.realpathSync(makeTmpDir());
596
+ fs.mkdirSync(path.join(home, '.totem'), { recursive: true });
597
+ fs.writeFileSync(path.join(home, '.totem', 'registry.json'), '{ not json', 'utf-8');
598
+ const prevHome = process.env['HOME'];
599
+ const prevProfile = process.env['USERPROFILE'];
600
+ process.env['HOME'] = home;
601
+ process.env['USERPROFILE'] = home;
602
+ try {
603
+ const result = await checkEstate();
604
+ expect(result.status).toBe('warn');
605
+ expect(result.message).toContain('Registry unreadable');
606
+ expect(result.gateExempt).toBe(true);
607
+ }
608
+ finally {
609
+ if (prevHome === undefined)
610
+ delete process.env['HOME'];
611
+ else
612
+ process.env['HOME'] = prevHome;
613
+ if (prevProfile === undefined)
614
+ delete process.env['USERPROFILE'];
615
+ else
616
+ process.env['USERPROFILE'] = prevProfile;
617
+ cleanTmpDir(home);
618
+ }
619
+ });
620
+ it('passes quietly on a clean estate, naming missing and unscannable counts', async () => {
621
+ const repo = path.join(estateDir, 'repo');
622
+ fs.mkdirSync(repo, { recursive: true });
623
+ const gone = path.join(estateDir, 'vanished');
624
+ const base = seams(repo);
625
+ const result = await checkEstate({
626
+ ...base,
627
+ registry: registryOf(repo, gone),
628
+ });
629
+ expect(result.status).toBe('pass');
630
+ // The denominator is what was ENUMERATED — an entry that was missing, not a
631
+ // git root, or unprobeable was never looked inside and must not inflate it.
632
+ expect(result.message).toContain('1 enumerated repo(s)');
633
+ expect(result.message).toContain('(1 missing)');
634
+ expect(result.message).toContain('0 linked worktree(s)');
635
+ expect(result.gateExempt).toBe(true);
636
+ });
637
+ it('names the not-git-root and unprobeable counts too', async () => {
638
+ const repo = path.join(estateDir, 'repo');
639
+ fs.mkdirSync(repo, { recursive: true });
640
+ const inside = path.join(repo, 'packages');
641
+ fs.mkdirSync(inside, { recursive: true });
642
+ const broken = path.join(estateDir, 'broken');
643
+ fs.mkdirSync(broken, { recursive: true });
644
+ const result = await checkEstate({
645
+ ...seams(repo, { toplevels: { [inside]: repo }, failToplevel: [broken] }),
646
+ registry: registryOf(repo, inside, broken),
647
+ });
648
+ expect(result.status).toBe('pass');
649
+ expect(result.message).toContain('1 enumerated repo(s)');
650
+ expect(result.message).toContain('1 not-git-root');
651
+ expect(result.message).toContain('1 unprobeable');
652
+ });
653
+ it('warns with counts and the --estate remediation when husks exist', async () => {
654
+ const repo = path.join(estateDir, 'repo');
655
+ fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
656
+ fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-b'), { recursive: true });
657
+ const result = await checkEstate(seams(repo));
658
+ expect(result.status).toBe('warn');
659
+ expect(result.message).toContain('2 husk candidate(s)');
660
+ expect(result.remediation).toContain('totem doctor --estate');
661
+ expect(result.gateExempt).toBe(true);
662
+ });
663
+ // A git that fails on every invocation is NOT a scan crash: the scan is
664
+ // fail-soft per probe, so this lands as unscannable rows and the row still
665
+ // reports. Asserted so the two failure classes stay distinguishable.
666
+ it('keeps reporting when every git probe fails, naming the unscannable count', async () => {
667
+ const repo = path.join(estateDir, 'repo');
668
+ fs.mkdirSync(repo, { recursive: true });
669
+ const result = await checkEstate(seams(repo, { throws: true }));
670
+ expect(result.status).toBe('pass');
671
+ expect(result.message).toContain('1 probe(s) unscannable');
672
+ });
673
+ it('warns (never throws) on a crash-class scan failure', async () => {
674
+ const repo = path.join(estateDir, 'repo');
675
+ fs.mkdirSync(repo, { recursive: true });
676
+ // A malformed registry is the reachable crash class: EVERY exec call site
677
+ // inside the scan is wrapped, so even a broken git seam degrades to
678
+ // unscannable rows (asserted above) rather than throwing. What the catch
679
+ // exists for is a crash BEFORE or AROUND the probes — a bad registry
680
+ // shape, or the dynamic core import failing.
681
+ const result = await checkEstate({ ...seams(repo), registry: { bad: null } });
682
+ expect(result.status).toBe('warn');
683
+ expect(result.message).toContain('Estate scan failed');
684
+ });
685
+ it('marks every row gateExempt so the sensor can never gate', async () => {
686
+ const repo = path.join(estateDir, 'repo');
687
+ fs.mkdirSync(repo, { recursive: true });
688
+ const huskRepo = path.join(estateDir, 'husk-repo');
689
+ fs.mkdirSync(path.join(huskRepo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
690
+ // All FOUR real paths: skip / pass / husk-warn / crash-warn.
691
+ const rows = [
692
+ await checkEstate({ registry: {}, wtRoots: [] }),
693
+ await checkEstate(seams(repo)),
694
+ await checkEstate(seams(huskRepo)),
695
+ await checkEstate({ ...seams(repo), registry: { bad: null } }),
696
+ ];
697
+ expect(rows.map((r) => r.status)).toEqual(['skip', 'pass', 'warn', 'warn']);
698
+ expect(rows[2].message).toContain('husk candidate(s)');
699
+ for (const row of rows)
700
+ expect(row.gateExempt).toBe(true);
701
+ expect(doctorGateFailed(rows, 'warn')).toBe(false);
702
+ });
495
703
  });
496
704
  // ─── Secrets file tracking check ────────────────────────
497
705
  describe('checkSecretsFileTracked', () => {
@@ -2035,4 +2243,195 @@ describe('checkFreezes', () => {
2035
2243
  expect(result.message).toContain('underivable');
2036
2244
  });
2037
2245
  });
2246
+ // ─── Estate row × wt-registry coupling (#2580 slice 2) ──
2247
+ describe('checkEstate — wt-registry roots', () => {
2248
+ let estateHome;
2249
+ let prevHome;
2250
+ let prevProfile;
2251
+ beforeEach(() => {
2252
+ estateHome = fs.realpathSync(makeTmpDir());
2253
+ prevHome = process.env['HOME'];
2254
+ prevProfile = process.env['USERPROFILE'];
2255
+ process.env['HOME'] = estateHome;
2256
+ process.env['USERPROFILE'] = estateHome;
2257
+ });
2258
+ afterEach(() => {
2259
+ if (prevHome === undefined)
2260
+ delete process.env['HOME'];
2261
+ else
2262
+ process.env['HOME'] = prevHome;
2263
+ if (prevProfile === undefined)
2264
+ delete process.env['USERPROFILE'];
2265
+ else
2266
+ process.env['USERPROFILE'] = prevProfile;
2267
+ cleanTmpDir(estateHome);
2268
+ });
2269
+ function writeWtRegistry(contents) {
2270
+ fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
2271
+ fs.writeFileSync(path.join(estateHome, '.totem', 'worktrees.json'), contents, 'utf-8');
2272
+ }
2273
+ // Invariant 8 on the AMBIENT row: the sensor row must sweep what `--estate`
2274
+ // sweeps, or a clean ambient line would contradict the explicit command.
2275
+ it('sweeps the recorded DEFAULT root with an EMPTY sync registry, finding the husk', async () => {
2276
+ // The default `~/.totem/worktrees` is the one recorded root that carries
2277
+ // container semantics — it exists solely to hold worktrees.
2278
+ const recorded = path.join(estateHome, '.totem', 'worktrees');
2279
+ fs.mkdirSync(path.join(recorded, 'left-behind'), { recursive: true });
2280
+ writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [recorded], worktrees: {} }));
2281
+ const result = await checkEstate({ registry: {} });
2282
+ expect(result.status).toBe('warn');
2283
+ expect(result.message).toContain('1 husk candidate(s)');
2284
+ expect(result.gateExempt).toBe(true);
2285
+ });
2286
+ it('sweeps a NON-default recorded root with shape evidence only — no by-location husks', async () => {
2287
+ // A recorded scratch root holds other things beside worktrees; an
2288
+ // arbitrary directory there is NOT residue-by-location (finding 11).
2289
+ const recorded = path.join(estateHome, 'scratch-root');
2290
+ fs.mkdirSync(path.join(recorded, 'unrelated-project'), { recursive: true });
2291
+ writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [recorded], worktrees: {} }));
2292
+ const result = await checkEstate({ registry: {} });
2293
+ expect(result.status).toBe('pass');
2294
+ expect(result.message).toContain('no stale worktrees or husk candidates');
2295
+ });
2296
+ it('surfaces the unreadable sync registry even when recorded roots keep the scan alive', async () => {
2297
+ // Finding 1: a live recorded root routes AROUND the empty-registry
2298
+ // short-circuit — the unreadable-registry disclosure must ride the live
2299
+ // row, never vanish into a clean pass.
2300
+ fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
2301
+ fs.writeFileSync(path.join(estateHome, '.totem', 'registry.json'), '{ not json', 'utf-8');
2302
+ const recorded = path.join(estateHome, '.totem', 'worktrees');
2303
+ fs.mkdirSync(recorded, { recursive: true });
2304
+ writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [recorded], worktrees: {} }));
2305
+ const result = await checkEstate({});
2306
+ expect(result.status).toBe('warn');
2307
+ expect(result.message).toContain('Sync registry unreadable');
2308
+ // The scan itself still ran — the disclosure rides a live row, not a skip.
2309
+ expect(result.message).toContain('no stale worktrees or husk candidates');
2310
+ expect(result.remediation).toContain('registry.json');
2311
+ // Warning text is flattened at interpolation (bot round, CR finding 2):
2312
+ // a multi-line parse error must never forge extra doctor rows.
2313
+ expect(result.message).not.toContain('\n');
2314
+ });
2315
+ it('keeps the unreadable-registry disclosure when the scan itself throws', async () => {
2316
+ // Re-verification round 2, finding 4: the catch arm was the one row shape
2317
+ // that dropped the disclosure — broken sync registry AND a scan failure
2318
+ // must surface BOTH signals on the same row.
2319
+ fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
2320
+ fs.writeFileSync(path.join(estateHome, '.totem', 'registry.json'), '{ not json', 'utf-8');
2321
+ // The seam getter throws AFTER the registry read populated its warnings —
2322
+ // the nearest injectable stand-in for a scan-side failure, since
2323
+ // `scanEstate` contains its own probe failures.
2324
+ const seams = {};
2325
+ Object.defineProperty(seams, 'wtRoots', {
2326
+ get() {
2327
+ throw new Error('scan-side failure');
2328
+ },
2329
+ enumerable: true,
2330
+ });
2331
+ const result = await checkEstate(seams);
2332
+ expect(result.status).toBe('warn');
2333
+ expect(result.message).toContain('Estate scan failed: scan-side failure');
2334
+ expect(result.message).toContain('Sync registry unreadable');
2335
+ });
2336
+ it('keeps the WORKTREE-registry disclosure when the scan itself throws', async () => {
2337
+ // The wt-side symmetry of the catch-arm fix (bot round, CR finding 3):
2338
+ // `wtWarnings` is hoisted with `registryWarnings`, so an unreadable
2339
+ // worktrees.json survives into the scan-failed row instead of silently
2340
+ // vanishing with the throw.
2341
+ writeWtRegistry('{ not json');
2342
+ // One registry entry keeps the scan off the empty short-circuit; the
2343
+ // throwing `safeExec` getter fires AFTER the worktree-registry read has
2344
+ // populated its warnings, landing the row in the catch arm.
2345
+ const seams = {
2346
+ registry: {
2347
+ [path.join(estateHome, 'repo')]: {
2348
+ path: path.join(estateHome, 'repo'),
2349
+ chunkCount: 0,
2350
+ lastSync: '2026-08-01T00:00:00.000Z',
2351
+ embedder: 'x',
2352
+ },
2353
+ },
2354
+ };
2355
+ Object.defineProperty(seams, 'safeExec', {
2356
+ get() {
2357
+ throw new Error('scan-side failure');
2358
+ },
2359
+ enumerable: true,
2360
+ });
2361
+ const result = await checkEstate(seams);
2362
+ expect(result.status).toBe('warn');
2363
+ expect(result.message).toContain('Estate scan failed: scan-side failure');
2364
+ expect(result.message).toContain('Cannot read worktree registry');
2365
+ });
2366
+ it('still skips when the recorded root no longer exists (empty sweep, not a hole)', async () => {
2367
+ writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [path.join(estateHome, 'gone')], worktrees: {} }));
2368
+ const result = await checkEstate({ registry: {} });
2369
+ expect(result.status).toBe('skip');
2370
+ expect(result.message).toContain('No registered repos');
2371
+ });
2372
+ it('treats an unreadable worktrees.json as degraded state, never a clean skip', async () => {
2373
+ // Round 2, CR outside-diff finding, short-circuit half: an unreadable
2374
+ // worktree registry yields zero roots — exactly what routes the row onto
2375
+ // the empty short-circuit — so the skip arm itself must disclose it.
2376
+ writeWtRegistry('{ not json');
2377
+ const result = await checkEstate({ registry: {} });
2378
+ expect(result.status).toBe('warn');
2379
+ expect(result.message).toContain('Worktree registry unreadable');
2380
+ expect(result.message).toContain('Cannot read worktree registry');
2381
+ // The scan never ran — the row must not claim a clean sweep.
2382
+ expect(result.message).not.toContain('no stale worktrees');
2383
+ expect(result.remediation).toContain('worktrees.json');
2384
+ // Degraded, but never a fail: the row is sensor-class.
2385
+ expect(result.gateExempt).toBe(true);
2386
+ });
2387
+ it('names BOTH files in the remediation when both registries are unreadable', async () => {
2388
+ // Round 3, CR inline: the sync-registry arm wins precedence and its
2389
+ // message carries wtNote(), but a remediation naming only registry.json
2390
+ // would leave the next run warning again on the file it never mentioned.
2391
+ fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
2392
+ fs.writeFileSync(path.join(estateHome, '.totem', 'registry.json'), '{ not json', 'utf-8');
2393
+ writeWtRegistry('{ not json');
2394
+ const result = await checkEstate();
2395
+ expect(result.status).toBe('warn');
2396
+ expect(result.message).toContain('Registry unreadable');
2397
+ expect(result.message).toContain('Cannot read worktree registry');
2398
+ expect(result.remediation).toContain('registry.json');
2399
+ expect(result.remediation).toContain('worktrees.json');
2400
+ expect(result.gateExempt).toBe(true);
2401
+ });
2402
+ it('demotes an otherwise-clean scan to warn when worktrees.json is unreadable', async () => {
2403
+ // Round 2, CR outside-diff finding, clean-arm half: the scan ran (one
2404
+ // registry entry routes past the short-circuit; a missing repo path needs
2405
+ // no git), but an unreadable worktree registry may name recorded roots
2406
+ // the sweep never saw — a green line would overstate the coverage.
2407
+ writeWtRegistry('{ not json');
2408
+ const missingRepo = path.join(estateHome, 'gone-repo');
2409
+ const result = await checkEstate({
2410
+ registry: {
2411
+ [missingRepo]: {
2412
+ path: missingRepo,
2413
+ chunkCount: 0,
2414
+ lastSync: '2026-08-01T00:00:00.000Z',
2415
+ embedder: 'x',
2416
+ },
2417
+ },
2418
+ });
2419
+ expect(result.status).toBe('warn');
2420
+ expect(result.message).toContain('no stale worktrees or husk candidates');
2421
+ expect(result.message).toContain('Cannot read worktree registry');
2422
+ expect(result.remediation).toContain('worktrees.json');
2423
+ expect(result.gateExempt).toBe(true);
2424
+ });
2425
+ it('honours the wtRoots seam without reading the home file', async () => {
2426
+ // The seam value flows through the same default-vs-standard partition as
2427
+ // production roots, so container semantics need the default location.
2428
+ const seamRoot = path.join(estateHome, '.totem', 'worktrees');
2429
+ fs.mkdirSync(path.join(seamRoot, 'residue'), { recursive: true });
2430
+ writeWtRegistry('{ not json');
2431
+ const result = await checkEstate({ registry: {}, wtRoots: [seamRoot] });
2432
+ expect(result.message).not.toContain('Cannot read worktree registry');
2433
+ expect(result.status).toBe('warn');
2434
+ expect(result.message).toContain('1 husk candidate(s)');
2435
+ });
2436
+ });
2038
2437
  //# sourceMappingURL=doctor.test.js.map