Introduces a chat loop where users send instructions to an agent that applies changes (create/update/delete phases, tasks, pages) and stays alive for follow-up messages. Includes schema + migration, repository layer, chat prompt, file-io action field extension, output handler chat mode, revert support for deletes, tRPC procedures, events, frontend slide-over UI with inline changeset display and revert, and docs.
391 lines
11 KiB
TypeScript
391 lines
11 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 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,
|
|
};
|
|
});
|
|
}
|