@mmnto/cli 1.101.0 → 1.101.2
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.
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +11 -5
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor.d.ts +36 -6
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +27 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +46 -1
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +16 -4
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/eject.d.ts +37 -2
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +79 -9
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +169 -7
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gate-install.test.js +22 -1
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/init-templates.js +8 -8
- package/dist/commands/install-hooks-exit-contract.test.js +171 -2
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +30 -0
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +151 -20
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/mail-cli-wiring.test.d.ts +15 -0
- package/dist/commands/mail-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/mail-cli-wiring.test.js +106 -0
- package/dist/commands/mail-cli-wiring.test.js.map +1 -0
- package/dist/commands/mail.d.ts +80 -0
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +291 -19
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +432 -4
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/shield-generated.d.ts +145 -0
- package/dist/commands/shield-generated.d.ts.map +1 -0
- package/dist/commands/shield-generated.js +266 -0
- package/dist/commands/shield-generated.js.map +1 -0
- package/dist/commands/shield-generated.test.d.ts +2 -0
- package/dist/commands/shield-generated.test.d.ts.map +1 -0
- package/dist/commands/shield-generated.test.js +323 -0
- package/dist/commands/shield-generated.test.js.map +1 -0
- package/dist/commands/shield.d.ts +2 -2
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +49 -4
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +23 -0
- package/dist/commands/shield.test.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +26 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status.test.js +55 -1
- package/dist/commands/status.test.js.map +1 -1
- package/dist/index.js +28 -7
- package/dist/index.js.map +1 -1
- package/dist/parse-nits.d.ts +20 -3
- package/dist/parse-nits.d.ts.map +1 -1
- package/dist/parse-nits.js +111 -6
- package/dist/parse-nits.js.map +1 -1
- package/dist/parse-nits.test.js +141 -0
- package/dist/parse-nits.test.js.map +1 -1
- package/dist/parsers/bot-review-parser.d.ts.map +1 -1
- package/dist/parsers/bot-review-parser.js +12 -2
- package/dist/parsers/bot-review-parser.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -0
- package/dist/utils.js.map +1 -1
- 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 } from '@mmnto/totem';
|
|
14
|
+
import { findTotemRepoRootSync, TotemError } from '@mmnto/totem';
|
|
15
15
|
import { cleanTmpDir } from '../test-utils.js';
|
|
16
16
|
import { log } from '../ui.js';
|
|
17
|
-
import { composeDispatch, mailCommand, mailReply, mailSend, pollMail, resolveMailExitCode, resolveSelfSender, validateDispatchContent, } from './mail.js';
|
|
17
|
+
import { composeDispatch, mailCommand, mailReply, mailSend, markSource, pollMail, resolveMailExitCode, resolveSelfSender, sanitizeEclBasename, validateDispatchContent, } from './mail.js';
|
|
18
18
|
// ─── Fixture helpers ────────────────────────────────────
|
|
19
19
|
let tmpRoot;
|
|
20
20
|
let workspace;
|
|
@@ -328,6 +328,102 @@ describe('pollMail — processed/ exclusion', () => {
|
|
|
328
328
|
]);
|
|
329
329
|
});
|
|
330
330
|
});
|
|
331
|
+
// ─── Broadcast processed-mark union subtraction ─────────
|
|
332
|
+
// mmnto-ai/totem#2412 (first-consumer-wins): under multi-seat self-resolution
|
|
333
|
+
// the flat union let ONE seat's broadcast mark hide the broadcast from every
|
|
334
|
+
// other seat in the poll. Broadcasts are per-seat consumable (ecl-gc keeps
|
|
335
|
+
// per-seat `_broadcast` stores), so a broadcast-named file subtracts only when
|
|
336
|
+
// EVERY resolved seat holds the mark; directed mail keeps the any-seat union.
|
|
337
|
+
describe('pollMail — broadcast marks are per-seat, not union (mmnto-ai/totem#2412)', () => {
|
|
338
|
+
const BROADCAST_FILE = '2026-07-16T2213Z-broadcast-seat-attribution.md';
|
|
339
|
+
const TWO_SEATS = { TOTEM_SELF_AGENT: 'totem-claude,totem-gemini' };
|
|
340
|
+
it("one seat's mark does NOT hide the broadcast from a multi-seat poll (the live repro)", () => {
|
|
341
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
342
|
+
{ name: BROADCAST_FILE, to: 'broadcast', subject: 'consumers move' },
|
|
343
|
+
]);
|
|
344
|
+
// Mark in processed/ ROOT (not _broadcast/) — the exact live-specimen shape.
|
|
345
|
+
writeProcessed('totem', 'totem-gemini', [BROADCAST_FILE]);
|
|
346
|
+
const result = poll({ env: TWO_SEATS });
|
|
347
|
+
expect(result.mail).toHaveLength(1);
|
|
348
|
+
expect(result.mail[0].file).toBe(BROADCAST_FILE);
|
|
349
|
+
});
|
|
350
|
+
it('subtracts the broadcast once EVERY resolved seat holds a mark (either location)', () => {
|
|
351
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
352
|
+
{ name: BROADCAST_FILE, to: 'broadcast', subject: 'consumers move' },
|
|
353
|
+
]);
|
|
354
|
+
// Mixed locations both count: one seat marks in processed/ root, the other
|
|
355
|
+
// in processed/_broadcast/.
|
|
356
|
+
writeProcessed('totem', 'totem-gemini', [BROADCAST_FILE]);
|
|
357
|
+
writeBroadcastProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
|
|
358
|
+
expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
it('directed mail keeps the any-seat union subtraction (unchanged)', () => {
|
|
361
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
362
|
+
{ name: '2026-07-16T2214Z-totem-gemini-directed-item.md', to: 'totem-gemini' },
|
|
363
|
+
]);
|
|
364
|
+
writeProcessed('totem', 'totem-gemini', ['2026-07-16T2214Z-totem-gemini-directed-item.md']);
|
|
365
|
+
expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
it('single-seat poll is bit-identical to the pre-fix behavior (own mark subtracts)', () => {
|
|
368
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
369
|
+
{ name: BROADCAST_FILE, to: 'broadcast', subject: 'consumers move' },
|
|
370
|
+
]);
|
|
371
|
+
writeProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
|
|
372
|
+
expect(poll({ env: { TOTEM_SELF_AGENT: 'totem-claude' } }).mail).toEqual([]);
|
|
373
|
+
});
|
|
374
|
+
it('duplicate seat ids are normalized — the requirement counts DISTINCT seats (GCA #2424)', () => {
|
|
375
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
376
|
+
{ name: BROADCAST_FILE, to: 'broadcast', subject: 'consumers move' },
|
|
377
|
+
]);
|
|
378
|
+
writeProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
|
|
379
|
+
writeProcessed('totem', 'totem-gemini', [BROADCAST_FILE]);
|
|
380
|
+
// Both distinct seats hold marks; the duplicated id must not raise the bar to 3.
|
|
381
|
+
const dupes = { TOTEM_SELF_AGENT: 'totem-claude,totem-claude,totem-gemini' };
|
|
382
|
+
expect(poll({ env: dupes }).mail).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
it('legacy broadcast WITHOUT the filename token stays on the union rule (disclosed residual)', () => {
|
|
385
|
+
// Subtraction runs pre-parse, so the broadcast rule keys on the positional
|
|
386
|
+
// filename token; a tokenless `to: broadcast` dispatch keeps the historical
|
|
387
|
+
// union subtraction — no worse than before the fix.
|
|
388
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
389
|
+
{ name: '2026-05-17T0103Z.md', to: 'broadcast', subject: 'legacy name' },
|
|
390
|
+
]);
|
|
391
|
+
writeProcessed('totem', 'totem-gemini', ['2026-05-17T0103Z.md']);
|
|
392
|
+
expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
|
|
393
|
+
});
|
|
394
|
+
it('includeProcessed still returns the RAW set — an all-seats-consumed broadcast stays visible to compaction', () => {
|
|
395
|
+
writeOutbox('totem-strategy', 'strategy-claude', [
|
|
396
|
+
{ name: BROADCAST_FILE, to: 'broadcast', subject: 'consumers move' },
|
|
397
|
+
]);
|
|
398
|
+
writeBroadcastProcessed('totem', 'totem-claude', [BROADCAST_FILE]);
|
|
399
|
+
writeBroadcastProcessed('totem', 'totem-gemini', [BROADCAST_FILE]);
|
|
400
|
+
expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
|
|
401
|
+
expect(poll({ env: TWO_SEATS, includeProcessed: true }).mail).toHaveLength(1);
|
|
402
|
+
});
|
|
403
|
+
it('a COLON-bearing broadcast-named file requires EVERY seat mark (CR @623 — classify on sanitized)', () => {
|
|
404
|
+
// Before the fix, `isBroadcastNamed` ran the raw `T\d{4}Z` stamp regex, which
|
|
405
|
+
// a colon stamp (`T05:10Z`) fails → the broadcast fell to the ANY-seat rule
|
|
406
|
+
// and one seat's mark hid it from the other (the #2412 class, resurrected for
|
|
407
|
+
// that name shape). Now classification runs on the sanitized basename.
|
|
408
|
+
const colon = '2026-07-18T05:10Z-broadcast-consumers.md';
|
|
409
|
+
const sanitized = sanitizeEclBasename(colon);
|
|
410
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [
|
|
411
|
+
{ name: colon, to: 'broadcast', subject: 'consumers move' },
|
|
412
|
+
]);
|
|
413
|
+
// Only meaningful where the FS preserves colons in a directory listing; NTFS
|
|
414
|
+
// turns the colon into an ADS the readdir never lists, so the corrupt inbound
|
|
415
|
+
// is simply absent there (no first-consumer-wins hazard). Guard so the
|
|
416
|
+
// assertion runs on colon-legal filesystems (CI/Linux) and no-ops on win32.
|
|
417
|
+
if (!fs.readdirSync(outbox).includes(colon))
|
|
418
|
+
return;
|
|
419
|
+
// Marks are stored sanitized; one seat's mark must NOT hide the broadcast.
|
|
420
|
+
writeProcessed('totem', 'totem-gemini', [sanitized]);
|
|
421
|
+
expect(poll({ env: TWO_SEATS }).mail.map((m) => sanitizeEclBasename(m.file))).toContain(sanitized);
|
|
422
|
+
// Only once EVERY resolved seat holds the mark does it subtract.
|
|
423
|
+
writeProcessed('totem', 'totem-claude', [sanitized]);
|
|
424
|
+
expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
331
427
|
// ─── Cross-sender basename-collision sensor ─────────────
|
|
332
428
|
describe('pollMail — cross-sender basename-collision sensor (mmnto-ai/totem#2311)', () => {
|
|
333
429
|
const NAME = '2026-07-06T1717Z-totem-claude-blind-reply.md';
|
|
@@ -1585,7 +1681,10 @@ describe('mailReply — sugar (mmnto-ai/totem#2042)', () => {
|
|
|
1585
1681
|
const src = writeSource();
|
|
1586
1682
|
const res = mailReply(src, {
|
|
1587
1683
|
from: 'totem-claude',
|
|
1588
|
-
|
|
1684
|
+
// Marker-bearing root: markSource resolves the repo root via the shared
|
|
1685
|
+
// walk-up resolver (CR @1480), so a marker-less fixture would climb to a
|
|
1686
|
+
// host ancestor `.totem`.
|
|
1687
|
+
repoRoot: selfRepoRoot(),
|
|
1589
1688
|
env: {},
|
|
1590
1689
|
now: fixedClock,
|
|
1591
1690
|
knownAgents: ['strategy-claude'],
|
|
@@ -1606,7 +1705,7 @@ describe('mailReply — sugar (mmnto-ai/totem#2042)', () => {
|
|
|
1606
1705
|
fs.writeFileSync(src, '---\nto: totem-claude\nsubject: legacy mail\n---\n\nBody.\n', 'utf-8');
|
|
1607
1706
|
const res = mailReply(src, {
|
|
1608
1707
|
from: 'totem-claude',
|
|
1609
|
-
repoRoot:
|
|
1708
|
+
repoRoot: selfRepoRoot(), // marker-bearing (CR @1480 resolver parity)
|
|
1610
1709
|
env: {},
|
|
1611
1710
|
now: fixedClock,
|
|
1612
1711
|
knownAgents: ['strategy-claude'],
|
|
@@ -1751,4 +1850,333 @@ describe('validateDispatchContent / composeDispatch / resolveSelfSender (units)'
|
|
|
1751
1850
|
expect(() => resolveSelfSender(totemRepo, {})).toThrow(/ambiguous/); // map → 2 agents
|
|
1752
1851
|
});
|
|
1753
1852
|
});
|
|
1853
|
+
// ─── Consume-marking: mail mark (mmnto-ai/totem#2396) ───
|
|
1854
|
+
describe('markSource — standalone mail mark (mmnto-ai/totem#2396)', () => {
|
|
1855
|
+
const SOLO = { TOTEM_SELF_AGENT: 'totem-claude' };
|
|
1856
|
+
function processedPath(seat, name, broadcast = false) {
|
|
1857
|
+
const base = path.join(selfRepoRoot(), '.totem', 'orchestration', seat, 'processed');
|
|
1858
|
+
return broadcast ? path.join(base, '_broadcast', name) : path.join(base, name);
|
|
1859
|
+
}
|
|
1860
|
+
it('marks a directed dispatch so a subsequent poll subtracts it (consume-atomicity)', () => {
|
|
1861
|
+
const name = '2026-07-16T2200Z-totem-claude-handoff.md';
|
|
1862
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [
|
|
1863
|
+
{ name, to: 'totem-claude', subject: 'lane handoff' },
|
|
1864
|
+
]);
|
|
1865
|
+
const source = path.join(outbox, name);
|
|
1866
|
+
// Pre-mark: the dispatch is unread for this seat.
|
|
1867
|
+
expect(poll({ env: SOLO }).mail.map((m) => m.file)).toContain(name);
|
|
1868
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), env: SOLO });
|
|
1869
|
+
expect(res.agent).toBe('totem-claude');
|
|
1870
|
+
expect(res.broadcast).toBe(false);
|
|
1871
|
+
expect(res.alreadyMarked).toBe(false);
|
|
1872
|
+
// The mark is a same-basename FULL COPY of the source under the directed store.
|
|
1873
|
+
expect(res.markPath).toBe(processedPath('totem-claude', name));
|
|
1874
|
+
expect(fs.readFileSync(res.markPath, 'utf-8')).toBe(fs.readFileSync(source, 'utf-8'));
|
|
1875
|
+
// Post-mark: the reader no longer surfaces it (phantom-unread closed).
|
|
1876
|
+
expect(poll({ env: SOLO }).mail).toEqual([]);
|
|
1877
|
+
});
|
|
1878
|
+
it('marks a broadcast dispatch under processed/_broadcast/ (per-seat store)', () => {
|
|
1879
|
+
const name = '2026-07-16T2213Z-broadcast-cohort-move.md';
|
|
1880
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [
|
|
1881
|
+
{ name, to: 'broadcast', subject: 'cohort move' },
|
|
1882
|
+
]);
|
|
1883
|
+
const source = path.join(outbox, name);
|
|
1884
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), agentId: 'totem-claude', env: {} });
|
|
1885
|
+
expect(res.broadcast).toBe(true);
|
|
1886
|
+
expect(res.markPath).toBe(processedPath('totem-claude', name, true));
|
|
1887
|
+
// Single-seat poll subtracts it (broadcast needs every seat; SOLO = just this one).
|
|
1888
|
+
expect(poll({ env: SOLO }).mail).toEqual([]);
|
|
1889
|
+
});
|
|
1890
|
+
it('is idempotent — a second mark is a no-op (alreadyMarked, same path)', () => {
|
|
1891
|
+
const name = '2026-07-16T2200Z-totem-claude-x.md';
|
|
1892
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [{ name, to: 'totem-claude' }]);
|
|
1893
|
+
const source = path.join(outbox, name);
|
|
1894
|
+
const first = markSource(source, {
|
|
1895
|
+
repoRoot: selfRepoRoot(),
|
|
1896
|
+
agentId: 'totem-claude',
|
|
1897
|
+
env: {},
|
|
1898
|
+
});
|
|
1899
|
+
expect(first.alreadyMarked).toBe(false);
|
|
1900
|
+
const second = markSource(source, {
|
|
1901
|
+
repoRoot: selfRepoRoot(),
|
|
1902
|
+
agentId: 'totem-claude',
|
|
1903
|
+
env: {},
|
|
1904
|
+
});
|
|
1905
|
+
expect(second.alreadyMarked).toBe(true);
|
|
1906
|
+
expect(second.markPath).toBe(first.markPath);
|
|
1907
|
+
});
|
|
1908
|
+
it('marks only into the resolved seat OWN cursor (single-writer, § A2.3)', () => {
|
|
1909
|
+
const name = '2026-07-16T2200Z-totem-gemini-item.md';
|
|
1910
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [{ name, to: 'totem-gemini' }]);
|
|
1911
|
+
const source = path.join(outbox, name);
|
|
1912
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), agentId: 'totem-gemini', env: {} });
|
|
1913
|
+
expect(res.markPath).toBe(processedPath('totem-gemini', name));
|
|
1914
|
+
// A different seat's cursor is untouched.
|
|
1915
|
+
expect(fs.existsSync(processedPath('totem-claude', name))).toBe(false);
|
|
1916
|
+
});
|
|
1917
|
+
it('hard-errors when the source is missing', () => {
|
|
1918
|
+
expect(() => markSource(path.join(tmpRoot, 'nope.md'), {
|
|
1919
|
+
repoRoot: selfRepoRoot(),
|
|
1920
|
+
agentId: 'totem-claude',
|
|
1921
|
+
env: {},
|
|
1922
|
+
})).toThrow(/cannot read mark source dispatch/);
|
|
1923
|
+
});
|
|
1924
|
+
it('hard-errors when the source is not parseable mail (a stray file is not markable)', () => {
|
|
1925
|
+
const stray = path.join(mkDir(path.join(tmpRoot, 'stray')), 'not-mail.md');
|
|
1926
|
+
fs.writeFileSync(stray, 'no frontmatter here\n', 'utf-8');
|
|
1927
|
+
expect(() => markSource(stray, { repoRoot: selfRepoRoot(), agentId: 'totem-claude', env: {} })).toThrow(/not parseable mail/);
|
|
1928
|
+
});
|
|
1929
|
+
it('hard-errors on ambiguous self with no --agent-id; explicit --agent-id disambiguates', () => {
|
|
1930
|
+
const name = '2026-07-16T2200Z-totem-claude-amb.md';
|
|
1931
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [{ name, to: 'totem-claude' }]);
|
|
1932
|
+
const source = path.join(outbox, name);
|
|
1933
|
+
// The `totem` repo hosts totem-claude + totem-gemini → ambiguous without --agent-id.
|
|
1934
|
+
expect(() => markSource(source, { repoRoot: selfRepoRoot(), env: {} })).toThrow(/cannot resolve the consuming seat/);
|
|
1935
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), agentId: 'totem-claude', env: {} });
|
|
1936
|
+
expect(res.agent).toBe('totem-claude');
|
|
1937
|
+
});
|
|
1938
|
+
it('resolves the repo root by walking up from a subdirectory (CR @1480 parity)', () => {
|
|
1939
|
+
const name = '2026-07-16T2200Z-totem-claude-subdir.md';
|
|
1940
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [{ name, to: 'totem-claude' }]);
|
|
1941
|
+
const source = path.join(outbox, name);
|
|
1942
|
+
// Invoke with a repoRoot BELOW the real root (which carries the `.totem`
|
|
1943
|
+
// marker): the shared resolver must climb to the real root so the mark lands
|
|
1944
|
+
// in the store the poll reads — not a phantom `<subdir>/.totem`.
|
|
1945
|
+
const subdir = mkDir(path.join(selfRepoRoot(), 'packages', 'cli'));
|
|
1946
|
+
const res = markSource(source, { repoRoot: subdir, agentId: 'totem-claude', env: {} });
|
|
1947
|
+
expect(res.markPath).toBe(processedPath('totem-claude', name));
|
|
1948
|
+
expect(fs.existsSync(processedPath('totem-claude', name))).toBe(true);
|
|
1949
|
+
// Nothing was written under the subdir.
|
|
1950
|
+
expect(fs.existsSync(path.join(subdir, '.totem'))).toBe(false);
|
|
1951
|
+
});
|
|
1952
|
+
it('refuses to mark a directed dispatch into a non-recipient seat (CR @1532 reject)', () => {
|
|
1953
|
+
const name = '2026-07-16T2200Z-totem-gemini-owned.md';
|
|
1954
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [{ name, to: 'totem-gemini' }]);
|
|
1955
|
+
const source = path.join(outbox, name);
|
|
1956
|
+
// Directed to totem-gemini, but marking as totem-claude → any-seat subtraction
|
|
1957
|
+
// would suppress gemini's dispatch in a union poll. Hard reject.
|
|
1958
|
+
expect(() => markSource(source, { repoRoot: selfRepoRoot(), agentId: 'totem-claude', env: {} })).toThrow(/refusing to mark directed dispatch/);
|
|
1959
|
+
// No mark was written into the wrong seat's cursor.
|
|
1960
|
+
expect(fs.existsSync(processedPath('totem-claude', name))).toBe(false);
|
|
1961
|
+
});
|
|
1962
|
+
it('accepts a directed mark when the resolving seat IS the recipient (CR @1532 accept)', () => {
|
|
1963
|
+
const name = '2026-07-16T2200Z-totem-gemini-owned2.md';
|
|
1964
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [{ name, to: 'totem-gemini' }]);
|
|
1965
|
+
const source = path.join(outbox, name);
|
|
1966
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), agentId: 'totem-gemini', env: {} });
|
|
1967
|
+
expect(res.agent).toBe('totem-gemini');
|
|
1968
|
+
expect(res.markPath).toBe(processedPath('totem-gemini', name));
|
|
1969
|
+
});
|
|
1970
|
+
});
|
|
1971
|
+
describe('mailReply — atomic consume-mark (mmnto-ai/totem#2396)', () => {
|
|
1972
|
+
const fixedClock = () => new Date('2026-07-16T18:00:00.000Z');
|
|
1973
|
+
const SRC_NAME = '2026-07-16T1710Z-totem-claude-orig.md';
|
|
1974
|
+
function writeSource(to = 'totem-claude') {
|
|
1975
|
+
const outbox = mkDir(path.join(workspace, 'totem-strategy', '.totem', 'orchestration', 'strategy-claude', 'outbox'));
|
|
1976
|
+
const p = path.join(outbox, SRC_NAME);
|
|
1977
|
+
fs.writeFileSync(p, [
|
|
1978
|
+
'---',
|
|
1979
|
+
'schema: adr-098-v0.4',
|
|
1980
|
+
'from: strategy-claude',
|
|
1981
|
+
`to: ${to}`,
|
|
1982
|
+
'timestamp: 2026-07-16T17:10:00.000Z',
|
|
1983
|
+
'subject: Original',
|
|
1984
|
+
'---',
|
|
1985
|
+
'',
|
|
1986
|
+
'orig body',
|
|
1987
|
+
'',
|
|
1988
|
+
].join('\n'), 'utf-8');
|
|
1989
|
+
return p;
|
|
1990
|
+
}
|
|
1991
|
+
it('marks the source processed in the same command as the reply (default; copy under the replying seat)', () => {
|
|
1992
|
+
const src = writeSource();
|
|
1993
|
+
const repoRoot = selfRepoRoot(); // marker-bearing (CR @1480 resolver parity)
|
|
1994
|
+
const res = mailReply(src, {
|
|
1995
|
+
from: 'totem-claude',
|
|
1996
|
+
repoRoot,
|
|
1997
|
+
env: {},
|
|
1998
|
+
now: fixedClock,
|
|
1999
|
+
knownAgents: ['strategy-claude'],
|
|
2000
|
+
});
|
|
2001
|
+
expect(res.mark).toBeDefined();
|
|
2002
|
+
expect(res.mark.agent).toBe('totem-claude'); // = the replying seat (source's recipient)
|
|
2003
|
+
expect(res.mark.broadcast).toBe(false);
|
|
2004
|
+
expect(res.mark.alreadyMarked).toBe(false);
|
|
2005
|
+
const expected = path.join(repoRoot, '.totem', 'orchestration', 'totem-claude', 'processed', SRC_NAME);
|
|
2006
|
+
expect(res.mark.markPath).toBe(expected);
|
|
2007
|
+
expect(fs.readFileSync(expected, 'utf-8')).toBe(fs.readFileSync(src, 'utf-8'));
|
|
2008
|
+
});
|
|
2009
|
+
it('a subsequent poll no longer surfaces the replied-to source', () => {
|
|
2010
|
+
const src = writeSource();
|
|
2011
|
+
// Pre-reply: the source IS unread for this seat (CR @2351 test-hardening —
|
|
2012
|
+
// without this the post-reply assertion could pass vacuously).
|
|
2013
|
+
expect(poll({ env: { TOTEM_SELF_AGENT: 'totem-claude' } }).mail.map((m) => m.file)).toContain(SRC_NAME);
|
|
2014
|
+
mailReply(src, {
|
|
2015
|
+
from: 'totem-claude',
|
|
2016
|
+
repoRoot: selfRepoRoot(),
|
|
2017
|
+
env: {},
|
|
2018
|
+
now: fixedClock,
|
|
2019
|
+
knownAgents: ['strategy-claude'],
|
|
2020
|
+
});
|
|
2021
|
+
expect(poll({ env: { TOTEM_SELF_AGENT: 'totem-claude' } }).mail.map((m) => m.file)).not.toContain(SRC_NAME);
|
|
2022
|
+
});
|
|
2023
|
+
it('when the mark fails after the reply lands, the error is distinguishable + anti-retry (greptile P1)', () => {
|
|
2024
|
+
const src = writeSource();
|
|
2025
|
+
const repoRoot = selfRepoRoot();
|
|
2026
|
+
// Block ONLY the mark write: occupy the seat's `processed/` PATH with a file
|
|
2027
|
+
// so markSource's mkdir fails — the reply (a sibling `outbox/` write) still
|
|
2028
|
+
// lands. Distinct from a reply failure.
|
|
2029
|
+
const seatDir = mkDir(path.join(repoRoot, '.totem', 'orchestration', 'totem-claude'));
|
|
2030
|
+
fs.writeFileSync(path.join(seatDir, 'processed'), 'occupied (not a dir)', 'utf-8');
|
|
2031
|
+
let threw;
|
|
2032
|
+
try {
|
|
2033
|
+
mailReply(src, {
|
|
2034
|
+
from: 'totem-claude',
|
|
2035
|
+
repoRoot,
|
|
2036
|
+
env: {},
|
|
2037
|
+
now: fixedClock,
|
|
2038
|
+
knownAgents: ['strategy-claude'],
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
catch (e) {
|
|
2042
|
+
threw = e;
|
|
2043
|
+
}
|
|
2044
|
+
expect(threw).toBeInstanceOf(TotemError);
|
|
2045
|
+
const err = threw;
|
|
2046
|
+
// Message names the LANDED reply path so a re-run is visibly unnecessary.
|
|
2047
|
+
expect(err.message).toContain('reply written to');
|
|
2048
|
+
expect(err.message).toContain(path.join('totem-claude', 'outbox'));
|
|
2049
|
+
// Hint forbids the duplicate-producing retry and routes to `mail mark`.
|
|
2050
|
+
expect(err.recoveryHint).toContain('do NOT retry mail reply');
|
|
2051
|
+
expect(err.recoveryHint).toContain(`totem mail mark ${src}`);
|
|
2052
|
+
// The reply really did land in the outbox.
|
|
2053
|
+
const outbox = path.join(repoRoot, '.totem', 'orchestration', 'totem-claude', 'outbox');
|
|
2054
|
+
expect(fs.readdirSync(outbox).filter((f) => f.endsWith('.md')).length).toBeGreaterThan(0);
|
|
2055
|
+
});
|
|
2056
|
+
it('--no-mark (noMark) leaves the source unmarked (stage-only reply)', () => {
|
|
2057
|
+
const src = writeSource();
|
|
2058
|
+
const repoRoot = mkDir(path.join(workspace, 'totem'));
|
|
2059
|
+
const res = mailReply(src, {
|
|
2060
|
+
from: 'totem-claude',
|
|
2061
|
+
noMark: true,
|
|
2062
|
+
repoRoot,
|
|
2063
|
+
env: {},
|
|
2064
|
+
now: fixedClock,
|
|
2065
|
+
knownAgents: ['strategy-claude'],
|
|
2066
|
+
});
|
|
2067
|
+
expect(res.mark).toBeUndefined();
|
|
2068
|
+
const markPath = path.join(repoRoot, '.totem', 'orchestration', 'totem-claude', 'processed', SRC_NAME);
|
|
2069
|
+
expect(fs.existsSync(markPath)).toBe(false);
|
|
2070
|
+
});
|
|
2071
|
+
it('marks a broadcast source under processed/_broadcast/ even when the reply itself is directed', () => {
|
|
2072
|
+
// Reply target derives from source.from (strategy-claude, directed), but the
|
|
2073
|
+
// MARK follows the SOURCE's class — a broadcast source lands in _broadcast/.
|
|
2074
|
+
const src = writeSource('broadcast');
|
|
2075
|
+
const repoRoot = selfRepoRoot(); // marker-bearing (CR @1480 resolver parity)
|
|
2076
|
+
const res = mailReply(src, {
|
|
2077
|
+
from: 'totem-claude',
|
|
2078
|
+
repoRoot,
|
|
2079
|
+
env: {},
|
|
2080
|
+
now: fixedClock,
|
|
2081
|
+
knownAgents: ['strategy-claude'],
|
|
2082
|
+
});
|
|
2083
|
+
expect(res.header.to).toBe('strategy-claude'); // reply is directed to the sender
|
|
2084
|
+
expect(res.mark.broadcast).toBe(true);
|
|
2085
|
+
expect(res.mark.markPath).toBe(path.join(repoRoot, '.totem', 'orchestration', 'totem-claude', 'processed', '_broadcast', SRC_NAME));
|
|
2086
|
+
});
|
|
2087
|
+
});
|
|
2088
|
+
// ─── NTFS-safe basename sanitization (mmnto-ai/totem#2431) ───
|
|
2089
|
+
// A colon in an ECL filename is an NTFS Alternate Data Stream separator: writing
|
|
2090
|
+
// `...T05:10Z-x.md` verbatim creates a 0-byte base file (`...T05`) with the real
|
|
2091
|
+
// bytes in an invisible stream `fs.readdirSync` never lists — so a colon-bearing
|
|
2092
|
+
// mark is unreadable and its dispatch reports unread forever. The writer stores
|
|
2093
|
+
// the sanitized name; the reader normalizes both sides before comparison.
|
|
2094
|
+
describe('sanitizeEclBasename (unit)', () => {
|
|
2095
|
+
it('strips colons on all platforms and is idempotent', () => {
|
|
2096
|
+
expect(sanitizeEclBasename('2026-07-18T05:10Z-foo.md')).toBe('2026-07-18T0510Z-foo.md');
|
|
2097
|
+
// Full ISO with seconds (two colons) collapses to the compact stamp shape.
|
|
2098
|
+
expect(sanitizeEclBasename('2026-07-18T05:10:23Z-foo.md')).toBe('2026-07-18T051023Z-foo.md');
|
|
2099
|
+
// A colon-free basename passes through unchanged (idempotent).
|
|
2100
|
+
expect(sanitizeEclBasename('2026-07-18T0510Z-foo.md')).toBe('2026-07-18T0510Z-foo.md');
|
|
2101
|
+
});
|
|
2102
|
+
});
|
|
2103
|
+
describe('markSource — colon-bearing source basenames are sanitized (mmnto-ai/totem#2431)', () => {
|
|
2104
|
+
const SOLO = { TOTEM_SELF_AGENT: 'totem-claude' };
|
|
2105
|
+
function processedPath(seat, name, broadcast = false) {
|
|
2106
|
+
const base = path.join(selfRepoRoot(), '.totem', 'orchestration', seat, 'processed');
|
|
2107
|
+
return broadcast ? path.join(base, '_broadcast', name) : path.join(base, name);
|
|
2108
|
+
}
|
|
2109
|
+
it('(a) marks a colon-bearing directed source under the sanitized name; the poll subtracts it', () => {
|
|
2110
|
+
const colon = '2026-07-18T05:10Z-totem-claude-legacy.md';
|
|
2111
|
+
const sanitized = '2026-07-18T0510Z-totem-claude-legacy.md';
|
|
2112
|
+
// `fs.writeFileSync` to the colon path round-trips via the ADS stream on
|
|
2113
|
+
// win32, so `markSource`'s read of the source still works; the mark is
|
|
2114
|
+
// written NTFS-safe (colon-free) so it is actually listable.
|
|
2115
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [
|
|
2116
|
+
{ name: colon, to: 'totem-claude', subject: 'legacy colon name' },
|
|
2117
|
+
]);
|
|
2118
|
+
const source = path.join(outbox, colon);
|
|
2119
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), env: SOLO });
|
|
2120
|
+
expect(res.fileName).toBe(sanitized); // stored NTFS-safe, not verbatim
|
|
2121
|
+
expect(res.markPath).toBe(processedPath('totem-claude', sanitized));
|
|
2122
|
+
expect(fs.existsSync(res.markPath)).toBe(true); // the mark is a real, listable file
|
|
2123
|
+
// Wherever the colon inbound is discoverable (a colon-legal FS), the reader
|
|
2124
|
+
// subtracts it via the shared sanitizer; on win32 the corrupt inbound is not
|
|
2125
|
+
// listed at all, so this holds vacuously — never a phantom-unread either way.
|
|
2126
|
+
const remaining = poll({ env: SOLO }).mail.map((m) => sanitizeEclBasename(m.file));
|
|
2127
|
+
expect(remaining).not.toContain(sanitized);
|
|
2128
|
+
});
|
|
2129
|
+
it('(b) idempotent re-mark of a colon-bearing source is a no-op (sanitized-name check)', () => {
|
|
2130
|
+
const colon = '2026-07-18T05:10Z-totem-claude-x.md';
|
|
2131
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [
|
|
2132
|
+
{ name: colon, to: 'totem-claude' },
|
|
2133
|
+
]);
|
|
2134
|
+
const source = path.join(outbox, colon);
|
|
2135
|
+
const first = markSource(source, {
|
|
2136
|
+
repoRoot: selfRepoRoot(),
|
|
2137
|
+
agentId: 'totem-claude',
|
|
2138
|
+
env: {},
|
|
2139
|
+
});
|
|
2140
|
+
expect(first.alreadyMarked).toBe(false);
|
|
2141
|
+
const second = markSource(source, {
|
|
2142
|
+
repoRoot: selfRepoRoot(),
|
|
2143
|
+
agentId: 'totem-claude',
|
|
2144
|
+
env: {},
|
|
2145
|
+
});
|
|
2146
|
+
expect(second.alreadyMarked).toBe(true);
|
|
2147
|
+
expect(second.markPath).toBe(first.markPath);
|
|
2148
|
+
});
|
|
2149
|
+
it('(c) sanitizes a colon-bearing broadcast source into processed/_broadcast/', () => {
|
|
2150
|
+
const colon = '2026-07-18T05:10Z-broadcast-cohort.md';
|
|
2151
|
+
const sanitized = '2026-07-18T0510Z-broadcast-cohort.md';
|
|
2152
|
+
const outbox = writeOutbox('totem-strategy', 'strategy-claude', [
|
|
2153
|
+
{ name: colon, to: 'broadcast', subject: 'cohort move' },
|
|
2154
|
+
]);
|
|
2155
|
+
const source = path.join(outbox, colon);
|
|
2156
|
+
const res = markSource(source, { repoRoot: selfRepoRoot(), agentId: 'totem-claude', env: {} });
|
|
2157
|
+
expect(res.broadcast).toBe(true);
|
|
2158
|
+
expect(res.fileName).toBe(sanitized);
|
|
2159
|
+
expect(res.markPath).toBe(processedPath('totem-claude', sanitized, true));
|
|
2160
|
+
expect(fs.existsSync(res.markPath)).toBe(true);
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
describe('mailSend — the filename emitter is colon-free / ADS-safe (mmnto-ai/totem#2431)', () => {
|
|
2164
|
+
it('(d) composes a colon-free compact-stamp outbox filename even from a seconds-bearing ISO clock', () => {
|
|
2165
|
+
// The emitter path is compactStamp (drops the HH:MM colon) → fileToken →
|
|
2166
|
+
// slugify, none of which can emit a colon. A raw ISO with seconds is the
|
|
2167
|
+
// worst case; the written name must still carry no colon.
|
|
2168
|
+
const res = mailSend({
|
|
2169
|
+
to: 'strategy-claude',
|
|
2170
|
+
subject: 'lane handoff',
|
|
2171
|
+
from: 'totem-claude',
|
|
2172
|
+
repoRoot: mkDir(path.join(workspace, 'totem')),
|
|
2173
|
+
env: {},
|
|
2174
|
+
now: () => new Date('2026-07-18T05:10:23.456Z'),
|
|
2175
|
+
knownAgents: ['strategy-claude'],
|
|
2176
|
+
});
|
|
2177
|
+
expect(res.fileName).not.toContain(':');
|
|
2178
|
+
expect(res.fileName).toMatch(/^2026-07-18T0510Z-/); // compact stamp, colon dropped
|
|
2179
|
+
expect(path.basename(res.filePath)).not.toContain(':');
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
1754
2182
|
//# sourceMappingURL=mail.test.js.map
|