Something went wrong. Try again.
An agent harness for spec-driven development.
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233import fs from "node:fs/promises";import http from "node:http";import path from "node:path";import { URL } from "node:url";import { buildAgentPacket, buildAgentRequest, runAgentCommand } from "./agent-harness.js";import { buildEventsFromChanges, buildWorkItemsFromEvents, listSpecChanges, matchingConsumersForEvent,} from "./changes.js";import { createCharterDatabase } from "./db/factory.js";import { buildSpecIndex, searchSpecIndex } from "./indexer.js";import { documentToRow, buildImportPlan } from "./spec-files.js";import type { AgentConsumerRow, AgentConsumerStatus, AgentPacketRow, AgentRequestRow, AgentRequestStatus, CharterDatabaseSnapshot, ProjectEventRow, SpecChange, SpecImportPlan, SpecMarkdownDocument, WorkItemRow, WorkItemStatus,} from "./types.js";
export interface CharterServerOptions { projectRoot?: string; specDir?: string;}
export interface CharterListenOptions extends CharterServerOptions { host?: string; port?: number; watch?: boolean; watchIntervalMs?: number; log?: CharterLogSink;}
export type CharterLogSink = (entry: CharterLogEntry) => void;
export type CharterLogLevel = "info" | "error";
export interface CharterLogEntry { ts: string; level: CharterLogLevel; event: string; [key: string]: unknown;}
type CharterLogEntryInput = Omit<CharterLogEntry, "ts"> & { level: CharterLogLevel; event: string };
export interface RunningSpecWatcher { ingestNow(reason?: string): Promise<SyncProjectionResult>; close(): void;}
export interface RunningCharterServer { server: http.Server; url: string; watcher?: RunningSpecWatcher;}
export interface ServerContext { projectRoot: string; specDir: string;}
interface StateBundle { plan: SpecImportPlan; snapshot: CharterDatabaseSnapshot; changes: SpecChange[];}
export interface SyncProjectionResult { synced: true; changes: ReturnType<typeof countChanges>; actionable: SpecChange[]; events: ProjectEventRow[]; workItems: WorkItemRow[]; eventsCreated: number; workItemsQueued: number; consumerMatches: number; snapshot: CharterDatabaseSnapshot;}
export function createCharterServer(options: CharterServerOptions = {}): http.Server { const context: ServerContext = { projectRoot: path.resolve(options.projectRoot ?? process.cwd()), specDir: options.specDir ?? "spec", };
return http.createServer((request, response) => { void handleRequest(context, request, response).catch((error) => { if (error instanceof HttpError) { sendJson(response, error.status, { error: error.message }); return; } const message = error instanceof Error ? error.message : String(error); sendJson(response, 500, { error: message }); }); });}
export async function startCharterServer(options: CharterListenOptions = {}): Promise<RunningCharterServer> { const context: ServerContext = { projectRoot: path.resolve(options.projectRoot ?? process.cwd()), specDir: options.specDir ?? "spec", }; const server = createCharterServer(context); const host = options.host ?? "127.0.0.1"; const port = options.port ?? 4173;
await new Promise<void>((resolve, reject) => { const onError = (error: Error) => { server.off("listening", onListening); reject(error); }; const onListening = () => { server.off("error", onError); resolve(); }; server.once("error", onError); server.once("listening", onListening); server.listen(port, host); });
const address = server.address(); const actualPort = typeof address === "object" && address ? address.port : port; const watchOptions = { ...(options.watchIntervalMs === undefined ? {} : { intervalMs: options.watchIntervalMs }), ...(options.log === undefined ? {} : { log: options.log }), }; const watcher = options.watch ? startSpecWatcher(context, watchOptions) : undefined; if (watcher) server.once("close", () => watcher.close()); return { server, url: `http://${host}:${actualPort}`, ...(watcher ? { watcher } : {}) };}
async function handleRequest(context: ServerContext, request: http.IncomingMessage, response: http.ServerResponse): Promise<void> { const method = request.method ?? "GET"; const requestUrl = new URL(request.url ?? "/", "http://charter.local"); const segments = requestUrl.pathname.split("/").filter(Boolean).map(decodeURIComponent);
if (method === "GET" && requestUrl.pathname === "/") { sendHtml(response, renderAppHtml()); return; }
if (method === "GET" && requestUrl.pathname === "/healthz") { sendJson(response, 200, { ok: true }); return; }
if (segments[0] !== "api") { sendJson(response, 404, { error: "Not found" }); return; }
if (segments.length === 2 && segments[1] === "project" && method === "GET") { const bundle = await loadState(context); sendJson(response, 200, projectSummary(context, bundle)); return; }
if (segments.length === 2 && segments[1] === "documents" && method === "GET") { const bundle = await loadState(context); sendJson(response, 200, { documents: documentSummaries(bundle), changes: actionableSummary(bundle.changes), }); return; }
if (segments.length === 2 && segments[1] === "documents" && method === "POST") { const body = await readJsonBody(request); const result = await createDocument(context, body); sendJson(response, 201, result); return; }
if (segments.length === 3 && segments[1] === "documents") { const documentId = segments[2]; if (!documentId) { sendJson(response, 400, { error: "Missing document id" }); return; } if (method === "GET") { const result = await getDocument(context, documentId); if (!result) { sendJson(response, 404, { error: `Unknown document: ${documentId}` }); return; } sendJson(response, 200, result); return; } if (method === "PUT") { const result = await updateDocument(context, documentId, await readJsonBody(request)); sendJson(response, 200, result); return; } if (method === "DELETE") { const result = await deleteDocumentProjection(context, documentId); sendJson(response, 200, result); return; } }
if (segments.length === 4 && segments[1] === "documents" && (segments[3] === "spans" || segments[3] === "comments") && method === "POST") { sendJson(response, 501, { error: `${segments[3]} are specified but not implemented in the current runtime slice` }); return; }
if (segments.length === 4 && segments[1] === "comments" && segments[3] === "actions" && method === "POST") { sendJson(response, 501, { error: "comment actions are specified but not implemented in the current runtime slice" }); return; }
if (segments.length === 2 && segments[1] === "changes" && method === "GET") { const bundle = await loadState(context); sendJson(response, 200, { changes: bundle.changes, actionable: actionableSummary(bundle.changes) }); return; }
if (segments.length === 2 && segments[1] === "sync" && method === "POST") { const result = await syncProjection(context); sendJson(response, 200, result); return; }
if (segments.length === 2 && segments[1] === "events" && method === "GET") { const snapshot = await loadSnapshot(context); sendJson(response, 200, { events: snapshot.events }); return; }
if (segments.length === 2 && (segments[1] === "work-items" || segments[1] === "work") && method === "GET") { const snapshot = await loadSnapshot(context); sendJson(response, 200, { workItems: Object.values(snapshot.workItems).sort(compareByUpdatedAtThenId) }); return; }
if (segments.length === 3 && segments[1] === "work-items" && method === "GET") { const snapshot = await loadSnapshot(context); const item = snapshot.workItems[segments[2] ?? ""]; if (!item) { sendJson(response, 404, { error: `Unknown work item: ${segments[2] ?? ""}` }); return; } sendJson(response, 200, { workItem: item }); return; }
if (segments.length === 4 && segments[1] === "work-items" && segments[3] === "ignore" && method === "POST") { const body = await readJsonBody(request); const result = await updateWorkItemStatus(context, segments[2] ?? "", "ignored", body); sendJson(response, 200, result); return; }
if (segments.length === 4 && segments[1] === "work-items" && segments[3] === "retry" && method === "POST") { const result = await updateWorkItemStatus(context, segments[2] ?? "", "queued", await readJsonBody(request)); sendJson(response, 200, result); return; }
if (segments.length === 4 && segments[1] === "work-items" && segments[3] === "run" && method === "POST") { const body = await readJsonBody(request); const result = await createRequestForWorkItem(context, segments[2] ?? "", body); sendJson(response, 201, result); return; }
if (segments.length === 2 && segments[1] === "agent" && method === "GET") { sendJson(response, 200, { routes: ["POST /api/agent/next"] }); return; }
if (segments.length === 3 && segments[1] === "agent" && segments[2] === "next" && method === "POST") { const body = await readJsonBody(request); const result = await createNextAgentRequest(context, body); sendJson(response, 201, result); return; }
if (segments.length === 2 && segments[1] === "requests" && method === "GET") { const snapshot = await loadSnapshot(context); sendJson(response, 200, { requests: Object.values(snapshot.requests).sort(compareByUpdatedAtThenId) }); return; }
if (segments.length === 3 && segments[1] === "requests" && method === "GET") { const snapshot = await loadSnapshot(context); const requestRow = snapshot.requests[segments[2] ?? ""]; if (!requestRow) { sendJson(response, 404, { error: `Unknown request: ${segments[2] ?? ""}` }); return; } sendJson(response, 200, { request: requestRow }); return; }
if (segments.length === 2 && segments[1] === "packets" && method === "GET") { const snapshot = await loadSnapshot(context); sendJson(response, 200, { packets: Object.values(snapshot.packets).sort(compareByUpdatedAtThenId) }); return; }
if (segments.length === 4 && segments[1] === "requests" && segments[3] === "packet" && method === "GET") { const result = await inspectRequestPacket(context, segments[2] ?? ""); if (!result) { sendJson(response, 404, { error: `Unknown request: ${segments[2] ?? ""}` }); return; } sendJson(response, 200, result); return; }
if (segments.length === 3 && segments[1] === "packets" && method === "GET") { const result = await inspectRequestPacket(context, segments[2] ?? ""); if (!result) { sendJson(response, 404, { error: `No materialized packet or request prompt found for: ${segments[2] ?? ""}` }); return; } sendJson(response, 200, result); return; }
if (segments.length === 2 && segments[1] === "consumers" && method === "GET") { const snapshot = await loadSnapshot(context); sendJson(response, 200, { consumers: Object.values(snapshot.consumers).sort(compareByUpdatedAtThenId), }); return; }
if (segments.length === 2 && segments[1] === "consumers" && method === "POST") { const result = await upsertConsumer(context, await readJsonBody(request), "consumer.registered"); sendJson(response, 201, result); return; }
if (segments.length === 4 && segments[1] === "consumers" && segments[3] === "match-queued" && method === "POST") { const result = await matchQueuedWorkForConsumer(context, segments[2] ?? ""); sendJson(response, 200, result); return; }
if (segments.length === 3 && segments[1] === "consumers" && method === "GET") { const snapshot = await loadSnapshot(context); const consumer = snapshot.consumers[segments[2] ?? ""]; if (!consumer) { sendJson(response, 404, { error: `Unknown consumer: ${segments[2] ?? ""}` }); return; } sendJson(response, 200, { consumer }); return; }
if (segments.length === 3 && segments[1] === "consumers" && (method === "PUT" || method === "PATCH")) { const result = await upsertConsumer(context, { ...(await readJsonBody(request) as Record<string, unknown>), id: segments[2] }, "consumer.updated"); sendJson(response, 200, result); return; }
if (segments.length === 2 && segments[1] === "search" && method === "GET") { const query = requestUrl.searchParams.get("q") ?? ""; const plan = await buildImportPlan({ projectRoot: context.projectRoot, specDir: context.specDir }); const index = buildSpecIndex(plan); sendJson(response, 200, { query, results: query.trim() ? searchSpecIndex(index, query) : [] }); return; }
sendJson(response, 404, { error: "Not found" });}
async function loadState(context: ServerContext): Promise<StateBundle> { const plan = await buildImportPlan({ projectRoot: context.projectRoot, specDir: context.specDir }); const snapshot = await loadSnapshot(context); const changes = listSpecChanges(plan, snapshot); return { plan, snapshot, changes };}
async function loadSnapshot(context: ServerContext): Promise<CharterDatabaseSnapshot> { return createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }).loadSnapshot();}
function projectSummary(context: ServerContext, bundle: StateBundle) { const workItems = Object.values(bundle.snapshot.workItems); const requests = Object.values(bundle.snapshot.requests); const packets = Object.values(bundle.snapshot.packets); const consumers = Object.values(bundle.snapshot.consumers); return { projectId: bundle.snapshot.projectId, projectRoot: context.projectRoot, specDir: context.specDir, specRoot: bundle.plan.specRoot, updatedAt: bundle.snapshot.updatedAt, documents: { current: bundle.plan.rows.length, materialized: Object.keys(bundle.snapshot.documents).length, }, changes: countChanges(bundle.changes), events: bundle.snapshot.events.length, workItems: countBy(workItems, (item) => item.status), consumers: countBy(consumers, (consumer) => consumer.status), requests: countBy(requests, (request) => request.status), packets: countBy(packets, (packet) => packet.status), adapter: process.env.CHARTER_DB_ADAPTER === "json" ? "json" : "jazz", };}
function documentSummaries(bundle: StateBundle) { const changeByDocumentId = new Map(bundle.changes.map((change) => [change.documentId, change])); return bundle.plan.rows.map((row) => ({ ...row, body: undefined, frontmatter: safeJson(row.frontmatterJson), headings: safeJson(row.headingsJson), change: changeByDocumentId.get(row.id)?.type ?? "unchanged", materializedSha256: bundle.snapshot.documents[row.id]?.sha256, }));}
async function getDocument(context: ServerContext, documentId: string) { const bundle = await loadState(context); const document = bundle.plan.documents.find((candidate) => candidate.id === documentId); const snapshotRow = bundle.snapshot.documents[documentId]; const change = bundle.changes.find((candidate) => candidate.documentId === documentId); if (!document && !snapshotRow) return undefined; return { document: document ? documentResponse(document) : undefined, materialized: snapshotRow, change: change?.type ?? "unchanged", };}
async function createDocument(context: ServerContext, body: unknown) { const record = objectBody(body); const id = requiredString(record, "id"); const title = optionalString(record.title) ?? id; const kind = optionalString(record.kind) ?? "note"; const status = optionalString(record.status) ?? "draft"; const relativePath = optionalString(record.path) ?? `${id}.md`; const raw = optionalString(record.raw) ?? `---\nid: ${id}\ntitle: ${title}\nkind: ${kind}\nstatus: ${status}\n---\n\n# ${title}\n`; const absolutePath = resolveSpecFile(context, relativePath);
try { await fs.stat(absolutePath); throw new HttpError(409, `Spec file already exists: ${relativePath}`); } catch (error) { if (!(error instanceof HttpError) && (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; if (error instanceof HttpError) throw error; }
await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, raw, "utf8"); return getDocument(context, id);}
async function updateDocument(context: ServerContext, documentId: string, body: unknown) { const record = objectBody(body); const raw = requiredString(record, "raw"); const bundle = await loadState(context); const existing = bundle.plan.documents.find((candidate) => candidate.id === documentId) ?? bundle.snapshot.documents[documentId]; const relativePath = optionalString(record.path) ?? existing?.path ?? `${documentId}.md`; const absolutePath = resolveSpecFile(context, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, raw, "utf8");
if (existing?.path && existing.path !== relativePath) { const oldPath = resolveSpecFile(context, existing.path); await fs.rm(oldPath, { force: true }); }
return getDocument(context, documentId);}
async function deleteDocumentProjection(context: ServerContext, documentId: string) { const bundle = await loadState(context); const existing = bundle.plan.documents.find((candidate) => candidate.id === documentId) ?? bundle.snapshot.documents[documentId]; if (!existing) throw new HttpError(404, `Unknown document: ${documentId}`); await fs.rm(resolveSpecFile(context, existing.path), { force: true }); return { deleted: documentId, path: existing.path, changes: (await loadState(context)).changes };}
async function syncProjection(context: ServerContext, options: { authority?: ProjectEventRow["authority"] } = {}): Promise<SyncProjectionResult> { const bundle = await loadState(context); const events = buildEventsFromChanges( bundle.plan, bundle.changes, options.authority === undefined ? {} : { authority: options.authority }, ); const workItems = buildWorkItemsFromEvents(bundle.plan, events, bundle.changes, Object.values(bundle.snapshot.consumers)); const db = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); await db.loadSnapshot(); for (const row of bundle.plan.rows) await db.upsertDocument(row); for (const change of bundle.changes.filter((candidate) => candidate.type === "deleted")) await db.deleteDocument(change.documentId); for (const event of events) await db.appendEvent(event); for (const item of workItems) { const existing = bundle.snapshot.workItems[item.id]; const merged: WorkItemRow = existing ? { ...existing, path: item.path, title: item.title, authority: item.authority, attachedDocumentIds: item.attachedDocumentIds, matchedConsumerIds: item.matchedConsumerIds, } : item; if (item.sourceCoValueId !== undefined) merged.sourceCoValueId = item.sourceCoValueId; else delete merged.sourceCoValueId; if (item.sourceField !== undefined) merged.sourceField = item.sourceField; else delete merged.sourceField; await db.upsertWorkItem(merged); if (item.matchedConsumerIds.length > 0) await db.appendEvent(consumerMatchedEvent(item)); } await db.saveSnapshot();
return { synced: true, changes: countChanges(bundle.changes), actionable: actionableSummary(bundle.changes), events, workItems, eventsCreated: events.length, workItemsQueued: workItems.length, consumerMatches: workItems.reduce((count, item) => count + item.matchedConsumerIds.length, 0), snapshot: await db.loadSnapshot(), };}
export function startSpecWatcher(context: ServerContext, options: { intervalMs?: number; log?: CharterLogSink } = {}): RunningSpecWatcher { const intervalMs = options.intervalMs ?? 750; const log = options.log ?? (() => undefined); let closed = false; let running = false; let pendingReason: string | undefined;
const emit = (entry: CharterLogEntryInput) => { log({ ts: new Date().toISOString(), ...entry }); };
const ingest = async (reason = "poll"): Promise<SyncProjectionResult> => { if (closed) throw new Error("Charter spec watcher is closed"); if (running) { pendingReason = reason; return { synced: true, changes: { added: 0, modified: 0, deleted: 0, unchanged: 0 }, actionable: [], events: [], workItems: [], eventsCreated: 0, workItemsQueued: 0, consumerMatches: 0, snapshot: await loadSnapshot(context), }; }
running = true; try { const result = await syncProjection(context, { authority: "human.file" }); logSyncResult(emit, result, reason); return result; } catch (error) { emit({ level: "error", event: "watch.ingest_failed", reason, message: error instanceof Error ? error.message : String(error), }); throw error; } finally { running = false; const nextReason = pendingReason; pendingReason = undefined; if (nextReason && !closed) void ingest(nextReason).catch(() => undefined); } };
emit({ level: "info", event: "watch.started", projectRoot: context.projectRoot, specDir: context.specDir, intervalMs, }); void ingest("startup").catch(() => undefined); const timer = setInterval(() => { void ingest("poll").catch(() => undefined); }, intervalMs);
return { ingestNow: ingest, close() { if (closed) return; closed = true; clearInterval(timer); emit({ level: "info", event: "watch.stopped" }); }, };}
function logSyncResult(emit: (entry: CharterLogEntryInput) => void, result: SyncProjectionResult, reason: string): void { if (result.actionable.length === 0) { if (reason === "startup") { emit({ level: "info", event: "sync.idle", reason, documents: Object.keys(result.snapshot.documents).length, queuedWorkItems: Object.values(result.snapshot.workItems).filter((item) => item.status === "queued").length, }); } return; }
emit({ level: "info", event: "sync.applied", reason, changes: result.changes, eventsCreated: result.eventsCreated, workItemsQueued: result.workItemsQueued, consumerMatches: result.consumerMatches, }); for (const change of result.actionable) { emit({ level: "info", event: "spec.change", changeType: change.type, documentId: change.documentId, path: change.path, previousSha256: change.previousSha256, currentSha256: change.currentSha256, }); } for (const projectEvent of result.events) { emit({ level: "info", event: "project.event", projectEventId: projectEvent.id, projectEventType: projectEvent.type, documentId: projectEvent.documentId, path: projectEvent.path, authority: projectEvent.authority, sha256: projectEvent.sha256, }); } for (const item of result.workItems) { emit({ level: "info", event: "work.queued", workItemId: item.id, documentId: item.documentId, path: item.path, matchedConsumerIds: item.matchedConsumerIds, }); }}
async function upsertConsumer(context: ServerContext, body: unknown, eventType: "consumer.registered" | "consumer.updated") { const record = objectBody(body); const id = requiredString(record, "id"); const now = new Date().toISOString(); const snapshot = await loadSnapshot(context); const previous = snapshot.consumers[id]; const runnerCommand = optionalString(record.runnerCommand) ?? optionalString(record.command) ?? previous?.runnerCommand; const consumer: AgentConsumerRow = { id, name: optionalString(record.name) ?? previous?.name ?? id, status: optionalConsumerStatus(record.status) ?? previous?.status ?? "active", runnerKind: optionalRunnerKind(record.runnerKind) ?? previous?.runnerKind ?? "custom", runnerConfigJson: jsonStringField(record.runnerConfigJson, record.runnerConfig, previous?.runnerConfigJson ?? "{}"), eventTypes: stringArrayField(record.eventTypes, previous?.eventTypes ?? ["spec.document.upserted", "spec.document.deleted"]), documentSelectors: stringArrayField(record.documentSelectors, previous?.documentSelectors ?? ["**/*.md"]), promptDocumentIds: stringArrayField(record.promptDocumentIds, previous?.promptDocumentIds ?? []), defaultContextDocumentIds: stringArrayField(record.defaultContextDocumentIds, previous?.defaultContextDocumentIds ?? []), cursorJson: jsonStringField(record.cursorJson, record.cursor, previous?.cursorJson ?? "{}"), createdAt: previous?.createdAt ?? now, updatedAt: now, ...(runnerCommand ? { runnerCommand } : {}), };
const db = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); await db.loadSnapshot(); const event = consumerEvent(consumer, previous ? "consumer.updated" : eventType); await db.upsertAgentConsumer(consumer); await db.appendEvent(event); await db.saveSnapshot();
const matchResult = record.matchExistingQueuedWork === true ? await rematchQueuedWorkForConsumer(context, consumer) : { matchedWorkItemIds: [], matchEventsCreated: 0 };
return { consumer, eventId: event.id, matchedWorkItemIds: matchResult.matchedWorkItemIds, matchEventsCreated: matchResult.matchEventsCreated, };}
async function matchQueuedWorkForConsumer(context: ServerContext, consumerId: string) { const snapshot = await loadSnapshot(context); const consumer = snapshot.consumers[consumerId]; if (!consumer) throw new HttpError(404, `Unknown consumer: ${consumerId}`); const matchResult = await rematchQueuedWorkForConsumer(context, consumer); return { consumer, ...matchResult };}
async function rematchQueuedWorkForConsumer(context: ServerContext, consumer: AgentConsumerRow) { const db = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); const snapshot = await db.loadSnapshot(); const eventById = new Map(snapshot.events.map((event) => [event.id, event])); const matchedWorkItemIds: string[] = []; let matchEventsCreated = 0;
for (const item of Object.values(snapshot.workItems).sort(compareByCreatedAtThenId)) { if (item.status !== "queued") continue; if (item.matchedConsumerIds.includes(consumer.id)) continue; const event = eventById.get(item.eventId); if (!event) continue; if (matchingConsumersForEvent(event, [consumer]).length === 0) continue;
const matchedConsumerIds = uniqueStrings([...item.matchedConsumerIds, consumer.id]); const matchedItem: WorkItemRow = { ...item, matchedConsumerIds }; await db.updateWorkItemStatus(item.id, item.status, { matchedConsumerIds }); await db.appendEvent(consumerMatchedEvent(matchedItem)); matchedWorkItemIds.push(item.id); matchEventsCreated += 1; }
await db.saveSnapshot(); return { matchedWorkItemIds, matchEventsCreated };}
async function createNextAgentRequest(context: ServerContext, body: unknown) { const snapshot = await loadSnapshot(context); const status = optionalWorkStatus(objectBody(body).status) ?? "queued"; const next = Object.values(snapshot.workItems) .filter((item) => item.status === status) .sort(compareByCreatedAtThenId)[0]; if (!next) throw new HttpError(404, `No ${status} work item is available`); return createRequest(context, [next], body);}
async function createRequestForWorkItem(context: ServerContext, workItemId: string, body: unknown) { const snapshot = await loadSnapshot(context); const item = snapshot.workItems[workItemId]; if (!item) throw new HttpError(404, `Unknown work item: ${workItemId}`); return createRequest(context, [item], body);}
async function createRequest(context: ServerContext, workItems: WorkItemRow[], body: unknown) { const record = objectBody(body); const snapshot = await loadSnapshot(context); const consumer = resolveConsumerForWorkItems(snapshot, workItems, optionalString(record.consumerId)); const bundle = await loadState(context); const extraDocumentIds = stringArrayField(record.attachedDocumentIds, []); const consumerDocumentIds = consumer ? [...consumer.promptDocumentIds, ...consumer.defaultContextDocumentIds] : []; const command = optionalString(record.command) ?? optionalString(record.agentCommand) ?? consumer?.runnerCommand ?? ""; const requestRow = buildAgentRequest({ plan: bundle.plan, changes: bundle.changes, command, workItems, attachedDocumentIds: uniqueStrings([...extraDocumentIds, ...consumerDocumentIds]), ...(consumer ? { consumerId: consumer.id } : {}), }); const packet = buildAgentPacket({ request: requestRow, workItems, documents: snapshot.documents, promptDocumentIds: consumer?.promptDocumentIds ?? [], });
const db = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); await db.loadSnapshot(); await db.upsertAgentPacket(packet); await db.upsertAgentRequest(requestRow); await db.appendEvent(agentPacketCreatedEvent(packet)); await db.appendEvent(agentRequestEvent(requestRow, "agent.request.created")); for (const item of workItems) { await db.updateWorkItemStatus(item.id, item.status, { requestId: requestRow.id }); } await db.saveSnapshot();
if (record.exec === true) return executeAgentRequest(context, requestRow, packet, workItems); return { request: requestRow, packet, workItems };}
function resolveConsumerForWorkItems(snapshot: CharterDatabaseSnapshot, workItems: WorkItemRow[], explicitConsumerId: string | undefined): AgentConsumerRow | undefined { if (explicitConsumerId) { const consumer = snapshot.consumers[explicitConsumerId]; if (!consumer) throw new HttpError(404, `Unknown consumer: ${explicitConsumerId}`); return consumer; } const matchedId = workItems.flatMap((item) => item.matchedConsumerIds ?? []) .find((consumerId) => snapshot.consumers[consumerId]?.status === "active"); return matchedId ? snapshot.consumers[matchedId] : undefined;}
async function executeAgentRequest(context: ServerContext, request: AgentRequestRow, packet: AgentPacketRow, workItems: WorkItemRow[]) { if (!request.command) throw new HttpError(400, "exec requires a consumer runnerCommand or command");
const executingDb = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); await executingDb.loadSnapshot(); await executingDb.updateAgentRequestStatus(request.id, "executing"); await executingDb.updateAgentPacketStatus(packet.id, "executing"); await executingDb.appendEvent(agentRequestEvent({ ...request, status: "executing", updatedAt: new Date().toISOString() }, "agent.request.executing")); for (const item of workItems) { await executingDb.updateWorkItemStatus(item.id, "running", { requestId: request.id, claimedBy: request.consumerId ?? "charter.agent", claimedAt: request.createdAt, }); } await executingDb.saveSnapshot();
const result = await runAgentCommand({ command: request.command, projectRoot: context.projectRoot, request }); const resultDb = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); const resultSnapshot = await resultDb.loadSnapshot(); const latestRequest = resultSnapshot.requests[request.id]; if (!latestRequest) throw new HttpError(500, `Agent request disappeared during execution: ${request.id}`);
const outputFields = { exitCode: result.exitCode, stdoutText: result.stdoutText, stderrText: result.stderrText, outputText: result.outputText, resultText: latestRequest.resultText ?? fallbackResultText(result.exitCode, result.outputText), };
let requestStatus: AgentRequestStatus; let packetStatus: AgentPacketRow["status"]; if (latestRequest.reportedAt) { requestStatus = latestRequest.status; packetStatus = latestRequest.reportStatus === "ignored" ? "ignored" : latestRequest.status; } else { requestStatus = result.exitCode === 0 ? "executed" : "failed"; packetStatus = result.exitCode === 0 ? "executed" : "failed"; }
await resultDb.updateAgentRequestStatus(request.id, requestStatus, outputFields); await resultDb.updateAgentPacketStatus(packet.id, packetStatus, outputFieldsForPacket(outputFields)); await resultDb.appendEvent(agentRequestEvent({ ...latestRequest, status: requestStatus }, requestEventTypeForStatus(requestStatus)));
if (!latestRequest.reportedAt) { const workStatus: WorkItemStatus = result.exitCode === 0 ? "done" : "failed"; for (const item of workItems) { if (resultSnapshot.workItems[item.id]) { await resultDb.updateWorkItemStatus(item.id, workStatus, { resultText: outputFields.resultText }); } } } await resultDb.saveSnapshot();
const finalSnapshot = await resultDb.loadSnapshot(); return { request: finalSnapshot.requests[request.id], packet: finalSnapshot.packets[packet.id], result, workItems: workItems.map((item) => finalSnapshot.workItems[item.id]).filter((item): item is WorkItemRow => item !== undefined), };}
function outputFieldsForPacket(fields: { resultText: string; outputText: string; stdoutText: string; stderrText: string; exitCode: number }) { return { resultText: fields.resultText, stdoutText: fields.stdoutText, stderrText: fields.stderrText, outputText: fields.outputText, exitCode: fields.exitCode, outputContract: `exit=${fields.exitCode}; ${fields.resultText}`.slice(0, 2000), };}
async function updateWorkItemStatus(context: ServerContext, workItemId: string, status: WorkItemStatus, body: unknown) { const record = objectBody(body); const message = optionalString(record.message); const db = createCharterDatabase({ projectRoot: context.projectRoot, projectId: "charter" }); await db.loadSnapshot(); await db.updateWorkItemStatus(workItemId, status, message ? { resultText: message, reportedAt: new Date().toISOString() } : {}); await db.saveSnapshot(); return { workItem: (await db.loadSnapshot()).workItems[workItemId] };}
async function inspectRequestPacket(context: ServerContext, requestOrPacketId: string) { const snapshot = await loadSnapshot(context); const requestRow = snapshot.requests[requestOrPacketId]; const packet = snapshot.packets[requestOrPacketId] ?? (requestRow?.packetId ? snapshot.packets[requestRow.packetId] : undefined); const request = requestRow ?? (packet?.requestId ? snapshot.requests[packet.requestId] : undefined); if (!packet && !request) return undefined;
const workItemIds = packet?.workItemIds ?? request?.workItemIds ?? []; const attachedDocumentIds = packet?.attachedDocumentIds ?? request?.attachedDocumentIds ?? []; const workItems = workItemIds.map((id) => snapshot.workItems[id]).filter((item): item is WorkItemRow => item !== undefined); const documents = attachedDocumentIds.map((id) => snapshot.documents[id]).filter((row) => row !== undefined); return { packet: packet ?? { id: request?.packetId ?? request?.id, requestId: request?.id, command: request?.command ?? "", promptSha256: request?.promptSha256 ?? "", workItemIds, eventIds: workItems.map((item) => item.eventId), attachedDocumentIds, prompt: request?.prompt ?? "", }, request, workItems, documents, };}
function documentResponse(document: SpecMarkdownDocument) { return { id: document.id, path: document.path, title: document.title, kind: document.kind, status: document.status ?? "draft", frontmatter: document.frontmatter, body: document.body, raw: document.raw, headings: document.headings, sha256: document.sha256, sizeBytes: document.sizeBytes, mtimeMs: document.mtimeMs, row: documentToRow(document), };}
function resolveSpecFile(context: ServerContext, relativePath: string): string { const specRoot = path.resolve(context.projectRoot, context.specDir); const absolutePath = path.resolve(specRoot, relativePath); const prefix = `${specRoot}${path.sep}`; if (absolutePath !== specRoot && !absolutePath.startsWith(prefix)) { throw new HttpError(400, `Path escapes spec directory: ${relativePath}`); } if (!absolutePath.endsWith(".md")) throw new HttpError(400, `Spec document path must end in .md: ${relativePath}`); return absolutePath;}
async function readJsonBody(request: http.IncomingMessage): Promise<unknown> { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); const raw = Buffer.concat(chunks).toString("utf8").trim(); if (!raw) return {}; try { return JSON.parse(raw); } catch { throw new HttpError(400, "Request body must be JSON"); }}
function sendJson(response: http.ServerResponse, status: number, value: unknown): void { if (value instanceof HttpError) { response.writeHead(value.status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(`${JSON.stringify({ error: value.message })}\n`); return; } response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(`${JSON.stringify(value, jsonReplacer, 2)}\n`);}
function sendHtml(response: http.ServerResponse, html: string): void { response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); response.end(html);}
function jsonReplacer(_key: string, value: unknown): unknown { return value === undefined ? undefined : value;}
function objectBody(value: unknown): Record<string, unknown> { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return value as Record<string, unknown>;}
function requiredString(record: Record<string, unknown>, key: string): string { const value = optionalString(record[key]); if (!value) throw new HttpError(400, `Missing string field: ${key}`); return value;}
function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined;}
function optionalWorkStatus(value: unknown): WorkItemStatus | undefined { if (["queued", "claimed", "running", "done", "ignored", "failed", "blocked"].includes(String(value))) return value as WorkItemStatus; return undefined;}
function optionalConsumerStatus(value: unknown): AgentConsumerStatus | undefined { if (["active", "paused", "archived"].includes(String(value))) return value as AgentConsumerStatus; return undefined;}
function optionalRunnerKind(value: unknown): AgentConsumerRow["runnerKind"] | undefined { if (["letta", "codex", "claude-code", "script", "custom"].includes(String(value))) return value as AgentConsumerRow["runnerKind"]; return undefined;}
function stringArrayField(value: unknown, fallback: string[]): string[] { if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && item.length > 0); if (typeof value === "string" && value.trim().length > 0) { return value.split(",").map((item) => item.trim()).filter(Boolean); } return fallback;}
function jsonStringField(raw: unknown, structured: unknown, fallback: string): string { if (typeof raw === "string" && raw.trim().length > 0) { try { JSON.parse(raw); return raw; } catch { throw new HttpError(400, "JSON string field must contain valid JSON"); } } if (structured !== undefined) return JSON.stringify(structured); return fallback;}
function uniqueStrings(values: string[]): string[] { return [...new Set(values)];}
function consumerEvent(consumer: AgentConsumerRow, type: "consumer.registered" | "consumer.updated"): ProjectEventRow { const occurredAt = new Date().toISOString(); return { id: `event:${type}:${consumer.id}:${occurredAt}`, type, documentId: consumer.id, path: `consumers/${consumer.id}`, occurredAt, authority: "human.ui", sourceField: "consumer", };}
function consumerMatchedEvent(item: WorkItemRow): ProjectEventRow { const occurredAt = new Date().toISOString(); return { id: `event:consumer.matched:${item.id}:${item.matchedConsumerIds.join(",")}:${occurredAt}`, type: "consumer.matched", documentId: item.documentId, path: item.path, occurredAt, authority: "charter.system", sourceField: "matchedConsumerIds", };}
function agentPacketCreatedEvent(packet: AgentPacketRow): ProjectEventRow { return { id: `event:agent.packet.created:${packet.id}:${packet.createdAt}`, type: "agent.packet.created", documentId: packet.id, path: `packets/${packet.id}`, occurredAt: packet.createdAt, authority: "charter.system", sourceField: "packet", };}
function agentRequestEvent(request: AgentRequestRow, type: ProjectEventRow["type"]): ProjectEventRow { return { id: `event:${type}:${request.id}:${new Date().toISOString()}`, type, documentId: request.id, path: `requests/${request.id}`, occurredAt: new Date().toISOString(), authority: "charter.agent", sourceField: "request", };}
function requestEventTypeForStatus(status: AgentRequestStatus): ProjectEventRow["type"] { if (status === "executed") return "agent.request.executed"; if (status === "failed") return "agent.request.failed"; if (status === "blocked") return "agent.request.blocked"; if (status === "executing") return "agent.request.executing"; return "agent.request.created";}
function fallbackResultText(exitCode: number, outputText: string): string { const trimmed = outputText.trim(); if (trimmed.length > 0) return trimmed.slice(0, 2000); return exitCode === 0 ? "Agent command completed successfully." : `Agent command failed with exit code ${exitCode}.`;}
function safeJson(value: string): unknown { try { return JSON.parse(value); } catch { return undefined; }}
function countChanges(changes: SpecChange[]) { return { added: changes.filter((change) => change.type === "added").length, modified: changes.filter((change) => change.type === "modified").length, deleted: changes.filter((change) => change.type === "deleted").length, unchanged: changes.filter((change) => change.type === "unchanged").length, };}
function actionableSummary(changes: SpecChange[]): SpecChange[] { return changes.filter((change) => change.type !== "unchanged");}
function countBy<T, K extends string>(values: T[], select: (value: T) => K): Record<K, number> { const result = {} as Record<K, number>; for (const value of values) { const key = select(value); result[key] = (result[key] ?? 0) + 1; } return result;}
function compareByCreatedAtThenId(left: WorkItemRow, right: WorkItemRow): number { return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);}
function compareByUpdatedAtThenId<T extends { id: string; updatedAt: string }>(left: T, right: T): number { return right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id);}
class HttpError extends Error { constructor(readonly status: number, message: string) { super(message); }}
function renderAppHtml(): string { return String.raw`<!doctype html><html lang="en"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Charter</title> <style> :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #11110f; color: #f4f1e8; } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at 20% 0%, rgba(255,255,255,.08), transparent 28rem), linear-gradient(180deg, #151512, #0f0f0d); } main { width: min(760px, calc(100vw - 40px)); border: 1px solid rgba(244, 241, 232, .16); background: rgba(21, 21, 18, .88); padding: clamp(28px, 5vw, 52px); box-shadow: 0 24px 80px rgba(0,0,0,.35); } p.eyebrow { margin: 0 0 18px; color: #9d988d; font: 700 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .18em; text-transform: uppercase; } h1 { margin: 0; font-size: clamp(42px, 9vw, 82px); line-height: .9; letter-spacing: -.07em; } p { max-width: 58ch; color: #c9c2b4; font-size: 16px; line-height: 1.6; } code { color: #f4f1e8; background: rgba(255,255,255,.08); padding: 2px 5px; border-radius: 4px; } .commands { display: grid; gap: 10px; margin-top: 28px; font: 700 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } .commands div { border: 1px solid rgba(244, 241, 232, .12); background: rgba(255,255,255,.04); padding: 12px 14px; } </style></head><body> <main> <p class="eyebrow">Charter inspection API</p> <h1>Edit files. Watch events.</h1> <p>Charter no longer tries to be the editor. Use Zed, Obsidian, or the filesystem. Run <code>charter watch</code> and it will ingest Markdown changes under <code>spec/</code> into the project event stream. This HTTP surface is only for inspection.</p> <div class="commands"> <div>npm run watch</div> <div>npm run changes</div> <div>npm run events</div> <div>npm run queue</div> </div> </main></body></html>`;}