Something went wrong. Try again.
An agent harness for spec-driven development.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188import type { AgentConsumerRow, CharterDatabaseSnapshot, ProjectEventRow, SpecChange, SpecImportPlan, WorkItemRow } from "./types.js";
export function listSpecChanges(plan: SpecImportPlan, snapshot: CharterDatabaseSnapshot): SpecChange[] { const changes: SpecChange[] = []; const currentRows = new Map(plan.rows.map((row) => [row.id, row]));
for (const row of plan.rows) { const previous = snapshot.documents[row.id]; if (!previous) { changes.push({ type: "added", documentId: row.id, path: row.path, title: row.title, currentSha256: row.sha256, currentPath: row.path, }); continue; }
if (previous.sha256 !== row.sha256 || previous.path !== row.path) { changes.push({ type: "modified", documentId: row.id, path: row.path, title: row.title, previousSha256: previous.sha256, currentSha256: row.sha256, previousPath: previous.path, currentPath: row.path, }); continue; }
changes.push({ type: "unchanged", documentId: row.id, path: row.path, title: row.title, previousSha256: previous.sha256, currentSha256: row.sha256, previousPath: previous.path, currentPath: row.path, }); }
for (const [documentId, row] of Object.entries(snapshot.documents)) { if (currentRows.has(documentId)) continue; changes.push({ type: "deleted", documentId, path: row.path, title: row.title, previousSha256: row.sha256, previousPath: row.path, }); }
return changes.sort((left, right) => { const order = changeOrder(left.type) - changeOrder(right.type); return order === 0 ? left.documentId.localeCompare(right.documentId) : order; });}
export function actionableChanges(changes: SpecChange[]): SpecChange[] { return changes.filter((change) => change.type !== "unchanged");}
export function buildEventsFromChanges( plan: SpecImportPlan, changes: SpecChange[], options: { authority?: ProjectEventRow["authority"] } = {},): ProjectEventRow[] { const rowById = new Map(plan.rows.map((row) => [row.id, row])); const authority = options.authority ?? "importer.sync";
return changes .filter((change) => change.type !== "unchanged") .map((change): ProjectEventRow => { if (change.type === "deleted") { const event: ProjectEventRow = { id: `${plan.generatedAt}:delete:${change.documentId}`, type: "spec.document.deleted", documentId: change.documentId, path: change.path, occurredAt: plan.generatedAt, authority, sourceField: "body", }; if (change.previousSha256 !== undefined) event.sha256 = change.previousSha256; return event; }
const row = rowById.get(change.documentId); if (!row) throw new Error(`Missing current row for ${change.documentId}`); return { id: `${plan.generatedAt}:upsert:${row.id}:${row.sha256.slice(0, 12)}`, type: "spec.document.upserted", documentId: row.id, path: row.path, sha256: row.sha256, occurredAt: plan.generatedAt, authority, sourceField: "body", }; });}
export function buildWorkItemsFromEvents( plan: SpecImportPlan, events: ProjectEventRow[], changes: SpecChange[], consumers: AgentConsumerRow[] = [],): WorkItemRow[] { const changeByDocumentId = new Map(changes.map((change) => [change.documentId, change])); const titleByDocumentId = new Map(plan.rows.map((row) => [row.id, row.title]));
return events.map((event): WorkItemRow => { const change = changeByDocumentId.get(event.documentId); const title = titleByDocumentId.get(event.documentId) ?? change?.title ?? event.documentId; const row: WorkItemRow = { id: `work:${event.id}`, kind: "spec-change", status: "queued", eventId: event.id, documentId: event.documentId, path: event.path, title, createdAt: event.occurredAt, updatedAt: event.occurredAt, authority: event.authority, attachedDocumentIds: [event.documentId], matchedConsumerIds: matchingConsumersForEvent(event, consumers).map((consumer) => consumer.id), }; if (event.sourceJazzRowId !== undefined) row.sourceJazzRowId = event.sourceJazzRowId; if (event.sourceField !== undefined) row.sourceField = event.sourceField; return row; });}
export function queuedWorkItems(snapshot: CharterDatabaseSnapshot): WorkItemRow[] { return Object.values(snapshot.workItems) .filter((item) => item.status === "queued") .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));}
export function matchingConsumersForEvent(event: ProjectEventRow, consumers: AgentConsumerRow[]): AgentConsumerRow[] { return consumers .filter((consumer) => consumer.status === "active") .filter((consumer) => eventTypeMatches(event, consumer)) .filter((consumer) => documentSelectorMatches(event, consumer)) .sort((left, right) => left.id.localeCompare(right.id));}
function eventTypeMatches(event: ProjectEventRow, consumer: AgentConsumerRow): boolean { return consumer.eventTypes.length === 0 || consumer.eventTypes.includes("*") || consumer.eventTypes.includes("all") || consumer.eventTypes.includes(event.type);}
function documentSelectorMatches(event: ProjectEventRow, consumer: AgentConsumerRow): boolean { if (consumer.documentSelectors.length === 0) return true; return consumer.documentSelectors.some((selector) => selectorMatchesDocument(selector, event.documentId, event.path));}
function selectorMatchesDocument(selector: string, documentId: string, documentPath: string): boolean { const normalized = selector.trim(); if (!normalized || normalized === "*" || normalized === "**" || normalized === "**/*.md") return true; if (normalized === documentId || normalized === documentPath) return true; if (normalized.endsWith("/**")) return documentPath.startsWith(normalized.slice(0, -2)); if (normalized.endsWith("/*")) { const prefix = normalized.slice(0, -1); const remainder = documentPath.slice(prefix.length); return documentPath.startsWith(prefix) && !remainder.includes("/"); } if (normalized.endsWith("*")) return documentPath.startsWith(normalized.slice(0, -1)); if (normalized.endsWith("/")) return documentPath.startsWith(normalized); return false;}
function changeOrder(type: SpecChange["type"]): number { if (type === "added") return 0; if (type === "modified") return 1; if (type === "deleted") return 2; return 3;}