2 Commits

Author SHA1 Message Date
0193861784 feat(wave3): add @mosaic/cli — unified mosaic CLI entry point
- Root Commander program with version + description
- Subcommand groups: coord, prdy, queue, quality-rails
- Single `mosaic` binary entry point
- Depends on all @mosaic/* workspace packages
2026-03-06 20:22:37 -06:00
7f7109fc09 feat(wave3): @mosaic/coord TypeScript orchestrator (#6)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 01:32:10 +00:00
24 changed files with 2906 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { rootCommand } from '../src/root-command.js';
rootCommand.parseAsync(process.argv);

View File

@@ -0,0 +1,15 @@
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts', 'bin/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {},
},
];

33
packages/cli/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@mosaic/cli",
"version": "0.1.0",
"type": "module",
"description": "Mosaic unified CLI — the mosaic command",
"bin": {
"mosaic": "./dist/bin/mosaic.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*",
"@mosaic/coord": "workspace:*",
"@mosaic/queue": "workspace:*",
"commander": "^13",
"picocolors": "^1.1"
},
"devDependencies": {
"@types/node": "^22",
"@typescript-eslint/parser": "^8",
"eslint": "^9",
"typescript": "^5",
"vitest": "^2"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from 'commander';
import { buildCoordCli } from '@mosaic/coord/dist/cli.js';
const COMMAND_NAME = 'coord';
export function registerCoordCommand(program: Command): void {
const coordCommand = buildCoordCli().commands.find((command) => command.name() === COMMAND_NAME);
if (coordCommand === undefined) {
throw new Error('Expected @mosaic/coord to expose a "coord" command.');
}
program.addCommand(coordCommand);
}

View File

@@ -0,0 +1,13 @@
import { Command } from 'commander';
import pc from 'picocolors';
// TODO(wave3): Replace this temporary shim once @mosaic/prdy lands in main.
export function registerPrdyCommand(program: Command): void {
program
.command('prdy')
.description('PRD workflow commands')
.action(() => {
console.error(pc.yellow('@mosaic/prdy CLI is not available in this workspace yet.'));
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,15 @@
import { Command } from 'commander';
import pc from 'picocolors';
// TODO(wave3): Replace this temporary shim once @mosaic/quality-rails lands in main.
export function registerQualityRailsCommand(program: Command): void {
program
.command('quality-rails')
.description('Quality rail commands')
.action(() => {
console.error(
pc.yellow('@mosaic/quality-rails CLI is not available in this workspace yet.'),
);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,15 @@
import { Command } from 'commander';
import { buildQueueCli } from '@mosaic/queue';
const COMMAND_NAME = 'queue';
export function registerQueueCommand(program: Command): void {
const queueCommand = buildQueueCli().commands.find((command) => command.name() === COMMAND_NAME);
if (queueCommand === undefined) {
throw new Error('Expected @mosaic/queue to expose a "queue" command.');
}
program.addCommand(queueCommand);
}

View File

@@ -0,0 +1 @@
export { rootCommand } from './root-command.js';

View File

@@ -0,0 +1,17 @@
import { Command } from 'commander';
import { registerCoordCommand } from './commands/coord.js';
import { registerPrdyCommand } from './commands/prdy.js';
import { registerQualityRailsCommand } from './commands/quality-rails.js';
import { registerQueueCommand } from './commands/queue.js';
import { VERSION } from './version.js';
export const rootCommand = new Command()
.name('mosaic')
.version(VERSION)
.description('Mosaic — AI agent orchestration platform');
registerCoordCommand(rootCommand);
registerPrdyCommand(rootCommand);
registerQueueCommand(rootCommand);
registerQualityRailsCommand(rootCommand);

View File

@@ -0,0 +1 @@
export const VERSION = '0.1.0';

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { rootCommand } from '../src/root-command.js';
describe('rootCommand', () => {
it('registers all top-level subcommand groups', () => {
const registeredSubcommands = rootCommand.commands
.map((command) => command.name())
.sort((left, right) => left.localeCompare(right));
expect(registeredSubcommands).toEqual([
'coord',
'prdy',
'quality-rails',
'queue',
]);
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "." },
"include": ["src", "bin"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "@mosaic/coord",
"version": "0.1.0",
"type": "module",
"description": "Mosaic mission coordination — TypeScript rewrite of mosaic coord",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "echo 'ok'",
"test": "vitest run"
},
"dependencies": {
"@mosaic/queue": "workspace:*",
"@mosaic/types": "workspace:*",
"commander": "^13",
"js-yaml": "^4"
},
"devDependencies": {
"@types/js-yaml": "^4",
"@types/node": "^22",
"typescript": "^5",
"vitest": "^2"
}
}

155
packages/coord/src/cli.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Command } from 'commander';
import { createMission, loadMission } from './mission.js';
import { runTask, resumeTask } from './runner.js';
import { getMissionStatus } from './status.js';
import type { MissionStatusSummary } from './types.js';
interface InitCommandOptions {
readonly name: string;
readonly project?: string;
readonly prefix?: string;
readonly milestones?: string;
readonly qualityGates?: string;
readonly version?: string;
readonly description?: string;
readonly force?: boolean;
}
interface RunCommandOptions {
readonly project: string;
readonly task: string;
readonly runtime?: 'claude' | 'codex';
readonly print?: boolean;
}
interface StatusCommandOptions {
readonly project: string;
readonly format?: 'json' | 'table';
}
function parseMilestones(value: string | undefined): string[] {
if (value === undefined || value.trim().length === 0) {
return [];
}
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function renderStatusTable(status: MissionStatusSummary): string {
const lines = [
`Mission: ${status.mission.name} (${status.mission.id})`,
`Status: ${status.mission.status}`,
`Project: ${status.mission.projectPath}`,
`Milestones: ${status.milestones.completed}/${status.milestones.total} completed`,
`Tasks: total=${status.tasks.total}, done=${status.tasks.done}, in-progress=${status.tasks.inProgress}, pending=${status.tasks.pending}, blocked=${status.tasks.blocked}, cancelled=${status.tasks.cancelled}`,
`Next task: ${status.nextTaskId ?? '—'}`,
`Active session: ${status.activeSession?.sessionId ?? 'none'}`,
];
return lines.join('\n');
}
export function buildCoordCli(): Command {
const program = new Command();
program
.name('mosaic')
.description('Mosaic CLI')
.exitOverride();
const coord = program.command('coord').description('Mission coordination commands');
coord
.command('init')
.description('Initialize orchestrator mission state')
.requiredOption('--name <name>', 'Mission name')
.option('--project <path>', 'Project path')
.option('--prefix <prefix>', 'Task prefix')
.option('--milestones <comma-separated>', 'Milestone names')
.option('--quality-gates <command>', 'Quality gate command')
.option('--version <semver>', 'Milestone version')
.option('--description <description>', 'Mission description')
.option('--force', 'Overwrite active mission')
.action(async (options: InitCommandOptions) => {
const mission = await createMission({
name: options.name,
projectPath: options.project,
prefix: options.prefix,
milestones: parseMilestones(options.milestones),
qualityGates: options.qualityGates,
version: options.version,
description: options.description,
force: options.force,
});
console.log(
JSON.stringify(
{
ok: true,
missionId: mission.id,
projectPath: mission.projectPath,
},
null,
2,
),
);
});
coord
.command('run')
.description('Run a mission task')
.requiredOption('--project <path>', 'Project path')
.requiredOption('--task <id>', 'Task id')
.option('--runtime <runtime>', 'Runtime (claude|codex)')
.option('--print', 'Print launch command only')
.action(async (options: RunCommandOptions) => {
const mission = await loadMission(options.project);
const run = await runTask(mission, options.task, {
runtime: options.runtime,
mode: options.print === true ? 'print-only' : 'interactive',
});
console.log(JSON.stringify(run, null, 2));
});
coord
.command('resume')
.description('Resume a mission task after stale/dead session lock')
.requiredOption('--project <path>', 'Project path')
.requiredOption('--task <id>', 'Task id')
.option('--runtime <runtime>', 'Runtime (claude|codex)')
.option('--print', 'Print launch command only')
.action(async (options: RunCommandOptions) => {
const mission = await loadMission(options.project);
const run = await resumeTask(mission, options.task, {
runtime: options.runtime,
mode: options.print === true ? 'print-only' : 'interactive',
});
console.log(JSON.stringify(run, null, 2));
});
coord
.command('status')
.description('Show mission status')
.requiredOption('--project <path>', 'Project path')
.option('--format <format>', 'Output format (table|json)', 'table')
.action(async (options: StatusCommandOptions) => {
const mission = await loadMission(options.project);
const status = await getMissionStatus(mission);
if (options.format === 'json') {
console.log(JSON.stringify(status, null, 2));
} else {
console.log(renderStatusTable(status));
}
});
return program;
}
export async function runCoordCli(argv: readonly string[] = process.argv): Promise<void> {
const program = buildCoordCli();
await program.parseAsync(argv);
}

View File

@@ -0,0 +1,34 @@
export {
createMission,
loadMission,
missionFilePath,
saveMission,
} from './mission.js';
export {
parseTasksFile,
updateTaskStatus,
writeTasksFile,
} from './tasks-file.js';
export { runTask, resumeTask } from './runner.js';
export { getMissionStatus, getTaskStatus } from './status.js';
export { buildCoordCli, runCoordCli } from './cli.js';
export type {
CreateMissionOptions,
Mission,
MissionMilestone,
MissionRuntime,
MissionSession,
MissionStatus,
MissionStatusSummary,
MissionTask,
NextTaskCapsule,
RunTaskOptions,
TaskDetail,
TaskRun,
TaskStatus,
} from './types.js';
export {
isMissionStatus,
isTaskStatus,
normalizeTaskStatus,
} from './types.js';

View File

@@ -0,0 +1,415 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { writeTasksFile } from './tasks-file.js';
import type {
CreateMissionOptions,
Mission,
MissionMilestone,
MissionSession,
} from './types.js';
import { isMissionStatus } from './types.js';
const DEFAULT_ORCHESTRATOR_DIR = '.mosaic/orchestrator';
const DEFAULT_MISSION_FILE = 'mission.json';
const DEFAULT_TASKS_FILE = 'docs/TASKS.md';
const DEFAULT_MANIFEST_FILE = 'docs/MISSION-MANIFEST.md';
const DEFAULT_SCRATCHPAD_DIR = 'docs/scratchpads';
const DEFAULT_MILESTONE_VERSION = '0.0.1';
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
return {};
}
function readString(
source: Record<string, unknown>,
...keys: readonly string[]
): string | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
function readNumber(
source: Record<string, unknown>,
...keys: readonly string[]
): number | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function normalizeMilestoneStatus(status: string | undefined): MissionMilestone['status'] {
if (status === 'completed') {
return 'completed';
}
if (status === 'in-progress') {
return 'in-progress';
}
if (status === 'blocked') {
return 'blocked';
}
return 'pending';
}
function normalizeSessionRuntime(runtime: string | undefined): MissionSession['runtime'] {
if (runtime === 'claude' || runtime === 'codex' || runtime === 'unknown') {
return runtime;
}
return 'unknown';
}
function normalizeEndedReason(reason: string | undefined): MissionSession['endedReason'] {
if (
reason === 'completed' ||
reason === 'paused' ||
reason === 'crashed' ||
reason === 'killed' ||
reason === 'unknown'
) {
return reason;
}
return undefined;
}
function normalizeMission(raw: unknown, resolvedProjectPath: string): Mission {
const source = asRecord(raw);
const id = readString(source, 'id', 'mission_id') ?? 'mission';
const name = readString(source, 'name') ?? 'Unnamed Mission';
const statusCandidate = readString(source, 'status') ?? 'inactive';
const status = isMissionStatus(statusCandidate) ? statusCandidate : 'inactive';
const mission: Mission = {
schemaVersion: 1,
id,
name,
description: readString(source, 'description'),
projectPath:
readString(source, 'projectPath', 'project_path') ?? resolvedProjectPath,
createdAt:
readString(source, 'createdAt', 'created_at') ?? new Date().toISOString(),
status,
tasksFile: readString(source, 'tasksFile', 'tasks_file') ?? DEFAULT_TASKS_FILE,
manifestFile:
readString(source, 'manifestFile', 'manifest_file') ?? DEFAULT_MANIFEST_FILE,
scratchpadFile:
readString(source, 'scratchpadFile', 'scratchpad_file') ??
`${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
orchestratorDir:
readString(source, 'orchestratorDir', 'orchestrator_dir') ??
DEFAULT_ORCHESTRATOR_DIR,
taskPrefix: readString(source, 'taskPrefix', 'task_prefix'),
qualityGates: readString(source, 'qualityGates', 'quality_gates'),
milestoneVersion: readString(source, 'milestoneVersion', 'milestone_version'),
milestones: [],
sessions: [],
};
const milestonesRaw = Array.isArray(source.milestones) ? source.milestones : [];
mission.milestones = milestonesRaw.map((milestoneValue, index) => {
const milestone = asRecord(milestoneValue);
return {
id: readString(milestone, 'id') ?? `phase-${index + 1}`,
name: readString(milestone, 'name') ?? `Phase ${index + 1}`,
status: normalizeMilestoneStatus(readString(milestone, 'status')),
branch: readString(milestone, 'branch'),
issueRef: readString(milestone, 'issueRef', 'issue_ref'),
startedAt: readString(milestone, 'startedAt', 'started_at'),
completedAt: readString(milestone, 'completedAt', 'completed_at'),
};
});
const sessionsRaw = Array.isArray(source.sessions) ? source.sessions : [];
mission.sessions = sessionsRaw.map((sessionValue, index) => {
const session = asRecord(sessionValue);
const fallbackSessionId = `sess-${String(index + 1).padStart(3, '0')}`;
return {
sessionId: readString(session, 'sessionId', 'session_id') ?? fallbackSessionId,
runtime: normalizeSessionRuntime(readString(session, 'runtime')),
pid: readNumber(session, 'pid'),
startedAt:
readString(session, 'startedAt', 'started_at') ?? mission.createdAt,
endedAt: readString(session, 'endedAt', 'ended_at'),
endedReason: normalizeEndedReason(
readString(session, 'endedReason', 'ended_reason'),
),
milestoneId: readString(session, 'milestoneId', 'milestone_id'),
lastTaskId: readString(session, 'lastTaskId', 'last_task_id'),
durationSeconds: readNumber(session, 'durationSeconds', 'duration_seconds'),
};
});
return mission;
}
function missionIdFromName(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `${slug || 'mission'}-${date}`;
}
function toAbsolutePath(basePath: string, targetPath: string): string {
if (path.isAbsolute(targetPath)) {
return targetPath;
}
return path.join(basePath, targetPath);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
return false;
}
throw error;
}
}
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
function renderManifest(mission: Mission): string {
const milestoneRows = mission.milestones
.map((milestone, index) => {
const issue = milestone.issueRef ?? '—';
const branch = milestone.branch ?? '—';
const started = milestone.startedAt ?? '—';
const completed = milestone.completedAt ?? '—';
return `| ${index + 1} | ${milestone.id} | ${milestone.name} | ${milestone.status} | ${branch} | ${issue} | ${started} | ${completed} |`;
})
.join('\n');
const body = [
`# Mission Manifest — ${mission.name}`,
'',
'> Persistent document tracking full mission scope, status, and session history.',
'',
'## Mission',
'',
`**ID:** ${mission.id}`,
`**Statement:** ${mission.description ?? ''}`,
'**Phase:** Intake',
'**Current Milestone:** —',
`**Progress:** 0 / ${mission.milestones.length} milestones`,
`**Status:** ${mission.status}`,
`**Last Updated:** ${new Date().toISOString().replace('T', ' ').replace(/\..+/, ' UTC')}`,
'',
'## Milestones',
'',
'| # | ID | Name | Status | Branch | Issue | Started | Completed |',
'|---|-----|------|--------|--------|-------|---------|-----------|',
milestoneRows,
'',
'## Session History',
'',
'| Session | Runtime | Started | Duration | Ended Reason | Last Task |',
'|---------|---------|---------|----------|--------------|-----------|',
'',
`## Scratchpad\n\nPath: \`${mission.scratchpadFile}\``,
'',
];
return body.join('\n');
}
function renderScratchpad(mission: Mission): string {
return [
`# Mission Scratchpad — ${mission.name}`,
'',
'> Append-only log. NEVER delete entries. NEVER overwrite sections.',
'',
'## Original Mission Prompt',
'',
'```',
'(Paste the mission prompt here on first session)',
'```',
'',
'## Planning Decisions',
'',
'## Session Log',
'',
'| Session | Date | Milestone | Tasks Done | Outcome |',
'|---------|------|-----------|------------|---------|',
'',
'## Open Questions',
'',
'## Corrections',
'',
].join('\n');
}
function buildMissionFromOptions(
options: CreateMissionOptions,
resolvedProjectPath: string,
): Mission {
const id = missionIdFromName(options.name);
const createdAt = new Date().toISOString();
const milestones = (options.milestones ?? []).map((name, index) => {
const cleanName = name.trim();
const milestoneName = cleanName.length > 0 ? cleanName : `Phase ${index + 1}`;
return {
id: `phase-${index + 1}`,
name: milestoneName,
status: 'pending' as const,
branch: milestoneName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''),
issueRef: undefined,
startedAt: undefined,
completedAt: undefined,
};
});
return {
schemaVersion: 1,
id,
name: options.name,
description: options.description,
projectPath: resolvedProjectPath,
createdAt,
status: 'active',
tasksFile: DEFAULT_TASKS_FILE,
manifestFile: DEFAULT_MANIFEST_FILE,
scratchpadFile: `${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
orchestratorDir: DEFAULT_ORCHESTRATOR_DIR,
taskPrefix: options.prefix,
qualityGates: options.qualityGates,
milestoneVersion: options.version ?? DEFAULT_MILESTONE_VERSION,
milestones,
sessions: [],
};
}
export function missionFilePath(projectPath: string, mission?: Mission): string {
const orchestratorDir = mission?.orchestratorDir ?? DEFAULT_ORCHESTRATOR_DIR;
const baseDir = path.isAbsolute(orchestratorDir)
? orchestratorDir
: path.join(projectPath, orchestratorDir);
return path.join(baseDir, DEFAULT_MISSION_FILE);
}
export async function saveMission(mission: Mission): Promise<void> {
const filePath = missionFilePath(mission.projectPath, mission);
const payload = `${JSON.stringify(mission, null, 2)}\n`;
await writeFileAtomic(filePath, payload);
}
export async function createMission(options: CreateMissionOptions): Promise<Mission> {
const name = options.name.trim();
if (name.length === 0) {
throw new Error('Mission name is required');
}
const resolvedProjectPath = path.resolve(options.projectPath ?? process.cwd());
const mission = buildMissionFromOptions({ ...options, name }, resolvedProjectPath);
const missionPath = missionFilePath(resolvedProjectPath, mission);
const hasExistingMission = await fileExists(missionPath);
if (hasExistingMission) {
const existingRaw = await fs.readFile(missionPath, 'utf8');
const existingMission = normalizeMission(JSON.parse(existingRaw), resolvedProjectPath);
const active = existingMission.status === 'active' || existingMission.status === 'paused';
if (active && options.force !== true) {
throw new Error(
`Active mission exists: ${existingMission.name} (${existingMission.status}). Use force to overwrite.`,
);
}
}
await saveMission(mission);
const manifestPath = toAbsolutePath(resolvedProjectPath, mission.manifestFile);
const scratchpadPath = toAbsolutePath(resolvedProjectPath, mission.scratchpadFile);
const tasksPath = toAbsolutePath(resolvedProjectPath, mission.tasksFile);
if (options.force === true || !(await fileExists(manifestPath))) {
await writeFileAtomic(manifestPath, renderManifest(mission));
}
if (!(await fileExists(scratchpadPath))) {
await writeFileAtomic(scratchpadPath, renderScratchpad(mission));
}
if (!(await fileExists(tasksPath))) {
await writeFileAtomic(tasksPath, writeTasksFile([]));
}
return mission;
}
export async function loadMission(projectPath: string): Promise<Mission> {
const resolvedProjectPath = path.resolve(projectPath);
const filePath = missionFilePath(resolvedProjectPath);
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
throw new Error(`No mission found at ${filePath}`);
}
throw error;
}
const mission = normalizeMission(JSON.parse(raw), resolvedProjectPath);
if (mission.status === 'inactive') {
throw new Error('Mission exists but is inactive. Re-initialize with mosaic coord init.');
}
return mission;
}

View File

@@ -0,0 +1,488 @@
import { spawn, spawnSync } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { loadMission, saveMission } from './mission.js';
import { parseTasksFile, updateTaskStatus } from './tasks-file.js';
import type {
Mission,
MissionMilestone,
MissionSession,
RunTaskOptions,
TaskRun,
} from './types.js';
const SESSION_LOCK_FILE = 'session.lock';
const NEXT_TASK_FILE = 'next-task.json';
interface SessionLockState {
session_id: string;
runtime: 'claude' | 'codex';
pid: number;
started_at: string;
project_path: string;
milestone_id?: string;
}
function orchestratorDirPath(mission: Mission): string {
if (path.isAbsolute(mission.orchestratorDir)) {
return mission.orchestratorDir;
}
return path.join(mission.projectPath, mission.orchestratorDir);
}
function sessionLockPath(mission: Mission): string {
return path.join(orchestratorDirPath(mission), SESSION_LOCK_FILE);
}
function nextTaskCapsulePath(mission: Mission): string {
return path.join(orchestratorDirPath(mission), NEXT_TASK_FILE);
}
function tasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function buildSessionId(mission: Mission): string {
return `sess-${String(mission.sessions.length + 1).padStart(3, '0')}`;
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function currentMilestone(mission: Mission): MissionMilestone | undefined {
return mission.milestones.find((milestone) => milestone.status === 'in-progress')
?? mission.milestones.find((milestone) => milestone.status === 'pending');
}
async function readTasks(mission: Mission) {
const filePath = tasksFilePath(mission);
try {
const content = await fs.readFile(filePath, 'utf8');
return parseTasksFile(content);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return [];
}
throw error;
}
}
function currentBranch(projectPath: string): string | undefined {
const result = spawnSync('git', ['-C', projectPath, 'branch', '--show-current'], {
encoding: 'utf8',
});
if (result.status !== 0) {
return undefined;
}
const branch = result.stdout.trim();
return branch.length > 0 ? branch : undefined;
}
function percentage(done: number, total: number): number {
if (total === 0) {
return 0;
}
return Math.floor((done / total) * 100);
}
function formatDurationSeconds(totalSeconds: number): string {
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
function buildContinuationPrompt(params: {
mission: Mission;
taskId: string;
runtime: 'claude' | 'codex';
tasksDone: number;
tasksTotal: number;
currentMilestone?: MissionMilestone;
previousSession?: MissionSession;
branch?: string;
}): string {
const {
mission,
taskId,
runtime,
tasksDone,
tasksTotal,
currentMilestone,
previousSession,
branch,
} = params;
const pct = percentage(tasksDone, tasksTotal);
const previousDuration =
previousSession?.durationSeconds !== undefined
? formatDurationSeconds(previousSession.durationSeconds)
: '—';
return [
'## Continuation Mission',
'',
`Continue **${mission.name}** from existing state.`,
'',
'## Setup',
'',
`- **Project:** ${mission.projectPath}`,
`- **State:** ${mission.tasksFile} (${tasksDone}/${tasksTotal} tasks complete)`,
`- **Manifest:** ${mission.manifestFile}`,
`- **Scratchpad:** ${mission.scratchpadFile}`,
'- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md',
`- **Quality gates:** ${mission.qualityGates ?? '—'}`,
`- **Target runtime:** ${runtime}`,
'',
'## Resume Point',
'',
`- **Current milestone:** ${currentMilestone?.name ?? '—'} (${currentMilestone?.id ?? '—'})`,
`- **Next task:** ${taskId}`,
`- **Progress:** ${tasksDone}/${tasksTotal} (${pct}%)`,
`- **Branch:** ${branch ?? '—'}`,
'',
'## Previous Session Context',
'',
`- **Session:** ${previousSession?.sessionId ?? '—'} (${previousSession?.runtime ?? '—'}, ${previousDuration})`,
`- **Ended:** ${previousSession?.endedReason ?? '—'}`,
`- **Last completed task:** ${previousSession?.lastTaskId ?? '—'}`,
'',
'## Instructions',
'',
'1. Read `~/.config/mosaic/guides/ORCHESTRATOR.md` for full protocol',
`2. Read \`${mission.manifestFile}\` for mission scope and status`,
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
`4. Read \`${mission.tasksFile}\` for current task state`,
'5. `git pull --rebase` to sync latest changes',
`6. Launch runtime with \`${runtime} -p\``,
`7. Continue execution from task **${taskId}**`,
'8. Follow Two-Phase Completion Protocol',
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
].join('\n');
}
function resolveLaunchCommand(
runtime: 'claude' | 'codex',
prompt: string,
configuredCommand: string[] | undefined,
): string[] {
if (configuredCommand === undefined || configuredCommand.length === 0) {
return [runtime, '-p', prompt];
}
const withInterpolation = configuredCommand.map((value) =>
value === '{prompt}' ? prompt : value,
);
if (withInterpolation.includes(prompt)) {
return withInterpolation;
}
return [...withInterpolation, prompt];
}
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
await fs.rename(tempPath, filePath);
}
async function readSessionLock(mission: Mission): Promise<SessionLockState | undefined> {
const filePath = sessionLockPath(mission);
try {
const raw = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(raw) as Partial<SessionLockState>;
if (
typeof data.session_id !== 'string' ||
(data.runtime !== 'claude' && data.runtime !== 'codex') ||
typeof data.pid !== 'number' ||
typeof data.started_at !== 'string' ||
typeof data.project_path !== 'string'
) {
return undefined;
}
return {
session_id: data.session_id,
runtime: data.runtime,
pid: data.pid,
started_at: data.started_at,
project_path: data.project_path,
milestone_id: data.milestone_id,
};
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
}
async function writeSessionLock(
mission: Mission,
lock: SessionLockState,
): Promise<void> {
await writeAtomicJson(sessionLockPath(mission), lock);
}
function markSessionCrashed(
mission: Mission,
sessionId: string,
endedAt: string,
): Mission {
const sessions = mission.sessions.map((session) => {
if (session.sessionId !== sessionId) {
return session;
}
if (session.endedAt !== undefined) {
return session;
}
const startedEpoch = Date.parse(session.startedAt);
const endedEpoch = Date.parse(endedAt);
const durationSeconds =
Number.isFinite(startedEpoch) && Number.isFinite(endedEpoch)
? Math.max(0, Math.floor((endedEpoch - startedEpoch) / 1000))
: undefined;
return {
...session,
endedAt,
endedReason: 'crashed' as const,
durationSeconds,
};
});
return {
...mission,
sessions,
};
}
export async function runTask(
mission: Mission,
taskId: string,
options: RunTaskOptions = {},
): Promise<TaskRun> {
const runtime = options.runtime ?? 'claude';
const mode = options.mode ?? 'interactive';
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const matches = tasks.filter((task) => task.id === taskId);
if (matches.length === 0) {
throw new Error(`Task not found: ${taskId}`);
}
if (matches.length > 1) {
throw new Error(`Duplicate task IDs found: ${taskId}`);
}
const task = matches[0];
if (task.status === 'done' || task.status === 'cancelled') {
throw new Error(`Task ${taskId} cannot be run from status ${task.status}`);
}
const tasksTotal = tasks.length;
const tasksDone = tasks.filter((candidate) => candidate.status === 'done').length;
const selectedMilestone =
freshMission.milestones.find((milestone) => milestone.id === options.milestoneId)
?? freshMission.milestones.find((milestone) => milestone.id === task.milestone)
?? currentMilestone(freshMission);
const continuationPrompt = buildContinuationPrompt({
mission: freshMission,
taskId,
runtime,
tasksDone,
tasksTotal,
currentMilestone: selectedMilestone,
previousSession: freshMission.sessions.at(-1),
branch: currentBranch(freshMission.projectPath),
});
const launchCommand = resolveLaunchCommand(runtime, continuationPrompt, options.command);
const startedAt = new Date().toISOString();
const sessionId = buildSessionId(freshMission);
const lockFile = sessionLockPath(freshMission);
await writeAtomicJson(nextTaskCapsulePath(freshMission), {
generated_at: startedAt,
runtime,
mission_id: freshMission.id,
mission_name: freshMission.name,
project_path: freshMission.projectPath,
quality_gates: freshMission.qualityGates ?? '',
current_milestone: {
id: selectedMilestone?.id ?? '',
name: selectedMilestone?.name ?? '',
},
next_task: taskId,
progress: {
tasks_done: tasksDone,
tasks_total: tasksTotal,
pct: percentage(tasksDone, tasksTotal),
},
current_branch: currentBranch(freshMission.projectPath) ?? '',
});
if (mode === 'print-only') {
return {
missionId: freshMission.id,
taskId,
sessionId,
runtime,
launchCommand,
startedAt,
lockFile,
};
}
await updateTaskStatus(freshMission, taskId, 'in-progress');
await writeSessionLock(freshMission, {
session_id: sessionId,
runtime,
pid: 0,
started_at: startedAt,
project_path: freshMission.projectPath,
milestone_id: selectedMilestone?.id,
});
const child = spawn(launchCommand[0], launchCommand.slice(1), {
cwd: freshMission.projectPath,
env: {
...process.env,
...(options.env ?? {}),
},
stdio: 'inherit',
});
await new Promise<void>((resolve, reject) => {
child.once('spawn', () => {
resolve();
});
child.once('error', (error) => {
reject(error);
});
});
const pid = child.pid;
if (pid === undefined) {
throw new Error('Failed to start task runtime process (pid missing)');
}
await writeSessionLock(freshMission, {
session_id: sessionId,
runtime,
pid,
started_at: startedAt,
project_path: freshMission.projectPath,
milestone_id: selectedMilestone?.id,
});
const updatedMission: Mission = {
...freshMission,
status: 'active',
sessions: [
...freshMission.sessions,
{
sessionId,
runtime,
pid,
startedAt,
milestoneId: selectedMilestone?.id,
lastTaskId: taskId,
},
],
};
await saveMission(updatedMission);
return {
missionId: updatedMission.id,
taskId,
sessionId,
runtime,
launchCommand,
startedAt,
pid,
lockFile,
};
}
export async function resumeTask(
mission: Mission,
taskId: string,
options: Omit<RunTaskOptions, 'milestoneId'> = {},
): Promise<TaskRun> {
const freshMission = await loadMission(mission.projectPath);
const lock = await readSessionLock(freshMission);
if (lock !== undefined && lock.pid > 0 && isPidAlive(lock.pid)) {
throw new Error(
`Session ${lock.session_id} is still running (PID ${lock.pid}).`,
);
}
let nextMissionState = freshMission;
if (lock !== undefined) {
const endedAt = new Date().toISOString();
nextMissionState = markSessionCrashed(freshMission, lock.session_id, endedAt);
await saveMission(nextMissionState);
await fs.rm(sessionLockPath(nextMissionState), { force: true });
}
return runTask(nextMissionState, taskId, options);
}

View File

@@ -0,0 +1,183 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { loadMission } from './mission.js';
import { parseTasksFile } from './tasks-file.js';
import type {
Mission,
MissionSession,
MissionStatusSummary,
MissionTask,
TaskDetail,
} from './types.js';
const SESSION_LOCK_FILE = 'session.lock';
interface SessionLockState {
session_id?: string;
runtime?: string;
pid?: number;
started_at?: string;
milestone_id?: string;
}
function tasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function sessionLockPath(mission: Mission): string {
const orchestratorDir = path.isAbsolute(mission.orchestratorDir)
? mission.orchestratorDir
: path.join(mission.projectPath, mission.orchestratorDir);
return path.join(orchestratorDir, SESSION_LOCK_FILE);
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readTasks(mission: Mission): Promise<MissionTask[]> {
try {
const content = await fs.readFile(tasksFilePath(mission), 'utf8');
return parseTasksFile(content);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return [];
}
throw error;
}
}
async function readActiveSession(mission: Mission): Promise<MissionSession | undefined> {
let lockRaw: string;
try {
lockRaw = await fs.readFile(sessionLockPath(mission), 'utf8');
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
const lock = JSON.parse(lockRaw) as SessionLockState;
if (
typeof lock.session_id !== 'string' ||
(lock.runtime !== 'claude' && lock.runtime !== 'codex') ||
typeof lock.started_at !== 'string'
) {
return undefined;
}
const pid = typeof lock.pid === 'number' ? lock.pid : undefined;
if (pid !== undefined && pid > 0 && !isPidAlive(pid)) {
return undefined;
}
const existingSession = mission.sessions.find(
(session) => session.sessionId === lock.session_id,
);
if (existingSession !== undefined) {
return existingSession;
}
return {
sessionId: lock.session_id,
runtime: lock.runtime,
pid,
startedAt: lock.started_at,
milestoneId: lock.milestone_id,
};
}
export async function getMissionStatus(mission: Mission): Promise<MissionStatusSummary> {
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const done = tasks.filter((task) => task.status === 'done').length;
const inProgress = tasks.filter((task) => task.status === 'in-progress').length;
const pending = tasks.filter((task) => task.status === 'not-started').length;
const blocked = tasks.filter((task) => task.status === 'blocked').length;
const cancelled = tasks.filter((task) => task.status === 'cancelled').length;
const nextTask = tasks.find((task) => task.status === 'not-started');
const completedMilestones = freshMission.milestones.filter(
(milestone) => milestone.status === 'completed',
).length;
const currentMilestone =
freshMission.milestones.find((milestone) => milestone.status === 'in-progress')
?? freshMission.milestones.find((milestone) => milestone.status === 'pending');
const activeSession = await readActiveSession(freshMission);
return {
mission: {
id: freshMission.id,
name: freshMission.name,
status: freshMission.status,
projectPath: freshMission.projectPath,
},
milestones: {
total: freshMission.milestones.length,
completed: completedMilestones,
current: currentMilestone,
},
tasks: {
total: tasks.length,
done,
inProgress,
pending,
blocked,
cancelled,
},
nextTaskId: nextTask?.id,
activeSession,
};
}
export async function getTaskStatus(
mission: Mission,
taskId: string,
): Promise<TaskDetail> {
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const matches = tasks.filter((task) => task.id === taskId);
if (matches.length === 0) {
throw new Error(`Task not found: ${taskId}`);
}
if (matches.length > 1) {
throw new Error(`Duplicate task IDs found: ${taskId}`);
}
const summary = await getMissionStatus(freshMission);
return {
missionId: freshMission.id,
task: matches[0],
isNextTask: summary.nextTaskId === taskId,
activeSession: summary.activeSession,
};
}

View File

@@ -0,0 +1,378 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Mission, MissionTask, TaskStatus } from './types.js';
import { normalizeTaskStatus } from './types.js';
const TASKS_LOCK_FILE = '.TASKS.md.lock';
const TASKS_LOCK_STALE_MS = 5 * 60 * 1000;
const TASKS_LOCK_WAIT_MS = 5 * 1000;
const TASKS_LOCK_RETRY_MS = 100;
const DEFAULT_TABLE_HEADER = [
'| id | status | milestone | description | pr | notes |',
'|----|--------|-----------|-------------|----|-------|',
] as const;
const DEFAULT_TASKS_PREAMBLE = [
'# Tasks',
'',
'> Single-writer: orchestrator only. Workers read but never modify.',
'',
...DEFAULT_TABLE_HEADER,
] as const;
interface ParsedTableRow {
readonly lineIndex: number;
readonly cells: string[];
}
interface ParsedTable {
readonly headerLineIndex: number;
readonly separatorLineIndex: number;
readonly headers: string[];
readonly rows: ParsedTableRow[];
readonly idColumn: number;
readonly statusColumn: number;
}
function normalizeHeaderName(input: string): string {
return input.trim().toLowerCase();
}
function splitMarkdownRow(line: string): string[] {
const trimmed = line.trim();
if (!trimmed.startsWith('|')) {
return [];
}
const parts = trimmed.split(/(?<!\\)\|/);
if (parts.length < 3) {
return [];
}
return parts.slice(1, -1).map((part) => part.trim().replace(/\\\|/g, '|'));
}
function isSeparatorRow(cells: readonly string[]): boolean {
return (
cells.length > 0 &&
cells.every((cell) => {
const value = cell.trim();
return /^:?-{3,}:?$/.test(value);
})
);
}
function parseTable(content: string): ParsedTable | undefined {
const lines = content.split(/\r?\n/);
let headerLineIndex = -1;
let separatorLineIndex = -1;
let headers: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const cells = splitMarkdownRow(lines[index]);
if (cells.length === 0) {
continue;
}
const normalized = cells.map(normalizeHeaderName);
if (!normalized.includes('id') || !normalized.includes('status')) {
continue;
}
if (index + 1 >= lines.length) {
continue;
}
const separatorCells = splitMarkdownRow(lines[index + 1]);
if (!isSeparatorRow(separatorCells)) {
continue;
}
headerLineIndex = index;
separatorLineIndex = index + 1;
headers = normalized;
break;
}
if (headerLineIndex < 0 || separatorLineIndex < 0) {
return undefined;
}
const idColumn = headers.indexOf('id');
const statusColumn = headers.indexOf('status');
if (idColumn < 0 || statusColumn < 0) {
return undefined;
}
const rows: ParsedTableRow[] = [];
let sawData = false;
for (let index = separatorLineIndex + 1; index < lines.length; index += 1) {
const rawLine = lines[index];
const trimmed = rawLine.trim();
if (!trimmed.startsWith('|')) {
if (sawData) {
break;
}
continue;
}
const cells = splitMarkdownRow(rawLine);
if (cells.length === 0) {
if (sawData) {
break;
}
continue;
}
sawData = true;
const normalizedRow = [...cells];
while (normalizedRow.length < headers.length) {
normalizedRow.push('');
}
rows.push({ lineIndex: index, cells: normalizedRow });
}
return {
headerLineIndex,
separatorLineIndex,
headers,
rows,
idColumn,
statusColumn,
};
}
function escapeTableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
}
function formatTableRow(cells: readonly string[]): string {
const escaped = cells.map((cell) => escapeTableCell(cell));
return `| ${escaped.join(' | ')} |`;
}
function parseDependencies(raw: string | undefined): string[] {
if (raw === undefined || raw.trim().length === 0) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function resolveTasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function acquireLock(lockPath: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < TASKS_LOCK_WAIT_MS) {
try {
const handle = await fs.open(lockPath, 'wx');
await handle.writeFile(
JSON.stringify(
{
pid: process.pid,
acquiredAt: new Date().toISOString(),
},
null,
2,
),
);
await handle.close();
return;
} catch (error) {
if (!isNodeErrorWithCode(error, 'EEXIST')) {
throw error;
}
try {
const stats = await fs.stat(lockPath);
if (Date.now() - stats.mtimeMs > TASKS_LOCK_STALE_MS) {
await fs.rm(lockPath, { force: true });
continue;
}
} catch (statError) {
if (!isNodeErrorWithCode(statError, 'ENOENT')) {
throw statError;
}
}
await delay(TASKS_LOCK_RETRY_MS);
}
}
throw new Error(`Timed out acquiring TASKS lock: ${lockPath}`);
}
async function releaseLock(lockPath: string): Promise<void> {
await fs.rm(lockPath, { force: true });
}
async function writeAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
const tempPath = path.join(
directory,
`.TASKS.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
export function parseTasksFile(content: string): MissionTask[] {
const parsedTable = parseTable(content);
if (parsedTable === undefined) {
return [];
}
const headerToColumn = new Map<string, number>();
parsedTable.headers.forEach((header, index) => {
headerToColumn.set(header, index);
});
const descriptionColumn =
headerToColumn.get('description') ?? headerToColumn.get('title') ?? -1;
const milestoneColumn = headerToColumn.get('milestone') ?? -1;
const prColumn = headerToColumn.get('pr') ?? -1;
const notesColumn = headerToColumn.get('notes') ?? -1;
const assigneeColumn = headerToColumn.get('assignee') ?? -1;
const dependenciesColumn = headerToColumn.get('dependencies') ?? -1;
const tasks: MissionTask[] = [];
for (const row of parsedTable.rows) {
const id = row.cells[parsedTable.idColumn]?.trim();
if (id === undefined || id.length === 0) {
continue;
}
const rawStatusValue = row.cells[parsedTable.statusColumn] ?? '';
const normalized = normalizeTaskStatus(rawStatusValue);
const title = descriptionColumn >= 0 ? row.cells[descriptionColumn] ?? '' : '';
const milestone = milestoneColumn >= 0 ? row.cells[milestoneColumn] ?? '' : '';
const pr = prColumn >= 0 ? row.cells[prColumn] ?? '' : '';
const notes = notesColumn >= 0 ? row.cells[notesColumn] ?? '' : '';
const assignee = assigneeColumn >= 0 ? row.cells[assigneeColumn] ?? '' : '';
const dependenciesRaw =
dependenciesColumn >= 0 ? row.cells[dependenciesColumn] ?? '' : '';
tasks.push({
id,
title,
status: normalized.status,
dependencies: parseDependencies(dependenciesRaw),
milestone: milestone.length > 0 ? milestone : undefined,
pr: pr.length > 0 ? pr : undefined,
notes: notes.length > 0 ? notes : undefined,
assignee: assignee.length > 0 ? assignee : undefined,
rawStatus: normalized.rawStatus,
line: row.lineIndex + 1,
});
}
return tasks;
}
export function writeTasksFile(tasks: MissionTask[]): string {
const lines: string[] = [...DEFAULT_TASKS_PREAMBLE];
for (const task of tasks) {
lines.push(
formatTableRow([
task.id,
task.status,
task.milestone ?? '',
task.title,
task.pr ?? '',
task.notes ?? '',
]),
);
}
return `${lines.join('\n')}\n`;
}
export async function updateTaskStatus(
mission: Mission,
taskId: string,
status: TaskStatus,
): Promise<void> {
const tasksFilePath = resolveTasksFilePath(mission);
const lockPath = path.join(path.dirname(tasksFilePath), TASKS_LOCK_FILE);
await fs.mkdir(path.dirname(tasksFilePath), { recursive: true });
await acquireLock(lockPath);
try {
let content: string;
try {
content = await fs.readFile(tasksFilePath, 'utf8');
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
throw new Error(`TASKS file not found: ${tasksFilePath}`);
}
throw error;
}
const table = parseTable(content);
if (table === undefined) {
throw new Error(`Could not parse TASKS table in ${tasksFilePath}`);
}
const matchingRows = table.rows.filter((row) => {
const rowTaskId = row.cells[table.idColumn]?.trim();
return rowTaskId === taskId;
});
if (matchingRows.length === 0) {
throw new Error(`Task not found in TASKS.md: ${taskId}`);
}
if (matchingRows.length > 1) {
throw new Error(`Duplicate task IDs found in TASKS.md: ${taskId}`);
}
const targetRow = matchingRows[0];
const updatedCells = [...targetRow.cells];
updatedCells[table.statusColumn] = status;
const lines = content.split(/\r?\n/);
lines[targetRow.lineIndex] = formatTableRow(updatedCells);
const updatedContent = `${lines.join('\n').replace(/\n+$/, '')}\n`;
await writeAtomic(tasksFilePath, updatedContent);
} finally {
await releaseLock(lockPath);
}
}

194
packages/coord/src/types.ts Normal file
View File

@@ -0,0 +1,194 @@
export type TaskStatus =
| 'not-started'
| 'in-progress'
| 'done'
| 'blocked'
| 'cancelled';
export type MissionStatus = 'active' | 'paused' | 'completed' | 'inactive';
export type MissionRuntime = 'claude' | 'codex' | 'unknown';
export interface MissionMilestone {
id: string;
name: string;
status: 'pending' | 'in-progress' | 'completed' | 'blocked';
branch?: string;
issueRef?: string;
startedAt?: string;
completedAt?: string;
}
export interface MissionSession {
sessionId: string;
runtime: MissionRuntime;
pid?: number;
startedAt: string;
endedAt?: string;
endedReason?: 'completed' | 'paused' | 'crashed' | 'killed' | 'unknown';
milestoneId?: string;
lastTaskId?: string;
durationSeconds?: number;
}
export interface Mission {
schemaVersion: 1;
id: string;
name: string;
description?: string;
projectPath: string;
createdAt: string;
status: MissionStatus;
tasksFile: string;
manifestFile: string;
scratchpadFile: string;
orchestratorDir: string;
taskPrefix?: string;
qualityGates?: string;
milestoneVersion?: string;
milestones: MissionMilestone[];
sessions: MissionSession[];
}
export interface MissionTask {
id: string;
title: string;
status: TaskStatus;
assignee?: string;
dependencies: string[];
milestone?: string;
pr?: string;
notes?: string;
rawStatus?: string;
line?: number;
}
export interface TaskRun {
missionId: string;
taskId: string;
sessionId: string;
runtime: 'claude' | 'codex';
launchCommand: string[];
startedAt: string;
pid?: number;
lockFile: string;
}
export interface MissionStatusSummary {
mission: Pick<Mission, 'id' | 'name' | 'status' | 'projectPath'>;
milestones: {
total: number;
completed: number;
current?: MissionMilestone;
};
tasks: {
total: number;
done: number;
inProgress: number;
pending: number;
blocked: number;
cancelled: number;
};
nextTaskId?: string;
activeSession?: MissionSession;
}
export interface TaskDetail {
missionId: string;
task: MissionTask;
isNextTask: boolean;
activeSession?: MissionSession;
}
export interface CreateMissionOptions {
name: string;
projectPath?: string;
prefix?: string;
milestones?: string[];
qualityGates?: string;
version?: string;
description?: string;
force?: boolean;
}
export interface RunTaskOptions {
runtime?: 'claude' | 'codex';
mode?: 'interactive' | 'print-only';
milestoneId?: string;
launchStrategy?: 'subprocess' | 'spawn-adapter';
env?: Record<string, string>;
command?: string[];
}
export interface NextTaskCapsule {
generatedAt: string;
runtime: 'claude' | 'codex';
missionId: string;
missionName: string;
projectPath: string;
qualityGates?: string;
currentMilestone: {
id?: string;
name?: string;
};
nextTask: string;
progress: {
tasksDone: number;
tasksTotal: number;
pct: number;
};
currentBranch?: string;
}
const LEGACY_TASK_STATUS: Readonly<Record<string, TaskStatus>> = {
'not-started': 'not-started',
pending: 'not-started',
todo: 'not-started',
'in-progress': 'in-progress',
in_progress: 'in-progress',
done: 'done',
completed: 'done',
blocked: 'blocked',
failed: 'blocked',
cancelled: 'cancelled',
};
export function normalizeTaskStatus(input: string): {
status: TaskStatus;
rawStatus?: string;
} {
const raw = input.trim().toLowerCase();
if (raw.length === 0) {
return { status: 'not-started' };
}
const normalized = LEGACY_TASK_STATUS[raw];
if (normalized === undefined) {
return { status: 'not-started', rawStatus: raw };
}
if (raw !== normalized) {
return { status: normalized, rawStatus: raw };
}
return { status: normalized };
}
export function isMissionStatus(value: string): value is MissionStatus {
return (
value === 'active' ||
value === 'paused' ||
value === 'completed' ||
value === 'inactive'
);
}
export function isTaskStatus(value: string): value is TaskStatus {
return (
value === 'not-started' ||
value === 'in-progress' ||
value === 'done' ||
value === 'blocked' ||
value === 'cancelled'
);
}

View File

@@ -0,0 +1,64 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createMission, loadMission, missionFilePath } from '../src/mission.js';
describe('mission lifecycle', () => {
it('creates and loads mission state files', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coord-mission-'));
try {
const mission = await createMission({
name: 'Wave 3 Mission',
projectPath: projectDir,
milestones: ['Phase One', 'Phase Two'],
qualityGates: 'pnpm lint && pnpm typecheck && pnpm test',
description: 'Wave 3 implementation',
});
expect(mission.id).toMatch(/^wave-3-mission-\d{8}$/);
expect(mission.status).toBe('active');
expect(mission.milestones).toHaveLength(2);
await expect(fs.stat(missionFilePath(projectDir, mission))).resolves.toBeDefined();
await expect(fs.stat(path.join(projectDir, 'docs/TASKS.md'))).resolves.toBeDefined();
await expect(
fs.stat(path.join(projectDir, '.mosaic/orchestrator/mission.json')),
).resolves.toBeDefined();
const loaded = await loadMission(projectDir);
expect(loaded.id).toBe(mission.id);
expect(loaded.name).toBe('Wave 3 Mission');
expect(loaded.qualityGates).toBe('pnpm lint && pnpm typecheck && pnpm test');
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects inactive missions on load', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coord-mission-inactive-'));
try {
const mission = await createMission({
name: 'Inactive Mission',
projectPath: projectDir,
});
const missionPath = missionFilePath(projectDir, mission);
const payload = JSON.parse(await fs.readFile(missionPath, 'utf8')) as {
status: string;
};
payload.status = 'inactive';
await fs.writeFile(missionPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
await expect(loadMission(projectDir)).rejects.toThrow(
'Mission exists but is inactive',
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { parseTasksFile, writeTasksFile } from '../src/tasks-file.js';
import type { MissionTask } from '../src/types.js';
describe('parseTasksFile', () => {
it('normalizes legacy statuses from TASKS.md', () => {
const content = [
'# Tasks — Demo',
'',
'| id | status | milestone | description | pr | notes |',
'|----|--------|-----------|-------------|----|-------|',
'| T-1 | pending | phase-1 | First task | #10 | note a |',
'| T-2 | completed | phase-1 | Second task | #11 | note b |',
'| T-3 | in_progress | phase-2 | Third task | | |',
'| T-4 | failed | phase-2 | Fourth task | | |',
'',
'trailing text ignored',
].join('\n');
const tasks = parseTasksFile(content);
expect(tasks).toHaveLength(4);
expect(tasks.map((task) => task.status)).toEqual([
'not-started',
'done',
'in-progress',
'blocked',
]);
expect(tasks.map((task) => task.rawStatus)).toEqual([
'pending',
'completed',
'in_progress',
'failed',
]);
expect(tasks[0]?.line).toBe(5);
});
});
describe('writeTasksFile', () => {
it('round-trips parse/write output', () => {
const tasks: MissionTask[] = [
{
id: 'W3-001',
title: 'Implement parser',
status: 'not-started',
milestone: 'phase-1',
pr: '#20',
notes: 'pending',
dependencies: [],
},
{
id: 'W3-002',
title: 'Implement runner',
status: 'in-progress',
milestone: 'phase-2',
notes: 'active',
dependencies: [],
},
];
const content = writeTasksFile(tasks);
const reparsed = parseTasksFile(content);
expect(reparsed).toHaveLength(2);
expect(reparsed.map((task) => task.id)).toEqual(['W3-001', 'W3-002']);
expect(reparsed.map((task) => task.status)).toEqual([
'not-started',
'in-progress',
]);
expect(reparsed[0]?.title).toBe('Implement parser');
expect(reparsed[1]?.milestone).toBe('phase-2');
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

739
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff