An agent harness for spec-driven development.
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}