From 372472c5fc73b7e35022ed5c1b8d9c1fdbb26348 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Mar 2026 22:31:19 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20@mosaic/coord=20=E2=80=94=20migrate=20f?= =?UTF-8?q?rom=20v0,=20gateway=20integration=20(P2-005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the orchestration coordination package from v0 with full file-based mission/task lifecycle management. Adds gateway REST API at /api/coord/* and agent tools for coord queries. Package modules: - types: Mission, task, session, milestone type definitions - mission: Create, load, save missions with atomic file writes - tasks-file: TASKS.md markdown table parsing/generation with file locking - runner: Task execution subprocess spawning with session locks - status: Mission and task status queries Gateway integration: - CoordModule with CoordService wrapping coord functions - CoordController exposing status/tasks/task-detail endpoints - Agent tools: coord_mission_status, coord_list_tasks, coord_task_detail Co-Authored-By: Claude Opus 4.6 --- apps/gateway/package.json | 1 + apps/gateway/src/agent/agent.module.ts | 2 + apps/gateway/src/agent/agent.service.ts | 5 +- apps/gateway/src/agent/tools/coord-tools.ts | 81 ++++ apps/gateway/src/agent/tools/index.ts | 1 + apps/gateway/src/app.module.ts | 2 + apps/gateway/src/coord/coord.controller.ts | 31 ++ apps/gateway/src/coord/coord.dto.ts | 49 +++ apps/gateway/src/coord/coord.module.ts | 10 + apps/gateway/src/coord/coord.service.ts | 73 +++ docs/TASKS.md | 10 +- packages/coord/package.json | 1 + packages/coord/src/index.ts | 21 +- packages/coord/src/mission.ts | 388 ++++++++++++++++ packages/coord/src/runner.ts | 464 ++++++++++++++++++++ packages/coord/src/status.ts | 175 ++++++++ packages/coord/src/tasks-file.ts | 376 ++++++++++++++++ packages/coord/src/types.ts | 184 ++++++++ pnpm-lock.yaml | 6 + 19 files changed, 1873 insertions(+), 7 deletions(-) create mode 100644 apps/gateway/src/agent/tools/coord-tools.ts create mode 100644 apps/gateway/src/coord/coord.controller.ts create mode 100644 apps/gateway/src/coord/coord.dto.ts create mode 100644 apps/gateway/src/coord/coord.module.ts create mode 100644 apps/gateway/src/coord/coord.service.ts create mode 100644 packages/coord/src/mission.ts create mode 100644 packages/coord/src/runner.ts create mode 100644 packages/coord/src/status.ts create mode 100644 packages/coord/src/tasks-file.ts create mode 100644 packages/coord/src/types.ts diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 3fefc16..ff696d2 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -16,6 +16,7 @@ "@mariozechner/pi-coding-agent": "~0.57.1", "@mosaic/auth": "workspace:^", "@mosaic/brain": "workspace:^", + "@mosaic/coord": "workspace:^", "@mosaic/db": "workspace:^", "@mosaic/types": "workspace:^", "@nestjs/common": "^11.0.0", diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 9c28cc4..ded8c0c 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -3,9 +3,11 @@ import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { RoutingService } from './routing.service.js'; import { ProvidersController } from './providers.controller.js'; +import { CoordModule } from '../coord/coord.module.js'; @Global() @Module({ + imports: [CoordModule], providers: [ProviderService, RoutingService, AgentService], controllers: [ProvidersController], exports: [AgentService, ProviderService, RoutingService], diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index bedddcd..fd78aee 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -8,8 +8,10 @@ import { } from '@mariozechner/pi-coding-agent'; import type { Brain } from '@mosaic/brain'; import { BRAIN } from '../brain/brain.tokens.js'; +import { CoordService } from '../coord/coord.service.js'; import { ProviderService } from './provider.service.js'; import { createBrainTools } from './tools/brain-tools.js'; +import { createCoordTools } from './tools/coord-tools.js'; export interface AgentSessionOptions { provider?: string; @@ -36,8 +38,9 @@ export class AgentService implements OnModuleDestroy { constructor( private readonly providerService: ProviderService, @Inject(BRAIN) private readonly brain: Brain, + private readonly coordService: CoordService, ) { - this.customTools = createBrainTools(brain); + this.customTools = [...createBrainTools(brain), ...createCoordTools(coordService)]; this.logger.log(`Registered ${this.customTools.length} custom tools`); } diff --git a/apps/gateway/src/agent/tools/coord-tools.ts b/apps/gateway/src/agent/tools/coord-tools.ts new file mode 100644 index 0000000..ffd94fb --- /dev/null +++ b/apps/gateway/src/agent/tools/coord-tools.ts @@ -0,0 +1,81 @@ +import { Type } from '@sinclair/typebox'; +import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; +import type { CoordService } from '../../coord/coord.service.js'; + +export function createCoordTools(coordService: CoordService): ToolDefinition[] { + const getMissionStatus: ToolDefinition = { + name: 'coord_mission_status', + label: 'Mission Status', + description: + 'Get the current orchestration mission status including milestones, tasks, and active session.', + parameters: Type.Object({ + projectPath: Type.Optional( + Type.String({ description: 'Project path. Defaults to gateway working directory.' }), + ), + }), + async execute(_toolCallId, params) { + const { projectPath } = params as { projectPath?: string }; + const resolvedPath = projectPath ?? process.cwd(); + const status = await coordService.getMissionStatus(resolvedPath); + return { + content: [ + { + type: 'text' as const, + text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.', + }, + ], + details: undefined, + }; + }, + }; + + const listCoordTasks: ToolDefinition = { + name: 'coord_list_tasks', + label: 'List Coord Tasks', + description: 'List all tasks from the orchestration TASKS.md file.', + parameters: Type.Object({ + projectPath: Type.Optional( + Type.String({ description: 'Project path. Defaults to gateway working directory.' }), + ), + }), + async execute(_toolCallId, params) { + const { projectPath } = params as { projectPath?: string }; + const resolvedPath = projectPath ?? process.cwd(); + const tasks = await coordService.listTasks(resolvedPath); + return { + content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }], + details: undefined, + }; + }, + }; + + const getCoordTaskDetail: ToolDefinition = { + name: 'coord_task_detail', + label: 'Coord Task Detail', + description: 'Get detailed status for a specific orchestration task.', + parameters: Type.Object({ + taskId: Type.String({ description: 'Task ID (e.g. P2-005)' }), + projectPath: Type.Optional( + Type.String({ description: 'Project path. Defaults to gateway working directory.' }), + ), + }), + async execute(_toolCallId, params) { + const { taskId, projectPath } = params as { taskId: string; projectPath?: string }; + const resolvedPath = projectPath ?? process.cwd(); + const detail = await coordService.getTaskStatus(resolvedPath, taskId); + return { + content: [ + { + type: 'text' as const, + text: detail + ? JSON.stringify(detail, null, 2) + : `Task ${taskId} not found in coord mission.`, + }, + ], + details: undefined, + }; + }, + }; + + return [getMissionStatus, listCoordTasks, getCoordTaskDetail]; +} diff --git a/apps/gateway/src/agent/tools/index.ts b/apps/gateway/src/agent/tools/index.ts index a11075d..b2e0416 100644 --- a/apps/gateway/src/agent/tools/index.ts +++ b/apps/gateway/src/agent/tools/index.ts @@ -1 +1,2 @@ export { createBrainTools } from './brain-tools.js'; +export { createCoordTools } from './coord-tools.js'; diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 02adb52..694123b 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -9,6 +9,7 @@ import { ConversationsModule } from './conversations/conversations.module.js'; import { ProjectsModule } from './projects/projects.module.js'; import { MissionsModule } from './missions/missions.module.js'; import { TasksModule } from './tasks/tasks.module.js'; +import { CoordModule } from './coord/coord.module.js'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { TasksModule } from './tasks/tasks.module.js'; ProjectsModule, MissionsModule, TasksModule, + CoordModule, ], controllers: [HealthController], }) diff --git a/apps/gateway/src/coord/coord.controller.ts b/apps/gateway/src/coord/coord.controller.ts new file mode 100644 index 0000000..48ae328 --- /dev/null +++ b/apps/gateway/src/coord/coord.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, NotFoundException, Param, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CoordService } from './coord.service.js'; + +@Controller('api/coord') +@UseGuards(AuthGuard) +export class CoordController { + constructor(private readonly coordService: CoordService) {} + + @Get('status') + async missionStatus(@Query('projectPath') projectPath?: string) { + const resolvedPath = projectPath ?? process.cwd(); + const status = await this.coordService.getMissionStatus(resolvedPath); + if (!status) throw new NotFoundException('No active coord mission found'); + return status; + } + + @Get('tasks') + async listTasks(@Query('projectPath') projectPath?: string) { + const resolvedPath = projectPath ?? process.cwd(); + return this.coordService.listTasks(resolvedPath); + } + + @Get('tasks/:taskId') + async taskStatus(@Param('taskId') taskId: string, @Query('projectPath') projectPath?: string) { + const resolvedPath = projectPath ?? process.cwd(); + const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); + if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`); + return detail; + } +} diff --git a/apps/gateway/src/coord/coord.dto.ts b/apps/gateway/src/coord/coord.dto.ts new file mode 100644 index 0000000..16405f3 --- /dev/null +++ b/apps/gateway/src/coord/coord.dto.ts @@ -0,0 +1,49 @@ +export interface CoordMissionStatusDto { + mission: { + id: string; + name: string; + status: string; + projectPath: string; + }; + milestones: { + total: number; + completed: number; + current?: { + id: string; + name: string; + status: string; + }; + }; + tasks: { + total: number; + done: number; + inProgress: number; + pending: number; + blocked: number; + cancelled: number; + }; + nextTaskId?: string; + activeSession?: { + sessionId: string; + runtime: string; + startedAt: string; + }; +} + +export interface CoordTaskDetailDto { + missionId: string; + task: { + id: string; + title: string; + status: string; + milestone?: string; + pr?: string; + notes?: string; + }; + isNextTask: boolean; + activeSession?: { + sessionId: string; + runtime: string; + startedAt: string; + }; +} diff --git a/apps/gateway/src/coord/coord.module.ts b/apps/gateway/src/coord/coord.module.ts new file mode 100644 index 0000000..d2f46e3 --- /dev/null +++ b/apps/gateway/src/coord/coord.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CoordService } from './coord.service.js'; +import { CoordController } from './coord.controller.js'; + +@Module({ + providers: [CoordService], + controllers: [CoordController], + exports: [CoordService], +}) +export class CoordModule {} diff --git a/apps/gateway/src/coord/coord.service.ts b/apps/gateway/src/coord/coord.service.ts new file mode 100644 index 0000000..89dfac4 --- /dev/null +++ b/apps/gateway/src/coord/coord.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + loadMission, + getMissionStatus, + getTaskStatus, + parseTasksFile, + type Mission, + type MissionStatusSummary, + type MissionTask, + type TaskDetail, +} from '@mosaic/coord'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +@Injectable() +export class CoordService { + private readonly logger = new Logger(CoordService.name); + + async loadMission(projectPath: string): Promise { + try { + return await loadMission(projectPath); + } catch (err) { + this.logger.debug( + `No coord mission at ${projectPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } + + async getMissionStatus(projectPath: string): Promise { + const mission = await this.loadMission(projectPath); + if (!mission) return null; + + try { + return await getMissionStatus(mission); + } catch (err) { + this.logger.error( + `Failed to get mission status: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } + + async getTaskStatus(projectPath: string, taskId: string): Promise { + const mission = await this.loadMission(projectPath); + if (!mission) return null; + + try { + return await getTaskStatus(mission, taskId); + } catch (err) { + this.logger.error( + `Failed to get task status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } + + async listTasks(projectPath: string): Promise { + const mission = await this.loadMission(projectPath); + if (!mission) return []; + + const tasksFile = path.isAbsolute(mission.tasksFile) + ? mission.tasksFile + : path.join(mission.projectPath, mission.tasksFile); + + try { + const content = await fs.readFile(tasksFile, 'utf8'); + return parseTasksFile(content); + } catch { + return []; + } + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index 7ba69bb..df4a2d3 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -21,12 +21,12 @@ | P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 | | P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 | | P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 | -| P1-009 | not-started | Phase 1 | Verify Phase 1 — gateway functional, API tested | — | #18 | +| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 | | P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 | -| P2-002 | not-started | Phase 2 | Multi-provider support — Anthropic + Ollama | — | #20 | -| P2-003 | not-started | Phase 2 | Agent routing engine — cost/capability matrix | — | #21 | -| P2-004 | not-started | Phase 2 | Tool registration — brain, queue, memory tools | — | #22 | -| P2-005 | not-started | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | — | #23 | +| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 | +| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 | +| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 | +| P2-005 | in-progress | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | — | #23 | | P2-006 | not-started | Phase 2 | Agent session management — tmux + monitoring | — | #24 | | P2-007 | not-started | Phase 2 | Verify Phase 2 — multi-provider routing works | — | #25 | | P3-001 | not-started | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | — | #26 | diff --git a/packages/coord/package.json b/packages/coord/package.json index 1a45d8a..a442b21 100644 --- a/packages/coord/package.json +++ b/packages/coord/package.json @@ -19,6 +19,7 @@ "@mosaic/types": "workspace:*" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^2.0.0" } diff --git a/packages/coord/src/index.ts b/packages/coord/src/index.ts index 0c18d5d..7ac5309 100644 --- a/packages/coord/src/index.ts +++ b/packages/coord/src/index.ts @@ -1 +1,20 @@ -export const VERSION = '0.0.0'; +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 type { + CreateMissionOptions, + Mission, + MissionMilestone, + MissionRuntime, + MissionSession, + MissionStatus, + MissionStatusSummary, + MissionTask, + NextTaskCapsule, + RunTaskOptions, + TaskDetail, + TaskRun, + TaskStatus, +} from './types.js'; +export { isMissionStatus, isTaskStatus, normalizeTaskStatus } from './types.js'; diff --git a/packages/coord/src/mission.ts b/packages/coord/src/mission.ts new file mode 100644 index 0000000..84dc371 --- /dev/null +++ b/packages/coord/src/mission.ts @@ -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 { + 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; + } + + 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; +} diff --git a/packages/coord/src/runner.ts b/packages/coord/src/runner.ts new file mode 100644 index 0000000..17d07d5 --- /dev/null +++ b/packages/coord/src/runner.ts @@ -0,0 +1,464 @@ +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: milestone, + 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:** ${milestone?.name ?? '—'} (${milestone?.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 { + 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 { + const filePath = sessionLockPath(mission); + + try { + const raw = await fs.readFile(filePath, 'utf8'); + const data = JSON.parse(raw) as Partial; + + 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 { + 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 { + 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((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 = {}, +): Promise { + 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); +} diff --git a/packages/coord/src/status.ts b/packages/coord/src/status.ts new file mode 100644 index 0000000..7f42b42 --- /dev/null +++ b/packages/coord/src/status.ts @@ -0,0 +1,175 @@ +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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/coord/src/tasks-file.ts b/packages/coord/src/tasks-file.ts new file mode 100644 index 0000000..9ca1db8 --- /dev/null +++ b/packages/coord/src/tasks-file.ts @@ -0,0 +1,376 @@ +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(/(? 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] as string); + 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] as string); + 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] as string; + 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 { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function acquireLock(lockPath: string): Promise { + 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 { + await fs.rm(lockPath, { force: true }); +} + +async function writeAtomic(filePath: string, content: string): Promise { + 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(); + 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 { + 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] as ParsedTableRow; + 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); + } +} diff --git a/packages/coord/src/types.ts b/packages/coord/src/types.ts new file mode 100644 index 0000000..9cb3258 --- /dev/null +++ b/packages/coord/src/types.ts @@ -0,0 +1,184 @@ +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; + 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; + 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> = { + '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' + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39fea73..72916e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@mosaic/brain': specifier: workspace:^ version: link:../../packages/brain + '@mosaic/coord': + specifier: workspace:^ + version: link:../../packages/coord '@mosaic/db': specifier: workspace:^ version: link:../../packages/db @@ -255,6 +258,9 @@ importers: specifier: workspace:* version: link:../types devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 typescript: specifier: ^5.8.0 version: 5.9.3