ai-foreman 1.6.2 → 1.7.1

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 (55) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +4 -0
  15. package/dist/cli/start.js +283 -28
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +112 -5
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/sources/sourceRegistry.d.ts +49 -0
  33. package/dist/sources/sourceRegistry.js +577 -0
  34. package/dist/statusReporter.d.ts +40 -0
  35. package/dist/statusReporter.js +31 -0
  36. package/dist/ticketPopulation.d.ts +54 -0
  37. package/dist/ticketPopulation.js +209 -0
  38. package/dist/tickets/blockers.d.ts +1 -1
  39. package/dist/tickets/blockers.js +2 -0
  40. package/dist/tickets/commands.d.ts +1 -0
  41. package/dist/tickets/commands.js +31 -1
  42. package/dist/tickets/delivery.d.ts +36 -0
  43. package/dist/tickets/delivery.js +204 -4
  44. package/dist/tickets/queue.d.ts +3 -0
  45. package/dist/tickets/queue.js +50 -1
  46. package/dist/tickets/setupConfig.js +30 -2
  47. package/dist/tickets/sourceFetch.js +6 -1
  48. package/dist/tickets/stateDb.d.ts +2 -1
  49. package/dist/tickets/stateDb.js +24 -1
  50. package/dist/tickets/ticketSchema.d.ts +53 -0
  51. package/dist/tickets/ticketSchema.js +11 -0
  52. package/dist/tickets/validate.js +1 -1
  53. package/dist/workflowDb.d.ts +113 -0
  54. package/dist/workflowDb.js +261 -0
  55. package/package.json +20 -3
@@ -0,0 +1,577 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { parse, stringify } from "yaml";
6
+ import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
7
+ import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
8
+ export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
9
+ export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
10
+ const CONFIG_FILE = "rafi-config.yaml";
11
+ const DEFAULT_REGISTRY = { version: 1, snapshot_storage: "local", entries: [] };
12
+ export function emptySourceRegistry(storage = "local") {
13
+ return { ...DEFAULT_REGISTRY, snapshot_storage: storage, entries: [] };
14
+ }
15
+ /** Read the shared registry and merge legacy source fields in memory. The next source-aware save persists migration. */
16
+ export function loadSourceRegistry(projectDir) {
17
+ const path = join(resolve(projectDir), CONFIG_FILE);
18
+ if (!existsSync(path))
19
+ return { registry: emptySourceRegistry(), configured: false, migrated: false, warnings: [] };
20
+ const raw = parse(readFileSync(path, "utf8"));
21
+ const parsed = normalizeRegistry(raw.sources);
22
+ const registry = cloneRegistry(parsed ?? DEFAULT_REGISTRY);
23
+ const warnings = [];
24
+ let migrated = false;
25
+ const planning = asObject(raw.planning);
26
+ if (Array.isArray(planning?.sources)) {
27
+ for (const value of planning.sources) {
28
+ if (typeof value !== "string" || !value.trim())
29
+ continue;
30
+ migrated = true;
31
+ migrateLegacyString(registry, value, warnings);
32
+ }
33
+ }
34
+ const tickets = asObject(raw.tickets);
35
+ if (Array.isArray(tickets?.sources)) {
36
+ for (const source of tickets.sources) {
37
+ migrated = true;
38
+ migrateTicketSource(registry, source, warnings);
39
+ }
40
+ }
41
+ warnings.push(...legacyTicketReferenceWarnings(projectDir, registry));
42
+ return { registry, configured: Boolean(parsed), migrated, warnings };
43
+ }
44
+ /** Persist the registry while preserving ticket populate/build settings and removing only superseded legacy fields. */
45
+ export function saveSourceRegistry(projectDir, registry) {
46
+ const root = resolve(projectDir);
47
+ const configPath = join(root, CONFIG_FILE);
48
+ const raw = existsSync(configPath) ? parse(readFileSync(configPath, "utf8")) : {};
49
+ raw.sources = cloneRegistry(registry);
50
+ const planning = asObject(raw.planning);
51
+ if (planning) {
52
+ delete planning.sources;
53
+ if (Object.keys(planning).length === 0)
54
+ delete raw.planning;
55
+ else
56
+ raw.planning = planning;
57
+ }
58
+ const tickets = asObject(raw.tickets);
59
+ if (tickets) {
60
+ delete tickets.sources;
61
+ raw.tickets = tickets;
62
+ }
63
+ mkdirSync(root, { recursive: true });
64
+ writeFileSync(configPath, stringify(raw, { lineWidth: 100 }), "utf8");
65
+ backfillUniqueTicketSourceIds(root, registry);
66
+ ensurePrivateCacheIgnored(root);
67
+ }
68
+ export function addPendingSourceDescription(registry, description, now = new Date()) {
69
+ const value = description.trim();
70
+ if (!value)
71
+ return cloneRegistry(registry);
72
+ const next = cloneRegistry(registry);
73
+ next.pending ??= [];
74
+ if (!next.pending.some((item) => item.description === value))
75
+ next.pending.push({ description: value, created_at: now.toISOString() });
76
+ return next;
77
+ }
78
+ export function setSourceStorage(registry, storage) {
79
+ return { ...cloneRegistry(registry), snapshot_storage: storage };
80
+ }
81
+ export function deactivateSource(registry, id) {
82
+ const next = cloneRegistry(registry);
83
+ const entry = next.entries.find((item) => item.id === id);
84
+ if (!entry)
85
+ throw new Error(`unknown source: ${id}`);
86
+ entry.active = false;
87
+ return next;
88
+ }
89
+ /** Remove only capture files introduced by a staged registry; existing history is never touched. */
90
+ export function discardStagedSourceCaptures(projectDir, original, staged) {
91
+ const root = resolve(projectDir);
92
+ const retained = new Set(original.entries.flatMap((entry) => entry.versions.map((version) => `${entry.id}:${version.fingerprint}`)));
93
+ const originalIds = new Set(original.entries.map((entry) => entry.id));
94
+ for (const entry of staged.entries) {
95
+ for (const version of entry.versions)
96
+ if (!retained.has(`${entry.id}:${version.fingerprint}`)) {
97
+ for (const path of [version.snapshot_path, version.manifest_path]) {
98
+ const absolute = resolve(root, path);
99
+ const rel = relative(root, absolute);
100
+ if (!rel.startsWith("..") && (rel.startsWith(".rafi/source-cache/") || rel.startsWith(".rafi/sources/")))
101
+ rmSync(absolute, { force: true });
102
+ }
103
+ }
104
+ if (!originalIds.has(entry.id) && entry.type === "local" && entry.locator.path?.startsWith(".tickets/imports/local-"))
105
+ rmSync(resolve(root, entry.locator.path), { recursive: true, force: true });
106
+ }
107
+ }
108
+ /** Compatibility bridge for legacy ticket setup callers. Existing entries and versions are retained. */
109
+ export function appendLegacyTicketSources(registry, sources) {
110
+ const next = cloneRegistry(registry);
111
+ const warnings = [];
112
+ for (const source of sources)
113
+ migrateTicketSource(next, source, warnings);
114
+ return next;
115
+ }
116
+ export function extractSourceRequests(output) {
117
+ const requests = [];
118
+ let cursor = 0;
119
+ while (true) {
120
+ const start = output.indexOf(SOURCE_REQUEST_START, cursor);
121
+ if (start < 0)
122
+ break;
123
+ const end = output.indexOf(SOURCE_REQUEST_END, start + SOURCE_REQUEST_START.length);
124
+ if (end < 0)
125
+ throw new Error(`source request is missing ${SOURCE_REQUEST_END}`);
126
+ const body = output.slice(start + SOURCE_REQUEST_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
127
+ let value;
128
+ try {
129
+ value = JSON.parse(body);
130
+ }
131
+ catch (error) {
132
+ throw new Error(`source request is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ const values = Array.isArray(value) ? value : [value];
135
+ for (const item of values)
136
+ if (item && typeof item === "object" && !Array.isArray(item))
137
+ requests.push(item);
138
+ cursor = end + SOURCE_REQUEST_END.length;
139
+ }
140
+ // Compatibility with the original single-line protocol. Preserve the complete remainder.
141
+ for (const match of output.matchAll(/^SOURCE_REQUEST:\s*(.+)$/gim))
142
+ requests.push({ description: match[1].trim() });
143
+ return requests;
144
+ }
145
+ /** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
146
+ export function sourceRequestFromAnswer(answer, projectDir) {
147
+ const value = answer.trim();
148
+ if (/^https?:\/\/\S+$/i.test(value))
149
+ return requestFromUrl(value);
150
+ const candidate = resolve(projectDir, value);
151
+ if (value && existsSync(candidate))
152
+ return { type: "local", label: basename(candidate), locator: { path: normalizeLocalPath(projectDir, candidate) } };
153
+ if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
154
+ return { type: "local", label: basename(value), locator: { path: value } };
155
+ if (/^(?:open\s+)?issues\s+for\s+(?:this|the)\s+repo(?:sitory)?$/i.test(value))
156
+ return { type: inferOriginProvider(projectDir), label: "Open repository issues", locator: { repository: inferOriginRepository(projectDir), mode: "issues", filters: { state: "open" } } };
157
+ return { description: value };
158
+ }
159
+ export async function registerSourceRequests(projectDir, registry, requests, opts = {}) {
160
+ const next = cloneRegistry(registry);
161
+ if (opts.storage)
162
+ next.snapshot_storage = opts.storage;
163
+ const added = [];
164
+ const snapshots = [];
165
+ const pending = [];
166
+ for (const raw of requests) {
167
+ const request = raw.description && !raw.locator ? sourceRequestFromAnswer(raw.description, projectDir) : raw;
168
+ if (!request.type || !request.locator) {
169
+ if (request.description?.trim()) {
170
+ const value = request.description.trim();
171
+ pending.push(value);
172
+ const updated = addPendingSourceDescription(next, value);
173
+ next.pending = updated.pending;
174
+ }
175
+ continue;
176
+ }
177
+ let locatorInput = request.locator;
178
+ if (request.type === "local" && locatorInput.path && opts.capture !== false) {
179
+ const absolute = resolve(projectDir, locatorInput.path);
180
+ const rel = relative(resolve(projectDir), absolute);
181
+ if (existsSync(absolute) && (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)))
182
+ locatorInput = { ...locatorInput, path: snapshotExternalLocalFile(projectDir, absolute) };
183
+ }
184
+ const locator = normalizeLocator(request.type, locatorInput, projectDir);
185
+ const key = sourceKey(request.type, locator);
186
+ let entry = next.entries.find((item) => sourceKey(item.type, item.locator) === key);
187
+ if (!entry) {
188
+ entry = { id: stableSourceId(key), type: request.type, label: request.label?.trim() || defaultLabel(request.type, locator), active: true, locator, versions: [] };
189
+ next.entries.push(entry);
190
+ added.push(entry);
191
+ }
192
+ else if (!entry.active)
193
+ entry.active = true;
194
+ if (raw.description?.trim() && next.pending)
195
+ next.pending = next.pending.filter((item) => item.description !== raw.description.trim());
196
+ if (opts.capture !== false) {
197
+ const version = await captureEntry(projectDir, entry, next.snapshot_storage);
198
+ if (!entry.versions.some((item) => item.fingerprint === version.fingerprint))
199
+ entry.versions.push(version);
200
+ snapshots.push(version.snapshot_path);
201
+ }
202
+ }
203
+ return { registry: next, entries: added, snapshots: [...new Set(snapshots)], pending };
204
+ }
205
+ export async function refreshSourceRegistry(projectDir, registry, ids) {
206
+ if (ids?.length) {
207
+ const known = new Set(registry.entries.map((entry) => entry.id));
208
+ const missing = ids.filter((id) => !known.has(id));
209
+ if (missing.length)
210
+ throw new Error(`unknown source${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
211
+ }
212
+ const selected = registry.entries.filter((entry) => entry.active && (!ids?.length || ids.includes(entry.id)));
213
+ const next = cloneRegistry(registry);
214
+ const snapshots = [];
215
+ for (const selectedEntry of selected) {
216
+ const entry = next.entries.find((item) => item.id === selectedEntry.id);
217
+ const version = await captureEntry(projectDir, entry, next.snapshot_storage);
218
+ if (!entry.versions.some((item) => item.fingerprint === version.fingerprint))
219
+ entry.versions.push(version);
220
+ snapshots.push(version.snapshot_path);
221
+ }
222
+ return { registry: next, entries: [], snapshots, pending: [] };
223
+ }
224
+ export function sourceContext(registry, options = {}) {
225
+ return registry.entries.map((entry) => {
226
+ const pinned = options.pinned?.get(entry.id);
227
+ const newest = entry.versions.at(-1);
228
+ const versions = entry.versions.filter((version) => pinned?.has(version.fingerprint) || (!options.metadataOnly?.has(entry.id) && version === newest));
229
+ return { id: entry.id, type: entry.type, label: entry.label, active: entry.active, locator: entry.locator, versions };
230
+ });
231
+ }
232
+ export function validateSourceVersionRef(registry, ref, projectDir = process.cwd()) {
233
+ const source = registry.entries.find((entry) => entry.id === ref.source_id);
234
+ if (!source)
235
+ return `unknown source registry entry ${ref.source_id}`;
236
+ const version = source.versions.find((item) => item.fingerprint === ref.fingerprint);
237
+ if (!version)
238
+ return `unknown captured version ${ref.fingerprint} for ${ref.source_id}`;
239
+ if (!existsSync(resolve(projectDir, version.snapshot_path)))
240
+ return `${version.storage === "local" ? "private" : "tracked"} source snapshot is missing for ${ref.source_id}@${ref.fingerprint}; restore it instead of substituting a newer version`;
241
+ return undefined;
242
+ }
243
+ async function captureEntry(projectDir, entry, storage) {
244
+ if (entry.type === "local")
245
+ return captureLocal(projectDir, entry, storage);
246
+ if (entry.type === "url")
247
+ return captureUrl(projectDir, entry, storage);
248
+ if (entry.type === "github" || entry.type === "gitlab")
249
+ return captureCliProvider(projectDir, entry, storage);
250
+ return captureApiProvider(projectDir, entry, storage);
251
+ }
252
+ function captureLocal(projectDir, entry, storage) {
253
+ const locator = entry.locator.path;
254
+ if (!locator)
255
+ throw new Error(`${entry.id}: local source needs a path`);
256
+ const absolute = isAbsolute(locator) ? locator : resolve(projectDir, locator);
257
+ const files = hasGlobMagic(locator) ? expandLocalGlob(projectDir, locator) : existsSync(absolute) ? collectLocalFiles(absolute) : [];
258
+ if (!files.length)
259
+ throw new Error(`${entry.id}: local source did not match files: ${locator}`);
260
+ const chunks = files.map((file) => {
261
+ const bytes = readFileSync(file);
262
+ return `\n===== ${isAbsolute(locator) ? basename(file) : relative(projectDir, file)} =====\n${bytes.toString("utf8")}`;
263
+ });
264
+ return writeCapture(projectDir, entry, storage, chunks.join("").trimStart(), "text/plain", files.length);
265
+ }
266
+ async function captureUrl(projectDir, entry, storage) {
267
+ if (!entry.locator.url)
268
+ throw new Error(`${entry.id}: URL source needs a URL`);
269
+ const fetched = await fetchAndSnapshotUrl(projectDir, entry.locator.url);
270
+ const version = writeCapture(projectDir, entry, storage, fetched.text, fetched.contentType, 1);
271
+ rmSync(resolve(projectDir, fetched.snapshotPath), { force: true });
272
+ rmSync(resolve(projectDir, fetched.metadataPath), { force: true });
273
+ return version;
274
+ }
275
+ function captureCliProvider(projectDir, entry, storage) {
276
+ const repository = entry.locator.repository || inferOriginRepository(projectDir);
277
+ const mode = entry.locator.mode ?? "issues";
278
+ let command;
279
+ let args;
280
+ if (entry.type === "github") {
281
+ command = "gh";
282
+ if (mode === "issue")
283
+ args = ["issue", "view", String(entry.locator.issue), "--repo", repository, "--json", "number,title,body,state,url,labels,comments"];
284
+ else if (mode === "project")
285
+ args = ["project", "item-list", String(entry.locator.project), "--owner", repository.split("/")[0], "--format", "json", "--limit", "500"];
286
+ else {
287
+ args = ["issue", "list", "--repo", repository, "--state", entry.locator.filters?.state ?? "open", "--limit", entry.locator.filters?.limit ?? "500", "--json", "number,title,body,state,url,labels"];
288
+ appendCliFilter(args, "--search", entry.locator.filters?.q ?? entry.locator.filters?.search);
289
+ appendCliFilter(args, "--label", entry.locator.filters?.label ?? entry.locator.filters?.labels);
290
+ appendCliFilter(args, "--assignee", entry.locator.filters?.assignee);
291
+ appendCliFilter(args, "--author", entry.locator.filters?.author);
292
+ appendCliFilter(args, "--milestone", entry.locator.filters?.milestone);
293
+ }
294
+ }
295
+ else {
296
+ command = "glab";
297
+ if (mode === "issue")
298
+ args = ["issue", "view", String(entry.locator.issue), "--repo", repository, "--output", "json"];
299
+ else if (mode === "board") {
300
+ const query = new URLSearchParams({ state: entry.locator.filters?.state ?? "opened", per_page: "100", ...(entry.locator.filters ?? {}) });
301
+ args = ["api", "--paginate", `projects/${encodeURIComponent(repository)}/issues?${query.toString()}`];
302
+ }
303
+ else {
304
+ args = ["issue", "list", "--repo", repository, "--state", entry.locator.filters?.state ?? "opened", "--per-page", entry.locator.filters?.limit ?? "500", "--output", "json"];
305
+ appendCliFilter(args, "--search", entry.locator.filters?.search);
306
+ appendCliFilter(args, "--label", entry.locator.filters?.label ?? entry.locator.filters?.labels);
307
+ appendCliFilter(args, "--assignee", entry.locator.filters?.assignee);
308
+ }
309
+ }
310
+ let text;
311
+ try {
312
+ text = execFileSync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
313
+ }
314
+ catch (error) {
315
+ throw new Error(`${entry.type} source ${entry.label} could not be retrieved with authenticated ${command}: ${error instanceof Error ? error.message : String(error)}`);
316
+ }
317
+ return writeCapture(projectDir, entry, storage, text, "application/json", countProviderItems(text));
318
+ }
319
+ async function captureApiProvider(projectDir, entry, storage) {
320
+ const opts = { importCap: 500, commentLimit: 10 };
321
+ const items = entry.type === "linear"
322
+ ? await fetchLinearIssues({ type: "linear", api_key_env: entry.locator.api_key_env ?? "LINEAR_API_KEY", team_key: entry.locator.team_key, filter: entry.locator.filter }, opts)
323
+ : await fetchJiraIssues({ type: "jira", site: required(entry.locator.site, `${entry.id}: Jira site`), email_env: entry.locator.email_env ?? "JIRA_EMAIL", token_env: entry.locator.token_env ?? "JIRA_API_TOKEN", jql: required(entry.locator.jql, `${entry.id}: Jira JQL`) }, opts);
324
+ return writeCapture(projectDir, entry, storage, `${JSON.stringify(items.map((item) => item.raw), null, 2)}\n`, "application/json", items.length);
325
+ }
326
+ function writeCapture(projectDir, entry, storage, content, contentType, itemCount) {
327
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
328
+ const fingerprint = createHash("sha256").update(normalized).digest("hex");
329
+ const root = resolve(projectDir);
330
+ const base = join(root, storage === "tracked" ? ".rafi/sources" : ".rafi/source-cache", entry.id);
331
+ const extension = contentType.includes("json") ? "json" : "md";
332
+ const snapshot = join(base, `${fingerprint}.${extension}`);
333
+ const manifest = join(base, `${fingerprint}.manifest.json`);
334
+ mkdirSync(base, { recursive: true });
335
+ if (!existsSync(snapshot))
336
+ writeFileSync(snapshot, normalized, "utf8");
337
+ const version = {
338
+ fingerprint, captured_at: new Date().toISOString(), storage,
339
+ snapshot_path: relative(root, snapshot), manifest_path: relative(root, manifest),
340
+ content_type: contentType, bytes: Buffer.byteLength(normalized), item_count: itemCount,
341
+ };
342
+ if (!existsSync(manifest))
343
+ writeFileSync(manifest, `${JSON.stringify({ source_id: entry.id, type: entry.type, label: entry.label, locator: entry.locator, ...version }, null, 2)}\n`, "utf8");
344
+ return version;
345
+ }
346
+ function collectLocalFiles(path) {
347
+ const stat = statSync(path);
348
+ if (stat.isFile())
349
+ return [path];
350
+ if (!stat.isDirectory())
351
+ throw new Error(`unsupported local source: ${path}`);
352
+ const out = [];
353
+ const visit = (dir) => {
354
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
355
+ if (item.isSymbolicLink() || [".git", "node_modules", ".rafi", ".tickets"].includes(item.name))
356
+ continue;
357
+ const child = join(dir, item.name);
358
+ if (item.isDirectory())
359
+ visit(child);
360
+ else if (item.isFile())
361
+ out.push(child);
362
+ if (out.length > 1000)
363
+ throw new Error("local source directory exceeds 1000 file limit");
364
+ }
365
+ };
366
+ visit(path);
367
+ return out.sort();
368
+ }
369
+ function hasGlobMagic(value) { return /[*?\[]/.test(value); }
370
+ function expandLocalGlob(projectDir, pattern) {
371
+ if (isAbsolute(pattern))
372
+ throw new Error("absolute globs are not supported; name an external file or directory explicitly");
373
+ const normalized = pattern.split(sep).join("/").replace(/^\.\//, "");
374
+ let body = "";
375
+ for (let index = 0; index < normalized.length; index++) {
376
+ const char = normalized[index];
377
+ if (char === "*" && normalized[index + 1] === "*") {
378
+ body += ".*";
379
+ index++;
380
+ }
381
+ else if (char === "*")
382
+ body += "[^/]*";
383
+ else if (char === "?")
384
+ body += "[^/]";
385
+ else
386
+ body += /[\\^$.[\]{}()+|]/.test(char) ? `\\${char}` : char;
387
+ }
388
+ const expression = new RegExp(`^${body}$`);
389
+ return collectLocalFiles(projectDir).filter((file) => expression.test(relative(projectDir, file).split(sep).join("/")));
390
+ }
391
+ function requestFromUrl(value) {
392
+ const url = new URL(normalizePublicHttpUrl(value));
393
+ const parts = url.pathname.split("/").filter(Boolean);
394
+ if (url.hostname.toLowerCase() === "github.com" && parts.length >= 2) {
395
+ const issueIndex = parts.indexOf("issues");
396
+ const projectIndex = parts.indexOf("projects");
397
+ if (issueIndex < 0 && projectIndex < 0)
398
+ return { type: "url", label: url.hostname, locator: { url: url.toString() } };
399
+ const projectOwner = ["orgs", "users"].includes(parts[0]) && projectIndex === 2 ? parts[1] : undefined;
400
+ const locator = { repository: projectOwner ?? `${parts[0]}/${parts[1]}`, mode: "issues", filters: Object.fromEntries(url.searchParams) };
401
+ if (issueIndex >= 0 && /^\d+$/.test(parts[issueIndex + 1] ?? "")) {
402
+ locator.mode = "issue";
403
+ locator.issue = Number(parts[issueIndex + 1]);
404
+ }
405
+ else if (projectIndex >= 0) {
406
+ locator.mode = "project";
407
+ locator.project = parts[projectIndex + 1];
408
+ }
409
+ return { type: "github", label: `GitHub ${locator.repository}`, locator };
410
+ }
411
+ if (url.hostname.toLowerCase().includes("gitlab") && parts.length >= 2) {
412
+ const issueIndex = parts.indexOf("issues");
413
+ if (issueIndex < 0 && !parts.includes("boards"))
414
+ return { type: "url", label: url.hostname, locator: { url: url.toString() } };
415
+ const locator = { repository: parts.slice(0, parts.indexOf("-") >= 0 ? parts.indexOf("-") : 2).join("/"), mode: "issues", filters: Object.fromEntries(url.searchParams) };
416
+ if (issueIndex >= 0 && /^\d+$/.test(parts[issueIndex + 1] ?? "")) {
417
+ locator.mode = "issue";
418
+ locator.issue = Number(parts[issueIndex + 1]);
419
+ }
420
+ if (parts.includes("boards"))
421
+ locator.mode = "board";
422
+ return { type: "gitlab", label: `GitLab ${locator.repository}`, locator };
423
+ }
424
+ return { type: "url", label: url.hostname, locator: { url: url.toString() } };
425
+ }
426
+ function normalizeLocator(type, locator, projectDir) {
427
+ const value = { ...locator, filters: locator.filters ? Object.fromEntries(Object.entries(locator.filters).sort()) : undefined };
428
+ if (type === "url" && value.url)
429
+ value.url = normalizePublicHttpUrl(value.url);
430
+ if (type === "local" && value.path)
431
+ value.path = normalizeLocalPath(projectDir, value.path);
432
+ if (value.site)
433
+ value.site = value.site.replace(/\/+$/, "").toLowerCase();
434
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
435
+ }
436
+ function normalizeLocalPath(projectDir, value) {
437
+ const root = resolve(projectDir);
438
+ const absolute = resolve(projectDir, value);
439
+ const rel = relative(root, absolute);
440
+ // Do not persist external absolute locators. The immutable snapshot remains usable.
441
+ return !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel) ? rel || "." : basename(absolute);
442
+ }
443
+ function sourceKey(type, locator) {
444
+ return `${type}:${canonical(locator)}`;
445
+ }
446
+ function stableSourceId(key) { return `src_${createHash("sha256").update(key).digest("hex").slice(0, 16)}`; }
447
+ function canonical(value) {
448
+ if (Array.isArray(value))
449
+ return `[${value.map(canonical).join(",")}]`;
450
+ if (value && typeof value === "object")
451
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
452
+ return JSON.stringify(value);
453
+ }
454
+ function defaultLabel(type, locator) { return locator.path || locator.url || locator.repository || locator.site || locator.team_key || type; }
455
+ function countProviderItems(text) { try {
456
+ const value = JSON.parse(text);
457
+ return Array.isArray(value) ? value.length : 1;
458
+ }
459
+ catch {
460
+ return 0;
461
+ } }
462
+ function appendCliFilter(args, flag, value) { if (value)
463
+ args.push(flag, value); }
464
+ function required(value, label) { if (!value)
465
+ throw new Error(`${label} is required`); return value; }
466
+ function inferOriginProvider(projectDir) {
467
+ const url = originUrl(projectDir).toLowerCase();
468
+ return url.includes("gitlab") ? "gitlab" : "github";
469
+ }
470
+ function inferOriginRepository(projectDir) {
471
+ const value = originUrl(projectDir).replace(/\.git$/, "");
472
+ const match = value.match(/(?:github|gitlab)(?:\.com|\.[^/:]+)[/:](.+)$/i) ?? value.match(/[:/]([^/:]+\/[^/]+)$/);
473
+ if (!match?.[1])
474
+ throw new Error("could not infer repository from git origin; provide owner/repository");
475
+ return match[1];
476
+ }
477
+ function originUrl(projectDir) {
478
+ try {
479
+ return execFileSync("git", ["remote", "get-url", "origin"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
480
+ }
481
+ catch {
482
+ throw new Error("could not infer source provider because this project has no readable origin remote");
483
+ }
484
+ }
485
+ function migrateLegacyString(registry, value, warnings) {
486
+ const trimmed = value.trim();
487
+ if (/^https?:\/\/\S+$/i.test(trimmed)) {
488
+ const req = requestFromUrl(trimmed);
489
+ addLegacyEntry(registry, req.type, req.label, req.locator);
490
+ return;
491
+ }
492
+ if (/^[.~\/]|[*?\[]|\.[A-Za-z0-9]{1,8}$/i.test(trimmed)) {
493
+ addLegacyEntry(registry, "local", trimmed, { path: trimmed });
494
+ return;
495
+ }
496
+ registry.pending ??= [];
497
+ registry.pending.push({ description: trimmed, created_at: new Date().toISOString() });
498
+ warnings.push(`legacy source needs interpretation: ${trimmed}`);
499
+ }
500
+ function migrateTicketSource(registry, raw, warnings) {
501
+ if (!raw || typeof raw !== "object")
502
+ return;
503
+ if (raw.type === "local")
504
+ for (const path of raw.paths ?? [])
505
+ addLegacyEntry(registry, "local", path, { path });
506
+ else if (raw.type === "url") {
507
+ const req = requestFromUrl(raw.url);
508
+ addLegacyEntry(registry, req.type, req.label, req.locator);
509
+ }
510
+ else if (raw.type === "linear")
511
+ addLegacyEntry(registry, "linear", raw.team_key ? `Linear ${raw.team_key}` : "Linear", { api_key_env: raw.api_key_env ?? "LINEAR_API_KEY", team_key: raw.team_key, filter: raw.filter });
512
+ else if (raw.type === "jira")
513
+ addLegacyEntry(registry, "jira", `Jira ${raw.site}`, { site: raw.site, email_env: raw.email_env ?? "JIRA_EMAIL", token_env: raw.token_env ?? "JIRA_API_TOKEN", jql: raw.jql });
514
+ else
515
+ warnings.push("unsupported legacy ticket source was left unchanged");
516
+ }
517
+ function addLegacyEntry(registry, type, label, locator) {
518
+ const key = sourceKey(type, locator);
519
+ if (!registry.entries.some((entry) => sourceKey(entry.type, entry.locator) === key))
520
+ registry.entries.push({ id: stableSourceId(key), type, label, active: true, locator, versions: [] });
521
+ }
522
+ function normalizeRegistry(value) {
523
+ if (!value || typeof value !== "object" || Array.isArray(value))
524
+ return undefined;
525
+ const raw = value;
526
+ if (raw.version !== 1 || !Array.isArray(raw.entries))
527
+ return undefined;
528
+ return { version: 1, snapshot_storage: raw.snapshot_storage === "tracked" ? "tracked" : "local", entries: raw.entries, ...(Array.isArray(raw.pending) ? { pending: raw.pending } : {}) };
529
+ }
530
+ function cloneRegistry(registry) { return JSON.parse(JSON.stringify(registry)); }
531
+ function asObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; }
532
+ function ensurePrivateCacheIgnored(projectDir) {
533
+ const path = join(projectDir, ".gitignore");
534
+ const marker = ".rafi/source-cache/";
535
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
536
+ if (!current.split(/\r?\n/).includes(marker))
537
+ writeFileSync(path, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${marker}\n`, "utf8");
538
+ }
539
+ function legacyTicketReferenceWarnings(projectDir, registry) {
540
+ const path = join(projectDir, ".tickets", "tickets.yaml");
541
+ if (!existsSync(path))
542
+ return [];
543
+ const raw = parse(readFileSync(path, "utf8"));
544
+ const warnings = [];
545
+ for (const ticket of raw?.tickets ?? [])
546
+ for (const ref of ticket.source_refs ?? [])
547
+ if (!ref.source_id && ref.source) {
548
+ const matches = matchingSourceIds(registry, ref.source);
549
+ if (matches.length > 1)
550
+ warnings.push(`ticket ${ticket.id ?? "unknown"} has ambiguous legacy source reference ${ref.source}: ${matches.join(", ")}`);
551
+ }
552
+ return warnings;
553
+ }
554
+ function backfillUniqueTicketSourceIds(projectDir, registry) {
555
+ const path = join(projectDir, ".tickets", "tickets.yaml");
556
+ if (!existsSync(path))
557
+ return;
558
+ const raw = parse(readFileSync(path, "utf8"));
559
+ let changed = false;
560
+ for (const ticket of raw?.tickets ?? [])
561
+ for (const ref of ticket.source_refs ?? [])
562
+ if (!ref.source_id && ref.source) {
563
+ const matches = matchingSourceIds(registry, ref.source);
564
+ if (matches.length === 1) {
565
+ ref.source_id = matches[0];
566
+ changed = true;
567
+ }
568
+ }
569
+ if (changed)
570
+ writeFileSync(path, stringify(raw, { lineWidth: 120 }), "utf8");
571
+ }
572
+ function matchingSourceIds(registry, value) {
573
+ return registry.entries.filter((entry) => {
574
+ const candidates = [entry.id, entry.label, entry.type, entry.locator.url, entry.locator.path, entry.locator.repository, entry.locator.site];
575
+ return candidates.some((candidate) => candidate === value);
576
+ }).map((entry) => entry.id);
577
+ }
@@ -0,0 +1,40 @@
1
+ import type { BuilderAdapter } from "./adapters/types.js";
2
+ export interface AgentStatusSnapshot {
3
+ role: string;
4
+ provider: string;
5
+ model: string;
6
+ reasoning: string;
7
+ fast: boolean;
8
+ ticket?: string;
9
+ stack?: string;
10
+ step: number;
11
+ total: number;
12
+ phase: string;
13
+ qaCycle?: number;
14
+ context?: {
15
+ used: number;
16
+ maximum?: number;
17
+ percentage?: number;
18
+ };
19
+ sessionTransition: string;
20
+ at: string;
21
+ }
22
+ export interface StatusClock {
23
+ now(): Date;
24
+ setInterval(callback: () => void, milliseconds: number): unknown;
25
+ clearInterval(handle: unknown): void;
26
+ }
27
+ /** Non-blocking five-minute status with provider-truthful context occupancy. */
28
+ export declare class AgentStatusReporter {
29
+ private readonly input;
30
+ private readonly emit;
31
+ private readonly clock;
32
+ private readonly intervalMs;
33
+ private handle?;
34
+ constructor(input: Omit<AgentStatusSnapshot, "context" | "at"> & {
35
+ adapter: BuilderAdapter | (() => BuilderAdapter);
36
+ }, emit: (line: string, snapshot: AgentStatusSnapshot) => void, clock?: StatusClock, intervalMs?: number);
37
+ start(): void;
38
+ tick(): Promise<void>;
39
+ stop(): void;
40
+ }
@@ -0,0 +1,31 @@
1
+ const systemClock = {
2
+ now: () => new Date(),
3
+ setInterval: (callback, milliseconds) => { const handle = setInterval(callback, milliseconds); handle.unref(); return handle; },
4
+ clearInterval: (handle) => clearInterval(handle),
5
+ };
6
+ /** Non-blocking five-minute status with provider-truthful context occupancy. */
7
+ export class AgentStatusReporter {
8
+ input;
9
+ emit;
10
+ clock;
11
+ intervalMs;
12
+ handle;
13
+ constructor(input, emit, clock = systemClock, intervalMs = 300_000) {
14
+ this.input = input;
15
+ this.emit = emit;
16
+ this.clock = clock;
17
+ this.intervalMs = intervalMs;
18
+ }
19
+ start() { if (this.handle !== undefined)
20
+ return; this.handle = this.clock.setInterval(() => void this.tick(), this.intervalMs); }
21
+ async tick() {
22
+ const adapter = typeof this.input.adapter === "function" ? this.input.adapter() : this.input.adapter;
23
+ const context = await adapter.contextUsage?.();
24
+ const { adapter: _adapter, ...base } = this.input;
25
+ const snapshot = { ...base, ...(context ? { context } : {}), at: this.clock.now().toISOString() };
26
+ const occupancy = context ? `context: ${context.used}/${context.maximum ?? "unknown"}${context.percentage === undefined ? "" : ` (${context.percentage.toFixed(1)}%)`}` : "context: unavailable";
27
+ this.emit(`[${snapshot.at}] ${snapshot.role} ${snapshot.provider}/${snapshot.model} reasoning=${snapshot.reasoning} fast=${snapshot.fast}; ${snapshot.ticket ? `ticket=${snapshot.ticket}; ` : ""}${snapshot.stack ? `stack=${snapshot.stack}; ` : ""}step=${snapshot.step}/${snapshot.total}; phase=${snapshot.phase}${snapshot.qaCycle ? `; qa_cycle=${snapshot.qaCycle}` : ""}; ${occupancy}; session=${snapshot.sessionTransition}`, snapshot);
28
+ }
29
+ stop() { if (this.handle !== undefined)
30
+ this.clock.clearInterval(this.handle); this.handle = undefined; }
31
+ }