Agents were bulk-reading all context task files (39 files) because filenames are opaque IDs and there was no way to find phase-relevant tasks without reading every file. Now writeInputFiles generates a context/index.json with tasksByPhase mapping phaseId to task metadata (file, id, name, status). Prompt updated to direct agents to read the index first.
414 lines
12 KiB
TypeScript
414 lines
12 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;
|
|
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<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 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<string, Array<{ file: string; id: string; name: string; status: string }>> = {};
|
|
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,
|
|
});
|
|
}
|
|
const contextDir = join(inputDir, 'context');
|
|
mkdirSync(contextDir, { recursive: true });
|
|
writeFileSync(
|
|
join(contextDir, 'index.json'),
|
|
JSON.stringify({ tasksByPhase }, null, 2) + '\n',
|
|
'utf-8',
|
|
);
|
|
}
|
|
|
|
// 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) : [];
|
|
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
|
|
return {
|
|
id,
|
|
title: String(data.title ?? ''),
|
|
dependencies: deps,
|
|
body,
|
|
action,
|
|
};
|
|
});
|
|
}
|
|
|
|
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) : [];
|
|
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 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$/, '');
|
|
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
|
|
return {
|
|
pageId,
|
|
title: String(data.title ?? ''),
|
|
summary: String(data.summary ?? ''),
|
|
body,
|
|
action,
|
|
};
|
|
});
|
|
}
|