/** * File-Based Agent I/O * * Writes context as input files before agent spawn and reads output files after completion. * Uses YAML frontmatter (gray-matter) for structured metadata and markdown bodies. * * Input: .cw/input/ — written by system before spawn * Output: .cw/output/ — written by agent during execution */ import { existsSync } from 'node:fs'; import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import matter from 'gray-matter'; import { nanoid } from 'nanoid'; import { tiptapJsonToMarkdown } from './content-serializer.js'; import type { AgentInputContext } from './types.js'; // Re-export for convenience export type { AgentInputContext } from './types.js'; // ============================================================================= // TYPES // ============================================================================= export interface WriteInputFilesOptions extends AgentInputContext { agentWorkdir: string; } export interface ParsedSummary { body: string; filesModified?: string[]; } export interface ParsedPhaseFile { id: string; title: string; dependencies: string[]; body: string; action?: 'create' | 'update' | 'delete'; } export interface ParsedTaskFile { id: string; title: string; category: string; type: string; dependencies: string[]; body: string; action?: 'create' | 'update' | 'delete'; phaseId?: string; parentTaskId?: string; } export interface ParsedDecisionFile { id: string; topic: string; decision: string; reason: string; body: string; } export interface ParsedPageFile { pageId: string; title: string; summary: string; body: string; action?: 'create' | 'update' | 'delete'; } // ============================================================================= // ID GENERATION // ============================================================================= export function generateId(): string { return nanoid(); } // ============================================================================= // INPUT FILE WRITING // ============================================================================= function formatFrontmatter(data: Record, body: string = ''): string { const lines: string[] = ['---']; for (const [key, value] of Object.entries(data)) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${key}: []`); } else { lines.push(`${key}:`); for (const item of value) { lines.push(` - ${String(item)}`); } } } else if (value instanceof Date) { lines.push(`${key}: "${value.toISOString()}"`); } else if (typeof value === 'string' && (value.includes('\n') || value.includes(':'))) { lines.push(`${key}: ${JSON.stringify(value)}`); } else { lines.push(`${key}: ${String(value)}`); } } lines.push('---'); if (body) { lines.push(''); lines.push(body); } return lines.join('\n') + '\n'; } export async function writeInputFiles(options: WriteInputFilesOptions): Promise { const inputDir = join(options.agentWorkdir, '.cw', 'input'); await mkdir(inputDir, { recursive: true }); // Write expected working directory marker for verification await writeFile( join(inputDir, '../expected-pwd.txt'), options.agentWorkdir, 'utf-8' ); const manifestFiles: string[] = []; // Collect all file writes, then flush in parallel const writes: Array<{ path: string; content: string }> = []; if (options.initiative) { const ini = options.initiative; const content = formatFrontmatter( { id: ini.id, name: ini.name, status: ini.status, branch: ini.branch, }, '', ); writes.push({ path: join(inputDir, 'initiative.md'), content }); manifestFiles.push('initiative.md'); } if (options.pages && options.pages.length > 0) { await mkdir(join(inputDir, 'pages'), { recursive: true }); for (const page of options.pages) { let bodyMarkdown = ''; if (page.content) { try { const parsed = JSON.parse(page.content); bodyMarkdown = tiptapJsonToMarkdown(parsed); } catch { // Invalid JSON content — skip } } const content = formatFrontmatter( { title: page.title, parentPageId: page.parentPageId, sortOrder: page.sortOrder, }, bodyMarkdown, ); const filename = `pages/${page.id}.md`; writes.push({ path: join(inputDir, 'pages', `${page.id}.md`), content }); manifestFiles.push(filename); } } if (options.phase) { const ph = options.phase; let bodyMarkdown = ''; if (ph.content) { try { bodyMarkdown = tiptapJsonToMarkdown(JSON.parse(ph.content)); } catch { // Invalid JSON content — skip } } const content = formatFrontmatter( { id: ph.id, name: ph.name, status: ph.status, }, bodyMarkdown, ); writes.push({ path: join(inputDir, 'phase.md'), content }); manifestFiles.push('phase.md'); } if (options.task) { const t = options.task; const content = formatFrontmatter( { id: t.id, name: t.name, category: t.category, type: t.type, priority: t.priority, status: t.status, }, t.description ?? '', ); writes.push({ path: join(inputDir, 'task.md'), content }); manifestFiles.push('task.md'); } // Write read-only context directories const contextFiles: string[] = []; if (options.phases && options.phases.length > 0) { await mkdir(join(inputDir, 'context', 'phases'), { recursive: true }); for (const ph of options.phases) { let bodyMarkdown = ''; if (ph.content) { try { bodyMarkdown = tiptapJsonToMarkdown(JSON.parse(ph.content)); } catch { // Invalid JSON content — skip } } const content = formatFrontmatter( { id: ph.id, name: ph.name, status: ph.status, dependsOn: ph.dependsOn ?? [], }, bodyMarkdown, ); const filename = `context/phases/${ph.id}.md`; writes.push({ path: join(inputDir, 'context', 'phases', `${ph.id}.md`), content }); contextFiles.push(filename); } } if (options.tasks && options.tasks.length > 0) { await mkdir(join(inputDir, 'context', 'tasks'), { recursive: true }); for (const t of options.tasks) { const content = formatFrontmatter( { id: t.id, name: t.name, phaseId: t.phaseId, parentTaskId: t.parentTaskId, category: t.category, type: t.type, priority: t.priority, status: t.status, summary: t.summary, }, t.description ?? '', ); const filename = `context/tasks/${t.id}.md`; writes.push({ path: join(inputDir, 'context', 'tasks', `${t.id}.md`), content }); contextFiles.push(filename); } } // Write context index — groups tasks by phaseId so agents can look up relevant files // without bulk-reading every context file if (options.tasks && options.tasks.length > 0) { const tasksByPhase: Record> = {}; for (const t of options.tasks) { const phaseId = t.phaseId ?? '_unassigned'; if (!tasksByPhase[phaseId]) tasksByPhase[phaseId] = []; tasksByPhase[phaseId].push({ file: `context/tasks/${t.id}.md`, id: t.id, name: t.name, status: t.status, }); } await mkdir(join(inputDir, 'context'), { recursive: true }); writes.push({ path: join(inputDir, 'context', 'index.json'), content: JSON.stringify({ tasksByPhase }, null, 2) + '\n', }); } // Replace agent placeholders in all content before writing const placeholders: Record = { '{AGENT_ID}': options.agentId ?? '', '{AGENT_NAME}': options.agentName ?? '', }; for (const w of writes) { for (const [token, value] of Object.entries(placeholders)) { w.content = w.content.replaceAll(token, value); } } // Flush all file writes in parallel — yields the event loop between I/O ops await Promise.all(writes.map(w => writeFile(w.path, w.content, 'utf-8'))); // Write manifest last (after all files exist) await writeFile( join(inputDir, 'manifest.json'), JSON.stringify({ files: manifestFiles, contextFiles, agentId: options.agentId ?? null, agentName: options.agentName ?? null, }) + '\n', 'utf-8', ); } // ============================================================================= // ERRAND INPUT FILE WRITING // ============================================================================= export async function writeErrandManifest(options: { agentWorkdir: string; errandId: string; description: string; branch: string; projectName: string; agentId: string; agentName: string; }): Promise { await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true }); // Write errand.md first (before manifest.json) const errandMdContent = formatFrontmatter({ id: options.errandId, description: options.description, branch: options.branch, project: options.projectName, }); await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8'); // Write manifest.json last (after all other files exist) await writeFile( join(options.agentWorkdir, '.cw', 'input', 'manifest.json'), JSON.stringify({ errandId: options.errandId, agentId: options.agentId, agentName: options.agentName, mode: 'errand', }) + '\n', 'utf-8', ); // Write expected-pwd.txt await writeFile( join(options.agentWorkdir, '.cw', 'expected-pwd.txt'), options.agentWorkdir, 'utf-8', ); } // ============================================================================= // OUTPUT FILE READING // ============================================================================= export async function readFrontmatterFile(filePath: string): Promise<{ data: Record; body: string } | null> { try { const raw = await readFile(filePath, 'utf-8'); const parsed = matter(raw); return { data: parsed.data as Record, body: parsed.content.trim() }; } catch { return null; } } async function readFrontmatterDir( dirPath: string, mapper: (data: Record, body: string, filename: string) => T | null, ): Promise { if (!existsSync(dirPath)) return []; const results: T[] = []; try { const entries = await readdir(dirPath); for (const entry of entries) { if (!entry.endsWith('.md')) continue; const filePath = join(dirPath, entry); const parsed = await readFrontmatterFile(filePath); if (!parsed) continue; const mapped = mapper(parsed.data, parsed.body, entry); if (mapped) results.push(mapped); } } catch { // Directory read error — return empty } return results; } export async function readSummary(agentWorkdir: string): Promise { const filePath = join(agentWorkdir, '.cw', 'output', 'SUMMARY.md'); const parsed = await readFrontmatterFile(filePath); if (!parsed) return null; const filesModified = parsed.data.files_modified; return { body: parsed.body, filesModified: Array.isArray(filesModified) ? filesModified.map(String) : undefined, }; } export async function readPhaseFiles(agentWorkdir: string): Promise { const dirPath = join(agentWorkdir, '.cw', 'output', 'phases'); return readFrontmatterDir(dirPath, (data, body, filename) => { const id = filename.replace(/\.md$/, ''); const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : []; const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete'; return { id, title: String(data.title ?? ''), dependencies: deps, body, action, }; }); } export async function readTaskFiles(agentWorkdir: string): Promise { const dirPath = join(agentWorkdir, '.cw', 'output', 'tasks'); return readFrontmatterDir(dirPath, (data, body, filename) => { const id = filename.replace(/\.md$/, ''); const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : []; const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete'; return { id, title: String(data.title ?? ''), category: String(data.category ?? 'execute'), type: String(data.type ?? 'auto'), dependencies: deps, body, action, phaseId: data.phaseId ? String(data.phaseId) : undefined, parentTaskId: data.parentTaskId ? String(data.parentTaskId) : undefined, }; }); } export async function readDecisionFiles(agentWorkdir: string): Promise { const dirPath = join(agentWorkdir, '.cw', 'output', 'decisions'); return readFrontmatterDir(dirPath, (data, body, filename) => { const id = filename.replace(/\.md$/, ''); return { id, topic: String(data.topic ?? ''), decision: String(data.decision ?? ''), reason: String(data.reason ?? ''), body, }; }); } export interface ParsedCommentResponse { commentId: string; body: string; resolved?: boolean; } export async function readCommentResponses(agentWorkdir: string): Promise { const filePath = join(agentWorkdir, '.cw', 'output', 'comment-responses.json'); try { const raw = await readFile(filePath, 'utf-8'); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed .filter((entry: unknown) => { if (typeof entry !== 'object' || entry === null) return false; const e = entry as Record; return typeof e.commentId === 'string' && typeof e.body === 'string'; }) .map((entry: Record) => ({ commentId: String(entry.commentId), body: String(entry.body), resolved: typeof entry.resolved === 'boolean' ? entry.resolved : undefined, })); } catch { return []; } } export async function readPageFiles(agentWorkdir: string): Promise { const dirPath = join(agentWorkdir, '.cw', 'output', 'pages'); return readFrontmatterDir(dirPath, (data, body, filename) => { const pageId = filename.replace(/\.md$/, ''); const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete'; return { pageId, title: String(data.title ?? ''), summary: String(data.summary ?? ''), body, action, }; }); }