- Add `createTaskForAgent` tRPC mutation: resolves agent → task → phase, creates sibling task
- Add `cw task add <name> --agent-id <id>` CLI command
- Replace `{AGENT_ID}` and `{AGENT_NAME}` placeholders in writeInputFiles() before flushing
- Update docs/agent.md and docs/cli-config.md
497 lines
15 KiB
TypeScript
497 lines
15 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 { 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<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 async function writeInputFiles(options: WriteInputFilesOptions): Promise<void> {
|
|
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<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,
|
|
});
|
|
}
|
|
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<string, string> = {
|
|
'{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<void> {
|
|
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<string, unknown>; body: string } | null> {
|
|
try {
|
|
const raw = await readFile(filePath, 'utf-8');
|
|
const parsed = matter(raw);
|
|
return { data: parsed.data as Record<string, unknown>, body: parsed.content.trim() };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function readFrontmatterDir<T>(
|
|
dirPath: string,
|
|
mapper: (data: Record<string, unknown>, body: string, filename: string) => T | null,
|
|
): Promise<T[]> {
|
|
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<ParsedSummary | null> {
|
|
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<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 async function readTaskFiles(agentWorkdir: string): Promise<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 async function readDecisionFiles(agentWorkdir: string): Promise<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 interface ParsedCommentResponse {
|
|
commentId: string;
|
|
body: string;
|
|
resolved?: boolean;
|
|
}
|
|
|
|
export async function readCommentResponses(agentWorkdir: string): Promise<ParsedCommentResponse[]> {
|
|
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<string, unknown>;
|
|
return typeof e.commentId === 'string' && typeof e.body === 'string';
|
|
})
|
|
.map((entry: Record<string, unknown>) => ({
|
|
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<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,
|
|
};
|
|
});
|
|
}
|