ai-foreman 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/adapters/codex.js +1 -0
  2. package/dist/agentRun.d.ts +4 -2
  3. package/dist/agentRun.js +32 -9
  4. package/dist/branch/git.d.ts +3 -0
  5. package/dist/branch/git.js +31 -5
  6. package/dist/branch/github.d.ts +2 -2
  7. package/dist/branch/github.js +2 -2
  8. package/dist/branch/gitlab.d.ts +3 -2
  9. package/dist/branch/gitlab.js +10 -4
  10. package/dist/branch/planner.d.ts +3 -1
  11. package/dist/branch/planner.js +24 -2
  12. package/dist/branch/runner.d.ts +10 -1
  13. package/dist/branch/runner.js +41 -13
  14. package/dist/branch/types.d.ts +4 -0
  15. package/dist/buildRuns.d.ts +35 -0
  16. package/dist/buildRuns.js +149 -0
  17. package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
  18. package/dist/cli/runtimeAuthPrompt.js +2 -2
  19. package/dist/cli/start.js +153 -16
  20. package/dist/cli/status.d.ts +2 -0
  21. package/dist/cli/status.js +71 -55
  22. package/dist/cli/tickets.js +154 -57
  23. package/dist/foreman.d.ts +3 -1
  24. package/dist/foreman.js +46 -4
  25. package/dist/index.js +0 -0
  26. package/dist/interviews.d.ts +90 -0
  27. package/dist/interviews.js +251 -0
  28. package/dist/log.d.ts +1 -1
  29. package/dist/roles.d.ts +11 -0
  30. package/dist/roles.js +8 -0
  31. package/dist/runtimeAuth.d.ts +1 -1
  32. package/dist/runtimeAuth.js +6 -25
  33. package/dist/runtimeReadiness.d.ts +14 -0
  34. package/dist/runtimeReadiness.js +122 -0
  35. package/dist/ticketPlanning.d.ts +83 -0
  36. package/dist/ticketPlanning.js +287 -0
  37. package/dist/tickets/commands.js +16 -0
  38. package/dist/tickets/delivery.d.ts +38 -0
  39. package/dist/tickets/delivery.js +152 -0
  40. package/dist/tickets/importer.js +4 -0
  41. package/dist/tickets/renderMarkdown.js +9 -0
  42. package/dist/tickets/setupConfig.d.ts +14 -0
  43. package/dist/tickets/setupConfig.js +59 -2
  44. package/dist/tickets/sourceFetch.d.ts +24 -0
  45. package/dist/tickets/sourceFetch.js +246 -0
  46. package/dist/tickets/stateDb.d.ts +10 -0
  47. package/dist/tickets/stateDb.js +24 -0
  48. package/dist/tickets/ticketLoader.js +18 -0
  49. package/dist/tickets/ticketSchema.d.ts +93 -0
  50. package/dist/tickets/ticketSchema.js +17 -0
  51. package/dist/tickets/validate.js +12 -0
  52. package/package.json +33 -2
@@ -0,0 +1,287 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { loadTicketsConfig, resolveTicketPaths, validateDocsRoot } from "./tickets/config.js";
6
+ import { loadTickets, validateTicketDefs } from "./tickets/ticketLoader.js";
7
+ import { StateDb } from "./tickets/stateDb.js";
8
+ import { detectCycles } from "./tickets/ticketLoader.js";
9
+ import { renderAndWrite } from "./tickets/renderMarkdown.js";
10
+ import { runAllValidation } from "./tickets/validate.js";
11
+ import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/delivery.js";
12
+ import { loadTicketSetupConfig, mergeTicketSetup, saveTicketSetupConfig } from "./tickets/setupConfig.js";
13
+ export const PROPOSAL_START = "RAFI_PROPOSAL_START";
14
+ export const PROPOSAL_END = "RAFI_PROPOSAL_END";
15
+ export function readTicketPlanningContext(projectDir) {
16
+ const config = loadTicketsConfig(projectDir);
17
+ const paths = resolveTicketPaths(config, projectDir);
18
+ const tickets = loadTickets(paths.tickets);
19
+ if (tickets.length > 0 && !existsSync(paths.stateDb))
20
+ throw new Error("tickets exist but the ignored ticket-state database is missing; restore it before planning so completed work is not treated as new");
21
+ const db = new StateDb(paths.stateDb);
22
+ try {
23
+ const states = [...db.getAllStates().values()].map(({ ticket_id, status }) => ({ ticket_id, status }));
24
+ return {
25
+ tickets,
26
+ states,
27
+ futureWork: db.getFutureWork().map(({ id, summary, disposition, rationale }) => ({ id, summary, disposition, rationale })),
28
+ existingNext: states.filter((state) => state.status === "next").map((state) => state.ticket_id),
29
+ };
30
+ }
31
+ finally {
32
+ db.close();
33
+ }
34
+ }
35
+ export function ticketPlanningFingerprint(projectDir) {
36
+ const config = loadTicketsConfig(projectDir);
37
+ const paths = resolveTicketPaths(config, projectDir);
38
+ return ["rafi-config.yaml", relative(projectDir, paths.tickets), relative(projectDir, paths.stateDb), `${relative(projectDir, paths.stateDb)}-wal`, `${relative(projectDir, paths.stateDb)}-shm`, ".tickets/delivery.yaml"]
39
+ .map((path) => ({ path, sha256: fileHash(join(projectDir, path)) }));
40
+ }
41
+ export function planningFingerprintChanges(projectDir, expected) {
42
+ return expected.filter((item) => fileHash(join(projectDir, item.path)) !== item.sha256).map((item) => item.path);
43
+ }
44
+ export function extractTicketPlanProposal(output, existingTickets = []) {
45
+ const start = output.lastIndexOf(PROPOSAL_START);
46
+ const end = output.lastIndexOf(PROPOSAL_END);
47
+ if (start < 0 || end <= start)
48
+ throw new Error(`planner output is missing ${PROPOSAL_START}/${PROPOSAL_END}`);
49
+ const raw = output.slice(start + PROPOSAL_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ }
54
+ catch (err) {
55
+ throw new Error(`planner proposal is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ const issues = validateTicketPlanProposal(parsed, existingTickets);
58
+ if (issues.length)
59
+ throw new Error(`invalid planner proposal:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
60
+ return parsed;
61
+ }
62
+ export function validateTicketPlanProposal(value, existingTickets = []) {
63
+ const issues = [];
64
+ if (!value || typeof value !== "object" || Array.isArray(value))
65
+ return ["proposal must be an object"];
66
+ const proposal = value;
67
+ if (proposal.version !== 1)
68
+ issues.push("version must be 1");
69
+ for (const field of ["title", "markdown"])
70
+ if (typeof proposal[field] !== "string" || !proposal[field])
71
+ issues.push(`${field} must be a non-empty string`);
72
+ for (const field of ["additions", "edits", "supersessions", "state_changes", "source_reconciliation", "future_work"])
73
+ if (!Array.isArray(proposal[field]))
74
+ issues.push(`${field} must be an array`);
75
+ if (!proposal.next || typeof proposal.next !== "object" || Array.isArray(proposal.next))
76
+ issues.push("next must be an object");
77
+ if (issues.length)
78
+ return issues;
79
+ const p = proposal;
80
+ const staged = [...existingTickets];
81
+ const knownExisting = new Set(existingTickets.map((ticket) => ticket.id));
82
+ for (const addition of p.additions) {
83
+ if (!addition || typeof addition !== "object") {
84
+ issues.push("addition must be a ticket object");
85
+ continue;
86
+ }
87
+ if (knownExisting.has(addition.id) || staged.some((ticket) => ticket.id === addition.id))
88
+ issues.push(`addition reuses ticket ID ${addition.id}`);
89
+ staged.push(addition);
90
+ }
91
+ for (const edit of p.edits) {
92
+ if (!edit || typeof edit.id !== "string" || !edit.patch || typeof edit.patch !== "object" || Array.isArray(edit.patch)) {
93
+ issues.push("edit must contain id and patch");
94
+ continue;
95
+ }
96
+ if (!staged.some((ticket) => ticket.id === edit.id))
97
+ issues.push(`edit references unknown ticket ${edit.id}`);
98
+ if ("id" in edit.patch)
99
+ issues.push(`edit ${edit.id} cannot change a stable ticket ID`);
100
+ const index = staged.findIndex((ticket) => ticket.id === edit.id);
101
+ if (index >= 0)
102
+ staged[index] = { ...staged[index], ...edit.patch, id: edit.id };
103
+ }
104
+ const ids = new Set(staged.map((ticket) => ticket.id));
105
+ for (const link of p.supersessions) {
106
+ if (!link.reason?.trim() || !link.replaced?.length || !link.replacements?.length)
107
+ issues.push("each supersession needs replaced, replacements, and reason");
108
+ for (const id of [...(link.replaced ?? []), ...(link.replacements ?? [])])
109
+ if (!ids.has(id))
110
+ issues.push(`supersession references unknown ticket ${id}`);
111
+ }
112
+ for (const change of p.state_changes)
113
+ if (!ids.has(change.id) || !change.reason?.trim())
114
+ issues.push(`invalid state change for ${change.id}`);
115
+ for (const item of p.source_reconciliation) {
116
+ if (!item.source?.trim() || !item.item?.trim())
117
+ issues.push("source reconciliation needs source and item");
118
+ if ((item.disposition === "deferred" || item.disposition === "excluded") && !item.reason?.trim())
119
+ issues.push(`${item.source}:${item.item} ${item.disposition} needs a reason`);
120
+ for (const id of item.ticket_ids ?? [])
121
+ if (!ids.has(id))
122
+ issues.push(`source reconciliation references unknown ticket ${id}`);
123
+ if (["mapped", "split", "combined"].includes(item.disposition)) {
124
+ for (const id of item.ticket_ids ?? []) {
125
+ const ticket = staged.find((candidate) => candidate.id === id);
126
+ if (ticket && !(ticket.source_refs ?? []).some((ref) => ref.source === item.source && ref.item === item.item)) {
127
+ issues.push(`ticket ${id} is missing source_refs provenance for ${item.source}:${item.item}`);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ if (!Array.isArray(p.next.ticket_ids) || typeof p.next.replace_existing !== "boolean")
133
+ issues.push("next needs ticket_ids and replace_existing");
134
+ else
135
+ for (const id of p.next.ticket_ids)
136
+ if (!ids.has(id))
137
+ issues.push(`next references unknown ticket ${id}`);
138
+ issues.push(...validateTicketDefs(staged).map((issue) => `${issue.path}: ${issue.message}`));
139
+ issues.push(...detectCycles(staged).map((cycle) => `dependency cycle: ${cycle}`));
140
+ if (p.delivery)
141
+ issues.push(...validateDeliveryConfig(p.delivery, staged).map((issue) => `delivery ${issue.path}: ${issue.message}`));
142
+ return [...new Set(issues)];
143
+ }
144
+ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
145
+ const root = resolve(projectDir);
146
+ const drift = planningFingerprintChanges(root, opts.expectedFingerprint);
147
+ if (drift.length)
148
+ throw new Error(`planning inputs changed after proposal generation: ${drift.join(", ")}; refresh the proposal and approve again`);
149
+ const config = loadTicketsConfig(root);
150
+ const paths = resolveTicketPaths(config, root);
151
+ const existing = loadTickets(paths.tickets);
152
+ if (existing.length > 0 && !existsSync(paths.stateDb))
153
+ throw new Error("tickets exist but the ignored ticket-state database is missing; restore it before planning so completed work is not treated as new");
154
+ const proposalIssues = validateTicketPlanProposal(proposal, existing);
155
+ if (proposalIssues.length)
156
+ throw new Error(`approved proposal is invalid:\n${proposalIssues.map((issue) => `- ${issue}`).join("\n")}`);
157
+ const lockPath = join(root, ".tickets", "planning.lock");
158
+ mkdirSync(dirname(lockPath), { recursive: true });
159
+ let lock;
160
+ try {
161
+ lock = openSync(lockPath, "wx", 0o600);
162
+ }
163
+ catch {
164
+ throw new Error(`another ticket planning apply holds ${lockPath}`);
165
+ }
166
+ const lockedDrift = planningFingerprintChanges(root, opts.expectedFingerprint);
167
+ if (lockedDrift.length) {
168
+ closeSync(lock);
169
+ rmSync(lockPath, { force: true });
170
+ throw new Error(`planning inputs changed while acquiring the apply lock: ${lockedDrift.join(", ")}; refresh the proposal and approve again`);
171
+ }
172
+ const docsRoot = validateDocsRoot(root, opts.docsRoot);
173
+ const stamp = (opts.now ?? new Date()).toISOString().replace(/[:.]/g, "-");
174
+ const backupDir = join(root, ".tickets", "backups", `ticket-plan-${stamp}-${randomUUID().slice(0, 8)}`);
175
+ const latestPlan = join(root, docsRoot, "rafi-ticket-plan.md");
176
+ const historyPlan = join(root, docsRoot, "rafi-ticket-plans", `${stamp}.md`);
177
+ const deliveryPath = join(root, ".tickets", "delivery.yaml");
178
+ const configPath = join(root, "rafi-config.yaml");
179
+ const material = [paths.tickets, paths.stateDb, `${paths.stateDb}-wal`, `${paths.stateDb}-shm`, deliveryPath, configPath, latestPlan, historyPlan, paths.progressDoc, paths.archiveDoc];
180
+ mkdirSync(backupDir, { recursive: true });
181
+ const backedUp = [];
182
+ for (const original of material)
183
+ if (existsSync(original)) {
184
+ const backup = join(backupDir, `${backedUp.length}-${original.split(/[\\/]/).pop()}`);
185
+ copyFileSync(original, backup);
186
+ backedUp.push({ original, backup });
187
+ }
188
+ writeFileSync(join(backupDir, "journal.json"), `${JSON.stringify({ status: "prepared", createdAt: new Date().toISOString(), files: backedUp }, null, 2)}\n`, "utf8");
189
+ try {
190
+ const staged = stageTickets(existing, proposal);
191
+ atomicWrite(paths.tickets, stringify({ tickets: staged }, { lineWidth: 120 }));
192
+ if (proposal.delivery)
193
+ atomicWrite(deliveryPath, stringify(normalizeDeliveryConfig(proposal.delivery), { lineWidth: 120 }));
194
+ if (proposal.build_defaults) {
195
+ const setup = mergeTicketSetup(loadTicketSetupConfig(root), { build: proposal.build_defaults });
196
+ saveTicketSetupConfig(root, setup);
197
+ }
198
+ mkdirSync(dirname(latestPlan), { recursive: true });
199
+ mkdirSync(dirname(historyPlan), { recursive: true });
200
+ atomicWrite(latestPlan, `${proposal.markdown.trimEnd()}\n`);
201
+ atomicWrite(historyPlan, `${proposal.markdown.trimEnd()}\n`);
202
+ const db = new StateDb(paths.stateDb);
203
+ try {
204
+ const now = new Date().toISOString();
205
+ db.transaction(() => {
206
+ const existingNext = [...db.getAllStates().values()].filter((state) => state.status === "next").map((state) => state.ticket_id);
207
+ if (proposal.next.replace_existing)
208
+ for (const id of existingNext)
209
+ db.upsertState(id, { status: "planned", updated_by: "rafi tickets plan" }, now);
210
+ for (const link of proposal.supersessions)
211
+ for (const id of link.replaced) {
212
+ const current = db.getState(id);
213
+ if (current?.status !== "done")
214
+ db.upsertState(id, { status: "canceled", last_error: `Superseded: ${link.reason}`, updated_by: "rafi tickets plan" }, now);
215
+ db.insertEvent({ timestamp: now, actor: "rafi tickets plan", ticket_id: id, event_type: "superseded", old_status: current?.status ?? "planned", new_status: current?.status === "done" ? "done" : "canceled", summary: link.reason, validation: null, evidence: current?.evidence ?? null, payload_json: JSON.stringify({ replacements: link.replacements }) });
216
+ }
217
+ for (const change of proposal.state_changes) {
218
+ const current = db.getState(change.id);
219
+ if (current?.status === "done" && change.status !== "done")
220
+ throw new Error(`proposal cannot change completed ticket ${change.id} away from done`);
221
+ db.upsertState(change.id, { status: change.status, updated_by: "rafi tickets plan" }, now);
222
+ }
223
+ for (const id of proposal.next.ticket_ids)
224
+ db.upsertState(id, { status: "next", updated_by: "rafi tickets plan" }, now);
225
+ for (const disposition of proposal.future_work) {
226
+ if (!db.getFutureWorkById(disposition.id))
227
+ throw new Error(`future work item ${disposition.id} does not exist`);
228
+ db.updateFutureWorkDisposition(disposition.id, disposition.disposition);
229
+ }
230
+ });
231
+ const states = db.getAllStates();
232
+ renderAndWrite({ config, projectDir: root, ticketDefs: staged, states, db });
233
+ const validation = runAllValidation(config, root, staged, states, db);
234
+ const errors = validation.filter((issue) => issue.severity === "error");
235
+ if (errors.length)
236
+ throw new Error(`post-apply tracker validation failed:\n${errors.map((issue) => `- ${issue.message}`).join("\n")}`);
237
+ }
238
+ finally {
239
+ db.close();
240
+ }
241
+ writeFileSync(join(backupDir, "journal.json"), `${JSON.stringify({ status: "committed", committedAt: new Date().toISOString(), files: backedUp }, null, 2)}\n`, "utf8");
242
+ return { added: proposal.additions.map((ticket) => ticket.id), edited: proposal.edits.map((edit) => edit.id), artifacts: [relative(root, latestPlan), relative(root, historyPlan)], backupDir: relative(root, backupDir) };
243
+ }
244
+ catch (err) {
245
+ for (const original of material)
246
+ if (existsSync(original) && !backedUp.some((entry) => entry.original === original))
247
+ rmSync(original);
248
+ for (const entry of backedUp)
249
+ copyFileSync(entry.backup, entry.original);
250
+ writeFileSync(join(backupDir, "journal.json"), `${JSON.stringify({ status: "rolled_back", rolledBackAt: new Date().toISOString(), error: err instanceof Error ? err.message : String(err), files: backedUp }, null, 2)}\n`, "utf8");
251
+ throw err;
252
+ }
253
+ finally {
254
+ closeSync(lock);
255
+ rmSync(lockPath, { force: true });
256
+ }
257
+ }
258
+ function stageTickets(existing, proposal) {
259
+ const tickets = existing.map((ticket) => ({ ...ticket }));
260
+ for (const edit of proposal.edits) {
261
+ const index = tickets.findIndex((ticket) => ticket.id === edit.id);
262
+ tickets[index] = { ...tickets[index], ...edit.patch, id: edit.id };
263
+ }
264
+ tickets.push(...proposal.additions.map((ticket) => ({ ...ticket })));
265
+ for (const link of proposal.supersessions) {
266
+ for (const id of link.replaced) {
267
+ const ticket = tickets.find((candidate) => candidate.id === id);
268
+ ticket.superseded_by = [...new Set([...(ticket.superseded_by ?? []), ...link.replacements])];
269
+ }
270
+ for (const id of link.replacements) {
271
+ const ticket = tickets.find((candidate) => candidate.id === id);
272
+ ticket.supersedes = [...new Set([...(ticket.supersedes ?? []), ...link.replaced])];
273
+ }
274
+ }
275
+ return tickets.sort((a, b) => a.order - b.order);
276
+ }
277
+ function atomicWrite(path, content) {
278
+ mkdirSync(dirname(path), { recursive: true });
279
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
280
+ writeFileSync(tmp, content, "utf8");
281
+ renameSync(tmp, path);
282
+ }
283
+ function fileHash(path) {
284
+ if (!existsSync(path))
285
+ return null;
286
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
287
+ }
@@ -123,6 +123,22 @@ export function cmdInit(projectDir, opts) {
123
123
  },
124
124
  },
125
125
  },
126
+ source_refs: {
127
+ type: "array",
128
+ items: {
129
+ type: "object",
130
+ required: ["source", "item"],
131
+ properties: {
132
+ source: { type: "string", minLength: 1 },
133
+ item: { type: "string", minLength: 1 },
134
+ url: { type: ["string", "null"] },
135
+ fingerprint: { type: ["string", "null"] },
136
+ note: { type: ["string", "null"] },
137
+ },
138
+ },
139
+ },
140
+ superseded_by: { type: "array", items: { type: "string" }, uniqueItems: true },
141
+ supersedes: { type: "array", items: { type: "string" }, uniqueItems: true },
126
142
  },
127
143
  };
128
144
  writeFileSync(join(ticketsDir, "schema", "tickets.schema.json"), JSON.stringify(ticketSchema, null, 2), "utf8");
@@ -0,0 +1,38 @@
1
+ import type { TicketDef } from "./ticketSchema.js";
2
+ import type { TicketState } from "./stateDb.js";
3
+ import type { TicketBuildCompletionMode, TicketBuildMergeMethod, TicketBuildProvider } from "./setupConfig.js";
4
+ export declare const DELIVERY_FILE = ".tickets/delivery.yaml";
5
+ export type DeliveryBranchMode = "current" | "per-ticket" | "shared";
6
+ export type DeliveryDependencyMode = "combine" | "wait" | "stack";
7
+ export interface DeliveryUnit {
8
+ id: string;
9
+ tickets: string[];
10
+ branch_mode: DeliveryBranchMode;
11
+ completion?: TicketBuildCompletionMode;
12
+ provider?: TicketBuildProvider;
13
+ pr_ready?: boolean;
14
+ merge_method?: TicketBuildMergeMethod;
15
+ cleanup?: boolean;
16
+ depends_on?: string[];
17
+ dependency_mode?: DeliveryDependencyMode;
18
+ }
19
+ export interface DeliveryConfig {
20
+ version: 1;
21
+ units: DeliveryUnit[];
22
+ }
23
+ export interface DeliveryIssue {
24
+ path: string;
25
+ message: string;
26
+ }
27
+ export declare function loadDeliveryConfig(projectDir: string): DeliveryConfig | undefined;
28
+ export declare function normalizeDeliveryConfig(raw: unknown): DeliveryConfig;
29
+ export declare function validateDeliveryConfig(raw: unknown, ticketDefs?: TicketDef[]): DeliveryIssue[];
30
+ export interface DeliveryUnitProgress {
31
+ unit: DeliveryUnit;
32
+ completed: number;
33
+ remaining: string[];
34
+ state: "complete" | "ready" | "waiting" | "resume";
35
+ }
36
+ export declare function deliveryProgress(config: DeliveryConfig, states: Map<string, TicketState>): DeliveryUnitProgress[];
37
+ export declare function formatDeliverySummary(config: DeliveryConfig, states: Map<string, TicketState>): string[];
38
+ export declare function selectDeliveryUnitForRun(config: DeliveryConfig, states: Map<string, TicketState>, skipUnitIds?: readonly string[]): DeliveryUnitProgress | undefined;
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ export const DELIVERY_FILE = ".tickets/delivery.yaml";
5
+ export function loadDeliveryConfig(projectDir) {
6
+ const path = join(projectDir, DELIVERY_FILE);
7
+ if (!existsSync(path))
8
+ return undefined;
9
+ const raw = parse(readFileSync(path, "utf8"));
10
+ const issues = validateDeliveryConfig(raw);
11
+ if (issues.length > 0) {
12
+ throw new Error(`${DELIVERY_FILE}:\n${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`);
13
+ }
14
+ return normalizeDeliveryConfig(raw);
15
+ }
16
+ export function normalizeDeliveryConfig(raw) {
17
+ const value = raw;
18
+ return {
19
+ version: 1,
20
+ units: value.units.map((unit) => ({
21
+ id: String(unit.id),
22
+ tickets: [...unit.tickets],
23
+ branch_mode: unit.branch_mode,
24
+ ...(unit.completion ? { completion: unit.completion } : {}),
25
+ ...(unit.provider ? { provider: unit.provider } : {}),
26
+ ...(typeof unit.pr_ready === "boolean" ? { pr_ready: unit.pr_ready } : {}),
27
+ ...(unit.merge_method ? { merge_method: unit.merge_method } : {}),
28
+ ...(typeof unit.cleanup === "boolean" ? { cleanup: unit.cleanup } : {}),
29
+ ...(Array.isArray(unit.depends_on) ? { depends_on: [...unit.depends_on] } : {}),
30
+ ...(unit.dependency_mode ? { dependency_mode: unit.dependency_mode } : {}),
31
+ })),
32
+ };
33
+ }
34
+ export function validateDeliveryConfig(raw, ticketDefs) {
35
+ const issues = [];
36
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
37
+ return [{ path: "$", message: "expected an object" }];
38
+ const value = raw;
39
+ if (value.version !== undefined && value.version !== 1)
40
+ issues.push({ path: "version", message: "expected 1" });
41
+ if (!Array.isArray(value.units))
42
+ return [...issues, { path: "units", message: "expected an array" }];
43
+ const unitIds = new Set();
44
+ const assigned = new Map();
45
+ const knownTickets = ticketDefs ? new Set(ticketDefs.map((ticket) => ticket.id)) : undefined;
46
+ for (let index = 0; index < value.units.length; index++) {
47
+ const path = `units[${index}]`;
48
+ const unit = value.units[index];
49
+ if (!unit || typeof unit !== "object" || Array.isArray(unit)) {
50
+ issues.push({ path, message: "expected an object" });
51
+ continue;
52
+ }
53
+ const u = unit;
54
+ if (typeof u.id !== "string" || !/^[A-Za-z][A-Za-z0-9-_]*$/.test(u.id))
55
+ issues.push({ path: `${path}.id`, message: "expected a stable identifier" });
56
+ else if (unitIds.has(u.id))
57
+ issues.push({ path: `${path}.id`, message: `duplicate unit ID: ${u.id}` });
58
+ else
59
+ unitIds.add(u.id);
60
+ if (!Array.isArray(u.tickets) || u.tickets.length === 0 || !u.tickets.every((id) => typeof id === "string" && id.length > 0)) {
61
+ issues.push({ path: `${path}.tickets`, message: "expected a non-empty ticket ID array" });
62
+ }
63
+ else {
64
+ for (const id of u.tickets) {
65
+ if (knownTickets && !knownTickets.has(id))
66
+ issues.push({ path: `${path}.tickets`, message: `unknown ticket: ${id}` });
67
+ const prior = assigned.get(id);
68
+ if (prior)
69
+ issues.push({ path: `${path}.tickets`, message: `${id} is already assigned to ${prior}` });
70
+ else
71
+ assigned.set(id, typeof u.id === "string" ? u.id : path);
72
+ }
73
+ }
74
+ enumValue(issues, `${path}.branch_mode`, u.branch_mode, ["current", "per-ticket", "shared"], true);
75
+ enumValue(issues, `${path}.completion`, u.completion, ["pr", "auto-merge", "direct-merge", "none"]);
76
+ enumValue(issues, `${path}.provider`, u.provider, ["auto", "github", "gitlab", "local"]);
77
+ enumValue(issues, `${path}.merge_method`, u.merge_method, ["squash", "merge", "rebase"]);
78
+ enumValue(issues, `${path}.dependency_mode`, u.dependency_mode, ["combine", "wait", "stack"]);
79
+ for (const field of ["pr_ready", "cleanup"]) {
80
+ if (u[field] !== undefined && typeof u[field] !== "boolean")
81
+ issues.push({ path: `${path}.${field}`, message: "expected a boolean" });
82
+ }
83
+ if (u.depends_on !== undefined && (!Array.isArray(u.depends_on) || !u.depends_on.every((id) => typeof id === "string"))) {
84
+ issues.push({ path: `${path}.depends_on`, message: "expected a unit ID array" });
85
+ }
86
+ }
87
+ const units = value.units.filter((unit) => Boolean(unit && typeof unit === "object" && !Array.isArray(unit)));
88
+ for (const unit of units) {
89
+ for (const dep of Array.isArray(unit.depends_on) ? unit.depends_on : []) {
90
+ if (!unitIds.has(String(dep)))
91
+ issues.push({ path: `units[${String(unit.id)}].depends_on`, message: `unknown unit: ${String(dep)}` });
92
+ if (dep === unit.id)
93
+ issues.push({ path: `units[${String(unit.id)}].depends_on`, message: "unit cannot depend on itself" });
94
+ }
95
+ }
96
+ for (const cycle of deliveryCycles(units))
97
+ issues.push({ path: "units", message: `dependency cycle: ${cycle}` });
98
+ return issues;
99
+ }
100
+ function enumValue(issues, path, value, allowed, required = false) {
101
+ if (value === undefined && !required)
102
+ return;
103
+ if (typeof value !== "string" || !allowed.includes(value))
104
+ issues.push({ path, message: `expected one of: ${allowed.join(", ")}` });
105
+ }
106
+ function deliveryCycles(units) {
107
+ const deps = new Map(units.map((unit) => [String(unit.id), (Array.isArray(unit.depends_on) ? unit.depends_on : []).map(String)]));
108
+ const visited = new Set();
109
+ const stack = new Set();
110
+ const cycles = [];
111
+ const walk = (id, path) => {
112
+ if (stack.has(id)) {
113
+ cycles.push([...path.slice(path.indexOf(id)), id].join(" -> "));
114
+ return;
115
+ }
116
+ if (visited.has(id))
117
+ return;
118
+ visited.add(id);
119
+ stack.add(id);
120
+ for (const dep of deps.get(id) ?? [])
121
+ if (deps.has(dep))
122
+ walk(dep, [...path, id]);
123
+ stack.delete(id);
124
+ };
125
+ for (const id of deps.keys())
126
+ walk(id, []);
127
+ return cycles;
128
+ }
129
+ export function deliveryProgress(config, states) {
130
+ const completedUnits = new Set(config.units.filter((unit) => unit.tickets.every((id) => ["done", "canceled"].includes(states.get(id)?.status ?? "planned"))).map((unit) => unit.id));
131
+ const out = [];
132
+ for (const unit of config.units) {
133
+ const remaining = unit.tickets.filter((id) => !["done", "canceled"].includes(states.get(id)?.status ?? "planned"));
134
+ const started = unit.tickets.some((id) => ["in_progress", "blocked", "done"].includes(states.get(id)?.status ?? "planned"));
135
+ const waiting = (unit.depends_on ?? []).some((id) => !completedUnits.has(id));
136
+ out.push({
137
+ unit,
138
+ completed: unit.tickets.length - remaining.length,
139
+ remaining,
140
+ state: remaining.length === 0 ? "complete" : waiting ? "waiting" : started ? "resume" : "ready",
141
+ });
142
+ }
143
+ return out;
144
+ }
145
+ export function formatDeliverySummary(config, states) {
146
+ return deliveryProgress(config, states).map(({ unit, completed, remaining, state }) => `${unit.id}: ${completed}/${unit.tickets.length} complete; ${remaining.length} remaining; ${unit.branch_mode}; ${unit.completion ?? "repository default"}; ${state}${(unit.depends_on ?? []).length ? `; depends on ${(unit.depends_on ?? []).join(", ")}` : ""}`);
147
+ }
148
+ export function selectDeliveryUnitForRun(config, states, skipUnitIds = []) {
149
+ const skipped = new Set(skipUnitIds);
150
+ const progress = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
151
+ return progress.find((item) => item.state === "resume") ?? progress.find((item) => item.state === "ready");
152
+ }
@@ -348,6 +348,10 @@ function importedItemToTicket(item, id, order, existing) {
348
348
  key: item.key ?? null,
349
349
  url: item.url ?? null,
350
350
  }),
351
+ source_refs: [
352
+ ...(existing?.source_refs ?? []).filter((ref) => !(ref.source === item.provider && ref.item === item.providerId)),
353
+ { source: item.provider, item: item.providerId, url: item.url ?? null },
354
+ ],
351
355
  };
352
356
  }
353
357
  function buildImportedNotes(item) {
@@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from "
2
2
  import { dirname, join } from "node:path";
3
3
  import { DateTime } from "luxon";
4
4
  import { buildNextQueue, buildActiveStatusRows } from "./queue.js";
5
+ import { formatDeliverySummary, loadDeliveryConfig } from "./delivery.js";
5
6
  // ── Table helpers ─────────────────────────────────────────────────────────────
6
7
  function esc(v) {
7
8
  return v.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
@@ -90,6 +91,14 @@ export function renderProgressDoc(input) {
90
91
  ["Remaining Tickets", String(queueRows.length > 0 ? "≥" + queueRows.length : "0")],
91
92
  ])));
92
93
  lines.push("");
94
+ // Delivery units are optional; repositories without delivery.yaml retain legacy defaults.
95
+ const delivery = loadDeliveryConfig(projectDir);
96
+ if (delivery) {
97
+ lines.push("## Delivery Units");
98
+ const deliveryRows = formatDeliverySummary(delivery, states);
99
+ lines.push(section("DELIVERY_UNITS", deliveryRows.length > 0 ? deliveryRows.map((row) => `- ${row}`).join("\n") : "_No delivery units configured._"));
100
+ lines.push("");
101
+ }
93
102
  // Rules
94
103
  if (config.rendering.includeRulesInProgressDoc) {
95
104
  lines.push(rulesContent);
@@ -20,6 +20,9 @@ export type TicketSourceConfig = {
20
20
  email_env: string;
21
21
  token_env: string;
22
22
  jql: string;
23
+ } | {
24
+ type: "url";
25
+ url: string;
23
26
  };
24
27
  export interface TicketPopulateSetupConfig {
25
28
  source_handling: "saved" | "prompt" | "manual";
@@ -65,9 +68,20 @@ export declare function mergeTicketSetup(current: TicketsSetupConfig | undefined
65
68
  build: Partial<TicketBuildSetupConfig>;
66
69
  }>): TicketsSetupConfig;
67
70
  export declare function localSourcePaths(setup: TicketsSetupConfig | undefined): string[];
71
+ /**
72
+ * Create records planning hints separately from completed ticket setup. Use
73
+ * them only as a setup prefill so `tickets.sources` remains the source of
74
+ * truth once the user has configured ticket ingestion.
75
+ */
76
+ export declare function configuredPlanningSources(projectDir: string): string[];
68
77
  export declare function externalSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
69
78
  type: "linear" | "jira";
70
79
  }>[];
80
+ export declare function urlSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
81
+ type: "url";
82
+ }>[];
83
+ export declare function normalizedSourceKey(source: TicketSourceConfig): string;
84
+ export declare function dedupeTicketSources(sources: TicketSourceConfig[]): TicketSourceConfig[];
71
85
  export declare function configuredDocsRoot(projectDir: string): string | undefined;
72
86
  export declare function configuredAppName(projectDir: string): string | undefined;
73
87
  export declare function configuredHarnessTargets(projectDir: string): HarnessTarget[] | undefined;
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import { loadDefaults } from "special-agents";
6
6
  export const RAFI_CONFIG_FILE = "rafi-config.yaml";
7
- const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker"];
7
+ const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
8
8
  const SKILL_NAMES = [
9
9
  "better-sqlite3-rebuild",
10
10
  "grill-me",
@@ -106,9 +106,49 @@ export function localSourcePaths(setup) {
106
106
  .filter((source) => source.type === "local")
107
107
  .flatMap((source) => source.paths));
108
108
  }
109
+ /**
110
+ * Create records planning hints separately from completed ticket setup. Use
111
+ * them only as a setup prefill so `tickets.sources` remains the source of
112
+ * truth once the user has configured ticket ingestion.
113
+ */
114
+ export function configuredPlanningSources(projectDir) {
115
+ const raw = loadRafiConfigObject(projectDir);
116
+ const planning = raw?.planning;
117
+ if (!Array.isArray(planning?.sources))
118
+ return [];
119
+ return unique(planning.sources.filter((source) => typeof source === "string" && source.trim().length > 0).map((source) => source.trim()));
120
+ }
109
121
  export function externalSources(setup) {
110
122
  return (setup?.sources ?? []).filter((source) => source.type === "linear" || source.type === "jira");
111
123
  }
124
+ export function urlSources(setup) {
125
+ return (setup?.sources ?? []).filter((source) => source.type === "url");
126
+ }
127
+ export function normalizedSourceKey(source) {
128
+ if (source.type === "local")
129
+ return `local:${unique(source.paths.map((path) => path.trim())).sort().join("|")}`;
130
+ if (source.type === "url") {
131
+ const url = new URL(source.url);
132
+ url.hash = "";
133
+ url.hostname = url.hostname.toLowerCase();
134
+ if ((url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80"))
135
+ url.port = "";
136
+ return `url:${url.toString()}`;
137
+ }
138
+ if (source.type === "linear")
139
+ return `linear:${source.api_key_env}:${source.team_key ?? ""}:${source.filter ?? ""}`;
140
+ return `jira:${source.site.replace(/\/+$/, "").toLowerCase()}:${source.jql}`;
141
+ }
142
+ export function dedupeTicketSources(sources) {
143
+ const seen = new Set();
144
+ return sources.filter((source) => {
145
+ const key = normalizedSourceKey(source);
146
+ if (seen.has(key))
147
+ return false;
148
+ seen.add(key);
149
+ return true;
150
+ });
151
+ }
112
152
  export function configuredDocsRoot(projectDir) {
113
153
  const raw = loadRafiConfigObject(projectDir);
114
154
  const docs = raw?.docs;
@@ -259,7 +299,24 @@ function normalizeSource(value, label) {
259
299
  jql,
260
300
  };
261
301
  }
262
- throw new Error(`${label}.type: expected local, linear, or jira`);
302
+ if (raw.type === "url") {
303
+ const url = stringField(raw.url);
304
+ if (!url)
305
+ throw new Error(`${label}.url: expected a public HTTP(S) URL`);
306
+ let parsed;
307
+ try {
308
+ parsed = new URL(url);
309
+ }
310
+ catch {
311
+ throw new Error(`${label}.url: expected a valid URL`);
312
+ }
313
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password) {
314
+ throw new Error(`${label}.url: expected HTTP(S) without embedded credentials`);
315
+ }
316
+ parsed.hash = "";
317
+ return { type: "url", url: parsed.toString() };
318
+ }
319
+ throw new Error(`${label}.type: expected local, linear, jira, or url`);
263
320
  }
264
321
  function normalizePopulate(value, label) {
265
322
  const raw = objectOrEmpty(value, label);