Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
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;
|
|
}
|
|
|
|
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;
|
|
}
|