@mmnto/cli 1.101.1 → 1.102.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.
- 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-detect.js +1 -1
- package/dist/commands/init-detect.js.map +1 -1
- package/dist/commands/init-detect.test.js +2 -1
- package/dist/commands/init-detect.test.js.map +1 -1
- package/dist/commands/init-templates.js +8 -8
- package/dist/commands/install-hooks.d.ts +16 -0
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +5 -1
- 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 +241 -9
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +359 -4
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/review-fan.d.ts.map +1 -1
- package/dist/commands/review-fan.js +32 -8
- package/dist/commands/review-fan.js.map +1 -1
- package/dist/commands/review-fan.test.js +153 -7
- package/dist/commands/review-fan.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 +27 -5
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +116 -41
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +92 -2
- 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/hook/schema.d.ts +2 -2
- package/dist/index.js +28 -7
- package/dist/index.js.map +1 -1
- package/dist/orchestrators/orchestrator.d.ts +86 -1
- package/dist/orchestrators/orchestrator.d.ts.map +1 -1
- package/dist/orchestrators/orchestrator.js +277 -9
- package/dist/orchestrators/orchestrator.js.map +1 -1
- package/dist/orchestrators/orchestrator.test.js +240 -4
- package/dist/orchestrators/orchestrator.test.js.map +1 -1
- package/dist/orchestrators/shell-orchestrator.d.ts +12 -1
- package/dist/orchestrators/shell-orchestrator.d.ts.map +1 -1
- package/dist/orchestrators/shell-orchestrator.js +256 -49
- package/dist/orchestrators/shell-orchestrator.js.map +1 -1
- package/dist/orchestrators/shell-orchestrator.test.js +252 -4
- package/dist/orchestrators/shell-orchestrator.test.js.map +1 -1
- package/dist/utils.d.ts +19 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +394 -108
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +406 -14
- package/dist/utils.test.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;
|
|
@@ -400,6 +400,29 @@ describe('pollMail — broadcast marks are per-seat, not union (mmnto-ai/totem#2
|
|
|
400
400
|
expect(poll({ env: TWO_SEATS }).mail).toEqual([]);
|
|
401
401
|
expect(poll({ env: TWO_SEATS, includeProcessed: true }).mail).toHaveLength(1);
|
|
402
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
|
+
});
|
|
403
426
|
});
|
|
404
427
|
// ─── Cross-sender basename-collision sensor ─────────────
|
|
405
428
|
describe('pollMail — cross-sender basename-collision sensor (mmnto-ai/totem#2311)', () => {
|
|
@@ -1658,7 +1681,10 @@ describe('mailReply — sugar (mmnto-ai/totem#2042)', () => {
|
|
|
1658
1681
|
const src = writeSource();
|
|
1659
1682
|
const res = mailReply(src, {
|
|
1660
1683
|
from: 'totem-claude',
|
|
1661
|
-
|
|
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(),
|
|
1662
1688
|
env: {},
|
|
1663
1689
|
now: fixedClock,
|
|
1664
1690
|
knownAgents: ['strategy-claude'],
|
|
@@ -1679,7 +1705,7 @@ describe('mailReply — sugar (mmnto-ai/totem#2042)', () => {
|
|
|
1679
1705
|
fs.writeFileSync(src, '---\nto: totem-claude\nsubject: legacy mail\n---\n\nBody.\n', 'utf-8');
|
|
1680
1706
|
const res = mailReply(src, {
|
|
1681
1707
|
from: 'totem-claude',
|
|
1682
|
-
repoRoot:
|
|
1708
|
+
repoRoot: selfRepoRoot(), // marker-bearing (CR @1480 resolver parity)
|
|
1683
1709
|
env: {},
|
|
1684
1710
|
now: fixedClock,
|
|
1685
1711
|
knownAgents: ['strategy-claude'],
|
|
@@ -1824,4 +1850,333 @@ describe('validateDispatchContent / composeDispatch / resolveSelfSender (units)'
|
|
|
1824
1850
|
expect(() => resolveSelfSender(totemRepo, {})).toThrow(/ambiguous/); // map → 2 agents
|
|
1825
1851
|
});
|
|
1826
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
|
+
});
|
|
1827
2182
|
//# sourceMappingURL=mail.test.js.map
|