From 18f3dd49ecf0672d406cf855dc005ed9dff5a400 Mon Sep 17 00:00:00 2001 From: marcie Date: Fri, 28 Aug 2026 17:37:15 -0500 Subject: [PATCH 1/4] mosaic watch: first embedded brain-tool dispatch command Architecture (fleet CLI integration, Jason ruling 2026-08-28): the package embeds the command surface; operator-owned implementations stay in the brain (tools/agent-watch). The command resolves the brain home via resolveBrainHome (MOSAIC_BRAIN_HOME wins, canonical ~/.mosaic adoption otherwise) and execs the tool there. - Pass-through contract: allowUnknownOption + variadic args capture the full ordered argument list (commander 13 measured behavior; no passThroughOptions, which would force enablePositionalOptions on the root program fleet-wide). Args, stdout/stderr, and the exit code belong to the tool. - Absent tool: exit 127 naming the resolved path (no guessing). - Signal death / null spawn status: exit 125. - Spec (6/6 green): brain-home precedence, exit mapping, absent-tool path. Live smoke verified through the fleet launcher: mosaic watch list returns real watcher state with exit-code passthrough. Pre-existing local vitest failures in fleet-agent-crud/fleet-regen/ compose-contract specs measured identical on a clean origin/next stash; not caused by this change (PR #1462 CI was terminal-green). --- packages/mosaic/src/cli.ts | 2 + packages/mosaic/src/commands/watch.spec.ts | 72 ++++++++++++++++++ packages/mosaic/src/commands/watch.ts | 87 ++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 packages/mosaic/src/commands/watch.spec.ts create mode 100644 packages/mosaic/src/commands/watch.ts diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f38e64a7..9aa91ba3 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; import { registerTelemetryCommand } from './commands/telemetry.js'; +import { registerWatchCommand } from './commands/watch.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerInteractionCommand } from './commands/interaction.js'; import { registerConfigCommand } from './commands/config.js'; @@ -428,6 +429,7 @@ registerSkillCommand(program); // ─── telemetry ─────────────────────────────────────────────────────────────── registerTelemetryCommand(program); +registerWatchCommand(program); // ─── update ───────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/watch.spec.ts b/packages/mosaic/src/commands/watch.spec.ts new file mode 100644 index 00000000..489ef8c2 --- /dev/null +++ b/packages/mosaic/src/commands/watch.spec.ts @@ -0,0 +1,72 @@ +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js'; + +// The dispatch command execs a real process with inherited stdio; the spec +// covers the pure resolution and exit-mapping surfaces plus the absent-tool +// path (which exits without spawning). Live pass-through is exercised by the +// fleet smoke test against the real brain tool. + +describe('resolveAgentWatchTool', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + }); + + it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => { + const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe( + join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'), + ); + }); + + it('resolves inside the brain tools tree', () => { + const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + const tool = resolveAgentWatchTool(tmp); + expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true); + }); +}); + +describe('exitStatusFor', () => { + it('maps absent tool to 127', () => { + expect(exitStatusFor({ status: 0 }, false)).toBe(127); + }); + + it('passes the tool exit status through', () => { + expect(exitStatusFor({ status: 2 }, true)).toBe(2); + expect(exitStatusFor({ status: 78 }, true)).toBe(78); + }); + + it('maps signal death / null status to 125', () => { + expect(exitStatusFor({ status: null }, true)).toBe(125); + }); +}); + +describe('registerWatchCommand absent-tool path', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + process.exitCode = undefined; + }); + + it('sets exitCode 127 with the resolved path when the tool is missing', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-')); + // The suite directory exists but the tool file does not. + mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true }); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + + const program = new Command(); + registerWatchCommand(program); + await program.parseAsync(['watch', 'list'], { from: 'user' }); + expect(process.exitCode).toBe(127); + }); +}); diff --git a/packages/mosaic/src/commands/watch.ts b/packages/mosaic/src/commands/watch.ts new file mode 100644 index 00000000..e3bde144 --- /dev/null +++ b/packages/mosaic/src/commands/watch.ts @@ -0,0 +1,87 @@ +import type { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { accessSync, constants } from 'node:fs'; +import { join } from 'node:path'; + +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; +import { resolveBrainHome } from '../fleet/brain-home.js'; + +/** + * `mosaic watch` — dispatch to the brain's agent-watch suite. + * + * Architecture (fleet CLI integration, Jason ruling 2026-08-28): the npm + * package embeds the COMMAND SURFACE; operator-owned tool implementations + * stay in the brain (`~/.mosaic/tools/`). The dispatch resolves the brain + * home (MOSAIC_BRAIN_HOME wins, else canonical ~/.mosaic when adopted — + * see brain-home.ts) and execs the tool there. Nothing operator-specific + * ships inside the package, keeping the framework boundary clean. + * + * Pass-through contract: arguments, stdout/stderr, and the exit code belong + * to the tool. The CLI adds nothing on success. When the tool is absent the + * CLI fails loudly with the resolved path (exit 127) instead of guessing. + */ + +export function resolveAgentWatchTool(mosaicHome: string): string { + return join(resolveBrainHome(mosaicHome), 'tools', 'agent-watch', 'agent-watch.sh'); +} + +/** Map a spawnSync result + tool existence to the CLI exit status. */ +export function exitStatusFor( + result: { status: number | null; error?: NodeJS.ErrnoException }, + toolExists: boolean, +): number { + if (!toolExists) return 127; + if (result.status !== null) return result.status; + return 125; // killed by signal / could not run +} + +export function registerWatchCommand(program: Command): void { + const cmd: Command = program + .command('watch') + .description('Wake-me-when watchers (agent-watch): start, list, stop') + // allowUnknownOption + variadic = full ordered pass-through: unknown + // options (--name, --when, ...) and their values land in args verbatim + // (commander 13 measured behavior), so the tool owns its own flag + // surface without the CLI needing passThroughOptions (which would + // force enablePositionalOptions fleet-wide on the root program). + .allowUnknownOption() + .argument('[args...]', 'args passed through to agent-watch.sh') + .action(async (args: string[], _opts: unknown, command: Command) => { + // --mosaic-home is not global in this CLI; walk parents for it and + // fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export + // it) wins inside resolveBrainHome regardless. + let mosaicHome: string | undefined; + for (let anc: Command | null = command; anc; anc = anc.parent) { + const v = (anc.opts() as Record)['mosaicHome']; + if (v !== undefined) { + mosaicHome = v; + break; + } + } + const tool = resolveAgentWatchTool(mosaicHome ?? DEFAULT_MOSAIC_HOME); + + let toolExists = true; + try { + accessSync(tool, constants.X_OK); + } catch { + toolExists = false; + } + if (!toolExists) { + console.error( + `mosaic watch: agent-watch not found (expected ${tool}). ` + + 'The watcher suite lives in the brain tree under tools/agent-watch/; ' + + 'check MOSAIC_BRAIN_HOME or the brain checkout.', + ); + process.exitCode = 127; + return; + } + + const result = spawnSync(tool, args, { stdio: 'inherit', env: process.env }); + process.exitCode = exitStatusFor(result, true); + }); + + cmd.addHelpText( + 'after', + '\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).', + ); +} -- 2.54.0 From 7d86d55577b48dc8ae041e40b0dadbf0a48c164d Mon Sep 17 00:00:00 2001 From: marcie Date: Fri, 28 Aug 2026 17:39:44 -0500 Subject: [PATCH 2/4] mosaic q + shared brain-dispatch helper (second embedded tool command) - brain-dispatch.ts: shared resolveBrainTool / execBrainTool / exitStatusFor / brainToolExists. Interpreter support (python3) for non-shell tools. Pass-through contract documented once, used by every brain-tool command. - mosaic q new|render -> tools/questions/q-new.sh | render.py. Unknown or missing subcommand: usage list + exit 2 (usage-error contract). - watch.ts refactored onto the shared helper; behavior unchanged. - Specs 13/13 green (resolution mapping, usage exit 2, live stub pass-through with exit 7, shared exit mapping). Live smoke: mosaic q (usage, rc 2), absent-tool watch (rc 127) verified against dist. --- packages/mosaic/src/cli.ts | 2 + .../mosaic/src/commands/brain-dispatch.ts | 67 ++++++++++++++ packages/mosaic/src/commands/q.spec.ts | 87 +++++++++++++++++++ packages/mosaic/src/commands/q.ts | 63 ++++++++++++++ packages/mosaic/src/commands/watch.ts | 51 +++-------- 5 files changed, 233 insertions(+), 37 deletions(-) create mode 100644 packages/mosaic/src/commands/brain-dispatch.ts create mode 100644 packages/mosaic/src/commands/q.spec.ts create mode 100644 packages/mosaic/src/commands/q.ts diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 9aa91ba3..d88edf13 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; import { registerTelemetryCommand } from './commands/telemetry.js'; +import { registerQCommand } from './commands/q.js'; import { registerWatchCommand } from './commands/watch.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerInteractionCommand } from './commands/interaction.js'; @@ -430,6 +431,7 @@ registerSkillCommand(program); registerTelemetryCommand(program); registerWatchCommand(program); +registerQCommand(program); // ─── update ───────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/brain-dispatch.ts b/packages/mosaic/src/commands/brain-dispatch.ts new file mode 100644 index 00000000..4a6c6fe5 --- /dev/null +++ b/packages/mosaic/src/commands/brain-dispatch.ts @@ -0,0 +1,67 @@ +import { spawnSync } from 'node:child_process'; +import { accessSync, constants } from 'node:fs'; +import { join } from 'node:path'; + +import { resolveBrainHome } from '../fleet/brain-home.js'; + +/** + * Shared brain-tool dispatch (fleet CLI integration, Jason ruling + * 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned + * implementations stay in the brain (tools/). Commands resolve the brain + * home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool + * there. Nothing operator-specific ships inside the package. + * + * Pass-through contract: arguments, stdout/stderr, and the exit code belong + * to the tool. The CLI adds nothing on success; absent tools fail loudly + * with the resolved path (127) instead of guessing. + */ + +/** Absolute path of a brain-relative tool. */ +export function resolveBrainTool(mosaicHome: string, relPath: string): string { + return join(resolveBrainHome(mosaicHome), ...relPath.split('/')); +} + +/** Map a spawnSync result + tool existence to the CLI exit status. */ +export function exitStatusFor( + result: { status: number | null; error?: NodeJS.ErrnoException }, + toolExists: boolean, +): number { + if (!toolExists) return 127; + if (result.status !== null) return result.status; + return 125; // killed by signal / could not run +} + +export function brainToolExists(tool: string): boolean { + try { + accessSync(tool, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Exec a brain tool with full pass-through. `interpreter` runs the tool + * through e.g. python3 (renderers); omit it for executable scripts. + * Returns the process exit status; callers assign it to process.exitCode. + */ +export function execBrainTool( + mosaicHome: string, + relPath: string, + args: string[], + interpreter?: string, +): number { + const tool = resolveBrainTool(mosaicHome, relPath); + if (!brainToolExists(tool)) { + console.error( + `mosaic: brain tool not found (expected ${tool}). ` + + 'Tool suites live in the brain tree under tools/; ' + + 'check MOSAIC_BRAIN_HOME or the brain checkout.', + ); + return 127; + } + const result = interpreter + ? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env }) + : spawnSync(tool, args, { stdio: 'inherit', env: process.env }); + return exitStatusFor(result, true); +} diff --git a/packages/mosaic/src/commands/q.spec.ts b/packages/mosaic/src/commands/q.spec.ts new file mode 100644 index 00000000..9cd9a132 --- /dev/null +++ b/packages/mosaic/src/commands/q.spec.ts @@ -0,0 +1,87 @@ +import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { registerQCommand, resolveQuestionTool } from './q.js'; +import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js'; + +describe('resolveBrainTool', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + }); + + it('joins brain home with the relative tool path', () => { + const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe( + join(tmp, 'tools', 'questions', 'q-new.sh'), + ); + }); +}); + +describe('resolveQuestionTool', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + }); + + it('maps new/render subcommands to their brain tools', () => { + const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh')); + expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py')); + expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined(); + }); +}); + +describe('registerQCommand usage + dispatch', () => { + const saved = process.env['MOSAIC_BRAIN_HOME']; + afterEach(() => { + if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME']; + else process.env['MOSAIC_BRAIN_HOME'] = saved; + process.exitCode = undefined; + }); + + it('exit 2 with the subcommand list when no/unknown subcommand', async () => { + process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-')); + const program = new Command(); + registerQCommand(program); + await program.parseAsync(['q'], { from: 'user' }); + expect(process.exitCode).toBe(2); + process.exitCode = undefined; + await program.parseAsync(['q', 'bogus'], { from: 'user' }); + expect(process.exitCode).toBe(2); + }); + + it('execs the brain tool with pass-through args and exit code', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'q-live-')); + process.env['MOSAIC_BRAIN_HOME'] = tmp; + mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true }); + const stub = join(tmp, 'tools', 'questions', 'q-new.sh'); + writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n'); + chmodSync(stub, 0o755); + + const program = new Command(); + registerQCommand(program); + await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' }); + expect(process.exitCode).toBe(7); + }); +}); + +describe('exitStatusFor (shared dispatch contract)', () => { + it('maps absent tool to 127', () => { + expect(exitStatusFor({ status: 0 }, false)).toBe(127); + }); + it('passes tool status through', () => { + expect(exitStatusFor({ status: 7 }, true)).toBe(7); + }); + it('maps signal death to 125', () => { + expect(exitStatusFor({ status: null }, true)).toBe(125); + }); +}); diff --git a/packages/mosaic/src/commands/q.ts b/packages/mosaic/src/commands/q.ts new file mode 100644 index 00000000..d7f3f667 --- /dev/null +++ b/packages/mosaic/src/commands/q.ts @@ -0,0 +1,63 @@ +import type { Command } from 'commander'; + +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; +import { execBrainTool, resolveBrainTool } from './brain-dispatch.js'; + +/** + * `mosaic q` — tracked decision questions (brain tools/questions). + * `new` files a question file (one FILE per question, merge-conflict + * impossible by construction); `render` regenerates the + * docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer). + */ +const SUBCOMMANDS: Record = { + new: { + path: 'tools/questions/q-new.sh', + help: 'file a question (--question, --slug, --owed-by, ...)', + }, + render: { + path: 'tools/questions/render.py', + interpreter: 'python3', + help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)', + }, +}; + +export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined { + const entry = SUBCOMMANDS[sub]; + return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined; +} + +export function registerQCommand(program: Command): void { + const cmd: Command = program + .command('q') + .description('Tracked decision questions: file and render (brain tools/questions)') + .allowUnknownOption() + .argument('[args...]', 'subcommand + args passed through to the question tools') + .action(async (args: string[], _opts: unknown, command: Command) => { + let mosaicHome: string | undefined; + for (let anc: Command | null = command; anc; anc = anc.parent) { + const v = (anc.opts() as Record)['mosaicHome']; + if (v !== undefined) { + mosaicHome = v; + break; + } + } + const home = mosaicHome ?? DEFAULT_MOSAIC_HOME; + + const sub = args[0]; + if (!sub || !(sub in SUBCOMMANDS)) { + console.error('mosaic q: expected a subcommand:'); + for (const [name, entry] of Object.entries(SUBCOMMANDS)) { + console.error(` mosaic q ${name} ${entry.help}`); + } + process.exitCode = 2; // usage error contract: invocation defect + return; + } + const entry = SUBCOMMANDS[sub]!; + process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter); + }); + + cmd.addHelpText( + 'after', + '\nEverything after the subcommand is passed through verbatim (args, output, exit code).', + ); +} diff --git a/packages/mosaic/src/commands/watch.ts b/packages/mosaic/src/commands/watch.ts index e3bde144..68ce927e 100644 --- a/packages/mosaic/src/commands/watch.ts +++ b/packages/mosaic/src/commands/watch.ts @@ -1,38 +1,21 @@ import type { Command } from 'commander'; -import { spawnSync } from 'node:child_process'; -import { accessSync, constants } from 'node:fs'; -import { join } from 'node:path'; import { DEFAULT_MOSAIC_HOME } from '../constants.js'; -import { resolveBrainHome } from '../fleet/brain-home.js'; +import { + brainToolExists, + execBrainTool, + exitStatusFor, + resolveBrainTool, +} from './brain-dispatch.js'; + +export { exitStatusFor }; /** * `mosaic watch` — dispatch to the brain's agent-watch suite. - * - * Architecture (fleet CLI integration, Jason ruling 2026-08-28): the npm - * package embeds the COMMAND SURFACE; operator-owned tool implementations - * stay in the brain (`~/.mosaic/tools/`). The dispatch resolves the brain - * home (MOSAIC_BRAIN_HOME wins, else canonical ~/.mosaic when adopted — - * see brain-home.ts) and execs the tool there. Nothing operator-specific - * ships inside the package, keeping the framework boundary clean. - * - * Pass-through contract: arguments, stdout/stderr, and the exit code belong - * to the tool. The CLI adds nothing on success. When the tool is absent the - * CLI fails loudly with the resolved path (exit 127) instead of guessing. + * See brain-dispatch.ts for the architecture and pass-through contract. */ - export function resolveAgentWatchTool(mosaicHome: string): string { - return join(resolveBrainHome(mosaicHome), 'tools', 'agent-watch', 'agent-watch.sh'); -} - -/** Map a spawnSync result + tool existence to the CLI exit status. */ -export function exitStatusFor( - result: { status: number | null; error?: NodeJS.ErrnoException }, - toolExists: boolean, -): number { - if (!toolExists) return 127; - if (result.status !== null) return result.status; - return 125; // killed by signal / could not run + return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh'); } export function registerWatchCommand(program: Command): void { @@ -58,15 +41,10 @@ export function registerWatchCommand(program: Command): void { break; } } - const tool = resolveAgentWatchTool(mosaicHome ?? DEFAULT_MOSAIC_HOME); + const home = mosaicHome ?? DEFAULT_MOSAIC_HOME; - let toolExists = true; - try { - accessSync(tool, constants.X_OK); - } catch { - toolExists = false; - } - if (!toolExists) { + const tool = resolveAgentWatchTool(home); + if (!brainToolExists(tool)) { console.error( `mosaic watch: agent-watch not found (expected ${tool}). ` + 'The watcher suite lives in the brain tree under tools/agent-watch/; ' + @@ -76,8 +54,7 @@ export function registerWatchCommand(program: Command): void { return; } - const result = spawnSync(tool, args, { stdio: 'inherit', env: process.env }); - process.exitCode = exitStatusFor(result, true); + process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args); }); cmd.addHelpText( -- 2.54.0 From 9c8b6ebfe12fb18a456456f770e13b850cb8d7e2 Mon Sep 17 00:00:00 2001 From: marcie Date: Fri, 28 Aug 2026 17:45:19 -0500 Subject: [PATCH 3/4] mosaic comms send: routed messaging (tmux same-host default, fleet-comms inter-site) Per FLEET-COMMS.md doctrine (Jason 2026-08-28): same-host seats talk over tmux agent-send.sh; fleet-comms only when the recipient is on another site. The command owns routing + flag translation between the two transports (agent-send -s/-C/-L/-f/-m vs comms-send -t site/agent -c/-m); validation, delivery semantics, and exit codes belong to the tools. - rc=2 draft contract documented in help: never retry, confirm with capture-pane. Exit codes pass through both paths. - --site requires MOSAIC_AGENT_NAME (exit 2 invocation defect when missing); missing fleet-comms checkout exits 127 naming the path. - Specs (routing + translation + passthrough with stub tools) green. --- packages/mosaic/src/cli.ts | 2 + packages/mosaic/src/commands/comms.spec.ts | 137 +++++++++++++++++++++ packages/mosaic/src/commands/comms.ts | 131 ++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 packages/mosaic/src/commands/comms.spec.ts create mode 100644 packages/mosaic/src/commands/comms.ts diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index d88edf13..7d82480c 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; import { registerTelemetryCommand } from './commands/telemetry.js'; +import { registerCommsCommand } from './commands/comms.js'; import { registerQCommand } from './commands/q.js'; import { registerWatchCommand } from './commands/watch.js'; import { registerAgentCommand } from './commands/agent.js'; @@ -432,6 +433,7 @@ registerSkillCommand(program); registerTelemetryCommand(program); registerWatchCommand(program); registerQCommand(program); +registerCommsCommand(program); // ─── update ───────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/comms.spec.ts b/packages/mosaic/src/commands/comms.spec.ts new file mode 100644 index 00000000..df5b2711 --- /dev/null +++ b/packages/mosaic/src/commands/comms.spec.ts @@ -0,0 +1,137 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + writeFileSync, + readFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js'; + +describe('arg translation', () => { + it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => { + expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']); + expect( + tmuxSendArgs('orch-01', 'unused', { + class: 'actionable', + socket: 'mosaic-fleet', + file: '/tmp/body.txt', + }), + ).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']); + }); + + it('fleet-comms path: -t site/agent and -c class', () => { + expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']); + expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([ + '-t', + 'usc/fred', + '-c', + 'human', + '-m', + 'hi', + ]); + }); +}); + +describe('registerCommsCommand routing', () => { + const savedBrain = process.env['MOSAIC_BRAIN_HOME']; + const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO']; + const savedAgent = process.env['MOSAIC_AGENT_NAME']; + afterEach(() => { + for (const [k, v] of [ + ['MOSAIC_BRAIN_HOME', savedBrain], + ['MOSAIC_FLEET_COMMS_REPO', savedRepo], + ['MOSAIC_AGENT_NAME', savedAgent], + ] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + process.exitCode = undefined; + }); + + function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } { + const brain = mkdtempSync(join(tmpdir(), 'comms-brain-')); + const repo = mkdtempSync(join(tmpdir(), 'comms-repo-')); + mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true }); + mkdirSync(join(repo, 'tools'), { recursive: true }); + const tmuxLog = join(brain, 'tmux.log'); + const commsLog = join(repo, 'comms.log'); + writeFileSync( + join(brain, 'tools', 'tmux', 'agent-send.sh'), + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`, + ); + writeFileSync( + join(repo, 'tools', 'comms-send.sh'), + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nexit 5\n`, + ); + chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755); + chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755); + process.env['MOSAIC_BRAIN_HOME'] = brain; + process.env['MOSAIC_FLEET_COMMS_REPO'] = repo; + process.env['MOSAIC_AGENT_NAME'] = 'tester'; + return { brain, repo, tmuxLog, commsLog }; + } + + it('default routes same-host via agent-send with translated flags and passes rc through', async () => { + const f = fixture(); + const program = new Command(); + registerCommsCommand(program); + await program.parseAsync( + [ + 'comms', + 'send', + 'orch-01', + '--class', + 'actionable', + '--socket', + 'mosaic-fleet', + 'verdict', + 'landed', + ], + { from: 'user' }, + ); + expect(process.exitCode).toBe(7); + expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe( + '-s orch-01 -C actionable -L mosaic-fleet -m verdict landed', + ); + expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked + }); + + it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => { + const f = fixture(); + const program = new Command(); + registerCommsCommand(program); + await program.parseAsync( + ['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'], + { from: 'user' }, + ); + expect(process.exitCode).toBe(5); + expect(readFileSync(f.commsLog, 'utf8').trim()).toBe('-t usc/fred -c human -m hello there'); + expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked + }); + + it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => { + const f = fixture(); + delete process.env['MOSAIC_AGENT_NAME']; + const program = new Command(); + registerCommsCommand(program); + await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' }); + expect(process.exitCode).toBe(2); + expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked + }); + + it('missing fleet-comms repo fails 127 naming the expected path', async () => { + fixture(); + process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo'; + const program = new Command(); + registerCommsCommand(program); + await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' }); + expect(process.exitCode).toBe(127); + }); +}); diff --git a/packages/mosaic/src/commands/comms.ts b/packages/mosaic/src/commands/comms.ts new file mode 100644 index 00000000..ae164cdb --- /dev/null +++ b/packages/mosaic/src/commands/comms.ts @@ -0,0 +1,131 @@ +import type { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { accessSync, constants } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; +import { execBrainTool } from './brain-dispatch.js'; + +/** + * `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine). + * + * Same-host (default): brain tools/tmux/agent-send.sh. Inter-site + * (--site ): the fleet-comms repo's comms-send.sh — never for + * local traffic (a git round trip per message; Jason 2026-08-28). + * + * Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is + * a CONTRACT, not a failure: never retry, confirm with capture-pane. + */ +export interface CommsSendOptions { + readonly class?: string; + readonly file?: string; + readonly socket?: string; + readonly site?: string; + readonly commsRepo?: string; +} + +export function defaultCommsRepo(): string { + return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms'); +} + +/** Build the agent-send.sh argv for the same-host path. */ +export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] { + const args = ['-s', target]; + if (opts.class) args.push('-C', opts.class); + if (opts.socket) args.push('-L', opts.socket); + if (opts.file) args.push('-f', opts.file); + else args.push('-m', message); + return args; +} + +/** Build the comms-send.sh argv for the inter-site path. */ +export function fleetCommsSendArgs( + site: string, + target: string, + message: string, + opts: CommsSendOptions, +): string[] { + const args = ['-t', `${site}/${target}`]; + if (opts.class) args.push('-c', opts.class); + args.push('-m', message); + return args; +} + +export function registerCommsCommand(program: Command): void { + const cmd: Command = program + .command('comms') + .description( + 'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)', + ) + .command('send') + .description('send [message...] — same-host tmux unless --site is given') + .option('--class ', 'terminal-log | actionable | human | reaction | digest') + .option('--file ', 'message body from file (same-host path only)') + .option('--socket ', 'tmux socket for the same-host send (e.g. mosaic-fleet)') + .option('--site ', 'route via fleet-comms to /') + .option('--comms-repo ', 'fleet-comms checkout', defaultCommsRepo()) + .argument('', 'destination seat (session name)') + .argument('[message...]', 'message text (joined; or use --file)') + .action( + async ( + target: string, + messageWords: string[], + opts: CommsSendOptions & Record, + command: Command, + ) => { + let mosaicHome: string | undefined; + for (let anc: Command | null = command; anc; anc = anc.parent) { + const v = (anc.opts() as Record)['mosaicHome']; + if (v !== undefined) { + mosaicHome = v; + break; + } + } + const home = mosaicHome ?? DEFAULT_MOSAIC_HOME; + const message = messageWords.join(' '); + + if (opts.site) { + const repo = opts.commsRepo ?? defaultCommsRepo(); + const tool = join(repo, 'tools', 'comms-send.sh'); + try { + accessSync(tool, constants.X_OK); + } catch { + console.error( + `mosaic comms: fleet-comms sender not found (expected ${tool}). ` + + 'Clone the fleet-comms repo or point --comms-repo at it.', + ); + process.exitCode = 127; + return; + } + if (!process.env['MOSAIC_AGENT_NAME']) { + console.error( + 'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).', + ); + process.exitCode = 2; // invocation defect: fixable by the caller + return; + } + const env = { ...process.env, FLEET_COMMS_SITE: opts.site }; + const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), { + stdio: 'inherit', + env, + }); + process.exitCode = result.status ?? 125; + return; + } + + // Same-host: the brain tool owns validation (bad class -> its rc 3) + // and absence (execBrainTool -> 127 with the resolved path). + process.exitCode = execBrainTool( + home, + 'tools/tmux/agent-send.sh', + tmuxSendArgs(target, message, opts), + ); + }, + ); + + cmd.addHelpText( + 'after', + '\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.', + ); +} -- 2.54.0 From e858f1bc62616b701e129a5283bda21e9f3eb06f Mon Sep 17 00:00:00 2001 From: marcie Date: Fri, 28 Aug 2026 17:50:39 -0500 Subject: [PATCH 4/4] mosaic dispatch: codex review fixes (comms repo binding, q lookup, watch help) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - comms: bind comms-send.sh to the SELECTED repository via FLEET_COMMS_REPO in the spawned env; without it, --comms-repo chose the executable but the tool still operated on the default checkout (codex on 9c8b6ebf). Spec asserts the stub observes the binding. - q: Object.hasOwn subcommand lookup (reserved property names like toString must not leak through the record); spec arm added. - watch: .helpOption(false) — the tool owns help too; --help now passes through to agent-watch.sh instead of commander's wrapper help (verified against dist). --- packages/mosaic/src/commands/comms.spec.ts | 10 ++++++++-- packages/mosaic/src/commands/comms.ts | 6 +++++- packages/mosaic/src/commands/q.spec.ts | 4 ++++ packages/mosaic/src/commands/q.ts | 2 +- packages/mosaic/src/commands/watch.ts | 4 ++++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/commands/comms.spec.ts b/packages/mosaic/src/commands/comms.spec.ts index df5b2711..a20a85aa 100644 --- a/packages/mosaic/src/commands/comms.spec.ts +++ b/packages/mosaic/src/commands/comms.spec.ts @@ -68,7 +68,7 @@ describe('registerCommsCommand routing', () => { ); writeFileSync( join(repo, 'tools', 'comms-send.sh'), - `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nexit 5\n`, + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`, ); chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755); chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755); @@ -112,7 +112,13 @@ describe('registerCommsCommand routing', () => { { from: 'user' }, ); expect(process.exitCode).toBe(5); - expect(readFileSync(f.commsLog, 'utf8').trim()).toBe('-t usc/fred -c human -m hello there'); + expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe( + '-t usc/fred -c human -m hello there', + ); + // The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf). + expect(readFileSync(f.commsLog, 'utf8')).toContain( + `FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`, + ); expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked }); diff --git a/packages/mosaic/src/commands/comms.ts b/packages/mosaic/src/commands/comms.ts index ae164cdb..cb6cada7 100644 --- a/packages/mosaic/src/commands/comms.ts +++ b/packages/mosaic/src/commands/comms.ts @@ -105,7 +105,11 @@ export function registerCommsCommand(program: Command): void { process.exitCode = 2; // invocation defect: fixable by the caller return; } - const env = { ...process.env, FLEET_COMMS_SITE: opts.site }; + // comms-send.sh locates its working repo via FLEET_COMMS_REPO + // (default $HOME/src/fleet-comms); without this, --comms-repo + // would select the executable but not the repository it operates + // on (codex review of 9c8b6ebf). + const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo }; const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), { stdio: 'inherit', env, diff --git a/packages/mosaic/src/commands/q.spec.ts b/packages/mosaic/src/commands/q.spec.ts index 9cd9a132..e86a7550 100644 --- a/packages/mosaic/src/commands/q.spec.ts +++ b/packages/mosaic/src/commands/q.spec.ts @@ -57,6 +57,10 @@ describe('registerQCommand usage + dispatch', () => { process.exitCode = undefined; await program.parseAsync(['q', 'bogus'], { from: 'user' }); expect(process.exitCode).toBe(2); + process.exitCode = undefined; + // Reserved property names must not leak through the record lookup. + await program.parseAsync(['q', 'toString'], { from: 'user' }); + expect(process.exitCode).toBe(2); }); it('execs the brain tool with pass-through args and exit code', async () => { diff --git a/packages/mosaic/src/commands/q.ts b/packages/mosaic/src/commands/q.ts index d7f3f667..0bcfb8bb 100644 --- a/packages/mosaic/src/commands/q.ts +++ b/packages/mosaic/src/commands/q.ts @@ -44,7 +44,7 @@ export function registerQCommand(program: Command): void { const home = mosaicHome ?? DEFAULT_MOSAIC_HOME; const sub = args[0]; - if (!sub || !(sub in SUBCOMMANDS)) { + if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) { console.error('mosaic q: expected a subcommand:'); for (const [name, entry] of Object.entries(SUBCOMMANDS)) { console.error(` mosaic q ${name} ${entry.help}`); diff --git a/packages/mosaic/src/commands/watch.ts b/packages/mosaic/src/commands/watch.ts index 68ce927e..a507b011 100644 --- a/packages/mosaic/src/commands/watch.ts +++ b/packages/mosaic/src/commands/watch.ts @@ -28,6 +28,10 @@ export function registerWatchCommand(program: Command): void { // surface without the CLI needing passThroughOptions (which would // force enablePositionalOptions fleet-wide on the root program). .allowUnknownOption() + // The tool owns help too: without this, commander would intercept + // --help and answer with wrapper help instead of agent-watch's own + // (codex review of 18f3dd49). + .helpOption(false) .argument('[args...]', 'args passed through to agent-watch.sh') .action(async (args: string[], _opts: unknown, command: Command) => { // --mosaic-home is not global in this CLI; walk parents for it and -- 2.54.0