@mmnto/cli 1.114.0 → 1.116.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 (101) hide show
  1. package/dist/commands/doctor-compliance.test.js +17 -0
  2. package/dist/commands/doctor-compliance.test.js.map +1 -1
  3. package/dist/commands/doctor-seat-identity.d.ts +87 -0
  4. package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
  5. package/dist/commands/doctor-seat-identity.js +427 -0
  6. package/dist/commands/doctor-seat-identity.js.map +1 -0
  7. package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
  8. package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
  9. package/dist/commands/doctor-seat-identity.test.js +390 -0
  10. package/dist/commands/doctor-seat-identity.test.js.map +1 -0
  11. package/dist/commands/doctor.d.ts.map +1 -1
  12. package/dist/commands/doctor.js +3 -0
  13. package/dist/commands/doctor.js.map +1 -1
  14. package/dist/commands/doctor.test.js +1 -0
  15. package/dist/commands/doctor.test.js.map +1 -1
  16. package/dist/commands/ecl-gc.d.ts.map +1 -1
  17. package/dist/commands/ecl-gc.js +18 -7
  18. package/dist/commands/ecl-gc.js.map +1 -1
  19. package/dist/commands/ecl-gc.test.js +63 -1
  20. package/dist/commands/ecl-gc.test.js.map +1 -1
  21. package/dist/commands/eject.d.ts +62 -3
  22. package/dist/commands/eject.d.ts.map +1 -1
  23. package/dist/commands/eject.js +382 -47
  24. package/dist/commands/eject.js.map +1 -1
  25. package/dist/commands/eject.test.js +557 -7
  26. package/dist/commands/eject.test.js.map +1 -1
  27. package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
  28. package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
  29. package/dist/commands/gemini-beforetool-contract.test.js +106 -0
  30. package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
  31. package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
  32. package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
  33. package/dist/commands/gemini-sessionstart-contract.test.js +199 -0
  34. package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
  35. package/dist/commands/init-templates.d.ts +4 -4
  36. package/dist/commands/init-templates.d.ts.map +1 -1
  37. package/dist/commands/init-templates.js +279 -16
  38. package/dist/commands/init-templates.js.map +1 -1
  39. package/dist/commands/init.test.js +25 -7
  40. package/dist/commands/init.test.js.map +1 -1
  41. package/dist/commands/install-hooks-exit-contract.test.js +5 -0
  42. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  43. package/dist/commands/mail-degraded-e4.test.js +126 -0
  44. package/dist/commands/mail-degraded-e4.test.js.map +1 -1
  45. package/dist/commands/mail.d.ts +19 -1
  46. package/dist/commands/mail.d.ts.map +1 -1
  47. package/dist/commands/mail.js +195 -30
  48. package/dist/commands/mail.js.map +1 -1
  49. package/dist/commands/mail.test.js +291 -3
  50. package/dist/commands/mail.test.js.map +1 -1
  51. package/dist/commands/orient.d.ts.map +1 -1
  52. package/dist/commands/orient.js +82 -0
  53. package/dist/commands/orient.js.map +1 -1
  54. package/dist/commands/orient.test.js +144 -1
  55. package/dist/commands/orient.test.js.map +1 -1
  56. package/dist/commands/retrospect.d.ts.map +1 -1
  57. package/dist/commands/retrospect.js +7 -3
  58. package/dist/commands/retrospect.js.map +1 -1
  59. package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
  60. package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
  61. package/dist/commands/seat-cli-wiring.test.js +147 -0
  62. package/dist/commands/seat-cli-wiring.test.js.map +1 -0
  63. package/dist/commands/seat-poll-chain.test.d.ts +13 -0
  64. package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
  65. package/dist/commands/seat-poll-chain.test.js +103 -0
  66. package/dist/commands/seat-poll-chain.test.js.map +1 -0
  67. package/dist/commands/seat.d.ts +92 -0
  68. package/dist/commands/seat.d.ts.map +1 -0
  69. package/dist/commands/seat.js +649 -0
  70. package/dist/commands/seat.js.map +1 -0
  71. package/dist/commands/seat.test.d.ts +19 -0
  72. package/dist/commands/seat.test.d.ts.map +1 -0
  73. package/dist/commands/seat.test.js +532 -0
  74. package/dist/commands/seat.test.js.map +1 -0
  75. package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
  76. package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
  77. package/dist/commands/sessionstart-manifest-contract.test.js +186 -0
  78. package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
  79. package/dist/commands/triage-pr.d.ts.map +1 -1
  80. package/dist/commands/triage-pr.js +2 -0
  81. package/dist/commands/triage-pr.js.map +1 -1
  82. package/dist/hooks/__tests__/auto-context.test.js +20 -0
  83. package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
  84. package/dist/hooks/auto-context.d.ts +19 -0
  85. package/dist/hooks/auto-context.d.ts.map +1 -1
  86. package/dist/hooks/auto-context.js +22 -4
  87. package/dist/hooks/auto-context.js.map +1 -1
  88. package/dist/index.js +71 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/parsers/bot-review-parser.d.ts +8 -1
  91. package/dist/parsers/bot-review-parser.d.ts.map +1 -1
  92. package/dist/parsers/bot-review-parser.js +21 -1
  93. package/dist/parsers/bot-review-parser.js.map +1 -1
  94. package/dist/parsers/bot-review-parser.test.js +23 -0
  95. package/dist/parsers/bot-review-parser.test.js.map +1 -1
  96. package/dist/parsers/triage-severity-mapper.d.ts.map +1 -1
  97. package/dist/parsers/triage-severity-mapper.js +32 -1
  98. package/dist/parsers/triage-severity-mapper.js.map +1 -1
  99. package/dist/parsers/triage-severity-mapper.test.js +57 -0
  100. package/dist/parsers/triage-severity-mapper.test.js.map +1 -1
  101. package/package.json +2 -2
@@ -11,10 +11,10 @@ import * as fs from 'node:fs';
11
11
  import * as os from 'node:os';
12
12
  import * as path from 'node:path';
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
- import { findTotemRepoRootSync, TotemError } from '@mmnto/totem';
14
+ import { findTotemRepoRootSync, SEAT_LIFECYCLE_FILENAME, SEAT_LIFECYCLE_SCHEMA_VERSION, TotemError, writeSeatLifecycle, } from '@mmnto/totem';
15
15
  import { cleanTmpDir } from '../test-utils.js';
16
16
  import { log } from '../ui.js';
17
- import { composeDispatch, mailCommand, mailReply, mailSend, markSource, pollMail, resolveMailExitCode, resolveSelfSender, sanitizeEclBasename, validateDispatchContent, } from './mail.js';
17
+ import { composeDispatch, formatTextResult, mailCommand, mailReply, mailSend, markSource, pollMail, resolveMailExitCode, resolveSelfSender, sanitizeEclBasename, validateDispatchContent, } from './mail.js';
18
18
  // ─── Fixture helpers ────────────────────────────────────
19
19
  let tmpRoot;
20
20
  let workspace;
@@ -59,6 +59,20 @@ function writeBroadcastProcessed(repo, recipientAgent, names) {
59
59
  for (const n of names)
60
60
  fs.writeFileSync(path.join(dir, n), '---\nto: broadcast\n---\n', 'utf-8');
61
61
  }
62
+ /**
63
+ * Declare a seat's lifecycle at `<repo>/.totem/orchestration/<seat>/lifecycle.json`
64
+ * (mmnto-ai/totem#2511) through CORE'S REAL WRITER — the same producer the
65
+ * `totem seat` verbs use — so the fixture can never be weaker than the on-disk
66
+ * contract the poll reads back (a hand-rolled JSON fixture could drift from the
67
+ * strict schema and quietly test nothing). Returns the marker path.
68
+ */
69
+ function writeLifecycleMarker(repo, seat, state) {
70
+ return writeSeatLifecycle(path.join(workspace, repo), seat, {
71
+ schemaVersion: SEAT_LIFECYCLE_SCHEMA_VERSION,
72
+ state,
73
+ since: '2026-08-11T00:00:00.000Z',
74
+ });
75
+ }
62
76
  /**
63
77
  * `totem` is the only cohort name resolveSelfAgents pre-knows; using it as
64
78
  * the "self" repo keeps the SELF_AGENT resolution working off the basename
@@ -424,6 +438,272 @@ describe('pollMail — broadcast marks are per-seat, not union (mmnto-ai/totem#2
424
438
  expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
425
439
  });
426
440
  });
441
+ // ─── Lifecycle-aware broadcast denominators ────────────
442
+ // mmnto-ai/totem#2511: a suspended-but-dir-present seat pinned every broadcast
443
+ // open forever (the denominator failure). The declared lifecycle marker takes
444
+ // such a seat OUT OF THE DENOMINATOR only — resolution stays lifecycle-blind,
445
+ // so directed mail keeps surfacing (a transition does not discharge an
446
+ // obligation edge, OQ4) and directed marks keep subtracting (an already-handled
447
+ // dispatch resurfacing as unread is the ADR-106 § A2.1 false-unread bomb).
448
+ describe('pollMail — lifecycle-aware broadcast denominators (mmnto-ai/totem#2511)', () => {
449
+ const BROADCAST_FILE = '2026-08-11T0930Z-broadcast-lifecycle-note.md';
450
+ const TWO_SEATS = { TOTEM_SELF_AGENT: 'totem-claude,totem-gemini' };
451
+ function writeBroadcast() {
452
+ writeOutbox('totem-strategy', 'strategy-claude', [
453
+ { name: BROADCAST_FILE, to: 'broadcast', subject: 'cohort note' },
454
+ ]);
455
+ }
456
+ it("a suspended seat leaves the denominator — the ACTIVE seats' marks alone close the broadcast", () => {
457
+ writeBroadcast();
458
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
459
+ // Only the ACTIVE seat holds a mark. Pre-#2511 the requirement was 2 and
460
+ // this broadcast stayed unread forever — the pinned-open denominator.
461
+ writeBroadcastProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
462
+ expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
463
+ });
464
+ it("a suspended seat's mark alone cannot close a broadcast the active seat has not marked", () => {
465
+ writeBroadcast();
466
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
467
+ // The SUSPENDED seat's mark, and only it. The requirement is 1 (one active
468
+ // seat), so a shared count map would read 1 >= 1 and vacuously consume the
469
+ // broadcast for the active seat that never saw it — which is why the
470
+ // broadcast count is folded from the ACTIVE seats' marks only.
471
+ writeBroadcastProcessed('totem', 'totem-gemini', [BROADCAST_FILE]);
472
+ expect(poll({ env: TWO_SEATS }).mail.map((m) => m.file)).toEqual([BROADCAST_FILE]);
473
+ });
474
+ it('a suspended seat stays RESOLVED: its directed mail surfaces, annotated once per poll', () => {
475
+ writeOutbox('totem-strategy', 'strategy-claude', [
476
+ { name: '2026-08-11T0931Z-totem-gemini-held-one.md', to: 'totem-gemini' },
477
+ { name: '2026-08-11T0932Z-totem-gemini-held-two.md', to: 'totem-gemini' },
478
+ ]);
479
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
480
+ const result = poll({ env: TWO_SEATS });
481
+ // Resolution is lifecycle-blind — the seat is still SELF.
482
+ expect(result.selfAgents.agents).toEqual(['totem-claude', 'totem-gemini']);
483
+ // Sense, never hide: both held dispatches surface (OQ4 — a suspension does
484
+ // not discharge the obligation edge).
485
+ expect(result.mail).toHaveLength(2);
486
+ // Bounded annotation: ONE notice for the seat, not one per file — and it
487
+ // rides the NON-GATING notices channel, never warnings (finding 1 of the
488
+ // falsification round: warnings arm three gates).
489
+ expect(result.warnings).toEqual([]);
490
+ const held = result.notices.filter((n) => n.includes('suspended seat'));
491
+ expect(held).toHaveLength(1);
492
+ expect(held[0]).toContain('totem-gemini');
493
+ expect(held[0]).toContain('obligation held, not discharged');
494
+ expect(held[0]).toContain('mmnto-ai/totem-status#127');
495
+ });
496
+ it('a suspended seat with held mail never pins the verdict to INCOMPLETE (the #2516 discriminator stays honest)', () => {
497
+ writeOutbox('totem-strategy', 'strategy-claude', [
498
+ { name: '2026-08-11T0939Z-totem-gemini-held.md', to: 'totem-gemini' },
499
+ ]);
500
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
501
+ const rendered = formatTextResult(poll({ env: TWO_SEATS }));
502
+ // The scan IS complete: the annotation renders as a Note line and the
503
+ // verdict must not claim degradation — a suspended seat must leave the
504
+ // INCOMPLETE token meaning "scan not trustworthy", not "lifecycle exists".
505
+ expect(rendered).toContain('Note: directed mail surfacing for suspended seat');
506
+ expect(rendered).not.toContain('INCOMPLETE');
507
+ expect(rendered).toContain('1 unread:');
508
+ });
509
+ it("a suspended seat's already-consumed directed mail stays consumed (no false-unread)", () => {
510
+ writeOutbox('totem-strategy', 'strategy-claude', [
511
+ { name: '2026-08-11T0933Z-totem-gemini-done.md', to: 'totem-gemini', subject: 'handled' },
512
+ { name: '2026-08-11T0934Z-totem-gemini-live.md', to: 'totem-gemini', subject: 'still open' },
513
+ ]);
514
+ writeProcessed('totem', 'totem-gemini', ['2026-08-11T0933Z-totem-gemini-done.md']);
515
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
516
+ // NON-VACUITY: the unconsumed sibling still surfaces, so "the handled one is
517
+ // gone" is a subtraction, not an empty scan.
518
+ expect(poll({ env: TWO_SEATS }).mail.map((m) => m.file)).toEqual([
519
+ '2026-08-11T0934Z-totem-gemini-live.md',
520
+ ]);
521
+ });
522
+ it('mail addressed to a RETIRED seat surfaces, with the retired-addressee warning (sense, never hide)', () => {
523
+ writeOutbox('totem-strategy', 'strategy-claude', [
524
+ { name: '2026-08-11T0935Z-totem-gemini-late.md', to: 'totem-gemini' },
525
+ { name: '2026-08-11T0936Z-totem-gemini-later.md', to: 'totem-gemini' },
526
+ ]);
527
+ writeLifecycleMarker('totem', 'totem-gemini', 'retired');
528
+ const result = poll({ env: TWO_SEATS });
529
+ expect(result.mail).toHaveLength(2);
530
+ // Notices channel, never warnings — same channel discipline as suspended.
531
+ expect(result.warnings).toEqual([]);
532
+ const retired = result.notices.filter((n) => n.includes('addressed to retired seat'));
533
+ expect(retired).toHaveLength(1);
534
+ expect(retired[0]).toContain('totem-gemini');
535
+ });
536
+ it('channel discipline: annotations ride the non-gating notices channel on BOTH paths; only the corrupt-marker warning arms the gates', () => {
537
+ // The `warnings` channel arms THREE gates (ecl-gc A2.2, A2.4
538
+ // verifyComplete, the #2516 INCOMPLETE verdict). Lifecycle annotations are
539
+ // informational senses on a healthy scan — they ride `notices`, which no
540
+ // gate reads, uniformly on the reader AND the compaction-discovery path. A
541
+ // corrupt marker IS a denominator-integrity anomaly and stays a warning on
542
+ // both paths.
543
+ writeOutbox('totem-strategy', 'strategy-claude', [
544
+ { name: '2026-08-11T0937Z-totem-gemini-held.md', to: 'totem-gemini' },
545
+ { name: '2026-08-11T0938Z-totem-claude-note.md', to: 'totem-claude' },
546
+ ]);
547
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
548
+ const readerView = poll({ env: TWO_SEATS });
549
+ const discoveryView = poll({ env: TWO_SEATS, includeProcessed: true });
550
+ for (const view of [readerView, discoveryView]) {
551
+ expect(view.warnings).toEqual([]);
552
+ expect(view.notices.filter((n) => n.includes('suspended seat'))).toHaveLength(1);
553
+ expect(view.mail).toHaveLength(2);
554
+ }
555
+ // Corrupt the marker: the integrity warning fires on the discovery path
556
+ // too (naming the marker path), redding the compaction gate by design.
557
+ const markerPath = path.join(workspace, 'totem', '.totem', 'orchestration', 'totem-gemini', SEAT_LIFECYCLE_FILENAME);
558
+ fs.writeFileSync(markerPath, '{not json', 'utf-8');
559
+ const corruptDiscovery = poll({ env: TWO_SEATS, includeProcessed: true });
560
+ const corruptWarnings = corruptDiscovery.warnings.filter((w) => w.includes(markerPath));
561
+ expect(corruptWarnings).toHaveLength(1);
562
+ });
563
+ it('all self seats suspended ⇒ broadcasts NEVER subtract, and the hold is NAMED with its recovery (never a clean render)', () => {
564
+ writeBroadcast();
565
+ writeLifecycleMarker('totem', 'totem-claude', 'suspended');
566
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
567
+ writeBroadcastProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
568
+ writeBroadcastProcessed('totem', 'totem-gemini', [BROADCAST_FILE]);
569
+ // activeCount 0 ⇒ required stays 1 (`max(…, 1)`) and the active-mark map is
570
+ // EMPTY, so the count is 0: an empty required set is never "closed" (the
571
+ // inherited orchestration.go boundary). Never vacuously consumed.
572
+ const result = poll({ env: TWO_SEATS });
573
+ expect(result.mail.map((m) => m.file)).toEqual([BROADCAST_FILE]);
574
+ // Falsification finding 2: the permanently-pinned broadcast must not
575
+ // render as a clean unread line — one notice names the all-suspended
576
+ // cause and the reactivation recovery. Warnings stay empty (the scan is
577
+ // healthy; nothing here may red a gate).
578
+ expect(result.warnings).toEqual([]);
579
+ const held = result.notices.filter((n) => n.includes('suspended/retired'));
580
+ expect(held).toHaveLength(1);
581
+ expect(held[0]).toContain('--reactivate');
582
+ });
583
+ it('all seats RETIRED ⇒ the hold notice routes to a fresh ruling, never to the --reactivate the CLI refuses (re-arm D-1)', () => {
584
+ writeBroadcast();
585
+ writeLifecycleMarker('totem', 'totem-claude', 'retired');
586
+ writeLifecycleMarker('totem', 'totem-gemini', 'retired');
587
+ const result = poll({ env: TWO_SEATS });
588
+ expect(result.mail.map((m) => m.file)).toEqual([BROADCAST_FILE]);
589
+ const held = result.notices.filter((n) => n.includes('retired'));
590
+ expect(held).toHaveLength(1);
591
+ // `seat add --reactivate` hard-errors on a retired seat — the notice must
592
+ // never advertise a recovery the CLI structurally refuses.
593
+ expect(held[0]).not.toContain('--reactivate');
594
+ expect(held[0]).toContain('register a replacement seat');
595
+ });
596
+ it('a broadcast-NAMED file whose parsed to: is directed earns no hold notice (re-arm D-2, the codex-F2 mislabel class)', () => {
597
+ // The filename token alone selected the flag pre-fold; parsed truth rules
598
+ // now — a mislabeled directed dispatch must not render a "held broadcast"
599
+ // claim above a verdict that shows nothing held.
600
+ writeOutbox('totem-strategy', 'strategy-claude', [
601
+ {
602
+ name: '2026-08-11T0940Z-broadcast-mislabeled.md',
603
+ to: 'strategy-claude',
604
+ subject: 'directed despite the name',
605
+ },
606
+ ]);
607
+ writeLifecycleMarker('totem', 'totem-claude', 'suspended');
608
+ writeLifecycleMarker('totem', 'totem-gemini', 'suspended');
609
+ const result = poll({ env: TWO_SEATS });
610
+ // Addressed to a non-self seat: no mail surfaces, and no hold exists.
611
+ expect(result.mail).toEqual([]);
612
+ expect(result.notices.filter((n) => n.includes('broadcast dispatches'))).toEqual([]);
613
+ });
614
+ it('a marker declaring `active` behaves exactly like no marker (absent ⇒ active)', () => {
615
+ writeBroadcast();
616
+ writeLifecycleMarker('totem', 'totem-gemini', 'active');
617
+ writeBroadcastProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
618
+ const result = poll({ env: TWO_SEATS });
619
+ // Both seats are in the denominator, so one mark does not close it.
620
+ expect(result.mail.map((m) => m.file)).toEqual([BROADCAST_FILE]);
621
+ expect(result.warnings).toEqual([]);
622
+ expect(result.notices).toEqual([]);
623
+ });
624
+ it('markerless tree: the FULL poll result is identical to the pre-#2511 snapshot', () => {
625
+ // The snapshot below was captured by running this exact fixture through
626
+ // HEAD's pre-#2511 `pollMail` and the post-change one side by side and
627
+ // deep-equalling the two results (directed + broadcast + marks + a
628
+ // half-marked broadcast + a roster-invalid address + a cross-sender
629
+ // collision, over a two-seat poll). Pinning the WHOLE object — not a
630
+ // spot-check — is what makes "markerless trees behave bit-identically"
631
+ // (#2511 data-model invariant) a test rather than a claim.
632
+ const DATE = '2026-08-01T1700Z';
633
+ writeOutbox('totem-strategy', 'strategy-claude', [
634
+ {
635
+ name: '2026-08-01T1700Z-totem-claude-directed.md',
636
+ to: 'totem-claude',
637
+ subject: 'one',
638
+ date: DATE,
639
+ },
640
+ {
641
+ name: '2026-08-01T1701Z-totem-claude-consumed.md',
642
+ to: 'totem-claude',
643
+ subject: 'done',
644
+ date: DATE,
645
+ },
646
+ {
647
+ name: '2026-08-01T1702Z-broadcast-cohort.md',
648
+ to: 'broadcast',
649
+ subject: 'bcast',
650
+ date: DATE,
651
+ },
652
+ { name: '2026-08-01T1703Z-broadcast-half.md', to: 'broadcast', subject: 'half', date: DATE },
653
+ {
654
+ name: '2026-08-01T1704Z-cohort-bad-address.md',
655
+ to: 'cohort',
656
+ subject: 'nobody',
657
+ date: DATE,
658
+ },
659
+ { name: '2026-08-01T1705Z-collide.md', to: 'totem-gemini', subject: 'collide-a', date: DATE },
660
+ ]);
661
+ writeOutbox('liquid-city', 'lc-claude', [
662
+ { name: '2026-08-01T1705Z-collide.md', to: 'totem-gemini', subject: 'collide-b', date: DATE },
663
+ {
664
+ name: '2026-08-01T1706Z-totem-gemini-directed.md',
665
+ to: 'totem-gemini',
666
+ subject: 'two',
667
+ date: DATE,
668
+ },
669
+ ]);
670
+ writeProcessed('totem', 'totem-claude', [
671
+ '2026-08-01T1701Z-totem-claude-consumed.md',
672
+ '2026-08-01T1703Z-broadcast-half.md',
673
+ ]);
674
+ writeProcessed('totem', 'totem-gemini', ['2026-08-01T1702Z-broadcast-cohort.md']);
675
+ writeBroadcastProcessed('totem', 'totem-claude', ['2026-08-01T1702Z-broadcast-cohort.md']);
676
+ const entry = (repo, sender, file, to, subject) => ({
677
+ file,
678
+ repo,
679
+ from: sender,
680
+ to,
681
+ date: DATE,
682
+ subject,
683
+ filePath: path.join(workspace, repo, '.totem', 'orchestration', sender, 'outbox', file),
684
+ });
685
+ expect(poll({ env: TWO_SEATS })).toEqual({
686
+ selfAgents: { agents: ['totem-claude', 'totem-gemini'], source: 'env' },
687
+ mail: [
688
+ entry('liquid-city', 'lc-claude', '2026-08-01T1706Z-totem-gemini-directed.md', 'totem-gemini', 'two'),
689
+ entry('totem-strategy', 'strategy-claude', '2026-08-01T1703Z-broadcast-half.md', 'broadcast', 'half'),
690
+ entry('totem-strategy', 'strategy-claude', '2026-08-01T1700Z-totem-claude-directed.md', 'totem-claude', 'one'),
691
+ entry('liquid-city', 'lc-claude', '2026-08-01T1705Z-collide.md', 'totem-gemini', 'collide-b'),
692
+ entry('totem-strategy', 'strategy-claude', '2026-08-01T1705Z-collide.md', 'totem-gemini', 'collide-a'),
693
+ ],
694
+ scanned: 6,
695
+ truncated: false,
696
+ workspace,
697
+ warnings: [
698
+ 'unresolvable outbox address: totem-strategy/strategy-claude/2026-08-01T1704Z-cohort-bad-address.md — to: "cohort" matches no roster agent; invisible to every seat-scoped poll',
699
+ 'cross-sender basename collision: 2026-08-01T1705Z-collide.md from liquid-city/lc-claude and totem-strategy/strategy-claude — a single processed/ mark would shadow both',
700
+ ],
701
+ // Additive field (fold): a markerless tree emits zero notices — the
702
+ // channel's existence is the only delta vs the pre-#2511 result shape.
703
+ notices: [],
704
+ });
705
+ });
706
+ });
427
707
  // ─── Cross-sender basename-collision sensor ─────────────
428
708
  describe('pollMail — cross-sender basename-collision sensor (mmnto-ai/totem#2311)', () => {
429
709
  const NAME = '2026-07-06T1717Z-totem-claude-blind-reply.md';
@@ -1291,7 +1571,15 @@ describe('mailCommand — --json output', () => {
1291
1571
  // ─── Exit contract + NOT-DERIVED verdict (mmnto-ai/totem#2312) ─────────────
1292
1572
  describe('resolveMailExitCode (unit)', () => {
1293
1573
  function result(selfAgents) {
1294
- return { selfAgents, mail: [], scanned: 0, truncated: false, workspace: '/w', warnings: [] };
1574
+ return {
1575
+ selfAgents,
1576
+ mail: [],
1577
+ scanned: 0,
1578
+ truncated: false,
1579
+ workspace: '/w',
1580
+ warnings: [],
1581
+ notices: [],
1582
+ };
1295
1583
  }
1296
1584
  it('is 2 when self is unresolved (source none), 0 when resolved', () => {
1297
1585
  expect(resolveMailExitCode(result({ agents: [], source: 'none' }))).toBe(2);