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,24 @@
1
+ export interface SourceFetchLimits {
2
+ timeoutMs: number;
3
+ maxBytes: number;
4
+ maxRedirects: number;
5
+ }
6
+ export declare const DEFAULT_SOURCE_FETCH_LIMITS: SourceFetchLimits;
7
+ export interface FetchedSource {
8
+ requestedUrl: string;
9
+ finalUrl: string;
10
+ contentType: "html" | "text" | "markdown" | "pdf";
11
+ text: string;
12
+ bytes: number;
13
+ fingerprint: string;
14
+ snapshotPath: string;
15
+ metadataPath: string;
16
+ fetchedAt: string;
17
+ }
18
+ export declare function fetchAndSnapshotUrl(projectDir: string, input: string, limits?: Partial<SourceFetchLimits>): Promise<FetchedSource>;
19
+ export declare function normalizePublicHttpUrl(input: string): string;
20
+ export declare function assertPublicDestination(input: string): Promise<void>;
21
+ export declare function isPublicAddress(address: string): boolean;
22
+ export declare function htmlToText(html: string): string;
23
+ /** Snapshot a local file outside the repository so absolute paths are never persisted. */
24
+ export declare function snapshotExternalLocalFile(projectDir: string, inputPath: string): string;
@@ -0,0 +1,246 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import { lookup as dnsLookup } from "node:dns/promises";
4
+ import { isIP } from "node:net";
5
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { readdirSync } from "node:fs";
7
+ import { parse as parseHtml } from "parse5";
8
+ export const DEFAULT_SOURCE_FETCH_LIMITS = {
9
+ timeoutMs: 15_000,
10
+ maxBytes: 10 * 1024 * 1024,
11
+ maxRedirects: 5,
12
+ };
13
+ export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
14
+ const effective = { ...DEFAULT_SOURCE_FETCH_LIMITS, ...limits };
15
+ const requested = normalizePublicHttpUrl(input);
16
+ let current = requested;
17
+ let response;
18
+ for (let redirects = 0; redirects <= effective.maxRedirects; redirects++) {
19
+ await assertPublicDestination(current);
20
+ const controller = new AbortController();
21
+ const timer = setTimeout(() => controller.abort(), effective.timeoutMs);
22
+ try {
23
+ response = await fetch(current, {
24
+ redirect: "manual",
25
+ signal: controller.signal,
26
+ headers: { Accept: "text/html,text/plain,text/markdown,application/pdf;q=0.9" },
27
+ });
28
+ }
29
+ finally {
30
+ clearTimeout(timer);
31
+ }
32
+ if (response.status >= 300 && response.status < 400) {
33
+ const location = response.headers.get("location");
34
+ if (!location)
35
+ throw new Error(`URL redirect ${response.status} did not include Location`);
36
+ if (redirects === effective.maxRedirects)
37
+ throw new Error(`URL exceeded ${effective.maxRedirects} redirects`);
38
+ current = normalizePublicHttpUrl(new URL(location, current).toString());
39
+ continue;
40
+ }
41
+ break;
42
+ }
43
+ if (!response)
44
+ throw new Error("URL fetch did not produce a response");
45
+ if (!response.ok)
46
+ throw new Error(`URL fetch failed with HTTP ${response.status}`);
47
+ const declaredLength = Number(response.headers.get("content-length"));
48
+ if (Number.isFinite(declaredLength) && declaredLength > effective.maxBytes)
49
+ throw new Error(`URL content exceeds ${effective.maxBytes} byte limit`);
50
+ const bytes = await readLimitedBody(response, effective.maxBytes);
51
+ const kind = detectContentType(response.headers.get("content-type"), bytes, current);
52
+ const text = await extractSourceText(kind, bytes);
53
+ const fetchedAt = new Date().toISOString();
54
+ const fingerprint = createHash("sha256").update(bytes).digest("hex");
55
+ const importsDir = join(resolve(projectDir), ".tickets", "imports");
56
+ mkdirSync(importsDir, { recursive: true });
57
+ const stamp = fetchedAt.replace(/[:.]/g, "-");
58
+ const stem = `url-${stamp}-${fingerprint.slice(0, 12)}`;
59
+ const snapshotPath = join(importsDir, `${stem}.md`);
60
+ const metadataPath = join(importsDir, `${stem}.json`);
61
+ writeFileSync(snapshotPath, `${text.trimEnd()}\n`, "utf8");
62
+ writeFileSync(metadataPath, `${JSON.stringify({ requestedUrl: requested, finalUrl: current, fetchedAt, bytes: bytes.length, contentType: kind, fingerprint }, null, 2)}\n`, "utf8");
63
+ return { requestedUrl: requested, finalUrl: current, contentType: kind, text, bytes: bytes.length, fingerprint, snapshotPath: relative(resolve(projectDir), snapshotPath), metadataPath: relative(resolve(projectDir), metadataPath), fetchedAt };
64
+ }
65
+ export function normalizePublicHttpUrl(input) {
66
+ let url;
67
+ try {
68
+ url = new URL(input);
69
+ }
70
+ catch {
71
+ throw new Error(`invalid URL: ${input}`);
72
+ }
73
+ if (url.protocol !== "http:" && url.protocol !== "https:")
74
+ throw new Error("URL source must use HTTP or HTTPS");
75
+ if (url.username || url.password)
76
+ throw new Error("URL source must not contain credentials");
77
+ if (!url.hostname)
78
+ throw new Error("URL source must include a hostname");
79
+ url.hash = "";
80
+ return url.toString();
81
+ }
82
+ export async function assertPublicDestination(input) {
83
+ const url = new URL(normalizePublicHttpUrl(input));
84
+ const addresses = isIP(url.hostname)
85
+ ? [{ address: url.hostname }]
86
+ : await dnsLookup(url.hostname, { all: true, verbatim: true });
87
+ if (addresses.length === 0)
88
+ throw new Error(`URL hostname did not resolve: ${url.hostname}`);
89
+ for (const { address } of addresses) {
90
+ if (!isPublicAddress(address))
91
+ throw new Error(`URL destination is not public: ${url.hostname} resolved to ${address}`);
92
+ }
93
+ }
94
+ export function isPublicAddress(address) {
95
+ const normalized = address.toLowerCase().split("%")[0];
96
+ if (normalized === "::1" || normalized === "::" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd"))
97
+ return false;
98
+ if (normalized.startsWith("::ffff:"))
99
+ return isPublicAddress(normalized.slice(7));
100
+ if (isIP(normalized) === 6) {
101
+ return !(normalized.startsWith("ff") ||
102
+ normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb") ||
103
+ normalized.startsWith("fc") || normalized.startsWith("fd") ||
104
+ normalized.startsWith("fec") || normalized.startsWith("fed") || normalized.startsWith("fee") || normalized.startsWith("fef") ||
105
+ normalized.startsWith("2001:db8:") || normalized.startsWith("2001:10:") || normalized.startsWith("2001:2:") ||
106
+ normalized.startsWith("100:") || normalized.startsWith("64:ff9b:1:"));
107
+ }
108
+ const parts = normalized.split(".").map(Number);
109
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
110
+ return false;
111
+ const [a, b] = parts;
112
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 ||
113
+ (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
114
+ (a === 192 && (b === 0 || b === 88 || b === 168)) ||
115
+ (a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19 || b === 51)) ||
116
+ (a === 203 && b === 0));
117
+ }
118
+ async function readLimitedBody(response, maxBytes) {
119
+ if (!response.body)
120
+ return new Uint8Array();
121
+ const reader = response.body.getReader();
122
+ const chunks = [];
123
+ let total = 0;
124
+ while (true) {
125
+ const { done, value } = await reader.read();
126
+ if (done)
127
+ break;
128
+ total += value.length;
129
+ if (total > maxBytes) {
130
+ await reader.cancel();
131
+ throw new Error(`URL content exceeds ${maxBytes} byte limit`);
132
+ }
133
+ chunks.push(value);
134
+ }
135
+ const out = new Uint8Array(total);
136
+ let offset = 0;
137
+ for (const chunk of chunks) {
138
+ out.set(chunk, offset);
139
+ offset += chunk.length;
140
+ }
141
+ return out;
142
+ }
143
+ function detectContentType(header, bytes, url) {
144
+ const mime = (header ?? "").split(";", 1)[0].trim().toLowerCase();
145
+ const magic = Buffer.from(bytes.subarray(0, 8)).toString("latin1");
146
+ if (magic.startsWith("%PDF-")) {
147
+ if (mime && mime !== "application/pdf" && mime !== "application/octet-stream")
148
+ throw new Error(`content-type ${mime} conflicts with PDF magic bytes`);
149
+ return "pdf";
150
+ }
151
+ if (["application/pdf"].includes(mime))
152
+ throw new Error("response claimed PDF but PDF magic bytes were missing");
153
+ if (["text/html", "application/xhtml+xml"].includes(mime))
154
+ return "html";
155
+ if (["text/markdown", "text/x-markdown"].includes(mime) || /\.md(?:own)?$/i.test(new URL(url).pathname))
156
+ return "markdown";
157
+ if (!mime || mime.startsWith("text/") || mime === "application/octet-stream") {
158
+ if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
159
+ throw new Error("binary URL content is not supported");
160
+ if ((!mime || mime === "application/octet-stream") && /^\s*(?:<!doctype\s+html|<html\b)/i.test(Buffer.from(bytes.subarray(0, 1024)).toString("utf8")))
161
+ return "html";
162
+ return "text";
163
+ }
164
+ throw new Error(`unsupported URL content-type: ${mime}`);
165
+ }
166
+ async function extractSourceText(kind, bytes) {
167
+ if (kind === "pdf") {
168
+ const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
169
+ const document = await pdfjs.getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false }).promise;
170
+ const pages = [];
171
+ for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) {
172
+ const page = await document.getPage(pageNumber);
173
+ const content = await page.getTextContent();
174
+ pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
175
+ page.cleanup();
176
+ }
177
+ await document.destroy();
178
+ return pages.join("\n\n");
179
+ }
180
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
181
+ return kind === "html" ? htmlToText(decoded) : decoded;
182
+ }
183
+ export function htmlToText(html) {
184
+ const doc = parseHtml(html);
185
+ const blocks = new Set(["address", "article", "aside", "blockquote", "br", "div", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "li", "main", "p", "section", "table", "tr"]);
186
+ const skip = new Set(["script", "style", "noscript", "svg"]);
187
+ const out = [];
188
+ const walk = (node) => {
189
+ const named = node;
190
+ const name = named.nodeName?.toLowerCase();
191
+ if (name && skip.has(name))
192
+ return;
193
+ if (name === "#text" && named.value)
194
+ out.push(named.value);
195
+ for (const child of named.childNodes ?? [])
196
+ walk(child);
197
+ if (name && blocks.has(name))
198
+ out.push("\n");
199
+ };
200
+ for (const child of doc.childNodes)
201
+ walk(child);
202
+ return out.join(" ").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
203
+ }
204
+ /** Snapshot a local file outside the repository so absolute paths are never persisted. */
205
+ export function snapshotExternalLocalFile(projectDir, inputPath) {
206
+ const root = realpathSync(resolve(projectDir));
207
+ const file = realpathSync(resolve(inputPath));
208
+ const rel = relative(root, file);
209
+ if (!rel.startsWith("..") && !isAbsolute(rel))
210
+ return rel;
211
+ if (statSync(file).isDirectory()) {
212
+ const hash = createHash("sha256").update(file).digest("hex").slice(0, 12);
213
+ const targetRoot = join(root, ".tickets", "imports", `local-${hash}-${basename(file).replace(/[^A-Za-z0-9._-]/g, "-")}`);
214
+ let count = 0;
215
+ let bytes = 0;
216
+ const copyDirectory = (source, target) => {
217
+ mkdirSync(target, { recursive: true });
218
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
219
+ if (entry.isSymbolicLink())
220
+ continue;
221
+ const from = join(source, entry.name);
222
+ const to = join(target, entry.name);
223
+ if (entry.isDirectory())
224
+ copyDirectory(from, to);
225
+ else if (entry.isFile()) {
226
+ count++;
227
+ bytes += statSync(from).size;
228
+ if (count > 1000 || bytes > DEFAULT_SOURCE_FETCH_LIMITS.maxBytes)
229
+ throw new Error("external local source directory exceeds snapshot limits");
230
+ copyFileSync(from, to);
231
+ }
232
+ }
233
+ };
234
+ copyDirectory(file, targetRoot);
235
+ return relative(root, targetRoot);
236
+ }
237
+ if (!statSync(file).isFile())
238
+ throw new Error(`local source is not a file or directory: ${inputPath}`);
239
+ const bytes = readFileSync(file);
240
+ const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
241
+ const dir = join(root, ".tickets", "imports");
242
+ mkdirSync(dir, { recursive: true });
243
+ const target = join(dir, `local-${hash}-${basename(file).replace(/[^A-Za-z0-9._-]/g, "-")}${extname(file) ? "" : ".txt"}`);
244
+ copyFileSync(file, target);
245
+ return relative(root, target);
246
+ }
@@ -81,6 +81,14 @@ export interface ReviewRecommendation {
81
81
  source_json: string;
82
82
  updated_at: string | null;
83
83
  }
84
+ export interface OperationReceipt {
85
+ operation_id: string;
86
+ operation_type: string;
87
+ ticket_id: string | null;
88
+ run_id: string;
89
+ completed_at: string;
90
+ payload_json: string;
91
+ }
84
92
  export declare class StateDb {
85
93
  private db;
86
94
  constructor(dbPath: string);
@@ -103,5 +111,7 @@ export declare class StateDb {
103
111
  getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
104
112
  getReviewRecommendation(id: number): ReviewRecommendation | undefined;
105
113
  updateReviewRecommendationStatus(id: number, status: ReviewRecommendationStatus, updatedAt: string): void;
114
+ getOperationReceipt(operationId: string): OperationReceipt | undefined;
115
+ recordOperationReceipt(receipt: OperationReceipt): boolean;
106
116
  transaction<T>(fn: () => T): T;
107
117
  }
@@ -96,12 +96,22 @@ CREATE TABLE IF NOT EXISTS review_recommendations (
96
96
  CHECK (status IN ('pending','accepted','deferred','dismissed'))
97
97
  );
98
98
 
99
+ CREATE TABLE IF NOT EXISTS operation_receipts (
100
+ operation_id TEXT PRIMARY KEY,
101
+ operation_type TEXT NOT NULL,
102
+ ticket_id TEXT,
103
+ run_id TEXT NOT NULL,
104
+ completed_at TEXT NOT NULL,
105
+ payload_json TEXT NOT NULL DEFAULT '{}'
106
+ );
107
+
99
108
  CREATE INDEX IF NOT EXISTS idx_ticket_state_status ON ticket_state(status);
100
109
  CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket_id ON ticket_events(ticket_id);
101
110
  CREATE INDEX IF NOT EXISTS idx_ticket_events_timestamp ON ticket_events(timestamp);
102
111
  CREATE INDEX IF NOT EXISTS idx_validation_snapshots_timestamp ON validation_snapshots(timestamp);
103
112
  CREATE INDEX IF NOT EXISTS idx_future_work_disposition ON future_work(disposition);
104
113
  CREATE INDEX IF NOT EXISTS idx_review_recommendations_status ON review_recommendations(status);
114
+ CREATE INDEX IF NOT EXISTS idx_operation_receipts_run ON operation_receipts(run_id);
105
115
  `;
106
116
  export class StateDb {
107
117
  db;
@@ -291,6 +301,20 @@ export class StateDb {
291
301
  .prepare("UPDATE review_recommendations SET status = ?, updated_at = ? WHERE id = ?")
292
302
  .run(status, updatedAt, id);
293
303
  }
304
+ // ── operation_receipts ───────────────────────────────────────────────────
305
+ getOperationReceipt(operationId) {
306
+ return this.db.prepare("SELECT * FROM operation_receipts WHERE operation_id = ?").get(operationId);
307
+ }
308
+ recordOperationReceipt(receipt) {
309
+ const result = this.db.prepare(`
310
+ INSERT OR IGNORE INTO operation_receipts (
311
+ operation_id,operation_type,ticket_id,run_id,completed_at,payload_json
312
+ ) VALUES (
313
+ @operation_id,@operation_type,@ticket_id,@run_id,@completed_at,@payload_json
314
+ )
315
+ `).run(receipt);
316
+ return result.changes === 1;
317
+ }
294
318
  // ── transactions ──────────────────────────────────────────────────────────
295
319
  transaction(fn) {
296
320
  return this.db.transaction(fn)();
@@ -47,6 +47,24 @@ export function validateTicketDefs(tickets) {
47
47
  errors.push({ path: `tickets[${t.id}].depends_on`, message: `references unknown ticket: ${dep}` });
48
48
  }
49
49
  }
50
+ for (const replacement of t.superseded_by ?? []) {
51
+ if (!ids.has(replacement))
52
+ errors.push({ path: `tickets[${t.id}].superseded_by`, message: `references unknown ticket: ${replacement}` });
53
+ if (replacement === t.id)
54
+ errors.push({ path: `tickets[${t.id}].superseded_by`, message: "ticket cannot supersede itself" });
55
+ const reciprocal = tickets.find((candidate) => candidate.id === replacement)?.supersedes ?? [];
56
+ if (ids.has(replacement) && !reciprocal.includes(t.id))
57
+ errors.push({ path: `tickets[${t.id}].superseded_by`, message: `${replacement} is missing reciprocal supersedes link to ${t.id}` });
58
+ }
59
+ for (const replaced of t.supersedes ?? []) {
60
+ if (!ids.has(replaced))
61
+ errors.push({ path: `tickets[${t.id}].supersedes`, message: `references unknown ticket: ${replaced}` });
62
+ if (replaced === t.id)
63
+ errors.push({ path: `tickets[${t.id}].supersedes`, message: "ticket cannot supersede itself" });
64
+ const reciprocal = tickets.find((candidate) => candidate.id === replaced)?.superseded_by ?? [];
65
+ if (!reciprocal.includes(t.id))
66
+ errors.push({ path: `tickets[${t.id}].supersedes`, message: `${replaced} is missing reciprocal superseded_by link to ${t.id}` });
67
+ }
50
68
  }
51
69
  for (const t of tickets) {
52
70
  if ((t.risk === "Medium" || t.risk === "High") && !t.rollback) {
@@ -7,6 +7,14 @@ export interface TicketExternalRef {
7
7
  key?: string | null;
8
8
  url?: string | null;
9
9
  }
10
+ /** General provenance reference. `external_refs` remains accepted for compatibility. */
11
+ export interface TicketSourceRef {
12
+ source: string;
13
+ item: string;
14
+ url?: string | null;
15
+ fingerprint?: string | null;
16
+ note?: string | null;
17
+ }
10
18
  export interface TicketDef {
11
19
  id: string;
12
20
  order: number;
@@ -23,6 +31,9 @@ export interface TicketDef {
23
31
  rollback?: string | null;
24
32
  notes?: string | null;
25
33
  external_refs?: TicketExternalRef[];
34
+ source_refs?: TicketSourceRef[];
35
+ superseded_by?: string[];
36
+ supersedes?: string[];
26
37
  }
27
38
  export declare const TICKET_JSON_SCHEMA: {
28
39
  readonly type: "object";
@@ -117,6 +128,47 @@ export declare const TICKET_JSON_SCHEMA: {
117
128
  };
118
129
  };
119
130
  };
131
+ readonly source_refs: {
132
+ readonly type: "array";
133
+ readonly items: {
134
+ readonly type: "object";
135
+ readonly required: readonly ["source", "item"];
136
+ readonly additionalProperties: true;
137
+ readonly properties: {
138
+ readonly source: {
139
+ readonly type: "string";
140
+ readonly minLength: 1;
141
+ };
142
+ readonly item: {
143
+ readonly type: "string";
144
+ readonly minLength: 1;
145
+ };
146
+ readonly url: {
147
+ readonly type: readonly ["string", "null"];
148
+ };
149
+ readonly fingerprint: {
150
+ readonly type: readonly ["string", "null"];
151
+ };
152
+ readonly note: {
153
+ readonly type: readonly ["string", "null"];
154
+ };
155
+ };
156
+ };
157
+ };
158
+ readonly superseded_by: {
159
+ readonly type: "array";
160
+ readonly items: {
161
+ readonly type: "string";
162
+ };
163
+ readonly uniqueItems: true;
164
+ };
165
+ readonly supersedes: {
166
+ readonly type: "array";
167
+ readonly items: {
168
+ readonly type: "string";
169
+ };
170
+ readonly uniqueItems: true;
171
+ };
120
172
  };
121
173
  };
122
174
  export declare const TICKETS_FILE_SCHEMA: {
@@ -218,6 +270,47 @@ export declare const TICKETS_FILE_SCHEMA: {
218
270
  };
219
271
  };
220
272
  };
273
+ readonly source_refs: {
274
+ readonly type: "array";
275
+ readonly items: {
276
+ readonly type: "object";
277
+ readonly required: readonly ["source", "item"];
278
+ readonly additionalProperties: true;
279
+ readonly properties: {
280
+ readonly source: {
281
+ readonly type: "string";
282
+ readonly minLength: 1;
283
+ };
284
+ readonly item: {
285
+ readonly type: "string";
286
+ readonly minLength: 1;
287
+ };
288
+ readonly url: {
289
+ readonly type: readonly ["string", "null"];
290
+ };
291
+ readonly fingerprint: {
292
+ readonly type: readonly ["string", "null"];
293
+ };
294
+ readonly note: {
295
+ readonly type: readonly ["string", "null"];
296
+ };
297
+ };
298
+ };
299
+ };
300
+ readonly superseded_by: {
301
+ readonly type: "array";
302
+ readonly items: {
303
+ readonly type: "string";
304
+ };
305
+ readonly uniqueItems: true;
306
+ };
307
+ readonly supersedes: {
308
+ readonly type: "array";
309
+ readonly items: {
310
+ readonly type: "string";
311
+ };
312
+ readonly uniqueItems: true;
313
+ };
221
314
  };
222
315
  };
223
316
  };
@@ -34,6 +34,23 @@ export const TICKET_JSON_SCHEMA = {
34
34
  },
35
35
  },
36
36
  },
37
+ source_refs: {
38
+ type: "array",
39
+ items: {
40
+ type: "object",
41
+ required: ["source", "item"],
42
+ additionalProperties: true,
43
+ properties: {
44
+ source: { type: "string", minLength: 1 },
45
+ item: { type: "string", minLength: 1 },
46
+ url: { type: ["string", "null"] },
47
+ fingerprint: { type: ["string", "null"] },
48
+ note: { type: ["string", "null"] },
49
+ },
50
+ },
51
+ },
52
+ superseded_by: { type: "array", items: { type: "string" }, uniqueItems: true },
53
+ supersedes: { type: "array", items: { type: "string" }, uniqueItems: true },
37
54
  },
38
55
  };
39
56
  export const TICKETS_FILE_SCHEMA = {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { validateTicketDefs, detectCycles } from "./ticketLoader.js";
4
4
  import { buildNextQueue } from "./queue.js";
5
+ import { loadDeliveryConfig, validateDeliveryConfig } from "./delivery.js";
5
6
  export function runAllValidation(config, projectDir, ticketDefs, states, db) {
6
7
  const issues = [];
7
8
  // Pass 1: schema and source
@@ -11,6 +12,17 @@ export function runAllValidation(config, projectDir, ticketDefs, states, db) {
11
12
  for (const cycle of detectCycles(ticketDefs)) {
12
13
  issues.push({ pass: 1, severity: "error", message: `[cycle] dependency cycle: ${cycle}` });
13
14
  }
15
+ try {
16
+ const delivery = loadDeliveryConfig(projectDir);
17
+ if (delivery) {
18
+ for (const issue of validateDeliveryConfig(delivery, ticketDefs)) {
19
+ issues.push({ pass: 1, severity: "error", message: `[delivery] ${issue.path}: ${issue.message}` });
20
+ }
21
+ }
22
+ }
23
+ catch (err) {
24
+ issues.push({ pass: 1, severity: "error", message: `[delivery] ${err instanceof Error ? err.message : String(err)}` });
25
+ }
14
26
  // Pass 2: state validation
15
27
  const ticketIds = new Set(ticketDefs.map((t) => t.id));
16
28
  for (const [id, state] of states) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -21,6 +21,34 @@
21
21
  "types": "./dist/agentRun.d.ts",
22
22
  "default": "./dist/agentRun.js"
23
23
  },
24
+ "./interviews.js": {
25
+ "types": "./dist/interviews.d.ts",
26
+ "default": "./dist/interviews.js"
27
+ },
28
+ "./runtime-readiness.js": {
29
+ "types": "./dist/runtimeReadiness.d.ts",
30
+ "default": "./dist/runtimeReadiness.js"
31
+ },
32
+ "./roles.js": {
33
+ "types": "./dist/roles.d.ts",
34
+ "default": "./dist/roles.js"
35
+ },
36
+ "./build-runs.js": {
37
+ "types": "./dist/buildRuns.d.ts",
38
+ "default": "./dist/buildRuns.js"
39
+ },
40
+ "./ticket-planning.js": {
41
+ "types": "./dist/ticketPlanning.d.ts",
42
+ "default": "./dist/ticketPlanning.js"
43
+ },
44
+ "./tickets/source-fetch.js": {
45
+ "types": "./dist/tickets/sourceFetch.d.ts",
46
+ "default": "./dist/tickets/sourceFetch.js"
47
+ },
48
+ "./tickets/setup-config.js": {
49
+ "types": "./dist/tickets/setupConfig.d.ts",
50
+ "default": "./dist/tickets/setupConfig.js"
51
+ },
24
52
  "./cli/tickets.js": {
25
53
  "types": "./dist/cli/tickets.d.ts",
26
54
  "default": "./dist/cli/tickets.js"
@@ -69,13 +97,16 @@
69
97
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
70
98
  },
71
99
  "dependencies": {
72
- "special-agents": "0.5.0",
100
+ "special-agents": "0.7.0",
101
+ "rafi-spec": "0.7.0",
73
102
  "@clack/prompts": "^1.4.0",
74
103
  "ajv": "^8.20.0",
75
104
  "ajv-formats": "^3.0.1",
76
105
  "better-sqlite3": "^12.10.0",
77
106
  "commander": "^12.1.0",
78
107
  "luxon": "^3.7.2",
108
+ "parse5": "7.2.1",
109
+ "pdfjs-dist": "4.10.38",
79
110
  "yaml": "^2.5.1"
80
111
  },
81
112
  "devDependencies": {