Files
Codewalkers/apps/server/agent/file-io.ts
Lukas May 536cdf08a1 feat: Propagate task summaries and input context to execution agents
Execution agents were spawning blind — no input files, no knowledge of
what predecessor tasks accomplished. This adds three capabilities:

1. summary column on tasks table — completeTask() reads the finishing
   agent's result.message and stores it on the task record
2. dispatchNext() gathers full initiative context (initiative, phase,
   sibling tasks, pages) and passes it as inputContext so agents get
   .cw/input/task.md, initiative.md, phase.md, and context directories
3. context/tasks/*.md files now include the summary field in frontmatter
   so dependent agents can see what prior agents accomplished
2026-03-03 13:42:37 +01:00

378 lines
10 KiB
TypeScript

/**
* 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 { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { readFileSync } from 'node:fs';
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;
}
export interface ParsedTaskFile {
id: string;
title: string;
category: string;
type: string;
dependencies: string[];
body: 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;
}
// =============================================================================
// ID GENERATION
// =============================================================================
export function generateId(): string {
return nanoid();
}
// =============================================================================
// INPUT FILE WRITING
// =============================================================================
function formatFrontmatter(data: Record<string, unknown>, 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 function writeInputFiles(options: WriteInputFilesOptions): void {
const inputDir = join(options.agentWorkdir, '.cw', 'input');
mkdirSync(inputDir, { recursive: true });
// Write expected working directory marker for verification
writeFileSync(
join(inputDir, '../expected-pwd.txt'),
options.agentWorkdir,
'utf-8'
);
const manifestFiles: string[] = [];
if (options.initiative) {
const ini = options.initiative;
const content = formatFrontmatter(
{
id: ini.id,
name: ini.name,
status: ini.status,
mergeRequiresApproval: ini.mergeRequiresApproval,
branch: ini.branch,
},
'',
);
writeFileSync(join(inputDir, 'initiative.md'), content, 'utf-8');
manifestFiles.push('initiative.md');
}
if (options.pages && options.pages.length > 0) {
const pagesDir = join(inputDir, 'pages');
mkdirSync(pagesDir, { 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`;
writeFileSync(join(pagesDir, `${page.id}.md`), content, 'utf-8');
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,
);
writeFileSync(join(inputDir, 'phase.md'), content, 'utf-8');
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 ?? '',
);
writeFileSync(join(inputDir, 'task.md'), content, 'utf-8');
manifestFiles.push('task.md');
}
// Write read-only context directories
const contextFiles: string[] = [];
if (options.phases && options.phases.length > 0) {
const phasesDir = join(inputDir, 'context', 'phases');
mkdirSync(phasesDir, { 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`;
writeFileSync(join(phasesDir, `${ph.id}.md`), content, 'utf-8');
contextFiles.push(filename);
}
}
if (options.tasks && options.tasks.length > 0) {
const tasksDir = join(inputDir, 'context', 'tasks');
mkdirSync(tasksDir, { 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`;
writeFileSync(join(tasksDir, `${t.id}.md`), content, 'utf-8');
contextFiles.push(filename);
}
}
// Write manifest listing exactly which files were created
writeFileSync(
join(inputDir, 'manifest.json'),
JSON.stringify({
files: manifestFiles,
contextFiles,
agentId: options.agentId ?? null,
agentName: options.agentName ?? null,
}) + '\n',
'utf-8',
);
}
// =============================================================================
// OUTPUT FILE READING
// =============================================================================
export function readFrontmatterFile(filePath: string): { data: Record<string, unknown>; body: string } | null {
try {
const raw = readFileSync(filePath, 'utf-8');
const parsed = matter(raw);
return { data: parsed.data as Record<string, unknown>, body: parsed.content.trim() };
} catch {
return null;
}
}
function readFrontmatterDir<T>(
dirPath: string,
mapper: (data: Record<string, unknown>, body: string, filename: string) => T | null,
): T[] {
if (!existsSync(dirPath)) return [];
const results: T[] = [];
try {
const entries = readdirSync(dirPath);
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
const filePath = join(dirPath, entry);
const parsed = 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 function readSummary(agentWorkdir: string): ParsedSummary | null {
const filePath = join(agentWorkdir, '.cw', 'output', 'SUMMARY.md');
const parsed = 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 function readPhaseFiles(agentWorkdir: string): ParsedPhaseFile[] {
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) : [];
return {
id,
title: String(data.title ?? ''),
dependencies: deps,
body,
};
});
}
export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
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) : [];
return {
id,
title: String(data.title ?? ''),
category: String(data.category ?? 'execute'),
type: String(data.type ?? 'auto'),
dependencies: deps,
body,
};
});
}
export function readDecisionFiles(agentWorkdir: string): ParsedDecisionFile[] {
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 function readPageFiles(agentWorkdir: string): ParsedPageFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const pageId = filename.replace(/\.md$/, '');
return {
pageId,
title: String(data.title ?? ''),
summary: String(data.summary ?? ''),
body,
};
});
}