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 { if (typeof value === 'object' && value !== null) { return value as Record; } return {}; } function readString( source: Record, ...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, ...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: unknown, index: number): MissionMilestone => { 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: unknown, index: number): MissionSession => { 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 { 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 { 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 milestones = (options.milestones ?? []).map((name, index): MissionMilestone => { 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, ''), }; }); return { schemaVersion: 1, id, name: options.name, description: options.description, projectPath: resolvedProjectPath, createdAt: new Date().toISOString(), 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 { 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 { 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 { 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; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in mission file: ${filePath}`); } const mission = normalizeMission(parsed, resolvedProjectPath); if (mission.status === 'inactive') { throw new Error('Mission exists but is inactive. Re-initialize with mosaic coord init.'); } return mission; }