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(