ai-foreman 1.7.0 → 1.7.2

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.
package/README.md CHANGED
@@ -176,7 +176,7 @@ gh repo view <host>/<owner>/<repo>
176
176
  | `--fast` | flag | off | Lower latency. |
177
177
  | `-y, --yes` | flag | off | Skips confirmation before builder edits ticket files. |
178
178
 
179
- Use `ai-foreman tickets setup:init` or `setup:update` to save local, Linear, or Jira Cloud ticket sources in `rafi-config.yaml`. Linear imports use `LINEAR_API_KEY`; Jira Cloud imports use `JIRA_EMAIL` and `JIRA_API_TOKEN`. Imported provider payloads are snapshotted under ignored `.tickets/imports/`, and mirrored tickets carry `external_refs`.
179
+ Use `ai-foreman tickets setup:init` or `setup:update` to append local, Linear, or Jira Cloud sources to the project-wide registry in `rafi-config.yaml`. Linear imports use `LINEAR_API_KEY`; Jira Cloud imports use `JIRA_EMAIL` and `JIRA_API_TOKEN`. Credentials are never persisted, and mirrored tickets retain source provenance.
180
180
 
181
181
  Use `ai-foreman tickets review` to accept, defer, or dismiss pending split/combine/duplicate recommendations rendered in the progress doc.
182
182
 
@@ -11,6 +11,7 @@ export declare function generatedTrackerDirtyPaths(paths: {
11
11
  progressDoc: string;
12
12
  archiveDoc?: string;
13
13
  }): string[];
14
+ export declare function collectBaseWorktreeDirtyPaths(cwd: string, opts?: BaseWorktreeCleanOptions): string[];
14
15
  export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
15
16
  export declare function ensureForemanExcluded(cwd: string): void;
16
17
  export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
@@ -25,13 +25,16 @@ export function generatedTrackerDirtyPaths(paths) {
25
25
  }
26
26
  return out;
27
27
  }
28
- export function ensureCleanBaseWorktree(cwd, opts = {}) {
28
+ export function collectBaseWorktreeDirtyPaths(cwd, opts = {}) {
29
29
  const allowedDirtyPaths = new Set([".tickets/.gitignore", ...(opts.allowedDirtyPaths ?? []).map(normalizeRepoPath)]);
30
30
  const status = runGit(cwd, ["status", "--porcelain", "--untracked-files=all"]).stdout;
31
- const dirty = status.split("\n").filter(Boolean).filter((line) => {
31
+ return status.split("\n").filter(Boolean).filter((line) => {
32
32
  const path = normalizeRepoPath(line.slice(3));
33
33
  return !path.startsWith(".foreman/") && !allowedDirtyPaths.has(path);
34
34
  });
35
+ }
36
+ export function ensureCleanBaseWorktree(cwd, opts = {}) {
37
+ const dirty = collectBaseWorktreeDirtyPaths(cwd, opts);
35
38
  if (dirty.length > 0) {
36
39
  throw new Error(`base worktree has uncommitted changes:\n${dirty.join("\n")}`);
37
40
  }
@@ -10,6 +10,7 @@ export interface DeliveryUnitSession {
10
10
  sessionId: string;
11
11
  ticket: string;
12
12
  }
13
+ export type BaseWorktreePolicy = "enforce" | "warn" | "skip";
13
14
  export declare function readDeliveryUnitSession(projectDir: string, unitId: string): DeliveryUnitSession | undefined;
14
15
  export interface BranchRunnerOptions {
15
16
  projectDir: string;
@@ -32,6 +33,7 @@ export interface BranchRunnerOptions {
32
33
  autoMergeTimeoutMinutes?: number | null;
33
34
  mergeMethod?: MergeMethod;
34
35
  allowedBaseDirtyPaths?: string[];
36
+ baseWorktreePolicy?: BaseWorktreePolicy;
35
37
  trackerPaths?: {
36
38
  progressDoc: string;
37
39
  archiveDoc: string;
@@ -24,7 +24,20 @@ function deliverySessionPath(projectDir, unitId) {
24
24
  return join(projectDir, ".foreman", "delivery-sessions", `${unitId}.json`);
25
25
  }
26
26
  export async function runBranchPlan(opts) {
27
- ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
27
+ const baseWorktreePolicy = opts.baseWorktreePolicy ?? "enforce";
28
+ if (baseWorktreePolicy !== "skip") {
29
+ try {
30
+ ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
31
+ }
32
+ catch (error) {
33
+ if (baseWorktreePolicy === "warn") {
34
+ console.warn(`foreman: warning: ${error instanceof Error ? error.message : String(error)}`);
35
+ }
36
+ else {
37
+ throw error;
38
+ }
39
+ }
40
+ }
28
41
  ensureForemanExcluded(opts.projectDir);
29
42
  const completionMode = opts.completionMode ?? (opts.createPr ? "pr" : "none");
30
43
  const reviewProvider = opts.reviewProvider ?? "github";
package/dist/cli/start.js CHANGED
@@ -18,7 +18,7 @@ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../
18
18
  import { loadTickets } from "../tickets/ticketLoader.js";
19
19
  import { StateDb } from "../tickets/stateDb.js";
20
20
  import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
21
- import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
21
+ import { collectBaseWorktreeDirtyPaths, currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
22
22
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
23
23
  import { preflightGlab } from "../branch/gitlab.js";
24
24
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
@@ -529,11 +529,20 @@ export function buildStartCommand() {
529
529
  if (branchMode) {
530
530
  const ticketsConfig = loadTicketsConfig(cwd);
531
531
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
532
- try {
533
- ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
532
+ const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
533
+ if (baseWorktreePolicy === "skip") {
534
+ const dirty = collectBaseWorktreeDirtyPaths(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
535
+ if (dirty.length > 0) {
536
+ console.warn(`foreman: warning: base worktree has uncommitted changes; build recovery will continue.\n${dirty.join("\n")}`);
537
+ }
534
538
  }
535
- catch (err) {
536
- fail(err instanceof Error ? err.message : String(err));
539
+ else {
540
+ try {
541
+ ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
542
+ }
543
+ catch (err) {
544
+ fail(err instanceof Error ? err.message : String(err));
545
+ }
537
546
  }
538
547
  if (branchDefaults.createReview && branchDefaults.reviewProvider) {
539
548
  await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
@@ -718,6 +727,7 @@ export function buildStartCommand() {
718
727
  autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
719
728
  mergeMethod: branchDefaults.mergeMethod,
720
729
  allowedBaseDirtyPaths,
730
+ baseWorktreePolicy,
721
731
  trackerPaths: {
722
732
  progressDoc: ticketsConfig.paths.progressDoc,
723
733
  archiveDoc: ticketsConfig.paths.archiveDoc,
@@ -266,6 +266,8 @@ async function collectTicketSetupPatch(dir, opts, current) {
266
266
  const buildPatch = {};
267
267
  if (opts.agentPreference)
268
268
  populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
269
+ if (opts.branchStrategy)
270
+ buildPatch.branch_strategy = parseBranchStrategy(opts.branchStrategy);
269
271
  if (opts.completion)
270
272
  buildPatch.completion = parseCompletionMode(opts.completion);
271
273
  if (opts.provider)
@@ -275,6 +277,15 @@ async function collectTicketSetupPatch(dir, opts, current) {
275
277
  if (opts.autoMergeTimeoutMinutes !== undefined) {
276
278
  buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
277
279
  }
280
+ if (buildPatch.branch_strategy === "current") {
281
+ if (buildPatch.completion && buildPatch.completion !== "none")
282
+ fail("--branch-strategy current requires --completion none");
283
+ buildPatch.completion = "none";
284
+ buildPatch.provider = buildPatch.provider ?? "local";
285
+ buildPatch.pr_ready = false;
286
+ buildPatch.auto_merge_wait = false;
287
+ buildPatch.auto_merge_timeout_minutes = null;
288
+ }
278
289
  if (!shouldPrompt(opts)) {
279
290
  const prefill = current.sources.length === 0
280
291
  ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
@@ -321,6 +332,32 @@ async function collectTicketSetupPatch(dir, opts, current) {
321
332
  }
322
333
  if (section === "build" || section === "all") {
323
334
  const recommended = recommendedBuildDefaults(dir);
335
+ const strategy = await select({
336
+ message: "Default ticket work mode:",
337
+ initialValue: current.build.branch_strategy,
338
+ options: [
339
+ { value: "current", label: "One branch - work the queue on the current branch" },
340
+ { value: "batch", label: "Batch branch - use shared branches for explicit delivery batches" },
341
+ { value: "branch-per-ticket", label: "Branch per ticket - isolate each ticket on its own branch" },
342
+ ],
343
+ });
344
+ if (isCancel(strategy))
345
+ process.exit(0);
346
+ buildPatch.branch_strategy = strategy;
347
+ if (strategy === "current") {
348
+ buildPatch.completion = "none";
349
+ buildPatch.provider = "local";
350
+ buildPatch.pr_ready = false;
351
+ buildPatch.merge_method = "squash";
352
+ buildPatch.cleanup = true;
353
+ buildPatch.auto_merge_wait = false;
354
+ buildPatch.auto_merge_timeout_minutes = null;
355
+ return {
356
+ sources,
357
+ populate: populatePatch,
358
+ build: buildPatch,
359
+ };
360
+ }
324
361
  const completion = await select({
325
362
  message: "Default completion behavior for branch ticket runs:",
326
363
  initialValue: current.build.completion === "none" ? recommended.completion : current.build.completion,
@@ -333,7 +370,6 @@ async function collectTicketSetupPatch(dir, opts, current) {
333
370
  });
334
371
  if (isCancel(completion))
335
372
  process.exit(0);
336
- buildPatch.branch_strategy = "branch-per-ticket";
337
373
  buildPatch.completion = completion;
338
374
  buildPatch.provider = recommended.provider;
339
375
  buildPatch.pr_ready = completion === "auto-merge";
@@ -517,6 +553,11 @@ function parseCompletionMode(value) {
517
553
  return value;
518
554
  fail("--completion must be one of: pr, auto-merge, direct-merge, none");
519
555
  }
556
+ function parseBranchStrategy(value) {
557
+ if (["current", "batch", "branch-per-ticket"].includes(value))
558
+ return value;
559
+ fail("--branch-strategy must be one of: current, batch, branch-per-ticket");
560
+ }
520
561
  function parseProvider(value) {
521
562
  if (["auto", "github", "gitlab", "local"].includes(value))
522
563
  return value;
@@ -853,6 +894,7 @@ export function buildTicketsCommand() {
853
894
  .option("--jira-jql <jql>", "Jira JQL query")
854
895
  .option("--url-source <urls...>", "add public HTTP(S) source URLs")
855
896
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
897
+ .option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
856
898
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
857
899
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
858
900
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
@@ -883,6 +925,7 @@ export function buildTicketsCommand() {
883
925
  .option("--jira-jql <jql>", "Jira JQL query")
884
926
  .option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
885
927
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
928
+ .option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
886
929
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
887
930
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
888
931
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
@@ -1261,7 +1304,7 @@ export function buildTicketsCommand() {
1261
1304
  .description("Print the ticket queue to stdout.")
1262
1305
  .option("-p, --project <dir>", "project directory (default: cwd)")
1263
1306
  .option("--limit <n>", "override view limit")
1264
- .option("--refresh", "query GitHub/GitLab and refresh cached stack review state")
1307
+ .option("--refresh", "query GitHub/GitLab and refresh cached batch review state")
1265
1308
  .action((opts) => {
1266
1309
  try {
1267
1310
  const dir = cwd(opts);
@@ -1275,6 +1318,7 @@ export function buildTicketsCommand() {
1275
1318
  console.log(line);
1276
1319
  return;
1277
1320
  }
1321
+ console.log("Batches: none configured. Queue is flat.");
1278
1322
  const rows = cmdQueue(dir, opts.limit !== undefined ? Number(opts.limit) : undefined);
1279
1323
  if (rows.length === 0) {
1280
1324
  console.log("No remaining tickets.");
@@ -0,0 +1,49 @@
1
+ import type { ProjectSourceEntry, ProjectSourceLocator, ProjectSourceType, SourceRegistryConfig, SourceSnapshotStorage, TicketSetupSource } from "rafi-spec";
2
+ export declare const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
3
+ export declare const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
4
+ export interface StructuredSourceRequest {
5
+ type?: ProjectSourceType;
6
+ description?: string;
7
+ label?: string;
8
+ locator?: ProjectSourceLocator;
9
+ }
10
+ export interface LoadedSourceRegistry {
11
+ registry: SourceRegistryConfig;
12
+ configured: boolean;
13
+ migrated: boolean;
14
+ warnings: string[];
15
+ }
16
+ export interface CaptureResult {
17
+ registry: SourceRegistryConfig;
18
+ entries: ProjectSourceEntry[];
19
+ snapshots: string[];
20
+ pending: string[];
21
+ }
22
+ export declare function emptySourceRegistry(storage?: SourceSnapshotStorage): SourceRegistryConfig;
23
+ /** Read the shared registry and merge legacy source fields in memory. The next source-aware save persists migration. */
24
+ export declare function loadSourceRegistry(projectDir: string): LoadedSourceRegistry;
25
+ /** Persist the registry while preserving ticket populate/build settings and removing only superseded legacy fields. */
26
+ export declare function saveSourceRegistry(projectDir: string, registry: SourceRegistryConfig): void;
27
+ export declare function addPendingSourceDescription(registry: SourceRegistryConfig, description: string, now?: Date): SourceRegistryConfig;
28
+ export declare function setSourceStorage(registry: SourceRegistryConfig, storage: SourceSnapshotStorage): SourceRegistryConfig;
29
+ export declare function deactivateSource(registry: SourceRegistryConfig, id: string): SourceRegistryConfig;
30
+ /** Remove only capture files introduced by a staged registry; existing history is never touched. */
31
+ export declare function discardStagedSourceCaptures(projectDir: string, original: SourceRegistryConfig, staged: SourceRegistryConfig): void;
32
+ /** Compatibility bridge for legacy ticket setup callers. Existing entries and versions are retained. */
33
+ export declare function appendLegacyTicketSources(registry: SourceRegistryConfig, sources: TicketSetupSource[]): SourceRegistryConfig;
34
+ export declare function extractSourceRequests(output: string): StructuredSourceRequest[];
35
+ /** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
36
+ export declare function sourceRequestFromAnswer(answer: string, projectDir: string): StructuredSourceRequest;
37
+ export declare function registerSourceRequests(projectDir: string, registry: SourceRegistryConfig, requests: StructuredSourceRequest[], opts?: {
38
+ capture?: boolean;
39
+ storage?: SourceSnapshotStorage;
40
+ }): Promise<CaptureResult>;
41
+ export declare function refreshSourceRegistry(projectDir: string, registry: SourceRegistryConfig, ids?: string[]): Promise<CaptureResult>;
42
+ export declare function sourceContext(registry: SourceRegistryConfig, options?: {
43
+ metadataOnly?: Set<string>;
44
+ pinned?: Map<string, Set<string>>;
45
+ }): Array<Record<string, unknown>>;
46
+ export declare function validateSourceVersionRef(registry: SourceRegistryConfig, ref: {
47
+ source_id: string;
48
+ fingerprint: string;
49
+ }, projectDir?: string): string | undefined;
@@ -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
+ }
@@ -15,6 +15,13 @@ export interface TicketSliceProposal {
15
15
  depends_on: string[];
16
16
  rollback?: string | null;
17
17
  notes?: string | null;
18
+ source_refs?: Array<{
19
+ source_id: string;
20
+ fingerprint: string;
21
+ item?: string;
22
+ url?: string | null;
23
+ note?: string | null;
24
+ }>;
18
25
  }
19
26
  export interface TicketPopulationProposalV1 {
20
27
  version: 1;
@@ -69,6 +69,10 @@ export function materializeTicketPopulation(proposal, plan, existing) {
69
69
  depends_on: slice.depends_on.map((ref) => sliceToTicket.get(ref)), summary: slice.summary,
70
70
  acceptance: [...slice.acceptance], required_tests: [...slice.required_tests], likely_files: [...slice.likely_files],
71
71
  rollback: slice.rollback, notes: slice.notes, plan_ref: { plan_id: plan.plan_id, revision: plan.revision, slice_ref: slice.slice_ref },
72
+ source_refs: (plan.slices.find((item) => item.slice_ref === slice.slice_ref)?.source_refs ?? slice.source_refs)?.map((ref) => ({
73
+ source: ref.source_id, item: ref.item ?? "document", source_id: ref.source_id, fingerprint: ref.fingerprint,
74
+ ...(ref.url !== undefined ? { url: ref.url } : {}), ...(ref.note !== undefined ? { note: ref.note } : {}),
75
+ })),
72
76
  };
73
77
  });
74
78
  const ownedIds = new Set(materialized.map((ticket) => ticket.id));
@@ -134,6 +134,7 @@ export function cmdInit(projectDir, opts) {
134
134
  properties: {
135
135
  source: { type: "string", minLength: 1 },
136
136
  item: { type: "string", minLength: 1 },
137
+ source_id: { type: "string", pattern: "^src_[A-Za-z0-9_-]+$" },
137
138
  url: { type: ["string", "null"] },
138
139
  fingerprint: { type: ["string", "null"] },
139
140
  note: { type: ["string", "null"] },
@@ -35,5 +35,5 @@ export interface ActiveStatusRow {
35
35
  }
36
36
  export declare function buildNextQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, queueLimit: number): QueueRow[];
37
37
  export declare function buildActiveStatusRows(queueRows: QueueRow[], ticketDefs: TicketDef[], states: Map<string, TicketState>): ActiveStatusRow[];
38
- /** Human-readable queue blocks keep a whole unfinished stack and its resume point visible. */
38
+ /** Human-readable queue blocks keep a whole unfinished batch and its resume point visible. */
39
39
  export declare function formatStackAwareQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, delivery?: DeliveryConfig): string[];
@@ -61,15 +61,16 @@ export function buildActiveStatusRows(queueRows, ticketDefs, states) {
61
61
  };
62
62
  });
63
63
  }
64
- /** Human-readable queue blocks keep a whole unfinished stack and its resume point visible. */
64
+ /** Human-readable queue blocks keep a whole unfinished batch and its resume point visible. */
65
65
  export function formatStackAwareQueue(ticketDefs, states, delivery) {
66
66
  if (!delivery?.stacks?.length)
67
- return buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`);
67
+ return ["Batches: none configured. Queue is flat.", ...buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`)];
68
68
  const defs = new Map(ticketDefs.map((ticket) => [ticket.id, ticket]));
69
69
  const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
70
70
  const stackedTickets = new Set();
71
71
  const lines = [];
72
- for (const stack of delivery.stacks) {
72
+ lines.push(`Batches: ${delivery.stacks.length} configured.`);
73
+ for (const [index, stack] of delivery.stacks.entries()) {
73
74
  const nodes = normalizeStackNodes(stack, delivery.units, ticketDefs);
74
75
  const ticketIds = stack.units.flatMap((id) => unitById.get(id)?.tickets ?? []);
75
76
  ticketIds.forEach((id) => stackedTickets.add(id));
@@ -79,16 +80,26 @@ export function formatStackAwareQueue(ticketDefs, states, delivery) {
79
80
  const next = ticketIds.find((id) => !terminal(states.get(id)?.status));
80
81
  const blockers = next && defs.get(next) ? resolveBlockers(defs.get(next), states) : [];
81
82
  const state = deriveStackState(stack, ticketIds, states, blockers);
82
- lines.push(`=== STACK START: ${stack.name} (${stack.id}) ===`);
83
- lines.push(`status=${state}; size=${ticketIds.length}; pr_depth=${nodes.length}; blockers=${blockers.join(",") || "none"}; completed_prefix=${completed.join(",") || "none"}; next=${next ?? "none"}`);
83
+ const units = stack.units.map((id) => unitById.get(id)).filter((unit) => Boolean(unit));
84
+ const branchModes = [...new Set(units.map((unit) => unit.branch_mode))].join(",") || "unknown";
85
+ const prChain = units.some((unit) => unit.dependency_mode === "stack") || nodes.length > 1;
86
+ lines.push(`=== Batch ${index + 1}: ${stack.name} (${stack.id}) ===`);
87
+ lines.push(`status=${state}; tickets=${ticketIds.length}; branch_mode=${branchModes}; blockers=${blockers.join(",") || "none"}; completed=${completed.join(",") || "none"}; next=${next ?? "none"}`);
88
+ if (prChain)
89
+ lines.push(`delivery=PR chain (${nodes.length} dependent branch${nodes.length === 1 ? "" : "es"})`);
84
90
  lines.push(`reviews=${stack.review_links?.join(",") || "none"}; remote_checked=${stack.remote_checked_at ?? "never"}${stack.remote_stale ? " (stale; run --refresh for a live check)" : ""}`);
85
91
  for (const id of ticketIds)
86
92
  lines.push(`${id} ${states.get(id)?.status ?? "planned"} ${defs.get(id)?.title ?? "unknown ticket"}`);
87
- lines.push(`=== STACK END: ${stack.name} (${stack.id}) ===`);
93
+ lines.push(`=== End Batch ${index + 1}: ${stack.name} (${stack.id}) ===`);
88
94
  }
95
+ let unbatched = false;
89
96
  for (const ticket of ticketDefs.sort((a, b) => a.order - b.order)) {
90
97
  if (stackedTickets.has(ticket.id) || terminal(states.get(ticket.id)?.status))
91
98
  continue;
99
+ if (!unbatched) {
100
+ lines.push("Unbatched tickets:");
101
+ unbatched = true;
102
+ }
92
103
  const blockers = resolveBlockers(ticket, states);
93
104
  lines.push(`${ticket.id} ${computeDisplayStatus(states.get(ticket.id)?.status ?? "planned", blockers)} ${ticket.title}`);
94
105
  }
@@ -3,7 +3,7 @@ export type HarnessTarget = "claude" | "codex";
3
3
  export type TicketBuildCompletionMode = "pr" | "auto-merge" | "direct-merge" | "none";
4
4
  export type TicketBuildProvider = "auto" | "github" | "gitlab" | "local";
5
5
  export type TicketBuildMergeMethod = "squash" | "merge" | "rebase";
6
- export type TicketBuildBranchStrategy = "branch-per-ticket" | "batch";
6
+ export type TicketBuildBranchStrategy = "current" | "batch" | "branch-per-ticket";
7
7
  export type TicketPopulateAgentPreference = "configured" | "claude" | "codex";
8
8
  export type TicketPopulateEnrichmentPolicy = "none" | "recommendations" | "agent";
9
9
  export type TicketSourceConfig = {
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import { loadDefaults } from "special-agents";
6
+ import { appendLegacyTicketSources, loadSourceRegistry } from "../sources/sourceRegistry.js";
6
7
  export const RAFI_CONFIG_FILE = "rafi-config.yaml";
7
8
  const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
8
9
  const SKILL_NAMES = [
@@ -48,7 +49,10 @@ export function loadTicketSetupConfig(projectDir) {
48
49
  const raw = loadRafiConfigObject(projectDir);
49
50
  if (!raw || raw.tickets === undefined)
50
51
  return undefined;
51
- return normalizeTicketsSetupConfig(raw.tickets, `${RAFI_CONFIG_FILE}.tickets`);
52
+ const setup = normalizeTicketsSetupConfig(raw.tickets, `${RAFI_CONFIG_FILE}.tickets`);
53
+ if (setup.sources.length === 0)
54
+ setup.sources = legacySourcesFromRegistry(loadSourceRegistry(projectDir).registry.entries);
55
+ return setup;
52
56
  }
53
57
  export function loadTicketSetupConfigWithDefaults(projectDir) {
54
58
  return loadTicketSetupConfig(projectDir) ?? cloneTicketSetup(DEFAULT_TICKET_SETUP);
@@ -59,7 +63,17 @@ export function hasTicketSetupConfig(projectDir) {
59
63
  }
60
64
  export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
61
65
  const config = loadRafiConfigObject(projectDir) ?? minimalRafiConfig(projectDir, opts);
62
- config.tickets = denormalizeTicketsSetupConfig(normalizeTicketsSetupConfig(setup, "tickets"));
66
+ const normalized = normalizeTicketsSetupConfig(setup, "tickets");
67
+ const ticketConfig = denormalizeTicketsSetupConfig(normalized);
68
+ delete ticketConfig.sources;
69
+ config.tickets = ticketConfig;
70
+ config.sources = appendLegacyTicketSources(loadSourceRegistry(projectDir).registry, normalized.sources);
71
+ const planning = config.planning && typeof config.planning === "object" && !Array.isArray(config.planning) ? config.planning : undefined;
72
+ if (planning) {
73
+ delete planning.sources;
74
+ if (Object.keys(planning).length === 0)
75
+ delete config.planning;
76
+ }
63
77
  mkdirSync(projectDir, { recursive: true });
64
78
  writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
65
79
  }
@@ -332,7 +346,7 @@ function normalizePopulate(value, label) {
332
346
  function normalizeBuild(value, label) {
333
347
  const raw = objectOrEmpty(value, label);
334
348
  return {
335
- branch_strategy: enumField(raw.branch_strategy, ["branch-per-ticket", "batch"], DEFAULT_TICKET_SETUP.build.branch_strategy, `${label}.branch_strategy`),
349
+ branch_strategy: enumField(raw.branch_strategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_strategy, `${label}.branch_strategy`),
336
350
  completion: enumField(raw.completion, ["pr", "auto-merge", "direct-merge", "none"], DEFAULT_TICKET_SETUP.build.completion, `${label}.completion`),
337
351
  provider: enumField(raw.provider, ["auto", "github", "gitlab", "local"], DEFAULT_TICKET_SETUP.build.provider, `${label}.provider`),
338
352
  pr_ready: booleanField(raw.pr_ready, DEFAULT_TICKET_SETUP.build.pr_ready, `${label}.pr_ready`),
@@ -424,3 +438,17 @@ function cloneTicketSetup(setup) {
424
438
  function unique(values) {
425
439
  return [...new Set(values)];
426
440
  }
441
+ function legacySourcesFromRegistry(entries) {
442
+ const out = [];
443
+ for (const entry of entries.filter((item) => item.active)) {
444
+ if (entry.type === "local" && entry.locator.path)
445
+ out.push({ type: "local", paths: [entry.locator.path] });
446
+ else if (entry.type === "url" && entry.locator.url)
447
+ out.push({ type: "url", url: entry.locator.url });
448
+ else if (entry.type === "linear")
449
+ out.push({ type: "linear", api_key_env: entry.locator.api_key_env ?? "LINEAR_API_KEY", team_key: entry.locator.team_key, filter: entry.locator.filter });
450
+ else if (entry.type === "jira" && entry.locator.site && entry.locator.jql)
451
+ out.push({ type: "jira", site: entry.locator.site, email_env: entry.locator.email_env ?? "JIRA_EMAIL", token_env: entry.locator.token_env ?? "JIRA_API_TOKEN", jql: entry.locator.jql });
452
+ }
453
+ return dedupeTicketSources(out);
454
+ }
@@ -23,7 +23,7 @@ export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
23
23
  response = await fetch(current, {
24
24
  redirect: "manual",
25
25
  signal: controller.signal,
26
- headers: { Accept: "text/html,text/plain,text/markdown,application/pdf;q=0.9" },
26
+ headers: { Accept: "text/html,text/plain,text/markdown,application/json,application/yaml,text/yaml,application/pdf;q=0.9" },
27
27
  });
28
28
  }
29
29
  finally {
@@ -154,6 +154,11 @@ function detectContentType(header, bytes, url) {
154
154
  return "html";
155
155
  if (["text/markdown", "text/x-markdown"].includes(mime) || /\.md(?:own)?$/i.test(new URL(url).pathname))
156
156
  return "markdown";
157
+ if (["application/json", "application/yaml", "application/x-yaml", "text/yaml", "text/x-yaml"].includes(mime) || /\.(?:json|ya?ml)$/i.test(new URL(url).pathname)) {
158
+ if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
159
+ throw new Error("binary URL content is not supported");
160
+ return "text";
161
+ }
157
162
  if (!mime || mime.startsWith("text/") || mime === "application/octet-stream") {
158
163
  if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
159
164
  throw new Error("binary URL content is not supported");
@@ -11,6 +11,7 @@ export interface TicketExternalRef {
11
11
  export interface TicketSourceRef {
12
12
  source: string;
13
13
  item: string;
14
+ source_id?: string;
14
15
  url?: string | null;
15
16
  fingerprint?: string | null;
16
17
  note?: string | null;
@@ -149,6 +150,10 @@ export declare const TICKET_JSON_SCHEMA: {
149
150
  readonly type: "string";
150
151
  readonly minLength: 1;
151
152
  };
153
+ readonly source_id: {
154
+ readonly type: "string";
155
+ readonly pattern: "^src_[A-Za-z0-9_-]+$";
156
+ };
152
157
  readonly url: {
153
158
  readonly type: readonly ["string", "null"];
154
159
  };
@@ -310,6 +315,10 @@ export declare const TICKETS_FILE_SCHEMA: {
310
315
  readonly type: "string";
311
316
  readonly minLength: 1;
312
317
  };
318
+ readonly source_id: {
319
+ readonly type: "string";
320
+ readonly pattern: "^src_[A-Za-z0-9_-]+$";
321
+ };
313
322
  readonly url: {
314
323
  readonly type: readonly ["string", "null"];
315
324
  };
@@ -43,6 +43,7 @@ export const TICKET_JSON_SCHEMA = {
43
43
  properties: {
44
44
  source: { type: "string", minLength: 1 },
45
45
  item: { type: "string", minLength: 1 },
46
+ source_id: { type: "string", pattern: "^src_[A-Za-z0-9_-]+$" },
46
47
  url: { type: ["string", "null"] },
47
48
  fingerprint: { type: ["string", "null"] },
48
49
  note: { type: ["string", "null"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
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",
@@ -65,6 +65,10 @@
65
65
  "types": "./dist/tickets/setupConfig.d.ts",
66
66
  "default": "./dist/tickets/setupConfig.js"
67
67
  },
68
+ "./sources/source-registry.js": {
69
+ "types": "./dist/sources/sourceRegistry.d.ts",
70
+ "default": "./dist/sources/sourceRegistry.js"
71
+ },
68
72
  "./cli/tickets.js": {
69
73
  "types": "./dist/cli/tickets.d.ts",
70
74
  "default": "./dist/cli/tickets.js"
@@ -114,8 +118,8 @@
114
118
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
115
119
  },
116
120
  "dependencies": {
117
- "special-agents": "0.8.0",
118
- "rafi-spec": "0.8.0",
121
+ "special-agents": "0.8.2",
122
+ "rafi-spec": "0.8.2",
119
123
  "@clack/prompts": "^1.4.0",
120
124
  "ajv": "^8.20.0",
121
125
  "ajv-formats": "^3.0.1",