AMD1213-C: repair stale array consumer, fail closed on foreign link provenance, validate manifests before mutation, and exercise the fleet MCP preflight call path.
1710 lines
62 KiB
TypeScript
1710 lines
62 KiB
TypeScript
/**
|
|
* Native runtime launcher — replaces the bash mosaic-launch script.
|
|
*
|
|
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
|
|
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
|
|
*/
|
|
|
|
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
appendFileSync,
|
|
} from 'node:fs';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { createRequire } from 'node:module';
|
|
import { homedir, hostname } from 'node:os';
|
|
import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path';
|
|
import type { Command } from 'commander';
|
|
import {
|
|
buildResolvedFleetCommsBlock,
|
|
renderToolsContractStatus,
|
|
resolveFleetIdentity,
|
|
} from '../fleet/comms-onboarding.js';
|
|
import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js';
|
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
|
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
|
|
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
|
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
|
|
|
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
|
|
|
/** Fleet context for the single harness-home resolution seam. */
|
|
export interface FleetHarnessContext {
|
|
readonly agentDir: string;
|
|
/** Active installed Mosaic root for fleet-specific helper resolution. */
|
|
readonly mosaicHome?: string;
|
|
}
|
|
|
|
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
|
claude: 'Claude Code',
|
|
codex: 'Codex',
|
|
opencode: 'OpenCode',
|
|
pi: 'Pi',
|
|
};
|
|
|
|
// ─── Harness home isolation ──────────────────────────────────────────────────
|
|
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
|
|
// tree — never the operator's base install. A bare `claude` / `pi` therefore
|
|
// keeps its own config AND its own auth, and stays a working break-glass no
|
|
// matter what mosaic does to its own tree.
|
|
//
|
|
// These paths are manifest-UNKNOWN, which resolves to operator ownership
|
|
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
|
|
// neither overwrite nor prune them. Overwrite-mode install still would.
|
|
//
|
|
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
|
|
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
|
|
// three: it also relocates XDG lookups for anything opencode spawns.
|
|
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
|
claude: 'CLAUDE_CONFIG_DIR',
|
|
pi: 'PI_CODING_AGENT_DIR',
|
|
codex: 'CODEX_HOME',
|
|
opencode: 'XDG_CONFIG_HOME',
|
|
};
|
|
|
|
/** Dedicated runtime home, optionally scoped to a user fleet agent. */
|
|
export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string {
|
|
return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`);
|
|
}
|
|
|
|
/**
|
|
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
|
* created on demand so a first launch does not fail on a missing path.
|
|
*/
|
|
function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record<string, string> {
|
|
const key = HARNESS_HOME_ENV[runtime];
|
|
if (!key) return {};
|
|
const home = harnessHome(runtime, fleet);
|
|
mkdirSync(home, { recursive: true });
|
|
return { [key]: home };
|
|
}
|
|
|
|
// ─── Launch record (immutable provenance) ────────────────────────────────────
|
|
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
|
|
// actually launched with, written before exec. No model involvement, no opt-out.
|
|
//
|
|
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
|
|
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
|
|
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
|
|
// disproved only by the parent process's argv and only because the parent had
|
|
// not yet exited. A record written before exec is the only place this survives.
|
|
//
|
|
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
|
|
// operator-classified in framework-manifest.txt and already covered by
|
|
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
|
|
// it.
|
|
//
|
|
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
|
|
// uses spawnSync, so the runtime is a CHILD with a different pid.
|
|
// launch-runtime.py appends the matching `lease.register` event.
|
|
//
|
|
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
|
|
// oversized argv values (the composed system prompt) become a digest + length.
|
|
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
|
|
|
|
const CLI_VERSION: string | null = (() => {
|
|
try {
|
|
// Resolved RELATIVELY: the package `exports` map does not expose
|
|
// package.json, so '@mosaicstack/mosaic/package.json' throws
|
|
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
|
|
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
interface NormativeFragmentDigest {
|
|
source_id: string;
|
|
sha256: string | null;
|
|
bytes: number | null;
|
|
missing?: boolean;
|
|
}
|
|
|
|
function sha256Of(value: string | Buffer): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Hash the normative sources injected into the agent. This is "what the agent
|
|
* IS" — and it is the same fragment set the lease broker hashes for promotion,
|
|
* so an unexpected digest here is a mechanically detectable red flag rather than
|
|
* a matter of judgement.
|
|
*/
|
|
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
|
const candidates: Array<[string, string]> = [
|
|
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
|
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
|
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
|
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
|
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
|
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
|
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
|
];
|
|
return candidates.map(([sourceId, path]) => {
|
|
try {
|
|
const bytes = readFileSync(path);
|
|
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
|
|
} catch {
|
|
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
|
|
}
|
|
});
|
|
}
|
|
|
|
/** argv with oversized values replaced by a digest, so the record stays small
|
|
* and never inlines injected content verbatim. */
|
|
function redactArgv(argv: string[]): string[] {
|
|
return argv.map((a) =>
|
|
typeof a === 'string' && a.length > 256
|
|
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
|
|
: a,
|
|
);
|
|
}
|
|
|
|
function recordLaunch(
|
|
runtime: RuntimeName,
|
|
cliArgs: string[],
|
|
yolo: boolean,
|
|
fleet?: FleetHarnessContext,
|
|
launchEnv: NodeJS.ProcessEnv = process.env,
|
|
): void {
|
|
try {
|
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
|
// Correlation id for the lease.register half. Set into process.env so it
|
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
|
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
|
const record = {
|
|
seq: Date.now(),
|
|
kind: 'session.launch',
|
|
launch_id: launchId,
|
|
ts: new Date().toISOString(),
|
|
host: hostname(),
|
|
pid: process.pid,
|
|
runtime,
|
|
mode: yolo ? 'yolo' : 'normal',
|
|
cwd: process.cwd(),
|
|
cli_version: CLI_VERSION,
|
|
config_home: harnessHome(runtime, fleet),
|
|
config_home_isolated: true,
|
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
|
argv: redactArgv(cliArgs),
|
|
normative_fragments: normativeFragmentDigests(runtime),
|
|
// names only — values are never recorded
|
|
mosaic_env_present: Object.keys(launchEnv)
|
|
.filter((k) => k.startsWith('MOSAIC_'))
|
|
.sort(),
|
|
};
|
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
|
mode: 0o600,
|
|
});
|
|
} catch (err) {
|
|
// Never block a launch on bookkeeping — but never fail silently either.
|
|
console.error(
|
|
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
|
|
|
function checkMosaicHome(): void {
|
|
if (!existsSync(MOSAIC_HOME)) {
|
|
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
|
|
console.error(
|
|
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function checkFile(path: string, label: string): void {
|
|
if (!existsSync(path)) {
|
|
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function checkRuntime(cmd: string): void {
|
|
try {
|
|
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
|
} catch {
|
|
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
|
|
console.error(`[mosaic] Install ${cmd} before launching.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function checkSoul(): void {
|
|
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
|
if (!existsSync(soulPath)) {
|
|
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
|
|
|
// Prefer the TypeScript wizard (idempotent, detects existing files)
|
|
try {
|
|
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
|
|
stdio: 'inherit',
|
|
});
|
|
if (result.status === 0 && existsSync(soulPath)) return;
|
|
} catch {
|
|
// Fall through to legacy init
|
|
}
|
|
|
|
// Fallback: legacy bash mosaic-init
|
|
const initBin = fwScript('mosaic-init');
|
|
if (existsSync(initBin)) {
|
|
spawnSync(initBin, [], { stdio: 'inherit' });
|
|
} else {
|
|
console.error('[mosaic] Setup failed. Run: mosaic wizard');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Claude settings validation ─────────────────────────────────────────────
|
|
|
|
interface SettingsAudit {
|
|
warnings: string[];
|
|
}
|
|
|
|
function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit {
|
|
const warnings: string[] = [];
|
|
const settingsPath = join(harnessHome('claude', fleet), 'settings.json');
|
|
const settings = readJson(settingsPath);
|
|
|
|
if (!settings) {
|
|
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
|
return { warnings };
|
|
}
|
|
|
|
// Check required hooks
|
|
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
|
|
|
const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh'];
|
|
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
|
|
|
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
|
const postHooks = (hooks?.['PostToolUse'] ?? []) as Array<Record<string, unknown>>;
|
|
|
|
const preCommands = preHooks.flatMap((h) => {
|
|
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
|
|
return inner.map((ih) => String(ih['command'] ?? ''));
|
|
});
|
|
const postCommands = postHooks.flatMap((h) => {
|
|
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
|
|
return inner.map((ih) => String(ih['command'] ?? ''));
|
|
});
|
|
|
|
for (const script of requiredPreToolUse) {
|
|
if (!preCommands.some((c) => c.includes(script))) {
|
|
warnings.push(`Missing PreToolUse hook: ${script}`);
|
|
}
|
|
}
|
|
for (const script of requiredPostToolUse) {
|
|
if (!postCommands.some((c) => c.includes(script))) {
|
|
warnings.push(`Missing PostToolUse hook: ${script}`);
|
|
}
|
|
}
|
|
|
|
// Check required plugins
|
|
const plugins = (settings['enabledPlugins'] ?? {}) as Record<string, boolean>;
|
|
const requiredPlugins = ['feature-dev', 'pr-review-toolkit', 'code-review'];
|
|
|
|
for (const plugin of requiredPlugins) {
|
|
const found = Object.keys(plugins).some((k) => k.startsWith(plugin) && plugins[k]);
|
|
if (!found) {
|
|
warnings.push(`Missing plugin: ${plugin}`);
|
|
}
|
|
}
|
|
|
|
// Check enableAllMcpTools
|
|
if (!settings['enableAllMcpTools']) {
|
|
warnings.push('enableAllMcpTools is not true — MCP tools may require per-tool approval');
|
|
}
|
|
|
|
return { warnings };
|
|
}
|
|
|
|
function printSettingsWarnings(audit: SettingsAudit): void {
|
|
if (audit.warnings.length === 0) return;
|
|
|
|
console.log('\n[mosaic] Claude Code settings audit:');
|
|
for (const w of audit.warnings) {
|
|
console.log(` ⚠ ${w}`);
|
|
}
|
|
console.log(
|
|
'[mosaic] Run: mosaic doctor — or see ~/.config/mosaic/runtime/claude/RUNTIME.md for required settings.\n',
|
|
);
|
|
}
|
|
|
|
function resolveExecutable(name: string): string {
|
|
const result = spawnSync('which', [name], { encoding: 'utf8' });
|
|
const path = result.status === 0 ? result.stdout.trim() : '';
|
|
if (!path || !isAbsolute(path) || !existsSync(path)) {
|
|
throw new Error(`required helper executable is unavailable: ${name}`);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function trustedFleetHelper(mosaicHome: string): string {
|
|
const root = resolve(mosaicHome);
|
|
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
try {
|
|
assertNoSymlinkAncestors(checker);
|
|
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
let cursor = root;
|
|
for (const component of relative(root, checker).split(sep).filter(Boolean)) {
|
|
const info = lstatSync(cursor);
|
|
if (
|
|
!info.isDirectory() ||
|
|
info.isSymbolicLink() ||
|
|
(info.mode & 0o022) !== 0 ||
|
|
(owner !== undefined && info.uid !== owner && info.uid !== 0)
|
|
) {
|
|
throw new Error('helper directory has unsafe type, owner, or permissions');
|
|
}
|
|
cursor = join(cursor, component);
|
|
}
|
|
const helperInfo = lstatSync(checker);
|
|
if (
|
|
!helperInfo.isFile() ||
|
|
helperInfo.isSymbolicLink() ||
|
|
(helperInfo.mode & 0o022) !== 0 ||
|
|
(helperInfo.mode & 0o111) === 0 ||
|
|
(owner !== undefined && helperInfo.uid !== owner && helperInfo.uid !== 0)
|
|
) {
|
|
throw new Error('helper has unsafe type, owner, or permissions');
|
|
}
|
|
} catch (error: unknown) {
|
|
throw new Error(
|
|
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
return checker;
|
|
}
|
|
|
|
export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void {
|
|
// Fleet launch must use the active --mosaic-home installation. Non-fleet
|
|
// launches retain the package/deployed helper resolver.
|
|
const checker = fleet?.mosaicHome
|
|
? trustedFleetHelper(fleet.mosaicHome)
|
|
: fwScript('mosaic-ensure-sequential-thinking');
|
|
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
|
const fleetClaudeConfig =
|
|
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
|
|
const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined;
|
|
const fleetOpenCodeHome =
|
|
runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined;
|
|
const python = resolveExecutable('python3');
|
|
const node = resolveExecutable('node');
|
|
const npx = resolveExecutable('npx');
|
|
const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':');
|
|
const result = spawnSync(
|
|
checker,
|
|
[
|
|
'--check',
|
|
'--runtime',
|
|
runtime,
|
|
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
|
|
],
|
|
{
|
|
stdio: 'ignore',
|
|
env: {
|
|
HOME: process.env['HOME'] ?? '',
|
|
PATH: capabilityPath,
|
|
LANG: process.env['LANG'] ?? 'C.UTF-8',
|
|
...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined
|
|
? {}
|
|
: { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }),
|
|
...(process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] === undefined
|
|
? {}
|
|
: { MOSAIC_SEQ_WARM_TIMEOUT_SEC: process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] }),
|
|
...(fleetCodexHome === undefined ? {} : { CODEX_HOME: fleetCodexHome }),
|
|
...(fleetOpenCodeHome === undefined ? {} : { XDG_CONFIG_HOME: fleetOpenCodeHome }),
|
|
},
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
|
const repairArgs =
|
|
fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`;
|
|
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ─── File helpers ────────────────────────────────────────────────────────────
|
|
|
|
function readOptional(path: string): string {
|
|
try {
|
|
return readFileSync(path, 'utf-8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function readInstalledToolsSecure(mosaicHome: string): string {
|
|
try {
|
|
return readRegularFileSecure(join(mosaicHome, 'TOOLS.md'), {
|
|
root: mosaicHome,
|
|
maxBytes: MAX_INSTALLED_TOOLS_BYTES,
|
|
}).content.toString('utf8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function readJson(path: string): Record<string, unknown> | null {
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Mission context ─────────────────────────────────────────────────────────
|
|
|
|
interface MissionInfo {
|
|
name: string;
|
|
id: string;
|
|
status: string;
|
|
milestoneCount: number;
|
|
completedCount: number;
|
|
}
|
|
|
|
function detectMission(): MissionInfo | null {
|
|
const missionFile = '.mosaic/orchestrator/mission.json';
|
|
const data = readJson(missionFile);
|
|
if (!data) return null;
|
|
|
|
const status = String(data['status'] ?? 'inactive');
|
|
if (status !== 'active' && status !== 'paused') return null;
|
|
|
|
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
|
|
const completed = milestones.filter(
|
|
(m) =>
|
|
typeof m === 'object' &&
|
|
m !== null &&
|
|
(m as Record<string, unknown>)['status'] === 'completed',
|
|
);
|
|
|
|
return {
|
|
name: String(data['name'] ?? 'unnamed'),
|
|
id: String(data['mission_id'] ?? ''),
|
|
status,
|
|
milestoneCount: milestones.length,
|
|
completedCount: completed.length,
|
|
};
|
|
}
|
|
|
|
function buildMissionBlock(mission: MissionInfo): string {
|
|
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
|
|
|
|
An active orchestration mission exists in this project. This is a BLOCKING requirement.
|
|
|
|
**Mission:** ${mission.name}
|
|
**ID:** ${mission.id}
|
|
**Status:** ${mission.status}
|
|
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
|
|
|
|
## MANDATORY — Before ANY Response to the User
|
|
|
|
You MUST complete these steps before responding to any user message, including simple greetings:
|
|
|
|
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
|
|
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
|
|
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
|
|
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
|
|
5. After reading all four, acknowledge the mission state to the user before proceeding
|
|
|
|
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
|
|
|
|
`;
|
|
}
|
|
|
|
// ─── PRD status ──────────────────────────────────────────────────────────────
|
|
|
|
function buildPrdBlock(): string {
|
|
const prdFile = 'docs/PRD.md';
|
|
if (!existsSync(prdFile)) return '';
|
|
|
|
const content = readFileSync(prdFile, 'utf-8');
|
|
const patterns = [
|
|
/^#{2,3} .*(problem statement|objective)/im,
|
|
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
|
|
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
|
|
/^#{2,3} .*functional requirement/im,
|
|
/^#{2,3} .*non.functional/im,
|
|
/^#{2,3} .*acceptance criteria/im,
|
|
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
|
|
/^#{2,3} .*(risk|open question)/im,
|
|
/^#{2,3} .*(success metric|test|verification)/im,
|
|
/^#{2,3} .*(milestone|delivery|scope version)/im,
|
|
];
|
|
|
|
let sections = 0;
|
|
for (const pattern of patterns) {
|
|
if (pattern.test(content)) sections++;
|
|
}
|
|
|
|
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
|
|
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
|
|
|
|
return `
|
|
# PRD Status
|
|
|
|
- **File:** docs/PRD.md
|
|
- **Status:** ${status}
|
|
- **Assumptions:** ${assumptions}
|
|
|
|
`;
|
|
}
|
|
|
|
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compose the full runtime contract for a harness: the resident-by-value core
|
|
* (CONSTITUTION + AGENTS + USER + TOOLS + runtime) plus operator overlays
|
|
* (`*.local.md` deltas), merged in precedence order so the model gets one
|
|
* pre-merged blob (DESIGN §3.2 / R7). Overlays are injected as deltas by value;
|
|
* base files keep their existing residency (USER injected; SOUL/STANDARDS are
|
|
* load-on-demand, so only their small `.local` deltas are injected here).
|
|
*
|
|
* `mosaicHome` is parameterized for testability; production callers use the
|
|
* module-level default.
|
|
*/
|
|
export function composeContract(
|
|
runtime: RuntimeName,
|
|
mosaicHome: string = MOSAIC_HOME,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string {
|
|
const runtimeContractPaths: Record<RuntimeName, string> = {
|
|
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
|
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
|
opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
|
|
pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'),
|
|
};
|
|
|
|
const runtimeFile = runtimeContractPaths[runtime];
|
|
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
|
|
|
|
const parts: string[] = [];
|
|
|
|
// Mission context (injected first)
|
|
const mission = detectMission();
|
|
if (mission) {
|
|
parts.push(buildMissionBlock(mission));
|
|
}
|
|
|
|
// PRD status
|
|
const prdBlock = buildPrdBlock();
|
|
if (prdBlock) parts.push(prdBlock);
|
|
|
|
// Hard gate
|
|
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
|
|
|
|
This contract is injected by \`mosaic\` launch and is mandatory.
|
|
|
|
First assistant response MUST start with exactly one mode declaration line:
|
|
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
|
|
2. Implementation mission: \`Now initiating Delivery mode...\`
|
|
3. Review-only mission: \`Now initiating Review mode...\`
|
|
|
|
No tool call or implementation step may occur before that first line.
|
|
|
|
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
|
|
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
|
|
`);
|
|
|
|
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
|
// pre-constitution installs that have not been re-seeded yet. Injected by
|
|
// value verbatim so the bare-launch fallback read is byte-equal (R8).
|
|
const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md'));
|
|
if (constitution) parts.push(constitution);
|
|
|
|
// AGENTS.md
|
|
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
|
|
|
|
// USER.md (+ USER.local.md operator overlay, appended directly under the
|
|
// profile its base owns).
|
|
const user = readOptional(join(mosaicHome, 'USER.md'));
|
|
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
|
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
|
|
if (userLocal.trim()) {
|
|
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
|
}
|
|
|
|
const fleetIdentity = resolveFleetIdentity(mosaicHome, env['MOSAIC_AGENT_NAME']);
|
|
if (!fleetIdentity.ok) {
|
|
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
|
}
|
|
const canonicalMember = fleetIdentity.identity?.member;
|
|
if (canonicalMember && env['MOSAIC_AGENT_CLASS']?.trim()) {
|
|
const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
|
if (ambientClass !== canonicalMember.className) {
|
|
throw new Error(
|
|
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// TOOLS.md
|
|
const tools = readInstalledToolsSecure(mosaicHome);
|
|
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
|
const toolsContractStatus = renderToolsContractStatus(mosaicHome);
|
|
if (toolsContractStatus) parts.push('\n\n' + toolsContractStatus);
|
|
|
|
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
|
// inject only the small `.local` delta by value so the customization reaches
|
|
// the model without re-injecting the full base prose (preserves the byte
|
|
// budget). Absent `.local` files → base-only, automatically (R7 §3.2).
|
|
const overlayBlocks: string[] = [];
|
|
const soulLocal = readOptional(join(mosaicHome, 'SOUL.local.md'));
|
|
if (soulLocal.trim()) {
|
|
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
|
|
}
|
|
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
|
|
if (standardsLocal.trim()) {
|
|
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
|
}
|
|
if (overlayBlocks.length > 0) {
|
|
parts.push('\n\n# Operator Overlays\n\n' + overlayBlocks.join('\n\n'));
|
|
}
|
|
|
|
// Runtime-specific contract
|
|
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
|
|
|
// Fleet launches derive every identity projection from the one canonical roster
|
|
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
|
// and tool-policy behavior.
|
|
const personaClass = canonicalMember?.className ?? env['MOSAIC_AGENT_CLASS'];
|
|
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
|
if (persona) parts.push('\n\n' + persona);
|
|
|
|
const toolPolicyName = canonicalMember
|
|
? canonicalMember.toolPolicy
|
|
: env['MOSAIC_AGENT_TOOL_POLICY'];
|
|
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
|
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
|
|
|
if (fleetIdentity.identity) {
|
|
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity);
|
|
if (fleetComms) parts.push('\n\n' + fleetComms);
|
|
}
|
|
|
|
return parts.join('\n');
|
|
}
|
|
|
|
function readFleetToolPolicyBlock(policy: string | undefined): string {
|
|
if (policy !== 'operator-interaction') return '';
|
|
return [
|
|
'# Fleet Tool Policy (operator-interaction)',
|
|
'',
|
|
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
|
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
|
'Delegate orchestrator-owned work through the authorized handoff boundary.',
|
|
].join('\n');
|
|
}
|
|
|
|
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
|
function buildRuntimePrompt(runtime: RuntimeName, env: NodeJS.ProcessEnv = process.env): string {
|
|
return composeContract(runtime, MOSAIC_HOME, env);
|
|
}
|
|
|
|
// ─── Session lock ────────────────────────────────────────────────────────────
|
|
|
|
function writeSessionLock(runtime: string): void {
|
|
const missionFile = '.mosaic/orchestrator/mission.json';
|
|
const lockFile = '.mosaic/orchestrator/session.lock';
|
|
const data = readJson(missionFile);
|
|
if (!data) return;
|
|
|
|
const status = String(data['status'] ?? 'inactive');
|
|
if (status !== 'active' && status !== 'paused') return;
|
|
|
|
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
|
const lock = {
|
|
session_id: sessionId,
|
|
runtime,
|
|
pid: process.pid,
|
|
started_at: new Date().toISOString(),
|
|
project_path: process.cwd(),
|
|
milestone_id: '',
|
|
};
|
|
|
|
try {
|
|
mkdirSync(dirname(lockFile), { recursive: true });
|
|
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
|
|
|
|
// Clean up on exit
|
|
const cleanup = () => {
|
|
try {
|
|
rmSync(lockFile, { force: true });
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
};
|
|
process.on('exit', cleanup);
|
|
process.on('SIGINT', () => {
|
|
cleanup();
|
|
process.exit(130);
|
|
});
|
|
process.on('SIGTERM', () => {
|
|
cleanup();
|
|
process.exit(143);
|
|
});
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
}
|
|
|
|
// ─── Resumable session advisory ──────────────────────────────────────────────
|
|
|
|
function checkResumableSession(): void {
|
|
const lockFile = '.mosaic/orchestrator/session.lock';
|
|
const missionFile = '.mosaic/orchestrator/mission.json';
|
|
|
|
if (existsSync(lockFile)) {
|
|
const lock = readJson(lockFile);
|
|
if (lock) {
|
|
const pid = Number(lock['pid'] ?? 0);
|
|
if (pid > 0) {
|
|
try {
|
|
process.kill(pid, 0); // Check if alive
|
|
} catch {
|
|
// Process is dead — stale lock
|
|
rmSync(lockFile, { force: true });
|
|
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
|
|
}
|
|
}
|
|
}
|
|
} else if (existsSync(missionFile)) {
|
|
const data = readJson(missionFile);
|
|
if (data && data['status'] === 'active') {
|
|
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
|
|
console.log('[mosaic] mosaic coord continue\n');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
|
|
|
function ensureRuntimeConfig(
|
|
runtime: RuntimeName,
|
|
destPath: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): void {
|
|
const prompt = buildRuntimePrompt(runtime, env);
|
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
const existing = readOptional(destPath);
|
|
if (existing !== prompt) {
|
|
writeFileSync(destPath, prompt);
|
|
}
|
|
}
|
|
|
|
// ─── Pi skill/extension discovery ────────────────────────────────────────────
|
|
|
|
/** Resolve a skill dir to its canonical real path so symlinked duplicates
|
|
* (e.g. ~/.pi/agent/skills/X -> ~/.config/mosaic/skills/X) collapse to one key.
|
|
* Falls back to the literal path if it can't be resolved (e.g. broken link). */
|
|
function skillRealPath(dir: string): string {
|
|
try {
|
|
return realpathSync(dir);
|
|
} catch {
|
|
return dir;
|
|
}
|
|
}
|
|
|
|
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
|
* skills dir and the project-local one relative to the launch cwd. */
|
|
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
|
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
|
// <home>/skills — there is no extra 'agent' segment under the isolated home.
|
|
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
|
|
}
|
|
|
|
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
|
* counts as a skill when it (or its symlink target) contains a SKILL.md.
|
|
* Exported for tests (real-FS coverage of symlink acceptance + realpath dedup). */
|
|
export function enumerateSkillDirs(roots: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const args: string[] = [];
|
|
for (const skillsRoot of roots) {
|
|
if (!existsSync(skillsRoot)) continue;
|
|
try {
|
|
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
// Synced fleet skills land as symlinks, so accept both dirs and links.
|
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
const skillDir = join(skillsRoot, entry.name);
|
|
if (!existsSync(join(skillDir, 'SKILL.md'))) continue;
|
|
const key = skillRealPath(skillDir);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
args.push('--skill', skillDir);
|
|
}
|
|
} catch {
|
|
// skip unreadable roots
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
/** Every skill dir Pi would link under `MOSAIC_PI_SKILL_MODE=all`: the Mosaic
|
|
* global/local catalog plus Pi's own native roots. `--no-skills` suppresses
|
|
* native auto-discovery, so 'all' must re-add the native roots explicitly or
|
|
* they would be silently dropped. Deduped by real path. */
|
|
function discoverPiSkills(cwd: string = process.cwd()): string[] {
|
|
return enumerateSkillDirs([
|
|
join(MOSAIC_HOME, 'skills'),
|
|
join(MOSAIC_HOME, 'skills-local'),
|
|
...piNativeSkillRoots(cwd),
|
|
]);
|
|
}
|
|
|
|
/** Real paths of skills Pi will auto-discover from its native roots. Used to
|
|
* drop redundant force-loads in 'discover' mode (which keeps native discovery
|
|
* on) so the same skill is not registered twice. */
|
|
function piNativeSkillRealPaths(cwd: string = process.cwd()): Set<string> {
|
|
const args = enumerateSkillDirs(piNativeSkillRoots(cwd));
|
|
const set = new Set<string>();
|
|
for (let i = 1; i < args.length; i += 2) {
|
|
const dir = args[i];
|
|
if (dir !== undefined) set.add(skillRealPath(dir));
|
|
}
|
|
return set;
|
|
}
|
|
|
|
type PiSkillMode = 'none' | 'all' | 'discover';
|
|
|
|
function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
|
|
const value = env['MOSAIC_PI_SKILL_MODE']?.trim().toLowerCase();
|
|
if (value === 'all' || value === 'discover') return value;
|
|
return 'none';
|
|
}
|
|
|
|
/**
|
|
* Fleet-critical Pi skills that are force-loaded on every Pi launch regardless
|
|
* of MOSAIC_PI_SKILL_MODE. They cover the highest-frequency cross-agent and
|
|
* git-provider operations where Pi workers historically improvised raw CLIs
|
|
* (raw `tmux send-keys`, raw `tea`/`gh`/`glab`) instead of the maintained
|
|
* `~/.config/mosaic/tools/` wrappers.
|
|
*
|
|
* An explicit `--skill <dir>` overrides `--no-skills` for that path, so forcing
|
|
* a single targeted skill surfaces the must-use toolkit without loading the full
|
|
* ~100-skill catalog (context bloat). Missing skills are skipped silently, so
|
|
* this is a no-op until the named skill is synced into ~/.config/mosaic/skills/.
|
|
*
|
|
* Override with MOSAIC_PI_FORCE_SKILLS (colon-separated skill dir names; set to
|
|
* an empty string to disable force-loading entirely).
|
|
*/
|
|
const DEFAULT_PI_FORCE_SKILLS = ['mosaic-tools'];
|
|
|
|
export function piForceSkillNames(env: NodeJS.ProcessEnv): string[] {
|
|
const override = env['MOSAIC_PI_FORCE_SKILLS'];
|
|
if (override === undefined) return DEFAULT_PI_FORCE_SKILLS;
|
|
return override
|
|
.split(':')
|
|
.map((name) => name.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
const args: string[] = [];
|
|
for (const name of piForceSkillNames(env)) {
|
|
const skillDir = join(MOSAIC_HOME, 'skills', name);
|
|
if (existsSync(join(skillDir, 'SKILL.md'))) {
|
|
args.push('--skill', skillDir);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
/** Concatenate `--skill <dir>` arg groups, dropping any skill already seen.
|
|
* Dedup is by real path, so a forced skill and the same skill reached via a
|
|
* different (e.g. symlinked) directory collapse to a single `--skill`. */
|
|
function mergeSkillArgs(...groups: string[][]): string[] {
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const group of groups) {
|
|
for (let i = 0; i < group.length; i += 2) {
|
|
const dir = group[i + 1];
|
|
if (group[i] !== '--skill' || dir === undefined) continue;
|
|
const key = skillRealPath(dir);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push('--skill', dir);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function buildPiSkillArgs(
|
|
_runtimeArgs: string[],
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
discoveredSkillArgs: string[] = discoverPiSkills(),
|
|
forcedSkillArgs: string[] = forcedPiSkillArgs(env),
|
|
nativeSkillRealPaths: Set<string> = piNativeSkillRealPaths(),
|
|
): string[] {
|
|
const mode = normalizePiSkillMode(env);
|
|
|
|
if (mode === 'discover') {
|
|
// Native Pi discovery stays on, so only force-load fleet skills it will NOT
|
|
// already find under its native roots — otherwise the same skill is
|
|
// registered twice (once natively, once via --skill). mergeSkillArgs first
|
|
// collapses any intra-forced-set realpath duplicates, mirroring 'all' mode.
|
|
const deduped = mergeSkillArgs(forcedSkillArgs);
|
|
const out: string[] = [];
|
|
for (let i = 0; i < deduped.length; i += 2) {
|
|
const dir = deduped[i + 1];
|
|
if (deduped[i] !== '--skill' || dir === undefined) continue;
|
|
if (nativeSkillRealPaths.has(skillRealPath(dir))) continue;
|
|
out.push('--skill', dir);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (mode === 'all') {
|
|
// 'all' links the full catalog; merge in the forced set so fleet-critical
|
|
// skills are guaranteed present even if they live only under skills-local/.
|
|
// discoverPiSkills already covers Pi's native roots, which `--no-skills`
|
|
// would otherwise suppress.
|
|
return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
|
|
}
|
|
|
|
return ['--no-skills', ...forcedSkillArgs];
|
|
}
|
|
|
|
function discoverPiExtension(): string[] {
|
|
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
|
return existsSync(ext) ? ['--extension', ext] : [];
|
|
}
|
|
|
|
// ─── Launch functions ────────────────────────────────────────────────────────
|
|
|
|
function getMissionPrompt(): string {
|
|
const mission = detectMission();
|
|
if (!mission) return '';
|
|
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
|
|
}
|
|
|
|
interface RuntimeLaunchContext {
|
|
readonly fleet?: FleetHarnessContext;
|
|
readonly declaredEnv?: Readonly<Record<string, string>>;
|
|
/** Test seam: bypass only final runtime binary discovery. */
|
|
readonly runtimeCheck?: (runtime: RuntimeName) => void;
|
|
/** Test seam: receives the fully composed final runtime invocation. */
|
|
readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void;
|
|
readonly recordLaunch?: boolean;
|
|
}
|
|
|
|
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {};
|
|
for (const name of [
|
|
'PATH',
|
|
'HOME',
|
|
'USER',
|
|
'LOGNAME',
|
|
'SHELL',
|
|
'TERM',
|
|
'COLORTERM',
|
|
'LANG',
|
|
'LC_ALL',
|
|
'TMPDIR',
|
|
'XDG_RUNTIME_DIR',
|
|
]) {
|
|
const value = process.env[name];
|
|
if (value !== undefined) env[name] = value;
|
|
}
|
|
return { ...env, ...declared };
|
|
}
|
|
|
|
function launchRuntime(
|
|
runtime: RuntimeName,
|
|
args: string[],
|
|
yolo: boolean,
|
|
context: RuntimeLaunchContext = {},
|
|
): never {
|
|
checkMosaicHome();
|
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
|
checkSoul();
|
|
(context.runtimeCheck ?? checkRuntime)(runtime);
|
|
|
|
// Pi doesn't need sequential-thinking (has native thinking levels)
|
|
if (runtime !== 'pi') {
|
|
checkSequentialThinking(runtime, context.fleet);
|
|
}
|
|
|
|
checkResumableSession();
|
|
|
|
const missionPrompt = getMissionPrompt();
|
|
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
|
const label = RUNTIME_LABELS[runtime];
|
|
const modeStr = yolo ? ' in YOLO mode' : '';
|
|
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
|
|
|
|
writeSessionLock(runtime);
|
|
const launchEnv =
|
|
context.declaredEnv === undefined ? process.env : minimalLaunchEnv(context.declaredEnv);
|
|
// A per-agent profile is the launch SSOT and intentionally does not require a
|
|
// second roster registry. Keep roster-v1 identity composition for legacy
|
|
// launches, but remove its identity keys from the contract-build environment
|
|
// for a profile-backed seat. The declared identity is still exported to the
|
|
// harness process below.
|
|
const contractEnv =
|
|
context.declaredEnv === undefined
|
|
? launchEnv
|
|
: Object.fromEntries(
|
|
Object.entries(launchEnv).filter(
|
|
([name]) =>
|
|
name !== 'MOSAIC_AGENT_NAME' &&
|
|
name !== 'MOSAIC_AGENT_CLASS' &&
|
|
name !== 'MOSAIC_AGENT_TOOL_POLICY',
|
|
),
|
|
);
|
|
|
|
switch (runtime) {
|
|
case 'claude': {
|
|
// Audit Claude Code settings and warn about missing hooks/plugins
|
|
const settingsAudit = auditClaudeSettings(context.fleet);
|
|
printSettingsWarnings(settingsAudit);
|
|
|
|
const prompt = buildRuntimePrompt('claude', contractEnv);
|
|
const cliArgs: string[] = [];
|
|
cliArgs.push('--append-system-prompt', prompt);
|
|
if (hasMissionNoArgs) {
|
|
cliArgs.push(missionPrompt);
|
|
} else {
|
|
cliArgs.push(...args);
|
|
}
|
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
|
if (context.recordLaunch !== false)
|
|
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
|
|
if (process.env['MOSAIC_LAUNCH_ID']) {
|
|
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
|
}
|
|
if (context.finalExecutor) {
|
|
context.finalExecutor('claude', cliArgs, launchEnv);
|
|
} else {
|
|
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'codex': {
|
|
ensureRuntimeConfig(
|
|
'codex',
|
|
join(harnessHome('codex', context.fleet), 'instructions.md'),
|
|
contractEnv,
|
|
);
|
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
|
if (hasMissionNoArgs) {
|
|
cliArgs.push(missionPrompt);
|
|
} else {
|
|
cliArgs.push(...args);
|
|
}
|
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
|
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
|
|
execRuntime('codex', cliArgs, {
|
|
...launchEnv,
|
|
...harnessEnv('codex', context.fleet),
|
|
...(process.env['MOSAIC_LAUNCH_ID']
|
|
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
|
|
: {}),
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'opencode': {
|
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
|
ensureRuntimeConfig(
|
|
'opencode',
|
|
join(harnessHome('opencode', context.fleet), 'opencode', 'AGENTS.md'),
|
|
contractEnv,
|
|
);
|
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
|
recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
|
|
execRuntime('opencode', args, {
|
|
...launchEnv,
|
|
...harnessEnv('opencode', context.fleet),
|
|
...(process.env['MOSAIC_LAUNCH_ID']
|
|
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
|
|
: {}),
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'pi': {
|
|
const prompt = buildRuntimePrompt('pi', contractEnv);
|
|
const cliArgs = ['--append-system-prompt', prompt];
|
|
cliArgs.push(...buildPiSkillArgs(args));
|
|
cliArgs.push(...discoverPiExtension());
|
|
if (hasMissionNoArgs) {
|
|
cliArgs.push(missionPrompt);
|
|
} else {
|
|
cliArgs.push(...args);
|
|
}
|
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
|
recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
|
|
if (process.env['MOSAIC_LAUNCH_ID']) {
|
|
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
|
}
|
|
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
|
|
break;
|
|
}
|
|
}
|
|
|
|
process.exit(0); // Unreachable but satisfies never
|
|
}
|
|
|
|
/**
|
|
* Resolve the lease broker's control socket path. Exported (in addition to
|
|
* being used internally by execLeaseGatedRuntime) so the C1 activation probe
|
|
* (lease-activation-probe.ts) can perform the same resolution when checking
|
|
* whether the broker supervisor is reachable — detection only, this never
|
|
* connects to the socket itself.
|
|
*/
|
|
export function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
|
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
|
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
|
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
|
}
|
|
|
|
function execLeaseGatedRuntime(
|
|
runtime: 'claude' | 'pi',
|
|
args: string[],
|
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
dangerous = false,
|
|
fleet?: FleetHarnessContext,
|
|
): void {
|
|
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
|
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
|
execRuntime(
|
|
'python3',
|
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
|
{
|
|
...baseEnv,
|
|
...harnessEnv(runtime, fleet),
|
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Fleet entry point reusing the normative runtime launch and exec path. */
|
|
export function launchFleetRuntime(
|
|
runtime: RuntimeName,
|
|
args: string[],
|
|
declaredEnv: Readonly<Record<string, string>>,
|
|
fleet: FleetHarnessContext,
|
|
): never {
|
|
return launchRuntime(runtime, args, false, { fleet, declaredEnv });
|
|
}
|
|
|
|
/** Bounded production-path test seam; all preflight and composition remain real. */
|
|
export function launchFleetRuntimeForTest(
|
|
runtime: RuntimeName,
|
|
args: string[],
|
|
declaredEnv: Readonly<Record<string, string>>,
|
|
fleet: FleetHarnessContext,
|
|
finalExecutor: NonNullable<RuntimeLaunchContext['finalExecutor']>,
|
|
): never {
|
|
return launchRuntime(runtime, args, false, {
|
|
fleet,
|
|
declaredEnv,
|
|
runtimeCheck: () => undefined,
|
|
finalExecutor,
|
|
recordLaunch: false,
|
|
});
|
|
}
|
|
|
|
/** exec into the runtime, replacing the current process. */
|
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
|
try {
|
|
// Use execFileSync with inherited stdio to replace the process
|
|
const result = spawnSync(cmd, args, {
|
|
stdio: 'inherit',
|
|
env,
|
|
});
|
|
process.exit(result.status ?? 0);
|
|
} catch (err) {
|
|
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Production glue for `mosaic [yolo] claudex` (EXPERIMENTAL — GPT models inside
|
|
* the Claude Code harness via claude-code-proxy). Assembles the real harness
|
|
* adapter and delegates the security-critical composition + fail-closed
|
|
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
|
* logic lives in the DI module, not here.
|
|
*/
|
|
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
|
writeSessionLock('claude');
|
|
const adapter: ClaudexHarnessAdapter = {
|
|
harnessPreflight: () => {
|
|
checkMosaicHome();
|
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
|
checkSoul();
|
|
checkRuntime('claude');
|
|
checkSequentialThinking('claude');
|
|
},
|
|
composePrompt: () => buildRuntimePrompt('claude'),
|
|
execLeaseGated: (cmdArgs, env, dangerous) =>
|
|
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
|
};
|
|
void launchClaudex(args, yolo, adapter);
|
|
}
|
|
|
|
// ─── Framework script/tool delegation ───────────────────────────────────────
|
|
|
|
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
|
if (!existsSync(scriptPath)) {
|
|
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
|
process.exit(1);
|
|
}
|
|
try {
|
|
execFileSync('bash', [scriptPath, ...args], {
|
|
stdio: 'inherit',
|
|
env: { ...process.env, ...env },
|
|
});
|
|
process.exit(0);
|
|
} catch (err) {
|
|
process.exit((err as { status?: number }).status ?? 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a path under the framework tools directory. Prefers the version
|
|
* bundled in the @mosaicstack/mosaic npm package (always matches the installed
|
|
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
|
*/
|
|
/**
|
|
* Exported so the C1 activation probe (lease-activation-probe.ts) can resolve
|
|
* the same lease-broker launcher/daemon artifacts execLeaseGatedRuntime()
|
|
* uses, for detection-only supervisor presence checks.
|
|
*/
|
|
export function resolveTool(...segments: string[]): string {
|
|
try {
|
|
const req = createRequire(import.meta.url);
|
|
const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json'));
|
|
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
|
|
if (existsSync(bundled)) return bundled;
|
|
} catch {
|
|
// Fall through to deployed copy
|
|
}
|
|
return join(MOSAIC_HOME, 'tools', ...segments);
|
|
}
|
|
|
|
function fwScript(name: string): string {
|
|
return resolveTool('_scripts', name);
|
|
}
|
|
|
|
function toolScript(toolDir: string, name: string): string {
|
|
return resolveTool(toolDir, name);
|
|
}
|
|
|
|
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
|
|
|
|
const COORD_SUBCMDS: Record<string, string> = {
|
|
status: 'session-status.sh',
|
|
session: 'session-status.sh',
|
|
init: 'mission-init.sh',
|
|
mission: 'mission-status.sh',
|
|
progress: 'mission-status.sh',
|
|
continue: 'continue-prompt.sh',
|
|
next: 'continue-prompt.sh',
|
|
run: 'session-run.sh',
|
|
start: 'session-run.sh',
|
|
smoke: 'smoke-test.sh',
|
|
test: 'smoke-test.sh',
|
|
resume: 'session-resume.sh',
|
|
recover: 'session-resume.sh',
|
|
};
|
|
|
|
function runCoord(args: string[]): never {
|
|
checkMosaicHome();
|
|
let runtime = 'claude';
|
|
let yoloFlag = '';
|
|
const coordArgs: string[] = [];
|
|
|
|
for (const arg of args) {
|
|
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
|
runtime = arg.slice(2);
|
|
} else if (arg === '--yolo') {
|
|
yoloFlag = '--yolo';
|
|
} else {
|
|
coordArgs.push(arg);
|
|
}
|
|
}
|
|
|
|
const subcmd = coordArgs[0] ?? 'help';
|
|
const subArgs = coordArgs.slice(1);
|
|
const script = COORD_SUBCMDS[subcmd];
|
|
|
|
if (!script) {
|
|
console.log(`mosaic coord — mission coordinator tools
|
|
|
|
Commands:
|
|
init --name <name> [opts] Initialize a new mission
|
|
mission [--project <path>] Show mission progress dashboard
|
|
status [--project <path>] Check agent session health
|
|
continue [--project <path>] Generate continuation prompt
|
|
run [--project <path>] Launch runtime with mission context
|
|
smoke Run orchestration smoke checks
|
|
resume [--project <path>] Crash recovery
|
|
|
|
Runtime: --claude (default) | --codex | --pi | --yolo`);
|
|
process.exit(subcmd === 'help' ? 0 : 1);
|
|
}
|
|
|
|
if (yoloFlag) subArgs.unshift(yoloFlag);
|
|
delegateToScript(toolScript('orchestrator', script), subArgs, {
|
|
MOSAIC_COORD_RUNTIME: runtime,
|
|
});
|
|
}
|
|
|
|
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
|
|
|
|
const PRDY_SUBCMDS: Record<string, string> = {
|
|
init: 'prdy-init.sh',
|
|
update: 'prdy-update.sh',
|
|
validate: 'prdy-validate.sh',
|
|
check: 'prdy-validate.sh',
|
|
status: 'prdy-status.sh',
|
|
};
|
|
|
|
function runPrdyLocal(args: string[]): never {
|
|
checkMosaicHome();
|
|
let runtime = 'claude';
|
|
const prdyArgs: string[] = [];
|
|
|
|
for (const arg of args) {
|
|
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
|
runtime = arg.slice(2);
|
|
} else {
|
|
prdyArgs.push(arg);
|
|
}
|
|
}
|
|
|
|
const subcmd = prdyArgs[0] ?? 'help';
|
|
const subArgs = prdyArgs.slice(1);
|
|
const script = PRDY_SUBCMDS[subcmd];
|
|
|
|
if (!script) {
|
|
console.log(`mosaic prdy — PRD creation and validation
|
|
|
|
Commands:
|
|
init [--project <path>] [--name <feature>] Create docs/PRD.md
|
|
update [--project <path>] Update existing PRD
|
|
validate [--project <path>] Check PRD completeness
|
|
status [--project <path>] Quick PRD health check
|
|
|
|
Runtime: --claude (default) | --codex | --pi`);
|
|
process.exit(subcmd === 'help' ? 0 : 1);
|
|
}
|
|
|
|
delegateToScript(toolScript('prdy', script), subArgs, {
|
|
MOSAIC_PRDY_RUNTIME: runtime,
|
|
});
|
|
}
|
|
|
|
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
|
|
|
|
function runSeq(args: string[]): never {
|
|
checkMosaicHome();
|
|
const action = args[0] ?? 'check';
|
|
const rest = args.slice(1);
|
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
|
|
|
switch (action) {
|
|
case 'check':
|
|
delegateToScript(checker, ['--check', ...rest]);
|
|
break; // unreachable
|
|
case 'fix':
|
|
case 'apply':
|
|
delegateToScript(checker, rest);
|
|
break;
|
|
case 'start': {
|
|
console.log('[mosaic] Starting sequential-thinking MCP server...');
|
|
try {
|
|
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
|
|
stdio: 'inherit',
|
|
});
|
|
process.exit(0);
|
|
} catch (err) {
|
|
process.exit((err as { status?: number }).status ?? 1);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ─── Upgrade ────────────────────────────────────────────────────────────────
|
|
|
|
function runUpgrade(args: string[]): never {
|
|
checkMosaicHome();
|
|
const subcmd = args[0];
|
|
|
|
if (!subcmd || subcmd === 'release') {
|
|
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
|
|
} else if (subcmd === 'check') {
|
|
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
|
|
} else if (subcmd === 'project') {
|
|
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
|
|
} else if (subcmd.startsWith('-')) {
|
|
delegateToScript(fwScript('mosaic-release-upgrade'), args);
|
|
} else {
|
|
delegateToScript(fwScript('mosaic-upgrade'), args);
|
|
}
|
|
}
|
|
|
|
// ─── Commander registration ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Handler invoked when a runtime subcommand (`<runtime>` or `yolo <runtime>`)
|
|
* is parsed. Exposed so tests can exercise the commander wiring without
|
|
* spawning subprocesses.
|
|
*/
|
|
export type RuntimeLaunchHandler = (
|
|
runtime: RuntimeName,
|
|
extraArgs: string[],
|
|
yolo: boolean,
|
|
) => void;
|
|
|
|
/**
|
|
* Handler invoked for `claudex` / `yolo claudex`. Kept separate from
|
|
* `RuntimeLaunchHandler` because claudex is an EXPERIMENTAL harness overlay
|
|
* (GPT-via-proxy), not one of the first-class runtimes. Exposed + injectable so
|
|
* the commander wiring can be exercised without composing a real launch.
|
|
*/
|
|
export type ClaudexLaunchHandler = (extraArgs: string[], yolo: boolean) => void;
|
|
|
|
/**
|
|
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
|
|
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
|
* can inject a spy and verify argument forwarding.
|
|
*/
|
|
export function registerRuntimeLaunchers(
|
|
program: Command,
|
|
handler: RuntimeLaunchHandler,
|
|
claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) =>
|
|
launchClaudexProduction(extraArgs, yolo),
|
|
): void {
|
|
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
|
program
|
|
.command(runtime)
|
|
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
handler(runtime, cmd.args, false);
|
|
});
|
|
}
|
|
|
|
// claudex — EXPERIMENTAL: GPT models inside the Claude Code harness via
|
|
// claude-code-proxy (ChatGPT-subscription OAuth). Isolated CLAUDE_CONFIG_DIR
|
|
// + zero-token-leak env injection live in claudex.ts.
|
|
program
|
|
.command('claudex')
|
|
.description('EXPERIMENTAL: launch Claude Code harness against GPT via claude-code-proxy')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
claudexHandler(cmd.args, false);
|
|
});
|
|
|
|
program
|
|
.command('yolo <runtime>')
|
|
.description(
|
|
'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)',
|
|
)
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
|
// claudex is an EXPERIMENTAL overlay, not a RuntimeName — dispatch it
|
|
// before the runtime allowlist check. Slice off the positional runtime
|
|
// name for the same reason as below (#454).
|
|
if (runtime === 'claudex') {
|
|
claudexHandler(cmd.args.slice(1), true);
|
|
return;
|
|
}
|
|
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
|
if (!valid.includes(runtime as RuntimeName)) {
|
|
console.error(
|
|
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
// Commander includes declared positional arguments (`<runtime>`) in
|
|
// `cmd.args` alongside any trailing excess args. Slice off the first
|
|
// element so we forward only true excess args — otherwise the runtime
|
|
// name leaks into the underlying CLI as an initial positional arg,
|
|
// which Claude Code interprets as the first user message.
|
|
// Regression test: launch.spec.ts, issue mosaicstack/stack#454.
|
|
handler(runtime as RuntimeName, cmd.args.slice(1), true);
|
|
});
|
|
}
|
|
|
|
export function registerLaunchCommands(program: Command): void {
|
|
// Runtime launchers + yolo mode wired to the real process-replacing launcher.
|
|
registerRuntimeLaunchers(program, (runtime, extraArgs, yolo) => {
|
|
launchRuntime(runtime, extraArgs, yolo);
|
|
});
|
|
|
|
// compose-contract — emit the composed runtime contract (base + operator
|
|
// overlays) for a harness to stdout, without launching. For inspection,
|
|
// `mosaic doctor`, diffing, and the composer test (R7).
|
|
program
|
|
.command('compose-contract <harness>')
|
|
.description('Print the composed runtime contract (base + *.local overlays) for a harness')
|
|
.action((harness: string) => {
|
|
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
|
if (!valid.includes(harness as RuntimeName)) {
|
|
console.error(`Unknown harness '${harness}'. Expected one of: ${valid.join(', ')}.`);
|
|
process.exitCode = 64;
|
|
return;
|
|
}
|
|
process.stdout.write(composeContract(harness as RuntimeName));
|
|
});
|
|
|
|
// Coord (mission orchestrator)
|
|
program
|
|
.command('coord')
|
|
.description('Mission coordinator tools (init, status, run, continue, resume)')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
runCoord(cmd.args);
|
|
});
|
|
|
|
// Prdy (PRD tools via local framework scripts)
|
|
program
|
|
.command('prdy')
|
|
.description('PRD creation and validation (init, update, validate, status)')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
runPrdyLocal(cmd.args);
|
|
});
|
|
|
|
// Seq (sequential-thinking MCP management)
|
|
program
|
|
.command('seq')
|
|
.description('sequential-thinking MCP management (check/fix/start)')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
runSeq(cmd.args);
|
|
});
|
|
|
|
// Upgrade (release + project)
|
|
program
|
|
.command('upgrade')
|
|
.description('Upgrade Mosaic release or project files')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
runUpgrade(cmd.args);
|
|
});
|
|
|
|
// Direct framework script delegates
|
|
const directCommands: Record<string, { desc: string; script: string }> = {
|
|
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
|
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
|
bootstrap: {
|
|
desc: 'Bootstrap a repo with Mosaic standards',
|
|
script: 'mosaic-bootstrap-repo',
|
|
},
|
|
};
|
|
|
|
for (const [name, { desc, script }] of Object.entries(directCommands)) {
|
|
program
|
|
.command(name)
|
|
.description(desc)
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action((_opts: unknown, cmd: Command) => {
|
|
checkMosaicHome();
|
|
delegateToScript(fwScript(script), cmd.args);
|
|
});
|
|
}
|
|
|
|
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
|
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
|
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
|
// Kept out of the generic `directCommands` loop above because this check
|
|
// must run and report BEFORE the bash script's own exit, and must be able
|
|
// to force a non-zero exit on its own — a silent pass on "enforcement
|
|
// hooks wired but activation absent" would leave a bricked host
|
|
// undiagnosed (see lease-doctor-check.ts docstring).
|
|
program
|
|
.command('doctor')
|
|
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action(async (_opts: unknown, cmd: Command) => {
|
|
checkMosaicHome();
|
|
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
|
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
|
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Print the #869 C5 lease-enforcement doctor result using the same
|
|
* `[mosaic-doctor]` prefix the bash audit script uses, but with a distinct
|
|
* `[ERROR]` severity token (louder than the script's own `[WARN]`) — this is
|
|
* a hard, actionable brick warning, not a soft drift warning, and must never
|
|
* read as just one more line among the script's routine warnings. Silent on
|
|
* an `ok` result, matching this file's other pre-flight checks
|
|
* (`checkMosaicHome`, `checkFile`, `checkRuntime`) which only print on
|
|
* failure. Returns whether the check failed, so the caller can force a
|
|
* non-zero exit regardless of the bash script's own exit code.
|
|
*/
|
|
function printLeaseDoctorCheck(
|
|
result: Awaited<ReturnType<typeof runLeaseEnforcementDoctorCheck>>,
|
|
): boolean {
|
|
if (result.status === 'error') {
|
|
console.error(`[mosaic-doctor] [ERROR] ${result.message}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Run the bash `mosaic-doctor` audit script (inheriting stdio, same as
|
|
* {@link delegateToScript}) and exit with a non-zero code if EITHER the
|
|
* script itself reported failure OR the lease-enforcement check above did —
|
|
* so `--fail-on-warn` and other script-level exit semantics are preserved,
|
|
* but the lease-enforcement ERROR can never be masked by an otherwise-green
|
|
* script run.
|
|
*/
|
|
function runDoctorScriptAndExit(scriptPath: string, args: string[], forceFailure: boolean): never {
|
|
if (!existsSync(scriptPath)) {
|
|
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
|
process.exit(1);
|
|
}
|
|
let scriptExitCode = 0;
|
|
try {
|
|
execFileSync('bash', [scriptPath, ...args], { stdio: 'inherit', env: process.env });
|
|
} catch (err) {
|
|
scriptExitCode = (err as { status?: number }).status ?? 1;
|
|
}
|
|
process.exit(forceFailure ? 1 : scriptExitCode);
|
|
}
|