feat: @mosaic/coord — migrate from v0, gateway integration (P2-005) (#77)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #77.
This commit is contained in:
388
packages/coord/src/mission.ts
Normal file
388
packages/coord/src/mission.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
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: 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<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 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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user