An agent harness for spec-driven development.
0

Configure Feed

Select the types of activity you want to include in your feed.

charter / src / changes.ts
6.7 kB 187 lines
1import type { AgentConsumerRow, CharterDatabaseSnapshot, ProjectEventRow, SpecChange, SpecImportPlan, WorkItemRow } from "./types.js"; 2 3export function listSpecChanges(plan: SpecImportPlan, snapshot: CharterDatabaseSnapshot): SpecChange[] { 4 const changes: SpecChange[] = []; 5 const currentRows = new Map(plan.rows.map((row) => [row.id, row])); 6 7 for (const row of plan.rows) { 8 const previous = snapshot.documents[row.id]; 9 if (!previous) { 10 changes.push({ 11 type: "added", 12 documentId: row.id, 13 path: row.path, 14 title: row.title, 15 currentSha256: row.sha256, 16 currentPath: row.path, 17 }); 18 continue; 19 } 20 21 if (previous.sha256 !== row.sha256 || previous.path !== row.path) { 22 changes.push({ 23 type: "modified", 24 documentId: row.id, 25 path: row.path, 26 title: row.title, 27 previousSha256: previous.sha256, 28 currentSha256: row.sha256, 29 previousPath: previous.path, 30 currentPath: row.path, 31 }); 32 continue; 33 } 34 35 changes.push({ 36 type: "unchanged", 37 documentId: row.id, 38 path: row.path, 39 title: row.title, 40 previousSha256: previous.sha256, 41 currentSha256: row.sha256, 42 previousPath: previous.path, 43 currentPath: row.path, 44 }); 45 } 46 47 for (const [documentId, row] of Object.entries(snapshot.documents)) { 48 if (currentRows.has(documentId)) continue; 49 changes.push({ 50 type: "deleted", 51 documentId, 52 path: row.path, 53 title: row.title, 54 previousSha256: row.sha256, 55 previousPath: row.path, 56 }); 57 } 58 59 return changes.sort((left, right) => { 60 const order = changeOrder(left.type) - changeOrder(right.type); 61 return order === 0 ? left.documentId.localeCompare(right.documentId) : order; 62 }); 63} 64 65export function actionableChanges(changes: SpecChange[]): SpecChange[] { 66 return changes.filter((change) => change.type !== "unchanged"); 67} 68 69export function buildEventsFromChanges( 70 plan: SpecImportPlan, 71 changes: SpecChange[], 72 options: { authority?: ProjectEventRow["authority"] } = {}, 73): ProjectEventRow[] { 74 const rowById = new Map(plan.rows.map((row) => [row.id, row])); 75 const authority = options.authority ?? "importer.sync"; 76 77 return changes 78 .filter((change) => change.type !== "unchanged") 79 .map((change): ProjectEventRow => { 80 if (change.type === "deleted") { 81 const event: ProjectEventRow = { 82 id: `${plan.generatedAt}:delete:${change.documentId}`, 83 type: "spec.document.deleted", 84 documentId: change.documentId, 85 path: change.path, 86 occurredAt: plan.generatedAt, 87 authority, 88 sourceField: "body", 89 }; 90 if (change.previousSha256 !== undefined) event.sha256 = change.previousSha256; 91 return event; 92 } 93 94 const row = rowById.get(change.documentId); 95 if (!row) throw new Error(`Missing current row for ${change.documentId}`); 96 return { 97 id: `${plan.generatedAt}:upsert:${row.id}:${row.sha256.slice(0, 12)}`, 98 type: "spec.document.upserted", 99 documentId: row.id, 100 path: row.path, 101 sha256: row.sha256, 102 occurredAt: plan.generatedAt, 103 authority, 104 sourceField: "body", 105 }; 106 }); 107} 108 109export function buildWorkItemsFromEvents( 110 plan: SpecImportPlan, 111 events: ProjectEventRow[], 112 changes: SpecChange[], 113 consumers: AgentConsumerRow[] = [], 114): WorkItemRow[] { 115 const changeByDocumentId = new Map(changes.map((change) => [change.documentId, change])); 116 const titleByDocumentId = new Map(plan.rows.map((row) => [row.id, row.title])); 117 118 return events.map((event): WorkItemRow => { 119 const change = changeByDocumentId.get(event.documentId); 120 const title = titleByDocumentId.get(event.documentId) ?? change?.title ?? event.documentId; 121 const row: WorkItemRow = { 122 id: `work:${event.id}`, 123 kind: "spec-change", 124 status: "queued", 125 eventId: event.id, 126 documentId: event.documentId, 127 path: event.path, 128 title, 129 createdAt: event.occurredAt, 130 updatedAt: event.occurredAt, 131 authority: event.authority, 132 attachedDocumentIds: [event.documentId], 133 matchedConsumerIds: matchingConsumersForEvent(event, consumers).map((consumer) => consumer.id), 134 }; 135 if (event.sourceJazzRowId !== undefined) row.sourceJazzRowId = event.sourceJazzRowId; 136 if (event.sourceField !== undefined) row.sourceField = event.sourceField; 137 return row; 138 }); 139} 140 141export function queuedWorkItems(snapshot: CharterDatabaseSnapshot): WorkItemRow[] { 142 return Object.values(snapshot.workItems) 143 .filter((item) => item.status === "queued") 144 .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); 145} 146 147export function matchingConsumersForEvent(event: ProjectEventRow, consumers: AgentConsumerRow[]): AgentConsumerRow[] { 148 return consumers 149 .filter((consumer) => consumer.status === "active") 150 .filter((consumer) => eventTypeMatches(event, consumer)) 151 .filter((consumer) => documentSelectorMatches(event, consumer)) 152 .sort((left, right) => left.id.localeCompare(right.id)); 153} 154 155function eventTypeMatches(event: ProjectEventRow, consumer: AgentConsumerRow): boolean { 156 return consumer.eventTypes.length === 0 157 || consumer.eventTypes.includes("*") 158 || consumer.eventTypes.includes("all") 159 || consumer.eventTypes.includes(event.type); 160} 161 162function documentSelectorMatches(event: ProjectEventRow, consumer: AgentConsumerRow): boolean { 163 if (consumer.documentSelectors.length === 0) return true; 164 return consumer.documentSelectors.some((selector) => selectorMatchesDocument(selector, event.documentId, event.path)); 165} 166 167function selectorMatchesDocument(selector: string, documentId: string, documentPath: string): boolean { 168 const normalized = selector.trim(); 169 if (!normalized || normalized === "*" || normalized === "**" || normalized === "**/*.md") return true; 170 if (normalized === documentId || normalized === documentPath) return true; 171 if (normalized.endsWith("/**")) return documentPath.startsWith(normalized.slice(0, -2)); 172 if (normalized.endsWith("/*")) { 173 const prefix = normalized.slice(0, -1); 174 const remainder = documentPath.slice(prefix.length); 175 return documentPath.startsWith(prefix) && !remainder.includes("/"); 176 } 177 if (normalized.endsWith("*")) return documentPath.startsWith(normalized.slice(0, -1)); 178 if (normalized.endsWith("/")) return documentPath.startsWith(normalized); 179 return false; 180} 181 182function changeOrder(type: SpecChange["type"]): number { 183 if (type === "added") return 0; 184 if (type === "modified") return 1; 185 if (type === "deleted") return 2; 186 return 3; 187}