Something went wrong. Try again.
An agent harness for spec-driven development.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243import fs from "node:fs/promises";import os from "node:os";import path from "node:path";import { createHash } from "node:crypto";import { spawn } from "node:child_process";import type { AgentPacketRow, AgentRequestRow, SpecChange, SpecDocumentRow, SpecImportPlan, WorkItemRow } from "./types.js";
export interface AgentRequestOptions { plan: SpecImportPlan; changes: SpecChange[]; command: string; consumerId?: string; attachedDocumentIds?: string[]; workItems?: WorkItemRow[];}
export interface AgentCommandResult { exitCode: number; stdoutText: string; stderrText: string; outputText: string; promptFile?: string;}
export function buildAgentRequest(options: AgentRequestOptions): AgentRequestRow { const actionable = options.changes.filter((change) => change.type !== "unchanged"); const workItems = options.workItems ?? []; const attachedDocumentIds = unique([ ...workItems.flatMap((item) => item.attachedDocumentIds), ...actionable.map((change) => change.documentId), ...(options.attachedDocumentIds ?? []), ]); const prompt = buildAgentPrompt({ plan: options.plan, changes: options.changes, attachedDocumentIds, workItems, ...(options.consumerId ? { consumerId: options.consumerId } : {}), }); const promptSha256 = sha256(prompt); const now = options.plan.generatedAt;
return { id: promptSha256.slice(0, 12), status: "planned", createdAt: now, updatedAt: now, requestedBy: "charter.agent", packetId: promptSha256.slice(0, 12), command: options.command, changeIds: actionable.map((change) => change.documentId), workItemIds: workItems.map((item) => item.id), attachedDocumentIds, prompt, promptSha256, ...(options.consumerId ? { consumerId: options.consumerId } : {}), };}
export function buildAgentPacket(options: { request: AgentRequestRow; workItems: WorkItemRow[]; documents: Record<string, SpecDocumentRow>; promptDocumentIds?: string[];}): AgentPacketRow { const affectedDocumentIds = unique(options.workItems.map((item) => item.documentId)); const eventIds = unique(options.workItems.map((item) => item.eventId)); const documentVersionIds = Object.fromEntries( options.request.attachedDocumentIds .map((documentId) => [documentId, options.documents[documentId]?.sha256]) .filter((entry): entry is [string, string] => typeof entry[1] === "string"), ); return { id: options.request.packetId ?? options.request.id, status: "planned", createdAt: options.request.createdAt, updatedAt: options.request.updatedAt, ...(options.request.consumerId ? { consumerId: options.request.consumerId } : {}), requestId: options.request.id, command: options.request.command, workItemIds: options.request.workItemIds, eventIds, affectedDocumentIds, promptDocumentIds: options.promptDocumentIds ?? [], attachedDocumentIds: options.request.attachedDocumentIds, documentVersionIds, prompt: options.request.prompt, promptSha256: options.request.promptSha256, outputContract: "Report files changed, behavior implemented, checks run, and unresolved spec ambiguity.", };}
export async function runAgentCommand(options: { command: string; projectRoot: string; request: AgentRequestRow;}): Promise<AgentCommandResult> { const tempPrompt = options.command.includes("{promptFile}") ? await writeTemporaryPromptFile(options.request) : undefined; const command = tempPrompt ? options.command.replaceAll("{promptFile}", shellQuote(tempPrompt)) : options.command;
const child = spawn(command, { cwd: options.projectRoot, shell: true, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, CHARTER_REQUEST_ID: options.request.id, CHARTER_PROMPT_SHA256: options.request.promptSha256, ...(tempPrompt ? { CHARTER_PROMPT_FILE: tempPrompt } : {}), }, });
const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => { stdoutChunks.push(Buffer.from(chunk)); process.stdout.write(chunk); }); child.stderr?.on("data", (chunk: Buffer) => { stderrChunks.push(Buffer.from(chunk)); process.stderr.write(chunk); }); child.stdin?.on("error", () => { // The child may exit before reading stdin. The exit code is the contract. }); child.stdin?.end(options.request.prompt);
const exitCode = await new Promise<number>((resolve, reject) => { child.on("error", reject); child.on("close", (code) => resolve(code ?? 1)); });
const stdoutText = Buffer.concat(stdoutChunks).toString("utf8"); const stderrText = Buffer.concat(stderrChunks).toString("utf8"); const outputText = [stdoutText, stderrText].filter(Boolean).join("\n");
return tempPrompt === undefined ? { exitCode, stdoutText, stderrText, outputText } : { exitCode, stdoutText, stderrText, outputText, promptFile: tempPrompt };}
function buildAgentPrompt(options: { plan: SpecImportPlan; changes: SpecChange[]; consumerId?: string; attachedDocumentIds: string[]; workItems: WorkItemRow[];}): string { const actionable = options.changes.filter((change) => change.type !== "unchanged"); const attachedIds = new Set(options.attachedDocumentIds); const attachedRows = options.plan.rows.filter((row) => attachedIds.has(row.id));
return `# Charter agent request
You are an implementation agent running in this repository:
${options.plan.projectRoot}
Charter uses Jazz-backed spec objects as the coordination surface. In this local filesystem client, Markdown in \`spec/\` is only an editable projection. Implement the durable work items below. Inspect the repository before editing. Preserve human-authored product intent. Run relevant checks before reporting.
${options.consumerId ? `Consumer: ${options.consumerId}\n` : ""}
## Completion contract
Before exiting, report the outcome back to Charter. Prefer one of:
npm run report -- --request "$CHARTER_REQUEST_ID" --status done --message "Implemented the requested work and checks pass." npm run report -- --request "$CHARTER_REQUEST_ID" --status failed --message "Failed because ..." npm run report -- --request "$CHARTER_REQUEST_ID" --status blocked --message "Blocked because ..."
The runner also captures stdout, stderr, and exit code, but the explicit report is the canonical completion signal.
## Durable work items
${options.workItems.length === 0 ? "No queued work items are attached to this request." : options.workItems.map(formatWorkItem).join("\n")}
## Current projection changes
${actionable.length === 0 ? "No unsynced projection changes were detected." : actionable.map(formatChange).join("\n")}
## Attached spec documents
${attachedRows.length === 0 ? "No spec documents are attached to this request." : attachedRows.map(formatDocument).join("\n\n")}
## Expected response
Report:
1. files changed2. behavior implemented3. checks run4. any unresolved spec ambiguity`;}
function formatWorkItem(item: WorkItemRow): string { const source = item.sourceJazzRowId ? ` source=${item.sourceJazzRowId}` : ""; const field = item.sourceField ? ` field=${item.sourceField}` : ""; return `- ${item.id}: ${item.kind} ${item.status} event=${item.eventId} document=${item.documentId} path=${item.path}${source}${field}`;}
function formatChange(change: SpecChange): string { const previous = change.previousSha256 ? ` previous=${change.previousSha256.slice(0, 12)}` : ""; const current = change.currentSha256 ? ` current=${change.currentSha256.slice(0, 12)}` : ""; const path = change.previousPath && change.currentPath && change.previousPath !== change.currentPath ? ` ${change.previousPath} -> ${change.currentPath}` : ` ${change.path}`; return `- ${change.type}: ${change.documentId}${path}${previous}${current}`;}
function formatDocument(row: { id: string; path: string; title: string; status: string; body: string }): string { return `### ${row.id} (${row.path})
Title: ${row.title}Status: ${row.status}
${row.body}`;}
async function writeTemporaryPromptFile(request: AgentRequestRow): Promise<string> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "charter-agent-")); const promptPath = path.join(dir, `request-${request.id}.md`); await fs.writeFile(promptPath, request.prompt, "utf8"); return promptPath;}
function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`;}
function sha256(input: string): string { return createHash("sha256").update(input).digest("hex");}
function unique(values: string[]): string[] { return [...new Set(values)];}