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.
This commit is contained in:
@@ -11,6 +11,7 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
|
|||||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||||
|
import { registerQCommand } from './commands/q.js';
|
||||||
import { registerWatchCommand } from './commands/watch.js';
|
import { registerWatchCommand } from './commands/watch.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
@@ -430,6 +431,7 @@ registerSkillCommand(program);
|
|||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
registerWatchCommand(program);
|
registerWatchCommand(program);
|
||||||
|
registerQCommand(program);
|
||||||
|
|
||||||
// ─── update ─────────────────────────────────────────────────────────────
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, { path: string; interpreter?: string; help: string }> = {
|
||||||
|
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<string, string | undefined>)['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).',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,21 @@
|
|||||||
import type { Command } from 'commander';
|
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 { 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.
|
* `mosaic watch` — dispatch to the brain's agent-watch suite.
|
||||||
*
|
* See brain-dispatch.ts for the architecture and pass-through contract.
|
||||||
* 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 {
|
export function resolveAgentWatchTool(mosaicHome: string): string {
|
||||||
return join(resolveBrainHome(mosaicHome), 'tools', 'agent-watch', 'agent-watch.sh');
|
return resolveBrainTool(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 {
|
export function registerWatchCommand(program: Command): void {
|
||||||
@@ -58,15 +41,10 @@ export function registerWatchCommand(program: Command): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tool = resolveAgentWatchTool(mosaicHome ?? DEFAULT_MOSAIC_HOME);
|
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
|
||||||
let toolExists = true;
|
const tool = resolveAgentWatchTool(home);
|
||||||
try {
|
if (!brainToolExists(tool)) {
|
||||||
accessSync(tool, constants.X_OK);
|
|
||||||
} catch {
|
|
||||||
toolExists = false;
|
|
||||||
}
|
|
||||||
if (!toolExists) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`mosaic watch: agent-watch not found (expected ${tool}). ` +
|
`mosaic watch: agent-watch not found (expected ${tool}). ` +
|
||||||
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
|
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
|
||||||
@@ -76,8 +54,7 @@ export function registerWatchCommand(program: Command): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = spawnSync(tool, args, { stdio: 'inherit', env: process.env });
|
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
|
||||||
process.exitCode = exitStatusFor(result, true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cmd.addHelpText(
|
cmd.addHelpText(
|
||||||
|
|||||||
Reference in New Issue
Block a user